Compare commits

...

127 Commits

Author SHA1 Message Date
TwinProduction
d65cebb1fb Remove Uptime.Last* parameters 2021-08-13 01:25:50 -04:00
TwinProduction
0b6fc6b520 Add GetUptimeByKey to store interface 2021-08-13 01:25:50 -04:00
TwinProduction
968b960283 Add memory.db to .gitignore 2021-08-13 01:25:50 -04:00
TwinProduction
77ba2169cf Close #125: Add more uptime badge colors 2021-08-11 21:05:51 -04:00
TwinProduction
f6c32a90ac Add small comment 2021-08-10 19:58:19 -04:00
TwinProduction
932a67d9e7 Update list of sponsors 2021-08-09 19:03:07 -04:00
TwinProduction
ee414df03f Add missing comment 2021-08-08 20:16:12 -04:00
TwinProduction
718f8260bb Register missing struct 2021-08-07 12:22:39 -04:00
TwinProduction
3cbe068fc1 Rename storage type inmemory to memory
This is technically a breaking change, but given how long ago this field was implemented as well as the fact that this is the default value if the type is not specified, I doubt anybody's explicitly setting it as inmemory
2021-08-07 12:11:35 -04:00
TwinProduction
4ada6ee7c9 Remove unneeded constants 2021-08-07 11:54:22 -04:00
TwinProduction
1e28905c8d Add clarification on hack 2021-08-07 11:46:58 -04:00
TwinProduction
4dbde07b85 Fix typo 2021-07-30 18:57:43 -04:00
TwinProduction
8f35679299 Improve documentation formatting 2021-07-30 18:56:05 -04:00
TwinProduction
897e1590ac Improve documentation 2021-07-30 18:46:07 -04:00
TwinProduction
48ef7c7313 #126: Update mistake in documentation 2021-07-30 12:38:29 -04:00
TwinProduction
941cc03f19 Add high level diagram 2021-07-30 00:34:07 -04:00
TwinProduction
a4c429a0e0 Update comment for testing purposes 2021-07-29 20:09:42 -04:00
TwinProduction
2074697efa Improve alerting tests 2021-07-29 19:54:40 -04:00
Chris
2ce02b0d7f Merge pull request #143 from zeylos/feature/teams_alert_provider
Add Microsoft Teams alerting provider
2021-07-29 19:16:03 -04:00
TwinProduction
8edca65041 Only trigger publish-latest on successful build in master 2021-07-29 19:14:48 -04:00
TwinProduction
cdbc075439 Fix #146: Alerting causes panic with some providers 2021-07-29 18:13:37 -04:00
Bastien
949fd65cb7 Minor fix on README.md
Fixed the webhook-url example for Teams
2021-07-29 11:12:53 +02:00
Bastien
54d06b8688 Merge branch 'master' into feature/teams_alert_provider 2021-07-29 11:01:53 +02:00
TwinProduction
07b1a2eafb Minor fix 2021-07-28 22:30:34 -04:00
TwinProduction
d3e0ef6519 Add workflow to publish latest on successful build 2021-07-28 22:25:25 -04:00
TwinProduction
9cd6355056 #126: Add client configuration 2021-07-28 21:52:14 -04:00
Bastien Ogier
7416384efe Revert config.yaml modification 2021-07-28 14:28:31 +02:00
Bastien Ogier
23fb69fca9 Add teams alerting provider 2021-07-28 14:20:53 +02:00
TwinProduction
be4e9aba1e Add section about deployment using Terraform 2021-07-27 21:36:11 -04:00
TwinProduction
ac0d00fdb5 Add notice in README possible deprecation of Kubernetes integration 2021-07-27 21:10:27 -04:00
TwinProduction
3293222cd6 Add warning log about possible deprecation of Kubernetes integration 2021-07-27 21:08:30 -04:00
TwinProduction
892f3ada6f Add tests for paging package 2021-07-24 22:15:59 -04:00
TwinProduction
f22a79eb7d Minor update 2021-07-24 21:08:55 -04:00
TwinProduction
911deb91d1 Add list of sponsors 2021-07-24 21:07:26 -04:00
TwinProduction
bcd4105af3 Fix potential race condition in test 2021-07-24 19:12:57 -04:00
Chris
423ada68b3 Update FUNDING.yml 2021-07-24 19:00:19 -04:00
Chris
70fa17349f Fix typo 2021-07-24 18:59:01 -04:00
Chris
e640ede709 Create FUNDING.yml 2021-07-24 18:58:30 -04:00
TwinProduction
fb3447eaf3 Improve test coverage 2021-07-18 23:24:09 -04:00
TwinProduction
46cf616a57 Rename TestStore_Insert to TestStore_SanityCheck 2021-07-18 23:13:19 -04:00
TwinProduction
cf48072167 Fix indentation 2021-07-18 23:07:24 -04:00
TwinProduction
97dd868ae8 Add sanity tests 2021-07-18 23:02:27 -04:00
TwinProduction
c18b2728c9 Revert back to covermode=atomic 2021-07-18 22:04:49 -04:00
TwinProduction
b3fd290e4d Remove -coverpkg=all from coverage file configuration and set covermode to count 2021-07-18 22:02:51 -04:00
TwinProduction
89e23a986c Add -coverpkg=all when building coverage file 2021-07-18 21:56:32 -04:00
TwinProduction
c454c868f6 Update codecov/codecov-action to v1.5.2 2021-07-18 21:47:18 -04:00
TwinProduction
6d82a54518 Rename example directory to examples 2021-07-18 20:52:42 -04:00
TwinProduction
bd3c01a4f4 Add example for sqlite 2021-07-18 20:48:22 -04:00
TwinProduction
43150ae484 Fix #132: ICMP doesn't work on Mac OS 2021-07-18 20:43:44 -04:00
TwinProduction
acb6757dc8 Minor tweaks 2021-07-18 17:29:08 -04:00
TwinProduction
2037d9aca6 Add documentation for new storage type 2021-07-18 17:29:08 -04:00
TwinProduction
c700154f5e Rename database package to sqlite 2021-07-18 17:29:08 -04:00
TwinProduction
aac72e3741 Improve test coverage 2021-07-18 17:29:08 -04:00
TwinProduction
1a597f92ba Increase test coverage and remove useless code 2021-07-18 17:29:08 -04:00
TwinProduction
56fedcedd1 Improve test coverage 2021-07-18 17:29:08 -04:00
TwinProduction
6bdce4fe29 Add persistence test 2021-07-18 17:29:08 -04:00
TwinProduction
381488a1b2 Add sqlite wal files 2021-07-18 17:29:08 -04:00
TwinProduction
42a909c1ad Update documentation 2021-07-18 17:29:08 -04:00
TwinProduction
5a4fa6f2b0 Fix issue with store closing on configuration file update 2021-07-18 17:29:08 -04:00
TwinProduction
bbbfe7f466 Increase sleep to give enough time for the goroutine to end its task 2021-07-18 17:29:08 -04:00
TwinProduction
7cf1750f86 Add BenchmarkConvertGroupAndServiceToKey 2021-07-18 17:29:08 -04:00
TwinProduction
b88ae5fcf6 Improve test coverage 2021-07-18 17:29:08 -04:00
TwinProduction
8516c41b43 Update benchmarks 2021-07-18 17:29:08 -04:00
TwinProduction
b90a64e2a6 Set synchronous PRAGMA instruction to NORMAL 2021-07-18 17:29:08 -04:00
TwinProduction
627173e64f Refactor duplicate functions 2021-07-18 17:29:08 -04:00
TwinProduction
8b5e5f54cc Refactor test setup/cleanup 2021-07-18 17:29:08 -04:00
TwinProduction
2c95cce7b3 Consolidate store tests into interface package + Fix issues 2021-07-18 17:29:08 -04:00
TwinProduction
2ef9329fa6 Fix pagination in memory store 2021-07-18 17:29:08 -04:00
TwinProduction
9384373f43 Fix result ordering issue 2021-07-18 17:29:08 -04:00
TwinProduction
d3a81a2d57 Major fixes and improvements 2021-07-18 17:29:08 -04:00
TwinProduction
fed32d3909 Minor improvements and fixes 2021-07-18 17:29:08 -04:00
TwinProduction
c1d9006aaf Uncomment memory benchmarks 2021-07-18 17:29:08 -04:00
TwinProduction
7126d36d85 Implement paging and refactor stores to match new store interface with paging 2021-07-18 17:29:08 -04:00
TwinProduction
677c7faffe Start working on paging implementation 2021-07-18 17:29:08 -04:00
TwinProduction
8dedcf7c74 Refactor code 2021-07-18 17:29:08 -04:00
TwinProduction
a4c69d6fc3 Implement service uptime support for database store 2021-07-18 17:29:08 -04:00
TwinProduction
943d0a19d1 Use time.Truncate instead of manually flooring the hour 2021-07-18 17:29:08 -04:00
TwinProduction
fd08c8b1e5 Reorder methods 2021-07-18 17:29:08 -04:00
TwinProduction
393147c300 Add table schema for service uptime 2021-07-18 17:29:08 -04:00
TwinProduction
f73e8a56ef Minor improvements 2021-07-18 17:29:08 -04:00
TwinProduction
4203355edc Improve benchmarks 2021-07-18 17:29:08 -04:00
TwinProduction
5cc1c11b1a Move all transactions to the exported methods 2021-07-18 17:29:08 -04:00
TwinProduction
796228466d Add missing transaction rollbacks 2021-07-18 17:29:08 -04:00
TwinProduction
23ba9795a6 Start working on GetAllServiceStatusesWithResultPagination for database storage 2021-07-18 17:29:08 -04:00
TwinProduction
1291e86a6f Rename deleteOldResults and deleteOldEvents to deleteOldServiceResults and deleteOldServiceEvents 2021-07-18 17:29:08 -04:00
TwinProduction
14316cfd31 Fix potential concurrent access issue 2021-07-18 17:29:08 -04:00
TwinProduction
670272f411 Refactor code and enable WAL for 4x performance improvement 2021-07-18 17:29:08 -04:00
TwinProduction
ffc3e644c5 Add benchmark scenarios for concurrent inserts 2021-07-18 17:29:08 -04:00
TwinProduction
bc42d15625 Automatically clear up old events 2021-07-18 17:29:08 -04:00
TwinProduction
20594b902c Remove unnecessary comments 2021-07-18 17:29:08 -04:00
TwinProduction
0a3267e499 Reuse transaction on insert to improve performance 2021-07-18 17:29:08 -04:00
TwinProduction
9c8bf2b69e Refactor uselessly complex code 2021-07-18 17:29:08 -04:00
TwinProduction
bd1eb7c61b #136: Start working on database persistence 2021-07-18 17:29:08 -04:00
TwinProduction
e6335da94f Minor update 2021-07-18 17:29:08 -04:00
TwinProduction
1498b6d8a2 Add Service.Key() method to generate the unique service key 2021-07-18 17:29:08 -04:00
TwinProduction
7aed826d65 Fix typo 2021-07-18 17:29:08 -04:00
TwinProduction
9b68582622 Minor update 2021-07-08 23:39:12 -04:00
TwinProduction
a1afeea56b Close #126: Don't follow redirects 2021-07-06 22:01:46 -04:00
Chris
38de0ec9cd Update README.md 2021-07-06 20:06:40 -04:00
Andrii Vakarev
9d8a3f1574 Document helm chart (#127) 2021-07-06 19:54:59 -04:00
TwinProduction
b904afb8b5 Replace - by _ in file names 2021-07-02 20:04:05 -04:00
TwinProduction
5bf560221f Remove cat-fact from example config 2021-06-29 23:35:31 -04:00
TwinProduction
574dd50b98 Update example config 2021-06-29 23:33:17 -04:00
TwinProduction
35c33620a5 Remove hidden feature HTTP_CLIENT_TIMEOUT_IN_SECONDS 2021-06-18 10:07:55 -04:00
TwinProduction
fc0c3499f4 Remove comment that no longer applies 2021-06-18 09:59:39 -04:00
TwinProduction
d03271d128 Update TwinProduction/gocache to v1.2.3 2021-06-18 09:56:55 -04:00
Chris
0560b98de4 Update README.md 2021-06-17 18:44:38 -04:00
TwinProduction
ca87547430 Update TwinProduction/gocache to v1.2.2 2021-06-06 14:54:58 -04:00
TwinProduction
e214d56af1 Add errors through result.AddError() 2021-06-05 18:51:51 -04:00
TwinProduction
8997eeef05 Fix #123: Deduplicate result errors 2021-06-05 18:50:24 -04:00
TwinProduction
5e00752c5a Include issue number in scenario name 2021-06-05 18:42:32 -04:00
TwinProduction
f9d132c369 Fix #122: Partially invalid JSONPath ending with string does not return an error 2021-06-05 18:41:42 -04:00
TwinProduction
ca977fefa8 Minor improvements 2021-06-05 16:35:52 -04:00
TwinProduction
d07d3434a6 #120: Add documentation for STARTTLS 2021-06-05 16:35:18 -04:00
gopher-johns
2131fa4412 #120: Add support for StartTLS protocol
* add starttls

* remove starttls from default config

Co-authored-by: Gopher Johns <gopher.johns28@gmail.com>
2021-06-05 15:47:11 -04:00
TwinProduction
81aeb7a48e Fix indentation 2021-06-02 18:59:08 -04:00
TwinProduction
eaf395738d Add quick start spoiler 2021-06-02 18:57:16 -04:00
TwinProduction
f6f1ecf623 Remove "Service auto discovery in Kubernetes" from list of features 2021-06-02 18:41:41 -04:00
TwinProduction
177081cf54 Move image to the feature section 2021-06-02 18:40:46 -04:00
TwinProduction
651bfcba22 Add dark-mode.png 2021-05-31 19:27:20 -04:00
TwinProduction
3cd1953c6c Add dark mode screenshot 2021-05-31 18:54:21 -04:00
Chris
9dd4e7047d Add Discord server badge 2021-05-31 00:21:57 -04:00
Chris
067ab78666 Minor fix 2021-05-30 17:09:43 -04:00
Chris
28acaeb067 Revert "Add discord server badge" 2021-05-30 17:09:28 -04:00
Chris
749aeb9e42 Add Discord server badge
This is temporary, I might remove it later
2021-05-30 16:07:54 -04:00
TwinProduction
8e02572880 Remove unused code 2021-05-28 18:48:17 -04:00
TwinProduction
1f6f0ce426 Fix inconsistent visual issue with settings bar occasionally appearing within the global container 2021-05-28 18:47:15 -04:00
764 changed files with 2194432 additions and 1660 deletions

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
github: [TwinProduction]

BIN
.github/assets/dark-mode.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

1
.github/assets/gatus-diagram.drawio vendored Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
.github/assets/teams-alerts.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -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
View 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

View File

@@ -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
View File

@@ -2,4 +2,7 @@
.vscode
gatus
db.db
config/config.yml
config/config.yml
db.db-shm
db.db-wal
memory.db

690
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -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"

View File

@@ -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

View File

@@ -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
}

