Compare commits
254 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d03271d128 | ||
|
|
0560b98de4 | ||
|
|
ca87547430 | ||
|
|
e214d56af1 | ||
|
|
8997eeef05 | ||
|
|
5e00752c5a | ||
|
|
f9d132c369 | ||
|
|
ca977fefa8 | ||
|
|
d07d3434a6 | ||
|
|
2131fa4412 | ||
|
|
81aeb7a48e | ||
|
|
eaf395738d | ||
|
|
f6f1ecf623 | ||
|
|
177081cf54 | ||
|
|
651bfcba22 | ||
|
|
3cd1953c6c | ||
|
|
9dd4e7047d | ||
|
|
067ab78666 | ||
|
|
28acaeb067 | ||
|
|
749aeb9e42 | ||
|
|
8e02572880 | ||
|
|
1f6f0ce426 | ||
|
|
7bc381b356 | ||
|
|
18420c2d60 | ||
|
|
0b4dc34c57 | ||
|
|
030212c156 | ||
|
|
63b0ac8b35 | ||
|
|
263b2f0f94 | ||
|
|
db23bd9073 | ||
|
|
40dc1cc270 | ||
|
|
67c3bf6e5e | ||
|
|
57ef931d38 | ||
|
|
e3038f0e80 | ||
|
|
8106832d69 | ||
|
|
758428b312 | ||
|
|
77de4c4742 | ||
|
|
a85c5d5486 | ||
|
|
c7d554efa5 | ||
|
|
f3afdf2977 | ||
|
|
19a0ba7271 | ||
|
|
4a4c88ae17 | ||
|
|
253e6f8338 | ||
|
|
1d412678ff | ||
|
|
48c7514fa5 | ||
|
|
d7b437595c | ||
|
|
50f530a05c | ||
|
|
2a632e8f87 | ||
|
|
857ad584e7 | ||
|
|
8b3b2f70bf | ||
|
|
4308f2c1ef | ||
|
|
425c93ed8f | ||
|
|
752e82d80b | ||
|
|
e91462ce41 | ||
|
|
347297a8ea | ||
|
|
56dbe2fea0 | ||
|
|
ebcca4317d | ||
|
|
e6355dfee8 | ||
|
|
7309888db5 | ||
|
|
f60eee86ee | ||
|
|
e46acb885c | ||
|
|
24da853820 | ||
|
|
4e5a86031f | ||
|
|
7f6f127f4f | ||
|
|
12c352254f | ||
|
|
2b9d986932 | ||
|
|
cdbf5f6c6f | ||
|
|
33562e97f4 | ||
|
|
c9acc83141 | ||
|
|
8c4c360472 | ||
|
|
2c8714f1fa | ||
|
|
8ec256edbf | ||
|
|
a48ec41bca | ||
|
|
541e0264ab | ||
|
|
f945e4b8a2 | ||
|
|
076b92a2b4 | ||
|
|
02e9f74a04 | ||
|
|
b37dd5e819 | ||
|
|
1775f80ffe | ||
|
|
3187db1e9a | ||
|
|
932eab00a0 | ||
|
|
c842ac2343 | ||
|
|
6320237326 | ||
|
|
8fe9d013b5 | ||
|
|
c094c06e56 | ||
|
|
f961bf961e | ||
|
|
404a3cea64 | ||
|
|
d000460a99 | ||
|
|
455fae05c1 | ||
|
|
85dff34350 | ||
|
|
99fa632021 | ||
|
|
cafcc9d45b | ||
|
|
dc929dac70 | ||
|
|
42825b62fb | ||
|
|
a89bb392ed | ||
|
|
e7c4d03c22 | ||
|
|
4e6bf91651 | ||
|
|
de31a7a62e | ||
|
|
7f0543ebd2 | ||
|
|
9b893aa4e0 | ||
|
|
50435f4030 | ||
|
|
9649d80388 | ||
|
|
8c3ab1eac2 | ||
|
|
cdb5ba080a | ||
|
|
0a145da912 | ||
|
|
b603cdb0ea | ||
|
|
11d1f24ceb | ||
|
|
c74472d332 | ||
|
|
78a1262e7c | ||
|
|
7ff8907eda | ||
|
|
1d21f5889d | ||
|
|
d7d904ae5f | ||
|
|
7390895514 | ||
|
|
2873d96b9f | ||
|
|
ea9623f695 | ||
|
|
9cdef02bdc | ||
|
|
16229592a2 | ||
|
|
52ad4ee9e5 | ||
|
|
b47c6dc408 | ||
|
|
8e2a2c4dbc | ||
|
|
8698736e7d | ||
|
|
cd1430f043 | ||
|
|
79bef8d391 | ||
|
|
9196f57487 | ||
|
|
1e0d9e184c | ||
|
|
bc42497cb7 | ||
|
|
b0b0ab574d | ||
|
|
e369484e5f | ||
|
|
d18618449f | ||
|
|
9186049589 | ||
|
|
4362831f71 | ||
|
|
99f558d43e | ||
|
|
4a3d5944b6 | ||
|
|
688456d7cf | ||
|
|
431fb3e9f2 | ||
|
|
a1679ddc5e | ||
|
|
d8d8e8720b | ||
|
|
4a0d9d058a | ||
|
|
c8ccf9b352 | ||
|
|
45c966fbca | ||
|
|
d8d4756ef3 | ||
|
|
1e9c54cc0f | ||
|
|
80570688e1 | ||
|
|
43e6e3e8f5 | ||
|
|
8b4c5c20f3 | ||
|
|
6f6db36b0f | ||
|
|
467874de10 | ||
|
|
8337f41425 | ||
|
|
601d676e34 | ||
|
|
fbb5d48bf7 | ||
|
|
119b80edc0 | ||
|
|
99e8cfb1ce | ||
|
|
dcbbec7931 | ||
|
|
2ccd656386 | ||
|
|
5755f3a699 | ||
|
|
911d809376 | ||
|
|
752c872d3b | ||
|
|
67a3e4e330 | ||
|
|
668ed3b1a2 | ||
|
|
dc6cb8fc1d | ||
|
|
f1aa5191bf | ||
|
|
bc6ca2ebd0 | ||
|
|
30801938b2 | ||
|
|
ddddd405bb | ||
|
|
2207dd9c32 | ||
|
|
3204a79eb6 | ||
|
|
e9ac115a95 | ||
|
|
c90c786f39 | ||
|
|
f10e2ac639 | ||
|
|
c2d899f2a3 | ||
|
|
7415d8e361 | ||
|
|
298dcc4790 | ||
|
|
2f2890c093 | ||
|
|
e463aec5f6 | ||
|
|
6b3e11a47c | ||
|
|
0985e3bed8 | ||
|
|
6d8fd267de | ||
|
|
e89bb932ea | ||
|
|
77737dbab6 | ||
|
|
271c3dc91d | ||
|
|
5860a27ab5 | ||
|
|
819093cb7e | ||
|
|
855c106e9b | ||
|
|
04de262268 | ||
|
|
26d8870cab | ||
|
|
aec867ae69 | ||
|
|
a515335c15 | ||
|
|
96dd9809f4 | ||
|
|
20b4c86023 | ||
|
|
6f8a728c5f | ||
|
|
1669f91a2d | ||
|
|
3d265afa37 | ||
|
|
aa050e9292 | ||
|
|
91a9fa5274 | ||
|
|
150e33a1c7 | ||
|
|
da9a6282c7 | ||
|
|
907b611505 | ||
|
|
eaf205eded | ||
|
|
329bd86e09 | ||
|
|
19bb831fbf | ||
|
|
bca38bd372 | ||
|
|
9095649afb | ||
|
|
4ff391bcf3 | ||
|
|
c27cb7af08 | ||
|
|
f1c0bbe73c | ||
|
|
2b4fc7138a | ||
|
|
7f647305ce | ||
|
|
fb5477f50b | ||
|
|
fc4858b1a8 | ||
|
|
0af8837710 | ||
|
|
8ca9fd7db5 | ||
|
|
029c87df89 | ||
|
|
4d24a4d647 | ||
|
|
5eb289c4d3 | ||
|
|
dbd95b1bbd | ||
|
|
678b78ac01 | ||
|
|
f8e1fc25a4 | ||
|
|
c3bc375ff1 | ||
|
|
8ecaf4cfd5 | ||
|
|
143e6cb0da | ||
|
|
be75ef9c58 | ||
|
|
118b62c979 | ||
|
|
d8cfcd04f1 | ||
|
|
641c2029f7 | ||
|
|
1209c477df | ||
|
|
6ca1e39b90 | ||
|
|
d206d63151 | ||
|
|
bc4380db19 | ||
|
|
3eb8299316 | ||
|
|
a83f6c7619 | ||
|
|
bbf39e70fd | ||
|
|
ddbf391c39 | ||
|
|
2eca1ab145 | ||
|
|
f0995f696f | ||
|
|
746032397e | ||
|
|
cf923af230 | ||
|
|
416178fb28 | ||
|
|
10ab9265d9 | ||
|
|
ef7b435f1b | ||
|
|
dea469d6fc | ||
|
|
83a5813daf | ||
|
|
c86173d46f | ||
|
|
c23ba7b85d | ||
|
|
0541adec5e | ||
|
|
eee5bc8f9d | ||
|
|
4d186c6e71 | ||
|
|
4be2273662 | ||
|
|
ef9ba10e45 | ||
|
|
b35747bf31 | ||
|
|
8cd82605dd | ||
|
|
7f0b862822 | ||
|
|
7f9fe9acdf | ||
|
|
46d476128d | ||
|
|
e309ffb1b6 | ||
|
|
48a1e435e4 |
@@ -3,3 +3,4 @@ Dockerfile
|
||||
.github
|
||||
.idea
|
||||
.git
|
||||
web/app
|
||||
BIN
.github/assets/dark-mode.png
vendored
Normal file
BIN
.github/assets/dark-mode.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
BIN
.github/assets/telegram-alerts.png
vendored
Normal file
BIN
.github/assets/telegram-alerts.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
6
.github/codecov.yml
vendored
Normal file
6
.github/codecov.yml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
ignore:
|
||||
- "watchdog/watchdog.go"
|
||||
|
||||
coverage:
|
||||
status:
|
||||
patch: off
|
||||
@@ -14,17 +14,19 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Set up Go 1.15
|
||||
uses: actions/setup-go@v1
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: 1.15
|
||||
id: go
|
||||
go-version: 1.16
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v2
|
||||
- name: Build binary to make sure it works
|
||||
run: go build -mod vendor
|
||||
- name: Test
|
||||
run: go test -mod vendor ./... -race -coverprofile=coverage.txt -covermode=atomic
|
||||
# We're using "sudo" because one of the tests leverages ping, which requires super-user privileges.
|
||||
# As for the 'env "PATH=$PATH" "GOROOT=$GOROOT"', we need it to use the same "go" executable that
|
||||
# was configured by the "Set up Go 1.15" step (otherwise, it'd use sudo's "go" executable)
|
||||
run: sudo env "PATH=$PATH" "GOROOT=$GOROOT" go test -mod vendor ./... -race -coverprofile=coverage.txt -covermode=atomic
|
||||
- name: Codecov
|
||||
uses: codecov/codecov-action@v1.0.14
|
||||
with:
|
||||
@@ -1,11 +1,12 @@
|
||||
name: docker
|
||||
name: publish
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
name: Publish
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v2
|
||||
@@ -20,14 +21,13 @@ jobs:
|
||||
- name: Login to Docker Registry
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Build and push docker image
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||
pull: true
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.IMAGE_REPOSITORY }}
|
||||
${{ env.IMAGE_REPOSITORY }}:${{ env.RELEASE }}
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,3 +1,5 @@
|
||||
bin
|
||||
.idea
|
||||
.vscode
|
||||
gatus
|
||||
db.db
|
||||
config/config.yml
|
||||
@@ -13,7 +13,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -mod vendor -a -installsuffix cgo -o gatus
|
||||
FROM scratch
|
||||
COPY --from=builder /app/gatus .
|
||||
COPY --from=builder /app/config.yaml ./config/config.yaml
|
||||
COPY --from=builder /app/static static/
|
||||
COPY --from=builder /app/web/static ./web/static
|
||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
ENV PORT=8080
|
||||
EXPOSE ${PORT}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 TwinProduction
|
||||
Copyright (c) 2021 TwinProduction
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
|
||||
14
Makefile
Normal file
14
Makefile
Normal file
@@ -0,0 +1,14 @@
|
||||
docker-build:
|
||||
docker build -t twinproduction/gatus:latest .
|
||||
|
||||
docker-build-and-run:
|
||||
docker build -t twinproduction/gatus:latest . && docker run -p 8080:8080 --name gatus twinproduction/gatus:latest
|
||||
|
||||
build-frontend:
|
||||
npm --prefix web/app run build
|
||||
|
||||
run-frontend:
|
||||
npm --prefix web/app run serve
|
||||
|
||||
test:
|
||||
go test ./alerting/... ./client/... ./config/... ./controller/... ./core/... ./jsonpath/... ./pattern/... ./security/... ./storage/... ./util/... ./watchdog/... -cover
|
||||
69
alerting/alert/alert.go
Normal file
69
alerting/alert/alert.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package alert
|
||||
|
||||
// Alert is the service's alert configuration
|
||||
type Alert struct {
|
||||
// Type of alert (required)
|
||||
Type Type `yaml:"type"`
|
||||
|
||||
// Enabled defines whether or not the alert is enabled
|
||||
//
|
||||
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
|
||||
// or not for provider.ParseWithDefaultAlert to work.
|
||||
Enabled *bool `yaml:"enabled"`
|
||||
|
||||
// FailureThreshold is the number of failures in a row needed before triggering the alert
|
||||
FailureThreshold int `yaml:"failure-threshold"`
|
||||
|
||||
// Description of the alert. Will be included in the alert sent.
|
||||
//
|
||||
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
|
||||
// or not for provider.ParseWithDefaultAlert to work.
|
||||
Description *string `yaml:"description"`
|
||||
|
||||
// SendOnResolved defines whether to send a second notification when the issue has been resolved
|
||||
//
|
||||
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
|
||||
// or not for provider.ParseWithDefaultAlert to work. Use Alert.IsSendingOnResolved() for a non-pointer
|
||||
SendOnResolved *bool `yaml:"send-on-resolved"`
|
||||
|
||||
// SuccessThreshold defines how many successful executions must happen in a row before an ongoing incident is marked as resolved
|
||||
SuccessThreshold int `yaml:"success-threshold"`
|
||||
|
||||
// ResolveKey is an optional field that is used by some providers (i.e. PagerDuty's dedup_key) to resolve
|
||||
// ongoing/triggered incidents
|
||||
ResolveKey string
|
||||
|
||||
// Triggered is used to determine whether an alert has been triggered. When an alert is resolved, this value
|
||||
// should be set back to false. It is used to prevent the same alert from going out twice.
|
||||
//
|
||||
// This value should only be modified if the provider.AlertProvider's Send function does not return an error for an
|
||||
// alert that hasn't been triggered yet. This doubles as a lazy retry. The reason why this behavior isn't also
|
||||
// applied for alerts that are already triggered and has become "healthy" again is to prevent a case where, for
|
||||
// some reason, the alert provider always returns errors when trying to send the resolved notification
|
||||
// (SendOnResolved).
|
||||
Triggered bool
|
||||
}
|
||||
|
||||
// GetDescription retrieves the description of the alert
|
||||
func (alert Alert) GetDescription() string {
|
||||
if alert.Description == nil {
|
||||
return ""
|
||||
}
|
||||
return *alert.Description
|
||||
}
|
||||
|
||||
// IsEnabled returns whether an alert is enabled or not
|
||||
func (alert Alert) IsEnabled() bool {
|
||||
if alert.Enabled == nil {
|
||||
return false
|
||||
}
|
||||
return *alert.Enabled
|
||||
}
|
||||
|
||||
// IsSendingOnResolved returns whether an alert is sending on resolve or not
|
||||
func (alert Alert) IsSendingOnResolved() bool {
|
||||
if alert.SendOnResolved == nil {
|
||||
return false
|
||||
}
|
||||
return *alert.SendOnResolved
|
||||
}
|
||||
36
alerting/alert/alert_test.go
Normal file
36
alerting/alert/alert_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package alert
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAlert_IsEnabled(t *testing.T) {
|
||||
if (Alert{Enabled: nil}).IsEnabled() {
|
||||
t.Error("alert.IsEnabled() should've returned false, because Enabled was set to nil")
|
||||
}
|
||||
if value := false; (Alert{Enabled: &value}).IsEnabled() {
|
||||
t.Error("alert.IsEnabled() should've returned false, because Enabled was set to false")
|
||||
}
|
||||
if value := true; !(Alert{Enabled: &value}).IsEnabled() {
|
||||
t.Error("alert.IsEnabled() should've returned true, because Enabled was set to true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlert_GetDescription(t *testing.T) {
|
||||
if (Alert{Description: nil}).GetDescription() != "" {
|
||||
t.Error("alert.GetDescription() should've returned an empty string, because Description was set to nil")
|
||||
}
|
||||
if value := "description"; (Alert{Description: &value}).GetDescription() != value {
|
||||
t.Error("alert.GetDescription() should've returned false, because Description was set to 'description'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlert_IsSendingOnResolved(t *testing.T) {
|
||||
if (Alert{SendOnResolved: nil}).IsSendingOnResolved() {
|
||||
t.Error("alert.IsSendingOnResolved() should've returned false, because SendOnResolved was set to nil")
|
||||
}
|
||||
if value := false; (Alert{SendOnResolved: &value}).IsSendingOnResolved() {
|
||||
t.Error("alert.IsSendingOnResolved() should've returned false, because SendOnResolved was set to false")
|
||||
}
|
||||
if value := true; !(Alert{SendOnResolved: &value}).IsSendingOnResolved() {
|
||||
t.Error("alert.IsSendingOnResolved() should've returned true, because SendOnResolved was set to true")
|
||||
}
|
||||
}
|
||||
31
alerting/alert/type.go
Normal file
31
alerting/alert/type.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package alert
|
||||
|
||||
// Type is the type of the alert.
|
||||
// The value will generally be the name of the alert provider
|
||||
type Type string
|
||||
|
||||
const (
|
||||
// TypeCustom is the Type for the custom alerting provider
|
||||
TypeCustom Type = "custom"
|
||||
|
||||
// TypeDiscord is the Type for the discord alerting provider
|
||||
TypeDiscord Type = "discord"
|
||||
|
||||
// TypeMattermost is the Type for the mattermost alerting provider
|
||||
TypeMattermost Type = "mattermost"
|
||||
|
||||
// TypeMessagebird is the Type for the messagebird alerting provider
|
||||
TypeMessagebird Type = "messagebird"
|
||||
|
||||
// TypePagerDuty is the Type for the pagerduty alerting provider
|
||||
TypePagerDuty Type = "pagerduty"
|
||||
|
||||
// TypeSlack is the Type for the slack alerting provider
|
||||
TypeSlack Type = "slack"
|
||||
|
||||
// TypeTelegram is the Type for the telegram alerting provider
|
||||
TypeTelegram Type = "telegram"
|
||||
|
||||
// TypeTwilio is the Type for the twilio alerting provider
|
||||
TypeTwilio Type = "twilio"
|
||||
)
|
||||
@@ -1,18 +1,25 @@
|
||||
package alerting
|
||||
|
||||
import (
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/alerting/provider"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/discord"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/mattermost"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/messagebird"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/pagerduty"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/slack"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/telegram"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/twilio"
|
||||
)
|
||||
|
||||
// Config is the configuration for alerting providers
|
||||
type Config struct {
|
||||
// Slack is the configuration for the slack alerting provider
|
||||
Slack *slack.AlertProvider `yaml:"slack"`
|
||||
// Custom is the configuration for the custom alerting provider
|
||||
Custom *custom.AlertProvider `yaml:"custom"`
|
||||
|
||||
// Discord is the configuration for the discord alerting provider
|
||||
Discord *discord.AlertProvider `yaml:"discord"`
|
||||
|
||||
// Mattermost is the configuration for the mattermost alerting provider
|
||||
Mattermost *mattermost.AlertProvider `yaml:"mattermost"`
|
||||
@@ -20,12 +27,70 @@ type Config struct {
|
||||
// Messagebird is the configuration for the messagebird alerting provider
|
||||
Messagebird *messagebird.AlertProvider `yaml:"messagebird"`
|
||||
|
||||
// Pagerduty is the configuration for the pagerduty alerting provider
|
||||
// PagerDuty is the configuration for the pagerduty alerting provider
|
||||
PagerDuty *pagerduty.AlertProvider `yaml:"pagerduty"`
|
||||
|
||||
// Slack is the configuration for the slack alerting provider
|
||||
Slack *slack.AlertProvider `yaml:"slack"`
|
||||
|
||||
// Telegram is the configuration for the telegram alerting provider
|
||||
Telegram *telegram.AlertProvider `yaml:"telegram"`
|
||||
|
||||
// Twilio is the configuration for the twilio alerting provider
|
||||
Twilio *twilio.AlertProvider `yaml:"twilio"`
|
||||
|
||||
// Custom is the configuration for the custom alerting provider
|
||||
Custom *custom.AlertProvider `yaml:"custom"`
|
||||
}
|
||||
|
||||
// GetAlertingProviderByAlertType returns an provider.AlertProvider by its corresponding alert.Type
|
||||
func (config Config) GetAlertingProviderByAlertType(alertType alert.Type) provider.AlertProvider {
|
||||
switch alertType {
|
||||
case alert.TypeCustom:
|
||||
if config.Custom == nil {
|
||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||
return nil
|
||||
}
|
||||
return config.Custom
|
||||
case alert.TypeDiscord:
|
||||
if config.Discord == nil {
|
||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||
return nil
|
||||
}
|
||||
return config.Discord
|
||||
case alert.TypeMattermost:
|
||||
if config.Mattermost == nil {
|
||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||
return nil
|
||||
}
|
||||
return config.Mattermost
|
||||
case alert.TypeMessagebird:
|
||||
if config.Messagebird == nil {
|
||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||
return nil
|
||||
}
|
||||
return config.Messagebird
|
||||
case alert.TypePagerDuty:
|
||||
if config.PagerDuty == nil {
|
||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||
return nil
|
||||
}
|
||||
return config.PagerDuty
|
||||
case alert.TypeSlack:
|
||||
if config.Slack == nil {
|
||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||
return nil
|
||||
}
|
||||
return config.Slack
|
||||
case alert.TypeTelegram:
|
||||
if config.Telegram == nil {
|
||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||
return nil
|
||||
}
|
||||
return config.Telegram
|
||||
case alert.TypeTwilio:
|
||||
if config.Twilio == nil {
|
||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||
return nil
|
||||
}
|
||||
return config.Twilio
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,11 +2,14 @@ package custom
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/client"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
@@ -14,11 +17,15 @@ import (
|
||||
// AlertProvider is the configuration necessary for sending an alert using a custom HTTP request
|
||||
// Technically, all alert providers should be reachable using the custom alert provider
|
||||
type AlertProvider struct {
|
||||
URL string `yaml:"url"`
|
||||
Method string `yaml:"method,omitempty"`
|
||||
Insecure bool `yaml:"insecure,omitempty"`
|
||||
Body string `yaml:"body,omitempty"`
|
||||
Headers map[string]string `yaml:"headers,omitempty"`
|
||||
URL string `yaml:"url"`
|
||||
Method string `yaml:"method,omitempty"`
|
||||
Insecure bool `yaml:"insecure,omitempty"`
|
||||
Body string `yaml:"body,omitempty"`
|
||||
Headers map[string]string `yaml:"headers,omitempty"`
|
||||
Placeholders map[string]map[string]string `yaml:"placeholders,omitempty"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
@@ -27,14 +34,29 @@ func (provider *AlertProvider) IsValid() bool {
|
||||
}
|
||||
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *core.Alert, result *core.Result, resolved bool) *AlertProvider {
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *alert.Alert, result *core.Result, resolved bool) *AlertProvider {
|
||||
return provider
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) buildRequest(serviceName, alertDescription string, resolved bool) *http.Request {
|
||||
// GetAlertStatePlaceholderValue returns the Placeholder value for ALERT_TRIGGERED_OR_RESOLVED if configured
|
||||
func (provider *AlertProvider) GetAlertStatePlaceholderValue(resolved bool) string {
|
||||
status := "TRIGGERED"
|
||||
if resolved {
|
||||
status = "RESOLVED"
|
||||
}
|
||||
if _, ok := provider.Placeholders["ALERT_TRIGGERED_OR_RESOLVED"]; ok {
|
||||
if val, ok := provider.Placeholders["ALERT_TRIGGERED_OR_RESOLVED"][status]; ok {
|
||||
return val
|
||||
}
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) buildHTTPRequest(serviceName, alertDescription string, resolved bool) *http.Request {
|
||||
body := provider.Body
|
||||
providerURL := provider.URL
|
||||
method := provider.Method
|
||||
|
||||
if strings.Contains(body, "[ALERT_DESCRIPTION]") {
|
||||
body = strings.ReplaceAll(body, "[ALERT_DESCRIPTION]", alertDescription)
|
||||
}
|
||||
@@ -43,9 +65,9 @@ func (provider *AlertProvider) buildRequest(serviceName, alertDescription string
|
||||
}
|
||||
if strings.Contains(body, "[ALERT_TRIGGERED_OR_RESOLVED]") {
|
||||
if resolved {
|
||||
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", "RESOLVED")
|
||||
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
|
||||
} else {
|
||||
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", "TRIGGERED")
|
||||
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(false))
|
||||
}
|
||||
}
|
||||
if strings.Contains(providerURL, "[ALERT_DESCRIPTION]") {
|
||||
@@ -56,9 +78,9 @@ func (provider *AlertProvider) buildRequest(serviceName, alertDescription string
|
||||
}
|
||||
if strings.Contains(providerURL, "[ALERT_TRIGGERED_OR_RESOLVED]") {
|
||||
if resolved {
|
||||
providerURL = strings.ReplaceAll(providerURL, "[ALERT_TRIGGERED_OR_RESOLVED]", "RESOLVED")
|
||||
providerURL = strings.ReplaceAll(providerURL, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
|
||||
} else {
|
||||
providerURL = strings.ReplaceAll(providerURL, "[ALERT_TRIGGERED_OR_RESOLVED]", "TRIGGERED")
|
||||
providerURL = strings.ReplaceAll(providerURL, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(false))
|
||||
}
|
||||
}
|
||||
if len(method) == 0 {
|
||||
@@ -74,7 +96,13 @@ func (provider *AlertProvider) buildRequest(serviceName, alertDescription string
|
||||
|
||||
// Send a request to the alert provider and return the body
|
||||
func (provider *AlertProvider) Send(serviceName, alertDescription string, resolved bool) ([]byte, error) {
|
||||
request := provider.buildRequest(serviceName, alertDescription, resolved)
|
||||
if os.Getenv("MOCK_ALERT_PROVIDER") == "true" {
|
||||
if os.Getenv("MOCK_ALERT_PROVIDER_ERROR") == "true" {
|
||||
return nil, errors.New("error")
|
||||
}
|
||||
return []byte("{}"), nil
|
||||
}
|
||||
request := provider.buildHTTPRequest(serviceName, alertDescription, resolved)
|
||||
response, err := client.GetHTTPClient(provider.Insecure).Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -88,3 +116,8 @@ func (provider *AlertProvider) Send(serviceName, alertDescription string, resolv
|
||||
}
|
||||
return ioutil.ReadAll(response.Body)
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package custom
|
||||
|
||||
import (
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"io/ioutil"
|
||||
"testing"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
|
||||
func TestAlertProvider_IsValid(t *testing.T) {
|
||||
@@ -17,7 +19,7 @@ func TestAlertProvider_IsValid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_buildRequestWhenResolved(t *testing.T) {
|
||||
func TestAlertProvider_buildHTTPRequestWhenResolved(t *testing.T) {
|
||||
const (
|
||||
ExpectedURL = "http://example.com/service-name?event=RESOLVED&description=alert-description"
|
||||
ExpectedBody = "service-name,alert-description,RESOLVED"
|
||||
@@ -27,7 +29,7 @@ func TestAlertProvider_buildRequestWhenResolved(t *testing.T) {
|
||||
Body: "[SERVICE_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
|
||||
Headers: nil,
|
||||
}
|
||||
request := customAlertProvider.buildRequest("service-name", "alert-description", true)
|
||||
request := customAlertProvider.buildHTTPRequest("service-name", "alert-description", true)
|
||||
if request.URL.String() != ExpectedURL {
|
||||
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
|
||||
}
|
||||
@@ -37,7 +39,7 @@ func TestAlertProvider_buildRequestWhenResolved(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_buildRequestWhenTriggered(t *testing.T) {
|
||||
func TestAlertProvider_buildHTTPRequestWhenTriggered(t *testing.T) {
|
||||
const (
|
||||
ExpectedURL = "http://example.com/service-name?event=TRIGGERED&description=alert-description"
|
||||
ExpectedBody = "service-name,alert-description,TRIGGERED"
|
||||
@@ -47,7 +49,7 @@ func TestAlertProvider_buildRequestWhenTriggered(t *testing.T) {
|
||||
Body: "[SERVICE_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
|
||||
Headers: map[string]string{"Authorization": "Basic hunter2"},
|
||||
}
|
||||
request := customAlertProvider.buildRequest("service-name", "alert-description", false)
|
||||
request := customAlertProvider.buildHTTPRequest("service-name", "alert-description", false)
|
||||
if request.URL.String() != ExpectedURL {
|
||||
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
|
||||
}
|
||||
@@ -59,11 +61,51 @@ func TestAlertProvider_buildRequestWhenTriggered(t *testing.T) {
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProvider(t *testing.T) {
|
||||
provider := AlertProvider{URL: "http://example.com"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &core.Alert{}, &core.Result{}, true)
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{}, true)
|
||||
if customAlertProvider == nil {
|
||||
t.Error("customAlertProvider shouldn't have been nil")
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if customAlertProvider != customAlertProvider {
|
||||
t.Error("customAlertProvider should've been equal to customAlertProvider")
|
||||
if customAlertProvider.URL != "http://example.com" {
|
||||
t.Error("expected URL to be http://example.com, got", customAlertProvider.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
|
||||
const (
|
||||
ExpectedURL = "http://example.com/service-name?event=test&description=alert-description"
|
||||
ExpectedBody = "service-name,alert-description,test"
|
||||
)
|
||||
customAlertProvider := &AlertProvider{
|
||||
URL: "http://example.com/[SERVICE_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
|
||||
Body: "[SERVICE_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
|
||||
Headers: nil,
|
||||
Placeholders: map[string]map[string]string{
|
||||
"ALERT_TRIGGERED_OR_RESOLVED": {
|
||||
"RESOLVED": "test",
|
||||
},
|
||||
},
|
||||
}
|
||||
request := customAlertProvider.buildHTTPRequest("service-name", "alert-description", true)
|
||||
if request.URL.String() != ExpectedURL {
|
||||
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
|
||||
}
|
||||
body, _ := ioutil.ReadAll(request.Body)
|
||||
if string(body) != ExpectedBody {
|
||||
t.Error("expected body to be", ExpectedBody, "was", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) {
|
||||
customAlertProvider := &AlertProvider{
|
||||
URL: "http://example.com/[SERVICE_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
|
||||
Body: "[SERVICE_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
|
||||
Headers: nil,
|
||||
Placeholders: nil,
|
||||
}
|
||||
if customAlertProvider.GetAlertStatePlaceholderValue(true) != "RESOLVED" {
|
||||
t.Error("expected RESOLVED, got", customAlertProvider.GetAlertStatePlaceholderValue(true))
|
||||
}
|
||||
if customAlertProvider.GetAlertStatePlaceholderValue(false) != "TRIGGERED" {
|
||||
t.Error("expected TRIGGERED, got", customAlertProvider.GetAlertStatePlaceholderValue(false))
|
||||
}
|
||||
}
|
||||
|
||||
72
alerting/provider/discord/discord.go
Normal file
72
alerting/provider/discord/discord.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package discord
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Discord
|
||||
type AlertProvider struct {
|
||||
WebhookURL string `yaml:"webhook-url"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
func (provider *AlertProvider) IsValid() bool {
|
||||
return len(provider.WebhookURL) > 0
|
||||
}
|
||||
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
|
||||
var message, results string
|
||||
var colorCode int
|
||||
if resolved {
|
||||
message = fmt.Sprintf("An alert for **%s** has been resolved after passing successfully %d time(s) in a row", service.Name, alert.SuccessThreshold)
|
||||
colorCode = 3066993
|
||||
} else {
|
||||
message = fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", service.Name, alert.FailureThreshold)
|
||||
colorCode = 15158332
|
||||
}
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
if conditionResult.Success {
|
||||
prefix = ":white_check_mark:"
|
||||
} else {
|
||||
prefix = ":x:"
|
||||
}
|
||||
results += fmt.Sprintf("%s - `%s`\\n", prefix, conditionResult.Condition)
|
||||
}
|
||||
return &custom.AlertProvider{
|
||||
URL: provider.WebhookURL,
|
||||
Method: http.MethodPost,
|
||||
Body: fmt.Sprintf(`{
|
||||
"content": "",
|
||||
"embeds": [
|
||||
{
|
||||
"title": ":helmet_with_white_cross: Gatus",
|
||||
"description": "%s:\n> %s",
|
||||
"color": %d,
|
||||
"fields": [
|
||||
{
|
||||
"name": "Condition results",
|
||||
"value": "%s",
|
||||
"inline": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`, message, alert.GetDescription(), colorCode, results),
|
||||
Headers: map[string]string{"Content-Type": "application/json"},
|
||||
}
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
66
alerting/provider/discord/discord_test.go
Normal file
66
alerting/provider/discord/discord_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package discord
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
|
||||
func TestAlertProvider_IsValid(t *testing.T) {
|
||||
invalidProvider := AlertProvider{WebhookURL: ""}
|
||||
if invalidProvider.IsValid() {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
validProvider := AlertProvider{WebhookURL: "http://example.com"}
|
||||
if !validProvider.IsValid() {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
||||
provider := AlertProvider{WebhookURL: "http://example.com"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "resolved") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring resolved")
|
||||
}
|
||||
if customAlertProvider.URL != "http://example.com" {
|
||||
t.Errorf("expected URL to be %s, got %s", "http://example.com", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
||||
provider := AlertProvider{WebhookURL: "http://example.com"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "triggered") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring triggered")
|
||||
}
|
||||
if customAlertProvider.URL != "http://example.com" {
|
||||
t.Errorf("expected URL to be %s, got %s", "http://example.com", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
@@ -12,6 +13,9 @@ import (
|
||||
type AlertProvider struct {
|
||||
WebhookURL string `yaml:"webhook-url"`
|
||||
Insecure bool `yaml:"insecure,omitempty"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
@@ -20,7 +24,7 @@ func (provider *AlertProvider) IsValid() bool {
|
||||
}
|
||||
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *core.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
|
||||
var message string
|
||||
var color string
|
||||
if resolved {
|
||||
@@ -38,7 +42,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
||||
} else {
|
||||
prefix = ":x:"
|
||||
}
|
||||
results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
|
||||
results += fmt.Sprintf("%s - `%s`\\n", prefix, conditionResult.Condition)
|
||||
}
|
||||
return &custom.AlertProvider{
|
||||
URL: provider.WebhookURL,
|
||||
@@ -69,7 +73,12 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
||||
]
|
||||
}
|
||||
]
|
||||
}`, message, message, alert.Description, color, service.URL, results),
|
||||
}`, message, message, alert.GetDescription(), color, service.URL, results),
|
||||
Headers: map[string]string{"Content-Type": "application/json"},
|
||||
}
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package mattermost
|
||||
|
||||
import (
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
|
||||
func TestAlertProvider_IsValid(t *testing.T) {
|
||||
@@ -18,23 +22,45 @@ func TestAlertProvider_IsValid(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
||||
provider := AlertProvider{WebhookURL: "http://example.com"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &core.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
|
||||
provider := AlertProvider{WebhookURL: "http://example.org"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
|
||||
if customAlertProvider == nil {
|
||||
t.Error("customAlertProvider shouldn't have been nil")
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "resolved") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring resolved")
|
||||
}
|
||||
if customAlertProvider.URL != "http://example.org" {
|
||||
t.Errorf("expected URL to be %s, got %s", "http://example.org", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
||||
provider := AlertProvider{WebhookURL: "http://example.com"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &core.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
|
||||
provider := AlertProvider{WebhookURL: "http://example.org"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
|
||||
if customAlertProvider == nil {
|
||||
t.Error("customAlertProvider shouldn't have been nil")
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "triggered") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring triggered")
|
||||
}
|
||||
if customAlertProvider.URL != "http://example.org" {
|
||||
t.Errorf("expected URL to be %s, got %s", "http://example.org", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
@@ -17,6 +18,9 @@ type AlertProvider struct {
|
||||
AccessKey string `yaml:"access-key"`
|
||||
Originator string `yaml:"originator"`
|
||||
Recipients string `yaml:"recipients"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
@@ -26,12 +30,12 @@ func (provider *AlertProvider) IsValid() bool {
|
||||
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
// Reference doc for messagebird https://developers.messagebird.com/api/sms-messaging/#send-outbound-sms
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *core.Alert, _ *core.Result, resolved bool) *custom.AlertProvider {
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *alert.Alert, _ *core.Result, resolved bool) *custom.AlertProvider {
|
||||
var message string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("RESOLVED: %s - %s", service.Name, alert.Description)
|
||||
message = fmt.Sprintf("RESOLVED: %s - %s", service.Name, alert.GetDescription())
|
||||
} else {
|
||||
message = fmt.Sprintf("TRIGGERED: %s - %s", service.Name, alert.Description)
|
||||
message = fmt.Sprintf("TRIGGERED: %s - %s", service.Name, alert.GetDescription())
|
||||
}
|
||||
|
||||
return &custom.AlertProvider{
|
||||
@@ -48,3 +52,8 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package messagebird
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
|
||||
@@ -28,13 +31,24 @@ func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
||||
Originator: "1",
|
||||
Recipients: "1",
|
||||
}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &core.Alert{}, &core.Result{}, true)
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{}, true)
|
||||
if customAlertProvider == nil {
|
||||
t.Error("customAlertProvider shouldn't have been nil")
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "RESOLVED") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring RESOLVED")
|
||||
}
|
||||
if customAlertProvider.URL != "https://rest.messagebird.com/messages" {
|
||||
t.Errorf("expected URL to be %s, got %s", "https://rest.messagebird.com/messages", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
||||
@@ -43,11 +57,22 @@ func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
||||
Originator: "1",
|
||||
Recipients: "1",
|
||||
}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &core.Alert{}, &core.Result{}, false)
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{}, false)
|
||||
if customAlertProvider == nil {
|
||||
t.Error("customAlertProvider shouldn't have been nil")
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "TRIGGERED") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring TRIGGERED")
|
||||
}
|
||||
if customAlertProvider.URL != "https://rest.messagebird.com/messages" {
|
||||
t.Errorf("expected URL to be %s, got %s", "https://rest.messagebird.com/messages", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
@@ -11,6 +12,9 @@ import (
|
||||
// AlertProvider is the configuration necessary for sending an alert using PagerDuty
|
||||
type AlertProvider struct {
|
||||
IntegrationKey string `yaml:"integration-key"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
@@ -21,14 +25,14 @@ func (provider *AlertProvider) IsValid() bool {
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
//
|
||||
// relevant: https://developer.pagerduty.com/docs/events-api-v2/trigger-events/
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *core.Alert, _ *core.Result, resolved bool) *custom.AlertProvider {
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *alert.Alert, _ *core.Result, resolved bool) *custom.AlertProvider {
|
||||
var message, eventAction, resolveKey string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("RESOLVED: %s - %s", service.Name, alert.Description)
|
||||
message = fmt.Sprintf("RESOLVED: %s - %s", service.Name, alert.GetDescription())
|
||||
eventAction = "resolve"
|
||||
resolveKey = alert.ResolveKey
|
||||
} else {
|
||||
message = fmt.Sprintf("TRIGGERED: %s - %s", service.Name, alert.Description)
|
||||
message = fmt.Sprintf("TRIGGERED: %s - %s", service.Name, alert.GetDescription())
|
||||
eventAction = "trigger"
|
||||
resolveKey = ""
|
||||
}
|
||||
@@ -50,3 +54,8 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package pagerduty
|
||||
|
||||
import (
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
|
||||
func TestAlertProvider_IsValid(t *testing.T) {
|
||||
@@ -19,22 +23,44 @@ func TestAlertProvider_IsValid(t *testing.T) {
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
||||
provider := AlertProvider{IntegrationKey: "00000000000000000000000000000000"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &core.Alert{}, &core.Result{}, true)
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{}, true)
|
||||
if customAlertProvider == nil {
|
||||
t.Error("customAlertProvider shouldn't have been nil")
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "RESOLVED") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring RESOLVED")
|
||||
}
|
||||
if customAlertProvider.URL != "https://events.pagerduty.com/v2/enqueue" {
|
||||
t.Errorf("expected URL to be %s, got %s", "https://events.pagerduty.com/v2/enqueue", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
||||
provider := AlertProvider{IntegrationKey: "00000000000000000000000000000000"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &core.Alert{}, &core.Result{}, false)
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{}, false)
|
||||
if customAlertProvider == nil {
|
||||
t.Error("customAlertProvider shouldn't have been nil")
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "TRIGGERED") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring TRIGGERED")
|
||||
}
|
||||
if customAlertProvider.URL != "https://events.pagerduty.com/v2/enqueue" {
|
||||
t.Errorf("expected URL to be %s, got %s", "https://events.pagerduty.com/v2/enqueue", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/discord"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/mattermost"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/messagebird"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/pagerduty"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/slack"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/telegram"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/twilio"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
@@ -16,15 +19,42 @@ type AlertProvider interface {
|
||||
IsValid() bool
|
||||
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
ToCustomAlertProvider(service *core.Service, alert *core.Alert, result *core.Result, resolved bool) *custom.AlertProvider
|
||||
ToCustomAlertProvider(service *core.Service, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
GetDefaultAlert() *alert.Alert
|
||||
}
|
||||
|
||||
// ParseWithDefaultAlert parses a service alert by using the provider's default alert as a baseline
|
||||
func ParseWithDefaultAlert(providerDefaultAlert, serviceAlert *alert.Alert) {
|
||||
if providerDefaultAlert == nil || serviceAlert == nil {
|
||||
return
|
||||
}
|
||||
if serviceAlert.Enabled == nil {
|
||||
serviceAlert.Enabled = providerDefaultAlert.Enabled
|
||||
}
|
||||
if serviceAlert.SendOnResolved == nil {
|
||||
serviceAlert.SendOnResolved = providerDefaultAlert.SendOnResolved
|
||||
}
|
||||
if serviceAlert.Description == nil {
|
||||
serviceAlert.Description = providerDefaultAlert.Description
|
||||
}
|
||||
if serviceAlert.FailureThreshold == 0 {
|
||||
serviceAlert.FailureThreshold = providerDefaultAlert.FailureThreshold
|
||||
}
|
||||
if serviceAlert.SuccessThreshold == 0 {
|
||||
serviceAlert.SuccessThreshold = providerDefaultAlert.SuccessThreshold
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
// Validate interface implementation on compile
|
||||
_ AlertProvider = (*custom.AlertProvider)(nil)
|
||||
_ AlertProvider = (*twilio.AlertProvider)(nil)
|
||||
_ AlertProvider = (*slack.AlertProvider)(nil)
|
||||
_ AlertProvider = (*discord.AlertProvider)(nil)
|
||||
_ AlertProvider = (*mattermost.AlertProvider)(nil)
|
||||
_ AlertProvider = (*messagebird.AlertProvider)(nil)
|
||||
_ AlertProvider = (*pagerduty.AlertProvider)(nil)
|
||||
_ AlertProvider = (*slack.AlertProvider)(nil)
|
||||
_ AlertProvider = (*telegram.AlertProvider)(nil)
|
||||
_ AlertProvider = (*twilio.AlertProvider)(nil)
|
||||
)
|
||||
|
||||
153
alerting/provider/provider_test.go
Normal file
153
alerting/provider/provider_test.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
)
|
||||
|
||||
func TestParseWithDefaultAlert(t *testing.T) {
|
||||
type Scenario struct {
|
||||
Name string
|
||||
DefaultAlert, ServiceAlert, ExpectedOutputAlert *alert.Alert
|
||||
}
|
||||
enabled := true
|
||||
disabled := false
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "service-alert-type-only",
|
||||
DefaultAlert: &alert.Alert{
|
||||
Enabled: &enabled,
|
||||
SendOnResolved: &enabled,
|
||||
Description: &firstDescription,
|
||||
FailureThreshold: 5,
|
||||
SuccessThreshold: 10,
|
||||
},
|
||||
ServiceAlert: &alert.Alert{
|
||||
Type: alert.TypeDiscord,
|
||||
},
|
||||
ExpectedOutputAlert: &alert.Alert{
|
||||
Type: alert.TypeDiscord,
|
||||
Enabled: &enabled,
|
||||
SendOnResolved: &enabled,
|
||||
Description: &firstDescription,
|
||||
FailureThreshold: 5,
|
||||
SuccessThreshold: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "service-alert-overwrites-default-alert",
|
||||
DefaultAlert: &alert.Alert{
|
||||
Enabled: &disabled,
|
||||
SendOnResolved: &disabled,
|
||||
Description: &firstDescription,
|
||||
FailureThreshold: 5,
|
||||
SuccessThreshold: 10,
|
||||
},
|
||||
ServiceAlert: &alert.Alert{
|
||||
Type: alert.TypeTelegram,
|
||||
Enabled: &enabled,
|
||||
SendOnResolved: &enabled,
|
||||
Description: &secondDescription,
|
||||
FailureThreshold: 6,
|
||||
SuccessThreshold: 11,
|
||||
},
|
||||
ExpectedOutputAlert: &alert.Alert{
|
||||
Type: alert.TypeTelegram,
|
||||
Enabled: &enabled,
|
||||
SendOnResolved: &enabled,
|
||||
Description: &secondDescription,
|
||||
FailureThreshold: 6,
|
||||
SuccessThreshold: 11,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "service-alert-partially-overwrites-default-alert",
|
||||
DefaultAlert: &alert.Alert{
|
||||
Enabled: &enabled,
|
||||
SendOnResolved: &enabled,
|
||||
Description: &firstDescription,
|
||||
FailureThreshold: 5,
|
||||
SuccessThreshold: 10,
|
||||
},
|
||||
ServiceAlert: &alert.Alert{
|
||||
Type: alert.TypeDiscord,
|
||||
Enabled: nil,
|
||||
SendOnResolved: nil,
|
||||
FailureThreshold: 6,
|
||||
SuccessThreshold: 11,
|
||||
},
|
||||
ExpectedOutputAlert: &alert.Alert{
|
||||
Type: alert.TypeDiscord,
|
||||
Enabled: &enabled,
|
||||
SendOnResolved: &enabled,
|
||||
Description: &firstDescription,
|
||||
FailureThreshold: 6,
|
||||
SuccessThreshold: 11,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "default-alert-type-should-be-ignored",
|
||||
DefaultAlert: &alert.Alert{
|
||||
Type: alert.TypeTelegram,
|
||||
Enabled: &enabled,
|
||||
SendOnResolved: &enabled,
|
||||
Description: &firstDescription,
|
||||
FailureThreshold: 5,
|
||||
SuccessThreshold: 10,
|
||||
},
|
||||
ServiceAlert: &alert.Alert{
|
||||
Type: alert.TypeDiscord,
|
||||
},
|
||||
ExpectedOutputAlert: &alert.Alert{
|
||||
Type: alert.TypeDiscord,
|
||||
Enabled: &enabled,
|
||||
SendOnResolved: &enabled,
|
||||
Description: &firstDescription,
|
||||
FailureThreshold: 5,
|
||||
SuccessThreshold: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "no-default-alert",
|
||||
DefaultAlert: &alert.Alert{
|
||||
Type: alert.TypeDiscord,
|
||||
Enabled: nil,
|
||||
SendOnResolved: nil,
|
||||
Description: &firstDescription,
|
||||
FailureThreshold: 2,
|
||||
SuccessThreshold: 5,
|
||||
},
|
||||
ServiceAlert: nil,
|
||||
ExpectedOutputAlert: nil,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
ParseWithDefaultAlert(scenario.DefaultAlert, scenario.ServiceAlert)
|
||||
if scenario.ExpectedOutputAlert == nil {
|
||||
if scenario.ServiceAlert != nil {
|
||||
t.Fail()
|
||||
}
|
||||
return
|
||||
}
|
||||
if scenario.ServiceAlert.IsEnabled() != scenario.ExpectedOutputAlert.IsEnabled() {
|
||||
t.Errorf("expected ServiceAlert.IsEnabled() to be %v, got %v", scenario.ExpectedOutputAlert.IsEnabled(), scenario.ServiceAlert.IsEnabled())
|
||||
}
|
||||
if scenario.ServiceAlert.IsSendingOnResolved() != scenario.ExpectedOutputAlert.IsSendingOnResolved() {
|
||||
t.Errorf("expected ServiceAlert.IsSendingOnResolved() to be %v, got %v", scenario.ExpectedOutputAlert.IsSendingOnResolved(), scenario.ServiceAlert.IsSendingOnResolved())
|
||||
}
|
||||
if scenario.ServiceAlert.GetDescription() != scenario.ExpectedOutputAlert.GetDescription() {
|
||||
t.Errorf("expected ServiceAlert.GetDescription() to be %v, got %v", scenario.ExpectedOutputAlert.GetDescription(), scenario.ServiceAlert.GetDescription())
|
||||
}
|
||||
if scenario.ServiceAlert.FailureThreshold != scenario.ExpectedOutputAlert.FailureThreshold {
|
||||
t.Errorf("expected ServiceAlert.FailureThreshold to be %v, got %v", scenario.ExpectedOutputAlert.FailureThreshold, scenario.ServiceAlert.FailureThreshold)
|
||||
}
|
||||
if scenario.ServiceAlert.SuccessThreshold != scenario.ExpectedOutputAlert.SuccessThreshold {
|
||||
t.Errorf("expected ServiceAlert.SuccessThreshold to be %v, got %v", scenario.ExpectedOutputAlert.SuccessThreshold, scenario.ServiceAlert.SuccessThreshold)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,17 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Slack
|
||||
type AlertProvider struct {
|
||||
WebhookURL string `yaml:"webhook-url"`
|
||||
WebhookURL string `yaml:"webhook-url"` // Slack webhook URL
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
@@ -19,7 +23,7 @@ func (provider *AlertProvider) IsValid() bool {
|
||||
}
|
||||
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *core.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
|
||||
var message, color, results string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", service.Name, alert.SuccessThreshold)
|
||||
@@ -31,11 +35,11 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
if conditionResult.Success {
|
||||
prefix = ":heavy_check_mark:"
|
||||
prefix = ":white_check_mark:"
|
||||
} else {
|
||||
prefix = ":x:"
|
||||
}
|
||||
results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
|
||||
results += fmt.Sprintf("%s - `%s`\\n", prefix, conditionResult.Condition)
|
||||
}
|
||||
return &custom.AlertProvider{
|
||||
URL: provider.WebhookURL,
|
||||
@@ -57,7 +61,12 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
||||
]
|
||||
}
|
||||
]
|
||||
}`, message, alert.Description, color, results),
|
||||
}`, message, alert.GetDescription(), color, results),
|
||||
Headers: map[string]string{"Content-Type": "application/json"},
|
||||
}
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
|
||||
@@ -20,22 +23,44 @@ func TestAlertProvider_IsValid(t *testing.T) {
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
||||
provider := AlertProvider{WebhookURL: "http://example.com"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &core.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
|
||||
if customAlertProvider == nil {
|
||||
t.Error("customAlertProvider shouldn't have been nil")
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "resolved") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring resolved")
|
||||
}
|
||||
if customAlertProvider.URL != "http://example.com" {
|
||||
t.Errorf("expected URL to be %s, got %s", "http://example.com", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
||||
provider := AlertProvider{WebhookURL: "http://example.com"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &core.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
|
||||
if customAlertProvider == nil {
|
||||
t.Error("customAlertProvider shouldn't have been nil")
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "triggered") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring triggered")
|
||||
}
|
||||
if customAlertProvider.URL != "http://example.com" {
|
||||
t.Errorf("expected URL to be %s, got %s", "http://example.com", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
60
alerting/provider/telegram/telegram.go
Normal file
60
alerting/provider/telegram/telegram.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Telegram
|
||||
type AlertProvider struct {
|
||||
Token string `yaml:"token"`
|
||||
ID string `yaml:"id"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
func (provider *AlertProvider) IsValid() bool {
|
||||
return len(provider.Token) > 0 && len(provider.ID) > 0
|
||||
}
|
||||
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
|
||||
var message, results string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("An alert for *%s* has been resolved:\\n—\\n _healthcheck passing successfully %d time(s) in a row_\\n— ", service.Name, alert.FailureThreshold)
|
||||
} else {
|
||||
message = fmt.Sprintf("An alert for *%s* has been triggered:\\n—\\n _healthcheck failed %d time(s) in a row_\\n— ", service.Name, alert.FailureThreshold)
|
||||
}
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
if conditionResult.Success {
|
||||
prefix = "✅"
|
||||
} else {
|
||||
prefix = "❌"
|
||||
}
|
||||
results += fmt.Sprintf("%s - `%s`\\n", prefix, conditionResult.Condition)
|
||||
}
|
||||
var text string
|
||||
if len(alert.GetDescription()) > 0 {
|
||||
text = fmt.Sprintf("⛑ *Gatus* \\n%s \\n*Description* \\n_%s_ \\n\\n*Condition results*\\n%s", message, alert.GetDescription(), results)
|
||||
} else {
|
||||
text = fmt.Sprintf("⛑ *Gatus* \\n%s \\n*Condition results*\\n%s", message, results)
|
||||
}
|
||||
return &custom.AlertProvider{
|
||||
URL: fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token),
|
||||
Method: http.MethodPost,
|
||||
Body: fmt.Sprintf(`{"chat_id": "%s", "text": "%s", "parse_mode": "MARKDOWN"}`, provider.ID, text),
|
||||
Headers: map[string]string{"Content-Type": "application/json"},
|
||||
}
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
91
alerting/provider/telegram/telegram_test.go
Normal file
91
alerting/provider/telegram/telegram_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
|
||||
func TestAlertProvider_IsValid(t *testing.T) {
|
||||
invalidProvider := AlertProvider{Token: "", ID: ""}
|
||||
if invalidProvider.IsValid() {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
validProvider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}
|
||||
if !validProvider.IsValid() {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
||||
provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "resolved") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring resolved")
|
||||
}
|
||||
if customAlertProvider.URL != fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token) {
|
||||
t.Errorf("expected URL to be %s, got %s", fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token), customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
//_, err := json.Marshal(customAlertProvider.Body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
||||
provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "0123456789"}
|
||||
description := "Healthcheck Successful"
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{Description: &description}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "triggered") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring triggered")
|
||||
}
|
||||
if customAlertProvider.URL != fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token) {
|
||||
t.Errorf("expected URL to be %s, got %s", fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token), customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithDescription(t *testing.T) {
|
||||
provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "0123456789"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "triggered") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring triggered")
|
||||
}
|
||||
if customAlertProvider.URL != fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token) {
|
||||
t.Errorf("expected URL to be %s, got %s", fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token), customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
@@ -16,6 +17,9 @@ type AlertProvider struct {
|
||||
Token string `yaml:"token"`
|
||||
From string `yaml:"from"`
|
||||
To string `yaml:"to"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
@@ -24,12 +28,12 @@ func (provider *AlertProvider) IsValid() bool {
|
||||
}
|
||||
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *core.Alert, _ *core.Result, resolved bool) *custom.AlertProvider {
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *alert.Alert, _ *core.Result, resolved bool) *custom.AlertProvider {
|
||||
var message string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("RESOLVED: %s - %s", service.Name, alert.Description)
|
||||
message = fmt.Sprintf("RESOLVED: %s - %s", service.Name, alert.GetDescription())
|
||||
} else {
|
||||
message = fmt.Sprintf("TRIGGERED: %s - %s", service.Name, alert.Description)
|
||||
message = fmt.Sprintf("TRIGGERED: %s - %s", service.Name, alert.GetDescription())
|
||||
}
|
||||
return &custom.AlertProvider{
|
||||
URL: fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json", provider.SID),
|
||||
@@ -45,3 +49,8 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package twilio
|
||||
|
||||
import (
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
)
|
||||
|
||||
func TestTwilioAlertProvider_IsValid(t *testing.T) {
|
||||
@@ -25,31 +28,51 @@ func TestTwilioAlertProvider_IsValid(t *testing.T) {
|
||||
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
||||
provider := AlertProvider{
|
||||
SID: "1",
|
||||
Token: "1",
|
||||
From: "1",
|
||||
To: "1",
|
||||
Token: "2",
|
||||
From: "3",
|
||||
To: "4",
|
||||
}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &core.Alert{}, &core.Result{}, true)
|
||||
description := "alert-description"
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{Name: "service-name"}, &alert.Alert{Description: &description}, &core.Result{}, true)
|
||||
if customAlertProvider == nil {
|
||||
t.Error("customAlertProvider shouldn't have been nil")
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "RESOLVED") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring RESOLVED")
|
||||
}
|
||||
if customAlertProvider.URL != "https://api.twilio.com/2010-04-01/Accounts/1/Messages.json" {
|
||||
t.Errorf("expected URL to be %s, got %s", "https://api.twilio.com/2010-04-01/Accounts/1/Messages.json", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
if customAlertProvider.Body != "Body=RESOLVED%3A+service-name+-+alert-description&From=3&To=4" {
|
||||
t.Errorf("expected body to be %s, got %s", "Body=RESOLVED%3A+service-name+-+alert-description&From=3&To=4", customAlertProvider.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
||||
provider := AlertProvider{
|
||||
SID: "1",
|
||||
Token: "1",
|
||||
From: "1",
|
||||
SID: "4",
|
||||
Token: "3",
|
||||
From: "2",
|
||||
To: "1",
|
||||
}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &core.Alert{}, &core.Result{}, false)
|
||||
description := "alert-description"
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{Name: "service-name"}, &alert.Alert{Description: &description}, &core.Result{}, false)
|
||||
if customAlertProvider == nil {
|
||||
t.Error("customAlertProvider shouldn't have been nil")
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "TRIGGERED") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring TRIGGERED")
|
||||
}
|
||||
if customAlertProvider.URL != "https://api.twilio.com/2010-04-01/Accounts/4/Messages.json" {
|
||||
t.Errorf("expected URL to be %s, got %s", "https://api.twilio.com/2010-04-01/Accounts/4/Messages.json", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
if customAlertProvider.Body != "Body=TRIGGERED%3A+service-name+-+alert-description&From=2&To=1" {
|
||||
t.Errorf("expected body to be %s, got %s", "Body=TRIGGERED%3A+service-name+-+alert-description&From=2&To=1", customAlertProvider.Body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,25 +2,51 @@ package client
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"strconv"
|
||||
"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: time.Second * 10,
|
||||
Timeout: httpTimeout,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 20,
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
},
|
||||
@@ -31,18 +57,19 @@ func GetHTTPClient(insecure bool) *http.Client {
|
||||
}
|
||||
if secureHTTPClient == nil {
|
||||
secureHTTPClient = &http.Client{
|
||||
Timeout: time.Second * 10,
|
||||
Timeout: httpTimeout,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 20,
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
},
|
||||
}
|
||||
}
|
||||
return secureHTTPClient
|
||||
}
|
||||
|
||||
// CanCreateConnectionToTCPService checks whether a connection can be established with a TCP service
|
||||
func CanCreateConnectionToTCPService(address string) bool {
|
||||
// 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)
|
||||
if err != nil {
|
||||
return false
|
||||
@@ -50,3 +77,53 @@ func CanCreateConnectionToTCPService(address string) bool {
|
||||
_ = conn.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
// CanPerformStartTLS checks whether a connection can be established to an address using the STARTTLS protocol
|
||||
func CanPerformStartTLS(address string, insecure bool) (connected bool, certificate *x509.Certificate, err error) {
|
||||
hostAndPort := strings.Split(address, ":")
|
||||
if len(hostAndPort) != 2 {
|
||||
return false, nil, errors.New("invalid address for starttls, format must be host:port")
|
||||
}
|
||||
smtpClient, err := smtp.Dial(address)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = smtpClient.StartTLS(&tls.Config{
|
||||
InsecureSkipVerify: insecure,
|
||||
ServerName: hostAndPort[0],
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if state, ok := smtpClient.TLSConnectionState(); ok {
|
||||
certificate = state.PeerCertificates[0]
|
||||
} else {
|
||||
return false, nil, errors.New("could not get TLS connection state")
|
||||
}
|
||||
return true, certificate, nil
|
||||
}
|
||||
|
||||
// Ping checks if an address can be pinged and returns the round-trip time if the address can be pinged
|
||||
//
|
||||
// Note that this function takes at least 100ms, even if the address is 127.0.0.1
|
||||
func Ping(address string) (bool, time.Duration) {
|
||||
pinger, err := ping.NewPinger(address)
|
||||
if err != nil {
|
||||
return false, 0
|
||||
}
|
||||
pinger.Count = 1
|
||||
pinger.Timeout = pingTimeout
|
||||
pinger.SetPrivileged(true)
|
||||
err = pinger.Run()
|
||||
if err != nil {
|
||||
return false, 0
|
||||
}
|
||||
if pinger.Statistics() != nil {
|
||||
// If the packet loss is 100, it means that the packet didn't reach the host
|
||||
if pinger.Statistics().PacketLoss == 100 {
|
||||
return false, pinger.Timeout
|
||||
}
|
||||
return true, pinger.Statistics().MaxRtt
|
||||
}
|
||||
return true, 0
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ package client
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGetHttpClient(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
@@ -26,3 +27,75 @@ func TestGetHttpClient(t *testing.T) {
|
||||
t.Error("insecureHTTPClient shouldn't have been nil, since it has been called once")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPing(t *testing.T) {
|
||||
pingTimeout = 500 * time.Millisecond
|
||||
if success, rtt := Ping("127.0.0.1"); !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 {
|
||||
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 {
|
||||
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, tt.args.insecure)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("CanPerformStartTLS() err=%v, wantErr=%v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if connected != tt.wantConnected {
|
||||
t.Errorf("CanPerformStartTLS() connected=%v, wantConnected=%v", connected, tt.wantConnected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
22
config.yaml
22
config.yaml
@@ -1,14 +1,14 @@
|
||||
services:
|
||||
- name: frontend
|
||||
- name: front-end
|
||||
group: core
|
||||
url: "https://twinnation.org/health"
|
||||
interval: 1m
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].status == UP"
|
||||
- "[RESPONSE_TIME] < 1000"
|
||||
- "[RESPONSE_TIME] < 150"
|
||||
|
||||
- name: backend
|
||||
- name: back-end
|
||||
group: core
|
||||
url: "http://example.org/"
|
||||
interval: 5m
|
||||
@@ -36,3 +36,19 @@ services:
|
||||
- "[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
|
||||
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"
|
||||
|
||||
222
config/config.go
222
config/config.go
@@ -5,12 +5,16 @@ import (
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting"
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/alerting/provider"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwinProduction/gatus/k8s"
|
||||
"github.com/TwinProduction/gatus/security"
|
||||
"github.com/TwinProduction/gatus/storage"
|
||||
"github.com/TwinProduction/gatus/util"
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
@@ -28,9 +32,6 @@ const (
|
||||
|
||||
// DefaultPort is the default port the service will listen on
|
||||
DefaultPort = 8080
|
||||
|
||||
// DefaultContextRoot is the default context root of the web application
|
||||
DefaultContextRoot = "/"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -40,13 +41,8 @@ var (
|
||||
// ErrConfigFileNotFound is an error returned when the configuration file could not be found
|
||||
ErrConfigFileNotFound = errors.New("configuration file not found")
|
||||
|
||||
// ErrConfigNotLoaded is an error returned when an attempt to Get() the configuration before loading it is made
|
||||
ErrConfigNotLoaded = errors.New("configuration is nil")
|
||||
|
||||
// ErrInvalidSecurityConfig is an error returned when the security configuration is invalid
|
||||
ErrInvalidSecurityConfig = errors.New("invalid security configuration")
|
||||
|
||||
config *Config
|
||||
)
|
||||
|
||||
// Config is the main configuration structure
|
||||
@@ -57,6 +53,10 @@ type Config struct {
|
||||
// Metrics Whether to expose metrics at /metrics
|
||||
Metrics bool `yaml:"metrics"`
|
||||
|
||||
// SkipInvalidConfigUpdate Whether to make the application ignore invalid configuration
|
||||
// if the configuration file is updated while the application is running
|
||||
SkipInvalidConfigUpdate bool `yaml:"skip-invalid-config-update"`
|
||||
|
||||
// DisableMonitoringLock Whether to disable the monitoring lock
|
||||
// The monitoring lock is what prevents multiple services from being processed at the same time.
|
||||
// Disabling this may lead to inaccurate response times
|
||||
@@ -74,43 +74,62 @@ type Config struct {
|
||||
// Kubernetes is the Kubernetes configuration
|
||||
Kubernetes *k8s.Config `yaml:"kubernetes"`
|
||||
|
||||
// Storage is the configuration for how the data is stored
|
||||
Storage *storage.Config `yaml:"storage"`
|
||||
|
||||
// Web is the configuration for the web listener
|
||||
Web *webConfig `yaml:"web"`
|
||||
Web *WebConfig `yaml:"web"`
|
||||
|
||||
filePath string // path to the file from which config was loaded from
|
||||
lastFileModTime time.Time // last modification time
|
||||
}
|
||||
|
||||
// Get returns the configuration, or panics if the configuration hasn't loaded yet
|
||||
func Get() *Config {
|
||||
if config == nil {
|
||||
panic(ErrConfigNotLoaded)
|
||||
// HasLoadedConfigurationFileBeenModified returns whether the file that the
|
||||
// configuration has been loaded from has been modified since it was last read
|
||||
func (config Config) HasLoadedConfigurationFileBeenModified() bool {
|
||||
if fileInfo, err := os.Stat(config.filePath); err == nil {
|
||||
if !fileInfo.ModTime().IsZero() {
|
||||
return config.lastFileModTime.Unix() != fileInfo.ModTime().Unix()
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// UpdateLastFileModTime refreshes Config.lastFileModTime
|
||||
func (config *Config) UpdateLastFileModTime() {
|
||||
if fileInfo, err := os.Stat(config.filePath); err == nil {
|
||||
if !fileInfo.ModTime().IsZero() {
|
||||
config.lastFileModTime = fileInfo.ModTime()
|
||||
}
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
// Load loads a custom configuration file
|
||||
// Note that the misconfiguration of some fields may lead to panics. This is on purpose.
|
||||
func Load(configFile string) error {
|
||||
func Load(configFile string) (*Config, error) {
|
||||
log.Printf("[config][Load] Reading configuration from configFile=%s", configFile)
|
||||
cfg, err := readConfigurationFile(configFile)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return ErrConfigFileNotFound
|
||||
return nil, ErrConfigFileNotFound
|
||||
}
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
config = cfg
|
||||
return nil
|
||||
cfg.filePath = configFile
|
||||
cfg.UpdateLastFileModTime()
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// LoadDefaultConfiguration loads the default configuration file
|
||||
func LoadDefaultConfiguration() error {
|
||||
err := Load(DefaultConfigurationFilePath)
|
||||
func LoadDefaultConfiguration() (*Config, error) {
|
||||
cfg, err := Load(DefaultConfigurationFilePath)
|
||||
if err != nil {
|
||||
if err == ErrConfigFileNotFound {
|
||||
return Load(DefaultFallbackConfigurationFilePath)
|
||||
}
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
return nil
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func readConfigurationFile(fileName string) (config *Config, err error) {
|
||||
@@ -136,51 +155,89 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
|
||||
} else {
|
||||
// Note that the functions below may panic, and this is on purpose to prevent Gatus from starting with
|
||||
// invalid configurations
|
||||
validateAlertingConfig(config)
|
||||
validateSecurityConfig(config)
|
||||
validateServicesConfig(config)
|
||||
validateKubernetesConfig(config)
|
||||
validateWebConfig(config)
|
||||
validateAlertingConfig(config.Alerting, config.Services, config.Debug)
|
||||
if err := validateSecurityConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateServicesConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateKubernetesConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateWebConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateStorageConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func validateWebConfig(config *Config) {
|
||||
if config.Web == nil {
|
||||
config.Web = &webConfig{Address: DefaultAddress, Port: DefaultPort, ContextRoot: DefaultContextRoot}
|
||||
} else {
|
||||
config.Web.validateAndSetDefaults()
|
||||
func validateStorageConfig(config *Config) error {
|
||||
if config.Storage == nil {
|
||||
config.Storage = &storage.Config{}
|
||||
}
|
||||
err := storage.Initialize(config.Storage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Remove all ServiceStatus that represent services which no longer exist in the configuration
|
||||
var keys []string
|
||||
for _, service := range config.Services {
|
||||
keys = append(keys, util.ConvertGroupAndServiceToKey(service.Group, service.Name))
|
||||
}
|
||||
numberOfServiceStatusesDeleted := storage.Get().DeleteAllServiceStatusesNotInKeys(keys)
|
||||
if numberOfServiceStatusesDeleted > 0 {
|
||||
log.Printf("[config][validateStorageConfig] Deleted %d service statuses because their matching services no longer existed", numberOfServiceStatusesDeleted)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateKubernetesConfig(config *Config) {
|
||||
func validateWebConfig(config *Config) error {
|
||||
if config.Web == nil {
|
||||
config.Web = &WebConfig{Address: DefaultAddress, Port: DefaultPort}
|
||||
} else {
|
||||
return config.Web.validateAndSetDefaults()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deprecated
|
||||
// I don't like the current implementation.
|
||||
func validateKubernetesConfig(config *Config) error {
|
||||
if config.Kubernetes != nil && config.Kubernetes.AutoDiscover {
|
||||
if config.Kubernetes.ServiceTemplate == nil {
|
||||
panic("kubernetes.service-template cannot be nil")
|
||||
return errors.New("kubernetes.service-template cannot be nil")
|
||||
}
|
||||
if config.Debug {
|
||||
log.Println("[config][validateKubernetesConfig] Automatically discovering Kubernetes services...")
|
||||
}
|
||||
discoveredServices, err := k8s.DiscoverServices(config.Kubernetes)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
return err
|
||||
}
|
||||
config.Services = append(config.Services, discoveredServices...)
|
||||
log.Printf("[config][validateKubernetesConfig] Discovered %d Kubernetes services", len(discoveredServices))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateServicesConfig(config *Config) {
|
||||
func validateServicesConfig(config *Config) error {
|
||||
for _, service := range config.Services {
|
||||
if config.Debug {
|
||||
log.Printf("[config][validateServicesConfig] Validating service '%s'", service.Name)
|
||||
}
|
||||
service.ValidateAndSetDefaults()
|
||||
if err := service.ValidateAndSetDefaults(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
log.Printf("[config][validateServicesConfig] Validated %d services", len(config.Services))
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSecurityConfig(config *Config) {
|
||||
func validateSecurityConfig(config *Config) error {
|
||||
if config.Security != nil {
|
||||
if config.Security.IsValid() {
|
||||
if config.Debug {
|
||||
@@ -189,29 +246,49 @@ func validateSecurityConfig(config *Config) {
|
||||
} else {
|
||||
// If there was an attempt to configure security, then it must mean that some confidential or private
|
||||
// data are exposed. As a result, we'll force a panic because it's better to be safe than sorry.
|
||||
panic(ErrInvalidSecurityConfig)
|
||||
return ErrInvalidSecurityConfig
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAlertingConfig(config *Config) {
|
||||
if config.Alerting == nil {
|
||||
// validateAlertingConfig validates the alerting configuration
|
||||
// Note that the alerting configuration has to be validated before the service configuration, because the default alert
|
||||
// returned by provider.AlertProvider.GetDefaultAlert() must be parsed before core.Service.ValidateAndSetDefaults()
|
||||
// sets the default alert values when none are set.
|
||||
func validateAlertingConfig(alertingConfig *alerting.Config, services []*core.Service, debug bool) {
|
||||
if alertingConfig == nil {
|
||||
log.Printf("[config][validateAlertingConfig] Alerting is not configured")
|
||||
return
|
||||
}
|
||||
alertTypes := []core.AlertType{
|
||||
core.SlackAlert,
|
||||
core.MattermostAlert,
|
||||
core.MessagebirdAlert,
|
||||
core.TwilioAlert,
|
||||
core.PagerDutyAlert,
|
||||
core.CustomAlert,
|
||||
alertTypes := []alert.Type{
|
||||
alert.TypeCustom,
|
||||
alert.TypeDiscord,
|
||||
alert.TypeMattermost,
|
||||
alert.TypeMessagebird,
|
||||
alert.TypePagerDuty,
|
||||
alert.TypeSlack,
|
||||
alert.TypeTelegram,
|
||||
alert.TypeTwilio,
|
||||
}
|
||||
var validProviders, invalidProviders []core.AlertType
|
||||
var validProviders, invalidProviders []alert.Type
|
||||
for _, alertType := range alertTypes {
|
||||
alertProvider := GetAlertingProviderByAlertType(config, alertType)
|
||||
alertProvider := alertingConfig.GetAlertingProviderByAlertType(alertType)
|
||||
if alertProvider != nil {
|
||||
if alertProvider.IsValid() {
|
||||
// Parse alerts with the provider's default alert
|
||||
if alertProvider.GetDefaultAlert() != nil {
|
||||
for _, service := range services {
|
||||
for alertIndex, serviceAlert := range service.Alerts {
|
||||
if alertType == serviceAlert.Type {
|
||||
if debug {
|
||||
log.Printf("[config][validateAlertingConfig] Parsing alert %d with provider's default alert for provider=%s in service=%s", alertIndex, alertType, service.Name)
|
||||
}
|
||||
provider.ParseWithDefaultAlert(alertProvider.GetDefaultAlert(), serviceAlert)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
validProviders = append(validProviders, alertType)
|
||||
} else {
|
||||
log.Printf("[config][validateAlertingConfig] Ignoring provider=%s because configuration is invalid", alertType)
|
||||
@@ -223,46 +300,3 @@ func validateAlertingConfig(config *Config) {
|
||||
}
|
||||
log.Printf("[config][validateAlertingConfig] configuredProviders=%s; ignoredProviders=%s", validProviders, invalidProviders)
|
||||
}
|
||||
|
||||
// GetAlertingProviderByAlertType returns an provider.AlertProvider by its corresponding core.AlertType
|
||||
func GetAlertingProviderByAlertType(config *Config, alertType core.AlertType) provider.AlertProvider {
|
||||
switch alertType {
|
||||
case core.SlackAlert:
|
||||
if config.Alerting.Slack == nil {
|
||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||
return nil
|
||||
}
|
||||
return config.Alerting.Slack
|
||||
case core.MattermostAlert:
|
||||
if config.Alerting.Mattermost == nil {
|
||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||
return nil
|
||||
}
|
||||
return config.Alerting.Mattermost
|
||||
case core.MessagebirdAlert:
|
||||
if config.Alerting.Messagebird == nil {
|
||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||
return nil
|
||||
}
|
||||
return config.Alerting.Messagebird
|
||||
case core.TwilioAlert:
|
||||
if config.Alerting.Twilio == nil {
|
||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||
return nil
|
||||
}
|
||||
return config.Alerting.Twilio
|
||||
case core.PagerDutyAlert:
|
||||
if config.Alerting.PagerDuty == nil {
|
||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||
return nil
|
||||
}
|
||||
return config.Alerting.PagerDuty
|
||||
case core.CustomAlert:
|
||||
if config.Alerting.Custom == nil {
|
||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||
return nil
|
||||
}
|
||||
return config.Alerting.Custom
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,25 +3,20 @@ package config
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// webConfig is the structure which supports the configuration of the endpoint
|
||||
// WebConfig is the structure which supports the configuration of the endpoint
|
||||
// which provides access to the web frontend
|
||||
type webConfig struct {
|
||||
type WebConfig struct {
|
||||
// Address to listen on (defaults to 0.0.0.0 specified by DefaultAddress)
|
||||
Address string `yaml:"address"`
|
||||
|
||||
// Port to listen on (default to 8080 specified by DefaultPort)
|
||||
Port int `yaml:"port"`
|
||||
|
||||
// ContextRoot set the root context for the web application
|
||||
ContextRoot string `yaml:"context-root"`
|
||||
}
|
||||
|
||||
// validateAndSetDefaults checks and sets the default values for fields that are not set
|
||||
func (web *webConfig) validateAndSetDefaults() {
|
||||
func (web *WebConfig) validateAndSetDefaults() error {
|
||||
// Validate the Address
|
||||
if len(web.Address) == 0 {
|
||||
web.Address = DefaultAddress
|
||||
@@ -30,34 +25,12 @@ func (web *webConfig) validateAndSetDefaults() {
|
||||
if web.Port == 0 {
|
||||
web.Port = DefaultPort
|
||||
} else if web.Port < 0 || web.Port > math.MaxUint16 {
|
||||
panic(fmt.Sprintf("invalid port: value should be between %d and %d", 0, math.MaxUint16))
|
||||
}
|
||||
// Validate the ContextRoot
|
||||
if len(web.ContextRoot) == 0 {
|
||||
web.ContextRoot = DefaultContextRoot
|
||||
} else {
|
||||
trimmedContextRoot := strings.Trim(web.ContextRoot, "/")
|
||||
if len(trimmedContextRoot) == 0 {
|
||||
web.ContextRoot = DefaultContextRoot
|
||||
return
|
||||
}
|
||||
rootContextURL, err := url.Parse(trimmedContextRoot)
|
||||
if err != nil {
|
||||
panic("invalid context root:" + err.Error())
|
||||
}
|
||||
if rootContextURL.Path != trimmedContextRoot {
|
||||
panic("invalid context root: too complex")
|
||||
}
|
||||
web.ContextRoot = "/" + strings.Trim(rootContextURL.Path, "/") + "/"
|
||||
return fmt.Errorf("invalid port: value should be between %d and %d", 0, math.MaxUint16)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SocketAddress returns the combination of the Address and the Port
|
||||
func (web *webConfig) SocketAddress() string {
|
||||
func (web *WebConfig) SocketAddress() string {
|
||||
return fmt.Sprintf("%s:%d", web.Address, web.Port)
|
||||
}
|
||||
|
||||
// PrependWithContextRoot appends the given path to the ContextRoot
|
||||
func (web *webConfig) PrependWithContextRoot(path string) string {
|
||||
return web.ContextRoot + strings.Trim(path, "/")
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWebConfig_SocketAddress(t *testing.T) {
|
||||
web := &webConfig{
|
||||
web := &WebConfig{
|
||||
Address: "0.0.0.0",
|
||||
Port: 8081,
|
||||
}
|
||||
@@ -14,86 +13,3 @@ func TestWebConfig_SocketAddress(t *testing.T) {
|
||||
t.Errorf("expected %s, got %s", "0.0.0.0:8081", web.SocketAddress())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebConfig_PrependWithContextRoot(t *testing.T) {
|
||||
web := &webConfig{ContextRoot: "/status/"}
|
||||
if result := web.PrependWithContextRoot("/api/v1/results"); result != "/status/api/v1/results" {
|
||||
t.Errorf("expected %s, got %s", "/status/api/v1/results", result)
|
||||
}
|
||||
if result := web.PrependWithContextRoot("/health"); result != "/status/health" {
|
||||
t.Errorf("expected %s, got %s", "/status/health", result)
|
||||
}
|
||||
if result := web.PrependWithContextRoot("/health/"); result != "/status/health" {
|
||||
t.Errorf("expected %s, got %s", "/status/health", result)
|
||||
}
|
||||
}
|
||||
|
||||
// validContextRootTest specifies all test case which should end up in
|
||||
// a valid context root used to bind the web interface to
|
||||
var validContextRootTests = []struct {
|
||||
name string
|
||||
path string
|
||||
expectedPath string
|
||||
}{
|
||||
{"Empty", "", "/"},
|
||||
{"/", "/", "/"},
|
||||
{"///", "///", "/"},
|
||||
{"Single character 'a'", "a", "/a/"},
|
||||
{"Slash at the beginning", "/status", "/status/"},
|
||||
{"Slashes at start and end", "/status/", "/status/"},
|
||||
{"Multiple slashes at start", "//status", "/status/"},
|
||||
{"Multiple slashes at start and end", "///status////", "/status/"},
|
||||
{"Contains '@' in path'", "me@/status/gatus", "/me@/status/gatus/"},
|
||||
{"Nested context with trailing slash", "/status/gatus/", "/status/gatus/"},
|
||||
{"Nested context without trailing slash", "/status/gatus/system", "/status/gatus/system/"},
|
||||
}
|
||||
|
||||
func TestWebConfig_ValidContextRoots(t *testing.T) {
|
||||
for idx, test := range validContextRootTests {
|
||||
t.Run(fmt.Sprintf("%d: %s", idx, test.name), func(t *testing.T) {
|
||||
expectValidResultForContextRoot(t, test.path, test.expectedPath)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func expectValidResultForContextRoot(t *testing.T, path string, expected string) {
|
||||
web := &webConfig{
|
||||
ContextRoot: path,
|
||||
}
|
||||
web.validateAndSetDefaults()
|
||||
if web.ContextRoot != expected {
|
||||
t.Errorf("expected %s, got %s", expected, web.ContextRoot)
|
||||
}
|
||||
}
|
||||
|
||||
// invalidContextRootTests contains all tests for context root which are
|
||||
// expected to fail and stop program execution
|
||||
var invalidContextRootTests = []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{"Only a fragment identifier", "#"},
|
||||
{"Invalid character in path", "/invalid" + string([]byte{0x7F})},
|
||||
{"Starts with protocol", "http://status/gatus"},
|
||||
{"Path with fragment", "/status/gatus#here"},
|
||||
{"Starts with '://'", "://status"},
|
||||
{"Contains query parameter", "/status/h?ello=world"},
|
||||
{"Contains '?'", "/status?"},
|
||||
}
|
||||
|
||||
func TestWebConfig_InvalidContextRoots(t *testing.T) {
|
||||
for idx, test := range invalidContextRootTests {
|
||||
t.Run(fmt.Sprintf("%d: %s", idx, test.name), func(t *testing.T) {
|
||||
expectInvalidResultForContextRoot(t, test.path)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func expectInvalidResultForContextRoot(t *testing.T, path string) {
|
||||
defer func() { recover() }()
|
||||
|
||||
web := &webConfig{ContextRoot: path}
|
||||
web.validateAndSetDefaults()
|
||||
|
||||
t.Fatal(fmt.Sprintf("Should've panicked because the configuration specifies an invalid context root: %s", path))
|
||||
}
|
||||
|
||||
105
controller/badge.go
Normal file
105
controller/badge.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwinProduction/gatus/storage"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// badgeHandler handles the automatic generation of badge based on the group name and service name passed.
|
||||
//
|
||||
// Valid values for {duration}: 7d, 24h, 1h
|
||||
// Pattern for {identifier}: <KEY>.svg
|
||||
func badgeHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
variables := mux.Vars(request)
|
||||
duration := variables["duration"]
|
||||
if duration != "7d" && duration != "24h" && duration != "1h" {
|
||||
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"))
|
||||
return
|
||||
}
|
||||
formattedDate := time.Now().Format(http.TimeFormat)
|
||||
writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
writer.Header().Set("Date", formattedDate)
|
||||
writer.Header().Set("Expires", formattedDate)
|
||||
writer.Header().Set("Content-Type", "image/svg+xml")
|
||||
_, _ = writer.Write(generateSVG(duration, serviceStatus.Uptime))
|
||||
}
|
||||
|
||||
func generateSVG(duration string, uptime *core.Uptime) []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"), ".") + "%"
|
||||
if strings.Contains(sanitizedValue, ".") {
|
||||
valueWidthAdjustment = -10
|
||||
}
|
||||
valueWidth = (len(sanitizedValue) * 11) + valueWidthAdjustment
|
||||
width := labelWidth + valueWidth
|
||||
labelX := labelWidth / 2
|
||||
valueX := labelWidth + (valueWidth / 2)
|
||||
svg := []byte(fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="20">
|
||||
<linearGradient id="b" x2="0" y2="100%%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
</linearGradient>
|
||||
<mask id="a">
|
||||
<rect width="%d" height="20" rx="3" fill="#fff"/>
|
||||
</mask>
|
||||
<g mask="url(#a)">
|
||||
<path fill="#555" d="M0 0h%dv20H0z"/>
|
||||
<path fill="%s" d="M%d 0h%dv20H%dz"/>
|
||||
<path fill="url(#b)" d="M0 0h%dv20H0z"/>
|
||||
</g>
|
||||
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
|
||||
<text x="%d" y="15" fill="#010101" fill-opacity=".3">
|
||||
uptime %s
|
||||
</text>
|
||||
<text x="%d" y="14">
|
||||
uptime %s
|
||||
</text>
|
||||
<text x="%d" y="15" fill="#010101" fill-opacity=".3">
|
||||
%s
|
||||
</text>
|
||||
<text x="%d" y="14">
|
||||
%s
|
||||
</text>
|
||||
</g>
|
||||
</svg>`, width, width, labelWidth, color, labelWidth, valueWidth, labelWidth, width, labelX, duration, labelX, duration, valueX, sanitizedValue, valueX, sanitizedValue))
|
||||
return svg
|
||||
}
|
||||
170
controller/controller.go
Normal file
170
controller/controller.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/TwinProduction/gatus/config"
|
||||
"github.com/TwinProduction/gatus/security"
|
||||
"github.com/TwinProduction/gatus/storage"
|
||||
"github.com/TwinProduction/gocache"
|
||||
"github.com/TwinProduction/health"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
const (
|
||||
cacheTTL = 10 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
cache = gocache.NewCache().WithMaxSize(100).WithEvictionPolicy(gocache.FirstInFirstOut)
|
||||
|
||||
// staticFolder is the path to the location of the static folder from the root path of the project
|
||||
// The only reason this is exposed is to allow running tests from a different path than the root path of the project
|
||||
staticFolder = "./web/static"
|
||||
|
||||
// server is the http.Server created by Handle.
|
||||
// The only reason it exists is for testing purposes.
|
||||
server *http.Server
|
||||
)
|
||||
|
||||
func init() {
|
||||
if err := cache.StartJanitor(); err != nil {
|
||||
log.Fatal("[controller][init] Failed to start cache janitor:", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Handle creates the router and starts the server
|
||||
func Handle(securityConfig *security.Config, webConfig *config.WebConfig, enableMetrics bool) {
|
||||
var router http.Handler = CreateRouter(securityConfig, enableMetrics)
|
||||
if os.Getenv("ENVIRONMENT") == "dev" {
|
||||
router = developmentCorsHandler(router)
|
||||
}
|
||||
server = &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", webConfig.Address, webConfig.Port),
|
||||
Handler: router,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 15 * time.Second,
|
||||
}
|
||||
log.Println("[controller][Handle] Listening on " + webConfig.SocketAddress())
|
||||
if os.Getenv("ROUTER_TEST") == "true" {
|
||||
return
|
||||
}
|
||||
log.Println("[controller][Handle]", server.ListenAndServe())
|
||||
}
|
||||
|
||||
// Shutdown stops the server
|
||||
func Shutdown() {
|
||||
if server != nil {
|
||||
_ = server.Shutdown(context.TODO())
|
||||
server = nil
|
||||
}
|
||||
}
|
||||
|
||||
// CreateRouter creates the router for the http server
|
||||
func CreateRouter(securityConfig *security.Config, enabledMetrics bool) *mux.Router {
|
||||
router := mux.NewRouter()
|
||||
if enabledMetrics {
|
||||
router.Handle("/metrics", promhttp.Handler()).Methods("GET")
|
||||
}
|
||||
router.Handle("/health", health.Handler().WithJSON(true)).Methods("GET")
|
||||
router.HandleFunc("/favicon.ico", favIconHandler).Methods("GET")
|
||||
router.HandleFunc("/api/v1/statuses", secureIfNecessary(securityConfig, serviceStatusesHandler)).Methods("GET") // No GzipHandler for this one, because we cache the content
|
||||
router.HandleFunc("/api/v1/statuses/{key}", secureIfNecessary(securityConfig, GzipHandlerFunc(serviceStatusHandler))).Methods("GET")
|
||||
router.HandleFunc("/api/v1/badges/uptime/{duration}/{identifier}", badgeHandler).Methods("GET")
|
||||
// SPA
|
||||
router.HandleFunc("/services/{service}", spaHandler).Methods("GET")
|
||||
// Everything else falls back on static content
|
||||
router.PathPrefix("/").Handler(GzipHandler(http.FileServer(http.Dir(staticFolder))))
|
||||
return router
|
||||
}
|
||||
|
||||
func secureIfNecessary(securityConfig *security.Config, handler http.HandlerFunc) http.HandlerFunc {
|
||||
if securityConfig != nil && securityConfig.IsValid() {
|
||||
return security.Handler(handler, securityConfig)
|
||||
}
|
||||
return handler
|
||||
}
|
||||
|
||||
// serviceStatusesHandler handles requests to retrieve all service statuses
|
||||
// Due to the size of the response, this function leverages a cache.
|
||||
// Must not be wrapped by GzipHandler
|
||||
func serviceStatusesHandler(writer http.ResponseWriter, r *http.Request) {
|
||||
page, pageSize := extractPageAndPageSizeFromRequest(r)
|
||||
gzipped := strings.Contains(r.Header.Get("Accept-Encoding"), "gzip")
|
||||
var exists bool
|
||||
var value interface{}
|
||||
if gzipped {
|
||||
writer.Header().Set("Content-Encoding", "gzip")
|
||||
value, exists = cache.Get(fmt.Sprintf("service-status-%d-%d-gzipped", page, pageSize))
|
||||
} else {
|
||||
value, exists = cache.Get(fmt.Sprintf("service-status-%d-%d", page, pageSize))
|
||||
}
|
||||
var data []byte
|
||||
if !exists {
|
||||
var err error
|
||||
buffer := &bytes.Buffer{}
|
||||
gzipWriter := gzip.NewWriter(buffer)
|
||||
data, err = json.Marshal(storage.Get().GetAllServiceStatusesWithResultPagination(page, pageSize))
|
||||
if err != nil {
|
||||
log.Printf("[controller][serviceStatusesHandler] Unable to marshal object to JSON: %s", err.Error())
|
||||
writer.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = writer.Write([]byte("Unable to marshal object to JSON"))
|
||||
return
|
||||
}
|
||||
_, _ = gzipWriter.Write(data)
|
||||
_ = gzipWriter.Close()
|
||||
gzippedData := buffer.Bytes()
|
||||
cache.SetWithTTL(fmt.Sprintf("service-status-%d-%d", page, pageSize), data, cacheTTL)
|
||||
cache.SetWithTTL(fmt.Sprintf("service-status-%d-%d-gzipped", page, pageSize), gzippedData, cacheTTL)
|
||||
if gzipped {
|
||||
data = gzippedData
|
||||
}
|
||||
} else {
|
||||
data = value.([]byte)
|
||||
}
|
||||
writer.Header().Add("Content-Type", "application/json")
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
_, _ = writer.Write(data)
|
||||
}
|
||||
|
||||
// serviceStatusHandler retrieves a single ServiceStatus by group name and service name
|
||||
func serviceStatusHandler(writer http.ResponseWriter, r *http.Request) {
|
||||
page, pageSize := extractPageAndPageSizeFromRequest(r)
|
||||
vars := mux.Vars(r)
|
||||
serviceStatus := storage.Get().GetServiceStatusByKey(vars["key"])
|
||||
if serviceStatus == nil {
|
||||
log.Printf("[controller][serviceStatusHandler] Service with key=%s not found", vars["key"])
|
||||
writer.WriteHeader(http.StatusNotFound)
|
||||
_, _ = writer.Write([]byte("not found"))
|
||||
return
|
||||
}
|
||||
data := map[string]interface{}{
|
||||
"serviceStatus": serviceStatus.WithResultPagination(page, pageSize),
|
||||
// The following fields, while present on core.ServiceStatus, are annotated to remain hidden so that we can
|
||||
// expose only the necessary data on /api/v1/statuses.
|
||||
// Since the /api/v1/statuses/{key} endpoint does need this data, however, we explicitly expose it here
|
||||
"events": serviceStatus.Events,
|
||||
"uptime": serviceStatus.Uptime,
|
||||
}
|
||||
output, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
log.Printf("[controller][serviceStatusHandler] Unable to marshal object to JSON: %s", err.Error())
|
||||
writer.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = writer.Write([]byte("unable to marshal object to JSON"))
|
||||
return
|
||||
}
|
||||
writer.Header().Add("Content-Type", "application/json")
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
_, _ = writer.Write(output)
|
||||
}
|
||||
329
controller/controller_test.go
Normal file
329
controller/controller_test.go
Normal file
@@ -0,0 +1,329 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwinProduction/gatus/config"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwinProduction/gatus/storage"
|
||||
"github.com/TwinProduction/gatus/watchdog"
|
||||
)
|
||||
|
||||
var (
|
||||
firstCondition = core.Condition("[STATUS] == 200")
|
||||
secondCondition = core.Condition("[RESPONSE_TIME] < 500")
|
||||
thirdCondition = core.Condition("[CERTIFICATE_EXPIRATION] < 72h")
|
||||
|
||||
timestamp = time.Now()
|
||||
|
||||
testService = core.Service{
|
||||
Name: "name",
|
||||
Group: "group",
|
||||
URL: "https://example.org/what/ever",
|
||||
Method: "GET",
|
||||
Body: "body",
|
||||
Interval: 30 * time.Second,
|
||||
Conditions: []*core.Condition{&firstCondition, &secondCondition, &thirdCondition},
|
||||
Alerts: nil,
|
||||
Insecure: false,
|
||||
NumberOfFailuresInARow: 0,
|
||||
NumberOfSuccessesInARow: 0,
|
||||
}
|
||||
testSuccessfulResult = core.Result{
|
||||
Hostname: "example.org",
|
||||
IP: "127.0.0.1",
|
||||
HTTPStatus: 200,
|
||||
Errors: nil,
|
||||
Connected: true,
|
||||
Success: true,
|
||||
Timestamp: timestamp,
|
||||
Duration: 150 * time.Millisecond,
|
||||
CertificateExpiration: 10 * time.Hour,
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{
|
||||
Condition: "[STATUS] == 200",
|
||||
Success: true,
|
||||
},
|
||||
{
|
||||
Condition: "[RESPONSE_TIME] < 500",
|
||||
Success: true,
|
||||
},
|
||||
{
|
||||
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
|
||||
Success: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
testUnsuccessfulResult = core.Result{
|
||||
Hostname: "example.org",
|
||||
IP: "127.0.0.1",
|
||||
HTTPStatus: 200,
|
||||
Errors: []string{"error-1", "error-2"},
|
||||
Connected: true,
|
||||
Success: false,
|
||||
Timestamp: timestamp,
|
||||
Duration: 750 * time.Millisecond,
|
||||
CertificateExpiration: 10 * time.Hour,
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{
|
||||
Condition: "[STATUS] == 200",
|
||||
Success: true,
|
||||
},
|
||||
{
|
||||
Condition: "[RESPONSE_TIME] < 500",
|
||||
Success: false,
|
||||
},
|
||||
{
|
||||
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
|
||||
Success: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func TestCreateRouter(t *testing.T) {
|
||||
defer storage.Get().Clear()
|
||||
defer cache.Clear()
|
||||
staticFolder = "../web/static"
|
||||
cfg := &config.Config{
|
||||
Metrics: true,
|
||||
Services: []*core.Service{
|
||||
{
|
||||
Name: "frontend",
|
||||
Group: "core",
|
||||
},
|
||||
{
|
||||
Name: "backend",
|
||||
Group: "core",
|
||||
},
|
||||
},
|
||||
}
|
||||
watchdog.UpdateServiceStatuses(cfg.Services[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateServiceStatuses(cfg.Services[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
router := CreateRouter(cfg.Security, cfg.Metrics)
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
ExpectedCode int
|
||||
Gzip bool
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "health",
|
||||
Path: "/health",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "metrics",
|
||||
Path: "/metrics",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "badges-1h",
|
||||
Path: "/api/v1/badges/uptime/1h/core_frontend.svg",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "badges-24h",
|
||||
Path: "/api/v1/badges/uptime/24h/core_backend.svg",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "badges-7d",
|
||||
Path: "/api/v1/badges/uptime/7d/core_frontend.svg",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "badges-with-invalid-duration",
|
||||
Path: "/api/v1/badges/uptime/3d/core_backend.svg",
|
||||
ExpectedCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
Name: "badges-for-invalid-key",
|
||||
Path: "/api/v1/badges/uptime/7d/invalid_key.svg",
|
||||
ExpectedCode: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
Name: "service-statuses",
|
||||
Path: "/api/v1/statuses",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "service-statuses-gzip",
|
||||
Path: "/api/v1/statuses",
|
||||
ExpectedCode: http.StatusOK,
|
||||
Gzip: true,
|
||||
},
|
||||
{
|
||||
Name: "service-statuses-pagination",
|
||||
Path: "/api/v1/statuses?page=1&pageSize=20",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "service-status",
|
||||
Path: "/api/v1/statuses/core_frontend",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "service-status-gzip",
|
||||
Path: "/api/v1/statuses/core_frontend",
|
||||
ExpectedCode: http.StatusOK,
|
||||
Gzip: true,
|
||||
},
|
||||
{
|
||||
Name: "service-status-for-invalid-key",
|
||||
Path: "/api/v1/statuses/invalid_key",
|
||||
ExpectedCode: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
Name: "favicon",
|
||||
Path: "/favicon.ico",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "frontend-home",
|
||||
Path: "/",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "frontend-assets",
|
||||
Path: "/js/app.js",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "frontend-service",
|
||||
Path: "/services/core_frontend",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
request, _ := http.NewRequest("GET", scenario.Path, nil)
|
||||
if scenario.Gzip {
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
}
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(responseRecorder, request)
|
||||
if responseRecorder.Code != scenario.ExpectedCode {
|
||||
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandle(t *testing.T) {
|
||||
defer storage.Get().Clear()
|
||||
defer cache.Clear()
|
||||
cfg := &config.Config{
|
||||
Web: &config.WebConfig{
|
||||
Address: "0.0.0.0",
|
||||
Port: rand.Intn(65534),
|
||||
},
|
||||
Services: []*core.Service{
|
||||
{
|
||||
Name: "frontend",
|
||||
Group: "core",
|
||||
},
|
||||
{
|
||||
Name: "backend",
|
||||
Group: "core",
|
||||
},
|
||||
},
|
||||
}
|
||||
_ = os.Setenv("ROUTER_TEST", "true")
|
||||
_ = os.Setenv("ENVIRONMENT", "dev")
|
||||
defer os.Clearenv()
|
||||
Handle(cfg.Security, cfg.Web, cfg.Metrics)
|
||||
defer Shutdown()
|
||||
request, _ := http.NewRequest("GET", "/health", nil)
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
server.Handler.ServeHTTP(responseRecorder, request)
|
||||
if responseRecorder.Code != http.StatusOK {
|
||||
t.Error("expected GET /health to return status code 200")
|
||||
}
|
||||
if server == nil {
|
||||
t.Fatal("server should've been set (but because we set ROUTER_TEST, it shouldn't have been started)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShutdown(t *testing.T) {
|
||||
// Pretend that we called controller.Handle(), which initializes the server variable
|
||||
server = &http.Server{}
|
||||
Shutdown()
|
||||
if server != nil {
|
||||
t.Error("server should've been shut down")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceStatusesHandler(t *testing.T) {
|
||||
defer storage.Get().Clear()
|
||||
defer cache.Clear()
|
||||
staticFolder = "../web/static"
|
||||
firstResult := &testSuccessfulResult
|
||||
secondResult := &testUnsuccessfulResult
|
||||
storage.Get().Insert(&testService, firstResult)
|
||||
storage.Get().Insert(&testService, secondResult)
|
||||
// Can't be bothered dealing with timezone issues on the worker that runs the automated tests
|
||||
firstResult.Timestamp = time.Time{}
|
||||
secondResult.Timestamp = time.Time{}
|
||||
router := CreateRouter(nil, false)
|
||||
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
ExpectedCode int
|
||||
ExpectedBody string
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "no-pagination",
|
||||
Path: "/api/v1/statuses",
|
||||
ExpectedCode: http.StatusOK,
|
||||
ExpectedBody: `{"group_name":{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"errors":null,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}]}}`,
|
||||
},
|
||||
{
|
||||
Name: "pagination-first-result",
|
||||
Path: "/api/v1/statuses?page=1&pageSize=1",
|
||||
ExpectedCode: http.StatusOK,
|
||||
ExpectedBody: `{"group_name":{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}]}}`,
|
||||
},
|
||||
{
|
||||
Name: "pagination-second-result",
|
||||
Path: "/api/v1/statuses?page=2&pageSize=1",
|
||||
ExpectedCode: http.StatusOK,
|
||||
ExpectedBody: `{"group_name":{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"errors":null,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"}]}}`,
|
||||
},
|
||||
{
|
||||
Name: "pagination-no-results",
|
||||
Path: "/api/v1/statuses?page=5&pageSize=20",
|
||||
ExpectedCode: http.StatusOK,
|
||||
ExpectedBody: `{"group_name":{"name":"name","group":"group","key":"group_name","results":[]}}`,
|
||||
},
|
||||
{
|
||||
Name: "invalid-pagination-should-fall-back-to-default",
|
||||
Path: "/api/v1/statuses?page=INVALID&pageSize=INVALID",
|
||||
ExpectedCode: http.StatusOK,
|
||||
ExpectedBody: `{"group_name":{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"errors":null,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}]}}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
request, _ := http.NewRequest("GET", scenario.Path, nil)
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(responseRecorder, request)
|
||||
if responseRecorder.Code != scenario.ExpectedCode {
|
||||
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
|
||||
}
|
||||
output := responseRecorder.Body.String()
|
||||
if output != scenario.ExpectedBody {
|
||||
t.Errorf("expected:\n %s\n\ngot:\n %s", scenario.ExpectedBody, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
10
controller/cors.go
Normal file
10
controller/cors.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package controller
|
||||
|
||||
import "net/http"
|
||||
|
||||
func developmentCorsHandler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "http://localhost:8081")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
8
controller/favicon.go
Normal file
8
controller/favicon.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package controller
|
||||
|
||||
import "net/http"
|
||||
|
||||
// favIconHandler handles requests for /favicon.ico
|
||||
func favIconHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
http.ServeFile(writer, request, staticFolder+"/favicon.ico")
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package controller
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
@@ -20,19 +20,30 @@ type gzipResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
}
|
||||
|
||||
// WriteHeader sends an HTTP response header with the provided status code.
|
||||
// It also deletes the Content-Length header, since the GZIP compression may modify the size of the payload
|
||||
func (w *gzipResponseWriter) WriteHeader(status int) {
|
||||
w.Header().Del("Content-Length")
|
||||
w.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
// Write writes len(b) bytes from b to the underlying data stream.
|
||||
func (w *gzipResponseWriter) Write(b []byte) (int, error) {
|
||||
return w.Writer.Write(b)
|
||||
}
|
||||
|
||||
// GzipHandler compresses the response of a given handler if the request's headers specify that the client
|
||||
// GzipHandler compresses the response of a given http.Handler if the request's headers specify that the client
|
||||
// supports gzip encoding
|
||||
func GzipHandler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, r *http.Request) {
|
||||
return GzipHandlerFunc(func(writer http.ResponseWriter, r *http.Request) {
|
||||
next.ServeHTTP(writer, r)
|
||||
})
|
||||
}
|
||||
|
||||
// GzipHandlerFunc compresses the response of a given http.HandlerFunc if the request's headers specify that the client
|
||||
// supports gzip encoding
|
||||
func GzipHandlerFunc(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, r *http.Request) {
|
||||
// If the request doesn't specify that it supports gzip, then don't compress it
|
||||
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||
next.ServeHTTP(writer, r)
|
||||
@@ -44,5 +55,5 @@ func GzipHandler(next http.Handler) http.Handler {
|
||||
gz.Reset(writer)
|
||||
defer gz.Close()
|
||||
next.ServeHTTP(&gzipResponseWriter{ResponseWriter: writer, Writer: gz}, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
8
controller/spa.go
Normal file
8
controller/spa.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package controller
|
||||
|
||||
import "net/http"
|
||||
|
||||
// spaHandler handles requests for /
|
||||
func spaHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
http.ServeFile(writer, request, staticFolder+"/index.html")
|
||||
}
|
||||
46
controller/util.go
Normal file
46
controller/util.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultPage is the default page to use if none is specified or an invalid value is provided
|
||||
DefaultPage = 1
|
||||
|
||||
// DefaultPageSize is the default page siZE to use if none is specified or an invalid value is provided
|
||||
DefaultPageSize = 20
|
||||
|
||||
// MaximumPageSize is the maximum page size allowed
|
||||
MaximumPageSize = 100
|
||||
)
|
||||
|
||||
func extractPageAndPageSizeFromRequest(r *http.Request) (page int, pageSize int) {
|
||||
var err error
|
||||
if pageParameter := r.URL.Query().Get("page"); len(pageParameter) == 0 {
|
||||
page = DefaultPage
|
||||
} else {
|
||||
page, err = strconv.Atoi(pageParameter)
|
||||
if err != nil {
|
||||
page = DefaultPage
|
||||
}
|
||||
if page < 1 {
|
||||
page = DefaultPage
|
||||
}
|
||||
}
|
||||
if pageSizeParameter := r.URL.Query().Get("pageSize"); len(pageSizeParameter) == 0 {
|
||||
pageSize = DefaultPageSize
|
||||
} else {
|
||||
pageSize, err = strconv.Atoi(pageSizeParameter)
|
||||
if err != nil {
|
||||
pageSize = DefaultPageSize
|
||||
}
|
||||
if pageSize > MaximumPageSize {
|
||||
pageSize = MaximumPageSize
|
||||
} else if pageSize < 1 {
|
||||
pageSize = DefaultPageSize
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
67
controller/util_test.go
Normal file
67
controller/util_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractPageAndPageSizeFromRequest(t *testing.T) {
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Page string
|
||||
PageSize string
|
||||
ExpectedPage int
|
||||
ExpectedPageSize int
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Page: "1",
|
||||
PageSize: "20",
|
||||
ExpectedPage: 1,
|
||||
ExpectedPageSize: 20,
|
||||
},
|
||||
{
|
||||
Page: "2",
|
||||
PageSize: "10",
|
||||
ExpectedPage: 2,
|
||||
ExpectedPageSize: 10,
|
||||
},
|
||||
{
|
||||
Page: "2",
|
||||
PageSize: "10",
|
||||
ExpectedPage: 2,
|
||||
ExpectedPageSize: 10,
|
||||
},
|
||||
{
|
||||
Page: "1",
|
||||
PageSize: "999999",
|
||||
ExpectedPage: 1,
|
||||
ExpectedPageSize: MaximumPageSize,
|
||||
},
|
||||
{
|
||||
Page: "-1",
|
||||
PageSize: "-1",
|
||||
ExpectedPage: DefaultPage,
|
||||
ExpectedPageSize: DefaultPageSize,
|
||||
},
|
||||
{
|
||||
Page: "invalid",
|
||||
PageSize: "invalid",
|
||||
ExpectedPage: DefaultPage,
|
||||
ExpectedPageSize: DefaultPageSize,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run("page-"+scenario.Page+"-pageSize-"+scenario.PageSize, func(t *testing.T) {
|
||||
request, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/statuses?page=%s&pageSize=%s", scenario.Page, scenario.PageSize), nil)
|
||||
actualPage, actualPageSize := extractPageAndPageSizeFromRequest(request)
|
||||
if actualPage != scenario.ExpectedPage {
|
||||
t.Errorf("expected %d, got %d", scenario.ExpectedPage, actualPage)
|
||||
}
|
||||
if actualPageSize != scenario.ExpectedPageSize {
|
||||
t.Errorf("expected %d, got %d", scenario.ExpectedPageSize, actualPageSize)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package core
|
||||
|
||||
// Alert is the service's alert configuration
|
||||
type Alert struct {
|
||||
// Type of alert
|
||||
Type AlertType `yaml:"type"`
|
||||
|
||||
// Enabled defines whether or not the alert is enabled
|
||||
Enabled bool `yaml:"enabled"`
|
||||
|
||||
// FailureThreshold is the number of failures in a row needed before triggering the alert
|
||||
FailureThreshold int `yaml:"failure-threshold"`
|
||||
|
||||
// Description of the alert. Will be included in the alert sent.
|
||||
Description string `yaml:"description"`
|
||||
|
||||
// SendOnResolved defines whether to send a second notification when the issue has been resolved
|
||||
SendOnResolved bool `yaml:"send-on-resolved"`
|
||||
|
||||
// SuccessThreshold defines how many successful executions must happen in a row before an ongoing incident is marked as resolved
|
||||
SuccessThreshold int `yaml:"success-threshold"`
|
||||
|
||||
// ResolveKey is an optional field that is used by some providers (i.e. PagerDuty's dedup_key) to resolve
|
||||
// ongoing/triggered incidents
|
||||
ResolveKey string
|
||||
|
||||
// Triggered is used to determine whether an alert has been triggered. When an alert is resolved, this value
|
||||
// should be set back to false. It is used to prevent the same alert from going out twice.
|
||||
Triggered bool
|
||||
}
|
||||
|
||||
// AlertType is the type of the alert.
|
||||
// The value will generally be the name of the alert provider
|
||||
type AlertType string
|
||||
|
||||
const (
|
||||
// SlackAlert is the AlertType for the slack alerting provider
|
||||
SlackAlert AlertType = "slack"
|
||||
|
||||
// MattermostAlert is the AlertType for the mattermost alerting provider
|
||||
MattermostAlert AlertType = "mattermost"
|
||||
|
||||
// MessagebirdAlert is the AlertType for the messagebird alerting provider
|
||||
MessagebirdAlert AlertType = "messagebird"
|
||||
|
||||
// PagerDutyAlert is the AlertType for the pagerduty alerting provider
|
||||
PagerDutyAlert AlertType = "pagerduty"
|
||||
|
||||
// TwilioAlert is the AlertType for the twilio alerting provider
|
||||
TwilioAlert AlertType = "twilio"
|
||||
|
||||
// CustomAlert is the AlertType for the custom alerting provider
|
||||
CustomAlert AlertType = "custom"
|
||||
)
|
||||
@@ -2,7 +2,6 @@ package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -24,7 +23,7 @@ const (
|
||||
|
||||
// DNSRCodePlaceholder is a place holder for DNS_RCODE
|
||||
//
|
||||
// Values that could be NOERROR, FORMERR, SERVFAIL, NXDOMAIN, NOTIMP and REFUSED
|
||||
// Values that could replace the placeholder: NOERROR, FORMERR, SERVFAIL, NXDOMAIN, NOTIMP, REFUSED
|
||||
DNSRCodePlaceholder = "[DNS_RCODE]"
|
||||
|
||||
// ResponseTimePlaceholder is a placeholder for the request response time, in milliseconds.
|
||||
@@ -48,101 +47,163 @@ const (
|
||||
CertificateExpirationPlaceholder = "[CERTIFICATE_EXPIRATION]"
|
||||
|
||||
// LengthFunctionPrefix is the prefix for the length function
|
||||
//
|
||||
// Usage: len([BODY].articles) == 10, len([BODY].name) > 5
|
||||
LengthFunctionPrefix = "len("
|
||||
|
||||
// HasFunctionPrefix is the prefix for the has function
|
||||
//
|
||||
// Usage: has([BODY].errors) == true
|
||||
HasFunctionPrefix = "has("
|
||||
|
||||
// PatternFunctionPrefix is the prefix for the pattern function
|
||||
//
|
||||
// Usage: [IP] == pat(192.168.*.*)
|
||||
PatternFunctionPrefix = "pat("
|
||||
|
||||
// AnyFunctionPrefix is the prefix for the any function
|
||||
//
|
||||
// Usage: [IP] == any(1.1.1.1, 1.0.0.1)
|
||||
AnyFunctionPrefix = "any("
|
||||
|
||||
// FunctionSuffix is the suffix for all functions
|
||||
FunctionSuffix = ")"
|
||||
|
||||
// InvalidConditionElementSuffix is the suffix that will be appended to an invalid condition
|
||||
InvalidConditionElementSuffix = "(INVALID)"
|
||||
|
||||
// maximumLengthBeforeTruncatingWhenComparedWithPattern is the maximum length an element being compared to a
|
||||
// pattern can have.
|
||||
//
|
||||
// This is only used for aesthetic purposes; it does not influence whether the condition evaluation results in a
|
||||
// success or a failure
|
||||
maximumLengthBeforeTruncatingWhenComparedWithPattern = 25
|
||||
)
|
||||
|
||||
// Condition is a condition that needs to be met in order for a Service to be considered healthy.
|
||||
type Condition string
|
||||
|
||||
// evaluate the Condition with the Result of the health check
|
||||
func (c *Condition) evaluate(result *Result) bool {
|
||||
condition := string(*c)
|
||||
// TODO: Add a mandatory space between each operators (e.g. " == " instead of "==") (BREAKING CHANGE)
|
||||
func (c Condition) evaluate(result *Result) bool {
|
||||
condition := string(c)
|
||||
success := false
|
||||
var resolvedCondition string
|
||||
conditionToDisplay := condition
|
||||
if strings.Contains(condition, "==") {
|
||||
parts := sanitizeAndResolve(strings.Split(condition, "=="), result)
|
||||
success = isEqual(parts[0], parts[1])
|
||||
resolvedCondition = fmt.Sprintf("%v == %v", parts[0], parts[1])
|
||||
parameters, resolvedParameters := sanitizeAndResolve(strings.Split(condition, "=="), result)
|
||||
success = isEqual(resolvedParameters[0], resolvedParameters[1])
|
||||
if !success {
|
||||
conditionToDisplay = prettify(parameters, resolvedParameters, "==")
|
||||
}
|
||||
} else if strings.Contains(condition, "!=") {
|
||||
parts := sanitizeAndResolve(strings.Split(condition, "!="), result)
|
||||
success = !isEqual(parts[0], parts[1])
|
||||
resolvedCondition = fmt.Sprintf("%v != %v", parts[0], parts[1])
|
||||
parameters, resolvedParameters := sanitizeAndResolve(strings.Split(condition, "!="), result)
|
||||
success = !isEqual(resolvedParameters[0], resolvedParameters[1])
|
||||
if !success {
|
||||
conditionToDisplay = prettify(parameters, resolvedParameters, "!=")
|
||||
}
|
||||
} else if strings.Contains(condition, "<=") {
|
||||
parts := sanitizeAndResolveNumerical(strings.Split(condition, "<="), result)
|
||||
success = parts[0] <= parts[1]
|
||||
resolvedCondition = fmt.Sprintf("%v <= %v", parts[0], parts[1])
|
||||
parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, "<="), result)
|
||||
success = resolvedParameters[0] <= resolvedParameters[1]
|
||||
if !success {
|
||||
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, "<=")
|
||||
}
|
||||
} else if strings.Contains(condition, ">=") {
|
||||
parts := sanitizeAndResolveNumerical(strings.Split(condition, ">="), result)
|
||||
success = parts[0] >= parts[1]
|
||||
resolvedCondition = fmt.Sprintf("%v >= %v", parts[0], parts[1])
|
||||
parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, ">="), result)
|
||||
success = resolvedParameters[0] >= resolvedParameters[1]
|
||||
if !success {
|
||||
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, ">=")
|
||||
}
|
||||
} else if strings.Contains(condition, ">") {
|
||||
parts := sanitizeAndResolveNumerical(strings.Split(condition, ">"), result)
|
||||
success = parts[0] > parts[1]
|
||||
resolvedCondition = fmt.Sprintf("%v > %v", parts[0], parts[1])
|
||||
parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, ">"), result)
|
||||
success = resolvedParameters[0] > resolvedParameters[1]
|
||||
if !success {
|
||||
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, ">")
|
||||
}
|
||||
} else if strings.Contains(condition, "<") {
|
||||
parts := sanitizeAndResolveNumerical(strings.Split(condition, "<"), result)
|
||||
success = parts[0] < parts[1]
|
||||
resolvedCondition = fmt.Sprintf("%v < %v", parts[0], parts[1])
|
||||
parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, "<"), result)
|
||||
success = resolvedParameters[0] < resolvedParameters[1]
|
||||
if !success {
|
||||
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
|
||||
}
|
||||
conditionToDisplay := condition
|
||||
// If the condition isn't a success, return what the resolved condition was too
|
||||
if !success {
|
||||
log.Printf("[Condition][evaluate] Condition '%s' did not succeed because '%s' is false", condition, resolvedCondition)
|
||||
// Check if the resolved condition was an invalid path
|
||||
isResolvedConditionInvalidPath := strings.ReplaceAll(resolvedCondition, fmt.Sprintf("%s ", InvalidConditionElementSuffix), "") == condition
|
||||
if isResolvedConditionInvalidPath {
|
||||
// Since, in the event of an invalid path, the resolvedCondition contains the condition itself,
|
||||
// we'll only display the resolvedCondition
|
||||
conditionToDisplay = resolvedCondition
|
||||
} else {
|
||||
conditionToDisplay = fmt.Sprintf("%s (%s)", condition, resolvedCondition)
|
||||
}
|
||||
//log.Printf("[Condition][evaluate] Condition '%s' did not succeed because '%s' is false", condition, condition)
|
||||
}
|
||||
result.ConditionResults = append(result.ConditionResults, &ConditionResult{Condition: conditionToDisplay, Success: success})
|
||||
return success
|
||||
}
|
||||
|
||||
// isEqual compares two strings.
|
||||
//
|
||||
// It also supports the pattern function. That is to say, if one of the strings starts with PatternFunctionPrefix
|
||||
// and ends with FunctionSuffix, it will be treated like a pattern.
|
||||
func isEqual(first, second string) bool {
|
||||
var isFirstPattern, isSecondPattern bool
|
||||
if strings.HasPrefix(first, PatternFunctionPrefix) && strings.HasSuffix(first, FunctionSuffix) {
|
||||
isFirstPattern = true
|
||||
first = strings.TrimSuffix(strings.TrimPrefix(first, PatternFunctionPrefix), FunctionSuffix)
|
||||
}
|
||||
if strings.HasPrefix(second, PatternFunctionPrefix) && strings.HasSuffix(second, FunctionSuffix) {
|
||||
isSecondPattern = true
|
||||
second = strings.TrimSuffix(strings.TrimPrefix(second, PatternFunctionPrefix), FunctionSuffix)
|
||||
}
|
||||
if isFirstPattern && !isSecondPattern {
|
||||
return pattern.Match(first, second)
|
||||
} else if !isFirstPattern && isSecondPattern {
|
||||
return pattern.Match(second, first)
|
||||
} else {
|
||||
return first == second
|
||||
}
|
||||
// hasBodyPlaceholder checks whether the condition has a BodyPlaceholder
|
||||
// Used for determining whether the response body should be read or not
|
||||
func (c Condition) hasBodyPlaceholder() bool {
|
||||
return strings.Contains(string(c), BodyPlaceholder)
|
||||
}
|
||||
|
||||
// sanitizeAndResolve sanitizes and resolves a list of element and returns the list of resolved elements
|
||||
func sanitizeAndResolve(list []string, result *Result) []string {
|
||||
var sanitizedList []string
|
||||
body := strings.TrimSpace(string(result.Body))
|
||||
for _, element := range list {
|
||||
// isEqual compares two strings.
|
||||
//
|
||||
// Supports the pattern and the any functions.
|
||||
// i.e. if one of the parameters starts with PatternFunctionPrefix and ends with FunctionSuffix, it will be treated like
|
||||
// a pattern.
|
||||
func isEqual(first, second string) bool {
|
||||
firstHasFunctionSuffix := strings.HasSuffix(first, FunctionSuffix)
|
||||
secondHasFunctionSuffix := strings.HasSuffix(second, FunctionSuffix)
|
||||
if firstHasFunctionSuffix || secondHasFunctionSuffix {
|
||||
var isFirstPattern, isSecondPattern bool
|
||||
if strings.HasPrefix(first, PatternFunctionPrefix) && firstHasFunctionSuffix {
|
||||
isFirstPattern = true
|
||||
first = strings.TrimSuffix(strings.TrimPrefix(first, PatternFunctionPrefix), FunctionSuffix)
|
||||
}
|
||||
if strings.HasPrefix(second, PatternFunctionPrefix) && secondHasFunctionSuffix {
|
||||
isSecondPattern = true
|
||||
second = strings.TrimSuffix(strings.TrimPrefix(second, PatternFunctionPrefix), FunctionSuffix)
|
||||
}
|
||||
if isFirstPattern && !isSecondPattern {
|
||||
return pattern.Match(first, second)
|
||||
} else if !isFirstPattern && isSecondPattern {
|
||||
return pattern.Match(second, first)
|
||||
}
|
||||
var isFirstAny, isSecondAny bool
|
||||
if strings.HasPrefix(first, AnyFunctionPrefix) && firstHasFunctionSuffix {
|
||||
isFirstAny = true
|
||||
first = strings.TrimSuffix(strings.TrimPrefix(first, AnyFunctionPrefix), FunctionSuffix)
|
||||
}
|
||||
if strings.HasPrefix(second, AnyFunctionPrefix) && secondHasFunctionSuffix {
|
||||
isSecondAny = true
|
||||
second = strings.TrimSuffix(strings.TrimPrefix(second, AnyFunctionPrefix), FunctionSuffix)
|
||||
}
|
||||
if isFirstAny && !isSecondAny {
|
||||
options := strings.Split(first, ",")
|
||||
for _, option := range options {
|
||||
if strings.TrimSpace(option) == second {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
} else if !isFirstAny && isSecondAny {
|
||||
options := strings.Split(second, ",")
|
||||
for _, option := range options {
|
||||
if strings.TrimSpace(option) == first {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
return first == second
|
||||
}
|
||||
|
||||
// sanitizeAndResolve sanitizes and resolves a list of elements and returns the list of parameters as well as a list
|
||||
// of resolved parameters
|
||||
func sanitizeAndResolve(elements []string, result *Result) ([]string, []string) {
|
||||
parameters := make([]string, len(elements))
|
||||
resolvedParameters := make([]string, len(elements))
|
||||
body := strings.TrimSpace(string(result.body))
|
||||
for i, element := range elements {
|
||||
element = strings.TrimSpace(element)
|
||||
parameters[i] = element
|
||||
switch strings.ToUpper(element) {
|
||||
case StatusPlaceholder:
|
||||
element = strconv.Itoa(result.HTTPStatus)
|
||||
@@ -161,47 +222,93 @@ func sanitizeAndResolve(list []string, result *Result) []string {
|
||||
default:
|
||||
// if contains the BodyPlaceholder, then evaluate json path
|
||||
if strings.Contains(element, BodyPlaceholder) {
|
||||
wantLength := false
|
||||
checkingForLength := false
|
||||
checkingForExistence := false
|
||||
if strings.HasPrefix(element, LengthFunctionPrefix) && strings.HasSuffix(element, FunctionSuffix) {
|
||||
wantLength = true
|
||||
checkingForLength = true
|
||||
element = strings.TrimSuffix(strings.TrimPrefix(element, LengthFunctionPrefix), FunctionSuffix)
|
||||
}
|
||||
resolvedElement, resolvedElementLength, err := jsonpath.Eval(strings.Replace(element, fmt.Sprintf("%s.", BodyPlaceholder), "", 1), result.Body)
|
||||
if err != nil {
|
||||
if err.Error() != "unexpected end of JSON input" {
|
||||
result.Errors = append(result.Errors, err.Error())
|
||||
}
|
||||
if wantLength {
|
||||
element = fmt.Sprintf("len(%s) %s", element, InvalidConditionElementSuffix)
|
||||
if strings.HasPrefix(element, HasFunctionPrefix) && strings.HasSuffix(element, FunctionSuffix) {
|
||||
checkingForExistence = true
|
||||
element = strings.TrimSuffix(strings.TrimPrefix(element, HasFunctionPrefix), FunctionSuffix)
|
||||
}
|
||||
resolvedElement, resolvedElementLength, err := jsonpath.Eval(strings.TrimPrefix(strings.TrimPrefix(element, BodyPlaceholder), "."), result.body)
|
||||
if checkingForExistence {
|
||||
if err != nil {
|
||||
element = "false"
|
||||
} else {
|
||||
element = fmt.Sprintf("%s %s", element, InvalidConditionElementSuffix)
|
||||
element = "true"
|
||||
}
|
||||
} else {
|
||||
if wantLength {
|
||||
element = fmt.Sprintf("%d", resolvedElementLength)
|
||||
if err != nil {
|
||||
if err.Error() != "unexpected end of JSON input" {
|
||||
result.AddError(err.Error())
|
||||
}
|
||||
if checkingForLength {
|
||||
element = LengthFunctionPrefix + element + FunctionSuffix + " " + InvalidConditionElementSuffix
|
||||
} else {
|
||||
element = element + " " + InvalidConditionElementSuffix
|
||||
}
|
||||
} else {
|
||||
element = resolvedElement
|
||||
if checkingForLength {
|
||||
element = strconv.Itoa(resolvedElementLength)
|
||||
} else {
|
||||
element = resolvedElement
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sanitizedList = append(sanitizedList, element)
|
||||
resolvedParameters[i] = element
|
||||
}
|
||||
return sanitizedList
|
||||
return parameters, resolvedParameters
|
||||
}
|
||||
|
||||
func sanitizeAndResolveNumerical(list []string, result *Result) []int64 {
|
||||
var sanitizedNumbers []int64
|
||||
sanitizedList := sanitizeAndResolve(list, result)
|
||||
for _, element := range sanitizedList {
|
||||
func sanitizeAndResolveNumerical(list []string, result *Result) (parameters []string, resolvedNumericalParameters []int64) {
|
||||
parameters, resolvedParameters := sanitizeAndResolve(list, result)
|
||||
for _, element := range resolvedParameters {
|
||||
if duration, err := time.ParseDuration(element); duration != 0 && err == nil {
|
||||
sanitizedNumbers = append(sanitizedNumbers, duration.Milliseconds())
|
||||
resolvedNumericalParameters = append(resolvedNumericalParameters, duration.Milliseconds())
|
||||
} else if number, err := strconv.ParseInt(element, 10, 64); err != nil {
|
||||
// Default to 0 if the string couldn't be converted to an integer
|
||||
sanitizedNumbers = append(sanitizedNumbers, 0)
|
||||
resolvedNumericalParameters = append(resolvedNumericalParameters, 0)
|
||||
} else {
|
||||
sanitizedNumbers = append(sanitizedNumbers, number)
|
||||
resolvedNumericalParameters = append(resolvedNumericalParameters, number)
|
||||
}
|
||||
}
|
||||
return sanitizedNumbers
|
||||
return parameters, resolvedNumericalParameters
|
||||
}
|
||||
|
||||
func prettifyNumericalParameters(parameters []string, resolvedParameters []int64, operator string) string {
|
||||
return prettify(parameters, []string{strconv.Itoa(int(resolvedParameters[0])), strconv.Itoa(int(resolvedParameters[1]))}, operator)
|
||||
}
|
||||
|
||||
// XXX: make this configurable? i.e. show-resolved-conditions-on-failure
|
||||
func prettify(parameters []string, resolvedParameters []string, operator string) string {
|
||||
// Since, in the event of an invalid path, the resolvedParameters also contain the condition itself,
|
||||
// we'll return the resolvedParameters as-is.
|
||||
if strings.HasSuffix(resolvedParameters[0], InvalidConditionElementSuffix) || strings.HasSuffix(resolvedParameters[1], InvalidConditionElementSuffix) {
|
||||
return resolvedParameters[0] + " " + operator + " " + resolvedParameters[1]
|
||||
}
|
||||
// If using the pattern function, truncate the parameter it's being compared to if said parameter is long enough
|
||||
if strings.HasPrefix(parameters[0], PatternFunctionPrefix) && strings.HasSuffix(parameters[0], FunctionSuffix) && len(resolvedParameters[1]) > maximumLengthBeforeTruncatingWhenComparedWithPattern {
|
||||
resolvedParameters[1] = fmt.Sprintf("%.25s...(truncated)", resolvedParameters[1])
|
||||
}
|
||||
if strings.HasPrefix(parameters[1], PatternFunctionPrefix) && strings.HasSuffix(parameters[1], FunctionSuffix) && len(resolvedParameters[0]) > maximumLengthBeforeTruncatingWhenComparedWithPattern {
|
||||
resolvedParameters[0] = fmt.Sprintf("%.25s...(truncated)", resolvedParameters[0])
|
||||
}
|
||||
// First element is a placeholder
|
||||
if parameters[0] != resolvedParameters[0] && parameters[1] == resolvedParameters[1] {
|
||||
return parameters[0] + " (" + resolvedParameters[0] + ") " + operator + " " + parameters[1]
|
||||
}
|
||||
// Second element is a placeholder
|
||||
if parameters[0] == resolvedParameters[0] && parameters[1] != resolvedParameters[1] {
|
||||
return parameters[0] + " " + operator + " " + parameters[1] + " (" + resolvedParameters[1] + ")"
|
||||
}
|
||||
// Both elements are placeholders...?
|
||||
if parameters[0] != resolvedParameters[0] && parameters[1] != resolvedParameters[1] {
|
||||
return parameters[0] + " (" + resolvedParameters[0] + ") " + operator + " " + parameters[1] + " (" + resolvedParameters[1] + ")"
|
||||
}
|
||||
// Neither elements are placeholders
|
||||
return parameters[0] + " " + operator + " " + parameters[1]
|
||||
}
|
||||
|
||||
84
core/condition_bench_test.go
Normal file
84
core/condition_bench_test.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package core
|
||||
|
||||
import "testing"
|
||||
|
||||
func BenchmarkCondition_evaluateWithBodyStringAny(b *testing.B) {
|
||||
condition := Condition("[BODY].name == any(john.doe, jane.doe)")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{body: []byte("{\"name\": \"john.doe\"}")}
|
||||
condition.evaluate(result)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
|
||||
func BenchmarkCondition_evaluateWithBodyStringAnyFailure(b *testing.B) {
|
||||
condition := Condition("[BODY].name == any(john.doe, jane.doe)")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{body: []byte("{\"name\": \"bob.doe\"}")}
|
||||
condition.evaluate(result)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
|
||||
func BenchmarkCondition_evaluateWithBodyString(b *testing.B) {
|
||||
condition := Condition("[BODY].name == john.doe")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{body: []byte("{\"name\": \"john.doe\"}")}
|
||||
condition.evaluate(result)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
|
||||
func BenchmarkCondition_evaluateWithBodyStringFailure(b *testing.B) {
|
||||
condition := Condition("[BODY].name == john.doe")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{body: []byte("{\"name\": \"bob.doe\"}")}
|
||||
condition.evaluate(result)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
|
||||
func BenchmarkCondition_evaluateWithBodyStringFailureInvalidPath(b *testing.B) {
|
||||
condition := Condition("[BODY].user.name == bob.doe")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{body: []byte("{\"name\": \"bob.doe\"}")}
|
||||
condition.evaluate(result)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
|
||||
func BenchmarkCondition_evaluateWithBodyStringLen(b *testing.B) {
|
||||
condition := Condition("len([BODY].name) == 8")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{body: []byte("{\"name\": \"john.doe\"}")}
|
||||
condition.evaluate(result)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
|
||||
func BenchmarkCondition_evaluateWithBodyStringLenFailure(b *testing.B) {
|
||||
condition := Condition("len([BODY].name) == 8")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{body: []byte("{\"name\": \"bob.doe\"}")}
|
||||
condition.evaluate(result)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
|
||||
func BenchmarkCondition_evaluateWithStatus(b *testing.B) {
|
||||
condition := Condition("[STATUS] == 200")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{HTTPStatus: 200}
|
||||
condition.evaluate(result)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
|
||||
func BenchmarkCondition_evaluateWithStatusFailure(b *testing.B) {
|
||||
condition := Condition("[STATUS] == 200")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{HTTPStatus: 400}
|
||||
condition.evaluate(result)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
@@ -6,383 +6,464 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCondition_evaluateWithIP(t *testing.T) {
|
||||
condition := Condition("[IP] == 127.0.0.1")
|
||||
result := &Result{IP: "127.0.0.1"}
|
||||
condition.evaluate(result)
|
||||
if !result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a success", condition)
|
||||
func TestCondition_evaluate(t *testing.T) {
|
||||
type scenario struct {
|
||||
Name string
|
||||
Condition Condition
|
||||
Result *Result
|
||||
ExpectedSuccess bool
|
||||
ExpectedOutput string
|
||||
}
|
||||
scenarios := []scenario{
|
||||
{
|
||||
Name: "ip",
|
||||
Condition: Condition("[IP] == 127.0.0.1"),
|
||||
Result: &Result{IP: "127.0.0.1"},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[IP] == 127.0.0.1",
|
||||
},
|
||||
{
|
||||
Name: "status",
|
||||
Condition: Condition("[STATUS] == 200"),
|
||||
Result: &Result{HTTPStatus: 200},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[STATUS] == 200",
|
||||
},
|
||||
{
|
||||
Name: "status-failure",
|
||||
Condition: Condition("[STATUS] == 200"),
|
||||
Result: &Result{HTTPStatus: 500},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[STATUS] (500) == 200",
|
||||
},
|
||||
{
|
||||
Name: "status-using-less-than",
|
||||
Condition: Condition("[STATUS] < 300"),
|
||||
Result: &Result{HTTPStatus: 201},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[STATUS] < 300",
|
||||
},
|
||||
{
|
||||
Name: "status-using-less-than-failure",
|
||||
Condition: Condition("[STATUS] < 300"),
|
||||
Result: &Result{HTTPStatus: 404},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[STATUS] (404) < 300",
|
||||
},
|
||||
{
|
||||
Name: "response-time-using-less-than",
|
||||
Condition: Condition("[RESPONSE_TIME] < 500"),
|
||||
Result: &Result{Duration: 50 * time.Millisecond},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[RESPONSE_TIME] < 500",
|
||||
},
|
||||
{
|
||||
Name: "response-time-using-less-than-with-duration",
|
||||
Condition: Condition("[RESPONSE_TIME] < 1s"),
|
||||
Result: &Result{Duration: 50 * time.Millisecond},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[RESPONSE_TIME] < 1s",
|
||||
},
|
||||
{
|
||||
Name: "response-time-using-less-than-invalid",
|
||||
Condition: Condition("[RESPONSE_TIME] < potato"),
|
||||
Result: &Result{Duration: 50 * time.Millisecond},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[RESPONSE_TIME] (50) < potato (0)", // Non-numerical values automatically resolve to 0
|
||||
},
|
||||
{
|
||||
Name: "response-time-using-greater-than",
|
||||
Condition: Condition("[RESPONSE_TIME] > 500"),
|
||||
Result: &Result{Duration: 750 * time.Millisecond},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[RESPONSE_TIME] > 500",
|
||||
},
|
||||
{
|
||||
Name: "response-time-using-greater-than-with-duration",
|
||||
Condition: Condition("[RESPONSE_TIME] > 1s"),
|
||||
Result: &Result{Duration: 2 * time.Second},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[RESPONSE_TIME] > 1s",
|
||||
},
|
||||
{
|
||||
Name: "response-time-using-greater-than-or-equal-to-equal",
|
||||
Condition: Condition("[RESPONSE_TIME] >= 500"),
|
||||
Result: &Result{Duration: 500 * time.Millisecond},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[RESPONSE_TIME] >= 500",
|
||||
},
|
||||
{
|
||||
Name: "response-time-using-greater-than-or-equal-to-greater",
|
||||
Condition: Condition("[RESPONSE_TIME] >= 500"),
|
||||
Result: &Result{Duration: 499 * time.Millisecond},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[RESPONSE_TIME] (499) >= 500",
|
||||
},
|
||||
{
|
||||
Name: "response-time-using-greater-than-or-equal-to-failure",
|
||||
Condition: Condition("[RESPONSE_TIME] >= 500"),
|
||||
Result: &Result{Duration: 499 * time.Millisecond},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[RESPONSE_TIME] (499) >= 500",
|
||||
},
|
||||
{
|
||||
Name: "response-time-using-less-than-or-equal-to-equal",
|
||||
Condition: Condition("[RESPONSE_TIME] <= 500"),
|
||||
Result: &Result{Duration: 500 * time.Millisecond},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[RESPONSE_TIME] <= 500",
|
||||
},
|
||||
{
|
||||
Name: "response-time-using-less-than-or-equal-to-less",
|
||||
Condition: Condition("[RESPONSE_TIME] <= 500"),
|
||||
Result: &Result{Duration: 25 * time.Millisecond},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[RESPONSE_TIME] <= 500",
|
||||
},
|
||||
{
|
||||
Name: "response-time-using-less-than-or-equal-to-failure",
|
||||
Condition: Condition("[RESPONSE_TIME] <= 500"),
|
||||
Result: &Result{Duration: 750 * time.Millisecond},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[RESPONSE_TIME] (750) <= 500",
|
||||
},
|
||||
{
|
||||
Name: "body",
|
||||
Condition: Condition("[BODY] == test"),
|
||||
Result: &Result{body: []byte("test")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY] == test",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath",
|
||||
Condition: Condition("[BODY].status == UP"),
|
||||
Result: &Result{body: []byte("{\"status\":\"UP\"}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY].status == UP",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-complex",
|
||||
Condition: Condition("[BODY].data.name == john"),
|
||||
Result: &Result{body: []byte("{\"data\": {\"id\": 1, \"name\": \"john\"}}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY].data.name == john",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-complex-invalid",
|
||||
Condition: Condition("[BODY].data.name == john"),
|
||||
Result: &Result{body: []byte("{\"data\": {\"id\": 1}}")},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[BODY].data.name (INVALID) == john",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-complex-len",
|
||||
Condition: Condition("len([BODY].data.name) == 4"),
|
||||
Result: &Result{body: []byte("{\"data\": {\"name\": \"john\"}}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "len([BODY].data.name) == 4",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-complex-len-invalid",
|
||||
Condition: Condition("len([BODY].data.name) == john"),
|
||||
Result: &Result{body: []byte("{\"data\": {\"id\": 1}}")},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "len([BODY].data.name) (INVALID) == john",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-double-placeholder",
|
||||
Condition: Condition("[BODY].user.firstName != [BODY].user.lastName"),
|
||||
Result: &Result{body: []byte("{\"user\": {\"firstName\": \"john\", \"lastName\": \"doe\"}}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY].user.firstName != [BODY].user.lastName",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-double-placeholder-failure",
|
||||
Condition: Condition("[BODY].user.firstName == [BODY].user.lastName"),
|
||||
Result: &Result{body: []byte("{\"user\": {\"firstName\": \"john\", \"lastName\": \"doe\"}}")},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[BODY].user.firstName (john) == [BODY].user.lastName (doe)",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-when-body-is-array",
|
||||
Condition: Condition("[BODY][0].id == 1"),
|
||||
Result: &Result{body: []byte("[{\"id\": 1}, {\"id\": 2}]")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY][0].id == 1",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-complex-int",
|
||||
Condition: Condition("[BODY].data.id == 1"),
|
||||
Result: &Result{body: []byte("{\"data\": {\"id\": 1}}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY].data.id == 1",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-complex-array-int",
|
||||
Condition: Condition("[BODY].data[1].id == 2"),
|
||||
Result: &Result{body: []byte("{\"data\": [{\"id\": 1}, {\"id\": 2}, {\"id\": 3}]}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY].data[1].id == 2",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-complex-int-using-greater-than",
|
||||
Condition: Condition("[BODY].data.id > 0"),
|
||||
Result: &Result{body: []byte("{\"data\": {\"id\": 1}}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY].data.id > 0",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-complex-int-using-greater-than-failure",
|
||||
Condition: Condition("[BODY].data.id > 5"),
|
||||
Result: &Result{body: []byte("{\"data\": {\"id\": 1}}")},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[BODY].data.id (1) > 5",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-complex-int-using-less-than",
|
||||
Condition: Condition("[BODY].data.id < 5"),
|
||||
Result: &Result{body: []byte("{\"data\": {\"id\": 2}}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY].data.id < 5",
|
||||
},
|
||||
{
|
||||
Name: "body-jsonpath-complex-int-using-less-than-failure",
|
||||
Condition: Condition("[BODY].data.id < 5"),
|
||||
Result: &Result{body: []byte("{\"data\": {\"id\": 10}}")},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[BODY].data.id (10) < 5",
|
||||
},
|
||||
{
|
||||
Name: "body-len-array",
|
||||
Condition: Condition("len([BODY].data) == 3"),
|
||||
Result: &Result{body: []byte("{\"data\": [{\"id\": 1}, {\"id\": 2}, {\"id\": 3}]}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "len([BODY].data) == 3",
|
||||
},
|
||||
{
|
||||
Name: "body-len-array-invalid",
|
||||
Condition: Condition("len([BODY].data) == 8"),
|
||||
Result: &Result{body: []byte("{\"name\": \"john.doe\"}")},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "len([BODY].data) (INVALID) == 8",
|
||||
},
|
||||
{
|
||||
Name: "body-len-string",
|
||||
Condition: Condition("len([BODY].name) == 8"),
|
||||
Result: &Result{body: []byte("{\"name\": \"john.doe\"}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "len([BODY].name) == 8",
|
||||
},
|
||||
{
|
||||
Name: "body-pattern",
|
||||
Condition: Condition("[BODY] == pat(*john*)"),
|
||||
Result: &Result{body: []byte("{\"name\": \"john.doe\"}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY] == pat(*john*)",
|
||||
},
|
||||
{
|
||||
Name: "body-pattern-2",
|
||||
Condition: Condition("[BODY].name == pat(john*)"),
|
||||
Result: &Result{body: []byte("{\"name\": \"john.doe\"}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY].name == pat(john*)",
|
||||
},
|
||||
{
|
||||
Name: "body-pattern-failure",
|
||||
Condition: Condition("[BODY].name == pat(bob*)"),
|
||||
Result: &Result{body: []byte("{\"name\": \"john.doe\"}")},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[BODY].name (john.doe) == pat(bob*)",
|
||||
},
|
||||
{
|
||||
Name: "body-pattern-html",
|
||||
Condition: Condition("[BODY] == pat(*<div id=\"user\">john.doe</div>*)"),
|
||||
Result: &Result{body: []byte(`<!DOCTYPE html><html lang="en"><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /></head><body><div id="user">john.doe</div></body></html>`)},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY] == pat(*<div id=\"user\">john.doe</div>*)",
|
||||
},
|
||||
{
|
||||
Name: "body-pattern-html-failure",
|
||||
Condition: Condition("[BODY] == pat(*<div id=\"user\">john.doe</div>*)"),
|
||||
Result: &Result{body: []byte(`<!DOCTYPE html><html lang="en"><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /></head><body><div id="user">jane.doe</div></body></html>`)},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[BODY] (<!DOCTYPE html><html lang...(truncated)) == pat(*<div id=\"user\">john.doe</div>*)",
|
||||
},
|
||||
{
|
||||
Name: "body-pattern-html-failure-alt",
|
||||
Condition: Condition("pat(*<div id=\"user\">john.doe</div>*) == [BODY]"),
|
||||
Result: &Result{body: []byte(`<!DOCTYPE html><html lang="en"><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /></head><body><div id="user">jane.doe</div></body></html>`)},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "pat(*<div id=\"user\">john.doe</div>*) == [BODY] (<!DOCTYPE html><html lang...(truncated))",
|
||||
},
|
||||
{
|
||||
Name: "ip-pattern",
|
||||
Condition: Condition("[IP] == pat(10.*)"),
|
||||
Result: &Result{IP: "10.0.0.0"},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[IP] == pat(10.*)",
|
||||
},
|
||||
{
|
||||
Name: "ip-pattern-failure",
|
||||
Condition: Condition("[IP] == pat(10.*)"),
|
||||
Result: &Result{IP: "255.255.255.255"},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[IP] (255.255.255.255) == pat(10.*)",
|
||||
},
|
||||
{
|
||||
Name: "status-pattern",
|
||||
Condition: Condition("[STATUS] == pat(4*)"),
|
||||
Result: &Result{HTTPStatus: 404},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[STATUS] == pat(4*)",
|
||||
},
|
||||
{
|
||||
Name: "status-pattern-failure",
|
||||
Condition: Condition("[STATUS] == pat(4*)"),
|
||||
Result: &Result{HTTPStatus: 200},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[STATUS] (200) == pat(4*)",
|
||||
},
|
||||
{
|
||||
Name: "body-any",
|
||||
Condition: Condition("[BODY].name == any(john.doe, jane.doe)"),
|
||||
Result: &Result{body: []byte("{\"name\": \"john.doe\"}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY].name == any(john.doe, jane.doe)",
|
||||
},
|
||||
{
|
||||
Name: "body-any-2",
|
||||
Condition: Condition("[BODY].name == any(john.doe, jane.doe)"),
|
||||
Result: &Result{body: []byte("{\"name\": \"jane.doe\"}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[BODY].name == any(john.doe, jane.doe)",
|
||||
},
|
||||
{
|
||||
Name: "body-any-failure",
|
||||
Condition: Condition("[BODY].name == any(john.doe, jane.doe)"),
|
||||
Result: &Result{body: []byte("{\"name\": \"bob\"}")},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[BODY].name (bob) == any(john.doe, jane.doe)",
|
||||
},
|
||||
{
|
||||
Name: "status-any",
|
||||
Condition: Condition("[STATUS] == any(200, 429)"),
|
||||
Result: &Result{HTTPStatus: 200},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[STATUS] == any(200, 429)",
|
||||
},
|
||||
{
|
||||
Name: "status-any-2",
|
||||
Condition: Condition("[STATUS] == any(200, 429)"),
|
||||
Result: &Result{HTTPStatus: 429},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[STATUS] == any(200, 429)",
|
||||
},
|
||||
{
|
||||
Name: "status-any-reverse",
|
||||
Condition: Condition("any(200, 429) == [STATUS]"),
|
||||
Result: &Result{HTTPStatus: 429},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "any(200, 429) == [STATUS]",
|
||||
},
|
||||
{
|
||||
Name: "status-any-failure",
|
||||
Condition: Condition("[STATUS] == any(200, 429)"),
|
||||
Result: &Result{HTTPStatus: 404},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[STATUS] (404) == any(200, 429)",
|
||||
},
|
||||
{
|
||||
Name: "connected",
|
||||
Condition: Condition("[CONNECTED] == true"),
|
||||
Result: &Result{Connected: true},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[CONNECTED] == true",
|
||||
},
|
||||
{
|
||||
Name: "connected-failure",
|
||||
Condition: Condition("[CONNECTED] == true"),
|
||||
Result: &Result{Connected: false},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[CONNECTED] (false) == true",
|
||||
},
|
||||
{
|
||||
Name: "certificate-expiration-not-set",
|
||||
Condition: Condition("[CERTIFICATE_EXPIRATION] == 0"),
|
||||
Result: &Result{},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[CERTIFICATE_EXPIRATION] == 0",
|
||||
},
|
||||
{
|
||||
Name: "certificate-expiration-greater-than-numerical",
|
||||
Condition: Condition("[CERTIFICATE_EXPIRATION] > " + strconv.FormatInt((time.Hour*24*28).Milliseconds(), 10)),
|
||||
Result: &Result{CertificateExpiration: time.Hour * 24 * 60},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[CERTIFICATE_EXPIRATION] > 2419200000",
|
||||
},
|
||||
{
|
||||
Name: "certificate-expiration-greater-than-numerical-failure",
|
||||
Condition: Condition("[CERTIFICATE_EXPIRATION] > " + strconv.FormatInt((time.Hour*24*28).Milliseconds(), 10)),
|
||||
Result: &Result{CertificateExpiration: time.Hour * 24 * 14},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[CERTIFICATE_EXPIRATION] (1209600000) > 2419200000",
|
||||
},
|
||||
{
|
||||
Name: "certificate-expiration-greater-than-duration",
|
||||
Condition: Condition("[CERTIFICATE_EXPIRATION] > 12h"),
|
||||
Result: &Result{CertificateExpiration: 24 * time.Hour},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "[CERTIFICATE_EXPIRATION] > 12h",
|
||||
},
|
||||
{
|
||||
Name: "certificate-expiration-greater-than-duration",
|
||||
Condition: Condition("[CERTIFICATE_EXPIRATION] > 48h"),
|
||||
Result: &Result{CertificateExpiration: 24 * time.Hour},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[CERTIFICATE_EXPIRATION] (86400000) > 48h (172800000)",
|
||||
},
|
||||
{
|
||||
Name: "has",
|
||||
Condition: Condition("has([BODY].errors) == false"),
|
||||
Result: &Result{body: []byte("{}")},
|
||||
ExpectedSuccess: true,
|
||||
ExpectedOutput: "has([BODY].errors) == false",
|
||||
},
|
||||
{
|
||||
Name: "has-failure",
|
||||
Condition: Condition("has([BODY].errors) == false"),
|
||||
Result: &Result{body: []byte("{\"errors\": [\"1\"]}")},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "has([BODY].errors) (true) == false",
|
||||
},
|
||||
{
|
||||
Name: "no-placeholders",
|
||||
Condition: Condition("1 == 2"),
|
||||
Result: &Result{},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "1 == 2",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
scenario.Condition.evaluate(scenario.Result)
|
||||
if scenario.Result.ConditionResults[0].Success != scenario.ExpectedSuccess {
|
||||
t.Errorf("Condition '%s' should have been success=%v", scenario.Condition, scenario.ExpectedSuccess)
|
||||
}
|
||||
if scenario.Result.ConditionResults[0].Condition != scenario.ExpectedOutput {
|
||||
t.Errorf("Condition '%s' should have resolved to '%s', got '%s'", scenario.Condition, scenario.ExpectedOutput, scenario.Result.ConditionResults[0].Condition)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithStatus(t *testing.T) {
|
||||
condition := Condition("[STATUS] == 201")
|
||||
func TestCondition_evaluateWithInvalidOperator(t *testing.T) {
|
||||
condition := Condition("[STATUS] ? 201")
|
||||
result := &Result{HTTPStatus: 201}
|
||||
condition.evaluate(result)
|
||||
if !result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a success", condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithStatusFailure(t *testing.T) {
|
||||
condition := Condition("[STATUS] == 200")
|
||||
result := &Result{HTTPStatus: 500}
|
||||
condition.evaluate(result)
|
||||
if result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a failure", condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithStatusUsingLessThan(t *testing.T) {
|
||||
condition := Condition("[STATUS] < 300")
|
||||
result := &Result{HTTPStatus: 201}
|
||||
condition.evaluate(result)
|
||||
if !result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a success", condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithStatusFailureUsingLessThan(t *testing.T) {
|
||||
condition := Condition("[STATUS] < 300")
|
||||
result := &Result{HTTPStatus: 404}
|
||||
condition.evaluate(result)
|
||||
if result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a failure", condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithResponseTimeUsingLessThan(t *testing.T) {
|
||||
condition := Condition("[RESPONSE_TIME] < 500")
|
||||
result := &Result{Duration: time.Millisecond * 50}
|
||||
condition.evaluate(result)
|
||||
if !result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a success", condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithResponseTimeUsingLessThanDuration(t *testing.T) {
|
||||
condition := Condition("[RESPONSE_TIME] < 1s")
|
||||
result := &Result{Duration: time.Millisecond * 50}
|
||||
condition.evaluate(result)
|
||||
if !result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a success", condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithResponseTimeUsingLessThanInvalid(t *testing.T) {
|
||||
condition := Condition("[RESPONSE_TIME] < potato")
|
||||
result := &Result{Duration: time.Millisecond * 50}
|
||||
condition.evaluate(result)
|
||||
if result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have failed because the condition has an invalid numerical value that should've automatically resolved to 0", condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithResponseTimeUsingGreaterThan(t *testing.T) {
|
||||
// Not exactly sure why you'd want to have a condition that checks if the response time is too fast,
|
||||
// but hey, who am I to judge?
|
||||
condition := Condition("[RESPONSE_TIME] > 500")
|
||||
result := &Result{Duration: time.Millisecond * 750}
|
||||
condition.evaluate(result)
|
||||
if !result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a success", condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithResponseTimeUsingGreaterThanDuration(t *testing.T) {
|
||||
condition := Condition("[RESPONSE_TIME] > 1s")
|
||||
result := &Result{Duration: time.Second * 2}
|
||||
condition.evaluate(result)
|
||||
if !result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a success", condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithResponseTimeUsingGreaterThanOrEqualTo(t *testing.T) {
|
||||
condition := Condition("[RESPONSE_TIME] >= 500")
|
||||
result := &Result{Duration: time.Millisecond * 500}
|
||||
condition.evaluate(result)
|
||||
if !result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a success", condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithResponseTimeUsingLessThanOrEqualTo(t *testing.T) {
|
||||
condition := Condition("[RESPONSE_TIME] <= 500")
|
||||
result := &Result{Duration: time.Millisecond * 500}
|
||||
condition.evaluate(result)
|
||||
if !result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a success", condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithBody(t *testing.T) {
|
||||
condition := Condition("[BODY] == test")
|
||||
result := &Result{Body: []byte("test")}
|
||||
condition.evaluate(result)
|
||||
if !result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a success", condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithBodyJSONPath(t *testing.T) {
|
||||
condition := Condition("[BODY].status == UP")
|
||||
result := &Result{Body: []byte("{\"status\":\"UP\"}")}
|
||||
condition.evaluate(result)
|
||||
if !result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a success", condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithBodyJSONPathComplex(t *testing.T) {
|
||||
condition := Condition("[BODY].data.name == john")
|
||||
result := &Result{Body: []byte("{\"data\": {\"id\": 1, \"name\": \"john\"}}")}
|
||||
condition.evaluate(result)
|
||||
if !result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a success", condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithInvalidBodyJSONPathComplex(t *testing.T) {
|
||||
expectedResolvedCondition := "[BODY].data.name (INVALID) == john"
|
||||
condition := Condition("[BODY].data.name == john")
|
||||
result := &Result{Body: []byte("{\"data\": {\"id\": 1}}")}
|
||||
condition.evaluate(result)
|
||||
if result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a failure, because the path was invalid", condition)
|
||||
}
|
||||
if result.ConditionResults[0].Condition != expectedResolvedCondition {
|
||||
t.Errorf("Condition '%s' should have resolved to '%s', but resolved to '%s' instead", condition, expectedResolvedCondition, result.ConditionResults[0].Condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithInvalidBodyJSONPathComplexWithLengthFunction(t *testing.T) {
|
||||
expectedResolvedCondition := "len([BODY].data.name) (INVALID) == john"
|
||||
condition := Condition("len([BODY].data.name) == john")
|
||||
result := &Result{Body: []byte("{\"data\": {\"id\": 1}}")}
|
||||
condition.evaluate(result)
|
||||
if result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a failure, because the path was invalid", condition)
|
||||
}
|
||||
if result.ConditionResults[0].Condition != expectedResolvedCondition {
|
||||
t.Errorf("Condition '%s' should have resolved to '%s', but resolved to '%s' instead", condition, expectedResolvedCondition, result.ConditionResults[0].Condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithBodyJSONPathDoublePlaceholders(t *testing.T) {
|
||||
condition := Condition("[BODY].user.firstName != [BODY].user.lastName")
|
||||
result := &Result{Body: []byte("{\"user\": {\"firstName\": \"john\", \"lastName\": \"doe\"}}")}
|
||||
condition.evaluate(result)
|
||||
if !result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a success", condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithBodyJSONPathDoublePlaceholdersFailure(t *testing.T) {
|
||||
condition := Condition("[BODY].user.firstName == [BODY].user.lastName")
|
||||
result := &Result{Body: []byte("{\"user\": {\"firstName\": \"john\", \"lastName\": \"doe\"}}")}
|
||||
condition.evaluate(result)
|
||||
if result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a failure", condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithBodyJSONPathLongInt(t *testing.T) {
|
||||
condition := Condition("[BODY].data.id == 1")
|
||||
result := &Result{Body: []byte("{\"data\": {\"id\": 1}}")}
|
||||
condition.evaluate(result)
|
||||
if !result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a success", condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithBodyJSONPathComplexInt(t *testing.T) {
|
||||
condition := Condition("[BODY].data[1].id == 2")
|
||||
result := &Result{Body: []byte("{\"data\": [{\"id\": 1}, {\"id\": 2}, {\"id\": 3}]}")}
|
||||
condition.evaluate(result)
|
||||
if !result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a success", condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithBodyJSONPathComplexIntUsingGreaterThan(t *testing.T) {
|
||||
condition := Condition("[BODY].data.id > 0")
|
||||
result := &Result{Body: []byte("{\"data\": {\"id\": 1}}")}
|
||||
condition.evaluate(result)
|
||||
if !result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a success", condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithBodyJSONPathComplexIntFailureUsingGreaterThan(t *testing.T) {
|
||||
condition := Condition("[BODY].data.id > 5")
|
||||
result := &Result{Body: []byte("{\"data\": {\"id\": 1}}")}
|
||||
condition.evaluate(result)
|
||||
if result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a failure", condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithBodyJSONPathComplexIntUsingLessThan(t *testing.T) {
|
||||
condition := Condition("[BODY].data.id < 5")
|
||||
result := &Result{Body: []byte("{\"data\": {\"id\": 2}}")}
|
||||
condition.evaluate(result)
|
||||
if !result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a success", condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithBodyJSONPathComplexIntFailureUsingLessThan(t *testing.T) {
|
||||
condition := Condition("[BODY].data.id < 5")
|
||||
result := &Result{Body: []byte("{\"data\": {\"id\": 10}}")}
|
||||
condition.evaluate(result)
|
||||
if result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a failure", condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithBodySliceLength(t *testing.T) {
|
||||
condition := Condition("len([BODY].data) == 3")
|
||||
result := &Result{Body: []byte("{\"data\": [{\"id\": 1}, {\"id\": 2}, {\"id\": 3}]}")}
|
||||
condition.evaluate(result)
|
||||
if !result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a success", condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithBodyStringLength(t *testing.T) {
|
||||
condition := Condition("len([BODY].name) == 8")
|
||||
result := &Result{Body: []byte("{\"name\": \"john.doe\"}")}
|
||||
condition.evaluate(result)
|
||||
if !result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a success", condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithBodyStringPattern(t *testing.T) {
|
||||
condition := Condition("[BODY].name == pat(*ohn*)")
|
||||
result := &Result{Body: []byte("{\"name\": \"john.doe\"}")}
|
||||
condition.evaluate(result)
|
||||
if !result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a success", condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithBodyStringPatternFailure(t *testing.T) {
|
||||
condition := Condition("[BODY].name == pat(bob*)")
|
||||
result := &Result{Body: []byte("{\"name\": \"john.doe\"}")}
|
||||
condition.evaluate(result)
|
||||
if result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a failure", condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithBodyPatternFailure(t *testing.T) {
|
||||
condition := Condition("[BODY] == pat(*john*)")
|
||||
result := &Result{Body: []byte("{\"name\": \"john.doe\"}")}
|
||||
condition.evaluate(result)
|
||||
if !result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a success", condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithIPPattern(t *testing.T) {
|
||||
condition := Condition("[IP] == pat(10.*)")
|
||||
result := &Result{IP: "10.0.0.0"}
|
||||
condition.evaluate(result)
|
||||
if !result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a success", condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithIPPatternFailure(t *testing.T) {
|
||||
condition := Condition("[IP] == pat(10.*)")
|
||||
result := &Result{IP: "255.255.255.255"}
|
||||
condition.evaluate(result)
|
||||
if result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a failure", condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithStatusPattern(t *testing.T) {
|
||||
condition := Condition("[STATUS] == pat(4*)")
|
||||
result := &Result{HTTPStatus: 404}
|
||||
condition.evaluate(result)
|
||||
if !result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a success", condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithStatusPatternFailure(t *testing.T) {
|
||||
condition := Condition("[STATUS] != pat(4*)")
|
||||
result := &Result{HTTPStatus: 404}
|
||||
condition.evaluate(result)
|
||||
if result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a failure", condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithConnected(t *testing.T) {
|
||||
condition := Condition("[CONNECTED] == true")
|
||||
result := &Result{Connected: true}
|
||||
condition.evaluate(result)
|
||||
if !result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a success", condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithConnectedFailure(t *testing.T) {
|
||||
condition := Condition("[CONNECTED] == true")
|
||||
result := &Result{Connected: false}
|
||||
condition.evaluate(result)
|
||||
if result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a failure", condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithUnsetCertificateExpiration(t *testing.T) {
|
||||
condition := Condition("[CERTIFICATE_EXPIRATION] == 0")
|
||||
result := &Result{}
|
||||
condition.evaluate(result)
|
||||
if !result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a success", condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithCertificateExpirationGreaterThanNumerical(t *testing.T) {
|
||||
acceptable := (time.Hour * 24 * 28).Milliseconds()
|
||||
condition := Condition("[CERTIFICATE_EXPIRATION] > " + strconv.FormatInt(acceptable, 10))
|
||||
result := &Result{CertificateExpiration: time.Hour * 24 * 60}
|
||||
condition.evaluate(result)
|
||||
if !result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a success", condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithCertificateExpirationGreaterThanNumericalFailure(t *testing.T) {
|
||||
acceptable := (time.Hour * 24 * 28).Milliseconds()
|
||||
condition := Condition("[CERTIFICATE_EXPIRATION] > " + strconv.FormatInt(acceptable, 10))
|
||||
result := &Result{CertificateExpiration: time.Hour * 24 * 14}
|
||||
condition.evaluate(result)
|
||||
if result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a failure", condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithCertificateExpirationGreaterThanDuration(t *testing.T) {
|
||||
condition := Condition("[CERTIFICATE_EXPIRATION] > 12h")
|
||||
result := &Result{CertificateExpiration: 24 * time.Hour}
|
||||
condition.evaluate(result)
|
||||
if !result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a success", condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCondition_evaluateWithCertificateExpirationGreaterThanDurationFailure(t *testing.T) {
|
||||
condition := Condition("[CERTIFICATE_EXPIRATION] > 48h")
|
||||
result := &Result{CertificateExpiration: 24 * time.Hour}
|
||||
condition.evaluate(result)
|
||||
if result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a failure", condition)
|
||||
if result.Success {
|
||||
t.Error("condition was invalid, result should've been a failure")
|
||||
}
|
||||
if len(result.Errors) != 1 {
|
||||
t.Error("condition was invalid, result should've had an error")
|
||||
}
|
||||
}
|
||||
|
||||
21
core/dns.go
21
core/dns.go
@@ -29,16 +29,17 @@ type DNS struct {
|
||||
QueryName string `yaml:"query-name"`
|
||||
}
|
||||
|
||||
func (d *DNS) validateAndSetDefault() {
|
||||
func (d *DNS) validateAndSetDefault() error {
|
||||
if len(d.QueryName) == 0 {
|
||||
panic(ErrDNSWithNoQueryName)
|
||||
return ErrDNSWithNoQueryName
|
||||
}
|
||||
if !strings.HasSuffix(d.QueryName, ".") {
|
||||
d.QueryName += "."
|
||||
}
|
||||
if _, ok := dns.StringToType[d.QueryType]; !ok {
|
||||
panic(ErrDNSWithInvalidQueryType)
|
||||
return ErrDNSWithInvalidQueryType
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DNS) query(url string, result *Result) {
|
||||
@@ -51,7 +52,7 @@ func (d *DNS) query(url string, result *Result) {
|
||||
m.SetQuestion(d.QueryName, queryType)
|
||||
r, _, err := c.Exchange(m, url)
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, err.Error())
|
||||
result.AddError(err.Error())
|
||||
return
|
||||
}
|
||||
result.Connected = true
|
||||
@@ -60,26 +61,26 @@ func (d *DNS) query(url string, result *Result) {
|
||||
switch rr.Header().Rrtype {
|
||||
case dns.TypeA:
|
||||
if a, ok := rr.(*dns.A); ok {
|
||||
result.Body = []byte(a.A.String())
|
||||
result.body = []byte(a.A.String())
|
||||
}
|
||||
case dns.TypeAAAA:
|
||||
if aaaa, ok := rr.(*dns.AAAA); ok {
|
||||
result.Body = []byte(aaaa.AAAA.String())
|
||||
result.body = []byte(aaaa.AAAA.String())
|
||||
}
|
||||
case dns.TypeCNAME:
|
||||
if cname, ok := rr.(*dns.CNAME); ok {
|
||||
result.Body = []byte(cname.Target)
|
||||
result.body = []byte(cname.Target)
|
||||
}
|
||||
case dns.TypeMX:
|
||||
if mx, ok := rr.(*dns.MX); ok {
|
||||
result.Body = []byte(mx.Mx)
|
||||
result.body = []byte(mx.Mx)
|
||||
}
|
||||
case dns.TypeNS:
|
||||
if ns, ok := rr.(*dns.NS); ok {
|
||||
result.Body = []byte(ns.Ns)
|
||||
result.body = []byte(ns.Ns)
|
||||
}
|
||||
default:
|
||||
result.Body = []byte("query type is not supported yet")
|
||||
result.body = []byte("query type is not supported yet")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/TwinProduction/gatus/pattern"
|
||||
)
|
||||
|
||||
func TestIntegrationQuery(t *testing.T) {
|
||||
@@ -61,7 +63,7 @@ func TestIntegrationQuery(t *testing.T) {
|
||||
},
|
||||
inputURL: "8.8.8.8",
|
||||
expectedDNSCode: "NOERROR",
|
||||
expectedBody: "b.iana-servers.net.",
|
||||
expectedBody: "*.iana-servers.net.",
|
||||
},
|
||||
{
|
||||
name: "test DNS with fake type and retrieve error",
|
||||
@@ -87,8 +89,15 @@ func TestIntegrationQuery(t *testing.T) {
|
||||
t.Errorf("DNSRCodePlaceholder '%s' should have been %s", result.DNSRCode, test.expectedDNSCode)
|
||||
}
|
||||
|
||||
if string(result.Body) != test.expectedBody {
|
||||
t.Errorf("got %s, expected result %s,", string(result.Body), test.expectedBody)
|
||||
if test.inputDNS.QueryType == "NS" {
|
||||
// Because there are often multiple nameservers backing a single domain, we'll only look at the suffix
|
||||
if !pattern.Match(test.expectedBody, string(result.body)) {
|
||||
t.Errorf("got %s, expected result %s,", string(result.body), test.expectedBody)
|
||||
}
|
||||
} else {
|
||||
if string(result.body) != test.expectedBody {
|
||||
t.Errorf("got %s, expected result %s,", string(result.body), test.expectedBody)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -100,8 +109,10 @@ func TestService_ValidateAndSetDefaultsWithNoDNSQueryName(t *testing.T) {
|
||||
QueryType: "A",
|
||||
QueryName: "",
|
||||
}
|
||||
dns.validateAndSetDefault()
|
||||
t.Fatal("Should've panicked because service`s dns didn't have a query name, which is a mandatory field for dns")
|
||||
err := dns.validateAndSetDefault()
|
||||
if err == nil {
|
||||
t.Fatal("Should've returned an error because service`s dns didn't have a query name, which is a mandatory field for dns")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ValidateAndSetDefaultsWithInvalidDNSQueryType(t *testing.T) {
|
||||
@@ -110,6 +121,8 @@ func TestService_ValidateAndSetDefaultsWithInvalidDNSQueryType(t *testing.T) {
|
||||
QueryType: "B",
|
||||
QueryName: "example.com",
|
||||
}
|
||||
dns.validateAndSetDefault()
|
||||
t.Fatal("Should've panicked because service`s dns query type is invalid, it needs to be a valid query name like A, AAAA, CNAME...")
|
||||
err := dns.validateAndSetDefault()
|
||||
if err == nil {
|
||||
t.Fatal("Should've returned an error because service`s dns query type is invalid, it needs to be a valid query name like A, AAAA, CNAME...")
|
||||
}
|
||||
}
|
||||
|
||||
26
core/event.go
Normal file
26
core/event.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package core
|
||||
|
||||
import "time"
|
||||
|
||||
// Event is something that happens at a specific time
|
||||
type Event struct {
|
||||
// Type is the kind of event
|
||||
Type EventType `json:"type"`
|
||||
|
||||
// Timestamp is the moment at which the event happened
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// EventType is, uh, the types of events?
|
||||
type EventType string
|
||||
|
||||
var (
|
||||
// EventStart is a type of event that represents when a service starts being monitored
|
||||
EventStart EventType = "START"
|
||||
|
||||
// EventHealthy is a type of event that represents a service passing all of its conditions
|
||||
EventHealthy EventType = "HEALTHY"
|
||||
|
||||
// EventUnhealthy is a type of event that represents a service failing one or more of its conditions
|
||||
EventUnhealthy EventType = "UNHEALTHY"
|
||||
)
|
||||
@@ -12,10 +12,7 @@ type Result struct {
|
||||
// DNSRCode is the response code of a DNS query in a human readable format
|
||||
DNSRCode string `json:"-"`
|
||||
|
||||
// Body is the response body
|
||||
Body []byte `json:"-"`
|
||||
|
||||
// Hostname extracted from the Service URL
|
||||
// Hostname extracted from Service.URL
|
||||
Hostname string `json:"hostname"`
|
||||
|
||||
// IP resolved from the Service URL
|
||||
@@ -28,10 +25,10 @@ type Result struct {
|
||||
Duration time.Duration `json:"duration"`
|
||||
|
||||
// Errors encountered during the evaluation of the service's health
|
||||
Errors []string `json:"errors"`
|
||||
Errors []string `json:"errors"` // XXX: find a way to filter out duplicate errors
|
||||
|
||||
// ConditionResults results of the service's conditions
|
||||
ConditionResults []*ConditionResult `json:"condition-results"`
|
||||
ConditionResults []*ConditionResult `json:"conditionResults"`
|
||||
|
||||
// Success whether the result signifies a success or not
|
||||
Success bool `json:"success"`
|
||||
@@ -41,4 +38,23 @@ type Result struct {
|
||||
|
||||
// CertificateExpiration is the duration before the certificate expires
|
||||
CertificateExpiration time.Duration `json:"-"`
|
||||
|
||||
// body is the response body
|
||||
//
|
||||
// Note that this variable is only used during the evaluation of a service's health.
|
||||
// This means that the call Service.EvaluateHealth both populates the body (if necessary)
|
||||
// and sets it to nil after the evaluation has been completed.
|
||||
body []byte
|
||||
}
|
||||
|
||||
// AddError adds an error to the result's list of errors.
|
||||
// It also ensures that there are no duplicates.
|
||||
func (r *Result) AddError(error string) {
|
||||
for _, resultError := range r.Errors {
|
||||
if resultError == error {
|
||||
// If the error already exists, don't add it
|
||||
return
|
||||
}
|
||||
}
|
||||
r.Errors = append(r.Errors, error)
|
||||
}
|
||||
|
||||
21
core/result_test.go
Normal file
21
core/result_test.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResult_AddError(t *testing.T) {
|
||||
result := &Result{}
|
||||
result.AddError("potato")
|
||||
if len(result.Errors) != 1 {
|
||||
t.Error("should've had 1 error")
|
||||
}
|
||||
result.AddError("potato")
|
||||
if len(result.Errors) != 1 {
|
||||
t.Error("should've still had 1 error, because a duplicate error was added")
|
||||
}
|
||||
result.AddError("tomato")
|
||||
if len(result.Errors) != 2 {
|
||||
t.Error("should've had 2 error")
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,19 @@
|
||||
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
|
||||
@@ -8,8 +22,25 @@ type ServiceStatus struct {
|
||||
// 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
|
||||
@@ -17,15 +48,67 @@ 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) {
|
||||
ss.Results = append(ss.Results, result)
|
||||
if len(ss.Results) > 20 {
|
||||
ss.Results = ss.Results[1:]
|
||||
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)
|
||||
}
|
||||
|
||||
92
core/service-status_bench_test.go
Normal file
92
core/service-status_bench_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
firstCondition = Condition("[STATUS] == 200")
|
||||
secondCondition = Condition("[RESPONSE_TIME] < 500")
|
||||
thirdCondition = Condition("[CERTIFICATE_EXPIRATION] < 72h")
|
||||
|
||||
timestamp = time.Now()
|
||||
|
||||
testService = Service{
|
||||
Name: "name",
|
||||
Group: "group",
|
||||
URL: "https://example.org/what/ever",
|
||||
Method: "GET",
|
||||
Body: "body",
|
||||
Interval: 30 * time.Second,
|
||||
Conditions: []*Condition{&firstCondition, &secondCondition, &thirdCondition},
|
||||
Alerts: nil,
|
||||
Insecure: false,
|
||||
NumberOfFailuresInARow: 0,
|
||||
NumberOfSuccessesInARow: 0,
|
||||
}
|
||||
testSuccessfulResult = Result{
|
||||
Hostname: "example.org",
|
||||
IP: "127.0.0.1",
|
||||
HTTPStatus: 200,
|
||||
body: []byte("body"),
|
||||
Errors: nil,
|
||||
Connected: true,
|
||||
Success: true,
|
||||
Timestamp: timestamp,
|
||||
Duration: 150 * time.Millisecond,
|
||||
CertificateExpiration: 10 * time.Hour,
|
||||
ConditionResults: []*ConditionResult{
|
||||
{
|
||||
Condition: "[STATUS] == 200",
|
||||
Success: true,
|
||||
},
|
||||
{
|
||||
Condition: "[RESPONSE_TIME] < 500",
|
||||
Success: true,
|
||||
},
|
||||
{
|
||||
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
|
||||
Success: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
testUnsuccessfulResult = Result{
|
||||
Hostname: "example.org",
|
||||
IP: "127.0.0.1",
|
||||
HTTPStatus: 200,
|
||||
body: []byte("body"),
|
||||
Errors: []string{"error-1", "error-2"},
|
||||
Connected: true,
|
||||
Success: false,
|
||||
Timestamp: timestamp,
|
||||
Duration: 750 * time.Millisecond,
|
||||
CertificateExpiration: 10 * time.Hour,
|
||||
ConditionResults: []*ConditionResult{
|
||||
{
|
||||
Condition: "[STATUS] == 200",
|
||||
Success: true,
|
||||
},
|
||||
{
|
||||
Condition: "[RESPONSE_TIME] < 500",
|
||||
Success: false,
|
||||
},
|
||||
{
|
||||
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
|
||||
Success: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func BenchmarkServiceStatus_WithResultPagination(b *testing.B) {
|
||||
service := &testService
|
||||
serviceStatus := NewServiceStatus(service)
|
||||
for i := 0; i < MaximumNumberOfResults; i++ {
|
||||
serviceStatus.AddResult(&testSuccessfulResult)
|
||||
}
|
||||
for n := 0; n < b.N; n++ {
|
||||
serviceStatus.WithResultPagination(1, 20)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package core
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewServiceStatus(t *testing.T) {
|
||||
service := &Service{Name: "name", Group: "group"}
|
||||
@@ -11,15 +14,53 @@ func TestNewServiceStatus(t *testing.T) {
|
||||
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 < 50; i++ {
|
||||
serviceStatus.AddResult(&Result{})
|
||||
for i := 0; i < MaximumNumberOfResults+10; i++ {
|
||||
serviceStatus.AddResult(&Result{Timestamp: time.Now()})
|
||||
}
|
||||
if len(serviceStatus.Results) != 20 {
|
||||
t.Errorf("expected serviceStatus.Results to not exceed a length of 20")
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
132
core/service.go
132
core/service.go
@@ -2,6 +2,7 @@ package core
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/ioutil"
|
||||
@@ -11,17 +12,32 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/client"
|
||||
)
|
||||
|
||||
const (
|
||||
// HostHeader is the name of the header used to specify the host
|
||||
HostHeader = "Host"
|
||||
|
||||
// ContentTypeHeader is the name of the header used to specify the content type
|
||||
ContentTypeHeader = "Content-Type"
|
||||
|
||||
// UserAgentHeader is the name of the header used to specify the request's user agent
|
||||
UserAgentHeader = "User-Agent"
|
||||
|
||||
// GatusUserAgent is the default user agent that Gatus uses to send requests.
|
||||
GatusUserAgent = "Gatus/1.0"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrServiceWithNoCondition is the error with which gatus will panic if a service is configured with no conditions
|
||||
// ErrServiceWithNoCondition is the error with which Gatus will panic if a service is configured with no conditions
|
||||
ErrServiceWithNoCondition = errors.New("you must specify at least one condition per service")
|
||||
|
||||
// ErrServiceWithNoURL is the error with which gatus will panic if a service is configured with no url
|
||||
// ErrServiceWithNoURL is the error with which Gatus will panic if a service is configured with no url
|
||||
ErrServiceWithNoURL = errors.New("you must specify an url for each service")
|
||||
|
||||
// ErrServiceWithNoName is the error with which gatus will panic if a service is configured with no name
|
||||
// ErrServiceWithNoName is the error with which Gatus will panic if a service is configured with no name
|
||||
ErrServiceWithNoName = errors.New("you must specify a name for each service")
|
||||
)
|
||||
|
||||
@@ -58,7 +74,7 @@ type Service struct {
|
||||
Conditions []*Condition `yaml:"conditions"`
|
||||
|
||||
// Alerts is the alerting configuration for the service in case of failure
|
||||
Alerts []*Alert `yaml:"alerts"`
|
||||
Alerts []*alert.Alert `yaml:"alerts"`
|
||||
|
||||
// Insecure is whether to skip verifying the server's certificate chain and host name
|
||||
Insecure bool `yaml:"insecure,omitempty"`
|
||||
@@ -66,12 +82,12 @@ type Service struct {
|
||||
// NumberOfFailuresInARow is the number of unsuccessful evaluations in a row
|
||||
NumberOfFailuresInARow int
|
||||
|
||||
// NumberOfFailuresInARow is the number of successful evaluations in a row
|
||||
// NumberOfSuccessesInARow is the number of successful evaluations in a row
|
||||
NumberOfSuccessesInARow int
|
||||
}
|
||||
|
||||
// ValidateAndSetDefaults validates the service's configuration and sets the default value of fields that have one
|
||||
func (service *Service) ValidateAndSetDefaults() {
|
||||
func (service *Service) ValidateAndSetDefaults() error {
|
||||
// Set default values
|
||||
if service.Interval == 0 {
|
||||
service.Interval = 1 * time.Minute
|
||||
@@ -82,34 +98,41 @@ func (service *Service) ValidateAndSetDefaults() {
|
||||
if len(service.Headers) == 0 {
|
||||
service.Headers = make(map[string]string)
|
||||
}
|
||||
for _, alert := range service.Alerts {
|
||||
if alert.FailureThreshold <= 0 {
|
||||
alert.FailureThreshold = 3
|
||||
// Automatically add user agent header if there isn't one specified in the service configuration
|
||||
if _, userAgentHeaderExists := service.Headers[UserAgentHeader]; !userAgentHeaderExists {
|
||||
service.Headers[UserAgentHeader] = GatusUserAgent
|
||||
}
|
||||
// Automatically add "Content-Type: application/json" header if there's no Content-Type set
|
||||
// and service.GraphQL is set to true
|
||||
if _, contentTypeHeaderExists := service.Headers[ContentTypeHeader]; !contentTypeHeaderExists && service.GraphQL {
|
||||
service.Headers[ContentTypeHeader] = "application/json"
|
||||
}
|
||||
for _, serviceAlert := range service.Alerts {
|
||||
if serviceAlert.FailureThreshold <= 0 {
|
||||
serviceAlert.FailureThreshold = 3
|
||||
}
|
||||
if alert.SuccessThreshold <= 0 {
|
||||
alert.SuccessThreshold = 2
|
||||
if serviceAlert.SuccessThreshold <= 0 {
|
||||
serviceAlert.SuccessThreshold = 2
|
||||
}
|
||||
}
|
||||
if len(service.Name) == 0 {
|
||||
panic(ErrServiceWithNoName)
|
||||
return ErrServiceWithNoName
|
||||
}
|
||||
if len(service.URL) == 0 {
|
||||
panic(ErrServiceWithNoURL)
|
||||
return ErrServiceWithNoURL
|
||||
}
|
||||
if len(service.Conditions) == 0 {
|
||||
panic(ErrServiceWithNoCondition)
|
||||
return ErrServiceWithNoCondition
|
||||
}
|
||||
|
||||
if service.DNS != nil {
|
||||
service.DNS.validateAndSetDefault()
|
||||
return
|
||||
return service.DNS.validateAndSetDefault()
|
||||
}
|
||||
|
||||
// Make sure that the request can be created
|
||||
_, err := http.NewRequest(service.Method, service.URL, bytes.NewBuffer([]byte(service.Body)))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EvaluateHealth sends a request to the service's URL and evaluates the conditions of the service.
|
||||
@@ -128,38 +151,25 @@ func (service *Service) EvaluateHealth() *Result {
|
||||
}
|
||||
}
|
||||
result.Timestamp = time.Now()
|
||||
// No need to keep the body after the service has been evaluated
|
||||
result.body = nil
|
||||
return result
|
||||
}
|
||||
|
||||
// GetAlertsTriggered returns a slice of alerts that have been triggered
|
||||
func (service *Service) GetAlertsTriggered() []Alert {
|
||||
var alerts []Alert
|
||||
if service.NumberOfFailuresInARow == 0 {
|
||||
return alerts
|
||||
}
|
||||
for _, alert := range service.Alerts {
|
||||
if alert.Enabled && alert.FailureThreshold == service.NumberOfFailuresInARow {
|
||||
alerts = append(alerts, *alert)
|
||||
continue
|
||||
}
|
||||
}
|
||||
return alerts
|
||||
}
|
||||
|
||||
func (service *Service) getIP(result *Result) {
|
||||
if service.DNS != nil {
|
||||
result.Hostname = strings.TrimSuffix(service.URL, ":53")
|
||||
} else {
|
||||
urlObject, err := url.Parse(service.URL)
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, err.Error())
|
||||
result.AddError(err.Error())
|
||||
return
|
||||
}
|
||||
result.Hostname = urlObject.Hostname()
|
||||
}
|
||||
ips, err := net.LookupIP(result.Hostname)
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, err.Error())
|
||||
result.AddError(err.Error())
|
||||
return
|
||||
}
|
||||
result.IP = ips[0].String()
|
||||
@@ -169,9 +179,12 @@ func (service *Service) call(result *Result) {
|
||||
var request *http.Request
|
||||
var response *http.Response
|
||||
var err error
|
||||
isServiceTCP := strings.HasPrefix(service.URL, "tcp://")
|
||||
var certificate *x509.Certificate
|
||||
isServiceDNS := service.DNS != nil
|
||||
isServiceHTTP := !isServiceTCP && !isServiceDNS
|
||||
isServiceTCP := strings.HasPrefix(service.URL, "tcp://")
|
||||
isServiceICMP := strings.HasPrefix(service.URL, "icmp://")
|
||||
isServiceStartTLS := strings.HasPrefix(service.URL, "starttls://")
|
||||
isServiceHTTP := !isServiceDNS && !isServiceTCP && !isServiceICMP && !isServiceStartTLS
|
||||
if isServiceHTTP {
|
||||
request = service.buildHTTPRequest()
|
||||
}
|
||||
@@ -179,25 +192,39 @@ func (service *Service) call(result *Result) {
|
||||
if isServiceDNS {
|
||||
service.DNS.query(service.URL, result)
|
||||
result.Duration = time.Since(startTime)
|
||||
} else if isServiceTCP {
|
||||
result.Connected = client.CanCreateConnectionToTCPService(strings.TrimPrefix(service.URL, "tcp://"))
|
||||
} else if isServiceStartTLS {
|
||||
result.Connected, certificate, err = client.CanPerformStartTLS(strings.TrimPrefix(service.URL, "starttls://"), service.Insecure)
|
||||
if err != nil {
|
||||
result.AddError(err.Error())
|
||||
return
|
||||
}
|
||||
result.Duration = time.Since(startTime)
|
||||
result.CertificateExpiration = time.Until(certificate.NotAfter)
|
||||
} else if isServiceTCP {
|
||||
result.Connected = client.CanCreateTCPConnection(strings.TrimPrefix(service.URL, "tcp://"))
|
||||
result.Duration = time.Since(startTime)
|
||||
} else if isServiceICMP {
|
||||
result.Connected, result.Duration = client.Ping(strings.TrimPrefix(service.URL, "icmp://"))
|
||||
} else {
|
||||
response, err = client.GetHTTPClient(service.Insecure).Do(request)
|
||||
result.Duration = time.Since(startTime)
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, err.Error())
|
||||
result.AddError(err.Error())
|
||||
return
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.TLS != nil && len(response.TLS.PeerCertificates) > 0 {
|
||||
certificate := response.TLS.PeerCertificates[0]
|
||||
result.CertificateExpiration = certificate.NotAfter.Sub(time.Now())
|
||||
certificate = response.TLS.PeerCertificates[0]
|
||||
result.CertificateExpiration = time.Until(certificate.NotAfter)
|
||||
}
|
||||
result.HTTPStatus = response.StatusCode
|
||||
result.Connected = response.StatusCode > 0
|
||||
result.Body, err = ioutil.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, err.Error())
|
||||
// Only read the body if there's a condition that uses the BodyPlaceholder
|
||||
if service.needsToReadBody() {
|
||||
result.body, err = ioutil.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
result.AddError(err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,6 +243,19 @@ func (service *Service) buildHTTPRequest() *http.Request {
|
||||
request, _ := http.NewRequest(service.Method, service.URL, bodyBuffer)
|
||||
for k, v := range service.Headers {
|
||||
request.Header.Set(k, v)
|
||||
if k == HostHeader {
|
||||
request.Host = v
|
||||
}
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
// needsToReadBody checks if there's any conditions that requires the response body to be read
|
||||
func (service *Service) needsToReadBody() bool {
|
||||
for _, condition := range service.Conditions {
|
||||
if condition.hasBodyPlaceholder() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
)
|
||||
|
||||
func TestService_ValidateAndSetDefaults(t *testing.T) {
|
||||
condition := Condition("[STATUS] == 200")
|
||||
service := Service{
|
||||
Name: "TwiNNatioN",
|
||||
Name: "twinnation-health",
|
||||
URL: "https://twinnation.org/health",
|
||||
Conditions: []*Condition{&condition},
|
||||
Alerts: []*Alert{{Type: PagerDutyAlert}},
|
||||
Alerts: []*alert.Alert{{Type: alert.TypePagerDuty}},
|
||||
}
|
||||
service.ValidateAndSetDefaults()
|
||||
if service.Method != "GET" {
|
||||
@@ -26,7 +30,7 @@ func TestService_ValidateAndSetDefaults(t *testing.T) {
|
||||
if len(service.Alerts) != 1 {
|
||||
t.Error("Service should've had 1 alert")
|
||||
}
|
||||
if service.Alerts[0].Enabled {
|
||||
if service.Alerts[0].IsEnabled() {
|
||||
t.Error("Service alert should've defaulted to disabled")
|
||||
}
|
||||
if service.Alerts[0].SuccessThreshold != 2 {
|
||||
@@ -45,8 +49,10 @@ func TestService_ValidateAndSetDefaultsWithNoName(t *testing.T) {
|
||||
URL: "http://example.com",
|
||||
Conditions: []*Condition{&condition},
|
||||
}
|
||||
service.ValidateAndSetDefaults()
|
||||
t.Fatal("Should've panicked because service didn't have a name, which is a mandatory field")
|
||||
err := service.ValidateAndSetDefaults()
|
||||
if err == nil {
|
||||
t.Fatal("Should've returned an error because service didn't have a name, which is a mandatory field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ValidateAndSetDefaultsWithNoUrl(t *testing.T) {
|
||||
@@ -57,8 +63,10 @@ func TestService_ValidateAndSetDefaultsWithNoUrl(t *testing.T) {
|
||||
URL: "",
|
||||
Conditions: []*Condition{&condition},
|
||||
}
|
||||
service.ValidateAndSetDefaults()
|
||||
t.Fatal("Should've panicked because service didn't have an url, which is a mandatory field")
|
||||
err := service.ValidateAndSetDefaults()
|
||||
if err == nil {
|
||||
t.Fatal("Should've returned an error because service didn't have an url, which is a mandatory field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ValidateAndSetDefaultsWithNoConditions(t *testing.T) {
|
||||
@@ -68,8 +76,10 @@ func TestService_ValidateAndSetDefaultsWithNoConditions(t *testing.T) {
|
||||
URL: "http://example.com",
|
||||
Conditions: nil,
|
||||
}
|
||||
service.ValidateAndSetDefaults()
|
||||
t.Fatal("Should've panicked because service didn't have at least 1 condition")
|
||||
err := service.ValidateAndSetDefaults()
|
||||
if err == nil {
|
||||
t.Fatal("Should've returned an error because service didn't have at least 1 condition")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ValidateAndSetDefaultsWithDNS(t *testing.T) {
|
||||
@@ -83,42 +93,117 @@ func TestService_ValidateAndSetDefaultsWithDNS(t *testing.T) {
|
||||
},
|
||||
Conditions: []*Condition{&conditionSuccess},
|
||||
}
|
||||
service.ValidateAndSetDefaults()
|
||||
err := service.ValidateAndSetDefaults()
|
||||
if err != nil {
|
||||
|
||||
}
|
||||
if service.DNS.QueryName != "example.com." {
|
||||
t.Error("Service.dns.query-name should be formatted with . suffix")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_GetAlertsTriggered(t *testing.T) {
|
||||
func TestService_buildHTTPRequest(t *testing.T) {
|
||||
condition := Condition("[STATUS] == 200")
|
||||
service := Service{
|
||||
Name: "TwiNNatioN",
|
||||
Name: "twinnation-health",
|
||||
URL: "https://twinnation.org/health",
|
||||
Conditions: []*Condition{&condition},
|
||||
Alerts: []*Alert{{Type: PagerDutyAlert, Enabled: true}},
|
||||
}
|
||||
service.ValidateAndSetDefaults()
|
||||
if service.NumberOfFailuresInARow != 0 {
|
||||
t.Error("Service.NumberOfFailuresInARow should start with 0")
|
||||
request := service.buildHTTPRequest()
|
||||
if request.Method != "GET" {
|
||||
t.Error("request.Method should've been GET, but was", request.Method)
|
||||
}
|
||||
if service.NumberOfSuccessesInARow != 0 {
|
||||
t.Error("Service.NumberOfSuccessesInARow should start with 0")
|
||||
if request.Host != "twinnation.org" {
|
||||
t.Error("request.Host should've been twinnation.org, but was", request.Host)
|
||||
}
|
||||
if len(service.GetAlertsTriggered()) > 0 {
|
||||
t.Error("No alerts should've been triggered, because service.NumberOfFailuresInARow is 0, which is below the failure threshold")
|
||||
if userAgent := request.Header.Get("User-Agent"); userAgent != GatusUserAgent {
|
||||
t.Errorf("request.Header.Get(User-Agent) should've been %s, but was %s", GatusUserAgent, userAgent)
|
||||
}
|
||||
service.NumberOfFailuresInARow = service.Alerts[0].FailureThreshold
|
||||
if len(service.GetAlertsTriggered()) != 1 {
|
||||
t.Error("Alert should've been triggered")
|
||||
}
|
||||
|
||||
func TestService_buildHTTPRequestWithCustomUserAgent(t *testing.T) {
|
||||
condition := Condition("[STATUS] == 200")
|
||||
service := Service{
|
||||
Name: "twinnation-health",
|
||||
URL: "https://twinnation.org/health",
|
||||
Conditions: []*Condition{&condition},
|
||||
Headers: map[string]string{
|
||||
"User-Agent": "Test/2.0",
|
||||
},
|
||||
}
|
||||
service.ValidateAndSetDefaults()
|
||||
request := service.buildHTTPRequest()
|
||||
if request.Method != "GET" {
|
||||
t.Error("request.Method should've been GET, but was", request.Method)
|
||||
}
|
||||
if request.Host != "twinnation.org" {
|
||||
t.Error("request.Host should've been twinnation.org, but was", request.Host)
|
||||
}
|
||||
if userAgent := request.Header.Get("User-Agent"); userAgent != "Test/2.0" {
|
||||
t.Errorf("request.Header.Get(User-Agent) should've been %s, but was %s", "Test/2.0", userAgent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_buildHTTPRequestWithHostHeader(t *testing.T) {
|
||||
condition := Condition("[STATUS] == 200")
|
||||
service := Service{
|
||||
Name: "twinnation-health",
|
||||
URL: "https://twinnation.org/health",
|
||||
Method: "POST",
|
||||
Conditions: []*Condition{&condition},
|
||||
Headers: map[string]string{
|
||||
"Host": "example.com",
|
||||
},
|
||||
}
|
||||
service.ValidateAndSetDefaults()
|
||||
request := service.buildHTTPRequest()
|
||||
if request.Method != "POST" {
|
||||
t.Error("request.Method should've been POST, but was", request.Method)
|
||||
}
|
||||
if request.Host != "example.com" {
|
||||
t.Error("request.Host should've been example.com, but was", request.Host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_buildHTTPRequestWithGraphQLEnabled(t *testing.T) {
|
||||
condition := Condition("[STATUS] == 200")
|
||||
service := Service{
|
||||
Name: "twinnation-graphql",
|
||||
URL: "https://twinnation.org/graphql",
|
||||
Method: "POST",
|
||||
Conditions: []*Condition{&condition},
|
||||
GraphQL: true,
|
||||
Body: `{
|
||||
users(gender: "female") {
|
||||
id
|
||||
name
|
||||
gender
|
||||
avatar
|
||||
}
|
||||
}`,
|
||||
}
|
||||
service.ValidateAndSetDefaults()
|
||||
request := service.buildHTTPRequest()
|
||||
if request.Method != "POST" {
|
||||
t.Error("request.Method should've been POST, but was", request.Method)
|
||||
}
|
||||
if contentType := request.Header.Get(ContentTypeHeader); contentType != "application/json" {
|
||||
t.Error("request.Header.Content-Type should've been application/json, but was", contentType)
|
||||
}
|
||||
body, _ := ioutil.ReadAll(request.Body)
|
||||
if !strings.HasPrefix(string(body), "{\"query\":") {
|
||||
t.Error("request.body should've started with '{\"query\":', but it didn't:", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationEvaluateHealth(t *testing.T) {
|
||||
condition := Condition("[STATUS] == 200")
|
||||
bodyCondition := Condition("[BODY].status == UP")
|
||||
service := Service{
|
||||
Name: "TwiNNatioN",
|
||||
Name: "twinnation-health",
|
||||
URL: "https://twinnation.org/health",
|
||||
Conditions: []*Condition{&condition},
|
||||
Conditions: []*Condition{&condition, &bodyCondition},
|
||||
}
|
||||
result := service.EvaluateHealth()
|
||||
if !result.ConditionResults[0].Success {
|
||||
@@ -132,11 +217,30 @@ func TestIntegrationEvaluateHealth(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationEvaluateHealthWithFailure(t *testing.T) {
|
||||
condition := Condition("[STATUS] == 500")
|
||||
service := Service{
|
||||
Name: "twinnation-health",
|
||||
URL: "https://twinnation.org/health",
|
||||
Conditions: []*Condition{&condition},
|
||||
}
|
||||
result := service.EvaluateHealth()
|
||||
if result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a failure", condition)
|
||||
}
|
||||
if !result.Connected {
|
||||
t.Error("Because the connection has been established, result.Connected should've been true")
|
||||
}
|
||||
if result.Success {
|
||||
t.Error("Because one of the conditions failed, success should have been false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationEvaluateHealthForDNS(t *testing.T) {
|
||||
conditionSuccess := Condition("[DNS_RCODE] == NOERROR")
|
||||
conditionBody := Condition("[BODY] == 93.184.216.34")
|
||||
service := Service{
|
||||
Name: "TwiNNatioN",
|
||||
Name: "example",
|
||||
URL: "8.8.8.8",
|
||||
DNS: &DNS{
|
||||
QueryType: "A",
|
||||
@@ -156,21 +260,59 @@ func TestIntegrationEvaluateHealthForDNS(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationEvaluateHealthWithFailure(t *testing.T) {
|
||||
condition := Condition("[STATUS] == 500")
|
||||
func TestIntegrationEvaluateHealthForICMP(t *testing.T) {
|
||||
conditionSuccess := Condition("[CONNECTED] == true")
|
||||
service := Service{
|
||||
Name: "TwiNNatioN",
|
||||
URL: "https://twinnation.org/health",
|
||||
Conditions: []*Condition{&condition},
|
||||
Name: "icmp-test",
|
||||
URL: "icmp://127.0.0.1",
|
||||
Conditions: []*Condition{&conditionSuccess},
|
||||
}
|
||||
result := service.EvaluateHealth()
|
||||
if result.ConditionResults[0].Success {
|
||||
t.Errorf("Condition '%s' should have been a failure", condition)
|
||||
if !result.ConditionResults[0].Success {
|
||||
t.Errorf("Conditions '%s' should have been a success", conditionSuccess)
|
||||
}
|
||||
if !result.Connected {
|
||||
t.Error("Because the connection has been established, result.Connected should've been true")
|
||||
}
|
||||
if result.Success {
|
||||
t.Error("Because one of the conditions failed, success should have been false")
|
||||
if !result.Success {
|
||||
t.Error("Because all conditions passed, this should have been a success")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_getIP(t *testing.T) {
|
||||
conditionSuccess := Condition("[CONNECTED] == true")
|
||||
service := Service{
|
||||
Name: "invalid-url-test",
|
||||
URL: "",
|
||||
Conditions: []*Condition{&conditionSuccess},
|
||||
}
|
||||
result := &Result{}
|
||||
service.getIP(result)
|
||||
if len(result.Errors) == 0 {
|
||||
t.Error("service.getIP(result) should've thrown an error because the URL is invalid, thus cannot be parsed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_NeedsToReadBody(t *testing.T) {
|
||||
statusCondition := Condition("[STATUS] == 200")
|
||||
bodyCondition := Condition("[BODY].status == UP")
|
||||
bodyConditionWithLength := Condition("len([BODY].tags) > 0")
|
||||
if (&Service{Conditions: []*Condition{&statusCondition}}).needsToReadBody() {
|
||||
t.Error("expected false, got true")
|
||||
}
|
||||
if !(&Service{Conditions: []*Condition{&bodyCondition}}).needsToReadBody() {
|
||||
t.Error("expected true, got false")
|
||||
}
|
||||
if !(&Service{Conditions: []*Condition{&bodyConditionWithLength}}).needsToReadBody() {
|
||||
t.Error("expected true, got false")
|
||||
}
|
||||
if !(&Service{Conditions: []*Condition{&statusCondition, &bodyCondition}}).needsToReadBody() {
|
||||
t.Error("expected true, got false")
|
||||
}
|
||||
if !(&Service{Conditions: []*Condition{&bodyCondition, &statusCondition}}).needsToReadBody() {
|
||||
t.Error("expected true, got false")
|
||||
}
|
||||
if !(&Service{Conditions: []*Condition{&bodyConditionWithLength, &statusCondition}}).needsToReadBody() {
|
||||
t.Error("expected true, got false")
|
||||
}
|
||||
}
|
||||
|
||||
152
core/uptime.go
Normal file
152
core/uptime.go
Normal file
@@ -0,0 +1,152 @@
|
||||
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
|
||||
SuccessfulExecutionsPerHour map[int64]uint64 `json:"-"`
|
||||
|
||||
// TotalExecutionsPerHour is a map containing the total number of checks (value)
|
||||
// for every hourly unix timestamps (key)
|
||||
// Deprecated
|
||||
TotalExecutionsPerHour map[int64]uint64 `json:"-"`
|
||||
|
||||
// HourlyStatistics is a map containing metrics collected (value) for every hourly unix timestamps (key)
|
||||
HourlyStatistics map[int64]*HourlyUptimeStatistics `json:"-"`
|
||||
}
|
||||
|
||||
// HourlyUptimeStatistics is a struct containing all metrics collected over the course of an hour
|
||||
type HourlyUptimeStatistics struct {
|
||||
TotalExecutions uint64 // Total number of checks
|
||||
SuccessfulExecutions uint64 // Number of successful executions
|
||||
TotalExecutionsResponseTime uint64 // Total response time for all executions
|
||||
}
|
||||
|
||||
// NewUptime creates a new Uptime
|
||||
func NewUptime() *Uptime {
|
||||
return &Uptime{
|
||||
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
|
||||
}
|
||||
24
core/uptime_bench_test.go
Normal file
24
core/uptime_bench_test.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func BenchmarkUptime_ProcessResult(b *testing.B) {
|
||||
uptime := 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{
|
||||
Duration: 18 * time.Millisecond,
|
||||
Success: n%15 == 0,
|
||||
Timestamp: timestamp,
|
||||
})
|
||||
// Simulate service with an interval of 3 minutes
|
||||
timestamp = timestamp.Add(3 * time.Minute)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
96
core/uptime_test.go
Normal file
96
core/uptime_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,16 @@
|
||||
alerting:
|
||||
mattermost:
|
||||
webhook-url: "http://mattermost:8065/hooks/tokengoeshere"
|
||||
insecure: true
|
||||
|
||||
services:
|
||||
- name: example
|
||||
url: http://example.org
|
||||
interval: 30s
|
||||
interval: 1m
|
||||
alerts:
|
||||
- type: mattermost
|
||||
enabled: true
|
||||
description: "healthcheck failed 3 times in a row"
|
||||
send-on-resolved: true
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
version: "3.8"
|
||||
services:
|
||||
gatus:
|
||||
image: twinproduction/gatus:latest
|
||||
ports:
|
||||
- 8080:8080
|
||||
volumes:
|
||||
- ./config.yaml:/config/config.yaml
|
||||
|
||||
mattermost-preview:
|
||||
image: mattermost/mattermost-preview:latest
|
||||
ports:
|
||||
- 8065:8065
|
||||
services:
|
||||
gatus:
|
||||
container_name: gatus
|
||||
image: twinproduction/gatus:latest
|
||||
ports:
|
||||
- 8080:8080
|
||||
volumes:
|
||||
- ./config.yaml:/config/config.yaml
|
||||
networks:
|
||||
- default
|
||||
|
||||
mattermost:
|
||||
container_name: mattermost
|
||||
image: mattermost/mattermost-preview:5.26.0
|
||||
ports:
|
||||
- 8065:8065
|
||||
networks:
|
||||
- default
|
||||
|
||||
networks:
|
||||
default:
|
||||
driver: bridge
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
version: "3.8"
|
||||
services:
|
||||
gatus:
|
||||
image: twinproduction/gatus:latest
|
||||
ports:
|
||||
- 8080:8080
|
||||
volumes:
|
||||
- ./config.yaml:/config/config.yaml
|
||||
gatus:
|
||||
image: twinproduction/gatus:latest
|
||||
ports:
|
||||
- 8080:8080
|
||||
volumes:
|
||||
- ./config.yaml:/config/config.yaml
|
||||
@@ -81,11 +81,11 @@ spec:
|
||||
protocol: TCP
|
||||
resources:
|
||||
limits:
|
||||
cpu: 200m
|
||||
memory: 50M
|
||||
cpu: 250m
|
||||
memory: 100M
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 20M
|
||||
memory: 30M
|
||||
volumeMounts:
|
||||
- mountPath: /config
|
||||
name: gatus-config
|
||||
|
||||
@@ -59,11 +59,11 @@ spec:
|
||||
protocol: TCP
|
||||
resources:
|
||||
limits:
|
||||
cpu: 200m
|
||||
memory: 50M
|
||||
cpu: 250m
|
||||
memory: 100M
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 20M
|
||||
memory: 30M
|
||||
volumeMounts:
|
||||
- mountPath: /config
|
||||
name: gatus-config
|
||||
|
||||
33
go.mod
33
go.mod
@@ -1,21 +1,26 @@
|
||||
module github.com/TwinProduction/gatus
|
||||
|
||||
go 1.15
|
||||
go 1.16
|
||||
|
||||
require (
|
||||
github.com/googleapis/gnostic v0.5.3 // indirect
|
||||
cloud.google.com/go v0.74.0 // indirect
|
||||
github.com/TwinProduction/gocache v1.2.3
|
||||
github.com/TwinProduction/health v1.0.0
|
||||
github.com/go-ping/ping v0.0.0-20201115131931-3300c582a663
|
||||
github.com/google/gofuzz v1.2.0 // indirect
|
||||
github.com/gorilla/mux v1.8.0
|
||||
github.com/imdario/mergo v0.3.11 // indirect
|
||||
github.com/miekg/dns v1.1.35
|
||||
github.com/prometheus/client_golang v1.7.1
|
||||
github.com/prometheus/common v0.13.0 // indirect
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f // indirect
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
||||
google.golang.org/protobuf v1.25.0 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
gopkg.in/yaml.v2 v2.3.0
|
||||
k8s.io/api v0.0.0-20190816222004-e3a6b8045b0b
|
||||
k8s.io/apimachinery v0.0.0-20190816221834-a9f1d8a9c101
|
||||
k8s.io/client-go v11.0.1-0.20190918222721-c0e3722d5cf0+incompatible
|
||||
k8s.io/klog v1.0.0 // indirect
|
||||
k8s.io/utils v0.0.0-20201027101359-01387209bb0d // indirect
|
||||
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
|
||||
)
|
||||
|
||||
replace k8s.io/client-go => k8s.io/client-go v0.18.14
|
||||
|
||||
444
go.sum
444
go.sum
@@ -1,10 +1,58 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.34.0 h1:eOI3/cP2VTU6uZLDYAoic+eyzzB9YyGmJ7eIjl8rOPg=
|
||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
|
||||
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
|
||||
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
|
||||
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
|
||||
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
|
||||
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
|
||||
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
|
||||
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
|
||||
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
|
||||
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
|
||||
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
|
||||
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
|
||||
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
|
||||
cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
|
||||
cloud.google.com/go v0.74.0 h1:kpgPA77kSSbjSs+fWHkPTxQ6J5Z2Qkruo5jfXEkHxNQ=
|
||||
cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
|
||||
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
|
||||
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
|
||||
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
|
||||
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
|
||||
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
|
||||
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
|
||||
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
|
||||
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
|
||||
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
|
||||
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
|
||||
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
|
||||
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
|
||||
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
|
||||
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
|
||||
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
|
||||
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
|
||||
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
github.com/Azure/go-autorest/autorest v0.9.0/go.mod h1:xyHB1BMZT0cuDHU7I0+g046+BFDTQ8rEZB0s4Yfa6bI=
|
||||
github.com/Azure/go-autorest/autorest/adal v0.5.0/go.mod h1:8Z9fGy2MpX0PvDjB1pEgQTmVqjGhiHBW7RJJEciWzS0=
|
||||
github.com/Azure/go-autorest/autorest/date v0.1.0/go.mod h1:plvfp3oPSKwf2DNjlBjWF/7vwR+cUD/ELuzDCXwHUVA=
|
||||
github.com/Azure/go-autorest/autorest/mocks v0.1.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0=
|
||||
github.com/Azure/go-autorest/autorest/mocks v0.2.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0=
|
||||
github.com/Azure/go-autorest/logger v0.1.0/go.mod h1:oExouG+K6PryycPJfVSxi/koC6LSNgds39diKLz7Vrc=
|
||||
github.com/Azure/go-autorest/tracing v0.5.0/go.mod h1:r/s2XiOKccPW3HrqB+W0TQzfbtp2fGCgRFtBroKn4Dk=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
|
||||
github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ=
|
||||
github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
||||
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.3 h1:4wFNih4CemUX+A99Gk/EsaU0SXSNZV42Ve77v7/7ToY=
|
||||
github.com/TwinProduction/gocache v1.2.3/go.mod h1:Yj2daITit8TTBgiOpc26XCDSbg9xcFskUilHj9u3Mh8=
|
||||
github.com/TwinProduction/health v1.0.0 h1:TVyYTAORQQZ8LaptX8jCHZRCGCAO6e+oJx19BUIzQYY=
|
||||
github.com/TwinProduction/health v1.0.0/go.mod h1:ys4mYKUeEfYrWmkm60xLtPjTuLIEDQNBZaTZvenLG1c=
|
||||
github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g=
|
||||
github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=
|
||||
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
@@ -31,8 +79,13 @@ github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QH
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY=
|
||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||
github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8=
|
||||
github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI=
|
||||
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||
@@ -44,20 +97,31 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
|
||||
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/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=
|
||||
github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
|
||||
github.com/elazarl/goproxy v0.0.0-20180725130230-947c36da3153/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc=
|
||||
github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs=
|
||||
github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/evanphx/json-patch v4.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
|
||||
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
||||
github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4=
|
||||
github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o=
|
||||
@@ -65,30 +129,46 @@ github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9
|
||||
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
||||
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
||||
github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas=
|
||||
github.com/go-openapi/jsonpointer v0.0.0-20160704185906-46af16f9f7b1/go.mod h1:+35s3my2LFTysnkMfxsJBAMHj/DoqoB9knIWoYG/Vk0=
|
||||
github.com/go-openapi/jsonreference v0.0.0-20160704190145-13c6e3589ad9/go.mod h1:W3Z9FmVs9qj+KR4zFKmDPGiLdk1D9Rlm7cyMvf57TTg=
|
||||
github.com/go-openapi/spec v0.0.0-20160808142527-6aced65f8501/go.mod h1:J8+jY1nAiCcj+friV/PDoE1/3eeccG9LYBs0tYvLOWc=
|
||||
github.com/go-openapi/swag v0.0.0-20160704191624-1d0bd113de87/go.mod h1:DXUve3Dpr1UfpPtxFw+EFuQ41HhCWZfha5jSVRG7C7I=
|
||||
github.com/go-ping/ping v0.0.0-20201115131931-3300c582a663 h1:jI2GiiRh+pPbey52EVmbU6kuLiXqwy4CXZ4gwUBj8Y0=
|
||||
github.com/go-ping/ping v0.0.0-20201115131931-3300c582a663/go.mod h1:35JbSyV/BYqHwwRA6Zr1uVDm1637YlNOU61wI797NPI=
|
||||
github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
|
||||
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/gogo/protobuf v1.2.1 h1:/s5zKNz0uPFCZ5hddgPdo2TK2TVrUNMn0OOX8/aZMTE=
|
||||
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
|
||||
github.com/gogo/protobuf v1.3.1 h1:DqDEcV5aeaTmdFBePNpYsp3FlcVH/2ISVVM9Qf8PSls=
|
||||
github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
|
||||
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
||||
github.com/golang/protobuf v0.0.0-20161109072736-4bd1920723d7/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.4.3 h1:JjCZWpVbqXDqFVmTfYWEVTMIYrL/NPdPSCHPJ0T/raM=
|
||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
@@ -99,19 +179,46 @@ github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5a
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.0 h1:/QaMHBdZ26BB3SSst0Iwl10Epc+xhTquomWX0oZEB6w=
|
||||
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw=
|
||||
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.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=
|
||||
github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/gnostic v0.5.3 h1:2qsuRm+bzgwSIKikigPASa2GhW8H2Dn4Qq7UxD8K/48=
|
||||
github.com/googleapis/gnostic v0.5.3/go.mod h1:TRWw1s4gxBGjSe301Dai3c7wXJAZy57+/6tawkOvqHQ=
|
||||
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||
github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY=
|
||||
github.com/googleapis/gnostic v0.1.0 h1:rVsPeBmXbYv4If/cumu1AzZPwV58q433hvONV1UEZoI=
|
||||
github.com/googleapis/gnostic v0.1.0/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY=
|
||||
github.com/gophercloud/gophercloud v0.1.0/go.mod h1:vxM41WHh5uqHVBMZHzuwNOHh8XEoIEcSTewFxm1c5g8=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
|
||||
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
||||
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||
github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
||||
github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
|
||||
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
|
||||
@@ -137,6 +244,9 @@ github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2p
|
||||
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
|
||||
github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA=
|
||||
github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
@@ -149,6 +259,8 @@ github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/u
|
||||
github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/json-iterator/go v1.1.10 h1:Kz6Cvnvv2wGdaG/V8yMvfkmNiXq9Ya2KUv4rouJJr68=
|
||||
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
||||
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=
|
||||
@@ -160,14 +272,13 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxv
|
||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs=
|
||||
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM=
|
||||
github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4=
|
||||
github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ=
|
||||
github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
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=
|
||||
@@ -190,8 +301,10 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI=
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
|
||||
github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg=
|
||||
github.com/nats-io/jwt v0.3.2/go.mod h1:/euKqTS1ZD+zzjYrY7pseZrTtWQSjujC7xjPc8wL6eU=
|
||||
github.com/nats-io/nats-server/v2 v2.1.2/go.mod h1:Afk+wRZqkMQs/p45uXdrVLuab3gwv3Z8C4HTBu8GD/k=
|
||||
@@ -199,12 +312,22 @@ github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzE
|
||||
github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
|
||||
github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs=
|
||||
github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
|
||||
github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
|
||||
github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.11.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||
github.com/onsi/ginkgo v1.14.1/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
|
||||
github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
|
||||
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||
github.com/onsi/gomega v1.10.2/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
|
||||
github.com/opentracing-contrib/go-observer v0.0.0-20170622124052-a52f23424492/go.mod h1:Ngi6UdF0k5OKD5t5wlmGhe/EDKPoUM3BXZSSfIuJbis=
|
||||
github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74=
|
||||
@@ -218,41 +341,43 @@ github.com/pact-foundation/pact-go v1.0.4/go.mod h1:uExwJY4kCzNPcHRj+hCR/HBbOOIw
|
||||
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
|
||||
github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=
|
||||
github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac=
|
||||
github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU=
|
||||
github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc=
|
||||
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
|
||||
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=
|
||||
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
|
||||
github.com/prometheus/client_golang v1.3.0/go.mod h1:hJaj2vgQTGQmVCsAACORcieXFeDPbaTKGT+JTgUa3og=
|
||||
github.com/prometheus/client_golang v1.7.1 h1:NTGy1Ja9pByO+xAeH/qiWnLrKtr3hJPNjaVUwnjpdpA=
|
||||
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
|
||||
github.com/prometheus/client_golang v1.9.0 h1:Rrch9mh17XcxvEu9D9DEpb4isxjGBtcevQjKvxPRQIU=
|
||||
github.com/prometheus/client_golang v1.9.0/go.mod h1:FqZLKOZnGdFAhOK4nqGHa7D66IdsO+O441Eve7ptJDU=
|
||||
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
|
||||
github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
|
||||
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4 h1:gQz4mCbXsO+nc9n1hCxHcGA3Zx3Eo+UHZoInFGUIXNM=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/client_model v0.1.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M=
|
||||
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
|
||||
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
|
||||
github.com/prometheus/common v0.7.0 h1:L+1lyG48J1zAQXA3RBX/nG/B3gjlHq0zTt2tlbJLyCY=
|
||||
github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt26CguLLsqA=
|
||||
github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
|
||||
github.com/prometheus/common v0.13.0 h1:vJlpe9wPgDRM1Z+7Wj3zUUjY1nr6/1jNKyl7llliccg=
|
||||
github.com/prometheus/common v0.13.0/go.mod h1:U+gB1OBLb1lF3O42bTCL+FK18tX9Oar16Clt/msog/s=
|
||||
github.com/prometheus/common v0.15.0 h1:4fgOnadei3EZvgRwxJ7RMpG1k1pOZth5Pc13tyspaKM=
|
||||
github.com/prometheus/common v0.15.0/go.mod h1:U+gB1OBLb1lF3O42bTCL+FK18tX9Oar16Clt/msog/s=
|
||||
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
|
||||
github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
|
||||
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
|
||||
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
|
||||
github.com/prometheus/procfs v0.1.3 h1:F0+tqvhOksq22sc6iCHF5WGlWjdwj92p0udFh1VFBS8=
|
||||
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
|
||||
github.com/prometheus/procfs v0.2.0 h1:wH4vA7pcjKuZzjF7lM8awk4fnuJO6idemZXoKnULUx4=
|
||||
github.com/prometheus/procfs v0.2.0/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
|
||||
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
|
||||
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
@@ -270,9 +395,10 @@ github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4k
|
||||
github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
|
||||
github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
|
||||
github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
|
||||
github.com/spf13/pflag v1.0.1 h1:aCvUg6QPl3ibpQUxyLkrEkCHtPqYJL4x9AuhqVqFis4=
|
||||
github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
|
||||
github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
|
||||
github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI=
|
||||
@@ -281,16 +407,29 @@ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/tidwall/redcon v1.3.2/go.mod h1:bdYBm4rlcWpst2XMwKVzWDF9CoUxEbUmM7CQrKeOZas=
|
||||
github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
|
||||
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
|
||||
github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
|
||||
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
|
||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
|
||||
go.etcd.io/bbolt v1.3.5 h1:XAzx9gjCb0Rxj7EoqcClPD1d5ZBxZJk0jbuoPHenBt0=
|
||||
go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
|
||||
go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg=
|
||||
go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
|
||||
go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
|
||||
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
||||
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
||||
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
||||
@@ -300,21 +439,49 @@ go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
|
||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190211182817-74369b46fc67/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
|
||||
golang.org/x/crypto v0.0.0-20200220183623-bac4c82f6975/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad h1:DN0cp81fZ3njFcrLCytUHRSUkqBjfTo4Tx9RJTWs0EY=
|
||||
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
|
||||
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
|
||||
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
|
||||
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
|
||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=
|
||||
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
|
||||
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
|
||||
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
|
||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
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/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=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -327,22 +494,57 @@ golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73r
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200625001655-4c5254603344 h1:vGXIOMxbNfDTk/aXCmfdLgkrSV+Z2tcbze+pEc3v5W4=
|
||||
golang.org/x/net v0.0.0-20191004110552-13f9640d40b9/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20201224014010-6772e930b67b h1:iFwSg7t5GZmB/Q5TjiEAsdoLDrdJRC1RiF2WhuV29Qw=
|
||||
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421 h1:Wo7BWFiOk0QRFMLYMqJGFMd9CgUAcGx7V+qEg/h5IBI=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5 h1:Lm4OryKCca1vehdsWogr9N4t7NfZxLbJoc/H0w4K4S4=
|
||||
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9 h1:SQFwaSi55rU7vdNs9Yr0Z324VNlrF+0wMqRXT4St8ck=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20170830134202-bb24a47a89ea/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -351,84 +553,221 @@ golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5h
|
||||
golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190209173611-3b5209105503/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191022100944-742c48ecaeb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/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=
|
||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f h1:+Nyd8tzPX9R7BWHguqsrbFdRx3WQ/1ib8I44HXV5yTA=
|
||||
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/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/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=
|
||||
golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.4 h1:0YWbFKbhXG/wIiuHDSKpS0Iy7FSA+u45VtBMfQcFTTc=
|
||||
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0 h1:/5xXl8Y5W96D+TtHSlonuFqGHIWVuyCkGJLwGh9JJFs=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 h1:Hir2P/De0WpUhtrKGGjvSb2YxUgyZ7EFOSLIcSSpiwE=
|
||||
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20181011042414-1f849cf54d09/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114 h1:DnSr2mCsxyCE6ZgIkmcWUQY2R5cH/6wL7eIxEmQOMSE=
|
||||
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
|
||||
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
|
||||
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
|
||||
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||
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-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
|
||||
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
||||
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
||||
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
|
||||
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
|
||||
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
|
||||
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
|
||||
google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
|
||||
google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
||||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190530194941-fb225487d101/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s=
|
||||
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
|
||||
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
|
||||
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/genproto v0.0.0-20201019141844-1ed22bb0c154/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
|
||||
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.0/go.mod h1:chYK+tFQF0nDUGJgXMSgLCQk3phJEuONr2DCgLDdAQM=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||
google.golang.org/grpc v1.22.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
|
||||
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
|
||||
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||
google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.0 h1:4MY060fB1DLGMB/7MBTLnwQUY6+F09GEiz6SsrNqyzM=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
|
||||
google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4c=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
@@ -444,29 +783,42 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWD
|
||||
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
||||
gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 h1:tQIYjPdBoyREyB9XMu+nnTclpTYkz2zFM+lzLJFO4gQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||
k8s.io/api v0.0.0-20190816222004-e3a6b8045b0b h1:RXEExX+zpnAwh3WmvQMVy5albf6/wuIP1W2ehL2lTqc=
|
||||
k8s.io/api v0.0.0-20190816222004-e3a6b8045b0b/go.mod h1:iuAfoD4hCxJ8Onx9kaTIt30j7jUFS00AXQi6QMi99vA=
|
||||
k8s.io/apimachinery v0.0.0-20190816221834-a9f1d8a9c101 h1:QtHYUjIdgXTtJVdYQhWIQZZoXa32aF3O9BNX2up2plE=
|
||||
k8s.io/apimachinery v0.0.0-20190816221834-a9f1d8a9c101/go.mod h1:ccL7Eh7zubPUSh9A3USN90/OzHNSVN6zxzde07TDCL0=
|
||||
k8s.io/client-go v11.0.1-0.20190918222721-c0e3722d5cf0+incompatible h1:Qbgu1b9y8BUhpTZD5+6z2pt0YD57D7FGaCkBg2UFaEY=
|
||||
k8s.io/client-go v11.0.1-0.20190918222721-c0e3722d5cf0+incompatible/go.mod h1:7vJpHMYJwNQCWgzmNV+VYUl1zCObLyodBc8nIyt8L5s=
|
||||
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||
k8s.io/api v0.18.14 h1:tKRYsRhfL7Hfs60rFm8sNdhWydDuk7vnBqnt8uy+i/Q=
|
||||
k8s.io/api v0.18.14/go.mod h1:rMEP0KbqUY9Bm/nbQBXtUizL9r7XvD7IV1XhnGSHsy4=
|
||||
k8s.io/apimachinery v0.18.14 h1:wH0doJJajeG0qIuQD1/yo5JrBDAsZ3olqlNXZBiauVw=
|
||||
k8s.io/apimachinery v0.18.14/go.mod h1:PF5taHbXgTEJLU+xMypMmYTXTWPJ5LaW8bfsisxnEXk=
|
||||
k8s.io/client-go v0.18.14 h1:9dWb5D0dBsuc2umLPuWVE07rPDmBNsggW3vvctDyJII=
|
||||
k8s.io/client-go v0.18.14/go.mod h1:fpZHBter1MB6bs+GISolsmIRsGlBEJyd0mllE0H9f2Y=
|
||||
k8s.io/gengo v0.0.0-20190128074634-0689ccc1d7d6/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0=
|
||||
k8s.io/klog v0.0.0-20181102134211-b9b56d5dfc92/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk=
|
||||
k8s.io/klog v0.3.0/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk=
|
||||
k8s.io/klog v1.0.0 h1:Pt+yjF5aB1xDSVbau4VsWe+dQNzA0qv1LlXdC2dF6Q8=
|
||||
k8s.io/klog v1.0.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I=
|
||||
k8s.io/klog/v2 v2.0.0/go.mod h1:PBfzABfn139FHAV07az/IF9Wp1bkk3vpT2XSJ76fSDE=
|
||||
k8s.io/utils v0.0.0-20201027101359-01387209bb0d h1:1qqs/6lQQGCeZhCu0tO7La4lAazDXic6BiCmpjWcWUo=
|
||||
k8s.io/utils v0.0.0-20201027101359-01387209bb0d/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA=
|
||||
sigs.k8s.io/yaml v1.1.0 h1:4A07+ZFc2wgJwo8YNlQpr1rVlgUDlxXHhPJciaPY5gs=
|
||||
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=
|
||||
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
||||
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
|
||||
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
|
||||
sigs.k8s.io/structured-merge-diff/v3 v3.0.0-20200116222232-67a7b8c61874/go.mod h1:PlARxl6Hbt/+BC80dRLi1qAmnMqwqDg62YvvVkZjemw=
|
||||
sigs.k8s.io/structured-merge-diff/v3 v3.0.0 h1:dOmIZBMfhcHS09XZkMyUgkq5trg3/jRyJYFZUiaOp8E=
|
||||
sigs.k8s.io/structured-merge-diff/v3 v3.0.0/go.mod h1:PlARxl6Hbt/+BC80dRLi1qAmnMqwqDg62YvvVkZjemw=
|
||||
sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o=
|
||||
sigs.k8s.io/yaml v1.2.0 h1:kr/MCeFWJWTwyaHoR9c8EjH9OumOmoF9YGiZd7lFm/Q=
|
||||
sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc=
|
||||
sourcegraph.com/sourcegraph/appdash v0.0.0-20190731080439-ebfcffb1b5c0/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU=
|
||||
|
||||
@@ -25,6 +25,9 @@ func walk(path string, object interface{}) (string, int, error) {
|
||||
case map[string]interface{}:
|
||||
return walk(strings.Replace(path, fmt.Sprintf("%s.", currentKey), "", 1), value)
|
||||
case string:
|
||||
if len(keys) > 1 {
|
||||
return "", 0, fmt.Errorf("couldn't walk through '%s', because '%s' was a string instead of an object", keys[1], currentKey)
|
||||
}
|
||||
return value, len(value), nil
|
||||
case []interface{}:
|
||||
return fmt.Sprintf("%v", value), len(value), nil
|
||||
@@ -41,7 +44,7 @@ func extractValue(currentKey string, value interface{}) interface{} {
|
||||
tmp := strings.SplitN(currentKey, "[", 3)
|
||||
arrayIndex, err := strconv.Atoi(strings.Replace(tmp[1], "]", "", 1))
|
||||
if err != nil {
|
||||
return value
|
||||
return nil
|
||||
}
|
||||
currentKey := tmp[0]
|
||||
// if currentKey contains only an index (i.e. [0] or 0)
|
||||
|
||||
@@ -1,152 +1,148 @@
|
||||
package jsonpath
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEval(t *testing.T) {
|
||||
path := "simple"
|
||||
data := `{"simple": "value"}`
|
||||
|
||||
expectedOutput := "value"
|
||||
|
||||
output, outputLength, err := Eval(path, []byte(data))
|
||||
if err != nil {
|
||||
t.Error("Didn't expect any error, but got", err)
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
Data string
|
||||
ExpectedOutput string
|
||||
ExpectedOutputLength int
|
||||
ExpectedError bool
|
||||
}
|
||||
if outputLength != len(expectedOutput) {
|
||||
t.Errorf("Expected output length to be %v, but was %v", len(expectedOutput), outputLength)
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "simple",
|
||||
Path: "key",
|
||||
Data: `{"key": "value"}`,
|
||||
ExpectedOutput: "value",
|
||||
ExpectedOutputLength: 5,
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "simple-with-invalid-data",
|
||||
Path: "key",
|
||||
Data: "invalid data",
|
||||
ExpectedOutput: "",
|
||||
ExpectedOutputLength: 0,
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "invalid-path",
|
||||
Path: "key",
|
||||
Data: `{}`,
|
||||
ExpectedOutput: "",
|
||||
ExpectedOutputLength: 0,
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "long-simple-walk",
|
||||
Path: "long.simple.walk",
|
||||
Data: `{"long": {"simple": {"walk": "value"}}}`,
|
||||
ExpectedOutput: "value",
|
||||
ExpectedOutputLength: 5,
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "array-of-maps",
|
||||
Path: "ids[1].id",
|
||||
Data: `{"ids": [{"id": 1}, {"id": 2}]}`,
|
||||
ExpectedOutput: "2",
|
||||
ExpectedOutputLength: 1,
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "array-of-values",
|
||||
Path: "ids[0]",
|
||||
Data: `{"ids": [1, 2]}`,
|
||||
ExpectedOutput: "1",
|
||||
ExpectedOutputLength: 1,
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "array-of-values-and-invalid-index",
|
||||
Path: "ids[wat]",
|
||||
Data: `{"ids": [1, 2]}`,
|
||||
ExpectedOutput: "",
|
||||
ExpectedOutputLength: 0,
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "array-of-values-at-root",
|
||||
Path: "[1]",
|
||||
Data: `[1, 2]`,
|
||||
ExpectedOutput: "2",
|
||||
ExpectedOutputLength: 1,
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "array-of-maps-at-root",
|
||||
Path: "[0].id",
|
||||
Data: `[{"id": 1}, {"id": 2}]`,
|
||||
ExpectedOutput: "1",
|
||||
ExpectedOutputLength: 1,
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "array-of-maps-at-root-and-invalid-index",
|
||||
Path: "[5].id",
|
||||
Data: `[{"id": 1}, {"id": 2}]`,
|
||||
ExpectedOutput: "",
|
||||
ExpectedOutputLength: 0,
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "long-walk-and-array",
|
||||
Path: "data.ids[0].id",
|
||||
Data: `{"data": {"ids": [{"id": 1}, {"id": 2}, {"id": 3}]}}`,
|
||||
ExpectedOutput: "1",
|
||||
ExpectedOutputLength: 1,
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "nested-array",
|
||||
Path: "[3][2]",
|
||||
Data: `[[1, 2], [3, 4], [], [5, 6, 7]]`,
|
||||
ExpectedOutput: "7",
|
||||
ExpectedOutputLength: 1,
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "map-of-nested-arrays",
|
||||
Path: "data[1][1]",
|
||||
Data: `{"data": [["a", "b", "c"], ["d", "eeeee", "f"]]}`,
|
||||
ExpectedOutput: "eeeee",
|
||||
ExpectedOutputLength: 5,
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "partially-invalid-path-issue122",
|
||||
Path: "data.name.invalid",
|
||||
Data: `{"data": {"name": "john"}}`,
|
||||
ExpectedOutput: "",
|
||||
ExpectedOutputLength: 0,
|
||||
ExpectedError: true,
|
||||
},
|
||||
}
|
||||
if output != expectedOutput {
|
||||
t.Errorf("Expected output to be %v, but was %v", expectedOutput, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalWithLongSimpleWalk(t *testing.T) {
|
||||
path := "long.simple.walk"
|
||||
data := `{"long": {"simple": {"walk": "value"}}}`
|
||||
|
||||
expectedOutput := "value"
|
||||
|
||||
output, _, err := Eval(path, []byte(data))
|
||||
if err != nil {
|
||||
t.Error("Didn't expect any error, but got", err)
|
||||
}
|
||||
if output != expectedOutput {
|
||||
t.Errorf("Expected output to be %v, but was %v", expectedOutput, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalWithArrayOfMaps(t *testing.T) {
|
||||
path := "ids[1].id"
|
||||
data := `{"ids": [{"id": 1}, {"id": 2}]}`
|
||||
|
||||
expectedOutput := "2"
|
||||
|
||||
output, _, err := Eval(path, []byte(data))
|
||||
if err != nil {
|
||||
t.Error("Didn't expect any error, but got", err)
|
||||
}
|
||||
|
||||
if output != expectedOutput {
|
||||
t.Errorf("Expected output to be %v, but was %v", expectedOutput, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalWithArrayOfValues(t *testing.T) {
|
||||
path := "ids[0]"
|
||||
data := `{"ids": [1, 2]}`
|
||||
|
||||
expectedOutput := "1"
|
||||
|
||||
output, _, err := Eval(path, []byte(data))
|
||||
if err != nil {
|
||||
t.Error("Didn't expect any error, but got", err)
|
||||
}
|
||||
if output != expectedOutput {
|
||||
t.Errorf("Expected output to be %v, but was %v", expectedOutput, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalWithRootArrayOfValues(t *testing.T) {
|
||||
path := "[1]"
|
||||
data := `[1, 2]`
|
||||
|
||||
expectedOutput := "2"
|
||||
|
||||
output, _, err := Eval(path, []byte(data))
|
||||
if err != nil {
|
||||
t.Error("Didn't expect any error, but got", err)
|
||||
}
|
||||
if output != expectedOutput {
|
||||
t.Errorf("Expected output to be %v, but was %v", expectedOutput, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalWithRootArrayOfMaps(t *testing.T) {
|
||||
path := "[0].id"
|
||||
data := `[{"id": 1}, {"id": 2}]`
|
||||
|
||||
expectedOutput := "1"
|
||||
|
||||
output, _, err := Eval(path, []byte(data))
|
||||
if err != nil {
|
||||
t.Error("Didn't expect any error, but got", err)
|
||||
}
|
||||
if output != expectedOutput {
|
||||
t.Errorf("Expected output to be %v, but was %v", expectedOutput, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalWithRootArrayOfMapsUsingInvalidArrayIndex(t *testing.T) {
|
||||
path := "[5].id"
|
||||
data := `[{"id": 1}, {"id": 2}]`
|
||||
|
||||
_, _, err := Eval(path, []byte(data))
|
||||
if err == nil {
|
||||
t.Error("Should've returned an error, but didn't")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalWithLongWalkAndArray(t *testing.T) {
|
||||
path := "data.ids[0].id"
|
||||
data := `{"data": {"ids": [{"id": 1}, {"id": 2}, {"id": 3}]}}`
|
||||
|
||||
expectedOutput := "1"
|
||||
|
||||
output, _, err := Eval(path, []byte(data))
|
||||
if err != nil {
|
||||
t.Error("Didn't expect any error, but got", err)
|
||||
}
|
||||
if output != expectedOutput {
|
||||
t.Errorf("Expected output to be %v, but was %v", expectedOutput, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalWithNestedArray(t *testing.T) {
|
||||
path := "[3][2]"
|
||||
data := `[[1, 2], [3, 4], [], [5, 6, 7]]`
|
||||
|
||||
expectedOutput := "7"
|
||||
|
||||
output, _, err := Eval(path, []byte(data))
|
||||
if err != nil {
|
||||
t.Error("Didn't expect any error, but got", err)
|
||||
}
|
||||
if output != expectedOutput {
|
||||
t.Errorf("Expected output to be %v, but was %v", expectedOutput, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalWithMapOfNestedArray(t *testing.T) {
|
||||
path := "data[1][1]"
|
||||
data := `{"data": [["a", "b", "c"], ["d", "e", "f"]]}`
|
||||
|
||||
expectedOutput := "e"
|
||||
|
||||
output, _, err := Eval(path, []byte(data))
|
||||
if err != nil {
|
||||
t.Error("Didn't expect any error, but got", err)
|
||||
}
|
||||
if output != expectedOutput {
|
||||
t.Errorf("Expected output to be %v, but was %v", expectedOutput, output)
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
output, outputLength, err := Eval(scenario.Path, []byte(scenario.Data))
|
||||
if (err != nil) != scenario.ExpectedError {
|
||||
if scenario.ExpectedError {
|
||||
t.Errorf("Expected error, got '%v'", err)
|
||||
} else {
|
||||
t.Errorf("Expected no error, got '%v'", err)
|
||||
}
|
||||
}
|
||||
if outputLength != scenario.ExpectedOutputLength {
|
||||
t.Errorf("Expected output length to be %v, but was %v", scenario.ExpectedOutputLength, outputLength)
|
||||
}
|
||||
if output != scenario.ExpectedOutput {
|
||||
t.Errorf("Expected output to be %v, but was %v", scenario.ExpectedOutput, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -15,20 +16,20 @@ import (
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
)
|
||||
|
||||
// KubernetesClientApi is a minimal interface for interacting with Kubernetes
|
||||
// KubernetesClientAPI is a minimal interface for interacting with Kubernetes
|
||||
// Created mostly to make mocking the Kubernetes client easier
|
||||
type KubernetesClientApi interface {
|
||||
type KubernetesClientAPI interface {
|
||||
GetServices(namespace string) ([]v1.Service, error)
|
||||
}
|
||||
|
||||
// KubernetesClient is a working implementation of KubernetesClientApi
|
||||
// KubernetesClient is a working implementation of KubernetesClientAPI
|
||||
type KubernetesClient struct {
|
||||
client *kubernetes.Clientset
|
||||
}
|
||||
|
||||
// GetServices returns a list of services for a given namespace
|
||||
func (k *KubernetesClient) GetServices(namespace string) ([]v1.Service, error) {
|
||||
services, err := k.client.CoreV1().Services(namespace).List(metav1.ListOptions{})
|
||||
services, err := k.client.CoreV1().Services(namespace).List(context.TODO(), metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -43,7 +44,7 @@ func NewKubernetesClient(client *kubernetes.Clientset) *KubernetesClient {
|
||||
}
|
||||
|
||||
// NewClient creates a Kubernetes client for the given ClusterMode
|
||||
func NewClient(clusterMode ClusterMode) (KubernetesClientApi, error) {
|
||||
func NewClient(clusterMode ClusterMode) (KubernetesClientAPI, error) {
|
||||
var kubeConfig *rest.Config
|
||||
var err error
|
||||
switch clusterMode {
|
||||
|
||||
@@ -5,6 +5,6 @@ import (
|
||||
)
|
||||
|
||||
// GetKubernetesServices return a list of Services from the given namespace
|
||||
func GetKubernetesServices(client KubernetesClientApi, namespace string) ([]v1.Service, error) {
|
||||
func GetKubernetesServices(client KubernetesClientAPI, namespace string) ([]v1.Service, error) {
|
||||
return client.GetServices(namespace)
|
||||
}
|
||||
|
||||
143
main.go
143
main.go
@@ -1,97 +1,88 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/TwinProduction/gatus/config"
|
||||
"github.com/TwinProduction/gatus/security"
|
||||
"github.com/TwinProduction/gatus/controller"
|
||||
"github.com/TwinProduction/gatus/storage"
|
||||
"github.com/TwinProduction/gatus/watchdog"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
const cacheTTL = 10 * time.Second
|
||||
|
||||
var (
|
||||
cachedServiceStatuses []byte
|
||||
cachedServiceStatusesGzipped []byte
|
||||
cachedServiceStatusesTimestamp time.Time
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := loadConfiguration()
|
||||
statusesHandler := serviceStatusesHandler
|
||||
if cfg.Security != nil && cfg.Security.IsValid() {
|
||||
statusesHandler = security.Handler(serviceStatusesHandler, cfg.Security)
|
||||
}
|
||||
http.HandleFunc("/favicon.ico", favIconHandler) // favicon needs to be always served from the root
|
||||
http.HandleFunc(cfg.Web.PrependWithContextRoot("/api/v1/statuses"), statusesHandler)
|
||||
http.HandleFunc(cfg.Web.PrependWithContextRoot("/health"), healthHandler)
|
||||
http.Handle(cfg.Web.ContextRoot, GzipHandler(http.StripPrefix(cfg.Web.ContextRoot, http.FileServer(http.Dir("./static")))))
|
||||
|
||||
if cfg.Metrics {
|
||||
http.Handle(cfg.Web.PrependWithContextRoot("/metrics"), promhttp.Handler())
|
||||
}
|
||||
log.Printf("[main][main] Listening on %s%s\n", cfg.Web.SocketAddress(), cfg.Web.ContextRoot)
|
||||
go watchdog.Monitor(cfg)
|
||||
log.Fatal(http.ListenAndServe(cfg.Web.SocketAddress(), nil))
|
||||
}
|
||||
|
||||
func loadConfiguration() *config.Config {
|
||||
var err error
|
||||
customConfigFile := os.Getenv("GATUS_CONFIG_FILE")
|
||||
if len(customConfigFile) > 0 {
|
||||
err = config.Load(customConfigFile)
|
||||
} else {
|
||||
err = config.LoadDefaultConfiguration()
|
||||
}
|
||||
cfg, err := loadConfiguration()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return config.Get()
|
||||
start(cfg)
|
||||
// Wait for termination signal
|
||||
signalChannel := make(chan os.Signal, 1)
|
||||
done := make(chan bool, 1)
|
||||
signal.Notify(signalChannel, os.Interrupt, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-signalChannel
|
||||
log.Println("Received termination signal, attempting to gracefully shut down")
|
||||
stop()
|
||||
save()
|
||||
done <- true
|
||||
}()
|
||||
<-done
|
||||
log.Println("Shutting down")
|
||||
}
|
||||
|
||||
func serviceStatusesHandler(writer http.ResponseWriter, r *http.Request) {
|
||||
if isExpired := cachedServiceStatusesTimestamp.IsZero() || time.Now().Sub(cachedServiceStatusesTimestamp) > cacheTTL; isExpired {
|
||||
buffer := &bytes.Buffer{}
|
||||
gzipWriter := gzip.NewWriter(buffer)
|
||||
data, err := watchdog.GetJSONEncodedServiceStatuses()
|
||||
if err != nil {
|
||||
log.Printf("[main][serviceStatusesHandler] Unable to marshal object to JSON: %s", err.Error())
|
||||
writer.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = writer.Write([]byte("Unable to marshal object to JSON"))
|
||||
func stop() {
|
||||
watchdog.Shutdown()
|
||||
controller.Shutdown()
|
||||
}
|
||||
|
||||
func save() {
|
||||
err := storage.Get().Save()
|
||||
if err != nil {
|
||||
log.Println("Failed to save storage provider:", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func 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 {
|
||||
cfg, err = config.Load(customConfigFile)
|
||||
} else {
|
||||
cfg, err = config.LoadDefaultConfiguration()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func listenToConfigurationFileChanges(cfg *config.Config) {
|
||||
for {
|
||||
time.Sleep(30 * time.Second)
|
||||
if cfg.HasLoadedConfigurationFileBeenModified() {
|
||||
log.Println("[main][listenToConfigurationFileChanges] Configuration file has been modified")
|
||||
save()
|
||||
updatedConfig, err := loadConfiguration()
|
||||
if err != nil {
|
||||
if cfg.SkipInvalidConfigUpdate {
|
||||
log.Println("[main][listenToConfigurationFileChanges] Failed to load new configuration:", err.Error())
|
||||
log.Println("[main][listenToConfigurationFileChanges] The configuration file was updated, but it is not valid. The old configuration will continue being used.")
|
||||
// Update the last file modification time to avoid trying to process the same invalid configuration again
|
||||
cfg.UpdateLastFileModTime()
|
||||
continue
|
||||
} else {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
stop()
|
||||
start(updatedConfig)
|
||||
return
|
||||
}
|
||||
gzipWriter.Write(data)
|
||||
gzipWriter.Close()
|
||||
cachedServiceStatuses = data
|
||||
cachedServiceStatusesGzipped = buffer.Bytes()
|
||||
cachedServiceStatusesTimestamp = time.Now()
|
||||
}
|
||||
var data []byte
|
||||
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||
writer.Header().Set("Content-Encoding", "gzip")
|
||||
data = cachedServiceStatusesGzipped
|
||||
} else {
|
||||
data = cachedServiceStatuses
|
||||
}
|
||||
writer.Header().Add("Content-type", "application/json")
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
_, _ = writer.Write(data)
|
||||
}
|
||||
|
||||
func healthHandler(writer http.ResponseWriter, _ *http.Request) {
|
||||
writer.Header().Add("Content-type", "application/json")
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
_, _ = writer.Write([]byte("{\"status\":\"UP\"}"))
|
||||
}
|
||||
|
||||
// favIconHandler handles requests for /favicon.ico
|
||||
func favIconHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
http.ServeFile(writer, request, "./static/favicon.ico")
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/TwinProduction/gatus/config"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
@@ -19,18 +18,16 @@ var (
|
||||
// PublishMetricsForService publishes metrics for the given service and its result.
|
||||
// These metrics will be exposed at /metrics if the metrics are enabled
|
||||
func PublishMetricsForService(service *core.Service, result *core.Result) {
|
||||
if config.Get().Metrics {
|
||||
rwLock.Lock()
|
||||
gauge, exists := gauges[fmt.Sprintf("%s_%s", service.Name, service.URL)]
|
||||
if !exists {
|
||||
gauge = promauto.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Subsystem: "gatus",
|
||||
Name: "tasks",
|
||||
ConstLabels: prometheus.Labels{"service": service.Name, "url": service.URL},
|
||||
}, []string{"status", "success"})
|
||||
gauges[fmt.Sprintf("%s_%s", service.Name, service.URL)] = gauge
|
||||
}
|
||||
rwLock.Unlock()
|
||||
gauge.WithLabelValues(strconv.Itoa(result.HTTPStatus), strconv.FormatBool(result.Success)).Inc()
|
||||
rwLock.Lock()
|
||||
gauge, exists := gauges[fmt.Sprintf("%s_%s", service.Name, service.URL)]
|
||||
if !exists {
|
||||
gauge = promauto.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Subsystem: "gatus",
|
||||
Name: "tasks",
|
||||
ConstLabels: prometheus.Labels{"service": service.Name, "url": service.URL},
|
||||
}, []string{"status", "success"})
|
||||
gauges[fmt.Sprintf("%s_%s", service.Name, service.URL)] = gauge
|
||||
}
|
||||
rwLock.Unlock()
|
||||
gauge.WithLabelValues(strconv.Itoa(result.HTTPStatus), strconv.FormatBool(result.Success)).Inc()
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
package pattern
|
||||
|
||||
import "path/filepath"
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Match checks whether a string matches a pattern
|
||||
func Match(pattern, s string) bool {
|
||||
if pattern == "*" {
|
||||
return true
|
||||
}
|
||||
// Separators found in the string break filepath.Match, so we'll remove all of them.
|
||||
// This has a pretty significant impact on performance when there are separators in
|
||||
// the strings, but at least it doesn't break filepath.Match.
|
||||
s = strings.ReplaceAll(s, string(filepath.Separator), "")
|
||||
pattern = strings.ReplaceAll(pattern, string(filepath.Separator), "")
|
||||
matched, _ := filepath.Match(pattern, s)
|
||||
return matched
|
||||
}
|
||||
|
||||
21
pattern/pattern_bench_test.go
Normal file
21
pattern/pattern_bench_test.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package pattern
|
||||
|
||||
import "testing"
|
||||
|
||||
func BenchmarkMatch(b *testing.B) {
|
||||
for n := 0; n < b.N; n++ {
|
||||
if !Match("*ing*", "livingroom") {
|
||||
b.Error("should've matched")
|
||||
}
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
|
||||
func BenchmarkMatchWithBackslash(b *testing.B) {
|
||||
for n := 0; n < b.N; n++ {
|
||||
if !Match("*ing*", "living\\room") {
|
||||
b.Error("should've matched")
|
||||
}
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package pattern
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMatch(t *testing.T) {
|
||||
testMatch(t, "*", "livingroom_123", true)
|
||||
@@ -15,6 +18,7 @@ func TestMatch(t *testing.T) {
|
||||
testMatch(t, "*vin*om*2*", "livingroom_123", true)
|
||||
testMatch(t, "livingroom_123", "livingroom_123", true)
|
||||
testMatch(t, "*livingroom_123*", "livingroom_123", true)
|
||||
testMatch(t, "*test*", "\\test", true)
|
||||
testMatch(t, "livingroom", "livingroom_123", false)
|
||||
testMatch(t, "livingroom123", "livingroom_123", false)
|
||||
testMatch(t, "what", "livingroom_123", false)
|
||||
@@ -24,14 +28,16 @@ func TestMatch(t *testing.T) {
|
||||
}
|
||||
|
||||
func testMatch(t *testing.T, pattern, key string, expectedToMatch bool) {
|
||||
matched := Match(pattern, key)
|
||||
if expectedToMatch {
|
||||
if !matched {
|
||||
t.Errorf("%s should've matched pattern '%s'", key, pattern)
|
||||
t.Run(fmt.Sprintf("pattern '%s' from '%s'", pattern, key), func(t *testing.T) {
|
||||
matched := Match(pattern, key)
|
||||
if expectedToMatch {
|
||||
if !matched {
|
||||
t.Errorf("%s should've matched pattern '%s'", key, pattern)
|
||||
}
|
||||
} else {
|
||||
if matched {
|
||||
t.Errorf("%s shouldn't have matched pattern '%s'", key, pattern)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if matched {
|
||||
t.Errorf("%s shouldn't have matched pattern '%s'", key, pattern)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Handler takes care of security for a given handler with the given security configuratioon
|
||||
// Handler takes care of security for a given handler with the given security configuration
|
||||
func Handler(handler http.HandlerFunc, security *Config) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
usernameEntered, passwordEntered, ok := r.BasicAuth()
|
||||
|
||||
6
static/bootstrap.min.css
vendored
6
static/bootstrap.min.css
vendored
File diff suppressed because one or more lines are too long
@@ -1,392 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Health Dashboard</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="./bootstrap.min.css" />
|
||||
<style>
|
||||
html, body {
|
||||
background-color: #f7f9fb;
|
||||
}
|
||||
html {
|
||||
height: 100%;
|
||||
}
|
||||
#results div.container:first-child {
|
||||
border-top-left-radius: 3px;
|
||||
border-top-right-radius: 3px;
|
||||
}
|
||||
#results div.container:last-child {
|
||||
border-bottom-left-radius: 3px;
|
||||
border-bottom-right-radius: 3px;
|
||||
border-bottom-width: 1px;
|
||||
border-color: #dee2e6;
|
||||
border-style: solid;
|
||||
}
|
||||
.status {
|
||||
cursor: pointer;
|
||||
transition: all 500ms ease-in-out;
|
||||
overflow-x: hidden;
|
||||
padding: .25em 0;
|
||||
color: white;
|
||||
}
|
||||
.title {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
.status:hover {
|
||||
opacity: 0.7;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
color: black;
|
||||
}
|
||||
.status-over-time {
|
||||
overflow: auto;
|
||||
}
|
||||
.status-over-time>span:not(:last-child) {
|
||||
margin-left: 2px;
|
||||
}
|
||||
.status-time-ago {
|
||||
color: #6a737d;
|
||||
opacity: 0.5;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.status-min-max-ms {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
#tooltip {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: white;
|
||||
border: 1px solid lightgray;
|
||||
border-radius: 4px;
|
||||
padding: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
#tooltip code {
|
||||
color: #212529;
|
||||
line-height: 1;
|
||||
}
|
||||
#tooltip .tooltip-title {
|
||||
font-weight: bold;
|
||||
margin-bottom: 0;
|
||||
display: block;
|
||||
}
|
||||
#tooltip .tooltip-title {
|
||||
margin-top: 8px;
|
||||
}
|
||||
#tooltip>.tooltip-title:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
#social {
|
||||
position: fixed;
|
||||
right: 5px;
|
||||
bottom: 5px;
|
||||
padding: 5px;
|
||||
margin: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
#social img {
|
||||
opacity: 0.3;
|
||||
}
|
||||
#social img:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
#settings {
|
||||
position: fixed;
|
||||
left: 5px;
|
||||
bottom: 5px;
|
||||
padding: 5px;
|
||||
}
|
||||
#settings select:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
.service-group {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.service-group h5:hover {
|
||||
color: #1b1e21 !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container my-3 rounded p-3 border shadow">
|
||||
<div class="mb-2">
|
||||
<div class="row">
|
||||
<div class="col-8 text-left my-auto">
|
||||
<div class="title display-4">Health Status</div>
|
||||
</div>
|
||||
<div class="col-4 text-right">
|
||||
<img src="logo.png" alt="Gatus" style="position: relative; min-width: 50px; max-width: 200px; width: 20%;"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="results"></div>
|
||||
</div>
|
||||
|
||||
<div id="tooltip" style="display: none">
|
||||
<div class="tooltip-title">Timestamp:</div>
|
||||
<code id="tooltip-timestamp">...</code>
|
||||
<div class="tooltip-title">Response time:</div>
|
||||
<code id="tooltip-response-time">...</code>
|
||||
<div class="tooltip-title">Conditions:</div>
|
||||
<code id="tooltip-conditions">...</code>
|
||||
<div id="tooltip-errors-container">
|
||||
<div class="tooltip-title">Errors:</div>
|
||||
<code id="tooltip-errors">...</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="./jquery.min.js"></script>
|
||||
|
||||
<div id="social">
|
||||
<a href="https://github.com/TwinProduction/gatus" target="_blank" title="Gatus on GitHub">
|
||||
<img src="./github.png" alt="GitHub" width="32" height="auto" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div id="settings">
|
||||
<div class="input-group input-group-sm">
|
||||
<div class="input-group-prepend">
|
||||
<div class="input-group-text">↻</div>
|
||||
</div>
|
||||
<select class="form-control form-control-sm" id="refresh-rate">
|
||||
<option value="10">10s</option>
|
||||
<option value="30" selected>30s</option>
|
||||
<option value="60">1m</option>
|
||||
<option value="120">2m</option>
|
||||
<option value="300">5m</option>
|
||||
<option value="600">10m</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let serviceStatuses = {};
|
||||
let timerHandler = 0;
|
||||
let refreshIntervalHandler = 0;
|
||||
let userClickedStatus = false;
|
||||
|
||||
function showTooltip(serviceName, index, element) {
|
||||
userClickedStatus = false;
|
||||
clearTimeout(timerHandler);
|
||||
let serviceResult = serviceStatuses[serviceName].results[index];
|
||||
$("#tooltip-timestamp").text(prettifyTimestamp(serviceResult.timestamp));
|
||||
$("#tooltip-response-time").text(parseInt(serviceResult.duration/1000000) + "ms");
|
||||
// Populate the condition section
|
||||
let conditions = "";
|
||||
for (let i in serviceResult['condition-results']) {
|
||||
let conditionResult = serviceResult['condition-results'][i];
|
||||
conditions += (conditionResult.success ? "✓" : "X") + " ~ " + htmlEntities(conditionResult.condition) + "<br />";
|
||||
}
|
||||
$("#tooltip-conditions").html(conditions);
|
||||
// Populate the error section only if there are errors
|
||||
if (serviceResult.errors && serviceResult.errors.length > 0) {
|
||||
let errors = "";
|
||||
for (let i in serviceResult.errors) {
|
||||
errors += "- " + htmlEntities(serviceResult.errors[i]) + "<br />";
|
||||
}
|
||||
$("#tooltip-errors").html(errors);
|
||||
$("#tooltip-errors-container").show();
|
||||
} else {
|
||||
$("#tooltip-errors-container").hide();
|
||||
}
|
||||
// Position tooltip
|
||||
$("#tooltip").css({top: "0px", left: "0px"}).show();
|
||||
let targetTopPosition = element.getBoundingClientRect().y + 30;
|
||||
let targetLeftPosition = element.getBoundingClientRect().x;
|
||||
// Make adjustments if necessary
|
||||
let tooltipBoundingClientRect = document.querySelector('#tooltip').getBoundingClientRect();
|
||||
if (targetLeftPosition + window.scrollX + tooltipBoundingClientRect.width + 50 > document.body.getBoundingClientRect().width) {
|
||||
targetLeftPosition = element.getBoundingClientRect().x - tooltipBoundingClientRect.width + element.getBoundingClientRect().width;
|
||||
if (targetLeftPosition < 0) {
|
||||
targetLeftPosition += -targetLeftPosition;
|
||||
}
|
||||
}
|
||||
if (targetTopPosition + window.scrollY + tooltipBoundingClientRect.height + 50 > document.body.getBoundingClientRect().height && targetTopPosition >= 0) {
|
||||
targetTopPosition = element.getBoundingClientRect().y - (tooltipBoundingClientRect.height + 10);
|
||||
if (targetTopPosition < 0) {
|
||||
targetTopPosition = element.getBoundingClientRect().y + 30;
|
||||
}
|
||||
}
|
||||
$("#tooltip").css({top: targetTopPosition + "px", left: targetLeftPosition + "px"});
|
||||
}
|
||||
|
||||
function fadeTooltip() {
|
||||
if (!userClickedStatus) {
|
||||
timerHandler = setTimeout(function () {
|
||||
$("#tooltip").hide();
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
function createStatusBadge(serviceStatusIndex, index, success) {
|
||||
if (success) {
|
||||
return "<span class='status badge badge-success' style='width: 5%' onmouseenter='showTooltip(\""+serviceStatusIndex+"\", "+index+", this)' onmouseleave='fadeTooltip()' onclick='userClickedStatus = !userClickedStatus;'>✓</span>";
|
||||
}
|
||||
return "<span class='status badge badge-danger' style='width: 5%' onmouseenter='showTooltip(\""+serviceStatusIndex+"\", "+index+", this)' onmouseleave='fadeTooltip()' onclick='userClickedStatus = !userClickedStatus;'>X</span>";
|
||||
}
|
||||
|
||||
function refreshStatuses() {
|
||||
$.getJSON("./api/v1/statuses", function (data) {
|
||||
// Update the table only if there's a change
|
||||
if (JSON.stringify(serviceStatuses) !== JSON.stringify(data)) {
|
||||
serviceStatuses = data;
|
||||
buildTable();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildTable() {
|
||||
let outputByGroup = {};
|
||||
for (let serviceStatusIndex in serviceStatuses) {
|
||||
let serviceStatusOverTime = "";
|
||||
let serviceStatus = serviceStatuses[serviceStatusIndex];
|
||||
let hostname = serviceStatus.results[serviceStatus.results.length-1].hostname;
|
||||
let minResponseTime = null;
|
||||
let maxResponseTime = null;
|
||||
let newestTimestamp = null;
|
||||
let oldestTimestamp = null;
|
||||
for (let resultIndex in serviceStatus.results) {
|
||||
let serviceResult = serviceStatus.results[resultIndex];
|
||||
serviceStatusOverTime = createStatusBadge(serviceStatusIndex, resultIndex, serviceResult.success) + serviceStatusOverTime;
|
||||
const responseTime = parseInt(serviceResult.duration/1000000);
|
||||
if (minResponseTime == null || minResponseTime > responseTime) {
|
||||
minResponseTime = responseTime;
|
||||
}
|
||||
if (maxResponseTime == null || maxResponseTime < responseTime) {
|
||||
maxResponseTime = responseTime;
|
||||
}
|
||||
const timestamp = new Date(serviceResult.timestamp);
|
||||
if (newestTimestamp == null || newestTimestamp < timestamp) {
|
||||
newestTimestamp = timestamp;
|
||||
}
|
||||
if (oldestTimestamp == null || oldestTimestamp > timestamp) {
|
||||
oldestTimestamp = timestamp;
|
||||
}
|
||||
}
|
||||
let output = ""
|
||||
+ "<div class='container py-3 border-left border-right border-top border-black rounded-0'>"
|
||||
+ " <div class='row mb-2'>"
|
||||
+ " <div class='col-md-10'>"
|
||||
+ " <span class='font-weight-bold'>" + serviceStatus.name + "</span> <span class='text-secondary font-weight-lighter'>- " + hostname + "</span>"
|
||||
+ " </div>"
|
||||
+ " <div class='col-md-2 text-right'>"
|
||||
+ " <span class='font-weight-lighter status-min-max-ms'>" + (minResponseTime === maxResponseTime ? minResponseTime : (minResponseTime + "-" + maxResponseTime)) + "ms</span>"
|
||||
+ " </div>"
|
||||
+ " </div>"
|
||||
+ " <div class='row'>"
|
||||
+ " <div class='col-12 d-flex flex-row-reverse status-over-time'>"
|
||||
+ " " + serviceStatusOverTime
|
||||
+ " </div>"
|
||||
+ " </div>"
|
||||
+ " <div class='row status-time-ago'>"
|
||||
+ " <div class='col-6'>"
|
||||
+ " " + generatePrettyTimeAgo(oldestTimestamp)
|
||||
+ " </div>"
|
||||
+ " <div class='col-6 text-right'>"
|
||||
+ " " + generatePrettyTimeAgo(newestTimestamp)
|
||||
+ " </div>"
|
||||
+ " </div>"
|
||||
+ "</div>";
|
||||
// create an empty entry if this group is new
|
||||
if (!outputByGroup[serviceStatus.group]) {
|
||||
outputByGroup[serviceStatus.group] = "";
|
||||
}
|
||||
outputByGroup[serviceStatus.group] += output;
|
||||
}
|
||||
let output = "";
|
||||
for (let group in outputByGroup) {
|
||||
// Services that don't have a group should be skipped and left for last
|
||||
if (group === 'undefined') {
|
||||
continue
|
||||
}
|
||||
let key = group.replace(/[^a-zA-Z0-9]/g, '');
|
||||
let existingGroupContentSelector = $("#service-group-" + key + "-content");
|
||||
let isCurrentlyHidden = existingGroupContentSelector.length && existingGroupContentSelector[0].style.display === 'none';
|
||||
let groupStatus = "<span class='text-success'>✓</span>";
|
||||
if (outputByGroup[group].includes("badge badge-danger")) {
|
||||
groupStatus = "<span class='text-warning'>~</span>";
|
||||
}
|
||||
output += ""
|
||||
+ "<div class='mt-" + (output.length ? '4' : '3') + "'>"
|
||||
+ " <div class='container pt-2 border-left border-right border-top border-black border-bottom service-group' id='service-group-" + key + "' data-group='" + key + "' onclick='toggleGroup(this)'>"
|
||||
+ " <h5 class='text-secondary text-monospace pb-0'>"
|
||||
+ " " + groupStatus + " " + group
|
||||
+ " <span class='float-right service-group-arrow' id='service-group-" + key + "-arrow'>" + (isCurrentlyHidden ? "▼" : "▲") + "</span>"
|
||||
+ " </h5>"
|
||||
+ " </div>"
|
||||
+ " <div class='service-group-content' id='service-group-" + key + "-content' style='" + (isCurrentlyHidden ? "display: none;" : "") + "'>"
|
||||
+ " " + outputByGroup[group]
|
||||
+ " </div>"
|
||||
+ "</div>";
|
||||
}
|
||||
// Add all services that don't have a group at the end
|
||||
if (outputByGroup['undefined']) {
|
||||
output += ""
|
||||
+ "<div class='mt-" + (output.length ? '4' : '3') + "'>"
|
||||
+ " " + outputByGroup['undefined']
|
||||
+ "</div>"
|
||||
}
|
||||
$("#results").html(output);
|
||||
}
|
||||
|
||||
function toggleGroup(element) {
|
||||
let selector = $("#service-group-" + element.dataset.group + "-content");
|
||||
selector.toggle("fast", function() {
|
||||
if (selector.length && selector[0].style.display === 'none') {
|
||||
$("#service-group-" + element.dataset.group + "-arrow").html("▼");
|
||||
} else {
|
||||
$("#service-group-" + element.dataset.group + "-arrow").html("▲");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function prettifyTimestamp(timestamp) {
|
||||
let date = new Date(timestamp);
|
||||
let YYYY = date.getFullYear();
|
||||
let MM = ((date.getMonth()+1)<10?"0":"")+""+(date.getMonth()+1);
|
||||
let DD = ((date.getDate())<10?"0":"")+""+(date.getDate());
|
||||
let hh = ((date.getHours())<10?"0":"")+""+(date.getHours());
|
||||
let mm = ((date.getMinutes())<10?"0":"")+""+(date.getMinutes());
|
||||
let ss = ((date.getSeconds())<10?"0":"")+""+(date.getSeconds());
|
||||
return YYYY + "-" + MM + "-" + DD + " " + hh + ":" + mm + ":" + ss;
|
||||
}
|
||||
|
||||
function generatePrettyTimeAgo(t) {
|
||||
let differenceInMs = new Date().getTime() - new Date(t).getTime();
|
||||
if (differenceInMs > 3600000) {
|
||||
let hours = (differenceInMs/3600000).toFixed(0);
|
||||
return hours + " hour" + (hours !== "1" ? "s" : "") + " ago";
|
||||
}
|
||||
if (differenceInMs > 60000) {
|
||||
let minutes = (differenceInMs/60000).toFixed(0);
|
||||
return minutes + " minute" + (minutes !== "1" ? "s" : "") + " ago";
|
||||
}
|
||||
return (differenceInMs/1000).toFixed(0) + " seconds ago";
|
||||
}
|
||||
|
||||
function htmlEntities(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function setRefreshInterval(seconds) {
|
||||
refreshStatuses();
|
||||
refreshIntervalHandler = setInterval(function() {
|
||||
refreshStatuses();
|
||||
}, seconds * 1000);
|
||||
}
|
||||
|
||||
$("#refresh-rate").change(function() {
|
||||
clearInterval(refreshIntervalHandler);
|
||||
setRefreshInterval($(this).val());
|
||||
});
|
||||
setRefreshInterval(30);
|
||||
$("#refresh-rate").val(30);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
2
static/jquery.min.js
vendored
2
static/jquery.min.js
vendored
File diff suppressed because one or more lines are too long
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 83 KiB |
8
storage/config.go
Normal file
8
storage/config.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package storage
|
||||
|
||||
// Config is the configuration for alerting providers
|
||||
type Config struct {
|
||||
// File is the path of the file to use for persistence
|
||||
// If blank, persistence is disabled.
|
||||
File string `yaml:"file"`
|
||||
}
|
||||
74
storage/storage.go
Normal file
74
storage/storage.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/TwinProduction/gatus/storage/store"
|
||||
"github.com/TwinProduction/gatus/storage/store/memory"
|
||||
)
|
||||
|
||||
var (
|
||||
provider store.Store
|
||||
|
||||
// initialized keeps track of whether the storage provider was initialized
|
||||
// Because store.Store is an interface, a nil check wouldn't be sufficient, so instead of doing reflection
|
||||
// every single time Get is called, we'll just lazily keep track of its existence through this variable
|
||||
initialized bool
|
||||
|
||||
ctx context.Context
|
||||
cancelFunc context.CancelFunc
|
||||
)
|
||||
|
||||
// Get retrieves the storage provider
|
||||
func Get() store.Store {
|
||||
if !initialized {
|
||||
log.Println("[storage][Get] Provider requested before it was initialized, automatically initializing")
|
||||
err := Initialize(nil)
|
||||
if err != nil {
|
||||
panic("failed to automatically initialize store: " + err.Error())
|
||||
}
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
// Initialize instantiates the storage provider based on the Config provider
|
||||
func Initialize(cfg *Config) error {
|
||||
initialized = true
|
||||
var err error
|
||||
if cancelFunc != nil {
|
||||
// Stop the active autoSave task
|
||||
cancelFunc()
|
||||
}
|
||||
if cfg == nil || len(cfg.File) == 0 {
|
||||
log.Println("[storage][Initialize] Creating storage provider")
|
||||
provider, _ = memory.NewStore("")
|
||||
} 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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go autoSave(7*time.Minute, ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// autoSave automatically calls the SaveFunc function of the provider at every interval
|
||||
func autoSave(interval time.Duration, ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Printf("[storage][autoSave] Stopping active job")
|
||||
return
|
||||
case <-time.After(interval):
|
||||
log.Printf("[storage][autoSave] Saving")
|
||||
err := provider.Save()
|
||||
if err != nil {
|
||||
log.Println("[storage][autoSave] Save failed:", err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
37
storage/storage_test.go
Normal file
37
storage/storage_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
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")
|
||||
}
|
||||
if cancelFunc == nil {
|
||||
t.Error("cancelFunc shouldn't have been nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
t.Error("ctx shouldn't have been nil")
|
||||
}
|
||||
// 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"
|
||||
if err := Initialize(&Config{File: file}); err != nil {
|
||||
t.Fatal("shouldn't have returned an error")
|
||||
}
|
||||
go autoSave(3*time.Millisecond, ctx)
|
||||
time.Sleep(15 * time.Millisecond)
|
||||
cancelFunc()
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
104
storage/store/memory/memory.go
Normal file
104
storage/store/memory/memory.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwinProduction/gatus/util"
|
||||
"github.com/TwinProduction/gocache"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gob.Register(&core.ServiceStatus{})
|
||||
gob.Register(&core.Uptime{})
|
||||
gob.Register(&core.Result{})
|
||||
gob.Register(&core.Event{})
|
||||
}
|
||||
|
||||
// Store that leverages gocache
|
||||
type Store struct {
|
||||
file string
|
||||
cache *gocache.Cache
|
||||
}
|
||||
|
||||
// NewStore creates a new store
|
||||
func NewStore(file string) (*Store, error) {
|
||||
store := &Store{
|
||||
file: file,
|
||||
cache: gocache.NewCache().WithMaxSize(gocache.NoMaxSize),
|
||||
}
|
||||
if len(file) > 0 {
|
||||
_, err := store.cache.ReadFromFile(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// GetAllServiceStatusesWithResultPagination 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 {
|
||||
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)
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
// GetServiceStatusByKey returns the service status for a given key
|
||||
func (s *Store) GetServiceStatusByKey(key string) *core.ServiceStatus {
|
||||
serviceStatus := s.cache.GetValue(key)
|
||||
if serviceStatus == nil {
|
||||
return nil
|
||||
}
|
||||
return serviceStatus.(*core.ServiceStatus)
|
||||
}
|
||||
|
||||
// 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)
|
||||
serviceStatus, exists := s.cache.Get(key)
|
||||
if !exists {
|
||||
serviceStatus = core.NewServiceStatus(service)
|
||||
}
|
||||
serviceStatus.(*core.ServiceStatus).AddResult(result)
|
||||
s.cache.Set(key, serviceStatus)
|
||||
}
|
||||
|
||||
// DeleteAllServiceStatusesNotInKeys removes all ServiceStatus that are not within the keys provided
|
||||
func (s *Store) DeleteAllServiceStatusesNotInKeys(keys []string) int {
|
||||
var keysToDelete []string
|
||||
for _, existingKey := range s.cache.GetKeysByPattern("*", 0) {
|
||||
shouldDelete := true
|
||||
for _, key := range keys {
|
||||
if existingKey == key {
|
||||
shouldDelete = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if shouldDelete {
|
||||
keysToDelete = append(keysToDelete, existingKey)
|
||||
}
|
||||
}
|
||||
return s.cache.DeleteAll(keysToDelete)
|
||||
}
|
||||
|
||||
// Clear deletes everything from the store
|
||||
func (s *Store) Clear() {
|
||||
s.cache.Clear()
|
||||
}
|
||||
|
||||
// Save persists the cache to the store file
|
||||
func (s *Store) Save() error {
|
||||
if len(s.file) > 0 {
|
||||
return s.cache.SaveToFile(s.file)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
272
storage/store/memory/memory_test.go
Normal file
272
storage/store/memory/memory_test.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwinProduction/gatus/util"
|
||||
)
|
||||
|
||||
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 TestStore_Insert(t *testing.T) {
|
||||
store, _ := NewStore("")
|
||||
store.Insert(&testService, &testSuccessfulResult)
|
||||
store.Insert(&testService, &testUnsuccessfulResult)
|
||||
|
||||
if store.cache.Count() != 1 {
|
||||
t.Fatalf("expected 1 ServiceStatus, got %d", store.cache.Count())
|
||||
}
|
||||
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)
|
||||
}
|
||||
if len(serviceStatus.Results) != 2 {
|
||||
t.Fatalf("Service '%s' should've had 2 results, but actually returned %d", serviceStatus.Name, len(serviceStatus.Results))
|
||||
}
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_Save(t *testing.T) {
|
||||
files := []string{
|
||||
"",
|
||||
t.TempDir() + "/test.db",
|
||||
}
|
||||
for _, file := range files {
|
||||
t.Run(file, func(t *testing.T) {
|
||||
store, err := NewStore(file)
|
||||
if err != nil {
|
||||
t.Fatal("expected no error, got", err.Error())
|
||||
}
|
||||
err = store.Save()
|
||||
if err != nil {
|
||||
t.Fatal("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
38
storage/store/store.go
Normal file
38
storage/store/store.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwinProduction/gatus/storage/store/memory"
|
||||
)
|
||||
|
||||
// Store is the interface that each stores should implement
|
||||
type Store interface {
|
||||
// GetAllServiceStatusesWithResultPagination 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
|
||||
|
||||
// GetServiceStatus returns the service status for a given service name in the given group
|
||||
GetServiceStatus(groupName, serviceName string) *core.ServiceStatus
|
||||
|
||||
// GetServiceStatusByKey returns the service status for a given key
|
||||
GetServiceStatusByKey(key string) *core.ServiceStatus
|
||||
|
||||
// Insert adds the observed result for the specified service into the store
|
||||
Insert(service *core.Service, result *core.Result)
|
||||
|
||||
// DeleteAllServiceStatusesNotInKeys removes all ServiceStatus that are not within the keys provided
|
||||
//
|
||||
// Used to delete services that have been persisted but are no longer part of the configured services
|
||||
DeleteAllServiceStatusesNotInKeys(keys []string) int
|
||||
|
||||
// Clear deletes everything from the store
|
||||
Clear()
|
||||
|
||||
// Save persists the data if and where it needs to be persisted
|
||||
Save() error
|
||||
}
|
||||
|
||||
var (
|
||||
// Validate interface implementation on compile
|
||||
_ Store = (*memory.Store)(nil)
|
||||
)
|
||||
137
storage/store/store_bench_test.go
Normal file
137
storage/store/store_bench_test.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwinProduction/gatus/storage/store/memory"
|
||||
)
|
||||
|
||||
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) {
|
||||
memoryStore, err := memory.NewStore("")
|
||||
if err != nil {
|
||||
b.Fatal("failed to create store:", err.Error())
|
||||
}
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Store Store
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "memory",
|
||||
Store: memoryStore,
|
||||
},
|
||||
}
|
||||
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)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkStore_Insert(b *testing.B) {
|
||||
memoryStore, err := memory.NewStore("")
|
||||
if err != nil {
|
||||
b.Fatal("failed to create store:", err.Error())
|
||||
}
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Store Store
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "memory",
|
||||
Store: memoryStore,
|
||||
},
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
b.ReportAllocs()
|
||||
})
|
||||
}
|
||||
}
|
||||
18
util/key.go
Normal file
18
util/key.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package util
|
||||
|
||||
import "strings"
|
||||
|
||||
// ConvertGroupAndServiceToKey converts a group and a service to a key
|
||||
func ConvertGroupAndServiceToKey(group, service string) string {
|
||||
return sanitize(group) + "_" + sanitize(service)
|
||||
}
|
||||
|
||||
func sanitize(s string) string {
|
||||
s = strings.TrimSpace(strings.ToLower(s))
|
||||
s = strings.ReplaceAll(s, "/", "-")
|
||||
s = strings.ReplaceAll(s, "_", "-")
|
||||
s = strings.ReplaceAll(s, ".", "-")
|
||||
s = strings.ReplaceAll(s, ",", "-")
|
||||
s = strings.ReplaceAll(s, " ", "-")
|
||||
return s
|
||||
}
|
||||
36
util/key_test.go
Normal file
36
util/key_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package util
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestConvertGroupAndServiceToKey(t *testing.T) {
|
||||
type Scenario struct {
|
||||
GroupName string
|
||||
ServiceName string
|
||||
ExpectedOutput string
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
GroupName: "Core",
|
||||
ServiceName: "Front End",
|
||||
ExpectedOutput: "core_front-end",
|
||||
},
|
||||
{
|
||||
GroupName: "Load balancers",
|
||||
ServiceName: "us-west-2",
|
||||
ExpectedOutput: "load-balancers_us-west-2",
|
||||
},
|
||||
{
|
||||
GroupName: "a/b test",
|
||||
ServiceName: "a",
|
||||
ExpectedOutput: "a-b-test_a",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.ExpectedOutput, func(t *testing.T) {
|
||||
output := ConvertGroupAndServiceToKey(scenario.GroupName, scenario.ServiceName)
|
||||
if output != scenario.ExpectedOutput {
|
||||
t.Errorf("Expected '%s', got '%s'", scenario.ExpectedOutput, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
15
vendor/cloud.google.com/go/AUTHORS
generated
vendored
15
vendor/cloud.google.com/go/AUTHORS
generated
vendored
@@ -1,15 +0,0 @@
|
||||
# This is the official list of cloud authors for copyright purposes.
|
||||
# This file is distinct from the CONTRIBUTORS files.
|
||||
# See the latter for an explanation.
|
||||
|
||||
# Names should be added to this file as:
|
||||
# Name or Organization <email address>
|
||||
# The email address is not required for organizations.
|
||||
|
||||
Filippo Valsorda <hi@filippo.io>
|
||||
Google Inc.
|
||||
Ingo Oeser <nightlyone@googlemail.com>
|
||||
Palm Stone Games, Inc.
|
||||
Paweł Knap <pawelknap88@gmail.com>
|
||||
Péter Szilágyi <peterke@gmail.com>
|
||||
Tyler Treat <ttreat31@gmail.com>
|
||||
40
vendor/cloud.google.com/go/CONTRIBUTORS
generated
vendored
40
vendor/cloud.google.com/go/CONTRIBUTORS
generated
vendored
@@ -1,40 +0,0 @@
|
||||
# People who have agreed to one of the CLAs and can contribute patches.
|
||||
# The AUTHORS file lists the copyright holders; this file
|
||||
# lists people. For example, Google employees are listed here
|
||||
# but not in AUTHORS, because Google holds the copyright.
|
||||
#
|
||||
# https://developers.google.com/open-source/cla/individual
|
||||
# https://developers.google.com/open-source/cla/corporate
|
||||
#
|
||||
# Names should be added to this file as:
|
||||
# Name <email address>
|
||||
|
||||
# Keep the list alphabetically sorted.
|
||||
|
||||
Alexis Hunt <lexer@google.com>
|
||||
Andreas Litt <andreas.litt@gmail.com>
|
||||
Andrew Gerrand <adg@golang.org>
|
||||
Brad Fitzpatrick <bradfitz@golang.org>
|
||||
Burcu Dogan <jbd@google.com>
|
||||
Dave Day <djd@golang.org>
|
||||
David Sansome <me@davidsansome.com>
|
||||
David Symonds <dsymonds@golang.org>
|
||||
Filippo Valsorda <hi@filippo.io>
|
||||
Glenn Lewis <gmlewis@google.com>
|
||||
Ingo Oeser <nightlyone@googlemail.com>
|
||||
James Hall <james.hall@shopify.com>
|
||||
Johan Euphrosine <proppy@google.com>
|
||||
Jonathan Amsterdam <jba@google.com>
|
||||
Kunpei Sakai <namusyaka@gmail.com>
|
||||
Luna Duclos <luna.duclos@palmstonegames.com>
|
||||
Magnus Hiie <magnus.hiie@gmail.com>
|
||||
Mario Castro <mariocaster@gmail.com>
|
||||
Michael McGreevy <mcgreevy@golang.org>
|
||||
Omar Jarjur <ojarjur@google.com>
|
||||
Paweł Knap <pawelknap88@gmail.com>
|
||||
Péter Szilágyi <peterke@gmail.com>
|
||||
Sarah Adams <shadams@google.com>
|
||||
Thanatat Tamtan <acoshift@gmail.com>
|
||||
Toby Burress <kurin@google.com>
|
||||
Tuo Shan <shantuo@google.com>
|
||||
Tyler Treat <ttreat31@gmail.com>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user