Compare commits

...

529 Commits

Author SHA1 Message Date
TwiN
31bf2aeb80 Update TwiN/health to v1.1.0 2021-11-15 20:11:13 -05:00
TwiN
787f6f0d74 Add feedback email address 2021-11-12 00:32:11 -05:00
TwiN
17a431321c Pass http.NoBody instead of nil as body 2021-11-11 00:14:00 -05:00
TwiN
05e9add16d Regenerate static assets 2021-11-09 00:16:48 -05:00
TwiN
c4ef56511d Update dependencies 2021-11-09 00:07:44 -05:00
TwiN
cfa2c8ef6f Minor updates 2021-11-09 00:06:41 -05:00
TwiN
f36b6863ce Minor update 2021-11-08 23:54:06 -05:00
TwiN
24482cf7a0 Fix icon_url for Mattermost 2021-11-08 21:07:16 -05:00
TwiN
d661a0ea6d Add logo.png in .github/assets 2021-11-08 21:05:16 -05:00
TwiN
a0ec6941ab Display number of days rather than hours if >72h 2021-11-08 20:57:58 -05:00
TwiN
5e711fb3b9 Use http.Error instead of writer.Write 2021-11-08 20:56:35 -05:00
TwiN
ab66e7ec8a Fix badge examples 2021-11-08 02:22:43 -05:00
TwiN
08aba6cd51 Minor updates 2021-11-04 21:40:05 -04:00
TwiN
d3805cd77a Fix #197; Fix #198: Deprecate storage.file in favor of storage.path and deprecate persistence with memory storage type 2021-11-04 21:33:13 -04:00
TwiN
dd70136e6c Omit empty hostname and errors field 2021-11-03 22:18:23 -04:00
TwiN
a94c480c22 Fix typo in comment 2021-11-03 22:17:58 -04:00
TwiN
10fd4ecd6b Minor fixes 2021-11-03 19:48:58 -04:00
TwiN
9287e2f9e2 Move store initialization to store package
This will allow importing storage.Config without importing every SQL drivers in the known universe
2021-10-28 19:35:46 -04:00
TwiN
257f859825 Rename getPagerDutyIntegrationKeyForGroup to getIntegrationKeyForGroup 2021-10-27 23:16:05 -04:00
TwiN
3a4ab62ddd #191: Handle memory issue caused by migration from Service to Endpoint 2021-10-24 21:20:01 -04:00
TwiN
a4e9d8e9b0 Revert "Add GATUS_DONT_EXPAND_ENV env var" 2021-10-24 18:34:39 -04:00
TwiN
3be6d04d29 Add GATUS_DONT_EXPAND_ENV env var 2021-10-24 16:20:24 -04:00
TwiN
b59ff6f89e Add ServiceAccount to Kubernetes example 2021-10-24 15:33:15 -04:00
TwiN
813fea93ee #167: Rename examples/minimal to .examples/docker-minimal 2021-10-24 15:27:25 -04:00
TwiN
8f50e44b45 #167: Rename examples/ to .examples/ 2021-10-24 15:20:39 -04:00
TwiN
fb2448c15a Omit fields that are not set 2021-10-24 15:03:41 -04:00
TwiN
db575aad13 Remove comments that no longer apply 2021-10-24 14:51:49 -04:00
TwiN
6ed93d4b82 Rename Service to Endpoint (#192)
* Add clarifications in comments

* #191: Rename Service to Endpoint
2021-10-23 16:47:12 -04:00
TwiN
634123d723 Add support for armv6 2021-10-18 12:16:31 -04:00
TwiN
75c25ac053 Don't support garbage browser versions 2021-10-08 00:21:29 -04:00
TwiN
8088736d6e Fix workflow 2021-10-07 22:42:37 -04:00
TwiN
6c45f5b99c ⚠ Migrate TwinProduction/gatus to TwiN/gatus 2021-10-07 21:28:04 -04:00
TwiN
422eaa6d37 Fix typo 2021-10-07 20:55:15 -04:00
TwinProduction
c423afb0bf Fix #182: Fix ICMP on Docker Linux 2021-10-07 01:21:13 -04:00
TwinProduction
835f768337 Shorten comment 2021-10-07 01:08:42 -04:00
TwinProduction
b3d0e54af2 Minor update 2021-10-07 01:08:17 -04:00
TwinProduction
1451cdfa64 Fix typo 2021-10-05 22:36:08 -04:00
TwinProduction
53cc9d88e5 Minor update 2021-10-05 20:44:18 -04:00
TwinProduction
a6bc0039e9 Rename integrations to overrides 2021-10-05 20:40:44 -04:00
achrefbensaadVPaccount
adbc2c5ad7 Add group-specific integration key for PagerDuty (#181)
* Added support for pagerduty integration per group

* Added pagerduty per group tests

* bugfix: if no team is provided and no general integration is provided return the first pagerduty integration in team integrations

* Updated README

* Update README.md

Co-authored-by: Chris <twin@twinnation.org>

* Update alerting/provider/pagerduty/pagerduty.go

Co-authored-by: Chris <twin@twinnation.org>

* Update alerting/provider/pagerduty/pagerduty.go

Co-authored-by: Chris <twin@twinnation.org>

Co-authored-by: Achref Ben Saad <achref.bensaad@cimpress.com>
Co-authored-by: Chris <twin@twinnation.org>
2021-10-05 20:01:36 -04:00
TwinProduction
154bc7dbc6 Update dependencies 2021-10-03 22:15:20 -04:00
TwinProduction
2d3fe9795f Add v3 to module path
Gatus wasn't intended to be used as a library, but I have a use case now.
2021-10-03 21:53:59 -04:00
TwinProduction
d19f564e4e Ensure that tested hour never goes negative 2021-10-03 21:50:58 -04:00
TwinProduction
babe7b0be9 Update Go to 1.17 2021-10-03 15:03:09 -04:00
TwinProduction
dee04945d0 Minor update 2021-10-03 15:03:09 -04:00
TwinProduction
bf455fb7cc Fix issue with under maintenance feature 2021-10-01 02:33:37 -04:00
TwinProduction
dfd2f7943f Fix issue with privileged call on linux 2021-10-01 02:33:16 -04:00
TwinProduction
fece11540b Remove unnecessary rows.Close() calls 2021-09-30 21:19:57 -04:00
TwinProduction
ac43ef4ab7 Refactor some code 2021-09-30 20:56:09 -04:00
TwinProduction
bc25fea1c0 Minor improvements 2021-09-30 20:45:47 -04:00
Carlotronics
30cb7b6ec8 Health check for SSL/TLS services (#177)
* protocol: starttls: add timeout support

Signed-off-by: Charles Decoux <charles@phowork.fr>

* protocol: add ssl support

Signed-off-by: Charles Decoux <charles@phowork.fr>
2021-09-30 16:15:17 -04:00
TwinProduction
289d834587 Change domain 2021-09-28 18:54:40 -04:00
TwinProduction
428e415616 Fix issue with under maintenance 2021-09-27 00:11:42 -04:00
TwinProduction
0d284c2494 Update front-end dependencies 2021-09-25 13:39:54 -04:00
TwinProduction
4a46a5ae9e Rename security.go and security_test.go to config.go and config_test.go 2021-09-22 00:53:13 -04:00
TwinProduction
df3a2016ff Move web and ui configurations in their own packages 2021-09-22 00:47:51 -04:00
TwinProduction
dda83761b5 Minor fix 2021-09-22 00:11:46 -04:00
TwinProduction
882444e0d5 Minor fix 2021-09-22 00:11:22 -04:00
TwinProduction
fa4736c672 Close #74: Add maintenance window 2021-09-22 00:04:51 -04:00
TwinProduction
dc173b29bc Fix indention 2021-09-18 21:43:19 -04:00
TwinProduction
c3a4ce1eb4 Minor update 2021-09-18 13:04:50 -04:00
TwinProduction
044f0454f8 Domain migration 2021-09-18 12:42:11 -04:00
newsr
9bd5c38a96 Add enabled parameter to service (#175)
* feat: Add enabled flag to service
* Add IsEnabled method

Co-authored-by: 1newsr <1newsr@users.noreply.github.com>
2021-09-18 11:52:11 -04:00
TwinProduction
d6b4c2394a Update frontend dependencies 2021-09-16 22:47:03 -04:00
TwinProduction
9fe4678193 Update line separator 2021-09-16 22:35:22 -04:00
TwinProduction
f41560cd3e Add configuration for whether to resolve failed conditions or not 2021-09-14 19:34:46 -04:00
TwinProduction
d7de795a9f Retrieve metrics from the past 2 hours for badges with a duration of 1h 2021-09-13 23:29:35 -04:00
TwinProduction
f79e87844b Update diagram 2021-09-12 18:55:38 -04:00
TwinProduction
c57a930bf3 Refactor controller and handlers 2021-09-12 18:39:09 -04:00
TwinProduction
d86afb2381 Refactor handler errors 2021-09-12 17:06:14 -04:00
TwinProduction
d69df41ef0 Ensure connection to database by pinging it once before creating the schema 2021-09-11 22:42:56 -04:00
TwinProduction
cbfdc359d3 Postgres performance improvement 2021-09-11 17:49:31 -04:00
TwinProduction
f3822a949d Expose postgres port 2021-09-11 17:48:50 -04:00
TwinProduction
db5fc8bc11 #77: Make page logo customizable 2021-09-11 04:33:14 -04:00
TwinProduction
7a68920889 #77: Make page title customizable 2021-09-11 01:51:14 -04:00
TwinProduction
effad21c64 Uniformize docker-compose files 2021-09-10 20:03:51 -04:00
TwinProduction
dafd547656 Add example for using Postgres 2021-09-10 19:21:48 -04:00
TwinProduction
20487790ca Improve test coverage with edge cases made possible with Postgres 2021-09-10 19:01:44 -04:00
TwinProduction
b58094e10b Exclude storage/store/sql/specific_postgres.go from test coverage 2021-09-10 19:01:44 -04:00
TwinProduction
bacf7d841b Close #124: Add support for Postgres as a storage solution 2021-09-10 19:01:44 -04:00
TwinProduction
06ef7f9efe Add test for NewEventFromResult 2021-09-06 16:34:03 -04:00
TwinProduction
bfbe928173 Fix uptime badge 2021-09-06 15:06:30 -04:00
TwinProduction
7887ca66bc #151: Add small note about binding a file instead of a folder 2021-09-06 13:28:35 -04:00
TwinProduction
a917b31591 Add log in case an error happens while updating the last config modification time 2021-09-06 13:28:35 -04:00
TwinProduction
556f559221 Remove Kubernetes auto discovery 2021-09-06 13:28:35 -04:00
TwinProduction
670e35949e Sort results alphabetically when returning all service statuses 2021-09-06 13:28:35 -04:00
TwinProduction
67642b130c Remove deprecated endpoints 2021-09-06 13:28:35 -04:00
TwinProduction
7c9e2742c1 Remove deprecated parameters from alerting providers 2021-09-06 13:28:35 -04:00
TwinProduction
66e312b72f Remove old memory uptime implementation and auto migration 2021-09-06 13:28:35 -04:00
TwinProduction
6e38114e27 Remove deprecated service[].insecure parameter (in favor of service[].client.insecure) 2021-09-06 13:28:35 -04:00
TwinProduction
9c99cc522d Close #159: Add the ability to hide the hostname of a service 2021-09-06 13:28:35 -04:00
TwinProduction
becc17202b Remove uptime from /api/v1/services/{key}/statuses and return the entire service status instead of a map 2021-09-06 13:28:35 -04:00
TwinProduction
c61b406483 Return array instead of map on /api/v1/services/statuses 2021-09-06 13:28:35 -04:00
TwinProduction
44c36a8a5e Remove kubernetes auto discovery example 2021-09-06 13:28:35 -04:00
TwinProduction
cfa7b0ed51 Fix badges 2021-09-02 09:28:11 -04:00
TwinProduction
ce433b57e0 Minor fixes 2021-08-29 19:12:03 -04:00
TwinProduction
d67c2ec251 Add Discord badge 2021-08-28 23:47:05 -04:00
TwinProduction
74e7bdae8c Add a few targets to Makefile 2021-08-23 20:40:13 -04:00
TwinProduction
8b0f432ffb Update API endpoints in documentation 2021-08-23 20:39:34 -04:00
TwinProduction
2577b196be Update documentation 2021-08-21 23:38:41 -04:00
TwinProduction
30b17f7bca Remove hourlyAverageResponseTime from serviceStatusHandler 2021-08-21 21:27:33 -04:00
TwinProduction
a626b00b59 Always display every hour in the duration specified on the chart 2021-08-21 21:27:33 -04:00
TwinProduction
0e7f1d19f4 Use new endpoint to retrieve statuses 2021-08-21 21:27:33 -04:00
TwinProduction
82d697b032 Generate chart on the backend instead of using obnoxiously large frontend library 2021-08-21 18:12:06 -04:00
TwinProduction
470e3a3ebc Add response time badge and chart 2021-08-21 18:12:06 -04:00
TwinProduction
bab69478dd Improve comment on HourlyUptimeStatistics.TotalExecutionsResponseTime 2021-08-21 18:12:06 -04:00
TwinProduction
f28d1b61f0 Add tests for GetAverageResponseTimeByKey 2021-08-21 18:12:06 -04:00
TwinProduction
75d8b40327 Add GetAverageResponseTimeByKey method on store for response time badges 2021-08-21 18:12:06 -04:00
TwinProduction
e8adc75afe Minor fix 2021-08-19 23:38:33 -04:00
TwinProduction
6942f0f8e0 Add response time chart 2021-08-19 23:12:48 -04:00
TwinProduction
733760dc06 Improve badge colors 2021-08-19 23:12:48 -04:00
TwinProduction
1a8452f375 Improve badge colors 2021-08-19 23:12:48 -04:00
TwinProduction
1cbee5b732 Update dependencies 2021-08-19 23:12:48 -04:00
TwinProduction
d65cebb1fb Remove Uptime.Last* parameters 2021-08-13 01:25:50 -04:00
TwinProduction
0b6fc6b520 Add GetUptimeByKey to store interface 2021-08-13 01:25:50 -04:00
TwinProduction
968b960283 Add memory.db to .gitignore 2021-08-13 01:25:50 -04:00
TwinProduction
77ba2169cf Close #125: Add more uptime badge colors 2021-08-11 21:05:51 -04:00
TwinProduction
f6c32a90ac Add small comment 2021-08-10 19:58:19 -04:00
TwinProduction
932a67d9e7 Update list of sponsors 2021-08-09 19:03:07 -04:00
TwinProduction
ee414df03f Add missing comment 2021-08-08 20:16:12 -04:00
TwinProduction
718f8260bb Register missing struct 2021-08-07 12:22:39 -04:00
TwinProduction
3cbe068fc1 Rename storage type inmemory to memory
This is technically a breaking change, but given how long ago this field was implemented as well as the fact that this is the default value if the type is not specified, I doubt anybody's explicitly setting it as inmemory
2021-08-07 12:11:35 -04:00
TwinProduction
4ada6ee7c9 Remove unneeded constants 2021-08-07 11:54:22 -04:00
TwinProduction
1e28905c8d Add clarification on hack 2021-08-07 11:46:58 -04:00
TwinProduction
4dbde07b85 Fix typo 2021-07-30 18:57:43 -04:00
TwinProduction
8f35679299 Improve documentation formatting 2021-07-30 18:56:05 -04:00
TwinProduction
897e1590ac Improve documentation 2021-07-30 18:46:07 -04:00
TwinProduction
48ef7c7313 #126: Update mistake in documentation 2021-07-30 12:38:29 -04:00
TwinProduction
941cc03f19 Add high level diagram 2021-07-30 00:34:07 -04:00
TwinProduction
a4c429a0e0 Update comment for testing purposes 2021-07-29 20:09:42 -04:00
TwinProduction
2074697efa Improve alerting tests 2021-07-29 19:54:40 -04:00
Chris
2ce02b0d7f Merge pull request #143 from zeylos/feature/teams_alert_provider
Add Microsoft Teams alerting provider
2021-07-29 19:16:03 -04:00
TwinProduction
8edca65041 Only trigger publish-latest on successful build in master 2021-07-29 19:14:48 -04:00
TwinProduction
cdbc075439 Fix #146: Alerting causes panic with some providers 2021-07-29 18:13:37 -04:00
Bastien
949fd65cb7 Minor fix on README.md
Fixed the webhook-url example for Teams
2021-07-29 11:12:53 +02:00
Bastien
54d06b8688 Merge branch 'master' into feature/teams_alert_provider 2021-07-29 11:01:53 +02:00
TwinProduction
07b1a2eafb Minor fix 2021-07-28 22:30:34 -04:00
TwinProduction
d3e0ef6519 Add workflow to publish latest on successful build 2021-07-28 22:25:25 -04:00
TwinProduction
9cd6355056 #126: Add client configuration 2021-07-28 21:52:14 -04:00
Bastien Ogier
7416384efe Revert config.yaml modification 2021-07-28 14:28:31 +02:00
Bastien Ogier
23fb69fca9 Add teams alerting provider 2021-07-28 14:20:53 +02:00
TwinProduction
be4e9aba1e Add section about deployment using Terraform 2021-07-27 21:36:11 -04:00
TwinProduction
ac0d00fdb5 Add notice in README possible deprecation of Kubernetes integration 2021-07-27 21:10:27 -04:00
TwinProduction
3293222cd6 Add warning log about possible deprecation of Kubernetes integration 2021-07-27 21:08:30 -04:00
TwinProduction
892f3ada6f Add tests for paging package 2021-07-24 22:15:59 -04:00
TwinProduction
f22a79eb7d Minor update 2021-07-24 21:08:55 -04:00
TwinProduction
911deb91d1 Add list of sponsors 2021-07-24 21:07:26 -04:00
TwinProduction
bcd4105af3 Fix potential race condition in test 2021-07-24 19:12:57 -04:00
Chris
423ada68b3 Update FUNDING.yml 2021-07-24 19:00:19 -04:00
Chris
70fa17349f Fix typo 2021-07-24 18:59:01 -04:00
Chris
e640ede709 Create FUNDING.yml 2021-07-24 18:58:30 -04:00
TwinProduction
fb3447eaf3 Improve test coverage 2021-07-18 23:24:09 -04:00
TwinProduction
46cf616a57 Rename TestStore_Insert to TestStore_SanityCheck 2021-07-18 23:13:19 -04:00
TwinProduction
cf48072167 Fix indentation 2021-07-18 23:07:24 -04:00
TwinProduction
97dd868ae8 Add sanity tests 2021-07-18 23:02:27 -04:00
TwinProduction
c18b2728c9 Revert back to covermode=atomic 2021-07-18 22:04:49 -04:00
TwinProduction
b3fd290e4d Remove -coverpkg=all from coverage file configuration and set covermode to count 2021-07-18 22:02:51 -04:00
TwinProduction
89e23a986c Add -coverpkg=all when building coverage file 2021-07-18 21:56:32 -04:00
TwinProduction
c454c868f6 Update codecov/codecov-action to v1.5.2 2021-07-18 21:47:18 -04:00
TwinProduction
6d82a54518 Rename example directory to examples 2021-07-18 20:52:42 -04:00
TwinProduction
bd3c01a4f4 Add example for sqlite 2021-07-18 20:48:22 -04:00
TwinProduction
43150ae484 Fix #132: ICMP doesn't work on Mac OS 2021-07-18 20:43:44 -04:00
TwinProduction
acb6757dc8 Minor tweaks 2021-07-18 17:29:08 -04:00
TwinProduction
2037d9aca6 Add documentation for new storage type 2021-07-18 17:29:08 -04:00
TwinProduction
c700154f5e Rename database package to sqlite 2021-07-18 17:29:08 -04:00
TwinProduction
aac72e3741 Improve test coverage 2021-07-18 17:29:08 -04:00
TwinProduction
1a597f92ba Increase test coverage and remove useless code 2021-07-18 17:29:08 -04:00
TwinProduction
56fedcedd1 Improve test coverage 2021-07-18 17:29:08 -04:00
TwinProduction
6bdce4fe29 Add persistence test 2021-07-18 17:29:08 -04:00
TwinProduction
381488a1b2 Add sqlite wal files 2021-07-18 17:29:08 -04:00
TwinProduction
42a909c1ad Update documentation 2021-07-18 17:29:08 -04:00
TwinProduction
5a4fa6f2b0 Fix issue with store closing on configuration file update 2021-07-18 17:29:08 -04:00
TwinProduction
bbbfe7f466 Increase sleep to give enough time for the goroutine to end its task 2021-07-18 17:29:08 -04:00
TwinProduction
7cf1750f86 Add BenchmarkConvertGroupAndServiceToKey 2021-07-18 17:29:08 -04:00
TwinProduction
b88ae5fcf6 Improve test coverage 2021-07-18 17:29:08 -04:00
TwinProduction
8516c41b43 Update benchmarks 2021-07-18 17:29:08 -04:00
TwinProduction
b90a64e2a6 Set synchronous PRAGMA instruction to NORMAL 2021-07-18 17:29:08 -04:00
TwinProduction
627173e64f Refactor duplicate functions 2021-07-18 17:29:08 -04:00
TwinProduction
8b5e5f54cc Refactor test setup/cleanup 2021-07-18 17:29:08 -04:00
TwinProduction
2c95cce7b3 Consolidate store tests into interface package + Fix issues 2021-07-18 17:29:08 -04:00
TwinProduction
2ef9329fa6 Fix pagination in memory store 2021-07-18 17:29:08 -04:00
TwinProduction
9384373f43 Fix result ordering issue 2021-07-18 17:29:08 -04:00
TwinProduction
d3a81a2d57 Major fixes and improvements 2021-07-18 17:29:08 -04:00
TwinProduction
fed32d3909 Minor improvements and fixes 2021-07-18 17:29:08 -04:00
TwinProduction
c1d9006aaf Uncomment memory benchmarks 2021-07-18 17:29:08 -04:00
TwinProduction
7126d36d85 Implement paging and refactor stores to match new store interface with paging 2021-07-18 17:29:08 -04:00
TwinProduction
677c7faffe Start working on paging implementation 2021-07-18 17:29:08 -04:00
TwinProduction
8dedcf7c74 Refactor code 2021-07-18 17:29:08 -04:00
TwinProduction
a4c69d6fc3 Implement service uptime support for database store 2021-07-18 17:29:08 -04:00
TwinProduction
943d0a19d1 Use time.Truncate instead of manually flooring the hour 2021-07-18 17:29:08 -04:00
TwinProduction
fd08c8b1e5 Reorder methods 2021-07-18 17:29:08 -04:00
TwinProduction
393147c300 Add table schema for service uptime 2021-07-18 17:29:08 -04:00
TwinProduction
f73e8a56ef Minor improvements 2021-07-18 17:29:08 -04:00
TwinProduction
4203355edc Improve benchmarks 2021-07-18 17:29:08 -04:00
TwinProduction
5cc1c11b1a Move all transactions to the exported methods 2021-07-18 17:29:08 -04:00
TwinProduction
796228466d Add missing transaction rollbacks 2021-07-18 17:29:08 -04:00
TwinProduction
23ba9795a6 Start working on GetAllServiceStatusesWithResultPagination for database storage 2021-07-18 17:29:08 -04:00
TwinProduction
1291e86a6f Rename deleteOldResults and deleteOldEvents to deleteOldServiceResults and deleteOldServiceEvents 2021-07-18 17:29:08 -04:00
TwinProduction
14316cfd31 Fix potential concurrent access issue 2021-07-18 17:29:08 -04:00
TwinProduction
670272f411 Refactor code and enable WAL for 4x performance improvement 2021-07-18 17:29:08 -04:00
TwinProduction
ffc3e644c5 Add benchmark scenarios for concurrent inserts 2021-07-18 17:29:08 -04:00
TwinProduction
bc42d15625 Automatically clear up old events 2021-07-18 17:29:08 -04:00
TwinProduction
20594b902c Remove unnecessary comments 2021-07-18 17:29:08 -04:00
TwinProduction
0a3267e499 Reuse transaction on insert to improve performance 2021-07-18 17:29:08 -04:00
TwinProduction
9c8bf2b69e Refactor uselessly complex code 2021-07-18 17:29:08 -04:00
TwinProduction
bd1eb7c61b #136: Start working on database persistence 2021-07-18 17:29:08 -04:00
TwinProduction
e6335da94f Minor update 2021-07-18 17:29:08 -04:00
TwinProduction
1498b6d8a2 Add Service.Key() method to generate the unique service key 2021-07-18 17:29:08 -04:00
TwinProduction
7aed826d65 Fix typo 2021-07-18 17:29:08 -04:00
TwinProduction
9b68582622 Minor update 2021-07-08 23:39:12 -04:00
TwinProduction
a1afeea56b Close #126: Don't follow redirects 2021-07-06 22:01:46 -04:00
Chris
38de0ec9cd Update README.md 2021-07-06 20:06:40 -04:00
Andrii Vakarev
9d8a3f1574 Document helm chart (#127) 2021-07-06 19:54:59 -04:00
TwinProduction
b904afb8b5 Replace - by _ in file names 2021-07-02 20:04:05 -04:00
TwinProduction
5bf560221f Remove cat-fact from example config 2021-06-29 23:35:31 -04:00
TwinProduction
574dd50b98 Update example config 2021-06-29 23:33:17 -04:00
TwinProduction
35c33620a5 Remove hidden feature HTTP_CLIENT_TIMEOUT_IN_SECONDS 2021-06-18 10:07:55 -04:00
TwinProduction
fc0c3499f4 Remove comment that no longer applies 2021-06-18 09:59:39 -04:00
TwinProduction
d03271d128 Update TwinProduction/gocache to v1.2.3 2021-06-18 09:56:55 -04:00
Chris
0560b98de4 Update README.md 2021-06-17 18:44:38 -04:00
TwinProduction
ca87547430 Update TwinProduction/gocache to v1.2.2 2021-06-06 14:54:58 -04:00
TwinProduction
e214d56af1 Add errors through result.AddError() 2021-06-05 18:51:51 -04:00
TwinProduction
8997eeef05 Fix #123: Deduplicate result errors 2021-06-05 18:50:24 -04:00
TwinProduction
5e00752c5a Include issue number in scenario name 2021-06-05 18:42:32 -04:00
TwinProduction
f9d132c369 Fix #122: Partially invalid JSONPath ending with string does not return an error 2021-06-05 18:41:42 -04:00
TwinProduction
ca977fefa8 Minor improvements 2021-06-05 16:35:52 -04:00
TwinProduction
d07d3434a6 #120: Add documentation for STARTTLS 2021-06-05 16:35:18 -04:00
gopher-johns
2131fa4412 #120: Add support for StartTLS protocol
* add starttls

* remove starttls from default config

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

* Fix yaml syntax

* Github Actions timeout disabled

* Add linux/386, linux/arm/v6, linux/arm/v7, linux/ppc64le, linux/s390x docker images

* Remove linux/386, linux/ppc64le, linux/s390x docker images

* Remove "Github Packages" from build step name

Co-authored-by: Chris C. <twin@twinnation.org>

* Split workflow file to test/docker

* Forward RELEASE variable to GITHUB_ENV

* Bring back timeout for tests

Co-authored-by: Chris C. <twin@twinnation.org>
2020-12-04 10:58:27 -05:00
TwinProduction
d1fe8d93ee Update documentation 2020-12-01 22:25:16 -05:00
TwinProduction
afdbb1ea77 Don't return unnecessary data 2020-11-30 09:40:57 -05:00
TwinProduction
d58a256628 Minor fix 2020-11-30 09:25:14 -05:00
TwinProduction
971967ae78 Don't append / at the end of the path 2020-11-30 09:23:03 -05:00
TwinProduction
43504913b4 Allow duplicate service names as long as they're in a different group (#13) 2020-11-30 08:44:58 -05:00
TwinProduction
f034bd15de Add missing comments 2020-11-29 19:03:40 -05:00
Chris C
35c232d925 Merge pull request #52 from TwinProduction/service-groups
Add service groups
2020-11-27 08:48:54 -05:00
TwinProduction
9c8bfcd19f Add service-status_test.go 2020-11-26 23:45:17 -05:00
TwinProduction
6a08c816e5 Update default configuration 2020-11-26 23:23:59 -05:00
TwinProduction
da4fb10bfc Add documentation for groups 2020-11-26 23:23:51 -05:00
TwinProduction
35b9758b23 Add non-grouped services at the bottom of the dashboard 2020-11-26 18:29:11 -05:00
TwinProduction
94eb3868e6 Start working on #13: Service groups 2020-11-26 18:09:01 -05:00
TwinProduction
68f32b0fcc Minor update 2020-11-24 19:03:48 -05:00
Chris C
66946e20f0 Merge pull request #51 from olimpias/add-messagebird-alerting-provider
Add Messagebird alerting
2020-11-24 18:58:28 -05:00
cemturker
64f1c41e0c Fix the test 2020-11-25 00:31:51 +01:00
cemturker
199f14667e put property name back as originator and recipients 2020-11-25 00:30:23 +01:00
cemturker
c5e2b0f159 Fix wrong set property names in readme 2020-11-24 21:05:27 +01:00
cemturker
a645c14452 fix the test error outputs 2020-11-24 17:16:54 +01:00
cemturker
daf27fd6d0 Review changes 2020-11-24 17:15:52 +01:00
cemturker
978acf3cd2 Fix the field name in test 2020-11-23 22:29:57 +01:00
cemturker
a870d3e43f Add Messagebird as an alerting provider 2020-11-23 22:20:06 +01:00
TwinProduction
66bf1c23df Minor updates 2020-11-21 17:53:26 -05:00
TwinProduction
4f17bc1b14 Improve test coverage 2020-11-21 17:53:18 -05:00
TwinProduction
5850ed82e4 Refactor code and update comments 2020-11-21 17:35:08 -05:00
Chris C
53e1012ca1 Merge pull request #46 from mindcrime-ilab/feature/context-root
Feature/context root
2020-11-21 17:09:34 -05:00
Michael Engelhardt
f36d99ef07 add test for nested context 2020-11-21 22:41:55 +01:00
Michael Engelhardt
9af8a02f05 Rename function to PrependWithContextRoot 2020-11-21 21:38:45 +01:00
Michael Engelhardt
c827eb7948 some refactorings and add test cases 2020-11-21 13:11:37 +01:00
Michael Engelhardt
c791986143 add test 2020-11-21 04:07:38 +01:00
Michael Engelhardt
5f10a92c36 simplification and refactorings 2020-11-21 03:42:42 +01:00
Michael Engelhardt
f9706a98ed start working on context root configuration 2020-11-21 02:33:02 +01:00
TwinProduction
76d45d7eb8 Add TestWebConfig_SocketAddress 2020-11-20 17:45:25 -05:00
TwinProduction
1b23b0dd1d Minor improvements 2020-11-20 17:40:57 -05:00
TwinProduction
315791151e Minor updates 2020-11-20 17:24:56 -05:00
Chris C
07312794c4 Merge pull request #43 from mindcrime-ilab/develop
add parameterization for host and port
2020-11-20 17:20:11 -05:00
Michael Engelhardt
4dd57b45e9 fix misleading messages on failing tests 2020-11-20 06:59:43 +01:00
Michael Engelhardt
3985c6c483 removed remaining flag parsing 2020-11-20 06:48:23 +01:00
TwinProduction
09ff879b3b Minor improvements 2020-11-19 22:41:30 -05:00
TwinProduction
10dfdd47a7 Update DNS documentation 2020-11-19 22:31:45 -05:00
TwinProduction
3a13dce3f0 Minor update 2020-11-19 21:10:59 -05:00
TwinProduction
faff3dc8de Update example health check 2020-11-19 21:10:42 -05:00
Chris C
7bfb583ac7 Merge pull request #44 from olimpias/add-dns-feature
Add health check for DNS services
2020-11-19 20:53:30 -05:00
Michael Engelhardt
4c151bdf8f add test for invalid port in config file 2020-11-19 20:03:30 +01:00
Michael Engelhardt
10310cf380 Add configuration values for address and port
Add (non-mandatory) configuration values to set address and port on which the web frontent will be served. If not set defaults will be applied.
2020-11-19 19:39:48 +01:00
cemturker
83eb696fe4 Add comments for dns errors 2020-11-19 13:59:03 +01:00
cemturker
8789a4ad55 Review changes 2020-11-19 09:31:30 +01:00
cemturker
1fb2635226 Remove extra newline 2020-11-18 23:11:01 +01:00
cemturker
1901dfcd81 Increase the code coverage 2020-11-18 23:07:38 +01:00
cemturker
cef94299e5 Merge branch 'master' into add-dns-feature
# Conflicts:
#	core/condition.go
2020-11-18 18:53:00 +01:00
cemturker
7f21fdee68 Change test structure into tabledriven
Review change
Update README.md
2020-11-18 18:51:22 +01:00
TwinProduction
85c7308448 Minor update 2020-11-17 19:34:35 -05:00
TwinProduction
7e35a6ebbd Rename PlaceHolder to Placeholder 2020-11-17 19:34:22 -05:00
cemturker
4ad5c7b99c Fix TestService_ValidateAndSetDefaultsWithDNS test case 2020-11-18 01:00:16 +01:00
cemturker
bc914e12b0 Add health check for DNS 2020-11-18 00:55:31 +01:00
Michael Engelhardt
9f485b14e0 fix shortcut for host 2020-11-18 00:46:37 +01:00
Michael Engelhardt
9039ef79cf add parameterization for host and port
Host and port can be specified by either a environment variable or a
dedicated command line parameter. Command line parameters will be preferred
over environment variables, which will be preferred over the application
defaults.
2020-11-18 00:29:10 +01:00
3096 changed files with 2660174 additions and 467105 deletions

View File

@@ -1,5 +1,7 @@
example
.examples
Dockerfile
.github
.idea
.git
web/app
*.db

View File

@@ -0,0 +1,16 @@
metrics: true
endpoints:
- name: website
url: https://twin.sh/health
interval: 30s
conditions:
- "[STATUS] == 200"
- name: github
url: https://api.github.com/healthz
interval: 5m
conditions:
- "[STATUS] == 200"
- name: example
url: https://example.com/
conditions:
- "[STATUS] == 200"

View File

@@ -0,0 +1,42 @@
version: "3.9"
services:
gatus:
container_name: gatus
image: twinproduction/gatus
restart: always
ports:
- "8080:8080"
volumes:
- ./config.yaml:/config/config.yaml
networks:
- metrics
prometheus:
container_name: prometheus
image: prom/prometheus:v2.14.0
restart: always
command: --config.file=/etc/prometheus/prometheus.yml
ports:
- "9090:9090"
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
networks:
- metrics
grafana:
container_name: grafana
image: grafana/grafana:6.4.4
restart: always
environment:
GF_SECURITY_ADMIN_PASSWORD: secret
ports:
- "3000:3000"
volumes:
- ./grafana/grafana.ini/:/etc/grafana/grafana.ini:ro
- ./grafana/provisioning/:/etc/grafana/provisioning/:ro
networks:
- metrics
networks:
metrics:
driver: bridge

View File

@@ -58,9 +58,9 @@
"steppedLine": false,
"targets": [
{
"expr": "sum(rate(gatus_tasks[30s])) by (service)",
"expr": "sum(rate(gatus_tasks[30s])) by (endpoint)",
"interval": "30s",
"legendFormat": "{{service}}",
"legendFormat": "{{endpoint}}",
"refId": "A"
}
],
@@ -145,9 +145,9 @@
"steppedLine": false,
"targets": [
{
"expr": "sum(rate(gatus_tasks{success=\"false\"}[30s])) by (service)",
"expr": "sum(rate(gatus_tasks{success=\"false\"}[30s])) by (endpoint)",
"interval": "30s",
"legendFormat": "{{service}}",
"legendFormat": "{{endpoint}}",
"refId": "A"
}
],
@@ -232,10 +232,10 @@
"steppedLine": false,
"targets": [
{
"expr": "sum(rate(gatus_tasks{success=\"true\"}[30s])) by (service)",
"expr": "sum(rate(gatus_tasks{success=\"true\"}[30s])) by (endpoint)",
"instant": false,
"interval": "30s",
"legendFormat": "{{service}}",
"legendFormat": "{{endpoint}}",
"refId": "A"
}
],

View File

@@ -0,0 +1,16 @@
alerting:
mattermost:
webhook-url: "http://mattermost:8065/hooks/tokengoeshere"
insecure: true
endpoints:
- name: example
url: https://example.org
interval: 1m
alerts:
- type: mattermost
enabled: true
description: "health check failed 3 times in a row"
send-on-resolved: true
conditions:
- "[STATUS] == 200"

View File

@@ -0,0 +1,23 @@
version: "3.9"
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

@@ -0,0 +1,42 @@
storage:
type: postgres
path: "postgres://username:password@postgres:5432/gatus?sslmode=disable"
endpoints:
- name: back-end
group: core
url: "https://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[CERTIFICATE_EXPIRATION] > 48h"
- name: monitoring
group: internal
url: "https://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"
- name: nas
group: internal
url: "https://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"
- name: example-dns-query
url: "8.8.8.8" # Address of the DNS server to use
interval: 5m
dns:
query-name: "example.com"
query-type: "A"
conditions:
- "[BODY] == 93.184.216.34"
- "[DNS_RCODE] == NOERROR"
- name: icmp-ping
url: "icmp://example.org"
interval: 1m
conditions:
- "[CONNECTED] == true"

View File

@@ -0,0 +1,29 @@
version: "3.9"
services:
postgres:
image: postgres
volumes:
- ./data/db:/var/lib/postgresql/data
ports:
- "5432:5432"
environment:
- POSTGRES_DB=gatus
- POSTGRES_USER=username
- POSTGRES_PASSWORD=password
networks:
- web
gatus:
image: twinproduction/gatus:latest
restart: always
ports:
- "8080:8080"
volumes:
- ./config.yaml:/config/config.yaml
networks:
- web
depends_on:
- postgres
networks:
web:

View File

@@ -0,0 +1,42 @@
storage:
type: sqlite
path: /data/data.db
endpoints:
- name: back-end
group: core
url: "https://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[CERTIFICATE_EXPIRATION] > 48h"
- name: monitoring
group: internal
url: "https://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"
- name: nas
group: internal
url: "https://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"
- name: example-dns-query
url: "8.8.8.8" # Address of the DNS server to use
interval: 5m
dns:
query-name: "example.com"
query-type: "A"
conditions:
- "[BODY] == 93.184.216.34"
- "[DNS_RCODE] == NOERROR"
- name: icmp-ping
url: "icmp://example.org"
interval: 1m
conditions:
- "[CONNECTED] == true"

View File

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

View File

@@ -0,0 +1,6 @@
endpoints:
- name: example
url: https://example.org
interval: 30s
conditions:
- "[STATUS] == 200"

View File

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

View File

@@ -0,0 +1,6 @@
endpoints:
- name: example
url: https://example.org
interval: 30s
conditions:
- "[STATUS] == 200"

View File

@@ -0,0 +1,99 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: gatus
namespace: kube-system
data:
config.yaml: |
metrics: true
endpoints:
- name: website
url: https://twin.sh/health
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- name: github
url: https://api.github.com/healthz
interval: 5m
conditions:
- "[STATUS] == 200"
- name: cat-fact
url: "https://cat-fact.herokuapp.com/facts/random"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].deleted == false"
- "len([BODY].text) > 0"
- "[BODY].text == pat(*cat*)"
- "[STATUS] == pat(2*)"
- "[CONNECTED] == true"
- name: example
url: https://example.com/
conditions:
- "[STATUS] == 200"
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: gatus
namespace: kube-system
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: gatus
namespace: kube-system
spec:
replicas: 1
selector:
matchLabels:
app: gatus
template:
metadata:
labels:
app: gatus
name: gatus
namespace: kube-system
spec:
serviceAccountName: gatus
terminationGracePeriodSeconds: 5
containers:
- image: twinproduction/gatus
imagePullPolicy: IfNotPresent
name: gatus
ports:
- containerPort: 8080
name: http
protocol: TCP
resources:
limits:
cpu: 250m
memory: 100M
requests:
cpu: 50m
memory: 30M
volumeMounts:
- mountPath: /config
name: gatus-config
volumes:
- configMap:
name: gatus
name: gatus-config
---
apiVersion: v1
kind: Service
metadata:
name: gatus
namespace: kube-system
spec:
ports:
- name: http
port: 8080
protocol: TCP
targetPort: 8080
selector:
app: gatus

2
.gitattributes vendored
View File

@@ -1 +1 @@
* text=lf
* text=auto eol=lf

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

BIN
.github/assets/endpoint-groups.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

1
.github/assets/gatus-diagram.drawio vendored Normal file
View File

@@ -0,0 +1 @@
<mxfile host="app.diagrams.net" modified="2021-09-12T22:49:28.336Z" agent="5.0 (Windows)" etag="r9FJ6Bphqwq-LaTO-jp3" version="15.0.6" type="device"><diagram id="FBbfVOMCjf6Z2LK8Yagy" name="Page-1">7Vtdb5swFP01edwEOCHJY5t03aR1q9RWbR9d8MCb4TJj8rFfP9OYBOI2YVrTi5S8RPjaxOack2sf7PTIJFlcSprFVxAy0fOccNEj057nDceu/iwDy1WADEarQCR5uAq5m8AN/8NM0DHRgocsbzRUAELxrBkMIE1ZoBoxKiXMm81+gGj2mtGIWYGbgAo7es9DFZuo5zibis+MR3HV9dgzNQmtWptAHtMQ5rUQueiRiQRQq6tkMWGiBK8CZnXfp1dq1yOTLFVtbvj2/UGwZFq4GpXZfXYJ8Yh9MN8yo6IwT2wGq5YVBCzUiJgiSBVDBCkVF5vouYQiDVnZjaNLmzZfATIddHXwJ1NqaeilhQIdilUiTO2qz7KjV5/NhHIoZMB2PJAZv6IyYmrXgw/XFGjtMkiYkkt9o2SCKj5rDoQaFUXrdhuc9YWB+h9g9yzYteKk4mlkwb8Bt0RqHnPFbjL6jMFc/+ReAnKmv4otdkNpP7m5YTQ2ejW/WM8xg5039L+KxTXp+86BwCIYktRoyeWDuf+58FgWPg6q4nRRr5wuTekNpey3lPIIU8n9Ezk7yfEwyRngkrPh47Fe1x1yiIPJjm9NAnOqgjgE/Elg6DYnAUKwJ4HhkeaZUds842NKeWRJOVcgy7U1tpK3lzN9H1vJYwurI5W2O2yrbdQ0XaXCEz+vNhyj8uOe+NnDD6pDcE/5bV9+e2UKeyd+jtVft+eHoPJzrBa7PT+oJttFdtnd54fg5jf/xM8efnB/P2aYNUOUSZjxkEmLuD1+sWkue//vHl1n2LW34a7tta9Ynmuv/cRliI8Y2Xp15A5H7RDzDoaY7bhvBA1+dQ8rj2BjVX1xDatrLS05LdSyg3g56Hi9tIHqC93techn+jIqL28ZTfIqrvupVXUQVB8dVM9OcVQpJhPIVfcAc/tjbMCIBdgtEyySNOkeXN4YXV/H6qi81ruWuKcjjtVRtecHd8//WN+It+aHoL4R9+yd5XI7Dn8zzrJT+Ltxnu0+899CA9G9ibM/agnW4SZO23vyNGEJyA6ag/7AwYZrjJsnm+dvnPfLk8RpmSdRDy0Q2+vmTM54wPISiDKsRwEpetrcPo2D/xKK2LZ3yvMAZNi9PLBGBi0PVIen6vZszgWH7oFFqvkQDyzk7Ui0Q4uk3zJpoq79q1HWtByAhgSEeOF9PfZpr8EQPVMeq5clrb1SH1XPtle6+4Ku4+0ZvwM6tn0ShrDfUqBtj9YS3FWq7bnu8hdSbR7TrLwsEnEWaLevwSolyAONLn1i4hpy/ryaJdMnUAqSWoMzwaOyQsGWlKFQgqdssv53nfM2+vbJfn2Td5W3vUeYQa4iWXqBri3SBtWRrQMs0nRx85fE57raHzvJxV8=</diagram></mxfile>

BIN
.github/assets/gatus-diagram.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 51 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

7
.github/codecov.yml vendored Normal file
View File

@@ -0,0 +1,7 @@
ignore:
- "watchdog/watchdog.go"
- "storage/store/sql/specific_postgres.go" # Can't test for postgres
coverage:
status:
patch: off

View File

@@ -14,18 +14,20 @@ 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.17
- 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" step (otherwise, it'd use sudo's "go" executable)
run: sudo env "PATH=$PATH" "GOROOT=$GOROOT" go test -mod vendor ./... -race -coverprofile=coverage.txt -covermode=atomic
- name: Codecov
uses: codecov/codecov-action@v1.0.14
uses: codecov/codecov-action@v2.1.0
with:
file: ./coverage.txt
files: ./coverage.txt

34
.github/workflows/publish-latest.yml vendored Normal file
View File

@@ -0,0 +1,34 @@
name: publish-latest
on:
workflow_run:
workflows: ["build"]
branches: [master]
types: [completed]
jobs:
publish-latest:
name: Publish latest
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
timeout-minutes: 30
steps:
- name: Check out code
uses: actions/checkout@v2
- name: Get image repository
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Login to Docker Registry
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push docker image
uses: docker/build-push-action@v2
with:
platforms: linux/amd64
pull: true
push: true
tags: |
${{ env.IMAGE_REPOSITORY }}:latest

33
.github/workflows/publish-release.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
name: publish-release
on:
release:
types: [published]
jobs:
publish-release:
name: Publish release
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Check out code
uses: actions/checkout@v2
- name: Get image repository
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Get the release
run: echo RELEASE=${GITHUB_REF/refs\/tags\//} >> $GITHUB_ENV
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Login to Docker Registry
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push docker image
uses: docker/build-push-action@v2
with:
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
pull: true
push: true
tags: |
${{ env.IMAGE_REPOSITORY }}:${{ env.RELEASE }}

10
.gitignore vendored
View File

@@ -1,2 +1,8 @@
bin
.idea
.idea
.vscode
gatus
db.db
config/config.yml
db.db-shm
db.db-wal
memory.db

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

37
Makefile Normal file
View File

@@ -0,0 +1,37 @@
BINARY=gatus
install:
go build -mod vendor -o $(BINARY) .
run:
GATUS_CONFIG_FILE=./config.yaml ./$(BINARY)
clean:
rm $(BINARY)
test:
sudo go test ./alerting/... ./client/... ./config/... ./controller/... ./core/... ./jsonpath/... ./pattern/... ./security/... ./storage/... ./util/... ./watchdog/... -cover
##########
# Docker #
##########
docker-build:
docker build -t twinproduction/gatus:latest .
docker-run:
docker run -p 8080:8080 --name gatus twinproduction/gatus:latest
docker-build-and-run: docker-build docker-run
#############
# Front end #
#############
frontend-build:
npm --prefix web/app run build
frontend-run:
npm --prefix web/app run serve

1189
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 a core.Endpoint's alert configuration
type Alert struct {
// Type of alert (required)
Type Type `yaml:"type"`
// Enabled defines whether 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")
}
}

34
alerting/alert/type.go Normal file
View File

@@ -0,0 +1,34 @@
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"
// TypeTeams is the Type for the teams alerting provider
TypeTeams Type = "teams"
// 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,27 +1,106 @@
package alerting
import (
"github.com/TwinProduction/gatus/alerting/provider/custom"
"github.com/TwinProduction/gatus/alerting/provider/pagerduty"
"github.com/TwinProduction/gatus/alerting/provider/slack"
"github.com/TwinProduction/gatus/alerting/provider/mattermost"
"github.com/TwinProduction/gatus/alerting/provider/twilio"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/alerting/provider"
"github.com/TwiN/gatus/v3/alerting/provider/custom"
"github.com/TwiN/gatus/v3/alerting/provider/discord"
"github.com/TwiN/gatus/v3/alerting/provider/mattermost"
"github.com/TwiN/gatus/v3/alerting/provider/messagebird"
"github.com/TwiN/gatus/v3/alerting/provider/pagerduty"
"github.com/TwiN/gatus/v3/alerting/provider/slack"
"github.com/TwiN/gatus/v3/alerting/provider/teams"
"github.com/TwiN/gatus/v3/alerting/provider/telegram"
"github.com/TwiN/gatus/v3/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"`
// Pagerduty is the configuration for the pagerduty alerting provider
// Messagebird is the configuration for the messagebird alerting provider
Messagebird *messagebird.AlertProvider `yaml:"messagebird"`
// 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"`
// Teams is the configuration for the teams alerting provider
Teams *teams.AlertProvider `yaml:"teams"`
// Telegram is the configuration for the telegram alerting provider
Telegram *telegram.AlertProvider `yaml:"telegram"`
// 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.TypeTeams:
if config.Teams == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
return nil
}
return config.Teams
case alert.TypeTelegram:
if config.Telegram == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
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,67 +2,100 @@ package custom
import (
"bytes"
"errors"
"fmt"
"io/ioutil"
"net/http"
"os"
"strings"
"github.com/TwinProduction/gatus/client"
"github.com/TwinProduction/gatus/core"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
)
// 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"`
Body string `yaml:"body,omitempty"`
Headers map[string]string `yaml:"headers,omitempty"`
Placeholders map[string]map[string]string `yaml:"placeholders,omitempty"`
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
return len(provider.URL) > 0
if provider.ClientConfig == nil {
provider.ClientConfig = client.GetDefaultConfig()
}
return len(provider.URL) > 0 && provider.ClientConfig != nil
}
// 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(endpoint *core.Endpoint, 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(endpointName, 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)
}
if strings.Contains(body, "[SERVICE_NAME]") {
body = strings.ReplaceAll(body, "[SERVICE_NAME]", serviceName)
if strings.Contains(body, "[SERVICE_NAME]") { // XXX: Remove this in v4.0.0
body = strings.ReplaceAll(body, "[SERVICE_NAME]", endpointName)
}
if strings.Contains(body, "[ENDPOINT_NAME]") {
body = strings.ReplaceAll(body, "[ENDPOINT_NAME]", endpointName)
}
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]") {
providerURL = strings.ReplaceAll(providerURL, "[ALERT_DESCRIPTION]", alertDescription)
}
if strings.Contains(providerURL, "[SERVICE_NAME]") {
providerURL = strings.ReplaceAll(providerURL, "[SERVICE_NAME]", serviceName)
if strings.Contains(providerURL, "[SERVICE_NAME]") { // XXX: Remove this in v4.0.0
providerURL = strings.ReplaceAll(providerURL, "[SERVICE_NAME]", endpointName)
}
if strings.Contains(providerURL, "[ENDPOINT_NAME]") {
providerURL = strings.ReplaceAll(providerURL, "[ENDPOINT_NAME]", endpointName)
}
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 {
method = "GET"
method = http.MethodGet
}
bodyBuffer := bytes.NewBuffer([]byte(body))
request, _ := http.NewRequest(method, providerURL, bodyBuffer)
@@ -73,9 +106,15 @@ 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)
response, err := client.GetHTTPClient(provider.Insecure).Do(request)
func (provider *AlertProvider) Send(endpointName, alertDescription string, resolved bool) ([]byte, error) {
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(endpointName, alertDescription, resolved)
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
if err != nil {
return nil, err
}
@@ -88,3 +127,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/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/core"
)
func TestAlertProvider_IsValid(t *testing.T) {
@@ -11,23 +13,23 @@ func TestAlertProvider_IsValid(t *testing.T) {
if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{URL: "http://example.com"}
validProvider := AlertProvider{URL: "https://example.com"}
if !validProvider.IsValid() {
t.Error("provider should've been valid")
}
}
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"
ExpectedURL = "https://example.com/endpoint-name?event=RESOLVED&description=alert-description"
ExpectedBody = "endpoint-name,alert-description,RESOLVED"
)
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]",
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[ENDPOINT_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
Headers: nil,
}
request := customAlertProvider.buildRequest("service-name", "alert-description", true)
request := customAlertProvider.buildHTTPRequest("endpoint-name", "alert-description", true)
if request.URL.String() != ExpectedURL {
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
}
@@ -37,17 +39,17 @@ 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"
ExpectedURL = "https://example.com/endpoint-name?event=TRIGGERED&description=alert-description"
ExpectedBody = "endpoint-name,alert-description,TRIGGERED"
)
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]",
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[ENDPOINT_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("endpoint-name", "alert-description", false)
if request.URL.String() != ExpectedURL {
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
}
@@ -58,12 +60,75 @@ 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)
provider := AlertProvider{URL: "https://example.com"}
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &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 != "https://example.com" {
t.Error("expected URL to be https://example.com, got", customAlertProvider.URL)
}
}
func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
const (
ExpectedURL = "https://example.com/endpoint-name?event=test&description=alert-description"
ExpectedBody = "endpoint-name,alert-description,test"
)
customAlertProvider := &AlertProvider{
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[ENDPOINT_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
Headers: nil,
Placeholders: map[string]map[string]string{
"ALERT_TRIGGERED_OR_RESOLVED": {
"RESOLVED": "test",
},
},
}
request := customAlertProvider.buildHTTPRequest("endpoint-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: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[ENDPOINT_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))
}
}
// TestAlertProvider_isBackwardCompatibleWithServiceRename checks if the custom alerting provider still supports
// service placeholders after the migration from "service" to "endpoint"
//
// XXX: Remove this in v4.0.0
func TestAlertProvider_isBackwardCompatibleWithServiceRename(t *testing.T) {
const (
ExpectedURL = "https://example.com/endpoint-name?event=TRIGGERED&description=alert-description"
ExpectedBody = "endpoint-name,alert-description,TRIGGERED"
)
customAlertProvider := &AlertProvider{
URL: "https://example.com/[SERVICE_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[SERVICE_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
}
request := customAlertProvider.buildHTTPRequest("endpoint-name", "alert-description", false)
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))
}
}