View File

@@ -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"},
}
}

View File

@@ -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) {

View File

@@ -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"},
}
}

View File

@@ -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) {

View File

@@ -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",

View File

@@ -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)
)

View File

@@ -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"},
}
}

View File

@@ -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) {

View 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 = "&#x2705;"
} else {
prefix = "&#x274C;"
}
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": "&#x1F6A8; 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
}

View 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())
}
}

View File

@@ -2,71 +2,29 @@ package client
import (
"crypto/tls"
"crypto/x509"
"errors"
"net"
"net/http"
"os"
"strconv"
"net/smtp"
"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
}
@@ -74,17 +32,44 @@ func CanCreateTCPConnection(address string) bool {
return true
}
// CanPerformStartTLS checks whether a connection can be established to an address using the STARTTLS protocol
func CanPerformStartTLS(address string, 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")
}
smtpClient, err := smtp.Dial(address)
if err != nil {
return
}
err = smtpClient.StartTLS(&tls.Config{
InsecureSkipVerify: config.Insecure,
ServerName: hostAndPort[0],
})
if err != nil {
return
}
if state, ok := smtpClient.TLSConnectionState(); ok {
certificate = state.PeerCertificates[0]
} else {
return false, nil, errors.New("could not get TLS connection state")
}
return true, certificate, nil
}
// Ping checks if an address can be pinged and returns the round-trip time if the address can be pinged
//
// Note that this function takes at least 100ms, even if the address is 127.0.0.1
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

