Compare commits

...

254 Commits

Author SHA1 Message Date
TwinProduction
d03271d128 Update TwinProduction/gocache to v1.2.3 2021-06-18 09:56:55 -04:00
Chris
0560b98de4 Update README.md 2021-06-17 18:44:38 -04:00
TwinProduction
ca87547430 Update TwinProduction/gocache to v1.2.2 2021-06-06 14:54:58 -04:00
TwinProduction
e214d56af1 Add errors through result.AddError() 2021-06-05 18:51:51 -04:00
TwinProduction
8997eeef05 Fix #123: Deduplicate result errors 2021-06-05 18:50:24 -04:00
TwinProduction
5e00752c5a Include issue number in scenario name 2021-06-05 18:42:32 -04:00
TwinProduction
f9d132c369 Fix #122: Partially invalid JSONPath ending with string does not return an error 2021-06-05 18:41:42 -04:00
TwinProduction
ca977fefa8 Minor improvements 2021-06-05 16:35:52 -04:00
TwinProduction
d07d3434a6 #120: Add documentation for STARTTLS 2021-06-05 16:35:18 -04:00
gopher-johns
2131fa4412 #120: Add support for StartTLS protocol
* add starttls

* remove starttls from default config

Co-authored-by: Gopher Johns <gopher.johns28@gmail.com>
2021-06-05 15:47:11 -04:00
TwinProduction
81aeb7a48e Fix indentation 2021-06-02 18:59:08 -04:00
TwinProduction
eaf395738d Add quick start spoiler 2021-06-02 18:57:16 -04:00
TwinProduction
f6f1ecf623 Remove "Service auto discovery in Kubernetes" from list of features 2021-06-02 18:41:41 -04:00
TwinProduction
177081cf54 Move image to the feature section 2021-06-02 18:40:46 -04:00
TwinProduction
651bfcba22 Add dark-mode.png 2021-05-31 19:27:20 -04:00
TwinProduction
3cd1953c6c Add dark mode screenshot 2021-05-31 18:54:21 -04:00
Chris
9dd4e7047d Add Discord server badge 2021-05-31 00:21:57 -04:00
Chris
067ab78666 Minor fix 2021-05-30 17:09:43 -04:00
Chris
28acaeb067 Revert "Add discord server badge" 2021-05-30 17:09:28 -04:00
Chris
749aeb9e42 Add Discord server badge
This is temporary, I might remove it later
2021-05-30 16:07:54 -04:00
TwinProduction
8e02572880 Remove unused code 2021-05-28 18:48:17 -04:00
TwinProduction
1f6f0ce426 Fix inconsistent visual issue with settings bar occasionally appearing within the global container 2021-05-28 18:47:15 -04:00
TwinProduction
7bc381b356 Fix typo 2021-05-24 21:46:00 -04:00
Chris
18420c2d60 Merge pull request #115 from TwinProduction/reload-on-update
#29: Automatically reload on configuration file update
2021-05-19 18:14:28 -04:00
TwinProduction
0b4dc34c57 Fix typo 2021-05-19 01:19:02 -04:00
TwinProduction
030212c156 Remove unnecessarily error check 2021-05-19 01:13:23 -04:00
TwinProduction
63b0ac8b35 Improve test coverage 2021-05-19 00:55:03 -04:00
TwinProduction
263b2f0f94 Fix failing tests 2021-05-18 23:27:43 -04:00
TwinProduction
db23bd9073 #29: Automatically reload on configuration file update 2021-05-18 22:29:15 -04:00
TwinProduction
40dc1cc270 Minor update 2021-05-16 23:45:01 -04:00
Chris
67c3bf6e5e Merge pull request #113 from TwinProduction/default-provider-alert
Implement default provider alert
2021-05-15 22:42:26 -04:00
TwinProduction
57ef931d38 Add TestEvalWithArrayOfValuesAndInvalidIndex 2021-05-15 22:38:13 -04:00
TwinProduction
e3038f0e80 Add TestEvalWithInvalidData 2021-05-15 22:26:51 -04:00
TwinProduction
8106832d69 Improve test coverage 2021-05-15 22:24:13 -04:00
TwinProduction
758428b312 Improve test coverage 2021-05-15 22:09:58 -04:00
TwinProduction
77de4c4742 Minor fixes 2021-05-15 21:54:23 -04:00
TwinProduction
a85c5d5486 Close #91: Implement default provider alert 2021-05-15 21:31:32 -04:00
TwinProduction
c7d554efa5 Add Docker build command 2021-05-13 21:24:32 -04:00
TwinProduction
f3afdf2977 Add example service for ICMP/ping 2021-05-13 21:24:22 -04:00
TwinProduction
19a0ba7271 #111: Don't explicitly specify ip4 for ICMP 2021-05-13 21:20:50 -04:00
TwinProduction
4a4c88ae17 Update frontend dependencies 2021-05-11 22:14:56 -04:00
TwinProduction
253e6f8338 Fix typo 2021-05-11 20:50:15 -04:00
TwinProduction
1d412678ff Minor visual improvement 2021-05-09 13:50:00 -04:00
TwinProduction
48c7514fa5 Minor visual improvement 2021-05-09 13:45:54 -04:00
TwinProduction
d7b437595c Add example-dns-query 2021-05-09 13:45:27 -04:00
TwinProduction
50f530a05c Fix #107: Correctly parse placeholder when [BODY] is an array 2021-05-09 13:28:22 -04:00
TwinProduction
2a632e8f87 Close #99: Implement dark theme 2021-05-09 12:59:22 -04:00
TwinProduction
857ad584e7 #104: Add support for HTTP_CLIENT_TIMEOUT_IN_SECONDS (undocumented) 2021-04-30 22:58:14 -04:00
TwinProduction
8b3b2f70bf Add deprecation comment on Uptime.migrateToHourlyStatistics 2021-04-25 19:56:09 -04:00
TwinProduction
4308f2c1ef Add missing comment 2021-04-24 16:59:33 -04:00
TwinProduction
425c93ed8f Add "Why Gatus?" section 2021-04-23 20:47:26 -04:00
TwinProduction
752e82d80b Tidy up comments 2021-04-18 01:01:10 -04:00
TwinProduction
e91462ce41 Unify uptime hourly metrics under Uptime.HourlyStatistics and add metric for response time 2021-04-18 00:51:47 -04:00
TwinProduction
347297a8ea Fix typo 2021-04-17 20:09:10 -04:00
TwinProduction
56dbe2fea0 Minor fix 2021-04-14 23:01:40 -04:00
TwinProduction
ebcca4317d Remove useless newline 2021-04-14 21:25:34 -04:00
TwinProduction
e6355dfee8 Update Go to 1.16 2021-04-13 22:30:50 -04:00
TwinProduction
7309888db5 Add missing timeout 2021-04-11 11:09:01 -04:00
TwinProduction
f60eee86ee Update tailwind and fix configuration 2021-04-06 23:39:53 -04:00
TwinProduction
e46acb885c Add missing newline 2021-03-30 20:01:22 -04:00
Jonah
24da853820 Add Telegram Alerting (#102) 2021-03-30 19:38:34 -04:00
TwinProduction
4e5a86031f Add comment for future breaking change 2021-03-27 21:22:34 -04:00
TwinProduction
7f6f127f4f Default showAverageResponseTime to true in details page too 2021-03-26 18:28:41 -04:00
TwinProduction
12c352254f Implement toggleable average response time (frontend) + Persist refresh interval (frontend) + Update dependencies (frontend) 2021-03-25 21:01:03 -04:00
TwinProduction
2b9d986932 Update date in LICENSE.md 2021-03-23 19:53:49 -04:00
TwinProduction
cdbf5f6c6f Update Go to 1.16 2021-03-21 19:37:17 -04:00
David Chidell
33562e97f4 Add API capabilities to docs (#101) 2021-03-19 21:33:46 -04:00
TwinProduction
c9acc83141 Extract magic number into a constant 2021-03-14 16:52:59 -04:00
TwinProduction
8c4c360472 Minor update 2021-03-14 13:36:54 -04:00
David Chidell
2c8714f1fa Truncate long string when using pattern function (#100)
- Omits verbose responses when using pattern match
- Change contains to match prefix and suffix, add 2nd test
2021-03-14 13:05:16 -04:00
TwinProduction
8ec256edbf Implement has() function to determine if an element at a JSONPath exists 2021-03-10 21:49:13 -05:00
TwinProduction
a48ec41bca Add test for invalid path 2021-03-09 19:39:22 -05:00
TwinProduction
541e0264ab Don't export, persist or retain result body after evaluation 2021-03-08 21:30:11 -05:00
TwinProduction
f945e4b8a2 #93: Gracefully handle breaking change to uptime maps by renaming variables 2021-03-06 15:19:35 -05:00
TwinProduction
076b92a2b4 Minor update 2021-03-05 20:33:06 -05:00
TwinProduction
02e9f74a04 Move alerting configuration documentation under Alerting 2021-03-05 20:25:20 -05:00
TwinProduction
b37dd5e819 Minor update 2021-03-05 00:50:24 -05:00
TwinProduction
1775f80ffe Back to alpine/1.16 (the change in reflected memory usage was due to 1.16's MADV_FREE change after all) 2021-03-05 00:49:58 -05:00
TwinProduction
3187db1e9a Switch gocache to FIFO instead of LRU 2021-03-05 00:40:11 -05:00
TwinProduction
932eab00a0 Test using Docker image with Go 1.15 instead of alpine, which has 1.16 2021-03-05 00:38:40 -05:00
TwinProduction
c842ac2343 Fix memory issue caused by previous shallow copy 2021-03-05 00:19:21 -05:00
TwinProduction
6320237326 Significantly improve uptime calculation 2021-03-04 23:00:30 -05:00
TwinProduction
8fe9d013b5 Close #48: Implement Discord alerting providers 2021-03-04 21:26:17 -05:00
TwinProduction
c094c06e56 Minor update 2021-03-03 22:31:55 -05:00
TwinProduction
f961bf961e Update documentation on alerting providers 2021-03-03 22:30:50 -05:00
TwinProduction
404a3cea64 Remove useless os.Kill because it cannot be caught 2021-03-01 22:35:32 -05:00
TwinProduction
d000460a99 Regenerate static files 2021-02-25 23:14:11 -05:00
TwinProduction
455fae05c1 Minor visual changes on pagination component 2021-02-25 23:11:25 -05:00
Chris C
85dff34350 Merge pull request #90 from TwinProduction/longer-result-history
First implementation of longer result history
2021-02-25 22:47:55 -05:00
TwinProduction
99fa632021 Increase test coverage 2021-02-25 19:02:02 -05:00
TwinProduction
cafcc9d45b Increase test coverage 2021-02-24 23:26:13 -05:00
TwinProduction
dc929dac70 #89: First implementation of longer result history 2021-02-24 22:41:36 -05:00
TwinProduction
42825b62fb Update documentation 2021-02-20 19:00:54 -05:00
TwinProduction
a89bb392ed Minor fix 2021-02-20 19:00:47 -05:00
TwinProduction
e7c4d03c22 Regenerate static files 2021-02-20 18:11:55 -05:00
TwinProduction
4e6bf91651 Update test step 2021-02-20 18:11:29 -05:00
TwinProduction
de31a7a62e Minor improvements 2021-02-20 18:08:00 -05:00
TwinProduction
7f0543ebd2 Minor improvements 2021-02-20 12:52:21 -05:00
TwinProduction
9b893aa4e0 Minor improvements 2021-02-19 20:34:35 -05:00
TwinProduction
50435f4030 Improve tests for alerting providers 2021-02-19 19:06:20 -05:00
TwinProduction
9649d80388 Update description 2021-02-19 18:52:37 -05:00
TwinProduction
8c3ab1eac2 Fix typo for colleague 2021-02-19 09:17:25 -05:00
TwinProduction
cdb5ba080a Minor fix 2021-02-18 23:47:53 -05:00
TwinProduction
0a145da912 Add documentation for the custom alert provider placeholders 2021-02-18 23:18:14 -05:00
TwinProduction
b603cdb0ea Minor changes to the custom alert provider placeholders 2021-02-18 23:17:51 -05:00
TwinProduction
11d1f24ceb Fix typo 2021-02-18 22:28:21 -05:00
Chris C
c74472d332 Merge pull request #85 from roberth1988/ISSUE73-ALERT_TRIGGERED_OR_RESOLVED-Placeholder
Make ALERT_TRIGGERED_OR_RESOLVED placeholder values configurable
2021-02-18 22:28:14 -05:00
Chris C
78a1262e7c Merge pull request #87 from avakarev/respec-system-proxy
Respect system proxy
2021-02-18 22:07:06 -05:00
Andrii Vakarev
7ff8907eda Respect system proxy 2021-02-19 01:03:38 +01:00
Robert Hoppe
1d21f5889d Move away from generic solution to a fixed one 2021-02-18 19:03:12 +01:00
Robert Hoppe
d7d904ae5f Bump up test coverage 2021-02-17 13:45:22 +01:00
Robert Hoppe
7390895514 Extend also tests on config 2021-02-17 13:27:11 +01:00
Robert Hoppe
2873d96b9f Introduce configureable place holders for alerting 2021-02-17 12:39:17 +01:00
TwinProduction
ea9623f695 Use TwinProduction/health for health endpoint 2021-02-12 23:29:21 -05:00
TwinProduction
9cdef02bdc Fix min/max response time issue caused by previous commit 2021-02-12 23:29:07 -05:00
TwinProduction
16229592a2 Fix small issue with min/max response times getting floored instead of rounded 2021-02-12 23:16:23 -05:00
TwinProduction
52ad4ee9e5 Update gocache to v1.2.1 2021-02-05 22:11:25 -05:00
TwinProduction
b47c6dc408 Merge branch 'master' of https://github.com/TwinProduction/gatus into persistence 2021-02-05 20:46:10 -05:00
TwinProduction
8e2a2c4dbc Implement graceful shutdown
- Shutdown the HTTP server before exiting
- Persist data to store before exiting, if applicable
2021-02-05 20:45:28 -05:00
TwinProduction
8698736e7d Add .svg suffix to badge image url 2021-02-05 19:58:20 -05:00
Chris C
cd1430f043 Merge pull request #83 from TwinProduction/persistence
Implement persistence
2021-02-02 23:23:43 -05:00
TwinProduction
79bef8d391 Implement persistence 2021-02-02 23:06:34 -05:00
TwinProduction
9196f57487 Add several tests 2021-02-01 01:37:56 -05:00
TwinProduction
1e0d9e184c Fix typo 2021-01-31 23:29:48 -05:00
Chris C
bc42497cb7 Merge pull request #81 from menduz/patch-1
fix: metrics were not working
2021-01-31 22:35:27 -05:00
Agustin Mendez
b0b0ab574d fix: metrics were not working 2021-01-31 23:45:32 -03:00
TwinProduction
e369484e5f Fix issue with event icons not displaying on mobile 2021-01-31 20:38:05 -05:00
TwinProduction
d18618449f Rebuild static files 2021-01-31 18:04:22 -05:00
TwinProduction
9186049589 Minor responsiveness improvements 2021-01-31 18:02:13 -05:00
TwinProduction
4362831f71 Improve responsiveness on smaller screens 2021-01-31 17:55:04 -05:00
TwinProduction
99f558d43e Fix minor responsiveness issue 2021-01-31 17:54:14 -05:00
TwinProduction
4a3d5944b6 Initialize Tooltip with hidden set to true 2021-01-31 17:01:02 -05:00
TwinProduction
688456d7cf Minor fix 2021-01-31 16:47:40 -05:00
TwinProduction
431fb3e9f2 Make title smaller on smaller screens 2021-01-31 16:47:31 -05:00
TwinProduction
a1679ddc5e Remove web.context-root 2021-01-31 05:49:01 -05:00
TwinProduction
d8d8e8720b Remove useless rule 2021-01-31 01:34:43 -05:00
TwinProduction
4a0d9d058a Fix documentation on badges 2021-01-31 01:25:06 -05:00
TwinProduction
c8ccf9b352 Minor improvements 2021-01-30 21:17:17 -05:00
TwinProduction
45c966fbca Undo changes to default config.yaml 2021-01-30 20:00:54 -05:00
TwinProduction
d8d4756ef3 Fix header in README.md 2021-01-30 19:49:31 -05:00
TwinProduction
1e9c54cc0f minor fixes 2021-01-30 00:23:12 -05:00
TwinProduction
80570688e1 Rebuild static files 2021-01-30 00:08:50 -05:00
TwinProduction
43e6e3e8f5 Fix Dockerfile 2021-01-29 23:10:20 -05:00
TwinProduction
8b4c5c20f3 Add docker-build-and-run 2021-01-29 23:10:11 -05:00
TwinProduction
6f6db36b0f Minor updates 2021-01-29 23:10:01 -05:00
Chris C
467874de10 Merge pull request #80 from TwinProduction/vue
Migrate frontend to Vue + Add service detail page
2021-01-29 22:06:30 -05:00
TwinProduction
8337f41425 Format stuffs 2021-01-28 23:52:01 -05:00
TwinProduction
601d676e34 Replace old static folder with new static folder 2021-01-28 23:25:29 -05:00
TwinProduction
fbb5d48bf7 Add events to service detail page 2021-01-28 22:44:31 -05:00
TwinProduction
119b80edc0 Add section for badges 2021-01-27 18:46:51 -05:00
TwinProduction
99e8cfb1ce Changed badge path to leverage ServiceStatus.Key 2021-01-27 18:26:07 -05:00
TwinProduction
dcbbec7931 Add page for individual service details 2021-01-27 18:25:37 -05:00
TwinProduction
2ccd656386 Add SERVER_URL constant based on environment 2021-01-25 22:05:19 -05:00
TwinProduction
5755f3a699 Minor fix 2021-01-25 20:57:05 -05:00
TwinProduction
911d809376 Create Makefile 2021-01-25 20:56:02 -05:00
TwinProduction
752c872d3b Rename json parameter condition-results to conditionResults 2021-01-25 20:55:49 -05:00
TwinProduction
67a3e4e330 Add tooltip 2021-01-25 20:54:57 -05:00
TwinProduction
668ed3b1a2 Migrate service group collapsing feature 2021-01-24 05:28:29 -05:00
TwinProduction
dc6cb8fc1d Start working on migrating frontend to Vue 3 2021-01-24 04:50:58 -05:00
TwinProduction
f1aa5191bf Add development CORS header when the "ENVIRONMENT" environment variable is set to "dev" 2021-01-24 04:48:07 -05:00
TwinProduction
bc6ca2ebd0 Migrate from Bootstrap to Tailwind 2021-01-23 23:39:26 -05:00
TwinProduction
30801938b2 Minor fix 2021-01-23 17:22:18 -05:00
TwinProduction
ddddd405bb Add blank status badges on entries that haven't been filled yet 2021-01-23 17:18:18 -05:00
TwinProduction
2207dd9c32 Fix test 2021-01-21 16:34:40 -05:00
TwinProduction
3204a79eb6 Lazily retry triggered alerts in case of failure 2021-01-21 16:14:32 -05:00
TwinProduction
e9ac115a95 Replace ✔️ by 2021-01-20 17:47:21 -05:00
TwinProduction
c90c786f39 Tweak build action 2021-01-19 00:01:55 -05:00
TwinProduction
f10e2ac639 Tweak build action 2021-01-18 23:55:41 -05:00
TwinProduction
c2d899f2a3 Tweak build action 2021-01-18 23:52:48 -05:00
TwinProduction
7415d8e361 Tweak build action 2021-01-18 23:47:17 -05:00
TwinProduction
298dcc4790 Tweak build action 2021-01-18 23:43:02 -05:00
TwinProduction
2f2890c093 Tweak build action 2021-01-18 23:38:23 -05:00
TwinProduction
e463aec5f6 Tweak build action 2021-01-18 23:34:16 -05:00
TwinProduction
6b3e11a47c Minor update 2021-01-18 23:28:33 -05:00
TwinProduction
0985e3bed8 Minor update 2021-01-17 16:38:22 -05:00
TwinProduction
6d8fd267de Fix mattermost docker-compose example 2021-01-16 20:36:59 -05:00
TwinProduction
e89bb932ea Fix pattern issue 2021-01-15 20:11:43 -05:00
TwinProduction
77737dbab6 Add TestCondition_evaluateWithBodyHTMLPattern 2021-01-15 19:45:17 -05:00
TwinProduction
271c3dc91d Performance improvements 2021-01-14 22:49:48 -05:00
TwinProduction
5860a27ab5 Improve existing tests 2021-01-14 22:49:19 -05:00
TwinProduction
819093cb7e Implement any function and prettify displayed condition on failure 2021-01-14 20:08:27 -05:00
TwinProduction
855c106e9b Reduce ping timeout during test 2021-01-12 22:19:19 -05:00
TwinProduction
04de262268 Add comment for pingTimeout 2021-01-12 21:37:21 -05:00
TwinProduction
26d8870cab Improve test coverage 2021-01-12 21:26:28 -05:00
TwinProduction
aec867ae69 Fix #72: Connected placeholder shouldn't resolve to true when when host is unreachable 2021-01-12 21:08:18 -05:00
TwinProduction
a515335c15 Improve test coverage 2021-01-10 01:32:50 -05:00
TwinProduction
96dd9809f4 Disable patch status 2021-01-10 01:27:20 -05:00
TwinProduction
20b4c86023 Improve test coverage 2021-01-10 01:22:27 -05:00
TwinProduction
6f8a728c5f Improve test coverage 2021-01-10 00:24:31 -05:00
TwinProduction
1669f91a2d Improve test coverage 2021-01-09 23:52:11 -05:00
TwinProduction
3d265afa37 Minor update 2021-01-09 23:09:35 -05:00
TwinProduction
aa050e9292 Add codecov.yml 2021-01-09 23:06:30 -05:00
TwinProduction
91a9fa5274 Improve testing coverage 2021-01-09 22:55:36 -05:00
TwinProduction
150e33a1c7 Update comment 2021-01-09 21:14:54 -05:00
TwinProduction
da9a6282c7 Fix test 2021-01-09 01:17:16 -05:00
TwinProduction
907b611505 Use a fixed date instead 2021-01-08 23:40:43 -05:00
TwinProduction
eaf205eded Minor fix 2021-01-08 22:56:13 -05:00
TwinProduction
329bd86e09 Replace GetAll by GetAllAsJSON and change storage package implementation 2021-01-08 22:41:57 -05:00
TwinProduction
19bb831fbf Update test to prevent this from happening again 2021-01-08 17:43:45 -05:00
TwinProduction
bca38bd372 Fix #70: Success always showing as true 2021-01-08 17:25:55 -05:00
TwinProduction
9095649afb Minor update 2021-01-04 23:38:47 -05:00
TwinProduction
4ff391bcf3 Minor improvements 2021-01-04 18:28:12 -05:00
TwinProduction
c27cb7af08 Fix issue with backslashes when using pattern function 2021-01-04 18:01:39 -05:00
TwinProduction
f1c0bbe73c Minor update 2021-01-04 18:00:36 -05:00
Chris C
2b4fc7138a Merge pull request #67 from cjheppell/split-memory-map-out-of-watchdog
Break watchdog into watchdog and storage (in-memory store)
2020-12-31 17:40:52 -05:00
Chris Heppell
7f647305ce Update storage/memory.go
Co-authored-by: Chris C. <twin@twinnation.org>
2020-12-31 22:31:51 +00:00
Chris Heppell
fb5477f50b inline json.Marshal return 2020-12-31 22:00:38 +00:00
Chris Heppell
fc4858b1a8 Update storage/memory.go
Co-authored-by: Chris C. <twin@twinnation.org>
2020-12-31 21:58:24 +00:00
Chris Heppell
0af8837710 return pointer for consistency 2020-12-31 20:49:13 +00:00
Chris Heppell
8ca9fd7db5 make each memory store struct have its own internal map
effectively removing the global state
2020-12-31 20:43:54 +00:00
Chris Heppell
029c87df89 add tests for new func 2020-12-31 20:43:54 +00:00
Chris Heppell
4d24a4d647 add func to store for getting single service status
and use that in the watchdog
2020-12-31 20:39:11 +00:00
Chris Heppell
5eb289c4d3 rename GetJSONEncodedServiceStatuses -> GetServiceStatusesAsJSON 2020-12-31 20:28:57 +00:00
Chris Heppell
dbd95b1bbd remove no longer valid comment
The results returned from the in-memory map are copies, so there's no concern over concurrent map access anymore, as the internal memory-map is hidden and inaccessible to callers
2020-12-31 12:13:06 +00:00
Chris Heppell
678b78ac01 off-topic: add artifact to gitignore 2020-12-31 12:13:06 +00:00
Chris Heppell
f8e1fc25a4 use the new store in the watchdog 2020-12-31 12:13:06 +00:00
Chris Heppell
c3bc375ff1 add memory tests 2020-12-31 12:13:06 +00:00
Chris Heppell
8ecaf4cfd5 create an in-memory store implementation 2020-12-31 11:53:53 +00:00
TwinProduction
143e6cb0da Improve test coverage 2020-12-30 20:07:07 -05:00
TwinProduction
be75ef9c58 Work on #58: Add default "User-Agent: Gatus/1.0" header 2020-12-30 19:56:12 -05:00
TwinProduction
118b62c979 Revert "Revert changes that were accidentally committed" but default to showing status on hover 2020-12-30 01:20:22 -05:00
TwinProduction
d8cfcd04f1 Revert changes that were accidentally committed 2020-12-30 01:18:30 -05:00
TwinProduction
641c2029f7 Switch to using TwinProduction/gocache for caching service status 2020-12-30 01:08:20 -05:00
TwinProduction
1209c477df Minor fix 2020-12-30 01:07:40 -05:00
TwinProduction
6ca1e39b90 Minor update 2020-12-30 01:04:58 -05:00
TwinProduction
d206d63151 Minor updates 2020-12-29 21:04:07 -05:00
TwinProduction
bc4380db19 Work on #62: Add uptime percentage + badges 2020-12-29 20:22:17 -05:00
TwinProduction
3eb8299316 Fix potential issue with integration DNS and NS fallbacks
Because there are often multiple nameservers backing a single domain, we
need to ensure that the test passes even if the nameserver returned isn't
the primary name server
2020-12-29 17:31:43 -05:00
TwinProduction
a83f6c7619 Remove unnecessary blank lines 2020-12-29 17:30:01 -05:00
TwinProduction
bbf39e70fd Improve test coverage 2020-12-29 17:27:58 -05:00
TwinProduction
ddbf391c39 Rename TestGetHttpClient to TestGetHTTPClient 2020-12-28 17:19:41 -05:00
TwinProduction
2eca1ab145 Get the round-trip time directly from the pinger 2020-12-27 17:07:50 -05:00
TwinProduction
f0995f696f Run tests as privileged user for ICMP test to pass 2020-12-25 03:09:06 -05:00
TwinProduction
746032397e Use 127.0.0.1 in ICMP test 2020-12-25 03:05:47 -05:00
TwinProduction
cf923af230 Fix dependencies 2020-12-25 03:02:44 -05:00
TwinProduction
416178fb28 Fix dependencies 2020-12-25 03:00:08 -05:00
TwinProduction
10ab9265d9 Fix dependencies 2020-12-25 02:50:21 -05:00
TwinProduction
ef7b435f1b Remove comment added by mistake 2020-12-25 02:43:54 -05:00
TwinProduction
dea469d6fc Pin github.com/googleapis/gnostic to v0.4.0
See https://github.com/google/gnostic/pull/155
2020-12-25 02:40:39 -05:00
TwinProduction
83a5813daf Work on #61: Add support for ICMP
+ Update dependencies
2020-12-25 00:07:18 -05:00
TwinProduction
c86173d46f Fix typo 2020-12-24 00:28:44 -05:00
TwinProduction
c23ba7b85d Update documentation 2020-12-18 18:40:20 -05:00
TwinProduction
0541adec5e Add TestService_buildHTTPRequestWithGraphQLEnabled test 2020-12-18 18:40:11 -05:00
TwinProduction
eee5bc8f9d Automatically add Content-Type: application/json header for GraphQL requests, unless a different Content-Type is already defined 2020-12-18 18:39:22 -05:00
TwinProduction
4d186c6e71 Improve test coverage 2020-12-18 18:06:57 -05:00
TwinProduction
4be2273662 Minor update 2020-12-18 18:06:50 -05:00
TwinProduction
ef9ba10e45 Fix #60: Fix undesired behavior when setting Host header 2020-12-18 17:37:03 -05:00
TwinProduction
b35747bf31 Improve documentation 2020-12-13 17:24:16 -05:00
TwinProduction
8cd82605dd Improve documentation 2020-12-12 23:32:26 -05:00
TwinProduction
7f0b862822 Refactor imports 2020-12-10 20:00:42 -05:00
TwinProduction
7f9fe9acdf Rename buildRequest to buildHTTPRequest 2020-12-08 20:03:59 -05:00
TwinProduction
46d476128d Add missing newline 2020-12-05 18:21:35 -05:00
TwinProduction
e309ffb1b6 Don't push latest 2020-12-04 11:26:51 -05:00
TwinProduction
48a1e435e4 Remove linux/arm/v6 2020-12-04 11:16:53 -05:00
1179 changed files with 219036 additions and 88159 deletions

View File

@@ -3,3 +3,4 @@ Dockerfile
.github
.idea
.git
web/app

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

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

@@ -0,0 +1,6 @@
ignore:
- "watchdog/watchdog.go"
coverage:
status:
patch: off

View File

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

View File

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

@@ -1,3 +1,5 @@
bin
.idea
.vscode
gatus
db.db
config/config.yml

View File

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

View File

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

14
Makefile Normal file
View 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

1677
README.md

File diff suppressed because it is too large Load Diff

69
alerting/alert/alert.go Normal file
View 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
}

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -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">&#x21bb;</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 ? "&#10003;" : "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;'>&#10003;</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'>&#10003;</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 ? "&#9660;" : "&#9650;") + "</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("&#9660;");
} else {
$("#service-group-" + element.dataset.group + "-arrow").html("&#9650;");
}
});
}
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
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>

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

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

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

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

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

View File

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