View File

@@ -0,0 +1,76 @@
package discord
import (
"fmt"
"net/http"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/alerting/provider/custom"
"github.com/TwiN/gatus/v3/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 endpoints 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(endpoint *core.Endpoint, 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", endpoint.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", endpoint.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)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\\n> " + alertDescription
}
return &custom.AlertProvider{
URL: provider.WebhookURL,
Method: http.MethodPost,
Body: fmt.Sprintf(`{
"content": "",
"embeds": [
{
"title": ":helmet_with_white_cross: Gatus",
"description": "%s%s",
"color": %d,
"fields": [
{
"name": "Condition results",
"value": "%s",
"inline": false
}
]
}
]
}`, message, description, 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,70 @@
package discord
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/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"}
alertDescription := "test"
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{Name: "svc"}, &alert.Alert{Description: &alertDescription}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
}
if !strings.Contains(customAlertProvider.Body, "resolved") {
t.Error("customAlertProvider.Body should've contained the substring resolved")
}
if customAlertProvider.URL != "http://example.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())
}
if expected := "An alert for **svc** has been resolved after passing successfully 0 time(s) in a row:\n> test"; expected != body["embeds"].([]interface{})[0].(map[string]interface{})["description"] {
t.Errorf("expected $.embeds[0].description to be %s, got %s", expected, body["embeds"].([]interface{})[0].(map[string]interface{})["description"])
}
}
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
provider := AlertProvider{WebhookURL: "http://example.com"}
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &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

