Compare commits

...

195 Commits

Author SHA1 Message Date
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
2205 changed files with 2236375 additions and 536425 deletions

View File

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

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: [TwinProduction]

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

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

@@ -0,0 +1 @@
<mxfile host="app.diagrams.net" modified="2021-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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

1
.github/codecov.yml vendored
View File

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

View File

@@ -28,6 +28,6 @@ jobs:
# was configured by the "Set up Go 1.15" step (otherwise, it'd use sudo's "go" executable)
run: sudo env "PATH=$PATH" "GOROOT=$GOROOT" go test -mod vendor ./... -race -coverprofile=coverage.txt -covermode=atomic
- name: Codecov
uses: codecov/codecov-action@v1.0.14
uses: codecov/codecov-action@v1.5.2
with:
file: ./coverage.txt

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

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

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 ${{ github.repository }} | 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/v7,linux/arm64
pull: true
push: true
tags: |
${{ env.IMAGE_REPOSITORY }}:${{ env.RELEASE }}

View File

@@ -1,33 +0,0 @@
name: publish
on:
release:
types: [published]
jobs:
build:
name: Publish
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 ${{ github.repository }} | 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/v7,linux/arm64
pull: true
push: true
tags: |
${{ env.IMAGE_REPOSITORY }}:${{ env.RELEASE }}

5
.gitignore vendored
View File

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

View File

@@ -1,14 +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-build-and-run:
docker build -t twinproduction/gatus:latest . && docker run -p 8080:8080 --name gatus twinproduction/gatus:latest
docker-run:
docker run -p 8080:8080 --name gatus twinproduction/gatus:latest
build-frontend:
docker-build-and-run: docker-build docker-run
#############
# Front end #
#############
frontend-build:
npm --prefix web/app run build
run-frontend:
frontend-run:
npm --prefix web/app run serve
test:
go test ./alerting/... ./client/... ./config/... ./controller/... ./core/... ./jsonpath/... ./pattern/... ./security/... ./storage/... ./util/... ./watchdog/... -cover

2140
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,9 @@ const (
// TypeSlack is the Type for the slack alerting provider
TypeSlack Type = "slack"
// TypeTeams is the Type for the teams alerting provider
TypeTeams Type = "teams"
// TypeTelegram is the Type for the telegram alerting provider
TypeTelegram Type = "telegram"

View File

@@ -9,6 +9,7 @@ import (
"github.com/TwinProduction/gatus/alerting/provider/messagebird"
"github.com/TwinProduction/gatus/alerting/provider/pagerduty"
"github.com/TwinProduction/gatus/alerting/provider/slack"
"github.com/TwinProduction/gatus/alerting/provider/teams"
"github.com/TwinProduction/gatus/alerting/provider/telegram"
"github.com/TwinProduction/gatus/alerting/provider/twilio"
)
@@ -33,6 +34,9 @@ type Config struct {
// Slack is the configuration for the slack alerting provider
Slack *slack.AlertProvider `yaml:"slack"`
// Teams is the configuration for the teams alerting provider
Teams *teams.AlertProvider `yaml:"teams"`
// Telegram is the configuration for the telegram alerting provider
Telegram *telegram.AlertProvider `yaml:"telegram"`
@@ -79,6 +83,12 @@ func (config Config) GetAlertingProviderByAlertType(alertType alert.Type) provid
return nil
}
return config.Slack
case alert.TypeTeams:
if config.Teams == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
return nil
}
return config.Teams
case alert.TypeTelegram:
if config.Telegram == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil

View File

@@ -19,18 +19,23 @@ import (
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"`
Placeholders map[string]map[string]string `yaml:"placeholders,omitempty"`
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client"`
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
return len(provider.URL) > 0
if provider.ClientConfig == nil {
provider.ClientConfig = client.GetDefaultConfig()
}
return len(provider.URL) > 0 && provider.ClientConfig != nil
}
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
@@ -103,7 +108,7 @@ func (provider *AlertProvider) Send(serviceName, alertDescription string, resolv
return []byte("{}"), nil
}
request := provider.buildHTTPRequest(serviceName, alertDescription, resolved)
response, err := client.GetHTTPClient(provider.Insecure).Do(request)
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
if err != nil {
return nil, err
}

View File

@@ -42,6 +42,10 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
}
results += fmt.Sprintf("%s - `%s`\\n", prefix, conditionResult.Condition)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\\n> " + alertDescription
}
return &custom.AlertProvider{
URL: provider.WebhookURL,
Method: http.MethodPost,
@@ -50,7 +54,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
"embeds": [
{
"title": ":helmet_with_white_cross: Gatus",
"description": "%s:\n> %s",
"description": "%s%s",
"color": %d,
"fields": [
{
@@ -61,7 +65,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
]
}
]
}`, message, alert.GetDescription(), colorCode, results),
}`, message, description, colorCode, results),
Headers: map[string]string{"Content-Type": "application/json"},
}
}

View File

@@ -23,7 +23,8 @@ func TestAlertProvider_IsValid(t *testing.T) {
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
provider := AlertProvider{WebhookURL: "http://example.com"}
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
alertDescription := "test"
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{Name: "svc"}, &alert.Alert{Description: &alertDescription}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
}
@@ -41,6 +42,9 @@ func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
if err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
if expected := "An alert for **svc** has been resolved after passing successfully 0 time(s) in a row:\n> test"; expected != body["embeds"].([]interface{})[0].(map[string]interface{})["description"] {
t.Errorf("expected $.embeds[0].description to be %s, got %s", expected, body["embeds"].([]interface{})[0].(map[string]interface{})["description"])
}
}
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {

View File

@@ -6,13 +6,16 @@ import (
"github.com/TwinProduction/gatus/alerting/alert"
"github.com/TwinProduction/gatus/alerting/provider/custom"
"github.com/TwinProduction/gatus/client"
"github.com/TwinProduction/gatus/core"
)
// AlertProvider is the configuration necessary for sending an alert using Mattermost
type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"`
Insecure bool `yaml:"insecure,omitempty"`
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client"`
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert"`
@@ -20,6 +23,9 @@ type AlertProvider struct {
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
if provider.ClientConfig == nil {
provider.ClientConfig = client.GetDefaultConfig()
}
return len(provider.WebhookURL) > 0
}
@@ -44,10 +50,14 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
}
results += fmt.Sprintf("%s - `%s`\\n", prefix, conditionResult.Condition)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\\n> " + alertDescription
}
return &custom.AlertProvider{
URL: provider.WebhookURL,
Method: http.MethodPost,
Insecure: provider.Insecure,
URL: provider.WebhookURL,
Method: http.MethodPost,
ClientConfig: provider.ClientConfig,
Body: fmt.Sprintf(`{
"text": "",
"username": "gatus",
@@ -56,7 +66,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
{
"title": ":rescue_worker_helmet: Gatus",
"fallback": "Gatus - %s",
"text": "%s:\n> %s",
"text": "%s%s",
"short": false,
"color": "%s",
"fields": [
@@ -73,7 +83,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
]
}
]
}`, message, message, alert.GetDescription(), color, service.URL, results),
}`, message, message, description, color, service.URL, results),
Headers: map[string]string{"Content-Type": "application/json"},
}
}

View File

@@ -23,7 +23,8 @@ func TestAlertProvider_IsValid(t *testing.T) {
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
provider := AlertProvider{WebhookURL: "http://example.org"}
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
alertDescription := "test"
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{Name: "svc"}, &alert.Alert{Description: &alertDescription}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
}
@@ -41,6 +42,9 @@ func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
if err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
if expected := "An alert for *svc* has been resolved after passing successfully 0 time(s) in a row:\n> test"; expected != body["attachments"].([]interface{})[0].(map[string]interface{})["text"] {
t.Errorf("expected $.attachments[0].description to be %s, got %s", expected, body["attachments"].([]interface{})[0].(map[string]interface{})["text"])
}
}
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {

View File

@@ -37,7 +37,6 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
} else {
message = fmt.Sprintf("TRIGGERED: %s - %s", service.Name, alert.GetDescription())
}
return &custom.AlertProvider{
URL: restAPIURL,
Method: http.MethodPost,

View File

@@ -9,6 +9,10 @@ import (
"github.com/TwinProduction/gatus/core"
)
const (
restAPIURL = "https://events.pagerduty.com/v2/enqueue"
)
// AlertProvider is the configuration necessary for sending an alert using PagerDuty
type AlertProvider struct {
IntegrationKey string `yaml:"integration-key"`
@@ -37,7 +41,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
resolveKey = ""
}
return &custom.AlertProvider{
URL: "https://events.pagerduty.com/v2/enqueue",
URL: restAPIURL,
Method: http.MethodPost,
Body: fmt.Sprintf(`{
"routing_key": "%s",

View File

@@ -8,6 +8,7 @@ import (
"github.com/TwinProduction/gatus/alerting/provider/messagebird"
"github.com/TwinProduction/gatus/alerting/provider/pagerduty"
"github.com/TwinProduction/gatus/alerting/provider/slack"
"github.com/TwinProduction/gatus/alerting/provider/teams"
"github.com/TwinProduction/gatus/alerting/provider/telegram"
"github.com/TwinProduction/gatus/alerting/provider/twilio"
"github.com/TwinProduction/gatus/core"
@@ -55,6 +56,7 @@ var (
_ AlertProvider = (*messagebird.AlertProvider)(nil)
_ AlertProvider = (*pagerduty.AlertProvider)(nil)
_ AlertProvider = (*slack.AlertProvider)(nil)
_ AlertProvider = (*teams.AlertProvider)(nil)
_ AlertProvider = (*telegram.AlertProvider)(nil)
_ AlertProvider = (*twilio.AlertProvider)(nil)
)

View File

@@ -41,6 +41,10 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
}
results += fmt.Sprintf("%s - `%s`\\n", prefix, conditionResult.Condition)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\\n> " + alertDescription
}
return &custom.AlertProvider{
URL: provider.WebhookURL,
Method: http.MethodPost,
@@ -49,7 +53,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
"attachments": [
{
"title": ":helmet_with_white_cross: Gatus",
"text": "%s:\n> %s",
"text": "%s%s",
"short": false,
"color": "%s",
"fields": [
@@ -61,7 +65,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
]
}
]
}`, message, alert.GetDescription(), color, results),
}`, message, description, color, results),
Headers: map[string]string{"Content-Type": "application/json"},
}
}

View File

@@ -23,7 +23,8 @@ func TestAlertProvider_IsValid(t *testing.T) {
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
provider := AlertProvider{WebhookURL: "http://example.com"}
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
alertDescription := "test"
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{Name: "svc"}, &alert.Alert{Description: &alertDescription}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
}
@@ -41,6 +42,9 @@ func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
if err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
if expected := "An alert for *svc* has been resolved after passing successfully 0 time(s) in a row:\n> test"; expected != body["attachments"].([]interface{})[0].(map[string]interface{})["text"] {
t.Errorf("expected $.attachments[0].description to be %s, got %s", expected, body["attachments"].([]interface{})[0].(map[string]interface{})["text"])
}
}
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {

View File

@@ -0,0 +1,77 @@
package teams
import (
"fmt"
"net/http"
"github.com/TwinProduction/gatus/alerting/alert"
"github.com/TwinProduction/gatus/alerting/provider/custom"
"github.com/TwinProduction/gatus/core"
)
// AlertProvider is the configuration necessary for sending an alert using Teams
type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"`
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
return len(provider.WebhookURL) > 0
}
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
var message string
var color string
if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", service.Name, alert.SuccessThreshold)
color = "#36A64F"
} else {
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", service.Name, alert.FailureThreshold)
color = "#DD0000"
}
var results string
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "&#x2705;"
} else {
prefix = "&#x274C;"
}
results += fmt.Sprintf("%s - `%s`<br/>", prefix, conditionResult.Condition)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\\n> " + alertDescription
}
return &custom.AlertProvider{
URL: provider.WebhookURL,
Method: http.MethodPost,
Body: fmt.Sprintf(`{
"@type": "MessageCard",
"@context": "http://schema.org/extensions",
"themeColor": "%s",
"title": "&#x1F6A8; Gatus",
"text": "%s%s",
"sections": [
{
"activityTitle": "URL",
"text": "%s"
},
{
"activityTitle": "Condition results",
"text": "%s"
}
]
}`, color, message, description, service.URL, results),
Headers: map[string]string{"Content-Type": "application/json"},
}
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}

View File

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

View File