View File

@@ -6,46 +6,93 @@ 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")
}
}
}
func TestCanPerformStartTLS(t *testing.T) {
type args struct {
address string
insecure bool
}
tests := []struct {
name string
args args
wantConnected bool
wantErr bool
}{
{
name: "invalid address",
args: args{
address: "test",
},
wantConnected: false,
wantErr: true,
},
{
name: "error dial",
args: args{
address: "test:1234",
},
wantConnected: false,
wantErr: true,
},
{
name: "valid starttls",
args: args{
address: "smtp.gmail.com:587",
},
wantConnected: true,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
connected, _, err := CanPerformStartTLS(tt.args.address, &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
}
if connected != tt.wantConnected {
t.Errorf("CanPerformStartTLS() connected=%v, wantConnected=%v", connected, tt.wantConnected)
}
})
}
}
func TestCanCreateTCPConnection(t *testing.T) {
if CanCreateTCPConnection("127.0.0.1", &Config{Timeout: 5 * time.Second}) {
t.Error("should've failed, because there's no port in the address")
}
}

73
client/config.go Normal file
View 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
View 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")
}
}

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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")
}
}

View File

@@ -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
View 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))
}
}

View File

@@ -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 {

View File

@@ -30,7 +30,6 @@ var (
Interval: 30 * time.Second,
Conditions: []*core.Condition{&firstCondition, &secondCondition, &thirdCondition},
Alerts: nil,
Insecure: false,
NumberOfFailuresInARow: 0,
NumberOfSuccessesInARow: 0,
}

View File

@@ -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) {

View File

@@ -126,7 +126,7 @@ func (c Condition) evaluate(result *Result) bool {
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, "<")
}
} else {
result.Errors = append(result.Errors, fmt.Sprintf("invalid condition '%s' has been provided", condition))
result.AddError(fmt.Sprintf("invalid condition '%s' has been provided", condition))
return false
}
if !success {
@@ -242,7 +242,7 @@ func sanitizeAndResolve(elements []string, result *Result) ([]string, []string)
} else {
if err != nil {
if err.Error() != "unexpected end of JSON input" {
result.Errors = append(result.Errors, err.Error())
result.AddError(err.Error())
}
if checkingForLength {
element = LengthFunctionPrefix + element + FunctionSuffix + " " + InvalidConditionElementSuffix

View File

@@ -38,6 +38,15 @@ func BenchmarkCondition_evaluateWithBodyStringFailure(b *testing.B) {
b.ReportAllocs()
}
func BenchmarkCondition_evaluateWithBodyStringFailureInvalidPath(b *testing.B) {
condition := Condition("[BODY].user.name == bob.doe")
for n := 0; n < b.N; n++ {
result := &Result{body: []byte("{\"name\": \"bob.doe\"}")}
condition.evaluate(result)
}
b.ReportAllocs()
}
func BenchmarkCondition_evaluateWithBodyStringLen(b *testing.B) {
condition := Condition("len([BODY].name) == 8")
for n := 0; n < b.N; n++ {

View File

@@ -52,7 +52,7 @@ func (d *DNS) query(url string, result *Result) {
m.SetQuestion(d.QueryName, queryType)
r, _, err := c.Exchange(m, url)
if err != nil {
result.Errors = append(result.Errors, err.Error())
result.AddError(err.Error())
return
}
result.Connected = true

View File

@@ -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
}

View File

@@ -46,3 +46,15 @@ type Result struct {
// and sets it to nil after the evaluation has been completed.
body []byte
}
// AddError adds an error to the result's list of errors.
// It also ensures that there are no duplicates.
func (r *Result) AddError(error string) {
for _, resultError := range r.Errors {
if resultError == error {
// If the error already exists, don't add it
return
}
}
r.Errors = append(r.Errors, error)
}

21
core/result_test.go Normal file
View File

@@ -0,0 +1,21 @@
package core
import (
"testing"
)
func TestResult_AddError(t *testing.T) {
result := &Result{}
result.AddError("potato")
if len(result.Errors) != 1 {
t.Error("should've had 1 error")
}
result.AddError("potato")
if len(result.Errors) != 1 {
t.Error("should've still had 1 error, because a duplicate error was added")
}
result.AddError("tomato")
if len(result.Errors) != 2 {
t.Error("should've had 2 error")
}
}

View File

@@ -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)
}

View File

@@ -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()
}

View File

@@ -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")
}
}