@@ -2,30 +2,42 @@ package mattermost
import (
"fmt"
"github.com/TwinProduction/gatus/alerting/provider/custom"
"github.com/TwinProduction/gatus/core"
"net/http"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/alerting/provider/custom"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
)
// AlertProvider is the configuration necessary for sending an alert using Mattermost
type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"`
Insecure bool `yaml:"insecure,omitempty"`
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client"`
// DefaultAlert is the default alert configuration to use for endpoints 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 {
if provider.ClientConfig == nil {
provider.ClientConfig = client.GetDefaultConfig()
}
return len(provider.WebhookURL) > 0
}
// 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(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
var message string
var color string
if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", service.Name, alert.SuccessThreshold)
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.Name, alert.SuccessThreshold)
color = "#36A64F"
} else {
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", service.Name, alert.FailureThreshold)
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.Name, alert.FailureThreshold)
color = "#DD0000"
}
var results string
@@ -36,21 +48,25 @@ 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)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\\n> " + alertDescription
}
return &custom.AlertProvider{
URL: provider.WebhookURL,
Method: "POST",
Insecure: provider.Insecure,
URL: provider.WebhookURL,
Method: http.MethodPost,
ClientConfig: provider.ClientConfig,
Body: fmt.Sprintf(`{
"text": "",
"username": "gatus",
"icon_url": "https://raw.githubusercontent.com/TwinProduction/gatus/master/static/logo.png",
"icon_url": "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png",
"attachments": [
{
"title": ":rescue_worker_helmet: Gatus",
"fallback": "Gatus - %s",
"text": "%s:\n> %s",
"text": "%s%s",
"short": false,
"color": "%s",
"fields": [
@@ -67,7 +83,12 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
]
}
]
}`, message, message, alert.Description, color, service.URL, results),
}`, message, message, description, color, endpoint.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/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/core"
)
func TestAlertProvider_IsValid(t *testing.T) {
@@ -18,23 +22,49 @@ 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"}
alertDescription := "test"
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{Name: "svc"}, &alert.Alert{Description: &alertDescription}, &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())
}
if expected := "An alert for *svc* has been resolved after passing successfully 0 time(s) in a row:\n> test"; expected != body["attachments"].([]interface{})[0].(map[string]interface{})["text"] {
t.Errorf("expected $.attachments[0].description to be %s, got %s", expected, body["attachments"].([]interface{})[0].(map[string]interface{})["text"])
}
}
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
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.Endpoint{}, &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

@@ -0,0 +1,58 @@
package messagebird
import (
"fmt"
"net/http"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/alerting/provider/custom"
"github.com/TwiN/gatus/v3/core"
)
const (
restAPIURL = "https://rest.messagebird.com/messages"
)
// AlertProvider is the configuration necessary for sending an alert using Messagebird
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 endpoints 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.AccessKey) > 0 && len(provider.Originator) > 0 && len(provider.Recipients) > 0
}
// 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(endpoint *core.Endpoint, alert *alert.Alert, _ *core.Result, resolved bool) *custom.AlertProvider {
var message string
if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription())
} else {
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.Name, alert.GetDescription())
}
return &custom.AlertProvider{
URL: restAPIURL,
Method: http.MethodPost,
Body: fmt.Sprintf(`{
"originator": "%s",
"recipients": "%s",
"body": "%s"
}`, provider.Originator, provider.Recipients, message),
Headers: map[string]string{
"Content-Type": "application/json",
"Authorization": fmt.Sprintf("AccessKey %s", provider.AccessKey),
},
}
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}

View File

@@ -0,0 +1,78 @@
package messagebird
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/core"
)
func TestMessagebirdAlertProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{}
if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{
AccessKey: "1",
Originator: "1",
Recipients: "1",
}
if !validProvider.IsValid() {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
provider := AlertProvider{
AccessKey: "1",
Originator: "1",
Recipients: "1",
}
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{}, 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 != "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) {
provider := AlertProvider{
AccessKey: "1",
Originator: "1",
Recipients: "1",
}
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{}, 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 != "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

@@ -2,37 +2,66 @@ package pagerduty
import (
"fmt"
"github.com/TwinProduction/gatus/alerting/provider/custom"
"github.com/TwinProduction/gatus/core"
"net/http"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/alerting/provider/custom"
"github.com/TwiN/gatus/v3/core"
)
const (
restAPIURL = "https://events.pagerduty.com/v2/enqueue"
)
// AlertProvider is the configuration necessary for sending an alert using PagerDuty
type AlertProvider struct {
IntegrationKey string `yaml:"integration-key"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
IntegrationKey string `yaml:"integration-key"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
return len(provider.IntegrationKey) == 32
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.IntegrationKey) != 32 {
return false
}
registeredGroups[override.Group] = true
}
}
// Either the default integration key has the right length, or there are overrides who are properly configured.
return len(provider.IntegrationKey) == 32 || len(provider.Overrides) != 0
}
// 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, result *core.Result, resolved bool) *custom.AlertProvider {
func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, 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", endpoint.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", endpoint.Name, alert.GetDescription())
eventAction = "trigger"
resolveKey = ""
}
return &custom.AlertProvider{
URL: "https://events.pagerduty.com/v2/enqueue",
Method: "POST",
URL: restAPIURL,
Method: http.MethodPost,
Body: fmt.Sprintf(`{
"routing_key": "%s",
"dedup_key": "%s",
@@ -42,9 +71,26 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
"source": "%s",
"severity": "critical"
}
}`, provider.IntegrationKey, resolveKey, eventAction, message, service.Name),
}`, provider.getIntegrationKeyForGroup(endpoint.Group), resolveKey, eventAction, message, endpoint.Name),
Headers: map[string]string{
"Content-Type": "application/json",
},
}
}
// getIntegrationKeyForGroup returns the appropriate pagerduty integration key for a given group
func (provider *AlertProvider) getIntegrationKeyForGroup(group string) string {
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
return override.IntegrationKey
}
}
}
return provider.IntegrationKey
}
// 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/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/core"
)
func TestAlertProvider_IsValid(t *testing.T) {
@@ -17,24 +21,205 @@ func TestAlertProvider_IsValid(t *testing.T) {
}
}
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
IntegrationKey: "00000000000000000000000000000000",
Group: "",
},
},
}
if providerWithInvalidOverrideGroup.IsValid() {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideIntegrationKey := AlertProvider{
Overrides: []Override{
{
IntegrationKey: "",
Group: "group",
},
},
}
if providerWithInvalidOverrideIntegrationKey.IsValid() {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
Overrides: []Override{
{
IntegrationKey: "00000000000000000000000000000000",
Group: "group",
},
},
}
if !providerWithValidOverride.IsValid() {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
provider := AlertProvider{IntegrationKey: "00000000000000000000000000000000"}
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &core.Alert{}, &core.Result{}, true)
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &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_ToCustomAlertProviderWithResolvedAlertAndOverride(t *testing.T) {
provider := AlertProvider{
IntegrationKey: "",
Overrides: []Override{
{
IntegrationKey: "00000000000000000000000000000000",
Group: "group",
},
},
}
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{}, 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 != "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.Endpoint{}, &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())
}
}
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlertAndOverride(t *testing.T) {
provider := AlertProvider{
IntegrationKey: "",
Overrides: []Override{
{
IntegrationKey: "00000000000000000000000000000000",
Group: "group",
},
},
}
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{}, 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 != "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_getIntegrationKeyForGroup(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
ExpectedOutput string
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
IntegrationKey: "00000000000000000000000000000001",
Overrides: nil,
},
InputGroup: "",
ExpectedOutput: "00000000000000000000000000000001",
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
IntegrationKey: "00000000000000000000000000000001",
Overrides: nil,
},
InputGroup: "group",
ExpectedOutput: "00000000000000000000000000000001",
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
IntegrationKey: "00000000000000000000000000000001",
Overrides: []Override{
{
Group: "group",
IntegrationKey: "00000000000000000000000000000002",
},
},
},
InputGroup: "",
ExpectedOutput: "00000000000000000000000000000001",
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
IntegrationKey: "00000000000000000000000000000001",
Overrides: []Override{
{
Group: "group",
IntegrationKey: "00000000000000000000000000000002",
},
},
},
InputGroup: "group",
ExpectedOutput: "00000000000000000000000000000002",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
if output := scenario.Provider.getIntegrationKeyForGroup(scenario.InputGroup); output != scenario.ExpectedOutput {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput, output)
}
})
}
}