@@ -2,71 +2,29 @@ package client
import (
"crypto/tls"
"crypto/x509"
"errors"
"net"
"net/http"
"os"
"strconv"
"net/smtp"
"runtime"
"strings"
"time"
"github.com/go-ping/ping"
)
var (
secureHTTPClient *http.Client
insecureHTTPClient *http.Client
// pingTimeout is the timeout for the Ping function
// This is mainly exposed for testing purposes
pingTimeout = 5 * time.Second
// httpTimeout is the timeout for secureHTTPClient and insecureHTTPClient
httpTimeout = 10 * time.Second
)
func init() {
// XXX: This is an undocumented feature. See https://github.com/TwinProduction/gatus/issues/104.
httpTimeoutInSecondsFromEnvironmentVariable := os.Getenv("HTTP_CLIENT_TIMEOUT_IN_SECONDS")
if len(httpTimeoutInSecondsFromEnvironmentVariable) > 0 {
if httpTimeoutInSeconds, err := strconv.Atoi(httpTimeoutInSecondsFromEnvironmentVariable); err == nil {
httpTimeout = time.Duration(httpTimeoutInSeconds) * time.Second
}
}
}
// GetHTTPClient returns the shared HTTP client
func GetHTTPClient(insecure bool) *http.Client {
if insecure {
if insecureHTTPClient == nil {
insecureHTTPClient = &http.Client{
Timeout: httpTimeout,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20,
Proxy: http.ProxyFromEnvironment,
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
},
}
}
return insecureHTTPClient
func GetHTTPClient(config *Config) *http.Client {
if config == nil {
return defaultConfig.getHTTPClient()
}
if secureHTTPClient == nil {
secureHTTPClient = &http.Client{
Timeout: httpTimeout,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20,
Proxy: http.ProxyFromEnvironment,
},
}
}
return secureHTTPClient
return config.getHTTPClient()
}
// CanCreateTCPConnection checks whether a connection can be established with a TCP service
func CanCreateTCPConnection(address string) bool {
conn, err := net.DialTimeout("tcp", address, 5*time.Second)
func CanCreateTCPConnection(address string, config *Config) bool {
conn, err := net.DialTimeout("tcp", address, config.Timeout)
if err != nil {
return false
}
@@ -74,17 +32,62 @@ func CanCreateTCPConnection(address string) bool {
return true
}
// CanPerformStartTLS checks whether a connection can be established to an address using the STARTTLS protocol
func CanPerformStartTLS(address string, config *Config) (connected bool, certificate *x509.Certificate, err error) {
hostAndPort := strings.Split(address, ":")
if len(hostAndPort) != 2 {
return false, nil, errors.New("invalid address for starttls, format must be host:port")
}
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) (bool, time.Duration) {
func Ping(address string, config *Config) (bool, time.Duration) {
pinger, err := ping.NewPinger(address)
if err != nil {
return false, 0
}
pinger.Count = 1
pinger.Timeout = pingTimeout
pinger.SetPrivileged(true)
pinger.Timeout = config.Timeout
// Set the pinger's privileged mode to true for windows
// https://github.com/TwinProduction/gatus/issues/132
pinger.SetPrivileged(runtime.GOOS == "windows")
err = pinger.Run()
if err != nil {
return false, 0

View File

@@ -6,46 +6,143 @@ import (
)
func TestGetHTTPClient(t *testing.T) {
if secureHTTPClient != nil {
t.Error("secureHTTPClient should've been nil since it hasn't been called a single time yet")
cfg := &Config{
Insecure: false,
IgnoreRedirect: false,
Timeout: 0,
}
if insecureHTTPClient != nil {
t.Error("insecureHTTPClient should've been nil since it hasn't been called a single time yet")
cfg.ValidateAndSetDefaults()
if GetHTTPClient(cfg) == nil {
t.Error("expected client to not be nil")
}
_ = GetHTTPClient(false)
if secureHTTPClient == nil {
t.Error("secureHTTPClient shouldn't have been nil, since it has been called once")
}
if insecureHTTPClient != nil {
t.Error("insecureHTTPClient should've been nil since it hasn't been called a single time yet")
}
_ = GetHTTPClient(true)
if secureHTTPClient == nil {
t.Error("secureHTTPClient shouldn't have been nil, since it has been called once")
}
if insecureHTTPClient == nil {
t.Error("insecureHTTPClient shouldn't have been nil, since it has been called once")
if GetHTTPClient(nil) == nil {
t.Error("expected client to not be nil")
}
}
func TestPing(t *testing.T) {
pingTimeout = 500 * time.Millisecond
if success, rtt := Ping("127.0.0.1"); !success {
if success, rtt := Ping("127.0.0.1", &Config{Timeout: 500 * time.Millisecond}); !success {
t.Error("expected true")
if rtt == 0 {
t.Error("Round-trip time returned on success should've higher than 0")
}
}
if success, rtt := Ping("256.256.256.256"); success {
if success, rtt := Ping("256.256.256.256", &Config{Timeout: 500 * time.Millisecond}); success {
t.Error("expected false, because the IP is invalid")
if rtt != 0 {
t.Error("Round-trip time returned on failure should've been 0")
}
}
if success, rtt := Ping("192.168.152.153"); success {
if success, rtt := Ping("192.168.152.153", &Config{Timeout: 500 * time.Millisecond}); success {
t.Error("expected false, because the IP is valid but the host should be unreachable")
if rtt != 0 {
t.Error("Round-trip time returned on failure should've been 0")
}
}
}
func TestCanPerformStartTLS(t *testing.T) {
type args struct {
address string
insecure bool
}
tests := []struct {
name string
args args
wantConnected bool
wantErr bool
}{
{
name: "invalid address",
args: args{
address: "test",
},
wantConnected: false,
wantErr: true,
},
{
name: "error dial",
args: args{
address: "test:1234",
},
wantConnected: false,
wantErr: true,
},
{
name: "valid starttls",
args: args{
address: "smtp.gmail.com:587",
},
wantConnected: true,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
connected, _, err := CanPerformStartTLS(tt.args.address, &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second})
if (err != nil) != tt.wantErr {
t.Errorf("CanPerformStartTLS() err=%v, wantErr=%v", err, tt.wantErr)
return
}
if connected != tt.wantConnected {
t.Errorf("CanPerformStartTLS() connected=%v, wantConnected=%v", connected, tt.wantConnected)
}
})
}
}
func 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 a HTTP client matching the Config's parameters.
func (c *Config) getHTTPClient() *http.Client {
if c.httpClient == nil {
c.httpClient = &http.Client{
Timeout: c.Timeout,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20,
Proxy: http.ProxyFromEnvironment,
TLSClientConfig: &tls.Config{
InsecureSkipVerify: c.Insecure,
},
},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if c.IgnoreRedirect {
// Don't follow redirects
return http.ErrUseLastResponse
}
// Follow redirects
return nil
},
}
}
return c.httpClient
}

37
client/config_test.go Normal file
View File

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

View File

@@ -1,7 +1,7 @@
services:
- name: front-end
group: core
url: "https://twinnation.org/health"
url: "https://twin.sh/health"
interval: 1m
conditions:
- "[STATUS] == 200"
@@ -10,14 +10,15 @@ services:
- name: back-end
group: core
url: "http://example.org/"
url: "https://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[CERTIFICATE_EXPIRATION] > 48h"
- name: monitoring
group: internal
url: "http://example.com/"
url: "https://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"
@@ -29,14 +30,6 @@ services:
conditions:
- "[STATUS] == 200"
- name: cat-fact
url: "https://cat-fact.herokuapp.com/facts/random"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].deleted == false"
- "len([BODY].text) > 0"
- name: example-dns-query
url: "8.8.8.8" # Address of the DNS server to use
interval: 5m

View File

@@ -10,11 +10,12 @@ import (
"github.com/TwinProduction/gatus/alerting"
"github.com/TwinProduction/gatus/alerting/alert"
"github.com/TwinProduction/gatus/alerting/provider"
"github.com/TwinProduction/gatus/config/maintenance"
"github.com/TwinProduction/gatus/config/ui"
"github.com/TwinProduction/gatus/config/web"
"github.com/TwinProduction/gatus/core"
"github.com/TwinProduction/gatus/k8s"
"github.com/TwinProduction/gatus/security"
"github.com/TwinProduction/gatus/storage"
"github.com/TwinProduction/gatus/util"
"gopkg.in/yaml.v2"
)
@@ -26,12 +27,6 @@ const (
// DefaultFallbackConfigurationFilePath is the default fallback path that will be used to search for the
// configuration file if DefaultConfigurationFilePath didn't work
DefaultFallbackConfigurationFilePath = "config/config.yml"
// DefaultAddress is the default address the service will bind to
DefaultAddress = "0.0.0.0"
// DefaultPort is the default port the service will listen on
DefaultPort = 8080
)
var (
@@ -71,14 +66,17 @@ type Config struct {
// Services List of services to monitor
Services []*core.Service `yaml:"services"`
// Kubernetes is the Kubernetes configuration
Kubernetes *k8s.Config `yaml:"kubernetes"`
// Storage is the configuration for how the data is stored
Storage *storage.Config `yaml:"storage"`
// Web is the configuration for the web listener
Web *WebConfig `yaml:"web"`
Web *web.Config `yaml:"web"`
// UI is the configuration for the UI
UI *ui.Config `yaml:"ui"`
// Maintenance is the configuration for creating a maintenance window in which no alerts are sent
Maintenance *maintenance.Config `yaml:"maintenance"`
filePath string // path to the file from which config was loaded from
lastFileModTime time.Time // last modification time
@@ -101,6 +99,8 @@ func (config *Config) UpdateLastFileModTime() {
if !fileInfo.ModTime().IsZero() {
config.lastFileModTime = fileInfo.ModTime()
}
} else {
log.Println("[config][UpdateLastFileModTime] Ran into error updating lastFileModTime:", err.Error())
}
}
@@ -149,8 +149,8 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
if 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)) {
// Check if the configuration file at least has services configured
if config == nil || config.Services == nil || len(config.Services) == 0 {
err = ErrNoServiceInConfig
} else {
// Note that the functions below may panic, and this is on purpose to prevent Gatus from starting with
@@ -162,10 +162,13 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
if err := validateServicesConfig(config); err != nil {
return nil, err
}
if err := validateKubernetesConfig(config); err != nil {
if err := validateWebConfig(config); err != nil {
return nil, err
}
if err := validateWebConfig(config); err != nil {
if err := validateUIConfig(config); err != nil {
return nil, err
}
if err := validateMaintenanceConfig(config); err != nil {
return nil, err
}
if err := validateStorageConfig(config); err != nil {
@@ -177,7 +180,9 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
func validateStorageConfig(config *Config) error {
if config.Storage == nil {
config.Storage = &storage.Config{}
config.Storage = &storage.Config{
Type: storage.TypeMemory,
}
}
err := storage.Initialize(config.Storage)
if err != nil {
@@ -186,7 +191,7 @@ func validateStorageConfig(config *Config) error {
// Remove all ServiceStatus that represent services which no longer exist in the configuration
var keys []string
for _, service := range config.Services {
keys = append(keys, util.ConvertGroupAndServiceToKey(service.Group, service.Name))
keys = append(keys, service.Key())
}
numberOfServiceStatusesDeleted := storage.Get().DeleteAllServiceStatusesNotInKeys(keys)
if numberOfServiceStatusesDeleted > 0 {
@@ -195,31 +200,33 @@ func validateStorageConfig(config *Config) error {
return nil
}
func validateWebConfig(config *Config) error {
if config.Web == nil {
config.Web = &WebConfig{Address: DefaultAddress, Port: DefaultPort}
func validateMaintenanceConfig(config *Config) error {
if config.Maintenance == nil {
config.Maintenance = maintenance.GetDefaultConfig()
} else {
return config.Web.validateAndSetDefaults()
if err := config.Maintenance.ValidateAndSetDefaults(); err != nil {
return err
}
}
return nil
}
// deprecated
// I don't like the current implementation.
func validateKubernetesConfig(config *Config) error {
if config.Kubernetes != nil && config.Kubernetes.AutoDiscover {
if config.Kubernetes.ServiceTemplate == nil {
return errors.New("kubernetes.service-template cannot be nil")
}
if config.Debug {
log.Println("[config][validateKubernetesConfig] Automatically discovering Kubernetes services...")
}
discoveredServices, err := k8s.DiscoverServices(config.Kubernetes)
if err != nil {
func validateUIConfig(config *Config) error {
if config.UI == nil {
config.UI = ui.GetDefaultConfig()
} else {
if err := config.UI.ValidateAndSetDefaults(); err != nil {
return err
}
config.Services = append(config.Services, discoveredServices...)
log.Printf("[config][validateKubernetesConfig] Discovered %d Kubernetes services", len(discoveredServices))
}
return nil
}
func validateWebConfig(config *Config) error {
if config.Web == nil {
config.Web = web.GetDefaultConfig()
} else {
return config.Web.ValidateAndSetDefaults()
}
return nil
}
@@ -268,6 +275,7 @@ func validateAlertingConfig(alertingConfig *alerting.Config, services []*core.Se
alert.TypeMessagebird,
alert.TypePagerDuty,
alert.TypeSlack,
alert.TypeTeams,
alert.TypeTelegram,
alert.TypeTwilio,
}

View File

@@ -2,7 +2,6 @@ package config
import (
"fmt"
"strings"
"testing"
"time"
@@ -14,11 +13,13 @@ import (
"github.com/TwinProduction/gatus/alerting/provider/messagebird"
"github.com/TwinProduction/gatus/alerting/provider/pagerduty"
"github.com/TwinProduction/gatus/alerting/provider/slack"
"github.com/TwinProduction/gatus/alerting/provider/teams"
"github.com/TwinProduction/gatus/alerting/provider/telegram"
"github.com/TwinProduction/gatus/alerting/provider/twilio"
"github.com/TwinProduction/gatus/client"
"github.com/TwinProduction/gatus/config/ui"
"github.com/TwinProduction/gatus/config/web"
"github.com/TwinProduction/gatus/core"
"github.com/TwinProduction/gatus/k8stest"
v1 "k8s.io/api/core/v1"
)
func TestLoadFileThatDoesNotExist(t *testing.T) {
@@ -37,20 +38,44 @@ func TestLoadDefaultConfigurationFile(t *testing.T) {
func TestParseAndValidateConfigBytes(t *testing.T) {
file := t.TempDir() + "/test.db"
ui.StaticFolder = "../web/static"
defer func() {
ui.StaticFolder = "./web/static"
}()
config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`
storage:
file: %s
maintenance:
enabled: true
start: 00:00
duration: 4h
every: [Monday, Thursday]
ui:
title: Test
services:
- name: twinnation
url: https://twinnation.org/health
- name: website
url: https://twin.sh/health
interval: 15s
conditions:
- "[STATUS] == 200"
- name: github
url: https://api.github.com/healthz
client:
insecure: true
ignore-redirect: true
timeout: 5s
conditions:
- "[STATUS] != 400"
- "[STATUS] != 500"
- name: example
url: https://example.com/
interval: 30m
client:
insecure: true
conditions:
- "[STATUS] == 200"
`, file)))
if err != nil {
t.Error("expected no error, got", err.Error())
@@ -58,40 +83,88 @@ services:
if config == nil {
t.Fatal("Config shouldn't have been nil")
}
if len(config.Services) != 2 {
if config.UI == nil || config.UI.Title != "Test" {
t.Error("Expected Config.UI.Title to be Test")
}
if mc := config.Maintenance; mc == nil || mc.Start != "00:00" || !mc.IsEnabled() || mc.Duration != 4*time.Hour || len(mc.Every) != 2 {
t.Error("Expected Config.Maintenance to be configured properly")
}
if len(config.Services) != 3 {
t.Error("Should have returned two services")
}
if config.Services[0].URL != "https://twinnation.org/health" {
t.Errorf("URL should have been %s", "https://twinnation.org/health")
}
if config.Services[1].URL != "https://api.github.com/healthz" {
t.Errorf("URL should have been %s", "https://api.github.com/healthz")
if config.Services[0].URL != "https://twin.sh/health" {
t.Errorf("URL should have been %s", "https://twin.sh/health")
}
if config.Services[0].Method != "GET" {
t.Errorf("Method should have been %s (default)", "GET")
}
if config.Services[1].Method != "GET" {
t.Errorf("Method should have been %s (default)", "GET")
}
if config.Services[0].Interval != 15*time.Second {
t.Errorf("Interval should have been %s", 15*time.Second)
}
if config.Services[1].Interval != 60*time.Second {
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
if config.Services[0].ClientConfig.Insecure != client.GetDefaultConfig().Insecure {
t.Errorf("ClientConfig.Insecure should have been %v, got %v", true, config.Services[0].ClientConfig.Insecure)
}
if config.Services[0].ClientConfig.IgnoreRedirect != client.GetDefaultConfig().IgnoreRedirect {
t.Errorf("ClientConfig.IgnoreRedirect should have been %v, got %v", true, config.Services[0].ClientConfig.IgnoreRedirect)
}
if config.Services[0].ClientConfig.Timeout != client.GetDefaultConfig().Timeout {
t.Errorf("ClientConfig.Timeout should have been %v, got %v", client.GetDefaultConfig().Timeout, config.Services[0].ClientConfig.Timeout)
}
if len(config.Services[0].Conditions) != 1 {
t.Errorf("There should have been %d conditions", 1)
}
if config.Services[1].URL != "https://api.github.com/healthz" {
t.Errorf("URL should have been %s", "https://api.github.com/healthz")
}
if config.Services[1].Method != "GET" {
t.Errorf("Method should have been %s (default)", "GET")
}
if config.Services[1].Interval != 60*time.Second {
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
}
if !config.Services[1].ClientConfig.Insecure {
t.Errorf("ClientConfig.Insecure should have been %v, got %v", true, config.Services[1].ClientConfig.Insecure)
}
if !config.Services[1].ClientConfig.IgnoreRedirect {
t.Errorf("ClientConfig.IgnoreRedirect should have been %v, got %v", true, config.Services[1].ClientConfig.IgnoreRedirect)
}
if config.Services[1].ClientConfig.Timeout != 5*time.Second {
t.Errorf("ClientConfig.Timeout should have been %v, got %v", 5*time.Second, config.Services[1].ClientConfig.Timeout)
}
if len(config.Services[1].Conditions) != 2 {
t.Errorf("There should have been %d conditions", 2)
}
if config.Services[2].URL != "https://example.com/" {
t.Errorf("URL should have been %s", "https://example.com/")
}
if config.Services[2].Method != "GET" {
t.Errorf("Method should have been %s (default)", "GET")
}
if config.Services[2].Interval != 30*time.Minute {
t.Errorf("Interval should have been %s, because it is the default value", 30*time.Minute)
}
if !config.Services[2].ClientConfig.Insecure {
t.Errorf("ClientConfig.Insecure should have been %v, got %v", true, config.Services[2].ClientConfig.Insecure)
}
if config.Services[2].ClientConfig.IgnoreRedirect {
t.Errorf("ClientConfig.IgnoreRedirect should have been %v by default, got %v", false, config.Services[2].ClientConfig.IgnoreRedirect)
}
if config.Services[2].ClientConfig.Timeout != 10*time.Second {
t.Errorf("ClientConfig.Timeout should have been %v by default, got %v", 10*time.Second, config.Services[2].ClientConfig.Timeout)
}
if len(config.Services[2].Conditions) != 1 {
t.Errorf("There should have been %d conditions", 1)
}
}
func TestParseAndValidateConfigBytesDefault(t *testing.T) {
config, err := parseAndValidateConfigBytes([]byte(`
services:
- name: twinnation
url: https://twinnation.org/health
- name: website
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"
`))
@@ -104,17 +177,26 @@ services:
if config.Metrics {
t.Error("Metrics should've been false by default")
}
if config.Services[0].URL != "https://twinnation.org/health" {
t.Errorf("URL should have been %s", "https://twinnation.org/health")
if config.Web.Address != web.DefaultAddress {
t.Errorf("Bind address should have been %s, because it is the default value", web.DefaultAddress)
}
if config.Web.Port != web.DefaultPort {
t.Errorf("Port should have been %d, because it is the default value", web.DefaultPort)
}
if config.Services[0].URL != "https://twin.sh/health" {
t.Errorf("URL should have been %s", "https://twin.sh/health")
}
if config.Services[0].Interval != 60*time.Second {
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
}
if config.Web.Address != DefaultAddress {
t.Errorf("Bind address should have been %s, because it is the default value", DefaultAddress)
if config.Services[0].ClientConfig.Insecure != client.GetDefaultConfig().Insecure {
t.Errorf("ClientConfig.Insecure should have been %v by default, got %v", true, config.Services[0].ClientConfig.Insecure)
}
if config.Web.Port != DefaultPort {
t.Errorf("Port should have been %d, because it is the default value", DefaultPort)
if config.Services[0].ClientConfig.IgnoreRedirect != client.GetDefaultConfig().IgnoreRedirect {
t.Errorf("ClientConfig.IgnoreRedirect should have been %v by default, got %v", true, config.Services[0].ClientConfig.IgnoreRedirect)
}
if config.Services[0].ClientConfig.Timeout != client.GetDefaultConfig().Timeout {
t.Errorf("ClientConfig.Timeout should have been %v by default, got %v", client.GetDefaultConfig().Timeout, config.Services[0].ClientConfig.Timeout)
}
}
@@ -123,8 +205,8 @@ func TestParseAndValidateConfigBytesWithAddress(t *testing.T) {
web:
address: 127.0.0.1
services:
- name: twinnation
url: https://twinnation.org/actuator/health
- name: website
url: https://twin.sh/actuator/health
conditions:
- "[STATUS] == 200"
`))
@@ -137,19 +219,17 @@ services:
if config.Metrics {
t.Error("Metrics should've been false by default")
}
if config.Services[0].URL != "https://twinnation.org/actuator/health" {
t.Errorf("URL should have been %s", "https://twinnation.org/actuator/health")
if config.Services[0].URL != "https://twin.sh/actuator/health" {
t.Errorf("URL should have been %s", "https://twin.sh/actuator/health")
}
if config.Services[0].Interval != 60*time.Second {
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
}
if config.Web.Address != "127.0.0.1" {
t.Errorf("Bind address should have been %s, because it is specified in config", "127.0.0.1")
}
if config.Web.Port != DefaultPort {
t.Errorf("Port should have been %d, because it is the default value", DefaultPort)
if config.Web.Port != web.DefaultPort {
t.Errorf("Port should have been %d, because it is the default value", web.DefaultPort)
}
}
@@ -158,8 +238,8 @@ func TestParseAndValidateConfigBytesWithPort(t *testing.T) {
web:
port: 12345
services:
- name: twinnation
url: https://twinnation.org/health
- name: website
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"
`))
@@ -172,14 +252,14 @@ services:
if config.Metrics {
t.Error("Metrics should've been false by default")
}
if config.Services[0].URL != "https://twinnation.org/health" {
t.Errorf("URL should have been %s", "https://twinnation.org/health")
if config.Services[0].URL != "https://twin.sh/health" {
t.Errorf("URL should have been %s", "https://twin.sh/health")
}
if config.Services[0].Interval != 60*time.Second {
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
}
if config.Web.Address != DefaultAddress {
t.Errorf("Bind address should have been %s, because it is the default value", DefaultAddress)
if config.Web.Address != web.DefaultAddress {
t.Errorf("Bind address should have been %s, because it is the default value", web.DefaultAddress)
}
if config.Web.Port != 12345 {
t.Errorf("Port should have been %d, because it is specified in config", 12345)
@@ -192,8 +272,8 @@ web:
port: 12345
address: 127.0.0.1
services:
- name: twinnation
url: https://twinnation.org/health
- name: website
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"
`))
@@ -206,8 +286,8 @@ services:
if config.Metrics {
t.Error("Metrics should've been false by default")
}
if config.Services[0].URL != "https://twinnation.org/health" {
t.Errorf("URL should have been %s", "https://twinnation.org/health")
if config.Services[0].URL != "https://twin.sh/health" {
t.Errorf("URL should have been %s", "https://twin.sh/health")
}
if config.Services[0].Interval != 60*time.Second {
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
@@ -226,8 +306,8 @@ web:
port: 65536
address: 127.0.0.1
services:
- name: twinnation
url: https://twinnation.org/health
- name: website
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"
`))
@@ -240,8 +320,8 @@ func TestParseAndValidateConfigBytesWithMetricsAndCustomUserAgentHeader(t *testi
config, err := parseAndValidateConfigBytes([]byte(`
metrics: true
services:
- name: twinnation
url: https://twinnation.org/health
- name: website
url: https://twin.sh/health
headers:
User-Agent: Test/2.0
conditions:
@@ -256,17 +336,17 @@ services:
if !config.Metrics {
t.Error("Metrics should have been true")
}
if config.Services[0].URL != "https://twinnation.org/health" {
t.Errorf("URL should have been %s", "https://twinnation.org/health")
if config.Services[0].URL != "https://twin.sh/health" {
t.Errorf("URL should have been %s", "https://twin.sh/health")
}
if config.Services[0].Interval != 60*time.Second {
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
}
if config.Web.Address != DefaultAddress {
t.Errorf("Bind address should have been %s, because it is the default value", DefaultAddress)
if config.Web.Address != web.DefaultAddress {
t.Errorf("Bind address should have been %s, because it is the default value", web.DefaultAddress)
}
if config.Web.Port != DefaultPort {
t.Errorf("Port should have been %d, because it is the default value", DefaultPort)
if config.Web.Port != web.DefaultPort {
t.Errorf("Port should have been %d, because it is the default value", web.DefaultPort)
}
if userAgent := config.Services[0].Headers["User-Agent"]; userAgent != "Test/2.0" {
t.Errorf("User-Agent should've been %s, got %s", "Test/2.0", userAgent)
@@ -280,8 +360,8 @@ web:
address: 192.168.0.1
port: 9090
services:
- name: twinnation
url: https://twinnation.org/health
- name: website
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"
`))
@@ -300,8 +380,8 @@ services:
if config.Web.Port != 9090 {
t.Errorf("Port should have been %d, because it is specified in config", 9090)
}
if config.Services[0].URL != "https://twinnation.org/health" {
t.Errorf("URL should have been %s", "https://twinnation.org/health")
if config.Services[0].URL != "https://twin.sh/health" {
t.Errorf("URL should have been %s", "https://twin.sh/health")
}
if config.Services[0].Interval != 60*time.Second {
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
@@ -339,6 +419,8 @@ alerting:
integration-key: "00000000000000000000000000000000"
mattermost:
webhook-url: "http://example.com"
client:
insecure: true
messagebird:
access-key: "1"
originator: "31619191918"
@@ -351,10 +433,12 @@ alerting:
token: "5678"
from: "+1-234-567-8901"
to: "+1-234-567-8901"
teams:
webhook-url: "http://example.com"
services:
- name: twinnation
url: https://twinnation.org/health
- name: website
url: https://twin.sh/health
alerts:
- type: slack
enabled: true
@@ -375,6 +459,8 @@ services:
enabled: true
failure-threshold: 12
success-threshold: 15
- type: teams
enabled: true
conditions:
- "[STATUS] == 200"
`))
@@ -395,14 +481,14 @@ services:
if len(config.Services) != 1 {
t.Error("There should've been 1 service")
}
if config.Services[0].URL != "https://twinnation.org/health" {
t.Errorf("URL should have been %s", "https://twinnation.org/health")
if config.Services[0].URL != "https://twin.sh/health" {
t.Errorf("URL should have been %s", "https://twin.sh/health")
}
if config.Services[0].Interval != 60*time.Second {
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
}
if len(config.Services[0].Alerts) != 7 {
t.Fatal("There should've been 7 alerts configured")
if len(config.Services[0].Alerts) != 8 {
t.Fatal("There should've been 8 alerts configured")
}
if config.Services[0].Alerts[0].Type != alert.TypeSlack {
@@ -489,6 +575,19 @@ services:
if config.Services[0].Alerts[6].SuccessThreshold != 15 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 15, config.Services[0].Alerts[6].SuccessThreshold)
}
if config.Services[0].Alerts[7].Type != alert.TypeTeams {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeTeams, config.Services[0].Alerts[7].Type)
}
if !config.Services[0].Alerts[7].IsEnabled() {
t.Error("The alert should've been enabled")
}
if config.Services[0].Alerts[7].FailureThreshold != 3 {
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Services[0].Alerts[7].FailureThreshold)
}
if config.Services[0].Alerts[7].SuccessThreshold != 2 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Services[0].Alerts[7].SuccessThreshold)
}
}
func TestParseAndValidateConfigBytesWithAlertingAndDefaultAlert(t *testing.T) {
@@ -538,10 +637,14 @@ alerting:
enabled: true
failure-threshold: 12
success-threshold: 15
teams:
webhook-url: "http://example.com"
default-alert:
enabled: true
services:
- name: twinnation
url: https://twinnation.org/health
- name: website
url: https://twin.sh/health
alerts:
- type: slack
- type: pagerduty
@@ -551,6 +654,7 @@ services:
success-threshold: 2 # test service alert override
- type: telegram
- type: twilio
- type: teams
conditions:
- "[STATUS] == 200"
`))
@@ -642,18 +746,26 @@ services:
if config.Alerting.Twilio.GetDefaultAlert() == nil {
t.Fatal("Twilio.GetDefaultAlert() shouldn't have returned nil")
}
if config.Alerting.Teams == nil || !config.Alerting.Teams.IsValid() {
t.Fatal("Teams alerting config should've been valid")
}
if config.Alerting.Teams.GetDefaultAlert() == nil {
t.Fatal("Teams.GetDefaultAlert() shouldn't have returned nil")
}
// Services
if len(config.Services) != 1 {
t.Error("There should've been 1 service")
}
if config.Services[0].URL != "https://twinnation.org/health" {
t.Errorf("URL should have been %s", "https://twinnation.org/health")
if config.Services[0].URL != "https://twin.sh/health" {
t.Errorf("URL should have been %s", "https://twin.sh/health")
}
if config.Services[0].Interval != 60*time.Second {
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
}
if len(config.Services[0].Alerts) != 7 {
t.Fatal("There should've been 7 alerts configured")
if len(config.Services[0].Alerts) != 8 {
t.Fatal("There should've been 8 alerts configured")
}
if config.Services[0].Alerts[0].Type != alert.TypeSlack {
@@ -743,6 +855,20 @@ services:
if config.Services[0].Alerts[6].SuccessThreshold != 15 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 15, config.Services[0].Alerts[6].SuccessThreshold)
}
if config.Services[0].Alerts[7].Type != alert.TypeTeams {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeTeams, config.Services[0].Alerts[7].Type)
}
if !config.Services[0].Alerts[7].IsEnabled() {
t.Error("The alert should've been enabled")
}
if config.Services[0].Alerts[7].FailureThreshold != 3 {
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Services[0].Alerts[7].FailureThreshold)
}
if config.Services[0].Alerts[7].SuccessThreshold != 2 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Services[0].Alerts[7].SuccessThreshold)
}
}
func TestParseAndValidateConfigBytesWithAlertingAndDefaultAlertAndMultipleAlertsOfSameTypeWithOverriddenParameters(t *testing.T) {
@@ -755,8 +881,8 @@ alerting:
description: "description"
services:
- name: twinnation
url: https://twinnation.org/health
- name: website
url: https://twin.sh/health
alerts:
- type: slack
failure-threshold: 10
@@ -830,8 +956,8 @@ alerting:
pagerduty:
integration-key: "INVALID_KEY"
services:
- name: twinnation
url: https://twinnation.org/health
- name: website
url: https://twin.sh/health
alerts:
- type: pagerduty
conditions:
@@ -864,8 +990,8 @@ alerting:
"text": "[ALERT_TRIGGERED_OR_RESOLVED]: [SERVICE_NAME] - [ALERT_DESCRIPTION]"
}
services:
- name: twinnation
url: https://twinnation.org/health
- name: website
url: https://twin.sh/health
alerts:
- type: custom
conditions:
@@ -892,8 +1018,8 @@ services:
if config.Alerting.Custom.GetAlertStatePlaceholderValue(false) != "TRIGGERED" {
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for TRIGGERED should've been 'TRIGGERED', got", config.Alerting.Custom.GetAlertStatePlaceholderValue(false))
}
if config.Alerting.Custom.Insecure {
t.Fatal("config.Alerting.Custom.Insecure shouldn't have been true")
if config.Alerting.Custom.ClientConfig.Insecure {
t.Errorf("ClientConfig.Insecure should have been %v, got %v", false, config.Alerting.Custom.ClientConfig.Insecure)
}
}
@@ -909,8 +1035,8 @@ alerting:
insecure: true
body: "[ALERT_TRIGGERED_OR_RESOLVED]: [SERVICE_NAME] - [ALERT_DESCRIPTION]"
services:
- name: twinnation
url: https://twinnation.org/health
- name: website
url: https://twin.sh/health
alerts:
- type: custom
conditions:
@@ -937,9 +1063,6 @@ services:
if config.Alerting.Custom.GetAlertStatePlaceholderValue(false) != "partial_outage" {
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for TRIGGERED should've been 'partial_outage'")
}
if !config.Alerting.Custom.Insecure {
t.Fatal("config.Alerting.Custom.Insecure shouldn't have been true")
}
}
func TestParseAndValidateConfigBytesWithCustomAlertingConfigAndOneCustomPlaceholderValue(t *testing.T) {
@@ -950,11 +1073,10 @@ alerting:
ALERT_TRIGGERED_OR_RESOLVED:
TRIGGERED: "partial_outage"
url: "https://example.com"
insecure: true
body: "[ALERT_TRIGGERED_OR_RESOLVED]: [SERVICE_NAME] - [ALERT_DESCRIPTION]"
services:
- name: twinnation
url: https://twinnation.org/health
- name: website
url: https://twin.sh/health
alerts:
- type: custom
conditions:
@@ -981,50 +1103,48 @@ services:
if config.Alerting.Custom.GetAlertStatePlaceholderValue(false) != "partial_outage" {
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for TRIGGERED should've been 'partial_outage'")
}
if !config.Alerting.Custom.Insecure {
t.Fatal("config.Alerting.Custom.Insecure shouldn't have been true")
}
}
func TestParseAndValidateConfigBytesWithCustomAlertingConfigThatHasInsecureSetToTrue(t *testing.T) {
config, err := parseAndValidateConfigBytes([]byte(`
alerting:
custom:
url: "https://example.com"
method: "POST"
insecure: true
body: |
{
"text": "[ALERT_TRIGGERED_OR_RESOLVED]: [SERVICE_NAME] - [ALERT_DESCRIPTION]"
}
func TestParseAndValidateConfigBytesWithInvalidServiceName(t *testing.T) {
_, err := parseAndValidateConfigBytes([]byte(`
services:
- name: twinnation
url: https://twinnation.org/health
alerts:
- type: custom
- name: ""
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"
`))
if err != nil {
t.Error("expected no error, got", err.Error())
if err != core.ErrServiceWithNoName {
t.Error("should've returned an error")
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
}
func TestParseAndValidateConfigBytesWithInvalidStorageConfig(t *testing.T) {
_, err := parseAndValidateConfigBytes([]byte(`
storage:
type: sqlite
services:
- name: example
url: https://example.org
conditions:
- "[STATUS] == 200"
`))
if err == nil {
t.Error("should've returned an error, because a file must be specified for a storage of type sqlite")
}
if config.Alerting == nil {
t.Fatal("config.Alerting shouldn't have been nil")
}
if config.Alerting.Custom == nil {
t.Fatal("PagerDuty alerting config shouldn't have been nil")
}
if !config.Alerting.Custom.IsValid() {
t.Error("Custom alerting config should've been valid")
}
if config.Alerting.Custom.Method != "POST" {
t.Error("config.Alerting.Custom.Method should've been POST")
}
if !config.Alerting.Custom.Insecure {
t.Error("config.Alerting.Custom.Insecure shouldn't have been true")
}
func TestParseAndValidateConfigBytesWithInvalidYAML(t *testing.T) {
_, err := parseAndValidateConfigBytes([]byte(`
storage:
invalid yaml
services:
- name: example
url: https://example.org
conditions:
- "[STATUS] == 200"
`))
if err == nil {
t.Error("should've returned an error")
}
}
@@ -1035,13 +1155,13 @@ security:
username: "admin"
password-sha512: "invalid-sha512-hash"
services:
- name: twinnation
url: https://twinnation.org/health
- name: website
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"
`))
if err == nil {
t.Error("Function should've returned an error")
t.Error("should've returned an error")
}
}
@@ -1054,8 +1174,8 @@ security:
username: "%s"
password-sha512: "%s"
services:
- name: twinnation
url: https://twinnation.org/health
- name: website
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"
`, expectedUsername, expectedPasswordHash)))
@@ -1089,113 +1209,6 @@ func TestParseAndValidateConfigBytesWithNoServicesOrAutoDiscovery(t *testing.T)
}
}
func TestParseAndValidateConfigBytesWithKubernetesAutoDiscovery(t *testing.T) {
var kubernetesServices []v1.Service
kubernetesServices = append(kubernetesServices, k8stest.CreateTestServices("service-1", "default", 8080))
kubernetesServices = append(kubernetesServices, k8stest.CreateTestServices("service-2", "default", 8080))
kubernetesServices = append(kubernetesServices, k8stest.CreateTestServices("service-2-canary", "default", 8080))
kubernetesServices = append(kubernetesServices, k8stest.CreateTestServices("service-3", "kube-system", 8080))
kubernetesServices = append(kubernetesServices, k8stest.CreateTestServices("service-4", "tools", 8080))
kubernetesServices = append(kubernetesServices, k8stest.CreateTestServices("service-5", "tools", 8080))
kubernetesServices = append(kubernetesServices, k8stest.CreateTestServices("service-6", "tools", 8080))
kubernetesServices = append(kubernetesServices, k8stest.CreateTestServices("service-7", "metrics", 8080))
kubernetesServices = append(kubernetesServices, k8stest.CreateTestServices("service-7-canary", "metrics", 8080))
k8stest.InitializeMockedKubernetesClient(kubernetesServices)
config, err := parseAndValidateConfigBytes([]byte(`
debug: true
kubernetes:
cluster-mode: "mock"
auto-discover: true
excluded-service-suffixes:
- canary
service-template:
interval: 29s
conditions:
- "[STATUS] == 200"
namespaces:
- name: default
hostname-suffix: ".default.svc.cluster.local"
target-path: "/health"
- name: tools
hostname-suffix: ".tools.svc.cluster.local"
target-path: "/health"
excluded-services:
- service-6
- name: metrics
hostname-suffix: ".metrics.svc.cluster.local"
target-path: "/health"
`))
if err != nil {
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
}
if config.Kubernetes == nil {
t.Fatal("Kuberbetes config shouldn't have been nil")
}
if len(config.Services) != 5 {
t.Error("Expected 5 services to have been added through k8s auto discovery, got", len(config.Services))
}
for _, service := range config.Services {
if service.Name == "service-2-canary" || service.Name == "service-7-canary" {
t.Errorf("service '%s' should've been excluded because excluded-service-suffixes has 'canary'", service.Name)
} else if service.Name == "service-6" {
t.Errorf("service '%s' should've been excluded because excluded-services has 'service-6'", service.Name)
} else if service.Name == "service-3" {
t.Errorf("service '%s' should've been excluded because the namespace 'kube-system' is not configured for auto discovery", service.Name)
} else {
if service.Interval != 29*time.Second {
t.Errorf("service '%s' should've had an interval of 29s, because the template is configured for it", service.Name)
}
if len(service.Conditions) != 1 {
t.Errorf("service '%s' should've had 1 condition", service.Name)
}
if len(service.Conditions) == 1 && *service.Conditions[0] != "[STATUS] == 200" {
t.Errorf("service '%s' should've had the condition '[STATUS] == 200', because the template is configured for it", service.Name)
}
if !strings.HasSuffix(service.URL, ".svc.cluster.local:8080/health") {
t.Errorf("service '%s' should've had an URL with the suffix '.svc.cluster.local:8080/health'", service.Name)
}
}
}
}
func TestParseAndValidateConfigBytesWithKubernetesAutoDiscoveryButNoServiceTemplate(t *testing.T) {
_, err := parseAndValidateConfigBytes([]byte(`
kubernetes:
cluster-mode: "mock"
auto-discover: true
namespaces:
- name: default
hostname-suffix: ".default.svc.cluster.local"
target-path: "/health"
`))
if err == nil {
t.Error("Function should've returned an error because providing a service-template is mandatory")
}
}
func TestParseAndValidateConfigBytesWithKubernetesAutoDiscoveryUsingClusterModeIn(t *testing.T) {
_, err := parseAndValidateConfigBytes([]byte(`
kubernetes:
cluster-mode: "in"
auto-discover: true
service-template:
interval: 30s
conditions:
- "[STATUS] == 200"
namespaces:
- name: default
hostname-suffix: ".default.svc.cluster.local"
target-path: "/health"
`))
if err == nil {
t.Error("Function should've returned an error because testing with ClusterModeIn isn't supported")
}
}
func TestGetAlertingProviderByAlertType(t *testing.T) {
alertingConfig := &alerting.Config{
Custom: &custom.AlertProvider{},
@@ -1206,6 +1219,7 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
Slack: &slack.AlertProvider{},
Telegram: &telegram.AlertProvider{},
Twilio: &twilio.AlertProvider{},
Teams: &teams.AlertProvider{},
}
if alertingConfig.GetAlertingProviderByAlertType(alert.TypeCustom) != alertingConfig.Custom {
t.Error("expected Custom configuration")
@@ -1231,4 +1245,7 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
if alertingConfig.GetAlertingProviderByAlertType(alert.TypeTwilio) != alertingConfig.Twilio {
t.Error("expected Twilio configuration")
}
if alertingConfig.GetAlertingProviderByAlertType(alert.TypeTeams) != alertingConfig.Teams {
t.Error("expected Teams configuration")
}
}

View File

@@ -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 services 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,217 @@
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", now.Hour()-4),
Duration: 8 * time.Hour,
},
expected: true,
},
{
name: "under-maintenance-starting-4h-ago-for-3h",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", now.Hour()-4),
Duration: 3 * time.Hour,
},
expected: false,
},
{
name: "under-maintenance-starting-5h-ago-for-1h",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", 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) {
if scenario.cfg.ValidateAndSetDefaults() != nil {
t.Fatal("validation shouldn't have returned an error")
}
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())
}
})
}
}

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

View File

@@ -1,36 +0,0 @@
package config
import (
"fmt"
"math"
)
// WebConfig is the structure which supports the configuration of the endpoint
// which provides access to the web frontend
type WebConfig struct {
// Address to listen on (defaults to 0.0.0.0 specified by DefaultAddress)
Address string `yaml:"address"`
// Port to listen on (default to 8080 specified by DefaultPort)
Port int `yaml:"port"`
}
// validateAndSetDefaults checks and sets the default values for fields that are not set
func (web *WebConfig) 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 *WebConfig) SocketAddress() string {
return fmt.Sprintf("%s:%d", web.Address, web.Port)
}

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 service will bind to
DefaultAddress = "0.0.0.0"
// DefaultPort is the default port the service 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())
}
}

View File

@@ -1,15 +0,0 @@
package config
import (
"testing"
)
func TestWebConfig_SocketAddress(t *testing.T) {
web := &WebConfig{
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())
}
}

View File

@@ -1,105 +0,0 @@
package controller
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/TwinProduction/gatus/core"
"github.com/TwinProduction/gatus/storage"
"github.com/gorilla/mux"
)
// badgeHandler handles the automatic generation of badge based on the group name and service name passed.
//
// Valid values for {duration}: 7d, 24h, 1h
// Pattern for {identifier}: <KEY>.svg
func badgeHandler(writer http.ResponseWriter, request *http.Request) {
variables := mux.Vars(request)
duration := variables["duration"]
if duration != "7d" && duration != "24h" && duration != "1h" {
writer.WriteHeader(http.StatusBadRequest)
_, _ = writer.Write([]byte("Durations supported: 7d, 24h, 1h"))
return
}
identifier := variables["identifier"]
key := strings.TrimSuffix(identifier, ".svg")
serviceStatus := storage.Get().GetServiceStatusByKey(key)
if serviceStatus == nil {
writer.WriteHeader(http.StatusNotFound)
_, _ = writer.Write([]byte("Requested service not found"))
return
}
if serviceStatus.Uptime == nil {
writer.WriteHeader(http.StatusInternalServerError)
_, _ = writer.Write([]byte("Failed to compute uptime"))
return
}
formattedDate := time.Now().Format(http.TimeFormat)
writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
writer.Header().Set("Date", formattedDate)
writer.Header().Set("Expires", formattedDate)
writer.Header().Set("Content-Type", "image/svg+xml")
_, _ = writer.Write(generateSVG(duration, serviceStatus.Uptime))
}
func generateSVG(duration string, uptime *core.Uptime) []byte {
var labelWidth, valueWidth, valueWidthAdjustment int
var color string
var value float64
switch duration {
case "7d":
labelWidth = 65
value = uptime.LastSevenDays
case "24h":
labelWidth = 70
value = uptime.LastTwentyFourHours
case "1h":
labelWidth = 65
value = uptime.LastHour
default:
}
if value >= 0.8 {
color = "#40cc11"
} else {
color = "#c7130a"
}
sanitizedValue := strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.2f", value*100), "0"), ".") + "%"
if strings.Contains(sanitizedValue, ".") {
valueWidthAdjustment = -10
}
valueWidth = (len(sanitizedValue) * 11) + valueWidthAdjustment
width := labelWidth + valueWidth
labelX := labelWidth / 2
valueX := labelWidth + (valueWidth / 2)
svg := []byte(fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="20">
<linearGradient id="b" x2="0" y2="100%%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<mask id="a">
<rect width="%d" height="20" rx="3" fill="#fff"/>
</mask>
<g mask="url(#a)">
<path fill="#555" d="M0 0h%dv20H0z"/>
<path fill="%s" d="M%d 0h%dv20H%dz"/>
<path fill="url(#b)" d="M0 0h%dv20H0z"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
<text x="%d" y="15" fill="#010101" fill-opacity=".3">
uptime %s
</text>
<text x="%d" y="14">
uptime %s
</text>
<text x="%d" y="15" fill="#010101" fill-opacity=".3">
%s
</text>
<text x="%d" y="14">
%s
</text>
</g>
</svg>`, width, width, labelWidth, color, labelWidth, valueWidth, labelWidth, width, labelX, duration, labelX, duration, valueX, sanitizedValue, valueX, sanitizedValue))
return svg
}

View File

@@ -1,53 +1,30 @@
package controller
import (
"bytes"
"compress/gzip"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/TwinProduction/gatus/config"
"github.com/TwinProduction/gatus/config/ui"
"github.com/TwinProduction/gatus/config/web"
"github.com/TwinProduction/gatus/controller/handler"
"github.com/TwinProduction/gatus/security"
"github.com/TwinProduction/gatus/storage"
"github.com/TwinProduction/gocache"
"github.com/TwinProduction/health"
"github.com/gorilla/mux"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
const (
cacheTTL = 10 * time.Second
)
var (
cache = gocache.NewCache().WithMaxSize(100).WithEvictionPolicy(gocache.FirstInFirstOut)
// staticFolder is the path to the location of the static folder from the root path of the project
// The only reason this is exposed is to allow running tests from a different path than the root path of the project
staticFolder = "./web/static"
// server is the http.Server created by Handle.
// The only reason it exists is for testing purposes.
server *http.Server
)
func init() {
if err := cache.StartJanitor(); err != nil {
log.Fatal("[controller][init] Failed to start cache janitor:", err.Error())
}
}
// Handle creates the router and starts the server
func Handle(securityConfig *security.Config, webConfig *config.WebConfig, enableMetrics bool) {
var router http.Handler = CreateRouter(securityConfig, enableMetrics)
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 = developmentCorsHandler(router)
router = handler.DevelopmentCORS(router)
}
server = &http.Server{
Addr: fmt.Sprintf("%s:%d", webConfig.Address, webConfig.Port),
@@ -70,101 +47,3 @@ func Shutdown() {
server = nil
}
}
// CreateRouter creates the router for the http server
func CreateRouter(securityConfig *security.Config, enabledMetrics bool) *mux.Router {
router := mux.NewRouter()
if enabledMetrics {
router.Handle("/metrics", promhttp.Handler()).Methods("GET")
}
router.Handle("/health", health.Handler().WithJSON(true)).Methods("GET")
router.HandleFunc("/favicon.ico", favIconHandler).Methods("GET")
router.HandleFunc("/api/v1/statuses", secureIfNecessary(securityConfig, serviceStatusesHandler)).Methods("GET") // No GzipHandler for this one, because we cache the content
router.HandleFunc("/api/v1/statuses/{key}", secureIfNecessary(securityConfig, GzipHandlerFunc(serviceStatusHandler))).Methods("GET")
router.HandleFunc("/api/v1/badges/uptime/{duration}/{identifier}", badgeHandler).Methods("GET")
// SPA
router.HandleFunc("/services/{service}", spaHandler).Methods("GET")
// Everything else falls back on static content
router.PathPrefix("/").Handler(GzipHandler(http.FileServer(http.Dir(staticFolder))))
return router
}
func secureIfNecessary(securityConfig *security.Config, handler http.HandlerFunc) http.HandlerFunc {
if securityConfig != nil && securityConfig.IsValid() {
return security.Handler(handler, securityConfig)
}
return handler
}
// serviceStatusesHandler handles requests to retrieve all service statuses
// Due to the size of the response, this function leverages a cache.
// Must not be wrapped by GzipHandler
func serviceStatusesHandler(writer http.ResponseWriter, r *http.Request) {
page, pageSize := extractPageAndPageSizeFromRequest(r)
gzipped := strings.Contains(r.Header.Get("Accept-Encoding"), "gzip")
var exists bool
var value interface{}
if gzipped {
writer.Header().Set("Content-Encoding", "gzip")
value, exists = cache.Get(fmt.Sprintf("service-status-%d-%d-gzipped", page, pageSize))
} else {
value, exists = cache.Get(fmt.Sprintf("service-status-%d-%d", page, pageSize))
}
var data []byte
if !exists {
var err error
buffer := &bytes.Buffer{}
gzipWriter := gzip.NewWriter(buffer)
data, err = json.Marshal(storage.Get().GetAllServiceStatusesWithResultPagination(page, pageSize))
if err != nil {
log.Printf("[controller][serviceStatusesHandler] Unable to marshal object to JSON: %s", err.Error())
writer.WriteHeader(http.StatusInternalServerError)
_, _ = writer.Write([]byte("Unable to marshal object to JSON"))
return
}
_, _ = gzipWriter.Write(data)
_ = gzipWriter.Close()
gzippedData := buffer.Bytes()
cache.SetWithTTL(fmt.Sprintf("service-status-%d-%d", page, pageSize), data, cacheTTL)
cache.SetWithTTL(fmt.Sprintf("service-status-%d-%d-gzipped", page, pageSize), gzippedData, cacheTTL)
if gzipped {
data = gzippedData
}
} else {
data = value.([]byte)
}
writer.Header().Add("Content-Type", "application/json")
writer.WriteHeader(http.StatusOK)
_, _ = writer.Write(data)
}
// serviceStatusHandler retrieves a single ServiceStatus by group name and service name
func serviceStatusHandler(writer http.ResponseWriter, r *http.Request) {
page, pageSize := extractPageAndPageSizeFromRequest(r)
vars := mux.Vars(r)
serviceStatus := storage.Get().GetServiceStatusByKey(vars["key"])
if serviceStatus == nil {
log.Printf("[controller][serviceStatusHandler] Service with key=%s not found", vars["key"])
writer.WriteHeader(http.StatusNotFound)
_, _ = writer.Write([]byte("not found"))
return
}
data := map[string]interface{}{
"serviceStatus": serviceStatus.WithResultPagination(page, pageSize),
// The following fields, while present on core.ServiceStatus, are annotated to remain hidden so that we can
// expose only the necessary data on /api/v1/statuses.
// Since the /api/v1/statuses/{key} endpoint does need this data, however, we explicitly expose it here
"events": serviceStatus.Events,
"uptime": serviceStatus.Uptime,
}
output, err := json.Marshal(data)
if err != nil {
log.Printf("[controller][serviceStatusHandler] Unable to marshal object to JSON: %s", err.Error())
writer.WriteHeader(http.StatusInternalServerError)
_, _ = writer.Write([]byte("unable to marshal object to JSON"))
return
}
writer.Header().Add("Content-Type", "application/json")
writer.WriteHeader(http.StatusOK)
_, _ = writer.Write(output)
}

View File

@@ -6,221 +6,15 @@ import (
"net/http/httptest"
"os"
"testing"
"time"
"github.com/TwinProduction/gatus/config"
"github.com/TwinProduction/gatus/config/web"
"github.com/TwinProduction/gatus/core"
"github.com/TwinProduction/gatus/storage"
"github.com/TwinProduction/gatus/watchdog"
)
var (
firstCondition = core.Condition("[STATUS] == 200")
secondCondition = core.Condition("[RESPONSE_TIME] < 500")
thirdCondition = core.Condition("[CERTIFICATE_EXPIRATION] < 72h")
timestamp = time.Now()
testService = core.Service{
Name: "name",
Group: "group",
URL: "https://example.org/what/ever",
Method: "GET",
Body: "body",
Interval: 30 * time.Second,
Conditions: []*core.Condition{&firstCondition, &secondCondition, &thirdCondition},
Alerts: nil,
Insecure: false,
NumberOfFailuresInARow: 0,
NumberOfSuccessesInARow: 0,
}
testSuccessfulResult = core.Result{
Hostname: "example.org",
IP: "127.0.0.1",
HTTPStatus: 200,
Errors: nil,
Connected: true,
Success: true,
Timestamp: timestamp,
Duration: 150 * time.Millisecond,
CertificateExpiration: 10 * time.Hour,
ConditionResults: []*core.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: true,
},
{
Condition: "[RESPONSE_TIME] < 500",
Success: true,
},
{
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
Success: true,
},
},
}
testUnsuccessfulResult = core.Result{
Hostname: "example.org",
IP: "127.0.0.1",
HTTPStatus: 200,
Errors: []string{"error-1", "error-2"},
Connected: true,
Success: false,
Timestamp: timestamp,
Duration: 750 * time.Millisecond,
CertificateExpiration: 10 * time.Hour,
ConditionResults: []*core.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: true,
},
{
Condition: "[RESPONSE_TIME] < 500",
Success: false,
},
{
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
Success: false,
},
},
}
)
func TestCreateRouter(t *testing.T) {
defer storage.Get().Clear()
defer cache.Clear()
staticFolder = "../web/static"
cfg := &config.Config{
Metrics: true,
Services: []*core.Service{
{
Name: "frontend",
Group: "core",
},
{
Name: "backend",
Group: "core",
},
},
}
watchdog.UpdateServiceStatuses(cfg.Services[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateServiceStatuses(cfg.Services[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
router := CreateRouter(cfg.Security, cfg.Metrics)
type Scenario struct {
Name string
Path string
ExpectedCode int
Gzip bool
}
scenarios := []Scenario{
{
Name: "health",
Path: "/health",
ExpectedCode: http.StatusOK,
},
{
Name: "metrics",
Path: "/metrics",
ExpectedCode: http.StatusOK,
},
{
Name: "badges-1h",
Path: "/api/v1/badges/uptime/1h/core_frontend.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badges-24h",
Path: "/api/v1/badges/uptime/24h/core_backend.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badges-7d",
Path: "/api/v1/badges/uptime/7d/core_frontend.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badges-with-invalid-duration",
Path: "/api/v1/badges/uptime/3d/core_backend.svg",
ExpectedCode: http.StatusBadRequest,
},
{
Name: "badges-for-invalid-key",
Path: "/api/v1/badges/uptime/7d/invalid_key.svg",
ExpectedCode: http.StatusNotFound,
},
{
Name: "service-statuses",
Path: "/api/v1/statuses",
ExpectedCode: http.StatusOK,
},
{
Name: "service-statuses-gzip",
Path: "/api/v1/statuses",
ExpectedCode: http.StatusOK,
Gzip: true,
},
{
Name: "service-statuses-pagination",
Path: "/api/v1/statuses?page=1&pageSize=20",
ExpectedCode: http.StatusOK,
},
{
Name: "service-status",
Path: "/api/v1/statuses/core_frontend",
ExpectedCode: http.StatusOK,
},
{
Name: "service-status-gzip",
Path: "/api/v1/statuses/core_frontend",
ExpectedCode: http.StatusOK,
Gzip: true,
},
{
Name: "service-status-for-invalid-key",
Path: "/api/v1/statuses/invalid_key",
ExpectedCode: http.StatusNotFound,
},
{
Name: "favicon",
Path: "/favicon.ico",
ExpectedCode: http.StatusOK,
},
{
Name: "frontend-home",
Path: "/",
ExpectedCode: http.StatusOK,
},
{
Name: "frontend-assets",
Path: "/js/app.js",
ExpectedCode: http.StatusOK,
},
{
Name: "frontend-service",
Path: "/services/core_frontend",
ExpectedCode: http.StatusOK,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
request, _ := http.NewRequest("GET", scenario.Path, nil)
if scenario.Gzip {
request.Header.Set("Accept-Encoding", "gzip")
}
responseRecorder := httptest.NewRecorder()
router.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != scenario.ExpectedCode {
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
}
})
}
}
func TestHandle(t *testing.T) {
defer storage.Get().Clear()
defer cache.Clear()
cfg := &config.Config{
Web: &config.WebConfig{
Web: &web.Config{
Address: "0.0.0.0",
Port: rand.Intn(65534),
},
@@ -238,7 +32,7 @@ func TestHandle(t *testing.T) {
_ = os.Setenv("ROUTER_TEST", "true")
_ = os.Setenv("ENVIRONMENT", "dev")
defer os.Clearenv()
Handle(cfg.Security, cfg.Web, cfg.Metrics)
Handle(cfg.Security, cfg.Web, cfg.UI, cfg.Metrics)
defer Shutdown()
request, _ := http.NewRequest("GET", "/health", nil)
responseRecorder := httptest.NewRecorder()
@@ -259,71 +53,3 @@ func TestShutdown(t *testing.T) {
t.Error("server should've been shut down")
}
}
func TestServiceStatusesHandler(t *testing.T) {
defer storage.Get().Clear()
defer cache.Clear()
staticFolder = "../web/static"
firstResult := &testSuccessfulResult
secondResult := &testUnsuccessfulResult
storage.Get().Insert(&testService, firstResult)
storage.Get().Insert(&testService, secondResult)
// Can't be bothered dealing with timezone issues on the worker that runs the automated tests
firstResult.Timestamp = time.Time{}
secondResult.Timestamp = time.Time{}
router := CreateRouter(nil, false)
type Scenario struct {
Name string
Path string
ExpectedCode int
ExpectedBody string
}
scenarios := []Scenario{
{
Name: "no-pagination",
Path: "/api/v1/statuses",
ExpectedCode: http.StatusOK,
ExpectedBody: `{"group_name":{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"errors":null,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}]}}`,
},
{
Name: "pagination-first-result",
Path: "/api/v1/statuses?page=1&pageSize=1",
ExpectedCode: http.StatusOK,
ExpectedBody: `{"group_name":{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}]}}`,
},
{
Name: "pagination-second-result",
Path: "/api/v1/statuses?page=2&pageSize=1",
ExpectedCode: http.StatusOK,
ExpectedBody: `{"group_name":{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"errors":null,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"}]}}`,
},
{
Name: "pagination-no-results",
Path: "/api/v1/statuses?page=5&pageSize=20",
ExpectedCode: http.StatusOK,
ExpectedBody: `{"group_name":{"name":"name","group":"group","key":"group_name","results":[]}}`,
},
{
Name: "invalid-pagination-should-fall-back-to-default",
Path: "/api/v1/statuses?page=INVALID&pageSize=INVALID",
ExpectedCode: http.StatusOK,
ExpectedBody: `{"group_name":{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"errors":null,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}]}}`,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
request, _ := http.NewRequest("GET", scenario.Path, nil)
responseRecorder := httptest.NewRecorder()
router.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != scenario.ExpectedCode {
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
}
output := responseRecorder.Body.String()
if output != scenario.ExpectedBody {
t.Errorf("expected:\n %s\n\ngot:\n %s", scenario.ExpectedBody, output)
}
})
}
}

View File

@@ -1,10 +0,0 @@
package controller
import "net/http"
func developmentCorsHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "http://localhost:8081")
next.ServeHTTP(w, r)
})
}

View File

@@ -1,8 +0,0 @@
package controller
import "net/http"
// favIconHandler handles requests for /favicon.ico
func favIconHandler(writer http.ResponseWriter, request *http.Request) {
http.ServeFile(writer, request, staticFolder+"/favicon.ico")
}

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

@@ -0,0 +1,229 @@
package handler
import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/TwinProduction/gatus/storage"
"github.com/TwinProduction/gatus/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 service 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 := storage.Get().GetUptimeByKey(key, from, time.Now())
if err != nil {
if err == common.ErrServiceNotFound {
writer.WriteHeader(http.StatusNotFound)
} else if err == common.ErrInvalidTimeRange {
writer.WriteHeader(http.StatusBadRequest)
} else {
writer.WriteHeader(http.StatusInternalServerError)
}
_, _ = writer.Write([]byte(err.Error()))
return
}
formattedDate := time.Now().Format(http.TimeFormat)
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 service 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 := storage.Get().GetAverageResponseTimeByKey(key, from, time.Now())
if err != nil {
if err == common.ErrServiceNotFound {
writer.WriteHeader(http.StatusNotFound)
} else if err == common.ErrInvalidTimeRange {
writer.WriteHeader(http.StatusBadRequest)
} else {
writer.WriteHeader(http.StatusInternalServerError)
}
_, _ = writer.Write([]byte(err.Error()))
return
}
formattedDate := time.Now().Format(http.TimeFormat)
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,221 @@
package handler
import (
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/TwinProduction/gatus/config"
"github.com/TwinProduction/gatus/core"
"github.com/TwinProduction/gatus/storage"
"github.com/TwinProduction/gatus/watchdog"
)
func TestUptimeBadge(t *testing.T) {
defer storage.Get().Clear()
defer cache.Clear()
cfg := &config.Config{
Metrics: true,
Services: []*core.Service{
{
Name: "frontend",
Group: "core",
},
{
Name: "backend",
Group: "core",
},
},
}
watchdog.UpdateServiceStatuses(cfg.Services[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateServiceStatuses(cfg.Services[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
router := CreateRouter("../../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/services/core_frontend/uptimes/1h/badge.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-uptime-24h",
Path: "/api/v1/services/core_backend/uptimes/24h/badge.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-uptime-7d",
Path: "/api/v1/services/core_frontend/uptimes/7d/badge.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-uptime-with-invalid-duration",
Path: "/api/v1/services/core_backend/uptimes/3d/badge.svg",
ExpectedCode: http.StatusBadRequest,
},
{
Name: "badge-uptime-for-invalid-key",
Path: "/api/v1/services/invalid_key/uptimes/7d/badge.svg",
ExpectedCode: http.StatusNotFound,
},
{
Name: "badge-response-time-1h",
Path: "/api/v1/services/core_frontend/response-times/1h/badge.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-response-time-24h",
Path: "/api/v1/services/core_backend/response-times/24h/badge.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-response-time-7d",
Path: "/api/v1/services/core_frontend/response-times/7d/badge.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-response-time-with-invalid-duration",
Path: "/api/v1/services/core_backend/response-times/3d/badge.svg",
ExpectedCode: http.StatusBadRequest,
},
{
Name: "badge-response-time-for-invalid-key",
Path: "/api/v1/services/invalid_key/response-times/7d/badge.svg",
ExpectedCode: http.StatusNotFound,
},
{
Name: "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, nil)
if scenario.Gzip {
request.Header.Set("Accept-Encoding", "gzip")
}
responseRecorder := httptest.NewRecorder()
router.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != scenario.ExpectedCode {
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
}
})
}
}
func 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))
}
})
}
}

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

@@ -0,0 +1,121 @@
package handler
import (
"log"
"math"
"net/http"
"sort"
"time"
"github.com/TwinProduction/gatus/storage"
"github.com/TwinProduction/gatus/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 := storage.Get().GetHourlyAverageResponseTimeByKey(vars["key"], from, time.Now())
if err != nil {
if err == common.ErrServiceNotFound {
writer.WriteHeader(http.StatusNotFound)
} else if err == common.ErrInvalidTimeRange {
writer.WriteHeader(http.StatusBadRequest)
} else {
writer.WriteHeader(http.StatusInternalServerError)
}
_, _ = writer.Write([]byte(err.Error()))
return
}
if len(hourlyAverageResponseTime) == 0 {
writer.WriteHeader(http.StatusNoContent)
_, _ = writer.Write(nil)
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,75 @@
package handler
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/TwinProduction/gatus/config"
"github.com/TwinProduction/gatus/core"
"github.com/TwinProduction/gatus/storage"
"github.com/TwinProduction/gatus/watchdog"
)
func TestResponseTimeChart(t *testing.T) {
defer storage.Get().Clear()
defer cache.Clear()
cfg := &config.Config{
Metrics: true,
Services: []*core.Service{
{
Name: "frontend",
Group: "core",
},
{
Name: "backend",
Group: "core",
},
},
}
watchdog.UpdateServiceStatuses(cfg.Services[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateServiceStatuses(cfg.Services[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
router := CreateRouter("../../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/services/core_backend/response-times/24h/chart.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "chart-response-time-7d",
Path: "/api/v1/services/core_frontend/response-times/7d/chart.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "chart-response-time-with-invalid-duration",
Path: "/api/v1/services/core_backend/response-times/3d/chart.svg",
ExpectedCode: http.StatusBadRequest,
},
{
Name: "chart-response-time-for-invalid-key",
Path: "/api/v1/services/invalid_key/response-times/7d/chart.svg",
ExpectedCode: http.StatusNotFound,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
request, _ := http.NewRequest("GET", scenario.Path, nil)
if scenario.Gzip {
request.Header.Set("Accept-Encoding", "gzip")
}
responseRecorder := httptest.NewRecorder()
router.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != scenario.ExpectedCode {
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
}
})
}
}

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,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, nil)
responseRecorder := httptest.NewRecorder()
router.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != scenario.ExpectedCode {
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
}
})
}
}

View File

@@ -1,4 +1,4 @@
package controller
package handler
import (
"compress/gzip"

View File

@@ -0,0 +1,41 @@
package handler
import (
"net/http"
"github.com/TwinProduction/gatus/config/ui"
"github.com/TwinProduction/gatus/security"
"github.com/TwinProduction/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/services/statuses", secureIfNecessary(securityConfig, ServiceStatuses)).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(ServiceStatus))).Methods("GET")
// TODO: router.HandleFunc("/api/v1/services/{key}/uptimes", secureIfNecessary(securityConfig, GzipHandlerFunc(serviceUptimesHandler))).Methods("GET")
// TODO: router.HandleFunc("/api/v1/services/{key}/events", secureIfNecessary(securityConfig, GzipHandlerFunc(serviceEventsHandler))).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")
// SPA
router.HandleFunc("/services/{service}", 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, nil)
if scenario.Gzip {
request.Header.Set("Accept-Encoding", "gzip")
}
responseRecorder := httptest.NewRecorder()
router.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != scenario.ExpectedCode {
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
}
})
}
}

View File

@@ -0,0 +1,103 @@
package handler
import (
"bytes"
"compress/gzip"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/TwinProduction/gatus/storage"
"github.com/TwinProduction/gatus/storage/store/common"
"github.com/TwinProduction/gatus/storage/store/common/paging"
"github.com/TwinProduction/gocache"
"github.com/gorilla/mux"
)
const (
cacheTTL = 10 * time.Second
)
var (
cache = gocache.NewCache().WithMaxSize(100).WithEvictionPolicy(gocache.FirstInFirstOut)
)
// ServiceStatuses handles requests to retrieve all service statuses
// Due to the size of the response, this function leverages a cache.
// Must not be wrapped by GzipHandler
func ServiceStatuses(writer http.ResponseWriter, r *http.Request) {
page, pageSize := extractPageAndPageSizeFromRequest(r)
gzipped := strings.Contains(r.Header.Get("Accept-Encoding"), "gzip")
var exists bool
var value interface{}
if gzipped {
writer.Header().Set("Content-Encoding", "gzip")
value, exists = cache.Get(fmt.Sprintf("service-status-%d-%d-gzipped", page, pageSize))
} else {
value, exists = cache.Get(fmt.Sprintf("service-status-%d-%d", page, pageSize))
}
var data []byte
if !exists {
var err error
buffer := &bytes.Buffer{}
gzipWriter := gzip.NewWriter(buffer)
serviceStatuses, err := storage.Get().GetAllServiceStatuses(paging.NewServiceStatusParams().WithResults(page, pageSize))
if err != nil {
log.Printf("[handler][ServiceStatuses] Failed to retrieve service statuses: %s", err.Error())
http.Error(writer, err.Error(), http.StatusInternalServerError)
return
}
data, err = json.Marshal(serviceStatuses)
if err != nil {
log.Printf("[handler][ServiceStatuses] 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("service-status-%d-%d", page, pageSize), data, cacheTTL)
cache.SetWithTTL(fmt.Sprintf("service-status-%d-%d-gzipped", page, pageSize), gzippedData, cacheTTL)
if gzipped {
data = gzippedData
}
} else {
data = value.([]byte)
}
writer.Header().Add("Content-Type", "application/json")
writer.WriteHeader(http.StatusOK)
_, _ = writer.Write(data)
}
// ServiceStatus retrieves a single ServiceStatus by group name and service name
func ServiceStatus(writer http.ResponseWriter, r *http.Request) {
page, pageSize := extractPageAndPageSizeFromRequest(r)
vars := mux.Vars(r)
serviceStatus, err := storage.Get().GetServiceStatusByKey(vars["key"], paging.NewServiceStatusParams().WithResults(page, pageSize).WithEvents(1, common.MaximumNumberOfEvents))
if err != nil {
if err == common.ErrServiceNotFound {
http.Error(writer, err.Error(), http.StatusNotFound)
return
}
log.Printf("[handler][ServiceStatus] Failed to retrieve service status: %s", err.Error())
http.Error(writer, err.Error(), http.StatusInternalServerError)
return
}
if serviceStatus == nil {
log.Printf("[handler][ServiceStatus] Service with key=%s not found", vars["key"])
http.Error(writer, "not found", http.StatusNotFound)
return
}
output, err := json.Marshal(serviceStatus)
if err != nil {
log.Printf("[handler][ServiceStatus] 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,215 @@
package handler
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/TwinProduction/gatus/config"
"github.com/TwinProduction/gatus/core"
"github.com/TwinProduction/gatus/storage"
"github.com/TwinProduction/gatus/watchdog"
)
var (
firstCondition = core.Condition("[STATUS] == 200")
secondCondition = core.Condition("[RESPONSE_TIME] < 500")
thirdCondition = core.Condition("[CERTIFICATE_EXPIRATION] < 72h")
timestamp = time.Now()
testService = core.Service{
Name: "name",
Group: "group",
URL: "https://example.org/what/ever",
Method: "GET",
Body: "body",
Interval: 30 * time.Second,
Conditions: []*core.Condition{&firstCondition, &secondCondition, &thirdCondition},
Alerts: nil,
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 TestServiceStatus(t *testing.T) {
defer storage.Get().Clear()
defer cache.Clear()
cfg := &config.Config{
Metrics: true,
Services: []*core.Service{
{
Name: "frontend",
Group: "core",
},
{
Name: "backend",
Group: "core",
},
},
}
watchdog.UpdateServiceStatuses(cfg.Services[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateServiceStatuses(cfg.Services[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
router := CreateRouter("../../web/static", cfg.Security, nil, cfg.Metrics)
type Scenario struct {
Name string
Path string
ExpectedCode int
Gzip bool
}
scenarios := []Scenario{
{
Name: "service-status",
Path: "/api/v1/services/core_frontend/statuses",
ExpectedCode: http.StatusOK,
},
{
Name: "service-status-gzip",
Path: "/api/v1/services/core_frontend/statuses",
ExpectedCode: http.StatusOK,
Gzip: true,
},
{
Name: "service-status-pagination",
Path: "/api/v1/services/core_frontend/statuses?page=1&pageSize=20",
ExpectedCode: http.StatusOK,
},
{
Name: "service-status-for-invalid-key",
Path: "/api/v1/services/invalid_key/statuses",
ExpectedCode: http.StatusNotFound,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
request, _ := http.NewRequest("GET", scenario.Path, nil)
if scenario.Gzip {
request.Header.Set("Accept-Encoding", "gzip")
}
responseRecorder := httptest.NewRecorder()
router.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != scenario.ExpectedCode {
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
}
})
}
}
func TestServiceStatuses(t *testing.T) {
defer storage.Get().Clear()
defer cache.Clear()
firstResult := &testSuccessfulResult
secondResult := &testUnsuccessfulResult
storage.Get().Insert(&testService, firstResult)
storage.Get().Insert(&testService, secondResult)
// Can't be bothered dealing with timezone issues on the worker that runs the automated tests
firstResult.Timestamp = time.Time{}
secondResult.Timestamp = time.Time{}
router := CreateRouter("../../web/static", nil, nil, false)
type Scenario struct {
Name string
Path string
ExpectedCode int
ExpectedBody string
}
scenarios := []Scenario{
{
Name: "no-pagination",
Path: "/api/v1/services/statuses",
ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"errors":null,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}],"events":[]}]`,
},
{
Name: "pagination-first-result",
Path: "/api/v1/services/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"}],"events":[]}]`,
},
{
Name: "pagination-second-result",
Path: "/api/v1/services/statuses?page=2&pageSize=1",
ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"errors":null,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"}],"events":[]}]`,
},
{
Name: "pagination-no-results",
Path: "/api/v1/services/statuses?page=5&pageSize=20",
ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[],"events":[]}]`,
},
{
Name: "invalid-pagination-should-fall-back-to-default",
Path: "/api/v1/services/statuses?page=INVALID&pageSize=INVALID",
ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"errors":null,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}],"events":[]}]`,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
request, _ := http.NewRequest("GET", scenario.Path, nil)
responseRecorder := httptest.NewRecorder()
router.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != scenario.ExpectedCode {
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
}
output := responseRecorder.Body.String()
if output != scenario.ExpectedBody {
t.Errorf("expected:\n %s\n\ngot:\n %s", scenario.ExpectedBody, output)
}
})
}
}

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

@@ -0,0 +1,27 @@
package handler
import (
"html/template"
"log"
"net/http"
"github.com/TwinProduction/gatus/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,65 @@
package handler
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/TwinProduction/gatus/config"
"github.com/TwinProduction/gatus/core"
"github.com/TwinProduction/gatus/storage"
"github.com/TwinProduction/gatus/watchdog"
)
func TestSinglePageApplication(t *testing.T) {
defer storage.Get().Clear()
defer cache.Clear()
cfg := &config.Config{
Metrics: true,
Services: []*core.Service{
{
Name: "frontend",
Group: "core",
},
{
Name: "backend",
Group: "core",
},
},
}
watchdog.UpdateServiceStatuses(cfg.Services[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateServiceStatuses(cfg.Services[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
router := CreateRouter("../../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-service",
Path: "/services/core_frontend",
ExpectedCode: http.StatusOK,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
request, _ := http.NewRequest("GET", scenario.Path, nil)
if scenario.Gzip {
request.Header.Set("Accept-Encoding", "gzip")
}
responseRecorder := httptest.NewRecorder()
router.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != scenario.ExpectedCode {
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
}
})
}
}

View File

@@ -0,0 +1,48 @@
package handler
import (
"net/http"
"strconv"
"github.com/TwinProduction/gatus/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), nil)
actualPage, actualPageSize := extractPageAndPageSizeFromRequest(request)
if actualPage != scenario.ExpectedPage {
t.Errorf("expected %d, got %d", scenario.ExpectedPage, actualPage)
}
if actualPageSize != scenario.ExpectedPageSize {
t.Errorf("expected %d, got %d", scenario.ExpectedPageSize, actualPageSize)
}
})
}
}

View File

@@ -1,8 +0,0 @@
package controller
import "net/http"
// spaHandler handles requests for /
func spaHandler(writer http.ResponseWriter, request *http.Request) {
http.ServeFile(writer, request, staticFolder+"/index.html")
}

View File

@@ -1,46 +0,0 @@
package controller
import (
"net/http"
"strconv"
)
const (
// DefaultPage is the default page to use if none is specified or an invalid value is provided
DefaultPage = 1
// DefaultPageSize is the default page siZE to use if none is specified or an invalid value is provided
DefaultPageSize = 20
// MaximumPageSize is the maximum page size allowed
MaximumPageSize = 100
)
func extractPageAndPageSizeFromRequest(r *http.Request) (page int, pageSize int) {
var err error
if pageParameter := r.URL.Query().Get("page"); len(pageParameter) == 0 {
page = DefaultPage
} else {
page, err = strconv.Atoi(pageParameter)
if err != nil {
page = DefaultPage
}
if page < 1 {
page = DefaultPage
}
}
if pageSizeParameter := r.URL.Query().Get("pageSize"); len(pageSizeParameter) == 0 {
pageSize = DefaultPageSize
} else {
pageSize, err = strconv.Atoi(pageSizeParameter)
if err != nil {
pageSize = DefaultPageSize
}
if pageSize > MaximumPageSize {
pageSize = MaximumPageSize
} else if pageSize < 1 {
pageSize = DefaultPageSize
}
}
return
}

View File

@@ -1,67 +0,0 @@
package controller
import (
"fmt"
"net/http"
"testing"
)
func TestExtractPageAndPageSizeFromRequest(t *testing.T) {
type Scenario struct {
Name string
Page string
PageSize string
ExpectedPage int
ExpectedPageSize int
}
scenarios := []Scenario{
{
Page: "1",
PageSize: "20",
ExpectedPage: 1,
ExpectedPageSize: 20,
},
{
Page: "2",
PageSize: "10",
ExpectedPage: 2,
ExpectedPageSize: 10,
},
{
Page: "2",
PageSize: "10",
ExpectedPage: 2,
ExpectedPageSize: 10,
},
{
Page: "1",
PageSize: "999999",
ExpectedPage: 1,
ExpectedPageSize: MaximumPageSize,
},
{
Page: "-1",
PageSize: "-1",
ExpectedPage: DefaultPage,
ExpectedPageSize: DefaultPageSize,
},
{
Page: "invalid",
PageSize: "invalid",
ExpectedPage: DefaultPage,
ExpectedPageSize: DefaultPageSize,
},
}
for _, scenario := range scenarios {
t.Run("page-"+scenario.Page+"-pageSize-"+scenario.PageSize, func(t *testing.T) {
request, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/statuses?page=%s&pageSize=%s", scenario.Page, scenario.PageSize), nil)
actualPage, actualPageSize := extractPageAndPageSizeFromRequest(request)
if actualPage != scenario.ExpectedPage {
t.Errorf("expected %d, got %d", scenario.ExpectedPage, actualPage)
}
if actualPageSize != scenario.ExpectedPageSize {
t.Errorf("expected %d, got %d", scenario.ExpectedPageSize, actualPageSize)
}
})
}
}

View File

@@ -85,48 +85,48 @@ type Condition string
// evaluate the Condition with the Result of the health check
// TODO: Add a mandatory space between each operators (e.g. " == " instead of "==") (BREAKING CHANGE)
func (c Condition) evaluate(result *Result) bool {
func (c Condition) evaluate(result *Result, dontResolveFailedConditions bool) bool {
condition := string(c)
success := false
conditionToDisplay := condition
if strings.Contains(condition, "==") {
parameters, resolvedParameters := sanitizeAndResolve(strings.Split(condition, "=="), result)
success = isEqual(resolvedParameters[0], resolvedParameters[1])
if !success {
if !success && !dontResolveFailedConditions {
conditionToDisplay = prettify(parameters, resolvedParameters, "==")
}
} else if strings.Contains(condition, "!=") {
parameters, resolvedParameters := sanitizeAndResolve(strings.Split(condition, "!="), result)
success = !isEqual(resolvedParameters[0], resolvedParameters[1])
if !success {
if !success && !dontResolveFailedConditions {
conditionToDisplay = prettify(parameters, resolvedParameters, "!=")
}
} else if strings.Contains(condition, "<=") {
parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, "<="), result)
success = resolvedParameters[0] <= resolvedParameters[1]
if !success {
if !success && !dontResolveFailedConditions {
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, "<=")
}
} else if strings.Contains(condition, ">=") {
parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, ">="), result)
success = resolvedParameters[0] >= resolvedParameters[1]
if !success {
if !success && !dontResolveFailedConditions {
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, ">=")
}
} else if strings.Contains(condition, ">") {
parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, ">"), result)
success = resolvedParameters[0] > resolvedParameters[1]
if !success {
if !success && !dontResolveFailedConditions {
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, ">")
}
} else if strings.Contains(condition, "<") {
parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, "<"), result)
success = resolvedParameters[0] < resolvedParameters[1]
if !success {
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
}
if !success {
@@ -242,7 +242,7 @@ func sanitizeAndResolve(elements []string, result *Result) ([]string, []string)
} else {
if err != nil {
if err.Error() != "unexpected end of JSON input" {
result.Errors = append(result.Errors, err.Error())
result.AddError(err.Error())
}
if checkingForLength {
element = LengthFunctionPrefix + element + FunctionSuffix + " " + InvalidConditionElementSuffix

View File

@@ -6,7 +6,7 @@ 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)
condition.evaluate(result, false)
}
b.ReportAllocs()
}
@@ -15,7 +15,7 @@ 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)
condition.evaluate(result, false)
}
b.ReportAllocs()
}
@@ -24,7 +24,7 @@ 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)
condition.evaluate(result, false)
}
b.ReportAllocs()
}
@@ -33,7 +33,16 @@ 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)
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()
}
@@ -42,7 +51,7 @@ 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)
condition.evaluate(result, false)
}
b.ReportAllocs()
}
@@ -51,7 +60,7 @@ 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)
condition.evaluate(result, false)
}
b.ReportAllocs()
}
@@ -60,7 +69,7 @@ func BenchmarkCondition_evaluateWithStatus(b *testing.B) {
condition := Condition("[STATUS] == 200")
for n := 0; n < b.N; n++ {
result := &Result{HTTPStatus: 200}
condition.evaluate(result)
condition.evaluate(result, false)
}
b.ReportAllocs()
}
@@ -69,7 +78,7 @@ func BenchmarkCondition_evaluateWithStatusFailure(b *testing.B) {
condition := Condition("[STATUS] == 200")
for n := 0; n < b.N; n++ {
result := &Result{HTTPStatus: 400}
condition.evaluate(result)
condition.evaluate(result, false)
}
b.ReportAllocs()
}

