Compare commits
240 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be4e9aba1e | ||
|
|
ac0d00fdb5 | ||
|
|
3293222cd6 | ||
|
|
892f3ada6f | ||
|
|
f22a79eb7d | ||
|
|
911deb91d1 | ||
|
|
bcd4105af3 | ||
|
|
423ada68b3 | ||
|
|
70fa17349f | ||
|
|
e640ede709 | ||
|
|
fb3447eaf3 | ||
|
|
46cf616a57 | ||
|
|
cf48072167 | ||
|
|
97dd868ae8 | ||
|
|
c18b2728c9 | ||
|
|
b3fd290e4d | ||
|
|
89e23a986c | ||
|
|
c454c868f6 | ||
|
|
6d82a54518 | ||
|
|
bd3c01a4f4 | ||
|
|
43150ae484 | ||
|
|
acb6757dc8 | ||
|
|
2037d9aca6 | ||
|
|
c700154f5e | ||
|
|
aac72e3741 | ||
|
|
1a597f92ba | ||
|
|
56fedcedd1 | ||
|
|
6bdce4fe29 | ||
|
|
381488a1b2 | ||
|
|
42a909c1ad | ||
|
|
5a4fa6f2b0 | ||
|
|
bbbfe7f466 | ||
|
|
7cf1750f86 | ||
|
|
b88ae5fcf6 | ||
|
|
8516c41b43 | ||
|
|
b90a64e2a6 | ||
|
|
627173e64f | ||
|
|
8b5e5f54cc | ||
|
|
2c95cce7b3 | ||
|
|
2ef9329fa6 | ||
|
|
9384373f43 | ||
|
|
d3a81a2d57 | ||
|
|
fed32d3909 | ||
|
|
c1d9006aaf | ||
|
|
7126d36d85 | ||
|
|
677c7faffe | ||
|
|
8dedcf7c74 | ||
|
|
a4c69d6fc3 | ||
|
|
943d0a19d1 | ||
|
|
fd08c8b1e5 | ||
|
|
393147c300 | ||
|
|
f73e8a56ef | ||
|
|
4203355edc | ||
|
|
5cc1c11b1a | ||
|
|
796228466d | ||
|
|
23ba9795a6 | ||
|
|
1291e86a6f | ||
|
|
14316cfd31 | ||
|
|
670272f411 | ||
|
|
ffc3e644c5 | ||
|
|
bc42d15625 | ||
|
|
20594b902c | ||
|
|
0a3267e499 | ||
|
|
9c8bf2b69e | ||
|
|
bd1eb7c61b | ||
|
|
e6335da94f | ||
|
|
1498b6d8a2 | ||
|
|
7aed826d65 | ||
|
|
9b68582622 | ||
|
|
a1afeea56b | ||
|
|
38de0ec9cd | ||
|
|
9d8a3f1574 | ||
|
|
b904afb8b5 | ||
|
|
5bf560221f | ||
|
|
574dd50b98 | ||
|
|
35c33620a5 | ||
|
|
fc0c3499f4 | ||
|
|
d03271d128 | ||
|
|
0560b98de4 | ||
|
|
ca87547430 | ||
|
|
e214d56af1 | ||
|
|
8997eeef05 | ||
|
|
5e00752c5a | ||
|
|
f9d132c369 | ||
|
|
ca977fefa8 | ||
|
|
d07d3434a6 | ||
|
|
2131fa4412 | ||
|
|
81aeb7a48e | ||
|
|
eaf395738d | ||
|
|
f6f1ecf623 | ||
|
|
177081cf54 | ||
|
|
651bfcba22 | ||
|
|
3cd1953c6c | ||
|
|
9dd4e7047d | ||
|
|
067ab78666 | ||
|
|
28acaeb067 | ||
|
|
749aeb9e42 | ||
|
|
8e02572880 | ||
|
|
1f6f0ce426 | ||
|
|
7bc381b356 | ||
|
|
18420c2d60 | ||
|
|
0b4dc34c57 | ||
|
|
030212c156 | ||
|
|
63b0ac8b35 | ||
|
|
263b2f0f94 | ||
|
|
db23bd9073 | ||
|
|
40dc1cc270 | ||
|
|
67c3bf6e5e | ||
|
|
57ef931d38 | ||
|
|
e3038f0e80 | ||
|
|
8106832d69 | ||
|
|
758428b312 | ||
|
|
77de4c4742 | ||
|
|
a85c5d5486 | ||
|
|
c7d554efa5 | ||
|
|
f3afdf2977 | ||
|
|
19a0ba7271 | ||
|
|
4a4c88ae17 | ||
|
|
253e6f8338 | ||
|
|
1d412678ff | ||
|
|
48c7514fa5 | ||
|
|
d7b437595c | ||
|
|
50f530a05c | ||
|
|
2a632e8f87 | ||
|
|
857ad584e7 | ||
|
|
8b3b2f70bf | ||
|
|
4308f2c1ef | ||
|
|
425c93ed8f | ||
|
|
752e82d80b | ||
|
|
e91462ce41 | ||
|
|
347297a8ea | ||
|
|
56dbe2fea0 | ||
|
|
ebcca4317d | ||
|
|
e6355dfee8 | ||
|
|
7309888db5 | ||
|
|
f60eee86ee | ||
|
|
e46acb885c | ||
|
|
24da853820 | ||
|
|
4e5a86031f | ||
|
|
7f6f127f4f | ||
|
|
12c352254f | ||
|
|
2b9d986932 | ||
|
|
cdbf5f6c6f | ||
|
|
33562e97f4 | ||
|
|
c9acc83141 | ||
|
|
8c4c360472 | ||
|
|
2c8714f1fa | ||
|
|
8ec256edbf | ||
|
|
a48ec41bca | ||
|
|
541e0264ab | ||
|
|
f945e4b8a2 | ||
|
|
076b92a2b4 | ||
|
|
02e9f74a04 | ||
|
|
b37dd5e819 | ||
|
|
1775f80ffe | ||
|
|
3187db1e9a | ||
|
|
932eab00a0 | ||
|
|
c842ac2343 | ||
|
|
6320237326 | ||
|
|
8fe9d013b5 | ||
|
|
c094c06e56 | ||
|
|
f961bf961e | ||
|
|
404a3cea64 | ||
|
|
d000460a99 | ||
|
|
455fae05c1 | ||
|
|
85dff34350 | ||
|
|
99fa632021 | ||
|
|
cafcc9d45b | ||
|
|
dc929dac70 | ||
|
|
42825b62fb | ||
|
|
a89bb392ed | ||
|
|
e7c4d03c22 | ||
|
|
4e6bf91651 | ||
|
|
de31a7a62e | ||
|
|
7f0543ebd2 | ||
|
|
9b893aa4e0 | ||
|
|
50435f4030 | ||
|
|
9649d80388 | ||
|
|
8c3ab1eac2 | ||
|
|
cdb5ba080a | ||
|
|
0a145da912 | ||
|
|
b603cdb0ea | ||
|
|
11d1f24ceb | ||
|
|
c74472d332 | ||
|
|
78a1262e7c | ||
|
|
7ff8907eda | ||
|
|
1d21f5889d | ||
|
|
d7d904ae5f | ||
|
|
7390895514 | ||
|
|
2873d96b9f | ||
|
|
ea9623f695 | ||
|
|
9cdef02bdc | ||
|
|
16229592a2 | ||
|
|
52ad4ee9e5 | ||
|
|
b47c6dc408 | ||
|
|
8e2a2c4dbc | ||
|
|
8698736e7d | ||
|
|
cd1430f043 | ||
|
|
79bef8d391 | ||
|
|
9196f57487 | ||
|
|
1e0d9e184c | ||
|
|
bc42497cb7 | ||
|
|
b0b0ab574d | ||
|
|
e369484e5f | ||
|
|
d18618449f | ||
|
|
9186049589 | ||
|
|
4362831f71 | ||
|
|
99f558d43e | ||
|
|
4a3d5944b6 | ||
|
|
688456d7cf | ||
|
|
431fb3e9f2 | ||
|
|
a1679ddc5e | ||
|
|
d8d8e8720b | ||
|
|
4a0d9d058a | ||
|
|
c8ccf9b352 | ||
|
|
45c966fbca | ||
|
|
d8d4756ef3 | ||
|
|
1e9c54cc0f | ||
|
|
80570688e1 | ||
|
|
43e6e3e8f5 | ||
|
|
8b4c5c20f3 | ||
|
|
6f6db36b0f | ||
|
|
467874de10 | ||
|
|
8337f41425 | ||
|
|
601d676e34 | ||
|
|
fbb5d48bf7 | ||
|
|
119b80edc0 | ||
|
|
99e8cfb1ce | ||
|
|
dcbbec7931 | ||
|
|
2ccd656386 | ||
|
|
5755f3a699 | ||
|
|
911d809376 | ||
|
|
752c872d3b | ||
|
|
67a3e4e330 | ||
|
|
668ed3b1a2 | ||
|
|
dc6cb8fc1d | ||
|
|
f1aa5191bf | ||
|
|
bc6ca2ebd0 | ||
|
|
30801938b2 | ||
|
|
ddddd405bb |
@@ -3,3 +3,4 @@ Dockerfile
|
||||
.github
|
||||
.idea
|
||||
.git
|
||||
web/app
|
||||
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
github: [TwinProduction]
|
||||
BIN
.github/assets/dark-mode.png
vendored
Normal file
BIN
.github/assets/dark-mode.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
BIN
.github/assets/telegram-alerts.png
vendored
Normal file
BIN
.github/assets/telegram-alerts.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
12
.github/workflows/build.yml
vendored
12
.github/workflows/build.yml
vendored
@@ -14,20 +14,20 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Set up Go 1.15
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: 1.15
|
||||
go-version: 1.16
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v2
|
||||
- name: Build binary to make sure it works
|
||||
run: go build -mod vendor
|
||||
- name: Test
|
||||
# We're using "sudo" because one of the tests leverages ping, which requires super-user privileges.
|
||||
# As for the "PATH=$PATH", we need it to use the same "go" executable that was configured by the "Set
|
||||
# up Go" step (otherwise, it'd use sudo's "go" executable)
|
||||
run: sudo "PATH=$PATH" go test -mod vendor ./... -race -coverprofile=coverage.txt -covermode=atomic
|
||||
# As for the 'env "PATH=$PATH" "GOROOT=$GOROOT"', we need it to use the same "go" executable that
|
||||
# was configured by the "Set up Go 1.15" step (otherwise, it'd use sudo's "go" executable)
|
||||
run: sudo env "PATH=$PATH" "GOROOT=$GOROOT" go test -mod vendor ./... -race -coverprofile=coverage.txt -covermode=atomic
|
||||
- name: Codecov
|
||||
uses: codecov/codecov-action@v1.0.14
|
||||
uses: codecov/codecov-action@v1.5.2
|
||||
with:
|
||||
file: ./coverage.txt
|
||||
|
||||
1
.github/workflows/publish.yml
vendored
1
.github/workflows/publish.yml
vendored
@@ -6,6 +6,7 @@ jobs:
|
||||
build:
|
||||
name: Publish
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,4 +1,7 @@
|
||||
bin
|
||||
.idea
|
||||
.vscode
|
||||
gatus
|
||||
gatus
|
||||
db.db
|
||||
config/config.yml
|
||||
db.db-shm
|
||||
db.db-wal
|
||||
@@ -13,7 +13,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -mod vendor -a -installsuffix cgo -o gatus
|
||||
FROM scratch
|
||||
COPY --from=builder /app/gatus .
|
||||
COPY --from=builder /app/config.yaml ./config/config.yaml
|
||||
COPY --from=builder /app/static static/
|
||||
COPY --from=builder /app/web/static ./web/static
|
||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
ENV PORT=8080
|
||||
EXPOSE ${PORT}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 TwinProduction
|
||||
Copyright (c) 2021 TwinProduction
|
||||
|
||||
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:
|
||||
|
||||
|
||||
14
Makefile
Normal file
14
Makefile
Normal file
@@ -0,0 +1,14 @@
|
||||
docker-build:
|
||||
docker build -t twinproduction/gatus:latest .
|
||||
|
||||
docker-build-and-run:
|
||||
docker build -t twinproduction/gatus:latest . && docker run -p 8080:8080 --name gatus twinproduction/gatus:latest
|
||||
|
||||
build-frontend:
|
||||
npm --prefix web/app run build
|
||||
|
||||
run-frontend:
|
||||
npm --prefix web/app run serve
|
||||
|
||||
test:
|
||||
go test ./alerting/... ./client/... ./config/... ./controller/... ./core/... ./jsonpath/... ./pattern/... ./security/... ./storage/... ./util/... ./watchdog/... -cover
|
||||
575
README.md
575
README.md
@@ -1,4 +1,4 @@
|
||||

|
||||

|
||||
|
||||