View File

@@ -1,12 +1,17 @@
package provider
import (
"github.com/TwinProduction/gatus/alerting/provider/custom"
"github.com/TwinProduction/gatus/alerting/provider/pagerduty"
"github.com/TwinProduction/gatus/alerting/provider/slack"
"github.com/TwinProduction/gatus/alerting/provider/mattermost"
"github.com/TwinProduction/gatus/alerting/provider/twilio"
"github.com/TwinProduction/gatus/core"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/alerting/provider/custom"
"github.com/TwiN/gatus/v3/alerting/provider/discord"
"github.com/TwiN/gatus/v3/alerting/provider/mattermost"
"github.com/TwiN/gatus/v3/alerting/provider/messagebird"
"github.com/TwiN/gatus/v3/alerting/provider/pagerduty"
"github.com/TwiN/gatus/v3/alerting/provider/slack"
"github.com/TwiN/gatus/v3/alerting/provider/teams"
"github.com/TwiN/gatus/v3/alerting/provider/telegram"
"github.com/TwiN/gatus/v3/alerting/provider/twilio"
"github.com/TwiN/gatus/v3/core"
)
// AlertProvider is the interface that each providers should implement
@@ -15,14 +20,43 @@ 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(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider
// GetDefaultAlert returns the provider's default alert configuration
GetDefaultAlert() *alert.Alert
}
// ParseWithDefaultAlert parses an Endpoint alert by using the provider's default alert as a baseline
func ParseWithDefaultAlert(providerDefaultAlert, endpointAlert *alert.Alert) {
if providerDefaultAlert == nil || endpointAlert == nil {
return
}
if endpointAlert.Enabled == nil {
endpointAlert.Enabled = providerDefaultAlert.Enabled
}
if endpointAlert.SendOnResolved == nil {
endpointAlert.SendOnResolved = providerDefaultAlert.SendOnResolved
}
if endpointAlert.Description == nil {
endpointAlert.Description = providerDefaultAlert.Description
}
if endpointAlert.FailureThreshold == 0 {
endpointAlert.FailureThreshold = providerDefaultAlert.FailureThreshold
}
if endpointAlert.SuccessThreshold == 0 {
endpointAlert.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 = (*teams.AlertProvider)(nil)
_ AlertProvider = (*telegram.AlertProvider)(nil)
_ AlertProvider = (*twilio.AlertProvider)(nil)
)

View File

@@ -0,0 +1,153 @@
package provider
import (
"testing"
"github.com/TwiN/gatus/v3/alerting/alert"
)
func TestParseWithDefaultAlert(t *testing.T) {
type Scenario struct {
Name string
DefaultAlert, EndpointAlert, ExpectedOutputAlert *alert.Alert
}
enabled := true
disabled := false
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []Scenario{
{
Name: "endpoint-alert-type-only",
DefaultAlert: &alert.Alert{
Enabled: &enabled,
SendOnResolved: &enabled,
Description: &firstDescription,
FailureThreshold: 5,
SuccessThreshold: 10,
},
EndpointAlert: &alert.Alert{
Type: alert.TypeDiscord,
},
ExpectedOutputAlert: &alert.Alert{
Type: alert.TypeDiscord,
Enabled: &enabled,
SendOnResolved: &enabled,
Description: &firstDescription,
FailureThreshold: 5,
SuccessThreshold: 10,
},
},
{
Name: "endpoint-alert-overwrites-default-alert",
DefaultAlert: &alert.Alert{
Enabled: &disabled,
SendOnResolved: &disabled,
Description: &firstDescription,
FailureThreshold: 5,
SuccessThreshold: 10,
},
EndpointAlert: &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: "endpoint-alert-partially-overwrites-default-alert",
DefaultAlert: &alert.Alert{
Enabled: &enabled,
SendOnResolved: &enabled,
Description: &firstDescription,
FailureThreshold: 5,
SuccessThreshold: 10,
},
EndpointAlert: &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,
},
EndpointAlert: &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,
},
EndpointAlert: nil,
ExpectedOutputAlert: nil,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
ParseWithDefaultAlert(scenario.DefaultAlert, scenario.EndpointAlert)
if scenario.ExpectedOutputAlert == nil {
if scenario.EndpointAlert != nil {
t.Fail()
}
return
}
if scenario.EndpointAlert.IsEnabled() != scenario.ExpectedOutputAlert.IsEnabled() {
t.Errorf("expected EndpointAlert.IsEnabled() to be %v, got %v", scenario.ExpectedOutputAlert.IsEnabled(), scenario.EndpointAlert.IsEnabled())
}
if scenario.EndpointAlert.IsSendingOnResolved() != scenario.ExpectedOutputAlert.IsSendingOnResolved() {
t.Errorf("expected EndpointAlert.IsSendingOnResolved() to be %v, got %v", scenario.ExpectedOutputAlert.IsSendingOnResolved(), scenario.EndpointAlert.IsSendingOnResolved())
}
if scenario.EndpointAlert.GetDescription() != scenario.ExpectedOutputAlert.GetDescription() {
t.Errorf("expected EndpointAlert.GetDescription() to be %v, got %v", scenario.ExpectedOutputAlert.GetDescription(), scenario.EndpointAlert.GetDescription())
}
if scenario.EndpointAlert.FailureThreshold != scenario.ExpectedOutputAlert.FailureThreshold {
t.Errorf("expected EndpointAlert.FailureThreshold to be %v, got %v", scenario.ExpectedOutputAlert.FailureThreshold, scenario.EndpointAlert.FailureThreshold)
}
if scenario.EndpointAlert.SuccessThreshold != scenario.ExpectedOutputAlert.SuccessThreshold {
t.Errorf("expected EndpointAlert.SuccessThreshold to be %v, got %v", scenario.ExpectedOutputAlert.SuccessThreshold, scenario.EndpointAlert.SuccessThreshold)
}
})
}
}

View File

@@ -2,13 +2,19 @@ package slack
import (
"fmt"
"github.com/TwinProduction/gatus/alerting/provider/custom"
"github.com/TwinProduction/gatus/core"
"net/http"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/alerting/provider/custom"
"github.com/TwiN/gatus/v3/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 endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert"`
}
// IsValid returns whether the provider's configuration is valid
@@ -17,35 +23,37 @@ 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 {
var message string
var color string
func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, 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)
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.Name, alert.SuccessThreshold)
color = "#36A64F"
} else {
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", service.Name, alert.FailureThreshold)
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.Name, alert.FailureThreshold)
color = "#DD0000"
}
var results string
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)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\\n> " + alertDescription
}
return &custom.AlertProvider{
URL: provider.WebhookURL,
Method: "POST",
Method: http.MethodPost,
Body: fmt.Sprintf(`{
"text": "",
"attachments": [
{
"title": ":helmet_with_white_cross: Gatus",
"text": "%s:\n> %s",
"text": "%s%s",
"short": false,
"color": "%s",
"fields": [
@@ -57,7 +65,12 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
]
}
]
}`, message, alert.Description, color, results),
}`, message, description, 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,13 @@
package slack
import (
"github.com/TwinProduction/gatus/core"
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/core"
)
func TestAlertProvider_IsValid(t *testing.T) {
@@ -19,22 +23,48 @@ 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)
alertDescription := "test"
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{Name: "svc"}, &alert.Alert{Description: &alertDescription}, &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())
}
if expected := "An alert for *svc* has been resolved after passing successfully 0 time(s) in a row:\n> test"; expected != body["attachments"].([]interface{})[0].(map[string]interface{})["text"] {
t.Errorf("expected $.attachments[0].description to be %s, got %s", expected, body["attachments"].([]interface{})[0].(map[string]interface{})["text"])
}
}
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
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.Endpoint{}, &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,77 @@
package teams
import (
"fmt"
"net/http"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/alerting/provider/custom"
"github.com/TwiN/gatus/v3/core"
)
// AlertProvider is the configuration necessary for sending an alert using Teams
type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"`
// DefaultAlert is the default alert configuration to use for endpoints 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(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
var message string
var color string
if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.Name, alert.SuccessThreshold)
color = "#36A64F"
} else {
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.Name, alert.FailureThreshold)
color = "#DD0000"
}
var results string
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "&#x2705;"
} else {
prefix = "&#x274C;"
}
results += fmt.Sprintf("%s - `%s`<br/>", prefix, conditionResult.Condition)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\\n> " + alertDescription
}
return &custom.AlertProvider{
URL: provider.WebhookURL,
Method: http.MethodPost,
Body: fmt.Sprintf(`{
"@type": "MessageCard",
"@context": "http://schema.org/extensions",
"themeColor": "%s",
"title": "&#x1F6A8; Gatus",
"text": "%s%s",
"sections": [
{
"activityTitle": "URL",
"text": "%s"
},
{
"activityTitle": "Condition results",
"text": "%s"
}
]
}`, color, message, description, endpoint.URL, results),
Headers: map[string]string{"Content-Type": "application/json"},
}
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}

View File

@@ -0,0 +1,70 @@
package teams
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/core"
)
func TestAlertProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{WebhookURL: ""}
if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{WebhookURL: "http://example.com"}
if !validProvider.IsValid() {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
provider := AlertProvider{WebhookURL: "http://example.org"}
alertDescription := "test"
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{Name: "svc"}, &alert.Alert{Description: &alertDescription}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
}
if !strings.Contains(customAlertProvider.Body, "resolved") {
t.Error("customAlertProvider.Body should've contained the substring resolved")
}
if customAlertProvider.URL != "http://example.org" {
t.Errorf("expected URL to be %s, got %s", "http://example.org", customAlertProvider.URL)
}
if customAlertProvider.Method != http.MethodPost {
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
}
body := make(map[string]interface{})
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
if err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
if expected := "An alert for *svc* has been resolved after passing successfully 0 time(s) in a row:\n> test"; expected != body["text"] {
t.Errorf("expected $.text to be %s, got %s", expected, body["text"])
}
}
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
provider := AlertProvider{WebhookURL: "http://example.org"}
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
}
if !strings.Contains(customAlertProvider.Body, "triggered") {
t.Error("customAlertProvider.Body should've contained the substring triggered")
}
if customAlertProvider.URL != "http://example.org" {
t.Errorf("expected URL to be %s, got %s", "http://example.org", customAlertProvider.URL)
}
if customAlertProvider.Method != http.MethodPost {
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
}
body := make(map[string]interface{})
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
if err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
}

