Compare commits

...

47 Commits

Author SHA1 Message Date
TwiN
ab2bee9c4b chore!: Update module from v3 to v4 2022-06-20 21:25:14 -04:00
TwiN
d1ced94030 fix(badge): Regenerate assets and tweak health badge width 2022-06-20 14:27:05 -04:00
asymness
a3e35c862c feat(badge): Implement UP/DOWN status badge (#291)
* Implement status badge endpoint

* Update integration tests for status badge generation

* Add status badge in the UI

* Update static assets

* Update README with status badge description

* Rename constants to pascal-case

* Check for success of the endpoint conditions

* Rename status badge to health badge
2022-06-20 13:59:45 -04:00
TwiN
0193a200b8 refactor(ci): Wrap benchmark workflow inputs with quotes 2022-06-19 22:37:19 -04:00
TwiN
7224464202 fix(ci): Set default repository to TwiN/gatus 2022-06-18 14:09:24 -04:00
TwiN
c457aadcab feat(ci): Add benchmark workflow 2022-06-18 14:04:11 -04:00
TwiN
f38b12d55b refactor(ci): Clean up steps 2022-06-18 13:06:24 -04:00
TwiN
e4c9ad8796 chore(ci): Update actions/checkout to v3 2022-06-18 12:58:26 -04:00
TwiN
5be1465b13 refactor(ci): Uniformize job names 2022-06-18 12:50:31 -04:00
TwiN
7215aa4bd6 docs(metrics): Update Grafana/Prometheus example 2022-06-18 12:42:41 -04:00
TwiN
829a9c2679 fix(dns): Use Cloudflare's DNS instead of Google's DNS 2022-06-16 20:21:44 -04:00
TwiN
dfcdc57a18 test(dns): Fix case with inconsistent results 2022-06-16 20:09:25 -04:00
TwiN
43e8c57701 test(dns): Fix case with inconsistent results 2022-06-16 20:02:46 -04:00
TwiN
076f5c45e8 test(metrics): Improve test coverage 2022-06-16 20:02:46 -04:00
TwiN
6d3c3d0892 refactor(metrics): Rename metric to metrics 2022-06-16 20:02:46 -04:00
TwiN
e620fd1214 docs: List possible values for Result.DNSRCode 2022-06-16 20:02:46 -04:00
asymness
5807d76c2f feat(ui): Implement parameter to hide URL from results (#294)
* Add support for HideURL UI config parameter

* Redact whole URL when hide-url parameter is set to true

* Add integration test for hide-url functionality

* Document the hide-url config parameter in README

* Apply suggestions from code review

Co-authored-by: TwiN <twin@linux.com>

* Update test to have client config with 1ms timeout

* Re-align README tables

* Update core/endpoint_test.go

* Update core/endpoint_test.go

Co-authored-by: TwiN <twin@linux.com>
2022-06-16 17:53:03 -04:00
mindcrime-ilab
017847240d feat(alerting): Add overrides for Mattermost (#292)
* add override support for mattermost

* add documentation for override Mattermost webhooks

* Apply suggestions from code review

Co-authored-by: TwiN <twin@linux.com>

* fix formatting

Co-authored-by: Michael Engelhardt <me@mindcrime.dev>
Co-authored-by: TwiN <twin@linux.com>
2022-06-15 23:25:37 -04:00
TwiN
c873b0ba0c feat(security)!: Remove deprecated SHA512 parameter for password hashing 2022-06-14 23:48:28 -04:00
TwiN
6f3150d936 feat(api)!: Remove deprecated paths 2022-06-14 23:40:26 -04:00
TwiN
0792f5490b feat(storage)!: Remove persistence for memory storage 2022-06-14 23:36:18 -04:00
TwiN
326ea1c3d1 refactor(client): Clean up client dns resolver 2022-06-13 20:35:51 -04:00
TwiN
fea95b8479 perf(storage): Improve benchmarks and fix race condition 2022-06-13 20:35:51 -04:00
TwiN
6d64c3c250 chore: Reformat some code and docs 2022-06-12 19:18:58 -04:00
TwiN
2b9d3e99d3 refactor: Fix indent 2022-06-12 19:18:50 -04:00
TwiN
9a5f245440 chore(ui): Update dependencies and rebuild static assets 2022-06-12 19:18:50 -04:00
TwiN
793172c783 feat(ux): Display loading animation while waiting for data to be retrieved
Fixes #275
2022-06-12 19:18:50 -04:00
TwiN
9f343bacf7 chore(ui): Move prettifyTimeDifference to helper as generatePrettyTimeDifference 2022-06-12 19:18:50 -04:00
TwiN
c31cb7540d fix(ui): Second shouldn't be plural if value is 1 2022-06-12 19:18:50 -04:00
TwiN
f9efa28223 fix(ui): Set default refresh interval to 300 (5m) 2022-06-12 19:18:50 -04:00
Andre Bindewald
2cbb35fe3b feat(client): Added client configuration option for using a custom DNS resolver (#284) 2022-06-12 18:45:08 -04:00
TwiN
f23fcbedb8 docs: Specify what type of applications can be monitored with TCP 2022-06-12 16:32:08 -04:00
TwiN
ad10f975b4 docs: Set example interval to 5m 2022-06-12 16:31:43 -04:00
TwiN
1c03524ca8 chore(alerting): Order types alphabetically 2022-06-12 14:18:18 -04:00
TwiN
4af135d1fb docs: Fix table formatting 2022-06-11 22:17:34 -04:00
TwiN
93b5a867bb chore(alerting): Add missing opsgenie compile-time interface validation 2022-06-07 19:43:23 -04:00
TwiN
f899f41d16 feat(alerting): Add ENDPOINT_GROUP and ENDPOINT_URL placeholders for custom provider
related: #282

note: this also phases out the deprecated [SERVICE_NAME] placeholder
2022-06-07 19:37:42 -04:00
TwiN
ab52676f23 build: Prevent Makefile test target from accidentally targeting test folder 2022-06-07 18:04:56 -04:00
mani9223-oss
27fc784411 feat(alerting): Add group-specific WebHook URL for Slack (#279) 2022-05-30 22:03:09 -04:00
Chris Grindstaff
d929c09c56 docs(cert): list valid units for CERTIFICATE_EXPIRATION (#285)
Fixes #246
2022-05-29 15:14:25 -04:00
TwiN
cff06e38cb docs(kubernetes): Add probes to example 2022-05-25 23:59:34 -04:00
TwiN
5b1aeaeb0c chore(test): Use io instead of io/ioutil 2022-05-16 22:19:42 -04:00
TwiN
90e9b55109 docs(metrics): Document available metrics 2022-05-16 22:18:38 -04:00
wei
cf9c00a2ad feat(metrics): Add more metrics (#278)
* add gatus_results_success and gatus_results_duration_seconds

* add metrics namespace

* add result http metrics

* add more metrics

* update

* extract endpoint type method

* initializedMetrics

* remove too many metrics

* update naming

* chore(metrics): Refactor code and merge results_dns_return_code_total, results_http_status_code_total into results_code_total

* docs(metrics): Update results_certificate_expiration_seconds description

* add TestEndpoint_Type

* remove name in table test

Co-authored-by: TwiN <twin@linux.com>
2022-05-16 21:10:45 -04:00
TwiN
fbdb5a3f0f test(maintenance): Add tests for edge cases 2022-05-07 16:46:51 -04:00
Bo-Yi Wu
dde930bed7 feat(alerting): Add group-specific WebHook URL for Google Chat (#272) 2022-05-07 14:34:21 -04:00
TwiN
a9fc876173 docs: Update description 2022-04-28 17:56:02 -04:00
163 changed files with 2953 additions and 9305 deletions

View File

@@ -1,23 +1,41 @@
## Usage
Gatus exposes Prometheus metrics at `/metrics` if the `metrics` configuration option is set to `true`.
To run this example, all you need to do is execute the following command:
```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.
![Gatus Grafana dashboard](../../.github/assets/grafana-dashboard.png)
Total results per minute:
## Queries
By default, this example has a Grafana dashboard with some panels, but for the sake of verbosity, you'll find
a list of simple queries below. Those make use of the `key` parameter, which is a concatenation of the endpoint's
group and name.
### Success rate
```
sum(rate(gatus_results_total{success="true"}[30s])) by (key) / sum(rate(gatus_results_total[30s])) by (key)
```
### Response time
```
gatus_results_duration_seconds
```
### Total results per minute
```
sum(rate(gatus_results_total[5m])*60) by (key)
```
Total successful results per minute:
### Total successful results per minute
```
sum(rate(gatus_results_total{success="true"}[5m])*60) by (key)
```
Total unsuccessful results per minute:
### Total unsuccessful results per minute
```
sum(rate(gatus_results_total{success="false"}[5m])*60) by (key)
```
sum(rate(gatus_results_total{success="true"}[5m])*60) by (key)
```

View File

@@ -2,15 +2,18 @@ metrics: true
endpoints:
- name: website
url: https://twin.sh/health
interval: 30s
interval: 5m
conditions:
- "[STATUS] == 200"
- name: example
url: https://example.com/
interval: 5m
conditions:
- "[STATUS] == 200"
- name: github
url: https://api.github.com/healthz
interval: 5m
conditions:
- "[STATUS] == 200"
- name: example
url: https://example.com/
conditions:
- "[STATUS] == 200"

View File

@@ -15,8 +15,266 @@
"editable": true,
"gnetId": null,
"graphTooltip": 0,
"id": 3,
"links": [],
"panels": [
{
"cacheTimeout": null,
"datasource": null,
"description": "Number of successful results compared to the total number of results during the current interval",
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"id": 9,
"links": [],
"options": {
"fieldOptions": {
"calcs": [
"mean"
],
"defaults": {
"mappings": [
{
"id": 0,
"op": "=",
"text": "N/A",
"type": 1,
"value": "null"
}
],
"max": 1,
"min": 0,
"nullValueMode": "connected",
"thresholds": [
{
"color": "red",
"value": null
},
{
"color": "semi-dark-orange",
"value": 0.6
},
{
"color": "yellow",
"value": 0.8
},
{
"color": "dark-green",
"value": 0.95
}
],
"unit": "percentunit"
},
"override": {},
"values": false
},
"orientation": "horizontal",
"showThresholdLabels": false,
"showThresholdMarkers": false
},
"pluginVersion": "6.4.4",
"targets": [
{
"expr": "sum(rate(gatus_results_total{success=\"true\"}[30s])) by (key) / sum(rate(gatus_results_total[30s])) by (key)",
"hide": false,
"legendFormat": "{{key}}",
"refId": "B"
}
],
"timeFrom": null,
"timeShift": null,
"title": "Success rate",
"type": "gauge"
},
{
"aliasColors": {},
"bars": false,
"cacheTimeout": null,
"dashLength": 10,
"dashes": false,
"datasource": null,
"fill": 1,
"fillGradient": 0,
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 0
},
"id": 11,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 1,
"links": [],
"nullPointMode": "null as zero",
"options": {
"dataLinks": []
},
"percentage": false,
"pluginVersion": "6.4.4",
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "gatus_results_duration_seconds",
"format": "time_series",
"instant": false,
"interval": "",
"intervalFactor": 1,
"legendFormat": "{{key}}",
"refId": "A"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Response time",
"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,
"cacheTimeout": null,
"dashLength": 10,
"dashes": false,
"datasource": null,
"fill": 1,
"fillGradient": 0,
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 8
},
"id": 10,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 1,
"links": [],
"nullPointMode": "connected",
"options": {
"dataLinks": []
},
"percentage": false,
"pluginVersion": "6.4.4",
"pointradius": 2,
"points": true,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "sum(rate(gatus_results_total{success=\"true\"}[30s])) by (key) / sum(rate(gatus_results_total[30s])) by (key)",
"format": "time_series",
"instant": false,
"interval": "",
"intervalFactor": 1,
"legendFormat": "{{key}}",
"refId": "A"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Success rate",
"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,
@@ -27,10 +285,10 @@
"fill": 1,
"fillGradient": 0,
"gridPos": {
"h": 7,
"h": 8,
"w": 12,
"x": 0,
"y": 0
"x": 12,
"y": 8
},
"id": 2,
"interval": "",
@@ -126,8 +384,8 @@
"gridPos": {
"h": 7,
"w": 12,
"x": 12,
"y": 0
"x": 0,
"y": 16
},
"id": 5,
"legend": {
@@ -204,94 +462,6 @@
"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,
@@ -304,7 +474,7 @@
"h": 7,
"w": 12,
"x": 12,
"y": 7
"y": 16
},
"id": 3,
"legend": {
@@ -380,7 +550,7 @@
}
}
],
"refresh": "10s",
"refresh": "1m",
"schemaVersion": 20,
"style": "dark",
"tags": [],
@@ -391,9 +561,22 @@
"from": "now-1h",
"to": "now"
},
"timepicker": {},
"timepicker": {
"refresh_intervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
]
},
"timezone": "",
"title": "Gatus",
"uid": "KPI7Qj1Wk",
"version": 1
"version": 2
}

View File

@@ -26,7 +26,7 @@ endpoints:
- "[STATUS] == 200"
- name: example-dns-query
url: "8.8.8.8" # Address of the DNS server to use
url: "1.1.1.1" # Address of the DNS server to use
interval: 5m
dns:
query-name: "example.com"

View File

@@ -26,7 +26,7 @@ endpoints:
- "[STATUS] == 200"
- name: example-dns-query
url: "8.8.8.8" # Address of the DNS server to use
url: "1.1.1.1" # Address of the DNS server to use
interval: 5m
dns:
query-name: "example.com"

View File

@@ -54,10 +54,10 @@ spec:
app: gatus
template:
metadata:
labels:
app: gatus
name: gatus
namespace: kube-system
labels:
app: gatus
spec:
serviceAccountName: gatus
terminationGracePeriodSeconds: 5
@@ -76,6 +76,22 @@ spec:
requests:
cpu: 50m
memory: 30M
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
successThreshold: 1
failureThreshold: 3
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
successThreshold: 1
failureThreshold: 5
volumeMounts:
- mountPath: /config
name: gatus-config

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

26
.github/workflows/benchmark.yml vendored Normal file
View File

@@ -0,0 +1,26 @@
name: benchmark
on:
workflow_dispatch:
inputs:
repository:
description: "Repository to checkout. Useful for benchmarking a fork. Format should be <owner>/<repository>."
required: true
default: "TwiN/gatus"
ref:
description: "Branch, tag or SHA to checkout"
required: true
default: "master"
jobs:
build:
name: benchmark
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/setup-go@v3
with:
go-version: 1.18
repository: "${{ github.event.inputs.repository }}"
ref: "${{ github.event.inputs.ref }}"
- uses: actions/checkout@v3
- name: Benchmark
run: go test -bench=. ./storage/store

View File

@@ -10,16 +10,14 @@ on:
- '*.md'
jobs:
build:
name: Build
name: build
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Set up Go
uses: actions/setup-go@v3
- uses: actions/setup-go@v3
with:
go-version: 1.18
- name: Check out code into the Go module directory
uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Build binary to make sure it works
run: go build -mod vendor
- name: Test

View File

@@ -6,13 +6,12 @@ on:
types: [completed]
jobs:
publish-latest:
name: Publish latest
name: publish-latest
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
timeout-minutes: 45
steps:
- name: Check out code
uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Get image repository
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Set up QEMU

View File

@@ -4,12 +4,11 @@ on:
types: [published]
jobs:
publish-release:
name: Publish release
name: publish-release
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Check out code
uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Get image repository
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Get the release

View File

@@ -1,5 +1,8 @@
BINARY=gatus
# Because there's a folder called "test", we need to make the target "test" phony
.PHONY: test
install:
go build -mod vendor -o $(BINARY) .
@@ -10,7 +13,7 @@ clean:
rm $(BINARY)
test:
sudo go test ./alerting/... ./client/... ./config/... ./controller/... ./core/... ./jsonpath/... ./pattern/... ./security/... ./storage/... ./util/... ./watchdog/... -cover
go test ./... -cover
##########

131
README.md
View File

@@ -7,7 +7,7 @@
[![Docker pulls](https://img.shields.io/docker/pulls/twinproduction/gatus.svg)](https://cloud.docker.com/repository/docker/twinproduction/gatus)
[![Follow TwiN](https://img.shields.io/github/followers/TwiN?label=Follow&style=social)](https://github.com/TwiN)
Gatus is a health dashboard that gives you the ability to monitor your services using HTTP, ICMP, TCP, and even DNS
Gatus is a developer-oriented health dashboard that gives you the ability to monitor your services using HTTP, ICMP, TCP, and even DNS
queries as well as evaluate the result of said queries by using a list of conditions on values like the status code,
the response time, the certificate expiration, the body and many others. The icing on top is that each of these health
checks can be paired with alerting via Slack, PagerDuty, Discord, Twilio and more.
@@ -56,6 +56,7 @@ Have any feedback or want to share your good/bad experience with Gatus? Feel fre
- [Security](#security)
- [Basic](#basic)
- [OIDC (ALPHA)](#oidc-alpha)
- [Metrics](#metrics)
- [Deployment](#deployment)
- [Docker](#docker)
- [Helm Chart](#helm-chart)
@@ -77,6 +78,7 @@ Have any feedback or want to share your good/bad experience with Gatus? Feel fre
- [Exposing Gatus on a custom port](#exposing-gatus-on-a-custom-port)
- [Badges](#badges)
- [Uptime](#uptime)
- [Health](#health)
- [Response time](#response-time)
- [API](#api)
- [High level design overview](#high-level-design-overview)
@@ -172,7 +174,8 @@ If you want to test it locally, see [Docker](#docker).
| `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.hide-hostname` | Whether to hide the hostname in the result. | `false` |
| `endpoints[].ui.hide-url` | Whether to ensure the URL is not displayed in the results. Useful if the URL contains a token. | `false` |
| `endpoints[].ui.dont-resolve-failed-conditions` | Whether to resolve failed conditions for the UI. | `false` |
| `alerting` | [Alerting configuration](#alerting). | `{}` |
| `security` | [Security configuration](#security). | `{}` |
@@ -219,15 +222,15 @@ Here are some examples of conditions you can use:
#### 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 (valid units are "s", "m", "h".) | `24h`, `48h`, 0 (if not protocol with certs) |
| `[DNS_RCODE]` | Resolves into the DNS status of the response | NOERROR |
#### Functions
@@ -281,6 +284,7 @@ the client used to send the request.
| `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.dns-resolver` | Override the DNS resolver using the format `{proto}://{host}:{port}`. | `""` |
| `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 `""` |
@@ -312,11 +316,22 @@ endpoints:
- "[STATUS] == 200"
```
This example shows how you can specify a custom DNS resolver:
```yaml
endpoints:
- name: with-custom-dns-resolver
url: "https://your.health.api/health"
client:
dns-resolver: "tcp://1.1.1.1:53"
conditions:
- "[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"
- name: with-custom-oauth2
url: "https://your.health.api/health"
client:
oauth2:
token-url: https://your-token-server/token
@@ -368,7 +383,7 @@ alerting:
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 30s
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
@@ -442,12 +457,16 @@ endpoints:
**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 |
| 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 |
| `alerting.googlechat.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.googlechat.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.googlechat.overrides[].webhook-url` | Teams Webhook URL | `""` |
```yaml
alerting:
@@ -470,12 +489,15 @@ endpoints:
```
#### 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 |
| `alerting.mattermost.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.mattermost.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.mattermist.overrides[].webhook-url` | Mattermost Webhook URL | `""` |
```yaml
alerting:
@@ -621,12 +643,14 @@ 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 |
| `alerting.slack.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.slack.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.slack.overrides[].webhook-url` | Slack Webhook URL | `""` |
```yaml
alerting:
slack:
@@ -796,8 +820,11 @@ leveraging Gatus, you could have Gatus call that application endpoint when an en
would then check if the endpoint that started failing was part of the recently deployed application, and if it was,
then automatically roll it back.
The placeholders `[ALERT_DESCRIPTION]` and `[ENDPOINT_NAME]` are automatically substituted for the alert description and
the endpoint name. These placeholders can be used in the body (`alerting.custom.body`) and in the url (`alerting.custom.url`).
Furthermore, you may use the following placeholders in the body (`alerting.custom.body`) and in the url (`alerting.custom.url`):
- `[ALERT_DESCRIPTION]` (resolved from `endpoints[].alerts[].description`)
- `[ENDPOINT_NAME]` (resolved from `endpoints[].name`)
- `[ENDPOINT_GROUP]` (resolved from `endpoints[].group`)
- `[ENDPOINT_URL]` (resolved from `endpoints[].url`)
If you have an alert using the `custom` provider with `send-on-resolved` set to `true`, you can use the
`[ALERT_TRIGGERED_OR_RESOLVED]` placeholder to differentiate the notifications.
@@ -812,7 +839,7 @@ alerting:
method: "POST"
body: |
{
"text": "[ALERT_TRIGGERED_OR_RESOLVED]: [ENDPOINT_NAME] - [ALERT_DESCRIPTION]"
"text": "[ALERT_TRIGGERED_OR_RESOLVED]: [ENDPOINT_GROUP] - [ENDPOINT_NAME] - [ALERT_DESCRIPTION]"
}
endpoints:
- name: website
@@ -1017,6 +1044,21 @@ security:
**NOTE:** The OIDC feature is currently in Alpha. Breaking changes may occur. Use this feature at your own risk.
### Metrics
To enable metrics, you must set `metrics` to `true`. Doing so will expose Prometheus-friendly metrics at the `/metrics`
endpoint on the same port your application is configured to run on (`web.port`).
| Metric name | Type | Description | Labels | Relevant endpoint types |
|:---------------------------------------------|:--------|:---------------------------------------------------------------------------|:--------------------------------|:------------------------|
| gatus_results_total | counter | Number of results per endpoint | key, group, name, type, success | All |
| gatus_results_code_total | counter | Total number of results by code | key, group, name, type, code | DNS, HTTP |
| gatus_results_connected_total | counter | Total number of results in which a connection was successfully established | key, group, name, type | All |
| gatus_results_duration_seconds | gauge | Duration of the request in seconds | key, group, name, type | All |
| gatus_results_certificate_expiration_seconds | gauge | Number of seconds until the certificate expires | key, group, name, type | HTTP, STARTTLS |
See [examples/docker-compose-grafana-prometheus](.examples/docker-compose-grafana-prometheus) for further documentation as well as an example.
## Deployment
Many examples can be found in the [.examples](.examples) folder, but this section will focus on the most popular ways of deploying Gatus.
@@ -1160,6 +1202,8 @@ endpoints:
Placeholders `[STATUS]` and `[BODY]` as well as the fields `endpoints[].body`, `endpoints[].headers`,
`endpoints[].method` and `endpoints[].graphql` are not supported for TCP endpoints.
This works for applications such as databases (Postgres, MySQL, etc.) and caches (Redis, Memcached, etc.).
**NOTE**: `[CONNECTED] == true` does not guarantee that the endpoint itself is healthy - it only guarantees that there's
something at the given address listening to the given port, and that a connection to that address was successfully
established.
@@ -1364,6 +1408,23 @@ Example:
If you'd like to see a visual example of each badge available, you can simply navigate to the endpoint's detail page.
#### Health
![Health](https://status.twin.sh/api/v1/endpoints/core_blog-external/health/badge.svg)
The path to generate a badge is the following:
```
/api/v1/endpoints/{key}/health/badge.svg
```
Where:
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`.
For instance, if you want the current status of the endpoint `frontend` in the group `core`,
the URL would look like this:
```
https://example.com/api/v1/endpoints/core_frontend/health/badge.svg
```
#### Response time
![Response time 1h](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/1h/badge.svg)
![Response time 24h](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/24h/badge.svg)

View File

@@ -1,20 +1,20 @@
package alerting
import (
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/alerting/provider"
"github.com/TwiN/gatus/v3/alerting/provider/custom"
"github.com/TwiN/gatus/v3/alerting/provider/discord"
"github.com/TwiN/gatus/v3/alerting/provider/email"
"github.com/TwiN/gatus/v3/alerting/provider/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"
"github.com/TwiN/gatus/v3/alerting/provider/telegram"
"github.com/TwiN/gatus/v3/alerting/provider/twilio"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/alerting/provider"
"github.com/TwiN/gatus/v4/alerting/provider/custom"
"github.com/TwiN/gatus/v4/alerting/provider/discord"
"github.com/TwiN/gatus/v4/alerting/provider/email"
"github.com/TwiN/gatus/v4/alerting/provider/googlechat"
"github.com/TwiN/gatus/v4/alerting/provider/mattermost"
"github.com/TwiN/gatus/v4/alerting/provider/messagebird"
"github.com/TwiN/gatus/v4/alerting/provider/opsgenie"
"github.com/TwiN/gatus/v4/alerting/provider/pagerduty"
"github.com/TwiN/gatus/v4/alerting/provider/slack"
"github.com/TwiN/gatus/v4/alerting/provider/teams"
"github.com/TwiN/gatus/v4/alerting/provider/telegram"
"github.com/TwiN/gatus/v4/alerting/provider/twilio"
)
// Config is the configuration for alerting providers
@@ -65,12 +65,6 @@ 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
@@ -83,6 +77,12 @@ func (config Config) GetAlertingProviderByAlertType(alertType alert.Type) provid
return nil
}
return config.Email
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.TypeMattermost:
if config.Mattermost == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil

View File

@@ -7,9 +7,9 @@ import (
"net/http"
"strings"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
)
// AlertProvider is the configuration necessary for sending an alert using a custom HTTP request
@@ -50,48 +50,28 @@ func (provider *AlertProvider) GetAlertStatePlaceholderValue(resolved bool) stri
return status
}
func (provider *AlertProvider) buildHTTPRequest(endpointName, alertDescription string, resolved bool) *http.Request {
body := provider.Body
providerURL := provider.URL
method := provider.Method
if strings.Contains(body, "[ALERT_DESCRIPTION]") {
body = strings.ReplaceAll(body, "[ALERT_DESCRIPTION]", alertDescription)
}
if strings.Contains(body, "[SERVICE_NAME]") { // XXX: Remove this in v4.0.0
body = strings.ReplaceAll(body, "[SERVICE_NAME]", endpointName)
}
if strings.Contains(body, "[ENDPOINT_NAME]") {
body = strings.ReplaceAll(body, "[ENDPOINT_NAME]", endpointName)
}
if strings.Contains(body, "[ALERT_TRIGGERED_OR_RESOLVED]") {
if resolved {
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
} else {
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(false))
}
}
if strings.Contains(providerURL, "[ALERT_DESCRIPTION]") {
providerURL = strings.ReplaceAll(providerURL, "[ALERT_DESCRIPTION]", alertDescription)
}
if strings.Contains(providerURL, "[SERVICE_NAME]") { // XXX: Remove this in v4.0.0
providerURL = strings.ReplaceAll(providerURL, "[SERVICE_NAME]", endpointName)
}
if strings.Contains(providerURL, "[ENDPOINT_NAME]") {
providerURL = strings.ReplaceAll(providerURL, "[ENDPOINT_NAME]", endpointName)
}
if strings.Contains(providerURL, "[ALERT_TRIGGERED_OR_RESOLVED]") {
if resolved {
providerURL = strings.ReplaceAll(providerURL, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
} else {
providerURL = strings.ReplaceAll(providerURL, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(false))
}
func (provider *AlertProvider) buildHTTPRequest(endpoint *core.Endpoint, alert *alert.Alert, resolved bool) *http.Request {
body, url, method := provider.Body, provider.URL, provider.Method
body = strings.ReplaceAll(body, "[ALERT_DESCRIPTION]", alert.GetDescription())
url = strings.ReplaceAll(url, "[ALERT_DESCRIPTION]", alert.GetDescription())
body = strings.ReplaceAll(body, "[ENDPOINT_NAME]", endpoint.Name)
url = strings.ReplaceAll(url, "[ENDPOINT_NAME]", endpoint.Name)
body = strings.ReplaceAll(body, "[ENDPOINT_GROUP]", endpoint.Group)
url = strings.ReplaceAll(url, "[ENDPOINT_GROUP]", endpoint.Group)
body = strings.ReplaceAll(body, "[ENDPOINT_URL]", endpoint.URL)
url = strings.ReplaceAll(url, "[ENDPOINT_URL]", endpoint.URL)
if resolved {
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
} else {
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(false))
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(false))
}
if len(method) == 0 {
method = http.MethodGet
}
bodyBuffer := bytes.NewBuffer([]byte(body))
request, _ := http.NewRequest(method, providerURL, bodyBuffer)
request, _ := http.NewRequest(method, url, bodyBuffer)
for k, v := range provider.Headers {
request.Header.Set(k, v)
}
@@ -99,7 +79,7 @@ func (provider *AlertProvider) buildHTTPRequest(endpointName, alertDescription s
}
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
request := provider.buildHTTPRequest(endpoint.Name, alert.GetDescription(), resolved)
request := provider.buildHTTPRequest(endpoint, alert, resolved)
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
if err != nil {
return err

View File

@@ -1,14 +1,15 @@
package custom
import (
"fmt"
"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"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/test"
)
func TestAlertProvider_IsValid(t *testing.T) {
@@ -99,77 +100,103 @@ func TestAlertProvider_Send(t *testing.T) {
}
}
func TestAlertProvider_buildHTTPRequestWhenResolved(t *testing.T) {
const (
ExpectedURL = "https://example.com/endpoint-name?event=RESOLVED&description=alert-description"
ExpectedBody = "endpoint-name,alert-description,RESOLVED"
)
func TestAlertProvider_buildHTTPRequest(t *testing.T) {
customAlertProvider := &AlertProvider{
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[ENDPOINT_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
Headers: nil,
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]&url=[ENDPOINT_URL]",
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ENDPOINT_URL],[ALERT_TRIGGERED_OR_RESOLVED]",
}
request := customAlertProvider.buildHTTPRequest("endpoint-name", "alert-description", true)
if request.URL.String() != ExpectedURL {
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
alertDescription := "alert-description"
scenarios := []struct {
AlertProvider *AlertProvider
Resolved bool
ExpectedURL string
ExpectedBody string
}{
{
AlertProvider: customAlertProvider,
Resolved: true,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=RESOLVED&description=alert-description&url=https://example.com",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,RESOLVED",
},
{
AlertProvider: customAlertProvider,
Resolved: false,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=TRIGGERED&description=alert-description&url=https://example.com",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,TRIGGERED",
},
}
body, _ := io.ReadAll(request.Body)
if string(body) != ExpectedBody {
t.Error("expected body to be", ExpectedBody, "was", string(body))
}
}
func TestAlertProvider_buildHTTPRequestWhenTriggered(t *testing.T) {
const (
ExpectedURL = "https://example.com/endpoint-name?event=TRIGGERED&description=alert-description"
ExpectedBody = "endpoint-name,alert-description,TRIGGERED"
)
customAlertProvider := &AlertProvider{
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[ENDPOINT_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
Headers: map[string]string{"Authorization": "Basic hunter2"},
}
request := customAlertProvider.buildHTTPRequest("endpoint-name", "alert-description", false)
if request.URL.String() != ExpectedURL {
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
}
body, _ := io.ReadAll(request.Body)
if string(body) != ExpectedBody {
t.Error("expected body to be", ExpectedBody, "was", string(body))
for _, scenario := range scenarios {
t.Run(fmt.Sprintf("resolved-%v-with-default-placeholders", scenario.Resolved), func(t *testing.T) {
request := customAlertProvider.buildHTTPRequest(
&core.Endpoint{Name: "endpoint-name", Group: "endpoint-group", URL: "https://example.com"},
&alert.Alert{Description: &alertDescription},
scenario.Resolved,
)
if request.URL.String() != scenario.ExpectedURL {
t.Error("expected URL to be", scenario.ExpectedURL, "got", request.URL.String())
}
body, _ := io.ReadAll(request.Body)
if string(body) != scenario.ExpectedBody {
t.Error("expected body to be", scenario.ExpectedBody, "got", string(body))
}
})
}
}
func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
const (
ExpectedURL = "https://example.com/endpoint-name?event=test&description=alert-description"
ExpectedBody = "endpoint-name,alert-description,test"
)
customAlertProvider := &AlertProvider{
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[ENDPOINT_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
Headers: nil,
Placeholders: map[string]map[string]string{
"ALERT_TRIGGERED_OR_RESOLVED": {
"RESOLVED": "test",
"RESOLVED": "fixed",
"TRIGGERED": "boom",
},
},
}
request := customAlertProvider.buildHTTPRequest("endpoint-name", "alert-description", true)
if request.URL.String() != ExpectedURL {
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
alertDescription := "alert-description"
scenarios := []struct {
AlertProvider *AlertProvider
Resolved bool
ExpectedURL string
ExpectedBody string
}{
{
AlertProvider: customAlertProvider,
Resolved: true,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=fixed&description=alert-description",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,fixed",
},
{
AlertProvider: customAlertProvider,
Resolved: false,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=boom&description=alert-description",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,boom",
},
}
body, _ := io.ReadAll(request.Body)
if string(body) != ExpectedBody {
t.Error("expected body to be", ExpectedBody, "was", string(body))
for _, scenario := range scenarios {
t.Run(fmt.Sprintf("resolved-%v-with-custom-placeholders", scenario.Resolved), func(t *testing.T) {
request := customAlertProvider.buildHTTPRequest(
&core.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&alert.Alert{Description: &alertDescription},
scenario.Resolved,
)
if request.URL.String() != scenario.ExpectedURL {
t.Error("expected URL to be", scenario.ExpectedURL, "got", request.URL.String())
}
body, _ := io.ReadAll(request.Body)
if string(body) != scenario.ExpectedBody {
t.Error("expected body to be", scenario.ExpectedBody, "got", string(body))
}
})
}
}
func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) {
customAlertProvider := &AlertProvider{
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[ENDPOINT_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
Headers: nil,
Placeholders: nil,
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
}
if customAlertProvider.GetAlertStatePlaceholderValue(true) != "RESOLVED" {
t.Error("expected RESOLVED, got", customAlertProvider.GetAlertStatePlaceholderValue(true))
@@ -187,26 +214,3 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
t.Error("expected default alert to be nil")
}
}
// TestAlertProvider_isBackwardCompatibleWithServiceRename checks if the custom alerting provider still supports
// service placeholders after the migration from "service" to "endpoint"
//
// XXX: Remove this in v4.0.0
func TestAlertProvider_isBackwardCompatibleWithServiceRename(t *testing.T) {
const (
ExpectedURL = "https://example.com/endpoint-name?event=TRIGGERED&description=alert-description"
ExpectedBody = "endpoint-name,alert-description,TRIGGERED"
)
customAlertProvider := &AlertProvider{
URL: "https://example.com/[SERVICE_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[SERVICE_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
}
request := customAlertProvider.buildHTTPRequest("endpoint-name", "alert-description", false)
if request.URL.String() != ExpectedURL {
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
}
body, _ := io.ReadAll(request.Body)
if string(body) != ExpectedBody {
t.Error("expected body to be", ExpectedBody, "was", string(body))
}
}

View File

@@ -6,9 +6,9 @@ import (
"io"
"net/http"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
)
// AlertProvider is the configuration necessary for sending an alert using Discord

View File

@@ -5,10 +5,10 @@ import (
"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"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/test"
)
func TestAlertProvider_IsValid(t *testing.T) {

View File

@@ -5,8 +5,8 @@ import (
"math"
"strings"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/core"
gomail "gopkg.in/mail.v2"
)

View File

@@ -3,8 +3,8 @@ package email
import (
"testing"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/core"
)
func TestAlertDefaultProvider_IsValid(t *testing.T) {

View File

@@ -6,9 +6,9 @@ import (
"io"
"net/http"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
)
// AlertProvider is the configuration necessary for sending an alert using Google chat
@@ -20,6 +20,15 @@ type AlertProvider struct {
// 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"`
WebhookURL string `yaml:"webhook-url"`
}
// IsValid returns whether the provider's configuration is valid
@@ -27,13 +36,24 @@ func (provider *AlertProvider) IsValid() bool {
if provider.ClientConfig == nil {
provider.ClientConfig = client.GetDefaultConfig()
}
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
return false
}
registeredGroups[override.Group] = true
}
}
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)
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer)
if err != nil {
return err
}
@@ -118,6 +138,18 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
}`, endpoint.Name, endpoint.Group, message, description, results, endpoint.URL)
}
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
return override.WebhookURL
}
}
}
return provider.WebhookURL
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert

View File

@@ -5,13 +5,13 @@ import (
"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"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/test"
)
func TestAlertProvider_IsValid(t *testing.T) {
func TestAlertDefaultProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{WebhookURL: ""}
if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid")
@@ -22,6 +22,43 @@ func TestAlertProvider_IsValid(t *testing.T) {
}
}
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
WebhookURL: "http://example.com",
Group: "",
},
},
}
if providerWithInvalidOverrideGroup.IsValid() {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
WebhookURL: "",
Group: "group",
},
},
}
if providerWithInvalidOverrideTo.IsValid() {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
WebhookURL: "http://example.com",
Overrides: []Override{
{
WebhookURL: "http://example.com",
Group: "group",
},
},
}
if !providerWithValidOverride.IsValid() {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
@@ -158,3 +195,66 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
tests := []struct {
Name string
Provider AlertProvider
InputGroup string
ExpectedOutput string
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: nil,
},
InputGroup: "",
ExpectedOutput: "http://example.com",
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: nil,
},
InputGroup: "group",
ExpectedOutput: "http://example.com",
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: []Override{
{
Group: "group",
WebhookURL: "http://example01.com",
},
},
},
InputGroup: "",
ExpectedOutput: "http://example.com",
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: []Override{
{
Group: "group",
WebhookURL: "http://example01.com",
},
},
},
InputGroup: "group",
ExpectedOutput: "http://example01.com",
},
}
for _, tt := range tests {
t.Run(tt.Name, func(t *testing.T) {
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
}
})
}
}

View File

@@ -6,9 +6,9 @@ import (
"io"
"net/http"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
)
// AlertProvider is the configuration necessary for sending an alert using Mattermost
@@ -20,6 +20,15 @@ type AlertProvider struct {
// 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"`
WebhookURL string `yaml:"webhook-url"`
}
// IsValid returns whether the provider's configuration is valid
@@ -27,13 +36,22 @@ func (provider *AlertProvider) IsValid() bool {
if provider.ClientConfig == nil {
provider.ClientConfig = client.GetDefaultConfig()
}
if provider.Overrides != nil {
registeredGroups := make(map[string]bool)
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
return false
}
registeredGroups[override.Group] = true
}
}
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)
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer)
if err != nil {
return err
}
@@ -101,6 +119,18 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
}`, message, message, description, color, endpoint.URL, results)
}
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
return override.WebhookURL
}
}
}
return provider.WebhookURL
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert

View File

@@ -5,10 +5,10 @@ import (
"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"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/test"
)
func TestAlertProvider_IsValid(t *testing.T) {
@@ -22,6 +22,47 @@ func TestAlertProvider_IsValid(t *testing.T) {
}
}
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
WebhookURL: "http://example.com",
Group: "",
},
},
}
if providerWithInvalidOverrideGroup.IsValid() {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideWebHookUrl := AlertProvider{
Overrides: []Override{
{
WebhookURL: "",
Group: "group",
},
},
}
if providerWithInvalidOverrideWebHookUrl.IsValid() {
t.Error("provider WebHookURL shoudn't have been valid")
}
providerWithValidOverride := AlertProvider{
WebhookURL: "http://example.com",
Overrides: []Override{
{
WebhookURL: "http://example.com",
Group: "group",
},
},
}
if !providerWithValidOverride.IsValid() {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
@@ -156,3 +197,66 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
tests := []struct {
Name string
Provider AlertProvider
InputGroup string
ExpectedOutput string
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: nil,
},
InputGroup: "",
ExpectedOutput: "http://example.com",
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: nil,
},
InputGroup: "group",
ExpectedOutput: "http://example.com",
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: []Override{
{
Group: "group",
WebhookURL: "http://example01.com",
},
},
},
InputGroup: "",
ExpectedOutput: "http://example.com",
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: []Override{
{
Group: "group",
WebhookURL: "http://example01.com",
},
},
},
InputGroup: "group",
ExpectedOutput: "http://example01.com",
},
}
for _, tt := range tests {
t.Run(tt.Name, func(t *testing.T) {
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
}
})
}
}

View File

@@ -6,9 +6,9 @@ import (
"io"
"net/http"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
)
const (

View File

@@ -5,10 +5,10 @@ import (
"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"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/test"
)
func TestMessagebirdAlertProvider_IsValid(t *testing.T) {

View File

@@ -9,9 +9,9 @@ import (
"strconv"
"strings"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
)
const (

View File

@@ -5,10 +5,10 @@ import (
"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"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/test"
)
func TestAlertProvider_IsValid(t *testing.T) {

View File

@@ -8,9 +8,9 @@ import (
"log"
"net/http"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
)
const (

View File

@@ -5,10 +5,10 @@ import (
"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"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/test"
)
func TestAlertProvider_IsValid(t *testing.T) {

View File

@@ -1,19 +1,20 @@
package provider
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"
"github.com/TwiN/gatus/v3/alerting/provider/slack"
"github.com/TwiN/gatus/v3/alerting/provider/teams"
"github.com/TwiN/gatus/v3/alerting/provider/telegram"
"github.com/TwiN/gatus/v3/alerting/provider/twilio"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/alerting/provider/custom"
"github.com/TwiN/gatus/v4/alerting/provider/discord"
"github.com/TwiN/gatus/v4/alerting/provider/email"
"github.com/TwiN/gatus/v4/alerting/provider/googlechat"
"github.com/TwiN/gatus/v4/alerting/provider/mattermost"
"github.com/TwiN/gatus/v4/alerting/provider/messagebird"
"github.com/TwiN/gatus/v4/alerting/provider/opsgenie"
"github.com/TwiN/gatus/v4/alerting/provider/pagerduty"
"github.com/TwiN/gatus/v4/alerting/provider/slack"
"github.com/TwiN/gatus/v4/alerting/provider/teams"
"github.com/TwiN/gatus/v4/alerting/provider/telegram"
"github.com/TwiN/gatus/v4/alerting/provider/twilio"
"github.com/TwiN/gatus/v4/core"
)
// AlertProvider is the interface that each providers should implement
@@ -58,6 +59,7 @@ var (
_ AlertProvider = (*googlechat.AlertProvider)(nil)
_ AlertProvider = (*mattermost.AlertProvider)(nil)
_ AlertProvider = (*messagebird.AlertProvider)(nil)
_ AlertProvider = (*opsgenie.AlertProvider)(nil)
_ AlertProvider = (*pagerduty.AlertProvider)(nil)
_ AlertProvider = (*slack.AlertProvider)(nil)
_ AlertProvider = (*teams.AlertProvider)(nil)

View File

@@ -3,7 +3,7 @@ package provider
import (
"testing"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v4/alerting/alert"
)
func TestParseWithDefaultAlert(t *testing.T) {

View File

@@ -6,28 +6,44 @@ import (
"io"
"net/http"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
)
// AlertProvider is the configuration necessary for sending an alert using Slack
type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"` // Slack webhook URL
// DefaultAlert is the default alert configuration to use for 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"`
WebhookURL string `yaml:"webhook-url"`
}
// 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.WebhookURL) == 0 {
return false
}
registeredGroups[override.Group] = true
}
}
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)
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer)
if err != nil {
return err
}
@@ -86,6 +102,18 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
}`, message, description, color, results)
}
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
return override.WebhookURL
}
}
}
return provider.WebhookURL
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert

View File

@@ -5,13 +5,13 @@ import (
"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"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/test"
)
func TestAlertProvider_IsValid(t *testing.T) {
func TestAlertDefaultProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{WebhookURL: ""}
if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid")
@@ -20,8 +20,44 @@ func TestAlertProvider_IsValid(t *testing.T) {
if !validProvider.IsValid() {
t.Error("provider should've been valid")
}
}
}
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
WebhookURL: "http://example.com",
Group: "",
},
},
}
if providerWithInvalidOverrideGroup.IsValid() {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
WebhookURL: "",
Group: "group",
},
},
}
if providerWithInvalidOverrideTo.IsValid() {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
WebhookURL: "http://example.com",
Overrides: []Override{
{
WebhookURL: "http://example.com",
Group: "group",
},
},
}
if !providerWithValidOverride.IsValid() {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
@@ -79,7 +115,7 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&core.Endpoint{Name: "name"},
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
@@ -175,3 +211,66 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
tests := []struct {
Name string
Provider AlertProvider
InputGroup string
ExpectedOutput string
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: nil,
},
InputGroup: "",
ExpectedOutput: "http://example.com",
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: nil,
},
InputGroup: "group",
ExpectedOutput: "http://example.com",
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: []Override{
{
Group: "group",
WebhookURL: "http://example01.com",
},
},
},
InputGroup: "",
ExpectedOutput: "http://example.com",
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: []Override{
{
Group: "group",
WebhookURL: "http://example01.com",
},
},
},
InputGroup: "group",
ExpectedOutput: "http://example01.com",
},
}
for _, tt := range tests {
t.Run(tt.Name, func(t *testing.T) {
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
t.Errorf("AlertProvider.getWebhookURLForGroup() = %v, want %v", got, tt.ExpectedOutput)
}
})
}
}

View File

@@ -6,9 +6,9 @@ import (
"io"
"net/http"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
)
// AlertProvider is the configuration necessary for sending an alert using Teams

View File

@@ -5,10 +5,10 @@ import (
"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"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/test"
)
func TestAlertDefaultProvider_IsValid(t *testing.T) {

View File

@@ -6,9 +6,9 @@ import (
"io"
"net/http"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
)
const defaultAPIURL = "https://api.telegram.org"

View File

@@ -5,10 +5,10 @@ import (
"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"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/test"
)
func TestAlertProvider_IsValid(t *testing.T) {

View File

@@ -8,9 +8,9 @@ import (
"net/http"
"net/url"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
)
// AlertProvider is the configuration necessary for sending an alert using Twilio

View File

@@ -3,8 +3,8 @@ package twilio
import (
"testing"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/core"
)
func TestTwilioAlertProvider_IsValid(t *testing.T) {

View File

@@ -2,12 +2,12 @@ package client
import (
"bytes"
"io/ioutil"
"io"
"net/http"
"testing"
"time"
"github.com/TwiN/gatus/v3/test"
"github.com/TwiN/gatus/v4/test"
)
func TestGetHTTPClient(t *testing.T) {
@@ -15,6 +15,7 @@ func TestGetHTTPClient(t *testing.T) {
Insecure: false,
IgnoreRedirect: false,
Timeout: 0,
DNSResolver: "tcp://1.1.1.1:53",
OAuth2Config: &OAuth2Config{
ClientID: "00000000-0000-0000-0000-000000000000",
ClientSecret: "secretsauce",
@@ -22,7 +23,10 @@ func TestGetHTTPClient(t *testing.T) {
Scopes: []string{"https://application.local/.default"},
},
}
cfg.ValidateAndSetDefaults()
err := cfg.ValidateAndSetDefaults()
if err != nil {
t.Errorf("expected error to be nil, but got: `%s`", err)
}
if GetHTTPClient(cfg) == nil {
t.Error("expected client to not be nil")
}
@@ -162,32 +166,27 @@ func TestCanCreateTCPConnection(t *testing.T) {
// 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(
Body: io.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
@@ -200,24 +199,19 @@ func TestHttpClientProvidesOAuth2BearerToken(t *testing.T) {
}
}),
}
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`

View File

@@ -4,7 +4,11 @@ import (
"context"
"crypto/tls"
"errors"
"log"
"net"
"net/http"
"regexp"
"strconv"
"time"
"golang.org/x/oauth2"
@@ -16,6 +20,8 @@ const (
)
var (
ErrInvalidDNSResolver = errors.New("invalid DNS resolver specified. Required format is {proto}://{ip}:{port}")
ErrInvalidDNSResolverPort = errors.New("invalid DNS resolver port")
ErrInvalidClientOAuth2Config = errors.New("invalid OAuth2 configuration, all fields are required")
defaultConfig = Config{
@@ -42,6 +48,10 @@ type Config struct {
// Timeout for the client
Timeout time.Duration `yaml:"timeout"`
// DNSResolver override for the HTTP client
// Expected format is {protocol}://{host}:{port}, e.g. tcp://1.1.1.1:53
DNSResolver string `yaml:"dns-resolver,omitempty"`
// 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.
@@ -51,6 +61,13 @@ type Config struct {
httpClient *http.Client
}
// DNSResolverConfig is the parsed configuration from the DNSResolver config string.
type DNSResolverConfig struct {
Protocol string
Host string
Port string
}
// 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
@@ -64,12 +81,50 @@ func (c *Config) ValidateAndSetDefaults() error {
if c.Timeout < time.Millisecond {
c.Timeout = 10 * time.Second
}
if c.HasCustomDNSResolver() {
// Validate the DNS resolver now to make sure it will not return an error later.
if _, err := c.parseDNSResolver(); err != nil {
return err
}
}
if c.HasOAuth2Config() && !c.OAuth2Config.isValid() {
return ErrInvalidClientOAuth2Config
}
return nil
}
// HasCustomDNSResolver returns whether a custom DNSResolver is configured
func (c *Config) HasCustomDNSResolver() bool {
return len(c.DNSResolver) > 0
}
// parseDNSResolver parses the DNS resolver into the DNSResolverConfig struct
func (c *Config) parseDNSResolver() (*DNSResolverConfig, error) {
re := regexp.MustCompile(`^(?P<proto>(.*))://(?P<host>[A-Za-z0-9\-\.]+):(?P<port>[0-9]+)?(.*)$`)
matches := re.FindStringSubmatch(c.DNSResolver)
if len(matches) == 0 {
return nil, ErrInvalidDNSResolver
}
r := make(map[string]string)
for i, k := range re.SubexpNames() {
if i != 0 && k != "" {
r[k] = matches[i]
}
}
port, err := strconv.Atoi(r["port"])
if err != nil {
return nil, err
}
if port < 1 || port > 65535 {
return nil, ErrInvalidDNSResolverPort
}
return &DNSResolverConfig{
Protocol: r["proto"],
Host: r["host"],
Port: r["port"],
}, nil
}
// HasOAuth2Config returns true if the client has OAuth2 configuration parameters
func (c *Config) HasOAuth2Config() bool {
return c.OAuth2Config != nil
@@ -102,6 +157,27 @@ func (c *Config) getHTTPClient() *http.Client {
return nil
},
}
if c.HasCustomDNSResolver() {
dnsResolver, err := c.parseDNSResolver()
if err != nil {
// We're ignoring the error, because it should have been validated on startup ValidateAndSetDefaults.
// It shouldn't happen, but if it does, we'll log it... Better safe than sorry ;)
log.Println("[client][getHTTPClient] THIS SHOULD NOT HAPPEN. Silently ignoring invalid DNS resolver due to error:", err.Error())
} else {
dialer := &net.Dialer{
Resolver: &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
d := net.Dialer{}
return d.DialContext(ctx, dnsResolver.Protocol, dnsResolver.Host+":"+dnsResolver.Port)
},
},
}
c.httpClient.Transport.(*http.Transport).DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.DialContext(ctx, network, addr)
}
}
}
if c.HasOAuth2Config() {
c.httpClient = configureOAuth2(c.httpClient, *c.OAuth2Config)
}

View File

@@ -35,3 +35,47 @@ func TestConfig_getHTTPClient(t *testing.T) {
t.Error("expected Config.IgnoreRedirect set to true to cause the HTTP client's CheckRedirect to return http.ErrUseLastResponse")
}
}
func TestConfig_ValidateAndSetDefaults_withCustomDNSResolver(t *testing.T) {
type args struct {
dnsResolver string
}
tests := []struct {
name string
args args
wantErr bool
}{
{
name: "with-valid-resolver",
args: args{
dnsResolver: "tcp://1.1.1.1:53",
},
wantErr: false,
},
{
name: "with-invalid-resolver-port",
args: args{
dnsResolver: "tcp://127.0.0.1:99999",
},
wantErr: true,
},
{
name: "with-invalid-resolver-format",
args: args{
dnsResolver: "foobar",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &Config{
DNSResolver: tt.args.dnsResolver,
}
err := cfg.ValidateAndSetDefaults()
if (err != nil) != tt.wantErr {
t.Errorf("ValidateAndSetDefaults() error=%v, wantErr=%v", err, tt.wantErr)
}
})
}
}

View File

@@ -31,7 +31,7 @@ endpoints:
- "[STATUS] == 200"
- name: example-dns-query
url: "8.8.8.8" # Address of the DNS server to use
url: "1.1.1.1" # Address of the DNS server to use
interval: 5m
dns:
query-name: "example.com"

View File

@@ -6,15 +6,15 @@ import (
"os"
"time"
"github.com/TwiN/gatus/v3/alerting"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/alerting/provider"
"github.com/TwiN/gatus/v3/config/maintenance"
"github.com/TwiN/gatus/v3/config/ui"
"github.com/TwiN/gatus/v3/config/web"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/security"
"github.com/TwiN/gatus/v3/storage"
"github.com/TwiN/gatus/v4/alerting"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/alerting/provider"
"github.com/TwiN/gatus/v4/config/maintenance"
"github.com/TwiN/gatus/v4/config/ui"
"github.com/TwiN/gatus/v4/config/web"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/security"
"github.com/TwiN/gatus/v4/storage"
"gopkg.in/yaml.v2"
)

View File

@@ -5,22 +5,22 @@ import (
"testing"
"time"
"github.com/TwiN/gatus/v3/alerting"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/alerting/provider/custom"
"github.com/TwiN/gatus/v3/alerting/provider/discord"
"github.com/TwiN/gatus/v3/alerting/provider/mattermost"
"github.com/TwiN/gatus/v3/alerting/provider/messagebird"
"github.com/TwiN/gatus/v3/alerting/provider/pagerduty"
"github.com/TwiN/gatus/v3/alerting/provider/slack"
"github.com/TwiN/gatus/v3/alerting/provider/teams"
"github.com/TwiN/gatus/v3/alerting/provider/telegram"
"github.com/TwiN/gatus/v3/alerting/provider/twilio"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/config/ui"
"github.com/TwiN/gatus/v3/config/web"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/storage"
"github.com/TwiN/gatus/v4/alerting"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/alerting/provider/custom"
"github.com/TwiN/gatus/v4/alerting/provider/discord"
"github.com/TwiN/gatus/v4/alerting/provider/mattermost"
"github.com/TwiN/gatus/v4/alerting/provider/messagebird"
"github.com/TwiN/gatus/v4/alerting/provider/pagerduty"
"github.com/TwiN/gatus/v4/alerting/provider/slack"
"github.com/TwiN/gatus/v4/alerting/provider/teams"
"github.com/TwiN/gatus/v4/alerting/provider/telegram"
"github.com/TwiN/gatus/v4/alerting/provider/twilio"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/config/ui"
"github.com/TwiN/gatus/v4/config/web"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/storage"
)
func TestLoadFileThatDoesNotExist(t *testing.T) {
@@ -1210,7 +1210,7 @@ endpoints:
t.Errorf("config.Security.Basic.Username should've been %s, but was %s", expectedUsername, config.Security.Basic.Username)
}
if config.Security.Basic.PasswordBcryptHashBase64Encoded != expectedPasswordHash {
t.Errorf("config.Security.Basic.PasswordBcryptHashBase64Encoded should've been %s, but was %s", expectedPasswordHash, config.Security.Basic.PasswordSha512Hash)
t.Errorf("config.Security.Basic.PasswordBcryptHashBase64Encoded should've been %s, but was %s", expectedPasswordHash, config.Security.Basic.PasswordBcryptHashBase64Encoded)
}
}
@@ -1309,53 +1309,3 @@ endpoints:
t.Error("services should've been merged in endpoints")
}
}
// XXX: Remove this in v4.0.0
func TestParseAndValidateConfigBytes_backwardCompatibleWithStorageFile(t *testing.T) {
file := t.TempDir() + "/test.db"
config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`
storage:
type: sqlite
file: %s
endpoints:
- name: website
url: https://twin.sh/actuator/health
conditions:
- "[STATUS] == 200"
`, file)))
if err != nil {
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
}
if config.Storage == nil || config.Storage.Path != file || config.Storage.Type != storage.TypeSQLite {
t.Error("expected storage to be set to sqlite, got", config.Storage)
}
}
// XXX: Remove this in v4.0.0
func TestParseAndValidateConfigBytes_backwardCompatibleWithStorageTypeMemoryAndFile(t *testing.T) {
file := t.TempDir() + "/test.db"
config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`
storage:
type: memory
file: %s
endpoints:
- name: website
url: https://twin.sh/actuator/health
conditions:
- "[STATUS] == 200"
`, file)))
if err != nil {
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
}
if config.Storage == nil || config.Storage.Path != file || config.Storage.Type != storage.TypeMemory {
t.Error("expected storage to be set to memory, got", config.Storage)
}
}

View File

@@ -98,6 +98,15 @@ func TestConfig_ValidateAndSetDefaults(t *testing.T) {
},
expectedError: nil,
},
{
name: "every-day-explicitly-at-2300",
cfg: &Config{
Start: "23:00",
Duration: time.Hour,
Every: []string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"},
},
expectedError: nil,
},
{
name: "every-monday-at-0000",
cfg: &Config{
@@ -168,6 +177,24 @@ func TestConfig_IsUnderMaintenance(t *testing.T) {
},
expected: true,
},
{
name: "under-maintenance-starting-now-for-8h-explicit-days",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", now.Hour()),
Duration: 8 * time.Hour,
Every: []string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"},
},
expected: true,
},
{
name: "under-maintenance-starting-now-for-23h-explicit-days",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", now.Hour()),
Duration: 23 * time.Hour,
Every: []string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"},
},
expected: true,
},
{
name: "under-maintenance-starting-4h-ago-for-8h",
cfg: &Config{
@@ -176,6 +203,14 @@ func TestConfig_IsUnderMaintenance(t *testing.T) {
},
expected: true,
},
{
name: "under-maintenance-starting-22h-ago-for-23h",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", normalizeHour(now.Hour()-22)),
Duration: 23 * time.Hour,
},
expected: true,
},
{
name: "under-maintenance-starting-4h-ago-for-3h",
cfg: &Config{

View File

@@ -8,10 +8,10 @@ import (
"os"
"time"
"github.com/TwiN/gatus/v3/config/ui"
"github.com/TwiN/gatus/v3/config/web"
"github.com/TwiN/gatus/v3/controller/handler"
"github.com/TwiN/gatus/v3/security"
"github.com/TwiN/gatus/v4/config/ui"
"github.com/TwiN/gatus/v4/config/web"
"github.com/TwiN/gatus/v4/controller/handler"
"github.com/TwiN/gatus/v4/security"
)
var (

View File

@@ -7,9 +7,9 @@ import (
"os"
"testing"
"github.com/TwiN/gatus/v3/config"
"github.com/TwiN/gatus/v3/config/web"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v4/config"
"github.com/TwiN/gatus/v4/config/web"
"github.com/TwiN/gatus/v4/core"
)
func TestHandle(t *testing.T) {

View File

@@ -7,8 +7,9 @@ import (
"strings"
"time"
"github.com/TwiN/gatus/v3/storage/store"
"github.com/TwiN/gatus/v3/storage/store/common"
"github.com/TwiN/gatus/v4/storage/store"
"github.com/TwiN/gatus/v4/storage/store/common"
"github.com/TwiN/gatus/v4/storage/store/common/paging"
"github.com/gorilla/mux"
)
@@ -21,6 +22,12 @@ const (
badgeColorHexVeryBad = "#c7130a"
)
const (
HealthStatusUp = "up"
HealthStatusDown = "down"
HealthStatusUnknown = "?"
)
// UptimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.
//
// Valid values for {duration}: 7d, 24h, 1h
@@ -95,6 +102,37 @@ func ResponseTimeBadge(writer http.ResponseWriter, request *http.Request) {
_, _ = writer.Write(generateResponseTimeBadgeSVG(duration, averageResponseTime))
}
// HealthBadge handles the automatic generation of badge based on the group name and endpoint name passed.
func HealthBadge(writer http.ResponseWriter, request *http.Request) {
variables := mux.Vars(request)
key := variables["key"]
pagingConfig := paging.NewEndpointStatusParams()
status, err := store.Get().GetEndpointStatusByKey(key, pagingConfig.WithResults(1, 1))
if err != nil {
if err == common.ErrEndpointNotFound {
http.Error(writer, err.Error(), http.StatusNotFound)
} else if err == common.ErrInvalidTimeRange {
http.Error(writer, err.Error(), http.StatusBadRequest)
} else {
http.Error(writer, err.Error(), http.StatusInternalServerError)
}
return
}
healthStatus := HealthStatusUnknown
if len(status.Results) > 0 {
if status.Results[0].Success {
healthStatus = HealthStatusUp
} else {
healthStatus = HealthStatusDown
}
}
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(generateHealthBadgeSVG(healthStatus))
}
func generateUptimeBadgeSVG(duration string, uptime float64) []byte {
var labelWidth, valueWidth, valueWidthAdjustment int
switch duration {
@@ -223,3 +261,61 @@ func getBadgeColorFromResponseTime(responseTime int) string {
}
return badgeColorHexVeryBad
}
func generateHealthBadgeSVG(healthStatus string) []byte {
var labelWidth, valueWidth int
switch healthStatus {
case HealthStatusUp:
valueWidth = 28
case HealthStatusDown:
valueWidth = 44
case HealthStatusUnknown:
valueWidth = 10
default:
}
color := getBadgeColorFromHealth(healthStatus)
labelWidth = 48
width := labelWidth + valueWidth
labelX := labelWidth / 2
valueX := labelWidth + (valueWidth / 2)
svg := []byte(fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="20">
<linearGradient id="b" x2="0" y2="100%%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<mask id="a">
<rect width="%d" height="20" rx="3" fill="#fff"/>
</mask>
<g mask="url(#a)">
<path fill="#555" d="M0 0h%dv20H0z"/>
<path fill="%s" d="M%d 0h%dv20H%dz"/>
<path fill="url(#b)" d="M0 0h%dv20H0z"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
<text x="%d" y="15" fill="#010101" fill-opacity=".3">
health
</text>
<text x="%d" y="14">
health
</text>
<text x="%d" y="15" fill="#010101" fill-opacity=".3">
%s
</text>
<text x="%d" y="14">
%s
</text>
</g>
</svg>`, width, width, labelWidth, color, labelWidth, valueWidth, labelWidth, width, labelX, labelX, valueX, healthStatus, valueX, healthStatus))
return svg
}
func getBadgeColorFromHealth(healthStatus string) string {
if healthStatus == HealthStatusUp {
return badgeColorHexAwesome
} else if healthStatus == HealthStatusDown {
return badgeColorHexVeryBad
}
return badgeColorHexPassable
}

View File

@@ -7,13 +7,13 @@ import (
"testing"
"time"
"github.com/TwiN/gatus/v3/config"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/storage/store"
"github.com/TwiN/gatus/v3/watchdog"
"github.com/TwiN/gatus/v4/config"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/storage/store"
"github.com/TwiN/gatus/v4/watchdog"
)
func TestUptimeBadge(t *testing.T) {
func TestBadge(t *testing.T) {
defer store.Get().Clear()
defer cache.Clear()
cfg := &config.Config{
@@ -29,8 +29,8 @@ func TestUptimeBadge(t *testing.T) {
},
},
}
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Connected: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Connected: false, Duration: time.Second, Timestamp: time.Now()})
router := CreateRouter("../../web/static", cfg.Security, nil, cfg.Metrics)
type Scenario struct {
Name string
@@ -89,21 +89,26 @@ func TestUptimeBadge(t *testing.T) {
Path: "/api/v1/endpoints/invalid_key/response-times/7d/badge.svg",
ExpectedCode: http.StatusNotFound,
},
{
Name: "badge-health-up",
Path: "/api/v1/endpoints/core_frontend/health/badge.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-health-down",
Path: "/api/v1/endpoints/core_backend/health/badge.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-health-for-invalid-key",
Path: "/api/v1/endpoints/invalid_key/health/badge.svg",
ExpectedCode: http.StatusNotFound,
},
{
Name: "chart-response-time-24h",
Path: "/api/v1/endpoints/core_backend/response-times/24h/chart.svg",
ExpectedCode: http.StatusOK,
},
{ // XXX: Remove this in v4.0.0
Name: "backward-compatible-services-badge-uptime-1h",
Path: "/api/v1/services/core_frontend/uptimes/1h/badge.svg",
ExpectedCode: http.StatusOK,
},
{ // XXX: Remove this in v4.0.0
Name: "backward-compatible-services-chart-response-time-24h",
Path: "/api/v1/services/core_backend/response-times/24h/chart.svg",
ExpectedCode: http.StatusOK,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
@@ -229,3 +234,30 @@ func TestGetBadgeColorFromResponseTime(t *testing.T) {
})
}
}
func TestGetBadgeColorFromHealth(t *testing.T) {
scenarios := []struct {
HealthStatus string
ExpectedColor string
}{
{
HealthStatus: HealthStatusUp,
ExpectedColor: badgeColorHexAwesome,
},
{
HealthStatus: HealthStatusDown,
ExpectedColor: badgeColorHexVeryBad,
},
{
HealthStatus: HealthStatusUnknown,
ExpectedColor: badgeColorHexPassable,
},
}
for _, scenario := range scenarios {
t.Run("health-"+scenario.HealthStatus, func(t *testing.T) {
if getBadgeColorFromHealth(scenario.HealthStatus) != scenario.ExpectedColor {
t.Errorf("expected %s from %s, got %v", scenario.ExpectedColor, scenario.HealthStatus, getBadgeColorFromHealth(scenario.HealthStatus))
}
})
}
}

View File

@@ -7,8 +7,8 @@ import (
"sort"
"time"
"github.com/TwiN/gatus/v3/storage/store"
"github.com/TwiN/gatus/v3/storage/store/common"
"github.com/TwiN/gatus/v4/storage/store"
"github.com/TwiN/gatus/v4/storage/store/common"
"github.com/gorilla/mux"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"

View File

@@ -6,10 +6,10 @@ import (
"testing"
"time"
"github.com/TwiN/gatus/v3/config"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/storage/store"
"github.com/TwiN/gatus/v3/watchdog"
"github.com/TwiN/gatus/v4/config"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/storage/store"
"github.com/TwiN/gatus/v4/watchdog"
)
func TestResponseTimeChart(t *testing.T) {
@@ -58,11 +58,6 @@ func TestResponseTimeChart(t *testing.T) {
Path: "/api/v1/endpoints/invalid_key/response-times/7d/chart.svg",
ExpectedCode: http.StatusNotFound,
},
{ // XXX: Remove this in v4.0.0
Name: "backward-compatible-services-chart-response-time-24h",
Path: "/api/v1/services/core_backend/response-times/24h/chart.svg",
ExpectedCode: http.StatusOK,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {

View File

@@ -4,7 +4,7 @@ import (
"fmt"
"net/http"
"github.com/TwiN/gatus/v3/security"
"github.com/TwiN/gatus/v4/security"
)
// ConfigHandler is a handler that returns information for the front end of the application.

View File

@@ -5,7 +5,7 @@ import (
"net/http/httptest"
"testing"
"github.com/TwiN/gatus/v3/security"
"github.com/TwiN/gatus/v4/security"
"github.com/gorilla/mux"
)

View File

@@ -10,10 +10,10 @@ import (
"strings"
"time"
"github.com/TwiN/gatus/v3/storage/store"
"github.com/TwiN/gatus/v3/storage/store/common"
"github.com/TwiN/gatus/v3/storage/store/common/paging"
"github.com/TwiN/gocache"
"github.com/TwiN/gatus/v4/storage/store"
"github.com/TwiN/gatus/v4/storage/store/common"
"github.com/TwiN/gatus/v4/storage/store/common/paging"
"github.com/TwiN/gocache/v2"
"github.com/gorilla/mux"
)

View File

@@ -6,17 +6,13 @@ import (
"testing"
"time"
"github.com/TwiN/gatus/v3/config"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/storage/store"
"github.com/TwiN/gatus/v3/watchdog"
"github.com/TwiN/gatus/v4/config"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/storage/store"
"github.com/TwiN/gatus/v4/watchdog"
)
var (
firstCondition = core.Condition("[STATUS] == 200")
secondCondition = core.Condition("[RESPONSE_TIME] < 500")
thirdCondition = core.Condition("[CERTIFICATE_EXPIRATION] < 72h")
timestamp = time.Now()
testEndpoint = core.Endpoint{
@@ -26,7 +22,7 @@ var (
Method: "GET",
Body: "body",
Interval: 30 * time.Second,
Conditions: []*core.Condition{&firstCondition, &secondCondition, &thirdCondition},
Conditions: []core.Condition{core.Condition("[STATUS] == 200"), core.Condition("[RESPONSE_TIME] < 500"), core.Condition("[CERTIFICATE_EXPIRATION] < 72h")},
Alerts: nil,
NumberOfFailuresInARow: 0,
NumberOfSuccessesInARow: 0,
@@ -131,11 +127,6 @@ func TestEndpointStatus(t *testing.T) {
Path: "/api/v1/endpoints/invalid_key/statuses",
ExpectedCode: http.StatusNotFound,
},
{ // XXX: Remove this in v4.0.0
Name: "backward-compatible-service-status",
Path: "/api/v1/services/core_frontend/statuses",
ExpectedCode: http.StatusOK,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
@@ -201,12 +192,6 @@ func TestEndpointStatuses(t *testing.T) {
ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}]}]`,
},
{ // XXX: Remove this in v4.0.0
Name: "backward-compatible-service-status",
Path: "/api/v1/services/statuses",
ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}]}]`,
},
}
for _, scenario := range scenarios {

View File

@@ -3,8 +3,8 @@ package handler
import (
"net/http"
"github.com/TwiN/gatus/v3/config/ui"
"github.com/TwiN/gatus/v3/security"
"github.com/TwiN/gatus/v4/config/ui"
"github.com/TwiN/gatus/v4/security"
"github.com/TwiN/health"
"github.com/gorilla/mux"
"github.com/prometheus/client_golang/prometheus/promhttp"
@@ -30,21 +30,14 @@ func CreateRouter(staticFolder string, securityConfig *security.Config, uiConfig
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}/health/badge.svg", HealthBadge).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")
// SPA
router.HandleFunc("/services/{name}", SinglePageApplication(staticFolder, uiConfig)).Methods("GET") // XXX: Remove this in v4.0.0
router.HandleFunc("/endpoints/{name}", SinglePageApplication(staticFolder, uiConfig)).Methods("GET")
router.HandleFunc("/", SinglePageApplication(staticFolder, uiConfig)).Methods("GET")
// Everything else falls back on static content

View File

@@ -5,7 +5,7 @@ import (
"log"
"net/http"
"github.com/TwiN/gatus/v3/config/ui"
"github.com/TwiN/gatus/v4/config/ui"
)
func SinglePageApplication(staticFolder string, ui *ui.Config) http.HandlerFunc {

View File

@@ -6,10 +6,10 @@ import (
"testing"
"time"
"github.com/TwiN/gatus/v3/config"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/storage/store"
"github.com/TwiN/gatus/v3/watchdog"
"github.com/TwiN/gatus/v4/config"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/storage/store"
"github.com/TwiN/gatus/v4/watchdog"
)
func TestSinglePageApplication(t *testing.T) {
@@ -48,11 +48,6 @@ func TestSinglePageApplication(t *testing.T) {
Path: "/endpoints/core_frontend",
ExpectedCode: http.StatusOK,
},
{ // XXX: Remove this in v4.0.0
Name: "frontend-service",
Path: "/services/core_frontend",
ExpectedCode: http.StatusOK,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {

View File

@@ -4,7 +4,7 @@ import (
"net/http"
"strconv"
"github.com/TwiN/gatus/v3/storage/store/common"
"github.com/TwiN/gatus/v4/storage/store/common"
)
const (

View File

@@ -6,8 +6,8 @@ import (
"strings"
"time"
"github.com/TwiN/gatus/v3/jsonpath"
"github.com/TwiN/gatus/v3/pattern"
"github.com/TwiN/gatus/v4/jsonpath"
"github.com/TwiN/gatus/v4/pattern"
)
const (

View File

@@ -2,8 +2,9 @@ package core
import (
"testing"
"time"
"github.com/TwiN/gatus/v3/pattern"
"github.com/TwiN/gatus/v4/pattern"
)
func TestIntegrationQuery(t *testing.T) {
@@ -21,7 +22,7 @@ func TestIntegrationQuery(t *testing.T) {
QueryType: "A",
QueryName: "example.com.",
},
inputURL: "8.8.8.8",
inputURL: "1.1.1.1",
expectedDNSCode: "NOERROR",
expectedBody: "93.184.216.34",
},
@@ -31,7 +32,7 @@ func TestIntegrationQuery(t *testing.T) {
QueryType: "AAAA",
QueryName: "example.com.",
},
inputURL: "8.8.8.8",
inputURL: "1.1.1.1",
expectedDNSCode: "NOERROR",
expectedBody: "2606:2800:220:1:248:1893:25c8:1946",
},
@@ -39,11 +40,11 @@ func TestIntegrationQuery(t *testing.T) {
name: "test DNS with type CNAME",
inputDNS: DNS{
QueryType: "CNAME",
QueryName: "doc.google.com.",
QueryName: "en.wikipedia.org.",
},
inputURL: "8.8.8.8",
inputURL: "1.1.1.1",
expectedDNSCode: "NOERROR",
expectedBody: "writely.l.google.com.",
expectedBody: "dyna.wikimedia.org.",
},
{
name: "test DNS with type MX",
@@ -51,7 +52,7 @@ func TestIntegrationQuery(t *testing.T) {
QueryType: "MX",
QueryName: "example.com.",
},
inputURL: "8.8.8.8",
inputURL: "1.1.1.1",
expectedDNSCode: "NOERROR",
expectedBody: ".",
},
@@ -61,7 +62,7 @@ func TestIntegrationQuery(t *testing.T) {
QueryType: "NS",
QueryName: "example.com.",
},
inputURL: "8.8.8.8",
inputURL: "1.1.1.1",
expectedDNSCode: "NOERROR",
expectedBody: "*.iana-servers.net.",
},
@@ -69,15 +70,14 @@ func TestIntegrationQuery(t *testing.T) {
name: "test DNS with fake type and retrieve error",
inputDNS: DNS{
QueryType: "B",
QueryName: "google",
QueryName: "example",
},
inputURL: "8.8.8.8",
inputURL: "1.1.1.1",
isErrExpected: true,
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
dns := test.inputDNS
result := &Result{}
@@ -86,9 +86,8 @@ func TestIntegrationQuery(t *testing.T) {
t.Errorf("there should be errors")
}
if result.DNSRCode != test.expectedDNSCode {
t.Errorf("DNSRCodePlaceholder '%s' should have been %s", result.DNSRCode, test.expectedDNSCode)
t.Errorf("expected DNSRCode to be %s, got %s", test.expectedDNSCode, result.DNSRCode)
}
if test.inputDNS.QueryType == "NS" {
// Because there are often multiple nameservers backing a single domain, we'll only look at the suffix
if !pattern.Match(test.expectedBody, string(result.body)) {
@@ -100,6 +99,7 @@ func TestIntegrationQuery(t *testing.T) {
}
}
})
time.Sleep(5 * time.Millisecond)
}
}

View File

@@ -12,12 +12,14 @@ import (
"strings"
"time"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core/ui"
"github.com/TwiN/gatus/v3/util"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core/ui"
"github.com/TwiN/gatus/v4/util"
)
type EndpointType string
const (
// HostHeader is the name of the header used to specify the host
HostHeader = "Host"
@@ -30,6 +32,14 @@ const (
// GatusUserAgent is the default user agent that Gatus uses to send requests.
GatusUserAgent = "Gatus/1.0"
// EndpointType enum for the endpoint type.
EndpointTypeDNS EndpointType = "DNS"
EndpointTypeTCP EndpointType = "TCP"
EndpointTypeICMP EndpointType = "ICMP"
EndpointTypeSTARTTLS EndpointType = "STARTTLS"
EndpointTypeTLS EndpointType = "TLS"
EndpointTypeHTTP EndpointType = "HTTP"
)
var (
@@ -79,7 +89,7 @@ type Endpoint struct {
Interval time.Duration `yaml:"interval,omitempty"`
// Conditions used to determine the health of the endpoint
Conditions []*Condition `yaml:"conditions"`
Conditions []Condition `yaml:"conditions"`
// Alerts is the alerting configuration for the endpoint in case of failure
Alerts []*alert.Alert `yaml:"alerts,omitempty"`
@@ -105,6 +115,24 @@ func (endpoint Endpoint) IsEnabled() bool {
return *endpoint.Enabled
}
// Type returns the endpoint type
func (endpoint Endpoint) Type() EndpointType {
switch {
case endpoint.DNS != nil:
return EndpointTypeDNS
case strings.HasPrefix(endpoint.URL, "tcp://"):
return EndpointTypeTCP
case strings.HasPrefix(endpoint.URL, "icmp://"):
return EndpointTypeICMP
case strings.HasPrefix(endpoint.URL, "starttls://"):
return EndpointTypeSTARTTLS
case strings.HasPrefix(endpoint.URL, "tls://"):
return EndpointTypeTLS
default:
return EndpointTypeHTTP
}
}
// ValidateAndSetDefaults validates the endpoint's configuration and sets the default value of fields that have one
func (endpoint *Endpoint) ValidateAndSetDefaults() error {
// Set default values
@@ -196,6 +224,11 @@ func (endpoint *Endpoint) EvaluateHealth() *Result {
// No need to keep the body after the endpoint has been evaluated
result.body = nil
// Clean up parameters that we don't need to keep in the results
if endpoint.UIConfig.HideURL {
for errIdx, errorString := range result.Errors {
result.Errors[errIdx] = strings.ReplaceAll(errorString, endpoint.URL, "<redacted>")
}
}
if endpoint.UIConfig.HideHostname {
for errIdx, errorString := range result.Errors {
result.Errors[errIdx] = strings.ReplaceAll(errorString, result.Hostname, "<redacted>")
@@ -229,21 +262,16 @@ func (endpoint *Endpoint) call(result *Result) {
var response *http.Response
var err error
var certificate *x509.Certificate
isTypeDNS := endpoint.DNS != nil
isTypeTCP := strings.HasPrefix(endpoint.URL, "tcp://")
isTypeICMP := strings.HasPrefix(endpoint.URL, "icmp://")
isTypeSTARTTLS := strings.HasPrefix(endpoint.URL, "starttls://")
isTypeTLS := strings.HasPrefix(endpoint.URL, "tls://")
isTypeHTTP := !isTypeDNS && !isTypeTCP && !isTypeICMP && !isTypeSTARTTLS && !isTypeTLS
if isTypeHTTP {
endpointType := endpoint.Type()
if endpointType == EndpointTypeHTTP {
request = endpoint.buildHTTPRequest()
}
startTime := time.Now()
if isTypeDNS {
if endpointType == EndpointTypeDNS {
endpoint.DNS.query(endpoint.URL, result)
result.Duration = time.Since(startTime)
} else if isTypeSTARTTLS || isTypeTLS {
if isTypeSTARTTLS {
} else if endpointType == EndpointTypeSTARTTLS || endpointType == EndpointTypeTLS {
if endpointType == EndpointTypeSTARTTLS {
result.Connected, certificate, err = client.CanPerformStartTLS(strings.TrimPrefix(endpoint.URL, "starttls://"), endpoint.ClientConfig)
} else {
result.Connected, certificate, err = client.CanPerformTLS(strings.TrimPrefix(endpoint.URL, "tls://"), endpoint.ClientConfig)
@@ -254,10 +282,10 @@ func (endpoint *Endpoint) call(result *Result) {
}
result.Duration = time.Since(startTime)
result.CertificateExpiration = time.Until(certificate.NotAfter)
} else if isTypeTCP {
} else if endpointType == EndpointTypeTCP {
result.Connected = client.CanCreateTCPConnection(strings.TrimPrefix(endpoint.URL, "tcp://"), endpoint.ClientConfig)
result.Duration = time.Since(startTime)
} else if isTypeICMP {
} else if endpointType == EndpointTypeICMP {
result.Connected, result.Duration = client.Ping(strings.TrimPrefix(endpoint.URL, "icmp://"), endpoint.ClientConfig)
} else {
response, err = client.GetHTTPClient(endpoint.ClientConfig).Do(request)

View File

@@ -1,6 +1,6 @@
package core
import "github.com/TwiN/gatus/v3/util"
import "github.com/TwiN/gatus/v4/util"
// EndpointStatus contains the evaluation Results of an Endpoint
type EndpointStatus struct {

View File

@@ -6,9 +6,9 @@ import (
"testing"
"time"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core/ui"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core/ui"
)
func TestEndpoint_IsEnabled(t *testing.T) {
@@ -23,12 +23,67 @@ func TestEndpoint_IsEnabled(t *testing.T) {
}
}
func TestEndpoint_Type(t *testing.T) {
type fields struct {
URL string
DNS *DNS
}
tests := []struct {
fields fields
want EndpointType
}{{
fields: fields{
URL: "1.1.1.1",
DNS: &DNS{
QueryType: "A",
QueryName: "example.com",
},
},
want: EndpointTypeDNS,
}, {
fields: fields{
URL: "tcp://127.0.0.1:6379",
},
want: EndpointTypeTCP,
}, {
fields: fields{
URL: "icmp://example.com",
},
want: EndpointTypeICMP,
}, {
fields: fields{
URL: "starttls://smtp.gmail.com:587",
},
want: EndpointTypeSTARTTLS,
}, {
fields: fields{
URL: "tls://example.com:443",
},
want: EndpointTypeTLS,
}, {
fields: fields{
URL: "https://twin.sh/health",
},
want: EndpointTypeHTTP,
}}
for _, tt := range tests {
t.Run(string(tt.want), func(t *testing.T) {
endpoint := Endpoint{
URL: tt.fields.URL,
DNS: tt.fields.DNS,
}
if got := endpoint.Type(); got != tt.want {
t.Errorf("Endpoint.Type() = %v, want %v", got, tt.want)
}
})
}
}
func TestEndpoint_ValidateAndSetDefaults(t *testing.T) {
condition := Condition("[STATUS] == 200")
endpoint := Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []*Condition{&condition},
Conditions: []Condition{Condition("[STATUS] == 200")},
Alerts: []*alert.Alert{{Type: alert.TypePagerDuty}},
}
endpoint.ValidateAndSetDefaults()
@@ -73,7 +128,7 @@ func TestEndpoint_ValidateAndSetDefaultsWithClientConfig(t *testing.T) {
endpoint := Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []*Condition{&condition},
Conditions: []Condition{condition},
ClientConfig: &client.Config{
Insecure: true,
IgnoreRedirect: true,
@@ -102,7 +157,7 @@ func TestEndpoint_ValidateAndSetDefaultsWithNoName(t *testing.T) {
endpoint := &Endpoint{
Name: "",
URL: "http://example.com",
Conditions: []*Condition{&condition},
Conditions: []Condition{condition},
}
err := endpoint.ValidateAndSetDefaults()
if err == nil {
@@ -116,7 +171,7 @@ func TestEndpoint_ValidateAndSetDefaultsWithNoUrl(t *testing.T) {
endpoint := &Endpoint{
Name: "example",
URL: "",
Conditions: []*Condition{&condition},
Conditions: []Condition{condition},
}
err := endpoint.ValidateAndSetDefaults()
if err == nil {
@@ -138,7 +193,6 @@ func TestEndpoint_ValidateAndSetDefaultsWithNoConditions(t *testing.T) {
}
func TestEndpoint_ValidateAndSetDefaultsWithDNS(t *testing.T) {
conditionSuccess := Condition("[DNS_RCODE] == NOERROR")
endpoint := &Endpoint{
Name: "dns-test",
URL: "http://example.com",
@@ -146,7 +200,7 @@ func TestEndpoint_ValidateAndSetDefaultsWithDNS(t *testing.T) {
QueryType: "A",
QueryName: "example.com",
},
Conditions: []*Condition{&conditionSuccess},
Conditions: []Condition{Condition("[DNS_RCODE] == NOERROR")},
}
err := endpoint.ValidateAndSetDefaults()
if err != nil {
@@ -162,7 +216,7 @@ func TestEndpoint_buildHTTPRequest(t *testing.T) {
endpoint := Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []*Condition{&condition},
Conditions: []Condition{condition},
}
endpoint.ValidateAndSetDefaults()
request := endpoint.buildHTTPRequest()
@@ -182,7 +236,7 @@ func TestEndpoint_buildHTTPRequestWithCustomUserAgent(t *testing.T) {
endpoint := Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []*Condition{&condition},
Conditions: []Condition{condition},
Headers: map[string]string{
"User-Agent": "Test/2.0",
},
@@ -206,7 +260,7 @@ func TestEndpoint_buildHTTPRequestWithHostHeader(t *testing.T) {
Name: "website-health",
URL: "https://twin.sh/health",
Method: "POST",
Conditions: []*Condition{&condition},
Conditions: []Condition{condition},
Headers: map[string]string{
"Host": "example.com",
},
@@ -227,7 +281,7 @@ func TestEndpoint_buildHTTPRequestWithGraphQLEnabled(t *testing.T) {
Name: "website-graphql",
URL: "https://twin.sh/graphql",
Method: "POST",
Conditions: []*Condition{&condition},
Conditions: []Condition{condition},
GraphQL: true,
Body: `{
users(gender: "female") {
@@ -258,7 +312,7 @@ func TestIntegrationEvaluateHealth(t *testing.T) {
endpoint := Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []*Condition{&condition, &bodyCondition},
Conditions: []Condition{condition, bodyCondition},
}
endpoint.ValidateAndSetDefaults()
result := endpoint.EvaluateHealth()
@@ -281,7 +335,7 @@ func TestIntegrationEvaluateHealthWithFailure(t *testing.T) {
endpoint := Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []*Condition{&condition},
Conditions: []Condition{condition},
}
endpoint.ValidateAndSetDefaults()
result := endpoint.EvaluateHealth()
@@ -301,7 +355,7 @@ func TestIntegrationEvaluateHealthWithInvalidCondition(t *testing.T) {
endpoint := Endpoint{
Name: "invalid-condition",
URL: "https://twin.sh/health",
Conditions: []*Condition{&condition},
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...
@@ -321,7 +375,7 @@ func TestIntegrationEvaluateHealthWithError(t *testing.T) {
endpoint := Endpoint{
Name: "invalid-host",
URL: "http://invalid/health",
Conditions: []*Condition{&condition},
Conditions: []Condition{condition},
UIConfig: &ui.Config{
HideHostname: true,
},
@@ -342,17 +396,42 @@ func TestIntegrationEvaluateHealthWithError(t *testing.T) {
}
}
func TestIntegrationEvaluateHealthWithErrorAndHideURL(t *testing.T) {
endpoint := Endpoint{
Name: "invalid-url",
URL: "https://httpstat.us/200?sleep=100",
Conditions: []Condition{Condition("[STATUS] == 200")},
ClientConfig: &client.Config{
Timeout: 1 * time.Millisecond,
},
UIConfig: &ui.Config{
HideURL: 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>") || strings.Contains(result.Errors[0], endpoint.URL) {
t.Error("result.Errors[0] should've had the URL redacted because ui.hide-url is set to true")
}
}
func TestIntegrationEvaluateHealthForDNS(t *testing.T) {
conditionSuccess := Condition("[DNS_RCODE] == NOERROR")
conditionBody := Condition("[BODY] == 93.184.216.34")
endpoint := Endpoint{
Name: "example",
URL: "8.8.8.8",
URL: "1.1.1.1",
DNS: &DNS{
QueryType: "A",
QueryName: "example.com.",
},
Conditions: []*Condition{&conditionSuccess, &conditionBody},
Conditions: []Condition{conditionSuccess, conditionBody},
}
endpoint.ValidateAndSetDefaults()
result := endpoint.EvaluateHealth()
@@ -372,7 +451,7 @@ func TestIntegrationEvaluateHealthForICMP(t *testing.T) {
endpoint := Endpoint{
Name: "icmp-test",
URL: "icmp://127.0.0.1",
Conditions: []*Condition{&conditionSuccess},
Conditions: []Condition{conditionSuccess},
}
endpoint.ValidateAndSetDefaults()
result := endpoint.EvaluateHealth()
@@ -392,7 +471,7 @@ func TestEndpoint_getIP(t *testing.T) {
endpoint := Endpoint{
Name: "invalid-url-test",
URL: "",
Conditions: []*Condition{&conditionSuccess},
Conditions: []Condition{conditionSuccess},
}
result := &Result{}
endpoint.getIP(result)
@@ -405,22 +484,22 @@ func TestEndpoint_NeedsToReadBody(t *testing.T) {
statusCondition := Condition("[STATUS] == 200")
bodyCondition := Condition("[BODY].status == UP")
bodyConditionWithLength := Condition("len([BODY].tags) > 0")
if (&Endpoint{Conditions: []*Condition{&statusCondition}}).needsToReadBody() {
if (&Endpoint{Conditions: []Condition{statusCondition}}).needsToReadBody() {
t.Error("expected false, got true")
}
if !(&Endpoint{Conditions: []*Condition{&bodyCondition}}).needsToReadBody() {
if !(&Endpoint{Conditions: []Condition{bodyCondition}}).needsToReadBody() {
t.Error("expected true, got false")
}
if !(&Endpoint{Conditions: []*Condition{&bodyConditionWithLength}}).needsToReadBody() {
if !(&Endpoint{Conditions: []Condition{bodyConditionWithLength}}).needsToReadBody() {
t.Error("expected true, got false")
}
if !(&Endpoint{Conditions: []*Condition{&statusCondition, &bodyCondition}}).needsToReadBody() {
if !(&Endpoint{Conditions: []Condition{statusCondition, bodyCondition}}).needsToReadBody() {
t.Error("expected true, got false")
}
if !(&Endpoint{Conditions: []*Condition{&bodyCondition, &statusCondition}}).needsToReadBody() {
if !(&Endpoint{Conditions: []Condition{bodyCondition, statusCondition}}).needsToReadBody() {
t.Error("expected true, got false")
}
if !(&Endpoint{Conditions: []*Condition{&bodyConditionWithLength, &statusCondition}}).needsToReadBody() {
if !(&Endpoint{Conditions: []Condition{bodyConditionWithLength, statusCondition}}).needsToReadBody() {
t.Error("expected true, got false")
}
}

View File

@@ -10,6 +10,8 @@ type Result struct {
HTTPStatus int `json:"status"`
// DNSRCode is the response code of a DNS query in a human-readable format
//
// Possible values: NOERROR, FORMERR, SERVFAIL, NXDOMAIN, NOTIMP, REFUSED
DNSRCode string `json:"-"`
// Hostname extracted from Endpoint.URL

View File

@@ -4,6 +4,8 @@ package ui
type Config struct {
// HideHostname whether to hide the hostname in the Result
HideHostname bool `yaml:"hide-hostname"`
// HideURL whether to ensure the URL is not displayed in the results. Useful if the URL contains a token.
HideURL bool `yaml:"hide-url"`
// DontResolveFailedConditions whether to resolve failed conditions in the Result for display in the UI
DontResolveFailedConditions bool `yaml:"dont-resolve-failed-conditions"`
}
@@ -12,6 +14,7 @@ type Config struct {
func GetDefaultConfig() *Config {
return &Config{
HideHostname: false,
HideURL: false,
DontResolveFailedConditions: false,
}
}

4
go.mod
View File

@@ -1,10 +1,9 @@
module github.com/TwiN/gatus/v3
module github.com/TwiN/gatus/v4
go 1.18
require (
github.com/TwiN/g8 v1.3.0
github.com/TwiN/gocache v1.2.4
github.com/TwiN/gocache/v2 v2.0.0
github.com/TwiN/health v1.4.0
github.com/coreos/go-oidc/v3 v3.1.0
@@ -34,7 +33,6 @@ require (
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
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-20211209124913-491a49abca63 // indirect

27
go.sum
View File

@@ -35,8 +35,6 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
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/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.4.0 h1:Ts7lb4ihYDpVEbFSGAhSEZTSwuDOADnwJLFngFl4xzw=
@@ -70,8 +68,6 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
@@ -83,7 +79,6 @@ github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-ping/ping v0.0.0-20210911151512-381826476871 h1:wtjTfjwAR/BYYMJ+QOLI/3J/qGEI0fgrkZvgsEWK2/Q=
github.com/go-ping/ping v0.0.0-20210911151512-381826476871/go.mod h1:xIFjORFzTxqIV/tDVGO4eDy/bLuSyawEeojSm3GfRGk=
github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
@@ -149,7 +144,6 @@ github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
@@ -187,13 +181,6 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.14.1/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.10.2/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
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=
@@ -233,16 +220,12 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
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=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU=
go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
@@ -292,7 +275,6 @@ golang.org/x/mod v0.5.1 h1:OJxoQ/rynoF0dcCdI7cLPktw/hR2cueqYfjm43oqK38=
golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -316,7 +298,6 @@ golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/
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=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
@@ -348,7 +329,6 @@ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cO
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -359,10 +339,7 @@ golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -378,12 +355,10 @@ golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201126233918-771906719818/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -540,12 +515,10 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
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=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

View File

@@ -7,10 +7,10 @@ import (
"syscall"
"time"
"github.com/TwiN/gatus/v3/config"
"github.com/TwiN/gatus/v3/controller"
"github.com/TwiN/gatus/v3/storage/store"
"github.com/TwiN/gatus/v3/watchdog"
"github.com/TwiN/gatus/v4/config"
"github.com/TwiN/gatus/v4/controller"
"github.com/TwiN/gatus/v4/storage/store"
"github.com/TwiN/gatus/v4/watchdog"
)
func main() {

View File

@@ -1,27 +0,0 @@
package metric
import (
"strconv"
"github.com/TwiN/gatus/v3/core"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
// 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) {
if resultCount == nil {
resultCount = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "gatus_results_total",
Help: "Number of results per endpoint",
}, []string{"key", "group", "name", "success"})
}
resultCount.WithLabelValues(endpoint.Key(), endpoint.Group, endpoint.Name, strconv.FormatBool(result.Success)).Inc()
}

73
metrics/metrics.go Normal file
View File

@@ -0,0 +1,73 @@
package metrics
import (
"strconv"
"github.com/TwiN/gatus/v4/core"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
const namespace = "gatus" // The prefix of the metrics
var (
initializedMetrics bool // Whether the metrics have been initialized
resultTotal *prometheus.CounterVec
resultDurationSeconds *prometheus.GaugeVec
resultConnectedTotal *prometheus.CounterVec
resultCodeTotal *prometheus.CounterVec
resultCertificateExpirationSeconds *prometheus.GaugeVec
)
func initializePrometheusMetrics() {
resultTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: namespace,
Name: "results_total",
Help: "Number of results per endpoint",
}, []string{"key", "group", "name", "type", "success"})
resultDurationSeconds = promauto.NewGaugeVec(prometheus.GaugeOpts{
Namespace: namespace,
Name: "results_duration_seconds",
Help: "Duration of the request in seconds",
}, []string{"key", "group", "name", "type"})
resultConnectedTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: namespace,
Name: "results_connected_total",
Help: "Total number of results in which a connection was successfully established",
}, []string{"key", "group", "name", "type"})
resultCodeTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: namespace,
Name: "results_code_total",
Help: "Total number of results by code",
}, []string{"key", "group", "name", "type", "code"})
resultCertificateExpirationSeconds = promauto.NewGaugeVec(prometheus.GaugeOpts{
Namespace: namespace,
Name: "results_certificate_expiration_seconds",
Help: "Number of seconds until the certificate expires",
}, []string{"key", "group", "name", "type"})
}
// 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) {
if !initializedMetrics {
initializePrometheusMetrics()
initializedMetrics = true
}
endpointType := endpoint.Type()
resultTotal.WithLabelValues(endpoint.Key(), endpoint.Group, endpoint.Name, string(endpointType), strconv.FormatBool(result.Success)).Inc()
resultDurationSeconds.WithLabelValues(endpoint.Key(), endpoint.Group, endpoint.Name, string(endpointType)).Set(result.Duration.Seconds())
if result.Connected {
resultConnectedTotal.WithLabelValues(endpoint.Key(), endpoint.Group, endpoint.Name, string(endpointType)).Inc()
}
if result.DNSRCode != "" {
resultCodeTotal.WithLabelValues(endpoint.Key(), endpoint.Group, endpoint.Name, string(endpointType), result.DNSRCode).Inc()
}
if result.HTTPStatus != 0 {
resultCodeTotal.WithLabelValues(endpoint.Key(), endpoint.Group, endpoint.Name, string(endpointType), strconv.Itoa(result.HTTPStatus)).Inc()
}
if result.CertificateExpiration != 0 {
resultCertificateExpirationSeconds.WithLabelValues(endpoint.Key(), endpoint.Group, endpoint.Name, string(endpointType)).Set(result.CertificateExpiration.Seconds())
}
}

116
metrics/metrics_test.go Normal file
View File

@@ -0,0 +1,116 @@
package metrics
import (
"bytes"
"testing"
"time"
"github.com/TwiN/gatus/v4/core"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/testutil"
)
func TestPublishMetricsForEndpoint(t *testing.T) {
httpEndpoint := &core.Endpoint{Name: "http-ep-name", Group: "http-ep-group", URL: "https://example.org"}
PublishMetricsForEndpoint(httpEndpoint, &core.Result{
HTTPStatus: 200,
Connected: true,
Duration: 123 * time.Millisecond,
ConditionResults: []*core.ConditionResult{
{Condition: "[STATUS] == 200", Success: true},
{Condition: "[CERTIFICATE_EXPIRATION] > 48h", Success: true},
},
Success: true,
CertificateExpiration: 49 * time.Hour,
})
err := testutil.GatherAndCompare(prometheus.Gatherers{prometheus.DefaultGatherer}, bytes.NewBufferString(`
# HELP gatus_results_certificate_expiration_seconds Number of seconds until the certificate expires
# TYPE gatus_results_certificate_expiration_seconds gauge
gatus_results_certificate_expiration_seconds{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 176400
# HELP gatus_results_code_total Total number of results by code
# TYPE gatus_results_code_total counter
gatus_results_code_total{code="200",group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 1
# HELP gatus_results_connected_total Total number of results in which a connection was successfully established
# TYPE gatus_results_connected_total counter
gatus_results_connected_total{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 1
# HELP gatus_results_duration_seconds Duration of the request in seconds
# TYPE gatus_results_duration_seconds gauge
gatus_results_duration_seconds{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 0.123
# HELP gatus_results_total Number of results per endpoint
# TYPE gatus_results_total counter
gatus_results_total{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",success="true",type="HTTP"} 1
`), "gatus_results_code_total", "gatus_results_connected_total", "gatus_results_duration_seconds", "gatus_results_total", "gatus_results_certificate_expiration_seconds")
if err != nil {
t.Errorf("Expected no errors but got: %v", err)
}
PublishMetricsForEndpoint(httpEndpoint, &core.Result{
HTTPStatus: 200,
Connected: true,
Duration: 125 * time.Millisecond,
ConditionResults: []*core.ConditionResult{
{Condition: "[STATUS] == 200", Success: true},
{Condition: "[CERTIFICATE_EXPIRATION] > 47h", Success: false},
},
Success: false,
CertificateExpiration: 47 * time.Hour,
})
err = testutil.GatherAndCompare(prometheus.Gatherers{prometheus.DefaultGatherer}, bytes.NewBufferString(`
# HELP gatus_results_certificate_expiration_seconds Number of seconds until the certificate expires
# TYPE gatus_results_certificate_expiration_seconds gauge
gatus_results_certificate_expiration_seconds{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 169200
# HELP gatus_results_code_total Total number of results by code
# TYPE gatus_results_code_total counter
gatus_results_code_total{code="200",group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 2
# HELP gatus_results_connected_total Total number of results in which a connection was successfully established
# TYPE gatus_results_connected_total counter
gatus_results_connected_total{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 2
# HELP gatus_results_duration_seconds Duration of the request in seconds
# TYPE gatus_results_duration_seconds gauge
gatus_results_duration_seconds{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 0.125
# HELP gatus_results_total Number of results per endpoint
# TYPE gatus_results_total counter
gatus_results_total{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",success="false",type="HTTP"} 1
gatus_results_total{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",success="true",type="HTTP"} 1
`), "gatus_results_code_total", "gatus_results_connected_total", "gatus_results_duration_seconds", "gatus_results_total", "gatus_results_certificate_expiration_seconds")
if err != nil {
t.Errorf("Expected no errors but got: %v", err)
}
dnsEndpoint := &core.Endpoint{Name: "dns-ep-name", Group: "dns-ep-group", URL: "1.1.1.1", DNS: &core.DNS{
QueryType: "A",
QueryName: "example.com.",
}}
PublishMetricsForEndpoint(dnsEndpoint, &core.Result{
DNSRCode: "NOERROR",
Connected: true,
Duration: 50 * time.Millisecond,
ConditionResults: []*core.ConditionResult{
{Condition: "[DNS_RCODE] == NOERROR", Success: true},
},
Success: true,
})
err = testutil.GatherAndCompare(prometheus.Gatherers{prometheus.DefaultGatherer}, bytes.NewBufferString(`
# HELP gatus_results_certificate_expiration_seconds Number of seconds until the certificate expires
# TYPE gatus_results_certificate_expiration_seconds gauge
gatus_results_certificate_expiration_seconds{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 169200
# HELP gatus_results_code_total Total number of results by code
# TYPE gatus_results_code_total counter
gatus_results_code_total{code="200",group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 2
gatus_results_code_total{code="NOERROR",group="dns-ep-group",key="dns-ep-group_dns-ep-name",name="dns-ep-name",type="DNS"} 1
# HELP gatus_results_connected_total Total number of results in which a connection was successfully established
# TYPE gatus_results_connected_total counter
gatus_results_connected_total{group="dns-ep-group",key="dns-ep-group_dns-ep-name",name="dns-ep-name",type="DNS"} 1
gatus_results_connected_total{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 2
# HELP gatus_results_duration_seconds Duration of the request in seconds
# TYPE gatus_results_duration_seconds gauge
gatus_results_duration_seconds{group="dns-ep-group",key="dns-ep-group_dns-ep-name",name="dns-ep-name",type="DNS"} 0.05
gatus_results_duration_seconds{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 0.125
# HELP gatus_results_total Number of results per endpoint
# TYPE gatus_results_total counter
gatus_results_total{group="dns-ep-group",key="dns-ep-group_dns-ep-name",name="dns-ep-name",success="true",type="DNS"} 1
gatus_results_total{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",success="false",type="HTTP"} 1
gatus_results_total{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",success="true",type="HTTP"} 1
`), "gatus_results_code_total", "gatus_results_connected_total", "gatus_results_duration_seconds", "gatus_results_total", "gatus_results_certificate_expiration_seconds")
if err != nil {
t.Errorf("Expected no errors but got: %v", err)
}
}

View File

@@ -1,17 +1,10 @@
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"`
@@ -19,8 +12,5 @@ type BasicConfig struct {
// 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)
return len(c.Username) > 0 && len(c.PasswordBcryptHashBase64Encoded) > 0
}

View File

@@ -2,26 +2,6 @@ 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",

View File

@@ -3,7 +3,6 @@ package security
import (
"encoding/base64"
"net/http"
"strings"
"github.com/TwiN/g8"
"github.com/gorilla/mux"
@@ -82,13 +81,6 @@ func (c *Config) ApplySecurityMiddleware(api *mux.Router) error {
_, _ = 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)
})

View File

@@ -23,10 +23,10 @@ func TestConfig_ApplySecurityMiddleware(t *testing.T) {
///////////
// BASIC //
///////////
// SHA512 (DEPRECATED)
// Bcrypt
c := &Config{Basic: &BasicConfig{
Username: "john.doe",
PasswordSha512Hash: "6b97ed68d14eb3f1aa959ce5d49c7dc612e1eb1dafd73b1e705847483fd6a6c809f2ceb4e8df6ff9984c6298ff0285cace6614bf8daa9f0070101b6c89899e22",
Username: "john.doe",
PasswordBcryptHashBase64Encoded: "JDJhJDA4JDFoRnpPY1hnaFl1OC9ISlFsa21VS09wOGlPU1ZOTDlHZG1qeTFvb3dIckRBUnlHUmNIRWlT",
}}
api := mux.NewRouter()
api.HandleFunc("/test", func(w http.ResponseWriter, r *http.Request) {
@@ -50,33 +50,6 @@ func TestConfig_ApplySecurityMiddleware(t *testing.T) {
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 //
//////////

View File

@@ -1,14 +0,0 @@
package security
import (
"crypto/sha512"
"fmt"
)
// 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))
return fmt.Sprintf("%x", hash.Sum(nil))
}

View File

@@ -1,12 +0,0 @@
package security
import "testing"
func TestSha512(t *testing.T) {
input := "password"
expectedHash := "b109f3bbbc244eb82441917ed06d618b9008dd09b3befd1b5e07394c706a8bb980b1d7785e5976ec049b46df5f1326af5a2ea6d103fd07c95385ffab0cacbc86"
hash := Sha512(input)
if hash != expectedHash {
t.Errorf("Expected hash to be '%s', but was '%s'", expectedHash, hash)
}
}

View File

@@ -2,13 +2,11 @@ package storage
import (
"errors"
"log"
)
var (
ErrSQLStorageRequiresPath = errors.New("sql storage requires a non-empty path to be defined")
ErrMemoryStorageDoesNotSupportPath = errors.New("memory storage does not support persistence, use sqlite if you want persistence on file")
ErrCannotSetBothFileAndPath = errors.New("file has been deprecated in favor of path: you cannot set both of them")
)
// Config is the configuration for storage
@@ -18,12 +16,6 @@ type Config struct {
// Note that not all Type support persistence
Path string `yaml:"path"`
// File is the path of the file to use for persistence
// If blank, persistence is disabled
//
// Deprecated
File string `yaml:"file"`
// Type of store
// If blank, uses the default in-memory store
Type Type `yaml:"type"`
@@ -31,14 +23,6 @@ type Config struct {
// ValidateAndSetDefaults validates the configuration and sets the default values (if applicable)
func (c *Config) ValidateAndSetDefaults() error {
if len(c.File) > 0 && len(c.Path) > 0 { // XXX: Remove for v4.0.0
return ErrCannotSetBothFileAndPath
} else if len(c.File) > 0 { // XXX: Remove for v4.0.0
log.Println("WARNING: Your configuration is using 'storage.file', which is deprecated in favor of 'storage.path'")
log.Println("WARNING: storage.file will be completely removed in v4.0.0, so please update your configuration")
log.Println("WARNING: See https://github.com/TwiN/gatus/issues/197")
c.Path = c.File
}
if c.Type == "" {
c.Type = TypeMemory
}
@@ -46,12 +30,7 @@ func (c *Config) ValidateAndSetDefaults() error {
return ErrSQLStorageRequiresPath
}
if c.Type == TypeMemory && len(c.Path) > 0 {
log.Println("WARNING: Your configuration is using a storage of type memory with persistence, which has been deprecated")
log.Println("WARNING: As of v4.0.0, the default storage type (memory) will not support persistence.")
log.Println("WARNING: If you want persistence, use 'storage.type: sqlite' instead of 'storage.type: memory'")
log.Println("WARNING: See https://github.com/TwiN/gatus/issues/198")
// XXX: Uncomment the following line for v4.0.0
//return ErrMemoryStorageDoesNotSupportPath
return ErrMemoryStorageDoesNotSupportPath
}
return nil
}

View File

@@ -2,19 +2,15 @@ package memory
import (
"encoding/gob"
"io/fs"
"log"
"os"
"sort"
"strings"
"sync"
"time"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/storage/store/common"
"github.com/TwiN/gatus/v3/storage/store/common/paging"
"github.com/TwiN/gatus/v3/util"
"github.com/TwiN/gocache"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/storage/store/common"
"github.com/TwiN/gatus/v4/storage/store/common/paging"
"github.com/TwiN/gatus/v4/util"
"github.com/TwiN/gocache/v2"
)
func init() {
@@ -28,11 +24,7 @@ func init() {
// Store that leverages gocache
type Store struct {
sync.RWMutex
// Deprecated
//
// File persistence will no longer be supported as of v4.0.0
// XXX: Remove me in v4.0.0
file string
cache *gocache.Cache
}
@@ -42,30 +34,8 @@ type Store struct {
// supports eventual persistence.
func NewStore(file string) (*Store, error) {
store := &Store{
file: file,
cache: gocache.NewCache().WithMaxSize(gocache.NoMaxSize),
}
// XXX: Remove the block below in v4.0.0 because persistence with the memory store will no longer be supported
// XXX: Make sure to also update gocache to v2.0.0
if len(file) > 0 {
_, err := store.cache.ReadFromFile(file)
if err != nil {
// XXX: Remove the block below in v4.0.0
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 = 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
return store, nil
}
}
return nil, err
}
}
return store, nil
}
@@ -221,9 +191,6 @@ func (s *Store) Clear() {
// Save persists the cache to the store file
func (s *Store) Save() error {
if len(s.file) > 0 {
return s.cache.SaveToFile(s.file)
}
return nil
}

View File

@@ -4,8 +4,8 @@ import (
"testing"
"time"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/storage/store/common/paging"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/storage/store/common/paging"
)
var (
@@ -22,7 +22,7 @@ var (
Method: "GET",
Body: "body",
Interval: 30 * time.Second,
Conditions: []*core.Condition{&firstCondition, &secondCondition, &thirdCondition},
Conditions: []core.Condition{firstCondition, secondCondition, thirdCondition},
Alerts: nil,
NumberOfFailuresInARow: 0,
NumberOfSuccessesInARow: 0,

View File

@@ -3,7 +3,7 @@ package memory
import (
"time"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v4/core"
)
const (

View File

@@ -4,7 +4,7 @@ import (
"testing"
"time"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v4/core"
)
func BenchmarkProcessUptimeAfterResult(b *testing.B) {

View File

@@ -4,7 +4,7 @@ import (
"testing"
"time"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v4/core"
)
func TestProcessUptimeAfterResult(t *testing.T) {

View File

@@ -1,9 +1,9 @@
package memory
import (
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/storage/store/common"
"github.com/TwiN/gatus/v3/storage/store/common/paging"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/storage/store/common"
"github.com/TwiN/gatus/v4/storage/store/common/paging"
)
// ShallowCopyEndpointStatus returns a shallow copy of a EndpointStatus with only the results

View File

@@ -3,9 +3,9 @@ package memory
import (
"testing"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/storage/store/common"
"github.com/TwiN/gatus/v3/storage/store/common/paging"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/storage/store/common"
"github.com/TwiN/gatus/v4/storage/store/common/paging"
)
func BenchmarkShallowCopyEndpointStatus(b *testing.B) {

View File

@@ -4,9 +4,9 @@ import (
"testing"
"time"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/storage/store/common"
"github.com/TwiN/gatus/v3/storage/store/common/paging"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/storage/store/common"
"github.com/TwiN/gatus/v4/storage/store/common/paging"
)
func TestAddResult(t *testing.T) {

View File

@@ -5,13 +5,14 @@ import (
"errors"
"fmt"
"log"
"strconv"
"strings"
"time"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/storage/store/common"
"github.com/TwiN/gatus/v3/storage/store/common/paging"
"github.com/TwiN/gatus/v3/util"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/storage/store/common"
"github.com/TwiN/gatus/v4/storage/store/common/paging"
"github.com/TwiN/gatus/v4/util"
_ "github.com/lib/pq"
_ "modernc.org/sqlite"
)
@@ -579,7 +580,7 @@ func (s *Store) getEndpointResultsByEndpointID(tx *sql.Tx, endpointID int64, pag
WHERE endpoint_result_id IN (`
index := 1
for endpointResultID := range idResultMap {
query += fmt.Sprintf("$%d,", index)
query += "$" + strconv.Itoa(index) + ","
args = append(args, endpointResultID)
index++
}

View File

@@ -4,9 +4,9 @@ import (
"testing"
"time"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/storage/store/common"
"github.com/TwiN/gatus/v3/storage/store/common/paging"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/storage/store/common"
"github.com/TwiN/gatus/v4/storage/store/common/paging"
)
var (
@@ -23,7 +23,7 @@ var (
Method: "GET",
Body: "body",
Interval: 30 * time.Second,
Conditions: []*core.Condition{&firstCondition, &secondCondition, &thirdCondition},
Conditions: []core.Condition{firstCondition, secondCondition, thirdCondition},
Alerts: nil,
NumberOfFailuresInARow: 0,
NumberOfSuccessesInARow: 0,

View File

@@ -5,11 +5,11 @@ import (
"log"
"time"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/storage"
"github.com/TwiN/gatus/v3/storage/store/common/paging"
"github.com/TwiN/gatus/v3/storage/store/memory"
"github.com/TwiN/gatus/v3/storage/store/sql"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/storage"
"github.com/TwiN/gatus/v4/storage/store/common/paging"
"github.com/TwiN/gatus/v4/storage/store/memory"
"github.com/TwiN/gatus/v4/storage/store/sql"
)
// Store is the interface that each stores should implement

View File

@@ -1,13 +1,14 @@
package store
import (
"strconv"
"testing"
"time"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/storage/store/common/paging"
"github.com/TwiN/gatus/v3/storage/store/memory"
"github.com/TwiN/gatus/v3/storage/store/sql"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/storage/store/common/paging"
"github.com/TwiN/gatus/v4/storage/store/memory"
"github.com/TwiN/gatus/v4/storage/store/sql"
)
func BenchmarkStore_GetAllEndpointStatuses(b *testing.B) {
@@ -48,23 +49,34 @@ func BenchmarkStore_GetAllEndpointStatuses(b *testing.B) {
},
}
for _, scenario := range scenarios {
scenario.Store.Insert(&testEndpoint, &testSuccessfulResult)
scenario.Store.Insert(&testEndpoint, &testUnsuccessfulResult)
b.Run(scenario.Name, func(b *testing.B) {
if scenario.Parallel {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
scenario.Store.GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(1, 20))
}
})
} else {
for n := 0; n < b.N; n++ {
scenario.Store.GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(1, 20))
numberOfEndpoints := []int{10, 25, 50, 100}
for _, numberOfEndpointsToCreate := range numberOfEndpoints {
// Create endpoints and insert results
for i := 0; i < numberOfEndpointsToCreate; i++ {
endpoint := testEndpoint
endpoint.Name = "endpoint" + strconv.Itoa(i)
// Insert 20 results for each endpoint
for j := 0; j < 20; j++ {
scenario.Store.Insert(&endpoint, &testSuccessfulResult)
}
}
b.ReportAllocs()
})
scenario.Store.Clear()
// Run the scenarios
b.Run(scenario.Name+"-with-"+strconv.Itoa(numberOfEndpointsToCreate)+"-endpoints", func(b *testing.B) {
if scenario.Parallel {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_, _ = scenario.Store.GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(1, 20))
}
})
} else {
for n := 0; n < b.N; n++ {
_, _ = scenario.Store.GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(1, 20))
}
}
b.ReportAllocs()
})
scenario.Store.Clear()
}
}
}

View File

@@ -4,12 +4,12 @@ import (
"testing"
"time"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/storage"
"github.com/TwiN/gatus/v3/storage/store/common"
"github.com/TwiN/gatus/v3/storage/store/common/paging"
"github.com/TwiN/gatus/v3/storage/store/memory"
"github.com/TwiN/gatus/v3/storage/store/sql"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/storage"
"github.com/TwiN/gatus/v4/storage/store/common"
"github.com/TwiN/gatus/v4/storage/store/common/paging"
"github.com/TwiN/gatus/v4/storage/store/memory"
"github.com/TwiN/gatus/v4/storage/store/sql"
)
var (
@@ -26,7 +26,7 @@ var (
Method: "GET",
Body: "body",
Interval: 30 * time.Second,
Conditions: []*core.Condition{&firstCondition, &secondCondition, &thirdCondition},
Conditions: []core.Condition{firstCondition, secondCondition, thirdCondition},
Alerts: nil,
NumberOfFailuresInARow: 0,
NumberOfSuccessesInARow: 0,
@@ -579,11 +579,6 @@ func TestInitialize(t *testing.T) {
Cfg: &storage.Config{Type: storage.TypeMemory},
ExpectedErr: nil,
},
{ // XXX: Remove for v4.0.0. See https://github.com/TwiN/gatus/issues/198
Name: "memory-with-path",
Cfg: &storage.Config{Type: storage.TypeMemory, Path: t.TempDir() + "/TestInitialize_memory-with-path.db"},
ExpectedErr: nil,
},
{
Name: "sqlite-no-path",
Cfg: &storage.Config{Type: storage.TypeSQLite},

View File

@@ -1 +0,0 @@
* text=lf

View File

@@ -1 +0,0 @@
.idea

View File

@@ -1,20 +0,0 @@
# Build the go application into a binary
FROM golang:alpine as builder
WORKDIR /app
ADD . ./
RUN CGO_ENABLED=0 GOOS=linux go build -mod vendor -a -installsuffix cgo -o bin/gocache-server cmd/server/main.go
RUN apk --update add --no-cache ca-certificates
FROM scratch
ENV APP_HOME=/app
ENV APP_DATA=/app/data
ENV PORT=6379
ENV MAX_CACHE_SIZE=100000
ENV MAX_MEMORY_USAGE=0
ENV AUTOSAVE="false"
VOLUME ${APP_DATA}
WORKDIR ${APP_HOME}
COPY --from=builder /app/bin/gocache-server ./bin/gocache-server
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
EXPOSE ${PORT}
ENTRYPOINT ["/app/bin/gocache-server"]

View File

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

View File

@@ -1,24 +0,0 @@
db: docker-build
dr: docker-run
drmem: docker-run-max-memory-usage
docker-build:
docker build --tag=gocache-server .
docker-run:
docker run -p 6666:6379 -e AUTOSAVE=true -e MAX_CACHE_SIZE=0 --name gocache-server -d gocache-server
docker-run-max-memory-usage:
docker run -p 6666:6379 -e AUTOSAVE=true -e MAX_CACHE_SIZE=0 -e MAX_MEMORY_USAGE=524288000 --name gocache-server -d gocache-server
run:
PORT=6666 go run cmd/server/main.go
start-redis:
docker run -p 6379:6379 --name redis -d redis
redis-benchmark:
redis-benchmark -p 6666 -t set,get -n 10000000 -r 200000 -q -P 512 -c 512
memtier-benchmark:
memtier_benchmark --port 6666 --hide-histogram --key-maximum 100000 --ratio 1:1 --expiry-range 1-100 --key-pattern R:R --randomize -n 100000

View File

@@ -1,580 +0,0 @@
# gocache
![build](https://github.com/TwiN/gocache/workflows/build/badge.svg?branch=master)
[![Go Report Card](https://goreportcard.com/badge/github.com/TwiN/gocache)](https://goreportcard.com/report/github.com/TwiN/gocache)
[![codecov](https://codecov.io/gh/TwiN/gocache/branch/master/graph/badge.svg)](https://codecov.io/gh/TwiN/gocache)
[![Go version](https://img.shields.io/github/go-mod/go-version/TwiN/gocache.svg)](https://github.com/TwiN/gocache)
[![Go Reference](https://pkg.go.dev/badge/github.com/TwiN/gocache.svg)](https://pkg.go.dev/github.com/TwiN/gocache)
[![Follow TwiN](https://img.shields.io/github/followers/TwiN?label=Follow&style=social)](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 persistence to file.
## 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)
- [Limitations](#limitations)
- [Eviction](#eviction)
- [MaxSize](#maxsize)
- [MaxMemoryUsage](#maxmemoryusage)
- [Expiration](#expiration)
- [Server](#server)
- [Running the server with Docker](#running-the-server-with-docker)
- [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)
- [How can I automatically save the cache to a file every 5 minutes?](#how-can-i-automatically-save-the-cache-to-a-file-every-5-minutes)
- [Why does the memory usage not go down?](#why-does-the-memory-usage-not-go-down)
## 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 bulk operations, persistence and patterns.
While meant to be used as a library, there's a Redis-compatible cache server included.
See the [Server](#server) section.
It may also serve as a good reference to use in order to implement gocache in your own applications.
## Usage
```
go get -u github.com/TwiN/gocache
```
If you're interested in using gocache as a server rather than an embedded library, see [Server](#server)
### 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.
| SaveToFile | Stores the content of the cache to a file so that it can be read using `ReadFromFile`. See [persistence](#persistence).
| ReadFromFile | Populates the cache using a file created using `SaveToFile`. See [persistence](#persistence).
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"
)
func main() {
cache := gocache.NewCache().WithEvictionPolicy(gocache.LeastRecentlyUsed).WithMaxSize(10000)
cache.StartJanitor() // Passively manages expired entries
cache.Set("key", "value")
cache.SetWithTTL("key-with-ttl", "value", 60*time.Minute)
cache.SetAll(map[string]interface{}{"k1": "v1", "k2": "v2", "k3": "v3"})
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] key=%s\n", key)
}
fmt.Println("Cache size before persisting cache to file:", cache.Count())
err := cache.SaveToFile("cache.bak")
if err != nil {
panic(fmt.Sprintf("failed to persist cache to file: %s", err.Error()))
}
cache.Expire("key", time.Hour)
time.Sleep(500*time.Millisecond)
timeUntilExpiration, _ := cache.TTL("key")
fmt.Println("Number of minutes before 'key' expires:", int(timeUntilExpiration.Seconds()))
cache.Delete("key")
cache.DeleteAll([]string{"k1", "k2", "k3"})
fmt.Println("Cache size before restoring cache from file:", cache.Count())
_, err = cache.ReadFromFile("cache.bak")
if err != nil {
panic(fmt.Sprintf("failed to restore cache from file: %s", err.Error()))
}
fmt.Println("Cache size after restoring cache from file:", cache.Count())
cache.Clear()
fmt.Println("Cache size after clearing the cache:", cache.Count())
}
```
<details>
<summary>Output</summary>
```
[Get] key=key; value=value; exists=true
[GetByKeys] key=k2; value=v2
[GetByKeys] key=k3; value=v3
[GetByKeys] key=k1; value=v1
[GetKeysByPattern] key=key
[GetKeysByPattern] key=key-with-ttl
Cache size before persisting cache to file: 5
Number of minutes before 'key' expires: 3599
Cache size before restoring cache from file: 1
Cache size after restoring cache from file: 5
Cache size after clearing the cache: 0
```
</details>
## Persistence
While gocache is an in-memory cache, you can still save the content of the cache in a file
and vice versa.
To save the content of the cache to a file:
```go
err := cache.SaveToFile(TestCacheFile)
```
To retrieve the content of the cache from a file:
```go
numberOfEntriesEvicted, err := newCache.ReadFromFile(TestCacheFile)
```
The `numberOfEntriesEvicted` will be non-zero only if the number of entries
in the file is higher than the cache's configured `MaxSize`.
### Limitations
While you can cache structs in memory out of the box, persisting structs to a file requires you to
**register the custom interfaces that your application uses with the `gob` package**.
```go
type YourCustomStruct struct {
A string
B int
}
// ...
cache.Set("key", YourCustomStruct{A: "test", B: 123})
```
To persist your custom struct properly:
```go
gob.Register(YourCustomStruct{})
cache.SaveToFile("gocache.bak")
```
The same applies for restoring the cache from a file:
```go
cache := NewCache()
gob.Register(YourCustomStruct{})
cache.ReadFromFile(TestCacheFile)
value, _ := cache.Get("key")
fmt.Println(value.(YourCustomStruct))
```
You only need to persist the struct once, so adding the following function in a file would suffice:
```go
func init() {
gob.Register(YourCustomStruct{})
}
```
Failure to register your custom structs will prevent gocache from persisting and/or parsing the value of each keys that
use said custom structs.
That being said, assuming that you're using gocache as a cache, this shouldn't create any bugs on your end, because
every key that cannot be parsed are not populated into the cache by `ReadFromFile`.
In other words, if you're falling back to a database or something similar when the cache doesn't have the key requested,
you'll be fine.
Note that if you need to modify the type of a variable in a struct, you should change the name of that variable as well.
For instance, if the struct has a `CreatedAt` variable with the type `time.Time` and that variable type is later
modified to `uint64`, decoding the struct would fail, however, if you rename the variable to `CreatedAtUnixTimeInMs`,
there won't be any decoding issues other than the loss of data for that field. You could also obviously handle the
migration gracefully by keeping both variables, populating the `CreatedAtUnixTimeInMs` variable with the `CreatedAt`
value and then removing the `CreatedAt` field.
## 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.
## Server
For the sake of convenience, a ready-to-go cache server is available through the `server` package.
#### As an application
```go
package main
import (
"github.com/TwiN/gocache"
gocacheserver "github.com/TwiN/gocache/server"
)
func main() {
cache := gocache.NewCache().WithEvictionPolicy(gocache.LeastRecentlyUsed).WithMaxSize(100000)
server := gocacheserver.NewServer(cache).WithPort(6379)
// This is a blocking function, therefore, you are expected to run this on a goroutine
server.Start()
}
```
The reason why the server is in a different package is because `gocache` limit its external dependencies to the strict
minimum (e.g. boltdb for persistence), however, rather than re-inventing the wheel, the server implementation uses
redcon, which is a very good Redis server framework for Go.
That way, those who desire to use gocache without the server will not add any extra dependencies
as long as they don't import the `server` package.
If you'd like to run it through the CLI:
```
go run cmd/server/main.go
```
Any Redis client should be able to interact with the server, though only the following instructions are supported:
- [X] GET
- [X] SET
- [X] DEL
- [X] PING
- [X] QUIT
- [X] INFO
- [X] EXPIRE
- [X] SETEX
- [X] TTL
- [X] FLUSHDB
- [X] EXISTS
- [X] ECHO
- [X] MGET
- [X] MSET
- [X] SCAN (kind of - cursor is not currently supported)
- [ ] KEYS
## Running the server with Docker
[![Docker pulls](https://img.shields.io/docker/pulls/twinproduction/gocache-server.svg)](https://cloud.docker.com/repository/docker/twinproduction/gocache-server)
```
docker run --name gocache-server -p 6379:6379 twinproduction/gocache-server
```
To build it locally, refer to the Makefile's `docker-build` and `docker-run` steps.
## 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?
Because this library doesn't persist immediately after every write operations, persistence is instead expected to be
done on a schedule, like for instance, every 10 minutes.
While this prevents you from losing all of your data, you may still lose some data if the application stopped 9 minutes
after the previous "auto 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"
)
const CacheFile = "gocache.data"
var cache = gocache.NewCache()
func main() {
// Load persisted data from file
cache.ReadFromFile(CacheFile)
// 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")
err := cache.SaveToFile(CacheFile)
if err != nil {
log.Println("Failed to save storage provider:", err.Error())
}
done <- true
}()
<-done
log.Println("Shutting down")
}
```
Note that this won't protect you from a SIGKILL, as this signal cannot be caught.
### How can I automatically save the cache to a file every 5 minutes?
Beside using the suggestion above, automatically persisting the cache on an interval will protect your application from
sudden terminations triggered by signals that cannot be caught, such as the force kill signal received by an application
being OOMKilled.
The simplest implementation could be something like this:
```go
const CacheFile = "gocache.data"
func main() {
cache := gocache.NewCache()
cache.ReadFromFile(CacheFile)
go autoSave(10*time.Minute)
// ...
}
func autoSave(interval time.Duration) {
for {
err := cache.SaveToFile(CacheFile)
if err != nil {
log.Println("Failed to persist cache to file:", err.Error())
}
time.Sleep(interval)
}
}
```
### Why does the memory usage not go down?
> **NOTE**: As of Go 1.16, this no longer applies. See [golang/go#42330](https://github.com/golang/go/issues/42330)
By default, Go uses `MADV_FREE` if the kernel supports it to release memory, which is significantly more efficient
than using `MADV_DONTNEED`. Unfortunately, this means that RSS doesn't go down unless the OS actually needs the
memory.
Technically, the memory _is_ available to the kernel, even if it shows a high memory usage, but the OS will only
use that memory if it needs to. In the case that the OS does need the freed memory, the RSS will go down and you'll
notice the memory usage lowering.
[reference](https://github.com/golang/go/issues/33376#issuecomment-666455792)
You can reproduce this by following the steps below:
- Start the server
- Note the memory usage
- Create 500k keys
- Note the memory usage
- Flush the cache
- Note that the memory usage has not decreased, despite the cache being empty.
**Substituting gocache for a normal map will yield the same result.**
If the released memory still appearing as used is a problem for you,
you can set the environment variable `GODEBUG` to `madvdontneed=1`.

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