View File

@@ -2,9 +2,11 @@ package core
import (
"bytes"
"crypto/x509"
"encoding/json"
"errors"
"io/ioutil"
"log"
"net"
"net/http"
"net/url"
@@ -13,6 +15,7 @@ import (
"github.com/TwinProduction/gatus/alerting/alert"
"github.com/TwinProduction/gatus/client"
"github.com/TwinProduction/gatus/util"
)
const (
@@ -76,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
@@ -88,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
}
@@ -134,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{}}
@@ -155,35 +178,20 @@ func (service *Service) EvaluateHealth() *Result {
return result
}
// GetAlertsTriggered returns a slice of alerts that have been triggered
func (service *Service) GetAlertsTriggered() []alert.Alert {
var alerts []alert.Alert
if service.NumberOfFailuresInARow == 0 {
return alerts
}
for _, alert := range service.Alerts {
if alert.IsEnabled() && alert.FailureThreshold == service.NumberOfFailuresInARow {
alerts = append(alerts, *alert)
continue
}
}
return alerts
}
func (service *Service) getIP(result *Result) {
if service.DNS != nil {
result.Hostname = strings.TrimSuffix(service.URL, ":53")
} else {
urlObject, err := url.Parse(service.URL)
if err != nil {
result.Errors = append(result.Errors, err.Error())
result.AddError(err.Error())
return
}
result.Hostname = urlObject.Hostname()
}
ips, err := net.LookupIP(result.Hostname)
if err != nil {
result.Errors = append(result.Errors, err.Error())
result.AddError(err.Error())
return
}
result.IP = ips[0].String()
@@ -193,10 +201,12 @@ func (service *Service) call(result *Result) {
var request *http.Request
var response *http.Response
var err error
var certificate *x509.Certificate
isServiceDNS := service.DNS != nil
isServiceTCP := strings.HasPrefix(service.URL, "tcp://")
isServiceICMP := strings.HasPrefix(service.URL, "icmp://")
isServiceHTTP := !isServiceDNS && !isServiceTCP && !isServiceICMP
isServiceStartTLS := strings.HasPrefix(service.URL, "starttls://")
isServiceHTTP := !isServiceDNS && !isServiceTCP && !isServiceICMP && !isServiceStartTLS
if isServiceHTTP {
request = service.buildHTTPRequest()
}
@@ -204,21 +214,29 @@ func (service *Service) call(result *Result) {
if isServiceDNS {
service.DNS.query(service.URL, result)
result.Duration = time.Since(startTime)
} else if isServiceStartTLS {
result.Connected, certificate, err = client.CanPerformStartTLS(strings.TrimPrefix(service.URL, "starttls://"), service.ClientConfig)
if err != nil {
result.AddError(err.Error())
return
}
result.Duration = time.Since(startTime)
result.CertificateExpiration = time.Until(certificate.NotAfter)
} else if isServiceTCP {
result.Connected = client.CanCreateTCPConnection(strings.TrimPrefix(service.URL, "tcp://"))
result.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.Errors = append(result.Errors, err.Error())
result.AddError(err.Error())
return
}
defer response.Body.Close()
if response.TLS != nil && len(response.TLS.PeerCertificates) > 0 {
certificate := response.TLS.PeerCertificates[0]
certificate = response.TLS.PeerCertificates[0]
result.CertificateExpiration = time.Until(certificate.NotAfter)
}
result.HTTPStatus = response.StatusCode
@@ -227,7 +245,7 @@ func (service *Service) call(result *Result) {
if service.needsToReadBody() {
result.body, err = ioutil.ReadAll(response.Body)
if err != nil {
result.Errors = append(result.Errors, err.Error())
result.AddError(err.Error())
}
}
}

46
core/service_status.go Normal file
View 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(),
}
}

View 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)
}
}