View File

@@ -0,0 +1,60 @@
package telegram
import (
"fmt"
"net/http"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/alerting/provider/custom"
"github.com/TwiN/gatus/v3/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 endpoints 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(endpoint *core.Endpoint, 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— ", endpoint.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— ", endpoint.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/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/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.Endpoint{}, &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.Endpoint{}, &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.Endpoint{}, &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

@@ -3,9 +3,12 @@ package twilio
import (
"encoding/base64"
"fmt"
"github.com/TwinProduction/gatus/alerting/provider/custom"
"github.com/TwinProduction/gatus/core"
"net/http"
"net/url"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/alerting/provider/custom"
"github.com/TwiN/gatus/v3/core"
)
// AlertProvider is the configuration necessary for sending an alert using Twilio
@@ -14,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 endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert"`
}
// IsValid returns whether the provider's configuration is valid
@@ -22,16 +28,16 @@ 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(endpoint *core.Endpoint, 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", endpoint.Name, alert.GetDescription())
} else {
message = fmt.Sprintf("TRIGGERED: %s - %s", service.Name, alert.Description)
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.Name, alert.GetDescription())
}
return &custom.AlertProvider{
URL: fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json", provider.SID),
Method: "POST",
Method: http.MethodPost,
Body: url.Values{
"To": {provider.To},
"From": {provider.From},
@@ -43,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/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/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.Endpoint{Name: "endpoint-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+endpoint-name+-+alert-description&From=3&To=4" {
t.Errorf("expected body to be %s, got %s", "Body=RESOLVED%3A+endpoint-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.Endpoint{Name: "endpoint-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+endpoint-name+-+alert-description&From=2&To=1" {
t.Errorf("expected body to be %s, got %s", "Body=TRIGGERED%3A+endpoint-name+-+alert-description&From=2&To=1", customAlertProvider.Body)
}
}

View File

@@ -2,51 +2,105 @@ package client
import (
"crypto/tls"
"crypto/x509"
"errors"
"net"
"net/http"
"net/smtp"
"runtime"
"strings"
"time"
)
var (
secureHTTPClient *http.Client
insecureHTTPClient *http.Client
"github.com/go-ping/ping"
)
// GetHTTPClient returns the shared HTTP client
func GetHTTPClient(insecure bool) *http.Client {
if insecure {
if insecureHTTPClient == nil {
insecureHTTPClient = &http.Client{
Timeout: time.Second * 10,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20,
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
},
}
}
return insecureHTTPClient
func GetHTTPClient(config *Config) *http.Client {
if config == nil {
return defaultConfig.getHTTPClient()
}
if secureHTTPClient == nil {
secureHTTPClient = &http.Client{
Timeout: time.Second * 10,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20,
},
}
}
return secureHTTPClient
return config.getHTTPClient()
}
// CanCreateConnectionToTCPService checks whether a connection can be established with a TCP service
func CanCreateConnectionToTCPService(address string) bool {
conn, err := net.DialTimeout("tcp", address, 5*time.Second)
// CanCreateTCPConnection checks whether a connection can be established with a TCP endpoint
func CanCreateTCPConnection(address string, config *Config) bool {
conn, err := net.DialTimeout("tcp", address, config.Timeout)
if err != nil {
return false
}
_ = conn.Close()
return true
}
// CanPerformStartTLS checks whether a connection can be established to an address using the STARTTLS protocol
func CanPerformStartTLS(address string, config *Config) (connected bool, certificate *x509.Certificate, err error) {
hostAndPort := strings.Split(address, ":")
if len(hostAndPort) != 2 {
return false, nil, errors.New("invalid address for starttls, format must be host:port")
}
connection, err := net.DialTimeout("tcp", address, config.Timeout)
if err != nil {
return
}
smtpClient, err := smtp.NewClient(connection, hostAndPort[0])
if err != nil {
return
}
err = smtpClient.StartTLS(&tls.Config{
InsecureSkipVerify: config.Insecure,
ServerName: hostAndPort[0],
})
if err != nil {
return
}
if state, ok := smtpClient.TLSConnectionState(); ok {
certificate = state.PeerCertificates[0]
} else {
return false, nil, errors.New("could not get TLS connection state")
}
return true, certificate, nil
}
// CanPerformTLS checks whether a connection can be established to an address using the TLS protocol
func CanPerformTLS(address string, config *Config) (connected bool, certificate *x509.Certificate, err error) {
connection, err := tls.DialWithDialer(&net.Dialer{Timeout: config.Timeout}, "tcp", address, nil)
if err != nil {
return
}
defer connection.Close()
verifiedChains := connection.ConnectionState().VerifiedChains
if len(verifiedChains) == 0 || len(verifiedChains[0]) == 0 {
return
}
return true, verifiedChains[0][0], 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, config *Config) (bool, time.Duration) {
pinger, err := ping.NewPinger(address)
if err != nil {
return false, 0
}
pinger.Count = 1
pinger.Timeout = config.Timeout
// Set the pinger's privileged mode to true for every GOOS except darwin
// See https://github.com/TwiN/gatus/issues/132
//
// Note that for this to work on Linux, Gatus must run with sudo privileges.
// See https://github.com/go-ping/ping#linux
pinger.SetPrivileged(runtime.GOOS != "darwin")
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,27 +2,147 @@ package client
import (
"testing"
"time"
)
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")
func TestGetHTTPClient(t *testing.T) {
cfg := &Config{
Insecure: false,
IgnoreRedirect: false,
Timeout: 0,
}
if insecureHTTPClient != nil {
t.Error("insecureHTTPClient should've been nil since it hasn't been called a single time yet")
cfg.ValidateAndSetDefaults()
if GetHTTPClient(cfg) == nil {
t.Error("expected client to not be nil")
}
_ = GetHTTPClient(false)
if secureHTTPClient == nil {
t.Error("secureHTTPClient shouldn't have been nil, since it has been called once")
}
if insecureHTTPClient != nil {
t.Error("insecureHTTPClient should've been nil since it hasn't been called a single time yet")
}
_ = GetHTTPClient(true)
if secureHTTPClient == nil {
t.Error("secureHTTPClient shouldn't have been nil, since it has been called once")
}
if insecureHTTPClient == nil {
t.Error("insecureHTTPClient shouldn't have been nil, since it has been called once")
if GetHTTPClient(nil) == nil {
t.Error("expected client to not be nil")
}
}
func TestPing(t *testing.T) {
if success, rtt := Ping("127.0.0.1", &Config{Timeout: 500 * time.Millisecond}); !success {
t.Error("expected true")
if rtt == 0 {
t.Error("Round-trip time returned on success should've higher than 0")
}
}
if success, rtt := Ping("256.256.256.256", &Config{Timeout: 500 * time.Millisecond}); success {
t.Error("expected false, because the IP is invalid")
if rtt != 0 {
t.Error("Round-trip time returned on failure should've been 0")
}
}
if success, rtt := Ping("192.168.152.153", &Config{Timeout: 500 * time.Millisecond}); success {
t.Error("expected false, because the IP is valid but the host should be unreachable")
if rtt != 0 {
t.Error("Round-trip time returned on failure should've been 0")
}
}
}
func TestCanPerformStartTLS(t *testing.T) {
type args struct {
address string
insecure bool
}
tests := []struct {
name string
args args
wantConnected bool
wantErr bool
}{
{
name: "invalid address",
args: args{
address: "test",
},
wantConnected: false,
wantErr: true,
},
{
name: "error dial",
args: args{
address: "test:1234",
},
wantConnected: false,
wantErr: true,
},
{
name: "valid starttls",
args: args{
address: "smtp.gmail.com:587",
},
wantConnected: true,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
connected, _, err := CanPerformStartTLS(tt.args.address, &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second})
if (err != nil) != tt.wantErr {
t.Errorf("CanPerformStartTLS() err=%v, wantErr=%v", err, tt.wantErr)
return
}
if connected != tt.wantConnected {
t.Errorf("CanPerformStartTLS() connected=%v, wantConnected=%v", connected, tt.wantConnected)
}
})
}
}
func TestCanPerformTLS(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 tls",
args: args{
address: "smtp.gmail.com:465",
},
wantConnected: true,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
connected, _, err := CanPerformTLS(tt.args.address, &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second})
if (err != nil) != tt.wantErr {
t.Errorf("CanPerformTLS() err=%v, wantErr=%v", err, tt.wantErr)
return
}
if connected != tt.wantConnected {
t.Errorf("CanPerformTLS() connected=%v, wantConnected=%v", connected, tt.wantConnected)
}
})
}
}
func TestCanCreateTCPConnection(t *testing.T) {
if CanCreateTCPConnection("127.0.0.1", &Config{Timeout: 5 * time.Second}) {
t.Error("should've failed, because there's no port in the address")
}
}

73
client/config.go Normal file
View File

@@ -0,0 +1,73 @@
package client
import (
"crypto/tls"
"net/http"
"time"
)
const (
defaultHTTPTimeout = 10 * time.Second
)
var (
// DefaultConfig is the default client configuration
defaultConfig = Config{
Insecure: false,
IgnoreRedirect: false,
Timeout: defaultHTTPTimeout,
}
)
// GetDefaultConfig returns a copy of the default configuration
func GetDefaultConfig() *Config {
cfg := defaultConfig
return &cfg
}
// Config is the configuration for clients
type Config struct {
// Insecure determines whether to skip verifying the server's certificate chain and host name
Insecure bool `yaml:"insecure"`
// IgnoreRedirect determines whether to ignore redirects (true) or follow them (false, default)
IgnoreRedirect bool `yaml:"ignore-redirect"`
// Timeout for the client
Timeout time.Duration `yaml:"timeout"`
httpClient *http.Client
}
// ValidateAndSetDefaults validates the client configuration and sets the default values if necessary
func (c *Config) ValidateAndSetDefaults() {
if c.Timeout < time.Millisecond {
c.Timeout = 10 * time.Second
}
}
// GetHTTPClient return an HTTP client matching the Config's parameters.
func (c *Config) getHTTPClient() *http.Client {
if c.httpClient == nil {
c.httpClient = &http.Client{
Timeout: c.Timeout,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20,
Proxy: http.ProxyFromEnvironment,
TLSClientConfig: &tls.Config{
InsecureSkipVerify: c.Insecure,
},
},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if c.IgnoreRedirect {
// Don't follow redirects
return http.ErrUseLastResponse
}
// Follow redirects
return nil
},
}
}
return c.httpClient
}

37
client/config_test.go Normal file
View File

@@ -0,0 +1,37 @@
package client
import (
"net/http"
"testing"
"time"
)
func TestConfig_getHTTPClient(t *testing.T) {
insecureConfig := &Config{Insecure: true}
insecureConfig.ValidateAndSetDefaults()
insecureClient := insecureConfig.getHTTPClient()
if !(insecureClient.Transport).(*http.Transport).TLSClientConfig.InsecureSkipVerify {
t.Error("expected Config.Insecure set to true to cause the HTTP client to skip certificate verification")
}
if insecureClient.Timeout != defaultHTTPTimeout {
t.Error("expected Config.Timeout to default the HTTP client to a timeout of 10s")
}
request, _ := http.NewRequest("GET", "", nil)
if err := insecureClient.CheckRedirect(request, nil); err != nil {
t.Error("expected Config.IgnoreRedirect set to false to cause the HTTP client's CheckRedirect to return nil")
}
secureConfig := &Config{IgnoreRedirect: true, Timeout: 5 * time.Second}
secureConfig.ValidateAndSetDefaults()
secureClient := secureConfig.getHTTPClient()
if (secureClient.Transport).(*http.Transport).TLSClientConfig.InsecureSkipVerify {
t.Error("expected Config.Insecure set to false to cause the HTTP client to not skip certificate verification")
}
if secureClient.Timeout != 5*time.Second {
t.Error("expected Config.Timeout to cause the HTTP client to have a timeout of 5s")
}
request, _ = http.NewRequest("GET", "", nil)
if err := secureClient.CheckRedirect(request, nil); err != http.ErrUseLastResponse {
t.Error("expected Config.IgnoreRedirect set to true to cause the HTTP client's CheckRedirect to return http.ErrUseLastResponse")
}
}

View File

@@ -1,16 +1,47 @@
metrics: true
services:
- name: twinnation
url: "https://twinnation.org/health"
interval: 30s
endpoints:
- name: front-end
group: core
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 1000"
- name: cat-fact
url: "https://cat-fact.herokuapp.com/facts/random"
interval: 1m
- "[RESPONSE_TIME] < 150"
- name: back-end
group: core
url: "https://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].deleted == false"
- "len([BODY].text) > 0"
- "[CERTIFICATE_EXPIRATION] > 48h"
- name: monitoring
group: internal
url: "https://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"
- name: nas
group: internal
url: "https://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"
- name: example-dns-query
url: "8.8.8.8" # Address of the DNS server to use
interval: 5m
dns:
query-name: "example.com"
query-type: "A"
conditions:
- "[BODY] == 93.184.216.34"
- "[DNS_RCODE] == NOERROR"
- name: icmp-ping
url: "icmp://example.org"
interval: 1m
conditions:
- "[CONNECTED] == true"

View File

