Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86d5dabf90 | ||
|
|
a81c81e42c | ||
|
|
bec2820969 | ||
|
|
0bf2271a73 | ||
|
|
bd4b91bbbd | ||
|
|
fdec317df0 | ||
|
|
8970ad5ad5 | ||
|
|
c4255e65bc | ||
|
|
fcf046cbe8 | ||
|
|
6932edc6d0 | ||
|
|
3f961a7408 | ||
|
|
4d0f3b6997 | ||
|
|
5a06599d96 | ||
|
|
d2a73a3590 | ||
|
|
932ecc436a | ||
|
|
1613274cb0 | ||
|
|
0b4720d94b | ||
|
|
16df341581 | ||
|
|
a848776a34 | ||
|
|
681b1c63f1 | ||
|
|
51a4b63fb5 | ||
|
|
3a7977d086 | ||
|
|
c682520dd9 | ||
|
|
24b7258338 | ||
|
|
89e6e4abd8 | ||
|
|
4700f54798 | ||
|
|
9ca4442e6a | ||
|
|
ce6f58f403 | ||
|
|
c466542990 | ||
|
|
9cb8c37298 | ||
|
|
f6f7e15735 | ||
|
|
c712133df0 | ||
|
|
fc016bd682 | ||
|
|
0e586e4152 | ||
|
|
ea425773e0 | ||
|
|
10949b11f4 | ||
|
|
0e022d04b1 | ||
|
|
3319e158b5 | ||
|
|
f467a77ae2 | ||
|
|
56048725e4 | ||
|
|
425c1d3674 | ||
|
|
8838f6f2ad | ||
|
|
139a78b2f6 | ||
|
|
dd5e3ee7ee | ||
|
|
9f8f7bb45e | ||
|
|
27e246859e | ||
|
|
f1688ac87a | ||
|
|
54779e1db8 | ||
|
|
be9087bee3 | ||
|
|
4ab5724fc1 | ||
|
|
45a47940ad | ||
|
|
1777d69495 | ||
|
|
8676b83fe3 | ||
|
|
b67701ff6d | ||
|
|
eb9acef9b5 | ||
|
|
00aec70fb8 | ||
|
|
18d28fc362 | ||
|
|
eb3545e994 | ||
|
|
ad71c8db34 | ||
|
|
6da281bf4e | ||
|
|
3dd8ba1a99 | ||
|
|
2503d21522 | ||
|
|
36a3419aec | ||
|
|
7353fad809 | ||
|
|
b5a26caa08 | ||
|
|
d7206546af | ||
|
|
4fa86a2c46 | ||
|
|
a6ed23b169 | ||
|
|
d9201c5084 | ||
|
|
d0ba8261e3 | ||
|
|
f89447badc | ||
|
|
14c42f6e6d | ||
|
|
7a05bdcb82 | ||
|
|
5eb7763052 | ||
|
|
8c73ae6035 | ||
|
|
6954e9dde7 | ||
|
|
f6336eac4e | ||
|
|
0331c18401 | ||
|
|
1f8fd29dad | ||
|
|
5877c4b2be | ||
|
|
c6d0809ecc |
23
.examples/docker-compose-grafana-prometheus/README.md
Normal file
23
.examples/docker-compose-grafana-prometheus/README.md
Normal file
@@ -0,0 +1,23 @@
|
||||
## Usage
|
||||
```console
|
||||
docker-compose up
|
||||
```
|
||||
Once you've done the above, you should be able to access the Grafana dashboard at `http://localhost:3000`.
|
||||
|
||||
## Queries
|
||||
Gatus uses Prometheus counters.
|
||||
|
||||
Total results per minute:
|
||||
```
|
||||
sum(rate(gatus_results_total[5m])*60) by (key)
|
||||
```
|
||||
|
||||
Total successful results per minute:
|
||||
```
|
||||
sum(rate(gatus_results_total{success="true"}[5m])*60) by (key)
|
||||
```
|
||||
|
||||
Total unsuccessful results per minute:
|
||||
```
|
||||
sum(rate(gatus_results_total{success="true"}[5m])*60) by (key)
|
||||
```
|
||||
@@ -7,7 +7,7 @@ services:
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./config.yaml:/config/config.yaml
|
||||
- ./config:/config
|
||||
networks:
|
||||
- metrics
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
"editable": true,
|
||||
"gnetId": null,
|
||||
"graphTooltip": 0,
|
||||
"id": 2,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
@@ -24,20 +23,26 @@
|
||||
"dashLength": 10,
|
||||
"dashes": false,
|
||||
"datasource": null,
|
||||
"description": "Number of results per minute",
|
||||
"fill": 1,
|
||||
"fillGradient": 0,
|
||||
"gridPos": {
|
||||
"h": 14,
|
||||
"h": 7,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 2,
|
||||
"interval": "",
|
||||
"legend": {
|
||||
"alignAsTable": false,
|
||||
"avg": false,
|
||||
"current": false,
|
||||
"hideEmpty": false,
|
||||
"hideZero": false,
|
||||
"max": false,
|
||||
"min": false,
|
||||
"rightSide": false,
|
||||
"show": true,
|
||||
"total": false,
|
||||
"values": false
|
||||
@@ -58,9 +63,13 @@
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(gatus_tasks[30s])) by (endpoint)",
|
||||
"expr": "sum(rate(gatus_results_total[5m])*60) by (key)",
|
||||
"format": "time_series",
|
||||
"hide": false,
|
||||
"instant": false,
|
||||
"interval": "30s",
|
||||
"legendFormat": "{{endpoint}}",
|
||||
"intervalFactor": 1,
|
||||
"legendFormat": "{{key}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
@@ -68,7 +77,7 @@
|
||||
"timeFrom": null,
|
||||
"timeRegions": [],
|
||||
"timeShift": null,
|
||||
"title": "All tasks executed over time",
|
||||
"title": "Total results per minute",
|
||||
"tooltip": {
|
||||
"shared": true,
|
||||
"sort": 0,
|
||||
@@ -84,6 +93,7 @@
|
||||
},
|
||||
"yaxes": [
|
||||
{
|
||||
"decimals": null,
|
||||
"format": "short",
|
||||
"label": null,
|
||||
"logBase": 1,
|
||||
@@ -119,93 +129,6 @@
|
||||
"x": 12,
|
||||
"y": 0
|
||||
},
|
||||
"id": 3,
|
||||
"legend": {
|
||||
"avg": false,
|
||||
"current": false,
|
||||
"max": false,
|
||||
"min": false,
|
||||
"show": true,
|
||||
"total": false,
|
||||
"values": false
|
||||
},
|
||||
"lines": true,
|
||||
"linewidth": 1,
|
||||
"nullPointMode": "null",
|
||||
"options": {
|
||||
"dataLinks": []
|
||||
},
|
||||
"percentage": false,
|
||||
"pointradius": 2,
|
||||
"points": false,
|
||||
"renderer": "flot",
|
||||
"seriesOverrides": [],
|
||||
"spaceLength": 10,
|
||||
"stack": false,
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(gatus_tasks{success=\"false\"}[30s])) by (endpoint)",
|
||||
"interval": "30s",
|
||||
"legendFormat": "{{endpoint}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"thresholds": [],
|
||||
"timeFrom": null,
|
||||
"timeRegions": [],
|
||||
"timeShift": null,
|
||||
"title": "Unsuccessful tasks",
|
||||
"tooltip": {
|
||||
"shared": true,
|
||||
"sort": 0,
|
||||
"value_type": "individual"
|
||||
},
|
||||
"type": "graph",
|
||||
"xaxis": {
|
||||
"buckets": null,
|
||||
"mode": "time",
|
||||
"name": null,
|
||||
"show": true,
|
||||
"values": []
|
||||
},
|
||||
"yaxes": [
|
||||
{
|
||||
"format": "short",
|
||||
"label": null,
|
||||
"logBase": 1,
|
||||
"max": null,
|
||||
"min": null,
|
||||
"show": true
|
||||
},
|
||||
{
|
||||
"format": "short",
|
||||
"label": null,
|
||||
"logBase": 1,
|
||||
"max": null,
|
||||
"min": null,
|
||||
"show": true
|
||||
}
|
||||
],
|
||||
"yaxis": {
|
||||
"align": false,
|
||||
"alignLevel": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"aliasColors": {},
|
||||
"bars": false,
|
||||
"dashLength": 10,
|
||||
"dashes": false,
|
||||
"datasource": null,
|
||||
"fill": 1,
|
||||
"fillGradient": 0,
|
||||
"gridPos": {
|
||||
"h": 7,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 7
|
||||
},
|
||||
"id": 5,
|
||||
"legend": {
|
||||
"avg": false,
|
||||
@@ -232,10 +155,10 @@
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(gatus_tasks{success=\"true\"}[30s])) by (endpoint)",
|
||||
"expr": "sum(rate(gatus_results_total{success=\"true\"}[5m])*60) by (key)",
|
||||
"instant": false,
|
||||
"interval": "30s",
|
||||
"legendFormat": "{{endpoint}}",
|
||||
"legendFormat": "{{key}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
@@ -243,7 +166,183 @@
|
||||
"timeFrom": null,
|
||||
"timeRegions": [],
|
||||
"timeShift": null,
|
||||
"title": "Successful tasks",
|
||||
"title": "Successful results per minute",
|
||||
"tooltip": {
|
||||
"shared": true,
|
||||
"sort": 0,
|
||||
"value_type": "individual"
|
||||
},
|
||||
"type": "graph",
|
||||
"xaxis": {
|
||||
"buckets": null,
|
||||
"mode": "time",
|
||||
"name": null,
|
||||
"show": true,
|
||||
"values": []
|
||||
},
|
||||
"yaxes": [
|
||||
{
|
||||
"decimals": null,
|
||||
"format": "short",
|
||||
"label": null,
|
||||
"logBase": 1,
|
||||
"max": null,
|
||||
"min": null,
|
||||
"show": true
|
||||
},
|
||||
{
|
||||
"format": "short",
|
||||
"label": null,
|
||||
"logBase": 1,
|
||||
"max": null,
|
||||
"min": null,
|
||||
"show": true
|
||||
}
|
||||
],
|
||||
"yaxis": {
|
||||
"align": false,
|
||||
"alignLevel": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"cacheTimeout": null,
|
||||
"colorBackground": false,
|
||||
"colorPostfix": false,
|
||||
"colorPrefix": false,
|
||||
"colorValue": true,
|
||||
"colors": [
|
||||
"#299c46",
|
||||
"rgba(237, 129, 40, 0.89)",
|
||||
"#d44a3a"
|
||||
],
|
||||
"datasource": null,
|
||||
"format": "none",
|
||||
"gauge": {
|
||||
"maxValue": 100,
|
||||
"minValue": 0,
|
||||
"show": false,
|
||||
"thresholdLabels": false,
|
||||
"thresholdMarkers": true
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 7,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 7
|
||||
},
|
||||
"id": 7,
|
||||
"interval": "",
|
||||
"links": [],
|
||||
"mappingType": 1,
|
||||
"mappingTypes": [
|
||||
{
|
||||
"name": "value to text",
|
||||
"value": 1
|
||||
},
|
||||
{
|
||||
"name": "range to text",
|
||||
"value": 2
|
||||
}
|
||||
],
|
||||
"maxDataPoints": 100,
|
||||
"nullPointMode": "connected",
|
||||
"nullText": null,
|
||||
"options": {},
|
||||
"postfix": "",
|
||||
"postfixFontSize": "50%",
|
||||
"prefix": "",
|
||||
"prefixFontSize": "50%",
|
||||
"rangeMaps": [
|
||||
{
|
||||
"from": "null",
|
||||
"text": "N/A",
|
||||
"to": "null"
|
||||
}
|
||||
],
|
||||
"sparkline": {
|
||||
"fillColor": "rgba(31, 118, 189, 0.18)",
|
||||
"full": false,
|
||||
"lineColor": "rgb(31, 120, 193)",
|
||||
"show": true,
|
||||
"ymax": null,
|
||||
"ymin": null
|
||||
},
|
||||
"tableColumn": "",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "rate(gatus_results_total{success=\"false\"}[1m])*60",
|
||||
"format": "time_series",
|
||||
"instant": false,
|
||||
"intervalFactor": 1,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"thresholds": "1,2",
|
||||
"timeFrom": null,
|
||||
"timeShift": null,
|
||||
"title": "Unsuccessful results",
|
||||
"type": "singlestat",
|
||||
"valueFontSize": "150%",
|
||||
"valueMaps": [
|
||||
{
|
||||
"op": "=",
|
||||
"text": "N/A",
|
||||
"value": "null"
|
||||
}
|
||||
],
|
||||
"valueName": "current"
|
||||
},
|
||||
{
|
||||
"aliasColors": {},
|
||||
"bars": false,
|
||||
"dashLength": 10,
|
||||
"dashes": false,
|
||||
"datasource": null,
|
||||
"fill": 1,
|
||||
"fillGradient": 0,
|
||||
"gridPos": {
|
||||
"h": 7,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 7
|
||||
},
|
||||
"id": 3,
|
||||
"legend": {
|
||||
"avg": false,
|
||||
"current": false,
|
||||
"max": false,
|
||||
"min": false,
|
||||
"show": true,
|
||||
"total": false,
|
||||
"values": false
|
||||
},
|
||||
"lines": true,
|
||||
"linewidth": 1,
|
||||
"nullPointMode": "null",
|
||||
"options": {
|
||||
"dataLinks": []
|
||||
},
|
||||
"percentage": false,
|
||||
"pointradius": 2,
|
||||
"points": false,
|
||||
"renderer": "flot",
|
||||
"seriesOverrides": [],
|
||||
"spaceLength": 10,
|
||||
"stack": false,
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(gatus_results_total{success=\"false\"}[5m])*60) by (key)",
|
||||
"interval": "30s",
|
||||
"legendFormat": "{{key}} ",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"thresholds": [],
|
||||
"timeFrom": null,
|
||||
"timeRegions": [],
|
||||
"timeShift": null,
|
||||
"title": "Unsuccessful results per minute",
|
||||
"tooltip": {
|
||||
"shared": true,
|
||||
"sort": 0,
|
||||
@@ -289,12 +388,12 @@
|
||||
"list": []
|
||||
},
|
||||
"time": {
|
||||
"from": "now-30m",
|
||||
"from": "now-1h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "",
|
||||
"title": "Gatus",
|
||||
"uid": "KPI7Qj1Wk",
|
||||
"version": 3
|
||||
"version": 1
|
||||
}
|
||||
@@ -6,7 +6,7 @@ services:
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./config.yaml:/config/config.yaml
|
||||
- ./config:/config
|
||||
networks:
|
||||
- default
|
||||
|
||||
|
||||
@@ -19,11 +19,11 @@ services:
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./config.yaml:/config/config.yaml
|
||||
- ./config:/config
|
||||
networks:
|
||||
- web
|
||||
depends_on:
|
||||
- postgres
|
||||
|
||||
networks:
|
||||
web:
|
||||
web:
|
||||
|
||||
@@ -5,5 +5,5 @@ services:
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./config.yaml:/config/config.yaml
|
||||
- ./data:/data/
|
||||
- ./config:/config
|
||||
- ./data:/data/
|
||||
|
||||
@@ -5,4 +5,4 @@ services:
|
||||
ports:
|
||||
- 8080:8080
|
||||
volumes:
|
||||
- ./config.yaml:/config/config.yaml
|
||||
- ./config:/config
|
||||
|
||||
BIN
.github/assets/dashboard-dark.png
vendored
BIN
.github/assets/dashboard-dark.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 90 KiB |
BIN
.github/assets/logo-with-dark-text.png
vendored
Normal file
BIN
.github/assets/logo-with-dark-text.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
BIN
.github/assets/logo-with-name.png
vendored
BIN
.github/assets/logo-with-name.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 27 KiB |
BIN
.github/assets/logo.png
vendored
BIN
.github/assets/logo.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 17 KiB |
5
.github/codecov.yml
vendored
5
.github/codecov.yml
vendored
@@ -5,3 +5,8 @@ ignore:
|
||||
coverage:
|
||||
status:
|
||||
patch: off
|
||||
project:
|
||||
default:
|
||||
target: 75%
|
||||
threshold: null
|
||||
|
||||
|
||||
4
.github/workflows/publish-latest.yml
vendored
4
.github/workflows/publish-latest.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
||||
name: Publish latest
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v2
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
- name: Build and push docker image
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
platforms: linux/amd64
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
|
||||
pull: true
|
||||
push: true
|
||||
tags: |
|
||||
|
||||
2
.github/workflows/publish-release.yml
vendored
2
.github/workflows/publish-release.yml
vendored
@@ -6,7 +6,7 @@ jobs:
|
||||
publish-release:
|
||||
name: Publish release
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -1,8 +1,7 @@
|
||||
.idea
|
||||
.vscode
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
gatus
|
||||
db.db
|
||||
config/config.yml
|
||||
db.db-shm
|
||||
db.db-wal
|
||||
memory.db
|
||||
config/config.yml
|
||||
566
README.md
566
README.md
@@ -1,11 +1,10 @@
|
||||

|
||||

|
||||
|
||||

|
||||
[](https://goreportcard.com/report/github.com/TwiN/gatus)
|
||||
[](https://codecov.io/gh/TwiN/gatus)
|
||||
[](https://github.com/TwiN/gatus)
|
||||
[](https://cloud.docker.com/repository/docker/twinproduction/gatus)
|
||||
[](https://discord.gg/TDfjeHmXDQ)
|
||||
[](https://github.com/TwiN)
|
||||
|
||||
Gatus is a health dashboard that gives you the ability to monitor your services using HTTP, ICMP, TCP, and even DNS
|
||||
@@ -41,8 +40,11 @@ Have any feedback or want to share your good/bad experience with Gatus? Feel fre
|
||||
- [Client configuration](#client-configuration)
|
||||
- [Alerting](#alerting)
|
||||
- [Configuring Discord alerts](#configuring-discord-alerts)
|
||||
- [Configuring Email alerts](#configuring-email-alerts)
|
||||
- [Configuring Google Chat alerts](#configuring-google-chat-alerts)
|
||||
- [Configuring Mattermost alerts](#configuring-mattermost-alerts)
|
||||
- [Configuring Messagebird alerts](#configuring-messagebird-alerts)
|
||||
- [Configuring Opsgenie alerts](#configuring-opsgenie-alerts)
|
||||
- [Configuring PagerDuty alerts](#configuring-pagerduty-alerts)
|
||||
- [Configuring Slack alerts](#configuring-slack-alerts)
|
||||
- [Configuring Teams alerts](#configuring-teams-alerts)
|
||||
@@ -51,6 +53,9 @@ Have any feedback or want to share your good/bad experience with Gatus? Feel fre
|
||||
- [Configuring custom alerts](#configuring-custom-alerts)
|
||||
- [Setting a default alert](#setting-a-default-alert)
|
||||
- [Maintenance](#maintenance)
|
||||
- [Security](#security)
|
||||
- [Basic](#basic)
|
||||
- [OIDC (ALPHA)](#oidc-alpha)
|
||||
- [Deployment](#deployment)
|
||||
- [Docker](#docker)
|
||||
- [Helm Chart](#helm-chart)
|
||||
@@ -66,7 +71,6 @@ Have any feedback or want to share your good/bad experience with Gatus? Feel fre
|
||||
- [Monitoring an endpoint using DNS queries](#monitoring-an-endpoint-using-dns-queries)
|
||||
- [Monitoring an endpoint using STARTTLS](#monitoring-an-endpoint-using-starttls)
|
||||
- [Monitoring an endpoint using TLS](#monitoring-an-endpoint-using-tls)
|
||||
- [Basic authentication](#basic-authentication)
|
||||
- [disable-monitoring-lock](#disable-monitoring-lock)
|
||||
- [Reloading configuration on the fly](#reloading-configuration-on-the-fly)
|
||||
- [Endpoint groups](#endpoint-groups)
|
||||
@@ -90,8 +94,8 @@ Gatus, on the other hand, allows you to configure health checks for each of your
|
||||
monitor these features and potentially alert you before any clients are impacted.
|
||||
|
||||
A sign you may want to look into Gatus is by simply asking yourself whether you'd receive an alert if your load balancer
|
||||
was to go down right now. Will any of your existing alerts by triggered? Your metrics won’t report an increase in errors
|
||||
if there’s no traffic that makes it to your applications. This puts you in a situation where your clients are the ones
|
||||
was to go down right now. Will any of your existing alerts be triggered? Your metrics won’t report an increase in errors
|
||||
if no traffic makes it to your applications. This puts you in a situation where your clients are the ones
|
||||
that will notify you about the degradation of your services rather than you reassuring them that you're working on
|
||||
fixing the issue before they even know about it.
|
||||
|
||||
@@ -101,7 +105,7 @@ The main features of Gatus are:
|
||||
- **Highly flexible health check conditions**: While checking the response status may be enough for some use cases, Gatus goes much further and allows you to add conditions on the response time, the response body and even the IP address.
|
||||
- **Ability to use Gatus for user acceptance tests**: Thanks to the point above, you can leverage this application to create automated user acceptance tests.
|
||||
- **Very easy to configure**: Not only is the configuration designed to be as readable as possible, it's also extremely easy to add a new service or a new endpoint to monitor.
|
||||
- **Alerting**: While having a pretty visual dashboard is useful to keep track of the state of your application(s), you probably don't want to stare at it all day. Thus, notifications via Slack, Mattermost, Messagebird, PagerDuty, Twilio and Teams are supported out of the box with the ability to configure a custom alerting provider for any needs you might have, whether it be a different provider or a custom application that manages automated rollbacks.
|
||||
- **Alerting**: While having a pretty visual dashboard is useful to keep track of the state of your application(s), you probably don't want to stare at it all day. Thus, notifications via Slack, Mattermost, Messagebird, PagerDuty, Twilio, Google chat and Teams are supported out of the box with the ability to configure a custom alerting provider for any needs you might have, whether it be a different provider or a custom application that manages automated rollbacks.
|
||||
- **Metrics**
|
||||
- **Low resource consumption**: As with most Go applications, the resource footprint that this application requires is negligibly small.
|
||||
- **[Badges](#badges)**:  
|
||||
@@ -141,105 +145,105 @@ If you want to test it locally, see [Docker](#docker).
|
||||
|
||||
|
||||
## Configuration
|
||||
| Parameter | Description | Default |
|
||||
|:---------------------------------------- |:----------------------------------------------------------------------------- |:-------------- |
|
||||
| `debug` | Whether to enable debug logs. | `false` |
|
||||
| `metrics` | Whether to expose metrics at /metrics. | `false` |
|
||||
| `storage` | [Storage configuration](#storage) | `{}` |
|
||||
| `endpoints` | List of endpoints to monitor. | Required `[]` |
|
||||
| `endpoints[].enabled` | Whether to monitor the endpoint. | `true` |
|
||||
| `endpoints[].name` | Name of the endpoint. Can be anything. | Required `""` |
|
||||
| `endpoints[].group` | Group name. Used to group multiple endpoints together on the dashboard. <br />See [Endpoint groups](#endpoint-groups). | `""` |
|
||||
| `endpoints[].url` | URL to send the request to. | Required `""` |
|
||||
| `endpoints[].method` | Request method. | `GET` |
|
||||
| `endpoints[].conditions` | Conditions used to determine the health of the endpoint. <br />See [Conditions](#conditions). | `[]` |
|
||||
| `endpoints[].interval` | Duration to wait between every status check. | `60s` |
|
||||
| `endpoints[].graphql` | Whether to wrap the body in a query param (`{"query":"$body"}`). | `false` |
|
||||
| `endpoints[].body` | Request body. | `""` |
|
||||
| `endpoints[].headers` | Request headers. | `{}` |
|
||||
| `endpoints[].dns` | Configuration for an endpoint of type DNS. <br />See [Monitoring an endpoint using DNS queries](#monitoring-an-endpoint-using-dns-queries). | `""` |
|
||||
| `endpoints[].dns.query-type` | Query type (e.g. MX) | `""` |
|
||||
| `endpoints[].dns.query-name` | Query name (e.g. example.com) | `""` |
|
||||
| `endpoints[].alerts[].type` | Type of alert. <br />Valid types: `slack`, `discord`, `pagerduty`, `twilio`, `mattermost`, `messagebird`, `teams` `custom`. | Required `""` |
|
||||
| `endpoints[].alerts[].enabled` | Whether to enable the alert. | `false` |
|
||||
| `endpoints[].alerts[].failure-threshold` | Number of failures in a row needed before triggering the alert. | `3` |
|
||||
| `endpoints[].alerts[].success-threshold` | Number of successes in a row before an ongoing incident is marked as resolved. | `2` |
|
||||
| `endpoints[].alerts[].send-on-resolved` | Whether to send a notification once a triggered alert is marked as resolved. | `false` |
|
||||
| `endpoints[].alerts[].description` | Description of the alert. Will be included in the alert sent. | `""` |
|
||||
| `endpoints[].client` | [Client configuration](#client-configuration). | `{}` |
|
||||
| `endpoints[].ui` | UI configuration at the endpoint level. | `{}` |
|
||||
| `endpoints[].ui.hide-hostname` | Whether to include the hostname in the result. | `false` |
|
||||
| `endpoints[].ui.dont-resolve-failed-conditions` | Whether to resolve failed conditions for the UI. | `false` |
|
||||
| `alerting` | [Alerting configuration](#alerting). | `{}` |
|
||||
| `security` | Security configuration. | `{}` |
|
||||
| `security.basic` | Basic authentication security configuration. | `{}` |
|
||||
| `security.basic.username` | Username for Basic authentication. | Required `""` |
|
||||
| `security.basic.password-sha512` | Password's SHA512 hash for Basic authentication. | Required `""` |
|
||||
| `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock). | `false` |
|
||||
| `skip-invalid-config-update` | Whether to ignore invalid configuration update. <br />See [Reloading configuration on the fly](#reloading-configuration-on-the-fly). | `false` |
|
||||
| `web` | Web configuration. | `{}` |
|
||||
| `web.address` | Address to listen on. | `0.0.0.0` |
|
||||
| `web.port` | Port to listen on. | `8080` |
|
||||
| `ui` | UI configuration. | `{}` |
|
||||
| `ui.title` | Title of the page. | `Health Dashboard ǀ Gatus` |
|
||||
| `ui.logo` | URL to the logo to display | `""` |
|
||||
| Parameter | Description | Default |
|
||||
|:------------------------------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------|:---------------------------|
|
||||
| `debug` | Whether to enable debug logs. | `false` |
|
||||
| `metrics` | Whether to expose metrics at /metrics. | `false` |
|
||||
| `storage` | [Storage configuration](#storage) | `{}` |
|
||||
| `endpoints` | List of endpoints to monitor. | Required `[]` |
|
||||
| `endpoints[].enabled` | Whether to monitor the endpoint. | `true` |
|
||||
| `endpoints[].name` | Name of the endpoint. Can be anything. | Required `""` |
|
||||
| `endpoints[].group` | Group name. Used to group multiple endpoints together on the dashboard. <br />See [Endpoint groups](#endpoint-groups). | `""` |
|
||||
| `endpoints[].url` | URL to send the request to. | Required `""` |
|
||||
| `endpoints[].method` | Request method. | `GET` |
|
||||
| `endpoints[].conditions` | Conditions used to determine the health of the endpoint. <br />See [Conditions](#conditions). | `[]` |
|
||||
| `endpoints[].interval` | Duration to wait between every status check. | `60s` |
|
||||
| `endpoints[].graphql` | Whether to wrap the body in a query param (`{"query":"$body"}`). | `false` |
|
||||
| `endpoints[].body` | Request body. | `""` |
|
||||
| `endpoints[].headers` | Request headers. | `{}` |
|
||||
| `endpoints[].dns` | Configuration for an endpoint of type DNS. <br />See [Monitoring an endpoint using DNS queries](#monitoring-an-endpoint-using-dns-queries). | `""` |
|
||||
| `endpoints[].dns.query-type` | Query type (e.g. MX) | `""` |
|
||||
| `endpoints[].dns.query-name` | Query name (e.g. example.com) | `""` |
|
||||
| `endpoints[].alerts[].type` | Type of alert. <br />Valid types: `slack`, `discord`, `email`, `googlechat`, `pagerduty`, `twilio`, `mattermost`, `messagebird`, `teams` `custom`. | Required `""` |
|
||||
| `endpoints[].alerts[].enabled` | Whether to enable the alert. | `false` |
|
||||
| `endpoints[].alerts[].failure-threshold` | Number of failures in a row needed before triggering the alert. | `3` |
|
||||
| `endpoints[].alerts[].success-threshold` | Number of successes in a row before an ongoing incident is marked as resolved. | `2` |
|
||||
| `endpoints[].alerts[].send-on-resolved` | Whether to send a notification once a triggered alert is marked as resolved. | `false` |
|
||||
| `endpoints[].alerts[].description` | Description of the alert. Will be included in the alert sent. | `""` |
|
||||
| `endpoints[].client` | [Client configuration](#client-configuration). | `{}` |
|
||||
| `endpoints[].ui` | UI configuration at the endpoint level. | `{}` |
|
||||
| `endpoints[].ui.hide-hostname` | Whether to include the hostname in the result. | `false` |
|
||||
| `endpoints[].ui.dont-resolve-failed-conditions` | Whether to resolve failed conditions for the UI. | `false` |
|
||||
| `alerting` | [Alerting configuration](#alerting). | `{}` |
|
||||
| `security` | [Security configuration](#security). | `{}` |
|
||||
| `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock). | `false` |
|
||||
| `skip-invalid-config-update` | Whether to ignore invalid configuration update. <br />See [Reloading configuration on the fly](#reloading-configuration-on-the-fly). | `false` |
|
||||
| `web` | Web configuration. | `{}` |
|
||||
| `web.address` | Address to listen on. | `0.0.0.0` |
|
||||
| `web.port` | Port to listen on. | `8080` |
|
||||
| `ui` | UI configuration. | `{}` |
|
||||
| `ui.title` | [Title of the document](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/title). | `Health Dashboard ǀ Gatus` |
|
||||
| `ui.header` | Header at the top of the dashboard. | `Health Status` |
|
||||
| `ui.logo` | URL to the logo to display. | `""` |
|
||||
| `ui.link` | Link to open when the logo is clicked. | `""` |
|
||||
| `maintenance` | [Maintenance configuration](#maintenance). | `{}` |
|
||||
|
||||
|
||||
### Conditions
|
||||
Here are some examples of conditions you can use:
|
||||
|
||||
| Condition | Description | Passing values | Failing values |
|
||||
|:-----------------------------|:------------------------------------------------------- |:-------------------------- | -------------- |
|
||||
| `[STATUS] == 200` | Status must be equal to 200 | 200 | 201, 404, ... |
|
||||
| `[STATUS] < 300` | Status must lower than 300 | 200, 201, 299 | 301, 302, ... |
|
||||
| `[STATUS] <= 299` | Status must be less than or equal to 299 | 200, 201, 299 | 301, 302, ... |
|
||||
| `[STATUS] > 400` | Status must be greater than 400 | 401, 402, 403, 404 | 400, 200, ... |
|
||||
| `[STATUS] == any(200, 429)` | Status must be either 200 or 429 | 200, 429 | 201, 400, ... |
|
||||
| `[CONNECTED] == true` | Connection to host must've been successful | true | false |
|
||||
| `[RESPONSE_TIME] < 500` | Response time must be below 500ms | 100ms, 200ms, 300ms | 500ms, 501ms |
|
||||
| `[IP] == 127.0.0.1` | Target IP must be 127.0.0.1 | 127.0.0.1 | 0.0.0.0 |
|
||||
| `[BODY] == 1` | The body must be equal to 1 | 1 | `{}`, `2`, ... |
|
||||
| `[BODY].user.name == john` | JSONPath value of `$.user.name` is equal to `john` | `{"user":{"name":"john"}}` | |
|
||||
| `[BODY].data[0].id == 1` | JSONPath value of `$.data[0].id` is equal to 1 | `{"data":[{"id":1}]}` | |
|
||||
| `[BODY].age == [BODY].id` | JSONPath value of `$.age` is equal JSONPath `$.id` | `{"age":1,"id":1}` | |
|
||||
| `len([BODY].data) < 5` | Array at JSONPath `$.data` has less than 5 elements | `{"data":[{"id":1}]}` | |
|
||||
| `len([BODY].name) == 8` | String at JSONPath `$.name` has a length of 8 | `{"name":"john.doe"}` | `{"name":"bob"}` |
|
||||
| `has([BODY].errors) == false` | JSONPath `$.errors` does not exist | `{"name":"john.doe"}` | `{"errors":[]}` |
|
||||
| `has([BODY].users) == true` | JSONPath `$.users` exists | `{"users":[]}` | `{}` |
|
||||
| `[BODY].name == pat(john*)` | String at JSONPath `$.name` matches pattern `john*` | `{"name":"john.doe"}` | `{"name":"bob"}` |
|
||||
| `[BODY].id == any(1, 2)` | Value at JSONPath `$.id` is equal to `1` or `2` | 1, 2 | 3, 4, 5 |
|
||||
| `[CERTIFICATE_EXPIRATION] > 48h` | Certificate expiration is more than 48h away | 49h, 50h, 123h | 1h, 24h, ... |
|
||||
| Condition | Description | Passing values | Failing values |
|
||||
|:---------------------------------|:----------------------------------------------------|:---------------------------|------------------|
|
||||
| `[STATUS] == 200` | Status must be equal to 200 | 200 | 201, 404, ... |
|
||||
| `[STATUS] < 300` | Status must lower than 300 | 200, 201, 299 | 301, 302, ... |
|
||||
| `[STATUS] <= 299` | Status must be less than or equal to 299 | 200, 201, 299 | 301, 302, ... |
|
||||
| `[STATUS] > 400` | Status must be greater than 400 | 401, 402, 403, 404 | 400, 200, ... |
|
||||
| `[STATUS] == any(200, 429)` | Status must be either 200 or 429 | 200, 429 | 201, 400, ... |
|
||||
| `[CONNECTED] == true` | Connection to host must've been successful | true | false |
|
||||
| `[RESPONSE_TIME] < 500` | Response time must be below 500ms | 100ms, 200ms, 300ms | 500ms, 501ms |
|
||||
| `[IP] == 127.0.0.1` | Target IP must be 127.0.0.1 | 127.0.0.1 | 0.0.0.0 |
|
||||
| `[BODY] == 1` | The body must be equal to 1 | 1 | `{}`, `2`, ... |
|
||||
| `[BODY].user.name == john` | JSONPath value of `$.user.name` is equal to `john` | `{"user":{"name":"john"}}` | |
|
||||
| `[BODY].data[0].id == 1` | JSONPath value of `$.data[0].id` is equal to 1 | `{"data":[{"id":1}]}` | |
|
||||
| `[BODY].age == [BODY].id` | JSONPath value of `$.age` is equal JSONPath `$.id` | `{"age":1,"id":1}` | |
|
||||
| `len([BODY].data) < 5` | Array at JSONPath `$.data` has less than 5 elements | `{"data":[{"id":1}]}` | |
|
||||
| `len([BODY].name) == 8` | String at JSONPath `$.name` has a length of 8 | `{"name":"john.doe"}` | `{"name":"bob"}` |
|
||||
| `has([BODY].errors) == false` | JSONPath `$.errors` does not exist | `{"name":"john.doe"}` | `{"errors":[]}` |
|
||||
| `has([BODY].users) == true` | JSONPath `$.users` exists | `{"users":[]}` | `{}` |
|
||||
| `[BODY].name == pat(john*)` | String at JSONPath `$.name` matches pattern `john*` | `{"name":"john.doe"}` | `{"name":"bob"}` |
|
||||
| `[BODY].id == any(1, 2)` | Value at JSONPath `$.id` is equal to `1` or `2` | 1, 2 | 3, 4, 5 |
|
||||
| `[CERTIFICATE_EXPIRATION] > 48h` | Certificate expiration is more than 48h away | 49h, 50h, 123h | 1h, 24h, ... |
|
||||
|
||||
|
||||
#### Placeholders
|
||||
| Placeholder | Description | Example of resolved value |
|
||||
|:-------------------------- |:--------------------------------------------------------------- |:------------------------- |
|
||||
| `[STATUS]` | Resolves into the HTTP status of the request | 404
|
||||
| `[RESPONSE_TIME]` | Resolves into the response time the request took, in ms | 10
|
||||
| `[IP]` | Resolves into the IP of the target host | 192.168.0.232
|
||||
| `[BODY]` | Resolves into the response body. Supports JSONPath. | `{"name":"john.doe"}`
|
||||
| `[CONNECTED]` | Resolves into whether a connection could be established | `true`
|
||||
| `[CERTIFICATE_EXPIRATION]` | Resolves into the duration before certificate expiration | `24h`, `48h`, 0 (if not protocol with certs)
|
||||
| `[DNS_RCODE]` | Resolves into the DNS status of the response | NOERROR
|
||||
| Placeholder | Description | Example of resolved value |
|
||||
|:---------------------------|:---------------------------------------------------------|:---------------------------------------------|
|
||||
| `[STATUS]` | Resolves into the HTTP status of the request | 404 |
|
||||
| `[RESPONSE_TIME]` | Resolves into the response time the request took, in ms | 10 |
|
||||
| `[IP]` | Resolves into the IP of the target host | 192.168.0.232 |
|
||||
| `[BODY]` | Resolves into the response body. Supports JSONPath. | `{"name":"john.doe"}` |
|
||||
| `[CONNECTED]` | Resolves into whether a connection could be established | `true` |
|
||||
| `[CERTIFICATE_EXPIRATION]` | Resolves into the duration before certificate expiration | `24h`, `48h`, 0 (if not protocol with certs) |
|
||||
| `[DNS_RCODE]` | Resolves into the DNS status of the response | NOERROR |
|
||||
|
||||
|
||||
#### Functions
|
||||
| Function | Description | Example |
|
||||
|:-----------|:---------------------------------------------------------------------------------------------------------------- |:-------------------------- |
|
||||
| `len` | Returns the length of the object/slice. Works only with the `[BODY]` placeholder. | `len([BODY].username) > 8`
|
||||
| `has` | Returns `true` or `false` based on whether a given path is valid. Works only with the `[BODY]` placeholder. | `has([BODY].errors) == false`
|
||||
| `pat` | Specifies that the string passed as parameter should be evaluated as a pattern. Works only with `==` and `!=`. | `[IP] == pat(192.168.*)`
|
||||
| `any` | Specifies that any one of the values passed as parameters is a valid value. Works only with `==` and `!=`. | `[BODY].ip == any(127.0.0.1, ::1)`
|
||||
| Function | Description | Example |
|
||||
|:---------|:---------------------------------------------------------------------------------------------------------------|:-----------------------------------|
|
||||
| `len` | Returns the length of the object/slice. Works only with the `[BODY]` placeholder. | `len([BODY].username) > 8` |
|
||||
| `has` | Returns `true` or `false` based on whether a given path is valid. Works only with the `[BODY]` placeholder. | `has([BODY].errors) == false` |
|
||||
| `pat` | Specifies that the string passed as parameter should be evaluated as a pattern. Works only with `==` and `!=`. | `[IP] == pat(192.168.*)` |
|
||||
| `any` | Specifies that any one of the values passed as parameters is a valid value. Works only with `==` and `!=`. | `[BODY].ip == any(127.0.0.1, ::1)` |
|
||||
|
||||
**NOTE**: Use `pat` only when you need to. `[STATUS] == pat(2*)` is a lot more expensive than `[STATUS] < 300`.
|
||||
|
||||
|
||||
### Storage
|
||||
| Parameter | Description | Default |
|
||||
|:------------------ |:-------------------------------------------------------------------------------------- |:-------------- |
|
||||
| `storage` | Storage configuration | `{}` |
|
||||
| `storage.path` | Path to persist the data in. Only supported for types `sqlite` and `postgres`. | `""` |
|
||||
| `storage.type` | Type of storage. Valid types: `memory`, `sqlite`, `postgres`. | `"memory"` |
|
||||
| Parameter | Description | Default |
|
||||
|:---------------|:-------------------------------------------------------------------------------|:-----------|
|
||||
| `storage` | Storage configuration | `{}` |
|
||||
| `storage.path` | Path to persist the data in. Only supported for types `sqlite` and `postgres`. | `""` |
|
||||
| `storage.type` | Type of storage. Valid types: `memory`, `sqlite`, `postgres`. | `"memory"` |
|
||||
|
||||
- If `storage.type` is `memory` (default):
|
||||
```yaml
|
||||
@@ -269,11 +273,16 @@ See [examples/docker-compose-postgres-storage](.examples/docker-compose-postgres
|
||||
In order to support a wide range of environments, each monitored endpoint has a unique configuration for
|
||||
the client used to send the request.
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|:-------------------------|:----------------------------------------------------------------------------- |:-------------- |
|
||||
| `client.insecure` | Whether to skip verifying the server's certificate chain and host name. | `false` |
|
||||
| `client.ignore-redirect` | Whether to ignore redirects (true) or follow them (false, default). | `false` |
|
||||
| `client.timeout` | Duration before timing out. | `10s` |
|
||||
| Parameter | Description | Default |
|
||||
|:------------------------------|:---------------------------------------------------------------------------|:----------------|
|
||||
| `client.insecure` | Whether to skip verifying the server's certificate chain and host name. | `false` |
|
||||
| `client.ignore-redirect` | Whether to ignore redirects (true) or follow them (false, default). | `false` |
|
||||
| `client.timeout` | Duration before timing out. | `10s` |
|
||||
| `client.oauth2` | OAuth2 client configuration. | `{}` |
|
||||
| `client.oauth2.token-url` | The token endpoint URL | required `""` |
|
||||
| `client.oauth2.client-id` | The client id which should be used for the `Client credentials flow` | required `""` |
|
||||
| `client.oauth2.client-secret` | The client secret which should be used for the `Client credentials flow` | required `""` |
|
||||
| `client.oauth2.scopes[]` | A list of `scopes` which should be used for the `Client credentials flow`. | required `[""]` |
|
||||
|
||||
Note that some of these parameters are ignored based on the type of endpoint. For instance, there's no certificate involved
|
||||
in ICMP requests (ping), therefore, setting `client.insecure` to `true` for an endpoint of that type will not do anything.
|
||||
@@ -300,6 +309,20 @@ endpoints:
|
||||
- "[STATUS] == 200"
|
||||
```
|
||||
|
||||
This example shows how you can use the `client.oauth2` configuration to query a backend API with `Bearer token`:
|
||||
```yaml
|
||||
endpoints:
|
||||
- name: website
|
||||
url: "https://your.health.api/getHealth"
|
||||
client:
|
||||
oauth2:
|
||||
token-url: https://your-token-server/token
|
||||
client-id: 00000000-0000-0000-0000-000000000000
|
||||
client-secret: your-client-secret
|
||||
scopes: ['https://your.health.api/.default']
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
```
|
||||
|
||||
### Alerting
|
||||
Gatus supports multiple alerting providers, such as Slack and PagerDuty, and supports different alerts for each
|
||||
@@ -308,25 +331,28 @@ individual endpoints with configurable descriptions and thresholds.
|
||||
Note that if an alerting provider is not properly configured, all alerts configured with the provider's type will be
|
||||
ignored.
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|:-----------------------|:---------------------------------------------------------------------------------------------------------------------- |:-------|
|
||||
| `alerting.discord` | Configuration for alerts of type `discord`. <br />See [Configuring Discord alerts](#configuring-discord-alerts). | `{}` |
|
||||
| `alerting.mattermost` | Configuration for alerts of type `mattermost`. <br />See [Configuring Mattermost alerts](#configuring-mattermost-alerts). | `{}` |
|
||||
| `alerting.messagebird` | Configuration for alerts of type `messagebird`. <br />See [Configuring Messagebird alerts](#configuring-messagebird-alerts). | `{}` |
|
||||
| `alerting.pagerduty` | Configuration for alerts of type `pagerduty`. <br />See [Configuring PagerDuty alerts](#configuring-pagerduty-alerts). | `{}` |
|
||||
| `alerting.slack` | Configuration for alerts of type `slack`. <br />See [Configuring Slack alerts](#configuring-slack-alerts). | `{}` |
|
||||
| `alerting.teams` | Configuration for alerts of type `teams`. <br />See [Configuring Teams alerts](#configuring-teams-alerts). | `{}` |
|
||||
| `alerting.telegram` | Configuration for alerts of type `telegram`. <br />See [Configuring Telegram alerts](#configuring-telegram-alerts). | `{}` |
|
||||
| `alerting.twilio` | Settings for alerts of type `twilio`. <br />See [Configuring Twilio alerts](#configuring-twilio-alerts). | `{}` |
|
||||
| `alerting.custom` | Configuration for custom actions on failure or alerts. <br />See [Configuring Custom alerts](#configuring-custom-alerts). | `{}` |
|
||||
| Parameter | Description | Default |
|
||||
|:-----------------------|:-----------------------------------------------------------------------------------------------------------------------------|:--------|
|
||||
| `alerting.discord` | Configuration for alerts of type `discord`. <br />See [Configuring Discord alerts](#configuring-discord-alerts). | `{}` |
|
||||
| `alerting.email` | Configuration for alerts of type `email`. <br />See [Configuring Email alerts](#configuring-email-alerts). | `{}` |
|
||||
| `alerting.googlechat` | Configuration for alerts of type `googlechat`. <br />See [Configuring Google Chat alerts](#configuring-google-chat-alerts). | `{}` |
|
||||
| `alerting.mattermost` | Configuration for alerts of type `mattermost`. <br />See [Configuring Mattermost alerts](#configuring-mattermost-alerts). | `{}` |
|
||||
| `alerting.messagebird` | Configuration for alerts of type `messagebird`. <br />See [Configuring Messagebird alerts](#configuring-messagebird-alerts). | `{}` |
|
||||
| `alerting.opsgenie` | Configuration for alerts of type `opsgenie`. <br />See [Configuring Opsgenie alerts](#configuring-opsgenie-alerts). | `{}` |
|
||||
| `alerting.pagerduty` | Configuration for alerts of type `pagerduty`. <br />See [Configuring PagerDuty alerts](#configuring-pagerduty-alerts). | `{}` |
|
||||
| `alerting.slack` | Configuration for alerts of type `slack`. <br />See [Configuring Slack alerts](#configuring-slack-alerts). | `{}` |
|
||||
| `alerting.teams` | Configuration for alerts of type `teams`. <br />See [Configuring Teams alerts](#configuring-teams-alerts). | `{}` |
|
||||
| `alerting.telegram` | Configuration for alerts of type `telegram`. <br />See [Configuring Telegram alerts](#configuring-telegram-alerts). | `{}` |
|
||||
| `alerting.twilio` | Settings for alerts of type `twilio`. <br />See [Configuring Twilio alerts](#configuring-twilio-alerts). | `{}` |
|
||||
| `alerting.custom` | Configuration for custom actions on failure or alerts. <br />See [Configuring Custom alerts](#configuring-custom-alerts). | `{}` |
|
||||
|
||||
|
||||
#### Configuring Discord alerts
|
||||
| Parameter | Description | Default |
|
||||
|:---------------------------------------- |:-------------------------------------------- |:-------------- |
|
||||
| `alerting.discord` | Configuration for alerts of type `discord` | `{}` |
|
||||
| `alerting.discord.webhook-url` | Discord Webhook URL | Required `""` |
|
||||
| `alerting.discord.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
| Parameter | Description | Default |
|
||||
|:---------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
|
||||
| `alerting.discord` | Configuration for alerts of type `discord` | `{}` |
|
||||
| `alerting.discord.webhook-url` | Discord Webhook URL | Required `""` |
|
||||
| `alerting.discord.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
@@ -349,13 +375,101 @@ endpoints:
|
||||
```
|
||||
|
||||
|
||||
#### Configuring Email alerts
|
||||
| Parameter | Description | Default |
|
||||
|:---------------------------------- |:------------------------------------------------------------------------------------------ |:------------- |
|
||||
| `alerting.email` | Configuration for alerts of type `email` | `{}` |
|
||||
| `alerting.email.from` | Email used to send the alert | Required `""` |
|
||||
| `alerting.email.username` | Username of the SMTP server used to send the alert. If empty, uses `alerting.email.from`. | `""` |
|
||||
| `alerting.email.password` | Password of the SMTP server used to send the alert | Required `""` |
|
||||
| `alerting.email.host` | Host of the mail server (e.g. `smtp.gmail.com`) | Required `""` |
|
||||
| `alerting.email.port` | Port the mail server is listening to (e.g. `587`) | Required `0` |
|
||||
| `alerting.email.to` | Email(s) to send the alerts to | Required `""` |
|
||||
| `alerting.email.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
| `alerting.email.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||
| `alerting.email.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||
| `alerting.email.overrides[].to` | Email(s) to send the alerts to | `""` |
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
email:
|
||||
from: "from@example.com"
|
||||
username: "from@example.com"
|
||||
password: "hunter2"
|
||||
host: "mail.example.com"
|
||||
port: 587
|
||||
to: "recipient1@example.com,recipient2@example.com"
|
||||
# You can also add group-specific to keys, which will
|
||||
# override the to key above for the specified groups
|
||||
overrides:
|
||||
- group: "core"
|
||||
to: "recipient3@example.com,recipient4@example.com"
|
||||
|
||||
endpoints:
|
||||
- name: website
|
||||
url: "https://twin.sh/health"
|
||||
interval: 5m
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].status == UP"
|
||||
- "[RESPONSE_TIME] < 300"
|
||||
alerts:
|
||||
- type: email
|
||||
enabled: true
|
||||
description: "healthcheck failed"
|
||||
send-on-resolved: true
|
||||
|
||||
- name: back-end
|
||||
group: core
|
||||
url: "https://example.org/"
|
||||
interval: 5m
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[CERTIFICATE_EXPIRATION] > 48h"
|
||||
alerts:
|
||||
- type: email
|
||||
enabled: true
|
||||
description: "healthcheck failed"
|
||||
send-on-resolved: true
|
||||
```
|
||||
|
||||
**NOTE:** Some mail servers are painfully slow.
|
||||
|
||||
#### Configuring Google Chat alerts
|
||||
| Parameter | Description | Default |
|
||||
|:------------------------------------|:--------------------------------------------------------------------------------------------|:--------------|
|
||||
| `alerting.googlechat` | Configuration for alerts of type `googlechat` | `{}` |
|
||||
| `alerting.googlechat.webhook-url` | Google Chat Webhook URL | Required `""` |
|
||||
| `alerting.googlechat.client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` |
|
||||
| `alerting.googlechat.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert). | N/A |
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
googlechat:
|
||||
webhook-url: "https://chat.googleapis.com/v1/spaces/*******/messages?key=**********&token=********"
|
||||
|
||||
endpoints:
|
||||
- name: website
|
||||
url: "https://twin.sh/health"
|
||||
interval: 30s
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].status == UP"
|
||||
- "[RESPONSE_TIME] < 300"
|
||||
alerts:
|
||||
- type: googlechat
|
||||
enabled: true
|
||||
description: "healthcheck failed"
|
||||
send-on-resolved: true
|
||||
```
|
||||
|
||||
#### Configuring Mattermost alerts
|
||||
| Parameter | Description | Default |
|
||||
|:----------------------------------- |:------------------------------------------------------------------------------------------- |:-------------- |
|
||||
| `alerting.mattermost` | Configuration for alerts of type `mattermost` | `{}` |
|
||||
| `alerting.mattermost.webhook-url` | Mattermost Webhook URL | Required `""` |
|
||||
| `alerting.mattermost.client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` |
|
||||
| `alerting.mattermost.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert). | N/A |
|
||||
| Parameter | Description | Default |
|
||||
|:------------------------------------|:--------------------------------------------------------------------------------------------|:--------------|
|
||||
| `alerting.mattermost` | Configuration for alerts of type `mattermost` | `{}` |
|
||||
| `alerting.mattermost.webhook-url` | Mattermost Webhook URL | Required `""` |
|
||||
| `alerting.mattermost.client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` |
|
||||
| `alerting.mattermost.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert). | N/A |
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
@@ -383,15 +497,14 @@ Here's an example of what the notifications look like:
|
||||
|
||||

|
||||
|
||||
|
||||
#### Configuring Messagebird alerts
|
||||
| Parameter | Description | Default |
|
||||
|:-------------------------------------|:----------------------------------------------------------------------------- |:-------------- |
|
||||
| `alerting.messagebird` | Settings for alerts of type `messagebird` | `{}` |
|
||||
| `alerting.messagebird.access-key` | Messagebird access key | Required `""` |
|
||||
| `alerting.messagebird.originator` | The sender of the message | Required `""` |
|
||||
| `alerting.messagebird.recipients` | The recipients of the message | Required `""` |
|
||||
| `alerting.messagebird.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
| Parameter | Description | Default |
|
||||
|:-------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
|
||||
| `alerting.messagebird` | Settings for alerts of type `messagebird` | `{}` |
|
||||
| `alerting.messagebird.access-key` | Messagebird access key | Required `""` |
|
||||
| `alerting.messagebird.originator` | The sender of the message | Required `""` |
|
||||
| `alerting.messagebird.recipients` | The recipients of the message | Required `""` |
|
||||
| `alerting.messagebird.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
|
||||
Example of sending **SMS** text message alert using Messagebird:
|
||||
```yaml
|
||||
@@ -418,26 +531,46 @@ endpoints:
|
||||
```
|
||||
|
||||
|
||||
#### Configuring Opsgenie alerts
|
||||
| Parameter | Description | Default |
|
||||
|:----------------------------------|:-------------------------------------------------------------------------------------------|:---------------------|
|
||||
| `alerting.opsgenie` | Configuration for alerts of type `opsgenie` | `{}` |
|
||||
| `alerting.opsgenie.api-key` | Opsgenie API Key | Required `""` |
|
||||
| `alerting.opsgenie.priority` | Priority level of the alert. | `P1` |
|
||||
| `alerting.opsgenie.source` | Source field of the alert. | `gatus` |
|
||||
| `alerting.opsgenie.entity-prefix` | Entity field prefix. | `gatus-` |
|
||||
| `alerting.opsgenie.alias-prefix` | Alias field prefix. | `gatus-healthcheck-` |
|
||||
| `alerting.opsgenie.tags` | Tags of alert. | `[]` |
|
||||
| `alerting.opsgenie.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
|
||||
Opsgenie provider will automatically open and close alerts.
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
opsgenie:
|
||||
api-key: "00000000-0000-0000-0000-000000000000"
|
||||
```
|
||||
|
||||
|
||||
#### Configuring PagerDuty alerts
|
||||
| Parameter | Description | Default |
|
||||
|:------------------------------------------------------ |:----------------------------------------------------------------------------- |:-------------- |
|
||||
| `alerting.pagerduty` | Configuration for alerts of type `pagerduty` | `{}` |
|
||||
| `alerting.pagerduty.integration-key` | PagerDuty Events API v2 integration key | `""` |
|
||||
| `alerting.pagerduty.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
| `alerting.pagerduty.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||
| `alerting.pagerduty.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||
| `alerting.pagerduty.overrides[].integration-key` | PagerDuty Events API v2 integration key | `""` |
|
||||
| Parameter | Description | Default |
|
||||
|:-------------------------------------------------|:-------------------------------------------------------------------------------------------|:--------|
|
||||
| `alerting.pagerduty` | Configuration for alerts of type `pagerduty` | `{}` |
|
||||
| `alerting.pagerduty.integration-key` | PagerDuty Events API v2 integration key | `""` |
|
||||
| `alerting.pagerduty.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||
| `alerting.pagerduty.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||
| `alerting.pagerduty.overrides[].integration-key` | PagerDuty Events API v2 integration key | `""` |
|
||||
| `alerting.pagerduty.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
|
||||
It is highly recommended to set `endpoints[].alerts[].send-on-resolved` to `true` for alerts
|
||||
of type `pagerduty`, because unlike other alerts, the operation resulting from setting said
|
||||
parameter to `true` will not create another incident, but mark the incident as resolved on
|
||||
parameter to `true` will not create another incident but mark the incident as resolved on
|
||||
PagerDuty instead.
|
||||
|
||||
Behavior:
|
||||
- By default, `alerting.pagerduty.integration-key` is used as the integration key
|
||||
- If the endpoint being evaluated belongs to a group (`endpoints[].group`) matching the value of `alerting.pagerduty.overrides[].group`, the provider will use that override's integration key instead of `alerting.pagerduty.integration-key`'s
|
||||
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
pagerduty:
|
||||
@@ -445,8 +578,8 @@ alerting:
|
||||
# You can also add group-specific integration keys, which will
|
||||
# override the integration key above for the specified groups
|
||||
overrides:
|
||||
- group: "core"
|
||||
integration-key: "********************************"
|
||||
- group: "core"
|
||||
integration-key: "********************************"
|
||||
|
||||
endpoints:
|
||||
- name: website
|
||||
@@ -482,11 +615,11 @@ endpoints:
|
||||
|
||||
|
||||
#### Configuring Slack alerts
|
||||
| Parameter | Description | Default |
|
||||
|:-------------------------------- |:----------------------------------------------------------------------------- |:-------------- |
|
||||
| `alerting.slack` | Configuration for alerts of type `slack` | `{}` |
|
||||
| `alerting.slack.webhook-url` | Slack Webhook URL | Required `""` |
|
||||
| `alerting.slack.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
| Parameter | Description | Default |
|
||||
|:-------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
|
||||
| `alerting.slack` | Configuration for alerts of type `slack` | `{}` |
|
||||
| `alerting.slack.webhook-url` | Slack Webhook URL | Required `""` |
|
||||
| `alerting.slack.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
@@ -519,11 +652,11 @@ Here's an example of what the notifications look like:
|
||||
|
||||
|
||||
#### Configuring Teams alerts
|
||||
| Parameter | Description | Default |
|
||||
|:-------------------------------- |:----------------------------------------------------------------------------- |:-------------- |
|
||||
| `alerting.teams` | Configuration for alerts of type `teams` | `{}` |
|
||||
| `alerting.teams.webhook-url` | Teams Webhook URL | Required `""` |
|
||||
| `alerting.teams.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
| Parameter | Description | Default |
|
||||
|:-------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
|
||||
| `alerting.teams` | Configuration for alerts of type `teams` | `{}` |
|
||||
| `alerting.teams.webhook-url` | Teams Webhook URL | Required `""` |
|
||||
| `alerting.teams.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
@@ -550,12 +683,13 @@ Here's an example of what the notifications look like:
|
||||

|
||||
|
||||
#### Configuring Telegram alerts
|
||||
| Parameter | Description | Default |
|
||||
|:----------------------------------- |:----------------------------------------------------------------------------- |:-------------- |
|
||||
| `alerting.telegram` | Configuration for alerts of type `telegram` | `{}` |
|
||||
| `alerting.telegram.token` | Telegram Bot Token | Required `""` |
|
||||
| `alerting.telegram.id` | Telegram User ID | Required `""` |
|
||||
| `alerting.telegram.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
| Parameter | Description | Default |
|
||||
|:----------------------------------|:-------------------------------------------------------------------------------------------|:---------------------------|
|
||||
| `alerting.telegram` | Configuration for alerts of type `telegram` | `{}` |
|
||||
| `alerting.telegram.token` | Telegram Bot Token | Required `""` |
|
||||
| `alerting.telegram.id` | Telegram User ID | Required `""` |
|
||||
| `alerting.telegram.api-url` | Telegram API URL | `https://api.telegram.org` |
|
||||
| `alerting.telegram.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
@@ -582,14 +716,14 @@ Here's an example of what the notifications look like:
|
||||
|
||||
|
||||
#### Configuring Twilio alerts
|
||||
| Parameter | Description | Default |
|
||||
|:--------------------------------- |:----------------------------------------------------------------------------- |:-------------- |
|
||||
| `alerting.twilio` | Settings for alerts of type `twilio` | `{}` |
|
||||
| `alerting.twilio.sid` | Twilio account SID | Required `""` |
|
||||
| `alerting.twilio.token` | Twilio auth token | Required `""` |
|
||||
| `alerting.twilio.from` | Number to send Twilio alerts from | Required `""` |
|
||||
| `alerting.twilio.to` | Number to send twilio alerts to | Required `""` |
|
||||
| `alerting.twilio.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
| Parameter | Description | Default |
|
||||
|:--------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
|
||||
| `alerting.twilio` | Settings for alerts of type `twilio` | `{}` |
|
||||
| `alerting.twilio.sid` | Twilio account SID | Required `""` |
|
||||
| `alerting.twilio.token` | Twilio auth token | Required `""` |
|
||||
| `alerting.twilio.from` | Number to send Twilio alerts from | Required `""` |
|
||||
| `alerting.twilio.to` | Number to send twilio alerts to | Required `""` |
|
||||
| `alerting.twilio.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
@@ -617,15 +751,15 @@ endpoints:
|
||||
|
||||
|
||||
#### Configuring custom alerts
|
||||
| Parameter | Description | Default |
|
||||
|:----------------------------------|:----------------------------------------------------------------------------- |:-------------- |
|
||||
| `alerting.custom` | Configuration for custom actions on failure or alerts | `{}` |
|
||||
| `alerting.custom.url` | Custom alerting request url | Required `""` |
|
||||
| `alerting.custom.method` | Request method | `GET` |
|
||||
| `alerting.custom.body` | Custom alerting request body. | `""` |
|
||||
| `alerting.custom.headers` | Custom alerting request headers | `{}` |
|
||||
| `alerting.custom.client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` |
|
||||
| `alerting.custom.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
| Parameter | Description | Default |
|
||||
|:--------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
|
||||
| `alerting.custom` | Configuration for custom actions on failure or alerts | `{}` |
|
||||
| `alerting.custom.url` | Custom alerting request url | Required `""` |
|
||||
| `alerting.custom.method` | Request method | `GET` |
|
||||
| `alerting.custom.body` | Custom alerting request body. | `""` |
|
||||
| `alerting.custom.headers` | Custom alerting request headers | `{}` |
|
||||
| `alerting.custom.client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` |
|
||||
| `alerting.custom.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
|
||||
While they're called alerts, you can use this feature to call anything.
|
||||
|
||||
@@ -642,7 +776,7 @@ If you have an alert using the `custom` provider with `send-on-resolved` set to
|
||||
The aforementioned placeholder will be replaced by `TRIGGERED` or `RESOLVED` accordingly, though it can be modified
|
||||
(details at the end of this section).
|
||||
|
||||
For all intents and purpose, we'll configure the custom alert with a Slack webhook, but you can call anything you want.
|
||||
For all intents and purposes, we'll configure the custom alert with a Slack webhook, but you can call anything you want.
|
||||
```yaml
|
||||
alerting:
|
||||
custom:
|
||||
@@ -777,12 +911,12 @@ endpoints:
|
||||
If you have maintenance windows, you may not want to be annoyed by alerts.
|
||||
To do that, you'll have to use the maintenance configuration:
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|:----------------------- |:----------------------------------------------------------------------------- |:--------------- |
|
||||
| `maintenance.enabled` | Whether the maintenance period is enabled | `true` |
|
||||
| `maintenance.start` | Time at which the maintenance window starts in `hh:mm` format (e.g. `23:00`) | Required `""` |
|
||||
| `maintenance.duration` | Duration of the maintenance window (e.g. `1h`, `30m`) | Required `""` |
|
||||
| `maintenance.every` | Days on which the maintenance period applies (e.g. `[Monday, Thursday]`).<br />If left empty, the maintenance window applies every day | `[]` |
|
||||
| Parameter | Description | Default |
|
||||
|:-----------------------|:---------------------------------------------------------------------------------------------------------------------------------------|:--------------|
|
||||
| `maintenance.enabled` | Whether the maintenance period is enabled | `true` |
|
||||
| `maintenance.start` | Time at which the maintenance window starts in `hh:mm` format (e.g. `23:00`) | Required `""` |
|
||||
| `maintenance.duration` | Duration of the maintenance window (e.g. `1h`, `30m`) | Required `""` |
|
||||
| `maintenance.every` | Days on which the maintenance period applies (e.g. `[Monday, Thursday]`).<br />If left empty, the maintenance window applies every day | `[]` |
|
||||
|
||||
**Note that the maintenance configuration uses UTC.**
|
||||
|
||||
@@ -804,6 +938,56 @@ maintenance:
|
||||
```
|
||||
|
||||
|
||||
### Security
|
||||
| Parameter | Description | Default |
|
||||
|:---------------------------------|:-----------------------------|:--------------|
|
||||
| `security` | Security configuration | `{}` |
|
||||
| `security.basic` | HTTP Basic configuration | `{}` |
|
||||
| `security.oidc` | OpenID Connect configuration | `{}` |
|
||||
|
||||
#### Basic
|
||||
| Parameter | Description | Default |
|
||||
|:----------------------------------------|:-----------------------------------------------------------------------------------|:--------------|
|
||||
| `security.basic` | HTTP Basic configuration | `{}` |
|
||||
| `security.basic.username` | Username for Basic authentication. | Required `""` |
|
||||
| `security.basic.password-bcrypt-base64` | Password hashed with Bcrypt and then encoded with base64 for Basic authentication. | Required `""` |
|
||||
|
||||
The example below will require that you authenticate with the username `john.doe` and the password `hunter2`:
|
||||
```yaml
|
||||
security:
|
||||
basic:
|
||||
username: "john.doe"
|
||||
password-bcrypt-base64: "JDJhJDEwJHRiMnRFakxWazZLdXBzRERQazB1TE8vckRLY05Yb1hSdnoxWU0yQ1FaYXZRSW1McmladDYu"
|
||||
```
|
||||
|
||||
**WARNING:** Make sure to carefully select to cost of the bcrypt hash. The higher the cost, the longer it takes to compute the hash,
|
||||
and basic auth verifies the password against the hash on every request. As of 2022-01-08, I suggest a cost of 8.
|
||||
|
||||
#### OIDC (ALPHA)
|
||||
| Parameter | Description | Default |
|
||||
|:---------------------------------|:---------------------------------------------------------------|:--------------|
|
||||
| `security.oidc` | OpenID Connect configuration | `{}` |
|
||||
| `security.oidc.issuer-url` | Issuer URL | Required `""` |
|
||||
| `security.oidc.redirect-url` | Redirect URL. Must end with `/authorization-code/callback` | Required `""` |
|
||||
| `security.oidc.client-id` | Client id | Required `""` |
|
||||
| `security.oidc.client-secret` | Client secret | Required `""` |
|
||||
| `security.oidc.scopes` | Scopes to request. The only scope you need is `openid`. | Required `[]` |
|
||||
| `security.oidc.allowed-subjects` | List of subjects to allow. If empty, all subjects are allowed. | `[]` |
|
||||
|
||||
```yaml
|
||||
security:
|
||||
oidc:
|
||||
issuer-url: "https://example.okta.com"
|
||||
redirect-url: "https://status.example.com/authorization-code/callback"
|
||||
client-id: "123456789"
|
||||
client-secret: "abcdefghijk"
|
||||
scopes: ["openid"]
|
||||
# You may optionally specify a list of allowed subjects. If this is not specified, all subjects will be allowed.
|
||||
#allowed-subjects: ["johndoe@example.com"]
|
||||
```
|
||||
|
||||
**NOTE:** The OIDC feature is currently in Alpha. Breaking changes may occur. Use this feature at your own risk.
|
||||
|
||||
|
||||
## Deployment
|
||||
Many examples can be found in the [.examples](.examples) folder, but this section will focus on the most popular ways of deploying Gatus.
|
||||
@@ -898,7 +1082,7 @@ will send a `POST` request to `http://localhost:8080/playground` with the follow
|
||||
> tells Gatus to only evaluate one endpoint at a time.
|
||||
|
||||
To ensure that Gatus provides reliable and accurate results (i.e. response time), Gatus only evaluates one endpoint at a time
|
||||
In other words, even if you have multiple endpoints with the exact same interval, they will not execute at the same time.
|
||||
In other words, even if you have multiple endpoints with the same interval, they will not execute at the same time.
|
||||
|
||||
You can test this yourself by running Gatus with several endpoints configured with a very short, unrealistic interval,
|
||||
such as 1ms. You'll notice that the response time does not fluctuate - that is because while endpoints are evaluated on
|
||||
@@ -916,10 +1100,10 @@ to respect the configured interval, for instance:
|
||||
- Endpoint B has an interval of 5s, and takes 1ms to complete
|
||||
- Endpoint B will be unable to run every 5s, because endpoint A's health evaluation takes longer than its interval
|
||||
|
||||
To sum it up, while Gatus can really handle any interval you throw at it, you're better off having slow requests with
|
||||
To sum it up, while Gatus can handle any interval you throw at it, you're better off having slow requests with
|
||||
higher interval.
|
||||
|
||||
As a rule of the thumb, I personally set interval for more complex health checks to `5m` (5 minutes) and
|
||||
As a rule of thumb, I personally set the interval for more complex health checks to `5m` (5 minutes) and
|
||||
simple health checks used for alerting (PagerDuty/Twilio) to `30s`.
|
||||
|
||||
|
||||
@@ -1021,18 +1205,6 @@ endpoints:
|
||||
```
|
||||
|
||||
|
||||
### Basic authentication
|
||||
You can require Basic authentication by leveraging the `security.basic` configuration:
|
||||
```yaml
|
||||
security:
|
||||
basic:
|
||||
username: "john.doe"
|
||||
password-sha512: "6b97ed68d14eb3f1aa959ce5d49c7dc612e1eb1dafd73b1e705847483fd6a6c809f2ceb4e8df6ff9984c6298ff0285cace6614bf8daa9f0070101b6c89899e22"
|
||||
```
|
||||
|
||||
The example above will require that you authenticate with the username `john.doe` as well as the password `hunter2`.
|
||||
|
||||
|
||||
### disable-monitoring-lock
|
||||
Setting `disable-monitoring-lock` to `true` means that multiple endpoints could be monitored at the same time.
|
||||
|
||||
@@ -1043,11 +1215,11 @@ There are three main reasons why you might want to disable the monitoring lock:
|
||||
- You're using Gatus for load testing (each endpoint are periodically evaluated on a different goroutine, so
|
||||
technically, if you create 100 endpoints with a 1 seconds interval, Gatus will send 100 requests per second)
|
||||
- You have a _lot_ of endpoints to monitor
|
||||
- You want to test multiple endpoints at very short interval (< 5s)
|
||||
- You want to test multiple endpoints at very short intervals (< 5s)
|
||||
|
||||
|
||||
### Reloading configuration on the fly
|
||||
For the sake on convenience, Gatus automatically reloads the configuration on the fly if the loaded configuration file
|
||||
For the sake of convenience, Gatus automatically reloads the configuration on the fly if the loaded configuration file
|
||||
is updated while Gatus is running.
|
||||
|
||||
By default, the application will exit if the updating configuration is invalid, but you can configure
|
||||
@@ -1136,8 +1308,8 @@ web:
|
||||

|
||||

|
||||
|
||||
Gatus can automatically generate a SVG badge for one of your monitored endpoints.
|
||||
This allows you to put badges in your individual applications' README or even create your own status page, if you
|
||||
Gatus can automatically generate an SVG badge for one of your monitored endpoints.
|
||||
This allows you to put badges in your individual applications' README or even create your own status page if you
|
||||
desire.
|
||||
|
||||
The path to generate a badge is the following:
|
||||
@@ -1161,7 +1333,7 @@ Example:
|
||||
```
|
||||

|
||||
```
|
||||
If you'd like to see a visual example of each badges available, you can simply navigate to the endpoint's detail page.
|
||||
If you'd like to see a visual example of each badge available, you can simply navigate to the endpoint's detail page.
|
||||
|
||||
|
||||
### Response time
|
||||
@@ -1179,7 +1351,7 @@ Where:
|
||||
|
||||
|
||||
### API
|
||||
Gatus provides a simple read-only API which can be queried in order to programmatically determine endpoint status and history.
|
||||
Gatus provides a simple read-only API that can be queried in order to programmatically determine endpoint status and history.
|
||||
|
||||
All endpoints are available via a GET request to the following endpoint:
|
||||
```
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
package alert
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
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 \\")
|
||||
)
|
||||
|
||||
// Alert is a core.Endpoint's alert configuration
|
||||
type Alert struct {
|
||||
// Type of alert (required)
|
||||
@@ -9,7 +19,7 @@ type Alert struct {
|
||||
//
|
||||
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
|
||||
// or not for provider.ParseWithDefaultAlert to work.
|
||||
Enabled *bool `yaml:"enabled"`
|
||||
Enabled *bool `yaml:"enabled,omitempty"`
|
||||
|
||||
// FailureThreshold is the number of failures in a row needed before triggering the alert
|
||||
FailureThreshold int `yaml:"failure-threshold"`
|
||||
@@ -31,7 +41,7 @@ type Alert struct {
|
||||
|
||||
// ResolveKey is an optional field that is used by some providers (i.e. PagerDuty's dedup_key) to resolve
|
||||
// ongoing/triggered incidents
|
||||
ResolveKey string
|
||||
ResolveKey string `yaml:"-"`
|
||||
|
||||
// Triggered is used to determine whether an alert has been triggered. When an alert is resolved, this value
|
||||
// should be set back to false. It is used to prevent the same alert from going out twice.
|
||||
@@ -41,7 +51,21 @@ type Alert struct {
|
||||
// applied for alerts that are already triggered and has become "healthy" again is to prevent a case where, for
|
||||
// some reason, the alert provider always returns errors when trying to send the resolved notification
|
||||
// (SendOnResolved).
|
||||
Triggered bool
|
||||
Triggered bool `yaml:"-"`
|
||||
}
|
||||
|
||||
// ValidateAndSetDefaults validates the alert's configuration and sets the default value of fields that have one
|
||||
func (alert *Alert) ValidateAndSetDefaults() error {
|
||||
if alert.FailureThreshold <= 0 {
|
||||
alert.FailureThreshold = 3
|
||||
}
|
||||
if alert.SuccessThreshold <= 0 {
|
||||
alert.SuccessThreshold = 2
|
||||
}
|
||||
if strings.ContainsAny(alert.GetDescription(), "\"\\") {
|
||||
return ErrAlertWithInvalidDescription
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDescription retrieves the description of the alert
|
||||
|
||||
@@ -1,6 +1,55 @@
|
||||
package alert
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAlert_ValidateAndSetDefaults(t *testing.T) {
|
||||
invalidDescription := "\""
|
||||
scenarios := []struct {
|
||||
name string
|
||||
alert Alert
|
||||
expectedError error
|
||||
expectedSuccessThreshold int
|
||||
expectedFailureThreshold int
|
||||
}{
|
||||
{
|
||||
name: "valid-empty",
|
||||
alert: Alert{
|
||||
Description: nil,
|
||||
FailureThreshold: 0,
|
||||
SuccessThreshold: 0,
|
||||
},
|
||||
expectedError: nil,
|
||||
expectedFailureThreshold: 3,
|
||||
expectedSuccessThreshold: 2,
|
||||
},
|
||||
{
|
||||
name: "invalid-description",
|
||||
alert: Alert{
|
||||
Description: &invalidDescription,
|
||||
FailureThreshold: 10,
|
||||
SuccessThreshold: 5,
|
||||
},
|
||||
expectedError: ErrAlertWithInvalidDescription,
|
||||
expectedFailureThreshold: 10,
|
||||
expectedSuccessThreshold: 5,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
if err := scenario.alert.ValidateAndSetDefaults(); err != scenario.expectedError {
|
||||
t.Errorf("expected error %v, got %v", scenario.expectedError, err)
|
||||
}
|
||||
if scenario.alert.SuccessThreshold != scenario.expectedSuccessThreshold {
|
||||
t.Errorf("expected success threshold %v, got %v", scenario.expectedSuccessThreshold, scenario.alert.SuccessThreshold)
|
||||
}
|
||||
if scenario.alert.FailureThreshold != scenario.expectedFailureThreshold {
|
||||
t.Errorf("expected failure threshold %v, got %v", scenario.expectedFailureThreshold, scenario.alert.FailureThreshold)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlert_IsEnabled(t *testing.T) {
|
||||
if (Alert{Enabled: nil}).IsEnabled() {
|
||||
|
||||
@@ -11,6 +11,12 @@ const (
|
||||
// TypeDiscord is the Type for the discord alerting provider
|
||||
TypeDiscord Type = "discord"
|
||||
|
||||
// TypeEmail is the Type for the email alerting provider
|
||||
TypeEmail Type = "email"
|
||||
|
||||
// TypeGoogleChat is the Type for the googlechat alerting provider
|
||||
TypeGoogleChat Type = "googlechat"
|
||||
|
||||
// TypeMattermost is the Type for the mattermost alerting provider
|
||||
TypeMattermost Type = "mattermost"
|
||||
|
||||
@@ -31,4 +37,7 @@ const (
|
||||
|
||||
// TypeTwilio is the Type for the twilio alerting provider
|
||||
TypeTwilio Type = "twilio"
|
||||
|
||||
// TypeOpsgenie is the Type for the opsgenie alerting provider
|
||||
TypeOpsgenie Type = "opsgenie"
|
||||
)
|
||||
|
||||
@@ -5,8 +5,11 @@ import (
|
||||
"github.com/TwiN/gatus/v3/alerting/provider"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/custom"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/discord"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/email"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/googlechat"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/mattermost"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/messagebird"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/opsgenie"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/pagerduty"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/slack"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/teams"
|
||||
@@ -17,31 +20,40 @@ import (
|
||||
// Config is the configuration for alerting providers
|
||||
type Config struct {
|
||||
// Custom is the configuration for the custom alerting provider
|
||||
Custom *custom.AlertProvider `yaml:"custom"`
|
||||
Custom *custom.AlertProvider `yaml:"custom,omitempty"`
|
||||
|
||||
// googlechat is the configuration for the Google chat alerting provider
|
||||
GoogleChat *googlechat.AlertProvider `yaml:"googlechat,omitempty"`
|
||||
|
||||
// Discord is the configuration for the discord alerting provider
|
||||
Discord *discord.AlertProvider `yaml:"discord"`
|
||||
Discord *discord.AlertProvider `yaml:"discord,omitempty"`
|
||||
|
||||
// Email is the configuration for the email alerting provider
|
||||
Email *email.AlertProvider `yaml:"email,omitempty"`
|
||||
|
||||
// Mattermost is the configuration for the mattermost alerting provider
|
||||
Mattermost *mattermost.AlertProvider `yaml:"mattermost"`
|
||||
Mattermost *mattermost.AlertProvider `yaml:"mattermost,omitempty"`
|
||||
|
||||
// Messagebird is the configuration for the messagebird alerting provider
|
||||
Messagebird *messagebird.AlertProvider `yaml:"messagebird"`
|
||||
Messagebird *messagebird.AlertProvider `yaml:"messagebird,omitempty"`
|
||||
|
||||
// PagerDuty is the configuration for the pagerduty alerting provider
|
||||
PagerDuty *pagerduty.AlertProvider `yaml:"pagerduty"`
|
||||
PagerDuty *pagerduty.AlertProvider `yaml:"pagerduty,omitempty"`
|
||||
|
||||
// Slack is the configuration for the slack alerting provider
|
||||
Slack *slack.AlertProvider `yaml:"slack"`
|
||||
Slack *slack.AlertProvider `yaml:"slack,omitempty"`
|
||||
|
||||
// Teams is the configuration for the teams alerting provider
|
||||
Teams *teams.AlertProvider `yaml:"teams"`
|
||||
Teams *teams.AlertProvider `yaml:"teams,omitempty"`
|
||||
|
||||
// Telegram is the configuration for the telegram alerting provider
|
||||
Telegram *telegram.AlertProvider `yaml:"telegram"`
|
||||
Telegram *telegram.AlertProvider `yaml:"telegram,omitempty"`
|
||||
|
||||
// Twilio is the configuration for the twilio alerting provider
|
||||
Twilio *twilio.AlertProvider `yaml:"twilio"`
|
||||
Twilio *twilio.AlertProvider `yaml:"twilio,omitempty"`
|
||||
|
||||
// Opsgenie is the configuration for the opsgenie alerting provider
|
||||
Opsgenie *opsgenie.AlertProvider `yaml:"opsgenie,omitempty"`
|
||||
}
|
||||
|
||||
// GetAlertingProviderByAlertType returns an provider.AlertProvider by its corresponding alert.Type
|
||||
@@ -53,12 +65,24 @@ func (config Config) GetAlertingProviderByAlertType(alertType alert.Type) provid
|
||||
return nil
|
||||
}
|
||||
return config.Custom
|
||||
case alert.TypeGoogleChat:
|
||||
if config.GoogleChat == nil {
|
||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||
return nil
|
||||
}
|
||||
return config.GoogleChat
|
||||
case alert.TypeDiscord:
|
||||
if config.Discord == nil {
|
||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||
return nil
|
||||
}
|
||||
return config.Discord
|
||||
case alert.TypeEmail:
|
||||
if config.Email == nil {
|
||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||
return nil
|
||||
}
|
||||
return config.Email
|
||||
case alert.TypeMattermost:
|
||||
if config.Mattermost == nil {
|
||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||
@@ -71,6 +95,12 @@ func (config Config) GetAlertingProviderByAlertType(alertType alert.Type) provid
|
||||
return nil
|
||||
}
|
||||
return config.Messagebird
|
||||
case alert.TypeOpsgenie:
|
||||
if config.Opsgenie == nil {
|
||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||
return nil
|
||||
}
|
||||
return config.Opsgenie
|
||||
case alert.TypePagerDuty:
|
||||
if config.PagerDuty == nil {
|
||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||
|
||||
@@ -2,11 +2,9 @@ package custom
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
@@ -24,10 +22,10 @@ type AlertProvider struct {
|
||||
Placeholders map[string]map[string]string `yaml:"placeholders,omitempty"`
|
||||
|
||||
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
||||
ClientConfig *client.Config `yaml:"client"`
|
||||
ClientConfig *client.Config `yaml:"client,omitempty"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
@@ -38,11 +36,6 @@ func (provider *AlertProvider) IsValid() bool {
|
||||
return len(provider.URL) > 0 && provider.ClientConfig != nil
|
||||
}
|
||||
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) *AlertProvider {
|
||||
return provider
|
||||
}
|
||||
|
||||
// GetAlertStatePlaceholderValue returns the Placeholder value for ALERT_TRIGGERED_OR_RESOLVED if configured
|
||||
func (provider *AlertProvider) GetAlertStatePlaceholderValue(resolved bool) string {
|
||||
status := "TRIGGERED"
|
||||
@@ -105,27 +98,17 @@ func (provider *AlertProvider) buildHTTPRequest(endpointName, alertDescription s
|
||||
return request
|
||||
}
|
||||
|
||||
// Send a request to the alert provider and return the body
|
||||
func (provider *AlertProvider) Send(endpointName, alertDescription string, resolved bool) ([]byte, error) {
|
||||
if os.Getenv("MOCK_ALERT_PROVIDER") == "true" {
|
||||
if os.Getenv("MOCK_ALERT_PROVIDER_ERROR") == "true" {
|
||||
return nil, errors.New("error")
|
||||
}
|
||||
return []byte("{}"), nil
|
||||
}
|
||||
request := provider.buildHTTPRequest(endpointName, alertDescription, resolved)
|
||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||
request := provider.buildHTTPRequest(endpoint.Name, alert.GetDescription(), resolved)
|
||||
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
if response.StatusCode > 399 {
|
||||
body, err := ioutil.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("call to provider alert returned status code %d", response.StatusCode)
|
||||
}
|
||||
return nil, fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return ioutil.ReadAll(response.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package custom
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/client"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
"github.com/TwiN/gatus/v3/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_IsValid(t *testing.T) {
|
||||
@@ -19,6 +22,83 @@ func TestAlertProvider_IsValid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
MockRoundTripper test.MockRoundTripper
|
||||
ExpectedError bool
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "triggered-error",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "resolved-error",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||
err := scenario.Provider.Send(
|
||||
&core.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if scenario.ExpectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.ExpectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_buildHTTPRequestWhenResolved(t *testing.T) {
|
||||
const (
|
||||
ExpectedURL = "https://example.com/endpoint-name?event=RESOLVED&description=alert-description"
|
||||
@@ -33,7 +113,7 @@ func TestAlertProvider_buildHTTPRequestWhenResolved(t *testing.T) {
|
||||
if request.URL.String() != ExpectedURL {
|
||||
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
|
||||
}
|
||||
body, _ := ioutil.ReadAll(request.Body)
|
||||
body, _ := io.ReadAll(request.Body)
|
||||
if string(body) != ExpectedBody {
|
||||
t.Error("expected body to be", ExpectedBody, "was", string(body))
|
||||
}
|
||||
@@ -53,23 +133,12 @@ func TestAlertProvider_buildHTTPRequestWhenTriggered(t *testing.T) {
|
||||
if request.URL.String() != ExpectedURL {
|
||||
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
|
||||
}
|
||||
body, _ := ioutil.ReadAll(request.Body)
|
||||
body, _ := io.ReadAll(request.Body)
|
||||
if string(body) != ExpectedBody {
|
||||
t.Error("expected body to be", ExpectedBody, "was", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProvider(t *testing.T) {
|
||||
provider := AlertProvider{URL: "https://example.com"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{}, true)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if customAlertProvider.URL != "https://example.com" {
|
||||
t.Error("expected URL to be https://example.com, got", customAlertProvider.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
|
||||
const (
|
||||
ExpectedURL = "https://example.com/endpoint-name?event=test&description=alert-description"
|
||||
@@ -89,7 +158,7 @@ func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
|
||||
if request.URL.String() != ExpectedURL {
|
||||
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
|
||||
}
|
||||
body, _ := ioutil.ReadAll(request.Body)
|
||||
body, _ := io.ReadAll(request.Body)
|
||||
if string(body) != ExpectedBody {
|
||||
t.Error("expected body to be", ExpectedBody, "was", string(body))
|
||||
}
|
||||
@@ -110,6 +179,15 @@ func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAlertProvider_isBackwardCompatibleWithServiceRename checks if the custom alerting provider still supports
|
||||
// service placeholders after the migration from "service" to "endpoint"
|
||||
//
|
||||
@@ -127,7 +205,7 @@ func TestAlertProvider_isBackwardCompatibleWithServiceRename(t *testing.T) {
|
||||
if request.URL.String() != ExpectedURL {
|
||||
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
|
||||
}
|
||||
body, _ := ioutil.ReadAll(request.Body)
|
||||
body, _ := io.ReadAll(request.Body)
|
||||
if string(body) != ExpectedBody {
|
||||
t.Error("expected body to be", ExpectedBody, "was", string(body))
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package discord
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/custom"
|
||||
"github.com/TwiN/gatus/v3/client"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
)
|
||||
|
||||
@@ -14,7 +16,7 @@ type AlertProvider struct {
|
||||
WebhookURL string `yaml:"webhook-url"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
@@ -22,15 +24,34 @@ func (provider *AlertProvider) IsValid() bool {
|
||||
return len(provider.WebhookURL) > 0
|
||||
}
|
||||
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
|
||||
request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if response.StatusCode > 399 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
||||
var message, results string
|
||||
var colorCode int
|
||||
if resolved {
|
||||
message = fmt.Sprintf("An alert for **%s** has been resolved after passing successfully %d time(s) in a row", endpoint.Name, alert.SuccessThreshold)
|
||||
message = fmt.Sprintf("An alert for **%s** has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
|
||||
colorCode = 3066993
|
||||
} else {
|
||||
message = fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", endpoint.Name, alert.FailureThreshold)
|
||||
message = fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
|
||||
colorCode = 15158332
|
||||
}
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
@@ -46,10 +67,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, al
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
description = ":\\n> " + alertDescription
|
||||
}
|
||||
return &custom.AlertProvider{
|
||||
URL: provider.WebhookURL,
|
||||
Method: http.MethodPost,
|
||||
Body: fmt.Sprintf(`{
|
||||
return fmt.Sprintf(`{
|
||||
"content": "",
|
||||
"embeds": [
|
||||
{
|
||||
@@ -65,9 +83,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, al
|
||||
]
|
||||
}
|
||||
]
|
||||
}`, message, description, colorCode, results),
|
||||
Headers: map[string]string{"Content-Type": "application/json"},
|
||||
}
|
||||
}`, message, description, colorCode, results)
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
|
||||
@@ -3,11 +3,12 @@ package discord
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/client"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
"github.com/TwiN/gatus/v3/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_IsValid(t *testing.T) {
|
||||
@@ -21,50 +22,137 @@ func TestAlertProvider_IsValid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
||||
provider := AlertProvider{WebhookURL: "http://example.com"}
|
||||
alertDescription := "test"
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{Name: "svc"}, &alert.Alert{Description: &alertDescription}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
MockRoundTripper test.MockRoundTripper
|
||||
ExpectedError bool
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "triggered-error",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "resolved-error",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "resolved") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring resolved")
|
||||
}
|
||||
if customAlertProvider.URL != "http://example.com" {
|
||||
t.Errorf("expected URL to be %s, got %s", "http://example.com", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
if expected := "An alert for **svc** has been resolved after passing successfully 0 time(s) in a row:\n> test"; expected != body["embeds"].([]interface{})[0].(map[string]interface{})["description"] {
|
||||
t.Errorf("expected $.embeds[0].description to be %s, got %s", expected, body["embeds"].([]interface{})[0].(map[string]interface{})["description"])
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||
err := scenario.Provider.Send(
|
||||
&core.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if scenario.ExpectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.ExpectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
||||
provider := AlertProvider{WebhookURL: "http://example.com"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
ExpectedBody string
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\n \"content\": \"\",\n \"embeds\": [\n {\n \"title\": \":helmet_with_white_cross: Gatus\",\n \"description\": \"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n> description-1\",\n \"color\": 15158332,\n \"fields\": [\n {\n \"name\": \"Condition results\",\n \"value\": \":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\n \"inline\": false\n }\n ]\n }\n ]\n}",
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\n \"content\": \"\",\n \"embeds\": [\n {\n \"title\": \":helmet_with_white_cross: Gatus\",\n \"description\": \"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row:\\n> description-2\",\n \"color\": 3066993,\n \"fields\": [\n {\n \"name\": \"Condition results\",\n \"value\": \":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\n \"inline\": false\n }\n ]\n }\n ]\n}",
|
||||
},
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "triggered") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring triggered")
|
||||
}
|
||||
if customAlertProvider.URL != "http://example.com" {
|
||||
t.Errorf("expected URL to be %s, got %s", "http://example.com", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
body := scenario.Provider.buildRequestBody(
|
||||
&core.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if body != scenario.ExpectedBody {
|
||||
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
|
||||
}
|
||||
out := make(map[string]interface{})
|
||||
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
109
alerting/provider/email/email.go
Normal file
109
alerting/provider/email/email.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
gomail "gopkg.in/mail.v2"
|
||||
)
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using SMTP
|
||||
type AlertProvider struct {
|
||||
From string `yaml:"from"`
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
To string `yaml:"to"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
|
||||
// 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"`
|
||||
To string `yaml:"to"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
func (provider *AlertProvider) IsValid() bool {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.To) == 0 {
|
||||
return false
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
|
||||
return len(provider.From) > 0 && len(provider.Password) > 0 && len(provider.Host) > 0 && len(provider.To) > 0 && provider.Port > 0 && provider.Port < math.MaxUint16
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||
var username string
|
||||
if len(provider.Username) > 0 {
|
||||
username = provider.Username
|
||||
} else {
|
||||
username = provider.From
|
||||
}
|
||||
subject, body := provider.buildMessageSubjectAndBody(endpoint, alert, result, resolved)
|
||||
m := gomail.NewMessage()
|
||||
m.SetHeader("From", provider.From)
|
||||
m.SetHeader("To", strings.Split(provider.getToForGroup(endpoint.Group), ",")...)
|
||||
m.SetHeader("Subject", subject)
|
||||
m.SetBody("text/plain", body)
|
||||
d := gomail.NewDialer(provider.Host, provider.Port, username, provider.Password)
|
||||
return d.DialAndSend(m)
|
||||
}
|
||||
|
||||
// buildMessageSubjectAndBody builds the message subject and body
|
||||
func (provider *AlertProvider) buildMessageSubjectAndBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) (string, string) {
|
||||
var subject, message, results string
|
||||
if resolved {
|
||||
subject = fmt.Sprintf("[%s] Alert resolved", endpoint.DisplayName())
|
||||
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
|
||||
} else {
|
||||
subject = fmt.Sprintf("[%s] Alert triggered", endpoint.DisplayName())
|
||||
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
|
||||
}
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
if conditionResult.Success {
|
||||
prefix = "✅"
|
||||
} else {
|
||||
prefix = "❌"
|
||||
}
|
||||
results += fmt.Sprintf("%s %s\n", prefix, conditionResult.Condition)
|
||||
}
|
||||
var description string
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
description = "\n\nAlert description: " + alertDescription
|
||||
}
|
||||
return subject, message + description + "\n\nCondition results:\n" + results
|
||||
}
|
||||
|
||||
// getToForGroup returns the appropriate email integration to for a given group
|
||||
func (provider *AlertProvider) getToForGroup(group string) string {
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
return override.To
|
||||
}
|
||||
}
|
||||
}
|
||||
return provider.To
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
183
alerting/provider/email/email_test.go
Normal file
183
alerting/provider/email/email_test.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
)
|
||||
|
||||
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
||||
invalidProvider := AlertProvider{}
|
||||
if invalidProvider.IsValid() {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
validProvider := AlertProvider{From: "from@example.com", Password: "password", Host: "smtp.gmail.com", Port: 587, To: "to@example.com"}
|
||||
if !validProvider.IsValid() {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
||||
providerWithInvalidOverrideGroup := AlertProvider{
|
||||
Overrides: []Override{
|
||||
{
|
||||
To: "to@example.com",
|
||||
Group: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
if providerWithInvalidOverrideGroup.IsValid() {
|
||||
t.Error("provider Group shouldn't have been valid")
|
||||
}
|
||||
providerWithInvalidOverrideTo := AlertProvider{
|
||||
Overrides: []Override{
|
||||
{
|
||||
To: "",
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
if providerWithInvalidOverrideTo.IsValid() {
|
||||
t.Error("provider integration key shouldn't have been valid")
|
||||
}
|
||||
providerWithValidOverride := AlertProvider{
|
||||
From: "from@example.com",
|
||||
Password: "password",
|
||||
Host: "smtp.gmail.com",
|
||||
Port: 587,
|
||||
To: "to@example.com",
|
||||
Overrides: []Override{
|
||||
{
|
||||
To: "to@example.com",
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
if !providerWithValidOverride.IsValid() {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
ExpectedSubject string
|
||||
ExpectedBody string
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedSubject: "[endpoint-name] Alert triggered",
|
||||
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedSubject: "[endpoint-name] Alert resolved",
|
||||
ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
subject, body := scenario.Provider.buildMessageSubjectAndBody(
|
||||
&core.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if subject != scenario.ExpectedSubject {
|
||||
t.Errorf("expected subject to be %s, got %s", scenario.ExpectedSubject, subject)
|
||||
}
|
||||
if body != scenario.ExpectedBody {
|
||||
t.Errorf("expected body to be %s, got %s", scenario.ExpectedBody, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_getToForGroup(t *testing.T) {
|
||||
tests := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
InputGroup string
|
||||
ExpectedOutput string
|
||||
}{
|
||||
{
|
||||
Name: "provider-no-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
To: "to@example.com",
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "",
|
||||
ExpectedOutput: "to@example.com",
|
||||
},
|
||||
{
|
||||
Name: "provider-no-override-specify-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
To: "to@example.com",
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "group",
|
||||
ExpectedOutput: "to@example.com",
|
||||
},
|
||||
{
|
||||
Name: "provider-with-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
To: "to@example.com",
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
To: "to01@example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "",
|
||||
ExpectedOutput: "to@example.com",
|
||||
},
|
||||
{
|
||||
Name: "provider-with-override-specify-group-should-override",
|
||||
Provider: AlertProvider{
|
||||
To: "to@example.com",
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
To: "to01@example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "group",
|
||||
ExpectedOutput: "to01@example.com",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.Name, func(t *testing.T) {
|
||||
if got := tt.Provider.getToForGroup(tt.InputGroup); got != tt.ExpectedOutput {
|
||||
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
124
alerting/provider/googlechat/googlechat.go
Normal file
124
alerting/provider/googlechat/googlechat.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package googlechat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/client"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
)
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Google chat
|
||||
type AlertProvider struct {
|
||||
WebhookURL string `yaml:"webhook-url"`
|
||||
|
||||
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
||||
ClientConfig *client.Config `yaml:"client,omitempty"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
func (provider *AlertProvider) IsValid() bool {
|
||||
if provider.ClientConfig == nil {
|
||||
provider.ClientConfig = client.GetDefaultConfig()
|
||||
}
|
||||
return len(provider.WebhookURL) > 0
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
|
||||
request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if response.StatusCode > 399 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
||||
var message, color string
|
||||
if resolved {
|
||||
color = "#36A64F"
|
||||
message = fmt.Sprintf("<font color='%s'>An alert has been resolved after passing successfully %d time(s) in a row</font>", color, alert.SuccessThreshold)
|
||||
} else {
|
||||
color = "#DD0000"
|
||||
message = fmt.Sprintf("<font color='%s'>An alert has been triggered due to having failed %d time(s) in a row</font>", color, alert.FailureThreshold)
|
||||
}
|
||||
var results string
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
if conditionResult.Success {
|
||||
prefix = "✅"
|
||||
} else {
|
||||
prefix = "❌"
|
||||
}
|
||||
results += fmt.Sprintf("%s %s<br>", prefix, conditionResult.Condition)
|
||||
}
|
||||
var description string
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
description = ":: " + alertDescription
|
||||
}
|
||||
return fmt.Sprintf(`{
|
||||
"cards": [
|
||||
{
|
||||
"sections": [
|
||||
{
|
||||
"widgets": [
|
||||
{
|
||||
"keyValue": {
|
||||
"topLabel": "%s [%s]",
|
||||
"content": "%s",
|
||||
"contentMultiline": "true",
|
||||
"bottomLabel": "%s",
|
||||
"icon": "BOOKMARK"
|
||||
}
|
||||
},
|
||||
{
|
||||
"keyValue": {
|
||||
"topLabel": "Condition results",
|
||||
"content": "%s",
|
||||
"contentMultiline": "true",
|
||||
"icon": "DESCRIPTION"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttons": [
|
||||
{
|
||||
"textButton": {
|
||||
"text": "URL",
|
||||
"onClick": {
|
||||
"openLink": {
|
||||
"url": "%s"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`, endpoint.Name, endpoint.Group, message, description, results, endpoint.URL)
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
160
alerting/provider/googlechat/googlechat_test.go
Normal file
160
alerting/provider/googlechat/googlechat_test.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package googlechat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/client"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
"github.com/TwiN/gatus/v3/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_IsValid(t *testing.T) {
|
||||
invalidProvider := AlertProvider{WebhookURL: ""}
|
||||
if invalidProvider.IsValid() {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
validProvider := AlertProvider{WebhookURL: "http://example.com"}
|
||||
if !validProvider.IsValid() {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
MockRoundTripper test.MockRoundTripper
|
||||
ExpectedError bool
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "triggered-error",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "resolved-error",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||
err := scenario.Provider.Send(
|
||||
&core.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if scenario.ExpectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.ExpectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
ExpectedBody string
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\n \"cards\": [\n {\n \"sections\": [\n {\n \"widgets\": [\n {\n \"keyValue\": {\n \"topLabel\": \"endpoint-name []\",\n \"content\": \"\u003cfont color='#DD0000'\u003eAn alert has been triggered due to having failed 3 time(s) in a row\u003c/font\u003e\",\n \"contentMultiline\": \"true\",\n \"bottomLabel\": \":: description-1\",\n \"icon\": \"BOOKMARK\"\n }\n },\n {\n \"keyValue\": {\n \"topLabel\": \"Condition results\",\n \"content\": \"❌ [CONNECTED] == true\u003cbr\u003e❌ [STATUS] == 200\u003cbr\u003e\",\n \"contentMultiline\": \"true\",\n \"icon\": \"DESCRIPTION\"\n }\n },\n {\n \"buttons\": [\n {\n \"textButton\": {\n \"text\": \"URL\",\n \"onClick\": {\n \"openLink\": {\n \"url\": \"\"\n }\n }\n }\n }\n ]\n }\n ]\n }\n ]\n }\n]\n}",
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\n \"cards\": [\n {\n \"sections\": [\n {\n \"widgets\": [\n {\n \"keyValue\": {\n \"topLabel\": \"endpoint-name []\",\n \"content\": \"\u003cfont color='#36A64F'\u003eAn alert has been resolved after passing successfully 5 time(s) in a row\u003c/font\u003e\",\n \"contentMultiline\": \"true\",\n \"bottomLabel\": \":: description-2\",\n \"icon\": \"BOOKMARK\"\n }\n },\n {\n \"keyValue\": {\n \"topLabel\": \"Condition results\",\n \"content\": \"✅ [CONNECTED] == true\u003cbr\u003e✅ [STATUS] == 200\u003cbr\u003e\",\n \"contentMultiline\": \"true\",\n \"icon\": \"DESCRIPTION\"\n }\n },\n {\n \"buttons\": [\n {\n \"textButton\": {\n \"text\": \"URL\",\n \"onClick\": {\n \"openLink\": {\n \"url\": \"\"\n }\n }\n }\n }\n ]\n }\n ]\n }\n ]\n }\n]\n}",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
body := scenario.Provider.buildRequestBody(
|
||||
&core.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
b, _ := json.Marshal(body)
|
||||
e, _ := json.Marshal(scenario.ExpectedBody)
|
||||
if body != scenario.ExpectedBody {
|
||||
t.Errorf("expected %s, got %s", e, b)
|
||||
}
|
||||
out := make(map[string]interface{})
|
||||
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
package mattermost
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/custom"
|
||||
"github.com/TwiN/gatus/v3/client"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
)
|
||||
@@ -15,10 +16,10 @@ type AlertProvider struct {
|
||||
WebhookURL string `yaml:"webhook-url"`
|
||||
|
||||
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
||||
ClientConfig *client.Config `yaml:"client"`
|
||||
ClientConfig *client.Config `yaml:"client,omitempty"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
@@ -29,15 +30,33 @@ func (provider *AlertProvider) IsValid() bool {
|
||||
return len(provider.WebhookURL) > 0
|
||||
}
|
||||
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
|
||||
var message string
|
||||
var color string
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
|
||||
request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if response.StatusCode > 399 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
||||
var message, color string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.Name, alert.SuccessThreshold)
|
||||
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.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", endpoint.Name, alert.FailureThreshold)
|
||||
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
|
||||
color = "#DD0000"
|
||||
}
|
||||
var results string
|
||||
@@ -54,11 +73,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, al
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
description = ":\\n> " + alertDescription
|
||||
}
|
||||
return &custom.AlertProvider{
|
||||
URL: provider.WebhookURL,
|
||||
Method: http.MethodPost,
|
||||
ClientConfig: provider.ClientConfig,
|
||||
Body: fmt.Sprintf(`{
|
||||
return fmt.Sprintf(`{
|
||||
"text": "",
|
||||
"username": "gatus",
|
||||
"icon_url": "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png",
|
||||
@@ -83,9 +98,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, al
|
||||
]
|
||||
}
|
||||
]
|
||||
}`, message, message, description, color, endpoint.URL, results),
|
||||
Headers: map[string]string{"Content-Type": "application/json"},
|
||||
}
|
||||
}`, message, message, description, color, endpoint.URL, results)
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
|
||||
@@ -3,11 +3,12 @@ package mattermost
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/client"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
"github.com/TwiN/gatus/v3/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_IsValid(t *testing.T) {
|
||||
@@ -21,50 +22,137 @@ func TestAlertProvider_IsValid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
||||
provider := AlertProvider{WebhookURL: "http://example.org"}
|
||||
alertDescription := "test"
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{Name: "svc"}, &alert.Alert{Description: &alertDescription}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
MockRoundTripper test.MockRoundTripper
|
||||
ExpectedError bool
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "triggered-error",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "resolved-error",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "resolved") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring resolved")
|
||||
}
|
||||
if customAlertProvider.URL != "http://example.org" {
|
||||
t.Errorf("expected URL to be %s, got %s", "http://example.org", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
if expected := "An alert for *svc* has been resolved after passing successfully 0 time(s) in a row:\n> test"; expected != body["attachments"].([]interface{})[0].(map[string]interface{})["text"] {
|
||||
t.Errorf("expected $.attachments[0].description to be %s, got %s", expected, body["attachments"].([]interface{})[0].(map[string]interface{})["text"])
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||
err := scenario.Provider.Send(
|
||||
&core.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if scenario.ExpectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.ExpectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
||||
provider := AlertProvider{WebhookURL: "http://example.org"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
ExpectedBody string
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\n \"text\": \"\",\n \"username\": \"gatus\",\n \"icon_url\": \"https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png\",\n \"attachments\": [\n {\n \"title\": \":rescue_worker_helmet: Gatus\",\n \"fallback\": \"Gatus - An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row\",\n \"text\": \"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row:\\n> description-1\",\n \"short\": false,\n \"color\": \"#DD0000\",\n \"fields\": [\n {\n \"title\": \"URL\",\n \"value\": \"\",\n \"short\": false\n },\n {\n \"title\": \"Condition results\",\n \"value\": \":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\n \"short\": false\n }\n ]\n }\n ]\n}",
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\n \"text\": \"\",\n \"username\": \"gatus\",\n \"icon_url\": \"https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png\",\n \"attachments\": [\n {\n \"title\": \":rescue_worker_helmet: Gatus\",\n \"fallback\": \"Gatus - An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row\",\n \"text\": \"An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row:\\n> description-2\",\n \"short\": false,\n \"color\": \"#36A64F\",\n \"fields\": [\n {\n \"title\": \"URL\",\n \"value\": \"\",\n \"short\": false\n },\n {\n \"title\": \"Condition results\",\n \"value\": \":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\n \"short\": false\n }\n ]\n }\n ]\n}",
|
||||
},
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "triggered") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring triggered")
|
||||
}
|
||||
if customAlertProvider.URL != "http://example.org" {
|
||||
t.Errorf("expected URL to be %s, got %s", "http://example.org", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
body := scenario.Provider.buildRequestBody(
|
||||
&core.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if body != scenario.ExpectedBody {
|
||||
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
|
||||
}
|
||||
out := make(map[string]interface{})
|
||||
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package messagebird
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/custom"
|
||||
"github.com/TwiN/gatus/v3/client"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
)
|
||||
|
||||
@@ -20,7 +22,7 @@ type AlertProvider struct {
|
||||
Recipients string `yaml:"recipients"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
@@ -28,28 +30,40 @@ func (provider *AlertProvider) IsValid() bool {
|
||||
return len(provider.AccessKey) > 0 && len(provider.Originator) > 0 && len(provider.Recipients) > 0
|
||||
}
|
||||
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
// Reference doc for messagebird https://developers.messagebird.com/api/sms-messaging/#send-outbound-sms
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, _ *core.Result, resolved bool) *custom.AlertProvider {
|
||||
// Send an alert using the provider
|
||||
// Reference doc for messagebird: https://developers.messagebird.com/api/sms-messaging/#send-outbound-sms
|
||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
|
||||
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Authorization", fmt.Sprintf("AccessKey %s", provider.AccessKey))
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if response.StatusCode > 399 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
||||
var message string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription())
|
||||
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
|
||||
} else {
|
||||
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.Name, alert.GetDescription())
|
||||
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
|
||||
}
|
||||
return &custom.AlertProvider{
|
||||
URL: restAPIURL,
|
||||
Method: http.MethodPost,
|
||||
Body: fmt.Sprintf(`{
|
||||
return fmt.Sprintf(`{
|
||||
"originator": "%s",
|
||||
"recipients": "%s",
|
||||
"body": "%s"
|
||||
}`, provider.Originator, provider.Recipients, message),
|
||||
Headers: map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": fmt.Sprintf("AccessKey %s", provider.AccessKey),
|
||||
},
|
||||
}
|
||||
}`, provider.Originator, provider.Recipients, message)
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
|
||||
@@ -3,11 +3,12 @@ package messagebird
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/client"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
"github.com/TwiN/gatus/v3/test"
|
||||
)
|
||||
|
||||
func TestMessagebirdAlertProvider_IsValid(t *testing.T) {
|
||||
@@ -25,54 +26,137 @@ func TestMessagebirdAlertProvider_IsValid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
||||
provider := AlertProvider{
|
||||
AccessKey: "1",
|
||||
Originator: "1",
|
||||
Recipients: "1",
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
MockRoundTripper test.MockRoundTripper
|
||||
ExpectedError bool
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "triggered-error",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "resolved-error",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{}, true)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "RESOLVED") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring RESOLVED")
|
||||
}
|
||||
if customAlertProvider.URL != "https://rest.messagebird.com/messages" {
|
||||
t.Errorf("expected URL to be %s, got %s", "https://rest.messagebird.com/messages", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||
err := scenario.Provider.Send(
|
||||
&core.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if scenario.ExpectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.ExpectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
||||
provider := AlertProvider{
|
||||
AccessKey: "1",
|
||||
Originator: "1",
|
||||
Recipients: "1",
|
||||
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
ExpectedBody string
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{AccessKey: "1", Originator: "2", Recipients: "3"},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\n \"originator\": \"2\",\n \"recipients\": \"3\",\n \"body\": \"TRIGGERED: endpoint-name - description-1\"\n}",
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{AccessKey: "4", Originator: "5", Recipients: "6"},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\n \"originator\": \"5\",\n \"recipients\": \"6\",\n \"body\": \"RESOLVED: endpoint-name - description-2\"\n}",
|
||||
},
|
||||
}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{}, false)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "TRIGGERED") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring TRIGGERED")
|
||||
}
|
||||
if customAlertProvider.URL != "https://rest.messagebird.com/messages" {
|
||||
t.Errorf("expected URL to be %s, got %s", "https://rest.messagebird.com/messages", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
body := scenario.Provider.buildRequestBody(
|
||||
&core.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if body != scenario.ExpectedBody {
|
||||
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
|
||||
}
|
||||
out := make(map[string]interface{})
|
||||
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
240
alerting/provider/opsgenie/opsgenie.go
Normal file
240
alerting/provider/opsgenie/opsgenie.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package opsgenie
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/client"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
)
|
||||
|
||||
const (
|
||||
restAPI = "https://api.opsgenie.com/v2/alerts"
|
||||
)
|
||||
|
||||
type AlertProvider struct {
|
||||
// APIKey to use for
|
||||
APIKey string `yaml:"api-key"`
|
||||
|
||||
// Priority to be used in Opsgenie alert payload
|
||||
//
|
||||
// default: P1
|
||||
Priority string `yaml:"priority"`
|
||||
|
||||
// Source define source to be used in Opsgenie alert payload
|
||||
//
|
||||
// default: gatus
|
||||
Source string `yaml:"source"`
|
||||
|
||||
// EntityPrefix is a prefix to be used in entity argument in Opsgenie alert payload
|
||||
//
|
||||
// default: gatus-
|
||||
EntityPrefix string `yaml:"entity-prefix"`
|
||||
|
||||
//AliasPrefix is a prefix to be used in alias argument in Opsgenie alert payload
|
||||
//
|
||||
// default: gatus-healthcheck-
|
||||
AliasPrefix string `yaml:"alias-prefix"`
|
||||
|
||||
// Tags to be used in Opsgenie alert payload
|
||||
//
|
||||
// default: []
|
||||
Tags []string `yaml:"tags"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
func (provider *AlertProvider) IsValid() bool {
|
||||
return len(provider.APIKey) > 0
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
//
|
||||
// Relevant: https://docs.opsgenie.com/docs/alert-api
|
||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||
err := provider.createAlert(endpoint, alert, result, resolved)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resolved {
|
||||
err = provider.closeAlert(endpoint, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if alert.IsSendingOnResolved() {
|
||||
if resolved {
|
||||
// The alert has been resolved and there's no error, so we can clear the alert's ResolveKey
|
||||
alert.ResolveKey = ""
|
||||
} else {
|
||||
alert.ResolveKey = provider.alias(buildKey(endpoint))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) createAlert(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||
payload := provider.buildCreateRequestBody(endpoint, alert, result, resolved)
|
||||
_, err := provider.sendRequest(restAPI, http.MethodPost, payload)
|
||||
return err
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) closeAlert(endpoint *core.Endpoint, alert *alert.Alert) error {
|
||||
payload := provider.buildCloseRequestBody(endpoint, alert)
|
||||
url := restAPI + "/" + provider.alias(buildKey(endpoint)) + "/close?identifierType=alias"
|
||||
_, err := provider.sendRequest(url, http.MethodPost, payload)
|
||||
return err
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) sendRequest(url, method string, payload interface{}) (*http.Response, error) {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fail to build alert payload: %v", payload)
|
||||
}
|
||||
request, err := http.NewRequest(method, url, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Authorization", "GenieKey "+provider.APIKey)
|
||||
res, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.StatusCode > 399 {
|
||||
rBody, _ := io.ReadAll(res.Body)
|
||||
return nil, fmt.Errorf("call to provider alert returned status code %d: %s", res.StatusCode, string(rBody))
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) buildCreateRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) alertCreateRequest {
|
||||
var message, description, results string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription())
|
||||
description = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
|
||||
} else {
|
||||
message = fmt.Sprintf("%s - %s", endpoint.Name, alert.GetDescription())
|
||||
description = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
|
||||
}
|
||||
if endpoint.Group != "" {
|
||||
message = fmt.Sprintf("[%s] %s", endpoint.Group, message)
|
||||
}
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
if conditionResult.Success {
|
||||
prefix = "▣"
|
||||
} else {
|
||||
prefix = "▢"
|
||||
}
|
||||
results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
|
||||
}
|
||||
description = description + "\n" + results
|
||||
key := buildKey(endpoint)
|
||||
details := map[string]string{
|
||||
"endpoint:url": endpoint.URL,
|
||||
"endpoint:group": endpoint.Group,
|
||||
"result:hostname": result.Hostname,
|
||||
"result:ip": result.IP,
|
||||
"result:dns_code": result.DNSRCode,
|
||||
"result:errors": strings.Join(result.Errors, ","),
|
||||
}
|
||||
for k, v := range details {
|
||||
if v == "" {
|
||||
delete(details, k)
|
||||
}
|
||||
}
|
||||
if result.HTTPStatus > 0 {
|
||||
details["result:http_status"] = strconv.Itoa(result.HTTPStatus)
|
||||
}
|
||||
return alertCreateRequest{
|
||||
Message: message,
|
||||
Description: description,
|
||||
Source: provider.source(),
|
||||
Priority: provider.priority(),
|
||||
Alias: provider.alias(key),
|
||||
Entity: provider.entity(key),
|
||||
Tags: provider.Tags,
|
||||
Details: details,
|
||||
}
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) buildCloseRequestBody(endpoint *core.Endpoint, alert *alert.Alert) alertCloseRequest {
|
||||
return alertCloseRequest{
|
||||
Source: buildKey(endpoint),
|
||||
Note: fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription()),
|
||||
}
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) source() string {
|
||||
source := provider.Source
|
||||
if source == "" {
|
||||
return "gatus"
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) alias(key string) string {
|
||||
alias := provider.AliasPrefix
|
||||
if alias == "" {
|
||||
alias = "gatus-healthcheck-"
|
||||
}
|
||||
return alias + key
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) entity(key string) string {
|
||||
alias := provider.EntityPrefix
|
||||
if alias == "" {
|
||||
alias = "gatus-"
|
||||
}
|
||||
return alias + key
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) priority() string {
|
||||
priority := provider.Priority
|
||||
if priority == "" {
|
||||
return "P1"
|
||||
}
|
||||
return priority
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
func buildKey(endpoint *core.Endpoint) string {
|
||||
name := toKebabCase(endpoint.Name)
|
||||
if endpoint.Group == "" {
|
||||
return name
|
||||
}
|
||||
return toKebabCase(endpoint.Group) + "-" + name
|
||||
}
|
||||
|
||||
func toKebabCase(val string) string {
|
||||
return strings.ToLower(strings.ReplaceAll(val, " ", "-"))
|
||||
}
|
||||
|
||||
type alertCreateRequest struct {
|
||||
Message string `json:"message"`
|
||||
Priority string `json:"priority"`
|
||||
Source string `json:"source"`
|
||||
Entity string `json:"entity"`
|
||||
Alias string `json:"alias"`
|
||||
Description string `json:"description"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Details map[string]string `json:"details"`
|
||||
}
|
||||
|
||||
type alertCloseRequest struct {
|
||||
Source string `json:"source"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
319
alerting/provider/opsgenie/opsgenie_test.go
Normal file
319
alerting/provider/opsgenie/opsgenie_test.go
Normal file
@@ -0,0 +1,319 @@
|
||||
package opsgenie
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/client"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
"github.com/TwiN/gatus/v3/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_IsValid(t *testing.T) {
|
||||
invalidProvider := AlertProvider{APIKey: ""}
|
||||
if invalidProvider.IsValid() {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
validProvider := AlertProvider{APIKey: "00000000-0000-0000-0000-000000000000"}
|
||||
if !validProvider.IsValid() {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
description := "my bad alert description"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
MockRoundTripper test.MockRoundTripper
|
||||
ExpectedError bool
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &description, SuccessThreshold: 1, FailureThreshold: 1},
|
||||
Resolved: false,
|
||||
ExpectedError: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
},
|
||||
{
|
||||
Name: "triggered-error",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedError: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedError: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
},
|
||||
{
|
||||
Name: "resolved-error",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedError: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||
err := scenario.Provider.Send(
|
||||
&core.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if scenario.ExpectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.ExpectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
description := "alert description"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider *AlertProvider
|
||||
Alert *alert.Alert
|
||||
Endpoint *core.Endpoint
|
||||
Result *core.Result
|
||||
Resolved bool
|
||||
want alertCreateRequest
|
||||
}{
|
||||
{
|
||||
Name: "missing all params (unresolved)",
|
||||
Provider: &AlertProvider{},
|
||||
Alert: &alert.Alert{},
|
||||
Endpoint: &core.Endpoint{},
|
||||
Result: &core.Result{},
|
||||
Resolved: false,
|
||||
want: alertCreateRequest{
|
||||
Message: " - ",
|
||||
Priority: "P1",
|
||||
Source: "gatus",
|
||||
Entity: "gatus-",
|
||||
Alias: "gatus-healthcheck-",
|
||||
Description: "An alert for ** has been triggered due to having failed 0 time(s) in a row\n",
|
||||
Tags: nil,
|
||||
Details: map[string]string{},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "missing all params (resolved)",
|
||||
Provider: &AlertProvider{},
|
||||
Alert: &alert.Alert{},
|
||||
Endpoint: &core.Endpoint{},
|
||||
Result: &core.Result{},
|
||||
Resolved: true,
|
||||
want: alertCreateRequest{
|
||||
Message: "RESOLVED: - ",
|
||||
Priority: "P1",
|
||||
Source: "gatus",
|
||||
Entity: "gatus-",
|
||||
Alias: "gatus-healthcheck-",
|
||||
Description: "An alert for ** has been resolved after passing successfully 0 time(s) in a row\n",
|
||||
Tags: nil,
|
||||
Details: map[string]string{},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "with default options (unresolved)",
|
||||
Provider: &AlertProvider{},
|
||||
Alert: &alert.Alert{
|
||||
Description: &description,
|
||||
FailureThreshold: 3,
|
||||
},
|
||||
Endpoint: &core.Endpoint{
|
||||
Name: "my super app",
|
||||
},
|
||||
Result: &core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{
|
||||
Condition: "[STATUS] == 200",
|
||||
Success: true,
|
||||
},
|
||||
{
|
||||
Condition: "[BODY] == OK",
|
||||
Success: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
Resolved: false,
|
||||
want: alertCreateRequest{
|
||||
Message: "my super app - " + description,
|
||||
Priority: "P1",
|
||||
Source: "gatus",
|
||||
Entity: "gatus-my-super-app",
|
||||
Alias: "gatus-healthcheck-my-super-app",
|
||||
Description: "An alert for *my super app* has been triggered due to having failed 3 time(s) in a row\n▣ - `[STATUS] == 200`\n▢ - `[BODY] == OK`\n",
|
||||
Tags: nil,
|
||||
Details: map[string]string{},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "with custom options (resolved)",
|
||||
Provider: &AlertProvider{
|
||||
Priority: "P5",
|
||||
EntityPrefix: "oompa-",
|
||||
AliasPrefix: "loompa-",
|
||||
Source: "gatus-hc",
|
||||
Tags: []string{"do-ba-dee-doo"},
|
||||
},
|
||||
Alert: &alert.Alert{
|
||||
Description: &description,
|
||||
SuccessThreshold: 4,
|
||||
},
|
||||
Endpoint: &core.Endpoint{
|
||||
Name: "my mega app",
|
||||
},
|
||||
Result: &core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{
|
||||
Condition: "[STATUS] == 200",
|
||||
Success: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
Resolved: true,
|
||||
want: alertCreateRequest{
|
||||
Message: "RESOLVED: my mega app - " + description,
|
||||
Priority: "P5",
|
||||
Source: "gatus-hc",
|
||||
Entity: "oompa-my-mega-app",
|
||||
Alias: "loompa-my-mega-app",
|
||||
Description: "An alert for *my mega app* has been resolved after passing successfully 4 time(s) in a row\n▣ - `[STATUS] == 200`\n",
|
||||
Tags: []string{"do-ba-dee-doo"},
|
||||
Details: map[string]string{},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "with default options and details (unresolved)",
|
||||
Provider: &AlertProvider{
|
||||
Tags: []string{"foo"},
|
||||
},
|
||||
Alert: &alert.Alert{
|
||||
Description: &description,
|
||||
FailureThreshold: 6,
|
||||
},
|
||||
Endpoint: &core.Endpoint{
|
||||
Name: "my app",
|
||||
Group: "end game",
|
||||
URL: "https://my.go/app",
|
||||
},
|
||||
Result: &core.Result{
|
||||
HTTPStatus: 400,
|
||||
Hostname: "my.go",
|
||||
Errors: []string{"error 01", "error 02"},
|
||||
Success: false,
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{
|
||||
Condition: "[STATUS] == 200",
|
||||
Success: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
Resolved: false,
|
||||
want: alertCreateRequest{
|
||||
Message: "[end game] my app - " + description,
|
||||
Priority: "P1",
|
||||
Source: "gatus",
|
||||
Entity: "gatus-end-game-my-app",
|
||||
Alias: "gatus-healthcheck-end-game-my-app",
|
||||
Description: "An alert for *end game/my app* has been triggered due to having failed 6 time(s) in a row\n▢ - `[STATUS] == 200`\n",
|
||||
Tags: []string{"foo"},
|
||||
Details: map[string]string{
|
||||
"endpoint:url": "https://my.go/app",
|
||||
"endpoint:group": "end game",
|
||||
"result:hostname": "my.go",
|
||||
"result:errors": "error 01,error 02",
|
||||
"result:http_status": "400",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
actual := scenario
|
||||
t.Run(actual.Name, func(t *testing.T) {
|
||||
if got := actual.Provider.buildCreateRequestBody(actual.Endpoint, actual.Alert, actual.Result, actual.Resolved); !reflect.DeepEqual(got, actual.want) {
|
||||
t.Errorf("buildCreateRequestBody() = %v, want %v", got, actual.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_buildCloseRequestBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
description := "alert description"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider *AlertProvider
|
||||
Alert *alert.Alert
|
||||
Endpoint *core.Endpoint
|
||||
want alertCloseRequest
|
||||
}{
|
||||
{
|
||||
Name: "Missing all values",
|
||||
Provider: &AlertProvider{},
|
||||
Alert: &alert.Alert{},
|
||||
Endpoint: &core.Endpoint{},
|
||||
want: alertCloseRequest{
|
||||
Source: "",
|
||||
Note: "RESOLVED: - ",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Basic values",
|
||||
Provider: &AlertProvider{},
|
||||
Alert: &alert.Alert{
|
||||
Description: &description,
|
||||
},
|
||||
Endpoint: &core.Endpoint{
|
||||
Name: "endpoint name",
|
||||
},
|
||||
want: alertCloseRequest{
|
||||
Source: "endpoint-name",
|
||||
Note: "RESOLVED: endpoint name - alert description",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
actual := scenario
|
||||
t.Run(actual.Name, func(t *testing.T) {
|
||||
if got := actual.Provider.buildCloseRequestBody(actual.Endpoint, actual.Alert); !reflect.DeepEqual(got, actual.want) {
|
||||
t.Errorf("buildCloseRequestBody() = %v, want %v", got, actual.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
package pagerduty
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/custom"
|
||||
"github.com/TwiN/gatus/v3/client"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
)
|
||||
|
||||
@@ -18,10 +22,10 @@ type AlertProvider struct {
|
||||
IntegrationKey string `yaml:"integration-key"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
||||
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"`
|
||||
Overrides []Override `yaml:"overrides,omitempty"`
|
||||
}
|
||||
|
||||
// Override is a case under which the default integration is overridden
|
||||
@@ -45,24 +49,56 @@ func (provider *AlertProvider) IsValid() bool {
|
||||
return len(provider.IntegrationKey) == 32 || len(provider.Overrides) != 0
|
||||
}
|
||||
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
// Send an alert using the provider
|
||||
//
|
||||
// relevant: https://developer.pagerduty.com/docs/events-api-v2/trigger-events/
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, _ *core.Result, resolved bool) *custom.AlertProvider {
|
||||
// Relevant: https://developer.pagerduty.com/docs/events-api-v2/trigger-events/
|
||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
|
||||
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if response.StatusCode > 399 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
if alert.IsSendingOnResolved() {
|
||||
if resolved {
|
||||
// The alert has been resolved and there's no error, so we can clear the alert's ResolveKey
|
||||
alert.ResolveKey = ""
|
||||
} else {
|
||||
// We need to retrieve the resolve key from the response
|
||||
body, err := io.ReadAll(response.Body)
|
||||
var payload pagerDutyResponsePayload
|
||||
if err = json.Unmarshal(body, &payload); err != nil {
|
||||
// Silently fail. We don't want to create tons of alerts just because we failed to parse the body.
|
||||
log.Printf("[pagerduty][Send] Ran into error unmarshaling pagerduty response: %s", err.Error())
|
||||
} else {
|
||||
alert.ResolveKey = payload.DedupKey
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
||||
var message, eventAction, resolveKey string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription())
|
||||
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
|
||||
eventAction = "resolve"
|
||||
resolveKey = alert.ResolveKey
|
||||
} else {
|
||||
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.Name, alert.GetDescription())
|
||||
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
|
||||
eventAction = "trigger"
|
||||
resolveKey = ""
|
||||
}
|
||||
return &custom.AlertProvider{
|
||||
URL: restAPIURL,
|
||||
Method: http.MethodPost,
|
||||
Body: fmt.Sprintf(`{
|
||||
return fmt.Sprintf(`{
|
||||
"routing_key": "%s",
|
||||
"dedup_key": "%s",
|
||||
"event_action": "%s",
|
||||
@@ -71,11 +107,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, al
|
||||
"source": "%s",
|
||||
"severity": "critical"
|
||||
}
|
||||
}`, provider.getIntegrationKeyForGroup(endpoint.Group), resolveKey, eventAction, message, endpoint.Name),
|
||||
Headers: map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
}`, provider.getIntegrationKeyForGroup(endpoint.Group), resolveKey, eventAction, message, endpoint.Name)
|
||||
}
|
||||
|
||||
// getIntegrationKeyForGroup returns the appropriate pagerduty integration key for a given group
|
||||
@@ -94,3 +126,9 @@ func (provider *AlertProvider) getIntegrationKeyForGroup(group string) string {
|
||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
type pagerDutyResponsePayload struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
DedupKey string `json:"dedup_key"`
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@ package pagerduty
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/client"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
"github.com/TwiN/gatus/v3/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_IsValid(t *testing.T) {
|
||||
@@ -57,107 +58,118 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
||||
provider := AlertProvider{IntegrationKey: "00000000000000000000000000000000"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{}, true)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "RESOLVED") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring RESOLVED")
|
||||
}
|
||||
if customAlertProvider.URL != "https://events.pagerduty.com/v2/enqueue" {
|
||||
t.Errorf("expected URL to be %s, got %s", "https://events.pagerduty.com/v2/enqueue", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlertAndOverride(t *testing.T) {
|
||||
provider := AlertProvider{
|
||||
IntegrationKey: "",
|
||||
Overrides: []Override{
|
||||
{
|
||||
IntegrationKey: "00000000000000000000000000000000",
|
||||
Group: "group",
|
||||
},
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
MockRoundTripper test.MockRoundTripper
|
||||
ExpectedError bool
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "triggered-error",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "resolved-error",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{}, true)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "RESOLVED") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring RESOLVED")
|
||||
}
|
||||
if customAlertProvider.URL != "https://events.pagerduty.com/v2/enqueue" {
|
||||
t.Errorf("expected URL to be %s, got %s", "https://events.pagerduty.com/v2/enqueue", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||
err := scenario.Provider.Send(
|
||||
&core.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if scenario.ExpectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.ExpectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
||||
provider := AlertProvider{IntegrationKey: "00000000000000000000000000000000"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{}, false)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "TRIGGERED") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring TRIGGERED")
|
||||
}
|
||||
if customAlertProvider.URL != "https://events.pagerduty.com/v2/enqueue" {
|
||||
t.Errorf("expected URL to be %s, got %s", "https://events.pagerduty.com/v2/enqueue", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlertAndOverride(t *testing.T) {
|
||||
provider := AlertProvider{
|
||||
IntegrationKey: "",
|
||||
Overrides: []Override{
|
||||
{
|
||||
IntegrationKey: "00000000000000000000000000000000",
|
||||
Group: "group",
|
||||
},
|
||||
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
description := "test"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
ExpectedBody string
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{IntegrationKey: "00000000000000000000000000000000"},
|
||||
Alert: alert.Alert{Description: &description},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\n \"routing_key\": \"00000000000000000000000000000000\",\n \"dedup_key\": \"\",\n \"event_action\": \"trigger\",\n \"payload\": {\n \"summary\": \"TRIGGERED: - test\",\n \"source\": \"\",\n \"severity\": \"critical\"\n }\n}",
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{IntegrationKey: "00000000000000000000000000000000"},
|
||||
Alert: alert.Alert{Description: &description, ResolveKey: "key"},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\n \"routing_key\": \"00000000000000000000000000000000\",\n \"dedup_key\": \"key\",\n \"event_action\": \"resolve\",\n \"payload\": {\n \"summary\": \"RESOLVED: - test\",\n \"source\": \"\",\n \"severity\": \"critical\"\n }\n}",
|
||||
},
|
||||
}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{}, false)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "TRIGGERED") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring TRIGGERED")
|
||||
}
|
||||
if customAlertProvider.URL != "https://events.pagerduty.com/v2/enqueue" {
|
||||
t.Errorf("expected URL to be %s, got %s", "https://events.pagerduty.com/v2/enqueue", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
body := scenario.Provider.buildRequestBody(&core.Endpoint{}, &scenario.Alert, &core.Result{}, scenario.Resolved)
|
||||
if body != scenario.ExpectedBody {
|
||||
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
|
||||
}
|
||||
out := make(map[string]interface{})
|
||||
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,3 +235,12 @@ func TestAlertProvider_getIntegrationKeyForGroup(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/custom"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/discord"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/email"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/googlechat"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/mattermost"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/messagebird"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/pagerduty"
|
||||
@@ -19,11 +21,11 @@ type AlertProvider interface {
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
IsValid() bool
|
||||
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
GetDefaultAlert() *alert.Alert
|
||||
|
||||
// Send an alert using the provider
|
||||
Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error
|
||||
}
|
||||
|
||||
// ParseWithDefaultAlert parses an Endpoint alert by using the provider's default alert as a baseline
|
||||
@@ -52,6 +54,8 @@ var (
|
||||
// Validate interface implementation on compile
|
||||
_ AlertProvider = (*custom.AlertProvider)(nil)
|
||||
_ AlertProvider = (*discord.AlertProvider)(nil)
|
||||
_ AlertProvider = (*email.AlertProvider)(nil)
|
||||
_ AlertProvider = (*googlechat.AlertProvider)(nil)
|
||||
_ AlertProvider = (*mattermost.AlertProvider)(nil)
|
||||
_ AlertProvider = (*messagebird.AlertProvider)(nil)
|
||||
_ AlertProvider = (*pagerduty.AlertProvider)(nil)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/custom"
|
||||
"github.com/TwiN/gatus/v3/client"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
)
|
||||
|
||||
@@ -14,7 +16,7 @@ type AlertProvider struct {
|
||||
WebhookURL string `yaml:"webhook-url"` // Slack webhook URL
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
@@ -22,14 +24,33 @@ func (provider *AlertProvider) IsValid() bool {
|
||||
return len(provider.WebhookURL) > 0
|
||||
}
|
||||
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
|
||||
request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if response.StatusCode > 399 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
||||
var message, color, results string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.Name, alert.SuccessThreshold)
|
||||
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.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", endpoint.Name, alert.FailureThreshold)
|
||||
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
|
||||
color = "#DD0000"
|
||||
}
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
@@ -45,10 +66,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, al
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
description = ":\\n> " + alertDescription
|
||||
}
|
||||
return &custom.AlertProvider{
|
||||
URL: provider.WebhookURL,
|
||||
Method: http.MethodPost,
|
||||
Body: fmt.Sprintf(`{
|
||||
return fmt.Sprintf(`{
|
||||
"text": "",
|
||||
"attachments": [
|
||||
{
|
||||
@@ -65,9 +83,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, al
|
||||
]
|
||||
}
|
||||
]
|
||||
}`, message, description, color, results),
|
||||
Headers: map[string]string{"Content-Type": "application/json"},
|
||||
}
|
||||
}`, message, description, color, results)
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
|
||||
@@ -3,11 +3,12 @@ package slack
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/client"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
"github.com/TwiN/gatus/v3/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_IsValid(t *testing.T) {
|
||||
@@ -15,56 +16,162 @@ func TestAlertProvider_IsValid(t *testing.T) {
|
||||
if invalidProvider.IsValid() {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
validProvider := AlertProvider{WebhookURL: "http://example.com"}
|
||||
validProvider := AlertProvider{WebhookURL: "https://example.com"}
|
||||
if !validProvider.IsValid() {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
||||
provider := AlertProvider{WebhookURL: "http://example.com"}
|
||||
alertDescription := "test"
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{Name: "svc"}, &alert.Alert{Description: &alertDescription}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
MockRoundTripper test.MockRoundTripper
|
||||
ExpectedError bool
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "triggered-error",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "resolved-error",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "resolved") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring resolved")
|
||||
}
|
||||
if customAlertProvider.URL != "http://example.com" {
|
||||
t.Errorf("expected URL to be %s, got %s", "http://example.com", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
if expected := "An alert for *svc* has been resolved after passing successfully 0 time(s) in a row:\n> test"; expected != body["attachments"].([]interface{})[0].(map[string]interface{})["text"] {
|
||||
t.Errorf("expected $.attachments[0].description to be %s, got %s", expected, body["attachments"].([]interface{})[0].(map[string]interface{})["text"])
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||
err := scenario.Provider.Send(
|
||||
&core.Endpoint{Name: "name"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if scenario.ExpectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.ExpectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
||||
provider := AlertProvider{WebhookURL: "http://example.com"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Endpoint core.Endpoint
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
ExpectedBody string
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Endpoint: core.Endpoint{Name: "name"},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\n \"text\": \"\",\n \"attachments\": [\n {\n \"title\": \":helmet_with_white_cross: Gatus\",\n \"text\": \"An alert for *name* has been triggered due to having failed 3 time(s) in a row:\\n> description-1\",\n \"short\": false,\n \"color\": \"#DD0000\",\n \"fields\": [\n {\n \"title\": \"Condition results\",\n \"value\": \":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\n \"short\": false\n }\n ]\n }\n ]\n}",
|
||||
},
|
||||
{
|
||||
Name: "triggered-with-group",
|
||||
Provider: AlertProvider{},
|
||||
Endpoint: core.Endpoint{Name: "name", Group: "group"},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\n \"text\": \"\",\n \"attachments\": [\n {\n \"title\": \":helmet_with_white_cross: Gatus\",\n \"text\": \"An alert for *group/name* has been triggered due to having failed 3 time(s) in a row:\\n> description-1\",\n \"short\": false,\n \"color\": \"#DD0000\",\n \"fields\": [\n {\n \"title\": \"Condition results\",\n \"value\": \":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\n \"short\": false\n }\n ]\n }\n ]\n}",
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Endpoint: core.Endpoint{Name: "name"},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\n \"text\": \"\",\n \"attachments\": [\n {\n \"title\": \":helmet_with_white_cross: Gatus\",\n \"text\": \"An alert for *name* has been resolved after passing successfully 5 time(s) in a row:\\n> description-2\",\n \"short\": false,\n \"color\": \"#36A64F\",\n \"fields\": [\n {\n \"title\": \"Condition results\",\n \"value\": \":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\n \"short\": false\n }\n ]\n }\n ]\n}",
|
||||
},
|
||||
{
|
||||
Name: "resolved-with-group",
|
||||
Provider: AlertProvider{},
|
||||
Endpoint: core.Endpoint{Name: "name", Group: "group"},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\n \"text\": \"\",\n \"attachments\": [\n {\n \"title\": \":helmet_with_white_cross: Gatus\",\n \"text\": \"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row:\\n> description-2\",\n \"short\": false,\n \"color\": \"#36A64F\",\n \"fields\": [\n {\n \"title\": \"Condition results\",\n \"value\": \":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\n \"short\": false\n }\n ]\n }\n ]\n}",
|
||||
},
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "triggered") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring triggered")
|
||||
}
|
||||
if customAlertProvider.URL != "http://example.com" {
|
||||
t.Errorf("expected URL to be %s, got %s", "http://example.com", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
body := scenario.Provider.buildRequestBody(
|
||||
&scenario.Endpoint,
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if body != scenario.ExpectedBody {
|
||||
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
|
||||
}
|
||||
out := make(map[string]interface{})
|
||||
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package teams
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/custom"
|
||||
"github.com/TwiN/gatus/v3/client"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
)
|
||||
|
||||
@@ -14,7 +16,7 @@ type AlertProvider struct {
|
||||
WebhookURL string `yaml:"webhook-url"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
@@ -22,15 +24,33 @@ func (provider *AlertProvider) IsValid() bool {
|
||||
return len(provider.WebhookURL) > 0
|
||||
}
|
||||
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
|
||||
var message string
|
||||
var color string
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
|
||||
request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if response.StatusCode > 399 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
||||
var message, color string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.Name, alert.SuccessThreshold)
|
||||
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.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", endpoint.Name, alert.FailureThreshold)
|
||||
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
|
||||
color = "#DD0000"
|
||||
}
|
||||
var results string
|
||||
@@ -47,10 +67,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, al
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
description = ":\\n> " + alertDescription
|
||||
}
|
||||
return &custom.AlertProvider{
|
||||
URL: provider.WebhookURL,
|
||||
Method: http.MethodPost,
|
||||
Body: fmt.Sprintf(`{
|
||||
return fmt.Sprintf(`{
|
||||
"@type": "MessageCard",
|
||||
"@context": "http://schema.org/extensions",
|
||||
"themeColor": "%s",
|
||||
@@ -66,9 +83,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, al
|
||||
"text": "%s"
|
||||
}
|
||||
]
|
||||
}`, color, message, description, endpoint.URL, results),
|
||||
Headers: map[string]string{"Content-Type": "application/json"},
|
||||
}
|
||||
}`, color, message, description, endpoint.URL, results)
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
|
||||
@@ -3,11 +3,12 @@ package teams
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/client"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
"github.com/TwiN/gatus/v3/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_IsValid(t *testing.T) {
|
||||
@@ -21,50 +22,137 @@ func TestAlertProvider_IsValid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
||||
provider := AlertProvider{WebhookURL: "http://example.org"}
|
||||
alertDescription := "test"
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{Name: "svc"}, &alert.Alert{Description: &alertDescription}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
MockRoundTripper test.MockRoundTripper
|
||||
ExpectedError bool
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "triggered-error",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "resolved-error",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "resolved") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring resolved")
|
||||
}
|
||||
if customAlertProvider.URL != "http://example.org" {
|
||||
t.Errorf("expected URL to be %s, got %s", "http://example.org", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
if expected := "An alert for *svc* has been resolved after passing successfully 0 time(s) in a row:\n> test"; expected != body["text"] {
|
||||
t.Errorf("expected $.text to be %s, got %s", expected, body["text"])
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||
err := scenario.Provider.Send(
|
||||
&core.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if scenario.ExpectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.ExpectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
||||
provider := AlertProvider{WebhookURL: "http://example.org"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
ExpectedBody string
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\n \"@type\": \"MessageCard\",\n \"@context\": \"http://schema.org/extensions\",\n \"themeColor\": \"#DD0000\",\n \"title\": \"🚨 Gatus\",\n \"text\": \"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row:\\n> description-1\",\n \"sections\": [\n {\n \"activityTitle\": \"URL\",\n \"text\": \"\"\n },\n {\n \"activityTitle\": \"Condition results\",\n \"text\": \"❌ - `[CONNECTED] == true`<br/>❌ - `[STATUS] == 200`<br/>\"\n }\n ]\n}",
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\n \"@type\": \"MessageCard\",\n \"@context\": \"http://schema.org/extensions\",\n \"themeColor\": \"#36A64F\",\n \"title\": \"🚨 Gatus\",\n \"text\": \"An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row:\\n> description-2\",\n \"sections\": [\n {\n \"activityTitle\": \"URL\",\n \"text\": \"\"\n },\n {\n \"activityTitle\": \"Condition results\",\n \"text\": \"✅ - `[CONNECTED] == true`<br/>✅ - `[STATUS] == 200`<br/>\"\n }\n ]\n}",
|
||||
},
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "triggered") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring triggered")
|
||||
}
|
||||
if customAlertProvider.URL != "http://example.org" {
|
||||
t.Errorf("expected URL to be %s, got %s", "http://example.org", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
body := scenario.Provider.buildRequestBody(
|
||||
&core.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if body != scenario.ExpectedBody {
|
||||
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
|
||||
}
|
||||
out := make(map[string]interface{})
|
||||
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/custom"
|
||||
"github.com/TwiN/gatus/v3/client"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
)
|
||||
|
||||
const defaultAPIURL = "https://api.telegram.org"
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Telegram
|
||||
type AlertProvider struct {
|
||||
Token string `yaml:"token"`
|
||||
ID string `yaml:"id"`
|
||||
Token string `yaml:"token"`
|
||||
ID string `yaml:"id"`
|
||||
APIURL string `yaml:"api-url"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
@@ -23,13 +28,36 @@ func (provider *AlertProvider) IsValid() bool {
|
||||
return len(provider.Token) > 0 && len(provider.ID) > 0
|
||||
}
|
||||
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
|
||||
apiURL := provider.APIURL
|
||||
if apiURL == "" {
|
||||
apiURL = defaultAPIURL
|
||||
}
|
||||
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/bot%s/sendMessage", apiURL, provider.Token), buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if response.StatusCode > 399 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
||||
var message, results string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("An alert for *%s* has been resolved:\\n—\\n _healthcheck passing successfully %d time(s) in a row_\\n— ", endpoint.Name, alert.FailureThreshold)
|
||||
message = fmt.Sprintf("An alert for *%s* has been resolved:\\n—\\n _healthcheck passing successfully %d time(s) in a row_\\n— ", endpoint.DisplayName(), alert.FailureThreshold)
|
||||
} else {
|
||||
message = fmt.Sprintf("An alert for *%s* has been triggered:\\n—\\n _healthcheck failed %d time(s) in a row_\\n— ", endpoint.Name, alert.FailureThreshold)
|
||||
message = fmt.Sprintf("An alert for *%s* has been triggered:\\n—\\n _healthcheck failed %d time(s) in a row_\\n— ", endpoint.DisplayName(), alert.FailureThreshold)
|
||||
}
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
@@ -46,12 +74,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, al
|
||||
} else {
|
||||
text = fmt.Sprintf("⛑ *Gatus* \\n%s \\n*Condition results*\\n%s", message, results)
|
||||
}
|
||||
return &custom.AlertProvider{
|
||||
URL: fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token),
|
||||
Method: http.MethodPost,
|
||||
Body: fmt.Sprintf(`{"chat_id": "%s", "text": "%s", "parse_mode": "MARKDOWN"}`, provider.ID, text),
|
||||
Headers: map[string]string{"Content-Type": "application/json"},
|
||||
}
|
||||
return fmt.Sprintf(`{"chat_id": "%s", "text": "%s", "parse_mode": "MARKDOWN"}`, provider.ID, text)
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
|
||||
@@ -2,13 +2,13 @@ package telegram
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/client"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
"github.com/TwiN/gatus/v3/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_IsValid(t *testing.T) {
|
||||
@@ -22,70 +22,137 @@ func TestAlertProvider_IsValid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
||||
provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
MockRoundTripper test.MockRoundTripper
|
||||
ExpectedError bool
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "triggered-error",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "resolved-error",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "resolved") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring resolved")
|
||||
}
|
||||
if customAlertProvider.URL != fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token) {
|
||||
t.Errorf("expected URL to be %s, got %s", fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token), customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
//_, err := json.Marshal(customAlertProvider.Body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||
err := scenario.Provider.Send(
|
||||
&core.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if scenario.ExpectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.ExpectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
||||
provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "0123456789"}
|
||||
description := "Healthcheck Successful"
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{Description: &description}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
ExpectedBody string
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{ID: "123"},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\"chat_id\": \"123\", \"text\": \"⛑ *Gatus* \\nAn alert for *endpoint-name* has been triggered:\\n—\\n _healthcheck failed 3 time(s) in a row_\\n— \\n*Description* \\n_description-1_ \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\", \"parse_mode\": \"MARKDOWN\"}",
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{ID: "123"},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\"chat_id\": \"123\", \"text\": \"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 3 time(s) in a row_\\n— \\n*Description* \\n_description-2_ \\n\\n*Condition results*\\n✅ - `[CONNECTED] == true`\\n✅ - `[STATUS] == 200`\\n\", \"parse_mode\": \"MARKDOWN\"}",
|
||||
},
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "triggered") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring triggered")
|
||||
}
|
||||
if customAlertProvider.URL != fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token) {
|
||||
t.Errorf("expected URL to be %s, got %s", fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token), customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
body := scenario.Provider.buildRequestBody(
|
||||
&core.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if body != scenario.ExpectedBody {
|
||||
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
|
||||
}
|
||||
out := make(map[string]interface{})
|
||||
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithDescription(t *testing.T) {
|
||||
provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "0123456789"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "triggered") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring triggered")
|
||||
}
|
||||
if customAlertProvider.URL != fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token) {
|
||||
t.Errorf("expected URL to be %s, got %s", fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token), customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
package twilio
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/custom"
|
||||
"github.com/TwiN/gatus/v3/client"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
)
|
||||
|
||||
@@ -19,7 +21,7 @@ type AlertProvider struct {
|
||||
To string `yaml:"to"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
@@ -27,27 +29,39 @@ func (provider *AlertProvider) IsValid() bool {
|
||||
return len(provider.Token) > 0 && len(provider.SID) > 0 && len(provider.From) > 0 && len(provider.To) > 0
|
||||
}
|
||||
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, _ *core.Result, resolved bool) *custom.AlertProvider {
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
|
||||
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json", provider.SID), buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(provider.SID+":"+provider.Token))))
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if response.StatusCode > 399 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
||||
var message string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription())
|
||||
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
|
||||
} else {
|
||||
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.Name, alert.GetDescription())
|
||||
}
|
||||
return &custom.AlertProvider{
|
||||
URL: fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json", provider.SID),
|
||||
Method: http.MethodPost,
|
||||
Body: url.Values{
|
||||
"To": {provider.To},
|
||||
"From": {provider.From},
|
||||
"Body": {message},
|
||||
}.Encode(),
|
||||
Headers: map[string]string{
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Authorization": fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", provider.SID, provider.Token)))),
|
||||
},
|
||||
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
|
||||
}
|
||||
return url.Values{
|
||||
"To": {provider.To},
|
||||
"From": {provider.From},
|
||||
"Body": {message},
|
||||
}.Encode()
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package twilio
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
@@ -25,54 +23,56 @@ func TestTwilioAlertProvider_IsValid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
||||
provider := AlertProvider{
|
||||
SID: "1",
|
||||
Token: "2",
|
||||
From: "3",
|
||||
To: "4",
|
||||
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
ExpectedBody string
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{SID: "1", Token: "2", From: "3", To: "4"},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "Body=TRIGGERED%3A+endpoint-name+-+description-1&From=3&To=4",
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{SID: "1", Token: "2", From: "3", To: "4"},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "Body=RESOLVED%3A+endpoint-name+-+description-2&From=3&To=4",
|
||||
},
|
||||
}
|
||||
description := "alert-description"
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{Name: "endpoint-name"}, &alert.Alert{Description: &description}, &core.Result{}, true)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "RESOLVED") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring RESOLVED")
|
||||
}
|
||||
if customAlertProvider.URL != "https://api.twilio.com/2010-04-01/Accounts/1/Messages.json" {
|
||||
t.Errorf("expected URL to be %s, got %s", "https://api.twilio.com/2010-04-01/Accounts/1/Messages.json", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
if customAlertProvider.Body != "Body=RESOLVED%3A+endpoint-name+-+alert-description&From=3&To=4" {
|
||||
t.Errorf("expected body to be %s, got %s", "Body=RESOLVED%3A+endpoint-name+-+alert-description&From=3&To=4", customAlertProvider.Body)
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
body := scenario.Provider.buildRequestBody(
|
||||
&core.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if body != scenario.ExpectedBody {
|
||||
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
||||
provider := AlertProvider{
|
||||
SID: "4",
|
||||
Token: "3",
|
||||
From: "2",
|
||||
To: "1",
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
description := "alert-description"
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{Name: "endpoint-name"}, &alert.Alert{Description: &description}, &core.Result{}, false)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "TRIGGERED") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring TRIGGERED")
|
||||
}
|
||||
if customAlertProvider.URL != "https://api.twilio.com/2010-04-01/Accounts/4/Messages.json" {
|
||||
t.Errorf("expected URL to be %s, got %s", "https://api.twilio.com/2010-04-01/Accounts/4/Messages.json", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
if customAlertProvider.Body != "Body=TRIGGERED%3A+endpoint-name+-+alert-description&From=2&To=1" {
|
||||
t.Errorf("expected body to be %s, got %s", "Body=TRIGGERED%3A+endpoint-name+-+alert-description&From=2&To=1", customAlertProvider.Body)
|
||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,14 @@ import (
|
||||
"github.com/go-ping/ping"
|
||||
)
|
||||
|
||||
// injectedHTTPClient is used for testing purposes
|
||||
var injectedHTTPClient *http.Client
|
||||
|
||||
// GetHTTPClient returns the shared HTTP client
|
||||
func GetHTTPClient(config *Config) *http.Client {
|
||||
if injectedHTTPClient != nil {
|
||||
return injectedHTTPClient
|
||||
}
|
||||
if config == nil {
|
||||
return defaultConfig.getHTTPClient()
|
||||
}
|
||||
@@ -104,3 +110,8 @@ func Ping(address string, config *Config) (bool, time.Duration) {
|
||||
}
|
||||
return true, 0
|
||||
}
|
||||
|
||||
// InjectHTTPClient is used to inject a custom HTTP client for testing purposes
|
||||
func InjectHTTPClient(httpClient *http.Client) {
|
||||
injectedHTTPClient = httpClient
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v3/test"
|
||||
)
|
||||
|
||||
func TestGetHTTPClient(t *testing.T) {
|
||||
@@ -10,6 +15,12 @@ func TestGetHTTPClient(t *testing.T) {
|
||||
Insecure: false,
|
||||
IgnoreRedirect: false,
|
||||
Timeout: 0,
|
||||
OAuth2Config: &OAuth2Config{
|
||||
ClientID: "00000000-0000-0000-0000-000000000000",
|
||||
ClientSecret: "secretsauce",
|
||||
TokenURL: "https://token-server.local/token",
|
||||
Scopes: []string{"https://application.local/.default"},
|
||||
},
|
||||
}
|
||||
cfg.ValidateAndSetDefaults()
|
||||
if GetHTTPClient(cfg) == nil {
|
||||
@@ -146,3 +157,71 @@ func TestCanCreateTCPConnection(t *testing.T) {
|
||||
t.Error("should've failed, because there's no port in the address")
|
||||
}
|
||||
}
|
||||
|
||||
// This test checks if a HTTP client configured with `configureOAuth2()` automatically
|
||||
// performs a Client Credentials OAuth2 flow and adds the obtained token as a `Authorization`
|
||||
// header to all outgoing HTTP calls.
|
||||
func TestHttpClientProvidesOAuth2BearerToken(t *testing.T) {
|
||||
|
||||
defer InjectHTTPClient(nil)
|
||||
|
||||
oAuth2Config := &OAuth2Config{
|
||||
ClientID: "00000000-0000-0000-0000-000000000000",
|
||||
ClientSecret: "secretsauce",
|
||||
TokenURL: "https://token-server.local/token",
|
||||
Scopes: []string{"https://application.local/.default"},
|
||||
}
|
||||
|
||||
mockHttpClient := &http.Client{
|
||||
Transport: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
|
||||
// if the mock HTTP client tries to get a token from the `token-server`
|
||||
// we provide the expected token response
|
||||
if r.Host == "token-server.local" {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: ioutil.NopCloser(bytes.NewReader(
|
||||
[]byte(
|
||||
`{"token_type":"Bearer","expires_in":3599,"ext_expires_in":3599,"access_token":"secret-token"}`,
|
||||
),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
// to verify the headers were sent as expected, we echo them back in the
|
||||
// `X-Org-Authorization` header and check if the token value matches our
|
||||
// mocked `token-server` response
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: map[string][]string{
|
||||
"X-Org-Authorization": {r.Header.Get("Authorization")},
|
||||
},
|
||||
Body: http.NoBody,
|
||||
}
|
||||
}),
|
||||
}
|
||||
|
||||
mockHttpClientWithOAuth := configureOAuth2(mockHttpClient, *oAuth2Config)
|
||||
InjectHTTPClient(mockHttpClientWithOAuth)
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, "http://127.0.0.1:8282", http.NoBody)
|
||||
if err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
|
||||
response, err := mockHttpClientWithOAuth.Do(request)
|
||||
if err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
|
||||
if response.Header == nil {
|
||||
t.Error("expected response headers, but got nil")
|
||||
}
|
||||
|
||||
// the mock response echos the Authorization header used in the request back
|
||||
// to us as `X-Org-Authorization` header, we check here if the value matches
|
||||
// our expected token `secret-token`
|
||||
if response.Header.Get("X-Org-Authorization") != "Bearer secret-token" {
|
||||
t.Error("exptected `secret-token` as Bearer token in the mocked response header `X-Org-Authorization`, but got", response.Header.Get("X-Org-Authorization"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/clientcredentials"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -11,7 +16,8 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
// DefaultConfig is the default client configuration
|
||||
ErrInvalidClientOAuth2Config = errors.New("invalid OAuth2 configuration, all fields are required")
|
||||
|
||||
defaultConfig = Config{
|
||||
Insecure: false,
|
||||
IgnoreRedirect: false,
|
||||
@@ -28,22 +34,50 @@ func GetDefaultConfig() *Config {
|
||||
// Config is the configuration for clients
|
||||
type Config struct {
|
||||
// Insecure determines whether to skip verifying the server's certificate chain and host name
|
||||
Insecure bool `yaml:"insecure"`
|
||||
Insecure bool `yaml:"insecure,omitempty"`
|
||||
|
||||
// IgnoreRedirect determines whether to ignore redirects (true) or follow them (false, default)
|
||||
IgnoreRedirect bool `yaml:"ignore-redirect"`
|
||||
IgnoreRedirect bool `yaml:"ignore-redirect,omitempty"`
|
||||
|
||||
// Timeout for the client
|
||||
Timeout time.Duration `yaml:"timeout"`
|
||||
|
||||
// OAuth2Config is the OAuth2 configuration used for the client.
|
||||
//
|
||||
// If non-nil, the http.Client returned by getHTTPClient will automatically retrieve a token if necessary.
|
||||
// See configureOAuth2 for more details.
|
||||
OAuth2Config *OAuth2Config `yaml:"oauth2,omitempty"`
|
||||
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// OAuth2Config is the configuration for the OAuth2 client credentials flow
|
||||
type OAuth2Config struct {
|
||||
TokenURL string `yaml:"token-url"` // e.g. https://dev-12345678.okta.com/token
|
||||
ClientID string `yaml:"client-id"`
|
||||
ClientSecret string `yaml:"client-secret"`
|
||||
Scopes []string `yaml:"scopes"` // e.g. ["openid"]
|
||||
}
|
||||
|
||||
// ValidateAndSetDefaults validates the client configuration and sets the default values if necessary
|
||||
func (c *Config) ValidateAndSetDefaults() {
|
||||
func (c *Config) ValidateAndSetDefaults() error {
|
||||
if c.Timeout < time.Millisecond {
|
||||
c.Timeout = 10 * time.Second
|
||||
}
|
||||
if c.HasOAuth2Config() && !c.OAuth2Config.isValid() {
|
||||
return ErrInvalidClientOAuth2Config
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasOAuth2Config returns true if the client has OAuth2 configuration parameters
|
||||
func (c *Config) HasOAuth2Config() bool {
|
||||
return c.OAuth2Config != nil
|
||||
}
|
||||
|
||||
// isValid() returns true if the OAuth2 configuration is valid
|
||||
func (c *OAuth2Config) isValid() bool {
|
||||
return len(c.TokenURL) > 0 && len(c.ClientID) > 0 && len(c.ClientSecret) > 0 && len(c.Scopes) > 0
|
||||
}
|
||||
|
||||
// GetHTTPClient return an HTTP client matching the Config's parameters.
|
||||
@@ -68,6 +102,22 @@ func (c *Config) getHTTPClient() *http.Client {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
if c.HasOAuth2Config() {
|
||||
c.httpClient = configureOAuth2(c.httpClient, *c.OAuth2Config)
|
||||
}
|
||||
}
|
||||
return c.httpClient
|
||||
}
|
||||
|
||||
// configureOAuth2 returns an HTTP client that will obtain and refresh tokens as necessary.
|
||||
// The returned Client and its Transport should not be modified.
|
||||
func configureOAuth2(httpClient *http.Client, c OAuth2Config) *http.Client {
|
||||
oauth2cfg := clientcredentials.Config{
|
||||
ClientID: c.ClientID,
|
||||
ClientSecret: c.ClientSecret,
|
||||
Scopes: c.Scopes,
|
||||
TokenURL: c.TokenURL,
|
||||
}
|
||||
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, httpClient)
|
||||
return oauth2cfg.Client(ctx)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
@@ -142,7 +141,7 @@ func LoadDefaultConfiguration() (*Config, error) {
|
||||
|
||||
func readConfigurationFile(fileName string) (config *Config, err error) {
|
||||
var bytes []byte
|
||||
if bytes, err = ioutil.ReadFile(fileName); err == nil {
|
||||
if bytes, err = os.ReadFile(fileName); err == nil {
|
||||
// file exists, so we'll parse it and return it
|
||||
return parseAndValidateConfigBytes(bytes)
|
||||
}
|
||||
@@ -274,8 +273,10 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*core.E
|
||||
alertTypes := []alert.Type{
|
||||
alert.TypeCustom,
|
||||
alert.TypeDiscord,
|
||||
alert.TypeEmail,
|
||||
alert.TypeMattermost,
|
||||
alert.TypeMessagebird,
|
||||
alert.TypeOpsgenie,
|
||||
alert.TypePagerDuty,
|
||||
alert.TypeSlack,
|
||||
alert.TypeTeams,
|
||||
|
||||
@@ -1172,12 +1172,12 @@ endpoints:
|
||||
|
||||
func TestParseAndValidateConfigBytesWithValidSecurityConfig(t *testing.T) {
|
||||
const expectedUsername = "admin"
|
||||
const expectedPasswordHash = "6b97ed68d14eb3f1aa959ce5d49c7dc612e1eb1dafd73b1e705847483fd6a6c809f2ceb4e8df6ff9984c6298ff0285cace6614bf8daa9f0070101b6c89899e22"
|
||||
const expectedPasswordHash = "JDJhJDEwJHRiMnRFakxWazZLdXBzRERQazB1TE8vckRLY05Yb1hSdnoxWU0yQ1FaYXZRSW1McmladDYu"
|
||||
config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`debug: true
|
||||
security:
|
||||
basic:
|
||||
username: "%s"
|
||||
password-sha512: "%s"
|
||||
password-bcrypt-base64: "%s"
|
||||
endpoints:
|
||||
- name: website
|
||||
url: https://twin.sh/health
|
||||
@@ -1202,8 +1202,8 @@ endpoints:
|
||||
if config.Security.Basic.Username != expectedUsername {
|
||||
t.Errorf("config.Security.Basic.Username should've been %s, but was %s", expectedUsername, config.Security.Basic.Username)
|
||||
}
|
||||
if config.Security.Basic.PasswordSha512Hash != expectedPasswordHash {
|
||||
t.Errorf("config.Security.Basic.PasswordSha512Hash should've been %s, but was %s", expectedPasswordHash, config.Security.Basic.PasswordSha512Hash)
|
||||
if config.Security.Basic.PasswordBcryptHashBase64Encoded != expectedPasswordHash {
|
||||
t.Errorf("config.Security.Basic.PasswordBcryptHashBase64Encoded should've been %s, but was %s", expectedPasswordHash, config.Security.Basic.PasswordSha512Hash)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ type Config struct {
|
||||
Every []string `yaml:"every"`
|
||||
|
||||
durationToStartFromMidnight time.Duration
|
||||
timeLocation *time.Location
|
||||
}
|
||||
|
||||
func GetDefaultConfig() *Config {
|
||||
|
||||
@@ -6,8 +6,10 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
defaultTitle = "Health Dashboard | Gatus"
|
||||
defaultLogo = ""
|
||||
defaultTitle = "Health Dashboard | Gatus"
|
||||
defaultHeader = "Health Status"
|
||||
defaultLogo = ""
|
||||
defaultLink = ""
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -18,15 +20,19 @@ var (
|
||||
|
||||
// Config is the configuration for the UI of Gatus
|
||||
type Config struct {
|
||||
Title string `yaml:"title"` // Title of the page
|
||||
Logo string `yaml:"logo"` // Logo to display on the page
|
||||
Title string `yaml:"title,omitempty"` // Title of the page
|
||||
Header string `yaml:"header,omitempty"` // Header is the text at the top of the page
|
||||
Logo string `yaml:"logo,omitempty"` // Logo to display on the page
|
||||
Link string `yaml:"link,omitempty"` // Link to open when clicking on the logo
|
||||
}
|
||||
|
||||
// GetDefaultConfig returns a Config struct with the default values
|
||||
func GetDefaultConfig() *Config {
|
||||
return &Config{
|
||||
Title: defaultTitle,
|
||||
Logo: defaultLogo,
|
||||
Title: defaultTitle,
|
||||
Header: defaultHeader,
|
||||
Logo: defaultLogo,
|
||||
Link: defaultLink,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +41,12 @@ func (cfg *Config) ValidateAndSetDefaults() error {
|
||||
if len(cfg.Title) == 0 {
|
||||
cfg.Title = defaultTitle
|
||||
}
|
||||
if len(cfg.Header) == 0 {
|
||||
cfg.Header = defaultHeader
|
||||
}
|
||||
if len(cfg.Header) == 0 {
|
||||
cfg.Header = defaultLink
|
||||
}
|
||||
t, err := template.ParseFiles(StaticFolder + "/index.html")
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -9,10 +9,21 @@ func TestConfig_ValidateAndSetDefaults(t *testing.T) {
|
||||
defer func() {
|
||||
StaticFolder = "./web/static"
|
||||
}()
|
||||
cfg := &Config{Title: ""}
|
||||
cfg := &Config{
|
||||
Title: "",
|
||||
Header: "",
|
||||
Logo: "",
|
||||
Link: "",
|
||||
}
|
||||
if err := cfg.ValidateAndSetDefaults(); err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
if cfg.Title != defaultTitle {
|
||||
t.Errorf("expected title to be %s, got %s", defaultTitle, cfg.Title)
|
||||
}
|
||||
if cfg.Header != defaultHeader {
|
||||
t.Errorf("expected header to be %s, got %s", defaultHeader, cfg.Header)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDefaultConfig(t *testing.T) {
|
||||
|
||||
@@ -51,11 +51,10 @@ func UptimeBadge(writer http.ResponseWriter, request *http.Request) {
|
||||
}
|
||||
return
|
||||
}
|
||||
formattedDate := time.Now().Format(http.TimeFormat)
|
||||
writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
writer.Header().Set("Date", formattedDate)
|
||||
writer.Header().Set("Expires", formattedDate)
|
||||
writer.Header().Set("Content-Type", "image/svg+xml")
|
||||
writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
writer.Header().Set("Expires", "0")
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
_, _ = writer.Write(generateUptimeBadgeSVG(duration, uptime))
|
||||
}
|
||||
|
||||
@@ -89,11 +88,10 @@ func ResponseTimeBadge(writer http.ResponseWriter, request *http.Request) {
|
||||
}
|
||||
return
|
||||
}
|
||||
formattedDate := time.Now().Format(http.TimeFormat)
|
||||
writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
writer.Header().Set("Date", formattedDate)
|
||||
writer.Header().Set("Expires", formattedDate)
|
||||
writer.Header().Set("Content-Type", "image/svg+xml")
|
||||
writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
writer.Header().Set("Expires", "0")
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
_, _ = writer.Write(generateResponseTimeBadgeSVG(duration, averageResponseTime))
|
||||
}
|
||||
|
||||
|
||||
@@ -112,6 +112,9 @@ func ResponseTimeChart(writer http.ResponseWriter, r *http.Request) {
|
||||
Series: []chart.Series{series},
|
||||
}
|
||||
writer.Header().Set("Content-Type", "image/svg+xml")
|
||||
writer.Header().Set("Cache-Control", "no-cache, no-store")
|
||||
writer.Header().Set("Expires", "0")
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
if err := graph.Render(chart.SVG, writer); err != nil {
|
||||
log.Println("[handler][ResponseTimeChart] Failed to render response time chart:", err.Error())
|
||||
return
|
||||
|
||||
26
controller/handler/config.go
Normal file
26
controller/handler/config.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwiN/gatus/v3/security"
|
||||
)
|
||||
|
||||
// ConfigHandler is a handler that returns information for the front end of the application.
|
||||
type ConfigHandler struct {
|
||||
securityConfig *security.Config
|
||||
}
|
||||
|
||||
func (handler ConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
hasOIDC := false
|
||||
isAuthenticated := true // Default to true if no security config is set
|
||||
if handler.securityConfig != nil {
|
||||
hasOIDC = handler.securityConfig.OIDC != nil
|
||||
isAuthenticated = handler.securityConfig.IsAuthenticated(r)
|
||||
}
|
||||
// Return the config
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(fmt.Sprintf(`{"oidc":%v,"authenticated":%v}`, hasOIDC, isAuthenticated)))
|
||||
}
|
||||
34
controller/handler/config_test.go
Normal file
34
controller/handler/config_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v3/security"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func TestConfigHandler_ServeHTTP(t *testing.T) {
|
||||
securityConfig := &security.Config{
|
||||
OIDC: &security.OIDCConfig{
|
||||
IssuerURL: "https://sso.gatus.io/",
|
||||
RedirectURL: "http://localhost:80/authorization-code/callback",
|
||||
Scopes: []string{"openid"},
|
||||
AllowedSubjects: []string{"user1@example.com"},
|
||||
},
|
||||
}
|
||||
handler := ConfigHandler{securityConfig: securityConfig}
|
||||
// Create a fake router. We're doing this because I need the gate to be initialized.
|
||||
securityConfig.ApplySecurityMiddleware(mux.NewRouter())
|
||||
// Test the config handler
|
||||
request, _ := http.NewRequest("GET", "/api/v1/config", http.NoBody)
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(responseRecorder, request)
|
||||
if responseRecorder.Code != http.StatusOK {
|
||||
t.Error("expected code to be 200, but was", responseRecorder.Code)
|
||||
}
|
||||
if responseRecorder.Body.String() != `{"oidc":true,"authenticated":false}` {
|
||||
t.Error("expected body to be `{\"oidc\":true,\"authenticated\":false}`, but was", responseRecorder.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,11 @@ import "net/http"
|
||||
|
||||
func DevelopmentCORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "http://localhost:8081")
|
||||
if r.Method == "OPTIONS" {
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package handler
|
||||
import (
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -11,7 +10,7 @@ import (
|
||||
|
||||
var gzPool = sync.Pool{
|
||||
New: func() interface{} {
|
||||
return gzip.NewWriter(ioutil.Discard)
|
||||
return gzip.NewWriter(io.Discard)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -15,21 +15,34 @@ func CreateRouter(staticFolder string, securityConfig *security.Config, uiConfig
|
||||
if enabledMetrics {
|
||||
router.Handle("/metrics", promhttp.Handler()).Methods("GET")
|
||||
}
|
||||
api := router.PathPrefix("/api").Subrouter()
|
||||
protected := api.PathPrefix("/").Subrouter()
|
||||
unprotected := api.PathPrefix("/").Subrouter()
|
||||
if securityConfig != nil {
|
||||
if err := securityConfig.RegisterHandlers(router); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := securityConfig.ApplySecurityMiddleware(protected); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
// Endpoints
|
||||
unprotected.Handle("/v1/config", ConfigHandler{securityConfig: securityConfig}).Methods("GET")
|
||||
protected.HandleFunc("/v1/endpoints/statuses", EndpointStatuses).Methods("GET") // No GzipHandler for this one, because we cache the content as Gzipped already
|
||||
protected.HandleFunc("/v1/endpoints/{key}/statuses", GzipHandlerFunc(EndpointStatus)).Methods("GET")
|
||||
unprotected.HandleFunc("/v1/endpoints/{key}/uptimes/{duration}/badge.svg", UptimeBadge).Methods("GET")
|
||||
unprotected.HandleFunc("/v1/endpoints/{key}/response-times/{duration}/badge.svg", ResponseTimeBadge).Methods("GET")
|
||||
unprotected.HandleFunc("/v1/endpoints/{key}/response-times/{duration}/chart.svg", ResponseTimeChart).Methods("GET")
|
||||
// XXX: Remove the lines between this and the next XXX comment in v4.0.0
|
||||
protected.HandleFunc("/v1/services/statuses", EndpointStatuses).Methods("GET") // No GzipHandler for this one, because we cache the content as Gzipped already
|
||||
protected.HandleFunc("/v1/services/{key}/statuses", GzipHandlerFunc(EndpointStatus)).Methods("GET")
|
||||
unprotected.HandleFunc("/v1/services/{key}/uptimes/{duration}/badge.svg", UptimeBadge).Methods("GET")
|
||||
unprotected.HandleFunc("/v1/services/{key}/response-times/{duration}/badge.svg", ResponseTimeBadge).Methods("GET")
|
||||
unprotected.HandleFunc("/v1/services/{key}/response-times/{duration}/chart.svg", ResponseTimeChart).Methods("GET")
|
||||
// XXX: Remove the lines between this and the previous XXX comment in v4.0.0
|
||||
// Misc
|
||||
router.Handle("/health", health.Handler().WithJSON(true)).Methods("GET")
|
||||
router.HandleFunc("/favicon.ico", FavIcon(staticFolder)).Methods("GET")
|
||||
// Endpoints
|
||||
router.HandleFunc("/api/v1/endpoints/statuses", secureIfNecessary(securityConfig, EndpointStatuses)).Methods("GET") // No GzipHandler for this one, because we cache the content as Gzipped already
|
||||
router.HandleFunc("/api/v1/endpoints/{key}/statuses", secureIfNecessary(securityConfig, GzipHandlerFunc(EndpointStatus))).Methods("GET")
|
||||
router.HandleFunc("/api/v1/endpoints/{key}/uptimes/{duration}/badge.svg", UptimeBadge).Methods("GET")
|
||||
router.HandleFunc("/api/v1/endpoints/{key}/response-times/{duration}/badge.svg", ResponseTimeBadge).Methods("GET")
|
||||
router.HandleFunc("/api/v1/endpoints/{key}/response-times/{duration}/chart.svg", ResponseTimeChart).Methods("GET")
|
||||
// XXX: Remove the lines between this and the next XXX comment in v4.0.0
|
||||
router.HandleFunc("/api/v1/services/statuses", secureIfNecessary(securityConfig, EndpointStatuses)).Methods("GET") // No GzipHandler for this one, because we cache the content as Gzipped already
|
||||
router.HandleFunc("/api/v1/services/{key}/statuses", secureIfNecessary(securityConfig, GzipHandlerFunc(EndpointStatus))).Methods("GET")
|
||||
router.HandleFunc("/api/v1/services/{key}/uptimes/{duration}/badge.svg", UptimeBadge).Methods("GET")
|
||||
router.HandleFunc("/api/v1/services/{key}/response-times/{duration}/badge.svg", ResponseTimeBadge).Methods("GET")
|
||||
router.HandleFunc("/api/v1/services/{key}/response-times/{duration}/chart.svg", ResponseTimeChart).Methods("GET")
|
||||
// XXX: Remove the lines between this and the previous XXX comment in v4.0.0
|
||||
// SPA
|
||||
router.HandleFunc("/services/{name}", SinglePageApplication(staticFolder, uiConfig)).Methods("GET") // XXX: Remove this in v4.0.0
|
||||
router.HandleFunc("/endpoints/{name}", SinglePageApplication(staticFolder, uiConfig)).Methods("GET")
|
||||
@@ -38,10 +51,3 @@ func CreateRouter(staticFolder string, securityConfig *security.Config, uiConfig
|
||||
router.PathPrefix("/").Handler(GzipHandler(http.FileServer(http.Dir(staticFolder))))
|
||||
return router
|
||||
}
|
||||
|
||||
func secureIfNecessary(securityConfig *security.Config, handler http.HandlerFunc) http.HandlerFunc {
|
||||
if securityConfig != nil && securityConfig.IsValid() {
|
||||
return security.Handler(handler, securityConfig)
|
||||
}
|
||||
return handler
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/ioutil"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -41,6 +41,9 @@ var (
|
||||
|
||||
// ErrEndpointWithNoName is the error with which Gatus will panic if an endpoint is configured with no name
|
||||
ErrEndpointWithNoName = errors.New("you must specify a name for each endpoint")
|
||||
|
||||
// ErrEndpointWithInvalidNameOrGroup is the error with which Gatus will panic if an endpoint has an invalid character where it shouldn't
|
||||
ErrEndpointWithInvalidNameOrGroup = errors.New("endpoint name and group must not have \" or \\")
|
||||
)
|
||||
|
||||
// Endpoint is the configuration of a monitored
|
||||
@@ -108,7 +111,9 @@ func (endpoint *Endpoint) ValidateAndSetDefaults() error {
|
||||
if endpoint.ClientConfig == nil {
|
||||
endpoint.ClientConfig = client.GetDefaultConfig()
|
||||
} else {
|
||||
endpoint.ClientConfig.ValidateAndSetDefaults()
|
||||
if err := endpoint.ClientConfig.ValidateAndSetDefaults(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if endpoint.UIConfig == nil {
|
||||
endpoint.UIConfig = ui.GetDefaultConfig()
|
||||
@@ -132,16 +137,16 @@ func (endpoint *Endpoint) ValidateAndSetDefaults() error {
|
||||
endpoint.Headers[ContentTypeHeader] = "application/json"
|
||||
}
|
||||
for _, endpointAlert := range endpoint.Alerts {
|
||||
if endpointAlert.FailureThreshold <= 0 {
|
||||
endpointAlert.FailureThreshold = 3
|
||||
}
|
||||
if endpointAlert.SuccessThreshold <= 0 {
|
||||
endpointAlert.SuccessThreshold = 2
|
||||
if err := endpointAlert.ValidateAndSetDefaults(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(endpoint.Name) == 0 {
|
||||
return ErrEndpointWithNoName
|
||||
}
|
||||
if strings.ContainsAny(endpoint.Name, "\"\\") || strings.ContainsAny(endpoint.Group, "\"\\") {
|
||||
return ErrEndpointWithInvalidNameOrGroup
|
||||
}
|
||||
if len(endpoint.URL) == 0 {
|
||||
return ErrEndpointWithNoURL
|
||||
}
|
||||
@@ -159,6 +164,14 @@ func (endpoint *Endpoint) ValidateAndSetDefaults() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DisplayName returns an identifier made up of the Name and, if not empty, the Group.
|
||||
func (endpoint Endpoint) DisplayName() string {
|
||||
if len(endpoint.Group) > 0 {
|
||||
return endpoint.Group + "/" + endpoint.Name
|
||||
}
|
||||
return endpoint.Name
|
||||
}
|
||||
|
||||
// Key returns the unique key for the Endpoint
|
||||
func (endpoint Endpoint) Key() string {
|
||||
return util.ConvertGroupAndEndpointNameToKey(endpoint.Group, endpoint.Name)
|
||||
@@ -184,6 +197,9 @@ func (endpoint *Endpoint) EvaluateHealth() *Result {
|
||||
result.body = nil
|
||||
// Clean up parameters that we don't need to keep in the results
|
||||
if endpoint.UIConfig.HideHostname {
|
||||
for errIdx, errorString := range result.Errors {
|
||||
result.Errors[errIdx] = strings.ReplaceAll(errorString, result.Hostname, "<redacted>")
|
||||
}
|
||||
result.Hostname = ""
|
||||
}
|
||||
return result
|
||||
@@ -259,7 +275,7 @@ func (endpoint *Endpoint) call(result *Result) {
|
||||
result.Connected = response.StatusCode > 0
|
||||
// Only read the body if there's a condition that uses the BodyPlaceholder
|
||||
if endpoint.needsToReadBody() {
|
||||
result.body, err = ioutil.ReadAll(response.Body)
|
||||
result.body, err = io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
result.AddError(err.Error())
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/client"
|
||||
"github.com/TwiN/gatus/v3/core/ui"
|
||||
)
|
||||
|
||||
func TestEndpoint_IsEnabled(t *testing.T) {
|
||||
@@ -245,7 +246,7 @@ func TestEndpoint_buildHTTPRequestWithGraphQLEnabled(t *testing.T) {
|
||||
if contentType := request.Header.Get(ContentTypeHeader); contentType != "application/json" {
|
||||
t.Error("request.Header.Content-Type should've been application/json, but was", contentType)
|
||||
}
|
||||
body, _ := ioutil.ReadAll(request.Body)
|
||||
body, _ := io.ReadAll(request.Body)
|
||||
if !strings.HasPrefix(string(body), "{\"query\":") {
|
||||
t.Error("request.body should've started with '{\"query\":', but it didn't:", string(body))
|
||||
}
|
||||
@@ -270,6 +271,9 @@ func TestIntegrationEvaluateHealth(t *testing.T) {
|
||||
if !result.Success {
|
||||
t.Error("Because all conditions passed, this should have been a success")
|
||||
}
|
||||
if result.Hostname != "twin.sh" {
|
||||
t.Error("result.Hostname should've been twin.sh, but was", result.Hostname)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationEvaluateHealthWithFailure(t *testing.T) {
|
||||
@@ -288,7 +292,53 @@ func TestIntegrationEvaluateHealthWithFailure(t *testing.T) {
|
||||
t.Error("Because the connection has been established, result.Connected should've been true")
|
||||
}
|
||||
if result.Success {
|
||||
t.Error("Because one of the conditions failed, success should have been false")
|
||||
t.Error("Because one of the conditions failed, result.Success should have been false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationEvaluateHealthWithInvalidCondition(t *testing.T) {
|
||||
condition := Condition("[STATUS] invalid 200")
|
||||
endpoint := Endpoint{
|
||||
Name: "invalid-condition",
|
||||
URL: "https://twin.sh/health",
|
||||
Conditions: []*Condition{&condition},
|
||||
}
|
||||
if err := endpoint.ValidateAndSetDefaults(); err != nil {
|
||||
// XXX: Should this really not return an error? After all, the condition is not valid and conditions are part of the endpoint...
|
||||
t.Error("endpoint validation should've been successful, but wasn't")
|
||||
}
|
||||
result := endpoint.EvaluateHealth()
|
||||
if result.Success {
|
||||
t.Error("Because one of the conditions was invalid, result.Success should have been false")
|
||||
}
|
||||
if len(result.Errors) == 0 {
|
||||
t.Error("There should've been an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationEvaluateHealthWithError(t *testing.T) {
|
||||
condition := Condition("[STATUS] == 200")
|
||||
endpoint := Endpoint{
|
||||
Name: "invalid-host",
|
||||
URL: "http://invalid/health",
|
||||
Conditions: []*Condition{&condition},
|
||||
UIConfig: &ui.Config{
|
||||
HideHostname: true,
|
||||
},
|
||||
}
|
||||
endpoint.ValidateAndSetDefaults()
|
||||
result := endpoint.EvaluateHealth()
|
||||
if result.Success {
|
||||
t.Error("Because one of the conditions was invalid, result.Success should have been false")
|
||||
}
|
||||
if len(result.Errors) == 0 {
|
||||
t.Error("There should've been an error")
|
||||
}
|
||||
if !strings.Contains(result.Errors[0], "<redacted>") {
|
||||
t.Error("result.Errors[0] should've had the hostname redacted because ui.hide-hostname is set to true")
|
||||
}
|
||||
if result.Hostname != "" {
|
||||
t.Error("result.Hostname should've been empty because ui.hide-hostname is set to true")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
38
go.mod
38
go.mod
@@ -3,36 +3,49 @@ module github.com/TwiN/gatus/v3
|
||||
go 1.17
|
||||
|
||||
require (
|
||||
github.com/TwiN/g8 v1.3.0
|
||||
github.com/TwiN/gocache v1.2.4
|
||||
github.com/TwiN/health v1.1.0
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.1.2 // indirect
|
||||
github.com/TwiN/gocache/v2 v2.0.0
|
||||
github.com/TwiN/health v1.3.0
|
||||
github.com/coreos/go-oidc/v3 v3.1.0
|
||||
github.com/go-ping/ping v0.0.0-20210911151512-381826476871
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/golang/protobuf v1.5.2 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/google/uuid v1.3.0
|
||||
github.com/gorilla/mux v1.8.0
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/lib/pq v1.10.3
|
||||
github.com/mattn/go-isatty v0.0.14 // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
|
||||
github.com/miekg/dns v1.1.43
|
||||
github.com/prometheus/client_golang v1.11.0
|
||||
github.com/wcharczuk/go-chart/v2 v2.1.0
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
|
||||
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c
|
||||
gopkg.in/mail.v2 v2.3.1
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
modernc.org/sqlite v1.13.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.1.2 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/golang/protobuf v1.5.2 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/mattn/go-isatty v0.0.14 // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
|
||||
github.com/prometheus/client_model v0.2.0 // indirect
|
||||
github.com/prometheus/common v0.31.1 // indirect
|
||||
github.com/prometheus/procfs v0.7.3 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
|
||||
github.com/wcharczuk/go-chart/v2 v2.1.0
|
||||
go.etcd.io/bbolt v1.3.6 // indirect
|
||||
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d // indirect
|
||||
golang.org/x/mod v0.5.1 // indirect
|
||||
golang.org/x/net v0.0.0-20210929193557-e81a3d93ecf6 // indirect
|
||||
golang.org/x/net v0.0.0-20211209124913-491a49abca63 // indirect
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect
|
||||
golang.org/x/sys v0.0.0-20211003122950-b1ebd4e1001c // indirect
|
||||
golang.org/x/tools v0.1.7 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
||||
google.golang.org/appengine v1.6.6 // indirect
|
||||
google.golang.org/protobuf v1.27.1 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||
gopkg.in/square/go-jose.v2 v2.5.1 // indirect
|
||||
lukechampine.com/uint128 v1.1.1 // indirect
|
||||
modernc.org/cc/v3 v3.35.8 // indirect
|
||||
modernc.org/ccgo/v3 v3.12.16 // indirect
|
||||
@@ -40,7 +53,6 @@ require (
|
||||
modernc.org/mathutil v1.4.1 // indirect
|
||||
modernc.org/memory v1.0.5 // indirect
|
||||
modernc.org/opt v0.1.1 // indirect
|
||||
modernc.org/sqlite v1.13.1
|
||||
modernc.org/strutil v1.1.1 // indirect
|
||||
modernc.org/token v1.0.0 // indirect
|
||||
)
|
||||
|
||||
28
go.sum
28
go.sum
@@ -33,10 +33,14 @@ cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/TwiN/g8 v1.3.0 h1:mNv3R35GhDn1gEV0BKMl1oupZ1tDtOWPTHUKu+W/k3U=
|
||||
github.com/TwiN/g8 v1.3.0/go.mod h1:SiIdItS0agSUloFqdQQt/RObB2jGSq+nnE9WfFv3RIo=
|
||||
github.com/TwiN/gocache v1.2.4 h1:AfJ1YRcxtQ/zZEN61URDwk/dwFG7LSRenU5qIm9dQzo=
|
||||
github.com/TwiN/gocache v1.2.4/go.mod h1:BjabsQQy6z5uHDorHa4LJVPEzFeitLIDbCtdv3gc1gA=
|
||||
github.com/TwiN/health v1.1.0 h1:IbXV4b5VPxzfIqOPiP/19JdBNFYM0oEDReLbUazhb2k=
|
||||
github.com/TwiN/health v1.1.0/go.mod h1:Bt+lEvSi6C/9NWb7OoGmUmgtS4dfPeMM9EINnURv5dE=
|
||||
github.com/TwiN/gocache/v2 v2.0.0 h1:CPbDNKdSJpmBkh7aWcO7D3KK1yWaMlwX+3dsBPE8/so=
|
||||
github.com/TwiN/gocache/v2 v2.0.0/go.mod h1:j4MABVaia2Tp53ERWc/3l4YxkswtPjB2hQzmL/kD/VQ=
|
||||
github.com/TwiN/health v1.3.0 h1:xw90rZqg0NH5MRkVHzlgtDdP+EQd43v3yMqQVtYlGHg=
|
||||
github.com/TwiN/health v1.3.0/go.mod h1:Bt+lEvSi6C/9NWb7OoGmUmgtS4dfPeMM9EINnURv5dE=
|
||||
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
@@ -55,7 +59,10 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/coreos/go-oidc/v3 v3.1.0 h1:6avEvcdvTa1qYsOZ6I5PRkSYHzpTNWgKYmaJfaYbrRw=
|
||||
github.com/coreos/go-oidc/v3 v3.1.0/go.mod h1:rEJ/idjfUyfkBit1eI1fvyr+64/g9dcKpAm8MJMesvo=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
@@ -190,6 +197,7 @@ github.com/onsi/gomega v1.10.2/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
|
||||
@@ -223,6 +231,8 @@ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/tidwall/redcon v1.3.2/go.mod h1:bdYBm4rlcWpst2XMwKVzWDF9CoUxEbUmM7CQrKeOZas=
|
||||
github.com/wcharczuk/go-chart/v2 v2.1.0 h1:tY2slqVQ6bN+yHSnDYwZebLQFkphK4WNrVwnt7CJZ2I=
|
||||
github.com/wcharczuk/go-chart/v2 v2.1.0/go.mod h1:yx7MvAVNcP/kN9lKXM/NTce4au4DFN99j6i1OwDclNA=
|
||||
@@ -244,6 +254,7 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
@@ -304,6 +315,7 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL
|
||||
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200505041828-1ed23360d12c/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
@@ -316,13 +328,14 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
||||
golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
|
||||
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20210929193557-e81a3d93ecf6 h1:Z04ewVs7JhXaYkmDhBERPi41gnltfQpMWDnTnQbaCqk=
|
||||
golang.org/x/net v0.0.0-20210929193557-e81a3d93ecf6/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211209124913-491a49abca63 h1:iocB37TsdFuN6IBRZ+ry36wrkoV51/tl5vOWqkcPGvY=
|
||||
golang.org/x/net v0.0.0-20211209124913-491a49abca63/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c h1:pkQiBZBvdos9qq4wBAHqlzuZHEXo07pqV06ef90u1WI=
|
||||
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -466,6 +479,7 @@ google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7
|
||||
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/appengine v1.6.6 h1:lMO5rYAqUxkmaj76jAkRUvt5JZgFymx/+Q5Mzfivuhc=
|
||||
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
@@ -523,12 +537,18 @@ google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ
|
||||
google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ=
|
||||
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk=
|
||||
gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
|
||||
gopkg.in/square/go-jose.v2 v2.5.1 h1:7odma5RETjNHWJnR32wx8t+Io4djHE1PqxCFx3iiZ2w=
|
||||
gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
|
||||
4
main.go
4
main.go
@@ -88,6 +88,8 @@ func listenToConfigurationFileChanges(cfg *config.Config) {
|
||||
time.Sleep(30 * time.Second)
|
||||
if cfg.HasLoadedConfigurationFileBeenModified() {
|
||||
log.Println("[main][listenToConfigurationFileChanges] Configuration file has been modified")
|
||||
stop()
|
||||
time.Sleep(time.Second) // Wait a bit to make sure everything is done.
|
||||
save()
|
||||
updatedConfig, err := loadConfiguration()
|
||||
if err != nil {
|
||||
@@ -101,7 +103,7 @@ func listenToConfigurationFileChanges(cfg *config.Config) {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
stop()
|
||||
initializeStorage(updatedConfig)
|
||||
start(updatedConfig)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package metric
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
@@ -11,24 +9,19 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
gauges = map[string]*prometheus.GaugeVec{}
|
||||
rwLock sync.RWMutex
|
||||
// This will be initialized once PublishMetricsForEndpoint.
|
||||
// The reason why we're doing this is that if metrics are disabled, we don't want to initialize it unnecessarily.
|
||||
resultCount *prometheus.CounterVec = nil
|
||||
)
|
||||
|
||||
// PublishMetricsForEndpoint publishes metrics for the given endpoint and its result.
|
||||
// These metrics will be exposed at /metrics if the metrics are enabled
|
||||
func PublishMetricsForEndpoint(endpoint *core.Endpoint, result *core.Result) {
|
||||
rwLock.Lock()
|
||||
gauge, exists := gauges[fmt.Sprintf("%s_%s", endpoint.Name, endpoint.URL)]
|
||||
if !exists {
|
||||
gauge = promauto.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Subsystem: "gatus",
|
||||
Name: "tasks",
|
||||
// TODO: remove the "service" key in v4.0.0, as it is only kept for backward compatibility
|
||||
ConstLabels: prometheus.Labels{"service": endpoint.Name, "endpoint": endpoint.Name, "url": endpoint.URL},
|
||||
}, []string{"status", "success"})
|
||||
gauges[fmt.Sprintf("%s_%s", endpoint.Name, endpoint.URL)] = gauge
|
||||
if resultCount == nil {
|
||||
resultCount = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "gatus_results_total",
|
||||
Help: "Number of results per endpoint",
|
||||
}, []string{"key", "group", "name", "success"})
|
||||
}
|
||||
rwLock.Unlock()
|
||||
gauge.WithLabelValues(strconv.Itoa(result.HTTPStatus), strconv.FormatBool(result.Success)).Inc()
|
||||
resultCount.WithLabelValues(endpoint.Key(), endpoint.Group, endpoint.Name, strconv.FormatBool(result.Success)).Inc()
|
||||
}
|
||||
|
||||
26
security/basic.go
Normal file
26
security/basic.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package security
|
||||
|
||||
import "log"
|
||||
|
||||
// BasicConfig is the configuration for Basic authentication
|
||||
type BasicConfig struct {
|
||||
// Username is the name which will need to be used for a successful authentication
|
||||
Username string `yaml:"username"`
|
||||
|
||||
// PasswordSha512Hash is the SHA512 hash of the password which will need to be used for a successful authentication
|
||||
// XXX: Remove this on v4.0.0
|
||||
// Deprecated: Use PasswordBcryptHashBase64Encoded instead
|
||||
PasswordSha512Hash string `yaml:"password-sha512"`
|
||||
|
||||
// PasswordBcryptHashBase64Encoded is the base64 encoded string of the Bcrypt hash of the password to use to
|
||||
// authenticate using basic auth.
|
||||
PasswordBcryptHashBase64Encoded string `yaml:"password-bcrypt-base64"`
|
||||
}
|
||||
|
||||
// isValid returns whether the basic security configuration is valid or not
|
||||
func (c *BasicConfig) isValid() bool {
|
||||
if len(c.PasswordSha512Hash) > 0 {
|
||||
log.Println("WARNING: security.basic.password-sha512 has been deprecated in favor of security.basic.password-bcrypt-base64")
|
||||
}
|
||||
return len(c.Username) > 0 && (len(c.PasswordSha512Hash) == 128 || len(c.PasswordBcryptHashBase64Encoded) > 0)
|
||||
}
|
||||
43
security/basic_test.go
Normal file
43
security/basic_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package security
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBasicConfig_IsValidUsingSHA512(t *testing.T) {
|
||||
basicConfig := &BasicConfig{
|
||||
Username: "admin",
|
||||
PasswordSha512Hash: Sha512("test"),
|
||||
}
|
||||
if !basicConfig.isValid() {
|
||||
t.Error("basicConfig should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicConfig_IsValidWhenPasswordIsInvalidUsingSHA512(t *testing.T) {
|
||||
basicConfig := &BasicConfig{
|
||||
Username: "admin",
|
||||
PasswordSha512Hash: "",
|
||||
}
|
||||
if basicConfig.isValid() {
|
||||
t.Error("basicConfig shouldn't have been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicConfig_IsValidUsingBcrypt(t *testing.T) {
|
||||
basicConfig := &BasicConfig{
|
||||
Username: "admin",
|
||||
PasswordBcryptHashBase64Encoded: "JDJhJDA4JDFoRnpPY1hnaFl1OC9ISlFsa21VS09wOGlPU1ZOTDlHZG1qeTFvb3dIckRBUnlHUmNIRWlT",
|
||||
}
|
||||
if !basicConfig.isValid() {
|
||||
t.Error("basicConfig should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicConfig_IsValidWhenPasswordIsInvalidUsingBcrypt(t *testing.T) {
|
||||
basicConfig := &BasicConfig{
|
||||
Username: "admin",
|
||||
PasswordBcryptHashBase64Encoded: "",
|
||||
}
|
||||
if basicConfig.isValid() {
|
||||
t.Error("basicConfig shouldn't have been valid")
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,109 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/TwiN/g8"
|
||||
"github.com/gorilla/mux"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
cookieNameState = "gatus_state"
|
||||
cookieNameNonce = "gatus_nonce"
|
||||
cookieNameSession = "gatus_session"
|
||||
)
|
||||
|
||||
// Config is the security configuration for Gatus
|
||||
type Config struct {
|
||||
Basic *BasicConfig `yaml:"basic"`
|
||||
Basic *BasicConfig `yaml:"basic,omitempty"`
|
||||
OIDC *OIDCConfig `yaml:"oidc,omitempty"`
|
||||
|
||||
gate *g8.Gate
|
||||
}
|
||||
|
||||
// IsValid returns whether the security configuration is valid or not
|
||||
func (c *Config) IsValid() bool {
|
||||
return c.Basic != nil && c.Basic.IsValid()
|
||||
return (c.Basic != nil && c.Basic.isValid()) || (c.OIDC != nil && c.OIDC.isValid())
|
||||
}
|
||||
|
||||
// BasicConfig is the configuration for Basic authentication
|
||||
type BasicConfig struct {
|
||||
// Username is the name which will need to be used for a successful authentication
|
||||
Username string `yaml:"username"`
|
||||
|
||||
// PasswordSha512Hash is the SHA512 hash of the password which will need to be used for a successful authentication
|
||||
PasswordSha512Hash string `yaml:"password-sha512"`
|
||||
// RegisterHandlers registers all handlers required based on the security configuration
|
||||
func (c *Config) RegisterHandlers(router *mux.Router) error {
|
||||
if c.OIDC != nil {
|
||||
if err := c.OIDC.initialize(); err != nil {
|
||||
return err
|
||||
}
|
||||
router.HandleFunc("/oidc/login", c.OIDC.loginHandler)
|
||||
router.HandleFunc("/authorization-code/callback", c.OIDC.callbackHandler)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsValid returns whether the basic security configuration is valid or not
|
||||
func (c *BasicConfig) IsValid() bool {
|
||||
return len(c.Username) > 0 && len(c.PasswordSha512Hash) == 128
|
||||
// ApplySecurityMiddleware applies an authentication middleware to the router passed.
|
||||
// The router passed should be a subrouter in charge of handlers that require authentication.
|
||||
func (c *Config) ApplySecurityMiddleware(api *mux.Router) error {
|
||||
if c.OIDC != nil {
|
||||
// We're going to use g8 for session handling
|
||||
clientProvider := g8.NewClientProvider(func(token string) *g8.Client {
|
||||
if _, exists := sessions.Get(token); exists {
|
||||
return g8.NewClient(token)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
customTokenExtractorFunc := func(request *http.Request) string {
|
||||
sessionCookie, err := request.Cookie(cookieNameSession)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return sessionCookie.Value
|
||||
}
|
||||
// TODO: g8: Add a way to update cookie after? would need the writer
|
||||
authorizationService := g8.NewAuthorizationService().WithClientProvider(clientProvider)
|
||||
c.gate = g8.New().WithAuthorizationService(authorizationService).WithCustomTokenExtractor(customTokenExtractorFunc)
|
||||
api.Use(c.gate.Protect)
|
||||
} else if c.Basic != nil {
|
||||
var decodedBcryptHash []byte
|
||||
if len(c.Basic.PasswordBcryptHashBase64Encoded) > 0 {
|
||||
var err error
|
||||
decodedBcryptHash, err = base64.URLEncoding.DecodeString(c.Basic.PasswordBcryptHashBase64Encoded)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
api.Use(func(handler http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
usernameEntered, passwordEntered, ok := r.BasicAuth()
|
||||
if len(c.Basic.PasswordBcryptHashBase64Encoded) > 0 {
|
||||
if !ok || usernameEntered != c.Basic.Username || bcrypt.CompareHashAndPassword(decodedBcryptHash, []byte(passwordEntered)) != nil {
|
||||
w.Header().Set("WWW-Authenticate", "Basic")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte("Unauthorized"))
|
||||
return
|
||||
}
|
||||
} else if len(c.Basic.PasswordSha512Hash) > 0 {
|
||||
if !ok || usernameEntered != c.Basic.Username || Sha512(passwordEntered) != strings.ToLower(c.Basic.PasswordSha512Hash) {
|
||||
w.Header().Set("WWW-Authenticate", "Basic")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte("Unauthorized"))
|
||||
return
|
||||
}
|
||||
}
|
||||
handler.ServeHTTP(w, r)
|
||||
})
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsAuthenticated checks whether the user is authenticated
|
||||
// If the Config does not warrant authentication, it will always return true.
|
||||
func (c *Config) IsAuthenticated(r *http.Request) bool {
|
||||
if c.gate != nil {
|
||||
token := c.gate.ExtractTokenFromRequest(r)
|
||||
_, hasSession := sessions.Get(token)
|
||||
return hasSession
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,23 +1,148 @@
|
||||
package security
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
func TestBasicConfig_IsValid(t *testing.T) {
|
||||
basicConfig := &BasicConfig{
|
||||
Username: "admin",
|
||||
PasswordSha512Hash: Sha512("test"),
|
||||
"github.com/gorilla/mux"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
func TestConfig_IsValid(t *testing.T) {
|
||||
c := &Config{
|
||||
Basic: nil,
|
||||
OIDC: nil,
|
||||
}
|
||||
if !basicConfig.IsValid() {
|
||||
t.Error("basicConfig should've been valid")
|
||||
if c.IsValid() {
|
||||
t.Error("expected empty config to be valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicConfig_IsValidWhenPasswordIsInvalid(t *testing.T) {
|
||||
basicConfig := &BasicConfig{
|
||||
Username: "admin",
|
||||
PasswordSha512Hash: "",
|
||||
func TestConfig_ApplySecurityMiddleware(t *testing.T) {
|
||||
///////////
|
||||
// BASIC //
|
||||
///////////
|
||||
// SHA512 (DEPRECATED)
|
||||
c := &Config{Basic: &BasicConfig{
|
||||
Username: "john.doe",
|
||||
PasswordSha512Hash: "6b97ed68d14eb3f1aa959ce5d49c7dc612e1eb1dafd73b1e705847483fd6a6c809f2ceb4e8df6ff9984c6298ff0285cace6614bf8daa9f0070101b6c89899e22",
|
||||
}}
|
||||
api := mux.NewRouter()
|
||||
api.HandleFunc("/test", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
if err := c.ApplySecurityMiddleware(api); err != nil {
|
||||
t.Error("expected no error, but was", err)
|
||||
}
|
||||
if basicConfig.IsValid() {
|
||||
t.Error("basicConfig shouldn't have been valid")
|
||||
// Try to access the route without basic auth
|
||||
request, _ := http.NewRequest("GET", "/test", http.NoBody)
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
api.ServeHTTP(responseRecorder, request)
|
||||
if responseRecorder.Code != http.StatusUnauthorized {
|
||||
t.Error("expected code to be 401, but was", responseRecorder.Code)
|
||||
}
|
||||
// Try again, but with basic auth
|
||||
request, _ = http.NewRequest("GET", "/test", http.NoBody)
|
||||
responseRecorder = httptest.NewRecorder()
|
||||
request.SetBasicAuth("john.doe", "hunter2")
|
||||
api.ServeHTTP(responseRecorder, request)
|
||||
if responseRecorder.Code != http.StatusOK {
|
||||
t.Error("expected code to be 200, but was", responseRecorder.Code)
|
||||
}
|
||||
// Bcrypt
|
||||
c = &Config{Basic: &BasicConfig{
|
||||
Username: "john.doe",
|
||||
PasswordBcryptHashBase64Encoded: "JDJhJDA4JDFoRnpPY1hnaFl1OC9ISlFsa21VS09wOGlPU1ZOTDlHZG1qeTFvb3dIckRBUnlHUmNIRWlT",
|
||||
}}
|
||||
api = mux.NewRouter()
|
||||
api.HandleFunc("/test", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
if err := c.ApplySecurityMiddleware(api); err != nil {
|
||||
t.Error("expected no error, but was", err)
|
||||
}
|
||||
// Try to access the route without basic auth
|
||||
request, _ = http.NewRequest("GET", "/test", http.NoBody)
|
||||
responseRecorder = httptest.NewRecorder()
|
||||
api.ServeHTTP(responseRecorder, request)
|
||||
if responseRecorder.Code != http.StatusUnauthorized {
|
||||
t.Error("expected code to be 401, but was", responseRecorder.Code)
|
||||
}
|
||||
// Try again, but with basic auth
|
||||
request, _ = http.NewRequest("GET", "/test", http.NoBody)
|
||||
responseRecorder = httptest.NewRecorder()
|
||||
request.SetBasicAuth("john.doe", "hunter2")
|
||||
api.ServeHTTP(responseRecorder, request)
|
||||
if responseRecorder.Code != http.StatusOK {
|
||||
t.Error("expected code to be 200, but was", responseRecorder.Code)
|
||||
}
|
||||
//////////
|
||||
// OIDC //
|
||||
//////////
|
||||
api = mux.NewRouter()
|
||||
api.HandleFunc("/test", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
c.OIDC = &OIDCConfig{
|
||||
IssuerURL: "https://sso.gatus.io/",
|
||||
RedirectURL: "http://localhost:80/authorization-code/callback",
|
||||
Scopes: []string{"openid"},
|
||||
AllowedSubjects: []string{"user1@example.com"},
|
||||
oauth2Config: oauth2.Config{},
|
||||
verifier: nil,
|
||||
}
|
||||
c.Basic = nil
|
||||
if err := c.ApplySecurityMiddleware(api); err != nil {
|
||||
t.Error("expected no error, but was", err)
|
||||
}
|
||||
// Try without any session cookie
|
||||
request, _ = http.NewRequest("GET", "/test", http.NoBody)
|
||||
responseRecorder = httptest.NewRecorder()
|
||||
api.ServeHTTP(responseRecorder, request)
|
||||
if responseRecorder.Code != http.StatusUnauthorized {
|
||||
t.Error("expected code to be 401, but was", responseRecorder.Code)
|
||||
}
|
||||
// Try with a session cookie
|
||||
request, _ = http.NewRequest("GET", "/test", http.NoBody)
|
||||
request.AddCookie(&http.Cookie{Name: "session", Value: "123"})
|
||||
responseRecorder = httptest.NewRecorder()
|
||||
api.ServeHTTP(responseRecorder, request)
|
||||
if responseRecorder.Code != http.StatusUnauthorized {
|
||||
t.Error("expected code to be 401, but was", responseRecorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_RegisterHandlers(t *testing.T) {
|
||||
c := &Config{}
|
||||
router := mux.NewRouter()
|
||||
c.RegisterHandlers(router)
|
||||
// Try to access the OIDC handler. This should fail, because the security config doesn't have OIDC
|
||||
request, _ := http.NewRequest("GET", "/oidc/login", http.NoBody)
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(responseRecorder, request)
|
||||
if responseRecorder.Code != http.StatusNotFound {
|
||||
t.Error("expected code to be 404, but was", responseRecorder.Code)
|
||||
}
|
||||
// Set an empty OIDC config. This should fail, because the IssuerURL is required.
|
||||
c.OIDC = &OIDCConfig{}
|
||||
if err := c.RegisterHandlers(router); err == nil {
|
||||
t.Fatal("expected an error, but got none")
|
||||
}
|
||||
// Set the OIDC config and try again
|
||||
c.OIDC = &OIDCConfig{
|
||||
IssuerURL: "https://sso.gatus.io/",
|
||||
RedirectURL: "http://localhost:80/authorization-code/callback",
|
||||
Scopes: []string{"openid"},
|
||||
AllowedSubjects: []string{"user1@example.com"},
|
||||
}
|
||||
if err := c.RegisterHandlers(router); err != nil {
|
||||
t.Fatal("expected no error, but got", err)
|
||||
}
|
||||
request, _ = http.NewRequest("GET", "/oidc/login", http.NoBody)
|
||||
responseRecorder = httptest.NewRecorder()
|
||||
router.ServeHTTP(responseRecorder, request)
|
||||
if responseRecorder.Code != http.StatusFound {
|
||||
t.Error("expected code to be 302, but was", responseRecorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Handler takes care of security for a given handler with the given security configuration
|
||||
func Handler(handler http.HandlerFunc, security *Config) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
usernameEntered, passwordEntered, ok := r.BasicAuth()
|
||||
if !ok || usernameEntered != security.Basic.Username || Sha512(passwordEntered) != strings.ToLower(security.Basic.PasswordSha512Hash) {
|
||||
w.Header().Set("WWW-Authenticate", "Basic")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte("Unauthorized"))
|
||||
return
|
||||
}
|
||||
handler(w, r)
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func mockHandler(writer http.ResponseWriter, _ *http.Request) {
|
||||
writer.WriteHeader(200)
|
||||
}
|
||||
|
||||
func TestHandlerWhenNotAuthenticated(t *testing.T) {
|
||||
handler := Handler(mockHandler, &Config{&BasicConfig{
|
||||
Username: "john.doe",
|
||||
PasswordSha512Hash: "6b97ed68d14eb3f1aa959ce5d49c7dc612e1eb1dafd73b1e705847483fd6a6c809f2ceb4e8df6ff9984c6298ff0285cace6614bf8daa9f0070101b6c89899e22",
|
||||
}})
|
||||
request, _ := http.NewRequest("GET", "/api/v1/results", nil)
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(responseRecorder, request)
|
||||
|
||||
if responseRecorder.Code != http.StatusUnauthorized {
|
||||
t.Error("Expected code to be 401, but was", responseRecorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerWhenAuthenticated(t *testing.T) {
|
||||
handler := Handler(mockHandler, &Config{&BasicConfig{
|
||||
Username: "john.doe",
|
||||
PasswordSha512Hash: "6b97ed68d14eb3f1aa959ce5d49c7dc612e1eb1dafd73b1e705847483fd6a6c809f2ceb4e8df6ff9984c6298ff0285cace6614bf8daa9f0070101b6c89899e22",
|
||||
}})
|
||||
request, _ := http.NewRequest("GET", "/api/v1/results", nil)
|
||||
request.SetBasicAuth("john.doe", "hunter2")
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(responseRecorder, request)
|
||||
|
||||
if responseRecorder.Code != http.StatusOK {
|
||||
t.Error("Expected code to be 200, but was", responseRecorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerWhenAuthenticatedWithBadCredentials(t *testing.T) {
|
||||
handler := Handler(mockHandler, &Config{&BasicConfig{
|
||||
Username: "john.doe",
|
||||
PasswordSha512Hash: "6b97ed68d14eb3f1aa959ce5d49c7dc612e1eb1dafd73b1e705847483fd6a6c809f2ceb4e8df6ff9984c6298ff0285cace6614bf8daa9f0070101b6c89899e22",
|
||||
}})
|
||||
request, _ := http.NewRequest("GET", "/api/v1/results", nil)
|
||||
request.SetBasicAuth("john.doe", "bad-password")
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(responseRecorder, request)
|
||||
|
||||
if responseRecorder.Code != http.StatusUnauthorized {
|
||||
t.Error("Expected code to be 401, but was", responseRecorder.Code)
|
||||
}
|
||||
}
|
||||
141
security/oidc.go
Normal file
141
security/oidc.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// OIDCConfig is the configuration for OIDC authentication
|
||||
type OIDCConfig struct {
|
||||
IssuerURL string `yaml:"issuer-url"` // e.g. https://dev-12345678.okta.com
|
||||
RedirectURL string `yaml:"redirect-url"` // e.g. http://localhost:8080/authorization-code/callback
|
||||
ClientID string `yaml:"client-id"`
|
||||
ClientSecret string `yaml:"client-secret"`
|
||||
Scopes []string `yaml:"scopes"` // e.g. ["openid"]
|
||||
AllowedSubjects []string `yaml:"allowed-subjects"` // e.g. ["user1@example.com"]. If empty, all subjects are allowed
|
||||
|
||||
oauth2Config oauth2.Config
|
||||
verifier *oidc.IDTokenVerifier
|
||||
}
|
||||
|
||||
// isValid returns whether the basic security configuration is valid or not
|
||||
func (c *OIDCConfig) isValid() bool {
|
||||
return len(c.IssuerURL) > 0 && len(c.RedirectURL) > 0 && strings.HasSuffix(c.RedirectURL, "/authorization-code/callback") && len(c.ClientID) > 0 && len(c.ClientSecret) > 0 && len(c.Scopes) > 0
|
||||
}
|
||||
|
||||
func (c *OIDCConfig) initialize() error {
|
||||
provider, err := oidc.NewProvider(context.Background(), c.IssuerURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.verifier = provider.Verifier(&oidc.Config{ClientID: c.ClientID})
|
||||
// Configure an OpenID Connect aware OAuth2 client.
|
||||
c.oauth2Config = oauth2.Config{
|
||||
ClientID: c.ClientID,
|
||||
ClientSecret: c.ClientSecret,
|
||||
Scopes: c.Scopes,
|
||||
RedirectURL: c.RedirectURL,
|
||||
Endpoint: provider.Endpoint(),
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *OIDCConfig) loginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
state, nonce := uuid.NewString(), uuid.NewString()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieNameState,
|
||||
Value: state,
|
||||
Path: "/",
|
||||
MaxAge: int(time.Hour.Seconds()),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
HttpOnly: true,
|
||||
})
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieNameNonce,
|
||||
Value: nonce,
|
||||
Path: "/",
|
||||
MaxAge: int(time.Hour.Seconds()),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
HttpOnly: true,
|
||||
})
|
||||
http.Redirect(w, r, c.oauth2Config.AuthCodeURL(state, oidc.Nonce(nonce)), http.StatusFound)
|
||||
}
|
||||
|
||||
func (c *OIDCConfig) callbackHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// Check if there's an error
|
||||
if len(r.URL.Query().Get("error")) > 0 {
|
||||
http.Error(w, r.URL.Query().Get("error")+": "+r.URL.Query().Get("error_description"), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Ensure that the state has the expected value
|
||||
state, err := r.Cookie(cookieNameState)
|
||||
if err != nil {
|
||||
http.Error(w, "state not found", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("state") != state.Value {
|
||||
http.Error(w, "state did not match", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Validate token
|
||||
oauth2Token, err := c.oauth2Config.Exchange(r.Context(), r.URL.Query().Get("code"))
|
||||
if err != nil {
|
||||
http.Error(w, "Error exchanging token: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
|
||||
if !ok {
|
||||
http.Error(w, "Missing 'id_token' in oauth2 token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
idToken, err := c.verifier.Verify(r.Context(), rawIDToken)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to verify id_token: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Validate nonce
|
||||
nonce, err := r.Cookie(cookieNameNonce)
|
||||
if err != nil {
|
||||
http.Error(w, "nonce not found", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if idToken.Nonce != nonce.Value {
|
||||
http.Error(w, "nonce did not match", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(c.AllowedSubjects) == 0 {
|
||||
// If there's no allowed subjects, all subjects are allowed.
|
||||
c.setSessionCookie(w, idToken)
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
for _, subject := range c.AllowedSubjects {
|
||||
if strings.ToLower(subject) == strings.ToLower(idToken.Subject) {
|
||||
c.setSessionCookie(w, idToken)
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
log.Printf("[security][callbackHandler] Subject %s is not in the list of allowed subjects", idToken.Subject)
|
||||
http.Redirect(w, r, "/?error=access_denied", http.StatusFound)
|
||||
}
|
||||
|
||||
func (c *OIDCConfig) setSessionCookie(w http.ResponseWriter, idToken *oidc.IDToken) {
|
||||
// At this point, the user has been confirmed. All that's left to do is create a session.
|
||||
sessionID := uuid.NewString()
|
||||
sessions.SetWithTTL(sessionID, idToken.Subject, time.Hour)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieNameSession,
|
||||
Value: sessionID,
|
||||
Path: "/",
|
||||
MaxAge: int(time.Hour.Seconds()),
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
70
security/oidc_test.go
Normal file
70
security/oidc_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
)
|
||||
|
||||
func TestOIDCConfig_isValid(t *testing.T) {
|
||||
c := &OIDCConfig{
|
||||
IssuerURL: "https://sso.gatus.io/",
|
||||
RedirectURL: "http://localhost:80/authorization-code/callback",
|
||||
ClientID: "client-id",
|
||||
ClientSecret: "client-secret",
|
||||
Scopes: []string{"openid"},
|
||||
AllowedSubjects: []string{"user1@example.com"},
|
||||
}
|
||||
if !c.isValid() {
|
||||
t.Error("OIDCConfig should be valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCConfig_callbackHandler(t *testing.T) {
|
||||
c := &OIDCConfig{
|
||||
IssuerURL: "https://sso.gatus.io/",
|
||||
RedirectURL: "http://localhost:80/authorization-code/callback",
|
||||
ClientID: "client-id",
|
||||
ClientSecret: "client-secret",
|
||||
Scopes: []string{"openid"},
|
||||
AllowedSubjects: []string{"user1@example.com"},
|
||||
}
|
||||
if err := c.initialize(); err != nil {
|
||||
t.Fatal("expected no error, but got", err)
|
||||
}
|
||||
// Try with no state cookie
|
||||
request, _ := http.NewRequest("GET", "/authorization-code/callback", nil)
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
c.callbackHandler(responseRecorder, request)
|
||||
if responseRecorder.Code != http.StatusBadRequest {
|
||||
t.Error("expected code to be 400, but was", responseRecorder.Code)
|
||||
}
|
||||
// Try with state cookie
|
||||
request, _ = http.NewRequest("GET", "/authorization-code/callback", nil)
|
||||
request.AddCookie(&http.Cookie{Name: cookieNameState, Value: "fake-state"})
|
||||
responseRecorder = httptest.NewRecorder()
|
||||
c.callbackHandler(responseRecorder, request)
|
||||
if responseRecorder.Code != http.StatusBadRequest {
|
||||
t.Error("expected code to be 400, but was", responseRecorder.Code)
|
||||
}
|
||||
// Try with state cookie and state query parameter
|
||||
request, _ = http.NewRequest("GET", "/authorization-code/callback?state=fake-state", nil)
|
||||
request.AddCookie(&http.Cookie{Name: cookieNameState, Value: "fake-state"})
|
||||
responseRecorder = httptest.NewRecorder()
|
||||
c.callbackHandler(responseRecorder, request)
|
||||
// Exchange should fail, so 500.
|
||||
if responseRecorder.Code != http.StatusInternalServerError {
|
||||
t.Error("expected code to be 500, but was", responseRecorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCConfig_setSessionCookie(t *testing.T) {
|
||||
c := &OIDCConfig{}
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
c.setSessionCookie(responseRecorder, &oidc.IDToken{Subject: "test@example.com"})
|
||||
if len(responseRecorder.Result().Cookies()) == 0 {
|
||||
t.Error("expected cookie to be set")
|
||||
}
|
||||
}
|
||||
5
security/sessions.go
Normal file
5
security/sessions.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package security
|
||||
|
||||
import "github.com/TwiN/gocache/v2"
|
||||
|
||||
var sessions = gocache.NewCache().WithEvictionPolicy(gocache.LeastRecentlyUsed) // TODO: Move this to storage
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
)
|
||||
|
||||
// Sha512 hashes a provided string using SHA512 and returns the resulting hash as a string
|
||||
// Deprecated: Use bcrypt instead
|
||||
func Sha512(s string) string {
|
||||
hash := sha512.New()
|
||||
hash.Write([]byte(s))
|
||||
|
||||
@@ -16,8 +16,6 @@ type Config struct {
|
||||
// Path is the path used by the store to achieve persistence
|
||||
// If blank, persistence is disabled.
|
||||
// Note that not all Type support persistence
|
||||
//
|
||||
// XXX: Rename to path for v4.0.0
|
||||
Path string `yaml:"path"`
|
||||
|
||||
// File is the path of the file to use for persistence
|
||||
|
||||
@@ -3,8 +3,8 @@ package memory
|
||||
import (
|
||||
"encoding/gob"
|
||||
"io/fs"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -51,12 +51,12 @@ func NewStore(file string) (*Store, error) {
|
||||
_, err := store.cache.ReadFromFile(file)
|
||||
if err != nil {
|
||||
// XXX: Remove the block below in v4.0.0
|
||||
if data, err2 := ioutil.ReadFile(file); err2 == nil {
|
||||
if data, err2 := os.ReadFile(file); err2 == nil {
|
||||
isFromOldVersion := strings.Contains(string(data), "*core.ServiceStatus")
|
||||
if isFromOldVersion {
|
||||
log.Println("WARNING: Couldn't read file due to recent change in v3.3.0, see https://github.com/TwiN/gatus/issues/191")
|
||||
log.Println("WARNING: Will automatically rename old file to " + file + ".old and overwrite the current file")
|
||||
if err = ioutil.WriteFile(file+".old", data, fs.ModePerm); err != nil {
|
||||
if err = os.WriteFile(file+".old", data, fs.ModePerm); err != nil {
|
||||
log.Println("WARNING: Tried my best to keep the old file, but it wasn't enough. Sorry, your file will be overwritten :(")
|
||||
}
|
||||
// Return the store regardless of whether there was an error or not
|
||||
|
||||
9
test/mock.go
Normal file
9
test/mock.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package test
|
||||
|
||||
import "net/http"
|
||||
|
||||
type MockRoundTripper func(r *http.Request) *http.Response
|
||||
|
||||
func (f MockRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
return f(r), nil
|
||||
}
|
||||
@@ -3,8 +3,8 @@ package util
|
||||
import "strings"
|
||||
|
||||
// ConvertGroupAndEndpointNameToKey converts a group and an endpoint to a key
|
||||
func ConvertGroupAndEndpointNameToKey(group, endpoint string) string {
|
||||
return sanitize(group) + "_" + sanitize(endpoint)
|
||||
func ConvertGroupAndEndpointNameToKey(groupName, endpointName string) string {
|
||||
return sanitize(groupName) + "_" + sanitize(endpointName)
|
||||
}
|
||||
|
||||
func sanitize(s string) string {
|
||||
|
||||
1
vendor/github.com/TwiN/g8/.gitattributes
generated
vendored
Normal file
1
vendor/github.com/TwiN/g8/.gitattributes
generated
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=lf
|
||||
2
vendor/github.com/TwiN/g8/.gitignore
generated
vendored
Normal file
2
vendor/github.com/TwiN/g8/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
.idea
|
||||
*.iml
|
||||
9
vendor/github.com/TwiN/g8/LICENSE.md
generated
vendored
Normal file
9
vendor/github.com/TwiN/g8/LICENSE.md
generated
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 TwiN
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
238
vendor/github.com/TwiN/g8/README.md
generated
vendored
Normal file
238
vendor/github.com/TwiN/g8/README.md
generated
vendored
Normal file
@@ -0,0 +1,238 @@
|
||||
# g8
|
||||
|
||||

|
||||
[](https://goreportcard.com/report/github.com/TwiN/g8)
|
||||
[](https://codecov.io/gh/TwiN/g8)
|
||||
[](https://github.com/TwiN/g8)
|
||||
[](https://pkg.go.dev/github.com/TwiN/g8)
|
||||
[](https://github.com/TwiN)
|
||||
|
||||
g8, pronounced gate, is a simple Go library for protecting HTTP handlers.
|
||||
|
||||
Tired of constantly re-implementing a security layer for each application? Me too, that's why I made g8.
|
||||
|
||||
|
||||
## Installation
|
||||
```console
|
||||
go get -u github.com/TwiN/g8
|
||||
```
|
||||
|
||||
|
||||
## Usage
|
||||
Because the entire purpose of g8 is to NOT waste time configuring the layer of security, the primary emphasis is to
|
||||
keep it as simple as possible.
|
||||
|
||||
|
||||
### Simple
|
||||
Just want a simple layer of security without the need for advanced permissions? This configuration is what you're
|
||||
looking for.
|
||||
|
||||
```go
|
||||
authorizationService := g8.NewAuthorizationService().WithToken("mytoken")
|
||||
gate := g8.New().WithAuthorizationService(authorizationService)
|
||||
|
||||
router := http.NewServeMux()
|
||||
router.Handle("/unprotected", yourHandler)
|
||||
router.Handle("/protected", gate.Protect(yourHandler))
|
||||
|
||||
http.ListenAndServe(":8080", router)
|
||||
```
|
||||
|
||||
The endpoint `/protected` is now only accessible if you pass the header `Authorization: Bearer mytoken`.
|
||||
|
||||
If you use `http.HandleFunc` instead of `http.Handle`, you may use `gate.ProtectFunc(yourHandler)` instead.
|
||||
|
||||
If you're not using the `Authorization` header, you can specify a custom token extractor.
|
||||
This enables use cases like [Protecting a handler using session cookie](#protecting-a-handler-using-session-cookie)
|
||||
|
||||
|
||||
### Advanced permissions
|
||||
If you have tokens with more permissions than others, g8's permission system will make managing authorization a breeze.
|
||||
|
||||
Rather than registering tokens, think of it as registering clients, the only difference being that clients may be
|
||||
configured with permissions while tokens cannot.
|
||||
|
||||
```go
|
||||
authorizationService := g8.NewAuthorizationService().WithClient(g8.NewClient("mytoken").WithPermission("admin"))
|
||||
gate := g8.New().WithAuthorizationService(authorizationService)
|
||||
|
||||
router := http.NewServeMux()
|
||||
router.Handle("/unprotected", yourHandler)
|
||||
router.Handle("/protected-with-admin", gate.ProtectWithPermissions(yourHandler, []string{"admin"}))
|
||||
|
||||
http.ListenAndServe(":8080", router)
|
||||
```
|
||||
|
||||
The endpoint `/protected-with-admin` is now only accessible if you pass the header `Authorization: Bearer mytoken`,
|
||||
because the client with the token `mytoken` has the permission `admin`. Note that the following handler would also be
|
||||
accessible with that token:
|
||||
```go
|
||||
router.Handle("/protected", gate.Protect(yourHandler))
|
||||
```
|
||||
|
||||
To clarify, both clients and tokens have access to handlers that aren't protected with extra permissions, and
|
||||
essentially, tokens are registered as clients with no extra permissions in the background.
|
||||
|
||||
Creating a token like so:
|
||||
```go
|
||||
authorizationService := g8.NewAuthorizationService().WithToken("mytoken")
|
||||
```
|
||||
is the equivalent of creating the following client:
|
||||
```go
|
||||
authorizationService := g8.NewAuthorizationService().WithClient(g8.NewClient("mytoken"))
|
||||
```
|
||||
|
||||
|
||||
### With client provider
|
||||
A client provider's task is to retrieve a Client from an external source (e.g. a database) when provided with a token.
|
||||
You should use a client provider when you have a lot of tokens and it wouldn't make sense to register all of them using
|
||||
`AuthorizationService`'s `WithToken`/`WithTokens`/`WithClient`/`WithClients`.
|
||||
|
||||
Note that the provider is used as a fallback source. As such, if a token is explicitly registered using one of the 4
|
||||
aforementioned functions, the client provider will not be used.
|
||||
|
||||
```go
|
||||
clientProvider := g8.NewClientProvider(func(token string) *g8.Client {
|
||||
// We'll assume that the following function calls your database and returns a struct "User" that
|
||||
// has the user's token as well as the permissions granted to said user
|
||||
user := database.GetUserByToken(token)
|
||||
if user != nil {
|
||||
return g8.NewClient(user.Token).WithPermissions(user.Permissions)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
authorizationService := g8.NewAuthorizationService().WithClientProvider(clientProvider)
|
||||
gate := g8.New().WithAuthorizationService(authorizationService)
|
||||
```
|
||||
|
||||
You can also configure the client provider to cache the output of the function you provide to retrieve clients by token:
|
||||
```go
|
||||
clientProvider := g8.NewClientProvider(...).WithCache(ttl, maxSize)
|
||||
```
|
||||
|
||||
Since g8 leverages [TwiN/gocache](https://github.com/TwiN/gocache), you can also use gocache's
|
||||
constants for configuring the TTL and the maximum size:
|
||||
- Setting the TTL to `gocache.NoExpiration` (-1) will disable the TTL.
|
||||
- Setting the maximum size to `gocache.NoMaxSize` (0) will disable the maximum cache size
|
||||
|
||||
If you're using a TTL and have a lot of tokens (100k+), you may want to use `clientProvider.StartJanitor()` to allow
|
||||
the cache to passively delete expired entries. If you have to re-initialize the client provider after the janitor has
|
||||
been started, make sure to stop the janitor first (`clientProvider.StopJanitor()`). This is because the janitor runs on
|
||||
a separate goroutine, thus, if you were to re-create a client provider and re-assign it, the old client provider would
|
||||
still exist in memory with the old cache. I'm only specifying this for completeness, because for the overwhelming
|
||||
majority of people, the gate will be created on application start and never modified again until the application shuts
|
||||
down, in which case, you don't even need to worry about stopping the janitor.
|
||||
|
||||
To avoid any misunderstandings, using a client provider is not mandatory. If you only have a few tokens and you can load
|
||||
them on application start, you can just leverage `AuthorizationService`'s `WithToken`/`WithTokens`/`WithClient`/`WithClients`.
|
||||
|
||||
|
||||
## AuthorizationService
|
||||
As the previous examples may have hinted, there are several ways to create clients. The one thing they have
|
||||
in common is that they all go through AuthorizationService, which is in charge of both managing clients and determining
|
||||
whether a request should be blocked or allowed through.
|
||||
|
||||
| Function | Description |
|
||||
|:-------------------|:---------------------------------------------------------------------------------------------------------------------------------|
|
||||
| WithToken | Creates a single static client with no extra permissions |
|
||||
| WithTokens | Creates a slice of static clients with no extra permissions |
|
||||
| WithClient | Creates a single static client |
|
||||
| WithClients | Creates a slice of static clients |
|
||||
| WithClientProvider | Creates a client provider which will allow a fallback to a dynamic source (e.g. to a database) when a static client is not found |
|
||||
|
||||
Except for `WithClientProvider`, every functions listed above can be called more than once.
|
||||
As a result, you may safely perform actions like this:
|
||||
```go
|
||||
authorizationService := g8.NewAuthorizationService().
|
||||
WithToken("123").
|
||||
WithToken("456").
|
||||
WithClient(g8.NewClient("789").WithPermission("admin"))
|
||||
gate := g8.New().WithAuthorizationService(authorizationService)
|
||||
```
|
||||
|
||||
Be aware that g8.Client supports a list of permissions as well. You may call `WithPermission` several times, or call
|
||||
`WithPermissions` with a slice of permissions instead.
|
||||
|
||||
|
||||
### Permissions
|
||||
Unlike client permissions, handler permissions are requirements.
|
||||
|
||||
A client may have as many permissions as you want, but for said client to have access to a handler protected by
|
||||
permissions, the client must have all permissions defined by said handler in order to have access to it.
|
||||
|
||||
In other words, a client with the permissions `create`, `read`, `update` and `delete` would have access to all of these handlers:
|
||||
```go
|
||||
gate := g8.New().WithAuthorizationService(g8.NewAuthorizationService().WithClient(g8.NewClient("mytoken").WithPermissions([]string{"create", "read", "update", "delete"})))
|
||||
router := http.NewServeMux()
|
||||
router.Handle("/", gate.Protect(homeHandler)) // equivalent of gate.ProtectWithPermissions(homeHandler, []string{})
|
||||
router.Handle("/create", gate.ProtectWithPermissions(createHandler, []string{"create"}))
|
||||
router.Handle("/read", gate.ProtectWithPermissions(readHandler, []string{"read"}))
|
||||
router.Handle("/update", gate.ProtectWithPermissions(updateHandler, []string{"update"}))
|
||||
router.Handle("/delete", gate.ProtectWithPermissions(deleteHandler, []string{"delete"}))
|
||||
router.Handle("/crud", gate.ProtectWithPermissions(crudHandler, []string{"create", "read", "update", "delete"}))
|
||||
```
|
||||
But it would not have access to the following handler, because while `mytoken` has the `read` permission, it does not
|
||||
have the `backup` permission:
|
||||
```go
|
||||
router.Handle("/backup", gate.ProtectWithPermissions(&testHandler{}, []string{"read", "backup"}))
|
||||
```
|
||||
|
||||
## Rate limiting
|
||||
To add a rate limit of 100 requests per second:
|
||||
```
|
||||
gate := g8.New().WithRateLimit(100)
|
||||
```
|
||||
|
||||
## Special use cases
|
||||
### Protecting a handler using session cookie
|
||||
If you want to only allow authenticated users to access a handler, you can use a custom token extractor function
|
||||
combined with a client provider.
|
||||
|
||||
First, we'll create a function to extract the session ID from the session cookie. While a session ID does not
|
||||
theoretically refer to a token, g8 uses the term `token` as a blanket term to refer to any string that can be used to
|
||||
identify a client.
|
||||
```go
|
||||
customTokenExtractorFunc := func(request *http.Request) string {
|
||||
sessionCookie, err := request.Cookie("session")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return sessionCookie.Value
|
||||
}
|
||||
```
|
||||
|
||||
Next, we need to create a client provider that will validate our token, which refers to the session ID in this case.
|
||||
```go
|
||||
clientProvider := g8.NewClientProvider(func(token string) *g8.Client {
|
||||
// We'll assume that the following function calls your database and validates whether the session is valid.
|
||||
isSessionValid := database.CheckIfSessionIsValid(token)
|
||||
if !isSessionValid {
|
||||
return nil // Returning nil will cause the gate to return a 401 Unauthorized.
|
||||
}
|
||||
// You could also retrieve the user and their permissions if you wanted instead, but for this example,
|
||||
// all we care about is confirming whether the session is valid or not.
|
||||
return g8.NewClient(token)
|
||||
})
|
||||
```
|
||||
|
||||
Keep in mind that you can get really creative with the client provider above.
|
||||
For instance, you could refresh the session's expiration time, which will allow the user to stay logged in for
|
||||
as long as they're active.
|
||||
|
||||
You're also not limited to using something stateful like the example above. You could use a JWT and have your client
|
||||
provider validate said JWT.
|
||||
|
||||
Finally, we can create the authorization service and the gate:
|
||||
```go
|
||||
authorizationService := g8.NewAuthorizationService().WithClientProvider(clientProvider)
|
||||
gate := g8.New().WithAuthorizationService(authorizationService).WithCustomTokenExtractor(customTokenExtractorFunc)
|
||||
```
|
||||
|
||||
If you need to access the token (session ID in this case) from the protected handlers, you can retrieve it from the
|
||||
request context by using the key `g8.TokenContextKey`:
|
||||
```go
|
||||
http.Handle("/handle", gate.ProtectFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID, _ := r.Context().Value(g8.TokenContextKey).(string)
|
||||
// ...
|
||||
}))
|
||||
```
|
||||
122
vendor/github.com/TwiN/g8/authorization.go
generated
vendored
Normal file
122
vendor/github.com/TwiN/g8/authorization.go
generated
vendored
Normal file
@@ -0,0 +1,122 @@
|
||||
package g8
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// AuthorizationService is the service that manages client/token registry and client fallback as well as the service
|
||||
// that determines whether a token meets the specific requirements to be authorized by a Gate or not.
|
||||
type AuthorizationService struct {
|
||||
clients map[string]*Client
|
||||
clientProvider *ClientProvider
|
||||
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewAuthorizationService creates a new AuthorizationService
|
||||
func NewAuthorizationService() *AuthorizationService {
|
||||
return &AuthorizationService{
|
||||
clients: make(map[string]*Client),
|
||||
}
|
||||
}
|
||||
|
||||
// WithToken is used to specify a single token for which authorization will be granted
|
||||
//
|
||||
// The client that will be created from this token will have access to all handlers that are not protected with a
|
||||
// specific permission.
|
||||
//
|
||||
// In other words, if you were to do the following:
|
||||
// gate := g8.New().WithAuthorizationService(g8.NewAuthorizationService().WithToken("12345"))
|
||||
//
|
||||
// The following handler would be accessible with the token 12345:
|
||||
// router.Handle("/1st-handler", gate.Protect(yourHandler))
|
||||
//
|
||||
// But not this one would not be accessible with the token 12345:
|
||||
// router.Handle("/2nd-handler", gate.ProtectWithPermissions(yourOtherHandler, []string{"admin"}))
|
||||
//
|
||||
// Calling this function multiple times will add multiple clients, though you may want to use WithTokens instead
|
||||
// if you plan to add multiple clients
|
||||
//
|
||||
// If you wish to configure advanced permissions, consider using WithClient instead.
|
||||
//
|
||||
func (authorizationService *AuthorizationService) WithToken(token string) *AuthorizationService {
|
||||
authorizationService.mutex.Lock()
|
||||
authorizationService.clients[token] = NewClient(token)
|
||||
authorizationService.mutex.Unlock()
|
||||
return authorizationService
|
||||
}
|
||||
|
||||
// WithTokens is used to specify a slice of tokens for which authorization will be granted
|
||||
func (authorizationService *AuthorizationService) WithTokens(tokens []string) *AuthorizationService {
|
||||
authorizationService.mutex.Lock()
|
||||
for _, token := range tokens {
|
||||
authorizationService.clients[token] = NewClient(token)
|
||||
}
|
||||
authorizationService.mutex.Unlock()
|
||||
return authorizationService
|
||||
}
|
||||
|
||||
// WithClient is used to specify a single client for which authorization will be granted
|
||||
//
|
||||
// When compared to WithToken, the advantage of using this function is that you may specify the client's
|
||||
// permissions and thus, be a lot more granular with what endpoint a token has access to.
|
||||
//
|
||||
// In other words, if you were to do the following:
|
||||
// gate := g8.New().WithAuthorizationService(g8.NewAuthorizationService().WithClient(g8.NewClient("12345").WithPermission("mod")))
|
||||
//
|
||||
// The following handlers would be accessible with the token 12345:
|
||||
// router.Handle("/1st-handler", gate.ProtectWithPermissions(yourHandler, []string{"mod"}))
|
||||
// router.Handle("/2nd-handler", gate.Protect(yourOtherHandler))
|
||||
//
|
||||
// But not this one, because the user does not have the permission "admin":
|
||||
// router.Handle("/3rd-handler", gate.ProtectWithPermissions(yetAnotherHandler, []string{"admin"}))
|
||||
//
|
||||
// Calling this function multiple times will add multiple clients, though you may want to use WithClients instead
|
||||
// if you plan to add multiple clients
|
||||
func (authorizationService *AuthorizationService) WithClient(client *Client) *AuthorizationService {
|
||||
authorizationService.mutex.Lock()
|
||||
authorizationService.clients[client.Token] = client
|
||||
authorizationService.mutex.Unlock()
|
||||
return authorizationService
|
||||
}
|
||||
|
||||
// WithClients is used to specify a slice of clients for which authorization will be granted
|
||||
func (authorizationService *AuthorizationService) WithClients(clients []*Client) *AuthorizationService {
|
||||
authorizationService.mutex.Lock()
|
||||
for _, client := range clients {
|
||||
authorizationService.clients[client.Token] = client
|
||||
}
|
||||
authorizationService.mutex.Unlock()
|
||||
return authorizationService
|
||||
}
|
||||
|
||||
// WithClientProvider allows specifying a custom provider to fetch clients by token.
|
||||
//
|
||||
// For example, you can use it to fallback to making a call in your database when a request is made with a token that
|
||||
// hasn't been specified via WithToken, WithTokens, WithClient or WithClients.
|
||||
func (authorizationService *AuthorizationService) WithClientProvider(provider *ClientProvider) *AuthorizationService {
|
||||
authorizationService.clientProvider = provider
|
||||
return authorizationService
|
||||
}
|
||||
|
||||
// IsAuthorized checks whether a client with a given token exists and has the permissions required.
|
||||
//
|
||||
// If permissionsRequired is nil or empty and a client with the given token exists, said client will have access to all
|
||||
// handlers that are not protected by a given permission.
|
||||
func (authorizationService *AuthorizationService) IsAuthorized(token string, permissionsRequired []string) bool {
|
||||
if len(token) == 0 {
|
||||
return false
|
||||
}
|
||||
authorizationService.mutex.RLock()
|
||||
client, _ := authorizationService.clients[token]
|
||||
authorizationService.mutex.RUnlock()
|
||||
// If there's no clients with the given token directly stored in the AuthorizationService, fall back to the
|
||||
// client provider, if there's one configured.
|
||||
if client == nil && authorizationService.clientProvider != nil {
|
||||
client = authorizationService.clientProvider.GetClientByToken(token)
|
||||
}
|
||||
if client != nil {
|
||||
return client.HasPermissions(permissionsRequired)
|
||||
}
|
||||
return false
|
||||
}
|
||||
58
vendor/github.com/TwiN/g8/client.go
generated
vendored
Normal file
58
vendor/github.com/TwiN/g8/client.go
generated
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
package g8
|
||||
|
||||
// Client is a struct containing both a Token and a slice of extra Permissions that said token has.
|
||||
type Client struct {
|
||||
// Token is the value used to authenticate with the API.
|
||||
Token string
|
||||
|
||||
// Permissions is a slice of extra permissions that may be used for more granular access control.
|
||||
//
|
||||
// If you only wish to use Gate.Protect and Gate.ProtectFunc, you do not have to worry about this,
|
||||
// since they're only used by Gate.ProtectWithPermissions and Gate.ProtectFuncWithPermissions
|
||||
Permissions []string
|
||||
}
|
||||
|
||||
// NewClient creates a Client with a given token
|
||||
func NewClient(token string) *Client {
|
||||
return &Client{
|
||||
Token: token,
|
||||
}
|
||||
}
|
||||
|
||||
// NewClientWithPermissions creates a Client with a slice of permissions
|
||||
// Equivalent to using NewClient and WithPermissions
|
||||
func NewClientWithPermissions(token string, permissions []string) *Client {
|
||||
return NewClient(token).WithPermissions(permissions)
|
||||
}
|
||||
|
||||
// WithPermissions adds a slice of permissions to a client
|
||||
func (client *Client) WithPermissions(permissions []string) *Client {
|
||||
client.Permissions = append(client.Permissions, permissions...)
|
||||
return client
|
||||
}
|
||||
|
||||
// WithPermission adds a permission to a client
|
||||
func (client *Client) WithPermission(permission string) *Client {
|
||||
client.Permissions = append(client.Permissions, permission)
|
||||
return client
|
||||
}
|
||||
|
||||
// HasPermission checks whether a client has a given permission
|
||||
func (client Client) HasPermission(permissionRequired string) bool {
|
||||
for _, permission := range client.Permissions {
|
||||
if permissionRequired == permission {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasPermissions checks whether a client has the all permissions passed
|
||||
func (client Client) HasPermissions(permissionsRequired []string) bool {
|
||||
for _, permissionRequired := range permissionsRequired {
|
||||
if !client.HasPermission(permissionRequired) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
140
vendor/github.com/TwiN/g8/clientprovider.go
generated
vendored
Normal file
140
vendor/github.com/TwiN/g8/clientprovider.go
generated
vendored
Normal file
@@ -0,0 +1,140 @@
|
||||
package g8
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gocache/v2"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNoExpiration is the error returned by ClientProvider.StartCacheJanitor if there was an attempt to start the
|
||||
// janitor despite no expiration being configured.
|
||||
// To clarify, this is because the cache janitor is only useful when an expiration is set.
|
||||
ErrNoExpiration = errors.New("no point starting the janitor if the TTL is set to not expire")
|
||||
|
||||
// ErrCacheNotInitialized is the error returned by ClientProvider.StartCacheJanitor if there was an attempt to start
|
||||
// the janitor despite the cache not having been initialized using ClientProvider.WithCache
|
||||
ErrCacheNotInitialized = errors.New("cannot start janitor because cache is not configured")
|
||||
)
|
||||
|
||||
// ClientProvider has the task of retrieving a Client from an external source (e.g. a database) when provided with a
|
||||
// token. It should be used when you have a lot of tokens, and it wouldn't make sense to register all of them using
|
||||
// AuthorizationService's WithToken, WithTokens, WithClient or WithClients.
|
||||
//
|
||||
// Note that the provider is used as a fallback source. As such, if a token is explicitly registered using one of the 4
|
||||
// aforementioned functions, the client provider will not be used by the AuthorizationService when a request is made
|
||||
// with said token. It will, however, be called upon if a token that is not explicitly registered in
|
||||
// AuthorizationService is sent alongside a request going through the Gate.
|
||||
//
|
||||
// clientProvider := g8.NewClientProvider(func(token string) *g8.Client {
|
||||
// // We'll assume that the following function calls your database and returns a struct "User" that
|
||||
// // has the user's token as well as the permissions granted to said user
|
||||
// user := database.GetUserByToken(token)
|
||||
// if user != nil {
|
||||
// return g8.NewClient(user.Token).WithPermissions(user.Permissions)
|
||||
// }
|
||||
// return nil
|
||||
// })
|
||||
// gate := g8.New().WithAuthorizationService(g8.NewAuthorizationService().WithClientProvider(clientProvider))
|
||||
//
|
||||
type ClientProvider struct {
|
||||
getClientByTokenFunc func(token string) *Client
|
||||
|
||||
cache *gocache.Cache
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
// NewClientProvider creates a ClientProvider
|
||||
// The parameter that must be passed is a function that the provider will use to retrieve a client by a given token
|
||||
//
|
||||
// Example:
|
||||
// clientProvider := g8.NewClientProvider(func(token string) *g8.Client {
|
||||
// // We'll assume that the following function calls your database and returns a struct "User" that
|
||||
// // has the user's token as well as the permissions granted to said user
|
||||
// user := database.GetUserByToken(token)
|
||||
// if user == nil {
|
||||
// return nil
|
||||
// }
|
||||
// return g8.NewClient(user.Token).WithPermissions(user.Permissions)
|
||||
// })
|
||||
// gate := g8.New().WithAuthorizationService(g8.NewAuthorizationService().WithClientProvider(clientProvider))
|
||||
//
|
||||
func NewClientProvider(getClientByTokenFunc func(token string) *Client) *ClientProvider {
|
||||
return &ClientProvider{
|
||||
getClientByTokenFunc: getClientByTokenFunc,
|
||||
}
|
||||
}
|
||||
|
||||
// WithCache adds cache options to the ClientProvider.
|
||||
//
|
||||
// ttl is the time until the cache entry will expire. A TTL of gocache.NoExpiration (-1) means no expiration
|
||||
// maxSize is the maximum amount of entries that can be in the cache at any given time.
|
||||
// If a value of gocache.NoMaxSize (0) or less is provided for maxSize, there will be no maximum size.
|
||||
//
|
||||
// Example:
|
||||
// clientProvider := g8.NewClientProvider(func(token string) *g8.Client {
|
||||
// // We'll assume that the following function calls your database and returns a struct "User" that
|
||||
// // has the user's token as well as the permissions granted to said user
|
||||
// user := database.GetUserByToken(token)
|
||||
// if user != nil {
|
||||
// return g8.NewClient(user.Token).WithPermissions(user.Permissions)
|
||||
// }
|
||||
// return nil
|
||||
// })
|
||||
// gate := g8.New().WithAuthorizationService(g8.NewAuthorizationService().WithClientProvider(clientProvider.WithCache(time.Hour, 70000)))
|
||||
//
|
||||
func (provider *ClientProvider) WithCache(ttl time.Duration, maxSize int) *ClientProvider {
|
||||
provider.cache = gocache.NewCache().WithEvictionPolicy(gocache.LeastRecentlyUsed).WithMaxSize(maxSize)
|
||||
provider.ttl = ttl
|
||||
return provider
|
||||
}
|
||||
|
||||
// StartCacheJanitor starts the cache janitor, which passively deletes expired cache entries in the background.
|
||||
//
|
||||
// Not really necessary unless you have a lot of clients (100000+).
|
||||
//
|
||||
// Even without the janitor, active eviction will still happen (i.e. when GetClientByToken is called, but the cache
|
||||
// entry for the given token has expired, the cache entry will be automatically deleted and re-fetched from the
|
||||
// user-defined getClientByTokenFunc)
|
||||
func (provider *ClientProvider) StartCacheJanitor() error {
|
||||
if provider.cache == nil {
|
||||
// Can't start the cache janitor if there's no cache
|
||||
return ErrCacheNotInitialized
|
||||
}
|
||||
if provider.ttl != gocache.NoExpiration {
|
||||
return provider.cache.StartJanitor()
|
||||
}
|
||||
return ErrNoExpiration
|
||||
}
|
||||
|
||||
// StopCacheJanitor stops the cache janitor
|
||||
//
|
||||
// Not required unless your application initializes multiple providers over the course of its lifecycle.
|
||||
// In English, that means if you initialize a ClientProvider only once on application start and it stays up
|
||||
// until your application shuts down, you don't need to call this function.
|
||||
func (provider *ClientProvider) StopCacheJanitor() {
|
||||
if provider.cache != nil {
|
||||
provider.cache.StopJanitor()
|
||||
}
|
||||
}
|
||||
|
||||
// GetClientByToken retrieves a client by its token through the provided getClientByTokenFunc.
|
||||
func (provider *ClientProvider) GetClientByToken(token string) *Client {
|
||||
if provider.cache == nil {
|
||||
return provider.getClientByTokenFunc(token)
|
||||
}
|
||||
if cachedClient, exists := provider.cache.Get(token); exists {
|
||||
if cachedClient == nil {
|
||||
return nil
|
||||
}
|
||||
// Safely typecast the client.
|
||||
// Regardless of whether the typecast is successful or not, we return client since it'll be either client or
|
||||
// nil. Technically, it should never be nil, but it's better to be safe than sorry.
|
||||
client, _ := cachedClient.(*Client)
|
||||
return client
|
||||
}
|
||||
client := provider.getClientByTokenFunc(token)
|
||||
provider.cache.SetWithTTL(token, client, provider.ttl)
|
||||
return client
|
||||
}
|
||||
217
vendor/github.com/TwiN/g8/gate.go
generated
vendored
Normal file
217
vendor/github.com/TwiN/g8/gate.go
generated
vendored
Normal file
@@ -0,0 +1,217 @@
|
||||
package g8
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// AuthorizationHeader is the header in which g8 looks for the authorization bearer token
|
||||
AuthorizationHeader = "Authorization"
|
||||
|
||||
// DefaultUnauthorizedResponseBody is the default response body returned if a request was sent with a missing or invalid token
|
||||
DefaultUnauthorizedResponseBody = "token is missing or invalid"
|
||||
|
||||
// DefaultTooManyRequestsResponseBody is the default response body returned if a request exceeded the allowed rate limit
|
||||
DefaultTooManyRequestsResponseBody = "too many requests"
|
||||
|
||||
// TokenContextKey is the key used to store the token in the context.
|
||||
TokenContextKey = "g8.token"
|
||||
)
|
||||
|
||||
// Gate is lock to the front door of your API, letting only those you allow through.
|
||||
type Gate struct {
|
||||
authorizationService *AuthorizationService
|
||||
unauthorizedResponseBody []byte
|
||||
|
||||
customTokenExtractorFunc func(request *http.Request) string
|
||||
|
||||
rateLimiter *RateLimiter
|
||||
tooManyRequestsResponseBody []byte
|
||||
}
|
||||
|
||||
// Deprecated: use New instead.
|
||||
func NewGate(authorizationService *AuthorizationService) *Gate {
|
||||
return &Gate{
|
||||
authorizationService: authorizationService,
|
||||
unauthorizedResponseBody: []byte(DefaultUnauthorizedResponseBody),
|
||||
tooManyRequestsResponseBody: []byte(DefaultTooManyRequestsResponseBody),
|
||||
}
|
||||
}
|
||||
|
||||
// New creates a new Gate.
|
||||
func New() *Gate {
|
||||
return &Gate{
|
||||
unauthorizedResponseBody: []byte(DefaultUnauthorizedResponseBody),
|
||||
tooManyRequestsResponseBody: []byte(DefaultTooManyRequestsResponseBody),
|
||||
}
|
||||
}
|
||||
|
||||
// WithAuthorizationService sets the authorization service to use.
|
||||
//
|
||||
// If there is no authorization service, Gate will not enforce authorization.
|
||||
func (gate *Gate) WithAuthorizationService(authorizationService *AuthorizationService) *Gate {
|
||||
gate.authorizationService = authorizationService
|
||||
return gate
|
||||
}
|
||||
|
||||
// WithCustomUnauthorizedResponseBody sets a custom response body when Gate determines that a request must be blocked
|
||||
func (gate *Gate) WithCustomUnauthorizedResponseBody(unauthorizedResponseBody []byte) *Gate {
|
||||
gate.unauthorizedResponseBody = unauthorizedResponseBody
|
||||
return gate
|
||||
}
|
||||
|
||||
// WithCustomTokenExtractor allows the specification of a custom function to extract a token from a request.
|
||||
// If a custom token extractor is not specified, the token will be extracted from the Authorization header.
|
||||
//
|
||||
// For instance, if you're using a session cookie, you can extract the token from the cookie like so:
|
||||
// authorizationService := g8.NewAuthorizationService()
|
||||
// customTokenExtractorFunc := func(request *http.Request) string {
|
||||
// sessionCookie, err := request.Cookie("session")
|
||||
// if err != nil {
|
||||
// return ""
|
||||
// }
|
||||
// return sessionCookie.Value
|
||||
// }
|
||||
// gate := g8.New().WithAuthorizationService(authorizationService).WithCustomTokenExtractor(customTokenExtractorFunc)
|
||||
//
|
||||
// You would normally use this with a client provider that matches whatever need you have.
|
||||
// For example, if you're using a session cookie, your client provider would retrieve the user from the session ID
|
||||
// extracted by this custom token extractor.
|
||||
//
|
||||
// Note that for the sake of convenience, the token extracted from the request is passed the protected handlers request
|
||||
// context under the key TokenContextKey. This is especially useful if the token is in fact a session ID.
|
||||
func (gate *Gate) WithCustomTokenExtractor(customTokenExtractorFunc func(request *http.Request) string) *Gate {
|
||||
gate.customTokenExtractorFunc = customTokenExtractorFunc
|
||||
return gate
|
||||
}
|
||||
|
||||
// WithRateLimit adds rate limiting to the Gate
|
||||
//
|
||||
// If you just want to use a gate for rate limiting purposes:
|
||||
// gate := g8.New().WithRateLimit(50)
|
||||
//
|
||||
func (gate *Gate) WithRateLimit(maximumRequestsPerSecond int) *Gate {
|
||||
gate.rateLimiter = NewRateLimiter(maximumRequestsPerSecond)
|
||||
return gate
|
||||
}
|
||||
|
||||
// Protect secures a handler, requiring requests going through to have a valid Authorization Bearer token.
|
||||
// Unlike ProtectWithPermissions, Protect will allow access to any registered tokens, regardless of their permissions
|
||||
// or lack thereof.
|
||||
//
|
||||
// Example:
|
||||
// gate := g8.New().WithAuthorizationService(g8.NewAuthorizationService().WithToken("token"))
|
||||
// router := http.NewServeMux()
|
||||
// // Without protection
|
||||
// router.Handle("/handle", yourHandler)
|
||||
// // With protection
|
||||
// router.Handle("/handle", gate.Protect(yourHandler))
|
||||
//
|
||||
// The token extracted from the request is passed to the handlerFunc request context under the key TokenContextKey
|
||||
func (gate *Gate) Protect(handler http.Handler) http.Handler {
|
||||
return gate.ProtectWithPermissions(handler, nil)
|
||||
}
|
||||
|
||||
// ProtectWithPermissions secures a handler, requiring requests going through to have a valid Authorization Bearer token
|
||||
// as well as a slice of permissions that must be met.
|
||||
//
|
||||
// Example:
|
||||
// gate := g8.New().WithAuthorizationService(g8.NewAuthorizationService().WithClient(g8.NewClient("token").WithPermission("admin")))
|
||||
// router := http.NewServeMux()
|
||||
// // Without protection
|
||||
// router.Handle("/handle", yourHandler)
|
||||
// // With protection
|
||||
// router.Handle("/handle", gate.ProtectWithPermissions(yourHandler, []string{"admin"}))
|
||||
//
|
||||
// The token extracted from the request is passed to the handlerFunc request context under the key TokenContextKey
|
||||
func (gate *Gate) ProtectWithPermissions(handler http.Handler, permissions []string) http.Handler {
|
||||
return gate.ProtectFuncWithPermissions(func(writer http.ResponseWriter, request *http.Request) {
|
||||
handler.ServeHTTP(writer, request)
|
||||
}, permissions)
|
||||
}
|
||||
|
||||
// ProtectWithPermission does the same thing as ProtectWithPermissions, but for a single permission instead of a
|
||||
// slice of permissions
|
||||
//
|
||||
// See ProtectWithPermissions for further documentation
|
||||
func (gate *Gate) ProtectWithPermission(handler http.Handler, permission string) http.Handler {
|
||||
return gate.ProtectFuncWithPermissions(func(writer http.ResponseWriter, request *http.Request) {
|
||||
handler.ServeHTTP(writer, request)
|
||||
}, []string{permission})
|
||||
}
|
||||
|
||||
// ProtectFunc secures a handlerFunc, requiring requests going through to have a valid Authorization Bearer token.
|
||||
// Unlike ProtectFuncWithPermissions, ProtectFunc will allow access to any registered tokens, regardless of their
|
||||
// permissions or lack thereof.
|
||||
//
|
||||
// Example:
|
||||
// gate := g8.New().WithAuthorizationService(g8.NewAuthorizationService().WithToken("token"))
|
||||
// router := http.NewServeMux()
|
||||
// // Without protection
|
||||
// router.HandleFunc("/handle", yourHandlerFunc)
|
||||
// // With protection
|
||||
// router.HandleFunc("/handle", gate.ProtectFunc(yourHandlerFunc))
|
||||
//
|
||||
// The token extracted from the request is passed to the handlerFunc request context under the key TokenContextKey
|
||||
func (gate *Gate) ProtectFunc(handlerFunc http.HandlerFunc) http.HandlerFunc {
|
||||
return gate.ProtectFuncWithPermissions(handlerFunc, nil)
|
||||
}
|
||||
|
||||
// ProtectFuncWithPermissions secures a handler, requiring requests going through to have a valid Authorization Bearer
|
||||
// token as well as a slice of permissions that must be met.
|
||||
//
|
||||
// Example:
|
||||
// gate := g8.New().WithAuthorizationService(g8.NewAuthorizationService().WithClient(g8.NewClient("token").WithPermission("admin")))
|
||||
// router := http.NewServeMux()
|
||||
// // Without protection
|
||||
// router.HandleFunc("/handle", yourHandlerFunc)
|
||||
// // With protection
|
||||
// router.HandleFunc("/handle", gate.ProtectFuncWithPermissions(yourHandlerFunc, []string{"admin"}))
|
||||
//
|
||||
// The token extracted from the request is passed to the handlerFunc request context under the key TokenContextKey
|
||||
func (gate *Gate) ProtectFuncWithPermissions(handlerFunc http.HandlerFunc, permissions []string) http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if gate.rateLimiter != nil {
|
||||
if !gate.rateLimiter.Try() {
|
||||
writer.WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = writer.Write(gate.tooManyRequestsResponseBody)
|
||||
return
|
||||
}
|
||||
}
|
||||
if gate.authorizationService != nil {
|
||||
token := gate.ExtractTokenFromRequest(request)
|
||||
if !gate.authorizationService.IsAuthorized(token, permissions) {
|
||||
writer.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = writer.Write(gate.unauthorizedResponseBody)
|
||||
return
|
||||
}
|
||||
request = request.WithContext(context.WithValue(request.Context(), TokenContextKey, token))
|
||||
}
|
||||
handlerFunc(writer, request)
|
||||
}
|
||||
}
|
||||
|
||||
// ProtectFuncWithPermission does the same thing as ProtectFuncWithPermissions, but for a single permission instead of a
|
||||
// slice of permissions
|
||||
//
|
||||
// See ProtectFuncWithPermissions for further documentation
|
||||
func (gate *Gate) ProtectFuncWithPermission(handlerFunc http.HandlerFunc, permission string) http.HandlerFunc {
|
||||
return gate.ProtectFuncWithPermissions(handlerFunc, []string{permission})
|
||||
}
|
||||
|
||||
// ExtractTokenFromRequest extracts a token from a request.
|
||||
//
|
||||
// By default, it extracts the bearer token from the AuthorizationHeader, but if a customTokenExtractorFunc is defined,
|
||||
// it will use that instead.
|
||||
//
|
||||
// Note that this method is internally used by Protect, ProtectWithPermission, ProtectFunc and
|
||||
// ProtectFuncWithPermissions, but it is exposed in case you need to use it directly.
|
||||
func (gate *Gate) ExtractTokenFromRequest(request *http.Request) string {
|
||||
if gate.customTokenExtractorFunc != nil {
|
||||
// A custom token extractor function is defined, so we'll use it instead of the default token extraction logic
|
||||
return gate.customTokenExtractorFunc(request)
|
||||
}
|
||||
return strings.TrimPrefix(request.Header.Get(AuthorizationHeader), "Bearer ")
|
||||
}
|
||||
42
vendor/github.com/TwiN/g8/ratelimiter.go
generated
vendored
Normal file
42
vendor/github.com/TwiN/g8/ratelimiter.go
generated
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
package g8
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RateLimiter is a fixed rate limiter
|
||||
type RateLimiter struct {
|
||||
maximumExecutionsPerSecond int
|
||||
executionsLeftInWindow int
|
||||
windowStartTime time.Time
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
// NewRateLimiter creates a RateLimiter
|
||||
func NewRateLimiter(maximumExecutionsPerSecond int) *RateLimiter {
|
||||
return &RateLimiter{
|
||||
windowStartTime: time.Now(),
|
||||
executionsLeftInWindow: maximumExecutionsPerSecond,
|
||||
maximumExecutionsPerSecond: maximumExecutionsPerSecond,
|
||||
}
|
||||
}
|
||||
|
||||
// Try updates the number of executions if the rate limit quota hasn't been reached and returns whether the
|
||||
// attempt was successful or not.
|
||||
//
|
||||
// Returns false if the execution was not successful (rate limit quota has been reached)
|
||||
// Returns true if the execution was successful (rate limit quota has not been reached)
|
||||
func (r *RateLimiter) Try() bool {
|
||||
r.mutex.Lock()
|
||||
defer r.mutex.Unlock()
|
||||
if time.Now().Add(-time.Second).After(r.windowStartTime) {
|
||||
r.windowStartTime = time.Now()
|
||||
r.executionsLeftInWindow = r.maximumExecutionsPerSecond
|
||||
}
|
||||
if r.executionsLeftInWindow == 0 {
|
||||
return false
|
||||
}
|
||||
r.executionsLeftInWindow--
|
||||
return true
|
||||
}
|
||||
1
vendor/github.com/TwiN/gocache/v2/.gitattributes
generated
vendored
Normal file
1
vendor/github.com/TwiN/gocache/v2/.gitattributes
generated
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=lf
|
||||
1
vendor/github.com/TwiN/gocache/v2/.gitignore
generated
vendored
Normal file
1
vendor/github.com/TwiN/gocache/v2/.gitignore
generated
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.idea
|
||||
9
vendor/github.com/TwiN/gocache/v2/LICENSE.md
generated
vendored
Normal file
9
vendor/github.com/TwiN/gocache/v2/LICENSE.md
generated
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 TwiN
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
370
vendor/github.com/TwiN/gocache/v2/README.md
generated
vendored
Normal file
370
vendor/github.com/TwiN/gocache/v2/README.md
generated
vendored
Normal file
@@ -0,0 +1,370 @@
|
||||
# gocache
|
||||

|
||||
[](https://goreportcard.com/report/github.com/TwiN/gocache)
|
||||
[](https://codecov.io/gh/TwiN/gocache)
|
||||
[](https://github.com/TwiN/gocache)
|
||||
[](https://pkg.go.dev/github.com/TwiN/gocache)
|
||||
[](https://github.com/TwiN)
|
||||
|
||||
gocache is an easy-to-use, high-performance, lightweight and thread-safe (goroutine-safe) in-memory key-value cache
|
||||
with support for LRU and FIFO eviction policies as well as expiration, bulk operations and even retrieval of keys by pattern.
|
||||
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Features](#features)
|
||||
- [Usage](#usage)
|
||||
- [Initializing the cache](#initializing-the-cache)
|
||||
- [Functions](#functions)
|
||||
- [Examples](#examples)
|
||||
- [Creating or updating an entry](#creating-or-updating-an-entry)
|
||||
- [Getting an entry](#getting-an-entry)
|
||||
- [Deleting an entry](#deleting-an-entry)
|
||||
- [Complex example](#complex-example)
|
||||
- [Persistence](#persistence)
|
||||
- [Eviction](#eviction)
|
||||
- [MaxSize](#maxsize)
|
||||
- [MaxMemoryUsage](#maxmemoryusage)
|
||||
- [Expiration](#expiration)
|
||||
- [Performance](#performance)
|
||||
- [Summary](#summary)
|
||||
- [Results](#results)
|
||||
- [FAQ](#faq)
|
||||
- [How can I persist the data on application termination?](#how-can-i-persist-the-data-on-application-termination)
|
||||
|
||||
|
||||
## Features
|
||||
gocache supports the following cache eviction policies:
|
||||
- First in first out (FIFO)
|
||||
- Least recently used (LRU)
|
||||
|
||||
It also supports cache entry TTL, which is both active and passive. Active expiration means that if you attempt
|
||||
to retrieve a cache key that has already expired, it will delete it on the spot and the behavior will be as if
|
||||
the cache key didn't exist. As for passive expiration, there's a background task that will take care of deleting
|
||||
expired keys.
|
||||
|
||||
It also includes what you'd expect from a cache, like GET/SET, bulk operations and get by pattern.
|
||||
|
||||
|
||||
## Usage
|
||||
```
|
||||
go get -u github.com/TwiN/gocache/v2
|
||||
```
|
||||
|
||||
|
||||
### Initializing the cache
|
||||
```go
|
||||
cache := gocache.NewCache().WithMaxSize(1000).WithEvictionPolicy(gocache.LeastRecentlyUsed)
|
||||
```
|
||||
|
||||
If you're planning on using expiration (`SetWithTTL` or `Expire`) and you want expired entries to be automatically deleted
|
||||
in the background, make sure to start the janitor when you instantiate the cache:
|
||||
|
||||
```go
|
||||
cache.StartJanitor()
|
||||
```
|
||||
|
||||
### Functions
|
||||
| Function | Description |
|
||||
| --------------------------------- | ----------- |
|
||||
| WithMaxSize | Sets the max size of the cache. `gocache.NoMaxSize` means there is no limit. If not set, the default max size is `gocache.DefaultMaxSize`.
|
||||
| WithMaxMemoryUsage | Sets the max memory usage of the cache. `gocache.NoMaxMemoryUsage` means there is no limit. The default behavior is to not evict based on memory usage.
|
||||
| WithEvictionPolicy | Sets the eviction algorithm to be used when the cache reaches the max size. If not set, the default eviction policy is `gocache.FirstInFirstOut` (FIFO).
|
||||
| WithForceNilInterfaceOnNilPointer | Configures whether values with a nil pointer passed to write functions should be forcefully set to nil. Defaults to true.
|
||||
| StartJanitor | Starts the janitor, which is in charge of deleting expired cache entries in the background.
|
||||
| StopJanitor | Stops the janitor.
|
||||
| Set | Same as `SetWithTTL`, but with no expiration (`gocache.NoExpiration`)
|
||||
| SetAll | Same as `Set`, but in bulk
|
||||
| SetWithTTL | Creates or updates a cache entry with the given key, value and expiration time. If the max size after the aforementioned operation is above the configured max size, the tail will be evicted. Depending on the eviction policy, the tail is defined as the oldest
|
||||
| Get | Gets a cache entry by its key.
|
||||
| GetByKeys | Gets a map of entries by their keys. The resulting map will contain all keys, even if some of the keys in the slice passed as parameter were not present in the cache.
|
||||
| GetAll | Gets all cache entries.
|
||||
| GetKeysByPattern | Retrieves a slice of keys that matches a given pattern.
|
||||
| Delete | Removes a key from the cache.
|
||||
| DeleteAll | Removes multiple keys from the cache.
|
||||
| Count | Gets the size of the cache. This includes cache keys which may have already expired, but have not been removed yet.
|
||||
| Clear | Wipes the cache.
|
||||
| TTL | Gets the time until a cache key expires.
|
||||
| Expire | Sets the expiration time of an existing cache key.
|
||||
|
||||
For further documentation, please refer to [Go Reference](https://pkg.go.dev/github.com/TwiN/gocache)
|
||||
|
||||
|
||||
### Examples
|
||||
|
||||
#### Creating or updating an entry
|
||||
```go
|
||||
cache.Set("key", "value")
|
||||
cache.Set("key", 1)
|
||||
cache.Set("key", struct{ Text string }{Test: "value"})
|
||||
cache.SetWithTTL("key", []byte("value"), 24*time.Hour)
|
||||
```
|
||||
|
||||
#### Getting an entry
|
||||
```go
|
||||
value, exists := cache.Get("key")
|
||||
```
|
||||
You can also get multiple entries by using `cache.GetByKeys([]string{"key1", "key2"})`
|
||||
|
||||
#### Deleting an entry
|
||||
```go
|
||||
cache.Delete("key")
|
||||
```
|
||||
You can also delete multiple entries by using `cache.DeleteAll([]string{"key1", "key2"})`
|
||||
|
||||
#### Complex example
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gocache/v2"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cache := gocache.NewCache().WithEvictionPolicy(gocache.LeastRecentlyUsed).WithMaxSize(10000)
|
||||
cache.StartJanitor() // Passively manages expired entries
|
||||
defer cache.StopJanitor()
|
||||
|
||||
cache.Set("key", "value")
|
||||
cache.SetWithTTL("key-with-ttl", "value", 60*time.Minute)
|
||||
cache.SetAll(map[string]interface{}{"k1": "v1", "k2": "v2", "k3": "v3"})
|
||||
|
||||
fmt.Println("[Count] Cache size:", cache.Count())
|
||||
|
||||
value, exists := cache.Get("key")
|
||||
fmt.Printf("[Get] key=key; value=%s; exists=%v\n", value, exists)
|
||||
for key, value := range cache.GetByKeys([]string{"k1", "k2", "k3"}) {
|
||||
fmt.Printf("[GetByKeys] key=%s; value=%s\n", key, value)
|
||||
}
|
||||
for _, key := range cache.GetKeysByPattern("key*", 0) {
|
||||
fmt.Printf("[GetKeysByPattern] pattern=key*; key=%s\n", key)
|
||||
}
|
||||
|
||||
cache.Expire("key", time.Hour)
|
||||
time.Sleep(500*time.Millisecond)
|
||||
timeUntilExpiration, _ := cache.TTL("key")
|
||||
fmt.Println("[TTL] Number of minutes before 'key' expires:", int(timeUntilExpiration.Seconds()))
|
||||
|
||||
cache.Delete("key")
|
||||
cache.DeleteAll([]string{"k1", "k2", "k3"})
|
||||
|
||||
cache.Clear()
|
||||
fmt.Println("[Count] Cache size after clearing the cache:", cache.Count())
|
||||
}
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Output</summary>
|
||||
|
||||
```
|
||||
[Count] Cache size: 5
|
||||
[Get] key=key; value=value; exists=true
|
||||
[GetByKeys] key=k1; value=v1
|
||||
[GetByKeys] key=k2; value=v2
|
||||
[GetByKeys] key=k3; value=v3
|
||||
[GetKeysByPattern] pattern=key*; key=key-with-ttl
|
||||
[GetKeysByPattern] pattern=key*; key=key
|
||||
[TTL] Number of minutes before 'key' expires: 3599
|
||||
[Count] Cache size after clearing the cache: 0
|
||||
```
|
||||
</details>
|
||||
|
||||
|
||||
## Persistence
|
||||
Prior to v2, gocache supported persistence out of the box.
|
||||
|
||||
After some thinking, I decided that persistence added too many dependencies, and given than this is a cache library
|
||||
and most people wouldn't be interested in persistence, I decided to get rid of it.
|
||||
|
||||
That being said, you can use the `GetAll` and `SetAll` methods of `gocache.Cache` to implement persistence yourself.
|
||||
|
||||
|
||||
## Eviction
|
||||
### MaxSize
|
||||
Eviction by MaxSize is the default behavior, and is also the most efficient.
|
||||
|
||||
The code below will create a cache that has a maximum size of 1000:
|
||||
```go
|
||||
cache := gocache.NewCache().WithMaxSize(1000)
|
||||
```
|
||||
This means that whenever an operation causes the total size of the cache to go above 1000, the tail will be evicted.
|
||||
|
||||
### MaxMemoryUsage
|
||||
Eviction by MaxMemoryUsage is **disabled by default**, and is in alpha.
|
||||
|
||||
The code below will create a cache that has a maximum memory usage of 50MB:
|
||||
```go
|
||||
cache := gocache.NewCache().WithMaxSize(0).WithMaxMemoryUsage(50*gocache.Megabyte)
|
||||
```
|
||||
This means that whenever an operation causes the total memory usage of the cache to go above 50MB, one or more tails
|
||||
will be evicted.
|
||||
|
||||
Unlike evictions caused by reaching the MaxSize, evictions triggered by MaxMemoryUsage may lead to multiple entries
|
||||
being evicted in a row. The reason for this is that if, for instance, you had 100 entries of 0.1MB each and you suddenly added
|
||||
a single entry of 10MB, 100 entries would need to be evicted to make enough space for that new big entry.
|
||||
|
||||
It's very important to keep in mind that eviction by MaxMemoryUsage is approximate.
|
||||
|
||||
**The only memory taken into consideration is the size of the cache, not the size of the entire application.**
|
||||
If you pass along 100MB worth of data in a matter of seconds, even though the cache's memory usage will remain
|
||||
under 50MB (or whatever you configure the MaxMemoryUsage to), the memory footprint generated by that 100MB will
|
||||
still exist until the next GC cycle.
|
||||
|
||||
As previously mentioned, this is a work in progress, and here's a list of the things you should keep in mind:
|
||||
- The memory usage of structs are a gross estimation and may not reflect the actual memory usage.
|
||||
- Native types (string, int, bool, []byte, etc.) are the most accurate for calculating the memory usage.
|
||||
- Adding an entry bigger than the configured MaxMemoryUsage will work, but it will evict all other entries.
|
||||
|
||||
|
||||
## Expiration
|
||||
There are two ways that the deletion of expired keys can take place:
|
||||
- Active
|
||||
- Passive
|
||||
|
||||
**Active deletion of expired keys** happens when an attempt is made to access the value of a cache entry that expired.
|
||||
`Get`, `GetByKeys` and `GetAll` are the only functions that can trigger active deletion of expired keys.
|
||||
|
||||
**Passive deletion of expired keys** runs in the background and is managed by the janitor.
|
||||
If you do not start the janitor, there will be no passive deletion of expired keys.
|
||||
|
||||
|
||||
## Performance
|
||||
### Summary
|
||||
- **Set**: Both map and gocache have the same performance.
|
||||
- **Get**: Map is faster than gocache.
|
||||
|
||||
This is because gocache keeps track of the head and the tail for eviction and expiration/TTL.
|
||||
|
||||
Ultimately, the difference is negligible.
|
||||
|
||||
We could add a way to disable eviction or disable expiration altogether just to match the map's performance,
|
||||
but if you're looking into using a library like gocache, odds are, you want more than just a map.
|
||||
|
||||
|
||||
### Results
|
||||
| key | value |
|
||||
|:------ |:-------- |
|
||||
| goos | windows |
|
||||
| goarch | amd64 |
|
||||
| cpu | i7-9700K |
|
||||
| mem | 32G DDR4 |
|
||||
|
||||
```
|
||||
// Normal map
|
||||
BenchmarkMap_Get
|
||||
BenchmarkMap_Get-8 46087372 26.7 ns/op
|
||||
BenchmarkMap_Set
|
||||
BenchmarkMap_Set/small_value-8 3841911 389 ns/op
|
||||
BenchmarkMap_Set/medium_value-8 3887074 391 ns/op
|
||||
BenchmarkMap_Set/large_value-8 3921956 393 ns/op
|
||||
// Gocache
|
||||
BenchmarkCache_Get
|
||||
BenchmarkCache_Get/FirstInFirstOut-8 27273036 46.4 ns/op
|
||||
BenchmarkCache_Get/LeastRecentlyUsed-8 26648248 46.3 ns/op
|
||||
BenchmarkCache_Set
|
||||
BenchmarkCache_Set/FirstInFirstOut_small_value-8 2919584 405 ns/op
|
||||
BenchmarkCache_Set/FirstInFirstOut_medium_value-8 2990841 391 ns/op
|
||||
BenchmarkCache_Set/FirstInFirstOut_large_value-8 2970513 391 ns/op
|
||||
BenchmarkCache_Set/LeastRecentlyUsed_small_value-8 2962939 402 ns/op
|
||||
BenchmarkCache_Set/LeastRecentlyUsed_medium_value-8 2962963 390 ns/op
|
||||
BenchmarkCache_Set/LeastRecentlyUsed_large_value-8 2962928 394 ns/op
|
||||
BenchmarkCache_SetUsingMaxMemoryUsage
|
||||
BenchmarkCache_SetUsingMaxMemoryUsage/small_value-8 2683356 447 ns/op
|
||||
BenchmarkCache_SetUsingMaxMemoryUsage/medium_value-8 2637578 441 ns/op
|
||||
BenchmarkCache_SetUsingMaxMemoryUsage/large_value-8 2672434 443 ns/op
|
||||
BenchmarkCache_SetWithMaxSize
|
||||
BenchmarkCache_SetWithMaxSize/100_small_value-8 4782966 252 ns/op
|
||||
BenchmarkCache_SetWithMaxSize/10000_small_value-8 4067967 296 ns/op
|
||||
BenchmarkCache_SetWithMaxSize/100000_small_value-8 3762055 328 ns/op
|
||||
BenchmarkCache_SetWithMaxSize/100_medium_value-8 4760479 252 ns/op
|
||||
BenchmarkCache_SetWithMaxSize/10000_medium_value-8 4081050 295 ns/op
|
||||
BenchmarkCache_SetWithMaxSize/100000_medium_value-8 3785050 330 ns/op
|
||||
BenchmarkCache_SetWithMaxSize/100_large_value-8 4732909 254 ns/op
|
||||
BenchmarkCache_SetWithMaxSize/10000_large_value-8 4079533 297 ns/op
|
||||
BenchmarkCache_SetWithMaxSize/100000_large_value-8 3712820 331 ns/op
|
||||
BenchmarkCache_SetWithMaxSizeAndLRU
|
||||
BenchmarkCache_SetWithMaxSizeAndLRU/100_small_value-8 4761732 254 ns/op
|
||||
BenchmarkCache_SetWithMaxSizeAndLRU/10000_small_value-8 4084474 296 ns/op
|
||||
BenchmarkCache_SetWithMaxSizeAndLRU/100000_small_value-8 3761402 329 ns/op
|
||||
BenchmarkCache_SetWithMaxSizeAndLRU/100_medium_value-8 4783075 254 ns/op
|
||||
BenchmarkCache_SetWithMaxSizeAndLRU/10000_medium_value-8 4103980 296 ns/op
|
||||
BenchmarkCache_SetWithMaxSizeAndLRU/100000_medium_value-8 3646023 331 ns/op
|
||||
BenchmarkCache_SetWithMaxSizeAndLRU/100_large_value-8 4779025 254 ns/op
|
||||
BenchmarkCache_SetWithMaxSizeAndLRU/10000_large_value-8 4096192 296 ns/op
|
||||
BenchmarkCache_SetWithMaxSizeAndLRU/100000_large_value-8 3726823 331 ns/op
|
||||
BenchmarkCache_GetSetMultipleConcurrent
|
||||
BenchmarkCache_GetSetMultipleConcurrent-8 707142 1698 ns/op
|
||||
BenchmarkCache_GetSetConcurrentWithFrequentEviction
|
||||
BenchmarkCache_GetSetConcurrentWithFrequentEviction/FirstInFirstOut-8 3616256 334 ns/op
|
||||
BenchmarkCache_GetSetConcurrentWithFrequentEviction/LeastRecentlyUsed-8 3636367 331 ns/op
|
||||
BenchmarkCache_GetConcurrentWithLRU
|
||||
BenchmarkCache_GetConcurrentWithLRU/FirstInFirstOut-8 4405557 268 ns/op
|
||||
BenchmarkCache_GetConcurrentWithLRU/LeastRecentlyUsed-8 4445475 269 ns/op
|
||||
BenchmarkCache_WithForceNilInterfaceOnNilPointer
|
||||
BenchmarkCache_WithForceNilInterfaceOnNilPointer/true_with_nil_struct_pointer-8 6184591 191 ns/op
|
||||
BenchmarkCache_WithForceNilInterfaceOnNilPointer/true-8 6090482 191 ns/op
|
||||
BenchmarkCache_WithForceNilInterfaceOnNilPointer/false_with_nil_struct_pointer-8 6184629 187 ns/op
|
||||
BenchmarkCache_WithForceNilInterfaceOnNilPointer/false-8 6281781 186 ns/op
|
||||
(Trimmed "BenchmarkCache_" for readability)
|
||||
WithForceNilInterfaceOnNilPointerWithConcurrency
|
||||
WithForceNilInterfaceOnNilPointerWithConcurrency/true_with_nil_struct_pointer-8 4379564 268 ns/op
|
||||
WithForceNilInterfaceOnNilPointerWithConcurrency/true-8 4379558 265 ns/op
|
||||
WithForceNilInterfaceOnNilPointerWithConcurrency/false_with_nil_struct_pointer-8 4444456 261 ns/op
|
||||
WithForceNilInterfaceOnNilPointerWithConcurrency/false-8 4493896 262 ns/op
|
||||
```
|
||||
|
||||
|
||||
## FAQ
|
||||
|
||||
### How can I persist the data on application termination?
|
||||
While creating your own auto save feature might come in handy, it may still lead to loss of data if the application
|
||||
automatically saves every 10 minutes and your application crashes 9 minutes after the previous save.
|
||||
|
||||
To increase your odds of not losing any data, you can use Go's `signal` package, more specifically its `Notify` function
|
||||
which allows listening for termination signals like SIGTERM and SIGINT. Once a termination signal is caught, you can
|
||||
add the necessary logic for a graceful shutdown.
|
||||
|
||||
In the following example, the code that would usually be present in the `main` function is moved to a different function
|
||||
named `Start` which is launched on a different goroutine so that listening for a termination signals is what blocks the
|
||||
main goroutine instead:
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/TwiN/gocache/v2"
|
||||
)
|
||||
|
||||
var cache = gocache.NewCache()
|
||||
|
||||
func main() {
|
||||
data := retrieveCacheEntriesUsingWhateverMeanYouUsedToPersistIt()
|
||||
cache.SetAll(data)
|
||||
// Start everything else on another goroutine to prevent blocking the main goroutine
|
||||
go Start()
|
||||
// Wait for termination signal
|
||||
sig := make(chan os.Signal, 1)
|
||||
done := make(chan bool, 1)
|
||||
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-sig
|
||||
log.Println("Received termination signal, attempting to gracefully shut down")
|
||||
// Persist the cache entries
|
||||
cacheEntries := cache.GetAll()
|
||||
persistCacheEntriesHoweverYouWant(cacheEntries)
|
||||
// Tell the main goroutine that we're done
|
||||
done <- true
|
||||
}()
|
||||
<-done
|
||||
log.Println("Shutting down")
|
||||
}
|
||||
```
|
||||
|
||||
Note that this won't protect you from a SIGKILL, as this signal cannot be caught.
|
||||
108
vendor/github.com/TwiN/gocache/v2/entry.go
generated
vendored
Normal file
108
vendor/github.com/TwiN/gocache/v2/entry.go
generated
vendored
Normal file
@@ -0,0 +1,108 @@
|
||||
package gocache
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// Entry is a cache entry
|
||||
type Entry struct {
|
||||
// Key is the name of the cache entry
|
||||
Key string
|
||||
|
||||
// Value is the value of the cache entry
|
||||
Value interface{}
|
||||
|
||||
// RelevantTimestamp is the variable used to store either:
|
||||
// - creation timestamp, if the Cache's EvictionPolicy is FirstInFirstOut
|
||||
// - last access timestamp, if the Cache's EvictionPolicy is LeastRecentlyUsed
|
||||
//
|
||||
// Note that updating an existing entry will also update this value
|
||||
RelevantTimestamp time.Time
|
||||
|
||||
// Expiration is the unix time in nanoseconds at which the entry will expire (-1 means no expiration)
|
||||
Expiration int64
|
||||
|
||||
next *Entry
|
||||
previous *Entry
|
||||
}
|
||||
|
||||
// Accessed updates the Entry's RelevantTimestamp to now
|
||||
func (entry *Entry) Accessed() {
|
||||
entry.RelevantTimestamp = time.Now()
|
||||
}
|
||||
|
||||
// Expired returns whether the Entry has expired
|
||||
func (entry Entry) Expired() bool {
|
||||
if entry.Expiration > 0 {
|
||||
if time.Now().UnixNano() > entry.Expiration {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SizeInBytes returns the size of an entry in bytes, approximately.
|
||||
func (entry *Entry) SizeInBytes() int {
|
||||
return toBytes(entry.Key) + toBytes(entry.Value) + 32
|
||||
}
|
||||
|
||||
func toBytes(value interface{}) int {
|
||||
switch value.(type) {
|
||||
case string:
|
||||
return int(unsafe.Sizeof(value)) + len(value.(string))
|
||||
case int8, uint8, bool:
|
||||
return int(unsafe.Sizeof(value)) + 1
|
||||
case int16, uint16:
|
||||
return int(unsafe.Sizeof(value)) + 2
|
||||
case int32, uint32, float32, complex64:
|
||||
return int(unsafe.Sizeof(value)) + 4
|
||||
case int64, uint64, int, uint, float64, complex128:
|
||||
return int(unsafe.Sizeof(value)) + 8
|
||||
case []interface{}:
|
||||
size := 0
|
||||
for _, v := range value.([]interface{}) {
|
||||
size += toBytes(v)
|
||||
}
|
||||
return int(unsafe.Sizeof(value)) + size
|
||||
case []string:
|
||||
size := 0
|
||||
for _, v := range value.([]string) {
|
||||
size += toBytes(v)
|
||||
}
|
||||
return int(unsafe.Sizeof(value)) + size
|
||||
case []int8:
|
||||
return int(unsafe.Sizeof(value)) + len(value.([]int8))
|
||||
case []uint8:
|
||||
return int(unsafe.Sizeof(value)) + len(value.([]uint8))
|
||||
case []bool:
|
||||
return int(unsafe.Sizeof(value)) + len(value.([]bool))
|
||||
case []int16:
|
||||
return int(unsafe.Sizeof(value)) + (len(value.([]int16)) * 2)
|
||||
case []uint16:
|
||||
return int(unsafe.Sizeof(value)) + (len(value.([]uint16)) * 2)
|
||||
case []int32:
|
||||
return int(unsafe.Sizeof(value)) + (len(value.([]int32)) * 4)
|
||||
case []uint32:
|
||||
return int(unsafe.Sizeof(value)) + (len(value.([]uint32)) * 4)
|
||||
case []float32:
|
||||
return int(unsafe.Sizeof(value)) + (len(value.([]float32)) * 4)
|
||||
case []complex64:
|
||||
return int(unsafe.Sizeof(value)) + (len(value.([]complex64)) * 4)
|
||||
case []int64:
|
||||
return int(unsafe.Sizeof(value)) + (len(value.([]int64)) * 8)
|
||||
case []uint64:
|
||||
return int(unsafe.Sizeof(value)) + (len(value.([]uint64)) * 8)
|
||||
case []int:
|
||||
return int(unsafe.Sizeof(value)) + (len(value.([]int)) * 8)
|
||||
case []uint:
|
||||
return int(unsafe.Sizeof(value)) + (len(value.([]uint)) * 8)
|
||||
case []float64:
|
||||
return int(unsafe.Sizeof(value)) + (len(value.([]float64)) * 8)
|
||||
case []complex128:
|
||||
return int(unsafe.Sizeof(value)) + (len(value.([]complex128)) * 8)
|
||||
default:
|
||||
return int(unsafe.Sizeof(value)) + len(fmt.Sprintf("%v", value))
|
||||
}
|
||||
}
|
||||
567
vendor/github.com/TwiN/gocache/v2/gocache.go
generated
vendored
Normal file
567
vendor/github.com/TwiN/gocache/v2/gocache.go
generated
vendored
Normal file
@@ -0,0 +1,567 @@
|
||||
package gocache
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
Debug = false
|
||||
)
|
||||
|
||||
const (
|
||||
// NoMaxSize means that the cache has no maximum number of entries in the cache
|
||||
// Setting Cache.maxSize to this value also means there will be no eviction
|
||||
NoMaxSize = 0
|
||||
|
||||
// NoMaxMemoryUsage means that the cache has no maximum number of entries in the cache
|
||||
NoMaxMemoryUsage = 0
|
||||
|
||||
// DefaultMaxSize is the max size set if no max size is specified
|
||||
DefaultMaxSize = 100000
|
||||
|
||||
// NoExpiration is the value that must be used as TTL to specify that the given key should never expire
|
||||
NoExpiration = -1
|
||||
|
||||
Kilobyte = 1024
|
||||
Megabyte = 1024 * Kilobyte
|
||||
Gigabyte = 1024 * Megabyte
|
||||
)
|
||||
|
||||
var (
|
||||
ErrKeyDoesNotExist = errors.New("key does not exist") // Returned when a cache key does not exist
|
||||
ErrKeyHasNoExpiration = errors.New("key has no expiration") // Returned when a cache key has no expiration
|
||||
ErrJanitorAlreadyRunning = errors.New("janitor is already running") // Returned when the janitor has already been started
|
||||
)
|
||||
|
||||
// Cache is the core struct of gocache which contains the data as well as all relevant configuration fields
|
||||
type Cache struct {
|
||||
// maxSize is the maximum amount of entries that can be in the cache at any given time
|
||||
// By default, this is set to DefaultMaxSize
|
||||
maxSize int
|
||||
|
||||
// maxMemoryUsage is the maximum amount of memory that can be taken up by the cache at any time
|
||||
// By default, this is set to NoMaxMemoryUsage, meaning that the default behavior is to not evict
|
||||
// based on maximum memory usage
|
||||
maxMemoryUsage int
|
||||
|
||||
// evictionPolicy is the eviction policy
|
||||
evictionPolicy EvictionPolicy
|
||||
|
||||
// stats is the object that contains cache statistics/metrics
|
||||
stats *Statistics
|
||||
|
||||
// entries is the content of the cache
|
||||
entries map[string]*Entry
|
||||
|
||||
// mutex is the lock for making concurrent operations on the cache
|
||||
mutex sync.RWMutex
|
||||
|
||||
// head is the cache entry at the head of the cache
|
||||
head *Entry
|
||||
|
||||
// tail is the last cache node and also the next entry that will be evicted
|
||||
tail *Entry
|
||||
|
||||
// stopJanitor is the channel used to stop the janitor
|
||||
stopJanitor chan bool
|
||||
|
||||
// memoryUsage is the approximate memory usage of the cache (dataset only) in bytes
|
||||
memoryUsage int
|
||||
|
||||
// forceNilInterfaceOnNilPointer determines whether all Set-like functions should set a value as nil if the
|
||||
// interface passed has a nil value but not a nil type.
|
||||
//
|
||||
// By default, interfaces are only nil when both their type and value is nil.
|
||||
// This means that when you pass a pointer to a nil value, the type of the interface
|
||||
// will still show as nil, which means that if you don't cast the interface after
|
||||
// retrieving it, a nil check will return that the value is not false.
|
||||
forceNilInterfaceOnNilPointer bool
|
||||
}
|
||||
|
||||
// MaxSize returns the maximum amount of keys that can be present in the cache before
|
||||
// new entries trigger the eviction of the tail
|
||||
func (cache *Cache) MaxSize() int {
|
||||
return cache.maxSize
|
||||
}
|
||||
|
||||
// MaxMemoryUsage returns the configured maxMemoryUsage of the cache
|
||||
func (cache *Cache) MaxMemoryUsage() int {
|
||||
return cache.maxMemoryUsage
|
||||
}
|
||||
|
||||
// EvictionPolicy returns the EvictionPolicy of the Cache
|
||||
func (cache *Cache) EvictionPolicy() EvictionPolicy {
|
||||
return cache.evictionPolicy
|
||||
}
|
||||
|
||||
// Stats returns statistics from the cache
|
||||
func (cache *Cache) Stats() Statistics {
|
||||
cache.mutex.RLock()
|
||||
stats := Statistics{
|
||||
EvictedKeys: cache.stats.EvictedKeys,
|
||||
ExpiredKeys: cache.stats.ExpiredKeys,
|
||||
Hits: cache.stats.Hits,
|
||||
Misses: cache.stats.Misses,
|
||||
}
|
||||
cache.mutex.RUnlock()
|
||||
return stats
|
||||
}
|
||||
|
||||
// MemoryUsage returns the current memory usage of the cache's dataset in bytes
|
||||
// If MaxMemoryUsage is set to NoMaxMemoryUsage, this will return 0
|
||||
func (cache *Cache) MemoryUsage() int {
|
||||
return cache.memoryUsage
|
||||
}
|
||||
|
||||
// WithMaxSize sets the maximum amount of entries that can be in the cache at any given time
|
||||
// A maxSize of 0 or less means infinite
|
||||
func (cache *Cache) WithMaxSize(maxSize int) *Cache {
|
||||
if maxSize < 0 {
|
||||
maxSize = NoMaxSize
|
||||
}
|
||||
if maxSize != NoMaxSize && cache.Count() == 0 {
|
||||
cache.entries = make(map[string]*Entry, maxSize)
|
||||
}
|
||||
cache.maxSize = maxSize
|
||||
return cache
|
||||
}
|
||||
|
||||
// WithMaxMemoryUsage sets the maximum amount of memory that can be used by the cache at any given time
|
||||
//
|
||||
// NOTE: This is approximate.
|
||||
//
|
||||
// Setting this to NoMaxMemoryUsage will disable eviction by memory usage
|
||||
func (cache *Cache) WithMaxMemoryUsage(maxMemoryUsageInBytes int) *Cache {
|
||||
if maxMemoryUsageInBytes < 0 {
|
||||
maxMemoryUsageInBytes = NoMaxMemoryUsage
|
||||
}
|
||||
cache.maxMemoryUsage = maxMemoryUsageInBytes
|
||||
return cache
|
||||
}
|
||||
|
||||
// WithEvictionPolicy sets eviction algorithm.
|
||||
// Defaults to FirstInFirstOut (FIFO)
|
||||
func (cache *Cache) WithEvictionPolicy(policy EvictionPolicy) *Cache {
|
||||
cache.evictionPolicy = policy
|
||||
return cache
|
||||
}
|
||||
|
||||
// WithForceNilInterfaceOnNilPointer sets whether all Set-like functions should set a value as nil if the
|
||||
// interface passed has a nil value but not a nil type.
|
||||
//
|
||||
// In Go, an interface is only nil if both its type and value are nil, which means that a nil pointer
|
||||
// (e.g. (*Struct)(nil)) will retain its attribution to the type, and the unmodified value returned from
|
||||
// Cache.Get, for instance, would return false when compared with nil if this option is set to false.
|
||||
//
|
||||
// We can bypass this by detecting if the interface's value is nil and setting it to nil rather than
|
||||
// a nil pointer, which will make the value returned from Cache.Get return true when compared with nil.
|
||||
// This is exactly what passing true to WithForceNilInterfaceOnNilPointer does, and it's also the default behavior.
|
||||
//
|
||||
// Alternatively, you may pass false to WithForceNilInterfaceOnNilPointer, which will mean that you'll have
|
||||
// to cast the value returned from Cache.Get to its original type to check for whether the pointer returned
|
||||
// is nil or not.
|
||||
//
|
||||
// If set to true (default):
|
||||
// cache := gocache.NewCache().WithForceNilInterfaceOnNilPointer(true)
|
||||
// cache.Set("key", (*Struct)(nil))
|
||||
// value, _ := cache.Get("key")
|
||||
// // the following returns true, because the interface{} was forcefully set to nil
|
||||
// if value == nil {}
|
||||
// // the following will panic, because the value has been casted to its type (which is nil)
|
||||
// if value.(*Struct) == nil {}
|
||||
//
|
||||
// If set to false:
|
||||
// cache := gocache.NewCache().WithForceNilInterfaceOnNilPointer(false)
|
||||
// cache.Set("key", (*Struct)(nil))
|
||||
// value, _ := cache.Get("key")
|
||||
// // the following returns false, because the interface{} returned has a non-nil type (*Struct)
|
||||
// if value == nil {}
|
||||
// // the following returns true, because the value has been casted to its type
|
||||
// if value.(*Struct) == nil {}
|
||||
//
|
||||
// In other words, if set to true, you do not need to cast the value returned from the cache to
|
||||
// to check if the value is nil.
|
||||
//
|
||||
// Defaults to true
|
||||
func (cache *Cache) WithForceNilInterfaceOnNilPointer(forceNilInterfaceOnNilPointer bool) *Cache {
|
||||
cache.forceNilInterfaceOnNilPointer = forceNilInterfaceOnNilPointer
|
||||
return cache
|
||||
}
|
||||
|
||||
// NewCache creates a new Cache
|
||||
//
|
||||
// Should be used in conjunction with Cache.WithMaxSize, Cache.WithMaxMemoryUsage and/or Cache.WithEvictionPolicy
|
||||
// gocache.NewCache().WithMaxSize(10000).WithEvictionPolicy(gocache.LeastRecentlyUsed)
|
||||
//
|
||||
func NewCache() *Cache {
|
||||
return &Cache{
|
||||
maxSize: DefaultMaxSize,
|
||||
evictionPolicy: FirstInFirstOut,
|
||||
stats: &Statistics{},
|
||||
entries: make(map[string]*Entry),
|
||||
mutex: sync.RWMutex{},
|
||||
stopJanitor: nil,
|
||||
forceNilInterfaceOnNilPointer: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Set creates or updates a key with a given value
|
||||
func (cache *Cache) Set(key string, value interface{}) {
|
||||
cache.SetWithTTL(key, value, NoExpiration)
|
||||
}
|
||||
|
||||
// SetWithTTL creates or updates a key with a given value and sets an expiration time (-1 is NoExpiration)
|
||||
//
|
||||
// The TTL provided must be greater than 0, or NoExpiration (-1). If a negative value that isn't -1 (NoExpiration) is
|
||||
// provided, the entry will not be created if the key doesn't exist
|
||||
func (cache *Cache) SetWithTTL(key string, value interface{}, ttl time.Duration) {
|
||||
// An interface is only nil if both its value and its type are nil, however, passing a nil pointer as an interface{}
|
||||
// means that the interface itself is not nil, because the interface value is nil but not the type.
|
||||
if cache.forceNilInterfaceOnNilPointer {
|
||||
if value != nil && (reflect.ValueOf(value).Kind() == reflect.Ptr && reflect.ValueOf(value).IsNil()) {
|
||||
value = nil
|
||||
}
|
||||
}
|
||||
cache.mutex.Lock()
|
||||
entry, ok := cache.get(key)
|
||||
if !ok {
|
||||
// A negative TTL that isn't -1 (NoExpiration) or 0 is an entry that will expire instantly,
|
||||
// so might as well just not create it in the first place
|
||||
if ttl != NoExpiration && ttl < 1 {
|
||||
cache.mutex.Unlock()
|
||||
return
|
||||
}
|
||||
// Cache entry doesn't exist, so we have to create a new one
|
||||
entry = &Entry{
|
||||
Key: key,
|
||||
Value: value,
|
||||
RelevantTimestamp: time.Now(),
|
||||
next: cache.head,
|
||||
}
|
||||
if cache.head == nil {
|
||||
cache.tail = entry
|
||||
} else {
|
||||
cache.head.previous = entry
|
||||
}
|
||||
cache.head = entry
|
||||
cache.entries[key] = entry
|
||||
if cache.maxMemoryUsage != NoMaxMemoryUsage {
|
||||
cache.memoryUsage += entry.SizeInBytes()
|
||||
}
|
||||
} else {
|
||||
// A negative TTL that isn't -1 (NoExpiration) or 0 is an entry that will expire instantly,
|
||||
// so might as well just delete it immediately instead of updating it
|
||||
if ttl != NoExpiration && ttl < 1 {
|
||||
cache.delete(key)
|
||||
cache.mutex.Unlock()
|
||||
return
|
||||
}
|
||||
if cache.maxMemoryUsage != NoMaxMemoryUsage {
|
||||
// Subtract the old entry from the cache's memoryUsage
|
||||
cache.memoryUsage -= entry.SizeInBytes()
|
||||
}
|
||||
// Update existing entry's value
|
||||
entry.Value = value
|
||||
entry.RelevantTimestamp = time.Now()
|
||||
if cache.maxMemoryUsage != NoMaxMemoryUsage {
|
||||
// Add the memory usage of the new entry to the cache's memoryUsage
|
||||
cache.memoryUsage += entry.SizeInBytes()
|
||||
}
|
||||
// Because we just updated the entry, we need to move it back to HEAD
|
||||
cache.moveExistingEntryToHead(entry)
|
||||
}
|
||||
if ttl != NoExpiration {
|
||||
entry.Expiration = time.Now().Add(ttl).UnixNano()
|
||||
} else {
|
||||
entry.Expiration = NoExpiration
|
||||
}
|
||||
// If the cache doesn't have a maxSize/maxMemoryUsage, then there's no point
|
||||
// checking if we need to evict an entry, so we'll just return now
|
||||
if cache.maxSize == NoMaxSize && cache.maxMemoryUsage == NoMaxMemoryUsage {
|
||||
cache.mutex.Unlock()
|
||||
return
|
||||
}
|
||||
// If there's a maxSize and the cache has more entries than the maxSize, evict
|
||||
if cache.maxSize != NoMaxSize && len(cache.entries) > cache.maxSize {
|
||||
cache.evict()
|
||||
}
|
||||
// If there's a maxMemoryUsage and the memoryUsage is above the maxMemoryUsage, evict
|
||||
if cache.maxMemoryUsage != NoMaxMemoryUsage && cache.memoryUsage > cache.maxMemoryUsage {
|
||||
for cache.memoryUsage > cache.maxMemoryUsage && len(cache.entries) > 0 {
|
||||
cache.evict()
|
||||
}
|
||||
}
|
||||
cache.mutex.Unlock()
|
||||
}
|
||||
|
||||
// SetAll creates or updates multiple values
|
||||
func (cache *Cache) SetAll(entries map[string]interface{}) {
|
||||
for key, value := range entries {
|
||||
cache.SetWithTTL(key, value, NoExpiration)
|
||||
}
|
||||
}
|
||||
|
||||
// Get retrieves an entry using the key passed as parameter
|
||||
// If there is no such entry, the value returned will be nil and the boolean will be false
|
||||
// If there is an entry, the value returned will be the value cached and the boolean will be true
|
||||
func (cache *Cache) Get(key string) (interface{}, bool) {
|
||||
cache.mutex.Lock()
|
||||
entry, ok := cache.get(key)
|
||||
if !ok {
|
||||
cache.mutex.Unlock()
|
||||
cache.stats.Misses++
|
||||
return nil, false
|
||||
}
|
||||
if entry.Expired() {
|
||||
cache.stats.ExpiredKeys++
|
||||
cache.delete(key)
|
||||
cache.mutex.Unlock()
|
||||
return nil, false
|
||||
}
|
||||
cache.stats.Hits++
|
||||
if cache.evictionPolicy == LeastRecentlyUsed {
|
||||
entry.Accessed()
|
||||
if cache.head == entry {
|
||||
cache.mutex.Unlock()
|
||||
return entry.Value, true
|
||||
}
|
||||
// Because the eviction policy is LRU, we need to move the entry back to HEAD
|
||||
cache.moveExistingEntryToHead(entry)
|
||||
}
|
||||
cache.mutex.Unlock()
|
||||
return entry.Value, true
|
||||
}
|
||||
|
||||
// GetValue retrieves an entry using the key passed as parameter
|
||||
// Unlike Get, this function only returns the value
|
||||
func (cache *Cache) GetValue(key string) interface{} {
|
||||
value, _ := cache.Get(key)
|
||||
return value
|
||||
}
|
||||
|
||||
// GetByKeys retrieves multiple entries using the keys passed as parameter
|
||||
// All keys are returned in the map, regardless of whether they exist or not, however, entries that do not exist in the
|
||||
// cache will return nil, meaning that there is no way of determining whether a key genuinely has the value nil, or
|
||||
// whether it doesn't exist in the cache using only this function.
|
||||
func (cache *Cache) GetByKeys(keys []string) map[string]interface{} {
|
||||
entries := make(map[string]interface{})
|
||||
for _, key := range keys {
|
||||
entries[key], _ = cache.Get(key)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// GetAll retrieves all cache entries
|
||||
//
|
||||
// If the eviction policy is LeastRecentlyUsed, note that unlike Get and GetByKeys, this does not update the last access
|
||||
// timestamp. The reason for this is that since all cache entries will be accessed, updating the last access timestamp
|
||||
// would provide very little benefit while harming the ability to accurately determine the next key that will be evicted
|
||||
//
|
||||
// You should probably avoid using this if you have a lot of entries.
|
||||
//
|
||||
// GetKeysByPattern is a good alternative if you want to retrieve entries that you do not have the key for, as it only
|
||||
// retrieves the keys and does not trigger active eviction and has a parameter for setting a limit to the number of keys
|
||||
// you wish to retrieve.
|
||||
func (cache *Cache) GetAll() map[string]interface{} {
|
||||
entries := make(map[string]interface{})
|
||||
cache.mutex.Lock()
|
||||
for key, entry := range cache.entries {
|
||||
if entry.Expired() {
|
||||
cache.delete(key)
|
||||
continue
|
||||
}
|
||||
entries[key] = entry.Value
|
||||
}
|
||||
cache.stats.Hits += uint64(len(entries))
|
||||
cache.mutex.Unlock()
|
||||
return entries
|
||||
}
|
||||
|
||||
// GetKeysByPattern retrieves a slice of keys that match a given pattern
|
||||
// If the limit is set to 0, the entire cache will be searched for matching keys.
|
||||
// If the limit is above 0, the search will stop once the specified number of matching keys have been found.
|
||||
//
|
||||
// e.g.
|
||||
// cache.GetKeysByPattern("*some*", 0) will return all keys containing "some" in them
|
||||
// cache.GetKeysByPattern("*some*", 5) will return 5 keys (or less) containing "some" in them
|
||||
//
|
||||
// Note that GetKeysByPattern does not trigger active evictions, nor does it count as accessing the entry, the latter
|
||||
// only applying if the cache uses the LeastRecentlyUsed eviction policy.
|
||||
// The reason for that behavior is that these two (active eviction and access) only applies when you access the value
|
||||
// of the cache entry, and this function only returns the keys.
|
||||
func (cache *Cache) GetKeysByPattern(pattern string, limit int) []string {
|
||||
var matchingKeys []string
|
||||
cache.mutex.Lock()
|
||||
for key, value := range cache.entries {
|
||||
if value.Expired() {
|
||||
continue
|
||||
}
|
||||
if MatchPattern(pattern, key) {
|
||||
matchingKeys = append(matchingKeys, key)
|
||||
if limit > 0 && len(matchingKeys) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
cache.mutex.Unlock()
|
||||
return matchingKeys
|
||||
}
|
||||
|
||||
// Delete removes a key from the cache
|
||||
//
|
||||
// Returns false if the key did not exist.
|
||||
func (cache *Cache) Delete(key string) bool {
|
||||
cache.mutex.Lock()
|
||||
ok := cache.delete(key)
|
||||
cache.mutex.Unlock()
|
||||
return ok
|
||||
}
|
||||
|
||||
// DeleteAll deletes multiple entries based on the keys passed as parameter
|
||||
//
|
||||
// Returns the number of keys deleted
|
||||
func (cache *Cache) DeleteAll(keys []string) int {
|
||||
numberOfKeysDeleted := 0
|
||||
cache.mutex.Lock()
|
||||
for _, key := range keys {
|
||||
if cache.delete(key) {
|
||||
numberOfKeysDeleted++
|
||||
}
|
||||
}
|
||||
cache.mutex.Unlock()
|
||||
return numberOfKeysDeleted
|
||||
}
|
||||
|
||||
// Count returns the total amount of entries in the cache, regardless of whether they're expired or not
|
||||
func (cache *Cache) Count() int {
|
||||
cache.mutex.RLock()
|
||||
count := len(cache.entries)
|
||||
cache.mutex.RUnlock()
|
||||
return count
|
||||
}
|
||||
|
||||
// Clear deletes all entries from the cache
|
||||
func (cache *Cache) Clear() {
|
||||
cache.mutex.Lock()
|
||||
cache.entries = make(map[string]*Entry)
|
||||
cache.memoryUsage = 0
|
||||
cache.head = nil
|
||||
cache.tail = nil
|
||||
cache.mutex.Unlock()
|
||||
}
|
||||
|
||||
// TTL returns the time until the cache entry specified by the key passed as parameter
|
||||
// will be deleted.
|
||||
func (cache *Cache) TTL(key string) (time.Duration, error) {
|
||||
cache.mutex.RLock()
|
||||
entry, ok := cache.get(key)
|
||||
cache.mutex.RUnlock()
|
||||
if !ok {
|
||||
return 0, ErrKeyDoesNotExist
|
||||
}
|
||||
if entry.Expiration == NoExpiration {
|
||||
return 0, ErrKeyHasNoExpiration
|
||||
}
|
||||
timeUntilExpiration := time.Until(time.Unix(0, entry.Expiration))
|
||||
if timeUntilExpiration < 0 {
|
||||
// The key has already expired but hasn't been deleted yet.
|
||||
// From the client's perspective, this means that the cache entry doesn't exist
|
||||
return 0, ErrKeyDoesNotExist
|
||||
}
|
||||
return timeUntilExpiration, nil
|
||||
}
|
||||
|
||||
// Expire sets a key's expiration time
|
||||
//
|
||||
// A TTL of -1 means that the key will never expire
|
||||
// A TTL of 0 means that the key will expire immediately
|
||||
// If using LRU, note that this does not reset the position of the key
|
||||
//
|
||||
// Returns true if the cache key exists and has had its expiration time altered
|
||||
func (cache *Cache) Expire(key string, ttl time.Duration) bool {
|
||||
entry, ok := cache.get(key)
|
||||
if !ok || entry.Expired() {
|
||||
return false
|
||||
}
|
||||
if ttl != NoExpiration {
|
||||
entry.Expiration = time.Now().Add(ttl).UnixNano()
|
||||
} else {
|
||||
entry.Expiration = NoExpiration
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// get retrieves an entry using the key passed as parameter, but unlike Get, it doesn't update the access time or
|
||||
// move the position of the entry to the head
|
||||
func (cache *Cache) get(key string) (*Entry, bool) {
|
||||
entry, ok := cache.entries[key]
|
||||
return entry, ok
|
||||
}
|
||||
|
||||
func (cache *Cache) delete(key string) bool {
|
||||
entry, ok := cache.entries[key]
|
||||
if ok {
|
||||
if cache.maxMemoryUsage != NoMaxMemoryUsage {
|
||||
cache.memoryUsage -= entry.SizeInBytes()
|
||||
}
|
||||
cache.removeExistingEntryReferences(entry)
|
||||
delete(cache.entries, key)
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
// moveExistingEntryToHead replaces the current cache head for an existing entry
|
||||
func (cache *Cache) moveExistingEntryToHead(entry *Entry) {
|
||||
if !(entry == cache.head && entry == cache.tail) {
|
||||
cache.removeExistingEntryReferences(entry)
|
||||
}
|
||||
if entry != cache.head {
|
||||
entry.next = cache.head
|
||||
entry.previous = nil
|
||||
if cache.head != nil {
|
||||
cache.head.previous = entry
|
||||
}
|
||||
cache.head = entry
|
||||
}
|
||||
}
|
||||
|
||||
// removeExistingEntryReferences modifies the next and previous reference of an existing entry and re-links
|
||||
// the next and previous entry accordingly, as well as the cache head or/and the cache tail if necessary.
|
||||
// Note that it does not remove the entry from the cache, only the references.
|
||||
func (cache *Cache) removeExistingEntryReferences(entry *Entry) {
|
||||
if cache.tail == entry && cache.head == entry {
|
||||
cache.tail = nil
|
||||
cache.head = nil
|
||||
} else if cache.tail == entry {
|
||||
cache.tail = cache.tail.previous
|
||||
} else if cache.head == entry {
|
||||
cache.head = cache.head.next
|
||||
}
|
||||
if entry.previous != nil {
|
||||
entry.previous.next = entry.next
|
||||
}
|
||||
if entry.next != nil {
|
||||
entry.next.previous = entry.previous
|
||||
}
|
||||
entry.next = nil
|
||||
entry.previous = nil
|
||||
}
|
||||
|
||||
// evict removes the tail from the cache
|
||||
func (cache *Cache) evict() {
|
||||
if cache.tail == nil || len(cache.entries) == 0 {
|
||||
return
|
||||
}
|
||||
if cache.tail != nil {
|
||||
oldTail := cache.tail
|
||||
cache.removeExistingEntryReferences(oldTail)
|
||||
delete(cache.entries, oldTail.Key)
|
||||
if cache.maxMemoryUsage != NoMaxMemoryUsage {
|
||||
cache.memoryUsage -= oldTail.SizeInBytes()
|
||||
}
|
||||
cache.stats.EvictedKeys++
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user