View File

@@ -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")
@@ -102,31 +144,6 @@ func TestService_ValidateAndSetDefaultsWithDNS(t *testing.T) {
}
}
func TestService_GetAlertsTriggered(t *testing.T) {
condition := Condition("[STATUS] == 200")
enabled := true
service := Service{
Name: "twinnation-health",
URL: "https://twinnation.org/health",
Conditions: []*Condition{&condition},
Alerts: []*alert.Alert{{Type: alert.TypePagerDuty, Enabled: &enabled}},
}
service.ValidateAndSetDefaults()
if service.NumberOfFailuresInARow != 0 {
t.Error("Service.NumberOfFailuresInARow should start with 0")
}
if service.NumberOfSuccessesInARow != 0 {
t.Error("Service.NumberOfSuccessesInARow should start with 0")
}
if len(service.GetAlertsTriggered()) > 0 {
t.Error("No alerts should've been triggered, because service.NumberOfFailuresInARow is 0, which is below the failure threshold")
}
service.NumberOfFailuresInARow = service.Alerts[0].FailureThreshold
if len(service.GetAlertsTriggered()) != 1 {
t.Error("Alert should've been triggered")
}
}
func TestService_buildHTTPRequest(t *testing.T) {
condition := Condition("[STATUS] == 200")
service := Service{
@@ -230,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)
@@ -249,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)
@@ -273,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)
@@ -292,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)

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -1,8 +0,0 @@
version: "3.8"
services:
gatus:
image: twinproduction/gatus:latest
ports:
- 8080:8080
volumes:
- ./config.yaml:/config/config.yaml