@@ -5,12 +5,17 @@ import (
"io/ioutil"
"log"
"os"
"time"
"github.com/TwinProduction/gatus/alerting"
"github.com/TwinProduction/gatus/alerting/provider"
"github.com/TwinProduction/gatus/core"
"github.com/TwinProduction/gatus/k8s"
"github.com/TwinProduction/gatus/security"
"github.com/TwiN/gatus/v3/alerting"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/alerting/provider"
"github.com/TwiN/gatus/v3/config/maintenance"
"github.com/TwiN/gatus/v3/config/ui"
"github.com/TwiN/gatus/v3/config/web"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/security"
"github.com/TwiN/gatus/v3/storage"
"gopkg.in/yaml.v2"
)
@@ -25,79 +30,114 @@ const (
)
var (
// ErrNoServiceInConfig is an error returned when a configuration file has no services configured
ErrNoServiceInConfig = errors.New("configuration file should contain at least 1 service")
// ErrNoEndpointInConfig is an error returned when a configuration file has no endpoints configured
ErrNoEndpointInConfig = errors.New("configuration file should contain at least 1 endpoint")
// 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
type Config struct {
// Debug Whether to enable debug logs
Debug bool `yaml:"debug"`
Debug bool `yaml:"debug,omitempty"`
// Metrics Whether to expose metrics at /metrics
Metrics bool `yaml:"metrics"`
Metrics bool `yaml:"metrics,omitempty"`
// 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,omitempty"`
// DisableMonitoringLock Whether to disable the monitoring lock
// The monitoring lock is what prevents multiple services from being processed at the same time.
// The monitoring lock is what prevents multiple endpoints from being processed at the same time.
// Disabling this may lead to inaccurate response times
DisableMonitoringLock bool `yaml:"disable-monitoring-lock"`
DisableMonitoringLock bool `yaml:"disable-monitoring-lock,omitempty"`
// Security Configuration for securing access to Gatus
Security *security.Config `yaml:"security"`
Security *security.Config `yaml:"security,omitempty"`
// Alerting Configuration for alerting
Alerting *alerting.Config `yaml:"alerting"`
Alerting *alerting.Config `yaml:"alerting,omitempty"`
// Services List of services to monitor
Services []*core.Service `yaml:"services"`
// Endpoints List of endpoints to monitor
Endpoints []*core.Endpoint `yaml:"endpoints,omitempty"`
// Kubernetes is the Kubernetes configuration
Kubernetes *k8s.Config `yaml:"kubernetes"`
// Services List of endpoints to monitor
//
// XXX: Remove this in v5.0.0
// XXX: This is not a typo -- not v4.0.0, but v5.0.0 -- I want to give enough time for people to migrate
//
// Deprecated in favor of Endpoints
Services []*core.Endpoint `yaml:"services,omitempty"`
// Storage is the configuration for how the data is stored
Storage *storage.Config `yaml:"storage,omitempty"`
// Web is the web configuration for the application
Web *web.Config `yaml:"web,omitempty"`
// UI is the configuration for the UI
UI *ui.Config `yaml:"ui,omitempty"`
// Maintenance is the configuration for creating a maintenance window in which no alerts are sent
Maintenance *maintenance.Config `yaml:"maintenance,omitempty"`
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()
}
} else {
log.Println("[config][UpdateLastFileModTime] Ran into error updating lastFileModTime:", err.Error())
}
return config
}
// Load loads a custom configuration file
func Load(configFile string) error {
// Note that the misconfiguration of some fields may lead to panics. This is on purpose.
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) {
@@ -109,56 +149,105 @@ func readConfigurationFile(fileName string) (config *Config, err error) {
return
}
// parseAndValidateConfigBytes parses a Gatus configuration file into a Config struct and validates its parameters
func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
// Expand environment variables
yamlBytes = []byte(os.ExpandEnv(string(yamlBytes)))
// Parse configuration file
err = yaml.Unmarshal(yamlBytes, &config)
if err != nil {
if err = yaml.Unmarshal(yamlBytes, &config); err != nil {
return
}
// Check if the configuration file at least has services configured or Kubernetes auto discovery enabled
if config == nil || ((config.Services == nil || len(config.Services) == 0) && (config.Kubernetes == nil || !config.Kubernetes.AutoDiscover)) {
err = ErrNoServiceInConfig
if config != nil && len(config.Services) > 0 { // XXX: Remove this in v5.0.0
log.Println("WARNING: Your configuration is using 'services:', which is deprecated in favor of 'endpoints:'.")
log.Println("WARNING: See https://github.com/TwiN/gatus/issues/191 for more information")
config.Endpoints = append(config.Endpoints, config.Services...)
config.Services = nil
}
// Check if the configuration file at least has endpoints configured
if config == nil || config.Endpoints == nil || len(config.Endpoints) == 0 {
err = ErrNoEndpointInConfig
} 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)
validateAlertingConfig(config.Alerting, config.Endpoints, config.Debug)
if err := validateSecurityConfig(config); err != nil {
return nil, err
}
if err := validateEndpointsConfig(config); err != nil {
return nil, err
}
if err := validateWebConfig(config); err != nil {
return nil, err
}
if err := validateUIConfig(config); err != nil {
return nil, err
}
if err := validateMaintenanceConfig(config); err != nil {
return nil, err
}
if err := validateStorageConfig(config); err != nil {
return nil, err
}
}
return
}
func validateKubernetesConfig(config *Config) {
if config.Kubernetes != nil && config.Kubernetes.AutoDiscover {
if config.Kubernetes.ServiceTemplate == nil {
panic("kubernetes.service-template cannot be nil")
func validateStorageConfig(config *Config) error {
if config.Storage == nil {
config.Storage = &storage.Config{
Type: storage.TypeMemory,
}
if config.Debug {
log.Println("[config][validateKubernetesConfig] Automatically discovering Kubernetes services...")
} else {
if err := config.Storage.ValidateAndSetDefaults(); err != nil {
return err
}
discoveredServices, err := k8s.DiscoverServices(config.Kubernetes)
if err != nil {
panic(err)
}
config.Services = append(config.Services, discoveredServices...)
log.Printf("[config][validateKubernetesConfig] Discovered %d Kubernetes services", len(discoveredServices))
}
return nil
}
func validateServicesConfig(config *Config) {
for _, service := range config.Services {
if config.Debug {
log.Printf("[config][validateServicesConfig] Validating service '%s'", service.Name)
func validateMaintenanceConfig(config *Config) error {
if config.Maintenance == nil {
config.Maintenance = maintenance.GetDefaultConfig()
} else {
if err := config.Maintenance.ValidateAndSetDefaults(); err != nil {
return err
}
service.ValidateAndSetDefaults()
}
log.Printf("[config][validateServicesConfig] Validated %d services", len(config.Services))
return nil
}
func validateSecurityConfig(config *Config) {
func validateUIConfig(config *Config) error {
if config.UI == nil {
config.UI = ui.GetDefaultConfig()
} else {
if err := config.UI.ValidateAndSetDefaults(); err != nil {
return err
}
}
return nil
}
func validateWebConfig(config *Config) error {
if config.Web == nil {
config.Web = web.GetDefaultConfig()
} else {
return config.Web.ValidateAndSetDefaults()
}
return nil
}
func validateEndpointsConfig(config *Config) error {
for _, endpoint := range config.Endpoints {
if config.Debug {
log.Printf("[config][validateEndpointsConfig] Validating endpoint '%s'", endpoint.Name)
}
if err := endpoint.ValidateAndSetDefaults(); err != nil {
return err
}
}
log.Printf("[config][validateEndpointsConfig] Validated %d endpoints", len(config.Endpoints))
return nil
}
func validateSecurityConfig(config *Config) error {
if config.Security != nil {
if config.Security.IsValid() {
if config.Debug {
@@ -167,28 +256,50 @@ 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 endpoint configuration, because the default alert
// returned by provider.AlertProvider.GetDefaultAlert() must be parsed before core.Endpoint.ValidateAndSetDefaults()
// sets the default alert values when none are set.
func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*core.Endpoint, debug bool) {
if alertingConfig == nil {
log.Printf("[config][validateAlertingConfig] Alerting is not configured")
return
}
alertTypes := []core.AlertType{
core.SlackAlert,
core.MattermostAlert,
core.TwilioAlert,
core.PagerDutyAlert,
core.CustomAlert,
alertTypes := []alert.Type{
alert.TypeCustom,
alert.TypeDiscord,
alert.TypeMattermost,
alert.TypeMessagebird,
alert.TypePagerDuty,
alert.TypeSlack,
alert.TypeTeams,
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 _, endpoint := range endpoints {
for alertIndex, endpointAlert := range endpoint.Alerts {
if alertType == endpointAlert.Type {
if debug {
log.Printf("[config][validateAlertingConfig] Parsing alert %d with provider's default alert for provider=%s in endpoint=%s", alertIndex, alertType, endpoint.Name)
}
provider.ParseWithDefaultAlert(alertProvider.GetDefaultAlert(), endpointAlert)
}
}
}
}
validProviders = append(validProviders, alertType)
} else {
log.Printf("[config][validateAlertingConfig] Ignoring provider=%s because configuration is invalid", alertType)
@@ -200,40 +311,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.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

@@ -0,0 +1,146 @@
package maintenance
import (
"errors"
"fmt"
"strconv"
"strings"
"time"
)
var (
errInvalidMaintenanceStartFormat = errors.New("invalid maintenance start format: must be hh:mm, between 00:00 and 23:59 inclusively (e.g. 23:00)")
errInvalidMaintenanceDuration = errors.New("invalid maintenance duration: must be bigger than 0 (e.g. 30m)")
errInvalidDayName = fmt.Errorf("invalid value specified for 'on'. supported values are %s", longDayNames)
longDayNames = []string{
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
}
)
// Config allows for the configuration of a maintenance period.
// During this maintenance period, no alerts will be sent.
//
// Uses UTC.
type Config struct {
Enabled *bool `yaml:"enabled"` // Whether the maintenance period is enabled. Enabled by default if nil.
Start string `yaml:"start"` // Time at which the maintenance period starts (e.g. 23:00)
Duration time.Duration `yaml:"duration"` // Duration of the maintenance period (e.g. 4h)
// Every is a list of days of the week during which maintenance period applies.
// See longDayNames for list of valid values.
// Every day if empty.
Every []string `yaml:"every"`
durationToStartFromMidnight time.Duration
timeLocation *time.Location
}
func GetDefaultConfig() *Config {
defaultValue := false
return &Config{
Enabled: &defaultValue,
}
}
// IsEnabled returns whether maintenance is enabled or not
func (c Config) IsEnabled() bool {
if c.Enabled == nil {
return true
}
return *c.Enabled
}
// ValidateAndSetDefaults validates the maintenance configuration and sets the default values if necessary.
//
// Must be called once in the application's lifecycle before IsUnderMaintenance is called, since it
// also sets durationToStartFromMidnight.
func (c *Config) ValidateAndSetDefaults() error {
if c == nil || !c.IsEnabled() {
// Don't waste time validating if maintenance is not enabled.
return nil
}
for _, day := range c.Every {
isDayValid := false
for _, longDayName := range longDayNames {
if day == longDayName {
isDayValid = true
break
}
}
if !isDayValid {
return errInvalidDayName
}
}
var err error
c.durationToStartFromMidnight, err = hhmmToDuration(c.Start)
if err != nil {
return err
}
if c.Duration <= 0 || c.Duration >= 24*time.Hour {
return errInvalidMaintenanceDuration
}
return nil
}
// IsUnderMaintenance checks whether the endpoints that Gatus monitors are within the configured maintenance window
func (c Config) IsUnderMaintenance() bool {
if !c.IsEnabled() {
return false
}
now := time.Now().UTC()
var dayWhereMaintenancePeriodWouldStart time.Time
if now.Hour() >= int(c.durationToStartFromMidnight.Hours()) {
dayWhereMaintenancePeriodWouldStart = now.Truncate(24 * time.Hour)
} else {
dayWhereMaintenancePeriodWouldStart = now.Add(-c.Duration).Truncate(24 * time.Hour)
}
hasMaintenanceEveryDay := len(c.Every) == 0
hasMaintenancePeriodScheduledToStartOnThatWeekday := c.hasDay(dayWhereMaintenancePeriodWouldStart.Weekday().String())
if !hasMaintenanceEveryDay && !hasMaintenancePeriodScheduledToStartOnThatWeekday {
// The day when the maintenance period would start is not scheduled
// to have any maintenance, so we can just return false.
return false
}
startOfMaintenancePeriod := dayWhereMaintenancePeriodWouldStart.Add(c.durationToStartFromMidnight)
endOfMaintenancePeriod := startOfMaintenancePeriod.Add(c.Duration)
return now.After(startOfMaintenancePeriod) && now.Before(endOfMaintenancePeriod)
}
func (c Config) hasDay(day string) bool {
for _, d := range c.Every {
if d == day {
return true
}
}
return false
}
func hhmmToDuration(s string) (time.Duration, error) {
if len(s) != 5 {
return 0, errInvalidMaintenanceStartFormat
}
var hours, minutes int
var err error
if hours, err = extractNumericalValueFromPotentiallyZeroPaddedString(s[:2]); err != nil {
return 0, err
}
if minutes, err = extractNumericalValueFromPotentiallyZeroPaddedString(s[3:5]); err != nil {
return 0, err
}
duration := (time.Duration(hours) * time.Hour) + (time.Duration(minutes) * time.Minute)
if hours < 0 || hours > 23 || minutes < 0 || minutes > 59 || duration < 0 || duration >= 24*time.Hour {
return 0, errInvalidMaintenanceStartFormat
}
return duration, nil
}
func extractNumericalValueFromPotentiallyZeroPaddedString(s string) (int, error) {
return strconv.Atoi(strings.TrimPrefix(s, "0"))
}

View File

@@ -0,0 +1,226 @@
package maintenance
import (
"errors"
"fmt"
"strconv"
"testing"
"time"
)
func TestGetDefaultConfig(t *testing.T) {
if *GetDefaultConfig().Enabled {
t.Fatal("expected default config to be disabled by default")
}
}
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
yes, no := true, false
scenarios := []struct {
name string
cfg *Config
expectedError error
}{
{
name: "nil",
cfg: nil,
expectedError: nil,
},
{
name: "disabled",
cfg: &Config{
Enabled: &no,
},
expectedError: nil,
},
{
name: "invalid-day",
cfg: &Config{
Every: []string{"invalid-day"},
},
expectedError: errInvalidDayName,
},
{
name: "invalid-day",
cfg: &Config{
Every: []string{"invalid-day"},
},
expectedError: errInvalidDayName,
},
{
name: "invalid-start-format",
cfg: &Config{
Start: "0000",
},
expectedError: errInvalidMaintenanceStartFormat,
},
{
name: "invalid-start-hours",
cfg: &Config{
Start: "25:00",
},
expectedError: errInvalidMaintenanceStartFormat,
},
{
name: "invalid-start-minutes",
cfg: &Config{
Start: "0:61",
},
expectedError: errInvalidMaintenanceStartFormat,
},
{
name: "invalid-start-minutes-non-numerical",
cfg: &Config{
Start: "00:zz",
},
expectedError: strconv.ErrSyntax,
},
{
name: "invalid-start-hours-non-numerical",
cfg: &Config{
Start: "zz:00",
},
expectedError: strconv.ErrSyntax,
},
{
name: "invalid-duration",
cfg: &Config{
Start: "23:00",
Duration: 0,
},
expectedError: errInvalidMaintenanceDuration,
},
{
name: "every-day-at-2300",
cfg: &Config{
Start: "23:00",
Duration: time.Hour,
},
expectedError: nil,
},
{
name: "every-monday-at-0000",
cfg: &Config{
Start: "00:00",
Duration: 30 * time.Minute,
Every: []string{"Monday"},
},
expectedError: nil,
},
{
name: "every-friday-and-sunday-at-0000-explicitly-enabled",
cfg: &Config{
Enabled: &yes,
Start: "08:00",
Duration: 8 * time.Hour,
Every: []string{"Friday", "Sunday"},
},
expectedError: nil,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.cfg.ValidateAndSetDefaults()
if !errors.Is(err, scenario.expectedError) {
t.Errorf("expected %v, got %v", scenario.expectedError, err)
}
})
}
}
func TestConfig_IsUnderMaintenance(t *testing.T) {
yes, no := true, false
now := time.Now().UTC()
scenarios := []struct {
name string
cfg *Config
expected bool
}{
{
name: "disabled",
cfg: &Config{
Enabled: &no,
},
expected: false,
},
{
name: "under-maintenance-explicitly-enabled",
cfg: &Config{
Enabled: &yes,
Start: fmt.Sprintf("%02d:00", now.Hour()),
Duration: 2 * time.Hour,
},
expected: true,
},
{
name: "under-maintenance-starting-now-for-2h",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", now.Hour()),
Duration: 2 * time.Hour,
},
expected: true,
},
{
name: "under-maintenance-starting-now-for-8h",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", now.Hour()),
Duration: 8 * time.Hour,
},
expected: true,
},
{
name: "under-maintenance-starting-4h-ago-for-8h",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", normalizeHour(now.Hour()-4)),
Duration: 8 * time.Hour,
},
expected: true,
},
{
name: "under-maintenance-starting-4h-ago-for-3h",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", normalizeHour(now.Hour()-4)),
Duration: 3 * time.Hour,
},
expected: false,
},
{
name: "under-maintenance-starting-5h-ago-for-1h",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", normalizeHour(now.Hour()-5)),
Duration: time.Hour,
},
expected: false,
},
{
name: "not-under-maintenance-today",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", now.Hour()),
Duration: time.Hour,
Every: []string{now.Add(48 * time.Hour).Weekday().String()},
},
expected: false,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
t.Log(scenario.cfg.Start)
t.Log(now)
if err := scenario.cfg.ValidateAndSetDefaults(); err != nil {
t.Fatal("validation shouldn't have returned an error, got", err)
}
isUnderMaintenance := scenario.cfg.IsUnderMaintenance()
if isUnderMaintenance != scenario.expected {
t.Errorf("expected %v, got %v", scenario.expected, isUnderMaintenance)
t.Logf("start=%v; duration=%v; now=%v", scenario.cfg.Start, scenario.cfg.Duration, time.Now().UTC())
}
})
}
}
func normalizeHour(hour int) int {
if hour < 0 {
return hour + 24
}
return hour
}

48
config/ui/ui.go Normal file
View File

@@ -0,0 +1,48 @@
package ui
import (
"bytes"
"html/template"
)
const (
defaultTitle = "Health Dashboard | Gatus"
defaultLogo = ""
)
var (
// 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"
)
// Config is the configuration for the UI of Gatus
type Config struct {
Title string `yaml:"title"` // Title of the page
Logo string `yaml:"logo"` // Logo to display on the page
}
// GetDefaultConfig returns a Config struct with the default values
func GetDefaultConfig() *Config {
return &Config{
Title: defaultTitle,
Logo: defaultLogo,
}
}
// ValidateAndSetDefaults validates the UI configuration and sets the default values if necessary.
func (cfg *Config) ValidateAndSetDefaults() error {
if len(cfg.Title) == 0 {
cfg.Title = defaultTitle
}
t, err := template.ParseFiles(StaticFolder + "/index.html")
if err != nil {
return err
}
var buffer bytes.Buffer
err = t.Execute(&buffer, cfg)
if err != nil {
return err
}
return nil
}

26
config/ui/ui_test.go Normal file
View File

@@ -0,0 +1,26 @@
package ui
import (
"testing"
)
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
StaticFolder = "../../web/static"
defer func() {
StaticFolder = "./web/static"
}()
cfg := &Config{Title: ""}
if err := cfg.ValidateAndSetDefaults(); err != nil {
t.Error("expected no error, got", err.Error())
}
}
func TestGetDefaultConfig(t *testing.T) {
defaultConfig := GetDefaultConfig()
if defaultConfig.Title != defaultTitle {
t.Error("expected GetDefaultConfig() to return defaultTitle, got", defaultConfig.Title)
}
if defaultConfig.Logo != defaultLogo {
t.Error("expected GetDefaultConfig() to return defaultLogo, got", defaultConfig.Logo)
}
}

49
config/web/web.go Normal file
View File

@@ -0,0 +1,49 @@
package web
import (
"fmt"
"math"
)
const (
// DefaultAddress is the default address the application will bind to
DefaultAddress = "0.0.0.0"
// DefaultPort is the default port the application will listen on
DefaultPort = 8080
)
// Config is the structure which supports the configuration of the endpoint
// which provides access to the web frontend
type Config 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"`
}
// GetDefaultConfig returns a Config struct with the default values
func GetDefaultConfig() *Config {
return &Config{Address: DefaultAddress, Port: DefaultPort}
}
// ValidateAndSetDefaults validates the web configuration and sets the default values if necessary.
func (web *Config) ValidateAndSetDefaults() error {
// Validate the Address
if len(web.Address) == 0 {
web.Address = DefaultAddress
}
// Validate the Port
if web.Port == 0 {
web.Port = DefaultPort
} else if web.Port < 0 || web.Port > math.MaxUint16 {
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 *Config) SocketAddress() string {
return fmt.Sprintf("%s:%d", web.Address, web.Port)
}

65
config/web/web_test.go Normal file
View File

@@ -0,0 +1,65 @@
package web
import (
"testing"
)
func TestGetDefaultConfig(t *testing.T) {
defaultConfig := GetDefaultConfig()
if defaultConfig.Port != DefaultPort {
t.Error("expected default config to have the default port")
}
if defaultConfig.Address != DefaultAddress {
t.Error("expected default config to have the default address")
}
}
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
scenarios := []struct {
name string
cfg *Config
expectedAddress string
expectedPort int
expectedErr bool
}{
{
name: "no-explicit-config",
cfg: &Config{},
expectedAddress: "0.0.0.0",
expectedPort: 8080,
expectedErr: false,
},
{
name: "invalid-port",
cfg: &Config{Port: 100000000},
expectedErr: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.cfg.ValidateAndSetDefaults()
if (err != nil) != scenario.expectedErr {
t.Errorf("expected the existence of an error to be %v, got %v", scenario.expectedErr, err)
return
}
if !scenario.expectedErr {
if scenario.cfg.Port != scenario.expectedPort {
t.Errorf("expected port to be %d, got %d", scenario.expectedPort, scenario.cfg.Port)
}
if scenario.cfg.Address != scenario.expectedAddress {
t.Errorf("expected address to be %s, got %s", scenario.expectedAddress, scenario.cfg.Address)
}
}
})
}
}
func TestConfig_SocketAddress(t *testing.T) {
web := &Config{
Address: "0.0.0.0",
Port: 8081,
}
if web.SocketAddress() != "0.0.0.0:8081" {
t.Errorf("expected %s, got %s", "0.0.0.0:8081", web.SocketAddress())
}
}

49
controller/controller.go Normal file
View File

@@ -0,0 +1,49 @@
package controller
import (
"context"
"fmt"
"log"
"net/http"
"os"
"time"
"github.com/TwiN/gatus/v3/config/ui"
"github.com/TwiN/gatus/v3/config/web"
"github.com/TwiN/gatus/v3/controller/handler"
"github.com/TwiN/gatus/v3/security"
)
var (
// server is the http.Server created by Handle.
// The only reason it exists is for testing purposes.
server *http.Server
)
// Handle creates the router and starts the server
func Handle(securityConfig *security.Config, webConfig *web.Config, uiConfig *ui.Config, enableMetrics bool) {
var router http.Handler = handler.CreateRouter(ui.StaticFolder, securityConfig, uiConfig, enableMetrics)
if os.Getenv("ENVIRONMENT") == "dev" {
router = handler.DevelopmentCORS(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
}
}

View File

@@ -0,0 +1,55 @@
package controller
import (
"math/rand"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/TwiN/gatus/v3/config"
"github.com/TwiN/gatus/v3/config/web"
"github.com/TwiN/gatus/v3/core"
)
func TestHandle(t *testing.T) {
cfg := &config.Config{
Web: &web.Config{
Address: "0.0.0.0",
Port: rand.Intn(65534),
},
Endpoints: []*core.Endpoint{
{
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.UI, cfg.Metrics)
defer Shutdown()
request, _ := http.NewRequest("GET", "/health", http.NoBody)
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")
}
}

227
controller/handler/badge.go Normal file
View File

@@ -0,0 +1,227 @@
package handler
import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/TwiN/gatus/v3/storage/store"
"github.com/TwiN/gatus/v3/storage/store/common"
"github.com/gorilla/mux"
)
const (
badgeColorHexAwesome = "#40cc11"
badgeColorHexGreat = "#94cc11"
badgeColorHexGood = "#ccd311"
badgeColorHexPassable = "#ccb311"
badgeColorHexBad = "#cc8111"
badgeColorHexVeryBad = "#c7130a"
)
// UptimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.
//
// Valid values for {duration}: 7d, 24h, 1h
func UptimeBadge(writer http.ResponseWriter, request *http.Request) {
variables := mux.Vars(request)
duration := variables["duration"]
var from time.Time
switch duration {
case "7d":
from = time.Now().Add(-7 * 24 * time.Hour)
case "24h":
from = time.Now().Add(-24 * time.Hour)
case "1h":
from = time.Now().Add(-2 * time.Hour) // Because uptime metrics are stored by hour, we have to cheat a little
default:
http.Error(writer, "Durations supported: 7d, 24h, 1h", http.StatusBadRequest)
return
}
key := variables["key"]
uptime, err := store.Get().GetUptimeByKey(key, from, time.Now())
if err != nil {
if err == common.ErrEndpointNotFound {
http.Error(writer, err.Error(), http.StatusNotFound)
} else if err == common.ErrInvalidTimeRange {
http.Error(writer, err.Error(), http.StatusBadRequest)
} else {
http.Error(writer, err.Error(), http.StatusInternalServerError)
}
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(generateUptimeBadgeSVG(duration, uptime))
}
// ResponseTimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.
//
// Valid values for {duration}: 7d, 24h, 1h
func ResponseTimeBadge(writer http.ResponseWriter, request *http.Request) {
variables := mux.Vars(request)
duration := variables["duration"]
var from time.Time
switch duration {
case "7d":
from = time.Now().Add(-7 * 24 * time.Hour)
case "24h":
from = time.Now().Add(-24 * time.Hour)
case "1h":
from = time.Now().Add(-2 * time.Hour) // Because response time metrics are stored by hour, we have to cheat a little
default:
http.Error(writer, "Durations supported: 7d, 24h, 1h", http.StatusBadRequest)
return
}
key := variables["key"]
averageResponseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now())
if err != nil {
if err == common.ErrEndpointNotFound {
http.Error(writer, err.Error(), http.StatusNotFound)
} else if err == common.ErrInvalidTimeRange {
http.Error(writer, err.Error(), http.StatusBadRequest)
} else {
http.Error(writer, err.Error(), http.StatusInternalServerError)
}
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(generateResponseTimeBadgeSVG(duration, averageResponseTime))
}
func generateUptimeBadgeSVG(duration string, uptime float64) []byte {
var labelWidth, valueWidth, valueWidthAdjustment int
switch duration {
case "7d":
labelWidth = 65
case "24h":
labelWidth = 70
case "1h":
labelWidth = 65
default:
}
color := getBadgeColorFromUptime(uptime)
sanitizedValue := strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.2f", uptime*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
}
func getBadgeColorFromUptime(uptime float64) string {
if uptime >= 0.975 {
return badgeColorHexAwesome
} else if uptime >= 0.95 {
return badgeColorHexGreat
} else if uptime >= 0.9 {
return badgeColorHexGood
} else if uptime >= 0.8 {
return badgeColorHexPassable
} else if uptime >= 0.65 {
return badgeColorHexBad
}
return badgeColorHexVeryBad
}
func generateResponseTimeBadgeSVG(duration string, averageResponseTime int) []byte {
var labelWidth, valueWidth int
switch duration {
case "7d":
labelWidth = 105
case "24h":
labelWidth = 110
case "1h":
labelWidth = 105
default:
}
color := getBadgeColorFromResponseTime(averageResponseTime)
sanitizedValue := strconv.Itoa(averageResponseTime) + "ms"
valueWidth = len(sanitizedValue) * 11
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">
response time %s
</text>
<text x="%d" y="14">
response time %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
}
func getBadgeColorFromResponseTime(responseTime int) string {
if responseTime <= 50 {
return badgeColorHexAwesome
} else if responseTime <= 200 {
return badgeColorHexGreat
} else if responseTime <= 300 {
return badgeColorHexGood
} else if responseTime <= 500 {
return badgeColorHexPassable
} else if responseTime <= 750 {
return badgeColorHexBad
}
return badgeColorHexVeryBad
}

View File

@@ -0,0 +1,231 @@
package handler
import (
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/TwiN/gatus/v3/config"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/storage/store"
"github.com/TwiN/gatus/v3/watchdog"
)
func TestUptimeBadge(t *testing.T) {
defer store.Get().Clear()
defer cache.Clear()
cfg := &config.Config{
Metrics: true,
Endpoints: []*core.Endpoint{
{
Name: "frontend",
Group: "core",
},
{
Name: "backend",
Group: "core",
},
},
}
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
router := CreateRouter("../../web/static", cfg.Security, nil, cfg.Metrics)
type Scenario struct {
Name string
Path string
ExpectedCode int
Gzip bool
}
scenarios := []Scenario{
{
Name: "badge-uptime-1h",
Path: "/api/v1/endpoints/core_frontend/uptimes/1h/badge.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-uptime-24h",
Path: "/api/v1/endpoints/core_backend/uptimes/24h/badge.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-uptime-7d",
Path: "/api/v1/endpoints/core_frontend/uptimes/7d/badge.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-uptime-with-invalid-duration",
Path: "/api/v1/endpoints/core_backend/uptimes/3d/badge.svg",
ExpectedCode: http.StatusBadRequest,
},
{
Name: "badge-uptime-for-invalid-key",
Path: "/api/v1/endpoints/invalid_key/uptimes/7d/badge.svg",
ExpectedCode: http.StatusNotFound,
},
{
Name: "badge-response-time-1h",
Path: "/api/v1/endpoints/core_frontend/response-times/1h/badge.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-response-time-24h",
Path: "/api/v1/endpoints/core_backend/response-times/24h/badge.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-response-time-7d",
Path: "/api/v1/endpoints/core_frontend/response-times/7d/badge.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-response-time-with-invalid-duration",
Path: "/api/v1/endpoints/core_backend/response-times/3d/badge.svg",
ExpectedCode: http.StatusBadRequest,
},
{
Name: "badge-response-time-for-invalid-key",
Path: "/api/v1/endpoints/invalid_key/response-times/7d/badge.svg",
ExpectedCode: http.StatusNotFound,
},
{
Name: "chart-response-time-24h",
Path: "/api/v1/endpoints/core_backend/response-times/24h/chart.svg",
ExpectedCode: http.StatusOK,
},
{ // XXX: Remove this in v4.0.0
Name: "backward-compatible-services-badge-uptime-1h",
Path: "/api/v1/services/core_frontend/uptimes/1h/badge.svg",
ExpectedCode: http.StatusOK,
},
{ // XXX: Remove this in v4.0.0
Name: "backward-compatible-services-chart-response-time-24h",
Path: "/api/v1/services/core_backend/response-times/24h/chart.svg",
ExpectedCode: http.StatusOK,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
request, _ := http.NewRequest("GET", scenario.Path, http.NoBody)
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 TestGetBadgeColorFromUptime(t *testing.T) {
scenarios := []struct {
Uptime float64
ExpectedColor string
}{
{
Uptime: 1,
ExpectedColor: badgeColorHexAwesome,
},
{
Uptime: 0.99,
ExpectedColor: badgeColorHexAwesome,
},
{
Uptime: 0.97,
ExpectedColor: badgeColorHexGreat,
},
{
Uptime: 0.95,
ExpectedColor: badgeColorHexGreat,
},
{
Uptime: 0.93,
ExpectedColor: badgeColorHexGood,
},
{
Uptime: 0.9,
ExpectedColor: badgeColorHexGood,
},
{
Uptime: 0.85,
ExpectedColor: badgeColorHexPassable,
},
{
Uptime: 0.7,
ExpectedColor: badgeColorHexBad,
},
{
Uptime: 0.65,
ExpectedColor: badgeColorHexBad,
},
{
Uptime: 0.6,
ExpectedColor: badgeColorHexVeryBad,
},
}
for _, scenario := range scenarios {
t.Run("uptime-"+strconv.Itoa(int(scenario.Uptime*100)), func(t *testing.T) {
if getBadgeColorFromUptime(scenario.Uptime) != scenario.ExpectedColor {
t.Errorf("expected %s from %f, got %v", scenario.ExpectedColor, scenario.Uptime, getBadgeColorFromUptime(scenario.Uptime))
}
})
}
}
func TestGetBadgeColorFromResponseTime(t *testing.T) {
scenarios := []struct {
ResponseTime int
ExpectedColor string
}{
{
ResponseTime: 10,
ExpectedColor: badgeColorHexAwesome,
},
{
ResponseTime: 50,
ExpectedColor: badgeColorHexAwesome,
},
{
ResponseTime: 75,
ExpectedColor: badgeColorHexGreat,
},
{
ResponseTime: 150,
ExpectedColor: badgeColorHexGreat,
},
{
ResponseTime: 201,
ExpectedColor: badgeColorHexGood,
},
{
ResponseTime: 300,
ExpectedColor: badgeColorHexGood,
},
{
ResponseTime: 301,
ExpectedColor: badgeColorHexPassable,
},
{
ResponseTime: 450,
ExpectedColor: badgeColorHexPassable,
},
{
ResponseTime: 700,
ExpectedColor: badgeColorHexBad,
},
{
ResponseTime: 1500,
ExpectedColor: badgeColorHexVeryBad,
},
}
for _, scenario := range scenarios {
t.Run("response-time-"+strconv.Itoa(scenario.ResponseTime), func(t *testing.T) {
if getBadgeColorFromResponseTime(scenario.ResponseTime) != scenario.ExpectedColor {
t.Errorf("expected %s from %d, got %v", scenario.ExpectedColor, scenario.ResponseTime, getBadgeColorFromResponseTime(scenario.ResponseTime))
}
})
}
}

119
controller/handler/chart.go Normal file
View File

@@ -0,0 +1,119 @@
package handler
import (
"log"
"math"
"net/http"
"sort"
"time"
"github.com/TwiN/gatus/v3/storage/store"
"github.com/TwiN/gatus/v3/storage/store/common"
"github.com/gorilla/mux"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
)
const timeFormat = "3:04PM"
var (
gridStyle = chart.Style{
StrokeColor: drawing.Color{R: 119, G: 119, B: 119, A: 40},
StrokeWidth: 1.0,
}
axisStyle = chart.Style{
FontColor: drawing.Color{R: 119, G: 119, B: 119, A: 255},
}
transparentStyle = chart.Style{
FillColor: drawing.Color{R: 255, G: 255, B: 255, A: 0},
}
)
func ResponseTimeChart(writer http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
duration := vars["duration"]
var from time.Time
switch duration {
case "7d":
from = time.Now().Truncate(time.Hour).Add(-24 * 7 * time.Hour)
case "24h":
from = time.Now().Truncate(time.Hour).Add(-24 * time.Hour)
default:
http.Error(writer, "Durations supported: 7d, 24h", http.StatusBadRequest)
return
}
hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(vars["key"], from, time.Now())
if err != nil {
if err == common.ErrEndpointNotFound {
http.Error(writer, err.Error(), http.StatusNotFound)
} else if err == common.ErrInvalidTimeRange {
http.Error(writer, err.Error(), http.StatusBadRequest)
} else {
http.Error(writer, err.Error(), http.StatusInternalServerError)
}
return
}
if len(hourlyAverageResponseTime) == 0 {
http.Error(writer, "", http.StatusNoContent)
return
}
series := chart.TimeSeries{
Name: "Average response time per hour",
Style: chart.Style{
StrokeWidth: 1.5,
DotWidth: 2.0,
},
}
keys := make([]int, 0, len(hourlyAverageResponseTime))
earliestTimestamp := int64(0)
for hourlyTimestamp := range hourlyAverageResponseTime {
keys = append(keys, int(hourlyTimestamp))
if earliestTimestamp == 0 || hourlyTimestamp < earliestTimestamp {
earliestTimestamp = hourlyTimestamp
}
}
for earliestTimestamp > from.Unix() {
earliestTimestamp -= int64(time.Hour.Seconds())
keys = append(keys, int(earliestTimestamp))
}
sort.Ints(keys)
var maxAverageResponseTime float64
for _, key := range keys {
averageResponseTime := float64(hourlyAverageResponseTime[int64(key)])
if maxAverageResponseTime < averageResponseTime {
maxAverageResponseTime = averageResponseTime
}
series.XValues = append(series.XValues, time.Unix(int64(key), 0))
series.YValues = append(series.YValues, averageResponseTime)
}
graph := chart.Chart{
Canvas: transparentStyle,
Background: transparentStyle,
Width: 1280,
Height: 300,
XAxis: chart.XAxis{
ValueFormatter: chart.TimeValueFormatterWithFormat(timeFormat),
GridMajorStyle: gridStyle,
GridMinorStyle: gridStyle,
Style: axisStyle,
NameStyle: axisStyle,
},
YAxis: chart.YAxis{
Name: "Average response time",
GridMajorStyle: gridStyle,
GridMinorStyle: gridStyle,
Style: axisStyle,
NameStyle: axisStyle,
Range: &chart.ContinuousRange{
Min: 0,
Max: math.Ceil(maxAverageResponseTime * 1.25),
},
},
Series: []chart.Series{series},
}
writer.Header().Set("Content-Type", "image/svg+xml")
if err := graph.Render(chart.SVG, writer); err != nil {
log.Println("[handler][ResponseTimeChart] Failed to render response time chart:", err.Error())
return
}
}

View File

@@ -0,0 +1,80 @@
package handler
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/TwiN/gatus/v3/config"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/storage/store"
"github.com/TwiN/gatus/v3/watchdog"
)
func TestResponseTimeChart(t *testing.T) {
defer store.Get().Clear()
defer cache.Clear()
cfg := &config.Config{
Metrics: true,
Endpoints: []*core.Endpoint{
{
Name: "frontend",
Group: "core",
},
{
Name: "backend",
Group: "core",
},
},
}
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
router := CreateRouter("../../web/static", cfg.Security, nil, cfg.Metrics)
type Scenario struct {
Name string
Path string
ExpectedCode int
Gzip bool
}
scenarios := []Scenario{
{
Name: "chart-response-time-24h",
Path: "/api/v1/endpoints/core_backend/response-times/24h/chart.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "chart-response-time-7d",
Path: "/api/v1/endpoints/core_frontend/response-times/7d/chart.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "chart-response-time-with-invalid-duration",
Path: "/api/v1/endpoints/core_backend/response-times/3d/chart.svg",
ExpectedCode: http.StatusBadRequest,
},
{
Name: "chart-response-time-for-invalid-key",
Path: "/api/v1/endpoints/invalid_key/response-times/7d/chart.svg",
ExpectedCode: http.StatusNotFound,
},
{ // XXX: Remove this in v4.0.0
Name: "backward-compatible-services-chart-response-time-24h",
Path: "/api/v1/services/core_backend/response-times/24h/chart.svg",
ExpectedCode: http.StatusOK,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
request, _ := http.NewRequest("GET", scenario.Path, http.NoBody)
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)
}
})
}
}

View File

@@ -0,0 +1,10 @@
package handler
import "net/http"
func DevelopmentCORS(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)
})
}