View File

@@ -8,11 +8,12 @@ import (
func TestCondition_evaluate(t *testing.T) {
type scenario struct {
Name string
Condition Condition
Result *Result
ExpectedSuccess bool
ExpectedOutput string
Name string
Condition Condition
Result *Result
DontResolveFailedConditions bool
ExpectedSuccess bool
ExpectedOutput string
}
scenarios := []scenario{
{
@@ -372,6 +373,14 @@ func TestCondition_evaluate(t *testing.T) {
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"),
@@ -435,6 +444,14 @@ func TestCondition_evaluate(t *testing.T) {
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"),
@@ -445,7 +462,7 @@ func TestCondition_evaluate(t *testing.T) {
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
scenario.Condition.evaluate(scenario.Result)
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)
}
@@ -459,7 +476,7 @@ func TestCondition_evaluate(t *testing.T) {
func TestCondition_evaluateWithInvalidOperator(t *testing.T) {
condition := Condition("[STATUS] ? 201")
result := &Result{HTTPStatus: 201}
condition.evaluate(result)
condition.evaluate(result, false)
if result.Success {
t.Error("condition was invalid, result should've been a failure")
}

View File

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

View File

@@ -24,3 +24,14 @@ var (
// EventUnhealthy is a type of event that represents a service failing one or more of its conditions
EventUnhealthy EventType = "UNHEALTHY"
)
// NewEventFromResult creates an Event from a Result
func NewEventFromResult(result *Result) *Event {
event := &Event{Timestamp: result.Timestamp}
if result.Success {
event.Type = EventHealthy
} else {
event.Type = EventUnhealthy
}
return event
}

12
core/event_test.go Normal file
View File

@@ -0,0 +1,12 @@
package core
import "testing"
func TestNewEventFromResult(t *testing.T) {
if event := NewEventFromResult(&Result{Success: true}); event.Type != EventHealthy {
t.Error("expected event.Type to be EventHealthy")
}
if event := NewEventFromResult(&Result{Success: false}); event.Type != EventUnhealthy {
t.Error("expected event.Type to be EventUnhealthy")
}
}

View File

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

21
core/result_test.go Normal file
View File

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

View File

@@ -1,114 +0,0 @@
package core
import (
"time"
"github.com/TwinProduction/gatus/util"
)
const (
// MaximumNumberOfResults is the maximum number of results that ServiceStatus.Results can have
MaximumNumberOfResults = 100
// MaximumNumberOfEvents is the maximum number of events that ServiceStatus.Events can have
MaximumNumberOfEvents = 50
)
// ServiceStatus contains the evaluation Results of a Service
type ServiceStatus struct {
// Name of the service
Name string `json:"name,omitempty"`
// Group the service is a part of. Used for grouping multiple services together on the front end.
Group string `json:"group,omitempty"`
// Key is the key representing the ServiceStatus
Key string `json:"key"`
// Results is the list of service evaluation results
Results []*Result `json:"results"`
// Events is a list of events
//
// We don't expose this through JSON, because the main dashboard doesn't need to have this data.
// However, the detailed service page does leverage this by including it to a map that will be
// marshalled alongside the ServiceStatus.
Events []*Event `json:"-"`
// Uptime information on the service's uptime
//
// We don't expose this through JSON, because the main dashboard doesn't need to have this data.
// However, the detailed service page does leverage this by including it to a map that will be
// marshalled alongside the ServiceStatus.
Uptime *Uptime `json:"-"`
}
// NewServiceStatus creates a new ServiceStatus
func NewServiceStatus(service *Service) *ServiceStatus {
return &ServiceStatus{
Name: service.Name,
Group: service.Group,
Key: util.ConvertGroupAndServiceToKey(service.Group, service.Name),
Results: make([]*Result, 0),
Events: []*Event{{
Type: EventStart,
Timestamp: time.Now(),
}},
Uptime: NewUptime(),
}
}
// WithResultPagination returns a shallow copy of the ServiceStatus with only the results
// within the range defined by the page and pageSize parameters
func (ss ServiceStatus) WithResultPagination(page, pageSize int) *ServiceStatus {
shallowCopy := ss
numberOfResults := len(shallowCopy.Results)
start := numberOfResults - (page * pageSize)
end := numberOfResults - ((page - 1) * pageSize)
if start > numberOfResults {
start = -1
} else if start < 0 {
start = 0
}
if end > numberOfResults {
end = numberOfResults
}
if start < 0 || end < 0 {
shallowCopy.Results = []*Result{}
} else {
shallowCopy.Results = shallowCopy.Results[start:end]
}
return &shallowCopy
}
// AddResult adds a Result to ServiceStatus.Results and makes sure that there are
// no more than 20 results in the Results slice
func (ss *ServiceStatus) AddResult(result *Result) {
if len(ss.Results) > 0 {
// Check if there's any change since the last result
// OR there's only 1 event, which only happens when there's a start event
if ss.Results[len(ss.Results)-1].Success != result.Success || len(ss.Events) == 1 {
event := &Event{Timestamp: result.Timestamp}
if result.Success {
event.Type = EventHealthy
} else {
event.Type = EventUnhealthy
}
ss.Events = append(ss.Events, event)
if len(ss.Events) > MaximumNumberOfEvents {
// Doing ss.Events[1:] would usually be sufficient, but in the case where for some reason, the slice has
// more than one extra element, we can get rid of all of them at once and thus returning the slice to a
// length of MaximumNumberOfEvents by using ss.Events[len(ss.Events)-MaximumNumberOfEvents:] instead
ss.Events = ss.Events[len(ss.Events)-MaximumNumberOfEvents:]
}
}
}
ss.Results = append(ss.Results, result)
if len(ss.Results) > MaximumNumberOfResults {
// Doing ss.Results[1:] would usually be sufficient, but in the case where for some reason, the slice has more
// than one extra element, we can get rid of all of them at once and thus returning the slice to a length of
// MaximumNumberOfResults by using ss.Results[len(ss.Results)-MaximumNumberOfResults:] instead
ss.Results = ss.Results[len(ss.Results)-MaximumNumberOfResults:]
}
ss.Uptime.ProcessResult(result)
}

View File

@@ -1,92 +0,0 @@
package core
import (
"testing"
"time"
)
var (
firstCondition = Condition("[STATUS] == 200")
secondCondition = Condition("[RESPONSE_TIME] < 500")
thirdCondition = Condition("[CERTIFICATE_EXPIRATION] < 72h")
timestamp = time.Now()
testService = Service{
Name: "name",
Group: "group",
URL: "https://example.org/what/ever",
Method: "GET",
Body: "body",
Interval: 30 * time.Second,
Conditions: []*Condition{&firstCondition, &secondCondition, &thirdCondition},
Alerts: nil,
Insecure: false,
NumberOfFailuresInARow: 0,
NumberOfSuccessesInARow: 0,
}
testSuccessfulResult = Result{
Hostname: "example.org",
IP: "127.0.0.1",
HTTPStatus: 200,
body: []byte("body"),
Errors: nil,
Connected: true,
Success: true,
Timestamp: timestamp,
Duration: 150 * time.Millisecond,
CertificateExpiration: 10 * time.Hour,
ConditionResults: []*ConditionResult{
{
Condition: "[STATUS] == 200",
Success: true,
},
{
Condition: "[RESPONSE_TIME] < 500",
Success: true,
},
{
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
Success: true,
},
},
}
testUnsuccessfulResult = Result{
Hostname: "example.org",
IP: "127.0.0.1",
HTTPStatus: 200,
body: []byte("body"),
Errors: []string{"error-1", "error-2"},
Connected: true,
Success: false,
Timestamp: timestamp,
Duration: 750 * time.Millisecond,
CertificateExpiration: 10 * time.Hour,
ConditionResults: []*ConditionResult{
{
Condition: "[STATUS] == 200",
Success: true,
},
{
Condition: "[RESPONSE_TIME] < 500",
Success: false,
},
{
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
Success: false,
},
},
}
)
func BenchmarkServiceStatus_WithResultPagination(b *testing.B) {
service := &testService
serviceStatus := NewServiceStatus(service)
for i := 0; i < MaximumNumberOfResults; i++ {
serviceStatus.AddResult(&testSuccessfulResult)
}
for n := 0; n < b.N; n++ {
serviceStatus.WithResultPagination(1, 20)
}
b.ReportAllocs()
}

View File

@@ -1,66 +0,0 @@
package core
import (
"testing"
"time"
)
func TestNewServiceStatus(t *testing.T) {
service := &Service{Name: "name", Group: "group"}
serviceStatus := NewServiceStatus(service)
if serviceStatus.Name != service.Name {
t.Errorf("expected %s, got %s", service.Name, serviceStatus.Name)
}
if serviceStatus.Group != service.Group {
t.Errorf("expected %s, got %s", service.Group, serviceStatus.Group)
}
if serviceStatus.Key != "group_name" {
t.Errorf("expected %s, got %s", "group_name", serviceStatus.Key)
}
}
func TestServiceStatus_AddResult(t *testing.T) {
service := &Service{Name: "name", Group: "group"}
serviceStatus := NewServiceStatus(service)
for i := 0; i < MaximumNumberOfResults+10; i++ {
serviceStatus.AddResult(&Result{Timestamp: time.Now()})
}
if len(serviceStatus.Results) != MaximumNumberOfResults {
t.Errorf("expected serviceStatus.Results to not exceed a length of %d", MaximumNumberOfResults)
}
}
func TestServiceStatus_WithResultPagination(t *testing.T) {
service := &Service{Name: "name", Group: "group"}
serviceStatus := NewServiceStatus(service)
for i := 0; i < 25; i++ {
serviceStatus.AddResult(&Result{Timestamp: time.Now()})
}
if len(serviceStatus.WithResultPagination(1, 1).Results) != 1 {
t.Errorf("expected to have 1 result")
}
if len(serviceStatus.WithResultPagination(5, 0).Results) != 0 {
t.Errorf("expected to have 0 results")
}
if len(serviceStatus.WithResultPagination(-1, 20).Results) != 0 {
t.Errorf("expected to have 0 result, because the page was invalid")
}
if len(serviceStatus.WithResultPagination(1, -1).Results) != 0 {
t.Errorf("expected to have 0 result, because the page size was invalid")
}
if len(serviceStatus.WithResultPagination(1, 10).Results) != 10 {
t.Errorf("expected to have 10 results, because given a page size of 10, page 1 should have 10 elements")
}
if len(serviceStatus.WithResultPagination(2, 10).Results) != 10 {
t.Errorf("expected to have 10 results, because given a page size of 10, page 2 should have 10 elements")
}
if len(serviceStatus.WithResultPagination(3, 10).Results) != 5 {
t.Errorf("expected to have 5 results, because given a page size of 10, page 3 should have 5 elements")
}
if len(serviceStatus.WithResultPagination(4, 10).Results) != 0 {
t.Errorf("expected to have 0 results, because given a page size of 10, page 4 should have 0 elements")
}
if len(serviceStatus.WithResultPagination(1, 50).Results) != 25 {
t.Errorf("expected to have 25 results, because there's only 25 results")
}
}

View File

@@ -2,6 +2,7 @@ package core
import (
"bytes"
"crypto/x509"
"encoding/json"
"errors"
"io/ioutil"
@@ -13,6 +14,8 @@ import (
"github.com/TwinProduction/gatus/alerting/alert"
"github.com/TwinProduction/gatus/client"
"github.com/TwinProduction/gatus/core/ui"
"github.com/TwinProduction/gatus/util"
)
const (
@@ -41,7 +44,11 @@ var (
)
// Service is the configuration of a monitored endpoint
// XXX: Rename this to Endpoint in v4.0.0?
type Service struct {
// Enabled defines whether to enable the service
Enabled *bool `yaml:"enabled,omitempty"`
// Name of the service. Can be anything.
Name string `yaml:"name"`
@@ -75,8 +82,11 @@ type Service struct {
// Alerts is the alerting configuration for the service in case of failure
Alerts []*alert.Alert `yaml:"alerts"`
// Insecure is whether to skip verifying the server's certificate chain and host name
Insecure bool `yaml:"insecure,omitempty"`
// ClientConfig is the configuration of the client used to communicate with the service's target
ClientConfig *client.Config `yaml:"client"`
// UIConfig is the configuration for the UI
UIConfig *ui.Config `yaml:"ui"`
// NumberOfFailuresInARow is the number of unsuccessful evaluations in a row
NumberOfFailuresInARow int
@@ -85,9 +95,25 @@ type Service struct {
NumberOfSuccessesInARow int
}
// IsEnabled returns whether the service is enabled or not
func (service Service) IsEnabled() bool {
if service.Enabled == nil {
return true
}
return *service.Enabled
}
// ValidateAndSetDefaults validates the service's configuration and sets the default value of fields that have one
func (service *Service) ValidateAndSetDefaults() error {
// Set default values
if service.ClientConfig == nil {
service.ClientConfig = client.GetDefaultConfig()
} else {
service.ClientConfig.ValidateAndSetDefaults()
}
if service.UIConfig == nil {
service.UIConfig = ui.GetDefaultConfig()
}
if service.Interval == 0 {
service.Interval = 1 * time.Minute
}
@@ -134,6 +160,11 @@ func (service *Service) ValidateAndSetDefaults() error {
return nil
}
// Key returns the unique key for the Service
func (service Service) Key() string {
return util.ConvertGroupAndServiceToKey(service.Group, service.Name)
}
// EvaluateHealth sends a request to the service's URL and evaluates the conditions of the service.
func (service *Service) EvaluateHealth() *Result {
result := &Result{Success: true, Errors: []string{}}
@@ -144,7 +175,7 @@ func (service *Service) EvaluateHealth() *Result {
result.Success = false
}
for _, condition := range service.Conditions {
success := condition.evaluate(result)
success := condition.evaluate(result, service.UIConfig.DontResolveFailedConditions)
if !success {
result.Success = false
}
@@ -152,38 +183,27 @@ func (service *Service) EvaluateHealth() *Result {
result.Timestamp = time.Now()
// No need to keep the body after the service has been evaluated
result.body = nil
// Clean up parameters that we don't need to keep in the results
if service.UIConfig.HideHostname {
result.Hostname = ""
}
return result
}
// GetAlertsTriggered returns a slice of alerts that have been triggered
func (service *Service) GetAlertsTriggered() []alert.Alert {
var alerts []alert.Alert
if service.NumberOfFailuresInARow == 0 {
return alerts
}
for _, alert := range service.Alerts {
if alert.IsEnabled() && alert.FailureThreshold == service.NumberOfFailuresInARow {
alerts = append(alerts, *alert)
continue
}
}
return alerts
}
func (service *Service) getIP(result *Result) {
if service.DNS != nil {
result.Hostname = strings.TrimSuffix(service.URL, ":53")
} else {
urlObject, err := url.Parse(service.URL)
if err != nil {
result.Errors = append(result.Errors, err.Error())
result.AddError(err.Error())
return
}
result.Hostname = urlObject.Hostname()
}
ips, err := net.LookupIP(result.Hostname)
if err != nil {
result.Errors = append(result.Errors, err.Error())
result.AddError(err.Error())
return
}
result.IP = ips[0].String()
@@ -193,10 +213,13 @@ func (service *Service) call(result *Result) {
var request *http.Request
var response *http.Response
var err error
var certificate *x509.Certificate
isServiceDNS := service.DNS != nil
isServiceTCP := strings.HasPrefix(service.URL, "tcp://")
isServiceICMP := strings.HasPrefix(service.URL, "icmp://")
isServiceHTTP := !isServiceDNS && !isServiceTCP && !isServiceICMP
isServiceStartTLS := strings.HasPrefix(service.URL, "starttls://")
isServiceTLS := strings.HasPrefix(service.URL, "tls://")
isServiceHTTP := !isServiceDNS && !isServiceTCP && !isServiceICMP && !isServiceStartTLS && !isServiceTLS
if isServiceHTTP {
request = service.buildHTTPRequest()
}
@@ -204,21 +227,33 @@ func (service *Service) call(result *Result) {
if isServiceDNS {
service.DNS.query(service.URL, result)
result.Duration = time.Since(startTime)
} else if isServiceStartTLS || isServiceTLS {
if isServiceStartTLS {
result.Connected, certificate, err = client.CanPerformStartTLS(strings.TrimPrefix(service.URL, "starttls://"), service.ClientConfig)
} else {
result.Connected, certificate, err = client.CanPerformStartTLS(strings.TrimPrefix(service.URL, "tls://"), service.ClientConfig)
}
if err != nil {
result.AddError(err.Error())
return
}
result.Duration = time.Since(startTime)
result.CertificateExpiration = time.Until(certificate.NotAfter)
} else if isServiceTCP {
result.Connected = client.CanCreateTCPConnection(strings.TrimPrefix(service.URL, "tcp://"))
result.Connected = client.CanCreateTCPConnection(strings.TrimPrefix(service.URL, "tcp://"), service.ClientConfig)
result.Duration = time.Since(startTime)
} else if isServiceICMP {
result.Connected, result.Duration = client.Ping(strings.TrimPrefix(service.URL, "icmp://"))
result.Connected, result.Duration = client.Ping(strings.TrimPrefix(service.URL, "icmp://"), service.ClientConfig)
} else {
response, err = client.GetHTTPClient(service.Insecure).Do(request)
response, err = client.GetHTTPClient(service.ClientConfig).Do(request)
result.Duration = time.Since(startTime)
if err != nil {
result.Errors = append(result.Errors, err.Error())
result.AddError(err.Error())
return
}
defer response.Body.Close()
if response.TLS != nil && len(response.TLS.PeerCertificates) > 0 {
certificate := response.TLS.PeerCertificates[0]
certificate = response.TLS.PeerCertificates[0]
result.CertificateExpiration = time.Until(certificate.NotAfter)
}
result.HTTPStatus = response.StatusCode
@@ -227,7 +262,7 @@ func (service *Service) call(result *Result) {
if service.needsToReadBody() {
result.body, err = ioutil.ReadAll(response.Body)
if err != nil {
result.Errors = append(result.Errors, err.Error())
result.AddError(err.Error())
}
}
}

38
core/service_status.go Normal file
View File

@@ -0,0 +1,38 @@
package core
// ServiceStatus contains the evaluation Results of a Service
type ServiceStatus struct {
// Name of the service
Name string `json:"name,omitempty"`
// Group the service is a part of. Used for grouping multiple services together on the front end.
Group string `json:"group,omitempty"`
// Key is the key representing the ServiceStatus
Key string `json:"key"`
// Results is the list of service evaluation results
Results []*Result `json:"results"`
// Events is a list of events
Events []*Event `json:"events"`
// Uptime information on the service's uptime
//
// Used by the memory store.
//
// To retrieve the uptime between two time, use store.GetUptimeByKey.
Uptime *Uptime `json:"-"`
}
// NewServiceStatus creates a new ServiceStatus
func NewServiceStatus(serviceKey, serviceGroup, serviceName string) *ServiceStatus {
return &ServiceStatus{
Name: serviceName,
Group: serviceGroup,
Key: serviceKey,
Results: make([]*Result, 0),
Events: make([]*Event, 0),
Uptime: NewUptime(),
}
}

View File

@@ -0,0 +1,19 @@
package core
import (
"testing"
)
func TestNewServiceStatus(t *testing.T) {
service := &Service{Name: "name", Group: "group"}
serviceStatus := NewServiceStatus(service.Key(), service.Group, service.Name)
if serviceStatus.Name != service.Name {
t.Errorf("expected %s, got %s", service.Name, serviceStatus.Name)
}
if serviceStatus.Group != service.Group {
t.Errorf("expected %s, got %s", service.Group, serviceStatus.Group)
}
if serviceStatus.Key != "group_name" {
t.Errorf("expected %s, got %s", "group_name", serviceStatus.Key)
}
}

View File

@@ -7,17 +7,43 @@ import (
"time"
"github.com/TwinProduction/gatus/alerting/alert"
"github.com/TwinProduction/gatus/client"
)
func TestService_IsEnabled(t *testing.T) {
if !(Service{Enabled: nil}).IsEnabled() {
t.Error("service.IsEnabled() should've returned true, because Enabled was set to nil")
}
if value := false; (Service{Enabled: &value}).IsEnabled() {
t.Error("service.IsEnabled() should've returned false, because Enabled was set to false")
}
if value := true; !(Service{Enabled: &value}).IsEnabled() {
t.Error("Service.IsEnabled() should've returned true, because Enabled was set to true")
}
}
func TestService_ValidateAndSetDefaults(t *testing.T) {
condition := Condition("[STATUS] == 200")
service := Service{
Name: "twinnation-health",
URL: "https://twinnation.org/health",
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []*Condition{&condition},
Alerts: []*alert.Alert{{Type: alert.TypePagerDuty}},
}
service.ValidateAndSetDefaults()
if service.ClientConfig == nil {
t.Error("client configuration should've been set to the default configuration")
} else {
if service.ClientConfig.Insecure != client.GetDefaultConfig().Insecure {
t.Errorf("Default client configuration should've set Insecure to %v, got %v", client.GetDefaultConfig().Insecure, service.ClientConfig.Insecure)
}
if service.ClientConfig.IgnoreRedirect != client.GetDefaultConfig().IgnoreRedirect {
t.Errorf("Default client configuration should've set IgnoreRedirect to %v, got %v", client.GetDefaultConfig().IgnoreRedirect, service.ClientConfig.IgnoreRedirect)
}
if service.ClientConfig.Timeout != client.GetDefaultConfig().Timeout {
t.Errorf("Default client configuration should've set Timeout to %v, got %v", client.GetDefaultConfig().Timeout, service.ClientConfig.Timeout)
}
}
if service.Method != "GET" {
t.Error("Service method should've defaulted to GET")
}
@@ -41,6 +67,34 @@ func TestService_ValidateAndSetDefaults(t *testing.T) {
}
}
func TestService_ValidateAndSetDefaultsWithClientConfig(t *testing.T) {
condition := Condition("[STATUS] == 200")
service := Service{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []*Condition{&condition},
ClientConfig: &client.Config{
Insecure: true,
IgnoreRedirect: true,
Timeout: 0,
},
}
service.ValidateAndSetDefaults()
if service.ClientConfig == nil {
t.Error("client configuration should've been set to the default configuration")
} else {
if !service.ClientConfig.Insecure {
t.Error("service.ClientConfig.Insecure should've been set to true")
}
if !service.ClientConfig.IgnoreRedirect {
t.Error("service.ClientConfig.IgnoreRedirect should've been set to true")
}
if service.ClientConfig.Timeout != client.GetDefaultConfig().Timeout {
t.Error("service.ClientConfig.Timeout should've been set to 10s, because the timeout value entered is not set or invalid")
}
}
}
func TestService_ValidateAndSetDefaultsWithNoName(t *testing.T) {
defer func() { recover() }()
condition := Condition("[STATUS] == 200")
@@ -102,36 +156,11 @@ func TestService_ValidateAndSetDefaultsWithDNS(t *testing.T) {
}
}
func TestService_GetAlertsTriggered(t *testing.T) {
condition := Condition("[STATUS] == 200")
enabled := true
service := Service{
Name: "twinnation-health",
URL: "https://twinnation.org/health",
Conditions: []*Condition{&condition},
Alerts: []*alert.Alert{{Type: alert.TypePagerDuty, Enabled: &enabled}},
}
service.ValidateAndSetDefaults()
if service.NumberOfFailuresInARow != 0 {
t.Error("Service.NumberOfFailuresInARow should start with 0")
}
if service.NumberOfSuccessesInARow != 0 {
t.Error("Service.NumberOfSuccessesInARow should start with 0")
}
if len(service.GetAlertsTriggered()) > 0 {
t.Error("No alerts should've been triggered, because service.NumberOfFailuresInARow is 0, which is below the failure threshold")
}
service.NumberOfFailuresInARow = service.Alerts[0].FailureThreshold
if len(service.GetAlertsTriggered()) != 1 {
t.Error("Alert should've been triggered")
}
}
func TestService_buildHTTPRequest(t *testing.T) {
condition := Condition("[STATUS] == 200")
service := Service{
Name: "twinnation-health",
URL: "https://twinnation.org/health",
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []*Condition{&condition},
}
service.ValidateAndSetDefaults()
@@ -139,8 +168,8 @@ func TestService_buildHTTPRequest(t *testing.T) {
if request.Method != "GET" {
t.Error("request.Method should've been GET, but was", request.Method)
}
if request.Host != "twinnation.org" {
t.Error("request.Host should've been twinnation.org, but was", request.Host)
if request.Host != "twin.sh" {
t.Error("request.Host should've been twin.sh, but was", request.Host)
}
if userAgent := request.Header.Get("User-Agent"); userAgent != GatusUserAgent {
t.Errorf("request.Header.Get(User-Agent) should've been %s, but was %s", GatusUserAgent, userAgent)
@@ -150,8 +179,8 @@ func TestService_buildHTTPRequest(t *testing.T) {
func TestService_buildHTTPRequestWithCustomUserAgent(t *testing.T) {
condition := Condition("[STATUS] == 200")
service := Service{
Name: "twinnation-health",
URL: "https://twinnation.org/health",
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []*Condition{&condition},
Headers: map[string]string{
"User-Agent": "Test/2.0",
@@ -162,8 +191,8 @@ func TestService_buildHTTPRequestWithCustomUserAgent(t *testing.T) {
if request.Method != "GET" {
t.Error("request.Method should've been GET, but was", request.Method)
}
if request.Host != "twinnation.org" {
t.Error("request.Host should've been twinnation.org, but was", request.Host)
if request.Host != "twin.sh" {
t.Error("request.Host should've been twin.sh, but was", request.Host)
}
if userAgent := request.Header.Get("User-Agent"); userAgent != "Test/2.0" {
t.Errorf("request.Header.Get(User-Agent) should've been %s, but was %s", "Test/2.0", userAgent)
@@ -173,8 +202,8 @@ func TestService_buildHTTPRequestWithCustomUserAgent(t *testing.T) {
func TestService_buildHTTPRequestWithHostHeader(t *testing.T) {
condition := Condition("[STATUS] == 200")
service := Service{
Name: "twinnation-health",
URL: "https://twinnation.org/health",
Name: "website-health",
URL: "https://twin.sh/health",
Method: "POST",
Conditions: []*Condition{&condition},
Headers: map[string]string{
@@ -194,8 +223,8 @@ func TestService_buildHTTPRequestWithHostHeader(t *testing.T) {
func TestService_buildHTTPRequestWithGraphQLEnabled(t *testing.T) {
condition := Condition("[STATUS] == 200")
service := Service{
Name: "twinnation-graphql",
URL: "https://twinnation.org/graphql",
Name: "website-graphql",
URL: "https://twin.sh/graphql",
Method: "POST",
Conditions: []*Condition{&condition},
GraphQL: true,
@@ -226,10 +255,11 @@ func TestIntegrationEvaluateHealth(t *testing.T) {
condition := Condition("[STATUS] == 200")
bodyCondition := Condition("[BODY].status == UP")
service := Service{
Name: "twinnation-health",
URL: "https://twinnation.org/health",
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []*Condition{&condition, &bodyCondition},
}
service.ValidateAndSetDefaults()
result := service.EvaluateHealth()
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
@@ -245,10 +275,11 @@ func TestIntegrationEvaluateHealth(t *testing.T) {
func TestIntegrationEvaluateHealthWithFailure(t *testing.T) {
condition := Condition("[STATUS] == 500")
service := Service{
Name: "twinnation-health",
URL: "https://twinnation.org/health",
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []*Condition{&condition},
}
service.ValidateAndSetDefaults()
result := service.EvaluateHealth()
if result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a failure", condition)
@@ -273,6 +304,7 @@ func TestIntegrationEvaluateHealthForDNS(t *testing.T) {
},
Conditions: []*Condition{&conditionSuccess, &conditionBody},
}
service.ValidateAndSetDefaults()
result := service.EvaluateHealth()
if !result.ConditionResults[0].Success {
t.Errorf("Conditions '%s' and %s should have been a success", conditionSuccess, conditionBody)
@@ -292,6 +324,7 @@ func TestIntegrationEvaluateHealthForICMP(t *testing.T) {
URL: "icmp://127.0.0.1",
Conditions: []*Condition{&conditionSuccess},
}
service.ValidateAndSetDefaults()
result := service.EvaluateHealth()
if !result.ConditionResults[0].Success {
t.Errorf("Conditions '%s' should have been a success", conditionSuccess)

17
core/ui/ui.go Normal file
View File

@@ -0,0 +1,17 @@
package ui
// Config is the UI configuration for services
type Config struct {
// HideHostname whether to hide the hostname in the Result
HideHostname bool `yaml:"hide-hostname"`
// DontResolveFailedConditions whether to resolve failed conditions in the Result for display in the UI
DontResolveFailedConditions bool `yaml:"dont-resolve-failed-conditions"`
}
// GetDefaultConfig retrieves the default UI configuration
func GetDefaultConfig() *Config {
return &Config{
HideHostname: false,
DontResolveFailedConditions: false,
}
}

View File

@@ -1,33 +1,11 @@
package core
import (
"log"
"time"
)
const (
numberOfHoursInTenDays = 10 * 24
sevenDays = 7 * 24 * time.Hour
)
// Uptime is the struct that contains the relevant data for calculating the uptime as well as the uptime itself
// and some other statistics
type Uptime struct {
LastSevenDays float64 `json:"7d"` // Uptime percentage over the past 7 days
LastTwentyFourHours float64 `json:"24h"` // Uptime percentage over the past 24 hours
LastHour float64 `json:"1h"` // Uptime percentage over the past hour
// SuccessfulExecutionsPerHour is a map containing the number of successes (value)
// for every hourly unix timestamps (key)
// Deprecated
SuccessfulExecutionsPerHour map[int64]uint64 `json:"-"`
// TotalExecutionsPerHour is a map containing the total number of checks (value)
// for every hourly unix timestamps (key)
// Deprecated
TotalExecutionsPerHour map[int64]uint64 `json:"-"`
// HourlyStatistics is a map containing metrics collected (value) for every hourly unix timestamps (key)
//
// Used only if the storage type is memory
HourlyStatistics map[int64]*HourlyUptimeStatistics `json:"-"`
}
@@ -35,7 +13,7 @@ type Uptime struct {
type HourlyUptimeStatistics struct {
TotalExecutions uint64 // Total number of checks
SuccessfulExecutions uint64 // Number of successful executions
TotalExecutionsResponseTime uint64 // Total response time for all executions
TotalExecutionsResponseTime uint64 // Total response time for all executions in milliseconds
}
// NewUptime creates a new Uptime
@@ -44,109 +22,3 @@ func NewUptime() *Uptime {
HourlyStatistics: make(map[int64]*HourlyUptimeStatistics),
}
}
// ProcessResult processes the result by extracting the relevant from the result and recalculating the uptime
// if necessary
func (uptime *Uptime) ProcessResult(result *Result) {
// XXX: Remove this on v3.0.0
if len(uptime.SuccessfulExecutionsPerHour) != 0 || len(uptime.TotalExecutionsPerHour) != 0 {
uptime.migrateToHourlyStatistics()
}
if uptime.HourlyStatistics == nil {
uptime.HourlyStatistics = make(map[int64]*HourlyUptimeStatistics)
}
unixTimestampFlooredAtHour := result.Timestamp.Unix() - (result.Timestamp.Unix() % 3600)
hourlyStats, _ := uptime.HourlyStatistics[unixTimestampFlooredAtHour]
if hourlyStats == nil {
hourlyStats = &HourlyUptimeStatistics{}
uptime.HourlyStatistics[unixTimestampFlooredAtHour] = hourlyStats
}
if result.Success {
hourlyStats.SuccessfulExecutions++
}
hourlyStats.TotalExecutions++
hourlyStats.TotalExecutionsResponseTime += uint64(result.Duration.Milliseconds())
// Clean up only when we're starting to have too many useless keys
// Note that this is only triggered when there are more entries than there should be after
// 10 days, despite the fact that we are deleting everything that's older than 7 days.
// This is to prevent re-iterating on every `ProcessResult` as soon as the uptime has been logged for 7 days.
if len(uptime.HourlyStatistics) > numberOfHoursInTenDays {
sevenDaysAgo := time.Now().Add(-(sevenDays + time.Hour)).Unix()
for hourlyUnixTimestamp := range uptime.HourlyStatistics {
if sevenDaysAgo > hourlyUnixTimestamp {
delete(uptime.HourlyStatistics, hourlyUnixTimestamp)
}
}
}
if result.Success {
// Recalculate uptime if at least one of the 1h, 24h or 7d uptime are not 100%
// If they're all 100%, then recalculating the uptime would be useless unless
// the result added was a failure (!result.Success)
if uptime.LastSevenDays != 1 || uptime.LastTwentyFourHours != 1 || uptime.LastHour != 1 {
uptime.recalculate()
}
} else {
// Recalculate uptime if at least one of the 1h, 24h or 7d uptime are not 0%
// If they're all 0%, then recalculating the uptime would be useless unless
// the result added was a success (result.Success)
if uptime.LastSevenDays != 0 || uptime.LastTwentyFourHours != 0 || uptime.LastHour != 0 {
uptime.recalculate()
}
}
}
func (uptime *Uptime) recalculate() {
uptimeBrackets := make(map[string]uint64)
now := time.Now()
// The oldest uptime bracket starts 7 days ago, so we'll start from there
timestamp := now.Add(-sevenDays)
for now.Sub(timestamp) >= 0 {
hourlyUnixTimestamp := timestamp.Unix() - (timestamp.Unix() % 3600)
hourlyStats := uptime.HourlyStatistics[hourlyUnixTimestamp]
if hourlyStats == nil || hourlyStats.TotalExecutions == 0 {
timestamp = timestamp.Add(time.Hour)
continue
}
uptimeBrackets["7d_success"] += hourlyStats.SuccessfulExecutions
uptimeBrackets["7d_total"] += hourlyStats.TotalExecutions
if now.Sub(timestamp) <= 24*time.Hour {
uptimeBrackets["24h_success"] += hourlyStats.SuccessfulExecutions
uptimeBrackets["24h_total"] += hourlyStats.TotalExecutions
}
if now.Sub(timestamp) <= time.Hour {
uptimeBrackets["1h_success"] += hourlyStats.SuccessfulExecutions
uptimeBrackets["1h_total"] += hourlyStats.TotalExecutions
}
timestamp = timestamp.Add(time.Hour)
}
if uptimeBrackets["7d_total"] > 0 {
uptime.LastSevenDays = float64(uptimeBrackets["7d_success"]) / float64(uptimeBrackets["7d_total"])
}
if uptimeBrackets["24h_total"] > 0 {
uptime.LastTwentyFourHours = float64(uptimeBrackets["24h_success"]) / float64(uptimeBrackets["24h_total"])
}
if uptimeBrackets["1h_total"] > 0 {
uptime.LastHour = float64(uptimeBrackets["1h_success"]) / float64(uptimeBrackets["1h_total"])
}
}
// XXX: Remove this on v3.0.0
// Deprecated
func (uptime *Uptime) migrateToHourlyStatistics() {
log.Println("[migrateToHourlyStatistics] Got", len(uptime.SuccessfulExecutionsPerHour), "entries for successful executions and", len(uptime.TotalExecutionsPerHour), "entries for total executions")
uptime.HourlyStatistics = make(map[int64]*HourlyUptimeStatistics)
for hourlyUnixTimestamp, totalExecutions := range uptime.TotalExecutionsPerHour {
if totalExecutions == 0 {
log.Println("[migrateToHourlyStatistics] Skipping entry at", hourlyUnixTimestamp, "because total number of executions is 0")
continue
}
uptime.HourlyStatistics[hourlyUnixTimestamp] = &HourlyUptimeStatistics{
TotalExecutions: totalExecutions,
SuccessfulExecutions: uptime.SuccessfulExecutionsPerHour[hourlyUnixTimestamp],
TotalExecutionsResponseTime: 0,
}
}
log.Println("[migrateToHourlyStatistics] Migrated", len(uptime.HourlyStatistics), "entries")
uptime.SuccessfulExecutionsPerHour = nil
uptime.TotalExecutionsPerHour = nil
}

View File

@@ -1,24 +0,0 @@
package core
import (
"testing"
"time"
)
func BenchmarkUptime_ProcessResult(b *testing.B) {
uptime := NewUptime()
now := time.Now()
now = time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())
// Start 12000 days ago
timestamp := now.Add(-12000 * 24 * time.Hour)
for n := 0; n < b.N; n++ {
uptime.ProcessResult(&Result{
Duration: 18 * time.Millisecond,
Success: n%15 == 0,
Timestamp: timestamp,
})
// Simulate service with an interval of 3 minutes
timestamp = timestamp.Add(3 * time.Minute)
}
b.ReportAllocs()
}

View File

@@ -1,96 +0,0 @@
package core
import (
"testing"
"time"
)
func TestUptime_ProcessResult(t *testing.T) {
service := &Service{Name: "name", Group: "group"}
serviceStatus := NewServiceStatus(service)
uptime := serviceStatus.Uptime
checkUptimes(t, serviceStatus, 0.00, 0.00, 0.00)
now := time.Now()
now = time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())
uptime.ProcessResult(&Result{Timestamp: now.Add(-7 * 24 * time.Hour), Success: true})
checkUptimes(t, serviceStatus, 1.00, 0.00, 0.00)
uptime.ProcessResult(&Result{Timestamp: now.Add(-6 * 24 * time.Hour), Success: false})
checkUptimes(t, serviceStatus, 0.50, 0.00, 0.00)
uptime.ProcessResult(&Result{Timestamp: now.Add(-8 * 24 * time.Hour), Success: true})
checkUptimes(t, serviceStatus, 0.50, 0.00, 0.00)
uptime.ProcessResult(&Result{Timestamp: now.Add(-24 * time.Hour), Success: true})
uptime.ProcessResult(&Result{Timestamp: now.Add(-12 * time.Hour), Success: true})
checkUptimes(t, serviceStatus, 0.75, 1.00, 0.00)
uptime.ProcessResult(&Result{Timestamp: now.Add(-1 * time.Hour), Success: true, Duration: 10 * time.Millisecond})
checkHourlyStatistics(t, uptime.HourlyStatistics[now.Unix()-now.Unix()%3600-3600], 10, 1, 1)
uptime.ProcessResult(&Result{Timestamp: now.Add(-30 * time.Minute), Success: false, Duration: 500 * time.Millisecond})
checkHourlyStatistics(t, uptime.HourlyStatistics[now.Unix()-now.Unix()%3600-3600], 510, 2, 1)
uptime.ProcessResult(&Result{Timestamp: now.Add(-15 * time.Minute), Success: false, Duration: 25 * time.Millisecond})
checkHourlyStatistics(t, uptime.HourlyStatistics[now.Unix()-now.Unix()%3600-3600], 535, 3, 1)
uptime.ProcessResult(&Result{Timestamp: now.Add(-10 * time.Minute), Success: false})
checkUptimes(t, serviceStatus, 0.50, 0.50, 0.25)
uptime.ProcessResult(&Result{Timestamp: now.Add(-120 * time.Hour), Success: true})
uptime.ProcessResult(&Result{Timestamp: now.Add(-119 * time.Hour), Success: true})
uptime.ProcessResult(&Result{Timestamp: now.Add(-118 * time.Hour), Success: true})
uptime.ProcessResult(&Result{Timestamp: now.Add(-117 * time.Hour), Success: true})
uptime.ProcessResult(&Result{Timestamp: now.Add(-10 * time.Hour), Success: true})
uptime.ProcessResult(&Result{Timestamp: now.Add(-8 * time.Hour), Success: true})
uptime.ProcessResult(&Result{Timestamp: now.Add(-30 * time.Minute), Success: true})
uptime.ProcessResult(&Result{Timestamp: now.Add(-25 * time.Minute), Success: true})
checkUptimes(t, serviceStatus, 0.75, 0.70, 0.50)
}
func TestServiceStatus_AddResultUptimeIsCleaningUpAfterItself(t *testing.T) {
service := &Service{Name: "name", Group: "group"}
serviceStatus := NewServiceStatus(service)
now := time.Now()
now = time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())
// Start 12 days ago
timestamp := now.Add(-12 * 24 * time.Hour)
for timestamp.Unix() <= now.Unix() {
serviceStatus.AddResult(&Result{Timestamp: timestamp, Success: true})
if len(serviceStatus.Uptime.HourlyStatistics) > numberOfHoursInTenDays {
t.Errorf("At no point in time should there be more than %d entries in serviceStatus.SuccessfulExecutionsPerHour, but there are %d", numberOfHoursInTenDays, len(serviceStatus.Uptime.HourlyStatistics))
}
if now.Sub(timestamp) > time.Hour && serviceStatus.Uptime.LastHour != 0 {
t.Error("most recent timestamp > 1h ago, expected serviceStatus.Uptime.LastHour to be 0, got", serviceStatus.Uptime.LastHour)
}
if now.Sub(timestamp) < time.Hour && serviceStatus.Uptime.LastHour == 0 {
t.Error("most recent timestamp < 1h ago, expected serviceStatus.Uptime.LastHour to NOT be 0, got", serviceStatus.Uptime.LastHour)
}
// Simulate service with an interval of 3 minutes
timestamp = timestamp.Add(3 * time.Minute)
}
}
func checkUptimes(t *testing.T, status *ServiceStatus, expectedUptimeDuringLastSevenDays, expectedUptimeDuringLastTwentyFourHours, expectedUptimeDuringLastHour float64) {
if status.Uptime.LastSevenDays != expectedUptimeDuringLastSevenDays {
t.Errorf("expected status.Uptime.LastSevenDays to be %f, got %f", expectedUptimeDuringLastHour, status.Uptime.LastSevenDays)
}
if status.Uptime.LastTwentyFourHours != expectedUptimeDuringLastTwentyFourHours {
t.Errorf("expected status.Uptime.LastTwentyFourHours to be %f, got %f", expectedUptimeDuringLastTwentyFourHours, status.Uptime.LastTwentyFourHours)
}
if status.Uptime.LastHour != expectedUptimeDuringLastHour {
t.Errorf("expected status.Uptime.LastHour to be %f, got %f", expectedUptimeDuringLastHour, status.Uptime.LastHour)
}
}
func checkHourlyStatistics(t *testing.T, hourlyUptimeStatistics *HourlyUptimeStatistics, expectedTotalExecutionsResponseTime uint64, expectedTotalExecutions uint64, expectedSuccessfulExecutions uint64) {
if hourlyUptimeStatistics.TotalExecutionsResponseTime != expectedTotalExecutionsResponseTime {
t.Error("TotalExecutionsResponseTime should've been", expectedTotalExecutionsResponseTime, "got", hourlyUptimeStatistics.TotalExecutionsResponseTime)
}
if hourlyUptimeStatistics.TotalExecutions != expectedTotalExecutions {
t.Error("TotalExecutions should've been", expectedTotalExecutions, "got", hourlyUptimeStatistics.TotalExecutions)
}
if hourlyUptimeStatistics.SuccessfulExecutions != expectedSuccessfulExecutions {
t.Error("SuccessfulExecutions should've been", expectedSuccessfulExecutions, "got", hourlyUptimeStatistics.SuccessfulExecutions)
}
}

View File

@@ -39,9 +39,9 @@ alerting:
You can now add alerts of type `pagerduty` in the services you've defined, like so:
```yaml
services:
- name: twinnation
- name: website
interval: 30s
url: "https://twinnation.org/health"
url: "https://twin.sh/health"
alerts:
- type: pagerduty
enabled: true
@@ -56,7 +56,7 @@ services:
```
The sample above will do the following:
- Send a request to the `https://twinnation.org/health` (`services[].url`) specified every **30s** (`services[].interval`)
- Send a request to the `https://twin.sh/health` (`services[].url`) specified every **30s** (`services[].interval`)
- Evaluate the conditions to determine whether the service is "healthy" or not
- **If all conditions are not met 3 (`services[].alerts[].failure-threshold`) times in a row**: Gatus will create a new incident
- **If, after an incident has been triggered, all conditions are met 5 (`services[].alerts[].success-threshold`) times in a row _AND_ `services[].alerts[].send-on-resolved` is set to `true`**: Gatus will resolve the triggered incident

View File

@@ -1,16 +0,0 @@
metrics: true
services:
- name: TwiNNatioN
url: https://twinnation.org/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

@@ -1,43 +0,0 @@
version: '3.7'
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

@@ -1,24 +0,0 @@
version: "3.8"
services:
gatus:
container_name: gatus
image: twinproduction/gatus:latest
ports:
- 8080:8080
volumes:
- ./config.yaml:/config/config.yaml
networks:
- default
mattermost:
container_name: mattermost
image: mattermost/mattermost-preview:5.26.0
ports:
- 8065:8065
networks:
- default
networks:
default:
driver: bridge

View File

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

View File

@@ -1,109 +0,0 @@
apiVersion: v1
data:
config.yaml: |
kubernetes:
cluster-mode: "in"
auto-discover: true
excluded-service-suffixes:
- canary
service-template:
interval: 30s
conditions:
- "[STATUS] == 200"
namespaces:
- name: default
hostname-suffix: ".default.svc.cluster.local"
target-path: "/health"
excluded-services:
- gatus
kind: ConfigMap
metadata:
name: gatus
namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: gatus
rules:
- apiGroups:
- ""
resources:
- services
verbs:
- list
- get
---
apiVersion: v1
automountServiceAccountToken: true
kind: ServiceAccount
metadata:
name: gatus
namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: gatus
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: gatus
subjects:
- kind: ServiceAccount
name: gatus
namespace: kube-system
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: gatus
namespace: kube-system
spec:
replicas: 1
selector:
matchLabels:
k8s-app: gatus
template:
metadata:
labels:
k8s-app: gatus
name: gatus
namespace: kube-system
spec:
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:
k8s-app: gatus

View File

@@ -1,87 +0,0 @@
apiVersion: v1
data:
config.yaml: |
metrics: true
services:
- name: TwiNNatioN
url: https://twinnation.org/health
interval: 1m
conditions:
- "[STATUS] == 200"
- 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"
kind: ConfigMap
metadata:
name: gatus
namespace: kube-system
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: gatus
namespace: kube-system
spec:
replicas: 1
selector:
matchLabels:
k8s-app: gatus
template:
metadata:
labels:
k8s-app: gatus
name: gatus
namespace: kube-system
spec:
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:
k8s-app: gatus

View File

@@ -0,0 +1,16 @@
metrics: true
services:
- 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

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