View 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"

View File

@@ -0,0 +1,9 @@
version: "3.8"
services:
gatus:
image: twinproduction/gatus:latest
ports:
- 8080:8080
volumes:
- ./config.yaml:/config/config.yaml
- ./data:/data/

View File

@@ -0,0 +1,8 @@
version: "3.8"
services:
gatus:
image: twinproduction/gatus:latest
ports:
- 8080:8080
volumes:
- ./config.yaml:/config/config.yaml

4
go.mod
View File

@@ -4,7 +4,7 @@ go 1.16
require (
cloud.google.com/go v0.74.0 // indirect
github.com/TwinProduction/gocache v1.2.1
github.com/TwinProduction/gocache v1.2.3
github.com/TwinProduction/health v1.0.0
github.com/go-ping/ping v0.0.0-20201115131931-3300c582a663
github.com/google/gofuzz v1.2.0 // indirect
@@ -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

54
go.sum
View File

@@ -49,8 +49,8 @@ github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbt
github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
github.com/TwinProduction/gocache v1.2.1 h1:NAdMwO9SQEZFmX69YWx6fzhwb6fHakkLri0451c+V1w=
github.com/TwinProduction/gocache v1.2.1/go.mod h1:6zkBoLjrFLkIISwkZTgLy67qliCGSon1xpORM4Ri5HM=
github.com/TwinProduction/gocache v1.2.3 h1:4wFNih4CemUX+A99Gk/EsaU0SXSNZV42Ve77v7/7ToY=
github.com/TwinProduction/gocache v1.2.3/go.mod h1:Yj2daITit8TTBgiOpc26XCDSbg9xcFskUilHj9u3Mh8=
github.com/TwinProduction/health v1.0.0 h1:TVyYTAORQQZ8LaptX8jCHZRCGCAO6e+oJx19BUIzQYY=
github.com/TwinProduction/health v1.0.0/go.mod h1:ys4mYKUeEfYrWmkm60xLtPjTuLIEDQNBZaTZvenLG1c=
github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g=
@@ -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=

View File

@@ -25,6 +25,9 @@ func walk(path string, object interface{}) (string, int, error) {
case map[string]interface{}:
return walk(strings.Replace(path, fmt.Sprintf("%s.", currentKey), "", 1), value)
case string:
if len(keys) > 1 {
return "", 0, fmt.Errorf("couldn't walk through '%s', because '%s' was a string instead of an object", keys[1], currentKey)
}
return value, len(value), nil
case []interface{}:
return fmt.Sprintf("%v", value), len(value), nil

View File

@@ -1,180 +1,148 @@
package jsonpath
import "testing"
import (
"testing"
)
func TestEval(t *testing.T) {
path := "simple"
data := `{"simple": "value"}`
expectedOutput := "value"
output, outputLength, err := Eval(path, []byte(data))
if err != nil {
t.Error("Didn't expect any error, but got", err)
type Scenario struct {
Name string
Path string
Data string
ExpectedOutput string
ExpectedOutputLength int
ExpectedError bool
}
if outputLength != len(expectedOutput) {
t.Errorf("Expected output length to be %v, but was %v", len(expectedOutput), outputLength)
scenarios := []Scenario{
{
Name: "simple",
Path: "key",
Data: `{"key": "value"}`,
ExpectedOutput: "value",
ExpectedOutputLength: 5,
ExpectedError: false,
},
{
Name: "simple-with-invalid-data",
Path: "key",
Data: "invalid data",
ExpectedOutput: "",
ExpectedOutputLength: 0,
ExpectedError: true,
},
{
Name: "invalid-path",
Path: "key",
Data: `{}`,
ExpectedOutput: "",
ExpectedOutputLength: 0,
ExpectedError: true,
},
{
Name: "long-simple-walk",
Path: "long.simple.walk",
Data: `{"long": {"simple": {"walk": "value"}}}`,
ExpectedOutput: "value",
ExpectedOutputLength: 5,
ExpectedError: false,
},
{
Name: "array-of-maps",
Path: "ids[1].id",
Data: `{"ids": [{"id": 1}, {"id": 2}]}`,
ExpectedOutput: "2",
ExpectedOutputLength: 1,
ExpectedError: false,
},
{
Name: "array-of-values",
Path: "ids[0]",
Data: `{"ids": [1, 2]}`,
ExpectedOutput: "1",
ExpectedOutputLength: 1,
ExpectedError: false,
},
{
Name: "array-of-values-and-invalid-index",
Path: "ids[wat]",
Data: `{"ids": [1, 2]}`,
ExpectedOutput: "",
ExpectedOutputLength: 0,
ExpectedError: true,
},
{
Name: "array-of-values-at-root",
Path: "[1]",
Data: `[1, 2]`,
ExpectedOutput: "2",
ExpectedOutputLength: 1,
ExpectedError: false,
},
{
Name: "array-of-maps-at-root",
Path: "[0].id",
Data: `[{"id": 1}, {"id": 2}]`,
ExpectedOutput: "1",
ExpectedOutputLength: 1,
ExpectedError: false,
},
{
Name: "array-of-maps-at-root-and-invalid-index",
Path: "[5].id",
Data: `[{"id": 1}, {"id": 2}]`,
ExpectedOutput: "",
ExpectedOutputLength: 0,
ExpectedError: true,
},
{
Name: "long-walk-and-array",
Path: "data.ids[0].id",
Data: `{"data": {"ids": [{"id": 1}, {"id": 2}, {"id": 3}]}}`,
ExpectedOutput: "1",
ExpectedOutputLength: 1,
ExpectedError: false,
},
{
Name: "nested-array",
Path: "[3][2]",
Data: `[[1, 2], [3, 4], [], [5, 6, 7]]`,
ExpectedOutput: "7",
ExpectedOutputLength: 1,
ExpectedError: false,
},
{
Name: "map-of-nested-arrays",
Path: "data[1][1]",
Data: `{"data": [["a", "b", "c"], ["d", "eeeee", "f"]]}`,
ExpectedOutput: "eeeee",
ExpectedOutputLength: 5,
ExpectedError: false,
},
{
Name: "partially-invalid-path-issue122",
Path: "data.name.invalid",
Data: `{"data": {"name": "john"}}`,
ExpectedOutput: "",
ExpectedOutputLength: 0,
ExpectedError: true,
},
}
if output != expectedOutput {
t.Errorf("Expected output to be %v, but was %v", expectedOutput, output)
}
}
func TestEvalWithInvalidData(t *testing.T) {
path := "simple"
data := `invalid data`
_, _, err := Eval(path, []byte(data))
if err == nil {
t.Error("expected an error")
}
}
func TestEvalWithInvalidPath(t *testing.T) {
path := "errors"
data := `{}`
_, _, err := Eval(path, []byte(data))
if err == nil {
t.Error("Expected error, but got", err)
}
}
func TestEvalWithLongSimpleWalk(t *testing.T) {
path := "long.simple.walk"
data := `{"long": {"simple": {"walk": "value"}}}`
expectedOutput := "value"
output, _, err := Eval(path, []byte(data))
if err != nil {
t.Error("Didn't expect any error, but got", err)
}
if output != expectedOutput {
t.Errorf("Expected output to be %v, but was %v", expectedOutput, output)
}
}
func TestEvalWithArrayOfMaps(t *testing.T) {
path := "ids[1].id"
data := `{"ids": [{"id": 1}, {"id": 2}]}`
expectedOutput := "2"
output, _, err := Eval(path, []byte(data))
if err != nil {
t.Error("Didn't expect any error, but got", err)
}
if output != expectedOutput {
t.Errorf("Expected output to be %v, but was %v", expectedOutput, output)
}
}
func TestEvalWithArrayOfValues(t *testing.T) {
path := "ids[0]"
data := `{"ids": [1, 2]}`
expectedOutput := "1"
output, _, err := Eval(path, []byte(data))
if err != nil {
t.Error("Didn't expect any error, but got", err)
}
if output != expectedOutput {
t.Errorf("Expected output to be %v, but was %v", expectedOutput, output)
}
}
func TestEvalWithArrayOfValuesAndInvalidIndex(t *testing.T) {
path := "ids[wat]"
data := `{"ids": [1, 2]}`
_, _, err := Eval(path, []byte(data))
if err == nil {
t.Error("Expected an error")
}
}
func TestEvalWithRootArrayOfValues(t *testing.T) {
path := "[1]"
data := `[1, 2]`
expectedOutput := "2"
output, _, err := Eval(path, []byte(data))
if err != nil {
t.Error("Didn't expect any error, but got", err)
}
if output != expectedOutput {
t.Errorf("Expected output to be %v, but was %v", expectedOutput, output)
}
}
func TestEvalWithRootArrayOfMaps(t *testing.T) {
path := "[0].id"
data := `[{"id": 1}, {"id": 2}]`
expectedOutput := "1"
output, _, err := Eval(path, []byte(data))
if err != nil {
t.Error("Didn't expect any error, but got", err)
}
if output != expectedOutput {
t.Errorf("Expected output to be %v, but was %v", expectedOutput, output)
}
}
func TestEvalWithRootArrayOfMapsUsingInvalidArrayIndex(t *testing.T) {
path := "[5].id"
data := `[{"id": 1}, {"id": 2}]`
_, _, err := Eval(path, []byte(data))
if err == nil {
t.Error("Should've returned an error, but didn't")
}
}
func TestEvalWithLongWalkAndArray(t *testing.T) {
path := "data.ids[0].id"
data := `{"data": {"ids": [{"id": 1}, {"id": 2}, {"id": 3}]}}`
expectedOutput := "1"
output, _, err := Eval(path, []byte(data))
if err != nil {
t.Error("Didn't expect any error, but got", err)
}
if output != expectedOutput {
t.Errorf("Expected output to be %v, but was %v", expectedOutput, output)
}
}
func TestEvalWithNestedArray(t *testing.T) {
path := "[3][2]"
data := `[[1, 2], [3, 4], [], [5, 6, 7]]`
expectedOutput := "7"
output, _, err := Eval(path, []byte(data))
if err != nil {
t.Error("Didn't expect any error, but got", err)
}
if output != expectedOutput {
t.Errorf("Expected output to be %v, but was %v", expectedOutput, output)
}
}
func TestEvalWithMapOfNestedArray(t *testing.T) {
path := "data[1][1]"
data := `{"data": [["a", "b", "c"], ["d", "e", "f"]]}`
expectedOutput := "e"
output, _, err := Eval(path, []byte(data))
if err != nil {
t.Error("Didn't expect any error, but got", err)
}
if output != expectedOutput {
t.Errorf("Expected output to be %v, but was %v", expectedOutput, output)
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
output, outputLength, err := Eval(scenario.Path, []byte(scenario.Data))
if (err != nil) != scenario.ExpectedError {
if scenario.ExpectedError {
t.Errorf("Expected error, got '%v'", err)
} else {
t.Errorf("Expected no error, got '%v'", err)
}
}
if outputLength != scenario.ExpectedOutputLength {
t.Errorf("Expected output length to be %v, but was %v", scenario.ExpectedOutputLength, outputLength)
}
if output != scenario.ExpectedOutput {
t.Errorf("Expected output to be %v, but was %v", scenario.ExpectedOutput, output)
}
})
}
}

12
main.go
View File

@@ -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 {

View File

@@ -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"`
}

View File

@@ -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())
}
}
}

View File

@@ -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)
}

View 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
)

View 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
)

View 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
}

View 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)
}
})
}
}

View File

@@ -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
}

View File

@@ -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)
}
}

View 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
}

View File

@@ -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,

View 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)
}
}

View 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)
}

View 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()
}

View 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")
}
}

View 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
}

View 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)
}
}

View File

@@ -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)
)

View File

@@ -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
View 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
View 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
View File

@@ -0,0 +1,11 @@
package util
import (
"testing"
)
func BenchmarkConvertGroupAndServiceToKey(b *testing.B) {
for n := 0; n < b.N; n++ {
ConvertGroupAndServiceToKey("group", "service")
}
}

View File

@@ -2,7 +2,7 @@
FROM golang:alpine as builder
WORKDIR /app
ADD . ./
RUN CGO_ENABLED=0 GOOS=linux go build -mod vendor -a -installsuffix cgo -o bin/gocache-server ./gocacheserver/main
RUN CGO_ENABLED=0 GOOS=linux go build -mod vendor -a -installsuffix cgo -o bin/gocache-server cmd/server/main.go
RUN apk --update add --no-cache ca-certificates
FROM scratch

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2020 TwinProduction
Copyright (c) 2021 TwinProduction
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

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