View File

@@ -0,0 +1,103 @@
package handler
import (
"bytes"
"compress/gzip"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/TwiN/gatus/v3/storage/store"
"github.com/TwiN/gatus/v3/storage/store/common"
"github.com/TwiN/gatus/v3/storage/store/common/paging"
"github.com/TwiN/gocache"
"github.com/gorilla/mux"
)
const (
cacheTTL = 10 * time.Second
)
var (
cache = gocache.NewCache().WithMaxSize(100).WithEvictionPolicy(gocache.FirstInFirstOut)
)
// EndpointStatuses handles requests to retrieve all EndpointStatus
// Due to the size of the response, this function leverages a cache.
// Must not be wrapped by GzipHandler
func EndpointStatuses(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("endpoint-status-%d-%d-gzipped", page, pageSize))
} else {
value, exists = cache.Get(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize))
}
var data []byte
if !exists {
var err error
buffer := &bytes.Buffer{}
gzipWriter := gzip.NewWriter(buffer)
endpointStatuses, err := store.Get().GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(page, pageSize))
if err != nil {
log.Printf("[handler][EndpointStatuses] Failed to retrieve endpoint statuses: %s", err.Error())
http.Error(writer, err.Error(), http.StatusInternalServerError)
return
}
data, err = json.Marshal(endpointStatuses)
if err != nil {
log.Printf("[handler][EndpointStatuses] Unable to marshal object to JSON: %s", err.Error())
http.Error(writer, "unable to marshal object to JSON", http.StatusInternalServerError)
return
}
_, _ = gzipWriter.Write(data)
_ = gzipWriter.Close()
gzippedData := buffer.Bytes()
cache.SetWithTTL(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize), data, cacheTTL)
cache.SetWithTTL(fmt.Sprintf("endpoint-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)
}
// EndpointStatus retrieves a single core.EndpointStatus by group and endpoint name
func EndpointStatus(writer http.ResponseWriter, r *http.Request) {
page, pageSize := extractPageAndPageSizeFromRequest(r)
vars := mux.Vars(r)
endpointStatus, err := store.Get().GetEndpointStatusByKey(vars["key"], paging.NewEndpointStatusParams().WithResults(page, pageSize).WithEvents(1, common.MaximumNumberOfEvents))
if err != nil {
if err == common.ErrEndpointNotFound {
http.Error(writer, err.Error(), http.StatusNotFound)
return
}
log.Printf("[handler][EndpointStatus] Failed to retrieve endpoint status: %s", err.Error())
http.Error(writer, err.Error(), http.StatusInternalServerError)
return
}
if endpointStatus == nil {
log.Printf("[handler][EndpointStatus] Endpoint with key=%s not found", vars["key"])
http.Error(writer, "not found", http.StatusNotFound)
return
}
output, err := json.Marshal(endpointStatus)
if err != nil {
log.Printf("[handler][EndpointStatus] Unable to marshal object to JSON: %s", err.Error())
http.Error(writer, "unable to marshal object to JSON", http.StatusInternalServerError)
return
}
writer.Header().Add("Content-Type", "application/json")
writer.WriteHeader(http.StatusOK)
_, _ = writer.Write(output)
}

View File

@@ -0,0 +1,226 @@
package handler
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/TwiN/gatus/v3/config"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/storage/store"
"github.com/TwiN/gatus/v3/watchdog"
)
var (
firstCondition = core.Condition("[STATUS] == 200")
secondCondition = core.Condition("[RESPONSE_TIME] < 500")
thirdCondition = core.Condition("[CERTIFICATE_EXPIRATION] < 72h")
timestamp = time.Now()
testEndpoint = core.Endpoint{
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,
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 TestEndpointStatus(t *testing.T) {
defer store.Get().Clear()
defer cache.Clear()
cfg := &config.Config{
Metrics: true,
Endpoints: []*core.Endpoint{
{
Name: "frontend",
Group: "core",
},
{
Name: "backend",
Group: "core",
},
},
}
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
router := CreateRouter("../../web/static", cfg.Security, nil, cfg.Metrics)
type Scenario struct {
Name string
Path string
ExpectedCode int
Gzip bool
}
scenarios := []Scenario{
{
Name: "endpoint-status",
Path: "/api/v1/endpoints/core_frontend/statuses",
ExpectedCode: http.StatusOK,
},
{
Name: "endpoint-status-gzip",
Path: "/api/v1/endpoints/core_frontend/statuses",
ExpectedCode: http.StatusOK,
Gzip: true,
},
{
Name: "endpoint-status-pagination",
Path: "/api/v1/endpoints/core_frontend/statuses?page=1&pageSize=20",
ExpectedCode: http.StatusOK,
},
{
Name: "endpoint-status-for-invalid-key",
Path: "/api/v1/endpoints/invalid_key/statuses",
ExpectedCode: http.StatusNotFound,
},
{ // XXX: Remove this in v4.0.0
Name: "backward-compatible-service-status",
Path: "/api/v1/services/core_frontend/statuses",
ExpectedCode: http.StatusOK,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
request, _ := http.NewRequest("GET", scenario.Path, http.NoBody)
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 TestEndpointStatuses(t *testing.T) {
defer store.Get().Clear()
defer cache.Clear()
firstResult := &testSuccessfulResult
secondResult := &testUnsuccessfulResult
store.Get().Insert(&testEndpoint, firstResult)
store.Get().Insert(&testEndpoint, 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("../../web/static", nil, nil, false)
type Scenario struct {
Name string
Path string
ExpectedCode int
ExpectedBody string
}
scenarios := []Scenario{
{
Name: "no-pagination",
Path: "/api/v1/endpoints/statuses",
ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"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/endpoints/statuses?page=1&pageSize=1",
ExpectedCode: http.StatusOK,
ExpectedBody: `[{"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/endpoints/statuses?page=2&pageSize=1",
ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"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/endpoints/statuses?page=5&pageSize=20",
ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[]}]`,
},
{
Name: "invalid-pagination-should-fall-back-to-default",
Path: "/api/v1/endpoints/statuses?page=INVALID&pageSize=INVALID",
ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"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"}]}]`,
},
{ // XXX: Remove this in v4.0.0
Name: "backward-compatible-service-status",
Path: "/api/v1/services/statuses",
ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"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, http.NoBody)
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)
}
})
}
}

View File

@@ -0,0 +1,12 @@
package handler
import (
"net/http"
)
// FavIcon handles requests for /favicon.ico
func FavIcon(staticFolder string) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
http.ServeFile(writer, request, staticFolder+"/favicon.ico")
}
}

View File

@@ -0,0 +1,33 @@
package handler
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestFavIcon(t *testing.T) {
router := CreateRouter("../../web/static", nil, nil, false)
type Scenario struct {
Name string
Path string
ExpectedCode int
}
scenarios := []Scenario{
{
Name: "favicon",
Path: "/favicon.ico",
ExpectedCode: http.StatusOK,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
request, _ := http.NewRequest("GET", scenario.Path, http.NoBody)
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)
}
})
}
}

View File

@@ -0,0 +1,59 @@
package handler
import (
"compress/gzip"
"io"
"io/ioutil"
"net/http"
"strings"
"sync"
)
var gzPool = sync.Pool{
New: func() interface{} {
return gzip.NewWriter(ioutil.Discard)
},
}
type gzipResponseWriter struct {
io.Writer
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 http.Handler if the request's headers specify that the client
// supports gzip encoding
func GzipHandler(next http.Handler) http.Handler {
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)
return
}
writer.Header().Set("Content-Encoding", "gzip")
gz := gzPool.Get().(*gzip.Writer)
defer gzPool.Put(gz)
gz.Reset(writer)
defer gz.Close()
next.ServeHTTP(&gzipResponseWriter{ResponseWriter: writer, Writer: gz}, r)
}
}

View File

@@ -0,0 +1,47 @@
package handler
import (
"net/http"
"github.com/TwiN/gatus/v3/config/ui"
"github.com/TwiN/gatus/v3/security"
"github.com/TwiN/health"
"github.com/gorilla/mux"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
func CreateRouter(staticFolder string, securityConfig *security.Config, uiConfig *ui.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", FavIcon(staticFolder)).Methods("GET")
// Endpoints
router.HandleFunc("/api/v1/endpoints/statuses", secureIfNecessary(securityConfig, EndpointStatuses)).Methods("GET") // No GzipHandler for this one, because we cache the content as Gzipped already
router.HandleFunc("/api/v1/endpoints/{key}/statuses", secureIfNecessary(securityConfig, GzipHandlerFunc(EndpointStatus))).Methods("GET")
router.HandleFunc("/api/v1/endpoints/{key}/uptimes/{duration}/badge.svg", UptimeBadge).Methods("GET")
router.HandleFunc("/api/v1/endpoints/{key}/response-times/{duration}/badge.svg", ResponseTimeBadge).Methods("GET")
router.HandleFunc("/api/v1/endpoints/{key}/response-times/{duration}/chart.svg", ResponseTimeChart).Methods("GET")
// XXX: Remove the lines between this and the next XXX comment in v4.0.0
router.HandleFunc("/api/v1/services/statuses", secureIfNecessary(securityConfig, EndpointStatuses)).Methods("GET") // No GzipHandler for this one, because we cache the content as Gzipped already
router.HandleFunc("/api/v1/services/{key}/statuses", secureIfNecessary(securityConfig, GzipHandlerFunc(EndpointStatus))).Methods("GET")
router.HandleFunc("/api/v1/services/{key}/uptimes/{duration}/badge.svg", UptimeBadge).Methods("GET")
router.HandleFunc("/api/v1/services/{key}/response-times/{duration}/badge.svg", ResponseTimeBadge).Methods("GET")
router.HandleFunc("/api/v1/services/{key}/response-times/{duration}/chart.svg", ResponseTimeChart).Methods("GET")
// XXX: Remove the lines between this and the previous XXX comment in v4.0.0
// SPA
router.HandleFunc("/services/{name}", SinglePageApplication(staticFolder, uiConfig)).Methods("GET") // XXX: Remove this in v4.0.0
router.HandleFunc("/endpoints/{name}", SinglePageApplication(staticFolder, uiConfig)).Methods("GET")
router.HandleFunc("/", SinglePageApplication(staticFolder, uiConfig)).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
}

View File

@@ -0,0 +1,58 @@
package handler
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestCreateRouter(t *testing.T) {
router := CreateRouter("../../web/static", nil, nil, true)
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: "scripts",
Path: "/js/app.js",
ExpectedCode: http.StatusOK,
},
{
Name: "scripts-gzipped",
Path: "/js/app.js",
ExpectedCode: http.StatusOK,
Gzip: true,
},
{
Name: "index-redirect",
Path: "/index.html",
ExpectedCode: http.StatusMovedPermanently,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
request, _ := http.NewRequest("GET", scenario.Path, http.NoBody)
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)
}
})
}
}

27
controller/handler/spa.go Normal file
View File

@@ -0,0 +1,27 @@
package handler
import (
"html/template"
"log"
"net/http"
"github.com/TwiN/gatus/v3/config/ui"
)
func SinglePageApplication(staticFolder string, ui *ui.Config) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
t, err := template.ParseFiles(staticFolder + "/index.html")
if err != nil {
log.Println("[handler][SinglePageApplication] Failed to parse template:", err.Error())
http.ServeFile(writer, request, staticFolder+"/index.html")
return
}
writer.Header().Set("Content-Type", "text/html")
err = t.Execute(writer, ui)
if err != nil {
log.Println("[handler][SinglePageApplication] Failed to parse template:", err.Error())
http.ServeFile(writer, request, staticFolder+"/index.html")
return
}
}
}

View File

@@ -0,0 +1,70 @@
package handler
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/TwiN/gatus/v3/config"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/storage/store"
"github.com/TwiN/gatus/v3/watchdog"
)
func TestSinglePageApplication(t *testing.T) {
defer store.Get().Clear()
defer cache.Clear()
cfg := &config.Config{
Metrics: true,
Endpoints: []*core.Endpoint{
{
Name: "frontend",
Group: "core",
},
{
Name: "backend",
Group: "core",
},
},
}
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
router := CreateRouter("../../web/static", cfg.Security, nil, cfg.Metrics)
type Scenario struct {
Name string
Path string
ExpectedCode int
Gzip bool
}
scenarios := []Scenario{
{
Name: "frontend-home",
Path: "/",
ExpectedCode: http.StatusOK,
},
{
Name: "frontend-endpoint",
Path: "/endpoints/core_frontend",
ExpectedCode: http.StatusOK,
},
{ // XXX: Remove this in v4.0.0
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, http.NoBody)
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)
}
})
}
}

View File

@@ -0,0 +1,48 @@
package handler
import (
"net/http"
"strconv"
"github.com/TwiN/gatus/v3/storage/store/common"
)
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 = common.MaximumNumberOfResults
)
func extractPageAndPageSizeFromRequest(r *http.Request) (page int, pageSize int) {
var err error
if pageParameter := r.URL.Query().Get("page"); len(pageParameter) == 0 {
page = DefaultPage
} else {
page, err = strconv.Atoi(pageParameter)
if err != nil {
page = DefaultPage
}
if page < 1 {
page = DefaultPage
}
}
if pageSizeParameter := r.URL.Query().Get("pageSize"); len(pageSizeParameter) == 0 {
pageSize = DefaultPageSize
} else {
pageSize, err = strconv.Atoi(pageSizeParameter)
if err != nil {
pageSize = DefaultPageSize
}
if pageSize > MaximumPageSize {
pageSize = MaximumPageSize
} else if pageSize < 1 {
pageSize = DefaultPageSize
}
}
return
}

View File

@@ -0,0 +1,67 @@
package handler
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), http.NoBody)
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,51 +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"
// 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,13 +2,12 @@ package core
import (
"fmt"
"log"
"strconv"
"strings"
"time"
"github.com/TwinProduction/gatus/jsonpath"
"github.com/TwinProduction/gatus/pattern"
"github.com/TwiN/gatus/v3/jsonpath"
"github.com/TwiN/gatus/v3/pattern"
)
const (
@@ -17,25 +16,30 @@ const (
// Values that could replace the placeholder: 200, 404, 500, ...
StatusPlaceholder = "[STATUS]"
// IPPlaceHolder is a placeholder for an IP.
// IPPlaceholder is a placeholder for an IP.
//
// Values that could replace the placeholder: 127.0.0.1, 10.0.0.1, ...
IPPlaceHolder = "[IP]"
IPPlaceholder = "[IP]"
// ResponseTimePlaceHolder is a placeholder for the request response time, in milliseconds.
// DNSRCodePlaceholder is a place holder for DNS_RCODE
//
// 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.
//
// Values that could replace the placeholder: 1, 500, 1000, ...
ResponseTimePlaceHolder = "[RESPONSE_TIME]"
ResponseTimePlaceholder = "[RESPONSE_TIME]"
// BodyPlaceHolder is a placeholder for the body of the response
// BodyPlaceholder is a placeholder for the body of the response
//
// Values that could replace the placeholder: {}, {"data":{"name":"john"}}, ...
BodyPlaceHolder = "[BODY]"
BodyPlaceholder = "[BODY]"
// ConnectedPlaceHolder is a placeholder for whether a connection was successfully established.
// ConnectedPlaceholder is a placeholder for whether a connection was successfully established.
//
// Values that could replace the placeholder: true, false
ConnectedPlaceHolder = "[CONNECTED]"
ConnectedPlaceholder = "[CONNECTED]"
// CertificateExpirationPlaceholder is a placeholder for the duration before certificate expiration, in milliseconds.
//
@@ -43,158 +47,268 @@ 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.
// Condition is a condition that needs to be met in order for an Endpoint 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, dontResolveFailedConditions bool) 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 && !dontResolveFailedConditions {
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 && !dontResolveFailedConditions {
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 && !dontResolveFailedConditions {
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 && !dontResolveFailedConditions {
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 && !dontResolveFailedConditions {
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 && !dontResolveFailedConditions {
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)
case IPPlaceHolder:
case IPPlaceholder:
element = result.IP
case ResponseTimePlaceHolder:
case ResponseTimePlaceholder:
element = strconv.Itoa(int(result.Duration.Milliseconds()))
case BodyPlaceHolder:
case BodyPlaceholder:
element = body
case ConnectedPlaceHolder:
case DNSRCodePlaceholder:
element = result.DNSRCode
case ConnectedPlaceholder:
element = strconv.FormatBool(result.Connected)
case CertificateExpirationPlaceholder:
element = strconv.FormatInt(int64(result.CertificateExpiration.Milliseconds()), 10)
element = strconv.FormatInt(result.CertificateExpiration.Milliseconds(), 10)
default:
// if contains the BodyPlaceHolder, then evaluate json path
if strings.Contains(element, BodyPlaceHolder) {
wantLength := false
// if contains the BodyPlaceholder, then evaluate json path
if strings.Contains(element, BodyPlaceholder) {
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)
}
// prettify returns a string representation of a condition with its parameters resolved between parentheses
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, false)
}
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, false)
}
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, false)
}
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, false)
}
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, false)
}
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, false)
}
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, false)
}
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, false)
}
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, false)
}
b.ReportAllocs()
}

10
core/condition_result.go Normal file
View File

@@ -0,0 +1,10 @@
package core
// ConditionResult result of a Condition
type ConditionResult struct {
// Condition that was evaluated
Condition string `json:"condition"`
// Success whether the condition was met (successful) or not (failed)
Success bool `json:"success"`
}

View File

@@ -6,383 +6,480 @@ 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) {
scenarios := []struct {
Name string
Condition Condition
Result *Result
DontResolveFailedConditions bool
ExpectedSuccess bool
ExpectedOutput string
}{
{
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: "status-any-failure-but-dont-resolve",
Condition: Condition("[STATUS] == any(200, 429)"),
Result: &Result{HTTPStatus: 404},
DontResolveFailedConditions: true,
ExpectedSuccess: false,
ExpectedOutput: "[STATUS] == 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: "has-failure-but-dont-resolve",
Condition: Condition("has([BODY].errors) == false"),
Result: &Result{body: []byte("{\"errors\": [\"1\"]}")},
DontResolveFailedConditions: true,
ExpectedSuccess: false,
ExpectedOutput: "has([BODY].errors) == 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, scenario.DontResolveFailedConditions)
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)
condition.evaluate(result, false)
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")
}
}

86
core/dns.go Normal file
View File

@@ -0,0 +1,86 @@
package core
import (
"errors"
"fmt"
"strings"
"github.com/miekg/dns"
)
var (
// ErrDNSWithNoQueryName is the error with which gatus will panic if a dns is configured without query name
ErrDNSWithNoQueryName = errors.New("you must specify a query name for DNS")
// ErrDNSWithInvalidQueryType is the error with which gatus will panic if a dns is configured with invalid query type
ErrDNSWithInvalidQueryType = errors.New("invalid query type")
)
const (
dnsPort = 53
)
// DNS is the configuration for a Endpoint of type DNS
type DNS struct {
// QueryType is the type for the DNS records like A, AAAA, CNAME...
QueryType string `yaml:"query-type"`
// QueryName is the query for DNS
QueryName string `yaml:"query-name"`
}
func (d *DNS) validateAndSetDefault() error {
if len(d.QueryName) == 0 {
return ErrDNSWithNoQueryName
}
if !strings.HasSuffix(d.QueryName, ".") {
d.QueryName += "."
}
if _, ok := dns.StringToType[d.QueryType]; !ok {
return ErrDNSWithInvalidQueryType
}
return nil
}
func (d *DNS) query(url string, result *Result) {
if !strings.Contains(url, ":") {
url = fmt.Sprintf("%s:%d", url, dnsPort)
}
queryType := dns.StringToType[d.QueryType]
c := new(dns.Client)
m := new(dns.Msg)
m.SetQuestion(d.QueryName, queryType)
r, _, err := c.Exchange(m, url)
if err != nil {
result.AddError(err.Error())
return
}
result.Connected = true
result.DNSRCode = dns.RcodeToString[r.Rcode]
for _, rr := range r.Answer {
switch rr.Header().Rrtype {
case dns.TypeA:
if a, ok := rr.(*dns.A); ok {
result.body = []byte(a.A.String())
}
case dns.TypeAAAA:
if aaaa, ok := rr.(*dns.AAAA); ok {
result.body = []byte(aaaa.AAAA.String())
}
case dns.TypeCNAME:
if cname, ok := rr.(*dns.CNAME); ok {
result.body = []byte(cname.Target)
}
case dns.TypeMX:
if mx, ok := rr.(*dns.MX); ok {
result.body = []byte(mx.Mx)
}
case dns.TypeNS:
if ns, ok := rr.(*dns.NS); ok {
result.body = []byte(ns.Ns)
}
default:
result.body = []byte("query type is not supported yet")
}
}
}

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