Compare commits
105 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d65cebb1fb | ||
|
|
0b6fc6b520 | ||
|
|
968b960283 | ||
|
|
77ba2169cf | ||
|
|
f6c32a90ac | ||
|
|
932a67d9e7 | ||
|
|
ee414df03f | ||
|
|
718f8260bb | ||
|
|
3cbe068fc1 | ||
|
|
4ada6ee7c9 | ||
|
|
1e28905c8d | ||
|
|
4dbde07b85 | ||
|
|
8f35679299 | ||
|
|
897e1590ac | ||
|
|
48ef7c7313 | ||
|
|
941cc03f19 | ||
|
|
a4c429a0e0 | ||
|
|
2074697efa | ||
|
|
2ce02b0d7f | ||
|
|
8edca65041 | ||
|
|
cdbc075439 | ||
|
|
949fd65cb7 | ||
|
|
54d06b8688 | ||
|
|
07b1a2eafb | ||
|
|
d3e0ef6519 | ||
|
|
9cd6355056 | ||
|
|
7416384efe | ||
|
|
23fb69fca9 | ||
|
|
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 |
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
github: [TwinProduction]
|
||||
1
.github/assets/gatus-diagram.drawio
vendored
Normal file
1
.github/assets/gatus-diagram.drawio
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<mxfile host="app.diagrams.net" modified="2021-07-30T04:27:17.723Z" agent="5.0 (Windows)" etag="hZKgW5ZLl0WUgYrCJXa4" version="14.9.2" type="device"><diagram id="1euQ5oT3BAcxlUCibzft" name="Page-1">7VvbcpswEP0aP6YDSPjy2MRum5lmptMkk+ZRARXUCtYV8oV+fUUsDLYSh07TLDP2k9FKWOKcw2q1EgNyka0/KjZPryDmchB48XpApoMg8IlHzE9lKTeWERlvDIkSsW3UGK7Fb26NnrUuRMyLnYYaQGox3zVGkOc80js2phSsdpt9B7nb65wl3DFcR0y61jsR63RjHYdeY//ERZLWPfuerclY3dgaipTFsGqZyGxALhSA3lxl6wsuK/BqXDb3fXimdjswxXPd5Yazebia3Z1ffuJfy+VwJm9K//ZsuPmXJZML+8B2sLqsEeCxAcQWQekUEsiZnDXWcwWLPOZVN54pNW0+A8yN0TfGH1zr0rLLFhqMKdWZtLWbPquOnn02aypgoSJ+4IFqjTCVcH2gXbhlwEiXQ8a1Ks19ikumxXJ3HMxqKNm2a2A2Fxbpv0Ddd1A3elNa5ImDfoNtBdQqFZpfz9kjBCvzxj2F49L8FV8fRtJ9cntDMLZyte+rX8t31ah/a0tbyh96/wksiqFIg5Yqv9n7Hwv3VeFdWBen63bltLSlV1Ry0FHJBFPJwfDEzkF2nnnb3oadeq7FYqch5L5d1x92yAj15XGmgRXTURoD/jSwDWPsNBCE2NMA8R1QjsPTkI5a3oRuaK7G0XKhQVXBNbaU9yMaMsaW8tjB6kilHXadRL2nGX6jaP1YY5yu9ExQ2Rmd2DnIDmqI409O7Bx2baiz9jY3daLnGXooalB1oucFelCTH5Sc6DlID0WN2+ixZg4704M699hRtlZBcwVLEXPl0PbCInF3RTn49yUjHfYtCT5ywLriRWGW1w9Cxeh4jfayRZNhN7iC/wXXxIHrWrLoZ++A8kNspOp91hZUX4ys1HShy/7BFaDD5eZsB8FQmm7PY7E0l0l1ecNZVtR200+rqn+YjtExdXOHV0xrrjIodO/wGo6w4aIOXDdc8kSxrHdgBT62uIJjTQ8F1lO9vG+AehQiONYEUXd+xqj8uBNetbGDv62zH6Pjb+sE7oqm+CUNDr1zyxTfLbt7YCLPeAaqf1EnGVNktAiyk9w9x+G9nZMko65OEtNHEnd1XnC1FBEvKiAqsxkF5Og+c/9UB35eg7rLz6koIuhhUqM+TIXmBag7Fd+shBTQO6gCGmJjhbxnj3byjXZN/uLunLihUgQGEpDyifQv9okhOkF3k8gHOdFWSbRrAEBRV7HUjQBuL9F1vD/d4+s4dKd7DGG/pkAnXQWKeg6nHmZboMUTrrZI2by6XGTyfWTW+QasSoIiMuiyBy6/QCEeQ1kyfQCtIWs1eC9FUlVo2JMyLLQUOb/YfqLlvZKfflne5HXUbYrNh1qPda3P3cjsDw==</diagram></mxfile>
|
||||
BIN
.github/assets/gatus-diagram.png
vendored
Normal file
BIN
.github/assets/gatus-diagram.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
.github/assets/teams-alerts.png
vendored
Normal file
BIN
.github/assets/teams-alerts.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -28,6 +28,6 @@ jobs:
|
||||
# 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
|
||||
|
||||
34
.github/workflows/publish-latest.yml
vendored
Normal file
34
.github/workflows/publish-latest.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
name: publish-latest
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["build"]
|
||||
branches: [master]
|
||||
types: [completed]
|
||||
jobs:
|
||||
publish-latest:
|
||||
name: Publish latest
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v2
|
||||
- name: Get image repository
|
||||
run: echo IMAGE_REPOSITORY=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
- name: Login to Docker Registry
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Build and push docker image
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
platforms: linux/amd64
|
||||
pull: true
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.IMAGE_REPOSITORY }}:latest
|
||||
@@ -1,10 +1,10 @@
|
||||
name: publish
|
||||
name: publish-release
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
jobs:
|
||||
build:
|
||||
name: Publish
|
||||
publish-release:
|
||||
name: Publish release
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -2,4 +2,7 @@
|
||||
.vscode
|
||||
gatus
|
||||
db.db
|
||||
config/config.yml
|
||||
config/config.yml
|
||||
db.db-shm
|
||||
db.db-wal
|
||||
memory.db
|
||||
654
README.md
654
README.md
@@ -26,7 +26,6 @@ For more details, see [Usage](#usage)
|
||||
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Why Gatus?](#why-gatus)
|
||||
- [Features](#features)
|
||||
- [Usage](#usage)
|
||||
@@ -34,19 +33,25 @@ For more details, see [Usage](#usage)
|
||||
- [Conditions](#conditions)
|
||||
- [Placeholders](#placeholders)
|
||||
- [Functions](#functions)
|
||||
- [Storage](#storage)
|
||||
- [Client configuration](#client-configuration)
|
||||
- [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 Messagebird alerts](#configuring-messagebird-alerts)
|
||||
- [Configuring PagerDuty alerts](#configuring-pagerduty-alerts)
|
||||
- [Configuring Slack alerts](#configuring-slack-alerts)
|
||||
- [Configuring Teams alerts](#configuring-teams-alerts)
|
||||
- [Configuring Telegram alerts](#configuring-telegram-alerts)
|
||||
- [Configuring Twilio alerts](#configuring-twilio-alerts)
|
||||
- [Configuring custom alerts](#configuring-custom-alerts)
|
||||
- [Setting a default alert](#setting-a-default-alert)
|
||||
- [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)
|
||||
@@ -63,11 +68,11 @@ For more details, see [Usage](#usage)
|
||||
- [Service groups](#service-groups)
|
||||
- [Exposing Gatus on a custom port](#exposing-gatus-on-a-custom-port)
|
||||
- [Uptime Badges (ALPHA)](#uptime-badges)
|
||||
- [API](#API)
|
||||
|
||||
- [API](#api)
|
||||
- [High level design overview](#high-level-design-overview)
|
||||
- [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?
|
||||
|
||||
@@ -86,21 +91,19 @@ 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.
|
||||
- **Ability to use Gatus for user acceptance tests**: Thanks to the point above, you can leverage this application to create automated user acceptance tests.
|
||||
- **Very easy to configure**: Not only is the configuration designed to be as readable as possible, it's also extremely easy to add a new service or a new endpoint to monitor.
|
||||
- **Alerting**: While having a pretty visual dashboard is useful to keep track of the state of your application(s), you probably don't want to stare at it all day. Thus, notifications via Slack, Mattermost, Messagebird, PagerDuty 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.
|
||||
- **Alerting**: While having a pretty visual dashboard is useful to keep track of the state of your application(s), you probably don't want to stare at it all day. Thus, notifications via Slack, Mattermost, Messagebird, PagerDuty, Twilio and Teams are supported out of the box with the ability to configure a custom alerting provider for any needs you might have, whether it be a different provider or a custom application that manages automated rollbacks.
|
||||
- **Metrics**
|
||||
- **Low resource consumption**: As with most Go applications, the resource footprint that this application requires is negligibly small.
|
||||
- **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.
|
||||
@@ -134,50 +137,46 @@ If you want to test it locally, see [Docker](#docker).
|
||||
|
||||
|
||||
## Configuration
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|:---------------------------------------- |:----------------------------------------------------------------------------- |:-------------- |
|
||||
| `debug` | Whether to enable debug logs | `false` |
|
||||
| `metrics` | Whether to expose metrics at /metrics | `false` |
|
||||
| `storage` | Storage configuration | `{}` |
|
||||
| `storage.file` | File to persist the data in. If not set, storage is in-memory only. | `""` |
|
||||
| `services` | List of services to monitor | Required `[]` |
|
||||
| `debug` | Whether to enable debug logs. | `false` |
|
||||
| `metrics` | Whether to expose metrics at /metrics. | `false` |
|
||||
| `storage` | Storage configuration. <br />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).
|
||||
| `web` | Web configuration | `{}` |
|
||||
| `web.address` | Address to listen on | `0.0.0.0` |
|
||||
| `web.port` | Port to listen on | `8080` |
|
||||
| `services[].group` | Group name. Used to group multiple services together on the dashboard. <br />See [Service groups](#service-groups). | `""` |
|
||||
| `services[].url` | URL to send the request to. | Required `""` |
|
||||
| `services[].method` | Request method. | `GET` |
|
||||
| `services[].conditions` | Conditions used to determine the health of the service. <br />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. <br />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`, `teams` `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. | `""` |
|
||||
| `services[].client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` |
|
||||
| `alerting` | Configuration for alerting. <br />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. <br />See [Reloading configuration on the fly](#reloading-configuration-on-the-fly). | `false` |
|
||||
| `web` | Web configuration. | `{}` |
|
||||
| `web.address` | Address to listen on. | `0.0.0.0` |
|
||||
| `web.port` | Port to listen on. | `8080` |
|
||||
|
||||
- For Kubernetes configuration, see [Kubernetes](#kubernetes-alpha).
|
||||
- For alerting configuration, see [Alerting](#alerting).
|
||||
For Kubernetes configuration, see [Kubernetes](#kubernetes-alpha).
|
||||
|
||||
|
||||
### Conditions
|
||||
|
||||
Here are some examples of conditions you can use:
|
||||
|
||||
| Condition | Description | Passing values | Failing values |
|
||||
@@ -204,7 +203,6 @@ Here are some examples of conditions you can use:
|
||||
|
||||
|
||||
#### Placeholders
|
||||
|
||||
| Placeholder | Description | Example of resolved value |
|
||||
|:-------------------------- |:--------------------------------------------------------------- |:------------------------- |
|
||||
| `[STATUS]` | Resolves into the HTTP status of the request | 404
|
||||
@@ -217,7 +215,6 @@ Here are some examples of conditions you can use:
|
||||
|
||||
|
||||
#### Functions
|
||||
|
||||
| Function | Description | Example |
|
||||
|:-----------|:---------------------------------------------------------------------------------------------------------------- |:-------------------------- |
|
||||
| `len` | Returns the length of the object/slice. Works only with the `[BODY]` placeholder. | `len([BODY].username) > 8`
|
||||
@@ -228,83 +225,86 @@ Here are some examples of conditions you can use:
|
||||
**NOTE**: Use `pat` only when you need to. `[STATUS] == pat(2*)` is a lot more expensive than `[STATUS] < 300`.
|
||||
|
||||
|
||||
### 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 configured properly, all alerts configured with the provider's type will be
|
||||
ignored.
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|:---------------------------------------- |:----------------------------------------------------------------------------- |:-------------- |
|
||||
| `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` | `{}` |
|
||||
| `alerting.twilio.sid` | Twilio account SID | Required `""` |
|
||||
| `alerting.twilio.token` | Twilio auth token | Required `""` |
|
||||
| `alerting.twilio.from` | Number to send Twilio alerts from | Required `""` |
|
||||
| `alerting.twilio.to` | Number to send twilio alerts to | Required `""` |
|
||||
| `alerting.mattermost` | Configuration for alerts of type `mattermost` | `{}` |
|
||||
| `alerting.mattermost.webhook-url` | Mattermost Webhook URL | Required `""` |
|
||||
| `alerting.mattermost.insecure` | Whether to skip verifying the server's certificate chain and host name | `false` |
|
||||
| `alerting.messagebird` | Settings for alerts of type `messagebird` | `{}` |
|
||||
| `alerting.messagebird.access-key` | Messagebird access key | Required `""` |
|
||||
| `alerting.messagebird.originator` | The sender of the message | Required `""` |
|
||||
| `alerting.messagebird.recipients` | The recipients of the message | Required `""` |
|
||||
| `alerting.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 | `{}` |
|
||||
| `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
|
||||
### Storage
|
||||
| Parameter | Description | Default |
|
||||
|:------------------ |:-------------------------------------------------------------------------------------- |:-------------- |
|
||||
| `storage` | Storage configuration | `{}` |
|
||||
| `storage.file` | File to persist the data in. If the type is `memory`, data is persisted on interval. | `""` |
|
||||
| `storage.type` | Type of storage. Valid types: `memory`, `sqlite`. | `"memory"` |
|
||||
|
||||
- If `storage.type` is `memory` (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
|
||||
alerting:
|
||||
slack:
|
||||
webhook-url: "https://hooks.slack.com/services/**********/**********/**********"
|
||||
storage:
|
||||
type: sqlite
|
||||
file: data.db
|
||||
```
|
||||
See [examples/docker-compose-sqlite-storage](examples/docker-compose-sqlite-storage) for an example.
|
||||
|
||||
|
||||
### Client configuration
|
||||
In order to support a wide range of environments, each monitored service has a unique configuration for
|
||||
the client used to send the request.
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|:-------------------------|:----------------------------------------------------------------------------- |:-------------- |
|
||||
| `client.insecure` | Whether to skip verifying the server's certificate chain and host name. | `false` |
|
||||
| `client.ignore-redirect` | Whether to ignore redirects (true) or follow them (false, default). | `false` |
|
||||
| `client.timeout` | Duration before timing out. | `10s` |
|
||||
|
||||
Note that some of these parameters are ignored based on the type of service. For instance, there's no certificate involved
|
||||
in ICMP requests (ping), therefore, setting `client.insecure` to `true` for a service of that type will not do anything.
|
||||
|
||||
This default configuration is as follows:
|
||||
```yaml
|
||||
client:
|
||||
insecure: false
|
||||
ignore-redirect: false
|
||||
timeout: 10s
|
||||
```
|
||||
Note that this configuration is only available under `services[]`, `alerting.mattermost` and `alerting.custom`.
|
||||
|
||||
Here's an example with the client configuration under `service[]`:
|
||||
```yaml
|
||||
services:
|
||||
- name: twinnation
|
||||
url: "https://twinnation.org/health"
|
||||
interval: 30s
|
||||
alerts:
|
||||
- type: slack
|
||||
enabled: true
|
||||
description: "healthcheck failed 3 times in a row"
|
||||
send-on-resolved: true
|
||||
- type: slack
|
||||
enabled: true
|
||||
failure-threshold: 5
|
||||
description: "healthcheck failed 5 times in a row"
|
||||
send-on-resolved: true
|
||||
client:
|
||||
insecure: false
|
||||
ignore-redirect: false
|
||||
timeout: 10s
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].status == UP"
|
||||
- "[RESPONSE_TIME] < 300"
|
||||
```
|
||||
|
||||
Here's an example of what the notifications look like:
|
||||
|
||||

|
||||
### 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 |
|
||||
|:-----------------------|:---------------------------------------------------------------------------------------------------------------------- |:-------|
|
||||
| `alerting.discord` | Configuration for alerts of type `discord`. <br />See [Configuring Discord alerts](#configuring-discord-alerts). | `{}` |
|
||||
| `alerting.mattermost` | Configuration for alerts of type `mattermost`. <br />See [Configuring Mattermost alerts](#configuring-mattermost-alerts). | `{}` |
|
||||
| `alerting.messagebird` | Configuration for alerts of type `messagebird`. <br />See [Configuring Messagebird alerts](#configuring-messagebird-alerts). | `{}` |
|
||||
| `alerting.pagerduty` | Configuration for alerts of type `pagerduty`. <br />See [Configuring PagerDuty alerts](#configuring-pagerduty-alerts). | `{}` |
|
||||
| `alerting.slack` | Configuration for alerts of type `slack`. <br />See [Configuring Slack alerts](#configuring-slack-alerts). | `{}` |
|
||||
| `alerting.teams` | Configuration for alerts of type `teams`. <br />See [Configuring Teams alerts](#configuring-teams-alerts). | `{}` |
|
||||
| `alerting.telegram` | Configuration for alerts of type `telegram`. <br />See [Configuring Telegram alerts](#configuring-telegram-alerts). | `{}` |
|
||||
| `alerting.twilio` | Settings for alerts of type `twilio`. <br />See [Configuring Twilio alerts](#configuring-twilio-alerts). | `{}` |
|
||||
| `alerting.custom` | Configuration for custom actions on failure or alerts. <br />See [Configuring Custom alerts](#configuring-custom-alerts). | `{}` |
|
||||
|
||||
|
||||
#### Configuring Discord alerts
|
||||
| Parameter | Description | Default |
|
||||
|:---------------------------------------- |:-------------------------------------------- |:-------------- |
|
||||
| `alerting.discord` | Configuration for alerts of type `discord` | `{}` |
|
||||
| `alerting.discord.webhook-url` | Discord Webhook URL | Required `""` |
|
||||
| `alerting.discord.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
@@ -315,24 +315,97 @@ services:
|
||||
- name: twinnation
|
||||
url: "https://twinnation.org/health"
|
||||
interval: 30s
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].status == UP"
|
||||
- "[RESPONSE_TIME] < 300"
|
||||
alerts:
|
||||
- type: discord
|
||||
enabled: true
|
||||
description: "healthcheck failed"
|
||||
send-on-resolved: true
|
||||
```
|
||||
|
||||
|
||||
#### Configuring Mattermost alerts
|
||||
| Parameter | Description | Default |
|
||||
|:----------------------------------- |:------------------------------------------------------------------------------------------- |:-------------- |
|
||||
| `alerting.mattermost` | Configuration for alerts of type `mattermost` | `{}` |
|
||||
| `alerting.mattermost.webhook-url` | Mattermost Webhook URL | Required `""` |
|
||||
| `alerting.mattermost.client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` |
|
||||
| `alerting.mattermost.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert). | N/A |
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
mattermost:
|
||||
webhook-url: "http://**********/hooks/**********"
|
||||
client:
|
||||
insecure: true
|
||||
|
||||
services:
|
||||
- name: twinnation
|
||||
url: "https://twinnation.org/health"
|
||||
interval: 30s
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].status == UP"
|
||||
- "[RESPONSE_TIME] < 300"
|
||||
alerts:
|
||||
- type: mattermost
|
||||
enabled: true
|
||||
description: "healthcheck failed"
|
||||
send-on-resolved: true
|
||||
```
|
||||
|
||||
Here's an example of what the notifications look like:
|
||||
|
||||

|
||||
|
||||
|
||||
#### Configuring Messagebird alerts
|
||||
| Parameter | Description | Default |
|
||||
|:-------------------------------------|:----------------------------------------------------------------------------- |:-------------- |
|
||||
| `alerting.messagebird` | Settings for alerts of type `messagebird` | `{}` |
|
||||
| `alerting.messagebird.access-key` | Messagebird access key | Required `""` |
|
||||
| `alerting.messagebird.originator` | The sender of the message | Required `""` |
|
||||
| `alerting.messagebird.recipients` | The recipients of the message | Required `""` |
|
||||
| `alerting.messagebird.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
|
||||
Example of sending **SMS** text message alert using Messagebird:
|
||||
```yaml
|
||||
alerting:
|
||||
messagebird:
|
||||
access-key: "..."
|
||||
originator: "31619191918"
|
||||
recipients: "31619191919,31619191920"
|
||||
services:
|
||||
- name: twinnation
|
||||
interval: 30s
|
||||
url: "https://twinnation.org/health"
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].status == UP"
|
||||
- "[RESPONSE_TIME] < 300"
|
||||
alerts:
|
||||
- type: messagebird
|
||||
enabled: true
|
||||
failure-threshold: 3
|
||||
send-on-resolved: true
|
||||
description: "healthcheck failed"
|
||||
```
|
||||
|
||||
|
||||
#### Configuring PagerDuty alerts
|
||||
| Parameter | Description | Default |
|
||||
|:---------------------------------------- |:----------------------------------------------------------------------------- |:-------------- |
|
||||
| `alerting.pagerduty` | Configuration for alerts of type `pagerduty` | `{}` |
|
||||
| `alerting.pagerduty.integration-key` | PagerDuty Events API v2 integration key. | Required `""` |
|
||||
| `alerting.pagerduty.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
|
||||
It is highly recommended to set `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
|
||||
PagerDuty instead.
|
||||
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
|
||||
PagerDuty instead.
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
@@ -343,6 +416,10 @@ services:
|
||||
- name: twinnation
|
||||
url: "https://twinnation.org/health"
|
||||
interval: 30s
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].status == UP"
|
||||
- "[RESPONSE_TIME] < 300"
|
||||
alerts:
|
||||
- type: pagerduty
|
||||
enabled: true
|
||||
@@ -350,14 +427,118 @@ services:
|
||||
success-threshold: 5
|
||||
send-on-resolved: true
|
||||
description: "healthcheck failed"
|
||||
```
|
||||
|
||||
|
||||
#### Configuring Slack alerts
|
||||
| Parameter | Description | Default |
|
||||
|:-------------------------------- |:----------------------------------------------------------------------------- |:-------------- |
|
||||
| `alerting.slack` | Configuration for alerts of type `slack` | `{}` |
|
||||
| `alerting.slack.webhook-url` | Slack Webhook URL | Required `""` |
|
||||
| `alerting.slack.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
slack:
|
||||
webhook-url: "https://hooks.slack.com/services/**********/**********/**********"
|
||||
|
||||
services:
|
||||
- name: twinnation
|
||||
url: "https://twinnation.org/health"
|
||||
interval: 30s
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].status == UP"
|
||||
- "[RESPONSE_TIME] < 300"
|
||||
alerts:
|
||||
- type: slack
|
||||
enabled: true
|
||||
description: "healthcheck failed 3 times in a row"
|
||||
send-on-resolved: true
|
||||
- type: slack
|
||||
enabled: true
|
||||
failure-threshold: 5
|
||||
description: "healthcheck failed 5 times in a row"
|
||||
send-on-resolved: true
|
||||
```
|
||||
|
||||
Here's an example of what the notifications look like:
|
||||
|
||||

|
||||
|
||||
|
||||
#### Configuring Teams alerts
|
||||
| Parameter | Description | Default |
|
||||
|:-------------------------------- |:----------------------------------------------------------------------------- |:-------------- |
|
||||
| `alerting.teams` | Configuration for alerts of type `teams` | `{}` |
|
||||
| `alerting.teams.webhook-url` | Teams Webhook URL | Required `""` |
|
||||
| `alerting.teams.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
teams:
|
||||
webhook-url: "https://********.webhook.office.com/webhookb2/************"
|
||||
|
||||
services:
|
||||
- name: twinnation
|
||||
url: "https://twinnation.org/health"
|
||||
interval: 30s
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].status == UP"
|
||||
- "[RESPONSE_TIME] < 300"
|
||||
alerts:
|
||||
- type: teams
|
||||
enabled: true
|
||||
description: "healthcheck failed"
|
||||
send-on-resolved: true
|
||||
```
|
||||
|
||||
Here's an example of what the notifications look like:
|
||||
|
||||

|
||||
|
||||
#### Configuring Telegram alerts
|
||||
| Parameter | Description | Default |
|
||||
|:----------------------------------- |:----------------------------------------------------------------------------- |:-------------- |
|
||||
| `alerting.telegram` | Configuration for alerts of type `telegram` | `{}` |
|
||||
| `alerting.telegram.token` | Telegram Bot Token | Required `""` |
|
||||
| `alerting.telegram.id` | Telegram User ID | Required `""` |
|
||||
| `alerting.telegram.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
telegram:
|
||||
token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
|
||||
id: "0123456789"
|
||||
|
||||
services:
|
||||
- name: twinnation
|
||||
url: "https://twinnation.org/health"
|
||||
interval: 30s
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].status == UP"
|
||||
alerts:
|
||||
- type: telegram
|
||||
enabled: true
|
||||
send-on-resolved: true
|
||||
```
|
||||
|
||||
Here's an example of what the notifications look like:
|
||||
|
||||

|
||||
|
||||
|
||||
#### Configuring Twilio alerts
|
||||
| Parameter | Description | Default |
|
||||
|:--------------------------------- |:----------------------------------------------------------------------------- |:-------------- |
|
||||
| `alerting.twilio` | Settings for alerts of type `twilio` | `{}` |
|
||||
| `alerting.twilio.sid` | Twilio account SID | Required `""` |
|
||||
| `alerting.twilio.token` | Twilio auth token | Required `""` |
|
||||
| `alerting.twilio.from` | Number to send Twilio alerts from | Required `""` |
|
||||
| `alerting.twilio.to` | Number to send twilio alerts to | Required `""` |
|
||||
| `alerting.twilio.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
@@ -371,101 +552,29 @@ services:
|
||||
- name: twinnation
|
||||
interval: 30s
|
||||
url: "https://twinnation.org/health"
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].status == UP"
|
||||
- "[RESPONSE_TIME] < 300"
|
||||
alerts:
|
||||
- type: twilio
|
||||
enabled: true
|
||||
failure-threshold: 5
|
||||
send-on-resolved: true
|
||||
description: "healthcheck failed"
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].status == UP"
|
||||
- "[RESPONSE_TIME] < 300"
|
||||
```
|
||||
|
||||
|
||||
#### Configuring Mattermost alerts
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
mattermost:
|
||||
webhook-url: "http://**********/hooks/**********"
|
||||
insecure: true
|
||||
|
||||
services:
|
||||
- name: twinnation
|
||||
url: "https://twinnation.org/health"
|
||||
interval: 30s
|
||||
alerts:
|
||||
- type: mattermost
|
||||
enabled: true
|
||||
description: "healthcheck failed"
|
||||
send-on-resolved: true
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].status == UP"
|
||||
- "[RESPONSE_TIME] < 300"
|
||||
```
|
||||
|
||||
Here's an example of what the notifications look like:
|
||||
|
||||

|
||||
|
||||
|
||||
#### Configuring Messagebird alerts
|
||||
|
||||
Example of sending **SMS** text message alert using Messagebird:
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
messagebird:
|
||||
access-key: "..."
|
||||
originator: "31619191918"
|
||||
recipients: "31619191919,31619191920"
|
||||
services:
|
||||
- name: twinnation
|
||||
interval: 30s
|
||||
url: "https://twinnation.org/health"
|
||||
alerts:
|
||||
- type: messagebird
|
||||
enabled: true
|
||||
failure-threshold: 3
|
||||
send-on-resolved: true
|
||||
description: "healthcheck failed"
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].status == UP"
|
||||
- "[RESPONSE_TIME] < 300"
|
||||
```
|
||||
|
||||
|
||||
#### 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
|
||||
| Parameter | Description | Default |
|
||||
|:----------------------------------|:----------------------------------------------------------------------------- |:-------------- |
|
||||
| `alerting.custom` | Configuration for custom actions on failure or alerts | `{}` |
|
||||
| `alerting.custom.url` | Custom alerting request url | Required `""` |
|
||||
| `alerting.custom.method` | Request method | `GET` |
|
||||
| `alerting.custom.body` | Custom alerting request body. | `""` |
|
||||
| `alerting.custom.headers` | Custom alerting request headers | `{}` |
|
||||
| `alerting.custom.client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` |
|
||||
| `alerting.custom.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
|
||||
While they're called alerts, you can use this feature to call anything.
|
||||
|
||||
@@ -488,7 +597,6 @@ alerting:
|
||||
custom:
|
||||
url: "https://hooks.slack.com/services/**********/**********/**********"
|
||||
method: "POST"
|
||||
insecure: true
|
||||
body: |
|
||||
{
|
||||
"text": "[ALERT_TRIGGERED_OR_RESOLVED]: [SERVICE_NAME] - [ALERT_DESCRIPTION]"
|
||||
@@ -497,6 +605,10 @@ services:
|
||||
- name: twinnation
|
||||
url: "https://twinnation.org/health"
|
||||
interval: 30s
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].status == UP"
|
||||
- "[RESPONSE_TIME] < 300"
|
||||
alerts:
|
||||
- type: custom
|
||||
enabled: true
|
||||
@@ -504,10 +616,6 @@ services:
|
||||
success-threshold: 3
|
||||
send-on-resolved: true
|
||||
description: "healthcheck failed"
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].status == UP"
|
||||
- "[RESPONSE_TIME] < 300"
|
||||
```
|
||||
|
||||
Note that you can customize the resolved values for the `[ALERT_TRIGGERED_OR_RESOLVED]` placeholder like so:
|
||||
@@ -523,7 +631,14 @@ As a result, the `[ALERT_TRIGGERED_OR_RESOLVED]` in the body of first example of
|
||||
`partial_outage` when an alert is triggered and `operational` when an alert is resolved.
|
||||
|
||||
|
||||
#### Setting a default provider alert
|
||||
#### Setting a default alert
|
||||
| Parameter | Description | Default |
|
||||
|:----------------------------------------------|:------------------------------------------------------------------------------|:--------|
|
||||
| `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 |
|
||||
|
||||
While you can specify the alert configuration directly in the service definition, it's tedious and may lead to a very
|
||||
long configuration file.
|
||||
@@ -546,24 +661,26 @@ As a result, your service configuration looks a lot tidier:
|
||||
services:
|
||||
- name: example
|
||||
url: "https://example.org"
|
||||
alerts:
|
||||
- type: slack
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
alerts:
|
||||
- type: slack
|
||||
|
||||
- name: other-example
|
||||
url: "https://example.com"
|
||||
alerts:
|
||||
- type: slack
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
alerts:
|
||||
- type: slack
|
||||
```
|
||||
|
||||
It also allows you to do things like this:
|
||||
```yaml
|
||||
services:
|
||||
- name: twinnation
|
||||
url: "https://twinnation.org/health"
|
||||
- name: example
|
||||
url: "https://example.org"
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
alerts:
|
||||
- type: slack
|
||||
failure-threshold: 5
|
||||
@@ -571,22 +688,53 @@ services:
|
||||
failure-threshold: 10
|
||||
- type: slack
|
||||
failure-threshold: 15
|
||||
```
|
||||
|
||||
Of course, you can also mix alert types:
|
||||
```yaml
|
||||
alerting:
|
||||
slack:
|
||||
webhook-url: "https://hooks.slack.com/services/**********/**********/**********"
|
||||
default-alert:
|
||||
enabled: true
|
||||
failure-threshold: 3
|
||||
pagerduty:
|
||||
integration-key: "********************************"
|
||||
default-alert:
|
||||
enabled: true
|
||||
failure-threshold: 5
|
||||
|
||||
services:
|
||||
- name: service-1
|
||||
url: "https://example.org"
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
alerts:
|
||||
- type: slack
|
||||
- type: pagerduty
|
||||
|
||||
- name: service-2
|
||||
url: "https://example.org"
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
alerts:
|
||||
- type: slack
|
||||
- type: pagerduty
|
||||
```
|
||||
|
||||
|
||||
### 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 |
|
||||
|:------------------------------------------- |:----------------------------------------------------------------------------- |:-------------- |
|
||||
| `kubernetes` | Kubernetes configuration | `{}` |
|
||||
| `kubernetes.auto-discover` | Whether to enable auto discovery | `false` |
|
||||
| `kubernetes.cluster-mode` | Cluster mode to use for authenticating. Supported values: `in`, `out` | Required `""` |
|
||||
| `kubernetes.service-template` | Service template. See `services[]` in [Configuration](#configuration) | Required `nil` |
|
||||
| `kubernetes.service-template` | Service template. <br />See `services[]` in [Configuration](#configuration) | Required `nil` |
|
||||
| `kubernetes.excluded-service-suffixes` | List of service suffixes to not monitor (e.g. `canary`) | `[]` |
|
||||
| `kubernetes.namespaces` | List of configurations for the namespaces from which services will be discovered | `[]` |
|
||||
| `kubernetes.namespaces[].name` | Namespace name | Required `""` |
|
||||
@@ -596,7 +744,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.
|
||||
|
||||
@@ -635,13 +782,14 @@ 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
|
||||
@@ -665,22 +813,37 @@ docker build . -t twinproduction/gatus
|
||||
```
|
||||
|
||||
|
||||
## Running the tests
|
||||
### 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:
|
||||
@@ -711,9 +874,8 @@ will send a `POST` request to `http://localhost:8080/playground` with the follow
|
||||
|
||||
|
||||
### 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.
|
||||
@@ -723,7 +885,7 @@ such as 1ms. You'll notice that the response time does not fluctuate - that is b
|
||||
different goroutines, there's a global lock that prevents multiple services from running at the same time.
|
||||
|
||||
Unfortunately, there is a drawback. If you have a lot of services, including some that are very slow or prone to time out (the default
|
||||
time out is 10s for HTTP and 5s for TCP), then it means that for the entire duration of the request, no other services can be evaluated.
|
||||
timeout is 10s), then it means that for the entire duration of the request, no other services can be evaluated.
|
||||
|
||||
**This does mean that Gatus will be unable to evaluate the health of other services**.
|
||||
The interval does not include the duration of the request itself, which means that if a service has an interval of 30s
|
||||
@@ -743,15 +905,16 @@ simple health checks used for alerting (PagerDuty/Twilio) to `30s`.
|
||||
|
||||
|
||||
### Default timeouts
|
||||
|
||||
| Protocol | Timeout |
|
||||
|:-------- |:------- |
|
||||
| HTTP | 10s
|
||||
| TCP | 5s
|
||||
| TCP | 10s
|
||||
| ICMP | 10s
|
||||
|
||||
To modify the timeout, see [Client configuration](#client-configuration).
|
||||
|
||||
|
||||
### Monitoring a TCP service
|
||||
|
||||
By prefixing `services[].url` with `tcp:\\`, you can monitor TCP services at a very basic level:
|
||||
|
||||
```yaml
|
||||
@@ -772,7 +935,6 @@ 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":
|
||||
|
||||
@@ -789,7 +951,6 @@ 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:
|
||||
@@ -811,7 +972,6 @@ There are two placeholders that can be used in the conditions for services of ty
|
||||
|
||||
|
||||
### 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
|
||||
@@ -826,7 +986,6 @@ services:
|
||||
|
||||
|
||||
### Basic authentication
|
||||
|
||||
You can require Basic authentication by leveraging the `security.basic` configuration:
|
||||
```yaml
|
||||
security:
|
||||
@@ -839,7 +998,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
|
||||
@@ -853,7 +1011,6 @@ technically, if you create 100 services with a 1 seconds interval, Gatus will se
|
||||
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -877,7 +1034,6 @@ the same as restarting the application.
|
||||
|
||||
|
||||
### Service groups
|
||||
|
||||
Service groups are used for grouping multiple services together on the dashboard.
|
||||
|
||||
```yaml
|
||||
@@ -923,7 +1079,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:
|
||||
@@ -937,6 +1092,7 @@ web:
|
||||
port: ${PORT}
|
||||
```
|
||||
|
||||
|
||||
### Uptime badges
|
||||

|
||||

|
||||
@@ -972,6 +1128,7 @@ Example: 
|
||||
|
||||
|
||||
## Sponsors
|
||||
You can find the full list of sponsors [here](https://github.com/sponsors/TwinProduction).
|
||||
|
||||
[<img src="https://github.com/math280h.png" width="50" />](https://github.com/math280h)
|
||||
[<img src="https://github.com/pyroscope-io.png" width="50" />](https://github.com/pyroscope-io)
|
||||
|
||||
@@ -23,6 +23,9 @@ const (
|
||||
// TypeSlack is the Type for the slack alerting provider
|
||||
TypeSlack Type = "slack"
|
||||
|
||||
// TypeTeams is the Type for the teams alerting provider
|
||||
TypeTeams Type = "teams"
|
||||
|
||||
// TypeTelegram is the Type for the telegram alerting provider
|
||||
TypeTelegram Type = "telegram"
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/TwinProduction/gatus/alerting/provider/messagebird"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/pagerduty"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/slack"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/teams"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/telegram"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/twilio"
|
||||
)
|
||||
@@ -33,6 +34,9 @@ type Config struct {
|
||||
// Slack is the configuration for the slack alerting provider
|
||||
Slack *slack.AlertProvider `yaml:"slack"`
|
||||
|
||||
// Teams is the configuration for the teams alerting provider
|
||||
Teams *teams.AlertProvider `yaml:"teams"`
|
||||
|
||||
// Telegram is the configuration for the telegram alerting provider
|
||||
Telegram *telegram.AlertProvider `yaml:"telegram"`
|
||||
|
||||
@@ -79,6 +83,12 @@ func (config Config) GetAlertingProviderByAlertType(alertType alert.Type) provid
|
||||
return nil
|
||||
}
|
||||
return config.Slack
|
||||
case alert.TypeTeams:
|
||||
if config.Teams == nil {
|
||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||
return nil
|
||||
}
|
||||
return config.Teams
|
||||
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
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -19,18 +20,29 @@ import (
|
||||
type AlertProvider struct {
|
||||
URL string `yaml:"url"`
|
||||
Method string `yaml:"method,omitempty"`
|
||||
Insecure bool `yaml:"insecure,omitempty"`
|
||||
Insecure bool `yaml:"insecure,omitempty"` // deprecated
|
||||
Body string `yaml:"body,omitempty"`
|
||||
Headers map[string]string `yaml:"headers,omitempty"`
|
||||
Placeholders map[string]map[string]string `yaml:"placeholders,omitempty"`
|
||||
|
||||
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
||||
ClientConfig *client.Config `yaml:"client"`
|
||||
|
||||
// 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.URL) > 0
|
||||
if provider.ClientConfig == nil {
|
||||
provider.ClientConfig = client.GetDefaultConfig()
|
||||
// XXX: remove the next 4 lines in v3.0.0
|
||||
if provider.Insecure {
|
||||
log.Println("WARNING: alerting.*.insecure has been deprecated and will be removed in v3.0.0 in favor of alerting.*.client.insecure")
|
||||
provider.ClientConfig.Insecure = true
|
||||
}
|
||||
}
|
||||
return len(provider.URL) > 0 && provider.ClientConfig != nil
|
||||
}
|
||||
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
@@ -103,7 +115,7 @@ func (provider *AlertProvider) Send(serviceName, alertDescription string, resolv
|
||||
return []byte("{}"), nil
|
||||
}
|
||||
request := provider.buildHTTPRequest(serviceName, alertDescription, resolved)
|
||||
response, err := client.GetHTTPClient(provider.Insecure).Do(request)
|
||||
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -42,6 +42,10 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
||||
}
|
||||
results += fmt.Sprintf("%s - `%s`\\n", prefix, conditionResult.Condition)
|
||||
}
|
||||
var description string
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
description = ":\\n> " + alertDescription
|
||||
}
|
||||
return &custom.AlertProvider{
|
||||
URL: provider.WebhookURL,
|
||||
Method: http.MethodPost,
|
||||
@@ -50,7 +54,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
||||
"embeds": [
|
||||
{
|
||||
"title": ":helmet_with_white_cross: Gatus",
|
||||
"description": "%s:\n> %s",
|
||||
"description": "%s%s",
|
||||
"color": %d,
|
||||
"fields": [
|
||||
{
|
||||
@@ -61,7 +65,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
||||
]
|
||||
}
|
||||
]
|
||||
}`, message, alert.GetDescription(), colorCode, results),
|
||||
}`, message, description, colorCode, results),
|
||||
Headers: map[string]string{"Content-Type": "application/json"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@ func TestAlertProvider_IsValid(t *testing.T) {
|
||||
|
||||
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)
|
||||
alertDescription := "test"
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{Name: "svc"}, &alert.Alert{Description: &alertDescription}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
@@ -41,6 +42,9 @@ func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
if expected := "An alert for **svc** has been resolved after passing successfully 0 time(s) in a row:\n> test"; expected != body["embeds"].([]interface{})[0].(map[string]interface{})["description"] {
|
||||
t.Errorf("expected $.embeds[0].description to be %s, got %s", expected, body["embeds"].([]interface{})[0].(map[string]interface{})["description"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
||||
|
||||
@@ -2,17 +2,22 @@ package mattermost
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||
"github.com/TwinProduction/gatus/client"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Mattermost
|
||||
type AlertProvider struct {
|
||||
WebhookURL string `yaml:"webhook-url"`
|
||||
Insecure bool `yaml:"insecure,omitempty"`
|
||||
Insecure bool `yaml:"insecure,omitempty"` // deprecated
|
||||
|
||||
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
||||
ClientConfig *client.Config `yaml:"client"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
||||
@@ -20,6 +25,14 @@ type AlertProvider struct {
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
func (provider *AlertProvider) IsValid() bool {
|
||||
if provider.ClientConfig == nil {
|
||||
provider.ClientConfig = client.GetDefaultConfig()
|
||||
// XXX: remove the next 3 lines in v3.0.0
|
||||
if provider.Insecure {
|
||||
log.Println("WARNING: alerting.mattermost.insecure has been deprecated and will be removed in v3.0.0 in favor of alerting.mattermost.client.insecure")
|
||||
provider.ClientConfig.Insecure = true
|
||||
}
|
||||
}
|
||||
return len(provider.WebhookURL) > 0
|
||||
}
|
||||
|
||||
@@ -44,10 +57,14 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
||||
}
|
||||
results += fmt.Sprintf("%s - `%s`\\n", prefix, conditionResult.Condition)
|
||||
}
|
||||
var description string
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
description = ":\\n> " + alertDescription
|
||||
}
|
||||
return &custom.AlertProvider{
|
||||
URL: provider.WebhookURL,
|
||||
Method: http.MethodPost,
|
||||
Insecure: provider.Insecure,
|
||||
URL: provider.WebhookURL,
|
||||
Method: http.MethodPost,
|
||||
ClientConfig: provider.ClientConfig,
|
||||
Body: fmt.Sprintf(`{
|
||||
"text": "",
|
||||
"username": "gatus",
|
||||
@@ -56,7 +73,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
||||
{
|
||||
"title": ":rescue_worker_helmet: Gatus",
|
||||
"fallback": "Gatus - %s",
|
||||
"text": "%s:\n> %s",
|
||||
"text": "%s%s",
|
||||
"short": false,
|
||||
"color": "%s",
|
||||
"fields": [
|
||||
@@ -73,7 +90,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
||||
]
|
||||
}
|
||||
]
|
||||
}`, message, message, alert.GetDescription(), color, service.URL, results),
|
||||
}`, message, message, description, color, service.URL, results),
|
||||
Headers: map[string]string{"Content-Type": "application/json"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@ func TestAlertProvider_IsValid(t *testing.T) {
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
||||
provider := AlertProvider{WebhookURL: "http://example.org"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
|
||||
alertDescription := "test"
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{Name: "svc"}, &alert.Alert{Description: &alertDescription}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
@@ -41,6 +42,9 @@ func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
if expected := "An alert for *svc* has been resolved after passing successfully 0 time(s) in a row:\n> test"; expected != body["attachments"].([]interface{})[0].(map[string]interface{})["text"] {
|
||||
t.Errorf("expected $.attachments[0].description to be %s, got %s", expected, body["attachments"].([]interface{})[0].(map[string]interface{})["text"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
||||
|
||||
@@ -9,6 +9,10 @@ import (
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
|
||||
const (
|
||||
restAPIURL = "https://events.pagerduty.com/v2/enqueue"
|
||||
)
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using PagerDuty
|
||||
type AlertProvider struct {
|
||||
IntegrationKey string `yaml:"integration-key"`
|
||||
@@ -37,7 +41,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
||||
resolveKey = ""
|
||||
}
|
||||
return &custom.AlertProvider{
|
||||
URL: "https://events.pagerduty.com/v2/enqueue",
|
||||
URL: restAPIURL,
|
||||
Method: http.MethodPost,
|
||||
Body: fmt.Sprintf(`{
|
||||
"routing_key": "%s",
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/TwinProduction/gatus/alerting/provider/messagebird"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/pagerduty"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/slack"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/teams"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/telegram"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/twilio"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
@@ -55,6 +56,7 @@ var (
|
||||
_ AlertProvider = (*messagebird.AlertProvider)(nil)
|
||||
_ AlertProvider = (*pagerduty.AlertProvider)(nil)
|
||||
_ AlertProvider = (*slack.AlertProvider)(nil)
|
||||
_ AlertProvider = (*teams.AlertProvider)(nil)
|
||||
_ AlertProvider = (*telegram.AlertProvider)(nil)
|
||||
_ AlertProvider = (*twilio.AlertProvider)(nil)
|
||||
)
|
||||
|
||||
@@ -41,6 +41,10 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
||||
}
|
||||
results += fmt.Sprintf("%s - `%s`\\n", prefix, conditionResult.Condition)
|
||||
}
|
||||
var description string
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
description = ":\\n> " + alertDescription
|
||||
}
|
||||
return &custom.AlertProvider{
|
||||
URL: provider.WebhookURL,
|
||||
Method: http.MethodPost,
|
||||
@@ -49,7 +53,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
||||
"attachments": [
|
||||
{
|
||||
"title": ":helmet_with_white_cross: Gatus",
|
||||
"text": "%s:\n> %s",
|
||||
"text": "%s%s",
|
||||
"short": false,
|
||||
"color": "%s",
|
||||
"fields": [
|
||||
@@ -61,7 +65,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
||||
]
|
||||
}
|
||||
]
|
||||
}`, message, alert.GetDescription(), color, results),
|
||||
}`, message, description, color, results),
|
||||
Headers: map[string]string{"Content-Type": "application/json"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@ func TestAlertProvider_IsValid(t *testing.T) {
|
||||
|
||||
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)
|
||||
alertDescription := "test"
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{Name: "svc"}, &alert.Alert{Description: &alertDescription}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
@@ -41,6 +42,9 @@ func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
if expected := "An alert for *svc* has been resolved after passing successfully 0 time(s) in a row:\n> test"; expected != body["attachments"].([]interface{})[0].(map[string]interface{})["text"] {
|
||||
t.Errorf("expected $.attachments[0].description to be %s, got %s", expected, body["attachments"].([]interface{})[0].(map[string]interface{})["text"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
||||
|
||||
77
alerting/provider/teams/teams.go
Normal file
77
alerting/provider/teams/teams.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package teams
|
||||
|
||||
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 Teams
|
||||
type AlertProvider struct {
|
||||
WebhookURL string `yaml:"webhook-url"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
|
||||
DefaultAlert *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 string
|
||||
var color string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", service.Name, alert.SuccessThreshold)
|
||||
color = "#36A64F"
|
||||
} else {
|
||||
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", service.Name, alert.FailureThreshold)
|
||||
color = "#DD0000"
|
||||
}
|
||||
var results string
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
if conditionResult.Success {
|
||||
prefix = "✅"
|
||||
} else {
|
||||
prefix = "❌"
|
||||
}
|
||||
results += fmt.Sprintf("%s - `%s`<br/>", prefix, conditionResult.Condition)
|
||||
}
|
||||
var description string
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
description = ":\\n> " + alertDescription
|
||||
}
|
||||
return &custom.AlertProvider{
|
||||
URL: provider.WebhookURL,
|
||||
Method: http.MethodPost,
|
||||
Body: fmt.Sprintf(`{
|
||||
"@type": "MessageCard",
|
||||
"@context": "http://schema.org/extensions",
|
||||
"themeColor": "%s",
|
||||
"title": "🚨 Gatus",
|
||||
"text": "%s%s",
|
||||
"sections": [
|
||||
{
|
||||
"activityTitle": "URL",
|
||||
"text": "%s"
|
||||
},
|
||||
{
|
||||
"activityTitle": "Condition results",
|
||||
"text": "%s"
|
||||
}
|
||||
]
|
||||
}`, color, message, description, 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
|
||||
}
|
||||
70
alerting/provider/teams/teams_test.go
Normal file
70
alerting/provider/teams/teams_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package teams
|
||||
|
||||
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.org"}
|
||||
alertDescription := "test"
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{Name: "svc"}, &alert.Alert{Description: &alertDescription}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
|
||||
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.org" {
|
||||
t.Errorf("expected URL to be %s, got %s", "http://example.org", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
if expected := "An alert for *svc* has been resolved after passing successfully 0 time(s) in a row:\n> test"; expected != body["text"] {
|
||||
t.Errorf("expected $.text to be %s, got %s", expected, body["text"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
||||
provider := AlertProvider{WebhookURL: "http://example.org"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
|
||||
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.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())
|
||||
}
|
||||
}
|
||||
@@ -7,70 +7,24 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"strconv"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-ping/ping"
|
||||
)
|
||||
|
||||
var (
|
||||
secureHTTPClient *http.Client
|
||||
insecureHTTPClient *http.Client
|
||||
|
||||
// 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
|
||||
)
|
||||
|
||||
func init() {
|
||||
// XXX: This is an undocumented feature. See https://github.com/TwinProduction/gatus/issues/104.
|
||||
httpTimeoutInSecondsFromEnvironmentVariable := os.Getenv("HTTP_CLIENT_TIMEOUT_IN_SECONDS")
|
||||
if len(httpTimeoutInSecondsFromEnvironmentVariable) > 0 {
|
||||
if httpTimeoutInSeconds, err := strconv.Atoi(httpTimeoutInSecondsFromEnvironmentVariable); err == nil {
|
||||
httpTimeout = time.Duration(httpTimeoutInSeconds) * time.Second
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetHTTPClient returns the shared HTTP client
|
||||
func GetHTTPClient(insecure bool) *http.Client {
|
||||
if insecure {
|
||||
if insecureHTTPClient == nil {
|
||||
insecureHTTPClient = &http.Client{
|
||||
Timeout: httpTimeout,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 20,
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return insecureHTTPClient
|
||||
func GetHTTPClient(config *Config) *http.Client {
|
||||
if config == nil {
|
||||
return defaultConfig.getHTTPClient()
|
||||
}
|
||||
if secureHTTPClient == nil {
|
||||
secureHTTPClient = &http.Client{
|
||||
Timeout: httpTimeout,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 20,
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
},
|
||||
}
|
||||
}
|
||||
return secureHTTPClient
|
||||
return config.getHTTPClient()
|
||||
}
|
||||
|
||||
// CanCreateTCPConnection checks whether a connection can be established with a TCP service
|
||||
func CanCreateTCPConnection(address string) bool {
|
||||
conn, err := net.DialTimeout("tcp", address, 5*time.Second)
|
||||
func CanCreateTCPConnection(address string, config *Config) bool {
|
||||
conn, err := net.DialTimeout("tcp", address, config.Timeout)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
@@ -79,7 +33,7 @@ func CanCreateTCPConnection(address string) bool {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
func CanPerformStartTLS(address string, config *Config) (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")
|
||||
@@ -89,7 +43,7 @@ func CanPerformStartTLS(address string, insecure bool) (connected bool, certific
|
||||
return
|
||||
}
|
||||
err = smtpClient.StartTLS(&tls.Config{
|
||||
InsecureSkipVerify: insecure,
|
||||
InsecureSkipVerify: config.Insecure,
|
||||
ServerName: hostAndPort[0],
|
||||
})
|
||||
if err != nil {
|
||||
@@ -106,14 +60,16 @@ func CanPerformStartTLS(address string, insecure bool) (connected bool, certific
|
||||
// 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
|
||||
func Ping(address string) (bool, time.Duration) {
|
||||
func Ping(address string, config *Config) (bool, time.Duration) {
|
||||
pinger, err := ping.NewPinger(address)
|
||||
if err != nil {
|
||||
return false, 0
|
||||
}
|
||||
pinger.Count = 1
|
||||
pinger.Timeout = pingTimeout
|
||||
pinger.SetPrivileged(true)
|
||||
pinger.Timeout = config.Timeout
|
||||
// Set the pinger's privileged mode to true for every operating system except darwin
|
||||
// https://github.com/TwinProduction/gatus/issues/132
|
||||
pinger.SetPrivileged(runtime.GOOS != "darwin")
|
||||
err = pinger.Run()
|
||||
if err != nil {
|
||||
return false, 0
|
||||
|
||||
@@ -6,43 +6,34 @@ import (
|
||||
)
|
||||
|
||||
func TestGetHTTPClient(t *testing.T) {
|
||||
if secureHTTPClient != nil {
|
||||
t.Error("secureHTTPClient should've been nil since it hasn't been called a single time yet")
|
||||
cfg := &Config{
|
||||
Insecure: false,
|
||||
IgnoreRedirect: false,
|
||||
Timeout: 0,
|
||||
}
|
||||
if insecureHTTPClient != nil {
|
||||
t.Error("insecureHTTPClient should've been nil since it hasn't been called a single time yet")
|
||||
cfg.ValidateAndSetDefaults()
|
||||
if GetHTTPClient(cfg) == nil {
|
||||
t.Error("expected client to not be nil")
|
||||
}
|
||||
_ = GetHTTPClient(false)
|
||||
if secureHTTPClient == nil {
|
||||
t.Error("secureHTTPClient shouldn't have been nil, since it has been called once")
|
||||
}
|
||||
if insecureHTTPClient != nil {
|
||||
t.Error("insecureHTTPClient should've been nil since it hasn't been called a single time yet")
|
||||
}
|
||||
_ = GetHTTPClient(true)
|
||||
if secureHTTPClient == nil {
|
||||
t.Error("secureHTTPClient shouldn't have been nil, since it has been called once")
|
||||
}
|
||||
if insecureHTTPClient == nil {
|
||||
t.Error("insecureHTTPClient shouldn't have been nil, since it has been called once")
|
||||
if GetHTTPClient(nil) == nil {
|
||||
t.Error("expected client to not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPing(t *testing.T) {
|
||||
pingTimeout = 500 * time.Millisecond
|
||||
if success, rtt := Ping("127.0.0.1"); !success {
|
||||
if success, rtt := Ping("127.0.0.1", &Config{Timeout: 500 * time.Millisecond}); !success {
|
||||
t.Error("expected true")
|
||||
if rtt == 0 {
|
||||
t.Error("Round-trip time returned on success should've higher than 0")
|
||||
}
|
||||
}
|
||||
if success, rtt := Ping("256.256.256.256"); success {
|
||||
if success, rtt := Ping("256.256.256.256", &Config{Timeout: 500 * time.Millisecond}); success {
|
||||
t.Error("expected false, because the IP is invalid")
|
||||
if rtt != 0 {
|
||||
t.Error("Round-trip time returned on failure should've been 0")
|
||||
}
|
||||
}
|
||||
if success, rtt := Ping("192.168.152.153"); success {
|
||||
if success, rtt := Ping("192.168.152.153", &Config{Timeout: 500 * time.Millisecond}); success {
|
||||
t.Error("expected false, because the IP is valid but the host should be unreachable")
|
||||
if rtt != 0 {
|
||||
t.Error("Round-trip time returned on failure should've been 0")
|
||||
@@ -88,7 +79,7 @@ func TestCanPerformStartTLS(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
connected, _, err := CanPerformStartTLS(tt.args.address, tt.args.insecure)
|
||||
connected, _, err := CanPerformStartTLS(tt.args.address, &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second})
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("CanPerformStartTLS() err=%v, wantErr=%v", err, tt.wantErr)
|
||||
return
|
||||
@@ -99,3 +90,9 @@ func TestCanPerformStartTLS(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanCreateTCPConnection(t *testing.T) {
|
||||
if CanCreateTCPConnection("127.0.0.1", &Config{Timeout: 5 * time.Second}) {
|
||||
t.Error("should've failed, because there's no port in the address")
|
||||
}
|
||||
}
|
||||
|
||||
73
client/config.go
Normal file
73
client/config.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultHTTPTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
// DefaultConfig is the default client configuration
|
||||
defaultConfig = Config{
|
||||
Insecure: false,
|
||||
IgnoreRedirect: false,
|
||||
Timeout: defaultHTTPTimeout,
|
||||
}
|
||||
)
|
||||
|
||||
// GetDefaultConfig returns a copy of the default configuration
|
||||
func GetDefaultConfig() *Config {
|
||||
cfg := defaultConfig
|
||||
return &cfg
|
||||
}
|
||||
|
||||
// Config is the configuration for clients
|
||||
type Config struct {
|
||||
// Insecure determines whether to skip verifying the server's certificate chain and host name
|
||||
Insecure bool `yaml:"insecure"`
|
||||
|
||||
// IgnoreRedirect determines whether to ignore redirects (true) or follow them (false, default)
|
||||
IgnoreRedirect bool `yaml:"ignore-redirect"`
|
||||
|
||||
// Timeout for the client
|
||||
Timeout time.Duration `yaml:"timeout"`
|
||||
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// ValidateAndSetDefaults validates the client configuration and sets the default values if necessary
|
||||
func (c *Config) ValidateAndSetDefaults() {
|
||||
if c.Timeout < time.Millisecond {
|
||||
c.Timeout = 10 * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
// GetHTTPClient return a HTTP client matching the Config's parameters.
|
||||
func (c *Config) getHTTPClient() *http.Client {
|
||||
if c.httpClient == nil {
|
||||
c.httpClient = &http.Client{
|
||||
Timeout: c.Timeout,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 20,
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: c.Insecure,
|
||||
},
|
||||
},
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if c.IgnoreRedirect {
|
||||
// Don't follow redirects
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
// Follow redirects
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
return c.httpClient
|
||||
}
|
||||
37
client/config_test.go
Normal file
37
client/config_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConfig_getHTTPClient(t *testing.T) {
|
||||
insecureConfig := &Config{Insecure: true}
|
||||
insecureConfig.ValidateAndSetDefaults()
|
||||
insecureClient := insecureConfig.getHTTPClient()
|
||||
if !(insecureClient.Transport).(*http.Transport).TLSClientConfig.InsecureSkipVerify {
|
||||
t.Error("expected Config.Insecure set to true to cause the HTTP client to skip certificate verification")
|
||||
}
|
||||
if insecureClient.Timeout != defaultHTTPTimeout {
|
||||
t.Error("expected Config.Timeout to default the HTTP client to a timeout of 10s")
|
||||
}
|
||||
request, _ := http.NewRequest("GET", "", nil)
|
||||
if err := insecureClient.CheckRedirect(request, nil); err != nil {
|
||||
t.Error("expected Config.IgnoreRedirect set to false to cause the HTTP client's CheckRedirect to return nil")
|
||||
}
|
||||
|
||||
secureConfig := &Config{IgnoreRedirect: true, Timeout: 5 * time.Second}
|
||||
secureConfig.ValidateAndSetDefaults()
|
||||
secureClient := secureConfig.getHTTPClient()
|
||||
if (secureClient.Transport).(*http.Transport).TLSClientConfig.InsecureSkipVerify {
|
||||
t.Error("expected Config.Insecure set to false to cause the HTTP client to not skip certificate verification")
|
||||
}
|
||||
if secureClient.Timeout != 5*time.Second {
|
||||
t.Error("expected Config.Timeout to cause the HTTP client to have a timeout of 5s")
|
||||
}
|
||||
request, _ = http.NewRequest("GET", "", nil)
|
||||
if err := secureClient.CheckRedirect(request, nil); err != http.ErrUseLastResponse {
|
||||
t.Error("expected Config.IgnoreRedirect set to true to cause the HTTP client's CheckRedirect to return http.ErrUseLastResponse")
|
||||
}
|
||||
}
|
||||
13
config.yaml
13
config.yaml
@@ -10,14 +10,15 @@ services:
|
||||
|
||||
- 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,14 +30,6 @@ services:
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
- name: cat-fact
|
||||
url: "https://cat-fact.herokuapp.com/facts/random"
|
||||
interval: 5m
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].deleted == false"
|
||||
- "len([BODY].text) > 0"
|
||||
|
||||
- name: example-dns-query
|
||||
url: "8.8.8.8" # Address of the DNS server to use
|
||||
interval: 5m
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/TwinProduction/gatus/k8s"
|
||||
"github.com/TwinProduction/gatus/security"
|
||||
"github.com/TwinProduction/gatus/storage"
|
||||
"github.com/TwinProduction/gatus/util"
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
@@ -177,7 +176,9 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
|
||||
|
||||
func validateStorageConfig(config *Config) error {
|
||||
if config.Storage == nil {
|
||||
config.Storage = &storage.Config{}
|
||||
config.Storage = &storage.Config{
|
||||
Type: storage.TypeMemory,
|
||||
}
|
||||
}
|
||||
err := storage.Initialize(config.Storage)
|
||||
if err != nil {
|
||||
@@ -186,7 +187,7 @@ func validateStorageConfig(config *Config) error {
|
||||
// Remove all ServiceStatus that represent services which no longer exist in the configuration
|
||||
var keys []string
|
||||
for _, service := range config.Services {
|
||||
keys = append(keys, util.ConvertGroupAndServiceToKey(service.Group, service.Name))
|
||||
keys = append(keys, service.Key())
|
||||
}
|
||||
numberOfServiceStatusesDeleted := storage.Get().DeleteAllServiceStatusesNotInKeys(keys)
|
||||
if numberOfServiceStatusesDeleted > 0 {
|
||||
@@ -208,6 +209,7 @@ func validateWebConfig(config *Config) error {
|
||||
// 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 {
|
||||
return errors.New("kubernetes.service-template cannot be nil")
|
||||
}
|
||||
@@ -268,6 +270,7 @@ func validateAlertingConfig(alertingConfig *alerting.Config, services []*core.Se
|
||||
alert.TypeMessagebird,
|
||||
alert.TypePagerDuty,
|
||||
alert.TypeSlack,
|
||||
alert.TypeTeams,
|
||||
alert.TypeTelegram,
|
||||
alert.TypeTwilio,
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
"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/alerting/provider/teams"
|
||||
"github.com/TwinProduction/gatus/client"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwinProduction/gatus/k8stest"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
@@ -40,17 +42,31 @@ func TestParseAndValidateConfigBytes(t *testing.T) {
|
||||
config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`
|
||||
storage:
|
||||
file: %s
|
||||
|
||||
services:
|
||||
- name: twinnation
|
||||
url: https://twinnation.org/health
|
||||
interval: 15s
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
- name: github
|
||||
url: https://api.github.com/healthz
|
||||
client:
|
||||
insecure: true
|
||||
ignore-redirect: true
|
||||
timeout: 5s
|
||||
conditions:
|
||||
- "[STATUS] != 400"
|
||||
- "[STATUS] != 500"
|
||||
|
||||
- name: example
|
||||
url: https://example.com/
|
||||
interval: 30m
|
||||
client:
|
||||
insecure: true
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
`, file)))
|
||||
if err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
@@ -58,33 +74,75 @@ services:
|
||||
if config == nil {
|
||||
t.Fatal("Config shouldn't have been nil")
|
||||
}
|
||||
if len(config.Services) != 2 {
|
||||
if len(config.Services) != 3 {
|
||||
t.Error("Should have returned two services")
|
||||
}
|
||||
|
||||
if config.Services[0].URL != "https://twinnation.org/health" {
|
||||
t.Errorf("URL should have been %s", "https://twinnation.org/health")
|
||||
}
|
||||
if config.Services[1].URL != "https://api.github.com/healthz" {
|
||||
t.Errorf("URL should have been %s", "https://api.github.com/healthz")
|
||||
}
|
||||
if config.Services[0].Method != "GET" {
|
||||
t.Errorf("Method should have been %s (default)", "GET")
|
||||
}
|
||||
if config.Services[1].Method != "GET" {
|
||||
t.Errorf("Method should have been %s (default)", "GET")
|
||||
}
|
||||
if config.Services[0].Interval != 15*time.Second {
|
||||
t.Errorf("Interval should have been %s", 15*time.Second)
|
||||
}
|
||||
if config.Services[1].Interval != 60*time.Second {
|
||||
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
|
||||
if config.Services[0].ClientConfig.Insecure != client.GetDefaultConfig().Insecure {
|
||||
t.Errorf("ClientConfig.Insecure should have been %v, got %v", true, config.Services[0].ClientConfig.Insecure)
|
||||
}
|
||||
if config.Services[0].ClientConfig.IgnoreRedirect != client.GetDefaultConfig().IgnoreRedirect {
|
||||
t.Errorf("ClientConfig.IgnoreRedirect should have been %v, got %v", true, config.Services[0].ClientConfig.IgnoreRedirect)
|
||||
}
|
||||
if config.Services[0].ClientConfig.Timeout != client.GetDefaultConfig().Timeout {
|
||||
t.Errorf("ClientConfig.Timeout should have been %v, got %v", client.GetDefaultConfig().Timeout, config.Services[0].ClientConfig.Timeout)
|
||||
}
|
||||
if len(config.Services[0].Conditions) != 1 {
|
||||
t.Errorf("There should have been %d conditions", 1)
|
||||
}
|
||||
|
||||
if config.Services[1].URL != "https://api.github.com/healthz" {
|
||||
t.Errorf("URL should have been %s", "https://api.github.com/healthz")
|
||||
}
|
||||
if config.Services[1].Method != "GET" {
|
||||
t.Errorf("Method should have been %s (default)", "GET")
|
||||
}
|
||||
if config.Services[1].Interval != 60*time.Second {
|
||||
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
|
||||
}
|
||||
if !config.Services[1].ClientConfig.Insecure {
|
||||
t.Errorf("ClientConfig.Insecure should have been %v, got %v", true, config.Services[1].ClientConfig.Insecure)
|
||||
}
|
||||
if !config.Services[1].ClientConfig.IgnoreRedirect {
|
||||
t.Errorf("ClientConfig.IgnoreRedirect should have been %v, got %v", true, config.Services[1].ClientConfig.IgnoreRedirect)
|
||||
}
|
||||
if config.Services[1].ClientConfig.Timeout != 5*time.Second {
|
||||
t.Errorf("ClientConfig.Timeout should have been %v, got %v", 5*time.Second, config.Services[1].ClientConfig.Timeout)
|
||||
}
|
||||
if len(config.Services[1].Conditions) != 2 {
|
||||
t.Errorf("There should have been %d conditions", 2)
|
||||
}
|
||||
|
||||
if config.Services[2].URL != "https://example.com/" {
|
||||
t.Errorf("URL should have been %s", "https://example.com/")
|
||||
}
|
||||
if config.Services[2].Method != "GET" {
|
||||
t.Errorf("Method should have been %s (default)", "GET")
|
||||
}
|
||||
if config.Services[2].Interval != 30*time.Minute {
|
||||
t.Errorf("Interval should have been %s, because it is the default value", 30*time.Minute)
|
||||
}
|
||||
if !config.Services[2].ClientConfig.Insecure {
|
||||
t.Errorf("ClientConfig.Insecure should have been %v, got %v", true, config.Services[2].ClientConfig.Insecure)
|
||||
}
|
||||
if config.Services[2].ClientConfig.IgnoreRedirect {
|
||||
t.Errorf("ClientConfig.IgnoreRedirect should have been %v by default, got %v", false, config.Services[2].ClientConfig.IgnoreRedirect)
|
||||
}
|
||||
if config.Services[2].ClientConfig.Timeout != 10*time.Second {
|
||||
t.Errorf("ClientConfig.Timeout should have been %v by default, got %v", 10*time.Second, config.Services[2].ClientConfig.Timeout)
|
||||
}
|
||||
if len(config.Services[2].Conditions) != 1 {
|
||||
t.Errorf("There should have been %d conditions", 1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndValidateConfigBytesDefault(t *testing.T) {
|
||||
@@ -104,17 +162,26 @@ services:
|
||||
if config.Metrics {
|
||||
t.Error("Metrics should've been false by default")
|
||||
}
|
||||
if config.Web.Address != DefaultAddress {
|
||||
t.Errorf("Bind address should have been %s, because it is the default value", DefaultAddress)
|
||||
}
|
||||
if config.Web.Port != DefaultPort {
|
||||
t.Errorf("Port should have been %d, because it is the default value", DefaultPort)
|
||||
}
|
||||
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 != DefaultAddress {
|
||||
t.Errorf("Bind address should have been %s, because it is the default value", DefaultAddress)
|
||||
if config.Services[0].ClientConfig.Insecure != client.GetDefaultConfig().Insecure {
|
||||
t.Errorf("ClientConfig.Insecure should have been %v by default, got %v", true, config.Services[0].ClientConfig.Insecure)
|
||||
}
|
||||
if config.Web.Port != DefaultPort {
|
||||
t.Errorf("Port should have been %d, because it is the default value", DefaultPort)
|
||||
if config.Services[0].ClientConfig.IgnoreRedirect != client.GetDefaultConfig().IgnoreRedirect {
|
||||
t.Errorf("ClientConfig.IgnoreRedirect should have been %v by default, got %v", true, config.Services[0].ClientConfig.IgnoreRedirect)
|
||||
}
|
||||
if config.Services[0].ClientConfig.Timeout != client.GetDefaultConfig().Timeout {
|
||||
t.Errorf("ClientConfig.Timeout should have been %v by default, got %v", client.GetDefaultConfig().Timeout, config.Services[0].ClientConfig.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,11 +210,9 @@ 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.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 != DefaultPort {
|
||||
t.Errorf("Port should have been %d, because it is the default value", DefaultPort)
|
||||
}
|
||||
@@ -339,6 +404,8 @@ alerting:
|
||||
integration-key: "00000000000000000000000000000000"
|
||||
mattermost:
|
||||
webhook-url: "http://example.com"
|
||||
client:
|
||||
insecure: true
|
||||
messagebird:
|
||||
access-key: "1"
|
||||
originator: "31619191918"
|
||||
@@ -351,6 +418,8 @@ alerting:
|
||||
token: "5678"
|
||||
from: "+1-234-567-8901"
|
||||
to: "+1-234-567-8901"
|
||||
teams:
|
||||
webhook-url: "http://example.com"
|
||||
|
||||
services:
|
||||
- name: twinnation
|
||||
@@ -375,6 +444,8 @@ services:
|
||||
enabled: true
|
||||
failure-threshold: 12
|
||||
success-threshold: 15
|
||||
- type: teams
|
||||
enabled: true
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
`))
|
||||
@@ -401,8 +472,8 @@ 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 len(config.Services[0].Alerts) != 7 {
|
||||
t.Fatal("There should've been 7 alerts configured")
|
||||
if len(config.Services[0].Alerts) != 8 {
|
||||
t.Fatal("There should've been 8 alerts configured")
|
||||
}
|
||||
|
||||
if config.Services[0].Alerts[0].Type != alert.TypeSlack {
|
||||
@@ -489,6 +560,19 @@ services:
|
||||
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)
|
||||
}
|
||||
|
||||
if config.Services[0].Alerts[7].Type != alert.TypeTeams {
|
||||
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeTeams, config.Services[0].Alerts[7].Type)
|
||||
}
|
||||
if !config.Services[0].Alerts[7].IsEnabled() {
|
||||
t.Error("The alert should've been enabled")
|
||||
}
|
||||
if config.Services[0].Alerts[7].FailureThreshold != 3 {
|
||||
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Services[0].Alerts[7].FailureThreshold)
|
||||
}
|
||||
if config.Services[0].Alerts[7].SuccessThreshold != 2 {
|
||||
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Services[0].Alerts[7].SuccessThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndValidateConfigBytesWithAlertingAndDefaultAlert(t *testing.T) {
|
||||
@@ -538,6 +622,10 @@ alerting:
|
||||
enabled: true
|
||||
failure-threshold: 12
|
||||
success-threshold: 15
|
||||
teams:
|
||||
webhook-url: "http://example.com"
|
||||
default-alert:
|
||||
enabled: true
|
||||
|
||||
services:
|
||||
- name: twinnation
|
||||
@@ -551,6 +639,7 @@ services:
|
||||
success-threshold: 2 # test service alert override
|
||||
- type: telegram
|
||||
- type: twilio
|
||||
- type: teams
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
`))
|
||||
@@ -642,6 +731,14 @@ services:
|
||||
if config.Alerting.Twilio.GetDefaultAlert() == nil {
|
||||
t.Fatal("Twilio.GetDefaultAlert() shouldn't have returned nil")
|
||||
}
|
||||
|
||||
if config.Alerting.Teams == nil || !config.Alerting.Teams.IsValid() {
|
||||
t.Fatal("Teams alerting config should've been valid")
|
||||
}
|
||||
if config.Alerting.Teams.GetDefaultAlert() == nil {
|
||||
t.Fatal("Teams.GetDefaultAlert() shouldn't have returned nil")
|
||||
}
|
||||
|
||||
// Services
|
||||
if len(config.Services) != 1 {
|
||||
t.Error("There should've been 1 service")
|
||||
@@ -652,8 +749,8 @@ 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 len(config.Services[0].Alerts) != 7 {
|
||||
t.Fatal("There should've been 7 alerts configured")
|
||||
if len(config.Services[0].Alerts) != 8 {
|
||||
t.Fatal("There should've been 8 alerts configured")
|
||||
}
|
||||
|
||||
if config.Services[0].Alerts[0].Type != alert.TypeSlack {
|
||||
@@ -743,6 +840,20 @@ services:
|
||||
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)
|
||||
}
|
||||
|
||||
if config.Services[0].Alerts[7].Type != alert.TypeTeams {
|
||||
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeTeams, config.Services[0].Alerts[7].Type)
|
||||
}
|
||||
if !config.Services[0].Alerts[7].IsEnabled() {
|
||||
t.Error("The alert should've been enabled")
|
||||
}
|
||||
if config.Services[0].Alerts[7].FailureThreshold != 3 {
|
||||
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Services[0].Alerts[7].FailureThreshold)
|
||||
}
|
||||
if config.Services[0].Alerts[7].SuccessThreshold != 2 {
|
||||
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Services[0].Alerts[7].SuccessThreshold)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestParseAndValidateConfigBytesWithAlertingAndDefaultAlertAndMultipleAlertsOfSameTypeWithOverriddenParameters(t *testing.T) {
|
||||
@@ -895,6 +1006,9 @@ services:
|
||||
if config.Alerting.Custom.Insecure {
|
||||
t.Fatal("config.Alerting.Custom.Insecure shouldn't have been true")
|
||||
}
|
||||
if config.Alerting.Custom.ClientConfig.Insecure {
|
||||
t.Errorf("ClientConfig.Insecure should have been %v, got %v", false, config.Alerting.Custom.ClientConfig.Insecure)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndValidateConfigBytesWithCustomAlertingConfigAndCustomPlaceholderValues(t *testing.T) {
|
||||
@@ -1028,6 +1142,49 @@ 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) {
|
||||
_, err := parseAndValidateConfigBytes([]byte(`
|
||||
security:
|
||||
@@ -1041,7 +1198,7 @@ services:
|
||||
- "[STATUS] == 200"
|
||||
`))
|
||||
if err == nil {
|
||||
t.Error("Function should've returned an error")
|
||||
t.Error("should've returned an error")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1173,7 +1330,7 @@ kubernetes:
|
||||
target-path: "/health"
|
||||
`))
|
||||
if err == nil {
|
||||
t.Error("Function should've returned an error because providing a service-template is mandatory")
|
||||
t.Error("should've returned an error because providing a service-template is mandatory")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1192,7 +1349,7 @@ kubernetes:
|
||||
target-path: "/health"
|
||||
`))
|
||||
if err == nil {
|
||||
t.Error("Function should've returned an error because testing with ClusterModeIn isn't supported")
|
||||
t.Error("should've returned an error because testing with ClusterModeIn isn't supported")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1206,6 +1363,7 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
|
||||
Slack: &slack.AlertProvider{},
|
||||
Telegram: &telegram.AlertProvider{},
|
||||
Twilio: &twilio.AlertProvider{},
|
||||
Teams: &teams.AlertProvider{},
|
||||
}
|
||||
if alertingConfig.GetAlertingProviderByAlertType(alert.TypeCustom) != alertingConfig.Custom {
|
||||
t.Error("expected Custom configuration")
|
||||
@@ -1231,4 +1389,7 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
|
||||
if alertingConfig.GetAlertingProviderByAlertType(alert.TypeTwilio) != alertingConfig.Twilio {
|
||||
t.Error("expected Twilio configuration")
|
||||
}
|
||||
if alertingConfig.GetAlertingProviderByAlertType(alert.TypeTeams) != alertingConfig.Teams {
|
||||
t.Error("expected Teams configuration")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwinProduction/gatus/storage"
|
||||
"github.com/TwinProduction/gatus/storage/store/common"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
@@ -18,22 +18,31 @@ import (
|
||||
func badgeHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
variables := mux.Vars(request)
|
||||
duration := variables["duration"]
|
||||
if duration != "7d" && duration != "24h" && duration != "1h" {
|
||||
var from time.Time
|
||||
switch duration {
|
||||
case "7d":
|
||||
from = time.Now().Add(-time.Hour * 24 * 7)
|
||||
case "24h":
|
||||
from = time.Now().Add(-time.Hour * 24)
|
||||
case "1h":
|
||||
from = time.Now().Add(-time.Hour)
|
||||
default:
|
||||
writer.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = writer.Write([]byte("Durations supported: 7d, 24h, 1h"))
|
||||
return
|
||||
}
|
||||
identifier := variables["identifier"]
|
||||
key := strings.TrimSuffix(identifier, ".svg")
|
||||
serviceStatus := storage.Get().GetServiceStatusByKey(key)
|
||||
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"))
|
||||
uptime, err := storage.Get().GetUptimeByKey(key, from, time.Now())
|
||||
if err != nil {
|
||||
if err == common.ErrServiceNotFound {
|
||||
writer.WriteHeader(http.StatusNotFound)
|
||||
} else if err == common.ErrInvalidTimeRange {
|
||||
writer.WriteHeader(http.StatusBadRequest)
|
||||
} else {
|
||||
writer.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
_, _ = writer.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
formattedDate := time.Now().Format(http.TimeFormat)
|
||||
@@ -41,31 +50,22 @@ func badgeHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("Date", formattedDate)
|
||||
writer.Header().Set("Expires", formattedDate)
|
||||
writer.Header().Set("Content-Type", "image/svg+xml")
|
||||
_, _ = writer.Write(generateSVG(duration, serviceStatus.Uptime))
|
||||
_, _ = writer.Write(generateSVG(duration, uptime))
|
||||
}
|
||||
|
||||
func generateSVG(duration string, uptime *core.Uptime) []byte {
|
||||
func generateSVG(duration string, uptime float64) []byte {
|
||||
var labelWidth, valueWidth, valueWidthAdjustment int
|
||||
var color string
|
||||
var value float64
|
||||
switch duration {
|
||||
case "7d":
|
||||
labelWidth = 65
|
||||
value = uptime.LastSevenDays
|
||||
case "24h":
|
||||
labelWidth = 70
|
||||
value = uptime.LastTwentyFourHours
|
||||
case "1h":
|
||||
labelWidth = 65
|
||||
value = uptime.LastHour
|
||||
default:
|
||||
}
|
||||
if value >= 0.8 {
|
||||
color = "#40cc11"
|
||||
} else {
|
||||
color = "#c7130a"
|
||||
}
|
||||
sanitizedValue := strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.2f", value*100), "0"), ".") + "%"
|
||||
color := getBadgeColorFromUptime(uptime)
|
||||
sanitizedValue := strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.2f", uptime*100), "0"), ".") + "%"
|
||||
if strings.Contains(sanitizedValue, ".") {
|
||||
valueWidthAdjustment = -10
|
||||
}
|
||||
@@ -103,3 +103,18 @@ func generateSVG(duration string, uptime *core.Uptime) []byte {
|
||||
</svg>`, width, width, labelWidth, color, labelWidth, valueWidth, labelWidth, width, labelX, duration, labelX, duration, valueX, sanitizedValue, valueX, sanitizedValue))
|
||||
return svg
|
||||
}
|
||||
|
||||
func getBadgeColorFromUptime(uptime float64) string {
|
||||
if uptime >= 0.975 {
|
||||
return "#40cc11"
|
||||
} else if uptime >= 0.95 {
|
||||
return "#94cc11"
|
||||
} else if uptime >= 0.9 {
|
||||
return "#ccc311"
|
||||
} else if uptime >= 0.8 {
|
||||
return "#ccb311"
|
||||
} else if uptime >= 0.5 {
|
||||
return "#cc8111"
|
||||
}
|
||||
return "#c7130a"
|
||||
}
|
||||
|
||||
32
controller/badge_test.go
Normal file
32
controller/badge_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetBadgeColorFromUptime(t *testing.T) {
|
||||
if getBadgeColorFromUptime(1) != "#40cc11" {
|
||||
t.Error("expected #40cc11 from an uptime of 1, got", getBadgeColorFromUptime(1))
|
||||
}
|
||||
if getBadgeColorFromUptime(0.95) != "#94cc11" {
|
||||
t.Error("expected #94cc11 from an uptime of 0.95, got", getBadgeColorFromUptime(0.95))
|
||||
}
|
||||
if getBadgeColorFromUptime(0.9) != "#ccc311" {
|
||||
t.Error("expected #c9cc11 from an uptime of 0.9, got", getBadgeColorFromUptime(0.9))
|
||||
}
|
||||
if getBadgeColorFromUptime(0.85) != "#ccb311" {
|
||||
t.Error("expected #ccb311 from an uptime of 0.85, got", getBadgeColorFromUptime(0.85))
|
||||
}
|
||||
if getBadgeColorFromUptime(0.75) != "#cc8111" {
|
||||
t.Error("expected #cc8111 from an uptime of 0.75, got", getBadgeColorFromUptime(0.75))
|
||||
}
|
||||
if getBadgeColorFromUptime(0.6) != "#cc8111" {
|
||||
t.Error("expected #cc8111 from an uptime of 0.6, got", getBadgeColorFromUptime(0.6))
|
||||
}
|
||||
if getBadgeColorFromUptime(0.25) != "#c7130a" {
|
||||
t.Error("expected #c7130a from an uptime of 0.25, got", getBadgeColorFromUptime(0.25))
|
||||
}
|
||||
if getBadgeColorFromUptime(0) != "#c7130a" {
|
||||
t.Error("expected #c7130a from an uptime of 0, got", getBadgeColorFromUptime(0))
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
"github.com/TwinProduction/gatus/config"
|
||||
"github.com/TwinProduction/gatus/security"
|
||||
"github.com/TwinProduction/gatus/storage"
|
||||
"github.com/TwinProduction/gatus/storage/store/common"
|
||||
"github.com/TwinProduction/gatus/storage/store/common/paging"
|
||||
"github.com/TwinProduction/gocache"
|
||||
"github.com/TwinProduction/health"
|
||||
"github.com/gorilla/mux"
|
||||
@@ -37,12 +39,6 @@ var (
|
||||
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(securityConfig *security.Config, webConfig *config.WebConfig, enableMetrics bool) {
|
||||
var router http.Handler = CreateRouter(securityConfig, enableMetrics)
|
||||
@@ -115,7 +111,7 @@ func serviceStatusesHandler(writer http.ResponseWriter, r *http.Request) {
|
||||
var err error
|
||||
buffer := &bytes.Buffer{}
|
||||
gzipWriter := gzip.NewWriter(buffer)
|
||||
data, err = json.Marshal(storage.Get().GetAllServiceStatusesWithResultPagination(page, pageSize))
|
||||
data, err = json.Marshal(storage.Get().GetAllServiceStatuses(paging.NewServiceStatusParams().WithResults(page, pageSize)))
|
||||
if err != nil {
|
||||
log.Printf("[controller][serviceStatusesHandler] Unable to marshal object to JSON: %s", err.Error())
|
||||
writer.WriteHeader(http.StatusInternalServerError)
|
||||
@@ -142,20 +138,28 @@ func serviceStatusesHandler(writer http.ResponseWriter, r *http.Request) {
|
||||
func serviceStatusHandler(writer http.ResponseWriter, r *http.Request) {
|
||||
page, pageSize := extractPageAndPageSizeFromRequest(r)
|
||||
vars := mux.Vars(r)
|
||||
serviceStatus := storage.Get().GetServiceStatusByKey(vars["key"])
|
||||
serviceStatus := storage.Get().GetServiceStatusByKey(vars["key"], paging.NewServiceStatusParams().WithResults(page, pageSize).WithEvents(1, common.MaximumNumberOfEvents))
|
||||
if serviceStatus == nil {
|
||||
log.Printf("[controller][serviceStatusHandler] Service with key=%s not found", vars["key"])
|
||||
writer.WriteHeader(http.StatusNotFound)
|
||||
_, _ = writer.Write([]byte("not found"))
|
||||
return
|
||||
}
|
||||
uptime7Days, _ := storage.Get().GetUptimeByKey(vars["key"], time.Now().Add(-time.Hour*24*7), time.Now())
|
||||
uptime24Hours, _ := storage.Get().GetUptimeByKey(vars["key"], time.Now().Add(-time.Hour*24), time.Now())
|
||||
uptime1Hour, _ := storage.Get().GetUptimeByKey(vars["key"], time.Now().Add(-time.Hour), time.Now())
|
||||
data := map[string]interface{}{
|
||||
"serviceStatus": serviceStatus.WithResultPagination(page, pageSize),
|
||||
"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,
|
||||
// TODO: remove this in v3.0.0. Not used by front-end, only used for API. Left here for v2.x.x backward compatibility
|
||||
"uptime": map[string]float64{
|
||||
"7d": uptime7Days,
|
||||
"24h": uptime24Hours,
|
||||
"1h": uptime1Hour,
|
||||
},
|
||||
}
|
||||
output, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
|
||||
@@ -30,7 +30,6 @@ var (
|
||||
Interval: 30 * time.Second,
|
||||
Conditions: []*core.Condition{&firstCondition, &secondCondition, &thirdCondition},
|
||||
Alerts: nil,
|
||||
Insecure: false,
|
||||
NumberOfFailuresInARow: 0,
|
||||
NumberOfSuccessesInARow: 0,
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package controller
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/TwinProduction/gatus/storage/store/common"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -13,7 +15,7 @@ const (
|
||||
DefaultPageSize = 20
|
||||
|
||||
// MaximumPageSize is the maximum page size allowed
|
||||
MaximumPageSize = 100
|
||||
MaximumPageSize = common.MaximumNumberOfResults
|
||||
)
|
||||
|
||||
func extractPageAndPageSizeFromRequest(r *http.Request) (page int, pageSize int) {
|
||||
|
||||
@@ -24,3 +24,14 @@ var (
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ type Result struct {
|
||||
Duration time.Duration `json:"duration"`
|
||||
|
||||
// Errors encountered during the evaluation of the service's health
|
||||
Errors []string `json:"errors"` // XXX: find a way to filter out duplicate errors
|
||||
Errors []string `json:"errors"`
|
||||
|
||||
// ConditionResults results of the service's conditions
|
||||
ConditionResults []*ConditionResult `json:"conditionResults"`
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/TwinProduction/gatus/util"
|
||||
)
|
||||
|
||||
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(service *Service) *ServiceStatus {
|
||||
return &ServiceStatus{
|
||||
Name: service.Name,
|
||||
Group: service.Group,
|
||||
Key: util.ConvertGroupAndServiceToKey(service.Group, service.Name),
|
||||
Results: make([]*Result, 0),
|
||||
Events: []*Event{{
|
||||
Type: EventStart,
|
||||
Timestamp: time.Now(),
|
||||
}},
|
||||
Uptime: NewUptime(),
|
||||
}
|
||||
}
|
||||
|
||||
// WithResultPagination returns a shallow copy of the ServiceStatus with only the results
|
||||
// within the range defined by the page and pageSize parameters
|
||||
func (ss ServiceStatus) WithResultPagination(page, pageSize int) *ServiceStatus {
|
||||
shallowCopy := ss
|
||||
numberOfResults := len(shallowCopy.Results)
|
||||
start := numberOfResults - (page * pageSize)
|
||||
end := numberOfResults - ((page - 1) * pageSize)
|
||||
if start > numberOfResults {
|
||||
start = -1
|
||||
} else if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if end > numberOfResults {
|
||||
end = numberOfResults
|
||||
}
|
||||
if start < 0 || end < 0 {
|
||||
shallowCopy.Results = []*Result{}
|
||||
} else {
|
||||
shallowCopy.Results = shallowCopy.Results[start:end]
|
||||
}
|
||||
return &shallowCopy
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if len(ss.Results) > 0 {
|
||||
// Check if there's any change since the last result
|
||||
// OR there's only 1 event, which only happens when there's a start event
|
||||
if ss.Results[len(ss.Results)-1].Success != result.Success || len(ss.Events) == 1 {
|
||||
event := &Event{Timestamp: result.Timestamp}
|
||||
if result.Success {
|
||||
event.Type = EventHealthy
|
||||
} else {
|
||||
event.Type = EventUnhealthy
|
||||
}
|
||||
ss.Events = append(ss.Events, event)
|
||||
if len(ss.Events) > MaximumNumberOfEvents {
|
||||
// Doing ss.Events[1:] would usually be sufficient, but in the case where for some reason, the slice has
|
||||
// more than one extra element, we can get rid of all of them at once and thus returning the slice to a
|
||||
// length of MaximumNumberOfEvents by using ss.Events[len(ss.Events)-MaximumNumberOfEvents:] instead
|
||||
ss.Events = ss.Events[len(ss.Events)-MaximumNumberOfEvents:]
|
||||
}
|
||||
}
|
||||
}
|
||||
ss.Results = append(ss.Results, result)
|
||||
if len(ss.Results) > MaximumNumberOfResults {
|
||||
// Doing ss.Results[1:] would usually be sufficient, but in the case where for some reason, the slice has more
|
||||
// than one extra element, we can get rid of all of them at once and thus returning the slice to a length of
|
||||
// MaximumNumberOfResults by using ss.Results[len(ss.Results)-MaximumNumberOfResults:] instead
|
||||
ss.Results = ss.Results[len(ss.Results)-MaximumNumberOfResults:]
|
||||
}
|
||||
ss.Uptime.ProcessResult(result)
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
firstCondition = Condition("[STATUS] == 200")
|
||||
secondCondition = Condition("[RESPONSE_TIME] < 500")
|
||||
thirdCondition = Condition("[CERTIFICATE_EXPIRATION] < 72h")
|
||||
|
||||
timestamp = time.Now()
|
||||
|
||||
testService = Service{
|
||||
Name: "name",
|
||||
Group: "group",
|
||||
URL: "https://example.org/what/ever",
|
||||
Method: "GET",
|
||||
Body: "body",
|
||||
Interval: 30 * time.Second,
|
||||
Conditions: []*Condition{&firstCondition, &secondCondition, &thirdCondition},
|
||||
Alerts: nil,
|
||||
Insecure: false,
|
||||
NumberOfFailuresInARow: 0,
|
||||
NumberOfSuccessesInARow: 0,
|
||||
}
|
||||
testSuccessfulResult = Result{
|
||||
Hostname: "example.org",
|
||||
IP: "127.0.0.1",
|
||||
HTTPStatus: 200,
|
||||
body: []byte("body"),
|
||||
Errors: nil,
|
||||
Connected: true,
|
||||
Success: true,
|
||||
Timestamp: timestamp,
|
||||
Duration: 150 * time.Millisecond,
|
||||
CertificateExpiration: 10 * time.Hour,
|
||||
ConditionResults: []*ConditionResult{
|
||||
{
|
||||
Condition: "[STATUS] == 200",
|
||||
Success: true,
|
||||
},
|
||||
{
|
||||
Condition: "[RESPONSE_TIME] < 500",
|
||||
Success: true,
|
||||
},
|
||||
{
|
||||
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
|
||||
Success: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
testUnsuccessfulResult = Result{
|
||||
Hostname: "example.org",
|
||||
IP: "127.0.0.1",
|
||||
HTTPStatus: 200,
|
||||
body: []byte("body"),
|
||||
Errors: []string{"error-1", "error-2"},
|
||||
Connected: true,
|
||||
Success: false,
|
||||
Timestamp: timestamp,
|
||||
Duration: 750 * time.Millisecond,
|
||||
CertificateExpiration: 10 * time.Hour,
|
||||
ConditionResults: []*ConditionResult{
|
||||
{
|
||||
Condition: "[STATUS] == 200",
|
||||
Success: true,
|
||||
},
|
||||
{
|
||||
Condition: "[RESPONSE_TIME] < 500",
|
||||
Success: false,
|
||||
},
|
||||
{
|
||||
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
|
||||
Success: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func BenchmarkServiceStatus_WithResultPagination(b *testing.B) {
|
||||
service := &testService
|
||||
serviceStatus := NewServiceStatus(service)
|
||||
for i := 0; i < MaximumNumberOfResults; i++ {
|
||||
serviceStatus.AddResult(&testSuccessfulResult)
|
||||
}
|
||||
for n := 0; n < b.N; n++ {
|
||||
serviceStatus.WithResultPagination(1, 20)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
@@ -1,66 +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)
|
||||
}
|
||||
if serviceStatus.Key != "group_name" {
|
||||
t.Errorf("expected %s, got %s", "group_name", serviceStatus.Key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceStatus_AddResult(t *testing.T) {
|
||||
service := &Service{Name: "name", Group: "group"}
|
||||
serviceStatus := NewServiceStatus(service)
|
||||
for i := 0; i < MaximumNumberOfResults+10; i++ {
|
||||
serviceStatus.AddResult(&Result{Timestamp: time.Now()})
|
||||
}
|
||||
if len(serviceStatus.Results) != MaximumNumberOfResults {
|
||||
t.Errorf("expected serviceStatus.Results to not exceed a length of %d", MaximumNumberOfResults)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceStatus_WithResultPagination(t *testing.T) {
|
||||
service := &Service{Name: "name", Group: "group"}
|
||||
serviceStatus := NewServiceStatus(service)
|
||||
for i := 0; i < 25; i++ {
|
||||
serviceStatus.AddResult(&Result{Timestamp: time.Now()})
|
||||
}
|
||||
if len(serviceStatus.WithResultPagination(1, 1).Results) != 1 {
|
||||
t.Errorf("expected to have 1 result")
|
||||
}
|
||||
if len(serviceStatus.WithResultPagination(5, 0).Results) != 0 {
|
||||
t.Errorf("expected to have 0 results")
|
||||
}
|
||||
if len(serviceStatus.WithResultPagination(-1, 20).Results) != 0 {
|
||||
t.Errorf("expected to have 0 result, because the page was invalid")
|
||||
}
|
||||
if len(serviceStatus.WithResultPagination(1, -1).Results) != 0 {
|
||||
t.Errorf("expected to have 0 result, because the page size was invalid")
|
||||
}
|
||||
if len(serviceStatus.WithResultPagination(1, 10).Results) != 10 {
|
||||
t.Errorf("expected to have 10 results, because given a page size of 10, page 1 should have 10 elements")
|
||||
}
|
||||
if len(serviceStatus.WithResultPagination(2, 10).Results) != 10 {
|
||||
t.Errorf("expected to have 10 results, because given a page size of 10, page 2 should have 10 elements")
|
||||
}
|
||||
if len(serviceStatus.WithResultPagination(3, 10).Results) != 5 {
|
||||
t.Errorf("expected to have 5 results, because given a page size of 10, page 3 should have 5 elements")
|
||||
}
|
||||
if len(serviceStatus.WithResultPagination(4, 10).Results) != 0 {
|
||||
t.Errorf("expected to have 0 results, because given a page size of 10, page 4 should have 0 elements")
|
||||
}
|
||||
if len(serviceStatus.WithResultPagination(1, 50).Results) != 25 {
|
||||
t.Errorf("expected to have 25 results, because there's only 25 results")
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/client"
|
||||
"github.com/TwinProduction/gatus/util"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -77,8 +79,13 @@ type Service struct {
|
||||
Alerts []*alert.Alert `yaml:"alerts"`
|
||||
|
||||
// Insecure is whether to skip verifying the server's certificate chain and host name
|
||||
//
|
||||
// deprecated
|
||||
Insecure bool `yaml:"insecure,omitempty"`
|
||||
|
||||
// ClientConfig is the configuration of the client used to communicate with the service's target
|
||||
ClientConfig *client.Config `yaml:"client"`
|
||||
|
||||
// NumberOfFailuresInARow is the number of unsuccessful evaluations in a row
|
||||
NumberOfFailuresInARow int
|
||||
|
||||
@@ -89,6 +96,16 @@ type Service struct {
|
||||
// ValidateAndSetDefaults validates the service's configuration and sets the default value of fields that have one
|
||||
func (service *Service) ValidateAndSetDefaults() error {
|
||||
// Set default values
|
||||
if service.ClientConfig == nil {
|
||||
service.ClientConfig = client.GetDefaultConfig()
|
||||
// XXX: remove the next 3 lines in v3.0.0
|
||||
if service.Insecure {
|
||||
log.Println("WARNING: services[].insecure has been deprecated and will be removed in v3.0.0 in favor of services[].client.insecure")
|
||||
service.ClientConfig.Insecure = true
|
||||
}
|
||||
} else {
|
||||
service.ClientConfig.ValidateAndSetDefaults()
|
||||
}
|
||||
if service.Interval == 0 {
|
||||
service.Interval = 1 * time.Minute
|
||||
}
|
||||
@@ -135,6 +152,11 @@ func (service *Service) ValidateAndSetDefaults() error {
|
||||
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.
|
||||
func (service *Service) EvaluateHealth() *Result {
|
||||
result := &Result{Success: true, Errors: []string{}}
|
||||
@@ -193,7 +215,7 @@ func (service *Service) call(result *Result) {
|
||||
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)
|
||||
result.Connected, certificate, err = client.CanPerformStartTLS(strings.TrimPrefix(service.URL, "starttls://"), service.ClientConfig)
|
||||
if err != nil {
|
||||
result.AddError(err.Error())
|
||||
return
|
||||
@@ -201,12 +223,12 @@ func (service *Service) call(result *Result) {
|
||||
result.Duration = time.Since(startTime)
|
||||
result.CertificateExpiration = time.Until(certificate.NotAfter)
|
||||
} else if isServiceTCP {
|
||||
result.Connected = client.CanCreateTCPConnection(strings.TrimPrefix(service.URL, "tcp://"))
|
||||
result.Connected = client.CanCreateTCPConnection(strings.TrimPrefix(service.URL, "tcp://"), service.ClientConfig)
|
||||
result.Duration = time.Since(startTime)
|
||||
} else if isServiceICMP {
|
||||
result.Connected, result.Duration = client.Ping(strings.TrimPrefix(service.URL, "icmp://"))
|
||||
result.Connected, result.Duration = client.Ping(strings.TrimPrefix(service.URL, "icmp://"), service.ClientConfig)
|
||||
} else {
|
||||
response, err = client.GetHTTPClient(service.Insecure).Do(request)
|
||||
response, err = client.GetHTTPClient(service.ClientConfig).Do(request)
|
||||
result.Duration = time.Since(startTime)
|
||||
if err != nil {
|
||||
result.AddError(err.Error())
|
||||
|
||||
46
core/service_status.go
Normal file
46
core/service_status.go
Normal file
@@ -0,0 +1,46 @@
|
||||
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"`
|
||||
|
||||
// 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.
|
||||
//
|
||||
// TODO: Get rid of this in favor of using the new store.GetUptimeByKey.
|
||||
// TODO: For memory, store the uptime in a different map? (is that possible, given that we need to persist it through gocache?)
|
||||
// Deprecated
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/client"
|
||||
)
|
||||
|
||||
func TestService_ValidateAndSetDefaults(t *testing.T) {
|
||||
@@ -18,6 +19,19 @@ func TestService_ValidateAndSetDefaults(t *testing.T) {
|
||||
Alerts: []*alert.Alert{{Type: alert.TypePagerDuty}},
|
||||
}
|
||||
service.ValidateAndSetDefaults()
|
||||
if service.ClientConfig == nil {
|
||||
t.Error("client configuration should've been set to the default configuration")
|
||||
} else {
|
||||
if service.ClientConfig.Insecure != client.GetDefaultConfig().Insecure {
|
||||
t.Errorf("Default client configuration should've set Insecure to %v, got %v", client.GetDefaultConfig().Insecure, service.ClientConfig.Insecure)
|
||||
}
|
||||
if service.ClientConfig.IgnoreRedirect != client.GetDefaultConfig().IgnoreRedirect {
|
||||
t.Errorf("Default client configuration should've set IgnoreRedirect to %v, got %v", client.GetDefaultConfig().IgnoreRedirect, service.ClientConfig.IgnoreRedirect)
|
||||
}
|
||||
if service.ClientConfig.Timeout != client.GetDefaultConfig().Timeout {
|
||||
t.Errorf("Default client configuration should've set Timeout to %v, got %v", client.GetDefaultConfig().Timeout, service.ClientConfig.Timeout)
|
||||
}
|
||||
}
|
||||
if service.Method != "GET" {
|
||||
t.Error("Service method should've defaulted to GET")
|
||||
}
|
||||
@@ -41,6 +55,34 @@ func TestService_ValidateAndSetDefaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ValidateAndSetDefaultsWithClientConfig(t *testing.T) {
|
||||
condition := Condition("[STATUS] == 200")
|
||||
service := Service{
|
||||
Name: "twinnation-health",
|
||||
URL: "https://twinnation.org/health",
|
||||
Conditions: []*Condition{&condition},
|
||||
ClientConfig: &client.Config{
|
||||
Insecure: true,
|
||||
IgnoreRedirect: true,
|
||||
Timeout: 0,
|
||||
},
|
||||
}
|
||||
service.ValidateAndSetDefaults()
|
||||
if service.ClientConfig == nil {
|
||||
t.Error("client configuration should've been set to the default configuration")
|
||||
} else {
|
||||
if !service.ClientConfig.Insecure {
|
||||
t.Error("service.ClientConfig.Insecure should've been set to true")
|
||||
}
|
||||
if !service.ClientConfig.IgnoreRedirect {
|
||||
t.Error("service.ClientConfig.IgnoreRedirect should've been set to true")
|
||||
}
|
||||
if service.ClientConfig.Timeout != client.GetDefaultConfig().Timeout {
|
||||
t.Error("service.ClientConfig.Timeout should've been set to 10s, because the timeout value entered is not set or invalid")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ValidateAndSetDefaultsWithNoName(t *testing.T) {
|
||||
defer func() { recover() }()
|
||||
condition := Condition("[STATUS] == 200")
|
||||
@@ -205,6 +247,7 @@ func TestIntegrationEvaluateHealth(t *testing.T) {
|
||||
URL: "https://twinnation.org/health",
|
||||
Conditions: []*Condition{&condition, &bodyCondition},
|
||||
}
|
||||
service.ValidateAndSetDefaults()
|
||||
result := service.EvaluateHealth()
|
||||
if !result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a success", condition)
|
||||
@@ -224,6 +267,7 @@ func TestIntegrationEvaluateHealthWithFailure(t *testing.T) {
|
||||
URL: "https://twinnation.org/health",
|
||||
Conditions: []*Condition{&condition},
|
||||
}
|
||||
service.ValidateAndSetDefaults()
|
||||
result := service.EvaluateHealth()
|
||||
if result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a failure", condition)
|
||||
@@ -248,6 +292,7 @@ func TestIntegrationEvaluateHealthForDNS(t *testing.T) {
|
||||
},
|
||||
Conditions: []*Condition{&conditionSuccess, &conditionBody},
|
||||
}
|
||||
service.ValidateAndSetDefaults()
|
||||
result := service.EvaluateHealth()
|
||||
if !result.ConditionResults[0].Success {
|
||||
t.Errorf("Conditions '%s' and %s should have been a success", conditionSuccess, conditionBody)
|
||||
@@ -267,6 +312,7 @@ func TestIntegrationEvaluateHealthForICMP(t *testing.T) {
|
||||
URL: "icmp://127.0.0.1",
|
||||
Conditions: []*Condition{&conditionSuccess},
|
||||
}
|
||||
service.ValidateAndSetDefaults()
|
||||
result := service.EvaluateHealth()
|
||||
if !result.ConditionResults[0].Success {
|
||||
t.Errorf("Conditions '%s' should have been a success", conditionSuccess)
|
||||
|
||||
122
core/uptime.go
122
core/uptime.go
@@ -1,22 +1,8 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
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 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
|
||||
|
||||
// SuccessfulExecutionsPerHour is a map containing the number of successes (value)
|
||||
// for every hourly unix timestamps (key)
|
||||
// Deprecated
|
||||
@@ -28,6 +14,8 @@ type Uptime struct {
|
||||
TotalExecutionsPerHour map[int64]uint64 `json:"-"`
|
||||
|
||||
// HourlyStatistics is a map containing metrics collected (value) for every hourly unix timestamps (key)
|
||||
//
|
||||
// Used only if the storage type is memory
|
||||
HourlyStatistics map[int64]*HourlyUptimeStatistics `json:"-"`
|
||||
}
|
||||
|
||||
@@ -44,109 +32,3 @@ func NewUptime() *Uptime {
|
||||
HourlyStatistics: make(map[int64]*HourlyUptimeStatistics),
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessResult processes the result by extracting the relevant from the result and recalculating the uptime
|
||||
// if necessary
|
||||
func (uptime *Uptime) ProcessResult(result *Result) {
|
||||
// XXX: Remove this on v3.0.0
|
||||
if len(uptime.SuccessfulExecutionsPerHour) != 0 || len(uptime.TotalExecutionsPerHour) != 0 {
|
||||
uptime.migrateToHourlyStatistics()
|
||||
}
|
||||
if uptime.HourlyStatistics == nil {
|
||||
uptime.HourlyStatistics = make(map[int64]*HourlyUptimeStatistics)
|
||||
}
|
||||
unixTimestampFlooredAtHour := result.Timestamp.Unix() - (result.Timestamp.Unix() % 3600)
|
||||
hourlyStats, _ := uptime.HourlyStatistics[unixTimestampFlooredAtHour]
|
||||
if hourlyStats == nil {
|
||||
hourlyStats = &HourlyUptimeStatistics{}
|
||||
uptime.HourlyStatistics[unixTimestampFlooredAtHour] = hourlyStats
|
||||
}
|
||||
if result.Success {
|
||||
hourlyStats.SuccessfulExecutions++
|
||||
}
|
||||
hourlyStats.TotalExecutions++
|
||||
hourlyStats.TotalExecutionsResponseTime += uint64(result.Duration.Milliseconds())
|
||||
// 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.HourlyStatistics) > numberOfHoursInTenDays {
|
||||
sevenDaysAgo := time.Now().Add(-(sevenDays + time.Hour)).Unix()
|
||||
for hourlyUnixTimestamp := range uptime.HourlyStatistics {
|
||||
if sevenDaysAgo > hourlyUnixTimestamp {
|
||||
delete(uptime.HourlyStatistics, hourlyUnixTimestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
hourlyUnixTimestamp := timestamp.Unix() - (timestamp.Unix() % 3600)
|
||||
hourlyStats := uptime.HourlyStatistics[hourlyUnixTimestamp]
|
||||
if hourlyStats == nil || hourlyStats.TotalExecutions == 0 {
|
||||
timestamp = timestamp.Add(time.Hour)
|
||||
continue
|
||||
}
|
||||
uptimeBrackets["7d_success"] += hourlyStats.SuccessfulExecutions
|
||||
uptimeBrackets["7d_total"] += hourlyStats.TotalExecutions
|
||||
if now.Sub(timestamp) <= 24*time.Hour {
|
||||
uptimeBrackets["24h_success"] += hourlyStats.SuccessfulExecutions
|
||||
uptimeBrackets["24h_total"] += hourlyStats.TotalExecutions
|
||||
}
|
||||
if now.Sub(timestamp) <= time.Hour {
|
||||
uptimeBrackets["1h_success"] += hourlyStats.SuccessfulExecutions
|
||||
uptimeBrackets["1h_total"] += hourlyStats.TotalExecutions
|
||||
}
|
||||
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"])
|
||||
}
|
||||
}
|
||||
|
||||
// XXX: Remove this on v3.0.0
|
||||
// Deprecated
|
||||
func (uptime *Uptime) migrateToHourlyStatistics() {
|
||||
log.Println("[migrateToHourlyStatistics] Got", len(uptime.SuccessfulExecutionsPerHour), "entries for successful executions and", len(uptime.TotalExecutionsPerHour), "entries for total executions")
|
||||
uptime.HourlyStatistics = make(map[int64]*HourlyUptimeStatistics)
|
||||
for hourlyUnixTimestamp, totalExecutions := range uptime.TotalExecutionsPerHour {
|
||||
if totalExecutions == 0 {
|
||||
log.Println("[migrateToHourlyStatistics] Skipping entry at", hourlyUnixTimestamp, "because total number of executions is 0")
|
||||
continue
|
||||
}
|
||||
uptime.HourlyStatistics[hourlyUnixTimestamp] = &HourlyUptimeStatistics{
|
||||
TotalExecutions: totalExecutions,
|
||||
SuccessfulExecutions: uptime.SuccessfulExecutionsPerHour[hourlyUnixTimestamp],
|
||||
TotalExecutionsResponseTime: 0,
|
||||
}
|
||||
}
|
||||
log.Println("[migrateToHourlyStatistics] Migrated", len(uptime.HourlyStatistics), "entries")
|
||||
uptime.SuccessfulExecutionsPerHour = nil
|
||||
uptime.TotalExecutionsPerHour = nil
|
||||
}
|
||||
|
||||
@@ -1,96 +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)
|
||||
|
||||
now := time.Now()
|
||||
now = time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())
|
||||
uptime.ProcessResult(&Result{Timestamp: now.Add(-7 * 24 * time.Hour), Success: true})
|
||||
checkUptimes(t, serviceStatus, 1.00, 0.00, 0.00)
|
||||
|
||||
uptime.ProcessResult(&Result{Timestamp: now.Add(-6 * 24 * time.Hour), Success: false})
|
||||
checkUptimes(t, serviceStatus, 0.50, 0.00, 0.00)
|
||||
|
||||
uptime.ProcessResult(&Result{Timestamp: now.Add(-8 * 24 * time.Hour), Success: true})
|
||||
checkUptimes(t, serviceStatus, 0.50, 0.00, 0.00)
|
||||
|
||||
uptime.ProcessResult(&Result{Timestamp: now.Add(-24 * time.Hour), Success: true})
|
||||
uptime.ProcessResult(&Result{Timestamp: now.Add(-12 * time.Hour), Success: true})
|
||||
checkUptimes(t, serviceStatus, 0.75, 1.00, 0.00)
|
||||
|
||||
uptime.ProcessResult(&Result{Timestamp: now.Add(-1 * time.Hour), Success: true, Duration: 10 * time.Millisecond})
|
||||
checkHourlyStatistics(t, uptime.HourlyStatistics[now.Unix()-now.Unix()%3600-3600], 10, 1, 1)
|
||||
uptime.ProcessResult(&Result{Timestamp: now.Add(-30 * time.Minute), Success: false, Duration: 500 * time.Millisecond})
|
||||
checkHourlyStatistics(t, uptime.HourlyStatistics[now.Unix()-now.Unix()%3600-3600], 510, 2, 1)
|
||||
uptime.ProcessResult(&Result{Timestamp: now.Add(-15 * time.Minute), Success: false, Duration: 25 * time.Millisecond})
|
||||
checkHourlyStatistics(t, uptime.HourlyStatistics[now.Unix()-now.Unix()%3600-3600], 535, 3, 1)
|
||||
|
||||
uptime.ProcessResult(&Result{Timestamp: now.Add(-10 * time.Minute), Success: false})
|
||||
checkUptimes(t, serviceStatus, 0.50, 0.50, 0.25)
|
||||
|
||||
uptime.ProcessResult(&Result{Timestamp: now.Add(-120 * time.Hour), Success: true})
|
||||
uptime.ProcessResult(&Result{Timestamp: now.Add(-119 * time.Hour), Success: true})
|
||||
uptime.ProcessResult(&Result{Timestamp: now.Add(-118 * time.Hour), Success: true})
|
||||
uptime.ProcessResult(&Result{Timestamp: now.Add(-117 * time.Hour), Success: true})
|
||||
uptime.ProcessResult(&Result{Timestamp: now.Add(-10 * time.Hour), Success: true})
|
||||
uptime.ProcessResult(&Result{Timestamp: now.Add(-8 * time.Hour), Success: true})
|
||||
uptime.ProcessResult(&Result{Timestamp: now.Add(-30 * time.Minute), Success: true})
|
||||
uptime.ProcessResult(&Result{Timestamp: 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.HourlyStatistics) > numberOfHoursInTenDays {
|
||||
t.Errorf("At no point in time should there be more than %d entries in serviceStatus.SuccessfulExecutionsPerHour, but there are %d", numberOfHoursInTenDays, len(serviceStatus.Uptime.HourlyStatistics))
|
||||
}
|
||||
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 3 minutes
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func checkHourlyStatistics(t *testing.T, hourlyUptimeStatistics *HourlyUptimeStatistics, expectedTotalExecutionsResponseTime uint64, expectedTotalExecutions uint64, expectedSuccessfulExecutions uint64) {
|
||||
if hourlyUptimeStatistics.TotalExecutionsResponseTime != expectedTotalExecutionsResponseTime {
|
||||
t.Error("TotalExecutionsResponseTime should've been", expectedTotalExecutionsResponseTime, "got", hourlyUptimeStatistics.TotalExecutionsResponseTime)
|
||||
}
|
||||
if hourlyUptimeStatistics.TotalExecutions != expectedTotalExecutions {
|
||||
t.Error("TotalExecutions should've been", expectedTotalExecutions, "got", hourlyUptimeStatistics.TotalExecutions)
|
||||
}
|
||||
if hourlyUptimeStatistics.SuccessfulExecutions != expectedSuccessfulExecutions {
|
||||
t.Error("SuccessfulExecutions should've been", expectedSuccessfulExecutions, "got", hourlyUptimeStatistics.SuccessfulExecutions)
|
||||
}
|
||||
}
|
||||
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/
|
||||
2
go.mod
2
go.mod
@@ -14,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
|
||||
|
||||
50
go.sum
50
go.sum
@@ -99,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=
|
||||
@@ -183,6 +185,7 @@ github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||
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/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -264,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=
|
||||
@@ -282,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=
|
||||
@@ -379,6 +388,8 @@ github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4O
|
||||
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=
|
||||
@@ -480,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=
|
||||
@@ -576,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=
|
||||
@@ -594,10 +607,11 @@ golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201126233918-771906719818/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
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/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf h1:MZ2shdL+ZM/XzY3ZGOnh4Nlpnxz5GSOhOmtHo3iPU6M=
|
||||
@@ -667,7 +681,9 @@ 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=
|
||||
@@ -812,6 +828,36 @@ 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=
|
||||
|
||||
12
main.go
12
main.go
@@ -34,6 +34,12 @@ func main() {
|
||||
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()
|
||||
@@ -46,12 +52,6 @@ func save() {
|
||||
}
|
||||
}
|
||||
|
||||
func start(cfg *config.Config) {
|
||||
go controller.Handle(cfg.Security, cfg.Web, cfg.Metrics)
|
||||
watchdog.Monitor(cfg)
|
||||
go listenToConfigurationFileChanges(cfg)
|
||||
}
|
||||
|
||||
func loadConfiguration() (cfg *config.Config, err error) {
|
||||
customConfigFile := os.Getenv("GATUS_CONFIG_FILE")
|
||||
if len(customConfigFile) > 0 {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package storage
|
||||
|
||||
// Config is the configuration for alerting providers
|
||||
// Config is the configuration for storage
|
||||
type Config struct {
|
||||
// File is the path of the file to use for persistence
|
||||
// If blank, persistence is disabled.
|
||||
// If blank, persistence is disabled
|
||||
File string `yaml:"file"`
|
||||
|
||||
// Type of store
|
||||
// If blank, uses the default in-memory store
|
||||
Type Type `yaml:"type"`
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/TwinProduction/gatus/storage/store"
|
||||
"github.com/TwinProduction/gatus/storage/store/memory"
|
||||
"github.com/TwinProduction/gatus/storage/store/sqlite"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -38,36 +39,52 @@ func Initialize(cfg *Config) error {
|
||||
initialized = true
|
||||
var err error
|
||||
if cancelFunc != nil {
|
||||
// Stop the active autoSave task
|
||||
// Stop the active autoSaveStore task, if there's already one
|
||||
cancelFunc()
|
||||
}
|
||||
if cfg == nil || len(cfg.File) == 0 {
|
||||
log.Println("[storage][Initialize] Creating storage provider")
|
||||
provider, _ = memory.NewStore("")
|
||||
if cfg == nil {
|
||||
cfg = &Config{}
|
||||
}
|
||||
if len(cfg.File) == 0 {
|
||||
log.Printf("[storage][Initialize] Creating storage provider with type=%s", cfg.Type)
|
||||
} else {
|
||||
ctx, cancelFunc = context.WithCancel(context.Background())
|
||||
log.Printf("[storage][Initialize] Creating storage provider with file=%s", cfg.File)
|
||||
provider, err = memory.NewStore(cfg.File)
|
||||
log.Printf("[storage][Initialize] Creating storage provider with type=%s and file=%s", cfg.Type, cfg.File)
|
||||
}
|
||||
ctx, cancelFunc = context.WithCancel(context.Background())
|
||||
switch cfg.Type {
|
||||
case TypeSQLite:
|
||||
provider, err = sqlite.NewStore(string(cfg.Type), cfg.File)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go autoSave(7*time.Minute, ctx)
|
||||
case TypeMemory:
|
||||
fallthrough
|
||||
default:
|
||||
if len(cfg.File) > 0 {
|
||||
provider, err = memory.NewStore(cfg.File)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go autoSaveStore(ctx, provider, 7*time.Minute)
|
||||
} else {
|
||||
provider, _ = memory.NewStore("")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// autoSave automatically calls the SaveFunc function of the provider at every interval
|
||||
func autoSave(interval time.Duration, ctx context.Context) {
|
||||
// autoSaveStore automatically calls the Save function of the provider at every interval
|
||||
func autoSaveStore(ctx context.Context, provider store.Store, interval time.Duration) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Printf("[storage][autoSave] Stopping active job")
|
||||
log.Printf("[storage][autoSaveStore] Stopping active job")
|
||||
return
|
||||
case <-time.After(interval):
|
||||
log.Printf("[storage][autoSave] Saving")
|
||||
log.Printf("[storage][autoSaveStore] Saving")
|
||||
err := provider.Save()
|
||||
if err != nil {
|
||||
log.Println("[storage][autoSave] Save failed:", err.Error())
|
||||
log.Println("[storage][autoSaveStore] Save failed:", err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,35 +3,92 @@ package storage
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwinProduction/gatus/storage/store/sqlite"
|
||||
)
|
||||
|
||||
func TestGet(t *testing.T) {
|
||||
store := Get()
|
||||
if store == nil {
|
||||
t.Error("store should've been automatically initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitialize(t *testing.T) {
|
||||
file := t.TempDir() + "/test.db"
|
||||
err := Initialize(&Config{File: file})
|
||||
if err != nil {
|
||||
t.Fatal("shouldn't have returned an error")
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Cfg *Config
|
||||
ExpectedErr error
|
||||
}
|
||||
if cancelFunc == nil {
|
||||
t.Error("cancelFunc shouldn't have been nil")
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "nil",
|
||||
Cfg: nil,
|
||||
ExpectedErr: nil,
|
||||
},
|
||||
{
|
||||
Name: "blank",
|
||||
Cfg: &Config{},
|
||||
ExpectedErr: nil,
|
||||
},
|
||||
{
|
||||
Name: "memory-no-file",
|
||||
Cfg: &Config{Type: TypeMemory},
|
||||
ExpectedErr: nil,
|
||||
},
|
||||
{
|
||||
Name: "memory-with-file",
|
||||
Cfg: &Config{Type: TypeMemory, File: t.TempDir() + "/TestInitialize_memory-with-file.db"},
|
||||
ExpectedErr: nil,
|
||||
},
|
||||
{
|
||||
Name: "sqlite-no-file",
|
||||
Cfg: &Config{Type: TypeSQLite},
|
||||
ExpectedErr: sqlite.ErrFilePathNotSpecified,
|
||||
},
|
||||
{
|
||||
Name: "sqlite-with-file",
|
||||
Cfg: &Config{Type: TypeSQLite, File: t.TempDir() + "/TestInitialize_sqlite-with-file.db"},
|
||||
ExpectedErr: nil,
|
||||
},
|
||||
}
|
||||
if ctx == nil {
|
||||
t.Error("ctx shouldn't have been nil")
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
err := Initialize(scenario.Cfg)
|
||||
if err != scenario.ExpectedErr {
|
||||
t.Errorf("expected %v, got %v", scenario.ExpectedErr, err)
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if cancelFunc == nil {
|
||||
t.Error("cancelFunc shouldn't have been nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
t.Error("ctx shouldn't have been nil")
|
||||
}
|
||||
if provider == nil {
|
||||
t.Fatal("provider shouldn't have been nit")
|
||||
}
|
||||
provider.Close()
|
||||
// Try to initialize it again
|
||||
err = Initialize(scenario.Cfg)
|
||||
if err != scenario.ExpectedErr {
|
||||
t.Errorf("expected %v, got %v", scenario.ExpectedErr, err)
|
||||
return
|
||||
}
|
||||
provider.Close()
|
||||
})
|
||||
}
|
||||
// Try to initialize it again
|
||||
err = Initialize(&Config{File: file})
|
||||
if err != nil {
|
||||
t.Fatal("shouldn't have returned an error")
|
||||
}
|
||||
cancelFunc()
|
||||
}
|
||||
|
||||
func TestAutoSave(t *testing.T) {
|
||||
file := t.TempDir() + "/test.db"
|
||||
file := t.TempDir() + "/TestAutoSave.db"
|
||||
if err := Initialize(&Config{File: file}); err != nil {
|
||||
t.Fatal("shouldn't have returned an error")
|
||||
}
|
||||
go autoSave(3*time.Millisecond, ctx)
|
||||
go autoSaveStore(ctx, provider, 3*time.Millisecond)
|
||||
time.Sleep(15 * time.Millisecond)
|
||||
cancelFunc()
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
|
||||
8
storage/store/common/errors.go
Normal file
8
storage/store/common/errors.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package common
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrServiceNotFound = errors.New("service not found") // When a service does not exist in the store
|
||||
ErrInvalidTimeRange = errors.New("'from' cannot be older than 'to'") // When an invalid time range is provided
|
||||
)
|
||||
9
storage/store/common/limits.go
Normal file
9
storage/store/common/limits.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package common
|
||||
|
||||
const (
|
||||
// MaximumNumberOfResults is the maximum number of results that a service can have
|
||||
MaximumNumberOfResults = 100
|
||||
|
||||
// MaximumNumberOfEvents is the maximum number of events that a service can have
|
||||
MaximumNumberOfEvents = 50
|
||||
)
|
||||
28
storage/store/common/paging/paging.go
Normal file
28
storage/store/common/paging/paging.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package paging
|
||||
|
||||
// ServiceStatusParams represents all parameters that can be used for paging purposes
|
||||
type ServiceStatusParams struct {
|
||||
EventsPage int // Number of the event page
|
||||
EventsPageSize int // Size of the event page
|
||||
ResultsPage int // Number of the result page
|
||||
ResultsPageSize int // Size of the result page
|
||||
}
|
||||
|
||||
// NewServiceStatusParams creates a new ServiceStatusParams
|
||||
func NewServiceStatusParams() *ServiceStatusParams {
|
||||
return &ServiceStatusParams{}
|
||||
}
|
||||
|
||||
// WithEvents sets the values for EventsPage and EventsPageSize
|
||||
func (params *ServiceStatusParams) WithEvents(page, pageSize int) *ServiceStatusParams {
|
||||
params.EventsPage = page
|
||||
params.EventsPageSize = pageSize
|
||||
return params
|
||||
}
|
||||
|
||||
// WithResults sets the values for ResultsPage and ResultsPageSize
|
||||
func (params *ServiceStatusParams) WithResults(page, pageSize int) *ServiceStatusParams {
|
||||
params.ResultsPage = page
|
||||
params.ResultsPageSize = pageSize
|
||||
return params
|
||||
}
|
||||
72
storage/store/common/paging/paging_test.go
Normal file
72
storage/store/common/paging/paging_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package paging
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNewServiceStatusParams(t *testing.T) {
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Params *ServiceStatusParams
|
||||
ExpectedEventsPage int
|
||||
ExpectedEventsPageSize int
|
||||
ExpectedResultsPage int
|
||||
ExpectedResultsPageSize int
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "empty-params",
|
||||
Params: NewServiceStatusParams(),
|
||||
ExpectedEventsPage: 0,
|
||||
ExpectedEventsPageSize: 0,
|
||||
ExpectedResultsPage: 0,
|
||||
ExpectedResultsPageSize: 0,
|
||||
},
|
||||
{
|
||||
Name: "with-events-page-2-size-7",
|
||||
Params: NewServiceStatusParams().WithEvents(2, 7),
|
||||
ExpectedEventsPage: 2,
|
||||
ExpectedEventsPageSize: 7,
|
||||
ExpectedResultsPage: 0,
|
||||
ExpectedResultsPageSize: 0,
|
||||
},
|
||||
{
|
||||
Name: "with-events-page-4-size-3-uptime",
|
||||
Params: NewServiceStatusParams().WithEvents(4, 3),
|
||||
ExpectedEventsPage: 4,
|
||||
ExpectedEventsPageSize: 3,
|
||||
ExpectedResultsPage: 0,
|
||||
ExpectedResultsPageSize: 0,
|
||||
},
|
||||
{
|
||||
Name: "with-results-page-1-size-20-uptime",
|
||||
Params: NewServiceStatusParams().WithResults(1, 20),
|
||||
ExpectedEventsPage: 0,
|
||||
ExpectedEventsPageSize: 0,
|
||||
ExpectedResultsPage: 1,
|
||||
ExpectedResultsPageSize: 20,
|
||||
},
|
||||
{
|
||||
Name: "with-results-page-2-size-10-events-page-3-size-50",
|
||||
Params: NewServiceStatusParams().WithResults(2, 10).WithEvents(3, 50),
|
||||
ExpectedEventsPage: 3,
|
||||
ExpectedEventsPageSize: 50,
|
||||
ExpectedResultsPage: 2,
|
||||
ExpectedResultsPageSize: 10,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
if scenario.Params.EventsPage != scenario.ExpectedEventsPage {
|
||||
t.Errorf("expected ExpectedEventsPage to be %d, was %d", scenario.ExpectedEventsPageSize, scenario.Params.EventsPage)
|
||||
}
|
||||
if scenario.Params.EventsPageSize != scenario.ExpectedEventsPageSize {
|
||||
t.Errorf("expected EventsPageSize to be %d, was %d", scenario.ExpectedEventsPageSize, scenario.Params.EventsPageSize)
|
||||
}
|
||||
if scenario.Params.ResultsPage != scenario.ExpectedResultsPage {
|
||||
t.Errorf("expected ResultsPage to be %d, was %d", scenario.ExpectedResultsPage, scenario.Params.ResultsPage)
|
||||
}
|
||||
if scenario.Params.ResultsPageSize != scenario.ExpectedResultsPageSize {
|
||||
t.Errorf("expected ResultsPageSize to be %d, was %d", scenario.ExpectedResultsPageSize, scenario.Params.ResultsPageSize)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,19 @@ package memory
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwinProduction/gatus/storage/store/common"
|
||||
"github.com/TwinProduction/gatus/storage/store/common/paging"
|
||||
"github.com/TwinProduction/gatus/util"
|
||||
"github.com/TwinProduction/gocache"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gob.Register(&core.ServiceStatus{})
|
||||
gob.Register(&core.HourlyUptimeStatistics{})
|
||||
gob.Register(&core.Uptime{})
|
||||
gob.Register(&core.Result{})
|
||||
gob.Register(&core.Event{})
|
||||
@@ -17,11 +22,15 @@ func init() {
|
||||
|
||||
// Store that leverages gocache
|
||||
type Store struct {
|
||||
sync.RWMutex
|
||||
file string
|
||||
cache *gocache.Cache
|
||||
}
|
||||
|
||||
// NewStore creates a new store
|
||||
// NewStore creates a new store using gocache.Cache
|
||||
//
|
||||
// This store holds everything in memory, and if the file parameter is not blank,
|
||||
// supports eventual persistence.
|
||||
func NewStore(file string) (*Store, error) {
|
||||
store := &Store{
|
||||
file: file,
|
||||
@@ -36,40 +45,75 @@ func NewStore(file string) (*Store, error) {
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// GetAllServiceStatusesWithResultPagination returns all monitored core.ServiceStatus
|
||||
// GetAllServiceStatuses returns all monitored core.ServiceStatus
|
||||
// with a subset of core.Result defined by the page and pageSize parameters
|
||||
func (s *Store) GetAllServiceStatusesWithResultPagination(page, pageSize int) map[string]*core.ServiceStatus {
|
||||
func (s *Store) GetAllServiceStatuses(params *paging.ServiceStatusParams) map[string]*core.ServiceStatus {
|
||||
serviceStatuses := s.cache.GetAll()
|
||||
pagedServiceStatuses := make(map[string]*core.ServiceStatus, len(serviceStatuses))
|
||||
for k, v := range serviceStatuses {
|
||||
pagedServiceStatuses[k] = v.(*core.ServiceStatus).WithResultPagination(page, pageSize)
|
||||
pagedServiceStatuses[k] = ShallowCopyServiceStatus(v.(*core.ServiceStatus), params)
|
||||
}
|
||||
return pagedServiceStatuses
|
||||
}
|
||||
|
||||
// GetServiceStatus returns the service status for a given service name in the given group
|
||||
func (s *Store) GetServiceStatus(groupName, serviceName string) *core.ServiceStatus {
|
||||
return s.GetServiceStatusByKey(util.ConvertGroupAndServiceToKey(groupName, serviceName))
|
||||
func (s *Store) GetServiceStatus(groupName, serviceName string, params *paging.ServiceStatusParams) *core.ServiceStatus {
|
||||
return s.GetServiceStatusByKey(util.ConvertGroupAndServiceToKey(groupName, serviceName), params)
|
||||
}
|
||||
|
||||
// GetServiceStatusByKey returns the service status for a given key
|
||||
func (s *Store) GetServiceStatusByKey(key string) *core.ServiceStatus {
|
||||
func (s *Store) GetServiceStatusByKey(key string, params *paging.ServiceStatusParams) *core.ServiceStatus {
|
||||
serviceStatus := s.cache.GetValue(key)
|
||||
if serviceStatus == nil {
|
||||
return nil
|
||||
}
|
||||
return serviceStatus.(*core.ServiceStatus)
|
||||
return ShallowCopyServiceStatus(serviceStatus.(*core.ServiceStatus), params)
|
||||
}
|
||||
|
||||
// GetUptimeByKey returns the uptime percentage during a time range
|
||||
func (s *Store) GetUptimeByKey(key string, from, to time.Time) (float64, error) {
|
||||
if from.After(to) {
|
||||
return 0, common.ErrInvalidTimeRange
|
||||
}
|
||||
serviceStatus := s.cache.GetValue(key)
|
||||
if serviceStatus == nil || serviceStatus.(*core.ServiceStatus).Uptime == nil {
|
||||
return 0, common.ErrServiceNotFound
|
||||
}
|
||||
successfulExecutions := uint64(0)
|
||||
totalExecutions := uint64(0)
|
||||
current := from
|
||||
for to.Sub(current) >= 0 {
|
||||
hourlyUnixTimestamp := current.Truncate(time.Hour).Unix()
|
||||
hourlyStats := serviceStatus.(*core.ServiceStatus).Uptime.HourlyStatistics[hourlyUnixTimestamp]
|
||||
if hourlyStats == nil || hourlyStats.TotalExecutions == 0 {
|
||||
current = current.Add(time.Hour)
|
||||
continue
|
||||
}
|
||||
successfulExecutions += hourlyStats.SuccessfulExecutions
|
||||
totalExecutions += hourlyStats.TotalExecutions
|
||||
current = current.Add(time.Hour)
|
||||
}
|
||||
if totalExecutions == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return float64(successfulExecutions) / float64(totalExecutions), nil
|
||||
}
|
||||
|
||||
// Insert adds the observed result for the specified service into the store
|
||||
func (s *Store) Insert(service *core.Service, result *core.Result) {
|
||||
key := util.ConvertGroupAndServiceToKey(service.Group, service.Name)
|
||||
key := service.Key()
|
||||
s.Lock()
|
||||
serviceStatus, exists := s.cache.Get(key)
|
||||
if !exists {
|
||||
serviceStatus = core.NewServiceStatus(service)
|
||||
serviceStatus = core.NewServiceStatus(key, service.Group, service.Name)
|
||||
serviceStatus.(*core.ServiceStatus).Events = append(serviceStatus.(*core.ServiceStatus).Events, &core.Event{
|
||||
Type: core.EventStart,
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
}
|
||||
serviceStatus.(*core.ServiceStatus).AddResult(result)
|
||||
AddResult(serviceStatus.(*core.ServiceStatus), result)
|
||||
s.cache.Set(key, serviceStatus)
|
||||
s.Unlock()
|
||||
}
|
||||
|
||||
// DeleteAllServiceStatusesNotInKeys removes all ServiceStatus that are not within the keys provided
|
||||
@@ -102,3 +146,8 @@ func (s *Store) Save() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close does nothing, because there's nothing to close
|
||||
func (s *Store) Close() {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwinProduction/gatus/util"
|
||||
"github.com/TwinProduction/gatus/storage/store/common/paging"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -81,174 +80,31 @@ var (
|
||||
}
|
||||
)
|
||||
|
||||
func TestStore_Insert(t *testing.T) {
|
||||
// Note that are much more extensive tests in /storage/store/store_test.go.
|
||||
// This test is simply an extra sanity check
|
||||
func TestStore_SanityCheck(t *testing.T) {
|
||||
store, _ := NewStore("")
|
||||
store.Insert(&testService, &testSuccessfulResult)
|
||||
if numberOfServiceStatuses := len(store.GetAllServiceStatuses(paging.NewServiceStatusParams())); numberOfServiceStatuses != 1 {
|
||||
t.Fatalf("expected 1 ServiceStatus, got %d", numberOfServiceStatuses)
|
||||
}
|
||||
store.Insert(&testService, &testUnsuccessfulResult)
|
||||
|
||||
if store.cache.Count() != 1 {
|
||||
t.Fatalf("expected 1 ServiceStatus, got %d", store.cache.Count())
|
||||
// Both results inserted are for the same service, therefore, the count shouldn't have increased
|
||||
if numberOfServiceStatuses := len(store.GetAllServiceStatuses(paging.NewServiceStatusParams())); numberOfServiceStatuses != 1 {
|
||||
t.Fatalf("expected 1 ServiceStatus, got %d", numberOfServiceStatuses)
|
||||
}
|
||||
key := fmt.Sprintf("%s_%s", testService.Group, testService.Name)
|
||||
serviceStatus := store.GetServiceStatusByKey(key)
|
||||
if serviceStatus == nil {
|
||||
t.Fatalf("Store should've had key '%s', but didn't", key)
|
||||
ss := store.GetServiceStatus(testService.Group, testService.Name, paging.NewServiceStatusParams().WithResults(1, 20).WithEvents(1, 20))
|
||||
if ss == nil {
|
||||
t.Fatalf("Store should've had key '%s', but didn't", testService.Key())
|
||||
}
|
||||
if len(serviceStatus.Results) != 2 {
|
||||
t.Fatalf("Service '%s' should've had 2 results, but actually returned %d", serviceStatus.Name, len(serviceStatus.Results))
|
||||
if len(ss.Events) != 3 {
|
||||
t.Errorf("Service '%s' should've had 3 events, got %d", ss.Name, len(ss.Events))
|
||||
}
|
||||
for i, r := range serviceStatus.Results {
|
||||
expectedResult := store.GetServiceStatus(testService.Group, testService.Name).Results[i]
|
||||
if r.HTTPStatus != expectedResult.HTTPStatus {
|
||||
t.Errorf("Result at index %d should've had a HTTPStatus of %d, but was actually %d", i, expectedResult.HTTPStatus, r.HTTPStatus)
|
||||
}
|
||||
if r.DNSRCode != expectedResult.DNSRCode {
|
||||
t.Errorf("Result at index %d should've had a DNSRCode of %s, but was actually %s", i, expectedResult.DNSRCode, r.DNSRCode)
|
||||
}
|
||||
if r.Hostname != expectedResult.Hostname {
|
||||
t.Errorf("Result at index %d should've had a Hostname of %s, but was actually %s", i, expectedResult.Hostname, r.Hostname)
|
||||
}
|
||||
if r.IP != expectedResult.IP {
|
||||
t.Errorf("Result at index %d should've had a IP of %s, but was actually %s", i, expectedResult.IP, r.IP)
|
||||
}
|
||||
if r.Connected != expectedResult.Connected {
|
||||
t.Errorf("Result at index %d should've had a Connected value of %t, but was actually %t", i, expectedResult.Connected, r.Connected)
|
||||
}
|
||||
if r.Duration != expectedResult.Duration {
|
||||
t.Errorf("Result at index %d should've had a Duration of %s, but was actually %s", i, expectedResult.Duration.String(), r.Duration.String())
|
||||
}
|
||||
if len(r.Errors) != len(expectedResult.Errors) {
|
||||
t.Errorf("Result at index %d should've had %d errors, but actually had %d errors", i, len(expectedResult.Errors), len(r.Errors))
|
||||
}
|
||||
if len(r.ConditionResults) != len(expectedResult.ConditionResults) {
|
||||
t.Errorf("Result at index %d should've had %d ConditionResults, but actually had %d ConditionResults", i, len(expectedResult.ConditionResults), len(r.ConditionResults))
|
||||
}
|
||||
if r.Success != expectedResult.Success {
|
||||
t.Errorf("Result at index %d should've had a Success of %t, but was actually %t", i, expectedResult.Success, r.Success)
|
||||
}
|
||||
if r.Timestamp != expectedResult.Timestamp {
|
||||
t.Errorf("Result at index %d should've had a Timestamp of %s, but was actually %s", i, expectedResult.Timestamp.String(), r.Timestamp.String())
|
||||
}
|
||||
if r.CertificateExpiration != expectedResult.CertificateExpiration {
|
||||
t.Errorf("Result at index %d should've had a CertificateExpiration of %s, but was actually %s", i, expectedResult.CertificateExpiration.String(), r.CertificateExpiration.String())
|
||||
}
|
||||
if len(ss.Results) != 2 {
|
||||
t.Errorf("Service '%s' should've had 2 results, got %d", ss.Name, len(ss.Results))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_GetServiceStatus(t *testing.T) {
|
||||
store, _ := NewStore("")
|
||||
store.Insert(&testService, &testSuccessfulResult)
|
||||
store.Insert(&testService, &testUnsuccessfulResult)
|
||||
|
||||
serviceStatus := store.GetServiceStatus(testService.Group, testService.Name)
|
||||
if serviceStatus == nil {
|
||||
t.Fatalf("serviceStatus shouldn't have been nil")
|
||||
}
|
||||
if serviceStatus.Uptime == nil {
|
||||
t.Fatalf("serviceStatus.Uptime shouldn't have been nil")
|
||||
}
|
||||
if serviceStatus.Uptime.LastHour != 0.5 {
|
||||
t.Errorf("serviceStatus.Uptime.LastHour should've been 0.5")
|
||||
}
|
||||
if serviceStatus.Uptime.LastTwentyFourHours != 0.5 {
|
||||
t.Errorf("serviceStatus.Uptime.LastTwentyFourHours should've been 0.5")
|
||||
}
|
||||
if serviceStatus.Uptime.LastSevenDays != 0.5 {
|
||||
t.Errorf("serviceStatus.Uptime.LastSevenDays should've been 0.5")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_GetServiceStatusForMissingStatusReturnsNil(t *testing.T) {
|
||||
store, _ := NewStore("")
|
||||
store.Insert(&testService, &testSuccessfulResult)
|
||||
|
||||
serviceStatus := store.GetServiceStatus("nonexistantgroup", "nonexistantname")
|
||||
if serviceStatus != nil {
|
||||
t.Errorf("Returned service status for group '%s' and name '%s' not nil after inserting the service into the store", testService.Group, testService.Name)
|
||||
}
|
||||
serviceStatus = store.GetServiceStatus(testService.Group, "nonexistantname")
|
||||
if serviceStatus != nil {
|
||||
t.Errorf("Returned service status for group '%s' and name '%s' not nil after inserting the service into the store", testService.Group, "nonexistantname")
|
||||
}
|
||||
serviceStatus = store.GetServiceStatus("nonexistantgroup", testService.Name)
|
||||
if serviceStatus != nil {
|
||||
t.Errorf("Returned service status for group '%s' and name '%s' not nil after inserting the service into the store", "nonexistantgroup", testService.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_GetServiceStatusByKey(t *testing.T) {
|
||||
store, _ := NewStore("")
|
||||
store.Insert(&testService, &testSuccessfulResult)
|
||||
store.Insert(&testService, &testUnsuccessfulResult)
|
||||
|
||||
serviceStatus := store.GetServiceStatusByKey(util.ConvertGroupAndServiceToKey(testService.Group, testService.Name))
|
||||
if serviceStatus == nil {
|
||||
t.Fatalf("serviceStatus shouldn't have been nil")
|
||||
}
|
||||
if serviceStatus.Uptime == nil {
|
||||
t.Fatalf("serviceStatus.Uptime shouldn't have been nil")
|
||||
}
|
||||
if serviceStatus.Uptime.LastHour != 0.5 {
|
||||
t.Errorf("serviceStatus.Uptime.LastHour should've been 0.5")
|
||||
}
|
||||
if serviceStatus.Uptime.LastTwentyFourHours != 0.5 {
|
||||
t.Errorf("serviceStatus.Uptime.LastTwentyFourHours should've been 0.5")
|
||||
}
|
||||
if serviceStatus.Uptime.LastSevenDays != 0.5 {
|
||||
t.Errorf("serviceStatus.Uptime.LastSevenDays should've been 0.5")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_GetAllServiceStatusesWithResultPagination(t *testing.T) {
|
||||
store, _ := NewStore("")
|
||||
firstResult := &testSuccessfulResult
|
||||
secondResult := &testUnsuccessfulResult
|
||||
store.Insert(&testService, firstResult)
|
||||
store.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{}
|
||||
serviceStatuses := store.GetAllServiceStatusesWithResultPagination(1, 20)
|
||||
if len(serviceStatuses) != 1 {
|
||||
t.Fatal("expected 1 service status")
|
||||
}
|
||||
actual, exists := serviceStatuses[util.ConvertGroupAndServiceToKey(testService.Group, testService.Name)]
|
||||
if !exists {
|
||||
t.Fatal("expected service status to exist")
|
||||
}
|
||||
if len(actual.Results) != 2 {
|
||||
t.Error("expected 2 results, got", len(actual.Results))
|
||||
}
|
||||
if len(actual.Events) != 2 {
|
||||
t.Error("expected 2 events, got", len(actual.Events))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_DeleteAllServiceStatusesNotInKeys(t *testing.T) {
|
||||
store, _ := NewStore("")
|
||||
firstService := core.Service{Name: "service-1", Group: "group"}
|
||||
secondService := core.Service{Name: "service-2", Group: "group"}
|
||||
result := &testSuccessfulResult
|
||||
store.Insert(&firstService, result)
|
||||
store.Insert(&secondService, result)
|
||||
if store.cache.Count() != 2 {
|
||||
t.Errorf("expected cache to have 2 keys, got %d", store.cache.Count())
|
||||
}
|
||||
if store.GetServiceStatusByKey(util.ConvertGroupAndServiceToKey(firstService.Group, firstService.Name)) == nil {
|
||||
t.Fatal("firstService should exist")
|
||||
}
|
||||
if store.GetServiceStatusByKey(util.ConvertGroupAndServiceToKey(secondService.Group, secondService.Name)) == nil {
|
||||
t.Fatal("secondService should exist")
|
||||
}
|
||||
store.DeleteAllServiceStatusesNotInKeys([]string{util.ConvertGroupAndServiceToKey(firstService.Group, firstService.Name)})
|
||||
if store.cache.Count() != 1 {
|
||||
t.Fatalf("expected cache to have 1 keys, got %d", store.cache.Count())
|
||||
}
|
||||
if store.GetServiceStatusByKey(util.ConvertGroupAndServiceToKey(firstService.Group, firstService.Name)) == nil {
|
||||
t.Error("secondService should've been deleted")
|
||||
}
|
||||
if store.GetServiceStatusByKey(util.ConvertGroupAndServiceToKey(secondService.Group, secondService.Name)) != nil {
|
||||
t.Error("firstService should still exist")
|
||||
if deleted := store.DeleteAllServiceStatusesNotInKeys([]string{}); deleted != 1 {
|
||||
t.Errorf("%d entries should've been deleted, got %d", 1, deleted)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
69
storage/store/memory/uptime.go
Normal file
69
storage/store/memory/uptime.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
|
||||
const (
|
||||
numberOfHoursInTenDays = 10 * 24
|
||||
sevenDays = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// processUptimeAfterResult processes the result by extracting the relevant from the result and recalculating the uptime
|
||||
// if necessary
|
||||
func processUptimeAfterResult(uptime *core.Uptime, result *core.Result) {
|
||||
// XXX: Remove this on v3.0.0
|
||||
if len(uptime.SuccessfulExecutionsPerHour) != 0 || len(uptime.TotalExecutionsPerHour) != 0 {
|
||||
migrateUptimeToHourlyStatistics(uptime)
|
||||
}
|
||||
if uptime.HourlyStatistics == nil {
|
||||
uptime.HourlyStatistics = make(map[int64]*core.HourlyUptimeStatistics)
|
||||
}
|
||||
unixTimestampFlooredAtHour := result.Timestamp.Truncate(time.Hour).Unix()
|
||||
hourlyStats, _ := uptime.HourlyStatistics[unixTimestampFlooredAtHour]
|
||||
if hourlyStats == nil {
|
||||
hourlyStats = &core.HourlyUptimeStatistics{}
|
||||
uptime.HourlyStatistics[unixTimestampFlooredAtHour] = hourlyStats
|
||||
}
|
||||
if result.Success {
|
||||
hourlyStats.SuccessfulExecutions++
|
||||
}
|
||||
hourlyStats.TotalExecutions++
|
||||
hourlyStats.TotalExecutionsResponseTime += uint64(result.Duration.Milliseconds())
|
||||
// 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 `processUptimeAfterResult` as soon as the uptime has been logged for 7 days.
|
||||
if len(uptime.HourlyStatistics) > numberOfHoursInTenDays {
|
||||
sevenDaysAgo := time.Now().Add(-(sevenDays + time.Hour)).Unix()
|
||||
for hourlyUnixTimestamp := range uptime.HourlyStatistics {
|
||||
if sevenDaysAgo > hourlyUnixTimestamp {
|
||||
delete(uptime.HourlyStatistics, hourlyUnixTimestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// XXX: Remove this on v3.0.0
|
||||
// Deprecated
|
||||
func migrateUptimeToHourlyStatistics(uptime *core.Uptime) {
|
||||
log.Println("[migrateUptimeToHourlyStatistics] Got", len(uptime.SuccessfulExecutionsPerHour), "entries for successful executions and", len(uptime.TotalExecutionsPerHour), "entries for total executions")
|
||||
uptime.HourlyStatistics = make(map[int64]*core.HourlyUptimeStatistics)
|
||||
for hourlyUnixTimestamp, totalExecutions := range uptime.TotalExecutionsPerHour {
|
||||
if totalExecutions == 0 {
|
||||
log.Println("[migrateUptimeToHourlyStatistics] Skipping entry at", hourlyUnixTimestamp, "because total number of executions is 0")
|
||||
continue
|
||||
}
|
||||
uptime.HourlyStatistics[hourlyUnixTimestamp] = &core.HourlyUptimeStatistics{
|
||||
TotalExecutions: totalExecutions,
|
||||
SuccessfulExecutions: uptime.SuccessfulExecutionsPerHour[hourlyUnixTimestamp],
|
||||
TotalExecutionsResponseTime: 0,
|
||||
}
|
||||
}
|
||||
log.Println("[migrateUptimeToHourlyStatistics] Migrated", len(uptime.HourlyStatistics), "entries")
|
||||
uptime.SuccessfulExecutionsPerHour = nil
|
||||
uptime.TotalExecutionsPerHour = nil
|
||||
}
|
||||
@@ -1,18 +1,20 @@
|
||||
package core
|
||||
package memory
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
|
||||
func BenchmarkUptime_ProcessResult(b *testing.B) {
|
||||
uptime := NewUptime()
|
||||
func BenchmarkProcessUptimeAfterResult(b *testing.B) {
|
||||
uptime := core.NewUptime()
|
||||
now := time.Now()
|
||||
now = time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())
|
||||
// Start 12000 days ago
|
||||
timestamp := now.Add(-12000 * 24 * time.Hour)
|
||||
for n := 0; n < b.N; n++ {
|
||||
uptime.ProcessResult(&Result{
|
||||
processUptimeAfterResult(uptime, &core.Result{
|
||||
Duration: 18 * time.Millisecond,
|
||||
Success: n%15 == 0,
|
||||
Timestamp: timestamp,
|
||||
72
storage/store/memory/uptime_test.go
Normal file
72
storage/store/memory/uptime_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
|
||||
func TestProcessUptimeAfterResult(t *testing.T) {
|
||||
service := &core.Service{Name: "name", Group: "group"}
|
||||
serviceStatus := core.NewServiceStatus(service.Key(), service.Group, service.Name)
|
||||
uptime := serviceStatus.Uptime
|
||||
|
||||
now := time.Now()
|
||||
now = time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())
|
||||
processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-7 * 24 * time.Hour), Success: true})
|
||||
|
||||
processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-6 * 24 * time.Hour), Success: false})
|
||||
|
||||
processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-8 * 24 * time.Hour), Success: true})
|
||||
|
||||
processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-24 * time.Hour), Success: true})
|
||||
processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-12 * time.Hour), Success: true})
|
||||
|
||||
processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-1 * time.Hour), Success: true, Duration: 10 * time.Millisecond})
|
||||
checkHourlyStatistics(t, uptime.HourlyStatistics[now.Unix()-now.Unix()%3600-3600], 10, 1, 1)
|
||||
processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-30 * time.Minute), Success: false, Duration: 500 * time.Millisecond})
|
||||
checkHourlyStatistics(t, uptime.HourlyStatistics[now.Unix()-now.Unix()%3600-3600], 510, 2, 1)
|
||||
processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-15 * time.Minute), Success: false, Duration: 25 * time.Millisecond})
|
||||
checkHourlyStatistics(t, uptime.HourlyStatistics[now.Unix()-now.Unix()%3600-3600], 535, 3, 1)
|
||||
|
||||
processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-10 * time.Minute), Success: false})
|
||||
|
||||
processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-120 * time.Hour), Success: true})
|
||||
processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-119 * time.Hour), Success: true})
|
||||
processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-118 * time.Hour), Success: true})
|
||||
processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-117 * time.Hour), Success: true})
|
||||
processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-10 * time.Hour), Success: true})
|
||||
processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-8 * time.Hour), Success: true})
|
||||
processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-30 * time.Minute), Success: true})
|
||||
processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-25 * time.Minute), Success: true})
|
||||
}
|
||||
|
||||
func TestAddResultUptimeIsCleaningUpAfterItself(t *testing.T) {
|
||||
service := &core.Service{Name: "name", Group: "group"}
|
||||
serviceStatus := core.NewServiceStatus(service.Key(), service.Group, service.Name)
|
||||
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() {
|
||||
AddResult(serviceStatus, &core.Result{Timestamp: timestamp, Success: true})
|
||||
if len(serviceStatus.Uptime.HourlyStatistics) > numberOfHoursInTenDays {
|
||||
t.Errorf("At no point in time should there be more than %d entries in serviceStatus.SuccessfulExecutionsPerHour, but there are %d", numberOfHoursInTenDays, len(serviceStatus.Uptime.HourlyStatistics))
|
||||
}
|
||||
// Simulate service with an interval of 3 minutes
|
||||
timestamp = timestamp.Add(3 * time.Minute)
|
||||
}
|
||||
}
|
||||
|
||||
func checkHourlyStatistics(t *testing.T, hourlyUptimeStatistics *core.HourlyUptimeStatistics, expectedTotalExecutionsResponseTime uint64, expectedTotalExecutions uint64, expectedSuccessfulExecutions uint64) {
|
||||
if hourlyUptimeStatistics.TotalExecutionsResponseTime != expectedTotalExecutionsResponseTime {
|
||||
t.Error("TotalExecutionsResponseTime should've been", expectedTotalExecutionsResponseTime, "got", hourlyUptimeStatistics.TotalExecutionsResponseTime)
|
||||
}
|
||||
if hourlyUptimeStatistics.TotalExecutions != expectedTotalExecutions {
|
||||
t.Error("TotalExecutions should've been", expectedTotalExecutions, "got", hourlyUptimeStatistics.TotalExecutions)
|
||||
}
|
||||
if hourlyUptimeStatistics.SuccessfulExecutions != expectedSuccessfulExecutions {
|
||||
t.Error("SuccessfulExecutions should've been", expectedSuccessfulExecutions, "got", hourlyUptimeStatistics.SuccessfulExecutions)
|
||||
}
|
||||
}
|
||||
81
storage/store/memory/util.go
Normal file
81
storage/store/memory/util.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwinProduction/gatus/storage/store/common"
|
||||
"github.com/TwinProduction/gatus/storage/store/common/paging"
|
||||
)
|
||||
|
||||
// ShallowCopyServiceStatus returns a shallow copy of a ServiceStatus with only the results
|
||||
// within the range defined by the page and pageSize parameters
|
||||
func ShallowCopyServiceStatus(ss *core.ServiceStatus, params *paging.ServiceStatusParams) *core.ServiceStatus {
|
||||
shallowCopy := &core.ServiceStatus{
|
||||
Name: ss.Name,
|
||||
Group: ss.Group,
|
||||
Key: ss.Key,
|
||||
Uptime: core.NewUptime(),
|
||||
}
|
||||
numberOfResults := len(ss.Results)
|
||||
resultsStart, resultsEnd := getStartAndEndIndex(numberOfResults, params.ResultsPage, params.ResultsPageSize)
|
||||
if resultsStart < 0 || resultsEnd < 0 {
|
||||
shallowCopy.Results = []*core.Result{}
|
||||
} else {
|
||||
shallowCopy.Results = ss.Results[resultsStart:resultsEnd]
|
||||
}
|
||||
numberOfEvents := len(ss.Events)
|
||||
eventsStart, eventsEnd := getStartAndEndIndex(numberOfEvents, params.EventsPage, params.EventsPageSize)
|
||||
if eventsStart < 0 || eventsEnd < 0 {
|
||||
shallowCopy.Events = []*core.Event{}
|
||||
} else {
|
||||
shallowCopy.Events = ss.Events[eventsStart:eventsEnd]
|
||||
}
|
||||
return shallowCopy
|
||||
}
|
||||
|
||||
func getStartAndEndIndex(numberOfResults int, page, pageSize int) (int, int) {
|
||||
if page < 1 || pageSize < 0 {
|
||||
return -1, -1
|
||||
}
|
||||
start := numberOfResults - (page * pageSize)
|
||||
end := numberOfResults - ((page - 1) * pageSize)
|
||||
if start > numberOfResults {
|
||||
start = -1
|
||||
} else if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if end > numberOfResults {
|
||||
end = numberOfResults
|
||||
}
|
||||
return start, end
|
||||
}
|
||||
|
||||
// AddResult adds a Result to ServiceStatus.Results and makes sure that there are
|
||||
// no more than MaximumNumberOfResults results in the Results slice
|
||||
func AddResult(ss *core.ServiceStatus, result *core.Result) {
|
||||
if ss == nil {
|
||||
return
|
||||
}
|
||||
if len(ss.Results) > 0 {
|
||||
// Check if there's any change since the last result
|
||||
if ss.Results[len(ss.Results)-1].Success != result.Success {
|
||||
ss.Events = append(ss.Events, core.NewEventFromResult(result))
|
||||
if len(ss.Events) > common.MaximumNumberOfEvents {
|
||||
// Doing ss.Events[1:] would usually be sufficient, but in the case where for some reason, the slice has
|
||||
// more than one extra element, we can get rid of all of them at once and thus returning the slice to a
|
||||
// length of MaximumNumberOfEvents by using ss.Events[len(ss.Events)-MaximumNumberOfEvents:] instead
|
||||
ss.Events = ss.Events[len(ss.Events)-common.MaximumNumberOfEvents:]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// This is the first result, so we need to add the first healthy/unhealthy event
|
||||
ss.Events = append(ss.Events, core.NewEventFromResult(result))
|
||||
}
|
||||
ss.Results = append(ss.Results, result)
|
||||
if len(ss.Results) > common.MaximumNumberOfResults {
|
||||
// Doing ss.Results[1:] would usually be sufficient, but in the case where for some reason, the slice has more
|
||||
// than one extra element, we can get rid of all of them at once and thus returning the slice to a length of
|
||||
// MaximumNumberOfResults by using ss.Results[len(ss.Results)-MaximumNumberOfResults:] instead
|
||||
ss.Results = ss.Results[len(ss.Results)-common.MaximumNumberOfResults:]
|
||||
}
|
||||
processUptimeAfterResult(ss.Uptime, result)
|
||||
}
|
||||
21
storage/store/memory/util_bench_test.go
Normal file
21
storage/store/memory/util_bench_test.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwinProduction/gatus/storage/store/common"
|
||||
"github.com/TwinProduction/gatus/storage/store/common/paging"
|
||||
)
|
||||
|
||||
func BenchmarkShallowCopyServiceStatus(b *testing.B) {
|
||||
service := &testService
|
||||
serviceStatus := core.NewServiceStatus(service.Key(), service.Group, service.Name)
|
||||
for i := 0; i < common.MaximumNumberOfResults; i++ {
|
||||
AddResult(serviceStatus, &testSuccessfulResult)
|
||||
}
|
||||
for n := 0; n < b.N; n++ {
|
||||
ShallowCopyServiceStatus(serviceStatus, paging.NewServiceStatusParams().WithResults(1, 20))
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
66
storage/store/memory/util_test.go
Normal file
66
storage/store/memory/util_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwinProduction/gatus/storage/store/common"
|
||||
"github.com/TwinProduction/gatus/storage/store/common/paging"
|
||||
)
|
||||
|
||||
func TestAddResult(t *testing.T) {
|
||||
service := &core.Service{Name: "name", Group: "group"}
|
||||
serviceStatus := core.NewServiceStatus(service.Key(), service.Group, service.Name)
|
||||
for i := 0; i < (common.MaximumNumberOfResults+common.MaximumNumberOfEvents)*2; i++ {
|
||||
AddResult(serviceStatus, &core.Result{Success: i%2 == 0, Timestamp: time.Now()})
|
||||
}
|
||||
if len(serviceStatus.Results) != common.MaximumNumberOfResults {
|
||||
t.Errorf("expected serviceStatus.Results to not exceed a length of %d", common.MaximumNumberOfResults)
|
||||
}
|
||||
if len(serviceStatus.Events) != common.MaximumNumberOfEvents {
|
||||
t.Errorf("expected serviceStatus.Events to not exceed a length of %d", common.MaximumNumberOfEvents)
|
||||
}
|
||||
// Try to add nil serviceStatus
|
||||
AddResult(nil, &core.Result{Timestamp: time.Now()})
|
||||
}
|
||||
|
||||
func TestShallowCopyServiceStatus(t *testing.T) {
|
||||
service := &core.Service{Name: "name", Group: "group"}
|
||||
serviceStatus := core.NewServiceStatus(service.Key(), service.Group, service.Name)
|
||||
ts := time.Now().Add(-25 * time.Hour)
|
||||
for i := 0; i < 25; i++ {
|
||||
AddResult(serviceStatus, &core.Result{Success: i%2 == 0, Timestamp: ts})
|
||||
ts = ts.Add(time.Hour)
|
||||
}
|
||||
if len(ShallowCopyServiceStatus(serviceStatus, paging.NewServiceStatusParams().WithResults(-1, -1)).Results) != 0 {
|
||||
t.Error("expected to have 0 result")
|
||||
}
|
||||
if len(ShallowCopyServiceStatus(serviceStatus, paging.NewServiceStatusParams().WithResults(1, 1)).Results) != 1 {
|
||||
t.Error("expected to have 1 result")
|
||||
}
|
||||
if len(ShallowCopyServiceStatus(serviceStatus, paging.NewServiceStatusParams().WithResults(5, 0)).Results) != 0 {
|
||||
t.Error("expected to have 0 results")
|
||||
}
|
||||
if len(ShallowCopyServiceStatus(serviceStatus, paging.NewServiceStatusParams().WithResults(-1, 20)).Results) != 0 {
|
||||
t.Error("expected to have 0 result, because the page was invalid")
|
||||
}
|
||||
if len(ShallowCopyServiceStatus(serviceStatus, paging.NewServiceStatusParams().WithResults(1, -1)).Results) != 0 {
|
||||
t.Error("expected to have 0 result, because the page size was invalid")
|
||||
}
|
||||
if len(ShallowCopyServiceStatus(serviceStatus, paging.NewServiceStatusParams().WithResults(1, 10)).Results) != 10 {
|
||||
t.Error("expected to have 10 results, because given a page size of 10, page 1 should have 10 elements")
|
||||
}
|
||||
if len(ShallowCopyServiceStatus(serviceStatus, paging.NewServiceStatusParams().WithResults(2, 10)).Results) != 10 {
|
||||
t.Error("expected to have 10 results, because given a page size of 10, page 2 should have 10 elements")
|
||||
}
|
||||
if len(ShallowCopyServiceStatus(serviceStatus, paging.NewServiceStatusParams().WithResults(3, 10)).Results) != 5 {
|
||||
t.Error("expected to have 5 results, because given a page size of 10, page 3 should have 5 elements")
|
||||
}
|
||||
if len(ShallowCopyServiceStatus(serviceStatus, paging.NewServiceStatusParams().WithResults(4, 10)).Results) != 0 {
|
||||
t.Error("expected to have 0 results, because given a page size of 10, page 4 should have 0 elements")
|
||||
}
|
||||
if len(ShallowCopyServiceStatus(serviceStatus, paging.NewServiceStatusParams().WithResults(1, 50)).Results) != 25 {
|
||||
t.Error("expected to have 25 results, because there's only 25 results")
|
||||
}
|
||||
}
|
||||
806
storage/store/sqlite/sqlite.go
Normal file
806
storage/store/sqlite/sqlite.go
Normal file
@@ -0,0 +1,806 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwinProduction/gatus/storage/store/common"
|
||||
"github.com/TwinProduction/gatus/storage/store/common/paging"
|
||||
"github.com/TwinProduction/gatus/util"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Note that only exported functions in this file may create, commit, or rollback a transaction //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
const (
|
||||
// arraySeparator is the separator used to separate multiple strings in a single column.
|
||||
// It's a dirty hack, but it's only used for persisting errors, and since this data will likely only ever be used
|
||||
// for aesthetic purposes, I deemed it wasn't worth the performance impact of yet another one-to-many table.
|
||||
arraySeparator = "|~|"
|
||||
|
||||
uptimeCleanUpThreshold = 10 * 24 * time.Hour // Maximum uptime age before triggering a clean up
|
||||
eventsCleanUpThreshold = common.MaximumNumberOfEvents + 10 // Maximum number of events before triggering a clean up
|
||||
resultsCleanUpThreshold = common.MaximumNumberOfResults + 10 // Maximum number of results before triggering a clean up
|
||||
|
||||
uptimeRetention = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrFilePathNotSpecified is the error returned when path parameter passed in NewStore is blank
|
||||
ErrFilePathNotSpecified = errors.New("file path cannot be empty")
|
||||
|
||||
// ErrDatabaseDriverNotSpecified is the error returned when the driver parameter passed in NewStore is blank
|
||||
ErrDatabaseDriverNotSpecified = errors.New("database driver cannot be empty")
|
||||
|
||||
errNoRowsReturned = errors.New("expected a row to be returned, but none was")
|
||||
)
|
||||
|
||||
// Store that leverages a database
|
||||
type Store struct {
|
||||
driver, file string
|
||||
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewStore initializes the database and creates the schema if it doesn't already exist in the file specified
|
||||
func NewStore(driver, path string) (*Store, error) {
|
||||
if len(driver) == 0 {
|
||||
return nil, ErrDatabaseDriverNotSpecified
|
||||
}
|
||||
if len(path) == 0 {
|
||||
return nil, ErrFilePathNotSpecified
|
||||
}
|
||||
store := &Store{driver: driver, file: path}
|
||||
var err error
|
||||
if store.db, err = sql.Open(driver, path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if driver == "sqlite" {
|
||||
_, _ = store.db.Exec("PRAGMA foreign_keys=ON")
|
||||
_, _ = store.db.Exec("PRAGMA journal_mode=WAL")
|
||||
_, _ = store.db.Exec("PRAGMA synchronous=NORMAL")
|
||||
// Prevents driver from running into "database is locked" errors
|
||||
// This is because we're using WAL to improve performance
|
||||
store.db.SetMaxOpenConns(1)
|
||||
}
|
||||
if err = store.createSchema(); err != nil {
|
||||
_ = store.db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// createSchema creates the schema required to perform all database operations.
|
||||
func (s *Store) createSchema() error {
|
||||
_, err := s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS service (
|
||||
service_id INTEGER PRIMARY KEY,
|
||||
service_key TEXT UNIQUE,
|
||||
service_name TEXT,
|
||||
service_group TEXT,
|
||||
UNIQUE(service_name, service_group)
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS service_event (
|
||||
service_event_id INTEGER PRIMARY KEY,
|
||||
service_id INTEGER REFERENCES service(service_id) ON DELETE CASCADE,
|
||||
event_type TEXT,
|
||||
event_timestamp TIMESTAMP
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS service_result (
|
||||
service_result_id INTEGER PRIMARY KEY,
|
||||
service_id INTEGER REFERENCES service(service_id) ON DELETE CASCADE,
|
||||
success INTEGER,
|
||||
errors TEXT,
|
||||
connected INTEGER,
|
||||
status INTEGER,
|
||||
dns_rcode TEXT,
|
||||
certificate_expiration INTEGER,
|
||||
hostname TEXT,
|
||||
ip TEXT,
|
||||
duration INTEGER,
|
||||
timestamp TIMESTAMP
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS service_result_condition (
|
||||
service_result_condition_id INTEGER PRIMARY KEY,
|
||||
service_result_id INTEGER REFERENCES service_result(service_result_id) ON DELETE CASCADE,
|
||||
condition TEXT,
|
||||
success INTEGER
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS service_uptime (
|
||||
service_uptime_id INTEGER PRIMARY KEY,
|
||||
service_id INTEGER REFERENCES service(service_id) ON DELETE CASCADE,
|
||||
hour_unix_timestamp INTEGER,
|
||||
total_executions INTEGER,
|
||||
successful_executions INTEGER,
|
||||
total_response_time INTEGER,
|
||||
UNIQUE(service_id, hour_unix_timestamp)
|
||||
)
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetAllServiceStatuses returns all monitored core.ServiceStatus
|
||||
// with a subset of core.Result defined by the page and pageSize parameters
|
||||
func (s *Store) GetAllServiceStatuses(params *paging.ServiceStatusParams) map[string]*core.ServiceStatus {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
keys, err := s.getAllServiceKeys(tx)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return nil
|
||||
}
|
||||
serviceStatuses := make(map[string]*core.ServiceStatus, len(keys))
|
||||
for _, key := range keys {
|
||||
serviceStatus, err := s.getServiceStatusByKey(tx, key, params)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
serviceStatuses[key] = serviceStatus
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
return serviceStatuses
|
||||
}
|
||||
|
||||
// GetServiceStatus returns the service status for a given service name in the given group
|
||||
func (s *Store) GetServiceStatus(groupName, serviceName string, params *paging.ServiceStatusParams) *core.ServiceStatus {
|
||||
return s.GetServiceStatusByKey(util.ConvertGroupAndServiceToKey(groupName, serviceName), params)
|
||||
}
|
||||
|
||||
// GetServiceStatusByKey returns the service status for a given key
|
||||
func (s *Store) GetServiceStatusByKey(key string, params *paging.ServiceStatusParams) *core.ServiceStatus {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
serviceStatus, err := s.getServiceStatusByKey(tx, key, params)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return nil
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
return serviceStatus
|
||||
}
|
||||
|
||||
// GetUptimeByKey returns the uptime percentage during a time range
|
||||
func (s *Store) GetUptimeByKey(key string, from, to time.Time) (float64, error) {
|
||||
if from.After(to) {
|
||||
return 0, common.ErrInvalidTimeRange
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
serviceID, _, _, err := s.getServiceIDGroupAndNameByKey(tx, key)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return 0, err
|
||||
}
|
||||
uptime, _, err := s.getServiceUptime(tx, serviceID, from, to)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return 0, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
return uptime, nil
|
||||
}
|
||||
|
||||
// Insert adds the observed result for the specified service into the store
|
||||
func (s *Store) Insert(service *core.Service, result *core.Result) {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
//start := time.Now()
|
||||
serviceID, err := s.getServiceID(tx, service)
|
||||
if err != nil {
|
||||
if err == common.ErrServiceNotFound {
|
||||
// Service doesn't exist in the database, insert it
|
||||
if serviceID, err = s.insertService(tx, service); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return // failed to insert service
|
||||
}
|
||||
} else {
|
||||
_ = tx.Rollback()
|
||||
return
|
||||
}
|
||||
}
|
||||
// First, we need to check if we need to insert a new event.
|
||||
//
|
||||
// A new event must be added if either of the following cases happen:
|
||||
// 1. There is only 1 event. The total number of events for a service can only be 1 if the only existing event is
|
||||
// of type EventStart, in which case we will have to create a new event of type EventHealthy or EventUnhealthy
|
||||
// based on result.Success.
|
||||
// 2. The lastResult.Success != result.Success. This implies that the service went from healthy to unhealthy or
|
||||
// vice-versa, in which case we will have to create a new event of type EventHealthy or EventUnhealthy
|
||||
// based on result.Success.
|
||||
numberOfEvents, err := s.getNumberOfEventsByServiceID(tx, serviceID)
|
||||
if err != nil {
|
||||
log.Printf("[sqlite][Insert] Failed to retrieve total number of events for group=%s; service=%s: %s", service.Group, service.Name, err.Error())
|
||||
}
|
||||
if numberOfEvents == 0 {
|
||||
// There's no events yet, which means we need to add the EventStart and the first healthy/unhealthy event
|
||||
err = s.insertEvent(tx, serviceID, &core.Event{
|
||||
Type: core.EventStart,
|
||||
Timestamp: result.Timestamp.Add(-50 * time.Millisecond),
|
||||
})
|
||||
if err != nil {
|
||||
// Silently fail
|
||||
log.Printf("[sqlite][Insert] Failed to insert event=%s for group=%s; service=%s: %s", core.EventStart, service.Group, service.Name, err.Error())
|
||||
}
|
||||
event := core.NewEventFromResult(result)
|
||||
if err = s.insertEvent(tx, serviceID, event); err != nil {
|
||||
// Silently fail
|
||||
log.Printf("[sqlite][Insert] Failed to insert event=%s for group=%s; service=%s: %s", event.Type, service.Group, service.Name, err.Error())
|
||||
}
|
||||
} else {
|
||||
// Get the success value of the previous result
|
||||
var lastResultSuccess bool
|
||||
if lastResultSuccess, err = s.getLastServiceResultSuccessValue(tx, serviceID); err != nil {
|
||||
log.Printf("[sqlite][Insert] Failed to retrieve outcome of previous result for group=%s; service=%s: %s", service.Group, service.Name, err.Error())
|
||||
} else {
|
||||
// If we managed to retrieve the outcome of the previous result, we'll compare it with the new result.
|
||||
// If the final outcome (success or failure) of the previous and the new result aren't the same, it means
|
||||
// that the service either went from Healthy to Unhealthy or Unhealthy -> Healthy, therefore, we'll add
|
||||
// an event to mark the change in state
|
||||
if lastResultSuccess != result.Success {
|
||||
event := core.NewEventFromResult(result)
|
||||
if err = s.insertEvent(tx, serviceID, event); err != nil {
|
||||
// Silently fail
|
||||
log.Printf("[sqlite][Insert] Failed to insert event=%s for group=%s; service=%s: %s", event.Type, service.Group, service.Name, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clean up old events if there's more than twice the maximum number of events
|
||||
// This lets us both keep the table clean without impacting performance too much
|
||||
// (since we're only deleting MaximumNumberOfEvents at a time instead of 1)
|
||||
if numberOfEvents > eventsCleanUpThreshold {
|
||||
if err = s.deleteOldServiceEvents(tx, serviceID); err != nil {
|
||||
log.Printf("[sqlite][Insert] Failed to delete old events for group=%s; service=%s: %s", service.Group, service.Name, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
// Second, we need to insert the result.
|
||||
if err = s.insertResult(tx, serviceID, result); err != nil {
|
||||
log.Printf("[sqlite][Insert] Failed to insert result for group=%s; service=%s: %s", service.Group, service.Name, err.Error())
|
||||
_ = tx.Rollback() // If we can't insert the result, we'll rollback now since there's no point continuing
|
||||
return
|
||||
}
|
||||
// Clean up old results
|
||||
numberOfResults, err := s.getNumberOfResultsByServiceID(tx, serviceID)
|
||||
if err != nil {
|
||||
log.Printf("[sqlite][Insert] Failed to retrieve total number of results for group=%s; service=%s: %s", service.Group, service.Name, err.Error())
|
||||
} else {
|
||||
if numberOfResults > resultsCleanUpThreshold {
|
||||
if err = s.deleteOldServiceResults(tx, serviceID); err != nil {
|
||||
log.Printf("[sqlite][Insert] Failed to delete old results for group=%s; service=%s: %s", service.Group, service.Name, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
// Finally, we need to insert the uptime data.
|
||||
// Because the uptime data significantly outlives the results, we can't rely on the results for determining the uptime
|
||||
if err = s.updateServiceUptime(tx, serviceID, result); err != nil {
|
||||
log.Printf("[sqlite][Insert] Failed to update uptime for group=%s; service=%s: %s", service.Group, service.Name, err.Error())
|
||||
}
|
||||
// Clean up old uptime entries
|
||||
ageOfOldestUptimeEntry, err := s.getAgeOfOldestServiceUptimeEntry(tx, serviceID)
|
||||
if err != nil {
|
||||
log.Printf("[sqlite][Insert] Failed to retrieve oldest service uptime entry for group=%s; service=%s: %s", service.Group, service.Name, err.Error())
|
||||
} else {
|
||||
if ageOfOldestUptimeEntry > uptimeCleanUpThreshold {
|
||||
if err = s.deleteOldUptimeEntries(tx, serviceID, time.Now().Add(-(uptimeRetention + time.Hour))); err != nil {
|
||||
log.Printf("[sqlite][Insert] Failed to delete old uptime entries for group=%s; service=%s: %s", service.Group, service.Name, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
//log.Printf("[sqlite][Insert] Successfully inserted result in duration=%dms", time.Since(start).Milliseconds())
|
||||
if err = tx.Commit(); err != nil {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// DeleteAllServiceStatusesNotInKeys removes all rows owned by a service whose key is not within the keys provided
|
||||
func (s *Store) DeleteAllServiceStatusesNotInKeys(keys []string) int {
|
||||
var err error
|
||||
var result sql.Result
|
||||
if len(keys) == 0 {
|
||||
// Delete everything
|
||||
result, err = s.db.Exec("DELETE FROM service")
|
||||
} else {
|
||||
args := make([]interface{}, 0, len(keys))
|
||||
for i := range keys {
|
||||
args = append(args, keys[i])
|
||||
}
|
||||
result, err = s.db.Exec(fmt.Sprintf("DELETE FROM service WHERE service_key NOT IN (%s)", strings.Trim(strings.Repeat("?,", len(keys)), ",")), args...)
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("[sqlite][DeleteAllServiceStatusesNotInKeys] Failed to delete rows that do not belong to any of keys=%v: %s", keys, err.Error())
|
||||
return 0
|
||||
}
|
||||
rowsAffects, _ := result.RowsAffected()
|
||||
return int(rowsAffects)
|
||||
}
|
||||
|
||||
// Clear deletes everything from the store
|
||||
func (s *Store) Clear() {
|
||||
_, _ = s.db.Exec("DELETE FROM service")
|
||||
}
|
||||
|
||||
// Save does nothing, because this store is immediately persistent.
|
||||
func (s *Store) Save() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close the database handle
|
||||
func (s *Store) Close() {
|
||||
_ = s.db.Close()
|
||||
}
|
||||
|
||||
// insertService inserts a service in the store and returns the generated id of said service
|
||||
func (s *Store) insertService(tx *sql.Tx, service *core.Service) (int64, error) {
|
||||
//log.Printf("[sqlite][insertService] Inserting service with group=%s and name=%s", service.Group, service.Name)
|
||||
result, err := tx.Exec(
|
||||
"INSERT INTO service (service_key, service_name, service_group) VALUES ($1, $2, $3)",
|
||||
service.Key(),
|
||||
service.Name,
|
||||
service.Group,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.LastInsertId()
|
||||
}
|
||||
|
||||
// insertEvent inserts a service event in the store
|
||||
func (s *Store) insertEvent(tx *sql.Tx, serviceID int64, event *core.Event) error {
|
||||
_, err := tx.Exec(
|
||||
"INSERT INTO service_event (service_id, event_type, event_timestamp) VALUES ($1, $2, $3)",
|
||||
serviceID,
|
||||
event.Type,
|
||||
event.Timestamp,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// insertResult inserts a result in the store
|
||||
func (s *Store) insertResult(tx *sql.Tx, serviceID int64, result *core.Result) error {
|
||||
res, err := tx.Exec(
|
||||
`
|
||||
INSERT INTO service_result (service_id, success, errors, connected, status, dns_rcode, certificate_expiration, hostname, ip, duration, timestamp)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
`,
|
||||
serviceID,
|
||||
result.Success,
|
||||
strings.Join(result.Errors, arraySeparator),
|
||||
result.Connected,
|
||||
result.HTTPStatus,
|
||||
result.DNSRCode,
|
||||
result.CertificateExpiration,
|
||||
result.Hostname,
|
||||
result.IP,
|
||||
result.Duration,
|
||||
result.Timestamp,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
serviceResultID, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.insertConditionResults(tx, serviceResultID, result.ConditionResults)
|
||||
}
|
||||
|
||||
func (s *Store) insertConditionResults(tx *sql.Tx, serviceResultID int64, conditionResults []*core.ConditionResult) error {
|
||||
var err error
|
||||
for _, cr := range conditionResults {
|
||||
_, err = tx.Exec("INSERT INTO service_result_condition (service_result_id, condition, success) VALUES ($1, $2, $3)",
|
||||
serviceResultID,
|
||||
cr.Condition,
|
||||
cr.Success,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) updateServiceUptime(tx *sql.Tx, serviceID int64, result *core.Result) error {
|
||||
unixTimestampFlooredAtHour := result.Timestamp.Truncate(time.Hour).Unix()
|
||||
var successfulExecutions int
|
||||
if result.Success {
|
||||
successfulExecutions = 1
|
||||
}
|
||||
_, err := tx.Exec(
|
||||
`
|
||||
INSERT INTO service_uptime (service_id, hour_unix_timestamp, total_executions, successful_executions, total_response_time)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT(service_id, hour_unix_timestamp) DO UPDATE SET
|
||||
total_executions = excluded.total_executions + total_executions,
|
||||
successful_executions = excluded.successful_executions + successful_executions,
|
||||
total_response_time = excluded.total_response_time + total_response_time
|
||||
`,
|
||||
serviceID,
|
||||
unixTimestampFlooredAtHour,
|
||||
1,
|
||||
successfulExecutions,
|
||||
result.Duration.Milliseconds(),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) getAllServiceKeys(tx *sql.Tx) (keys []string, err error) {
|
||||
rows, err := tx.Query("SELECT service_key FROM service")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var key string
|
||||
_ = rows.Scan(&key)
|
||||
keys = append(keys, key)
|
||||
}
|
||||
_ = rows.Close()
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Store) getServiceStatusByKey(tx *sql.Tx, key string, parameters *paging.ServiceStatusParams) (*core.ServiceStatus, error) {
|
||||
serviceID, serviceGroup, serviceName, err := s.getServiceIDGroupAndNameByKey(tx, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
serviceStatus := core.NewServiceStatus(key, serviceGroup, serviceName)
|
||||
if parameters.EventsPageSize > 0 {
|
||||
if serviceStatus.Events, err = s.getEventsByServiceID(tx, serviceID, parameters.EventsPage, parameters.EventsPageSize); err != nil {
|
||||
log.Printf("[sqlite][getServiceStatusByKey] Failed to retrieve events for key=%s: %s", key, err.Error())
|
||||
}
|
||||
}
|
||||
if parameters.ResultsPageSize > 0 {
|
||||
if serviceStatus.Results, err = s.getResultsByServiceID(tx, serviceID, parameters.ResultsPage, parameters.ResultsPageSize); err != nil {
|
||||
log.Printf("[sqlite][getServiceStatusByKey] Failed to retrieve results for key=%s: %s", key, err.Error())
|
||||
}
|
||||
}
|
||||
//if parameters.IncludeUptime {
|
||||
// now := time.Now()
|
||||
// serviceStatus.Uptime.LastHour, _, err = s.getServiceUptime(tx, serviceID, now.Add(-time.Hour), now)
|
||||
// serviceStatus.Uptime.LastTwentyFourHours, _, err = s.getServiceUptime(tx, serviceID, now.Add(-24*time.Hour), now)
|
||||
// serviceStatus.Uptime.LastSevenDays, _, err = s.getServiceUptime(tx, serviceID, now.Add(-7*24*time.Hour), now)
|
||||
//}
|
||||
return serviceStatus, nil
|
||||
}
|
||||
|
||||
func (s *Store) getServiceIDGroupAndNameByKey(tx *sql.Tx, key string) (id int64, group, name string, err error) {
|
||||
rows, err := tx.Query(
|
||||
`
|
||||
SELECT service_id, service_group, service_name
|
||||
FROM service
|
||||
WHERE service_key = $1
|
||||
LIMIT 1
|
||||
`,
|
||||
key,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, "", "", err
|
||||
}
|
||||
for rows.Next() {
|
||||
_ = rows.Scan(&id, &group, &name)
|
||||
}
|
||||
_ = rows.Close()
|
||||
if id == 0 {
|
||||
return 0, "", "", common.ErrServiceNotFound
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Store) getEventsByServiceID(tx *sql.Tx, serviceID int64, page, pageSize int) (events []*core.Event, err error) {
|
||||
rows, err := tx.Query(
|
||||
`
|
||||
SELECT event_type, event_timestamp
|
||||
FROM service_event
|
||||
WHERE service_id = $1
|
||||
ORDER BY service_event_id ASC
|
||||
LIMIT $2 OFFSET $3
|
||||
`,
|
||||
serviceID,
|
||||
pageSize,
|
||||
(page-1)*pageSize,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
event := &core.Event{}
|
||||
_ = rows.Scan(&event.Type, &event.Timestamp)
|
||||
events = append(events, event)
|
||||
}
|
||||
_ = rows.Close()
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Store) getResultsByServiceID(tx *sql.Tx, serviceID int64, page, pageSize int) (results []*core.Result, err error) {
|
||||
rows, err := tx.Query(
|
||||
`
|
||||
SELECT service_result_id, success, errors, connected, status, dns_rcode, certificate_expiration, hostname, ip, duration, timestamp
|
||||
FROM service_result
|
||||
WHERE service_id = $1
|
||||
ORDER BY service_result_id DESC -- Normally, we'd sort by timestamp, but sorting by service_result_id is faster
|
||||
LIMIT $2 OFFSET $3
|
||||
`,
|
||||
//`
|
||||
// SELECT * FROM (
|
||||
// SELECT service_result_id, success, errors, connected, status, dns_rcode, certificate_expiration, hostname, ip, duration, timestamp
|
||||
// FROM service_result
|
||||
// WHERE service_id = $1
|
||||
// ORDER BY service_result_id DESC -- Normally, we'd sort by timestamp, but sorting by service_result_id is faster
|
||||
// LIMIT $2 OFFSET $3
|
||||
// )
|
||||
// ORDER BY service_result_id ASC -- Normally, we'd sort by timestamp, but sorting by service_result_id is faster
|
||||
//`,
|
||||
serviceID,
|
||||
pageSize,
|
||||
(page-1)*pageSize,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
idResultMap := make(map[int64]*core.Result)
|
||||
for rows.Next() {
|
||||
result := &core.Result{}
|
||||
var id int64
|
||||
var joinedErrors string
|
||||
_ = rows.Scan(&id, &result.Success, &joinedErrors, &result.Connected, &result.HTTPStatus, &result.DNSRCode, &result.CertificateExpiration, &result.Hostname, &result.IP, &result.Duration, &result.Timestamp)
|
||||
if len(joinedErrors) != 0 {
|
||||
result.Errors = strings.Split(joinedErrors, arraySeparator)
|
||||
}
|
||||
//results = append(results, result)
|
||||
// This is faster than using a subselect
|
||||
results = append([]*core.Result{result}, results...)
|
||||
idResultMap[id] = result
|
||||
}
|
||||
_ = rows.Close()
|
||||
// Get the conditionResults
|
||||
for serviceResultID, result := range idResultMap {
|
||||
rows, err = tx.Query(
|
||||
`
|
||||
SELECT condition, success
|
||||
FROM service_result_condition
|
||||
WHERE service_result_id = $1
|
||||
`,
|
||||
serviceResultID,
|
||||
)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for rows.Next() {
|
||||
conditionResult := &core.ConditionResult{}
|
||||
if err = rows.Scan(&conditionResult.Condition, &conditionResult.Success); err != nil {
|
||||
return
|
||||
}
|
||||
result.ConditionResults = append(result.ConditionResults, conditionResult)
|
||||
}
|
||||
_ = rows.Close()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Store) getServiceUptime(tx *sql.Tx, serviceID int64, from, to time.Time) (uptime float64, avgResponseTime time.Duration, err error) {
|
||||
rows, err := tx.Query(
|
||||
`
|
||||
SELECT SUM(total_executions), SUM(successful_executions), SUM(total_response_time)
|
||||
FROM service_uptime
|
||||
WHERE service_id = $1
|
||||
AND hour_unix_timestamp >= $2
|
||||
AND hour_unix_timestamp <= $3
|
||||
`,
|
||||
serviceID,
|
||||
from.Unix(),
|
||||
to.Unix(),
|
||||
)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
var totalExecutions, totalSuccessfulExecutions, totalResponseTime int
|
||||
for rows.Next() {
|
||||
_ = rows.Scan(&totalExecutions, &totalSuccessfulExecutions, &totalResponseTime)
|
||||
break
|
||||
}
|
||||
_ = rows.Close()
|
||||
if totalExecutions > 0 {
|
||||
uptime = float64(totalSuccessfulExecutions) / float64(totalExecutions)
|
||||
avgResponseTime = time.Duration(float64(totalResponseTime)/float64(totalExecutions)) * time.Millisecond
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Store) getServiceID(tx *sql.Tx, service *core.Service) (int64, error) {
|
||||
rows, err := tx.Query("SELECT service_id FROM service WHERE service_key = $1", service.Key())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var id int64
|
||||
var found bool
|
||||
for rows.Next() {
|
||||
_ = rows.Scan(&id)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
_ = rows.Close()
|
||||
if !found {
|
||||
return 0, common.ErrServiceNotFound
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *Store) getNumberOfEventsByServiceID(tx *sql.Tx, serviceID int64) (int64, error) {
|
||||
rows, err := tx.Query("SELECT COUNT(1) FROM service_event WHERE service_id = $1", serviceID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var numberOfEvents int64
|
||||
for rows.Next() {
|
||||
_ = rows.Scan(&numberOfEvents)
|
||||
}
|
||||
_ = rows.Close()
|
||||
return numberOfEvents, nil
|
||||
}
|
||||
|
||||
func (s *Store) getNumberOfResultsByServiceID(tx *sql.Tx, serviceID int64) (int64, error) {
|
||||
rows, err := tx.Query("SELECT COUNT(1) FROM service_result WHERE service_id = $1", serviceID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var numberOfResults int64
|
||||
for rows.Next() {
|
||||
_ = rows.Scan(&numberOfResults)
|
||||
}
|
||||
_ = rows.Close()
|
||||
return numberOfResults, nil
|
||||
}
|
||||
|
||||
func (s *Store) getAgeOfOldestServiceUptimeEntry(tx *sql.Tx, serviceID int64) (time.Duration, error) {
|
||||
rows, err := tx.Query(
|
||||
`
|
||||
SELECT hour_unix_timestamp
|
||||
FROM service_uptime
|
||||
WHERE service_id = $1
|
||||
ORDER BY hour_unix_timestamp
|
||||
LIMIT 1
|
||||
`,
|
||||
serviceID,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var oldestServiceUptimeUnixTimestamp int64
|
||||
var found bool
|
||||
for rows.Next() {
|
||||
_ = rows.Scan(&oldestServiceUptimeUnixTimestamp)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
_ = rows.Close()
|
||||
if !found {
|
||||
return 0, errNoRowsReturned
|
||||
}
|
||||
return time.Since(time.Unix(oldestServiceUptimeUnixTimestamp, 0)), nil
|
||||
}
|
||||
|
||||
func (s *Store) getLastServiceResultSuccessValue(tx *sql.Tx, serviceID int64) (bool, error) {
|
||||
rows, err := tx.Query("SELECT success FROM service_result WHERE service_id = $1 ORDER BY service_result_id DESC LIMIT 1", serviceID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
var success bool
|
||||
var found bool
|
||||
for rows.Next() {
|
||||
_ = rows.Scan(&success)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
_ = rows.Close()
|
||||
if !found {
|
||||
return false, errNoRowsReturned
|
||||
}
|
||||
return success, nil
|
||||
}
|
||||
|
||||
// deleteOldServiceEvents deletes old service events that are no longer needed
|
||||
func (s *Store) deleteOldServiceEvents(tx *sql.Tx, serviceID int64) error {
|
||||
_, err := tx.Exec(
|
||||
`
|
||||
DELETE FROM service_event
|
||||
WHERE service_id = $1
|
||||
AND service_event_id NOT IN (
|
||||
SELECT service_event_id
|
||||
FROM service_event
|
||||
WHERE service_id = $1
|
||||
ORDER BY service_event_id DESC
|
||||
LIMIT $2
|
||||
)
|
||||
`,
|
||||
serviceID,
|
||||
common.MaximumNumberOfEvents,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
//rowsAffected, _ := result.RowsAffected()
|
||||
//log.Printf("deleted %d rows from service_event", rowsAffected)
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteOldServiceResults deletes old service results that are no longer needed
|
||||
func (s *Store) deleteOldServiceResults(tx *sql.Tx, serviceID int64) error {
|
||||
_, err := tx.Exec(
|
||||
`
|
||||
DELETE FROM service_result
|
||||
WHERE service_id = $1
|
||||
AND service_result_id NOT IN (
|
||||
SELECT service_result_id
|
||||
FROM service_result
|
||||
WHERE service_id = $1
|
||||
ORDER BY service_result_id DESC
|
||||
LIMIT $2
|
||||
)
|
||||
`,
|
||||
serviceID,
|
||||
common.MaximumNumberOfResults,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
//rowsAffected, _ := result.RowsAffected()
|
||||
//log.Printf("deleted %d rows from service_result", rowsAffected)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) deleteOldUptimeEntries(tx *sql.Tx, serviceID int64, maxAge time.Time) error {
|
||||
_, err := tx.Exec("DELETE FROM service_uptime WHERE service_id = $1 AND hour_unix_timestamp < $2", serviceID, maxAge.Unix())
|
||||
//if err != nil {
|
||||
// return err
|
||||
//}
|
||||
//rowsAffected, _ := result.RowsAffected()
|
||||
//log.Printf("deleted %d rows from service_uptime", rowsAffected)
|
||||
return err
|
||||
}
|
||||
362
storage/store/sqlite/sqlite_test.go
Normal file
362
storage/store/sqlite/sqlite_test.go
Normal file
@@ -0,0 +1,362 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwinProduction/gatus/storage/store/common"
|
||||
"github.com/TwinProduction/gatus/storage/store/common/paging"
|
||||
)
|
||||
|
||||
var (
|
||||
firstCondition = core.Condition("[STATUS] == 200")
|
||||
secondCondition = core.Condition("[RESPONSE_TIME] < 500")
|
||||
thirdCondition = core.Condition("[CERTIFICATE_EXPIRATION] < 72h")
|
||||
|
||||
now = 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: now,
|
||||
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: now,
|
||||
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 TestNewStore(t *testing.T) {
|
||||
if _, err := NewStore("", "TestNewStore.db"); err != ErrDatabaseDriverNotSpecified {
|
||||
t.Error("expected error due to blank driver parameter")
|
||||
}
|
||||
if _, err := NewStore("sqlite", ""); err != ErrFilePathNotSpecified {
|
||||
t.Error("expected error due to blank path parameter")
|
||||
}
|
||||
if store, err := NewStore("sqlite", t.TempDir()+"/TestNewStore.db"); err != nil {
|
||||
t.Error("shouldn't have returned any error, got", err.Error())
|
||||
} else {
|
||||
_ = store.db.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_InsertCleansUpOldUptimeEntriesProperly(t *testing.T) {
|
||||
store, _ := NewStore("sqlite", t.TempDir()+"/TestStore_InsertCleansUpOldUptimeEntriesProperly.db")
|
||||
defer store.Close()
|
||||
now := time.Now().Round(time.Minute)
|
||||
now = time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())
|
||||
|
||||
store.Insert(&testService, &core.Result{Timestamp: now.Add(-5 * time.Hour), Success: true})
|
||||
|
||||
tx, _ := store.db.Begin()
|
||||
oldest, _ := store.getAgeOfOldestServiceUptimeEntry(tx, 1)
|
||||
_ = tx.Commit()
|
||||
if oldest.Truncate(time.Hour) != 5*time.Hour {
|
||||
t.Errorf("oldest service uptime entry should've been ~5 hours old, was %s", oldest)
|
||||
}
|
||||
|
||||
// The oldest cache entry should remain at ~5 hours old, because this entry is more recent
|
||||
store.Insert(&testService, &core.Result{Timestamp: now.Add(-3 * time.Hour), Success: true})
|
||||
|
||||
tx, _ = store.db.Begin()
|
||||
oldest, _ = store.getAgeOfOldestServiceUptimeEntry(tx, 1)
|
||||
_ = tx.Commit()
|
||||
if oldest.Truncate(time.Hour) != 5*time.Hour {
|
||||
t.Errorf("oldest service uptime entry should've been ~5 hours old, was %s", oldest)
|
||||
}
|
||||
|
||||
// The oldest cache entry should now become at ~8 hours old, because this entry is older
|
||||
store.Insert(&testService, &core.Result{Timestamp: now.Add(-8 * time.Hour), Success: true})
|
||||
|
||||
tx, _ = store.db.Begin()
|
||||
oldest, _ = store.getAgeOfOldestServiceUptimeEntry(tx, 1)
|
||||
_ = tx.Commit()
|
||||
if oldest.Truncate(time.Hour) != 8*time.Hour {
|
||||
t.Errorf("oldest service uptime entry should've been ~8 hours old, was %s", oldest)
|
||||
}
|
||||
|
||||
// Since this is one hour before reaching the clean up threshold, the oldest entry should now be this one
|
||||
store.Insert(&testService, &core.Result{Timestamp: now.Add(-(uptimeCleanUpThreshold - time.Hour)), Success: true})
|
||||
|
||||
tx, _ = store.db.Begin()
|
||||
oldest, _ = store.getAgeOfOldestServiceUptimeEntry(tx, 1)
|
||||
_ = tx.Commit()
|
||||
if oldest.Truncate(time.Hour) != uptimeCleanUpThreshold-time.Hour {
|
||||
t.Errorf("oldest service uptime entry should've been ~%s hours old, was %s", uptimeCleanUpThreshold-time.Hour, oldest)
|
||||
}
|
||||
|
||||
// Since this entry is after the uptimeCleanUpThreshold, both this entry as well as the previous
|
||||
// one should be deleted since they both surpass uptimeRetention
|
||||
store.Insert(&testService, &core.Result{Timestamp: now.Add(-(uptimeCleanUpThreshold + time.Hour)), Success: true})
|
||||
|
||||
tx, _ = store.db.Begin()
|
||||
oldest, _ = store.getAgeOfOldestServiceUptimeEntry(tx, 1)
|
||||
_ = tx.Commit()
|
||||
if oldest.Truncate(time.Hour) != 8*time.Hour {
|
||||
t.Errorf("oldest service uptime entry should've been ~8 hours old, was %s", oldest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_InsertCleansUpEventsAndResultsProperly(t *testing.T) {
|
||||
store, _ := NewStore("sqlite", t.TempDir()+"/TestStore_InsertCleansUpEventsAndResultsProperly.db")
|
||||
defer store.Close()
|
||||
for i := 0; i < resultsCleanUpThreshold+eventsCleanUpThreshold; i++ {
|
||||
store.Insert(&testService, &testSuccessfulResult)
|
||||
store.Insert(&testService, &testUnsuccessfulResult)
|
||||
ss := store.GetServiceStatusByKey(testService.Key(), paging.NewServiceStatusParams().WithResults(1, common.MaximumNumberOfResults*5).WithEvents(1, common.MaximumNumberOfEvents*5))
|
||||
if len(ss.Results) > resultsCleanUpThreshold+1 {
|
||||
t.Errorf("number of results shouldn't have exceeded %d, reached %d", resultsCleanUpThreshold, len(ss.Results))
|
||||
}
|
||||
if len(ss.Events) > eventsCleanUpThreshold+1 {
|
||||
t.Errorf("number of events shouldn't have exceeded %d, reached %d", eventsCleanUpThreshold, len(ss.Events))
|
||||
}
|
||||
}
|
||||
store.Clear()
|
||||
}
|
||||
|
||||
func TestStore_Persistence(t *testing.T) {
|
||||
file := t.TempDir() + "/TestStore_Persistence.db"
|
||||
store, _ := NewStore("sqlite", file)
|
||||
store.Insert(&testService, &testSuccessfulResult)
|
||||
store.Insert(&testService, &testUnsuccessfulResult)
|
||||
if uptime, _ := store.GetUptimeByKey(testService.Key(), time.Now().Add(-time.Hour), time.Now()); uptime != 0.5 {
|
||||
t.Errorf("the uptime over the past 1h should've been 0.5, got %f", uptime)
|
||||
}
|
||||
if uptime, _ := store.GetUptimeByKey(testService.Key(), time.Now().Add(-time.Hour*24), time.Now()); uptime != 0.5 {
|
||||
t.Errorf("the uptime over the past 24h should've been 0.5, got %f", uptime)
|
||||
}
|
||||
if uptime, _ := store.GetUptimeByKey(testService.Key(), time.Now().Add(-time.Hour*24*7), time.Now()); uptime != 0.5 {
|
||||
t.Errorf("the uptime over the past 7d should've been 0.5, got %f", uptime)
|
||||
}
|
||||
ssFromOldStore := store.GetServiceStatus(testService.Group, testService.Name, paging.NewServiceStatusParams().WithResults(1, common.MaximumNumberOfResults).WithEvents(1, common.MaximumNumberOfEvents))
|
||||
if ssFromOldStore == nil || ssFromOldStore.Group != "group" || ssFromOldStore.Name != "name" || len(ssFromOldStore.Events) != 3 || len(ssFromOldStore.Results) != 2 {
|
||||
store.Close()
|
||||
t.Fatal("sanity check failed")
|
||||
}
|
||||
store.Close()
|
||||
store, _ = NewStore("sqlite", file)
|
||||
defer store.Close()
|
||||
ssFromNewStore := store.GetServiceStatus(testService.Group, testService.Name, paging.NewServiceStatusParams().WithResults(1, common.MaximumNumberOfResults).WithEvents(1, common.MaximumNumberOfEvents))
|
||||
if ssFromNewStore == nil || ssFromNewStore.Group != "group" || ssFromNewStore.Name != "name" || len(ssFromNewStore.Events) != 3 || len(ssFromNewStore.Results) != 2 {
|
||||
t.Fatal("failed sanity check")
|
||||
}
|
||||
if ssFromNewStore == ssFromOldStore {
|
||||
t.Fatal("ss from the old and new store should have a different memory address")
|
||||
}
|
||||
for i := range ssFromNewStore.Events {
|
||||
if ssFromNewStore.Events[i].Timestamp != ssFromOldStore.Events[i].Timestamp {
|
||||
t.Error("new and old should've been the same")
|
||||
}
|
||||
if ssFromNewStore.Events[i].Type != ssFromOldStore.Events[i].Type {
|
||||
t.Error("new and old should've been the same")
|
||||
}
|
||||
}
|
||||
for i := range ssFromOldStore.Results {
|
||||
if ssFromNewStore.Results[i].Timestamp != ssFromOldStore.Results[i].Timestamp {
|
||||
t.Error("new and old should've been the same")
|
||||
}
|
||||
if ssFromNewStore.Results[i].Success != ssFromOldStore.Results[i].Success {
|
||||
t.Error("new and old should've been the same")
|
||||
}
|
||||
if ssFromNewStore.Results[i].Connected != ssFromOldStore.Results[i].Connected {
|
||||
t.Error("new and old should've been the same")
|
||||
}
|
||||
if ssFromNewStore.Results[i].IP != ssFromOldStore.Results[i].IP {
|
||||
t.Error("new and old should've been the same")
|
||||
}
|
||||
if ssFromNewStore.Results[i].Hostname != ssFromOldStore.Results[i].Hostname {
|
||||
t.Error("new and old should've been the same")
|
||||
}
|
||||
if ssFromNewStore.Results[i].HTTPStatus != ssFromOldStore.Results[i].HTTPStatus {
|
||||
t.Error("new and old should've been the same")
|
||||
}
|
||||
if ssFromNewStore.Results[i].DNSRCode != ssFromOldStore.Results[i].DNSRCode {
|
||||
t.Error("new and old should've been the same")
|
||||
}
|
||||
if len(ssFromNewStore.Results[i].Errors) != len(ssFromOldStore.Results[i].Errors) {
|
||||
t.Error("new and old should've been the same")
|
||||
} else {
|
||||
for j := range ssFromOldStore.Results[i].Errors {
|
||||
if ssFromNewStore.Results[i].Errors[j] != ssFromOldStore.Results[i].Errors[j] {
|
||||
t.Error("new and old should've been the same")
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(ssFromNewStore.Results[i].ConditionResults) != len(ssFromOldStore.Results[i].ConditionResults) {
|
||||
t.Error("new and old should've been the same")
|
||||
} else {
|
||||
for j := range ssFromOldStore.Results[i].ConditionResults {
|
||||
if ssFromNewStore.Results[i].ConditionResults[j].Condition != ssFromOldStore.Results[i].ConditionResults[j].Condition {
|
||||
t.Error("new and old should've been the same")
|
||||
}
|
||||
if ssFromNewStore.Results[i].ConditionResults[j].Success != ssFromOldStore.Results[i].ConditionResults[j].Success {
|
||||
t.Error("new and old should've been the same")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_Save(t *testing.T) {
|
||||
store, _ := NewStore("sqlite", t.TempDir()+"/TestStore_Save.db")
|
||||
defer store.Close()
|
||||
if store.Save() != nil {
|
||||
t.Error("Save shouldn't do anything for this store")
|
||||
}
|
||||
}
|
||||
|
||||
// Note that are much more extensive tests in /storage/store/store_test.go.
|
||||
// This test is simply an extra sanity check
|
||||
func TestStore_SanityCheck(t *testing.T) {
|
||||
store, _ := NewStore("sqlite", t.TempDir()+"/TestStore_SanityCheck.db")
|
||||
defer store.Close()
|
||||
store.Insert(&testService, &testSuccessfulResult)
|
||||
if numberOfServiceStatuses := len(store.GetAllServiceStatuses(paging.NewServiceStatusParams())); numberOfServiceStatuses != 1 {
|
||||
t.Fatalf("expected 1 ServiceStatus, got %d", numberOfServiceStatuses)
|
||||
}
|
||||
store.Insert(&testService, &testUnsuccessfulResult)
|
||||
// Both results inserted are for the same service, therefore, the count shouldn't have increased
|
||||
if numberOfServiceStatuses := len(store.GetAllServiceStatuses(paging.NewServiceStatusParams())); numberOfServiceStatuses != 1 {
|
||||
t.Fatalf("expected 1 ServiceStatus, got %d", numberOfServiceStatuses)
|
||||
}
|
||||
ss := store.GetServiceStatus(testService.Group, testService.Name, paging.NewServiceStatusParams().WithResults(1, 20).WithEvents(1, 20))
|
||||
if ss == nil {
|
||||
t.Fatalf("Store should've had key '%s', but didn't", testService.Key())
|
||||
}
|
||||
if len(ss.Events) != 3 {
|
||||
t.Errorf("Service '%s' should've had 3 events, got %d", ss.Name, len(ss.Events))
|
||||
}
|
||||
if len(ss.Results) != 2 {
|
||||
t.Errorf("Service '%s' should've had 2 results, got %d", ss.Name, len(ss.Results))
|
||||
}
|
||||
if deleted := store.DeleteAllServiceStatusesNotInKeys([]string{}); deleted != 1 {
|
||||
t.Errorf("%d entries should've been deleted, got %d", 1, deleted)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStore_InvalidTransaction tests what happens if an invalid transaction is passed as parameter
|
||||
func TestStore_InvalidTransaction(t *testing.T) {
|
||||
store, _ := NewStore("sqlite", t.TempDir()+"/TestStore_InvalidTransaction.db")
|
||||
defer store.Close()
|
||||
tx, _ := store.db.Begin()
|
||||
tx.Commit()
|
||||
if _, err := store.insertService(tx, &testService); err == nil {
|
||||
t.Error("should've returned an error, because the transaction was already committed")
|
||||
}
|
||||
if err := store.insertEvent(tx, 1, core.NewEventFromResult(&testSuccessfulResult)); err == nil {
|
||||
t.Error("should've returned an error, because the transaction was already committed")
|
||||
}
|
||||
if err := store.insertResult(tx, 1, &testSuccessfulResult); err == nil {
|
||||
t.Error("should've returned an error, because the transaction was already committed")
|
||||
}
|
||||
if err := store.insertConditionResults(tx, 1, testSuccessfulResult.ConditionResults); err == nil {
|
||||
t.Error("should've returned an error, because the transaction was already committed")
|
||||
}
|
||||
if err := store.updateServiceUptime(tx, 1, &testSuccessfulResult); err == nil {
|
||||
t.Error("should've returned an error, because the transaction was already committed")
|
||||
}
|
||||
if _, err := store.getAllServiceKeys(tx); err == nil {
|
||||
t.Error("should've returned an error, because the transaction was already committed")
|
||||
}
|
||||
if _, err := store.getServiceStatusByKey(tx, testService.Key(), paging.NewServiceStatusParams().WithResults(1, 20)); err == nil {
|
||||
t.Error("should've returned an error, because the transaction was already committed")
|
||||
}
|
||||
if _, err := store.getEventsByServiceID(tx, 1, 1, 50); err == nil {
|
||||
t.Error("should've returned an error, because the transaction was already committed")
|
||||
}
|
||||
if _, err := store.getResultsByServiceID(tx, 1, 1, 50); err == nil {
|
||||
t.Error("should've returned an error, because the transaction was already committed")
|
||||
}
|
||||
if err := store.deleteOldServiceEvents(tx, 1); err == nil {
|
||||
t.Error("should've returned an error, because the transaction was already committed")
|
||||
}
|
||||
if err := store.deleteOldServiceResults(tx, 1); err == nil {
|
||||
t.Error("should've returned an error, because the transaction was already committed")
|
||||
}
|
||||
if _, _, err := store.getServiceUptime(tx, 1, time.Now(), time.Now()); err == nil {
|
||||
t.Error("should've returned an error, because the transaction was already committed")
|
||||
}
|
||||
if _, err := store.getServiceID(tx, &testService); err == nil {
|
||||
t.Error("should've returned an error, because the transaction was already committed")
|
||||
}
|
||||
if _, err := store.getNumberOfEventsByServiceID(tx, 1); err == nil {
|
||||
t.Error("should've returned an error, because the transaction was already committed")
|
||||
}
|
||||
if _, err := store.getNumberOfResultsByServiceID(tx, 1); err == nil {
|
||||
t.Error("should've returned an error, because the transaction was already committed")
|
||||
}
|
||||
if _, err := store.getAgeOfOldestServiceUptimeEntry(tx, 1); err == nil {
|
||||
t.Error("should've returned an error, because the transaction was already committed")
|
||||
}
|
||||
if _, err := store.getLastServiceResultSuccessValue(tx, 1); err == nil {
|
||||
t.Error("should've returned an error, because the transaction was already committed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_NoRows(t *testing.T) {
|
||||
store, _ := NewStore("sqlite", t.TempDir()+"/TestStore_NoRows.db")
|
||||
defer store.Close()
|
||||
tx, _ := store.db.Begin()
|
||||
defer tx.Rollback()
|
||||
if _, err := store.getLastServiceResultSuccessValue(tx, 1); err != errNoRowsReturned {
|
||||
t.Errorf("should've %v, got %v", errNoRowsReturned, err)
|
||||
}
|
||||
if _, err := store.getAgeOfOldestServiceUptimeEntry(tx, 1); err != errNoRowsReturned {
|
||||
t.Errorf("should've %v, got %v", errNoRowsReturned, err)
|
||||
}
|
||||
}
|
||||
@@ -2,20 +2,26 @@ package store
|
||||
|
||||
import (
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwinProduction/gatus/storage/store/common/paging"
|
||||
"github.com/TwinProduction/gatus/storage/store/memory"
|
||||
"github.com/TwinProduction/gatus/storage/store/sqlite"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Store is the interface that each stores should implement
|
||||
type Store interface {
|
||||
// GetAllServiceStatusesWithResultPagination returns the JSON encoding of all monitored core.ServiceStatus
|
||||
// GetAllServiceStatuses returns the JSON encoding of all monitored core.ServiceStatus
|
||||
// with a subset of core.Result defined by the page and pageSize parameters
|
||||
GetAllServiceStatusesWithResultPagination(page, pageSize int) map[string]*core.ServiceStatus
|
||||
GetAllServiceStatuses(params *paging.ServiceStatusParams) map[string]*core.ServiceStatus
|
||||
|
||||
// GetServiceStatus returns the service status for a given service name in the given group
|
||||
GetServiceStatus(groupName, serviceName string) *core.ServiceStatus
|
||||
GetServiceStatus(groupName, serviceName string, params *paging.ServiceStatusParams) *core.ServiceStatus
|
||||
|
||||
// GetServiceStatusByKey returns the service status for a given key
|
||||
GetServiceStatusByKey(key string) *core.ServiceStatus
|
||||
GetServiceStatusByKey(key string, params *paging.ServiceStatusParams) *core.ServiceStatus
|
||||
|
||||
// GetUptimeByKey returns the uptime percentage during a time range
|
||||
GetUptimeByKey(key string, from, to time.Time) (float64, error)
|
||||
|
||||
// Insert adds the observed result for the specified service into the store
|
||||
Insert(service *core.Service, result *core.Result)
|
||||
@@ -30,9 +36,16 @@ type Store interface {
|
||||
|
||||
// Save persists the data if and where it needs to be persisted
|
||||
Save() error
|
||||
|
||||
// Close terminates every connections and closes the store, if applicable.
|
||||
// Should only be used before stopping the application.
|
||||
Close()
|
||||
}
|
||||
|
||||
// TODO: add method to check state of store (by keeping track of silent errors)
|
||||
|
||||
var (
|
||||
// Validate interface implementation on compile
|
||||
_ Store = (*memory.Store)(nil)
|
||||
_ Store = (*sqlite.Store)(nil)
|
||||
)
|
||||
|
||||
@@ -5,105 +5,66 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwinProduction/gatus/storage/store/common/paging"
|
||||
"github.com/TwinProduction/gatus/storage/store/memory"
|
||||
"github.com/TwinProduction/gatus/storage/store/sqlite"
|
||||
)
|
||||
|
||||
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 BenchmarkStore_GetAllAsJSON(b *testing.B) {
|
||||
func BenchmarkStore_GetAllServiceStatuses(b *testing.B) {
|
||||
memoryStore, err := memory.NewStore("")
|
||||
if err != nil {
|
||||
b.Fatal("failed to create store:", err.Error())
|
||||
}
|
||||
sqliteStore, err := sqlite.NewStore("sqlite", b.TempDir()+"/BenchmarkStore_GetAllServiceStatuses.db")
|
||||
if err != nil {
|
||||
b.Fatal("failed to create store:", err.Error())
|
||||
}
|
||||
defer sqliteStore.Close()
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Store Store
|
||||
Name string
|
||||
Store Store
|
||||
Parallel bool
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "memory",
|
||||
Store: memoryStore,
|
||||
Name: "memory",
|
||||
Store: memoryStore,
|
||||
Parallel: false,
|
||||
},
|
||||
{
|
||||
Name: "memory-parallel",
|
||||
Store: memoryStore,
|
||||
Parallel: true,
|
||||
},
|
||||
{
|
||||
Name: "sqlite",
|
||||
Store: sqliteStore,
|
||||
Parallel: false,
|
||||
},
|
||||
{
|
||||
Name: "sqlite-parallel",
|
||||
Store: sqliteStore,
|
||||
Parallel: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Store.Insert(&testService, &testSuccessfulResult)
|
||||
scenario.Store.Insert(&testService, &testUnsuccessfulResult)
|
||||
b.Run(scenario.Name, func(b *testing.B) {
|
||||
for n := 0; n < b.N; n++ {
|
||||
scenario.Store.GetAllServiceStatusesWithResultPagination(1, 20)
|
||||
if scenario.Parallel {
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
scenario.Store.GetAllServiceStatuses(paging.NewServiceStatusParams().WithResults(1, 20))
|
||||
}
|
||||
})
|
||||
} else {
|
||||
for n := 0; n < b.N; n++ {
|
||||
scenario.Store.GetAllServiceStatuses(paging.NewServiceStatusParams().WithResults(1, 20))
|
||||
}
|
||||
}
|
||||
b.ReportAllocs()
|
||||
})
|
||||
scenario.Store.Clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,26 +73,129 @@ func BenchmarkStore_Insert(b *testing.B) {
|
||||
if err != nil {
|
||||
b.Fatal("failed to create store:", err.Error())
|
||||
}
|
||||
sqliteStore, err := sqlite.NewStore("sqlite", b.TempDir()+"/BenchmarkStore_Insert.db")
|
||||
if err != nil {
|
||||
b.Fatal("failed to create store:", err.Error())
|
||||
}
|
||||
defer sqliteStore.Close()
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Store Store
|
||||
Name string
|
||||
Store Store
|
||||
Parallel bool
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "memory",
|
||||
Store: memoryStore,
|
||||
Name: "memory",
|
||||
Store: memoryStore,
|
||||
Parallel: false,
|
||||
},
|
||||
{
|
||||
Name: "memory-parallel",
|
||||
Store: memoryStore,
|
||||
Parallel: true,
|
||||
},
|
||||
{
|
||||
Name: "sqlite",
|
||||
Store: sqliteStore,
|
||||
Parallel: false,
|
||||
},
|
||||
{
|
||||
Name: "sqlite-parallel",
|
||||
Store: sqliteStore,
|
||||
Parallel: false,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
b.Run(scenario.Name, func(b *testing.B) {
|
||||
for n := 0; n < b.N; n++ {
|
||||
if n%100 == 0 {
|
||||
scenario.Store.Insert(&testService, &testSuccessfulResult)
|
||||
} else {
|
||||
scenario.Store.Insert(&testService, &testUnsuccessfulResult)
|
||||
if scenario.Parallel {
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
n := 0
|
||||
for pb.Next() {
|
||||
var result core.Result
|
||||
if n%10 == 0 {
|
||||
result = testUnsuccessfulResult
|
||||
} else {
|
||||
result = testSuccessfulResult
|
||||
}
|
||||
result.Timestamp = time.Now()
|
||||
scenario.Store.Insert(&testService, &result)
|
||||
n++
|
||||
}
|
||||
})
|
||||
} else {
|
||||
for n := 0; n < b.N; n++ {
|
||||
var result core.Result
|
||||
if n%10 == 0 {
|
||||
result = testUnsuccessfulResult
|
||||
} else {
|
||||
result = testSuccessfulResult
|
||||
}
|
||||
result.Timestamp = time.Now()
|
||||
scenario.Store.Insert(&testService, &result)
|
||||
}
|
||||
}
|
||||
b.ReportAllocs()
|
||||
scenario.Store.Clear()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkStore_GetServiceStatusByKey(b *testing.B) {
|
||||
memoryStore, err := memory.NewStore("")
|
||||
if err != nil {
|
||||
b.Fatal("failed to create store:", err.Error())
|
||||
}
|
||||
sqliteStore, err := sqlite.NewStore("sqlite", b.TempDir()+"/BenchmarkStore_GetServiceStatusByKey.db")
|
||||
if err != nil {
|
||||
b.Fatal("failed to create store:", err.Error())
|
||||
}
|
||||
defer sqliteStore.Close()
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Store Store
|
||||
Parallel bool
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "memory",
|
||||
Store: memoryStore,
|
||||
Parallel: false,
|
||||
},
|
||||
{
|
||||
Name: "memory-parallel",
|
||||
Store: memoryStore,
|
||||
Parallel: true,
|
||||
},
|
||||
{
|
||||
Name: "sqlite",
|
||||
Store: sqliteStore,
|
||||
Parallel: false,
|
||||
},
|
||||
{
|
||||
Name: "sqlite-parallel",
|
||||
Store: sqliteStore,
|
||||
Parallel: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
for i := 0; i < 50; i++ {
|
||||
scenario.Store.Insert(&testService, &testSuccessfulResult)
|
||||
scenario.Store.Insert(&testService, &testUnsuccessfulResult)
|
||||
}
|
||||
b.Run(scenario.Name, func(b *testing.B) {
|
||||
if scenario.Parallel {
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
scenario.Store.GetServiceStatusByKey(testService.Key(), paging.NewServiceStatusParams().WithResults(1, 20))
|
||||
}
|
||||
})
|
||||
} else {
|
||||
for n := 0; n < b.N; n++ {
|
||||
scenario.Store.GetServiceStatusByKey(testService.Key(), paging.NewServiceStatusParams().WithResults(1, 20))
|
||||
}
|
||||
}
|
||||
b.ReportAllocs()
|
||||
})
|
||||
scenario.Store.Clear()
|
||||
}
|
||||
}
|
||||
|
||||
401
storage/store/store_test.go
Normal file
401
storage/store/store_test.go
Normal file
@@ -0,0 +1,401 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwinProduction/gatus/storage/store/common"
|
||||
"github.com/TwinProduction/gatus/storage/store/common/paging"
|
||||
"github.com/TwinProduction/gatus/storage/store/memory"
|
||||
"github.com/TwinProduction/gatus/storage/store/sqlite"
|
||||
)
|
||||
|
||||
var (
|
||||
firstCondition = core.Condition("[STATUS] == 200")
|
||||
secondCondition = core.Condition("[RESPONSE_TIME] < 500")
|
||||
thirdCondition = core.Condition("[CERTIFICATE_EXPIRATION] < 72h")
|
||||
|
||||
now = time.Now().Truncate(time.Minute)
|
||||
|
||||
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{
|
||||
Timestamp: now,
|
||||
Success: true,
|
||||
Hostname: "example.org",
|
||||
IP: "127.0.0.1",
|
||||
HTTPStatus: 200,
|
||||
Errors: nil,
|
||||
Connected: true,
|
||||
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{
|
||||
Timestamp: now,
|
||||
Success: false,
|
||||
Hostname: "example.org",
|
||||
IP: "127.0.0.1",
|
||||
HTTPStatus: 200,
|
||||
Errors: []string{"error-1", "error-2"},
|
||||
Connected: true,
|
||||
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,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Store Store
|
||||
}
|
||||
|
||||
func initStoresAndBaseScenarios(t *testing.T, testName string) []*Scenario {
|
||||
memoryStore, err := memory.NewStore("")
|
||||
if err != nil {
|
||||
t.Fatal("failed to create store:", err.Error())
|
||||
}
|
||||
sqliteStore, err := sqlite.NewStore("sqlite", t.TempDir()+"/"+testName+".db")
|
||||
if err != nil {
|
||||
t.Fatal("failed to create store:", err.Error())
|
||||
}
|
||||
return []*Scenario{
|
||||
{
|
||||
Name: "memory",
|
||||
Store: memoryStore,
|
||||
},
|
||||
{
|
||||
Name: "sqlite",
|
||||
Store: sqliteStore,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func cleanUp(scenarios []*Scenario) {
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Store.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_GetServiceStatusByKey(t *testing.T) {
|
||||
scenarios := initStoresAndBaseScenarios(t, "TestStore_GetServiceStatusByKey")
|
||||
defer cleanUp(scenarios)
|
||||
firstResult := testSuccessfulResult
|
||||
firstResult.Timestamp = now.Add(-time.Minute)
|
||||
secondResult := testUnsuccessfulResult
|
||||
secondResult.Timestamp = now
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
scenario.Store.Insert(&testService, &firstResult)
|
||||
scenario.Store.Insert(&testService, &secondResult)
|
||||
|
||||
serviceStatus := scenario.Store.GetServiceStatusByKey(testService.Key(), paging.NewServiceStatusParams().WithEvents(1, common.MaximumNumberOfEvents).WithResults(1, common.MaximumNumberOfResults))
|
||||
if serviceStatus == nil {
|
||||
t.Fatalf("serviceStatus shouldn't have been nil")
|
||||
}
|
||||
if serviceStatus.Name != testService.Name {
|
||||
t.Fatalf("serviceStatus.Name should've been %s, got %s", testService.Name, serviceStatus.Name)
|
||||
}
|
||||
if serviceStatus.Group != testService.Group {
|
||||
t.Fatalf("serviceStatus.Group should've been %s, got %s", testService.Group, serviceStatus.Group)
|
||||
}
|
||||
if len(serviceStatus.Results) != 2 {
|
||||
t.Fatalf("serviceStatus.Results should've had 2 entries")
|
||||
}
|
||||
if serviceStatus.Results[0].Timestamp.After(serviceStatus.Results[1].Timestamp) {
|
||||
t.Error("The result at index 0 should've been older than the result at index 1")
|
||||
}
|
||||
scenario.Store.Clear()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_GetServiceStatusForMissingStatusReturnsNil(t *testing.T) {
|
||||
scenarios := initStoresAndBaseScenarios(t, "TestStore_GetServiceStatusForMissingStatusReturnsNil")
|
||||
defer cleanUp(scenarios)
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
scenario.Store.Insert(&testService, &testSuccessfulResult)
|
||||
serviceStatus := scenario.Store.GetServiceStatus("nonexistantgroup", "nonexistantname", paging.NewServiceStatusParams().WithEvents(1, common.MaximumNumberOfEvents).WithResults(1, common.MaximumNumberOfResults))
|
||||
if serviceStatus != nil {
|
||||
t.Errorf("Returned service status for group '%s' and name '%s' not nil after inserting the service into the store", testService.Group, testService.Name)
|
||||
}
|
||||
serviceStatus = scenario.Store.GetServiceStatus(testService.Group, "nonexistantname", paging.NewServiceStatusParams().WithEvents(1, common.MaximumNumberOfEvents).WithResults(1, common.MaximumNumberOfResults))
|
||||
if serviceStatus != nil {
|
||||
t.Errorf("Returned service status for group '%s' and name '%s' not nil after inserting the service into the store", testService.Group, "nonexistantname")
|
||||
}
|
||||
serviceStatus = scenario.Store.GetServiceStatus("nonexistantgroup", testService.Name, paging.NewServiceStatusParams().WithEvents(1, common.MaximumNumberOfEvents).WithResults(1, common.MaximumNumberOfResults))
|
||||
if serviceStatus != nil {
|
||||
t.Errorf("Returned service status for group '%s' and name '%s' not nil after inserting the service into the store", "nonexistantgroup", testService.Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_GetAllServiceStatuses(t *testing.T) {
|
||||
scenarios := initStoresAndBaseScenarios(t, "TestStore_GetAllServiceStatuses")
|
||||
defer cleanUp(scenarios)
|
||||
firstResult := testSuccessfulResult
|
||||
secondResult := testUnsuccessfulResult
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
scenario.Store.Insert(&testService, &firstResult)
|
||||
scenario.Store.Insert(&testService, &secondResult)
|
||||
// Can't be bothered dealing with timezone issues on the worker that runs the automated tests
|
||||
serviceStatuses := scenario.Store.GetAllServiceStatuses(paging.NewServiceStatusParams().WithResults(1, 20))
|
||||
if len(serviceStatuses) != 1 {
|
||||
t.Fatal("expected 1 service status")
|
||||
}
|
||||
actual, exists := serviceStatuses[testService.Key()]
|
||||
if !exists {
|
||||
t.Fatal("expected service status to exist")
|
||||
}
|
||||
if len(actual.Results) != 2 {
|
||||
t.Error("expected 2 results, got", len(actual.Results))
|
||||
}
|
||||
if len(actual.Events) != 0 {
|
||||
t.Error("expected 0 events, got", len(actual.Events))
|
||||
}
|
||||
scenario.Store.Clear()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_GetAllServiceStatusesWithResultsAndEvents(t *testing.T) {
|
||||
scenarios := initStoresAndBaseScenarios(t, "TestStore_GetAllServiceStatusesWithResultsAndEvents")
|
||||
defer cleanUp(scenarios)
|
||||
firstResult := testSuccessfulResult
|
||||
secondResult := testUnsuccessfulResult
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
scenario.Store.Insert(&testService, &firstResult)
|
||||
scenario.Store.Insert(&testService, &secondResult)
|
||||
// Can't be bothered dealing with timezone issues on the worker that runs the automated tests
|
||||
serviceStatuses := scenario.Store.GetAllServiceStatuses(paging.NewServiceStatusParams().WithResults(1, 20).WithEvents(1, 50))
|
||||
if len(serviceStatuses) != 1 {
|
||||
t.Fatal("expected 1 service status")
|
||||
}
|
||||
actual, exists := serviceStatuses[testService.Key()]
|
||||
if !exists {
|
||||
t.Fatal("expected service status to exist")
|
||||
}
|
||||
if len(actual.Results) != 2 {
|
||||
t.Error("expected 2 results, got", len(actual.Results))
|
||||
}
|
||||
if len(actual.Events) != 3 {
|
||||
t.Error("expected 3 events, got", len(actual.Events))
|
||||
}
|
||||
scenario.Store.Clear()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_GetServiceStatusPage1IsHasMoreRecentResultsThanPage2(t *testing.T) {
|
||||
scenarios := initStoresAndBaseScenarios(t, "TestStore_GetServiceStatusPage1IsHasMoreRecentResultsThanPage2")
|
||||
defer cleanUp(scenarios)
|
||||
firstResult := testSuccessfulResult
|
||||
firstResult.Timestamp = now.Add(-time.Minute)
|
||||
secondResult := testUnsuccessfulResult
|
||||
secondResult.Timestamp = now
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
scenario.Store.Insert(&testService, &firstResult)
|
||||
scenario.Store.Insert(&testService, &secondResult)
|
||||
serviceStatusPage1 := scenario.Store.GetServiceStatusByKey(testService.Key(), paging.NewServiceStatusParams().WithResults(1, 1))
|
||||
if serviceStatusPage1 == nil {
|
||||
t.Fatalf("serviceStatusPage1 shouldn't have been nil")
|
||||
}
|
||||
if len(serviceStatusPage1.Results) != 1 {
|
||||
t.Fatalf("serviceStatusPage1 should've had 1 result")
|
||||
}
|
||||
serviceStatusPage2 := scenario.Store.GetServiceStatusByKey(testService.Key(), paging.NewServiceStatusParams().WithResults(2, 1))
|
||||
if serviceStatusPage2 == nil {
|
||||
t.Fatalf("serviceStatusPage2 shouldn't have been nil")
|
||||
}
|
||||
if len(serviceStatusPage2.Results) != 1 {
|
||||
t.Fatalf("serviceStatusPage2 should've had 1 result")
|
||||
}
|
||||
// Compare the timestamp of both pages
|
||||
if !serviceStatusPage1.Results[0].Timestamp.After(serviceStatusPage2.Results[0].Timestamp) {
|
||||
t.Errorf("The result from the first page should've been more recent than the results from the second page")
|
||||
}
|
||||
scenario.Store.Clear()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_GetUptimeByKey(t *testing.T) {
|
||||
scenarios := initStoresAndBaseScenarios(t, "TestStore_GetUptimeByKey")
|
||||
defer cleanUp(scenarios)
|
||||
firstResult := testSuccessfulResult
|
||||
firstResult.Timestamp = now.Add(-time.Minute)
|
||||
secondResult := testUnsuccessfulResult
|
||||
secondResult.Timestamp = now
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
if _, err := scenario.Store.GetUptimeByKey(testService.Key(), time.Now().Add(-time.Hour), time.Now()); err != common.ErrServiceNotFound {
|
||||
t.Errorf("should've returned not found because there's nothing yet, got %v", err)
|
||||
}
|
||||
scenario.Store.Insert(&testService, &firstResult)
|
||||
scenario.Store.Insert(&testService, &secondResult)
|
||||
if uptime, _ := scenario.Store.GetUptimeByKey(testService.Key(), time.Now().Add(-time.Hour), time.Now()); uptime != 0.5 {
|
||||
t.Errorf("the uptime over the past 1h should've been 0.5, got %f", uptime)
|
||||
}
|
||||
if uptime, _ := scenario.Store.GetUptimeByKey(testService.Key(), time.Now().Add(-time.Hour*24), time.Now()); uptime != 0.5 {
|
||||
t.Errorf("the uptime over the past 24h should've been 0.5, got %f", uptime)
|
||||
}
|
||||
if uptime, _ := scenario.Store.GetUptimeByKey(testService.Key(), time.Now().Add(-time.Hour*24*7), time.Now()); uptime != 0.5 {
|
||||
t.Errorf("the uptime over the past 7d should've been 0.5, got %f", uptime)
|
||||
}
|
||||
if _, err := scenario.Store.GetUptimeByKey(testService.Key(), time.Now(), time.Now().Add(-time.Hour)); err == nil {
|
||||
t.Error("should've returned an error because the parameter 'from' cannot be older than 'to'")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_Insert(t *testing.T) {
|
||||
scenarios := initStoresAndBaseScenarios(t, "TestStore_Insert")
|
||||
defer cleanUp(scenarios)
|
||||
firstResult := testSuccessfulResult
|
||||
firstResult.Timestamp = now.Add(-time.Minute)
|
||||
secondResult := testUnsuccessfulResult
|
||||
secondResult.Timestamp = now
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
scenario.Store.Insert(&testService, &testSuccessfulResult)
|
||||
scenario.Store.Insert(&testService, &testUnsuccessfulResult)
|
||||
|
||||
ss := scenario.Store.GetServiceStatusByKey(testService.Key(), paging.NewServiceStatusParams().WithEvents(1, common.MaximumNumberOfEvents).WithResults(1, common.MaximumNumberOfResults))
|
||||
if ss == nil {
|
||||
t.Fatalf("Store should've had key '%s', but didn't", testService.Key())
|
||||
}
|
||||
if len(ss.Events) != 3 {
|
||||
t.Fatalf("Service '%s' should've had 3 events, got %d", ss.Name, len(ss.Events))
|
||||
}
|
||||
if len(ss.Results) != 2 {
|
||||
t.Fatalf("Service '%s' should've had 2 results, got %d", ss.Name, len(ss.Results))
|
||||
}
|
||||
for i, expectedResult := range []core.Result{testSuccessfulResult, testUnsuccessfulResult} {
|
||||
if expectedResult.HTTPStatus != ss.Results[i].HTTPStatus {
|
||||
t.Errorf("Result at index %d should've had a HTTPStatus of %d, got %d", i, ss.Results[i].HTTPStatus, expectedResult.HTTPStatus)
|
||||
}
|
||||
if expectedResult.DNSRCode != ss.Results[i].DNSRCode {
|
||||
t.Errorf("Result at index %d should've had a DNSRCode of %s, got %s", i, ss.Results[i].DNSRCode, expectedResult.DNSRCode)
|
||||
}
|
||||
if expectedResult.Hostname != ss.Results[i].Hostname {
|
||||
t.Errorf("Result at index %d should've had a Hostname of %s, got %s", i, ss.Results[i].Hostname, expectedResult.Hostname)
|
||||
}
|
||||
if expectedResult.IP != ss.Results[i].IP {
|
||||
t.Errorf("Result at index %d should've had a IP of %s, got %s", i, ss.Results[i].IP, expectedResult.IP)
|
||||
}
|
||||
if expectedResult.Connected != ss.Results[i].Connected {
|
||||
t.Errorf("Result at index %d should've had a Connected value of %t, got %t", i, ss.Results[i].Connected, expectedResult.Connected)
|
||||
}
|
||||
if expectedResult.Duration != ss.Results[i].Duration {
|
||||
t.Errorf("Result at index %d should've had a Duration of %s, got %s", i, ss.Results[i].Duration.String(), expectedResult.Duration.String())
|
||||
}
|
||||
if len(expectedResult.Errors) != len(ss.Results[i].Errors) {
|
||||
t.Errorf("Result at index %d should've had %d errors, but actually had %d errors", i, len(ss.Results[i].Errors), len(expectedResult.Errors))
|
||||
} else {
|
||||
for j := range expectedResult.Errors {
|
||||
if ss.Results[i].Errors[j] != expectedResult.Errors[j] {
|
||||
t.Error("should've been the same")
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(expectedResult.ConditionResults) != len(ss.Results[i].ConditionResults) {
|
||||
t.Errorf("Result at index %d should've had %d ConditionResults, but actually had %d ConditionResults", i, len(ss.Results[i].ConditionResults), len(expectedResult.ConditionResults))
|
||||
} else {
|
||||
for j := range expectedResult.ConditionResults {
|
||||
if ss.Results[i].ConditionResults[j].Condition != expectedResult.ConditionResults[j].Condition {
|
||||
t.Error("should've been the same")
|
||||
}
|
||||
if ss.Results[i].ConditionResults[j].Success != expectedResult.ConditionResults[j].Success {
|
||||
t.Error("should've been the same")
|
||||
}
|
||||
}
|
||||
}
|
||||
if expectedResult.Success != ss.Results[i].Success {
|
||||
t.Errorf("Result at index %d should've had a Success of %t, got %t", i, ss.Results[i].Success, expectedResult.Success)
|
||||
}
|
||||
if expectedResult.Timestamp.Unix() != ss.Results[i].Timestamp.Unix() {
|
||||
t.Errorf("Result at index %d should've had a Timestamp of %d, got %d", i, ss.Results[i].Timestamp.Unix(), expectedResult.Timestamp.Unix())
|
||||
}
|
||||
if expectedResult.CertificateExpiration != ss.Results[i].CertificateExpiration {
|
||||
t.Errorf("Result at index %d should've had a CertificateExpiration of %s, got %s", i, ss.Results[i].CertificateExpiration.String(), expectedResult.CertificateExpiration.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_DeleteAllServiceStatusesNotInKeys(t *testing.T) {
|
||||
scenarios := initStoresAndBaseScenarios(t, "TestStore_DeleteAllServiceStatusesNotInKeys")
|
||||
defer cleanUp(scenarios)
|
||||
firstService := core.Service{Name: "service-1", Group: "group"}
|
||||
secondService := core.Service{Name: "service-2", Group: "group"}
|
||||
result := &testSuccessfulResult
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
scenario.Store.Insert(&firstService, result)
|
||||
scenario.Store.Insert(&secondService, result)
|
||||
if scenario.Store.GetServiceStatusByKey(firstService.Key(), paging.NewServiceStatusParams()) == nil {
|
||||
t.Fatal("firstService should exist")
|
||||
}
|
||||
if scenario.Store.GetServiceStatusByKey(secondService.Key(), paging.NewServiceStatusParams()) == nil {
|
||||
t.Fatal("secondService should exist")
|
||||
}
|
||||
scenario.Store.DeleteAllServiceStatusesNotInKeys([]string{firstService.Key()})
|
||||
if scenario.Store.GetServiceStatusByKey(firstService.Key(), paging.NewServiceStatusParams()) == nil {
|
||||
t.Error("secondService should've been deleted")
|
||||
}
|
||||
if scenario.Store.GetServiceStatusByKey(secondService.Key(), paging.NewServiceStatusParams()) != nil {
|
||||
t.Error("firstService should still exist")
|
||||
}
|
||||
// Delete everything
|
||||
scenario.Store.DeleteAllServiceStatusesNotInKeys([]string{})
|
||||
if len(scenario.Store.GetAllServiceStatuses(paging.NewServiceStatusParams())) != 0 {
|
||||
t.Errorf("everything should've been deleted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
9
storage/type.go
Normal file
9
storage/type.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package storage
|
||||
|
||||
// Type of the store.
|
||||
type Type string
|
||||
|
||||
const (
|
||||
TypeMemory Type = "memory" // In-memory store
|
||||
TypeSQLite Type = "sqlite" // SQLite store
|
||||
)
|
||||
11
util/key_bench_test.go
Normal file
11
util/key_bench_test.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func BenchmarkConvertGroupAndServiceToKey(b *testing.B) {
|
||||
for n := 0; n < b.N; n++ {
|
||||
ConvertGroupAndServiceToKey("group", "service")
|
||||
}
|
||||
}
|
||||
19
vendor/github.com/kballard/go-shellquote/LICENSE
generated
vendored
Normal file
19
vendor/github.com/kballard/go-shellquote/LICENSE
generated
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
Copyright (C) 2014 Kevin Ballard
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the "Software"),
|
||||
to deal in the Software without restriction, including without limitation
|
||||
the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
and/or sell copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included
|
||||
in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
|
||||
OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
36
vendor/github.com/kballard/go-shellquote/README
generated
vendored
Normal file
36
vendor/github.com/kballard/go-shellquote/README
generated
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
PACKAGE
|
||||
|
||||
package shellquote
|
||||
import "github.com/kballard/go-shellquote"
|
||||
|
||||
Shellquote provides utilities for joining/splitting strings using sh's
|
||||
word-splitting rules.
|
||||
|
||||
VARIABLES
|
||||
|
||||
var (
|
||||
UnterminatedSingleQuoteError = errors.New("Unterminated single-quoted string")
|
||||
UnterminatedDoubleQuoteError = errors.New("Unterminated double-quoted string")
|
||||
UnterminatedEscapeError = errors.New("Unterminated backslash-escape")
|
||||
)
|
||||
|
||||
|
||||
FUNCTIONS
|
||||
|
||||
func Join(args ...string) string
|
||||
Join quotes each argument and joins them with a space. If passed to
|
||||
/bin/sh, the resulting string will be split back into the original
|
||||
arguments.
|
||||
|
||||
func Split(input string) (words []string, err error)
|
||||
Split splits a string according to /bin/sh's word-splitting rules. It
|
||||
supports backslash-escapes, single-quotes, and double-quotes. Notably it
|
||||
does not support the $'' style of quoting. It also doesn't attempt to
|
||||
perform any other sort of expansion, including brace expansion, shell
|
||||
expansion, or pathname expansion.
|
||||
|
||||
If the given input has an unterminated quoted string or ends in a
|
||||
backslash-escape, one of UnterminatedSingleQuoteError,
|
||||
UnterminatedDoubleQuoteError, or UnterminatedEscapeError is returned.
|
||||
|
||||
|
||||
3
vendor/github.com/kballard/go-shellquote/doc.go
generated
vendored
Normal file
3
vendor/github.com/kballard/go-shellquote/doc.go
generated
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
// Shellquote provides utilities for joining/splitting strings using sh's
|
||||
// word-splitting rules.
|
||||
package shellquote
|
||||
102
vendor/github.com/kballard/go-shellquote/quote.go
generated
vendored
Normal file
102
vendor/github.com/kballard/go-shellquote/quote.go
generated
vendored
Normal file
@@ -0,0 +1,102 @@
|
||||
package shellquote
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Join quotes each argument and joins them with a space.
|
||||
// If passed to /bin/sh, the resulting string will be split back into the
|
||||
// original arguments.
|
||||
func Join(args ...string) string {
|
||||
var buf bytes.Buffer
|
||||
for i, arg := range args {
|
||||
if i != 0 {
|
||||
buf.WriteByte(' ')
|
||||
}
|
||||
quote(arg, &buf)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
const (
|
||||
specialChars = "\\'\"`${[|&;<>()*?!"
|
||||
extraSpecialChars = " \t\n"
|
||||
prefixChars = "~"
|
||||
)
|
||||
|
||||
func quote(word string, buf *bytes.Buffer) {
|
||||
// We want to try to produce a "nice" output. As such, we will
|
||||
// backslash-escape most characters, but if we encounter a space, or if we
|
||||
// encounter an extra-special char (which doesn't work with
|
||||
// backslash-escaping) we switch over to quoting the whole word. We do this
|
||||
// with a space because it's typically easier for people to read multi-word
|
||||
// arguments when quoted with a space rather than with ugly backslashes
|
||||
// everywhere.
|
||||
origLen := buf.Len()
|
||||
|
||||
if len(word) == 0 {
|
||||
// oops, no content
|
||||
buf.WriteString("''")
|
||||
return
|
||||
}
|
||||
|
||||
cur, prev := word, word
|
||||
atStart := true
|
||||
for len(cur) > 0 {
|
||||
c, l := utf8.DecodeRuneInString(cur)
|
||||
cur = cur[l:]
|
||||
if strings.ContainsRune(specialChars, c) || (atStart && strings.ContainsRune(prefixChars, c)) {
|
||||
// copy the non-special chars up to this point
|
||||
if len(cur) < len(prev) {
|
||||
buf.WriteString(prev[0 : len(prev)-len(cur)-l])
|
||||
}
|
||||
buf.WriteByte('\\')
|
||||
buf.WriteRune(c)
|
||||
prev = cur
|
||||
} else if strings.ContainsRune(extraSpecialChars, c) {
|
||||
// start over in quote mode
|
||||
buf.Truncate(origLen)
|
||||
goto quote
|
||||
}
|
||||
atStart = false
|
||||
}
|
||||
if len(prev) > 0 {
|
||||
buf.WriteString(prev)
|
||||
}
|
||||
return
|
||||
|
||||
quote:
|
||||
// quote mode
|
||||
// Use single-quotes, but if we find a single-quote in the word, we need
|
||||
// to terminate the string, emit an escaped quote, and start the string up
|
||||
// again
|
||||
inQuote := false
|
||||
for len(word) > 0 {
|
||||
i := strings.IndexRune(word, '\'')
|
||||
if i == -1 {
|
||||
break
|
||||
}
|
||||
if i > 0 {
|
||||
if !inQuote {
|
||||
buf.WriteByte('\'')
|
||||
inQuote = true
|
||||
}
|
||||
buf.WriteString(word[0:i])
|
||||
}
|
||||
word = word[i+1:]
|
||||
if inQuote {
|
||||
buf.WriteByte('\'')
|
||||
inQuote = false
|
||||
}
|
||||
buf.WriteString("\\'")
|
||||
}
|
||||
if len(word) > 0 {
|
||||
if !inQuote {
|
||||
buf.WriteByte('\'')
|
||||
}
|
||||
buf.WriteString(word)
|
||||
buf.WriteByte('\'')
|
||||
}
|
||||
}
|
||||
156
vendor/github.com/kballard/go-shellquote/unquote.go
generated
vendored
Normal file
156
vendor/github.com/kballard/go-shellquote/unquote.go
generated
vendored
Normal file
@@ -0,0 +1,156 @@
|
||||
package shellquote
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var (
|
||||
UnterminatedSingleQuoteError = errors.New("Unterminated single-quoted string")
|
||||
UnterminatedDoubleQuoteError = errors.New("Unterminated double-quoted string")
|
||||
UnterminatedEscapeError = errors.New("Unterminated backslash-escape")
|
||||
)
|
||||
|
||||
var (
|
||||
splitChars = " \n\t"
|
||||
singleChar = '\''
|
||||
doubleChar = '"'
|
||||
escapeChar = '\\'
|
||||
doubleEscapeChars = "$`\"\n\\"
|
||||
)
|
||||
|
||||
// Split splits a string according to /bin/sh's word-splitting rules. It
|
||||
// supports backslash-escapes, single-quotes, and double-quotes. Notably it does
|
||||
// not support the $'' style of quoting. It also doesn't attempt to perform any
|
||||
// other sort of expansion, including brace expansion, shell expansion, or
|
||||
// pathname expansion.
|
||||
//
|
||||
// If the given input has an unterminated quoted string or ends in a
|
||||
// backslash-escape, one of UnterminatedSingleQuoteError,
|
||||
// UnterminatedDoubleQuoteError, or UnterminatedEscapeError is returned.
|
||||
func Split(input string) (words []string, err error) {
|
||||
var buf bytes.Buffer
|
||||
words = make([]string, 0)
|
||||
|
||||
for len(input) > 0 {
|
||||
// skip any splitChars at the start
|
||||
c, l := utf8.DecodeRuneInString(input)
|
||||
if strings.ContainsRune(splitChars, c) {
|
||||
input = input[l:]
|
||||
continue
|
||||
} else if c == escapeChar {
|
||||
// Look ahead for escaped newline so we can skip over it
|
||||
next := input[l:]
|
||||
if len(next) == 0 {
|
||||
err = UnterminatedEscapeError
|
||||
return
|
||||
}
|
||||
c2, l2 := utf8.DecodeRuneInString(next)
|
||||
if c2 == '\n' {
|
||||
input = next[l2:]
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
var word string
|
||||
word, input, err = splitWord(input, &buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
words = append(words, word)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func splitWord(input string, buf *bytes.Buffer) (word string, remainder string, err error) {
|
||||
buf.Reset()
|
||||
|
||||
raw:
|
||||
{
|
||||
cur := input
|
||||
for len(cur) > 0 {
|
||||
c, l := utf8.DecodeRuneInString(cur)
|
||||
cur = cur[l:]
|
||||
if c == singleChar {
|
||||
buf.WriteString(input[0 : len(input)-len(cur)-l])
|
||||
input = cur
|
||||
goto single
|
||||
} else if c == doubleChar {
|
||||
buf.WriteString(input[0 : len(input)-len(cur)-l])
|
||||
input = cur
|
||||
goto double
|
||||
} else if c == escapeChar {
|
||||
buf.WriteString(input[0 : len(input)-len(cur)-l])
|
||||
input = cur
|
||||
goto escape
|
||||
} else if strings.ContainsRune(splitChars, c) {
|
||||
buf.WriteString(input[0 : len(input)-len(cur)-l])
|
||||
return buf.String(), cur, nil
|
||||
}
|
||||
}
|
||||
if len(input) > 0 {
|
||||
buf.WriteString(input)
|
||||
input = ""
|
||||
}
|
||||
goto done
|
||||
}
|
||||
|
||||
escape:
|
||||
{
|
||||
if len(input) == 0 {
|
||||
return "", "", UnterminatedEscapeError
|
||||
}
|
||||
c, l := utf8.DecodeRuneInString(input)
|
||||
if c == '\n' {
|
||||
// a backslash-escaped newline is elided from the output entirely
|
||||
} else {
|
||||
buf.WriteString(input[:l])
|
||||
}
|
||||
input = input[l:]
|
||||
}
|
||||
goto raw
|
||||
|
||||
single:
|
||||
{
|
||||
i := strings.IndexRune(input, singleChar)
|
||||
if i == -1 {
|
||||
return "", "", UnterminatedSingleQuoteError
|
||||
}
|
||||
buf.WriteString(input[0:i])
|
||||
input = input[i+1:]
|
||||
goto raw
|
||||
}
|
||||
|
||||
double:
|
||||
{
|
||||
cur := input
|
||||
for len(cur) > 0 {
|
||||
c, l := utf8.DecodeRuneInString(cur)
|
||||
cur = cur[l:]
|
||||
if c == doubleChar {
|
||||
buf.WriteString(input[0 : len(input)-len(cur)-l])
|
||||
input = cur
|
||||
goto raw
|
||||
} else if c == escapeChar {
|
||||
// bash only supports certain escapes in double-quoted strings
|
||||
c2, l2 := utf8.DecodeRuneInString(cur)
|
||||
cur = cur[l2:]
|
||||
if strings.ContainsRune(doubleEscapeChars, c2) {
|
||||
buf.WriteString(input[0 : len(input)-len(cur)-l-l2])
|
||||
if c2 == '\n' {
|
||||
// newline is special, skip the backslash entirely
|
||||
} else {
|
||||
buf.WriteRune(c2)
|
||||
}
|
||||
input = cur
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", "", UnterminatedDoubleQuoteError
|
||||
}
|
||||
|
||||
done:
|
||||
return buf.String(), input, nil
|
||||
}
|
||||
14
vendor/github.com/mattn/go-isatty/.travis.yml
generated
vendored
Normal file
14
vendor/github.com/mattn/go-isatty/.travis.yml
generated
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
language: go
|
||||
sudo: false
|
||||
go:
|
||||
- 1.13.x
|
||||
- tip
|
||||
|
||||
before_install:
|
||||
- go get -t -v ./...
|
||||
|
||||
script:
|
||||
- ./go.test.sh
|
||||
|
||||
after_success:
|
||||
- bash <(curl -s https://codecov.io/bash)
|
||||
9
vendor/github.com/mattn/go-isatty/LICENSE
generated
vendored
Normal file
9
vendor/github.com/mattn/go-isatty/LICENSE
generated
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
Copyright (c) Yasuhiro MATSUMOTO <mattn.jp@gmail.com>
|
||||
|
||||
MIT License (Expat)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
50
vendor/github.com/mattn/go-isatty/README.md
generated
vendored
Normal file
50
vendor/github.com/mattn/go-isatty/README.md
generated
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
# go-isatty
|
||||
|
||||
[](http://godoc.org/github.com/mattn/go-isatty)
|
||||
[](https://codecov.io/gh/mattn/go-isatty)
|
||||
[](https://coveralls.io/github/mattn/go-isatty?branch=master)
|
||||
[](https://goreportcard.com/report/mattn/go-isatty)
|
||||
|
||||
isatty for golang
|
||||
|
||||
## Usage
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/mattn/go-isatty"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if isatty.IsTerminal(os.Stdout.Fd()) {
|
||||
fmt.Println("Is Terminal")
|
||||
} else if isatty.IsCygwinTerminal(os.Stdout.Fd()) {
|
||||
fmt.Println("Is Cygwin/MSYS2 Terminal")
|
||||
} else {
|
||||
fmt.Println("Is Not Terminal")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
```
|
||||
$ go get github.com/mattn/go-isatty
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
## Author
|
||||
|
||||
Yasuhiro Matsumoto (a.k.a mattn)
|
||||
|
||||
## Thanks
|
||||
|
||||
* k-takata: base idea for IsCygwinTerminal
|
||||
|
||||
https://github.com/k-takata/go-iscygpty
|
||||
2
vendor/github.com/mattn/go-isatty/doc.go
generated
vendored
Normal file
2
vendor/github.com/mattn/go-isatty/doc.go
generated
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
// Package isatty implements interface to isatty
|
||||
package isatty
|
||||
5
vendor/github.com/mattn/go-isatty/go.mod
generated
vendored
Normal file
5
vendor/github.com/mattn/go-isatty/go.mod
generated
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
module github.com/mattn/go-isatty
|
||||
|
||||
go 1.12
|
||||
|
||||
require golang.org/x/sys v0.0.0-20200116001909-b77594299b42
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user