|
||||
[](https://goreportcard.com/report/github.com/TwinProduction/gatus)
|
||||
@@ -7,32 +7,47 @@
|
||||
[](https://cloud.docker.com/repository/docker/twinproduction/gatus)
|
||||
[](https://github.com/TwinProduction)
|
||||
|
||||
A service health dashboard in Go that is meant to be used as a docker
|
||||
image with a custom configuration file.
|
||||
Gatus is a health dashboard that gives you the ability to monitor your services using HTTP, ICMP, TCP, and even DNS
|
||||
queries as well as evaluate the result of said queries by using a list of conditions on values like the status code,
|
||||
the response time, the certificate expiration, the body and many others. The icing on top is that each of these health
|
||||
checks can be paired with alerting via Slack, PagerDuty, Discord and even Twilio.
|
||||
|
||||
I personally deploy it in my Kubernetes cluster and let it monitor the status of my
|
||||
core applications: https://status.twinnation.org/
|
||||
|
||||
<details>
|
||||
<summary><b>Quick start</b></summary>
|
||||
|
||||
```
|
||||
docker run -p 8080:8080 --name gatus twinproduction/gatus
|
||||
```
|
||||
For more details, see [Usage](#usage)
|
||||
</details>
|
||||
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Why Gatus?](#why-gatus)
|
||||
- [Features](#features)
|
||||
- [Usage](#usage)
|
||||
- [Configuration](#configuration)
|
||||
- [Configuration](#configuration)
|
||||
- [Conditions](#conditions)
|
||||
- [Placeholders](#placeholders)
|
||||
- [Functions](#functions)
|
||||
- [Alerting](#alerting)
|
||||
- [Configuring Slack alerts](#configuring-slack-alerts)
|
||||
- [Configuring Discord alerts](#configuring-discord-alerts)
|
||||
- [Configuring PagerDuty alerts](#configuring-pagerduty-alerts)
|
||||
- [Configuring Twilio alerts](#configuring-twilio-alerts)
|
||||
- [Configuring Mattermost alerts](#configuring-mattermost-alerts)
|
||||
- [Configuring Messagebird alerts](#configuring-messagebird-alerts)
|
||||
- [Configuring Telegram alerts](#configuring-telegram-alerts)
|
||||
- [Configuring custom alerts](#configuring-custom-alerts)
|
||||
- [Kubernetes (ALPHA)](#kubernetes-alpha)
|
||||
- [Auto Discovery](#auto-discovery)
|
||||
- [Deploying](#deploying)
|
||||
- [Docker](#docker)
|
||||
- [Deployment](#deployment)
|
||||
- [Docker](#docker)
|
||||
- [Helm Chart](#helm-chart)
|
||||
- [Terraform](#terraform)
|
||||
- [Running the tests](#running-the-tests)
|
||||
- [Using in Production](#using-in-production)
|
||||
- [FAQ](#faq)
|
||||
@@ -42,14 +57,36 @@ core applications: https://status.twinnation.org/
|
||||
- [Monitoring a TCP service](#monitoring-a-tcp-service)
|
||||
- [Monitoring a service using ICMP](#monitoring-a-service-using-icmp)
|
||||
- [Monitoring a service using DNS queries](#monitoring-a-service-using-dns-queries)
|
||||
- [Monitoring a service using STARTTLS](#monitoring-a-service-using-starttls)
|
||||
- [Basic authentication](#basic-authentication)
|
||||
- [disable-monitoring-lock](#disable-monitoring-lock)
|
||||
- [Reloading configuration on the fly](#reloading-configuration-on-the-fly)
|
||||
- [Service groups](#service-groups)
|
||||
- [Exposing Gatus on a custom port](#exposing-gatus-on-a-custom-port)
|
||||
- [Uptime Badges (ALPHA)](#uptime-badges)
|
||||
- [API](#API)
|
||||
- [Sponsors](#sponsors)
|
||||
|
||||
## Why Gatus?
|
||||
Before getting into the specifics, I want to address the most common question:
|
||||
> Why would I use Gatus when I can just use Prometheus’ Alertmanager, Cloudwatch or even Splunk?
|
||||
|
||||
Neither of these can tell you that there’s a problem if there are no clients actively calling the endpoint.
|
||||
In other words, it's because monitoring metrics mostly rely on existing traffic, which effectively means that unless
|
||||
your clients are already experiencing a problem, you won't be notified.
|
||||
|
||||
Gatus, on the other hand, allows you to configure health checks for each of your features, which in turn allows it to
|
||||
monitor these features and potentially alert you before any clients are impacted.
|
||||
|
||||
A sign you may want to look into Gatus is by simply asking yourself whether you'd receive an alert if your load balancer
|
||||
was to go down right now. Will any of your existing alerts by triggered? Your metrics won’t report an increase in errors
|
||||
if there’s no traffic that makes it to your applications. This puts you in a situation where your clients are the ones
|
||||
that will notify you about the degradation of your services rather than you reassuring them that you're working on
|
||||
fixing the issue before they even know about it.
|
||||
|
||||
|
||||
## Features
|
||||

|
||||
|
||||
The main features of Gatus are:
|
||||
- **Highly flexible health check conditions**: While checking the response status may be enough for some use cases, Gatus goes much further and allows you to add conditions on the response time, the response body and even the IP address.
|
||||
@@ -58,12 +95,10 @@ The main features of Gatus are:
|
||||
- **Alerting**: While having a pretty visual dashboard is useful to keep track of the state of your application(s), you probably don't want to stare at it all day. Thus, notifications via Slack, Mattermost, Messagebird, PagerDuty and Twilio are supported out of the box with the ability to configure a custom alerting provider for any needs you might have, whether it be a different provider or a custom application that manages automated rollbacks.
|
||||
- **Metrics**
|
||||
- **Low resource consumption**: As with most Go applications, the resource footprint that this application requires is negligibly small.
|
||||
- **GitHub uptime badges** (ALPHA):   
|
||||
- **Service auto discovery in Kubernetes** (ALPHA)
|
||||
- **GitHub uptime badges**:   
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
By default, the configuration file is expected to be at `config/config.yaml`.
|
||||
|
||||
You can specify a custom path by setting the `GATUS_CONFIG_FILE` environment variable.
|
||||
@@ -82,7 +117,7 @@ services:
|
||||
- "[RESPONSE_TIME] < 300" # Response time must be under 300ms
|
||||
- name: example
|
||||
url: "https://example.org/"
|
||||
interval: 30s
|
||||
interval: 5m
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
```
|
||||
@@ -91,38 +126,131 @@ This example would look like this:
|
||||
|
||||

|
||||
|
||||
Note that you can also add environment variables in the configuration file (i.e. `$DOMAIN`, `${DOMAIN}`)
|
||||
Note that you can also use environment variables in the configuration file (e.g. `$DOMAIN`, `${DOMAIN}`)
|
||||
|
||||
If you want to test it locally, see [Docker](#docker).
|
||||
|
||||
|
||||
### Configuration
|
||||
## Configuration
|
||||
| Parameter | Description | Default |
|
||||
|:---------------------------------------- |:----------------------------------------------------------------------------- |:-------------- |
|
||||
| `debug` | Whether to enable debug logs. | `false` |
|
||||
| `metrics` | Whether to expose metrics at /metrics. | `false` |
|
||||
| `storage` | Storage configuration. See [Storage](#storage). | `{}` |
|
||||
| `services` | List of services to monitor. | Required `[]` |
|
||||
| `services[].name` | Name of the service. Can be anything. | Required `""` |
|
||||
| `services[].group` | Group name. Used to group multiple services together on the dashboard. See [Service groups](#service-groups). | `""` |
|
||||
| `services[].url` | URL to send the request to. | Required `""` |
|
||||
| `services[].method` | Request method. | `GET` |
|
||||
| `services[].insecure` | Whether to skip verifying the server's certificate chain and host name. | `false` |
|
||||
| `services[].conditions` | Conditions used to determine the health of the service. See [Conditions](#conditions). | `[]` |
|
||||
| `services[].interval` | Duration to wait between every status check. | `60s` |
|
||||
| `services[].graphql` | Whether to wrap the body in a query param (`{"query":"$body"}`). | `false` |
|
||||
| `services[].body` | Request body. | `""` |
|
||||
| `services[].headers` | Request headers. | `{}` |
|
||||
| `services[].dns` | Configuration for a service of type DNS. See [Monitoring a service using DNS queries](#monitoring-a-service-using-dns-queries). | `""` |
|
||||
| `services[].dns.query-type` | Query type for DNS service. | `""` |
|
||||
| `services[].dns.query-name` | Query name for DNS service. | `""` |
|
||||
| `services[].alerts[].type` | Type of alert. Valid types: `slack`, `discord`, `pagerduty`, `twilio`, `mattermost`, `messagebird`, `custom`. | Required `""` |
|
||||
| `services[].alerts[].enabled` | Whether to enable the alert. | `false` |
|
||||
| `services[].alerts[].failure-threshold` | Number of failures in a row needed before triggering the alert. | `3` |
|
||||
| `services[].alerts[].success-threshold` | Number of successes in a row before an ongoing incident is marked as resolved. | `2` |
|
||||
| `services[].alerts[].send-on-resolved` | Whether to send a notification once a triggered alert is marked as resolved. | `false` |
|
||||
| `services[].alerts[].description` | Description of the alert. Will be included in the alert sent. | `""` |
|
||||
| `alerting` | Configuration for alerting. See [Alerting](#alerting). | `{}` |
|
||||
| `security` | Security configuration. | `{}` |
|
||||
| `security.basic` | Basic authentication security configuration. | `{}` |
|
||||
| `security.basic.username` | Username for Basic authentication. | Required `""` |
|
||||
| `security.basic.password-sha512` | Password's SHA512 hash for Basic authentication. | Required `""` |
|
||||
| `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock). | `false` |
|
||||
| `skip-invalid-config-update` | Whether to ignore invalid configuration update. See [Reloading configuration on the fly](#reloading-configuration-on-the-fly). | `false` |
|
||||
| `web` | Web configuration. | `{}` |
|
||||
| `web.address` | Address to listen on. | `0.0.0.0` |
|
||||
| `web.port` | Port to listen on. | `8080` |
|
||||
|
||||
For Kubernetes configuration, see [Kubernetes](#kubernetes-alpha).
|
||||
|
||||
|
||||
### Conditions
|
||||
Here are some examples of conditions you can use:
|
||||
|
||||
| Condition | Description | Passing values | Failing values |
|
||||
|:-----------------------------|:------------------------------------------------------- |:-------------------------- | -------------- |
|
||||
| `[STATUS] == 200` | Status must be equal to 200 | 200 | 201, 404, ... |
|
||||
| `[STATUS] < 300` | Status must lower than 300 | 200, 201, 299 | 301, 302, ... |
|
||||
| `[STATUS] <= 299` | Status must be less than or equal to 299 | 200, 201, 299 | 301, 302, ... |
|
||||
| `[STATUS] > 400` | Status must be greater than 400 | 401, 402, 403, 404 | 400, 200, ... |
|
||||
| `[STATUS] == any(200, 429)` | Status must be either 200 or 429 | 200, 429 | 201, 400, ... |
|
||||
| `[CONNECTED] == true` | Connection to host must've been successful | true, false | |
|
||||
| `[RESPONSE_TIME] < 500` | Response time must be below 500ms | 100ms, 200ms, 300ms | 500ms, 501ms |
|
||||
| `[IP] == 127.0.0.1` | Target IP must be 127.0.0.1 | 127.0.0.1 | 0.0.0.0 |
|
||||
| `[BODY] == 1` | The body must be equal to 1 | 1 | `{}`, `2`, ... |
|
||||
| `[BODY].user.name == john` | JSONPath value of `$.user.name` is equal to `john` | `{"user":{"name":"john"}}` | |
|
||||
| `[BODY].data[0].id == 1` | JSONPath value of `$.data[0].id` is equal to 1 | `{"data":[{"id":1}]}` | |
|
||||
| `[BODY].age == [BODY].id` | JSONPath value of `$.age` is equal JSONPath `$.id` | `{"age":1,"id":1}` | |
|
||||
| `len([BODY].data) < 5` | Array at JSONPath `$.data` has less than 5 elements | `{"data":[{"id":1}]}` | |
|
||||
| `len([BODY].name) == 8` | String at JSONPath `$.name` has a length of 8 | `{"name":"john.doe"}` | `{"name":"bob"}` |
|
||||
| `has([BODY].errors) == false` | JSONPath `$.errors` does not exist | `{"name":"john.doe"}` | `{"errors":[]}` |
|
||||
| `has([BODY].users) == true` | JSONPath `$.users` exists | `{"users":[]}` | `{}` |
|
||||
| `[BODY].name == pat(john*)` | String at JSONPath `$.name` matches pattern `john*` | `{"name":"john.doe"}` | `{"name":"bob"}` |
|
||||
| `[BODY].id == any(1, 2)` | Value at JSONPath `$.id` is equal to `1` or `2` | 1, 2 | 3, 4, 5 |
|
||||
| `[CERTIFICATE_EXPIRATION] > 48h` | Certificate expiration is more than 48h away | 49h, 50h, 123h | 1h, 24h, ... |
|
||||
|
||||
|
||||
#### 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 using HTTPS)
|
||||
| `[DNS_RCODE]` | Resolves into the DNS status of the response | NOERROR
|
||||
|
||||
|
||||
#### Functions
|
||||
| Function | Description | Example |
|
||||
|:-----------|:---------------------------------------------------------------------------------------------------------------- |:-------------------------- |
|
||||
| `len` | Returns the length of the object/slice. Works only with the `[BODY]` placeholder. | `len([BODY].username) > 8`
|
||||
| `has` | Returns `true` or `false` based on whether a given path is valid. Works only with the `[BODY]` placeholder. | `has([BODY].errors) == false`
|
||||
| `pat` | Specifies that the string passed as parameter should be evaluated as a pattern. Works only with `==` and `!=`. | `[IP] == pat(192.168.*)`
|
||||
| `any` | Specifies that any one of the values passed as parameters is a valid value. Works only with `==` and `!=`. | `[BODY].ip == any(127.0.0.1, ::1)`
|
||||
|
||||
**NOTE**: Use `pat` only when you need to. `[STATUS] == pat(2*)` is a lot more expensive than `[STATUS] < 300`.
|
||||
|
||||
|
||||
### Storage
|
||||
| Parameter | Description | Default |
|
||||
|:------------------ |:-------------------------------------------------------------------------------------- |:-------------- |
|
||||
| `storage` | Storage configuration | `{}` |
|
||||
| `storage.file` | File to persist the data in. If the type is `inmemory`, data is persisted on interval. | `""` |
|
||||
| `storage.type` | Type of storage. Valid types: `inmemory`, `sqlite`. | `"inmemory"` |
|
||||
|
||||
- If `storage.type` is `inmemory` (default) and `storage.file` is set to a non-blank value.
|
||||
Furthermore, the data is periodically persisted, but everything remains in memory.
|
||||
- If `storage.type` is `sqlite`, `storage.file` must not be blank.
|
||||
```yaml
|
||||
storage:
|
||||
type: sqlite
|
||||
file: data.db
|
||||
```
|
||||
See [examples/docker-compose-sqlite-storage](examples/docker-compose-sqlite-storage) for an example.
|
||||
|
||||
|
||||
### Alerting
|
||||
Gatus supports multiple alerting providers, such as Slack and PagerDuty, and supports different alerts for each
|
||||
individual services with configurable descriptions and thresholds.
|
||||
|
||||
Note that if an alerting provider is not properly configured, all alerts configured with the provider's type will be
|
||||
ignored.
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|:---------------------------------------- |:----------------------------------------------------------------------------- |:-------------- |
|
||||
| `debug` | Whether to enable debug logs | `false` |
|
||||
| `metrics` | Whether to expose metrics at /metrics | `false` |
|
||||
| `services` | List of services to monitor | Required `[]` |
|
||||
| `services[].name` | Name of the service. Can be anything. | Required `""` |
|
||||
| `services[].group` | Group name. Used to group multiple services together on the dashboard. See [Service groups](#service-groups). | `""` |
|
||||
| `services[].url` | URL to send the request to | Required `""` |
|
||||
| `services[].method` | Request method | `GET` |
|
||||
| `services[].insecure` | Whether to skip verifying the server's certificate chain and host name | `false` |
|
||||
| `services[].conditions` | Conditions used to determine the health of the service | `[]` |
|
||||
| `services[].interval` | Duration to wait between every status check | `60s` |
|
||||
| `services[].graphql` | Whether to wrap the body in a query param (`{"query":"$body"}`) | `false` |
|
||||
| `services[].body` | Request body | `""` |
|
||||
| `services[].headers` | Request headers | `{}` |
|
||||
| `services[].dns` | Configuration for a service of type DNS. See [Monitoring using DNS queries](#monitoring-using-dns-queries) | `""` |
|
||||
| `services[].dns.query-type` | Query type for DNS service | `""` |
|
||||
| `services[].dns.query-name` | Query name for DNS service | `""` |
|
||||
| `services[].alerts[].type` | Type of alert. Valid types: `slack`, `pagerduty`, `twilio`, `mattermost`, `messagebird`, `custom` | Required `""` |
|
||||
| `services[].alerts[].enabled` | Whether to enable the alert | `false` |
|
||||
| `services[].alerts[].failure-threshold` | Number of failures in a row needed before triggering the alert | `3` |
|
||||
| `services[].alerts[].success-threshold` | Number of successes in a row before an ongoing incident is marked as resolved | `2` |
|
||||
| `services[].alerts[].send-on-resolved` | Whether to send a notification once a triggered alert is marked as resolved | `false` |
|
||||
| `services[].alerts[].description` | Description of the alert. Will be included in the alert sent | `""` |
|
||||
| `alerting` | Configuration for alerting | `{}` |
|
||||
| `alerting.slack` | Configuration for alerts of type `slack` | `{}` |
|
||||
| `alerting.slack.webhook-url` | Slack Webhook URL | Required `""` |
|
||||
| `alerting.discord` | Configuration for alerts of type `discord` | `{}` |
|
||||
| `alerting.discord.webhook-url` | Discord Webhook URL | Required `""` |
|
||||
| `alerting.pagerduty` | Configuration for alerts of type `pagerduty` | `{}` |
|
||||
| `alerting.pagerduty.integration-key` | PagerDuty Events API v2 integration key. | Required `""` |
|
||||
| `alerting.twilio` | Settings for alerts of type `twilio` | `{}` |
|
||||
@@ -137,83 +265,28 @@ Note that you can also add environment variables in the configuration file (i.e.
|
||||
| `alerting.messagebird.access-key` | Messagebird access key | Required `""` |
|
||||
| `alerting.messagebird.originator` | The sender of the message | Required `""` |
|
||||
| `alerting.messagebird.recipients` | The recipients of the message | Required `""` |
|
||||
| `alerting.telegram` | Configuration for alerts of type `telegram` | `{}` |
|
||||
| `alerting.telegram.token` | Telegram Bot Token | Required `""` |
|
||||
| `alerting.telegram.id` | Telegram User ID | Required `""` |
|
||||
| `alerting.custom` | Configuration for custom actions on failure or alerts | `{}` |
|
||||
| `alerting.custom.url` | Custom alerting request url | Required `""` |
|
||||
| `alerting.custom.method` | Request method | `GET` |
|
||||
| `alerting.custom.insecure` | Whether to skip verifying the server's certificate chain and host name | `false` |
|
||||
| `alerting.custom.body` | Custom alerting request body. | `""` |
|
||||
| `alerting.custom.headers` | Custom alerting request headers | `{}` |
|
||||
| `security` | Security configuration | `{}` |
|
||||
| `security.basic` | Basic authentication security configuration | `{}` |
|
||||
| `security.basic.username` | Username for Basic authentication | Required `""` |
|
||||
| `security.basic.password-sha512` | Password's SHA512 hash for Basic authentication | Required `""` |
|
||||
| `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock) | `false` |
|
||||
| `web` | Web configuration | `{}` |
|
||||
| `web.address` | Address to listen on | `0.0.0.0` |
|
||||
| `web.port` | Port to listen on | `8080` |
|
||||
| `web.context-root` | Context root at which Gatus will be exposed (frontend and backend) | `/` |
|
||||
|
||||
For Kubernetes configuration, see [Kubernetes](#kubernetes-alpha)
|
||||
|
||||
|
||||
### Conditions
|
||||
|
||||
Here are some examples of conditions you can use:
|
||||
|
||||
| Condition | Description | Passing values | Failing values |
|
||||
|:-----------------------------|:------------------------------------------------------- |:-------------------------- | -------------- |
|
||||
| `[STATUS] == 200` | Status must be equal to 200 | 200 | 201, 404, ... |
|
||||
| `[STATUS] < 300` | Status must lower than 300 | 200, 201, 299 | 301, 302, ... |
|
||||
| `[STATUS] <= 299` | Status must be less than or equal to 299 | 200, 201, 299 | 301, 302, ... |
|
||||
| `[STATUS] > 400` | Status must be greater than 400 | 401, 402, 403, 404 | 400, 200, ... |
|
||||
| `[STATUS] == any(200, 429)` | Status must be either 200 or 420 | 200, 429 | 201, 400, ... |
|
||||
| `[CONNECTED] == true` | Connection to host must've been successful | true, false | |
|
||||
| `[RESPONSE_TIME] < 500` | Response time must be below 500ms | 100ms, 200ms, 300ms | 500ms, 501ms |
|
||||
| `[IP] == 127.0.0.1` | Target IP must be 127.0.0.1 | 127.0.0.1 | 0.0.0.0 |
|
||||
| `[BODY] == 1` | The body must be equal to 1 | 1 | `{}`, `2`, ... |
|
||||
| `[BODY].user.name == john` | JSONPath value of `$.user.name` is equal to `john` | `{"user":{"name":"john"}}` | |
|
||||
| `[BODY].data[0].id == 1` | JSONPath value of `$.data[0].id` is equal to 1 | `{"data":[{"id":1}]}` | |
|
||||
| `[BODY].age == [BODY].id` | JSONPath value of `$.age` is equal JSONPath `$.id` | `{"age":1,"id":1}` | |
|
||||
| `len([BODY].data) < 5` | Array at JSONPath `$.data` has less than 5 elements | `{"data":[{"id":1}]}` | |
|
||||
| `len([BODY].name) == 8` | String at JSONPath `$.name` has a length of 8 | `{"name":"john.doe"}` | `{"name":"bob"}` |
|
||||
| `[BODY].name == pat(john*)` | String at JSONPath `$.name` matches pattern `john*` | `{"name":"john.doe"}` | `{"name":"bob"}` |
|
||||
| `[BODY].id == any(1, 2)` | Value at JSONPath `$.id` is equal to `1` or `2` | 1, 2 | 3, 4, 5 |
|
||||
| `[CERTIFICATE_EXPIRATION] > 48h` | Certificate expiration is more than 48h away | 49h, 50h, 123h | 1h, 24h, ... |
|
||||
|
||||
|
||||
#### Placeholders
|
||||
|
||||
| Placeholder | Description | Example of resolved value |
|
||||
|:-------------------------- |:--------------------------------------------------------------- |:------------------------- |
|
||||
| `[STATUS]` | Resolves into the HTTP status of the request | 404
|
||||
| `[RESPONSE_TIME]` | Resolves into the response time the request took, in ms | 10
|
||||
| `[IP]` | Resolves into the IP of the target host | 192.168.0.232
|
||||
| `[BODY]` | Resolves into the response body. Supports JSONPath. | `{"name":"john.doe"}`
|
||||
| `[CONNECTED]` | Resolves into whether a connection could be established | `true`
|
||||
| `[CERTIFICATE_EXPIRATION]` | Resolves into the duration before certificate expiration | `24h`, `48h`, 0 (if not using HTTPS)
|
||||
| `[DNS_RCODE]` | Resolves into the DNS status of the response | NOERROR
|
||||
|
||||
|
||||
#### Functions
|
||||
|
||||
| Function | Description | Example |
|
||||
|:-----------|:---------------------------------------------------------------------------------------------------------------- |:-------------------------- |
|
||||
| `len` | Returns the length of the object/slice. Works only with the `[BODY]` placeholder. | `len([BODY].username) > 8`
|
||||
| `pat` | Specifies that the string passed as parameter should be evaluated as a pattern. Works only with `==` and `!=`. | `[IP] == pat(192.168.*)`
|
||||
| `any` | Specifies that any one of the values passed as parameters is a valid value. Works only with `==` and `!=`. | `[BODY].ip == any(127.0.0.1, ::1)`
|
||||
|
||||
**NOTE**: Use `pat` only when you need to. `[STATUS] == pat(2*)` is a lot more expensive than `[STATUS] < 300`.
|
||||
|
||||
|
||||
### Alerting
|
||||
| `alerting.*.default-alert.enabled` | Whether to enable the alert | N/A |
|
||||
| `alerting.*.default-alert.failure-threshold` | Number of failures in a row needed before triggering the alert | N/A |
|
||||
| `alerting.*.default-alert.success-threshold` | Number of successes in a row before an ongoing incident is marked as resolved | N/A |
|
||||
| `alerting.*.default-alert.send-on-resolved` | Whether to send a notification once a triggered alert is marked as resolved | N/A |
|
||||
| `alerting.*.default-alert.description` | Description of the alert. Will be included in the alert sent | N/A |
|
||||
|
||||
|
||||
#### Configuring Slack alerts
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
slack:
|
||||
webhook-url: "https://hooks.slack.com/services/**********/**********/**********"
|
||||
|
||||
services:
|
||||
- name: twinnation
|
||||
url: "https://twinnation.org/health"
|
||||
@@ -239,8 +312,29 @@ Here's an example of what the notifications look like:
|
||||

|
||||
|
||||
|
||||
#### Configuring PagerDuty alerts
|
||||
#### Configuring Discord alerts
|
||||
```yaml
|
||||
alerting:
|
||||
discord:
|
||||
webhook-url: "https://discord.com/api/webhooks/**********/**********"
|
||||
|
||||
services:
|
||||
- name: twinnation
|
||||
url: "https://twinnation.org/health"
|
||||
interval: 30s
|
||||
alerts:
|
||||
- type: discord
|
||||
enabled: true
|
||||
description: "healthcheck failed"
|
||||
send-on-resolved: true
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].status == UP"
|
||||
- "[RESPONSE_TIME] < 300"
|
||||
```
|
||||
|
||||
|
||||
#### Configuring PagerDuty alerts
|
||||
It is highly recommended to set `services[].alerts[].send-on-resolved` to `true` for alerts
|
||||
of type `pagerduty`, because unlike other alerts, the operation resulting from setting said
|
||||
parameter to `true` will not create another incident, but mark the incident as resolved on
|
||||
@@ -250,6 +344,7 @@ PagerDuty instead.
|
||||
alerting:
|
||||
pagerduty:
|
||||
integration-key: "********************************"
|
||||
|
||||
services:
|
||||
- name: twinnation
|
||||
url: "https://twinnation.org/health"
|
||||
@@ -260,7 +355,7 @@ services:
|
||||
failure-threshold: 3
|
||||
success-threshold: 5
|
||||
send-on-resolved: true
|
||||
description: "healthcheck failed 3 times in a row"
|
||||
description: "healthcheck failed"
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].status == UP"
|
||||
@@ -269,7 +364,6 @@ services:
|
||||
|
||||
|
||||
#### Configuring Twilio alerts
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
twilio:
|
||||
@@ -277,6 +371,7 @@ alerting:
|
||||
token: "..."
|
||||
from: "+1-234-567-8901"
|
||||
to: "+1-234-567-8901"
|
||||
|
||||
services:
|
||||
- name: twinnation
|
||||
interval: 30s
|
||||
@@ -286,7 +381,7 @@ services:
|
||||
enabled: true
|
||||
failure-threshold: 5
|
||||
send-on-resolved: true
|
||||
description: "healthcheck failed 5 times in a row"
|
||||
description: "healthcheck failed"
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].status == UP"
|
||||
@@ -295,12 +390,12 @@ services:
|
||||
|
||||
|
||||
#### Configuring Mattermost alerts
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
mattermost:
|
||||
webhook-url: "http://**********/hooks/**********"
|
||||
insecure: true
|
||||
|
||||
services:
|
||||
- name: twinnation
|
||||
url: "https://twinnation.org/health"
|
||||
@@ -308,7 +403,7 @@ services:
|
||||
alerts:
|
||||
- type: mattermost
|
||||
enabled: true
|
||||
description: "healthcheck failed 3 times in a row"
|
||||
description: "healthcheck failed"
|
||||
send-on-resolved: true
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
@@ -340,7 +435,7 @@ services:
|
||||
enabled: true
|
||||
failure-threshold: 3
|
||||
send-on-resolved: true
|
||||
description: "healthcheck failed 3 times in a row"
|
||||
description: "healthcheck failed"
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].status == UP"
|
||||
@@ -348,8 +443,32 @@ services:
|
||||
```
|
||||
|
||||
|
||||
#### Configuring custom alerts
|
||||
#### Configuring Telegram alerts
|
||||
```yaml
|
||||
alerting:
|
||||
telegram:
|
||||
token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
|
||||
id: "0123456789"
|
||||
|
||||
services:
|
||||
- name: twinnation
|
||||
url: "https://twinnation.org/health"
|
||||
interval: 30s
|
||||
alerts:
|
||||
- type: telegram
|
||||
enabled: true
|
||||
send-on-resolved: true
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].status == UP"
|
||||
```
|
||||
|
||||
Here's an example of what the notifications look like:
|
||||
|
||||

|
||||
|
||||
|
||||
#### Configuring custom alerts
|
||||
While they're called alerts, you can use this feature to call anything.
|
||||
|
||||
For instance, you could automate rollbacks by having an application that keeps tracks of new deployments, and by
|
||||
@@ -357,14 +476,15 @@ leveraging Gatus, you could have Gatus call that application endpoint when a ser
|
||||
would then check if the service that started failing was recently deployed, and if it was, then automatically
|
||||
roll it back.
|
||||
|
||||
The values `[ALERT_DESCRIPTION]` and `[SERVICE_NAME]` are automatically substituted for the alert description and the
|
||||
service name respectively in the body (`alerting.custom.body`) as well as the url (`alerting.custom.url`).
|
||||
The placeholders `[ALERT_DESCRIPTION]` and `[SERVICE_NAME]` are automatically substituted for the alert description and
|
||||
the service name. These placeholders can be used in the body (`alerting.custom.body`) and in the url (`alerting.custom.url`).
|
||||
|
||||
If you have `send-on-resolved` set to `true`, you may want to use `[ALERT_TRIGGERED_OR_RESOLVED]` to differentiate
|
||||
the notifications. It will be replaced for either `TRIGGERED` or `RESOLVED`, based on the situation.
|
||||
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.
|
||||
The aforementioned placeholder will be replaced by `TRIGGERED` or `RESOLVED` accordingly, though it can be modified
|
||||
(details at the end of this section).
|
||||
|
||||
For all intents and purpose, we'll configure the custom alert with a Slack webhook, but you can call anything you want.
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
custom:
|
||||
@@ -385,17 +505,83 @@ services:
|
||||
failure-threshold: 10
|
||||
success-threshold: 3
|
||||
send-on-resolved: true
|
||||
description: "healthcheck failed 10 times in a row"
|
||||
description: "healthcheck failed"
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].status == UP"
|
||||
- "[RESPONSE_TIME] < 300"
|
||||
```
|
||||
|
||||
### Kubernetes (ALPHA)
|
||||
Note that you can customize the resolved values for the `[ALERT_TRIGGERED_OR_RESOLVED]` placeholder like so:
|
||||
```yaml
|
||||
alerting:
|
||||
custom:
|
||||
placeholders:
|
||||
ALERT_TRIGGERED_OR_RESOLVED:
|
||||
TRIGGERED: "partial_outage"
|
||||
RESOLVED: "operational"
|
||||
```
|
||||
As a result, the `[ALERT_TRIGGERED_OR_RESOLVED]` in the body of first example of this section would be replaced by
|
||||
`partial_outage` when an alert is triggered and `operational` when an alert is resolved.
|
||||
|
||||
|
||||
#### Setting a default provider alert
|
||||
While you can specify the alert configuration directly in the service definition, it's tedious and may lead to a very
|
||||
long configuration file.
|
||||
|
||||
To avoid such problem, you can use the `default-alert` parameter present in each provider configuration:
|
||||
```yaml
|
||||
alerting:
|
||||
slack:
|
||||
webhook-url: "https://hooks.slack.com/services/**********/**********/**********"
|
||||
default-alert:
|
||||
enabled: true
|
||||
description: "healthcheck failed"
|
||||
send-on-resolved: true
|
||||
failure-threshold: 5
|
||||
success-threshold: 5
|
||||
```
|
||||
|
||||
As a result, your service configuration looks a lot tidier:
|
||||
```yaml
|
||||
services:
|
||||
- name: example
|
||||
url: "https://example.org"
|
||||
alerts:
|
||||
- type: slack
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
- name: other-example
|
||||
url: "https://example.com"
|
||||
alerts:
|
||||
- type: slack
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
```
|
||||
|
||||
It also allows you to do things like this:
|
||||
```yaml
|
||||
services:
|
||||
- name: twinnation
|
||||
url: "https://twinnation.org/health"
|
||||
alerts:
|
||||
- type: slack
|
||||
failure-threshold: 5
|
||||
- type: slack
|
||||
failure-threshold: 10
|
||||
- type: slack
|
||||
failure-threshold: 15
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
```
|
||||
|
||||
|
||||
### Kubernetes (ALPHA)
|
||||
> **WARNING**: This feature is in ALPHA. This means that it is very likely to change in the near future, which means that
|
||||
> while you can use this feature as you see fit, there may be breaking changes in future releases.
|
||||
>
|
||||
> **NOTICE**: This feature may be removed. To give your opinion on the subject, see https://github.com/TwinProduction/gatus/discussions/135.
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|:------------------------------------------- |:----------------------------------------------------------------------------- |:-------------- |
|
||||
@@ -412,7 +598,6 @@ services:
|
||||
|
||||
|
||||
#### Auto Discovery
|
||||
|
||||
Auto discovery works by reading all `Service` resources from the configured `namespaces` and appending the `hostname-suffix` as
|
||||
well as the configured `target-path` to the service name and making an HTTP call.
|
||||
|
||||
@@ -451,15 +636,21 @@ Note that `hostname-suffix` could also be something like `.yourdomain.com`, in w
|
||||
monitored would be `potato.example.com/health`, assuming you have a service named `potato` and a matching ingress
|
||||
to map `potato.example.com` to the `potato` service.
|
||||
|
||||
#### Deploying
|
||||
|
||||
See [example/kubernetes-with-auto-discovery](example/kubernetes-with-auto-discovery)
|
||||
For a full example, see [examples/kubernetes-with-auto-discovery](examples/kubernetes-with-auto-discovery)
|
||||
|
||||
|
||||
## Docker
|
||||
## Deployment
|
||||
Many examples can be found in the [examples](examples) folder, but this section will focus on the most popular ways of deploying Gatus.
|
||||
|
||||
|
||||
### Docker
|
||||
To run Gatus locally with Docker:
|
||||
```
|
||||
docker run -p 8080:8080 --name gatus twinproduction/gatus
|
||||
```
|
||||
|
||||
Other than using one of the examples provided in the `examples` folder, you can also try it out locally by
|
||||
creating a configuration file - we'll call it `config.yaml` for this example - and running the following
|
||||
creating a configuration file, we'll call it `config.yaml` for this example, and running the following
|
||||
command:
|
||||
```
|
||||
docker run -p 8080:8080 --mount type=bind,source="$(pwd)"/config.yaml,target=/config/config.yaml --name gatus twinproduction/gatus
|
||||
@@ -470,58 +661,75 @@ If you're on Windows, replace `"$(pwd)"` by the absolute path to your current di
|
||||
docker run -p 8080:8080 --mount type=bind,source=C:/Users/Chris/Desktop/config.yaml,target=/config/config.yaml --name gatus twinproduction/gatus
|
||||
```
|
||||
|
||||
To build the image locally:
|
||||
```
|
||||
docker build . -t twinproduction/gatus
|
||||
```
|
||||
|
||||
|
||||
### Helm Chart
|
||||
[Helm](https://helm.sh) must be installed to use the chart.
|
||||
Please refer to Helm's [documentation](https://helm.sh/docs/) to get started.
|
||||
|
||||
Once Helm is set up properly, add the repository as follows:
|
||||
|
||||
```console
|
||||
helm repo add gatus https://avakarev.github.io/gatus-chart
|
||||
```
|
||||
|
||||
To get more details, please check chart's [configuration](https://github.com/avakarev/gatus-chart#configuration)
|
||||
and [helmfile example](https://github.com/avakarev/gatus-chart#helmfileyaml-example)
|
||||
|
||||
|
||||
### Terraform
|
||||
Gatus can be deployed on Terraform by using the following module: [terraform-kubernetes-gatus](https://github.com/TwinProduction/terraform-kubernetes-gatus).
|
||||
|
||||
|
||||
|
||||
## Running the tests
|
||||
|
||||
```
|
||||
go test ./... -mod vendor
|
||||
```
|
||||
|
||||
|
||||
## Using in Production
|
||||
|
||||
See the [example](example) folder.
|
||||
See the [Deployment](#deployment) section.
|
||||
|
||||
|
||||
## FAQ
|
||||
|
||||
### Sending a GraphQL request
|
||||
|
||||
By setting `services[].graphql` to true, the body will automatically be wrapped by the standard GraphQL `query` parameter.
|
||||
|
||||
For instance, the following configuration:
|
||||
```yaml
|
||||
services:
|
||||
- name: filter users by gender
|
||||
- name: filter-users-by-gender
|
||||
url: http://localhost:8080/playground
|
||||
method: POST
|
||||
graphql: true
|
||||
body: |
|
||||
{
|
||||
user(gender: "female") {
|
||||
users(gender: "female") {
|
||||
id
|
||||
name
|
||||
gender
|
||||
avatar
|
||||
}
|
||||
}
|
||||
headers:
|
||||
Content-Type: application/json # XXX: as of v1.9.2, this header is automatically added when graphql is set to true
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].data.user[0].gender == female"
|
||||
- "[BODY].data.users[0].gender == female"
|
||||
```
|
||||
|
||||
will send a `POST` request to `http://localhost:8080/playground` with the following body:
|
||||
```json
|
||||
{"query":" {\n user(gender: \"female\") {\n id\n name\n gender\n avatar\n }\n }"}
|
||||
{"query":" {\n users(gender: \"female\") {\n id\n name\n gender\n avatar\n }\n }"}
|
||||
```
|
||||
|
||||
|
||||
### Recommended interval
|
||||
|
||||
**NOTE**: This does not _really_ apply if `disable-monitoring-lock` is set to `true`, as the monitoring lock is what
|
||||
tells Gatus to only evaluate one service at a time.
|
||||
> **NOTE**: This does not _really_ apply if `disable-monitoring-lock` is set to `true`, as the monitoring lock is what
|
||||
> tells Gatus to only evaluate one service at a time.
|
||||
|
||||
To ensure that Gatus provides reliable and accurate results (i.e. response time), Gatus only evaluates one service at a time
|
||||
In other words, even if you have multiple services with the exact same interval, they will not execute at the same time.
|
||||
@@ -551,7 +759,6 @@ simple health checks used for alerting (PagerDuty/Twilio) to `30s`.
|
||||
|
||||
|
||||
### Default timeouts
|
||||
|
||||
| Protocol | Timeout |
|
||||
|:-------- |:------- |
|
||||
| HTTP | 10s
|
||||
@@ -580,13 +787,12 @@ established.
|
||||
|
||||
|
||||
### Monitoring a service using ICMP
|
||||
|
||||
By prefixing `services[].url` with `icmp:\\`, you can monitor services at a very basic level using ICMP, or more
|
||||
commonly known as "ping" or "echo":
|
||||
|
||||
```yaml
|
||||
services:
|
||||
- name: ICMP
|
||||
- name: ping-example
|
||||
url: "icmp://example.com"
|
||||
conditions:
|
||||
- "[CONNECTED] == true"
|
||||
@@ -597,11 +803,10 @@ You can specify a domain prefixed by `icmp://`, or an IP address prefixed by `ic
|
||||
|
||||
|
||||
### Monitoring a service using DNS queries
|
||||
|
||||
Defining a `dns` configuration in a service will automatically mark that service as a service of type DNS:
|
||||
```yaml
|
||||
services:
|
||||
- name: example dns query
|
||||
- name: example-dns-query
|
||||
url: "8.8.8.8" # Address of the DNS server to use
|
||||
interval: 30s
|
||||
dns:
|
||||
@@ -618,10 +823,22 @@ There are two placeholders that can be used in the conditions for services of ty
|
||||
`NOERROR`, `FORMERR`, `SERVFAIL`, `NXDOMAIN`, etc.
|
||||
|
||||
|
||||
### Monitoring a service using STARTTLS
|
||||
If you have an email server that you want to ensure there are no problems with, monitoring it through STARTTLS
|
||||
will serve as a good initial indicator:
|
||||
```yaml
|
||||
services:
|
||||
- name: starttls-smtp-example
|
||||
url: "starttls://smtp.gmail.com:587"
|
||||
interval: 30m
|
||||
conditions:
|
||||
- "[CONNECTED] == true"
|
||||
- "[CERTIFICATE_EXPIRATION] > 48h"
|
||||
```
|
||||
|
||||
|
||||
### Basic authentication
|
||||
|
||||
You can require Basic authentication by leveraging the `security.basic` configuration:
|
||||
|
||||
```yaml
|
||||
security:
|
||||
basic:
|
||||
@@ -633,7 +850,6 @@ The example above will require that you authenticate with the username `john.doe
|
||||
|
||||
|
||||
### disable-monitoring-lock
|
||||
|
||||
Setting `disable-monitoring-lock` to `true` means that multiple services could be monitored at the same time.
|
||||
|
||||
While this behavior wouldn't generally be harmful, conditions using the `[RESPONSE_TIME]` placeholder could be impacted
|
||||
@@ -646,8 +862,30 @@ technically, if you create 100 services with a 1 seconds interval, Gatus will se
|
||||
- You want to test multiple services at very short interval (< 5s)
|
||||
|
||||
|
||||
### Service groups
|
||||
### Reloading configuration on the fly
|
||||
For the sake on convenience, Gatus automatically reloads the configuration on the fly if the loaded configuration file
|
||||
is updated while Gatus is running.
|
||||
|
||||
By default, the application will exit if the updating configuration is invalid, but you can configure
|
||||
Gatus to continue running if the configuration file is updated with an invalid configuration by
|
||||
setting `skip-invalid-config-update` to `true`.
|
||||
|
||||
Keep in mind that it is in your best interest to ensure the validity of the configuration file after each update you
|
||||
apply to the configuration file while Gatus is running by looking at the log and making sure that you do not see the
|
||||
following message:
|
||||
```
|
||||
The configuration file was updated, but it is not valid. The old configuration will continue being used.
|
||||
```
|
||||
Failure to do so may result in Gatus being unable to start if the application is restarted for whatever reason.
|
||||
|
||||
I recommend not setting `skip-invalid-config-update` to `true` to avoid a situation like this, but the choice is yours
|
||||
to make.
|
||||
|
||||
Note that if you are not using a file storage, updating the configuration while Gatus is running is effectively
|
||||
the same as restarting the application.
|
||||
|
||||
|
||||
### Service groups
|
||||
Service groups are used for grouping multiple services together on the dashboard.
|
||||
|
||||
```yaml
|
||||
@@ -693,7 +931,6 @@ The configuration above will result in a dashboard that looks like this:
|
||||
|
||||
|
||||
### Exposing Gatus on a custom port
|
||||
|
||||
By default, Gatus is exposed on port `8080`, but you may specify a different port by setting the `web.port` parameter:
|
||||
```yaml
|
||||
web:
|
||||
@@ -708,11 +945,9 @@ web:
|
||||
```
|
||||
|
||||
### Uptime badges
|
||||

|
||||

|
||||

|
||||
|
||||
> **NOTE**: This feature is currently in ALPHA
|
||||

|
||||

|
||||

|
||||
|
||||
Gatus can automatically generate a SVG badge for one of your monitored services.
|
||||
This allows you to put badges in your individual services' README or even create your own status page, if you
|
||||
@@ -724,21 +959,51 @@ The endpoint to generate a badge is the following:
|
||||
```
|
||||
Where:
|
||||
- `{duration}` is `7d`, `24h` or `1h`
|
||||
- `{identifier}` has the following pattern: `group-<GROUP_NAME>-service-<SERVICE_NAME>.svg`
|
||||
- `{identifier}` has the pattern `<GROUP_NAME>_<SERVICE_NAME>.svg` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`.
|
||||
|
||||
For instance, if you want the uptime during the last 24 hours from the service `frontend` in the group `core`,
|
||||
the URL would look like this:
|
||||
```
|
||||
http://example.com/api/v1/badges/uptime/7d/group-core-service-frontend.svg
|
||||
http://example.com/api/v1/badges/uptime/7d/core_frontend.svg
|
||||
```
|
||||
|
||||
If you want to display a service that is not part of a group, you must leave the group value empty:
|
||||
```
|
||||
http://example.com/api/v1/badges/uptime/7d/group--service-frontend.svg
|
||||
http://example.com/api/v1/badges/uptime/7d/_frontend.svg
|
||||
```
|
||||
|
||||
Example: 
|
||||
Example: 
|
||||
```
|
||||

|
||||

|
||||
```
|
||||
|
||||
If you'd like to see a visual example of each badges available, you can simply navigate to the service's detail page.
|
||||
|
||||
### API
|
||||
Gatus provides a simple read-only API which can be queried in order to programmatically determine service status and history.
|
||||
|
||||
All services are available via a GET request to the following endpoint:
|
||||
```
|
||||
/api/v1/statuses
|
||||
````
|
||||
|
||||
Example: https://status.twinnation.org/api/v1/statuses
|
||||
|
||||
Specific services can also be queried by using the following pattern:
|
||||
```
|
||||
/api/v1/statuses/{group}_{service}
|
||||
```
|
||||
|
||||
Example: https://status.twinnation.org/api/v1/statuses/core_twinnation-home
|
||||
|
||||
Gzip compression will be used if the `Accept-Encoding` HTTP header contains `gzip`.
|
||||
|
||||
The API will return a JSON payload with the `Content-Type` response header set to `application/json`.
|
||||
No such header is required to query the API.
|
||||
|
||||
|
||||
## Sponsors
|
||||
You can find the full list of sponsors [here](https://github.com/sponsors/TwinProduction).
|
||||
|
||||
[<img src="https://github.com/math280h.png" width="35" />](https://github.com/math280h)
|
||||
[<img src="https://github.com/mateothegreat.png" width="35" />](https://github.com/mateothegreat)
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
package core
|
||||
package alert
|
||||
|
||||
// Alert is the service's alert configuration
|
||||
type Alert struct {
|
||||
// Type of alert
|
||||
Type AlertType `yaml:"type"`
|
||||
// Type of alert (required)
|
||||
Type Type `yaml:"type"`
|
||||
|
||||
// Enabled defines whether or not the alert is enabled
|
||||
Enabled bool `yaml:"enabled"`
|
||||
//
|
||||
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
|
||||
// or not for provider.ParseWithDefaultAlert to work.
|
||||
Enabled *bool `yaml:"enabled"`
|
||||
|
||||
// FailureThreshold is the number of failures in a row needed before triggering the alert
|
||||
FailureThreshold int `yaml:"failure-threshold"`
|
||||
|
||||
// Description of the alert. Will be included in the alert sent.
|
||||
Description string `yaml:"description"`
|
||||
//
|
||||
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
|
||||
// or not for provider.ParseWithDefaultAlert to work.
|
||||
Description *string `yaml:"description"`
|
||||
|
||||
// SendOnResolved defines whether to send a second notification when the issue has been resolved
|
||||
SendOnResolved bool `yaml:"send-on-resolved"`
|
||||
//
|
||||
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
|
||||
// or not for provider.ParseWithDefaultAlert to work. Use Alert.IsSendingOnResolved() for a non-pointer
|
||||
SendOnResolved *bool `yaml:"send-on-resolved"`
|
||||
|
||||
// SuccessThreshold defines how many successful executions must happen in a row before an ongoing incident is marked as resolved
|
||||
SuccessThreshold int `yaml:"success-threshold"`
|
||||
@@ -35,26 +44,26 @@ type Alert struct {
|
||||
Triggered bool
|
||||
}
|
||||
|
||||
// AlertType is the type of the alert.
|
||||
// The value will generally be the name of the alert provider
|
||||
type AlertType string
|
||||
// GetDescription retrieves the description of the alert
|
||||
func (alert Alert) GetDescription() string {
|
||||
if alert.Description == nil {
|
||||
return ""
|
||||
}
|
||||
return *alert.Description
|
||||
}
|
||||
|
||||
const (
|
||||
// SlackAlert is the AlertType for the slack alerting provider
|
||||
SlackAlert AlertType = "slack"
|
||||
// IsEnabled returns whether an alert is enabled or not
|
||||
func (alert Alert) IsEnabled() bool {
|
||||
if alert.Enabled == nil {
|
||||
return false
|
||||
}
|
||||
return *alert.Enabled
|
||||
}
|
||||
|
||||
// MattermostAlert is the AlertType for the mattermost alerting provider
|
||||
MattermostAlert AlertType = "mattermost"
|
||||
|
||||
// MessagebirdAlert is the AlertType for the messagebird alerting provider
|
||||
MessagebirdAlert AlertType = "messagebird"
|
||||
|
||||
// PagerDutyAlert is the AlertType for the pagerduty alerting provider
|
||||
PagerDutyAlert AlertType = "pagerduty"
|
||||
|
||||
// TwilioAlert is the AlertType for the twilio alerting provider
|
||||
TwilioAlert AlertType = "twilio"
|
||||
|
||||
// CustomAlert is the AlertType for the custom alerting provider
|
||||
CustomAlert AlertType = "custom"
|
||||
)
|
||||
// IsSendingOnResolved returns whether an alert is sending on resolve or not
|
||||
func (alert Alert) IsSendingOnResolved() bool {
|
||||
if alert.SendOnResolved == nil {
|
||||
return false
|
||||
}
|
||||
return *alert.SendOnResolved
|
||||
}
|
||||
36
alerting/alert/alert_test.go
Normal file
36
alerting/alert/alert_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package alert
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAlert_IsEnabled(t *testing.T) {
|
||||
if (Alert{Enabled: nil}).IsEnabled() {
|
||||
t.Error("alert.IsEnabled() should've returned false, because Enabled was set to nil")
|
||||
}
|
||||
if value := false; (Alert{Enabled: &value}).IsEnabled() {
|
||||
t.Error("alert.IsEnabled() should've returned false, because Enabled was set to false")
|
||||
}
|
||||
if value := true; !(Alert{Enabled: &value}).IsEnabled() {
|
||||
t.Error("alert.IsEnabled() should've returned true, because Enabled was set to true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlert_GetDescription(t *testing.T) {
|
||||
if (Alert{Description: nil}).GetDescription() != "" {
|
||||
t.Error("alert.GetDescription() should've returned an empty string, because Description was set to nil")
|
||||
}
|
||||
if value := "description"; (Alert{Description: &value}).GetDescription() != value {
|
||||
t.Error("alert.GetDescription() should've returned false, because Description was set to 'description'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlert_IsSendingOnResolved(t *testing.T) {
|
||||
if (Alert{SendOnResolved: nil}).IsSendingOnResolved() {
|
||||
t.Error("alert.IsSendingOnResolved() should've returned false, because SendOnResolved was set to nil")
|
||||
}
|
||||
if value := false; (Alert{SendOnResolved: &value}).IsSendingOnResolved() {
|
||||
t.Error("alert.IsSendingOnResolved() should've returned false, because SendOnResolved was set to false")
|
||||
}
|
||||
if value := true; !(Alert{SendOnResolved: &value}).IsSendingOnResolved() {
|
||||
t.Error("alert.IsSendingOnResolved() should've returned true, because SendOnResolved was set to true")
|
||||
}
|
||||
}
|
||||
31
alerting/alert/type.go
Normal file
31
alerting/alert/type.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package alert
|
||||
|
||||
// Type is the type of the alert.
|
||||
// The value will generally be the name of the alert provider
|
||||
type Type string
|
||||
|
||||
const (
|
||||
// TypeCustom is the Type for the custom alerting provider
|
||||
TypeCustom Type = "custom"
|
||||
|
||||
// TypeDiscord is the Type for the discord alerting provider
|
||||
TypeDiscord Type = "discord"
|
||||
|
||||
// TypeMattermost is the Type for the mattermost alerting provider
|
||||
TypeMattermost Type = "mattermost"
|
||||
|
||||
// TypeMessagebird is the Type for the messagebird alerting provider
|
||||
TypeMessagebird Type = "messagebird"
|
||||
|
||||
// TypePagerDuty is the Type for the pagerduty alerting provider
|
||||
TypePagerDuty Type = "pagerduty"
|
||||
|
||||
// TypeSlack is the Type for the slack alerting provider
|
||||
TypeSlack Type = "slack"
|
||||
|
||||
// TypeTelegram is the Type for the telegram alerting provider
|
||||
TypeTelegram Type = "telegram"
|
||||
|
||||
// TypeTwilio is the Type for the twilio alerting provider
|
||||
TypeTwilio Type = "twilio"
|
||||
)
|
||||
@@ -1,18 +1,25 @@
|
||||
package alerting
|
||||
|
||||
import (
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/alerting/provider"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/discord"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/mattermost"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/messagebird"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/pagerduty"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/slack"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/telegram"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/twilio"
|
||||
)
|
||||
|
||||
// Config is the configuration for alerting providers
|
||||
type Config struct {
|
||||
// Slack is the configuration for the slack alerting provider
|
||||
Slack *slack.AlertProvider `yaml:"slack"`
|
||||
// Custom is the configuration for the custom alerting provider
|
||||
Custom *custom.AlertProvider `yaml:"custom"`
|
||||
|
||||
// Discord is the configuration for the discord alerting provider
|
||||
Discord *discord.AlertProvider `yaml:"discord"`
|
||||
|
||||
// Mattermost is the configuration for the mattermost alerting provider
|
||||
Mattermost *mattermost.AlertProvider `yaml:"mattermost"`
|
||||
@@ -20,12 +27,70 @@ type Config struct {
|
||||
// Messagebird is the configuration for the messagebird alerting provider
|
||||
Messagebird *messagebird.AlertProvider `yaml:"messagebird"`
|
||||
|
||||
// Pagerduty is the configuration for the pagerduty alerting provider
|
||||
// PagerDuty is the configuration for the pagerduty alerting provider
|
||||
PagerDuty *pagerduty.AlertProvider `yaml:"pagerduty"`
|
||||
|
||||
// Slack is the configuration for the slack alerting provider
|
||||
Slack *slack.AlertProvider `yaml:"slack"`
|
||||
|
||||
// Telegram is the configuration for the telegram alerting provider
|
||||
Telegram *telegram.AlertProvider `yaml:"telegram"`
|
||||
|
||||
// Twilio is the configuration for the twilio alerting provider
|
||||
Twilio *twilio.AlertProvider `yaml:"twilio"`
|
||||
|
||||
// Custom is the configuration for the custom alerting provider
|
||||
Custom *custom.AlertProvider `yaml:"custom"`
|
||||
}
|
||||
|
||||
// GetAlertingProviderByAlertType returns an provider.AlertProvider by its corresponding alert.Type
|
||||
func (config Config) GetAlertingProviderByAlertType(alertType alert.Type) provider.AlertProvider {
|
||||
switch alertType {
|
||||
case alert.TypeCustom:
|
||||
if config.Custom == nil {
|
||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||
return nil
|
||||
}
|
||||
return config.Custom
|
||||
case alert.TypeDiscord:
|
||||
if config.Discord == nil {
|
||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||
return nil
|
||||
}
|
||||
return config.Discord
|
||||
case alert.TypeMattermost:
|
||||
if config.Mattermost == nil {
|
||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||
return nil
|
||||
}
|
||||
return config.Mattermost
|
||||
case alert.TypeMessagebird:
|
||||
if config.Messagebird == nil {
|
||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||
return nil
|
||||
}
|
||||
return config.Messagebird
|
||||
case alert.TypePagerDuty:
|
||||
if config.PagerDuty == nil {
|
||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||
return nil
|
||||
}
|
||||
return config.PagerDuty
|
||||
case alert.TypeSlack:
|
||||
if config.Slack == nil {
|
||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||
return nil
|
||||
}
|
||||
return config.Slack
|
||||
case alert.TypeTelegram:
|
||||
if config.Telegram == nil {
|
||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||
return nil
|
||||
}
|
||||
return config.Telegram
|
||||
case alert.TypeTwilio:
|
||||
if config.Twilio == nil {
|
||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||
return nil
|
||||
}
|
||||
return config.Twilio
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/client"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
@@ -16,11 +17,15 @@ import (
|
||||
// AlertProvider is the configuration necessary for sending an alert using a custom HTTP request
|
||||
// Technically, all alert providers should be reachable using the custom alert provider
|
||||
type AlertProvider struct {
|
||||
URL string `yaml:"url"`
|
||||
Method string `yaml:"method,omitempty"`
|
||||
Insecure bool `yaml:"insecure,omitempty"`
|
||||
Body string `yaml:"body,omitempty"`
|
||||
Headers map[string]string `yaml:"headers,omitempty"`
|
||||
URL string `yaml:"url"`
|
||||
Method string `yaml:"method,omitempty"`
|
||||
Insecure bool `yaml:"insecure,omitempty"`
|
||||
Body string `yaml:"body,omitempty"`
|
||||
Headers map[string]string `yaml:"headers,omitempty"`
|
||||
Placeholders map[string]map[string]string `yaml:"placeholders,omitempty"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
@@ -29,14 +34,29 @@ func (provider *AlertProvider) IsValid() bool {
|
||||
}
|
||||
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *core.Alert, result *core.Result, resolved bool) *AlertProvider {
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *alert.Alert, result *core.Result, resolved bool) *AlertProvider {
|
||||
return provider
|
||||
}
|
||||
|
||||
// GetAlertStatePlaceholderValue returns the Placeholder value for ALERT_TRIGGERED_OR_RESOLVED if configured
|
||||
func (provider *AlertProvider) GetAlertStatePlaceholderValue(resolved bool) string {
|
||||
status := "TRIGGERED"
|
||||
if resolved {
|
||||
status = "RESOLVED"
|
||||
}
|
||||
if _, ok := provider.Placeholders["ALERT_TRIGGERED_OR_RESOLVED"]; ok {
|
||||
if val, ok := provider.Placeholders["ALERT_TRIGGERED_OR_RESOLVED"][status]; ok {
|
||||
return val
|
||||
}
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) buildHTTPRequest(serviceName, 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)
|
||||
}
|
||||
@@ -45,9 +65,9 @@ func (provider *AlertProvider) buildHTTPRequest(serviceName, alertDescription st
|
||||
}
|
||||
if strings.Contains(body, "[ALERT_TRIGGERED_OR_RESOLVED]") {
|
||||
if resolved {
|
||||
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", "RESOLVED")
|
||||
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
|
||||
} else {
|
||||
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", "TRIGGERED")
|
||||
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(false))
|
||||
}
|
||||
}
|
||||
if strings.Contains(providerURL, "[ALERT_DESCRIPTION]") {
|
||||
@@ -58,9 +78,9 @@ func (provider *AlertProvider) buildHTTPRequest(serviceName, alertDescription st
|
||||
}
|
||||
if strings.Contains(providerURL, "[ALERT_TRIGGERED_OR_RESOLVED]") {
|
||||
if resolved {
|
||||
providerURL = strings.ReplaceAll(providerURL, "[ALERT_TRIGGERED_OR_RESOLVED]", "RESOLVED")
|
||||
providerURL = strings.ReplaceAll(providerURL, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
|
||||
} else {
|
||||
providerURL = strings.ReplaceAll(providerURL, "[ALERT_TRIGGERED_OR_RESOLVED]", "TRIGGERED")
|
||||
providerURL = strings.ReplaceAll(providerURL, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(false))
|
||||
}
|
||||
}
|
||||
if len(method) == 0 {
|
||||
@@ -96,3 +116,8 @@ func (provider *AlertProvider) Send(serviceName, alertDescription string, resolv
|
||||
}
|
||||
return ioutil.ReadAll(response.Body)
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"io/ioutil"
|
||||
"testing"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
|
||||
@@ -60,11 +61,51 @@ func TestAlertProvider_buildHTTPRequestWhenTriggered(t *testing.T) {
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProvider(t *testing.T) {
|
||||
provider := AlertProvider{URL: "http://example.com"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &core.Alert{}, &core.Result{}, true)
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{}, true)
|
||||
if customAlertProvider == nil {
|
||||
t.Error("customAlertProvider shouldn't have been nil")
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if customAlertProvider != customAlertProvider {
|
||||
t.Error("customAlertProvider should've been equal to customAlertProvider")
|
||||
if customAlertProvider.URL != "http://example.com" {
|
||||
t.Error("expected URL to be http://example.com, got", customAlertProvider.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
|
||||
const (
|
||||
ExpectedURL = "http://example.com/service-name?event=test&description=alert-description"
|
||||
ExpectedBody = "service-name,alert-description,test"
|
||||
)
|
||||
customAlertProvider := &AlertProvider{
|
||||
URL: "http://example.com/[SERVICE_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
|
||||
Body: "[SERVICE_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
|
||||
Headers: nil,
|
||||
Placeholders: map[string]map[string]string{
|
||||
"ALERT_TRIGGERED_OR_RESOLVED": {
|
||||
"RESOLVED": "test",
|
||||
},
|
||||
},
|
||||
}
|
||||
request := customAlertProvider.buildHTTPRequest("service-name", "alert-description", true)
|
||||
if request.URL.String() != ExpectedURL {
|
||||
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
|
||||
}
|
||||
body, _ := ioutil.ReadAll(request.Body)
|
||||
if string(body) != ExpectedBody {
|
||||
t.Error("expected body to be", ExpectedBody, "was", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) {
|
||||
customAlertProvider := &AlertProvider{
|
||||
URL: "http://example.com/[SERVICE_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
|
||||
Body: "[SERVICE_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
|
||||
Headers: nil,
|
||||
Placeholders: nil,
|
||||
}
|
||||
if customAlertProvider.GetAlertStatePlaceholderValue(true) != "RESOLVED" {
|
||||
t.Error("expected RESOLVED, got", customAlertProvider.GetAlertStatePlaceholderValue(true))
|
||||
}
|
||||
if customAlertProvider.GetAlertStatePlaceholderValue(false) != "TRIGGERED" {
|
||||
t.Error("expected TRIGGERED, got", customAlertProvider.GetAlertStatePlaceholderValue(false))
|
||||
}
|
||||
}
|
||||
|
||||
72
alerting/provider/discord/discord.go
Normal file
72
alerting/provider/discord/discord.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package discord
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Discord
|
||||
type AlertProvider struct {
|
||||
WebhookURL string `yaml:"webhook-url"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
func (provider *AlertProvider) IsValid() bool {
|
||||
return len(provider.WebhookURL) > 0
|
||||
}
|
||||
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
|
||||
var message, results string
|
||||
var colorCode int
|
||||
if resolved {
|
||||
message = fmt.Sprintf("An alert for **%s** has been resolved after passing successfully %d time(s) in a row", service.Name, alert.SuccessThreshold)
|
||||
colorCode = 3066993
|
||||
} else {
|
||||
message = fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", service.Name, alert.FailureThreshold)
|
||||
colorCode = 15158332
|
||||
}
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
if conditionResult.Success {
|
||||
prefix = ":white_check_mark:"
|
||||
} else {
|
||||
prefix = ":x:"
|
||||
}
|
||||
results += fmt.Sprintf("%s - `%s`\\n", prefix, conditionResult.Condition)
|
||||
}
|
||||
return &custom.AlertProvider{
|
||||
URL: provider.WebhookURL,
|
||||
Method: http.MethodPost,
|
||||
Body: fmt.Sprintf(`{
|
||||
"content": "",
|
||||
"embeds": [
|
||||
{
|
||||
"title": ":helmet_with_white_cross: Gatus",
|
||||
"description": "%s:\n> %s",
|
||||
"color": %d,
|
||||
"fields": [
|
||||
{
|
||||
"name": "Condition results",
|
||||
"value": "%s",
|
||||
"inline": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`, message, alert.GetDescription(), colorCode, results),
|
||||
Headers: map[string]string{"Content-Type": "application/json"},
|
||||
}
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
66
alerting/provider/discord/discord_test.go
Normal file
66
alerting/provider/discord/discord_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package discord
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
|
||||
func TestAlertProvider_IsValid(t *testing.T) {
|
||||
invalidProvider := AlertProvider{WebhookURL: ""}
|
||||
if invalidProvider.IsValid() {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
validProvider := AlertProvider{WebhookURL: "http://example.com"}
|
||||
if !validProvider.IsValid() {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
||||
provider := AlertProvider{WebhookURL: "http://example.com"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "resolved") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring resolved")
|
||||
}
|
||||
if customAlertProvider.URL != "http://example.com" {
|
||||
t.Errorf("expected URL to be %s, got %s", "http://example.com", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
||||
provider := AlertProvider{WebhookURL: "http://example.com"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "triggered") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring triggered")
|
||||
}
|
||||
if customAlertProvider.URL != "http://example.com" {
|
||||
t.Errorf("expected URL to be %s, got %s", "http://example.com", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
@@ -12,6 +13,9 @@ import (
|
||||
type AlertProvider struct {
|
||||
WebhookURL string `yaml:"webhook-url"`
|
||||
Insecure bool `yaml:"insecure,omitempty"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
@@ -20,7 +24,7 @@ func (provider *AlertProvider) IsValid() bool {
|
||||
}
|
||||
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *core.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
|
||||
var message string
|
||||
var color string
|
||||
if resolved {
|
||||
@@ -38,7 +42,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
||||
} else {
|
||||
prefix = ":x:"
|
||||
}
|
||||
results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
|
||||
results += fmt.Sprintf("%s - `%s`\\n", prefix, conditionResult.Condition)
|
||||
}
|
||||
return &custom.AlertProvider{
|
||||
URL: provider.WebhookURL,
|
||||
@@ -69,7 +73,12 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
||||
]
|
||||
}
|
||||
]
|
||||
}`, message, message, alert.Description, color, service.URL, results),
|
||||
}`, message, message, alert.GetDescription(), color, service.URL, results),
|
||||
Headers: map[string]string{"Content-Type": "application/json"},
|
||||
}
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package mattermost
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
|
||||
@@ -19,23 +22,45 @@ func TestAlertProvider_IsValid(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
||||
provider := AlertProvider{WebhookURL: "http://example.com"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &core.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
|
||||
provider := AlertProvider{WebhookURL: "http://example.org"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
|
||||
if customAlertProvider == nil {
|
||||
t.Error("customAlertProvider shouldn't have been nil")
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "resolved") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring resolved")
|
||||
}
|
||||
if customAlertProvider.URL != "http://example.org" {
|
||||
t.Errorf("expected URL to be %s, got %s", "http://example.org", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
||||
provider := AlertProvider{WebhookURL: "http://example.com"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &core.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
|
||||
provider := AlertProvider{WebhookURL: "http://example.org"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
|
||||
if customAlertProvider == nil {
|
||||
t.Error("customAlertProvider shouldn't have been nil")
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "triggered") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring triggered")
|
||||
}
|
||||
if customAlertProvider.URL != "http://example.org" {
|
||||
t.Errorf("expected URL to be %s, got %s", "http://example.org", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
@@ -17,6 +18,9 @@ type AlertProvider struct {
|
||||
AccessKey string `yaml:"access-key"`
|
||||
Originator string `yaml:"originator"`
|
||||
Recipients string `yaml:"recipients"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
@@ -26,12 +30,12 @@ func (provider *AlertProvider) IsValid() bool {
|
||||
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
// Reference doc for messagebird https://developers.messagebird.com/api/sms-messaging/#send-outbound-sms
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *core.Alert, _ *core.Result, resolved bool) *custom.AlertProvider {
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *alert.Alert, _ *core.Result, resolved bool) *custom.AlertProvider {
|
||||
var message string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("RESOLVED: %s - %s", service.Name, alert.Description)
|
||||
message = fmt.Sprintf("RESOLVED: %s - %s", service.Name, alert.GetDescription())
|
||||
} else {
|
||||
message = fmt.Sprintf("TRIGGERED: %s - %s", service.Name, alert.Description)
|
||||
message = fmt.Sprintf("TRIGGERED: %s - %s", service.Name, alert.GetDescription())
|
||||
}
|
||||
|
||||
return &custom.AlertProvider{
|
||||
@@ -48,3 +52,8 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package messagebird
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
|
||||
@@ -28,13 +31,24 @@ func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
||||
Originator: "1",
|
||||
Recipients: "1",
|
||||
}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &core.Alert{}, &core.Result{}, true)
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{}, true)
|
||||
if customAlertProvider == nil {
|
||||
t.Error("customAlertProvider shouldn't have been nil")
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "RESOLVED") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring RESOLVED")
|
||||
}
|
||||
if customAlertProvider.URL != "https://rest.messagebird.com/messages" {
|
||||
t.Errorf("expected URL to be %s, got %s", "https://rest.messagebird.com/messages", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
||||
@@ -43,11 +57,22 @@ func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
||||
Originator: "1",
|
||||
Recipients: "1",
|
||||
}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &core.Alert{}, &core.Result{}, false)
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{}, false)
|
||||
if customAlertProvider == nil {
|
||||
t.Error("customAlertProvider shouldn't have been nil")
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "TRIGGERED") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring TRIGGERED")
|
||||
}
|
||||
if customAlertProvider.URL != "https://rest.messagebird.com/messages" {
|
||||
t.Errorf("expected URL to be %s, got %s", "https://rest.messagebird.com/messages", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
@@ -11,6 +12,9 @@ import (
|
||||
// AlertProvider is the configuration necessary for sending an alert using PagerDuty
|
||||
type AlertProvider struct {
|
||||
IntegrationKey string `yaml:"integration-key"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
@@ -21,14 +25,14 @@ func (provider *AlertProvider) IsValid() bool {
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
//
|
||||
// relevant: https://developer.pagerduty.com/docs/events-api-v2/trigger-events/
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *core.Alert, _ *core.Result, resolved bool) *custom.AlertProvider {
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *alert.Alert, _ *core.Result, resolved bool) *custom.AlertProvider {
|
||||
var message, eventAction, resolveKey string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("RESOLVED: %s - %s", service.Name, alert.Description)
|
||||
message = fmt.Sprintf("RESOLVED: %s - %s", service.Name, alert.GetDescription())
|
||||
eventAction = "resolve"
|
||||
resolveKey = alert.ResolveKey
|
||||
} else {
|
||||
message = fmt.Sprintf("TRIGGERED: %s - %s", service.Name, alert.Description)
|
||||
message = fmt.Sprintf("TRIGGERED: %s - %s", service.Name, alert.GetDescription())
|
||||
eventAction = "trigger"
|
||||
resolveKey = ""
|
||||
}
|
||||
@@ -50,3 +54,8 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package pagerduty
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
|
||||
@@ -20,22 +23,44 @@ func TestAlertProvider_IsValid(t *testing.T) {
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
||||
provider := AlertProvider{IntegrationKey: "00000000000000000000000000000000"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &core.Alert{}, &core.Result{}, true)
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{}, true)
|
||||
if customAlertProvider == nil {
|
||||
t.Error("customAlertProvider shouldn't have been nil")
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "RESOLVED") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring RESOLVED")
|
||||
}
|
||||
if customAlertProvider.URL != "https://events.pagerduty.com/v2/enqueue" {
|
||||
t.Errorf("expected URL to be %s, got %s", "https://events.pagerduty.com/v2/enqueue", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
||||
provider := AlertProvider{IntegrationKey: "00000000000000000000000000000000"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &core.Alert{}, &core.Result{}, false)
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{}, false)
|
||||
if customAlertProvider == nil {
|
||||
t.Error("customAlertProvider shouldn't have been nil")
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "TRIGGERED") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring TRIGGERED")
|
||||
}
|
||||
if customAlertProvider.URL != "https://events.pagerduty.com/v2/enqueue" {
|
||||
t.Errorf("expected URL to be %s, got %s", "https://events.pagerduty.com/v2/enqueue", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/discord"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/mattermost"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/messagebird"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/pagerduty"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/slack"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/telegram"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/twilio"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
@@ -16,15 +19,42 @@ type AlertProvider interface {
|
||||
IsValid() bool
|
||||
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
ToCustomAlertProvider(service *core.Service, alert *core.Alert, result *core.Result, resolved bool) *custom.AlertProvider
|
||||
ToCustomAlertProvider(service *core.Service, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
GetDefaultAlert() *alert.Alert
|
||||
}
|
||||
|
||||
// ParseWithDefaultAlert parses a service alert by using the provider's default alert as a baseline
|
||||
func ParseWithDefaultAlert(providerDefaultAlert, serviceAlert *alert.Alert) {
|
||||
if providerDefaultAlert == nil || serviceAlert == nil {
|
||||
return
|
||||
}
|
||||
if serviceAlert.Enabled == nil {
|
||||
serviceAlert.Enabled = providerDefaultAlert.Enabled
|
||||
}
|
||||
if serviceAlert.SendOnResolved == nil {
|
||||
serviceAlert.SendOnResolved = providerDefaultAlert.SendOnResolved
|
||||
}
|
||||
if serviceAlert.Description == nil {
|
||||
serviceAlert.Description = providerDefaultAlert.Description
|
||||
}
|
||||
if serviceAlert.FailureThreshold == 0 {
|
||||
serviceAlert.FailureThreshold = providerDefaultAlert.FailureThreshold
|
||||
}
|
||||
if serviceAlert.SuccessThreshold == 0 {
|
||||
serviceAlert.SuccessThreshold = providerDefaultAlert.SuccessThreshold
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
// Validate interface implementation on compile
|
||||
_ AlertProvider = (*custom.AlertProvider)(nil)
|
||||
_ AlertProvider = (*twilio.AlertProvider)(nil)
|
||||
_ AlertProvider = (*slack.AlertProvider)(nil)
|
||||
_ AlertProvider = (*discord.AlertProvider)(nil)
|
||||
_ AlertProvider = (*mattermost.AlertProvider)(nil)
|
||||
_ AlertProvider = (*messagebird.AlertProvider)(nil)
|
||||
_ AlertProvider = (*pagerduty.AlertProvider)(nil)
|
||||
_ AlertProvider = (*slack.AlertProvider)(nil)
|
||||
_ AlertProvider = (*telegram.AlertProvider)(nil)
|
||||
_ AlertProvider = (*twilio.AlertProvider)(nil)
|
||||
)
|
||||
|
||||
153
alerting/provider/provider_test.go
Normal file
153
alerting/provider/provider_test.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
)
|
||||
|
||||
func TestParseWithDefaultAlert(t *testing.T) {
|
||||
type Scenario struct {
|
||||
Name string
|
||||
DefaultAlert, ServiceAlert, ExpectedOutputAlert *alert.Alert
|
||||
}
|
||||
enabled := true
|
||||
disabled := false
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "service-alert-type-only",
|
||||
DefaultAlert: &alert.Alert{
|
||||
Enabled: &enabled,
|
||||
SendOnResolved: &enabled,
|
||||
Description: &firstDescription,
|
||||
FailureThreshold: 5,
|
||||
SuccessThreshold: 10,
|
||||
},
|
||||
ServiceAlert: &alert.Alert{
|
||||
Type: alert.TypeDiscord,
|
||||
},
|
||||
ExpectedOutputAlert: &alert.Alert{
|
||||
Type: alert.TypeDiscord,
|
||||
Enabled: &enabled,
|
||||
SendOnResolved: &enabled,
|
||||
Description: &firstDescription,
|
||||
FailureThreshold: 5,
|
||||
SuccessThreshold: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "service-alert-overwrites-default-alert",
|
||||
DefaultAlert: &alert.Alert{
|
||||
Enabled: &disabled,
|
||||
SendOnResolved: &disabled,
|
||||
Description: &firstDescription,
|
||||
FailureThreshold: 5,
|
||||
SuccessThreshold: 10,
|
||||
},
|
||||
ServiceAlert: &alert.Alert{
|
||||
Type: alert.TypeTelegram,
|
||||
Enabled: &enabled,
|
||||
SendOnResolved: &enabled,
|
||||
Description: &secondDescription,
|
||||
FailureThreshold: 6,
|
||||
SuccessThreshold: 11,
|
||||
},
|
||||
ExpectedOutputAlert: &alert.Alert{
|
||||
Type: alert.TypeTelegram,
|
||||
Enabled: &enabled,
|
||||
SendOnResolved: &enabled,
|
||||
Description: &secondDescription,
|
||||
FailureThreshold: 6,
|
||||
SuccessThreshold: 11,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "service-alert-partially-overwrites-default-alert",
|
||||
DefaultAlert: &alert.Alert{
|
||||
Enabled: &enabled,
|
||||
SendOnResolved: &enabled,
|
||||
Description: &firstDescription,
|
||||
FailureThreshold: 5,
|
||||
SuccessThreshold: 10,
|
||||
},
|
||||
ServiceAlert: &alert.Alert{
|
||||
Type: alert.TypeDiscord,
|
||||
Enabled: nil,
|
||||
SendOnResolved: nil,
|
||||
FailureThreshold: 6,
|
||||
SuccessThreshold: 11,
|
||||
},
|
||||
ExpectedOutputAlert: &alert.Alert{
|
||||
Type: alert.TypeDiscord,
|
||||
Enabled: &enabled,
|
||||
SendOnResolved: &enabled,
|
||||
Description: &firstDescription,
|
||||
FailureThreshold: 6,
|
||||
SuccessThreshold: 11,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "default-alert-type-should-be-ignored",
|
||||
DefaultAlert: &alert.Alert{
|
||||
Type: alert.TypeTelegram,
|
||||
Enabled: &enabled,
|
||||
SendOnResolved: &enabled,
|
||||
Description: &firstDescription,
|
||||
FailureThreshold: 5,
|
||||
SuccessThreshold: 10,
|
||||
},
|
||||
ServiceAlert: &alert.Alert{
|
||||
Type: alert.TypeDiscord,
|
||||
},
|
||||
ExpectedOutputAlert: &alert.Alert{
|
||||
Type: alert.TypeDiscord,
|
||||
Enabled: &enabled,
|
||||
SendOnResolved: &enabled,
|
||||
Description: &firstDescription,
|
||||
FailureThreshold: 5,
|
||||
SuccessThreshold: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "no-default-alert",
|
||||
DefaultAlert: &alert.Alert{
|
||||
Type: alert.TypeDiscord,
|
||||
Enabled: nil,
|
||||
SendOnResolved: nil,
|
||||
Description: &firstDescription,
|
||||
FailureThreshold: 2,
|
||||
SuccessThreshold: 5,
|
||||
},
|
||||
ServiceAlert: nil,
|
||||
ExpectedOutputAlert: nil,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
ParseWithDefaultAlert(scenario.DefaultAlert, scenario.ServiceAlert)
|
||||
if scenario.ExpectedOutputAlert == nil {
|
||||
if scenario.ServiceAlert != nil {
|
||||
t.Fail()
|
||||
}
|
||||
return
|
||||
}
|
||||
if scenario.ServiceAlert.IsEnabled() != scenario.ExpectedOutputAlert.IsEnabled() {
|
||||
t.Errorf("expected ServiceAlert.IsEnabled() to be %v, got %v", scenario.ExpectedOutputAlert.IsEnabled(), scenario.ServiceAlert.IsEnabled())
|
||||
}
|
||||
if scenario.ServiceAlert.IsSendingOnResolved() != scenario.ExpectedOutputAlert.IsSendingOnResolved() {
|
||||
t.Errorf("expected ServiceAlert.IsSendingOnResolved() to be %v, got %v", scenario.ExpectedOutputAlert.IsSendingOnResolved(), scenario.ServiceAlert.IsSendingOnResolved())
|
||||
}
|
||||
if scenario.ServiceAlert.GetDescription() != scenario.ExpectedOutputAlert.GetDescription() {
|
||||
t.Errorf("expected ServiceAlert.GetDescription() to be %v, got %v", scenario.ExpectedOutputAlert.GetDescription(), scenario.ServiceAlert.GetDescription())
|
||||
}
|
||||
if scenario.ServiceAlert.FailureThreshold != scenario.ExpectedOutputAlert.FailureThreshold {
|
||||
t.Errorf("expected ServiceAlert.FailureThreshold to be %v, got %v", scenario.ExpectedOutputAlert.FailureThreshold, scenario.ServiceAlert.FailureThreshold)
|
||||
}
|
||||
if scenario.ServiceAlert.SuccessThreshold != scenario.ExpectedOutputAlert.SuccessThreshold {
|
||||
t.Errorf("expected ServiceAlert.SuccessThreshold to be %v, got %v", scenario.ExpectedOutputAlert.SuccessThreshold, scenario.ServiceAlert.SuccessThreshold)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,17 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Slack
|
||||
type AlertProvider struct {
|
||||
WebhookURL string `yaml:"webhook-url"`
|
||||
WebhookURL string `yaml:"webhook-url"` // Slack webhook URL
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
@@ -19,7 +23,7 @@ func (provider *AlertProvider) IsValid() bool {
|
||||
}
|
||||
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *core.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
|
||||
var message, color, results string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", service.Name, alert.SuccessThreshold)
|
||||
@@ -35,7 +39,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
||||
} else {
|
||||
prefix = ":x:"
|
||||
}
|
||||
results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
|
||||
results += fmt.Sprintf("%s - `%s`\\n", prefix, conditionResult.Condition)
|
||||
}
|
||||
return &custom.AlertProvider{
|
||||
URL: provider.WebhookURL,
|
||||
@@ -57,7 +61,12 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
||||
]
|
||||
}
|
||||
]
|
||||
}`, message, alert.Description, color, results),
|
||||
}`, message, alert.GetDescription(), color, results),
|
||||
Headers: map[string]string{"Content-Type": "application/json"},
|
||||
}
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
|
||||
@@ -20,22 +23,44 @@ func TestAlertProvider_IsValid(t *testing.T) {
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
||||
provider := AlertProvider{WebhookURL: "http://example.com"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &core.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
|
||||
if customAlertProvider == nil {
|
||||
t.Error("customAlertProvider shouldn't have been nil")
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "resolved") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring resolved")
|
||||
}
|
||||
if customAlertProvider.URL != "http://example.com" {
|
||||
t.Errorf("expected URL to be %s, got %s", "http://example.com", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
||||
provider := AlertProvider{WebhookURL: "http://example.com"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &core.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
|
||||
if customAlertProvider == nil {
|
||||
t.Error("customAlertProvider shouldn't have been nil")
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "triggered") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring triggered")
|
||||
}
|
||||
if customAlertProvider.URL != "http://example.com" {
|
||||
t.Errorf("expected URL to be %s, got %s", "http://example.com", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
60
alerting/provider/telegram/telegram.go
Normal file
60
alerting/provider/telegram/telegram.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Telegram
|
||||
type AlertProvider struct {
|
||||
Token string `yaml:"token"`
|
||||
ID string `yaml:"id"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
func (provider *AlertProvider) IsValid() bool {
|
||||
return len(provider.Token) > 0 && len(provider.ID) > 0
|
||||
}
|
||||
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
|
||||
var message, results string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("An alert for *%s* has been resolved:\\n—\\n _healthcheck passing successfully %d time(s) in a row_\\n— ", service.Name, alert.FailureThreshold)
|
||||
} else {
|
||||
message = fmt.Sprintf("An alert for *%s* has been triggered:\\n—\\n _healthcheck failed %d time(s) in a row_\\n— ", service.Name, alert.FailureThreshold)
|
||||
}
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
if conditionResult.Success {
|
||||
prefix = "✅"
|
||||
} else {
|
||||
prefix = "❌"
|
||||
}
|
||||
results += fmt.Sprintf("%s - `%s`\\n", prefix, conditionResult.Condition)
|
||||
}
|
||||
var text string
|
||||
if len(alert.GetDescription()) > 0 {
|
||||
text = fmt.Sprintf("⛑ *Gatus* \\n%s \\n*Description* \\n_%s_ \\n\\n*Condition results*\\n%s", message, alert.GetDescription(), results)
|
||||
} else {
|
||||
text = fmt.Sprintf("⛑ *Gatus* \\n%s \\n*Condition results*\\n%s", message, results)
|
||||
}
|
||||
return &custom.AlertProvider{
|
||||
URL: fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token),
|
||||
Method: http.MethodPost,
|
||||
Body: fmt.Sprintf(`{"chat_id": "%s", "text": "%s", "parse_mode": "MARKDOWN"}`, provider.ID, text),
|
||||
Headers: map[string]string{"Content-Type": "application/json"},
|
||||
}
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
91
alerting/provider/telegram/telegram_test.go
Normal file
91
alerting/provider/telegram/telegram_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
|
||||
func TestAlertProvider_IsValid(t *testing.T) {
|
||||
invalidProvider := AlertProvider{Token: "", ID: ""}
|
||||
if invalidProvider.IsValid() {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
validProvider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}
|
||||
if !validProvider.IsValid() {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
||||
provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "resolved") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring resolved")
|
||||
}
|
||||
if customAlertProvider.URL != fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token) {
|
||||
t.Errorf("expected URL to be %s, got %s", fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token), customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
//_, err := json.Marshal(customAlertProvider.Body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
||||
provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "0123456789"}
|
||||
description := "Healthcheck Successful"
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{Description: &description}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "triggered") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring triggered")
|
||||
}
|
||||
if customAlertProvider.URL != fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token) {
|
||||
t.Errorf("expected URL to be %s, got %s", fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token), customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithDescription(t *testing.T) {
|
||||
provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "0123456789"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "triggered") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring triggered")
|
||||
}
|
||||
if customAlertProvider.URL != fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token) {
|
||||
t.Errorf("expected URL to be %s, got %s", fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token), customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
@@ -16,6 +17,9 @@ type AlertProvider struct {
|
||||
Token string `yaml:"token"`
|
||||
From string `yaml:"from"`
|
||||
To string `yaml:"to"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
@@ -24,12 +28,12 @@ func (provider *AlertProvider) IsValid() bool {
|
||||
}
|
||||
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *core.Alert, _ *core.Result, resolved bool) *custom.AlertProvider {
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *alert.Alert, _ *core.Result, resolved bool) *custom.AlertProvider {
|
||||
var message string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("RESOLVED: %s - %s", service.Name, alert.Description)
|
||||
message = fmt.Sprintf("RESOLVED: %s - %s", service.Name, alert.GetDescription())
|
||||
} else {
|
||||
message = fmt.Sprintf("TRIGGERED: %s - %s", service.Name, alert.Description)
|
||||
message = fmt.Sprintf("TRIGGERED: %s - %s", service.Name, alert.GetDescription())
|
||||
}
|
||||
return &custom.AlertProvider{
|
||||
URL: fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json", provider.SID),
|
||||
@@ -45,3 +49,8 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package twilio
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
|
||||
@@ -26,31 +28,51 @@ func TestTwilioAlertProvider_IsValid(t *testing.T) {
|
||||
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
||||
provider := AlertProvider{
|
||||
SID: "1",
|
||||
Token: "1",
|
||||
From: "1",
|
||||
To: "1",
|
||||
Token: "2",
|
||||
From: "3",
|
||||
To: "4",
|
||||
}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &core.Alert{}, &core.Result{}, true)
|
||||
description := "alert-description"
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{Name: "service-name"}, &alert.Alert{Description: &description}, &core.Result{}, true)
|
||||
if customAlertProvider == nil {
|
||||
t.Error("customAlertProvider shouldn't have been nil")
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "RESOLVED") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring RESOLVED")
|
||||
}
|
||||
if customAlertProvider.URL != "https://api.twilio.com/2010-04-01/Accounts/1/Messages.json" {
|
||||
t.Errorf("expected URL to be %s, got %s", "https://api.twilio.com/2010-04-01/Accounts/1/Messages.json", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
if customAlertProvider.Body != "Body=RESOLVED%3A+service-name+-+alert-description&From=3&To=4" {
|
||||
t.Errorf("expected body to be %s, got %s", "Body=RESOLVED%3A+service-name+-+alert-description&From=3&To=4", customAlertProvider.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
||||
provider := AlertProvider{
|
||||
SID: "1",
|
||||
Token: "1",
|
||||
From: "1",
|
||||
SID: "4",
|
||||
Token: "3",
|
||||
From: "2",
|
||||
To: "1",
|
||||
}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &core.Alert{}, &core.Result{}, false)
|
||||
description := "alert-description"
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{Name: "service-name"}, &alert.Alert{Description: &description}, &core.Result{}, false)
|
||||
if customAlertProvider == nil {
|
||||
t.Error("customAlertProvider shouldn't have been nil")
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "TRIGGERED") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring TRIGGERED")
|
||||
}
|
||||
if customAlertProvider.URL != "https://api.twilio.com/2010-04-01/Accounts/4/Messages.json" {
|
||||
t.Errorf("expected URL to be %s, got %s", "https://api.twilio.com/2010-04-01/Accounts/4/Messages.json", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
if customAlertProvider.Body != "Body=TRIGGERED%3A+service-name+-+alert-description&From=2&To=1" {
|
||||
t.Errorf("expected body to be %s, got %s", "Body=TRIGGERED%3A+service-name+-+alert-description&From=2&To=1", customAlertProvider.Body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,13 @@ package client
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/smtp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-ping/ping"
|
||||
@@ -16,6 +21,9 @@ var (
|
||||
// pingTimeout is the timeout for the Ping function
|
||||
// This is mainly exposed for testing purposes
|
||||
pingTimeout = 5 * time.Second
|
||||
|
||||
// httpTimeout is the timeout for secureHTTPClient and insecureHTTPClient
|
||||
httpTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// GetHTTPClient returns the shared HTTP client
|
||||
@@ -23,24 +31,32 @@ func GetHTTPClient(insecure bool) *http.Client {
|
||||
if insecure {
|
||||
if insecureHTTPClient == nil {
|
||||
insecureHTTPClient = &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Timeout: httpTimeout,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 20,
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
},
|
||||
},
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse // Don't follow redirects
|
||||
},
|
||||
}
|
||||
}
|
||||
return insecureHTTPClient
|
||||
}
|
||||
if secureHTTPClient == nil {
|
||||
secureHTTPClient = &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Timeout: httpTimeout,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 20,
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
},
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse // Don't follow redirects
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -57,6 +73,31 @@ func CanCreateTCPConnection(address string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// CanPerformStartTLS checks whether a connection can be established to an address using the STARTTLS protocol
|
||||
func CanPerformStartTLS(address string, insecure bool) (connected bool, certificate *x509.Certificate, err error) {
|
||||
hostAndPort := strings.Split(address, ":")
|
||||
if len(hostAndPort) != 2 {
|
||||
return false, nil, errors.New("invalid address for starttls, format must be host:port")
|
||||
}
|
||||
smtpClient, err := smtp.Dial(address)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = smtpClient.StartTLS(&tls.Config{
|
||||
InsecureSkipVerify: insecure,
|
||||
ServerName: hostAndPort[0],
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if state, ok := smtpClient.TLSConnectionState(); ok {
|
||||
certificate = state.PeerCertificates[0]
|
||||
} else {
|
||||
return false, nil, errors.New("could not get TLS connection state")
|
||||
}
|
||||
return true, certificate, nil
|
||||
}
|
||||
|
||||
// Ping checks if an address can be pinged and returns the round-trip time if the address can be pinged
|
||||
//
|
||||
// Note that this function takes at least 100ms, even if the address is 127.0.0.1
|
||||
@@ -67,8 +108,9 @@ func Ping(address string) (bool, time.Duration) {
|
||||
}
|
||||
pinger.Count = 1
|
||||
pinger.Timeout = pingTimeout
|
||||
pinger.SetNetwork("ip4")
|
||||
pinger.SetPrivileged(true)
|
||||
// Set the pinger's privileged mode to true for every operating system except darwin
|
||||
// https://github.com/TwinProduction/gatus/issues/132
|
||||
pinger.SetPrivileged(runtime.GOOS != "darwin")
|
||||
err = pinger.Run()
|
||||
if err != nil {
|
||||
return false, 0
|
||||
|
||||
@@ -49,3 +49,59 @@ func TestPing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanPerformStartTLS(t *testing.T) {
|
||||
type args struct {
|
||||
address string
|
||||
insecure bool
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
wantConnected bool
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "invalid address",
|
||||
args: args{
|
||||
address: "test",
|
||||
},
|
||||
wantConnected: false,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "error dial",
|
||||
args: args{
|
||||
address: "test:1234",
|
||||
},
|
||||
wantConnected: false,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "valid starttls",
|
||||
args: args{
|
||||
address: "smtp.gmail.com:587",
|
||||
},
|
||||
wantConnected: true,
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
connected, _, err := CanPerformStartTLS(tt.args.address, tt.args.insecure)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("CanPerformStartTLS() err=%v, wantErr=%v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if connected != tt.wantConnected {
|
||||
t.Errorf("CanPerformStartTLS() connected=%v, wantConnected=%v", connected, tt.wantConnected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanCreateTCPConnection(t *testing.T) {
|
||||
if CanCreateTCPConnection("127.0.0.1") {
|
||||
t.Error("should've failed, because there's no port in the address")
|
||||
}
|
||||
}
|
||||
|
||||
29
config.yaml
29
config.yaml
@@ -1,23 +1,24 @@
|
||||
services:
|
||||
- name: frontend
|
||||
- name: front-end
|
||||
group: core
|
||||
url: "https://twinnation.org/health"
|
||||
interval: 1m
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].status == UP"
|
||||
- "[RESPONSE_TIME] < 70"
|
||||
- "[RESPONSE_TIME] < 150"
|
||||
|
||||
- name: backend
|
||||
- name: back-end
|
||||
group: core
|
||||
url: "http://example.org/"
|
||||
url: "https://example.org/"
|
||||
interval: 5m
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[CERTIFICATE_EXPIRATION] > 48h"
|
||||
|
||||
- name: monitoring
|
||||
group: internal
|
||||
url: "http://example.com/"
|
||||
url: "https://example.org/"
|
||||
interval: 5m
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
@@ -29,10 +30,18 @@ services:
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
- name: cat-fact
|
||||
url: "https://cat-fact.herokuapp.com/facts/random"
|
||||
- name: example-dns-query
|
||||
url: "8.8.8.8" # Address of the DNS server to use
|
||||
interval: 5m
|
||||
dns:
|
||||
query-name: "example.com"
|
||||
query-type: "A"
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].deleted == false"
|
||||
- "len([BODY].text) > 0"
|
||||
- "[BODY] == 93.184.216.34"
|
||||
- "[DNS_RCODE] == NOERROR"
|
||||
|
||||
- name: icmp-ping
|
||||
url: "icmp://example.org"
|
||||
interval: 1m
|
||||
conditions:
|
||||
- "[CONNECTED] == true"
|
||||
|
||||
226
config/config.go
226
config/config.go
@@ -5,12 +5,15 @@ import (
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting"
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/alerting/provider"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwinProduction/gatus/k8s"
|
||||
"github.com/TwinProduction/gatus/security"
|
||||
"github.com/TwinProduction/gatus/storage"
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
@@ -28,9 +31,6 @@ const (
|
||||
|
||||
// DefaultPort is the default port the service will listen on
|
||||
DefaultPort = 8080
|
||||
|
||||
// DefaultContextRoot is the default context root of the web application
|
||||
DefaultContextRoot = "/"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -40,13 +40,8 @@ var (
|
||||
// ErrConfigFileNotFound is an error returned when the configuration file could not be found
|
||||
ErrConfigFileNotFound = errors.New("configuration file not found")
|
||||
|
||||
// ErrConfigNotLoaded is an error returned when an attempt to Get() the configuration before loading it is made
|
||||
ErrConfigNotLoaded = errors.New("configuration is nil")
|
||||
|
||||
// ErrInvalidSecurityConfig is an error returned when the security configuration is invalid
|
||||
ErrInvalidSecurityConfig = errors.New("invalid security configuration")
|
||||
|
||||
config *Config
|
||||
)
|
||||
|
||||
// Config is the main configuration structure
|
||||
@@ -57,6 +52,10 @@ type Config struct {
|
||||
// Metrics Whether to expose metrics at /metrics
|
||||
Metrics bool `yaml:"metrics"`
|
||||
|
||||
// SkipInvalidConfigUpdate Whether to make the application ignore invalid configuration
|
||||
// if the configuration file is updated while the application is running
|
||||
SkipInvalidConfigUpdate bool `yaml:"skip-invalid-config-update"`
|
||||
|
||||
// DisableMonitoringLock Whether to disable the monitoring lock
|
||||
// The monitoring lock is what prevents multiple services from being processed at the same time.
|
||||
// Disabling this may lead to inaccurate response times
|
||||
@@ -74,49 +73,62 @@ type Config struct {
|
||||
// Kubernetes is the Kubernetes configuration
|
||||
Kubernetes *k8s.Config `yaml:"kubernetes"`
|
||||
|
||||
// Storage is the configuration for how the data is stored
|
||||
Storage *storage.Config `yaml:"storage"`
|
||||
|
||||
// Web is the configuration for the web listener
|
||||
Web *webConfig `yaml:"web"`
|
||||
Web *WebConfig `yaml:"web"`
|
||||
|
||||
filePath string // path to the file from which config was loaded from
|
||||
lastFileModTime time.Time // last modification time
|
||||
}
|
||||
|
||||
// Get returns the configuration, or panics if the configuration hasn't loaded yet
|
||||
func Get() *Config {
|
||||
if config == nil {
|
||||
panic(ErrConfigNotLoaded)
|
||||
// HasLoadedConfigurationFileBeenModified returns whether the file that the
|
||||
// configuration has been loaded from has been modified since it was last read
|
||||
func (config Config) HasLoadedConfigurationFileBeenModified() bool {
|
||||
if fileInfo, err := os.Stat(config.filePath); err == nil {
|
||||
if !fileInfo.ModTime().IsZero() {
|
||||
return config.lastFileModTime.Unix() != fileInfo.ModTime().Unix()
|
||||
}
|
||||
}
|
||||
return config
|
||||
return false
|
||||
}
|
||||
|
||||
// Set sets the configuration
|
||||
// Used only for testing
|
||||
func Set(cfg *Config) {
|
||||
config = cfg
|
||||
// UpdateLastFileModTime refreshes Config.lastFileModTime
|
||||
func (config *Config) UpdateLastFileModTime() {
|
||||
if fileInfo, err := os.Stat(config.filePath); err == nil {
|
||||
if !fileInfo.ModTime().IsZero() {
|
||||
config.lastFileModTime = fileInfo.ModTime()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load loads a custom configuration file
|
||||
// Note that the misconfiguration of some fields may lead to panics. This is on purpose.
|
||||
func Load(configFile string) error {
|
||||
func Load(configFile string) (*Config, error) {
|
||||
log.Printf("[config][Load] Reading configuration from configFile=%s", configFile)
|
||||
cfg, err := readConfigurationFile(configFile)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return ErrConfigFileNotFound
|
||||
return nil, ErrConfigFileNotFound
|
||||
}
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
config = cfg
|
||||
return nil
|
||||
cfg.filePath = configFile
|
||||
cfg.UpdateLastFileModTime()
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// LoadDefaultConfiguration loads the default configuration file
|
||||
func LoadDefaultConfiguration() error {
|
||||
err := Load(DefaultConfigurationFilePath)
|
||||
func LoadDefaultConfiguration() (*Config, error) {
|
||||
cfg, err := Load(DefaultConfigurationFilePath)
|
||||
if err != nil {
|
||||
if err == ErrConfigFileNotFound {
|
||||
return Load(DefaultFallbackConfigurationFilePath)
|
||||
}
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
return nil
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func readConfigurationFile(fileName string) (config *Config, err error) {
|
||||
@@ -142,51 +154,92 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
|
||||
} else {
|
||||
// Note that the functions below may panic, and this is on purpose to prevent Gatus from starting with
|
||||
// invalid configurations
|
||||
validateAlertingConfig(config)
|
||||
validateSecurityConfig(config)
|
||||
validateServicesConfig(config)
|
||||
validateKubernetesConfig(config)
|
||||
validateWebConfig(config)
|
||||
validateAlertingConfig(config.Alerting, config.Services, config.Debug)
|
||||
if err := validateSecurityConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateServicesConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateKubernetesConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateWebConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateStorageConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func validateWebConfig(config *Config) {
|
||||
if config.Web == nil {
|
||||
config.Web = &webConfig{Address: DefaultAddress, Port: DefaultPort, ContextRoot: DefaultContextRoot}
|
||||
} else {
|
||||
config.Web.validateAndSetDefaults()
|
||||
func validateStorageConfig(config *Config) error {
|
||||
if config.Storage == nil {
|
||||
config.Storage = &storage.Config{
|
||||
Type: storage.TypeInMemory,
|
||||
}
|
||||
}
|
||||
err := storage.Initialize(config.Storage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Remove all ServiceStatus that represent services which no longer exist in the configuration
|
||||
var keys []string
|
||||
for _, service := range config.Services {
|
||||
keys = append(keys, service.Key())
|
||||
}
|
||||
numberOfServiceStatusesDeleted := storage.Get().DeleteAllServiceStatusesNotInKeys(keys)
|
||||
if numberOfServiceStatusesDeleted > 0 {
|
||||
log.Printf("[config][validateStorageConfig] Deleted %d service statuses because their matching services no longer existed", numberOfServiceStatusesDeleted)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateKubernetesConfig(config *Config) {
|
||||
func validateWebConfig(config *Config) error {
|
||||
if config.Web == nil {
|
||||
config.Web = &WebConfig{Address: DefaultAddress, Port: DefaultPort}
|
||||
} else {
|
||||
return config.Web.validateAndSetDefaults()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deprecated
|
||||
// I don't like the current implementation.
|
||||
func validateKubernetesConfig(config *Config) error {
|
||||
if config.Kubernetes != nil && config.Kubernetes.AutoDiscover {
|
||||
log.Println("WARNING - The Kubernetes integration is planned to be removed in v3.0.0. If you're seeing this message, it's because you're currently using it, and you may want to give your opinion at https://github.com/TwinProduction/gatus/discussions/135")
|
||||
if config.Kubernetes.ServiceTemplate == nil {
|
||||
panic("kubernetes.service-template cannot be nil")
|
||||
return errors.New("kubernetes.service-template cannot be nil")
|
||||
}
|
||||
if config.Debug {
|
||||
log.Println("[config][validateKubernetesConfig] Automatically discovering Kubernetes services...")
|
||||
}
|
||||
discoveredServices, err := k8s.DiscoverServices(config.Kubernetes)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
return err
|
||||
}
|
||||
config.Services = append(config.Services, discoveredServices...)
|
||||
log.Printf("[config][validateKubernetesConfig] Discovered %d Kubernetes services", len(discoveredServices))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateServicesConfig(config *Config) {
|
||||
func validateServicesConfig(config *Config) error {
|
||||
for _, service := range config.Services {
|
||||
if config.Debug {
|
||||
log.Printf("[config][validateServicesConfig] Validating service '%s'", service.Name)
|
||||
}
|
||||
service.ValidateAndSetDefaults()
|
||||
if err := service.ValidateAndSetDefaults(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
log.Printf("[config][validateServicesConfig] Validated %d services", len(config.Services))
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSecurityConfig(config *Config) {
|
||||
func validateSecurityConfig(config *Config) error {
|
||||
if config.Security != nil {
|
||||
if config.Security.IsValid() {
|
||||
if config.Debug {
|
||||
@@ -195,29 +248,49 @@ func validateSecurityConfig(config *Config) {
|
||||
} else {
|
||||
// If there was an attempt to configure security, then it must mean that some confidential or private
|
||||
// data are exposed. As a result, we'll force a panic because it's better to be safe than sorry.
|
||||
panic(ErrInvalidSecurityConfig)
|
||||
return ErrInvalidSecurityConfig
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAlertingConfig(config *Config) {
|
||||
if config.Alerting == nil {
|
||||
// validateAlertingConfig validates the alerting configuration
|
||||
// Note that the alerting configuration has to be validated before the service configuration, because the default alert
|
||||
// returned by provider.AlertProvider.GetDefaultAlert() must be parsed before core.Service.ValidateAndSetDefaults()
|
||||
// sets the default alert values when none are set.
|
||||
func validateAlertingConfig(alertingConfig *alerting.Config, services []*core.Service, debug bool) {
|
||||
if alertingConfig == nil {
|
||||
log.Printf("[config][validateAlertingConfig] Alerting is not configured")
|
||||
return
|
||||
}
|
||||
alertTypes := []core.AlertType{
|
||||
core.SlackAlert,
|
||||
core.MattermostAlert,
|
||||
core.MessagebirdAlert,
|
||||
core.TwilioAlert,
|
||||
core.PagerDutyAlert,
|
||||
core.CustomAlert,
|
||||
alertTypes := []alert.Type{
|
||||
alert.TypeCustom,
|
||||
alert.TypeDiscord,
|
||||
alert.TypeMattermost,
|
||||
alert.TypeMessagebird,
|
||||
alert.TypePagerDuty,
|
||||
alert.TypeSlack,
|
||||
alert.TypeTelegram,
|
||||
alert.TypeTwilio,
|
||||
}
|
||||
var validProviders, invalidProviders []core.AlertType
|
||||
var validProviders, invalidProviders []alert.Type
|
||||
for _, alertType := range alertTypes {
|
||||
alertProvider := GetAlertingProviderByAlertType(config, alertType)
|
||||
alertProvider := alertingConfig.GetAlertingProviderByAlertType(alertType)
|
||||
if alertProvider != nil {
|
||||
if alertProvider.IsValid() {
|
||||
// Parse alerts with the provider's default alert
|
||||
if alertProvider.GetDefaultAlert() != nil {
|
||||
for _, service := range services {
|
||||
for alertIndex, serviceAlert := range service.Alerts {
|
||||
if alertType == serviceAlert.Type {
|
||||
if debug {
|
||||
log.Printf("[config][validateAlertingConfig] Parsing alert %d with provider's default alert for provider=%s in service=%s", alertIndex, alertType, service.Name)
|
||||
}
|
||||
provider.ParseWithDefaultAlert(alertProvider.GetDefaultAlert(), serviceAlert)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
validProviders = append(validProviders, alertType)
|
||||
} else {
|
||||
log.Printf("[config][validateAlertingConfig] Ignoring provider=%s because configuration is invalid", alertType)
|
||||
@@ -229,46 +302,3 @@ func validateAlertingConfig(config *Config) {
|
||||
}
|
||||
log.Printf("[config][validateAlertingConfig] configuredProviders=%s; ignoredProviders=%s", validProviders, invalidProviders)
|
||||
}
|
||||
|
||||
// GetAlertingProviderByAlertType returns an provider.AlertProvider by its corresponding core.AlertType
|
||||
func GetAlertingProviderByAlertType(config *Config, alertType core.AlertType) provider.AlertProvider {
|
||||
switch alertType {
|
||||
case core.SlackAlert:
|
||||
if config.Alerting.Slack == nil {
|
||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||
return nil
|
||||
}
|
||||
return config.Alerting.Slack
|
||||
case core.MattermostAlert:
|
||||
if config.Alerting.Mattermost == nil {
|
||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||
return nil
|
||||
}
|
||||
return config.Alerting.Mattermost
|
||||
case core.MessagebirdAlert:
|
||||
if config.Alerting.Messagebird == nil {
|
||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||
return nil
|
||||
}
|
||||
return config.Alerting.Messagebird
|
||||
case core.TwilioAlert:
|
||||
if config.Alerting.Twilio == nil {
|
||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||
return nil
|
||||
}
|
||||
return config.Alerting.Twilio
|
||||
case core.PagerDutyAlert:
|
||||
if config.Alerting.PagerDuty == nil {
|
||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||
return nil
|
||||
}
|
||||
return config.Alerting.PagerDuty
|
||||
case core.CustomAlert:
|
||||
if config.Alerting.Custom == nil {
|
||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||
return nil
|
||||
}
|
||||
return config.Alerting.Custom
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,43 +6,40 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting"
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/discord"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/mattermost"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/messagebird"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/pagerduty"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/slack"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/telegram"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/twilio"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwinProduction/gatus/k8stest"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
)
|
||||
|
||||
func TestGetBeforeConfigIsLoaded(t *testing.T) {
|
||||
defer func() { recover() }()
|
||||
Get()
|
||||
t.Fatal("Should've panicked because the configuration hasn't been loaded yet")
|
||||
}
|
||||
|
||||
func TestSet(t *testing.T) {
|
||||
if config != nil {
|
||||
t.Fatal("config should've been nil")
|
||||
}
|
||||
Set(&Config{})
|
||||
if config == nil {
|
||||
t.Fatal("config shouldn't have been nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileThatDoesNotExist(t *testing.T) {
|
||||
err := Load("file-that-does-not-exist.yaml")
|
||||
_, err := Load("file-that-does-not-exist.yaml")
|
||||
if err == nil {
|
||||
t.Error("Should've returned an error, because the file specified doesn't exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaultConfigurationFile(t *testing.T) {
|
||||
err := LoadDefaultConfiguration()
|
||||
_, err := LoadDefaultConfiguration()
|
||||
if err == nil {
|
||||
t.Error("Should've returned an error, because there's no configuration files at the default path nor the default fallback path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndValidateConfigBytes(t *testing.T) {
|
||||
config, err := parseAndValidateConfigBytes([]byte(`
|
||||
file := t.TempDir() + "/test.db"
|
||||
config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`
|
||||
storage:
|
||||
file: %s
|
||||
services:
|
||||
- name: twinnation
|
||||
url: https://twinnation.org/health
|
||||
@@ -54,9 +51,9 @@ services:
|
||||
conditions:
|
||||
- "[STATUS] != 400"
|
||||
- "[STATUS] != 500"
|
||||
`))
|
||||
`, file)))
|
||||
if err != nil {
|
||||
t.Error("No error should've been returned")
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
if config == nil {
|
||||
t.Fatal("Config shouldn't have been nil")
|
||||
@@ -99,7 +96,7 @@ services:
|
||||
- "[STATUS] == 200"
|
||||
`))
|
||||
if err != nil {
|
||||
t.Error("No error should've been returned")
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
if config == nil {
|
||||
t.Fatal("Config shouldn't have been nil")
|
||||
@@ -119,9 +116,6 @@ services:
|
||||
if config.Web.Port != DefaultPort {
|
||||
t.Errorf("Port should have been %d, because it is the default value", DefaultPort)
|
||||
}
|
||||
if config.Web.ContextRoot != DefaultContextRoot {
|
||||
t.Errorf("ContextRoot should have been %s, because it is the default value", DefaultContextRoot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndValidateConfigBytesWithAddress(t *testing.T) {
|
||||
@@ -135,7 +129,7 @@ services:
|
||||
- "[STATUS] == 200"
|
||||
`))
|
||||
if err != nil {
|
||||
t.Error("No error should've been returned")
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
if config == nil {
|
||||
t.Fatal("Config shouldn't have been nil")
|
||||
@@ -170,7 +164,7 @@ services:
|
||||
- "[STATUS] == 200"
|
||||
`))
|
||||
if err != nil {
|
||||
t.Error("No error should've been returned")
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
if config == nil {
|
||||
t.Fatal("Config shouldn't have been nil")
|
||||
@@ -204,7 +198,7 @@ services:
|
||||
- "[STATUS] == 200"
|
||||
`))
|
||||
if err != nil {
|
||||
t.Error("No error should've been returned")
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
if config == nil {
|
||||
t.Fatal("Config shouldn't have been nil")
|
||||
@@ -226,47 +220,8 @@ services:
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndValidateConfigBytesWithPortAndHostAndContextRoot(t *testing.T) {
|
||||
config, err := parseAndValidateConfigBytes([]byte(`
|
||||
web:
|
||||
port: 12345
|
||||
address: 127.0.0.1
|
||||
context-root: /deeply/nested/down=/their
|
||||
services:
|
||||
- name: twinnation
|
||||
url: https://twinnation.org/health
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
`))
|
||||
if err != nil {
|
||||
t.Error("No error should've been returned")
|
||||
}
|
||||
if config == nil {
|
||||
t.Fatal("Config shouldn't have been nil")
|
||||
}
|
||||
if config.Metrics {
|
||||
t.Error("Metrics should've been false by default")
|
||||
}
|
||||
if config.Services[0].URL != "https://twinnation.org/health" {
|
||||
t.Errorf("URL should have been %s", "https://twinnation.org/health")
|
||||
}
|
||||
if config.Services[0].Interval != 60*time.Second {
|
||||
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
|
||||
}
|
||||
if config.Web.Address != "127.0.0.1" {
|
||||
t.Errorf("Bind address should have been %s, because it is specified in config", "127.0.0.1")
|
||||
}
|
||||
if config.Web.Port != 12345 {
|
||||
t.Errorf("Port should have been %d, because it is specified in config", 12345)
|
||||
}
|
||||
if config.Web.ContextRoot != "/deeply/nested/down=/their/" {
|
||||
t.Errorf("Port should have been %s, because it is specified in config", "/deeply/nested/down=/their/")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndValidateConfigBytesWithInvalidPort(t *testing.T) {
|
||||
defer func() { recover() }()
|
||||
_, _ = parseAndValidateConfigBytes([]byte(`
|
||||
_, err := parseAndValidateConfigBytes([]byte(`
|
||||
web:
|
||||
port: 65536
|
||||
address: 127.0.0.1
|
||||
@@ -276,7 +231,9 @@ services:
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
`))
|
||||
t.Fatal("Should've panicked because the configuration specifies an invalid port value")
|
||||
if err == nil {
|
||||
t.Fatal("Should've returned an error because the configuration specifies an invalid port value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndValidateConfigBytesWithMetricsAndCustomUserAgentHeader(t *testing.T) {
|
||||
@@ -291,7 +248,7 @@ services:
|
||||
- "[STATUS] == 200"
|
||||
`))
|
||||
if err != nil {
|
||||
t.Error("No error should've been returned")
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
if config == nil {
|
||||
t.Fatal("Config shouldn't have been nil")
|
||||
@@ -311,9 +268,6 @@ services:
|
||||
if config.Web.Port != DefaultPort {
|
||||
t.Errorf("Port should have been %d, because it is the default value", DefaultPort)
|
||||
}
|
||||
if config.Web.ContextRoot != DefaultContextRoot {
|
||||
t.Errorf("ContextRoot should have been %s, because it is the default value", DefaultContextRoot)
|
||||
}
|
||||
if userAgent := config.Services[0].Headers["User-Agent"]; userAgent != "Test/2.0" {
|
||||
t.Errorf("User-Agent should've been %s, got %s", "Test/2.0", userAgent)
|
||||
}
|
||||
@@ -332,7 +286,7 @@ services:
|
||||
- "[STATUS] == 200"
|
||||
`))
|
||||
if err != nil {
|
||||
t.Error("No error should've been returned")
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
if config == nil {
|
||||
t.Fatal("Config shouldn't have been nil")
|
||||
@@ -376,16 +330,28 @@ badconfig:
|
||||
func TestParseAndValidateConfigBytesWithAlerting(t *testing.T) {
|
||||
config, err := parseAndValidateConfigBytes([]byte(`
|
||||
debug: true
|
||||
|
||||
alerting:
|
||||
slack:
|
||||
webhook-url: "http://example.com"
|
||||
discord:
|
||||
webhook-url: "http://example.org"
|
||||
pagerduty:
|
||||
integration-key: "00000000000000000000000000000000"
|
||||
mattermost:
|
||||
webhook-url: "http://example.com"
|
||||
messagebird:
|
||||
access-key: "1"
|
||||
originator: "31619191918"
|
||||
recipients: "31619191919"
|
||||
telegram:
|
||||
token: 123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11
|
||||
id: 0123456789
|
||||
twilio:
|
||||
sid: "1234"
|
||||
token: "5678"
|
||||
from: "+1-234-567-8901"
|
||||
to: "+1-234-567-8901"
|
||||
|
||||
services:
|
||||
- name: twinnation
|
||||
url: https://twinnation.org/health
|
||||
@@ -397,13 +363,199 @@ services:
|
||||
failure-threshold: 7
|
||||
success-threshold: 5
|
||||
description: "Healthcheck failed 7 times in a row"
|
||||
- type: messagebird
|
||||
- type: mattermost
|
||||
enabled: true
|
||||
- type: messagebird
|
||||
- type: discord
|
||||
enabled: true
|
||||
failure-threshold: 10
|
||||
- type: telegram
|
||||
enabled: true
|
||||
- type: twilio
|
||||
enabled: true
|
||||
failure-threshold: 12
|
||||
success-threshold: 15
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
`))
|
||||
if err != nil {
|
||||
t.Error("No error should've been returned")
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
if config == nil {
|
||||
t.Fatal("Config shouldn't have been nil")
|
||||
}
|
||||
// Alerting providers
|
||||
if config.Alerting == nil {
|
||||
t.Fatal("config.Alerting shouldn't have been nil")
|
||||
}
|
||||
if config.Alerting.Slack == nil || !config.Alerting.Slack.IsValid() {
|
||||
t.Fatal("Slack alerting config should've been valid")
|
||||
}
|
||||
// Services
|
||||
if len(config.Services) != 1 {
|
||||
t.Error("There should've been 1 service")
|
||||
}
|
||||
if config.Services[0].URL != "https://twinnation.org/health" {
|
||||
t.Errorf("URL should have been %s", "https://twinnation.org/health")
|
||||
}
|
||||
if config.Services[0].Interval != 60*time.Second {
|
||||
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
|
||||
}
|
||||
if len(config.Services[0].Alerts) != 7 {
|
||||
t.Fatal("There should've been 7 alerts configured")
|
||||
}
|
||||
|
||||
if config.Services[0].Alerts[0].Type != alert.TypeSlack {
|
||||
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeSlack, config.Services[0].Alerts[0].Type)
|
||||
}
|
||||
if !config.Services[0].Alerts[0].IsEnabled() {
|
||||
t.Error("The alert should've been enabled")
|
||||
}
|
||||
if config.Services[0].Alerts[0].FailureThreshold != 3 {
|
||||
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Services[0].Alerts[0].FailureThreshold)
|
||||
}
|
||||
if config.Services[0].Alerts[0].SuccessThreshold != 2 {
|
||||
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Services[0].Alerts[0].SuccessThreshold)
|
||||
}
|
||||
|
||||
if config.Services[0].Alerts[1].Type != alert.TypePagerDuty {
|
||||
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypePagerDuty, config.Services[0].Alerts[1].Type)
|
||||
}
|
||||
if config.Services[0].Alerts[1].GetDescription() != "Healthcheck failed 7 times in a row" {
|
||||
t.Errorf("The description of the alert should've been %s, but it was %s", "Healthcheck failed 7 times in a row", config.Services[0].Alerts[1].GetDescription())
|
||||
}
|
||||
if config.Services[0].Alerts[1].FailureThreshold != 7 {
|
||||
t.Errorf("The failure threshold of the alert should've been %d, but it was %d", 7, config.Services[0].Alerts[1].FailureThreshold)
|
||||
}
|
||||
if config.Services[0].Alerts[1].SuccessThreshold != 5 {
|
||||
t.Errorf("The success threshold of the alert should've been %d, but it was %d", 5, config.Services[0].Alerts[1].SuccessThreshold)
|
||||
}
|
||||
|
||||
if config.Services[0].Alerts[2].Type != alert.TypeMattermost {
|
||||
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeMattermost, config.Services[0].Alerts[2].Type)
|
||||
}
|
||||
if !config.Services[0].Alerts[2].IsEnabled() {
|
||||
t.Error("The alert should've been enabled")
|
||||
}
|
||||
if config.Services[0].Alerts[2].FailureThreshold != 3 {
|
||||
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Services[0].Alerts[2].FailureThreshold)
|
||||
}
|
||||
if config.Services[0].Alerts[2].SuccessThreshold != 2 {
|
||||
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Services[0].Alerts[2].SuccessThreshold)
|
||||
}
|
||||
|
||||
if config.Services[0].Alerts[3].Type != alert.TypeMessagebird {
|
||||
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeMessagebird, config.Services[0].Alerts[3].Type)
|
||||
}
|
||||
if config.Services[0].Alerts[3].IsEnabled() {
|
||||
t.Error("The alert should've been disabled")
|
||||
}
|
||||
|
||||
if config.Services[0].Alerts[4].Type != alert.TypeDiscord {
|
||||
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeDiscord, config.Services[0].Alerts[4].Type)
|
||||
}
|
||||
if !config.Services[0].Alerts[4].IsEnabled() {
|
||||
t.Error("The alert should've been enabled")
|
||||
}
|
||||
if config.Services[0].Alerts[4].FailureThreshold != 10 {
|
||||
t.Errorf("The failure threshold of the alert should've been %d, but it was %d", 10, config.Services[0].Alerts[4].FailureThreshold)
|
||||
}
|
||||
if config.Services[0].Alerts[4].SuccessThreshold != 2 {
|
||||
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Services[0].Alerts[4].SuccessThreshold)
|
||||
}
|
||||
|
||||
if config.Services[0].Alerts[5].Type != alert.TypeTelegram {
|
||||
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeTelegram, config.Services[0].Alerts[5].Type)
|
||||
}
|
||||
if !config.Services[0].Alerts[5].IsEnabled() {
|
||||
t.Error("The alert should've been enabled")
|
||||
}
|
||||
if config.Services[0].Alerts[5].FailureThreshold != 3 {
|
||||
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Services[0].Alerts[5].FailureThreshold)
|
||||
}
|
||||
if config.Services[0].Alerts[5].SuccessThreshold != 2 {
|
||||
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Services[0].Alerts[5].SuccessThreshold)
|
||||
}
|
||||
|
||||
if config.Services[0].Alerts[6].Type != alert.TypeTwilio {
|
||||
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeTwilio, config.Services[0].Alerts[6].Type)
|
||||
}
|
||||
if !config.Services[0].Alerts[6].IsEnabled() {
|
||||
t.Error("The alert should've been enabled")
|
||||
}
|
||||
if config.Services[0].Alerts[6].FailureThreshold != 12 {
|
||||
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 12, config.Services[0].Alerts[6].FailureThreshold)
|
||||
}
|
||||
if config.Services[0].Alerts[6].SuccessThreshold != 15 {
|
||||
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 15, config.Services[0].Alerts[6].SuccessThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndValidateConfigBytesWithAlertingAndDefaultAlert(t *testing.T) {
|
||||
config, err := parseAndValidateConfigBytes([]byte(`
|
||||
debug: true
|
||||
|
||||
alerting:
|
||||
slack:
|
||||
webhook-url: "http://example.com"
|
||||
default-alert:
|
||||
enabled: true
|
||||
discord:
|
||||
webhook-url: "http://example.org"
|
||||
default-alert:
|
||||
enabled: true
|
||||
failure-threshold: 10
|
||||
success-threshold: 1
|
||||
pagerduty:
|
||||
integration-key: "00000000000000000000000000000000"
|
||||
default-alert:
|
||||
enabled: true
|
||||
description: default description
|
||||
failure-threshold: 7
|
||||
success-threshold: 5
|
||||
mattermost:
|
||||
webhook-url: "http://example.com"
|
||||
default-alert:
|
||||
enabled: true
|
||||
messagebird:
|
||||
access-key: "1"
|
||||
originator: "31619191918"
|
||||
recipients: "31619191919"
|
||||
default-alert:
|
||||
enabled: false
|
||||
send-on-resolved: true
|
||||
telegram:
|
||||
token: 123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11
|
||||
id: 0123456789
|
||||
default-alert:
|
||||
enabled: true
|
||||
twilio:
|
||||
sid: "1234"
|
||||
token: "5678"
|
||||
from: "+1-234-567-8901"
|
||||
to: "+1-234-567-8901"
|
||||
default-alert:
|
||||
enabled: true
|
||||
failure-threshold: 12
|
||||
success-threshold: 15
|
||||
|
||||
services:
|
||||
- name: twinnation
|
||||
url: https://twinnation.org/health
|
||||
alerts:
|
||||
- type: slack
|
||||
- type: pagerduty
|
||||
- type: mattermost
|
||||
- type: messagebird
|
||||
- type: discord
|
||||
success-threshold: 2 # test service alert override
|
||||
- type: telegram
|
||||
- type: twilio
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
`))
|
||||
if err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
if config == nil {
|
||||
t.Fatal("Config shouldn't have been nil")
|
||||
@@ -411,24 +563,43 @@ services:
|
||||
if config.Metrics {
|
||||
t.Error("Metrics should've been false by default")
|
||||
}
|
||||
// Alerting providers
|
||||
if config.Alerting == nil {
|
||||
t.Fatal("config.Alerting shouldn't have been nil")
|
||||
}
|
||||
if config.Alerting.Slack == nil || !config.Alerting.Slack.IsValid() {
|
||||
t.Fatal("Slack alerting config should've been valid")
|
||||
}
|
||||
if config.Alerting.Slack.GetDefaultAlert() == nil {
|
||||
t.Fatal("Slack.GetDefaultAlert() shouldn't have returned nil")
|
||||
}
|
||||
if config.Alerting.Slack.WebhookURL != "http://example.com" {
|
||||
t.Errorf("Slack webhook should've been %s, but was %s", "http://example.com", config.Alerting.Slack.WebhookURL)
|
||||
}
|
||||
|
||||
if config.Alerting.PagerDuty == nil || !config.Alerting.PagerDuty.IsValid() {
|
||||
t.Fatal("PagerDuty alerting config should've been valid")
|
||||
}
|
||||
if config.Alerting.Messagebird == nil || !config.Alerting.Messagebird.IsValid() {
|
||||
t.Fatal("Messagebird alerting config should've been valid")
|
||||
if config.Alerting.PagerDuty.GetDefaultAlert() == nil {
|
||||
t.Fatal("PagerDuty.GetDefaultAlert() shouldn't have returned nil")
|
||||
}
|
||||
if config.Alerting.PagerDuty.IntegrationKey != "00000000000000000000000000000000" {
|
||||
t.Errorf("PagerDuty integration key should've been %s, but was %s", "00000000000000000000000000000000", config.Alerting.PagerDuty.IntegrationKey)
|
||||
}
|
||||
|
||||
if config.Alerting.Mattermost == nil || !config.Alerting.Mattermost.IsValid() {
|
||||
t.Fatal("Mattermost alerting config should've been valid")
|
||||
}
|
||||
if config.Alerting.Mattermost.GetDefaultAlert() == nil {
|
||||
t.Fatal("Mattermost.GetDefaultAlert() shouldn't have returned nil")
|
||||
}
|
||||
|
||||
if config.Alerting.Messagebird == nil || !config.Alerting.Messagebird.IsValid() {
|
||||
t.Fatal("Messagebird alerting config should've been valid")
|
||||
}
|
||||
if config.Alerting.Messagebird.GetDefaultAlert() == nil {
|
||||
t.Fatal("Messagebird.GetDefaultAlert() shouldn't have returned nil")
|
||||
}
|
||||
if config.Alerting.Messagebird.AccessKey != "1" {
|
||||
t.Errorf("Messagebird access key should've been %s, but was %s", "1", config.Alerting.Messagebird.AccessKey)
|
||||
}
|
||||
@@ -438,6 +609,40 @@ services:
|
||||
if config.Alerting.Messagebird.Recipients != "31619191919" {
|
||||
t.Errorf("Messagebird to recipients should've been %s, but was %s", "31619191919", config.Alerting.Messagebird.Recipients)
|
||||
}
|
||||
|
||||
if config.Alerting.Discord == nil || !config.Alerting.Discord.IsValid() {
|
||||
t.Fatal("Discord alerting config should've been valid")
|
||||
}
|
||||
if config.Alerting.Discord.GetDefaultAlert() == nil {
|
||||
t.Fatal("Discord.GetDefaultAlert() shouldn't have returned nil")
|
||||
}
|
||||
if config.Alerting.Discord.WebhookURL != "http://example.org" {
|
||||
t.Errorf("Discord webhook should've been %s, but was %s", "http://example.org", config.Alerting.Discord.WebhookURL)
|
||||
}
|
||||
if config.Alerting.GetAlertingProviderByAlertType(alert.TypeDiscord) != config.Alerting.Discord {
|
||||
t.Error("expected discord configuration")
|
||||
}
|
||||
|
||||
if config.Alerting.Telegram == nil || !config.Alerting.Telegram.IsValid() {
|
||||
t.Fatal("Telegram alerting config should've been valid")
|
||||
}
|
||||
if config.Alerting.Telegram.GetDefaultAlert() == nil {
|
||||
t.Fatal("Telegram.GetDefaultAlert() shouldn't have returned nil")
|
||||
}
|
||||
if config.Alerting.Telegram.Token != "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" {
|
||||
t.Errorf("Telegram token should've been %s, but was %s", "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", config.Alerting.Telegram.Token)
|
||||
}
|
||||
if config.Alerting.Telegram.ID != "0123456789" {
|
||||
t.Errorf("Telegram ID should've been %s, but was %s", "012345689", config.Alerting.Telegram.ID)
|
||||
}
|
||||
|
||||
if config.Alerting.Twilio == nil || !config.Alerting.Twilio.IsValid() {
|
||||
t.Fatal("Twilio alerting config should've been valid")
|
||||
}
|
||||
if config.Alerting.Twilio.GetDefaultAlert() == nil {
|
||||
t.Fatal("Twilio.GetDefaultAlert() shouldn't have returned nil")
|
||||
}
|
||||
// Services
|
||||
if len(config.Services) != 1 {
|
||||
t.Error("There should've been 1 service")
|
||||
}
|
||||
@@ -447,13 +652,14 @@ services:
|
||||
if config.Services[0].Interval != 60*time.Second {
|
||||
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
|
||||
}
|
||||
if config.Services[0].Alerts == nil {
|
||||
t.Fatal("The service alerts shouldn't have been nil")
|
||||
if len(config.Services[0].Alerts) != 7 {
|
||||
t.Fatal("There should've been 7 alerts configured")
|
||||
}
|
||||
if len(config.Services[0].Alerts) != 3 {
|
||||
t.Fatal("There should've been 3 alert configured")
|
||||
|
||||
if config.Services[0].Alerts[0].Type != alert.TypeSlack {
|
||||
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeSlack, config.Services[0].Alerts[0].Type)
|
||||
}
|
||||
if !config.Services[0].Alerts[0].Enabled {
|
||||
if !config.Services[0].Alerts[0].IsEnabled() {
|
||||
t.Error("The alert should've been enabled")
|
||||
}
|
||||
if config.Services[0].Alerts[0].FailureThreshold != 3 {
|
||||
@@ -462,23 +668,159 @@ services:
|
||||
if config.Services[0].Alerts[0].SuccessThreshold != 2 {
|
||||
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Services[0].Alerts[0].SuccessThreshold)
|
||||
}
|
||||
|
||||
if config.Services[0].Alerts[1].Type != alert.TypePagerDuty {
|
||||
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypePagerDuty, config.Services[0].Alerts[1].Type)
|
||||
}
|
||||
if config.Services[0].Alerts[1].GetDescription() != "default description" {
|
||||
t.Errorf("The description of the alert should've been %s, but it was %s", "default description", config.Services[0].Alerts[1].GetDescription())
|
||||
}
|
||||
if config.Services[0].Alerts[1].FailureThreshold != 7 {
|
||||
t.Errorf("The failure threshold of the alert should've been %d, but it was %d", 7, config.Services[0].Alerts[1].FailureThreshold)
|
||||
}
|
||||
if config.Services[0].Alerts[1].SuccessThreshold != 5 {
|
||||
t.Errorf("The success threshold of the alert should've been %d, but it was %d", 5, config.Services[0].Alerts[1].SuccessThreshold)
|
||||
}
|
||||
if config.Services[0].Alerts[0].Type != core.SlackAlert {
|
||||
t.Errorf("The type of the alert should've been %s, but it was %s", core.SlackAlert, config.Services[0].Alerts[0].Type)
|
||||
|
||||
if config.Services[0].Alerts[2].Type != alert.TypeMattermost {
|
||||
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeMattermost, config.Services[0].Alerts[2].Type)
|
||||
}
|
||||
if config.Services[0].Alerts[1].Type != core.PagerDutyAlert {
|
||||
t.Errorf("The type of the alert should've been %s, but it was %s", core.PagerDutyAlert, config.Services[0].Alerts[1].Type)
|
||||
if !config.Services[0].Alerts[2].IsEnabled() {
|
||||
t.Error("The alert should've been enabled")
|
||||
}
|
||||
if config.Services[0].Alerts[1].Description != "Healthcheck failed 7 times in a row" {
|
||||
t.Errorf("The description of the alert should've been %s, but it was %s", "Healthcheck failed 7 times in a row", config.Services[0].Alerts[0].Description)
|
||||
if config.Services[0].Alerts[2].FailureThreshold != 3 {
|
||||
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Services[0].Alerts[2].FailureThreshold)
|
||||
}
|
||||
if config.Services[0].Alerts[2].Type != core.MessagebirdAlert {
|
||||
t.Errorf("The type of the alert should've been %s, but it was %s", core.MessagebirdAlert, config.Services[0].Alerts[1].Type)
|
||||
if config.Services[0].Alerts[2].SuccessThreshold != 2 {
|
||||
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Services[0].Alerts[2].SuccessThreshold)
|
||||
}
|
||||
|
||||
if config.Services[0].Alerts[3].Type != alert.TypeMessagebird {
|
||||
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeMessagebird, config.Services[0].Alerts[3].Type)
|
||||
}
|
||||
if config.Services[0].Alerts[3].IsEnabled() {
|
||||
t.Error("The alert should've been disabled")
|
||||
}
|
||||
if !config.Services[0].Alerts[3].IsSendingOnResolved() {
|
||||
t.Error("The alert should be sending on resolve")
|
||||
}
|
||||
|
||||
if config.Services[0].Alerts[4].Type != alert.TypeDiscord {
|
||||
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeDiscord, config.Services[0].Alerts[4].Type)
|
||||
}
|
||||
if !config.Services[0].Alerts[4].IsEnabled() {
|
||||
t.Error("The alert should've been enabled")
|
||||
}
|
||||
if config.Services[0].Alerts[4].FailureThreshold != 10 {
|
||||
t.Errorf("The failure threshold of the alert should've been %d, but it was %d", 10, config.Services[0].Alerts[4].FailureThreshold)
|
||||
}
|
||||
if config.Services[0].Alerts[4].SuccessThreshold != 2 {
|
||||
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Services[0].Alerts[4].SuccessThreshold)
|
||||
}
|
||||
|
||||
if config.Services[0].Alerts[5].Type != alert.TypeTelegram {
|
||||
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeTelegram, config.Services[0].Alerts[5].Type)
|
||||
}
|
||||
if !config.Services[0].Alerts[5].IsEnabled() {
|
||||
t.Error("The alert should've been enabled")
|
||||
}
|
||||
if config.Services[0].Alerts[5].FailureThreshold != 3 {
|
||||
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Services[0].Alerts[5].FailureThreshold)
|
||||
}
|
||||
if config.Services[0].Alerts[5].SuccessThreshold != 2 {
|
||||
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Services[0].Alerts[5].SuccessThreshold)
|
||||
}
|
||||
|
||||
if config.Services[0].Alerts[6].Type != alert.TypeTwilio {
|
||||
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeTwilio, config.Services[0].Alerts[6].Type)
|
||||
}
|
||||
if !config.Services[0].Alerts[6].IsEnabled() {
|
||||
t.Error("The alert should've been enabled")
|
||||
}
|
||||
if config.Services[0].Alerts[6].FailureThreshold != 12 {
|
||||
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 12, config.Services[0].Alerts[6].FailureThreshold)
|
||||
}
|
||||
if config.Services[0].Alerts[6].SuccessThreshold != 15 {
|
||||
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 15, config.Services[0].Alerts[6].SuccessThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndValidateConfigBytesWithAlertingAndDefaultAlertAndMultipleAlertsOfSameTypeWithOverriddenParameters(t *testing.T) {
|
||||
config, err := parseAndValidateConfigBytes([]byte(`
|
||||
alerting:
|
||||
slack:
|
||||
webhook-url: "http://example.com"
|
||||
default-alert:
|
||||
enabled: true
|
||||
description: "description"
|
||||
|
||||
services:
|
||||
- name: twinnation
|
||||
url: https://twinnation.org/health
|
||||
alerts:
|
||||
- type: slack
|
||||
failure-threshold: 10
|
||||
- type: slack
|
||||
failure-threshold: 20
|
||||
description: "wow"
|
||||
- type: slack
|
||||
enabled: false
|
||||
failure-threshold: 30
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
`))
|
||||
if err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
if config == nil {
|
||||
t.Fatal("Config shouldn't have been nil")
|
||||
}
|
||||
// Alerting providers
|
||||
if config.Alerting == nil {
|
||||
t.Fatal("config.Alerting shouldn't have been nil")
|
||||
}
|
||||
if config.Alerting.Slack == nil || !config.Alerting.Slack.IsValid() {
|
||||
t.Fatal("Slack alerting config should've been valid")
|
||||
}
|
||||
// Services
|
||||
if len(config.Services) != 1 {
|
||||
t.Error("There should've been 2 services")
|
||||
}
|
||||
if config.Services[0].Alerts[0].Type != alert.TypeSlack {
|
||||
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeSlack, config.Services[0].Alerts[0].Type)
|
||||
}
|
||||
if config.Services[0].Alerts[1].Type != alert.TypeSlack {
|
||||
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeSlack, config.Services[0].Alerts[1].Type)
|
||||
}
|
||||
if config.Services[0].Alerts[2].Type != alert.TypeSlack {
|
||||
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeSlack, config.Services[0].Alerts[2].Type)
|
||||
}
|
||||
if !config.Services[0].Alerts[0].IsEnabled() {
|
||||
t.Error("The alert should've been enabled")
|
||||
}
|
||||
if !config.Services[0].Alerts[1].IsEnabled() {
|
||||
t.Error("The alert should've been enabled")
|
||||
}
|
||||
if config.Services[0].Alerts[2].IsEnabled() {
|
||||
t.Error("The alert should've been disabled")
|
||||
}
|
||||
if config.Services[0].Alerts[0].GetDescription() != "description" {
|
||||
t.Errorf("The description of the alert should've been %s, but it was %s", "description", config.Services[0].Alerts[0].GetDescription())
|
||||
}
|
||||
if config.Services[0].Alerts[1].GetDescription() != "wow" {
|
||||
t.Errorf("The description of the alert should've been %s, but it was %s", "description", config.Services[0].Alerts[1].GetDescription())
|
||||
}
|
||||
if config.Services[0].Alerts[2].GetDescription() != "description" {
|
||||
t.Errorf("The description of the alert should've been %s, but it was %s", "description", config.Services[0].Alerts[2].GetDescription())
|
||||
}
|
||||
if config.Services[0].Alerts[0].FailureThreshold != 10 {
|
||||
t.Errorf("The failure threshold of the alert should've been %d, but it was %d", 10, config.Services[0].Alerts[0].FailureThreshold)
|
||||
}
|
||||
if config.Services[0].Alerts[1].FailureThreshold != 20 {
|
||||
t.Errorf("The failure threshold of the alert should've been %d, but it was %d", 20, config.Services[0].Alerts[1].FailureThreshold)
|
||||
}
|
||||
if config.Services[0].Alerts[2].FailureThreshold != 30 {
|
||||
t.Errorf("The failure threshold of the alert should've been %d, but it was %d", 30, config.Services[0].Alerts[2].FailureThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -496,7 +838,7 @@ services:
|
||||
- "[STATUS] == 200"
|
||||
`))
|
||||
if err != nil {
|
||||
t.Error("No error should've been returned")
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
if config == nil {
|
||||
t.Fatal("Config shouldn't have been nil")
|
||||
@@ -530,7 +872,7 @@ services:
|
||||
- "[STATUS] == 200"
|
||||
`))
|
||||
if err != nil {
|
||||
t.Error("No error should've been returned")
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
if config == nil {
|
||||
t.Fatal("Config shouldn't have been nil")
|
||||
@@ -544,11 +886,106 @@ services:
|
||||
if !config.Alerting.Custom.IsValid() {
|
||||
t.Fatal("Custom alerting config should've been valid")
|
||||
}
|
||||
if config.Alerting.Custom.GetAlertStatePlaceholderValue(true) != "RESOLVED" {
|
||||
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for RESOLVED should've been 'RESOLVED', got", config.Alerting.Custom.GetAlertStatePlaceholderValue(true))
|
||||
}
|
||||
if config.Alerting.Custom.GetAlertStatePlaceholderValue(false) != "TRIGGERED" {
|
||||
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for TRIGGERED should've been 'TRIGGERED', got", config.Alerting.Custom.GetAlertStatePlaceholderValue(false))
|
||||
}
|
||||
if config.Alerting.Custom.Insecure {
|
||||
t.Fatal("config.Alerting.Custom.Insecure shouldn't have been true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndValidateConfigBytesWithCustomAlertingConfigAndCustomPlaceholderValues(t *testing.T) {
|
||||
config, err := parseAndValidateConfigBytes([]byte(`
|
||||
alerting:
|
||||
custom:
|
||||
placeholders:
|
||||
ALERT_TRIGGERED_OR_RESOLVED:
|
||||
TRIGGERED: "partial_outage"
|
||||
RESOLVED: "operational"
|
||||
url: "https://example.com"
|
||||
insecure: true
|
||||
body: "[ALERT_TRIGGERED_OR_RESOLVED]: [SERVICE_NAME] - [ALERT_DESCRIPTION]"
|
||||
services:
|
||||
- name: twinnation
|
||||
url: https://twinnation.org/health
|
||||
alerts:
|
||||
- type: custom
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
`))
|
||||
if err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
if config == nil {
|
||||
t.Fatal("Config shouldn't have been nil")
|
||||
}
|
||||
if config.Alerting == nil {
|
||||
t.Fatal("config.Alerting shouldn't have been nil")
|
||||
}
|
||||
if config.Alerting.Custom == nil {
|
||||
t.Fatal("PagerDuty alerting config shouldn't have been nil")
|
||||
}
|
||||
if !config.Alerting.Custom.IsValid() {
|
||||
t.Fatal("Custom alerting config should've been valid")
|
||||
}
|
||||
if config.Alerting.Custom.GetAlertStatePlaceholderValue(true) != "operational" {
|
||||
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for RESOLVED should've been 'operational'")
|
||||
}
|
||||
if config.Alerting.Custom.GetAlertStatePlaceholderValue(false) != "partial_outage" {
|
||||
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for TRIGGERED should've been 'partial_outage'")
|
||||
}
|
||||
if !config.Alerting.Custom.Insecure {
|
||||
t.Fatal("config.Alerting.Custom.Insecure shouldn't have been true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndValidateConfigBytesWithCustomAlertingConfigAndOneCustomPlaceholderValue(t *testing.T) {
|
||||
config, err := parseAndValidateConfigBytes([]byte(`
|
||||
alerting:
|
||||
custom:
|
||||
placeholders:
|
||||
ALERT_TRIGGERED_OR_RESOLVED:
|
||||
TRIGGERED: "partial_outage"
|
||||
url: "https://example.com"
|
||||
insecure: true
|
||||
body: "[ALERT_TRIGGERED_OR_RESOLVED]: [SERVICE_NAME] - [ALERT_DESCRIPTION]"
|
||||
services:
|
||||
- name: twinnation
|
||||
url: https://twinnation.org/health
|
||||
alerts:
|
||||
- type: custom
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
`))
|
||||
if err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
if config == nil {
|
||||
t.Fatal("Config shouldn't have been nil")
|
||||
}
|
||||
if config.Alerting == nil {
|
||||
t.Fatal("config.Alerting shouldn't have been nil")
|
||||
}
|
||||
if config.Alerting.Custom == nil {
|
||||
t.Fatal("PagerDuty alerting config shouldn't have been nil")
|
||||
}
|
||||
if !config.Alerting.Custom.IsValid() {
|
||||
t.Fatal("Custom alerting config should've been valid")
|
||||
}
|
||||
if config.Alerting.Custom.GetAlertStatePlaceholderValue(true) != "RESOLVED" {
|
||||
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for RESOLVED should've been 'RESOLVED'")
|
||||
}
|
||||
if config.Alerting.Custom.GetAlertStatePlaceholderValue(false) != "partial_outage" {
|
||||
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for TRIGGERED should've been 'partial_outage'")
|
||||
}
|
||||
if !config.Alerting.Custom.Insecure {
|
||||
t.Fatal("config.Alerting.Custom.Insecure shouldn't have been true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndValidateConfigBytesWithCustomAlertingConfigThatHasInsecureSetToTrue(t *testing.T) {
|
||||
config, err := parseAndValidateConfigBytes([]byte(`
|
||||
alerting:
|
||||
@@ -569,7 +1006,7 @@ services:
|
||||
- "[STATUS] == 200"
|
||||
`))
|
||||
if err != nil {
|
||||
t.Error("No error should've been returned")
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
if config == nil {
|
||||
t.Fatal("Config shouldn't have been nil")
|
||||
@@ -591,9 +1028,51 @@ services:
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndValidateConfigBytesWithInvalidServiceName(t *testing.T) {
|
||||
_, err := parseAndValidateConfigBytes([]byte(`
|
||||
services:
|
||||
- name: ""
|
||||
url: https://twinnation.org/health
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
`))
|
||||
if err != core.ErrServiceWithNoName {
|
||||
t.Error("should've returned an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndValidateConfigBytesWithInvalidStorageConfig(t *testing.T) {
|
||||
_, err := parseAndValidateConfigBytes([]byte(`
|
||||
storage:
|
||||
type: sqlite
|
||||
services:
|
||||
- name: example
|
||||
url: https://example.org
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
`))
|
||||
if err == nil {
|
||||
t.Error("should've returned an error, because a file must be specified for a storage of type sqlite")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndValidateConfigBytesWithInvalidYAML(t *testing.T) {
|
||||
_, err := parseAndValidateConfigBytes([]byte(`
|
||||
storage:
|
||||
invalid yaml
|
||||
services:
|
||||
- name: example
|
||||
url: https://example.org
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
`))
|
||||
if err == nil {
|
||||
t.Error("should've returned an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndValidateConfigBytesWithInvalidSecurityConfig(t *testing.T) {
|
||||
defer func() { recover() }()
|
||||
_, _ = parseAndValidateConfigBytes([]byte(`
|
||||
_, err := parseAndValidateConfigBytes([]byte(`
|
||||
security:
|
||||
basic:
|
||||
username: "admin"
|
||||
@@ -604,13 +1083,15 @@ services:
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
`))
|
||||
t.Error("Function should've panicked")
|
||||
if err == nil {
|
||||
t.Error("should've returned an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndValidateConfigBytesWithValidSecurityConfig(t *testing.T) {
|
||||
const expectedUsername = "admin"
|
||||
const expectedPasswordHash = "6b97ed68d14eb3f1aa959ce5d49c7dc612e1eb1dafd73b1e705847483fd6a6c809f2ceb4e8df6ff9984c6298ff0285cace6614bf8daa9f0070101b6c89899e22"
|
||||
config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`
|
||||
config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`debug: true
|
||||
security:
|
||||
basic:
|
||||
username: "%s"
|
||||
@@ -622,7 +1103,7 @@ services:
|
||||
- "[STATUS] == 200"
|
||||
`, expectedUsername, expectedPasswordHash)))
|
||||
if err != nil {
|
||||
t.Error("No error should've been returned")
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
if config == nil {
|
||||
t.Fatal("Config shouldn't have been nil")
|
||||
@@ -689,7 +1170,7 @@ kubernetes:
|
||||
target-path: "/health"
|
||||
`))
|
||||
if err != nil {
|
||||
t.Error("No error should've been returned")
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
if config == nil {
|
||||
t.Fatal("Config shouldn't have been nil")
|
||||
@@ -725,8 +1206,7 @@ kubernetes:
|
||||
}
|
||||
|
||||
func TestParseAndValidateConfigBytesWithKubernetesAutoDiscoveryButNoServiceTemplate(t *testing.T) {
|
||||
defer func() { recover() }()
|
||||
_, _ = parseAndValidateConfigBytes([]byte(`
|
||||
_, err := parseAndValidateConfigBytes([]byte(`
|
||||
kubernetes:
|
||||
cluster-mode: "mock"
|
||||
auto-discover: true
|
||||
@@ -735,12 +1215,13 @@ kubernetes:
|
||||
hostname-suffix: ".default.svc.cluster.local"
|
||||
target-path: "/health"
|
||||
`))
|
||||
t.Error("Function should've panicked because providing a service-template is mandatory")
|
||||
if err == nil {
|
||||
t.Error("should've returned an error because providing a service-template is mandatory")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndValidateConfigBytesWithKubernetesAutoDiscoveryUsingClusterModeIn(t *testing.T) {
|
||||
defer func() { recover() }()
|
||||
_, _ = parseAndValidateConfigBytes([]byte(`
|
||||
_, err := parseAndValidateConfigBytes([]byte(`
|
||||
kubernetes:
|
||||
cluster-mode: "in"
|
||||
auto-discover: true
|
||||
@@ -753,6 +1234,44 @@ kubernetes:
|
||||
hostname-suffix: ".default.svc.cluster.local"
|
||||
target-path: "/health"
|
||||
`))
|
||||
// TODO: find a way to test this?
|
||||
t.Error("Function should've panicked because testing with ClusterModeIn isn't supported")
|
||||
if err == nil {
|
||||
t.Error("should've returned an error because testing with ClusterModeIn isn't supported")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAlertingProviderByAlertType(t *testing.T) {
|
||||
alertingConfig := &alerting.Config{
|
||||
Custom: &custom.AlertProvider{},
|
||||
Discord: &discord.AlertProvider{},
|
||||
Mattermost: &mattermost.AlertProvider{},
|
||||
Messagebird: &messagebird.AlertProvider{},
|
||||
PagerDuty: &pagerduty.AlertProvider{},
|
||||
Slack: &slack.AlertProvider{},
|
||||
Telegram: &telegram.AlertProvider{},
|
||||
Twilio: &twilio.AlertProvider{},
|
||||
}
|
||||
if alertingConfig.GetAlertingProviderByAlertType(alert.TypeCustom) != alertingConfig.Custom {
|
||||
t.Error("expected Custom configuration")
|
||||
}
|
||||
if alertingConfig.GetAlertingProviderByAlertType(alert.TypeDiscord) != alertingConfig.Discord {
|
||||
t.Error("expected Discord configuration")
|
||||
}
|
||||
if alertingConfig.GetAlertingProviderByAlertType(alert.TypeMattermost) != alertingConfig.Mattermost {
|
||||
t.Error("expected Mattermost configuration")
|
||||
}
|
||||
if alertingConfig.GetAlertingProviderByAlertType(alert.TypeMessagebird) != alertingConfig.Messagebird {
|
||||
t.Error("expected Messagebird configuration")
|
||||
}
|
||||
if alertingConfig.GetAlertingProviderByAlertType(alert.TypePagerDuty) != alertingConfig.PagerDuty {
|
||||
t.Error("expected PagerDuty configuration")
|
||||
}
|
||||
if alertingConfig.GetAlertingProviderByAlertType(alert.TypeSlack) != alertingConfig.Slack {
|
||||
t.Error("expected Slack configuration")
|
||||
}
|
||||
if alertingConfig.GetAlertingProviderByAlertType(alert.TypeTelegram) != alertingConfig.Telegram {
|
||||
t.Error("expected Telegram configuration")
|
||||
}
|
||||
if alertingConfig.GetAlertingProviderByAlertType(alert.TypeTwilio) != alertingConfig.Twilio {
|
||||
t.Error("expected Twilio configuration")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,25 +3,20 @@ package config
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// webConfig is the structure which supports the configuration of the endpoint
|
||||
// WebConfig is the structure which supports the configuration of the endpoint
|
||||
// which provides access to the web frontend
|
||||
type webConfig struct {
|
||||
type WebConfig struct {
|
||||
// Address to listen on (defaults to 0.0.0.0 specified by DefaultAddress)
|
||||
Address string `yaml:"address"`
|
||||
|
||||
// Port to listen on (default to 8080 specified by DefaultPort)
|
||||
Port int `yaml:"port"`
|
||||
|
||||
// ContextRoot set the root context for the web application
|
||||
ContextRoot string `yaml:"context-root"`
|
||||
}
|
||||
|
||||
// validateAndSetDefaults checks and sets the default values for fields that are not set
|
||||
func (web *webConfig) validateAndSetDefaults() {
|
||||
func (web *WebConfig) validateAndSetDefaults() error {
|
||||
// Validate the Address
|
||||
if len(web.Address) == 0 {
|
||||
web.Address = DefaultAddress
|
||||
@@ -30,34 +25,12 @@ func (web *webConfig) validateAndSetDefaults() {
|
||||
if web.Port == 0 {
|
||||
web.Port = DefaultPort
|
||||
} else if web.Port < 0 || web.Port > math.MaxUint16 {
|
||||
panic(fmt.Sprintf("invalid port: value should be between %d and %d", 0, math.MaxUint16))
|
||||
}
|
||||
// Validate the ContextRoot
|
||||
if len(web.ContextRoot) == 0 {
|
||||
web.ContextRoot = DefaultContextRoot
|
||||
} else {
|
||||
trimmedContextRoot := strings.Trim(web.ContextRoot, "/")
|
||||
if len(trimmedContextRoot) == 0 {
|
||||
web.ContextRoot = DefaultContextRoot
|
||||
return
|
||||
}
|
||||
rootContextURL, err := url.Parse(trimmedContextRoot)
|
||||
if err != nil {
|
||||
panic("invalid context root:" + err.Error())
|
||||
}
|
||||
if rootContextURL.Path != trimmedContextRoot {
|
||||
panic("invalid context root: too complex")
|
||||
}
|
||||
web.ContextRoot = "/" + strings.Trim(rootContextURL.Path, "/") + "/"
|
||||
return fmt.Errorf("invalid port: value should be between %d and %d", 0, math.MaxUint16)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SocketAddress returns the combination of the Address and the Port
|
||||
func (web *webConfig) SocketAddress() string {
|
||||
func (web *WebConfig) SocketAddress() string {
|
||||
return fmt.Sprintf("%s:%d", web.Address, web.Port)
|
||||
}
|
||||
|
||||
// PrependWithContextRoot appends the given path to the ContextRoot
|
||||
func (web *webConfig) PrependWithContextRoot(path string) string {
|
||||
return web.ContextRoot + strings.Trim(path, "/")
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWebConfig_SocketAddress(t *testing.T) {
|
||||
web := &webConfig{
|
||||
web := &WebConfig{
|
||||
Address: "0.0.0.0",
|
||||
Port: 8081,
|
||||
}
|
||||
@@ -14,86 +13,3 @@ func TestWebConfig_SocketAddress(t *testing.T) {
|
||||
t.Errorf("expected %s, got %s", "0.0.0.0:8081", web.SocketAddress())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebConfig_PrependWithContextRoot(t *testing.T) {
|
||||
web := &webConfig{ContextRoot: "/status/"}
|
||||
if result := web.PrependWithContextRoot("/api/v1/results"); result != "/status/api/v1/results" {
|
||||
t.Errorf("expected %s, got %s", "/status/api/v1/results", result)
|
||||
}
|
||||
if result := web.PrependWithContextRoot("/health"); result != "/status/health" {
|
||||
t.Errorf("expected %s, got %s", "/status/health", result)
|
||||
}
|
||||
if result := web.PrependWithContextRoot("/health/"); result != "/status/health" {
|
||||
t.Errorf("expected %s, got %s", "/status/health", result)
|
||||
}
|
||||
}
|
||||
|
||||
// validContextRootTest specifies all test case which should end up in
|
||||
// a valid context root used to bind the web interface to
|
||||
var validContextRootTests = []struct {
|
||||
name string
|
||||
path string
|
||||
expectedPath string
|
||||
}{
|
||||
{"Empty", "", "/"},
|
||||
{"/", "/", "/"},
|
||||
{"///", "///", "/"},
|
||||
{"Single character 'a'", "a", "/a/"},
|
||||
{"Slash at the beginning", "/status", "/status/"},
|
||||
{"Slashes at start and end", "/status/", "/status/"},
|
||||
{"Multiple slashes at start", "//status", "/status/"},
|
||||
{"Multiple slashes at start and end", "///status////", "/status/"},
|
||||
{"Contains '@' in path'", "me@/status/gatus", "/me@/status/gatus/"},
|
||||
{"Nested context with trailing slash", "/status/gatus/", "/status/gatus/"},
|
||||
{"Nested context without trailing slash", "/status/gatus/system", "/status/gatus/system/"},
|
||||
}
|
||||
|
||||
func TestWebConfig_ValidContextRoots(t *testing.T) {
|
||||
for idx, test := range validContextRootTests {
|
||||
t.Run(fmt.Sprintf("%d: %s", idx, test.name), func(t *testing.T) {
|
||||
expectValidResultForContextRoot(t, test.path, test.expectedPath)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func expectValidResultForContextRoot(t *testing.T, path string, expected string) {
|
||||
web := &webConfig{
|
||||
ContextRoot: path,
|
||||
}
|
||||
web.validateAndSetDefaults()
|
||||
if web.ContextRoot != expected {
|
||||
t.Errorf("expected %s, got %s", expected, web.ContextRoot)
|
||||
}
|
||||
}
|
||||
|
||||
// invalidContextRootTests contains all tests for context root which are
|
||||
// expected to fail and stop program execution
|
||||
var invalidContextRootTests = []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{"Only a fragment identifier", "#"},
|
||||
{"Invalid character in path", "/invalid" + string([]byte{0x7F})},
|
||||
{"Starts with protocol", "http://status/gatus"},
|
||||
{"Path with fragment", "/status/gatus#here"},
|
||||
{"Starts with '://'", "://status"},
|
||||
{"Contains query parameter", "/status/h?ello=world"},
|
||||
{"Contains '?'", "/status?"},
|
||||
}
|
||||
|
||||
func TestWebConfig_InvalidContextRoots(t *testing.T) {
|
||||
for idx, test := range invalidContextRootTests {
|
||||
t.Run(fmt.Sprintf("%d: %s", idx, test.name), func(t *testing.T) {
|
||||
expectInvalidResultForContextRoot(t, test.path)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func expectInvalidResultForContextRoot(t *testing.T, path string) {
|
||||
defer func() { recover() }()
|
||||
|
||||
web := &webConfig{ContextRoot: path}
|
||||
web.validateAndSetDefaults()
|
||||
|
||||
t.Fatal(fmt.Sprintf("Should've panicked because the configuration specifies an invalid context root: %s", path))
|
||||
}
|
||||
|
||||
@@ -7,44 +7,42 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwinProduction/gatus/watchdog"
|
||||
"github.com/TwinProduction/gatus/storage"
|
||||
"github.com/TwinProduction/gatus/storage/store/paging"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// badgeHandler handles the automatic generation of badge based on the group name and service name passed.
|
||||
//
|
||||
// Valid values for {duration}: 7d, 24h, 1h
|
||||
// Pattern for {identifier}: group-<GROUP_NAME>-service-<SERVICE_NAME>.svg
|
||||
// Pattern for {identifier}: <KEY>.svg
|
||||
func badgeHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
variables := mux.Vars(request)
|
||||
duration := variables["duration"]
|
||||
// group-<GROUP_NAME>-service-<SERVICE_NAME>.svg
|
||||
identifier := variables["identifier"]
|
||||
if duration != "7d" && duration != "24h" && duration != "1h" {
|
||||
writer.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = writer.Write([]byte("Durations supported: 7d, 24h, 1h"))
|
||||
return
|
||||
}
|
||||
parts := strings.Split(identifier, "-service-")
|
||||
if len(parts) != 2 || !strings.HasPrefix(identifier, "group-") || !strings.HasSuffix(identifier, ".svg") {
|
||||
writer.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = writer.Write([]byte("Invalid path: Pattern should look like /group-<GROUP_NAME>-service-<SERVICE_NAME>.svg"))
|
||||
return
|
||||
}
|
||||
groupName := strings.TrimPrefix(parts[0], "group-")
|
||||
serviceName := strings.TrimSuffix(parts[1], ".svg")
|
||||
uptime := watchdog.GetUptimeByServiceGroupAndName(groupName, serviceName)
|
||||
if uptime == nil {
|
||||
identifier := variables["identifier"]
|
||||
key := strings.TrimSuffix(identifier, ".svg")
|
||||
serviceStatus := storage.Get().GetServiceStatusByKey(key, paging.NewServiceStatusParams().WithUptime())
|
||||
if serviceStatus == nil {
|
||||
writer.WriteHeader(http.StatusNotFound)
|
||||
_, _ = writer.Write([]byte("Requested service not found"))
|
||||
return
|
||||
}
|
||||
if serviceStatus.Uptime == nil {
|
||||
writer.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = writer.Write([]byte("Failed to compute uptime"))
|
||||
return
|
||||
}
|
||||
formattedDate := time.Now().Format(http.TimeFormat)
|
||||
writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
writer.Header().Set("Date", formattedDate)
|
||||
writer.Header().Set("Expires", formattedDate)
|
||||
writer.Header().Set("Content-Type", "image/svg+xml")
|
||||
_, _ = writer.Write(generateSVG(duration, uptime))
|
||||
_, _ = writer.Write(generateSVG(duration, serviceStatus.Uptime))
|
||||
}
|
||||
|
||||
func generateSVG(duration string, uptime *core.Uptime) []byte {
|
||||
|
||||
@@ -3,16 +3,22 @@ package controller
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/TwinProduction/gatus/config"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwinProduction/gatus/security"
|
||||
"github.com/TwinProduction/gatus/watchdog"
|
||||
"github.com/TwinProduction/gatus/storage"
|
||||
"github.com/TwinProduction/gatus/storage/store/paging"
|
||||
"github.com/TwinProduction/gocache"
|
||||
"github.com/TwinProduction/health"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
@@ -22,66 +28,92 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
cache = gocache.NewCache().WithMaxSize(100).WithEvictionPolicy(gocache.LeastRecentlyUsed)
|
||||
cache = gocache.NewCache().WithMaxSize(100).WithEvictionPolicy(gocache.FirstInFirstOut)
|
||||
|
||||
// staticFolder is the path to the location of the static folder from the root path of the project
|
||||
// The only reason this is exposed is to allow running tests from a different path than the root path of the project
|
||||
staticFolder = "./web/static"
|
||||
|
||||
// server is the http.Server created by Handle.
|
||||
// The only reason it exists is for testing purposes.
|
||||
server *http.Server
|
||||
)
|
||||
|
||||
func init() {
|
||||
if err := cache.StartJanitor(); err != nil {
|
||||
log.Fatal("[controller][init] Failed to start cache janitor:", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Handle creates the router and starts the server
|
||||
func Handle() {
|
||||
cfg := config.Get()
|
||||
router := CreateRouter(cfg)
|
||||
server := &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", cfg.Web.Address, cfg.Web.Port),
|
||||
func Handle(securityConfig *security.Config, webConfig *config.WebConfig, enableMetrics bool) {
|
||||
var router http.Handler = CreateRouter(securityConfig, enableMetrics)
|
||||
if os.Getenv("ENVIRONMENT") == "dev" {
|
||||
router = developmentCorsHandler(router)
|
||||
}
|
||||
server = &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", webConfig.Address, webConfig.Port),
|
||||
Handler: router,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 15 * time.Second,
|
||||
}
|
||||
log.Printf("[controller][Handle] Listening on %s%s\n", cfg.Web.SocketAddress(), cfg.Web.ContextRoot)
|
||||
log.Fatal(server.ListenAndServe())
|
||||
log.Println("[controller][Handle] Listening on " + webConfig.SocketAddress())
|
||||
if os.Getenv("ROUTER_TEST") == "true" {
|
||||
return
|
||||
}
|
||||
log.Println("[controller][Handle]", server.ListenAndServe())
|
||||
}
|
||||
|
||||
// Shutdown stops the server
|
||||
func Shutdown() {
|
||||
if server != nil {
|
||||
_ = server.Shutdown(context.TODO())
|
||||
server = nil
|
||||
}
|
||||
}
|
||||
|
||||
// CreateRouter creates the router for the http server
|
||||
func CreateRouter(cfg *config.Config) *mux.Router {
|
||||
func CreateRouter(securityConfig *security.Config, enabledMetrics bool) *mux.Router {
|
||||
router := mux.NewRouter()
|
||||
statusesHandler := serviceStatusesHandler
|
||||
if cfg.Security != nil && cfg.Security.IsValid() {
|
||||
statusesHandler = security.Handler(serviceStatusesHandler, cfg.Security)
|
||||
}
|
||||
router.HandleFunc("/favicon.ico", favIconHandler).Methods("GET") // favicon needs to be always served from the root
|
||||
router.HandleFunc(cfg.Web.PrependWithContextRoot("/api/v1/statuses"), statusesHandler).Methods("GET")
|
||||
router.HandleFunc(cfg.Web.PrependWithContextRoot("/api/v1/badges/uptime/{duration}/{identifier}"), badgeHandler).Methods("GET")
|
||||
router.HandleFunc(cfg.Web.PrependWithContextRoot("/health"), healthHandler).Methods("GET")
|
||||
router.PathPrefix(cfg.Web.ContextRoot).Handler(GzipHandler(http.StripPrefix(cfg.Web.ContextRoot, http.FileServer(http.Dir("./static")))))
|
||||
if cfg.Metrics {
|
||||
router.Handle(cfg.Web.PrependWithContextRoot("/metrics"), promhttp.Handler()).Methods("GET")
|
||||
if enabledMetrics {
|
||||
router.Handle("/metrics", promhttp.Handler()).Methods("GET")
|
||||
}
|
||||
router.Handle("/health", health.Handler().WithJSON(true)).Methods("GET")
|
||||
router.HandleFunc("/favicon.ico", favIconHandler).Methods("GET")
|
||||
router.HandleFunc("/api/v1/statuses", secureIfNecessary(securityConfig, serviceStatusesHandler)).Methods("GET") // No GzipHandler for this one, because we cache the content
|
||||
router.HandleFunc("/api/v1/statuses/{key}", secureIfNecessary(securityConfig, GzipHandlerFunc(serviceStatusHandler))).Methods("GET")
|
||||
router.HandleFunc("/api/v1/badges/uptime/{duration}/{identifier}", badgeHandler).Methods("GET")
|
||||
// SPA
|
||||
router.HandleFunc("/services/{service}", spaHandler).Methods("GET")
|
||||
// Everything else falls back on static content
|
||||
router.PathPrefix("/").Handler(GzipHandler(http.FileServer(http.Dir(staticFolder))))
|
||||
return router
|
||||
}
|
||||
|
||||
func secureIfNecessary(securityConfig *security.Config, handler http.HandlerFunc) http.HandlerFunc {
|
||||
if securityConfig != nil && securityConfig.IsValid() {
|
||||
return security.Handler(handler, securityConfig)
|
||||
}
|
||||
return handler
|
||||
}
|
||||
|
||||
// serviceStatusesHandler handles requests to retrieve all service statuses
|
||||
// Due to the size of the response, this function leverages a cache.
|
||||
// Must not be wrapped by GzipHandler
|
||||
func serviceStatusesHandler(writer http.ResponseWriter, r *http.Request) {
|
||||
page, pageSize := extractPageAndPageSizeFromRequest(r)
|
||||
gzipped := strings.Contains(r.Header.Get("Accept-Encoding"), "gzip")
|
||||
var exists bool
|
||||
var value interface{}
|
||||
if gzipped {
|
||||
writer.Header().Set("Content-Encoding", "gzip")
|
||||
value, exists = cache.Get("service-status-gzipped")
|
||||
value, exists = cache.Get(fmt.Sprintf("service-status-%d-%d-gzipped", page, pageSize))
|
||||
} else {
|
||||
value, exists = cache.Get("service-status")
|
||||
value, exists = cache.Get(fmt.Sprintf("service-status-%d-%d", page, pageSize))
|
||||
}
|
||||
var data []byte
|
||||
if !exists {
|
||||
var err error
|
||||
buffer := &bytes.Buffer{}
|
||||
gzipWriter := gzip.NewWriter(buffer)
|
||||
data, err = watchdog.GetServiceStatusesAsJSON()
|
||||
data, err = json.Marshal(storage.Get().GetAllServiceStatuses(paging.NewServiceStatusParams().WithResults(page, pageSize)))
|
||||
if err != nil {
|
||||
log.Printf("[main][serviceStatusesHandler] Unable to marshal object to JSON: %s", err.Error())
|
||||
log.Printf("[controller][serviceStatusesHandler] Unable to marshal object to JSON: %s", err.Error())
|
||||
writer.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = writer.Write([]byte("Unable to marshal object to JSON"))
|
||||
return
|
||||
@@ -89,8 +121,8 @@ func serviceStatusesHandler(writer http.ResponseWriter, r *http.Request) {
|
||||
_, _ = gzipWriter.Write(data)
|
||||
_ = gzipWriter.Close()
|
||||
gzippedData := buffer.Bytes()
|
||||
cache.SetWithTTL("service-status", data, cacheTTL)
|
||||
cache.SetWithTTL("service-status-gzipped", gzippedData, cacheTTL)
|
||||
cache.SetWithTTL(fmt.Sprintf("service-status-%d-%d", page, pageSize), data, cacheTTL)
|
||||
cache.SetWithTTL(fmt.Sprintf("service-status-%d-%d-gzipped", page, pageSize), gzippedData, cacheTTL)
|
||||
if gzipped {
|
||||
data = gzippedData
|
||||
}
|
||||
@@ -102,13 +134,33 @@ func serviceStatusesHandler(writer http.ResponseWriter, r *http.Request) {
|
||||
_, _ = writer.Write(data)
|
||||
}
|
||||
|
||||
func healthHandler(writer http.ResponseWriter, _ *http.Request) {
|
||||
// serviceStatusHandler retrieves a single ServiceStatus by group name and service name
|
||||
func serviceStatusHandler(writer http.ResponseWriter, r *http.Request) {
|
||||
page, pageSize := extractPageAndPageSizeFromRequest(r)
|
||||
vars := mux.Vars(r)
|
||||
serviceStatus := storage.Get().GetServiceStatusByKey(vars["key"], paging.NewServiceStatusParams().WithResults(page, pageSize).WithEvents(1, core.MaximumNumberOfEvents).WithUptime())
|
||||
if serviceStatus == nil {
|
||||
log.Printf("[controller][serviceStatusHandler] Service with key=%s not found", vars["key"])
|
||||
writer.WriteHeader(http.StatusNotFound)
|
||||
_, _ = writer.Write([]byte("not found"))
|
||||
return
|
||||
}
|
||||
data := map[string]interface{}{
|
||||
"serviceStatus": serviceStatus,
|
||||
// The following fields, while present on core.ServiceStatus, are annotated to remain hidden so that we can
|
||||
// expose only the necessary data on /api/v1/statuses.
|
||||
// Since the /api/v1/statuses/{key} endpoint does need this data, however, we explicitly expose it here
|
||||
"events": serviceStatus.Events,
|
||||
"uptime": serviceStatus.Uptime,
|
||||
}
|
||||
output, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
log.Printf("[controller][serviceStatusHandler] Unable to marshal object to JSON: %s", err.Error())
|
||||
writer.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = writer.Write([]byte("unable to marshal object to JSON"))
|
||||
return
|
||||
}
|
||||
writer.Header().Add("Content-Type", "application/json")
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
_, _ = writer.Write([]byte("{\"status\":\"UP\"}"))
|
||||
}
|
||||
|
||||
// favIconHandler handles requests for /favicon.ico
|
||||
func favIconHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
http.ServeFile(writer, request, "./static/favicon.ico")
|
||||
_, _ = writer.Write(output)
|
||||
}
|
||||
|
||||
329
controller/controller_test.go
Normal file
329
controller/controller_test.go
Normal file
@@ -0,0 +1,329 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwinProduction/gatus/config"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwinProduction/gatus/storage"
|
||||
"github.com/TwinProduction/gatus/watchdog"
|
||||
)
|
||||
|
||||
var (
|
||||
firstCondition = core.Condition("[STATUS] == 200")
|
||||
secondCondition = core.Condition("[RESPONSE_TIME] < 500")
|
||||
thirdCondition = core.Condition("[CERTIFICATE_EXPIRATION] < 72h")
|
||||
|
||||
timestamp = time.Now()
|
||||
|
||||
testService = core.Service{
|
||||
Name: "name",
|
||||
Group: "group",
|
||||
URL: "https://example.org/what/ever",
|
||||
Method: "GET",
|
||||
Body: "body",
|
||||
Interval: 30 * time.Second,
|
||||
Conditions: []*core.Condition{&firstCondition, &secondCondition, &thirdCondition},
|
||||
Alerts: nil,
|
||||
Insecure: false,
|
||||
NumberOfFailuresInARow: 0,
|
||||
NumberOfSuccessesInARow: 0,
|
||||
}
|
||||
testSuccessfulResult = core.Result{
|
||||
Hostname: "example.org",
|
||||
IP: "127.0.0.1",
|
||||
HTTPStatus: 200,
|
||||
Errors: nil,
|
||||
Connected: true,
|
||||
Success: true,
|
||||
Timestamp: timestamp,
|
||||
Duration: 150 * time.Millisecond,
|
||||
CertificateExpiration: 10 * time.Hour,
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{
|
||||
Condition: "[STATUS] == 200",
|
||||
Success: true,
|
||||
},
|
||||
{
|
||||
Condition: "[RESPONSE_TIME] < 500",
|
||||
Success: true,
|
||||
},
|
||||
{
|
||||
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
|
||||
Success: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
testUnsuccessfulResult = core.Result{
|
||||
Hostname: "example.org",
|
||||
IP: "127.0.0.1",
|
||||
HTTPStatus: 200,
|
||||
Errors: []string{"error-1", "error-2"},
|
||||
Connected: true,
|
||||
Success: false,
|
||||
Timestamp: timestamp,
|
||||
Duration: 750 * time.Millisecond,
|
||||
CertificateExpiration: 10 * time.Hour,
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{
|
||||
Condition: "[STATUS] == 200",
|
||||
Success: true,
|
||||
},
|
||||
{
|
||||
Condition: "[RESPONSE_TIME] < 500",
|
||||
Success: false,
|
||||
},
|
||||
{
|
||||
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
|
||||
Success: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func TestCreateRouter(t *testing.T) {
|
||||
defer storage.Get().Clear()
|
||||
defer cache.Clear()
|
||||
staticFolder = "../web/static"
|
||||
cfg := &config.Config{
|
||||
Metrics: true,
|
||||
Services: []*core.Service{
|
||||
{
|
||||
Name: "frontend",
|
||||
Group: "core",
|
||||
},
|
||||
{
|
||||
Name: "backend",
|
||||
Group: "core",
|
||||
},
|
||||
},
|
||||
}
|
||||
watchdog.UpdateServiceStatuses(cfg.Services[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateServiceStatuses(cfg.Services[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
router := CreateRouter(cfg.Security, cfg.Metrics)
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
ExpectedCode int
|
||||
Gzip bool
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "health",
|
||||
Path: "/health",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "metrics",
|
||||
Path: "/metrics",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "badges-1h",
|
||||
Path: "/api/v1/badges/uptime/1h/core_frontend.svg",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "badges-24h",
|
||||
Path: "/api/v1/badges/uptime/24h/core_backend.svg",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "badges-7d",
|
||||
Path: "/api/v1/badges/uptime/7d/core_frontend.svg",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "badges-with-invalid-duration",
|
||||
Path: "/api/v1/badges/uptime/3d/core_backend.svg",
|
||||
ExpectedCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
Name: "badges-for-invalid-key",
|
||||
Path: "/api/v1/badges/uptime/7d/invalid_key.svg",
|
||||
ExpectedCode: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
Name: "service-statuses",
|
||||
Path: "/api/v1/statuses",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "service-statuses-gzip",
|
||||
Path: "/api/v1/statuses",
|
||||
ExpectedCode: http.StatusOK,
|
||||
Gzip: true,
|
||||
},
|
||||
{
|
||||
Name: "service-statuses-pagination",
|
||||
Path: "/api/v1/statuses?page=1&pageSize=20",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "service-status",
|
||||
Path: "/api/v1/statuses/core_frontend",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "service-status-gzip",
|
||||
Path: "/api/v1/statuses/core_frontend",
|
||||
ExpectedCode: http.StatusOK,
|
||||
Gzip: true,
|
||||
},
|
||||
{
|
||||
Name: "service-status-for-invalid-key",
|
||||
Path: "/api/v1/statuses/invalid_key",
|
||||
ExpectedCode: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
Name: "favicon",
|
||||
Path: "/favicon.ico",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "frontend-home",
|
||||
Path: "/",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "frontend-assets",
|
||||
Path: "/js/app.js",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "frontend-service",
|
||||
Path: "/services/core_frontend",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
request, _ := http.NewRequest("GET", scenario.Path, nil)
|
||||
if scenario.Gzip {
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
}
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(responseRecorder, request)
|
||||
if responseRecorder.Code != scenario.ExpectedCode {
|
||||
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandle(t *testing.T) {
|
||||
defer storage.Get().Clear()
|
||||
defer cache.Clear()
|
||||
cfg := &config.Config{
|
||||
Web: &config.WebConfig{
|
||||
Address: "0.0.0.0",
|
||||
Port: rand.Intn(65534),
|
||||
},
|
||||
Services: []*core.Service{
|
||||
{
|
||||
Name: "frontend",
|
||||
Group: "core",
|
||||
},
|
||||
{
|
||||
Name: "backend",
|
||||
Group: "core",
|
||||
},
|
||||
},
|
||||
}
|
||||
_ = os.Setenv("ROUTER_TEST", "true")
|
||||
_ = os.Setenv("ENVIRONMENT", "dev")
|
||||
defer os.Clearenv()
|
||||
Handle(cfg.Security, cfg.Web, cfg.Metrics)
|
||||
defer Shutdown()
|
||||
request, _ := http.NewRequest("GET", "/health", nil)
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
server.Handler.ServeHTTP(responseRecorder, request)
|
||||
if responseRecorder.Code != http.StatusOK {
|
||||
t.Error("expected GET /health to return status code 200")
|
||||
}
|
||||
if server == nil {
|
||||
t.Fatal("server should've been set (but because we set ROUTER_TEST, it shouldn't have been started)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShutdown(t *testing.T) {
|
||||
// Pretend that we called controller.Handle(), which initializes the server variable
|
||||
server = &http.Server{}
|
||||
Shutdown()
|
||||
if server != nil {
|
||||
t.Error("server should've been shut down")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceStatusesHandler(t *testing.T) {
|
||||
defer storage.Get().Clear()
|
||||
defer cache.Clear()
|
||||
staticFolder = "../web/static"
|
||||
firstResult := &testSuccessfulResult
|
||||
secondResult := &testUnsuccessfulResult
|
||||
storage.Get().Insert(&testService, firstResult)
|
||||
storage.Get().Insert(&testService, secondResult)
|
||||
// Can't be bothered dealing with timezone issues on the worker that runs the automated tests
|
||||
firstResult.Timestamp = time.Time{}
|
||||
secondResult.Timestamp = time.Time{}
|
||||
router := CreateRouter(nil, false)
|
||||
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
ExpectedCode int
|
||||
ExpectedBody string
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "no-pagination",
|
||||
Path: "/api/v1/statuses",
|
||||
ExpectedCode: http.StatusOK,
|
||||
ExpectedBody: `{"group_name":{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"errors":null,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}]}}`,
|
||||
},
|
||||
{
|
||||
Name: "pagination-first-result",
|
||||
Path: "/api/v1/statuses?page=1&pageSize=1",
|
||||
ExpectedCode: http.StatusOK,
|
||||
ExpectedBody: `{"group_name":{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}]}}`,
|
||||
},
|
||||
{
|
||||
Name: "pagination-second-result",
|
||||
Path: "/api/v1/statuses?page=2&pageSize=1",
|
||||
ExpectedCode: http.StatusOK,
|
||||
ExpectedBody: `{"group_name":{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"errors":null,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"}]}}`,
|
||||
},
|
||||
{
|
||||
Name: "pagination-no-results",
|
||||
Path: "/api/v1/statuses?page=5&pageSize=20",
|
||||
ExpectedCode: http.StatusOK,
|
||||
ExpectedBody: `{"group_name":{"name":"name","group":"group","key":"group_name","results":[]}}`,
|
||||
},
|
||||
{
|
||||
Name: "invalid-pagination-should-fall-back-to-default",
|
||||
Path: "/api/v1/statuses?page=INVALID&pageSize=INVALID",
|
||||
ExpectedCode: http.StatusOK,
|
||||
ExpectedBody: `{"group_name":{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"errors":null,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}]}}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
request, _ := http.NewRequest("GET", scenario.Path, nil)
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(responseRecorder, request)
|
||||
if responseRecorder.Code != scenario.ExpectedCode {
|
||||
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
|
||||
}
|
||||
output := responseRecorder.Body.String()
|
||||
if output != scenario.ExpectedBody {
|
||||
t.Errorf("expected:\n %s\n\ngot:\n %s", scenario.ExpectedBody, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
10
controller/cors.go
Normal file
10
controller/cors.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package controller
|
||||
|
||||
import "net/http"
|
||||
|
||||
func developmentCorsHandler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "http://localhost:8081")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
8
controller/favicon.go
Normal file
8
controller/favicon.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package controller
|
||||
|
||||
import "net/http"
|
||||
|
||||
// favIconHandler handles requests for /favicon.ico
|
||||
func favIconHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
http.ServeFile(writer, request, staticFolder+"/favicon.ico")
|
||||
}
|
||||
@@ -32,10 +32,18 @@ func (w *gzipResponseWriter) Write(b []byte) (int, error) {
|
||||
return w.Writer.Write(b)
|
||||
}
|
||||
|
||||
// GzipHandler compresses the response of a given handler if the request's headers specify that the client
|
||||
// GzipHandler compresses the response of a given http.Handler if the request's headers specify that the client
|
||||
// supports gzip encoding
|
||||
func GzipHandler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, r *http.Request) {
|
||||
return GzipHandlerFunc(func(writer http.ResponseWriter, r *http.Request) {
|
||||
next.ServeHTTP(writer, r)
|
||||
})
|
||||
}
|
||||
|
||||
// GzipHandlerFunc compresses the response of a given http.HandlerFunc if the request's headers specify that the client
|
||||
// supports gzip encoding
|
||||
func GzipHandlerFunc(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, r *http.Request) {
|
||||
// If the request doesn't specify that it supports gzip, then don't compress it
|
||||
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||
next.ServeHTTP(writer, r)
|
||||
@@ -47,5 +55,5 @@ func GzipHandler(next http.Handler) http.Handler {
|
||||
gz.Reset(writer)
|
||||
defer gz.Close()
|
||||
next.ServeHTTP(&gzipResponseWriter{ResponseWriter: writer, Writer: gz}, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
8
controller/spa.go
Normal file
8
controller/spa.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package controller
|
||||
|
||||
import "net/http"
|
||||
|
||||
// spaHandler handles requests for /
|
||||
func spaHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
http.ServeFile(writer, request, staticFolder+"/index.html")
|
||||
}
|
||||
48
controller/util.go
Normal file
48
controller/util.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultPage is the default page to use if none is specified or an invalid value is provided
|
||||
DefaultPage = 1
|
||||
|
||||
// DefaultPageSize is the default page siZE to use if none is specified or an invalid value is provided
|
||||
DefaultPageSize = 20
|
||||
|
||||
// MaximumPageSize is the maximum page size allowed
|
||||
MaximumPageSize = core.MaximumNumberOfResults
|
||||
)
|
||||
|
||||
func extractPageAndPageSizeFromRequest(r *http.Request) (page int, pageSize int) {
|
||||
var err error
|
||||
if pageParameter := r.URL.Query().Get("page"); len(pageParameter) == 0 {
|
||||
page = DefaultPage
|
||||
} else {
|
||||
page, err = strconv.Atoi(pageParameter)
|
||||
if err != nil {
|
||||
page = DefaultPage
|
||||
}
|
||||
if page < 1 {
|
||||
page = DefaultPage
|
||||
}
|
||||
}
|
||||
if pageSizeParameter := r.URL.Query().Get("pageSize"); len(pageSizeParameter) == 0 {
|
||||
pageSize = DefaultPageSize
|
||||
} else {
|
||||
pageSize, err = strconv.Atoi(pageSizeParameter)
|
||||
if err != nil {
|
||||
pageSize = DefaultPageSize
|
||||
}
|
||||
if pageSize > MaximumPageSize {
|
||||
pageSize = MaximumPageSize
|
||||
} else if pageSize < 1 {
|
||||
pageSize = DefaultPageSize
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
67
controller/util_test.go
Normal file
67
controller/util_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractPageAndPageSizeFromRequest(t *testing.T) {
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Page string
|
||||
PageSize string
|
||||
ExpectedPage int
|
||||
ExpectedPageSize int
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Page: "1",
|
||||
PageSize: "20",
|
||||
ExpectedPage: 1,
|
||||
ExpectedPageSize: 20,
|
||||
},
|
||||
{
|
||||
Page: "2",
|
||||
PageSize: "10",
|
||||
ExpectedPage: 2,
|
||||
ExpectedPageSize: 10,
|
||||
},
|
||||
{
|
||||
Page: "2",
|
||||
PageSize: "10",
|
||||
ExpectedPage: 2,
|
||||
ExpectedPageSize: 10,
|
||||
},
|
||||
{
|
||||
Page: "1",
|
||||
PageSize: "999999",
|
||||
ExpectedPage: 1,
|
||||
ExpectedPageSize: MaximumPageSize,
|
||||
},
|
||||
{
|
||||
Page: "-1",
|
||||
PageSize: "-1",
|
||||
ExpectedPage: DefaultPage,
|
||||
ExpectedPageSize: DefaultPageSize,
|
||||
},
|
||||
{
|
||||
Page: "invalid",
|
||||
PageSize: "invalid",
|
||||
ExpectedPage: DefaultPage,
|
||||
ExpectedPageSize: DefaultPageSize,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run("page-"+scenario.Page+"-pageSize-"+scenario.PageSize, func(t *testing.T) {
|
||||
request, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/statuses?page=%s&pageSize=%s", scenario.Page, scenario.PageSize), nil)
|
||||
actualPage, actualPageSize := extractPageAndPageSizeFromRequest(request)
|
||||
if actualPage != scenario.ExpectedPage {
|
||||
t.Errorf("expected %d, got %d", scenario.ExpectedPage, actualPage)
|
||||
}
|
||||
if actualPageSize != scenario.ExpectedPageSize {
|
||||
t.Errorf("expected %d, got %d", scenario.ExpectedPageSize, actualPageSize)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ const (
|
||||
|
||||
// DNSRCodePlaceholder is a place holder for DNS_RCODE
|
||||
//
|
||||
// Values that could be NOERROR, FORMERR, SERVFAIL, NXDOMAIN, NOTIMP and REFUSED
|
||||
// Values that could replace the placeholder: NOERROR, FORMERR, SERVFAIL, NXDOMAIN, NOTIMP, REFUSED
|
||||
DNSRCodePlaceholder = "[DNS_RCODE]"
|
||||
|
||||
// ResponseTimePlaceholder is a placeholder for the request response time, in milliseconds.
|
||||
@@ -51,14 +51,19 @@ const (
|
||||
// Usage: len([BODY].articles) == 10, len([BODY].name) > 5
|
||||
LengthFunctionPrefix = "len("
|
||||
|
||||
// HasFunctionPrefix is the prefix for the has function
|
||||
//
|
||||
// Usage: has([BODY].errors) == true
|
||||
HasFunctionPrefix = "has("
|
||||
|
||||
// PatternFunctionPrefix is the prefix for the pattern function
|
||||
//
|
||||
// Usage: pat(192.168.*.*)
|
||||
// Usage: [IP] == pat(192.168.*.*)
|
||||
PatternFunctionPrefix = "pat("
|
||||
|
||||
// AnyFunctionPrefix is the prefix for the any function
|
||||
//
|
||||
// Usage: any(1.1.1.1, 1.0.0.1)
|
||||
// Usage: [IP] == any(1.1.1.1, 1.0.0.1)
|
||||
AnyFunctionPrefix = "any("
|
||||
|
||||
// FunctionSuffix is the suffix for all functions
|
||||
@@ -66,12 +71,20 @@ const (
|
||||
|
||||
// InvalidConditionElementSuffix is the suffix that will be appended to an invalid condition
|
||||
InvalidConditionElementSuffix = "(INVALID)"
|
||||
|
||||
// maximumLengthBeforeTruncatingWhenComparedWithPattern is the maximum length an element being compared to a
|
||||
// pattern can have.
|
||||
//
|
||||
// This is only used for aesthetic purposes; it does not influence whether the condition evaluation results in a
|
||||
// success or a failure
|
||||
maximumLengthBeforeTruncatingWhenComparedWithPattern = 25
|
||||
)
|
||||
|
||||
// Condition is a condition that needs to be met in order for a Service to be considered healthy.
|
||||
type Condition string
|
||||
|
||||
// evaluate the Condition with the Result of the health check
|
||||
// TODO: Add a mandatory space between each operators (e.g. " == " instead of "==") (BREAKING CHANGE)
|
||||
func (c Condition) evaluate(result *Result) bool {
|
||||
condition := string(c)
|
||||
success := false
|
||||
@@ -113,7 +126,7 @@ func (c Condition) evaluate(result *Result) bool {
|
||||
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, "<")
|
||||
}
|
||||
} else {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("invalid condition '%s' has been provided", condition))
|
||||
result.AddError(fmt.Sprintf("invalid condition '%s' has been provided", condition))
|
||||
return false
|
||||
}
|
||||
if !success {
|
||||
@@ -123,6 +136,12 @@ func (c Condition) evaluate(result *Result) bool {
|
||||
return success
|
||||
}
|
||||
|
||||
// hasBodyPlaceholder checks whether the condition has a BodyPlaceholder
|
||||
// Used for determining whether the response body should be read or not
|
||||
func (c Condition) hasBodyPlaceholder() bool {
|
||||
return strings.Contains(string(c), BodyPlaceholder)
|
||||
}
|
||||
|
||||
// isEqual compares two strings.
|
||||
//
|
||||
// Supports the pattern and the any functions.
|
||||
@@ -181,7 +200,7 @@ func isEqual(first, second string) bool {
|
||||
func sanitizeAndResolve(elements []string, result *Result) ([]string, []string) {
|
||||
parameters := make([]string, len(elements))
|
||||
resolvedParameters := make([]string, len(elements))
|
||||
body := strings.TrimSpace(string(result.Body))
|
||||
body := strings.TrimSpace(string(result.body))
|
||||
for i, element := range elements {
|
||||
element = strings.TrimSpace(element)
|
||||
parameters[i] = element
|
||||
@@ -203,26 +222,39 @@ func sanitizeAndResolve(elements []string, result *Result) ([]string, []string)
|
||||
default:
|
||||
// if contains the BodyPlaceholder, then evaluate json path
|
||||
if strings.Contains(element, BodyPlaceholder) {
|
||||
wantLength := false
|
||||
checkingForLength := false
|
||||
checkingForExistence := false
|
||||
if strings.HasPrefix(element, LengthFunctionPrefix) && strings.HasSuffix(element, FunctionSuffix) {
|
||||
wantLength = true
|
||||
checkingForLength = true
|
||||
element = strings.TrimSuffix(strings.TrimPrefix(element, LengthFunctionPrefix), FunctionSuffix)
|
||||
}
|
||||
resolvedElement, resolvedElementLength, err := jsonpath.Eval(strings.TrimPrefix(element, BodyPlaceholder+"."), result.Body)
|
||||
if err != nil {
|
||||
if err.Error() != "unexpected end of JSON input" {
|
||||
result.Errors = append(result.Errors, err.Error())
|
||||
}
|
||||
if wantLength {
|
||||
element = LengthFunctionPrefix + element + FunctionSuffix + " " + InvalidConditionElementSuffix
|
||||
if strings.HasPrefix(element, HasFunctionPrefix) && strings.HasSuffix(element, FunctionSuffix) {
|
||||
checkingForExistence = true
|
||||
element = strings.TrimSuffix(strings.TrimPrefix(element, HasFunctionPrefix), FunctionSuffix)
|
||||
}
|
||||
resolvedElement, resolvedElementLength, err := jsonpath.Eval(strings.TrimPrefix(strings.TrimPrefix(element, BodyPlaceholder), "."), result.body)
|
||||
if checkingForExistence {
|
||||
if err != nil {
|
||||
element = "false"
|
||||
} else {
|
||||
element = element + " " + InvalidConditionElementSuffix
|
||||
element = "true"
|
||||
}
|
||||
} else {
|
||||
if wantLength {
|
||||
element = strconv.Itoa(resolvedElementLength)
|
||||
if err != nil {
|
||||
if err.Error() != "unexpected end of JSON input" {
|
||||
result.AddError(err.Error())
|
||||
}
|
||||
if checkingForLength {
|
||||
element = LengthFunctionPrefix + element + FunctionSuffix + " " + InvalidConditionElementSuffix
|
||||
} else {
|
||||
element = element + " " + InvalidConditionElementSuffix
|
||||
}
|
||||
} else {
|
||||
element = resolvedElement
|
||||
if checkingForLength {
|
||||
element = strconv.Itoa(resolvedElementLength)
|
||||
} else {
|
||||
element = resolvedElement
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -258,6 +290,13 @@ func prettify(parameters []string, resolvedParameters []string, operator string)
|
||||
if strings.HasSuffix(resolvedParameters[0], InvalidConditionElementSuffix) || strings.HasSuffix(resolvedParameters[1], InvalidConditionElementSuffix) {
|
||||
return resolvedParameters[0] + " " + operator + " " + resolvedParameters[1]
|
||||
}
|
||||
// If using the pattern function, truncate the parameter it's being compared to if said parameter is long enough
|
||||
if strings.HasPrefix(parameters[0], PatternFunctionPrefix) && strings.HasSuffix(parameters[0], FunctionSuffix) && len(resolvedParameters[1]) > maximumLengthBeforeTruncatingWhenComparedWithPattern {
|
||||
resolvedParameters[1] = fmt.Sprintf("%.25s...(truncated)", resolvedParameters[1])
|
||||
}
|
||||
if strings.HasPrefix(parameters[1], PatternFunctionPrefix) && strings.HasSuffix(parameters[1], FunctionSuffix) && len(resolvedParameters[0]) > maximumLengthBeforeTruncatingWhenComparedWithPattern {
|
||||
resolvedParameters[0] = fmt.Sprintf("%.25s...(truncated)", resolvedParameters[0])
|
||||
}
|
||||
// First element is a placeholder
|
||||
if parameters[0] != resolvedParameters[0] && parameters[1] == resolvedParameters[1] {
|
||||
return parameters[0] + " (" + resolvedParameters[0] + ") " + operator + " " + parameters[1]
|
||||
|
||||
@@ -5,7 +5,7 @@ import "testing"
|
||||
func BenchmarkCondition_evaluateWithBodyStringAny(b *testing.B) {
|
||||
condition := Condition("[BODY].name == any(john.doe, jane.doe)")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{Body: []byte("{\"name\": \"john.doe\"}")}
|
||||
result := &Result{body: []byte("{\"name\": \"john.doe\"}")}
|
||||
condition.evaluate(result)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
@@ -14,7 +14,7 @@ func BenchmarkCondition_evaluateWithBodyStringAny(b *testing.B) {
|
||||
func BenchmarkCondition_evaluateWithBodyStringAnyFailure(b *testing.B) {
|
||||
condition := Condition("[BODY].name == any(john.doe, jane.doe)")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")}
|
||||
result := &Result{body: []byte("{\"name\": \"bob.doe\"}")}
|
||||
condition.evaluate(result)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
@@ -23,7 +23,7 @@ func BenchmarkCondition_evaluateWithBodyStringAnyFailure(b *testing.B) {
|
||||
func BenchmarkCondition_evaluateWithBodyString(b *testing.B) {
|
||||
condition := Condition("[BODY].name == john.doe")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{Body: []byte("{\"name\": \"john.doe\"}")}
|
||||
result := &Result{body: []byte("{\"name\": \"john.doe\"}")}
|
||||
condition.evaluate(result)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
@@ -32,7 +32,16 @@ func BenchmarkCondition_evaluateWithBodyString(b *testing.B) {
|
||||
func BenchmarkCondition_evaluateWithBodyStringFailure(b *testing.B) {
|
||||
condition := Condition("[BODY].name == john.doe")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")}
|
||||
result := &Result{body: []byte("{\"name\": \"bob.doe\"}")}
|
||||
condition.evaluate(result)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
|
||||
func BenchmarkCondition_evaluateWithBodyStringFailureInvalidPath(b *testing.B) {
|
||||
condition := Condition("[BODY].user.name == bob.doe")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{body: []byte("{\"name\": \"bob.doe\"}")}
|
||||
condition.evaluate(result)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
@@ -41,7 +50,7 @@ func BenchmarkCondition_evaluateWithBodyStringFailure(b *testing.B) {
|
||||
func BenchmarkCondition_evaluateWithBodyStringLen(b *testing.B) {
|
||||
condition := Condition("len([BODY].name) == 8")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{Body: []byte("{\"name\": \"john.doe\"}")}
|
||||
result := &Result{body: []byte("{\"name\": \"john.doe\"}")}
|
||||
condition.evaluate(result)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
@@ -50,7 +59,7 @@ func BenchmarkCondition_evaluateWithBodyStringLen(b *testing.B) {
|
||||
func BenchmarkCondition_evaluateWithBodyStringLenFailure(b *testing.B) {
|
||||
condition := Condition("len([BODY].name) == 8")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")}
|
||||
result := &Result{body: []byte("{\"name\": \"bob.doe\"}")}
|
||||
condition.evaluate(result)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
21
core/dns.go
21
core/dns.go
@@ -29,16 +29,17 @@ type DNS struct {
|
||||
QueryName string `yaml:"query-name"`
|
||||
}
|
||||
|
||||
func (d *DNS) validateAndSetDefault() {
|
||||
func (d *DNS) validateAndSetDefault() error {
|
||||
if len(d.QueryName) == 0 {
|
||||
panic(ErrDNSWithNoQueryName)
|
||||
return ErrDNSWithNoQueryName
|
||||
}
|
||||
if !strings.HasSuffix(d.QueryName, ".") {
|
||||
d.QueryName += "."
|
||||
}
|
||||
if _, ok := dns.StringToType[d.QueryType]; !ok {
|
||||
panic(ErrDNSWithInvalidQueryType)
|
||||
return ErrDNSWithInvalidQueryType
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DNS) query(url string, result *Result) {
|
||||
@@ -51,7 +52,7 @@ func (d *DNS) query(url string, result *Result) {
|
||||
m.SetQuestion(d.QueryName, queryType)
|
||||
r, _, err := c.Exchange(m, url)
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, err.Error())
|
||||
result.AddError(err.Error())
|
||||
return
|
||||
}
|
||||
result.Connected = true
|
||||
@@ -60,26 +61,26 @@ func (d *DNS) query(url string, result *Result) {
|
||||
switch rr.Header().Rrtype {
|
||||
case dns.TypeA:
|
||||
if a, ok := rr.(*dns.A); ok {
|
||||
result.Body = []byte(a.A.String())
|
||||
result.body = []byte(a.A.String())
|
||||
}
|
||||
case dns.TypeAAAA:
|
||||
if aaaa, ok := rr.(*dns.AAAA); ok {
|
||||
result.Body = []byte(aaaa.AAAA.String())
|
||||
result.body = []byte(aaaa.AAAA.String())
|
||||
}
|
||||
case dns.TypeCNAME:
|
||||
if cname, ok := rr.(*dns.CNAME); ok {
|
||||
result.Body = []byte(cname.Target)
|
||||
result.body = []byte(cname.Target)
|
||||
}
|
||||
case dns.TypeMX:
|
||||
if mx, ok := rr.(*dns.MX); ok {
|
||||
result.Body = []byte(mx.Mx)
|
||||
result.body = []byte(mx.Mx)
|
||||
}
|
||||
case dns.TypeNS:
|
||||
if ns, ok := rr.(*dns.NS); ok {
|
||||
result.Body = []byte(ns.Ns)
|
||||
result.body = []byte(ns.Ns)
|
||||
}
|
||||
default:
|
||||
result.Body = []byte("query type is not supported yet")
|
||||
result.body = []byte("query type is not supported yet")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,12 +91,12 @@ func TestIntegrationQuery(t *testing.T) {
|
||||
|
||||
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)) {
|
||||
t.Errorf("got %s, expected result %s,", string(result.Body), test.expectedBody)
|
||||
if !pattern.Match(test.expectedBody, string(result.body)) {
|
||||
t.Errorf("got %s, expected result %s,", string(result.body), test.expectedBody)
|
||||
}
|
||||
} else {
|
||||
if string(result.Body) != test.expectedBody {
|
||||
t.Errorf("got %s, expected result %s,", string(result.Body), test.expectedBody)
|
||||
if string(result.body) != test.expectedBody {
|
||||
t.Errorf("got %s, expected result %s,", string(result.body), test.expectedBody)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -109,8 +109,10 @@ func TestService_ValidateAndSetDefaultsWithNoDNSQueryName(t *testing.T) {
|
||||
QueryType: "A",
|
||||
QueryName: "",
|
||||
}
|
||||
dns.validateAndSetDefault()
|
||||
t.Fatal("Should've panicked because service`s dns didn't have a query name, which is a mandatory field for dns")
|
||||
err := dns.validateAndSetDefault()
|
||||
if err == nil {
|
||||
t.Fatal("Should've returned an error because service`s dns didn't have a query name, which is a mandatory field for dns")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ValidateAndSetDefaultsWithInvalidDNSQueryType(t *testing.T) {
|
||||
@@ -119,6 +121,8 @@ func TestService_ValidateAndSetDefaultsWithInvalidDNSQueryType(t *testing.T) {
|
||||
QueryType: "B",
|
||||
QueryName: "example.com",
|
||||
}
|
||||
dns.validateAndSetDefault()
|
||||
t.Fatal("Should've panicked because service`s dns query type is invalid, it needs to be a valid query name like A, AAAA, CNAME...")
|
||||
err := dns.validateAndSetDefault()
|
||||
if err == nil {
|
||||
t.Fatal("Should've returned an error because service`s dns query type is invalid, it needs to be a valid query name like A, AAAA, CNAME...")
|
||||
}
|
||||
}
|
||||
|
||||
37
core/event.go
Normal file
37
core/event.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package core
|
||||
|
||||
import "time"
|
||||
|
||||
// Event is something that happens at a specific time
|
||||
type Event struct {
|
||||
// Type is the kind of event
|
||||
Type EventType `json:"type"`
|
||||
|
||||
// Timestamp is the moment at which the event happened
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// EventType is, uh, the types of events?
|
||||
type EventType string
|
||||
|
||||
var (
|
||||
// EventStart is a type of event that represents when a service starts being monitored
|
||||
EventStart EventType = "START"
|
||||
|
||||
// EventHealthy is a type of event that represents a service passing all of its conditions
|
||||
EventHealthy EventType = "HEALTHY"
|
||||
|
||||
// EventUnhealthy is a type of event that represents a service failing one or more of its conditions
|
||||
EventUnhealthy EventType = "UNHEALTHY"
|
||||
)
|
||||
|
||||
// NewEventFromResult creates an Event from a Result
|
||||
func NewEventFromResult(result *Result) *Event {
|
||||
event := &Event{Timestamp: result.Timestamp}
|
||||
if result.Success {
|
||||
event.Type = EventHealthy
|
||||
} else {
|
||||
event.Type = EventUnhealthy
|
||||
}
|
||||
return event
|
||||
}
|
||||
@@ -12,10 +12,7 @@ type Result struct {
|
||||
// DNSRCode is the response code of a DNS query in a human readable format
|
||||
DNSRCode string `json:"-"`
|
||||
|
||||
// Body is the response body
|
||||
Body []byte `json:"-"`
|
||||
|
||||
// Hostname extracted from the Service URL
|
||||
// Hostname extracted from Service.URL
|
||||
Hostname string `json:"hostname"`
|
||||
|
||||
// IP resolved from the Service URL
|
||||
@@ -31,7 +28,7 @@ type Result struct {
|
||||
Errors []string `json:"errors"`
|
||||
|
||||
// ConditionResults results of the service's conditions
|
||||
ConditionResults []*ConditionResult `json:"condition-results"`
|
||||
ConditionResults []*ConditionResult `json:"conditionResults"`
|
||||
|
||||
// Success whether the result signifies a success or not
|
||||
Success bool `json:"success"`
|
||||
@@ -41,4 +38,23 @@ type Result struct {
|
||||
|
||||
// CertificateExpiration is the duration before the certificate expires
|
||||
CertificateExpiration time.Duration `json:"-"`
|
||||
|
||||
// body is the response body
|
||||
//
|
||||
// Note that this variable is only used during the evaluation of a service's health.
|
||||
// This means that the call Service.EvaluateHealth both populates the body (if necessary)
|
||||
// and sets it to nil after the evaluation has been completed.
|
||||
body []byte
|
||||
}
|
||||
|
||||
// AddError adds an error to the result's list of errors.
|
||||
// It also ensures that there are no duplicates.
|
||||
func (r *Result) AddError(error string) {
|
||||
for _, resultError := range r.Errors {
|
||||
if resultError == error {
|
||||
// If the error already exists, don't add it
|
||||
return
|
||||
}
|
||||
}
|
||||
r.Errors = append(r.Errors, error)
|
||||
}
|
||||
|
||||
21
core/result_test.go
Normal file
21
core/result_test.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResult_AddError(t *testing.T) {
|
||||
result := &Result{}
|
||||
result.AddError("potato")
|
||||
if len(result.Errors) != 1 {
|
||||
t.Error("should've had 1 error")
|
||||
}
|
||||
result.AddError("potato")
|
||||
if len(result.Errors) != 1 {
|
||||
t.Error("should've still had 1 error, because a duplicate error was added")
|
||||
}
|
||||
result.AddError("tomato")
|
||||
if len(result.Errors) != 2 {
|
||||
t.Error("should've had 2 error")
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package core
|
||||
|
||||
// ServiceStatus contains the evaluation Results of a Service
|
||||
type ServiceStatus struct {
|
||||
// Name of the service
|
||||
Name string `json:"name,omitempty"`
|
||||
|
||||
// Group the service is a part of. Used for grouping multiple services together on the front end.
|
||||
Group string `json:"group,omitempty"`
|
||||
|
||||
// Results is the list of service evaluation results
|
||||
Results []*Result `json:"results"`
|
||||
|
||||
// Uptime information on the service's uptime
|
||||
Uptime *Uptime `json:"uptime"`
|
||||
}
|
||||
|
||||
// NewServiceStatus creates a new ServiceStatus
|
||||
func NewServiceStatus(service *Service) *ServiceStatus {
|
||||
return &ServiceStatus{
|
||||
Name: service.Name,
|
||||
Group: service.Group,
|
||||
Results: make([]*Result, 0),
|
||||
Uptime: NewUptime(),
|
||||
}
|
||||
}
|
||||
|
||||
// AddResult adds a Result to ServiceStatus.Results and makes sure that there are
|
||||
// no more than 20 results in the Results slice
|
||||
func (ss *ServiceStatus) AddResult(result *Result) {
|
||||
ss.Results = append(ss.Results, result)
|
||||
if len(ss.Results) > 20 {
|
||||
ss.Results = ss.Results[1:]
|
||||
}
|
||||
ss.Uptime.ProcessResult(result)
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewServiceStatus(t *testing.T) {
|
||||
service := &Service{Name: "name", Group: "group"}
|
||||
serviceStatus := NewServiceStatus(service)
|
||||
if serviceStatus.Name != service.Name {
|
||||
t.Errorf("expected %s, got %s", service.Name, serviceStatus.Name)
|
||||
}
|
||||
if serviceStatus.Group != service.Group {
|
||||
t.Errorf("expected %s, got %s", service.Group, serviceStatus.Group)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceStatus_AddResult(t *testing.T) {
|
||||
service := &Service{Name: "name", Group: "group"}
|
||||
serviceStatus := NewServiceStatus(service)
|
||||
for i := 0; i < 50; i++ {
|
||||
serviceStatus.AddResult(&Result{Timestamp: time.Now()})
|
||||
}
|
||||
if len(serviceStatus.Results) != 20 {
|
||||
t.Errorf("expected serviceStatus.Results to not exceed a length of 20")
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package core
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/ioutil"
|
||||
@@ -11,7 +12,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/client"
|
||||
"github.com/TwinProduction/gatus/util"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -72,7 +75,7 @@ type Service struct {
|
||||
Conditions []*Condition `yaml:"conditions"`
|
||||
|
||||
// Alerts is the alerting configuration for the service in case of failure
|
||||
Alerts []*Alert `yaml:"alerts"`
|
||||
Alerts []*alert.Alert `yaml:"alerts"`
|
||||
|
||||
// Insecure is whether to skip verifying the server's certificate chain and host name
|
||||
Insecure bool `yaml:"insecure,omitempty"`
|
||||
@@ -80,12 +83,12 @@ type Service struct {
|
||||
// NumberOfFailuresInARow is the number of unsuccessful evaluations in a row
|
||||
NumberOfFailuresInARow int
|
||||
|
||||
// NumberOfFailuresInARow is the number of successful evaluations in a row
|
||||
// NumberOfSuccessesInARow is the number of successful evaluations in a row
|
||||
NumberOfSuccessesInARow int
|
||||
}
|
||||
|
||||
// ValidateAndSetDefaults validates the service's configuration and sets the default value of fields that have one
|
||||
func (service *Service) ValidateAndSetDefaults() {
|
||||
func (service *Service) ValidateAndSetDefaults() error {
|
||||
// Set default values
|
||||
if service.Interval == 0 {
|
||||
service.Interval = 1 * time.Minute
|
||||
@@ -105,32 +108,37 @@ func (service *Service) ValidateAndSetDefaults() {
|
||||
if _, contentTypeHeaderExists := service.Headers[ContentTypeHeader]; !contentTypeHeaderExists && service.GraphQL {
|
||||
service.Headers[ContentTypeHeader] = "application/json"
|
||||
}
|
||||
for _, alert := range service.Alerts {
|
||||
if alert.FailureThreshold <= 0 {
|
||||
alert.FailureThreshold = 3
|
||||
for _, serviceAlert := range service.Alerts {
|
||||
if serviceAlert.FailureThreshold <= 0 {
|
||||
serviceAlert.FailureThreshold = 3
|
||||
}
|
||||
if alert.SuccessThreshold <= 0 {
|
||||
alert.SuccessThreshold = 2
|
||||
if serviceAlert.SuccessThreshold <= 0 {
|
||||
serviceAlert.SuccessThreshold = 2
|
||||
}
|
||||
}
|
||||
if len(service.Name) == 0 {
|
||||
panic(ErrServiceWithNoName)
|
||||
return ErrServiceWithNoName
|
||||
}
|
||||
if len(service.URL) == 0 {
|
||||
panic(ErrServiceWithNoURL)
|
||||
return ErrServiceWithNoURL
|
||||
}
|
||||
if len(service.Conditions) == 0 {
|
||||
panic(ErrServiceWithNoCondition)
|
||||
return ErrServiceWithNoCondition
|
||||
}
|
||||
if service.DNS != nil {
|
||||
service.DNS.validateAndSetDefault()
|
||||
return
|
||||
return service.DNS.validateAndSetDefault()
|
||||
}
|
||||
// Make sure that the request can be created
|
||||
_, err := http.NewRequest(service.Method, service.URL, bytes.NewBuffer([]byte(service.Body)))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Key returns the unique key for the Service
|
||||
func (service Service) Key() string {
|
||||
return util.ConvertGroupAndServiceToKey(service.Group, service.Name)
|
||||
}
|
||||
|
||||
// EvaluateHealth sends a request to the service's URL and evaluates the conditions of the service.
|
||||
@@ -149,38 +157,25 @@ func (service *Service) EvaluateHealth() *Result {
|
||||
}
|
||||
}
|
||||
result.Timestamp = time.Now()
|
||||
// No need to keep the body after the service has been evaluated
|
||||
result.body = nil
|
||||
return result
|
||||
}
|
||||
|
||||
// GetAlertsTriggered returns a slice of alerts that have been triggered
|
||||
func (service *Service) GetAlertsTriggered() []Alert {
|
||||
var alerts []Alert
|
||||
if service.NumberOfFailuresInARow == 0 {
|
||||
return alerts
|
||||
}
|
||||
for _, alert := range service.Alerts {
|
||||
if alert.Enabled && alert.FailureThreshold == service.NumberOfFailuresInARow {
|
||||
alerts = append(alerts, *alert)
|
||||
continue
|
||||
}
|
||||
}
|
||||
return alerts
|
||||
}
|
||||
|
||||
func (service *Service) getIP(result *Result) {
|
||||
if service.DNS != nil {
|
||||
result.Hostname = strings.TrimSuffix(service.URL, ":53")
|
||||
} else {
|
||||
urlObject, err := url.Parse(service.URL)
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, err.Error())
|
||||
result.AddError(err.Error())
|
||||
return
|
||||
}
|
||||
result.Hostname = urlObject.Hostname()
|
||||
}
|
||||
ips, err := net.LookupIP(result.Hostname)
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, err.Error())
|
||||
result.AddError(err.Error())
|
||||
return
|
||||
}
|
||||
result.IP = ips[0].String()
|
||||
@@ -190,10 +185,12 @@ func (service *Service) call(result *Result) {
|
||||
var request *http.Request
|
||||
var response *http.Response
|
||||
var err error
|
||||
var certificate *x509.Certificate
|
||||
isServiceDNS := service.DNS != nil
|
||||
isServiceTCP := strings.HasPrefix(service.URL, "tcp://")
|
||||
isServiceICMP := strings.HasPrefix(service.URL, "icmp://")
|
||||
isServiceHTTP := !isServiceDNS && !isServiceTCP && !isServiceICMP
|
||||
isServiceStartTLS := strings.HasPrefix(service.URL, "starttls://")
|
||||
isServiceHTTP := !isServiceDNS && !isServiceTCP && !isServiceICMP && !isServiceStartTLS
|
||||
if isServiceHTTP {
|
||||
request = service.buildHTTPRequest()
|
||||
}
|
||||
@@ -201,6 +198,14 @@ func (service *Service) call(result *Result) {
|
||||
if isServiceDNS {
|
||||
service.DNS.query(service.URL, result)
|
||||
result.Duration = time.Since(startTime)
|
||||
} else if isServiceStartTLS {
|
||||
result.Connected, certificate, err = client.CanPerformStartTLS(strings.TrimPrefix(service.URL, "starttls://"), service.Insecure)
|
||||
if err != nil {
|
||||
result.AddError(err.Error())
|
||||
return
|
||||
}
|
||||
result.Duration = time.Since(startTime)
|
||||
result.CertificateExpiration = time.Until(certificate.NotAfter)
|
||||
} else if isServiceTCP {
|
||||
result.Connected = client.CanCreateTCPConnection(strings.TrimPrefix(service.URL, "tcp://"))
|
||||
result.Duration = time.Since(startTime)
|
||||
@@ -210,18 +215,22 @@ func (service *Service) call(result *Result) {
|
||||
response, err = client.GetHTTPClient(service.Insecure).Do(request)
|
||||
result.Duration = time.Since(startTime)
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, err.Error())
|
||||
result.AddError(err.Error())
|
||||
return
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.TLS != nil && len(response.TLS.PeerCertificates) > 0 {
|
||||
certificate := response.TLS.PeerCertificates[0]
|
||||
result.CertificateExpiration = certificate.NotAfter.Sub(time.Now())
|
||||
certificate = response.TLS.PeerCertificates[0]
|
||||
result.CertificateExpiration = time.Until(certificate.NotAfter)
|
||||
}
|
||||
result.HTTPStatus = response.StatusCode
|
||||
result.Connected = response.StatusCode > 0
|
||||
result.Body, err = ioutil.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, err.Error())
|
||||
// Only read the body if there's a condition that uses the BodyPlaceholder
|
||||
if service.needsToReadBody() {
|
||||
result.body, err = ioutil.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
result.AddError(err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -246,3 +255,13 @@ func (service *Service) buildHTTPRequest() *http.Request {
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
// needsToReadBody checks if there's any conditions that requires the response body to be read
|
||||
func (service *Service) needsToReadBody() bool {
|
||||
for _, condition := range service.Conditions {
|
||||
if condition.hasBodyPlaceholder() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
50
core/service_status.go
Normal file
50
core/service_status.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package core
|
||||
|
||||
const (
|
||||
// MaximumNumberOfResults is the maximum number of results that ServiceStatus.Results can have
|
||||
MaximumNumberOfResults = 100
|
||||
|
||||
// MaximumNumberOfEvents is the maximum number of events that ServiceStatus.Events can have
|
||||
MaximumNumberOfEvents = 50
|
||||
)
|
||||
|
||||
// ServiceStatus contains the evaluation Results of a Service
|
||||
type ServiceStatus struct {
|
||||
// Name of the service
|
||||
Name string `json:"name,omitempty"`
|
||||
|
||||
// Group the service is a part of. Used for grouping multiple services together on the front end.
|
||||
Group string `json:"group,omitempty"`
|
||||
|
||||
// Key is the key representing the ServiceStatus
|
||||
Key string `json:"key"`
|
||||
|
||||
// Results is the list of service evaluation results
|
||||
Results []*Result `json:"results"`
|
||||
|
||||
// Events is a list of events
|
||||
//
|
||||
// We don't expose this through JSON, because the main dashboard doesn't need to have this data.
|
||||
// However, the detailed service page does leverage this by including it to a map that will be
|
||||
// marshalled alongside the ServiceStatus.
|
||||
Events []*Event `json:"-"`
|
||||
|
||||
// Uptime information on the service's uptime
|
||||
//
|
||||
// We don't expose this through JSON, because the main dashboard doesn't need to have this data.
|
||||
// However, the detailed service page does leverage this by including it to a map that will be
|
||||
// marshalled alongside the ServiceStatus.
|
||||
Uptime *Uptime `json:"-"`
|
||||
}
|
||||
|
||||
// NewServiceStatus creates a new ServiceStatus
|
||||
func NewServiceStatus(serviceKey, serviceGroup, serviceName string) *ServiceStatus {
|
||||
return &ServiceStatus{
|
||||
Name: serviceName,
|
||||
Group: serviceGroup,
|
||||
Key: serviceKey,
|
||||
Results: make([]*Result, 0),
|
||||
Events: make([]*Event, 0),
|
||||
Uptime: NewUptime(),
|
||||
}
|
||||
}
|
||||
19
core/service_status_test.go
Normal file
19
core/service_status_test.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewServiceStatus(t *testing.T) {
|
||||
service := &Service{Name: "name", Group: "group"}
|
||||
serviceStatus := NewServiceStatus(service.Key(), service.Group, service.Name)
|
||||
if serviceStatus.Name != service.Name {
|
||||
t.Errorf("expected %s, got %s", service.Name, serviceStatus.Name)
|
||||
}
|
||||
if serviceStatus.Group != service.Group {
|
||||
t.Errorf("expected %s, got %s", service.Group, serviceStatus.Group)
|
||||
}
|
||||
if serviceStatus.Key != "group_name" {
|
||||
t.Errorf("expected %s, got %s", "group_name", serviceStatus.Key)
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,17 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
)
|
||||
|
||||
func TestService_ValidateAndSetDefaults(t *testing.T) {
|
||||
condition := Condition("[STATUS] == 200")
|
||||
service := Service{
|
||||
Name: "TwiNNatioN",
|
||||
Name: "twinnation-health",
|
||||
URL: "https://twinnation.org/health",
|
||||
Conditions: []*Condition{&condition},
|
||||
Alerts: []*Alert{{Type: PagerDutyAlert}},
|
||||
Alerts: []*alert.Alert{{Type: alert.TypePagerDuty}},
|
||||
}
|
||||
service.ValidateAndSetDefaults()
|
||||
if service.Method != "GET" {
|
||||
@@ -28,7 +30,7 @@ func TestService_ValidateAndSetDefaults(t *testing.T) {
|
||||
if len(service.Alerts) != 1 {
|
||||
t.Error("Service should've had 1 alert")
|
||||
}
|
||||
if service.Alerts[0].Enabled {
|
||||
if service.Alerts[0].IsEnabled() {
|
||||
t.Error("Service alert should've defaulted to disabled")
|
||||
}
|
||||
if service.Alerts[0].SuccessThreshold != 2 {
|
||||
@@ -47,8 +49,10 @@ func TestService_ValidateAndSetDefaultsWithNoName(t *testing.T) {
|
||||
URL: "http://example.com",
|
||||
Conditions: []*Condition{&condition},
|
||||
}
|
||||
service.ValidateAndSetDefaults()
|
||||
t.Fatal("Should've panicked because service didn't have a name, which is a mandatory field")
|
||||
err := service.ValidateAndSetDefaults()
|
||||
if err == nil {
|
||||
t.Fatal("Should've returned an error because service didn't have a name, which is a mandatory field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ValidateAndSetDefaultsWithNoUrl(t *testing.T) {
|
||||
@@ -59,8 +63,10 @@ func TestService_ValidateAndSetDefaultsWithNoUrl(t *testing.T) {
|
||||
URL: "",
|
||||
Conditions: []*Condition{&condition},
|
||||
}
|
||||
service.ValidateAndSetDefaults()
|
||||
t.Fatal("Should've panicked because service didn't have an url, which is a mandatory field")
|
||||
err := service.ValidateAndSetDefaults()
|
||||
if err == nil {
|
||||
t.Fatal("Should've returned an error because service didn't have an url, which is a mandatory field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ValidateAndSetDefaultsWithNoConditions(t *testing.T) {
|
||||
@@ -70,8 +76,10 @@ func TestService_ValidateAndSetDefaultsWithNoConditions(t *testing.T) {
|
||||
URL: "http://example.com",
|
||||
Conditions: nil,
|
||||
}
|
||||
service.ValidateAndSetDefaults()
|
||||
t.Fatal("Should've panicked because service didn't have at least 1 condition")
|
||||
err := service.ValidateAndSetDefaults()
|
||||
if err == nil {
|
||||
t.Fatal("Should've returned an error because service didn't have at least 1 condition")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ValidateAndSetDefaultsWithDNS(t *testing.T) {
|
||||
@@ -85,40 +93,19 @@ func TestService_ValidateAndSetDefaultsWithDNS(t *testing.T) {
|
||||
},
|
||||
Conditions: []*Condition{&conditionSuccess},
|
||||
}
|
||||
service.ValidateAndSetDefaults()
|
||||
err := service.ValidateAndSetDefaults()
|
||||
if err != nil {
|
||||
|
||||
}
|
||||
if service.DNS.QueryName != "example.com." {
|
||||
t.Error("Service.dns.query-name should be formatted with . suffix")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_GetAlertsTriggered(t *testing.T) {
|
||||
condition := Condition("[STATUS] == 200")
|
||||
service := Service{
|
||||
Name: "TwiNNatioN",
|
||||
URL: "https://twinnation.org/health",
|
||||
Conditions: []*Condition{&condition},
|
||||
Alerts: []*Alert{{Type: PagerDutyAlert, Enabled: true}},
|
||||
}
|
||||
service.ValidateAndSetDefaults()
|
||||
if service.NumberOfFailuresInARow != 0 {
|
||||
t.Error("Service.NumberOfFailuresInARow should start with 0")
|
||||
}
|
||||
if service.NumberOfSuccessesInARow != 0 {
|
||||
t.Error("Service.NumberOfSuccessesInARow should start with 0")
|
||||
}
|
||||
if len(service.GetAlertsTriggered()) > 0 {
|
||||
t.Error("No alerts should've been triggered, because service.NumberOfFailuresInARow is 0, which is below the failure threshold")
|
||||
}
|
||||
service.NumberOfFailuresInARow = service.Alerts[0].FailureThreshold
|
||||
if len(service.GetAlertsTriggered()) != 1 {
|
||||
t.Error("Alert should've been triggered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_buildHTTPRequest(t *testing.T) {
|
||||
condition := Condition("[STATUS] == 200")
|
||||
service := Service{
|
||||
Name: "TwiNNatioN",
|
||||
Name: "twinnation-health",
|
||||
URL: "https://twinnation.org/health",
|
||||
Conditions: []*Condition{&condition},
|
||||
}
|
||||
@@ -138,7 +125,7 @@ func TestService_buildHTTPRequest(t *testing.T) {
|
||||
func TestService_buildHTTPRequestWithCustomUserAgent(t *testing.T) {
|
||||
condition := Condition("[STATUS] == 200")
|
||||
service := Service{
|
||||
Name: "TwiNNatioN",
|
||||
Name: "twinnation-health",
|
||||
URL: "https://twinnation.org/health",
|
||||
Conditions: []*Condition{&condition},
|
||||
Headers: map[string]string{
|
||||
@@ -161,7 +148,7 @@ func TestService_buildHTTPRequestWithCustomUserAgent(t *testing.T) {
|
||||
func TestService_buildHTTPRequestWithHostHeader(t *testing.T) {
|
||||
condition := Condition("[STATUS] == 200")
|
||||
service := Service{
|
||||
Name: "TwiNNatioN",
|
||||
Name: "twinnation-health",
|
||||
URL: "https://twinnation.org/health",
|
||||
Method: "POST",
|
||||
Conditions: []*Condition{&condition},
|
||||
@@ -182,13 +169,13 @@ func TestService_buildHTTPRequestWithHostHeader(t *testing.T) {
|
||||
func TestService_buildHTTPRequestWithGraphQLEnabled(t *testing.T) {
|
||||
condition := Condition("[STATUS] == 200")
|
||||
service := Service{
|
||||
Name: "TwiNNatioN",
|
||||
Name: "twinnation-graphql",
|
||||
URL: "https://twinnation.org/graphql",
|
||||
Method: "POST",
|
||||
Conditions: []*Condition{&condition},
|
||||
GraphQL: true,
|
||||
Body: `{
|
||||
user(gender: "female") {
|
||||
users(gender: "female") {
|
||||
id
|
||||
name
|
||||
gender
|
||||
@@ -206,16 +193,17 @@ func TestService_buildHTTPRequestWithGraphQLEnabled(t *testing.T) {
|
||||
}
|
||||
body, _ := ioutil.ReadAll(request.Body)
|
||||
if !strings.HasPrefix(string(body), "{\"query\":") {
|
||||
t.Error("request.Body should've started with '{\"query\":', but it didn't:", string(body))
|
||||
t.Error("request.body should've started with '{\"query\":', but it didn't:", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationEvaluateHealth(t *testing.T) {
|
||||
condition := Condition("[STATUS] == 200")
|
||||
bodyCondition := Condition("[BODY].status == UP")
|
||||
service := Service{
|
||||
Name: "TwiNNatioN",
|
||||
Name: "twinnation-health",
|
||||
URL: "https://twinnation.org/health",
|
||||
Conditions: []*Condition{&condition},
|
||||
Conditions: []*Condition{&condition, &bodyCondition},
|
||||
}
|
||||
result := service.EvaluateHealth()
|
||||
if !result.ConditionResults[0].Success {
|
||||
@@ -232,7 +220,7 @@ func TestIntegrationEvaluateHealth(t *testing.T) {
|
||||
func TestIntegrationEvaluateHealthWithFailure(t *testing.T) {
|
||||
condition := Condition("[STATUS] == 500")
|
||||
service := Service{
|
||||
Name: "TwiNNatioN",
|
||||
Name: "twinnation-health",
|
||||
URL: "https://twinnation.org/health",
|
||||
Conditions: []*Condition{&condition},
|
||||
}
|
||||
@@ -252,7 +240,7 @@ func TestIntegrationEvaluateHealthForDNS(t *testing.T) {
|
||||
conditionSuccess := Condition("[DNS_RCODE] == NOERROR")
|
||||
conditionBody := Condition("[BODY] == 93.184.216.34")
|
||||
service := Service{
|
||||
Name: "TwiNNatioN",
|
||||
Name: "example",
|
||||
URL: "8.8.8.8",
|
||||
DNS: &DNS{
|
||||
QueryType: "A",
|
||||
@@ -275,7 +263,7 @@ func TestIntegrationEvaluateHealthForDNS(t *testing.T) {
|
||||
func TestIntegrationEvaluateHealthForICMP(t *testing.T) {
|
||||
conditionSuccess := Condition("[CONNECTED] == true")
|
||||
service := Service{
|
||||
Name: "ICMP test",
|
||||
Name: "icmp-test",
|
||||
URL: "icmp://127.0.0.1",
|
||||
Conditions: []*Condition{&conditionSuccess},
|
||||
}
|
||||
@@ -294,7 +282,7 @@ func TestIntegrationEvaluateHealthForICMP(t *testing.T) {
|
||||
func TestService_getIP(t *testing.T) {
|
||||
conditionSuccess := Condition("[CONNECTED] == true")
|
||||
service := Service{
|
||||
Name: "Invalid URL test",
|
||||
Name: "invalid-url-test",
|
||||
URL: "",
|
||||
Conditions: []*Condition{&conditionSuccess},
|
||||
}
|
||||
@@ -304,3 +292,27 @@ func TestService_getIP(t *testing.T) {
|
||||
t.Error("service.getIP(result) should've thrown an error because the URL is invalid, thus cannot be parsed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_NeedsToReadBody(t *testing.T) {
|
||||
statusCondition := Condition("[STATUS] == 200")
|
||||
bodyCondition := Condition("[BODY].status == UP")
|
||||
bodyConditionWithLength := Condition("len([BODY].tags) > 0")
|
||||
if (&Service{Conditions: []*Condition{&statusCondition}}).needsToReadBody() {
|
||||
t.Error("expected false, got true")
|
||||
}
|
||||
if !(&Service{Conditions: []*Condition{&bodyCondition}}).needsToReadBody() {
|
||||
t.Error("expected true, got false")
|
||||
}
|
||||
if !(&Service{Conditions: []*Condition{&bodyConditionWithLength}}).needsToReadBody() {
|
||||
t.Error("expected true, got false")
|
||||
}
|
||||
if !(&Service{Conditions: []*Condition{&statusCondition, &bodyCondition}}).needsToReadBody() {
|
||||
t.Error("expected true, got false")
|
||||
}
|
||||
if !(&Service{Conditions: []*Condition{&bodyCondition, &statusCondition}}).needsToReadBody() {
|
||||
t.Error("expected true, got false")
|
||||
}
|
||||
if !(&Service{Conditions: []*Condition{&bodyConditionWithLength, &statusCondition}}).needsToReadBody() {
|
||||
t.Error("expected true, got false")
|
||||
}
|
||||
}
|
||||
|
||||
113
core/uptime.go
113
core/uptime.go
@@ -1,114 +1,45 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// RFC3339WithoutMinutesAndSeconds is the format defined by RFC3339 (see time.RFC3339) but with the minutes
|
||||
// and seconds hardcoded to 0.
|
||||
RFC3339WithoutMinutesAndSeconds = "2006-01-02T15:00:00Z07:00"
|
||||
|
||||
numberOfHoursInTenDays = 10 * 24
|
||||
sevenDays = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// Uptime is the struct that contains the relevant data for calculating the uptime as well as the uptime itself
|
||||
// and some other statistics
|
||||
type Uptime struct {
|
||||
// LastSevenDays is the uptime percentage over the past 7 days
|
||||
LastSevenDays float64 `json:"7d"`
|
||||
LastSevenDays float64 `json:"7d"` // Uptime percentage over the past 7 days
|
||||
LastTwentyFourHours float64 `json:"24h"` // Uptime percentage over the past 24 hours
|
||||
LastHour float64 `json:"1h"` // Uptime percentage over the past hour
|
||||
|
||||
// LastTwentyFourHours is the uptime percentage over the past 24 hours
|
||||
LastTwentyFourHours float64 `json:"24h"`
|
||||
// SuccessfulExecutionsPerHour is a map containing the number of successes (value)
|
||||
// for every hourly unix timestamps (key)
|
||||
// Deprecated
|
||||
SuccessfulExecutionsPerHour map[int64]uint64 `json:"-"`
|
||||
|
||||
// LastHour is the uptime percentage over the past hour
|
||||
LastHour float64 `json:"1h"`
|
||||
// TotalExecutionsPerHour is a map containing the total number of checks (value)
|
||||
// for every hourly unix timestamps (key)
|
||||
// Deprecated
|
||||
TotalExecutionsPerHour map[int64]uint64 `json:"-"`
|
||||
|
||||
successCountPerHour map[string]uint64
|
||||
totalCountPerHour map[string]uint64
|
||||
// HourlyStatistics is a map containing metrics collected (value) for every hourly unix timestamps (key)
|
||||
HourlyStatistics map[int64]*HourlyUptimeStatistics `json:"-"`
|
||||
}
|
||||
|
||||
// HourlyUptimeStatistics is a struct containing all metrics collected over the course of an hour
|
||||
type HourlyUptimeStatistics struct {
|
||||
TotalExecutions uint64 // Total number of checks
|
||||
SuccessfulExecutions uint64 // Number of successful executions
|
||||
TotalExecutionsResponseTime uint64 // Total response time for all executions
|
||||
}
|
||||
|
||||
// NewUptime creates a new Uptime
|
||||
func NewUptime() *Uptime {
|
||||
return &Uptime{
|
||||
successCountPerHour: make(map[string]uint64),
|
||||
totalCountPerHour: make(map[string]uint64),
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessResult processes the result by extracting the relevant from the result and recalculating the uptime
|
||||
// if necessary
|
||||
func (uptime *Uptime) ProcessResult(result *Result) {
|
||||
timestampDateWithHour := result.Timestamp.Format(RFC3339WithoutMinutesAndSeconds)
|
||||
if result.Success {
|
||||
uptime.successCountPerHour[timestampDateWithHour]++
|
||||
}
|
||||
uptime.totalCountPerHour[timestampDateWithHour]++
|
||||
// Clean up only when we're starting to have too many useless keys
|
||||
// Note that this is only triggered when there are more entries than there should be after
|
||||
// 10 days, despite the fact that we are deleting everything that's older than 7 days.
|
||||
// This is to prevent re-iterating on every `ProcessResult` as soon as the uptime has been logged for 7 days.
|
||||
if len(uptime.totalCountPerHour) > numberOfHoursInTenDays {
|
||||
sevenDaysAgo := time.Now().Add(-(sevenDays + time.Hour))
|
||||
for k := range uptime.totalCountPerHour {
|
||||
dateWithHour, err := time.Parse(time.RFC3339, k)
|
||||
if err != nil {
|
||||
// This shouldn't happen, but we'll log it in case it does happen
|
||||
log.Println("[uptime][ProcessResult] Failed to parse programmatically generated timestamp:", err.Error())
|
||||
continue
|
||||
}
|
||||
if sevenDaysAgo.Unix() > dateWithHour.Unix() {
|
||||
delete(uptime.totalCountPerHour, k)
|
||||
delete(uptime.successCountPerHour, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
if result.Success {
|
||||
// Recalculate uptime if at least one of the 1h, 24h or 7d uptime are not 100%
|
||||
// If they're all 100%, then recalculating the uptime would be useless unless
|
||||
// the result added was a failure (!result.Success)
|
||||
if uptime.LastSevenDays != 1 || uptime.LastTwentyFourHours != 1 || uptime.LastHour != 1 {
|
||||
uptime.recalculate()
|
||||
}
|
||||
} else {
|
||||
// Recalculate uptime if at least one of the 1h, 24h or 7d uptime are not 0%
|
||||
// If they're all 0%, then recalculating the uptime would be useless unless
|
||||
// the result added was a success (result.Success)
|
||||
if uptime.LastSevenDays != 0 || uptime.LastTwentyFourHours != 0 || uptime.LastHour != 0 {
|
||||
uptime.recalculate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (uptime *Uptime) recalculate() {
|
||||
uptimeBrackets := make(map[string]uint64)
|
||||
now := time.Now()
|
||||
// The oldest uptime bracket starts 7 days ago, so we'll start from there
|
||||
timestamp := now.Add(-sevenDays)
|
||||
for now.Sub(timestamp) >= 0 {
|
||||
timestampDateWithHour := timestamp.Format(RFC3339WithoutMinutesAndSeconds)
|
||||
successCountForTimestamp := uptime.successCountPerHour[timestampDateWithHour]
|
||||
totalCountForTimestamp := uptime.totalCountPerHour[timestampDateWithHour]
|
||||
uptimeBrackets["7d_success"] += successCountForTimestamp
|
||||
uptimeBrackets["7d_total"] += totalCountForTimestamp
|
||||
if now.Sub(timestamp) <= 24*time.Hour {
|
||||
uptimeBrackets["24h_success"] += successCountForTimestamp
|
||||
uptimeBrackets["24h_total"] += totalCountForTimestamp
|
||||
}
|
||||
if now.Sub(timestamp) <= time.Hour {
|
||||
uptimeBrackets["1h_success"] += successCountForTimestamp
|
||||
uptimeBrackets["1h_total"] += totalCountForTimestamp
|
||||
}
|
||||
timestamp = timestamp.Add(time.Hour)
|
||||
}
|
||||
if uptimeBrackets["7d_total"] > 0 {
|
||||
uptime.LastSevenDays = float64(uptimeBrackets["7d_success"]) / float64(uptimeBrackets["7d_total"])
|
||||
}
|
||||
if uptimeBrackets["24h_total"] > 0 {
|
||||
uptime.LastTwentyFourHours = float64(uptimeBrackets["24h_success"]) / float64(uptimeBrackets["24h_total"])
|
||||
}
|
||||
if uptimeBrackets["1h_total"] > 0 {
|
||||
uptime.LastHour = float64(uptimeBrackets["1h_success"]) / float64(uptimeBrackets["1h_total"])
|
||||
HourlyStatistics: make(map[int64]*HourlyUptimeStatistics),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestUptime_ProcessResult(t *testing.T) {
|
||||
service := &Service{Name: "name", Group: "group"}
|
||||
serviceStatus := NewServiceStatus(service)
|
||||
uptime := serviceStatus.Uptime
|
||||
|
||||
checkUptimes(t, serviceStatus, 0.00, 0.00, 0.00)
|
||||
|
||||
uptime.ProcessResult(&Result{Timestamp: time.Now().Add(-7 * 24 * time.Hour), Success: true})
|
||||
checkUptimes(t, serviceStatus, 1.00, 0.00, 0.00)
|
||||
|
||||
uptime.ProcessResult(&Result{Timestamp: time.Now().Add(-6 * 24 * time.Hour), Success: false})
|
||||
checkUptimes(t, serviceStatus, 0.50, 0.00, 0.00)
|
||||
|
||||
uptime.ProcessResult(&Result{Timestamp: time.Now().Add(-8 * 24 * time.Hour), Success: true})
|
||||
checkUptimes(t, serviceStatus, 0.50, 0.00, 0.00)
|
||||
|
||||
uptime.ProcessResult(&Result{Timestamp: time.Now().Add(-24 * time.Hour), Success: true})
|
||||
uptime.ProcessResult(&Result{Timestamp: time.Now().Add(-12 * time.Hour), Success: true})
|
||||
checkUptimes(t, serviceStatus, 0.75, 1.00, 0.00)
|
||||
|
||||
uptime.ProcessResult(&Result{Timestamp: time.Now().Add(-1 * time.Hour), Success: true})
|
||||
uptime.ProcessResult(&Result{Timestamp: time.Now().Add(-30 * time.Minute), Success: false})
|
||||
uptime.ProcessResult(&Result{Timestamp: time.Now().Add(-15 * time.Minute), Success: false})
|
||||
uptime.ProcessResult(&Result{Timestamp: time.Now().Add(-10 * time.Minute), Success: false})
|
||||
checkUptimes(t, serviceStatus, 0.50, 0.50, 0.25)
|
||||
|
||||
uptime.ProcessResult(&Result{Timestamp: time.Now().Add(-120 * time.Hour), Success: true})
|
||||
uptime.ProcessResult(&Result{Timestamp: time.Now().Add(-119 * time.Hour), Success: true})
|
||||
uptime.ProcessResult(&Result{Timestamp: time.Now().Add(-118 * time.Hour), Success: true})
|
||||
uptime.ProcessResult(&Result{Timestamp: time.Now().Add(-117 * time.Hour), Success: true})
|
||||
uptime.ProcessResult(&Result{Timestamp: time.Now().Add(-10 * time.Hour), Success: true})
|
||||
uptime.ProcessResult(&Result{Timestamp: time.Now().Add(-8 * time.Hour), Success: true})
|
||||
uptime.ProcessResult(&Result{Timestamp: time.Now().Add(-30 * time.Minute), Success: true})
|
||||
uptime.ProcessResult(&Result{Timestamp: time.Now().Add(-25 * time.Minute), Success: true})
|
||||
checkUptimes(t, serviceStatus, 0.75, 0.70, 0.50)
|
||||
}
|
||||
|
||||
func TestServiceStatus_AddResultUptimeIsCleaningUpAfterItself(t *testing.T) {
|
||||
service := &Service{Name: "name", Group: "group"}
|
||||
serviceStatus := NewServiceStatus(service)
|
||||
now := time.Now()
|
||||
now = time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())
|
||||
// Start 12 days ago
|
||||
timestamp := now.Add(-12 * 24 * time.Hour)
|
||||
for timestamp.Unix() <= now.Unix() {
|
||||
serviceStatus.AddResult(&Result{Timestamp: timestamp, Success: true})
|
||||
if len(serviceStatus.Uptime.successCountPerHour) > numberOfHoursInTenDays {
|
||||
t.Errorf("At no point in time should there be more than %d entries in serviceStatus.successCountPerHour", numberOfHoursInTenDays)
|
||||
}
|
||||
//fmt.Printf("timestamp=%s; uptimeDuringLastHour=%f; timeAgo=%s\n", timestamp.Format(time.RFC3339), serviceStatus.UptimeDuringLastHour, time.Since(timestamp))
|
||||
if now.Sub(timestamp) > time.Hour && serviceStatus.Uptime.LastHour != 0 {
|
||||
t.Error("most recent timestamp > 1h ago, expected serviceStatus.Uptime.LastHour to be 0, got", serviceStatus.Uptime.LastHour)
|
||||
}
|
||||
if now.Sub(timestamp) < time.Hour && serviceStatus.Uptime.LastHour == 0 {
|
||||
t.Error("most recent timestamp < 1h ago, expected serviceStatus.Uptime.LastHour to NOT be 0, got", serviceStatus.Uptime.LastHour)
|
||||
}
|
||||
// Simulate service with an interval of 1 minute
|
||||
timestamp = timestamp.Add(3 * time.Minute)
|
||||
}
|
||||
}
|
||||
|
||||
func checkUptimes(t *testing.T, status *ServiceStatus, expectedUptimeDuringLastSevenDays, expectedUptimeDuringLastTwentyFourHours, expectedUptimeDuringLastHour float64) {
|
||||
if status.Uptime.LastSevenDays != expectedUptimeDuringLastSevenDays {
|
||||
t.Errorf("expected status.Uptime.LastSevenDays to be %f, got %f", expectedUptimeDuringLastHour, status.Uptime.LastSevenDays)
|
||||
}
|
||||
if status.Uptime.LastTwentyFourHours != expectedUptimeDuringLastTwentyFourHours {
|
||||
t.Errorf("expected status.Uptime.LastTwentyFourHours to be %f, got %f", expectedUptimeDuringLastTwentyFourHours, status.Uptime.LastTwentyFourHours)
|
||||
}
|
||||
if status.Uptime.LastHour != expectedUptimeDuringLastHour {
|
||||
t.Errorf("expected status.Uptime.LastHour to be %f, got %f", expectedUptimeDuringLastHour, status.Uptime.LastHour)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
version: "3.8"
|
||||
services:
|
||||
gatus:
|
||||
image: twinproduction/gatus:latest
|
||||
ports:
|
||||
- 8080:8080
|
||||
volumes:
|
||||
- ./config.yaml:/config/config.yaml
|
||||
42
examples/docker-compose-sqlite-storage/config.yaml
Normal file
42
examples/docker-compose-sqlite-storage/config.yaml
Normal file
@@ -0,0 +1,42 @@
|
||||
storage:
|
||||
type: sqlite
|
||||
file: /data/data.db
|
||||
|
||||
services:
|
||||
- name: back-end
|
||||
group: core
|
||||
url: "https://example.org/"
|
||||
interval: 5m
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[CERTIFICATE_EXPIRATION] > 48h"
|
||||
|
||||
- name: monitoring
|
||||
group: internal
|
||||
url: "https://example.org/"
|
||||
interval: 5m
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
- name: nas
|
||||
group: internal
|
||||
url: "https://example.org/"
|
||||
interval: 5m
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
- name: example-dns-query
|
||||
url: "8.8.8.8" # Address of the DNS server to use
|
||||
interval: 5m
|
||||
dns:
|
||||
query-name: "example.com"
|
||||
query-type: "A"
|
||||
conditions:
|
||||
- "[BODY] == 93.184.216.34"
|
||||
- "[DNS_RCODE] == NOERROR"
|
||||
|
||||
- name: icmp-ping
|
||||
url: "icmp://example.org"
|
||||
interval: 1m
|
||||
conditions:
|
||||
- "[CONNECTED] == true"
|
||||
@@ -0,0 +1,9 @@
|
||||
version: "3.8"
|
||||
services:
|
||||
gatus:
|
||||
image: twinproduction/gatus:latest
|
||||
ports:
|
||||
- 8080:8080
|
||||
volumes:
|
||||
- ./config.yaml:/config/config.yaml
|
||||
- ./data:/data/
|
||||
8
examples/docker-compose/docker-compose.yml
Normal file
8
examples/docker-compose/docker-compose.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
version: "3.8"
|
||||
services:
|
||||
gatus:
|
||||
image: twinproduction/gatus:latest
|
||||
ports:
|
||||
- 8080:8080
|
||||
volumes:
|
||||
- ./config.yaml:/config/config.yaml
|
||||
@@ -81,11 +81,11 @@ spec:
|
||||
protocol: TCP
|
||||
resources:
|
||||
limits:
|
||||
cpu: 200m
|
||||
memory: 50M
|
||||
cpu: 250m
|
||||
memory: 100M
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 20M
|
||||
memory: 30M
|
||||
volumeMounts:
|
||||
- mountPath: /config
|
||||
name: gatus-config
|
||||
@@ -59,11 +59,11 @@ spec:
|
||||
protocol: TCP
|
||||
resources:
|
||||
limits:
|
||||
cpu: 200m
|
||||
memory: 50M
|
||||
cpu: 250m
|
||||
memory: 100M
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 20M
|
||||
memory: 30M
|
||||
volumeMounts:
|
||||
- mountPath: /config
|
||||
name: gatus-config
|
||||
7
go.mod
7
go.mod
@@ -1,10 +1,11 @@
|
||||
module github.com/TwinProduction/gatus
|
||||
|
||||
go 1.15
|
||||
go 1.16
|
||||
|
||||
require (
|
||||
cloud.google.com/go v0.74.0 // indirect
|
||||
github.com/TwinProduction/gocache v1.1.0
|
||||
github.com/TwinProduction/gocache v1.2.3
|
||||
github.com/TwinProduction/health v1.0.0
|
||||
github.com/go-ping/ping v0.0.0-20201115131931-3300c582a663
|
||||
github.com/google/gofuzz v1.2.0 // indirect
|
||||
github.com/gorilla/mux v1.8.0
|
||||
@@ -13,13 +14,13 @@ require (
|
||||
github.com/prometheus/client_golang v1.9.0
|
||||
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad // indirect
|
||||
golang.org/x/net v0.0.0-20201224014010-6772e930b67b // indirect
|
||||
golang.org/x/sys v0.0.0-20201223074533-0d417f636930 // indirect
|
||||
golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf // indirect
|
||||
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
k8s.io/api v0.18.14
|
||||
k8s.io/apimachinery v0.18.14
|
||||
k8s.io/client-go v0.18.14
|
||||
modernc.org/sqlite v1.11.2
|
||||
)
|
||||
|
||||
replace k8s.io/client-go => k8s.io/client-go v0.18.14
|
||||
|
||||
90
go.sum
90
go.sum
@@ -1,5 +1,4 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.34.0 h1:eOI3/cP2VTU6uZLDYAoic+eyzzB9YyGmJ7eIjl8rOPg=
|
||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
|
||||
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
|
||||
@@ -50,8 +49,10 @@ github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbt
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
||||
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
|
||||
github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
|
||||
github.com/TwinProduction/gocache v1.1.0 h1:mibBUyccd8kGHlm5dXhTMDOvWBK4mjNqGyOOkG8mib8=
|
||||
github.com/TwinProduction/gocache v1.1.0/go.mod h1:+qH57V/K4oAcX9C7CvgJTwUX4lzfIUXQC/6XaRSOS1Y=
|
||||
github.com/TwinProduction/gocache v1.2.3 h1:4wFNih4CemUX+A99Gk/EsaU0SXSNZV42Ve77v7/7ToY=
|
||||
github.com/TwinProduction/gocache v1.2.3/go.mod h1:Yj2daITit8TTBgiOpc26XCDSbg9xcFskUilHj9u3Mh8=
|
||||
github.com/TwinProduction/health v1.0.0 h1:TVyYTAORQQZ8LaptX8jCHZRCGCAO6e+oJx19BUIzQYY=
|
||||
github.com/TwinProduction/health v1.0.0/go.mod h1:ys4mYKUeEfYrWmkm60xLtPjTuLIEDQNBZaTZvenLG1c=
|
||||
github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g=
|
||||
github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=
|
||||
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
@@ -73,8 +74,6 @@ github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+Ce
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
|
||||
github.com/boltdb/bolt v1.3.1 h1:JQmyP4ZBrce+ZQu0dY660FMfatumYDLun9hBCUVIkF4=
|
||||
github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps=
|
||||
github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ=
|
||||
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
@@ -100,6 +99,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||
github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM=
|
||||
github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
|
||||
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
|
||||
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
|
||||
@@ -142,7 +143,6 @@ github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/me
|
||||
github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/gogo/protobuf v1.2.1 h1:/s5zKNz0uPFCZ5hddgPdo2TK2TVrUNMn0OOX8/aZMTE=
|
||||
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
|
||||
github.com/gogo/protobuf v1.3.1 h1:DqDEcV5aeaTmdFBePNpYsp3FlcVH/2ISVVM9Qf8PSls=
|
||||
github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
|
||||
@@ -161,7 +161,6 @@ github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71
|
||||
github.com/golang/protobuf v0.0.0-20161109072736-4bd1920723d7/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
@@ -172,7 +171,6 @@ github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrU
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.4.3 h1:JjCZWpVbqXDqFVmTfYWEVTMIYrL/NPdPSCHPJ0T/raM=
|
||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
@@ -184,13 +182,12 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.0 h1:/QaMHBdZ26BB3SSst0Iwl10Epc+xhTquomWX0oZEB6w=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.4 h1:L8R9j+yAqZuZjsqh/z+F1NCffTKKLShY6zXTItVIZ8M=
|
||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
@@ -220,7 +217,6 @@ github.com/gophercloud/gophercloud v0.1.0/go.mod h1:vxM41WHh5uqHVBMZHzuwNOHh8XEo
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
|
||||
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||
github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw=
|
||||
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
||||
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||
@@ -271,6 +267,8 @@ github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/X
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
|
||||
github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
@@ -289,7 +287,11 @@ github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN
|
||||
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
|
||||
github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg=
|
||||
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
|
||||
@@ -362,21 +364,18 @@ github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXP
|
||||
github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=
|
||||
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
|
||||
github.com/prometheus/client_golang v1.3.0/go.mod h1:hJaj2vgQTGQmVCsAACORcieXFeDPbaTKGT+JTgUa3og=
|
||||
github.com/prometheus/client_golang v1.7.1 h1:NTGy1Ja9pByO+xAeH/qiWnLrKtr3hJPNjaVUwnjpdpA=
|
||||
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
|
||||
github.com/prometheus/client_golang v1.9.0 h1:Rrch9mh17XcxvEu9D9DEpb4isxjGBtcevQjKvxPRQIU=
|
||||
github.com/prometheus/client_golang v1.9.0/go.mod h1:FqZLKOZnGdFAhOK4nqGHa7D66IdsO+O441Eve7ptJDU=
|
||||
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
|
||||
github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
|
||||
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4 h1:gQz4mCbXsO+nc9n1hCxHcGA3Zx3Eo+UHZoInFGUIXNM=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/client_model v0.1.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M=
|
||||
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
|
||||
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
|
||||
github.com/prometheus/common v0.7.0 h1:L+1lyG48J1zAQXA3RBX/nG/B3gjlHq0zTt2tlbJLyCY=
|
||||
github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt26CguLLsqA=
|
||||
github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
|
||||
github.com/prometheus/common v0.15.0 h1:4fgOnadei3EZvgRwxJ7RMpG1k1pOZth5Pc13tyspaKM=
|
||||
@@ -385,11 +384,12 @@ github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R
|
||||
github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
|
||||
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
|
||||
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
|
||||
github.com/prometheus/procfs v0.1.3 h1:F0+tqvhOksq22sc6iCHF5WGlWjdwj92p0udFh1VFBS8=
|
||||
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
|
||||
github.com/prometheus/procfs v0.2.0 h1:wH4vA7pcjKuZzjF7lM8awk4fnuJO6idemZXoKnULUx4=
|
||||
github.com/prometheus/procfs v0.2.0/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
|
||||
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
@@ -407,7 +407,6 @@ github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJ
|
||||
github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
|
||||
github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
|
||||
github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
github.com/spf13/pflag v1.0.1 h1:aCvUg6QPl3ibpQUxyLkrEkCHtPqYJL4x9AuhqVqFis4=
|
||||
github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
@@ -418,7 +417,6 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
||||
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=
|
||||
@@ -432,6 +430,8 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
||||
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.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
|
||||
go.etcd.io/bbolt v1.3.5 h1:XAzx9gjCb0Rxj7EoqcClPD1d5ZBxZJk0jbuoPHenBt0=
|
||||
go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
|
||||
go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg=
|
||||
go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
|
||||
go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
|
||||
@@ -457,7 +457,6 @@ golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8U
|
||||
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200220183623-bac4c82f6975/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad h1:DN0cp81fZ3njFcrLCytUHRSUkqBjfTo4Tx9RJTWs0EY=
|
||||
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||
@@ -479,7 +478,6 @@ golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTk
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
|
||||
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
@@ -493,6 +491,7 @@ golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzB
|
||||
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.0 h1:8pl+sMODzuvGJkmj2W4kZihvVb5mKm8pB/X44PIQHv8=
|
||||
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20170114055629-f2499483f923/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -529,7 +528,6 @@ golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/
|
||||
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 h1:vGXIOMxbNfDTk/aXCmfdLgkrSV+Z2tcbze+pEc3v5W4=
|
||||
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=
|
||||
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
@@ -540,7 +538,6 @@ golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v
|
||||
golang.org/x/net v0.0.0-20201224014010-6772e930b67b h1:iFwSg7t5GZmB/Q5TjiEAsdoLDrdJRC1RiF2WhuV29Qw=
|
||||
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421 h1:Wo7BWFiOk0QRFMLYMqJGFMd9CgUAcGx7V+qEg/h5IBI=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
@@ -554,7 +551,6 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e h1:vcxGaoTs7kV8m5Np9uUNQin4BrLOthgV7252N8V+FwY=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -592,6 +588,7 @@ golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
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=
|
||||
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -608,15 +605,14 @@ golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
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-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f h1:+Nyd8tzPX9R7BWHguqsrbFdRx3WQ/1ib8I44HXV5yTA=
|
||||
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=
|
||||
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201223074533-0d417f636930 h1:vRgIt+nup/B/BwIS0g2oC0haq0iqbV3ZA+u6+0TlNCo=
|
||||
golang.org/x/sys v0.0.0-20201223074533-0d417f636930/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c h1:VwygUrnw9jn88c4u8GD3rZQbqrP/tgas88tPUbBxQrk=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf h1:MZ2shdL+ZM/XzY3ZGOnh4Nlpnxz5GSOhOmtHo3iPU6M=
|
||||
golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
@@ -624,7 +620,6 @@ golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fq
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.4 h1:0YWbFKbhXG/wIiuHDSKpS0Iy7FSA+u45VtBMfQcFTTc=
|
||||
@@ -632,7 +627,6 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0 h1:/5xXl8Y5W96D+TtHSlonuFqGHIWVuyCkGJLwGh9JJFs=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 h1:Hir2P/De0WpUhtrKGGjvSb2YxUgyZ7EFOSLIcSSpiwE=
|
||||
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
@@ -666,7 +660,6 @@ golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtn
|
||||
golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114 h1:DnSr2mCsxyCE6ZgIkmcWUQY2R5cH/6wL7eIxEmQOMSE=
|
||||
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
@@ -688,11 +681,12 @@ golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc
|
||||
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||
golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
|
||||
golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2 h1:vEtypaVub6UvKkiXZ2xx9QIvp9TL7sI7xp7vdi2kezA=
|
||||
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -717,7 +711,6 @@ google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ
|
||||
google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
|
||||
@@ -785,14 +778,12 @@ google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQ
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.0 h1:4MY060fB1DLGMB/7MBTLnwQUY6+F09GEiz6SsrNqyzM=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
|
||||
google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4c=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
@@ -808,12 +799,10 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWD
|
||||
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
||||
gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
||||
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=
|
||||
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
@@ -839,13 +828,42 @@ k8s.io/klog v1.0.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I=
|
||||
k8s.io/kube-openapi v0.0.0-20200410145947-61e04a5be9a6/go.mod h1:GRQhZsXIAJ1xR0C9bd8UpWHZ5plfAS9fzPjJuQ6JL3E=
|
||||
k8s.io/utils v0.0.0-20200324210504-a9aa75ae1b89 h1:d4vVOjXm687F1iLSP2q3lyPPuyvTUt3aVoBpi2DqRsU=
|
||||
k8s.io/utils v0.0.0-20200324210504-a9aa75ae1b89/go.mod h1:sZAwmy6armz5eXlNoLmJcl4F1QuKu7sr+mFQ0byX7Ew=
|
||||
lukechampine.com/uint128 v1.1.1 h1:pnxCASz787iMf+02ssImqk6OLt+Z5QHMoZyUXR4z6JU=
|
||||
lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||
modernc.org/cc/v3 v3.33.6 h1:r63dgSzVzRxUpAJFPQWHy1QeZeY1ydNENUDaBx1GqYc=
|
||||
modernc.org/cc/v3 v3.33.6/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
|
||||
modernc.org/ccgo/v3 v3.9.5 h1:dEuUSf8WN51rDkprFuAqjfchKEzN0WttP/Py3enBwjk=
|
||||
modernc.org/ccgo/v3 v3.9.5/go.mod h1:umuo2EP2oDSBnD3ckjaVUXMrmeAw8C8OSICVa0iFf60=
|
||||
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
|
||||
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
|
||||
modernc.org/libc v1.7.13-0.20210308123627-12f642a52bb8/go.mod h1:U1eq8YWr/Kc1RWCMFUWEdkTg8OTcfLw2kY8EDwl039w=
|
||||
modernc.org/libc v1.9.8/go.mod h1:U1eq8YWr/Kc1RWCMFUWEdkTg8OTcfLw2kY8EDwl039w=
|
||||
modernc.org/libc v1.9.11 h1:QUxZMs48Ahg2F7SN41aERvMfGLY2HU/ADnB9DC4Yts8=
|
||||
modernc.org/libc v1.9.11/go.mod h1:NyF3tsA5ArIjJ83XB0JlqhjTabTCHm9aX4XMPHyQn0Q=
|
||||
modernc.org/mathutil v1.1.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/mathutil v1.4.0 h1:GCjoRaBew8ECCKINQA2nYjzvufFW9YiEuuB+rQ9bn2E=
|
||||
modernc.org/mathutil v1.4.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.0.4 h1:utMBrFcpnQDdNsmM6asmyH/FM9TqLPS7XF7otpJmrwM=
|
||||
modernc.org/memory v1.0.4/go.mod h1:nV2OApxradM3/OVbs2/0OsP6nPfakXpi50C7dcoHXlc=
|
||||
modernc.org/opt v0.1.1 h1:/0RX92k9vwVeDXj+Xn23DKp2VJubL7k8qNffND6qn3A=
|
||||
modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sqlite v1.11.2 h1:ShWQpeD3ag/bmx6TqidBlIWonWmQaSQKls3aenCbt+w=
|
||||
modernc.org/sqlite v1.11.2/go.mod h1:+mhs/P1ONd+6G7hcAs6irwDi/bjTQ7nLW6LHRBsEa3A=
|
||||
modernc.org/strutil v1.1.1 h1:xv+J1BXY3Opl2ALrBwyfEikFAj8pmqcpnfmuwUwcozs=
|
||||
modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw=
|
||||
modernc.org/tcl v1.5.5 h1:N03RwthgTR/l/eQvz3UjfYnvVVj1G2sZqzFGfoD4HE4=
|
||||
modernc.org/tcl v1.5.5/go.mod h1:ADkaTUuwukkrlhqwERyq0SM8OvyXo7+TjFz7yAF56EI=
|
||||
modernc.org/token v1.0.0 h1:a0jaWiNMDhDUtqOj09wvjWWAqd3q7WpBulmL9H2egsk=
|
||||
modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
modernc.org/z v1.0.1 h1:WyIDpEpAIx4Hel6q/Pcgj/VhaQV5XPJ2I6ryIYbjnpc=
|
||||
modernc.org/z v1.0.1/go.mod h1:8/SRk5C/HgiQWCgXdfpb+1RvhORdkz5sw72d3jjtyqA=
|
||||
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
||||
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
|
||||
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
|
||||
sigs.k8s.io/structured-merge-diff/v3 v3.0.0-20200116222232-67a7b8c61874/go.mod h1:PlARxl6Hbt/+BC80dRLi1qAmnMqwqDg62YvvVkZjemw=
|
||||
sigs.k8s.io/structured-merge-diff/v3 v3.0.0 h1:dOmIZBMfhcHS09XZkMyUgkq5trg3/jRyJYFZUiaOp8E=
|
||||
sigs.k8s.io/structured-merge-diff/v3 v3.0.0/go.mod h1:PlARxl6Hbt/+BC80dRLi1qAmnMqwqDg62YvvVkZjemw=
|
||||
sigs.k8s.io/yaml v1.1.0 h1:4A07+ZFc2wgJwo8YNlQpr1rVlgUDlxXHhPJciaPY5gs=
|
||||
sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o=
|
||||
sigs.k8s.io/yaml v1.2.0 h1:kr/MCeFWJWTwyaHoR9c8EjH9OumOmoF9YGiZd7lFm/Q=
|
||||
sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc=
|
||||
|
||||
@@ -25,6 +25,9 @@ func walk(path string, object interface{}) (string, int, error) {
|
||||
case map[string]interface{}:
|
||||
return walk(strings.Replace(path, fmt.Sprintf("%s.", currentKey), "", 1), value)
|
||||
case string:
|
||||
if len(keys) > 1 {
|
||||
return "", 0, fmt.Errorf("couldn't walk through '%s', because '%s' was a string instead of an object", keys[1], currentKey)
|
||||
}
|
||||
return value, len(value), nil
|
||||
case []interface{}:
|
||||
return fmt.Sprintf("%v", value), len(value), nil
|
||||
@@ -41,7 +44,7 @@ func extractValue(currentKey string, value interface{}) interface{} {
|
||||
tmp := strings.SplitN(currentKey, "[", 3)
|
||||
arrayIndex, err := strconv.Atoi(strings.Replace(tmp[1], "]", "", 1))
|
||||
if err != nil {
|
||||
return value
|
||||
return nil
|
||||
}
|
||||
currentKey := tmp[0]
|
||||
// if currentKey contains only an index (i.e. [0] or 0)
|
||||
|
||||
@@ -1,152 +1,148 @@
|
||||
package jsonpath
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEval(t *testing.T) {
|
||||
path := "simple"
|
||||
data := `{"simple": "value"}`
|
||||
|
||||
expectedOutput := "value"
|
||||
|
||||
output, outputLength, err := Eval(path, []byte(data))
|
||||
if err != nil {
|
||||
t.Error("Didn't expect any error, but got", err)
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
Data string
|
||||
ExpectedOutput string
|
||||
ExpectedOutputLength int
|
||||
ExpectedError bool
|
||||
}
|
||||
if outputLength != len(expectedOutput) {
|
||||
t.Errorf("Expected output length to be %v, but was %v", len(expectedOutput), outputLength)
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "simple",
|
||||
Path: "key",
|
||||
Data: `{"key": "value"}`,
|
||||
ExpectedOutput: "value",
|
||||
ExpectedOutputLength: 5,
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "simple-with-invalid-data",
|
||||
Path: "key",
|
||||
Data: "invalid data",
|
||||
ExpectedOutput: "",
|
||||
ExpectedOutputLength: 0,
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "invalid-path",
|
||||
Path: "key",
|
||||
Data: `{}`,
|
||||
ExpectedOutput: "",
|
||||
ExpectedOutputLength: 0,
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "long-simple-walk",
|
||||
Path: "long.simple.walk",
|
||||
Data: `{"long": {"simple": {"walk": "value"}}}`,
|
||||
ExpectedOutput: "value",
|
||||
ExpectedOutputLength: 5,
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "array-of-maps",
|
||||
Path: "ids[1].id",
|
||||
Data: `{"ids": [{"id": 1}, {"id": 2}]}`,
|
||||
ExpectedOutput: "2",
|
||||
ExpectedOutputLength: 1,
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "array-of-values",
|
||||
Path: "ids[0]",
|
||||
Data: `{"ids": [1, 2]}`,
|
||||
ExpectedOutput: "1",
|
||||
ExpectedOutputLength: 1,
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "array-of-values-and-invalid-index",
|
||||
Path: "ids[wat]",
|
||||
Data: `{"ids": [1, 2]}`,
|
||||
ExpectedOutput: "",
|
||||
ExpectedOutputLength: 0,
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "array-of-values-at-root",
|
||||
Path: "[1]",
|
||||
Data: `[1, 2]`,
|
||||
ExpectedOutput: "2",
|
||||
ExpectedOutputLength: 1,
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "array-of-maps-at-root",
|
||||
Path: "[0].id",
|
||||
Data: `[{"id": 1}, {"id": 2}]`,
|
||||
ExpectedOutput: "1",
|
||||
ExpectedOutputLength: 1,
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "array-of-maps-at-root-and-invalid-index",
|
||||
Path: "[5].id",
|
||||
Data: `[{"id": 1}, {"id": 2}]`,
|
||||
ExpectedOutput: "",
|
||||
ExpectedOutputLength: 0,
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "long-walk-and-array",
|
||||
Path: "data.ids[0].id",
|
||||
Data: `{"data": {"ids": [{"id": 1}, {"id": 2}, {"id": 3}]}}`,
|
||||
ExpectedOutput: "1",
|
||||
ExpectedOutputLength: 1,
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "nested-array",
|
||||
Path: "[3][2]",
|
||||
Data: `[[1, 2], [3, 4], [], [5, 6, 7]]`,
|
||||
ExpectedOutput: "7",
|
||||
ExpectedOutputLength: 1,
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "map-of-nested-arrays",
|
||||
Path: "data[1][1]",
|
||||
Data: `{"data": [["a", "b", "c"], ["d", "eeeee", "f"]]}`,
|
||||
ExpectedOutput: "eeeee",
|
||||
ExpectedOutputLength: 5,
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "partially-invalid-path-issue122",
|
||||
Path: "data.name.invalid",
|
||||
Data: `{"data": {"name": "john"}}`,
|
||||
ExpectedOutput: "",
|
||||
ExpectedOutputLength: 0,
|
||||
ExpectedError: true,
|
||||
},
|
||||
}
|
||||
if output != expectedOutput {
|
||||
t.Errorf("Expected output to be %v, but was %v", expectedOutput, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalWithLongSimpleWalk(t *testing.T) {
|
||||
path := "long.simple.walk"
|
||||
data := `{"long": {"simple": {"walk": "value"}}}`
|
||||
|
||||
expectedOutput := "value"
|
||||
|
||||
output, _, err := Eval(path, []byte(data))
|
||||
if err != nil {
|
||||
t.Error("Didn't expect any error, but got", err)
|
||||
}
|
||||
if output != expectedOutput {
|
||||
t.Errorf("Expected output to be %v, but was %v", expectedOutput, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalWithArrayOfMaps(t *testing.T) {
|
||||
path := "ids[1].id"
|
||||
data := `{"ids": [{"id": 1}, {"id": 2}]}`
|
||||
|
||||
expectedOutput := "2"
|
||||
|
||||
output, _, err := Eval(path, []byte(data))
|
||||
if err != nil {
|
||||
t.Error("Didn't expect any error, but got", err)
|
||||
}
|
||||
|
||||
if output != expectedOutput {
|
||||
t.Errorf("Expected output to be %v, but was %v", expectedOutput, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalWithArrayOfValues(t *testing.T) {
|
||||
path := "ids[0]"
|
||||
data := `{"ids": [1, 2]}`
|
||||
|
||||
expectedOutput := "1"
|
||||
|
||||
output, _, err := Eval(path, []byte(data))
|
||||
if err != nil {
|
||||
t.Error("Didn't expect any error, but got", err)
|
||||
}
|
||||
if output != expectedOutput {
|
||||
t.Errorf("Expected output to be %v, but was %v", expectedOutput, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalWithRootArrayOfValues(t *testing.T) {
|
||||
path := "[1]"
|
||||
data := `[1, 2]`
|
||||
|
||||
expectedOutput := "2"
|
||||
|
||||
output, _, err := Eval(path, []byte(data))
|
||||
if err != nil {
|
||||
t.Error("Didn't expect any error, but got", err)
|
||||
}
|
||||
if output != expectedOutput {
|
||||
t.Errorf("Expected output to be %v, but was %v", expectedOutput, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalWithRootArrayOfMaps(t *testing.T) {
|
||||
path := "[0].id"
|
||||
data := `[{"id": 1}, {"id": 2}]`
|
||||
|
||||
expectedOutput := "1"
|
||||
|
||||
output, _, err := Eval(path, []byte(data))
|
||||
if err != nil {
|
||||
t.Error("Didn't expect any error, but got", err)
|
||||
}
|
||||
if output != expectedOutput {
|
||||
t.Errorf("Expected output to be %v, but was %v", expectedOutput, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalWithRootArrayOfMapsUsingInvalidArrayIndex(t *testing.T) {
|
||||
path := "[5].id"
|
||||
data := `[{"id": 1}, {"id": 2}]`
|
||||
|
||||
_, _, err := Eval(path, []byte(data))
|
||||
if err == nil {
|
||||
t.Error("Should've returned an error, but didn't")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalWithLongWalkAndArray(t *testing.T) {
|
||||
path := "data.ids[0].id"
|
||||
data := `{"data": {"ids": [{"id": 1}, {"id": 2}, {"id": 3}]}}`
|
||||
|
||||
expectedOutput := "1"
|
||||
|
||||
output, _, err := Eval(path, []byte(data))
|
||||
if err != nil {
|
||||
t.Error("Didn't expect any error, but got", err)
|
||||
}
|
||||
if output != expectedOutput {
|
||||
t.Errorf("Expected output to be %v, but was %v", expectedOutput, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalWithNestedArray(t *testing.T) {
|
||||
path := "[3][2]"
|
||||
data := `[[1, 2], [3, 4], [], [5, 6, 7]]`
|
||||
|
||||
expectedOutput := "7"
|
||||
|
||||
output, _, err := Eval(path, []byte(data))
|
||||
if err != nil {
|
||||
t.Error("Didn't expect any error, but got", err)
|
||||
}
|
||||
if output != expectedOutput {
|
||||
t.Errorf("Expected output to be %v, but was %v", expectedOutput, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalWithMapOfNestedArray(t *testing.T) {
|
||||
path := "data[1][1]"
|
||||
data := `{"data": [["a", "b", "c"], ["d", "e", "f"]]}`
|
||||
|
||||
expectedOutput := "e"
|
||||
|
||||
output, _, err := Eval(path, []byte(data))
|
||||
if err != nil {
|
||||
t.Error("Didn't expect any error, but got", err)
|
||||
}
|
||||
if output != expectedOutput {
|
||||
t.Errorf("Expected output to be %v, but was %v", expectedOutput, output)
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
output, outputLength, err := Eval(scenario.Path, []byte(scenario.Data))
|
||||
if (err != nil) != scenario.ExpectedError {
|
||||
if scenario.ExpectedError {
|
||||
t.Errorf("Expected error, got '%v'", err)
|
||||
} else {
|
||||
t.Errorf("Expected no error, got '%v'", err)
|
||||
}
|
||||
}
|
||||
if outputLength != scenario.ExpectedOutputLength {
|
||||
t.Errorf("Expected output length to be %v, but was %v", scenario.ExpectedOutputLength, outputLength)
|
||||
}
|
||||
if output != scenario.ExpectedOutput {
|
||||
t.Errorf("Expected output to be %v, but was %v", scenario.ExpectedOutput, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,13 +16,13 @@ import (
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
)
|
||||
|
||||
// KubernetesClientApi is a minimal interface for interacting with Kubernetes
|
||||
// KubernetesClientAPI is a minimal interface for interacting with Kubernetes
|
||||
// Created mostly to make mocking the Kubernetes client easier
|
||||
type KubernetesClientApi interface {
|
||||
type KubernetesClientAPI interface {
|
||||
GetServices(namespace string) ([]v1.Service, error)
|
||||
}
|
||||
|
||||
// KubernetesClient is a working implementation of KubernetesClientApi
|
||||
// KubernetesClient is a working implementation of KubernetesClientAPI
|
||||
type KubernetesClient struct {
|
||||
client *kubernetes.Clientset
|
||||
}
|
||||
@@ -44,7 +44,7 @@ func NewKubernetesClient(client *kubernetes.Clientset) *KubernetesClient {
|
||||
}
|
||||
|
||||
// NewClient creates a Kubernetes client for the given ClusterMode
|
||||
func NewClient(clusterMode ClusterMode) (KubernetesClientApi, error) {
|
||||
func NewClient(clusterMode ClusterMode) (KubernetesClientAPI, error) {
|
||||
var kubeConfig *rest.Config
|
||||
var err error
|
||||
switch clusterMode {
|
||||
|
||||
@@ -5,6 +5,6 @@ import (
|
||||
)
|
||||
|
||||
// GetKubernetesServices return a list of Services from the given namespace
|
||||
func GetKubernetesServices(client KubernetesClientApi, namespace string) ([]v1.Service, error) {
|
||||
func GetKubernetesServices(client KubernetesClientAPI, namespace string) ([]v1.Service, error) {
|
||||
return client.GetServices(namespace)
|
||||
}
|
||||
|
||||
87
main.go
87
main.go
@@ -1,29 +1,88 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/TwinProduction/gatus/config"
|
||||
"github.com/TwinProduction/gatus/controller"
|
||||
"github.com/TwinProduction/gatus/storage"
|
||||
"github.com/TwinProduction/gatus/watchdog"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := loadConfiguration()
|
||||
go watchdog.Monitor(cfg)
|
||||
controller.Handle()
|
||||
}
|
||||
|
||||
func loadConfiguration() *config.Config {
|
||||
var err error
|
||||
customConfigFile := os.Getenv("GATUS_CONFIG_FILE")
|
||||
if len(customConfigFile) > 0 {
|
||||
err = config.Load(customConfigFile)
|
||||
} else {
|
||||
err = config.LoadDefaultConfiguration()
|
||||
}
|
||||
cfg, err := loadConfiguration()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return config.Get()
|
||||
start(cfg)
|
||||
// Wait for termination signal
|
||||
signalChannel := make(chan os.Signal, 1)
|
||||
done := make(chan bool, 1)
|
||||
signal.Notify(signalChannel, os.Interrupt, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-signalChannel
|
||||
log.Println("Received termination signal, attempting to gracefully shut down")
|
||||
stop()
|
||||
save()
|
||||
done <- true
|
||||
}()
|
||||
<-done
|
||||
log.Println("Shutting down")
|
||||
}
|
||||
|
||||
func start(cfg *config.Config) {
|
||||
go controller.Handle(cfg.Security, cfg.Web, cfg.Metrics)
|
||||
watchdog.Monitor(cfg)
|
||||
go listenToConfigurationFileChanges(cfg)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
watchdog.Shutdown()
|
||||
controller.Shutdown()
|
||||
}
|
||||
|
||||
func save() {
|
||||
err := storage.Get().Save()
|
||||
if err != nil {
|
||||
log.Println("Failed to save storage provider:", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfiguration() (cfg *config.Config, err error) {
|
||||
customConfigFile := os.Getenv("GATUS_CONFIG_FILE")
|
||||
if len(customConfigFile) > 0 {
|
||||
cfg, err = config.Load(customConfigFile)
|
||||
} else {
|
||||
cfg, err = config.LoadDefaultConfiguration()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func listenToConfigurationFileChanges(cfg *config.Config) {
|
||||
for {
|
||||
time.Sleep(30 * time.Second)
|
||||
if cfg.HasLoadedConfigurationFileBeenModified() {
|
||||
log.Println("[main][listenToConfigurationFileChanges] Configuration file has been modified")
|
||||
save()
|
||||
updatedConfig, err := loadConfiguration()
|
||||
if err != nil {
|
||||
if cfg.SkipInvalidConfigUpdate {
|
||||
log.Println("[main][listenToConfigurationFileChanges] Failed to load new configuration:", err.Error())
|
||||
log.Println("[main][listenToConfigurationFileChanges] The configuration file was updated, but it is not valid. The old configuration will continue being used.")
|
||||
// Update the last file modification time to avoid trying to process the same invalid configuration again
|
||||
cfg.UpdateLastFileModTime()
|
||||
continue
|
||||
} else {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
stop()
|
||||
start(updatedConfig)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/TwinProduction/gatus/config"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
@@ -19,18 +18,16 @@ var (
|
||||
// PublishMetricsForService publishes metrics for the given service and its result.
|
||||
// These metrics will be exposed at /metrics if the metrics are enabled
|
||||
func PublishMetricsForService(service *core.Service, result *core.Result) {
|
||||
if config.Get().Metrics {
|
||||
rwLock.Lock()
|
||||
gauge, exists := gauges[fmt.Sprintf("%s_%s", service.Name, service.URL)]
|
||||
if !exists {
|
||||
gauge = promauto.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Subsystem: "gatus",
|
||||
Name: "tasks",
|
||||
ConstLabels: prometheus.Labels{"service": service.Name, "url": service.URL},
|
||||
}, []string{"status", "success"})
|
||||
gauges[fmt.Sprintf("%s_%s", service.Name, service.URL)] = gauge
|
||||
}
|
||||
rwLock.Unlock()
|
||||
gauge.WithLabelValues(strconv.Itoa(result.HTTPStatus), strconv.FormatBool(result.Success)).Inc()
|
||||
rwLock.Lock()
|
||||
gauge, exists := gauges[fmt.Sprintf("%s_%s", service.Name, service.URL)]
|
||||
if !exists {
|
||||
gauge = promauto.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Subsystem: "gatus",
|
||||
Name: "tasks",
|
||||
ConstLabels: prometheus.Labels{"service": service.Name, "url": service.URL},
|
||||
}, []string{"status", "success"})
|
||||
gauges[fmt.Sprintf("%s_%s", service.Name, service.URL)] = gauge
|
||||
}
|
||||
rwLock.Unlock()
|
||||
gauge.WithLabelValues(strconv.Itoa(result.HTTPStatus), strconv.FormatBool(result.Success)).Inc()
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Handler takes care of security for a given handler with the given security configuratioon
|
||||
// Handler takes care of security for a given handler with the given security configuration
|
||||
func Handler(handler http.HandlerFunc, security *Config) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
usernameEntered, passwordEntered, ok := r.BasicAuth()
|
||||
|
||||
6
static/bootstrap.min.css
vendored
6
static/bootstrap.min.css
vendored
File diff suppressed because one or more lines are too long
@@ -1,414 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Health Dashboard</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="./bootstrap.min.css" />
|
||||
<style>
|
||||
html, body {
|
||||
background-color: #f7f9fb;
|
||||
}
|
||||
html {
|
||||
height: 100%;
|
||||
}
|
||||
#results div.container:first-child {
|
||||
border-top-left-radius: 3px;
|
||||
border-top-right-radius: 3px;
|
||||
}
|
||||
#results div.container:last-child {
|
||||
border-bottom-left-radius: 3px;
|
||||
border-bottom-right-radius: 3px;
|
||||
border-bottom-width: 1px;
|
||||
border-color: #dee2e6;
|
||||
border-style: solid;
|
||||
}
|
||||
.status {
|
||||
cursor: pointer;
|
||||
transition: all 500ms ease-in-out;
|
||||
overflow-x: hidden;
|
||||
padding: .25em 0;
|
||||
color: white;
|
||||
}
|
||||
.title {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
.status:hover {
|
||||
opacity: 0.7;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
color: black;
|
||||
}
|
||||
.status-over-time {
|
||||
overflow: auto;
|
||||
}
|
||||
.status-over-time>span:not(:last-child) {
|
||||
margin-left: 2px;
|
||||
}
|
||||
.status-time-ago {
|
||||
color: #6a737d;
|
||||
opacity: 0.5;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.status-min-max-ms {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
#tooltip {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: white;
|
||||
border: 1px solid lightgray;
|
||||
border-radius: 4px;
|
||||
padding: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
#tooltip code {
|
||||
color: #212529;
|
||||
line-height: 1;
|
||||
}
|
||||
#tooltip .tooltip-title {
|
||||
font-weight: bold;
|
||||
margin-bottom: 0;
|
||||
display: block;
|
||||
}
|
||||
#tooltip .tooltip-title {
|
||||
margin-top: 8px;
|
||||
}
|
||||
#tooltip>.tooltip-title:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
#social {
|
||||
position: fixed;
|
||||
right: 5px;
|
||||
bottom: 5px;
|
||||
padding: 5px;
|
||||
margin: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
#social img {
|
||||
opacity: 0.3;
|
||||
}
|
||||
#social img:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
#settings {
|
||||
position: fixed;
|
||||
left: 5px;
|
||||
bottom: 5px;
|
||||
padding: 5px;
|
||||
}
|
||||
#settings select:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
.service-group {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.service-group h5:hover {
|
||||
color: #1b1e21 !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container my-3 rounded p-3 border shadow">
|
||||
<div class="mb-2">
|
||||
<div class="row">
|
||||
<div class="col-8 text-left my-auto">
|
||||
<div class="title display-4">Health Status</div>
|
||||
</div>
|
||||
<div class="col-4 text-right">
|
||||
<img src="logo.png" alt="Gatus" style="position: relative; min-width: 50px; max-width: 200px; width: 20%;"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="results"></div>
|
||||
</div>
|
||||
|
||||
<div id="tooltip" style="display: none">
|
||||
<div class="tooltip-title">Timestamp:</div>
|
||||
<code id="tooltip-timestamp">...</code>
|
||||
<div class="tooltip-title">Response time:</div>
|
||||
<code id="tooltip-response-time">...</code>
|
||||
<div class="tooltip-title">Conditions:</div>
|
||||
<code id="tooltip-conditions">...</code>
|
||||
<div id="tooltip-errors-container">
|
||||
<div class="tooltip-title">Errors:</div>
|
||||
<code id="tooltip-errors">...</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="./jquery.min.js"></script>
|
||||
|
||||
<div id="social">
|
||||
<a href="https://github.com/TwinProduction/gatus" target="_blank" title="Gatus on GitHub">
|
||||
<img src="./github.png" alt="GitHub" width="32" height="auto" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div id="settings">
|
||||
<div class="input-group input-group-sm">
|
||||
<div class="input-group-prepend">
|
||||
<div class="input-group-text">↻</div>
|
||||
</div>
|
||||
<select class="form-control form-control-sm" id="refresh-rate">
|
||||
<option value="10">10s</option>
|
||||
<option value="30" selected>30s</option>
|
||||
<option value="60">1m</option>
|
||||
<option value="120">2m</option>
|
||||
<option value="300">5m</option>
|
||||
<option value="600">10m</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let serviceStatuses = {};
|
||||
let timerHandler = 0;
|
||||
let refreshIntervalHandler = 0;
|
||||
let userClickedStatus = false;
|
||||
|
||||
// TODO: make this variable configurable and persist the choice in localStorage
|
||||
let showStatusOnHover = true;
|
||||
|
||||
function showTooltip(serviceName, index, element) {
|
||||
//userClickedStatus = false;
|
||||
clearTimeout(timerHandler);
|
||||
let serviceResult = serviceStatuses[serviceName].results[index];
|
||||
$("#tooltip-timestamp").text(prettifyTimestamp(serviceResult.timestamp));
|
||||
$("#tooltip-response-time").text(parseInt(serviceResult.duration/1000000) + "ms");
|
||||
// Populate the condition section
|
||||
let conditions = "";
|
||||
for (let i in serviceResult['condition-results']) {
|
||||
let conditionResult = serviceResult['condition-results'][i];
|
||||
conditions += (conditionResult.success ? "✓" : "X") + " ~ " + htmlEntities(conditionResult.condition) + "<br />";
|
||||
}
|
||||
$("#tooltip-conditions").html(conditions);
|
||||
// Populate the error section only if there are errors
|
||||
if (serviceResult.errors && serviceResult.errors.length > 0) {
|
||||
let errors = "";
|
||||
for (let i in serviceResult.errors) {
|
||||
errors += "- " + htmlEntities(serviceResult.errors[i]) + "<br />";
|
||||
}
|
||||
$("#tooltip-errors").html(errors);
|
||||
$("#tooltip-errors-container").show();
|
||||
} else {
|
||||
$("#tooltip-errors-container").hide();
|
||||
}
|
||||
// Position tooltip
|
||||
$("#tooltip").css({top: "0px", left: "0px"}).show();
|
||||
let targetTopPosition = element.getBoundingClientRect().y + 30;
|
||||
let targetLeftPosition = element.getBoundingClientRect().x;
|
||||
// Make adjustments if necessary
|
||||
let tooltipBoundingClientRect = document.querySelector('#tooltip').getBoundingClientRect();
|
||||
if (targetLeftPosition + window.scrollX + tooltipBoundingClientRect.width + 50 > document.body.getBoundingClientRect().width) {
|
||||
targetLeftPosition = element.getBoundingClientRect().x - tooltipBoundingClientRect.width + element.getBoundingClientRect().width;
|
||||
if (targetLeftPosition < 0) {
|
||||
targetLeftPosition += -targetLeftPosition;
|
||||
}
|
||||
}
|
||||
if (targetTopPosition + window.scrollY + tooltipBoundingClientRect.height + 50 > document.body.getBoundingClientRect().height && targetTopPosition >= 0) {
|
||||
targetTopPosition = element.getBoundingClientRect().y - (tooltipBoundingClientRect.height + 10);
|
||||
if (targetTopPosition < 0) {
|
||||
targetTopPosition = element.getBoundingClientRect().y + 30;
|
||||
}
|
||||
}
|
||||
$("#tooltip").css({top: targetTopPosition + "px", left: targetLeftPosition + "px"});
|
||||
}
|
||||
|
||||
function fadeTooltip() {
|
||||
if (!userClickedStatus) {
|
||||
timerHandler = setTimeout(function () {
|
||||
$("#tooltip").hide();
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTooltip(serviceName, index, element) {
|
||||
console.log("userClickedStatus="+userClickedStatus);
|
||||
if (!userClickedStatus) {
|
||||
showTooltip(serviceName, index, element);
|
||||
userClickedStatus = true;
|
||||
} else {
|
||||
$("#tooltip").hide();
|
||||
userClickedStatus = false;
|
||||
}
|
||||
}
|
||||
|
||||
function createStatusBadge(serviceStatusIndex, index, success) {
|
||||
if (success) {
|
||||
if (showStatusOnHover) {
|
||||
return "<span class='status badge badge-success' style='width: 5%' onmouseenter='showTooltip(\"" + serviceStatusIndex + "\", " + index + ", this)' onmouseleave='fadeTooltip()' onclick='userClickedStatus = !userClickedStatus;'>✓</span>";
|
||||
} else {
|
||||
return "<span class='status badge badge-success' style='width: 5%' onclick='toggleTooltip(\"" + serviceStatusIndex + "\", " + index + ", this)'>✓</span>";
|
||||
}
|
||||
}
|
||||
if (showStatusOnHover) {
|
||||
return "<span class='status badge badge-danger' style='width: 5%' onmouseenter='showTooltip(\"" + serviceStatusIndex + "\", " + index + ", this)' onmouseleave='fadeTooltip()' onclick='userClickedStatus = !userClickedStatus;'>X</span>";
|
||||
} else {
|
||||
return "<span class='status badge badge-danger' style='width: 5%' onclick='toggleTooltip(\"" + serviceStatusIndex + "\", " + index + ", this)'>X</span>";
|
||||
}
|
||||
}
|
||||
|
||||
function refreshStatuses() {
|
||||
$.getJSON("./api/v1/statuses", function (data) {
|
||||
// Update the table only if there's a change
|
||||
if (JSON.stringify(serviceStatuses) !== JSON.stringify(data)) {
|
||||
serviceStatuses = data;
|
||||
buildTable();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildTable() {
|
||||
let outputByGroup = {};
|
||||
for (let serviceStatusIndex in serviceStatuses) {
|
||||
let serviceStatusOverTime = "";
|
||||
let serviceStatus = serviceStatuses[serviceStatusIndex];
|
||||
let hostname = serviceStatus.results[serviceStatus.results.length-1].hostname;
|
||||
let minResponseTime = null;
|
||||
let maxResponseTime = null;
|
||||
let newestTimestamp = null;
|
||||
let oldestTimestamp = null;
|
||||
for (let resultIndex in serviceStatus.results) {
|
||||
let serviceResult = serviceStatus.results[resultIndex];
|
||||
serviceStatusOverTime = createStatusBadge(serviceStatusIndex, resultIndex, serviceResult.success) + serviceStatusOverTime;
|
||||
const responseTime = parseInt(serviceResult.duration/1000000);
|
||||
if (minResponseTime == null || minResponseTime > responseTime) {
|
||||
minResponseTime = responseTime;
|
||||
}
|
||||
if (maxResponseTime == null || maxResponseTime < responseTime) {
|
||||
maxResponseTime = responseTime;
|
||||
}
|
||||
const timestamp = new Date(serviceResult.timestamp);
|
||||
if (newestTimestamp == null || newestTimestamp < timestamp) {
|
||||
newestTimestamp = timestamp;
|
||||
}
|
||||
if (oldestTimestamp == null || oldestTimestamp > timestamp) {
|
||||
oldestTimestamp = timestamp;
|
||||
}
|
||||
}
|
||||
let output = ""
|
||||
+ "<div class='container py-3 border-left border-right border-top border-black rounded-0'>"
|
||||
+ " <div class='row mb-2'>"
|
||||
+ " <div class='col-md-10'>"
|
||||
+ " <span class='font-weight-bold'>" + serviceStatus.name + "</span> <span class='text-secondary font-weight-lighter'>- " + hostname + "</span>"
|
||||
+ " </div>"
|
||||
+ " <div class='col-md-2 text-right'>"
|
||||
+ " <span class='font-weight-lighter status-min-max-ms'>" + (minResponseTime === maxResponseTime ? minResponseTime : (minResponseTime + "-" + maxResponseTime)) + "ms</span>"
|
||||
+ " </div>"
|
||||
+ " </div>"
|
||||
+ " <div class='row'>"
|
||||
+ " <div class='col-12 d-flex flex-row-reverse status-over-time'>"
|
||||
+ " " + serviceStatusOverTime
|
||||
+ " </div>"
|
||||
+ " </div>"
|
||||
+ " <div class='row status-time-ago'>"
|
||||
+ " <div class='col-6'>"
|
||||
+ " " + generatePrettyTimeAgo(oldestTimestamp)
|
||||
+ " </div>"
|
||||
+ " <div class='col-6 text-right'>"
|
||||
+ " " + generatePrettyTimeAgo(newestTimestamp)
|
||||
+ " </div>"
|
||||
+ " </div>"
|
||||
+ "</div>";
|
||||
// create an empty entry if this group is new
|
||||
if (!outputByGroup[serviceStatus.group]) {
|
||||
outputByGroup[serviceStatus.group] = "";
|
||||
}
|
||||
outputByGroup[serviceStatus.group] += output;
|
||||
}
|
||||
let output = "";
|
||||
for (let group in outputByGroup) {
|
||||
// Services that don't have a group should be skipped and left for last
|
||||
if (group === 'undefined') {
|
||||
continue
|
||||
}
|
||||
let key = group.replace(/[^a-zA-Z0-9]/g, '');
|
||||
let existingGroupContentSelector = $("#service-group-" + key + "-content");
|
||||
let isCurrentlyHidden = existingGroupContentSelector.length && existingGroupContentSelector[0].style.display === 'none';
|
||||
let groupStatus = "<span class='text-success'>✓</span>";
|
||||
if (outputByGroup[group].includes("badge badge-danger")) {
|
||||
groupStatus = "<span class='text-warning'>~</span>";
|
||||
}
|
||||
output += ""
|
||||
+ "<div class='mt-" + (output.length ? '4' : '3') + "'>"
|
||||
+ " <div class='container pt-2 border-left border-right border-top border-black border-bottom service-group' id='service-group-" + key + "' data-group='" + key + "' onclick='toggleGroup(this)'>"
|
||||
+ " <h5 class='text-secondary text-monospace pb-0'>"
|
||||
+ " " + groupStatus + " " + group
|
||||
+ " <span class='float-right service-group-arrow' id='service-group-" + key + "-arrow'>" + (isCurrentlyHidden ? "▼" : "▲") + "</span>"
|
||||
+ " </h5>"
|
||||
+ " </div>"
|
||||
+ " <div class='service-group-content' id='service-group-" + key + "-content' style='" + (isCurrentlyHidden ? "display: none;" : "") + "'>"
|
||||
+ " " + outputByGroup[group]
|
||||
+ " </div>"
|
||||
+ "</div>";
|
||||
}
|
||||
// Add all services that don't have a group at the end
|
||||
if (outputByGroup['undefined']) {
|
||||
output += ""
|
||||
+ "<div class='mt-" + (output.length ? '4' : '3') + "'>"
|
||||
+ " " + outputByGroup['undefined']
|
||||
+ "</div>"
|
||||
}
|
||||
$("#results").html(output);
|
||||
}
|
||||
|
||||
function toggleGroup(element) {
|
||||
let selector = $("#service-group-" + element.dataset.group + "-content");
|
||||
selector.toggle("fast", function() {
|
||||
if (selector.length && selector[0].style.display === 'none') {
|
||||
$("#service-group-" + element.dataset.group + "-arrow").html("▼");
|
||||
} else {
|
||||
$("#service-group-" + element.dataset.group + "-arrow").html("▲");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function prettifyTimestamp(timestamp) {
|
||||
let date = new Date(timestamp);
|
||||
let YYYY = date.getFullYear();
|
||||
let MM = ((date.getMonth()+1)<10?"0":"")+""+(date.getMonth()+1);
|
||||
let DD = ((date.getDate())<10?"0":"")+""+(date.getDate());
|
||||
let hh = ((date.getHours())<10?"0":"")+""+(date.getHours());
|
||||
let mm = ((date.getMinutes())<10?"0":"")+""+(date.getMinutes());
|
||||
let ss = ((date.getSeconds())<10?"0":"")+""+(date.getSeconds());
|
||||
return YYYY + "-" + MM + "-" + DD + " " + hh + ":" + mm + ":" + ss;
|
||||
}
|
||||
|
||||
function generatePrettyTimeAgo(t) {
|
||||
let differenceInMs = new Date().getTime() - new Date(t).getTime();
|
||||
if (differenceInMs > 3600000) {
|
||||
let hours = (differenceInMs/3600000).toFixed(0);
|
||||
return hours + " hour" + (hours !== "1" ? "s" : "") + " ago";
|
||||
}
|
||||
if (differenceInMs > 60000) {
|
||||
let minutes = (differenceInMs/60000).toFixed(0);
|
||||
return minutes + " minute" + (minutes !== "1" ? "s" : "") + " ago";
|
||||
}
|
||||
return (differenceInMs/1000).toFixed(0) + " seconds ago";
|
||||
}
|
||||
|
||||
function htmlEntities(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function setRefreshInterval(seconds) {
|
||||
refreshStatuses();
|
||||
refreshIntervalHandler = setInterval(function() {
|
||||
refreshStatuses();
|
||||
}, seconds * 1000);
|
||||
}
|
||||
|
||||
$("#refresh-rate").change(function() {
|
||||
clearInterval(refreshIntervalHandler);
|
||||
setRefreshInterval($(this).val());
|
||||
});
|
||||
setRefreshInterval(30);
|
||||
$("#refresh-rate").val(30);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
2
static/jquery.min.js
vendored
2
static/jquery.min.js
vendored
File diff suppressed because one or more lines are too long
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user