Compare commits

...
206 Commits
Author SHA1 Message Date
David WheatleyandGitHub e5a94979dd fix: add Google Chat to list of alert types when determining valid providers (#341) 2022-10-15 17:56:38 -04:00
TwiN 3c0ea72a5c ci: Bump docker/build-push-action to v3 2022-10-10 22:11:19 -04:00
TwiN d17e893131 docs: Uniformize number of newlines between each header 2022-10-10 22:05:48 -04:00
TwiN 7ea34ec8a8 docs: Add link for sponsoring below description 2022-10-10 21:49:55 -04:00
TwiNandGitHub f6b99f34db ci: Add missing concurrency.group parameter 2022-10-09 23:08:52 -04:00
TwiN 37495ac3f3 ci: Prevent publish-latest workflow from running concurrently 2022-10-09 23:02:05 -04:00
TwiN 557f696f88 fix(alerting): Encode messagebird request body using json.Marshal 2022-10-09 22:59:18 -04:00
TwiN c86492dbfd fix(alerting): Encode ntfy request body using json.Marshal
Relevant: #336
2022-10-09 22:58:18 -04:00
TwiN 8a4db600c9 test: Add tests for endpoint display name 2022-10-09 21:34:36 -04:00
TwiN 02879e2645 ci: Bump docker/build-push-action to v3 and add "stable" tag 2022-10-09 21:33:55 -04:00
TwiNandGitHub 00b56ecefd feat: Bundle assets in binary using go:embed (#340)
Fixes #47
2022-10-09 21:33:31 -04:00
TwiN 47dd18a0b5 test(alerting): Add coverage for ntfy's request body 2022-10-09 16:45:01 -04:00
TwiN 1a708ebca2 test(alerting): Fix tests following change to defaults 2022-10-09 16:45:01 -04:00
TwiN 5f8e62dad0 fix(alerting): Make priority and url optional for ntfy 2022-10-09 16:45:01 -04:00
TwiN b74f7758dc docs(alerting): Document how to configure ntfy alerts 2022-10-09 16:45:01 -04:00
TwiN 899c19b2d7 fix: Swap tag for resolved and triggered 2022-10-09 16:45:01 -04:00
TwiN 35038a63c4 feat(alerting): Implement ntfy provider
Closes #308

Work remaining:
- Add the documentation on the README.md
- Test it with an actual Ntfy instance (I've only used https://ntfy.sh/docs/examples/#gatus as a reference; I haven't actually tested it yet)
2022-10-09 16:45:01 -04:00
TwiN 7b2af3c514 chore: Fix alerting provider order 2022-10-09 16:45:01 -04:00
TwiN 4ab7428599 chore: Format code 2022-10-09 16:45:01 -04:00
TwiN be88af5d48 chore: Update Go to 1.19 + Update dependencies 2022-09-21 20:16:00 -04:00
TwiN 5bb3f6d0a9 refactor: Use %w instead of %s for formatting errors 2022-09-20 21:54:59 -04:00
TwiN 17c14a7243 docs(alerting): Provide better Matrix examples 2022-09-19 22:08:39 -04:00
TwiN f44d4055e6 refactor(alerting): Clean up Matrix code 2022-09-19 22:08:18 -04:00
TwiN 38054f57e5 feat: Set minimum interval for endpoints with [DOMAIN_EXPIRATION] to 5m 2022-09-15 21:23:14 -04:00
TwiN 33ce0e99b5 chore: Fix typo in deprecation message 2022-09-15 17:41:24 -04:00
TwiN b5e6466c1d docs(security): Link "Securing Gatus with OIDC using Auth0" article 2022-09-09 22:59:13 -04:00
TwiN f89ecd5c64 fix(ui): Decrease size of error message 2022-09-09 22:58:45 -04:00
TwiN e434178a5c test(alerting): Make sure ClientConfig is set after IsValid() call in Telegram provider 2022-09-07 19:02:30 -04:00
Lukas SchlöttererandGitHub 7a3ee1b557 feat(alerting): add client config for telegram (#324) 2022-09-07 18:50:59 -04:00
TwiN e51abaf5bd chore: Add check-domain-expiration to placeholder configuration file 2022-09-07 18:19:57 -04:00
TwiN 46d6d6c733 test(alerting): Improve coverage for custom alerting provider 2022-09-07 18:19:20 -04:00
TwiN d9f86f1155 fix(storage): Default domain_expiration to 0 for SQL when the column doesn't already exist
This will prevent temporary issues with the parsing of old results that would otherwise
have a value of NULL for domain_expiration

Fixes an issue introduced by #325
2022-09-07 18:18:26 -04:00
TwiNandGitHub 01484832fc feat: Add [DOMAIN_EXPIRATION] placeholder for monitoring domain expiration using WHOIS (#325)
* feat: Add [DOMAIN_EXPIRATION] placeholder for monitoring domain expiration using WHOIS

* test: Fix issue caused by possibility of millisecond elapsed during previous tests

* test: Fix test with different behavior based on architecture

* docs: Revert accidental change to starttls example

* docs: Fix mistake in comment for Condition.hasIPPlaceholder()
2022-09-06 21:22:02 -04:00
TwiN 4857b43771 test: Improve coverage for Endpoint.Type() 2022-09-01 21:12:29 -04:00
TwiN 52d7cb6f04 ux: Improve endpoint validation by checking type on start 2022-09-01 21:12:29 -04:00
TwiN 5c6bf84106 ux: Improve error message when endpoint is invalid 2022-09-01 21:12:29 -04:00
TwiN c84ae1cd55 refactor: Remove unused file 2022-09-01 21:12:29 -04:00
TwiN daf8e3a16f test(chart): Improve coverage for response time charts 2022-08-30 20:00:04 -04:00
TwiN df719958cf chore(remote): Log message about feature being a candidate for removal 2022-08-23 21:38:50 -04:00
TwiN 2be81b8e1a docs(remote): Add "Remote instances (EXPERIMENTAL)" section 2022-08-22 18:26:36 -04:00
TwiN 4bed86dec9 ui(event): Add divider between each event 2022-08-18 20:24:52 -04:00
TwiN 072cf20cc6 chore(dependencies): Update tailwindcss to 3.1.8 2022-08-18 20:24:08 -04:00
TwiN cca421e283 refactor(storage): Remove TODO comment about writeThroughCache 2022-08-18 19:29:39 -04:00
TwiN a044f1d274 docs(storage): Add documentation for storage.caching 2022-08-18 19:29:39 -04:00
TwiN 9de6334f21 feat(storage): Add optional write-through cache to sql store 2022-08-18 19:29:39 -04:00
TwiNandGitHub f01b66f083 refactor(storage): Remove decommissioned path for memory store (#313) 2022-08-11 20:42:56 -04:00
TwiN 262d436533 ci: Remove paths-ignore since workflow_run doesn't support it 2022-08-11 20:33:25 -04:00
TwiN b8ab17eee1 ci: Decrease timeout-minutes to 20 2022-08-11 20:31:36 -04:00
TwiN 7bbd7bcee3 ci: Stop publish-latest from being triggered by changes in .github folder 2022-08-11 20:29:33 -04:00
TwiN 4865d12147 chore(ci): Remove redundant input
Since we now get to pick from what branch to execute the workflow, it is no longer necessary to have a branch input.
2022-08-11 20:27:48 -04:00
TwiN 0713ca1c1a chore(ci): Remove useless "description" parameter 2022-08-11 20:25:10 -04:00
TwiN dce202d0be feat(ci): Add publish-experimental workflow 2022-08-11 20:24:23 -04:00
TwiN 4673d147db chore(ci): Update docker/login-action to v2 2022-08-11 20:17:17 -04:00
TwiN 0943c45ae6 test(badge): Add test cases for custom response-time badge thresholds 2022-08-10 21:26:36 -04:00
TwiN 798c4248ff refactor(badge): Fix formatting 2022-08-10 21:09:22 -04:00
1bce4e727e feat(api): Configurable response time badge thresholds (#309)
* recreated all changes for setting thresholds on Uptime Badges

* Suggestion accepted: Update core/ui/ui.go

Co-authored-by: TwiN <twin@linux.com>

* Suggestion accepted: Update core/ui/ui.go

Co-authored-by: TwiN <twin@linux.com>

* implemented final suggestions by Twin

* Update controller/handler/badge.go

* Update README.md

* test: added the suggestons to set the UiConfig at another line

Co-authored-by: TwiN <twin@linux.com>
2022-08-10 21:05:34 -04:00
TwiN 1aa94a3365 feat(remote): Implement lazy distributed feature (#64)
THIS IS AN EXPERIMENTAL FEATURE/IMPLEMENTATION, AND IT MAY BE REMOVED IN THE FUTURE.

Note that for now, it will be an undocumented feature.
2022-07-28 20:29:29 -04:00
TwiN 319f460553 docs: Update GetHTTPClient comment 2022-07-28 20:29:29 -04:00
TwiN 7daf2b5cac legal: Revert change to copyright appendix
relevant: #203
2022-07-28 17:55:23 -04:00
TwiNandGitHub f0fc275f67 legal: Update copyright dates 2022-07-28 09:20:42 -04:00
KalissaacandTwiN 04a682eddc style(alerting): Alphabetically sort Matrix provider 2022-07-20 19:00:12 -04:00
KalissaacandTwiN 2fb807632c style(alerting): Add comments and rename character bytes constant 2022-07-20 19:00:12 -04:00
KalissaacandTwiN 4b339bca37 fix(alerting): Update Matrix send endpoint to v3 2022-07-20 19:00:12 -04:00
KalissaacandTwiN 09c3a6c72b fix(alerting): Reuse MatrixProviderConfig struct 2022-07-20 19:00:12 -04:00
KalissaacandTwiN 755c8bb43a fix(alerting): Alphabetically sort Matrix provider 2022-07-20 19:00:12 -04:00
KalissaacandTwiN 9d4a639f31 test(alerting): Add Matrix tests 2022-07-20 19:00:12 -04:00
KalissaacandTwiN 60e6b2b039 docs(alerting): Add Matrix alerts to README 2022-07-20 19:00:12 -04:00
KalissaacandTwiN 37f3f964ea feat(alerts): Add Matrix alert provider 2022-07-20 19:00:12 -04:00
TwiN 4a1a8ff380 ci: Increase timeout-minutes to 60 2022-07-18 20:45:42 -04:00
TwiN 6787fed062 docs: Update feedback/question contact 2022-07-14 18:13:46 -04:00
TwiN ab2bee9c4b chore!: Update module from v3 to v4 2022-06-20 21:25:14 -04:00
TwiN d1ced94030 fix(badge): Regenerate assets and tweak health badge width 2022-06-20 14:27:05 -04:00
asymnessandGitHub a3e35c862c feat(badge): Implement UP/DOWN status badge (#291)
* Implement status badge endpoint

* Update integration tests for status badge generation

* Add status badge in the UI

* Update static assets

* Update README with status badge description

* Rename constants to pascal-case

* Check for success of the endpoint conditions

* Rename status badge to health badge
2022-06-20 13:59:45 -04:00
TwiN 0193a200b8 refactor(ci): Wrap benchmark workflow inputs with quotes 2022-06-19 22:37:19 -04:00
TwiN 7224464202 fix(ci): Set default repository to TwiN/gatus 2022-06-18 14:09:24 -04:00
TwiN c457aadcab feat(ci): Add benchmark workflow 2022-06-18 14:04:11 -04:00
TwiN f38b12d55b refactor(ci): Clean up steps 2022-06-18 13:06:24 -04:00
TwiN e4c9ad8796 chore(ci): Update actions/checkout to v3 2022-06-18 12:58:26 -04:00
TwiN 5be1465b13 refactor(ci): Uniformize job names 2022-06-18 12:50:31 -04:00
TwiN 7215aa4bd6 docs(metrics): Update Grafana/Prometheus example 2022-06-18 12:42:41 -04:00
TwiN 829a9c2679 fix(dns): Use Cloudflare's DNS instead of Google's DNS 2022-06-16 20:21:44 -04:00
TwiN dfcdc57a18 test(dns): Fix case with inconsistent results 2022-06-16 20:09:25 -04:00
TwiN 43e8c57701 test(dns): Fix case with inconsistent results 2022-06-16 20:02:46 -04:00
TwiN 076f5c45e8 test(metrics): Improve test coverage 2022-06-16 20:02:46 -04:00
TwiN 6d3c3d0892 refactor(metrics): Rename metric to metrics 2022-06-16 20:02:46 -04:00
TwiN e620fd1214 docs: List possible values for Result.DNSRCode 2022-06-16 20:02:46 -04:00
5807d76c2f feat(ui): Implement parameter to hide URL from results (#294)
* Add support for HideURL UI config parameter

* Redact whole URL when hide-url parameter is set to true

* Add integration test for hide-url functionality

* Document the hide-url config parameter in README

* Apply suggestions from code review

Co-authored-by: TwiN <twin@linux.com>

* Update test to have client config with 1ms timeout

* Re-align README tables

* Update core/endpoint_test.go

* Update core/endpoint_test.go

Co-authored-by: TwiN <twin@linux.com>
2022-06-16 17:53:03 -04:00
017847240d feat(alerting): Add overrides for Mattermost (#292)
* add override support for mattermost

* add documentation for override Mattermost webhooks

* Apply suggestions from code review

Co-authored-by: TwiN <twin@linux.com>

* fix formatting

Co-authored-by: Michael Engelhardt <me@mindcrime.dev>
Co-authored-by: TwiN <twin@linux.com>
2022-06-15 23:25:37 -04:00
TwiN c873b0ba0c feat(security)!: Remove deprecated SHA512 parameter for password hashing 2022-06-14 23:48:28 -04:00
TwiN 6f3150d936 feat(api)!: Remove deprecated paths 2022-06-14 23:40:26 -04:00
TwiN 0792f5490b feat(storage)!: Remove persistence for memory storage 2022-06-14 23:36:18 -04:00
TwiN 326ea1c3d1 refactor(client): Clean up client dns resolver 2022-06-13 20:35:51 -04:00
TwiN fea95b8479 perf(storage): Improve benchmarks and fix race condition 2022-06-13 20:35:51 -04:00
TwiN 6d64c3c250 chore: Reformat some code and docs 2022-06-12 19:18:58 -04:00
TwiN 2b9d3e99d3 refactor: Fix indent 2022-06-12 19:18:50 -04:00
TwiN 9a5f245440 chore(ui): Update dependencies and rebuild static assets 2022-06-12 19:18:50 -04:00
TwiN 793172c783 feat(ux): Display loading animation while waiting for data to be retrieved
Fixes #275
2022-06-12 19:18:50 -04:00
TwiN 9f343bacf7 chore(ui): Move prettifyTimeDifference to helper as generatePrettyTimeDifference 2022-06-12 19:18:50 -04:00
TwiN c31cb7540d fix(ui): Second shouldn't be plural if value is 1 2022-06-12 19:18:50 -04:00
TwiN f9efa28223 fix(ui): Set default refresh interval to 300 (5m) 2022-06-12 19:18:50 -04:00
Andre BindewaldandGitHub 2cbb35fe3b feat(client): Added client configuration option for using a custom DNS resolver (#284) 2022-06-12 18:45:08 -04:00
TwiN f23fcbedb8 docs: Specify what type of applications can be monitored with TCP 2022-06-12 16:32:08 -04:00
TwiN ad10f975b4 docs: Set example interval to 5m 2022-06-12 16:31:43 -04:00
TwiN 1c03524ca8 chore(alerting): Order types alphabetically 2022-06-12 14:18:18 -04:00
TwiN 4af135d1fb docs: Fix table formatting 2022-06-11 22:17:34 -04:00
TwiN 93b5a867bb chore(alerting): Add missing opsgenie compile-time interface validation 2022-06-07 19:43:23 -04:00
TwiN f899f41d16 feat(alerting): Add ENDPOINT_GROUP and ENDPOINT_URL placeholders for custom provider
related: #282

note: this also phases out the deprecated [SERVICE_NAME] placeholder
2022-06-07 19:37:42 -04:00
TwiN ab52676f23 build: Prevent Makefile test target from accidentally targeting test folder 2022-06-07 18:04:56 -04:00
mani9223-ossandGitHub 27fc784411 feat(alerting): Add group-specific WebHook URL for Slack (#279) 2022-05-30 22:03:09 -04:00
Chris GrindstaffandGitHub d929c09c56 docs(cert): list valid units for CERTIFICATE_EXPIRATION (#285)
Fixes #246
2022-05-29 15:14:25 -04:00
TwiN cff06e38cb docs(kubernetes): Add probes to example 2022-05-25 23:59:34 -04:00
TwiN 5b1aeaeb0c chore(test): Use io instead of io/ioutil 2022-05-16 22:19:42 -04:00
TwiN 90e9b55109 docs(metrics): Document available metrics 2022-05-16 22:18:38 -04:00
cf9c00a2ad feat(metrics): Add more metrics (#278)
* add gatus_results_success and gatus_results_duration_seconds

* add metrics namespace

* add result http metrics

* add more metrics

* update

* extract endpoint type method

* initializedMetrics

* remove too many metrics

* update naming

* chore(metrics): Refactor code and merge results_dns_return_code_total, results_http_status_code_total into results_code_total

* docs(metrics): Update results_certificate_expiration_seconds description

* add TestEndpoint_Type

* remove name in table test

Co-authored-by: TwiN <twin@linux.com>
2022-05-16 21:10:45 -04:00
TwiN fbdb5a3f0f test(maintenance): Add tests for edge cases 2022-05-07 16:46:51 -04:00
Bo-Yi WuandGitHub dde930bed7 feat(alerting): Add group-specific WebHook URL for Google Chat (#272) 2022-05-07 14:34:21 -04:00
TwiNandGitHub a9fc876173 docs: Update description 2022-04-28 17:56:02 -04:00
TwiN 08b31ba263 chore: Update frontend dependencies 2022-04-25 20:47:01 -04:00
TwiN 9ede992e4e feat(ui): Add support for buttons below header (#106) 2022-04-25 20:20:32 -04:00
TwiN dcb997f501 docs: Fix table format 2022-04-25 19:55:17 -04:00
TwiN c8efdac23a chore(ci): Update actions/setup-go to v3 2022-04-15 14:32:48 -04:00
e307d1ab35 feat(alerting): Add group-specific WebHook URL for Discord (#271)
* feat(alerting): Add group-specific webhook URL for discord

Add group-specific webhook URL for discord alert

Provides support for paging multiple Discords based on the group selector while keeping backward compatibility to the old Discords configuration manifest

integration per team can be specified in the overrides sections in an array form.

ref: #96

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>

* docs: update

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>

* Update README.md

* Update README.md

* Update alerting/provider/discord/discord.go

Co-authored-by: TwiN <twin@linux.com>

* Update README.md

Co-authored-by: TwiN <twin@linux.com>

* test: revert testing name

* Update alerting/provider/discord/discord_test.go

Co-authored-by: TwiN <twin@linux.com>

Co-authored-by: TwiN <twin@linux.com>
2022-04-11 20:30:21 -04:00
TwiN e6c6b4e06f chore: Update TwiN/health to v1.4.0 2022-04-11 01:39:47 -04:00
TwiN 5843c58a36 chore: Update Go to 1.18 2022-03-26 02:15:32 -04:00
Bo-Yi WuandGitHub 5281f8068d feat(alerting): Add group-specific webhook URL for teams (#266)
* feat(alert): Add group-specific webhook URL for teams

Add group-specific webhook URL for teams alert

ref: https://github.com/TwiN/gatus/issues/96

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>

* Update README.md

* Update README.md
2022-03-23 20:31:10 -04:00
TwiN 86d5dabf90 security: Pin dependency versions
Too many cases of open source projects in the JS ecosystem just going wild lately
2022-03-22 19:34:55 -04:00
Bo-Yi WuandGitHub a81c81e42c feat(alert): Add group-specific to email list (#264)
* feat(alert): Add group-specific to email list

Add group-specific to list for email alert

https://github.com/TwiN/gatus/issues/96

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>

* docs: update

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>

* Update README.md

* Update README.md

* Update README.md

* chore: update

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>

* Update README.md
2022-03-20 21:54:20 -04:00
Bo-Yi WuandGitHub bec2820969 docs(example): move config.yaml to config folder (#265)
ref: https://github.com/TwiN/gatus/issues/151#issuecomment-912932934

update all exmaple in docker-compose file.

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2022-03-20 00:04:13 -04:00
TwiN 0bf2271a73 test: Improve coverage for endpoint health evaluation edge cases (#262) 2022-03-15 20:53:03 -04:00
TwiN bd4b91bbbd fix: Display "<redacted>" instead of "host" in errors (#262) 2022-03-15 20:51:59 -04:00
Shashank DandGitHub fdec317df0 fix(config): replace hostname in error string if opted (#262) 2022-03-15 20:17:57 -04:00
TwiN 8970ad5ad5 refactor: Align new code from #259 with existing code 2022-03-09 21:05:57 -05:00
Andre BindewaldandGitHub c4255e65bc feat(client): OAuth2 Client credential support (#259)
* Initial implementation

* Added OAuth2 support to `client` config

* Revert "Initial implementation"

This reverts commit 7f2f3a603a.

* Restore vendored clientcredentials

* configureOAuth2 is now a func (including tests)

* README update

* Use the same OAuth2Config in all related tests

* Cleanup & comments
2022-03-09 20:53:51 -05:00
JonahandGitHub fcf046cbe8 feat(alerting): Add support for custom Telegram API URL (#257) 2022-03-05 15:44:11 -05:00
TwiN 6932edc6d0 docs: Fix Google Chat alerting configuration example 2022-02-14 20:03:08 -05:00
TwiN 3f961a7408 fix(ui): Prettify event timestamps
Closes #243
2022-02-03 20:16:13 -05:00
TwiN 4d0f3b6997 chore: Update Vue dependencies 2022-02-03 20:08:48 -05:00
TwiN 5a06599d96 chore: Update front-end dependencies 2022-01-30 18:10:39 -05:00
AzariaandGitHub d2a73a3590 chore: Fix grammatical issues in README (#241) 2022-01-23 20:06:55 -05:00
TwiN 932ecc436a test(security): Replace password-sha512 by password-bcrypt-base64 for test case 2022-01-17 11:55:05 -05:00
TwiN 1613274cb0 style(ui): Improve login UI design 2022-01-17 10:37:09 -05:00
TwiN 0b4720d94b build(gha): Increase timeout from 30 to 45 minutes 2022-01-16 23:26:24 -05:00
TwiN 16df341581 refactor: Remove unused function prettifyUptime 2022-01-16 22:02:58 -05:00
TwiN a848776a34 refactor(alerting): Sort alert types alphabetically 2022-01-16 00:07:19 -05:00
TwiN 681b1c63f1 docs: Fix broken Google Chat references 2022-01-16 00:06:03 -05:00
Kostiantyn PolischukandGitHub 51a4b63fb5 feat(alerting): Add Google Chat alerting provider (#234) 2022-01-14 21:00:00 -05:00
Khinshan KhanandGitHub 3a7977d086 build(docker): support all platforms that publish release supports (#238) 2022-01-13 21:37:25 -05:00
TwiN c682520dd9 fix(security): Use LRU eviction policy for OIDC sessions 2022-01-13 18:42:19 -05:00
TwiN 24b7258338 docs: Re-order parameters in Opsgenie and PagerDuty 2022-01-11 20:22:44 -05:00
TwiN 89e6e4abd8 fix(alerting): Omit nil structs within alerting provider struct 2022-01-11 20:13:37 -05:00
TwiN 4700f54798 docs: Remove outdated comment 2022-01-11 20:11:25 -05:00
TwiN 9ca4442e6a docs: Add missing section "Configuring Opsgenie alerts" 2022-01-11 20:10:06 -05:00
ce6f58f403 feat(alerting): Allow specifying a different username for email provider (#231)
* Update email alerting provider to supply a username, maintaining backwards compatibility with from

* Update README.md

Co-authored-by: Tom Moitié <tomm@gendius.co.uk>
Co-authored-by: TwiN <twin@twinnation.org>
2022-01-11 20:07:25 -05:00
TwiN c466542990 test(security): Add tests for basic auth with bcrypt 2022-01-08 19:59:45 -05:00
TwiN 9cb8c37298 fix(security): Make sure to panic on start if the security middlewares return an error 2022-01-08 19:59:45 -05:00
TwiN f6f7e15735 docs(security): Add warning about using a high cost for bcrypt 2022-01-08 19:59:45 -05:00
TwiN c712133df0 feat(security): Implement Bcrypt for basic auth and deprecate SHA512
I've re-written the code for this several times before but always ended up not going through with it because a hashed Bcrypt string has dollar signs in it, which caused issues with the config due to environment variable support.
I finally decided to go through with it by forcing users to base64 encode the bcrypt hash
2022-01-08 19:59:45 -05:00
TwiN fc016bd682 feat(ui): Make logo link and header configurable
Closes #213
2022-01-08 15:24:22 -05:00
TwiN 0e586e4152 refactor: Split direct and indirect modules 2022-01-06 23:24:07 -05:00
TwiN ea425773e0 style: Use SVG instead of PNG for GitHub's logo 2022-01-03 21:41:53 -05:00
TwiN 10949b11f4 fix(oidc): Default isAuthenticated to true, not false 2022-01-02 21:37:15 -05:00
TwiN 0e022d04b1 chore: Fix inconsistent indentation + Rebuild assets 2022-01-02 21:14:01 -05:00
TwiN 3319e158b5 branding: Update logo 2022-01-02 21:14:01 -05:00
TwiN f467a77ae2 chore: Upgrade Tailwind from v2 to v3 2022-01-02 21:14:01 -05:00
TwiN 56048725e4 oidc: Display error if applicable 2022-01-02 21:14:01 -05:00
TwiN 425c1d3674 oidc: Add /api/v1/config route for determining whether to display a login button on the UI 2022-01-02 21:14:01 -05:00
TwiN 8838f6f2ad oidc: Add documentation 2022-01-02 21:14:01 -05:00
TwiN 139a78b2f6 oidc: Log when a subject tries to authenticate but isn't allowed 2022-01-02 21:14:01 -05:00
TwiN dd5e3ee7ee Add tests for OIDC 2022-01-02 21:14:01 -05:00
TwiN 9f8f7bb45e Fix codecov configuration 2022-01-02 21:14:01 -05:00
TwiN 27e246859e Use code coverage target instead of threshold 2022-01-02 21:14:01 -05:00
TwiN f1688ac87a Set code coverage threshold to 75% 2022-01-02 21:14:01 -05:00
TwiN 54779e1db8 Fix basic security tests 2022-01-02 21:14:01 -05:00
TwiN be9087bee3 #205: Work on supporting OpenID Connect for auth 2022-01-02 21:14:01 -05:00
TwiN 4ab5724fc1 Default to dark theme 2022-01-02 21:14:01 -05:00
TwiN 45a47940ad #205: Start working on adding support for IODC 2022-01-02 21:14:01 -05:00
TwiN 1777d69495 Remove unnecessary check 2021-12-14 23:26:18 -05:00
Bastien OgierandTwiN 8676b83fe3 Changed: add maintenance to README on root config table 2021-12-14 19:55:40 -05:00
TwiN b67701ff6d BREAKING: Replace metrics 2021-12-13 19:38:20 -05:00
TwiN eb9acef9b5 Minor update 2021-12-13 19:38:20 -05:00
TwiN 00aec70fb8 Update dependencies 2021-12-13 19:38:20 -05:00
TwiN 18d28fc362 Add tests for validation of description 2021-12-12 17:03:11 -05:00
TwiN eb3545e994 Add tests for Slack alert body with endpoint that has a group 2021-12-12 17:03:11 -05:00
TwiN ad71c8db34 Support displaying group name when applicable for every alert provider 2021-12-12 17:03:11 -05:00
TwiN 6da281bf4e Disallow certain characters in endpoint name, group and alert description 2021-12-12 17:03:11 -05:00
TwiN 3dd8ba1a99 Add missing alert type 2021-12-09 21:18:44 -05:00
TwiN 2503d21522 Refactor Opsgenie alerting provider code 2021-12-09 21:18:25 -05:00
TwiN 36a3419aec Update .gitignore 2021-12-09 20:40:28 -05:00
Vinicius ReisandGitHub 7353fad809 Opsgenie Alert Provider (#214)
*  opsgenie alert provider

*  add unit tests

* ✏️ typofix

* 📝 update readme

*  add details

*  use group to previne colisions

* ✏️ typofix

* ✏️ typofix
2021-12-09 20:32:38 -05:00
TwiNandGitHub b5a26caa08 Merge pull request #217 from ianagbip1oti/patch-3
Grammatical Fix
2021-12-09 01:19:54 -05:00
Princess LanaandGitHub d7206546af Grammatical Fix 2021-12-08 22:16:06 -08:00
TwiNandGitHub 4fa86a2c46 Merge pull request #216 from ianagbip1oti/patch-2
Grammatical Fix
2021-12-09 01:14:51 -05:00
Princess LanaandGitHub a6ed23b169 Grammatical Fix 2021-12-08 22:13:12 -08:00
TwiNandGitHub d9201c5084 Merge pull request #215 from ianagbip1oti/patch-1
Grammatical fix
2021-12-09 01:08:27 -05:00
Princess LanaandGitHub d0ba8261e3 Grammatical fix 2021-12-08 22:07:04 -08:00
TwiNandGitHub f89447badc Merge pull request #211 from Juneezee/deprecate-ioutil
refactor: move from io/ioutil to io and os packages
2021-12-03 20:31:31 -05:00
TwiN 14c42f6e6d Fix #212: Stale endpoint on dashboard after config change 2021-12-03 18:20:14 -05:00
Eng Zer Jun 7a05bdcb82 refactor: move from io/ioutil to io and os packages
The io/ioutil package has been deprecated as of Go 1.16, see
https://golang.org/doc/go1.16#ioutil. This commit replaces the existing
io/ioutil functions with their new definitions in io and os packages.

Signed-off-by: Eng Zer Jun <engzerjun@gmail.com>
2021-12-03 14:44:17 +08:00
TwiNandGitHub 5eb7763052 Implement email alerts 2021-12-02 23:31:14 -05:00
TwiN 8c73ae6035 Fix #22: Improve alerting provider tests by mocking HTTP client 2021-12-02 23:10:21 -05:00
TwiN 6954e9dde7 Move alerting provider mocked tests at the watchdog level 2021-12-02 22:15:51 -05:00
TwiN f6336eac4e Fix #117: Implement email alerts 2021-12-02 21:05:17 -05:00
TwiN 0331c18401 Make sure cache is disabled for badges and charts 2021-11-24 18:36:31 -05:00
TwiN 1f8fd29dad Get rid of Discord link
I don't have time to manage a community 😩
2021-11-23 19:18:29 -05:00
TwiN 5877c4b2be Update TwiN/health to v1.3.0 2021-11-19 23:43:24 -05:00
TwiN c6d0809ecc Hide Alert.ResolveKey and Alert.Triggered 2021-11-18 00:11:32 -05:00
374 changed files with 45258 additions and 16049 deletions
@@ -0,0 +1,41 @@
## Usage
Gatus exposes Prometheus metrics at `/metrics` if the `metrics` configuration option is set to `true`.
To run this example, all you need to do is execute the following command:
```console
docker-compose up
```
Once you've done the above, you should be able to access the Grafana dashboard at `http://localhost:3000`.
![Gatus Grafana dashboard](../../.github/assets/grafana-dashboard.png)
## Queries
By default, this example has a Grafana dashboard with some panels, but for the sake of verbosity, you'll find
a list of simple queries below. Those make use of the `key` parameter, which is a concatenation of the endpoint's
group and name.
### Success rate
```
sum(rate(gatus_results_total{success="true"}[30s])) by (key) / sum(rate(gatus_results_total[30s])) by (key)
```
### Response time
```
gatus_results_duration_seconds
```
### Total results per minute
```
sum(rate(gatus_results_total[5m])*60) by (key)
```
### Total successful results per minute
```
sum(rate(gatus_results_total{success="true"}[5m])*60) by (key)
```
### Total unsuccessful results per minute
```
sum(rate(gatus_results_total{success="false"}[5m])*60) by (key)
```
@@ -2,15 +2,18 @@ metrics: true
endpoints: endpoints:
- name: website - name: website
url: https://twin.sh/health url: https://twin.sh/health
interval: 30s interval: 5m
conditions: conditions:
- "[STATUS] == 200" - "[STATUS] == 200"
- name: example
url: https://example.com/
interval: 5m
conditions:
- "[STATUS] == 200"
- name: github - name: github
url: https://api.github.com/healthz url: https://api.github.com/healthz
interval: 5m interval: 5m
conditions: conditions:
- "[STATUS] == 200" - "[STATUS] == 200"
- name: example
url: https://example.com/
conditions:
- "[STATUS] == 200"
@@ -7,7 +7,7 @@ services:
ports: ports:
- "8080:8080" - "8080:8080"
volumes: volumes:
- ./config.yaml:/config/config.yaml - ./config:/config
networks: networks:
- metrics - metrics
@@ -0,0 +1,582 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"gnetId": null,
"graphTooltip": 0,
"id": 3,
"links": [],
"panels": [
{
"cacheTimeout": null,
"datasource": null,
"description": "Number of successful results compared to the total number of results during the current interval",
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"id": 9,
"links": [],
"options": {
"fieldOptions": {
"calcs": [
"mean"
],
"defaults": {
"mappings": [
{
"id": 0,
"op": "=",
"text": "N/A",
"type": 1,
"value": "null"
}
],
"max": 1,
"min": 0,
"nullValueMode": "connected",
"thresholds": [
{
"color": "red",
"value": null
},
{
"color": "semi-dark-orange",
"value": 0.6
},
{
"color": "yellow",
"value": 0.8
},
{
"color": "dark-green",
"value": 0.95
}
],
"unit": "percentunit"
},
"override": {},
"values": false
},
"orientation": "horizontal",
"showThresholdLabels": false,
"showThresholdMarkers": false
},
"pluginVersion": "6.4.4",
"targets": [
{
"expr": "sum(rate(gatus_results_total{success=\"true\"}[30s])) by (key) / sum(rate(gatus_results_total[30s])) by (key)",
"hide": false,
"legendFormat": "{{key}}",
"refId": "B"
}
],
"timeFrom": null,
"timeShift": null,
"title": "Success rate",
"type": "gauge"
},
{
"aliasColors": {},
"bars": false,
"cacheTimeout": null,
"dashLength": 10,
"dashes": false,
"datasource": null,
"fill": 1,
"fillGradient": 0,
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 0
},
"id": 11,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 1,
"links": [],
"nullPointMode": "null as zero",
"options": {
"dataLinks": []
},
"percentage": false,
"pluginVersion": "6.4.4",
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "gatus_results_duration_seconds",
"format": "time_series",
"instant": false,
"interval": "",
"intervalFactor": 1,
"legendFormat": "{{key}}",
"refId": "A"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Response time",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"aliasColors": {},
"bars": false,
"cacheTimeout": null,
"dashLength": 10,
"dashes": false,
"datasource": null,
"fill": 1,
"fillGradient": 0,
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 8
},
"id": 10,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 1,
"links": [],
"nullPointMode": "connected",
"options": {
"dataLinks": []
},
"percentage": false,
"pluginVersion": "6.4.4",
"pointradius": 2,
"points": true,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "sum(rate(gatus_results_total{success=\"true\"}[30s])) by (key) / sum(rate(gatus_results_total[30s])) by (key)",
"format": "time_series",
"instant": false,
"interval": "",
"intervalFactor": 1,
"legendFormat": "{{key}}",
"refId": "A"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Success rate",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": null,
"description": "Number of results per minute",
"fill": 1,
"fillGradient": 0,
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 8
},
"id": 2,
"interval": "",
"legend": {
"alignAsTable": false,
"avg": false,
"current": false,
"hideEmpty": false,
"hideZero": false,
"max": false,
"min": false,
"rightSide": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 1,
"nullPointMode": "null",
"options": {
"dataLinks": []
},
"percentage": false,
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "sum(rate(gatus_results_total[5m])*60) by (key)",
"format": "time_series",
"hide": false,
"instant": false,
"interval": "30s",
"intervalFactor": 1,
"legendFormat": "{{key}}",
"refId": "A"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Total results per minute",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"decimals": null,
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": null,
"fill": 1,
"fillGradient": 0,
"gridPos": {
"h": 7,
"w": 12,
"x": 0,
"y": 16
},
"id": 5,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 1,
"nullPointMode": "null",
"options": {
"dataLinks": []
},
"percentage": false,
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "sum(rate(gatus_results_total{success=\"true\"}[5m])*60) by (key)",
"instant": false,
"interval": "30s",
"legendFormat": "{{key}}",
"refId": "A"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Successful results per minute",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"decimals": null,
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": null,
"fill": 1,
"fillGradient": 0,
"gridPos": {
"h": 7,
"w": 12,
"x": 12,
"y": 16
},
"id": 3,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 1,
"nullPointMode": "null",
"options": {
"dataLinks": []
},
"percentage": false,
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "sum(rate(gatus_results_total{success=\"false\"}[5m])*60) by (key)",
"interval": "30s",
"legendFormat": "{{key}} ",
"refId": "A"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Unsuccessful results per minute",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
}
],
"refresh": "1m",
"schemaVersion": 20,
"style": "dark",
"tags": [],
"templating": {
"list": []
},
"time": {
"from": "now-1h",
"to": "now"
},
"timepicker": {
"refresh_intervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
]
},
"timezone": "",
"title": "Gatus",
"uid": "KPI7Qj1Wk",
"version": 2
}
@@ -1,300 +0,0 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"gnetId": null,
"graphTooltip": 0,
"id": 2,
"links": [],
"panels": [
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": null,
"fill": 1,
"fillGradient": 0,
"gridPos": {
"h": 14,
"w": 12,
"x": 0,
"y": 0
},
"id": 2,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 1,
"nullPointMode": "null",
"options": {
"dataLinks": []
},
"percentage": false,
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "sum(rate(gatus_tasks[30s])) by (endpoint)",
"interval": "30s",
"legendFormat": "{{endpoint}}",
"refId": "A"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "All tasks executed over time",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": null,
"fill": 1,
"fillGradient": 0,
"gridPos": {
"h": 7,
"w": 12,
"x": 12,
"y": 0
},
"id": 3,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 1,
"nullPointMode": "null",
"options": {
"dataLinks": []
},
"percentage": false,
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "sum(rate(gatus_tasks{success=\"false\"}[30s])) by (endpoint)",
"interval": "30s",
"legendFormat": "{{endpoint}}",
"refId": "A"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Unsuccessful tasks",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": null,
"fill": 1,
"fillGradient": 0,
"gridPos": {
"h": 7,
"w": 12,
"x": 12,
"y": 7
},
"id": 5,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 1,
"nullPointMode": "null",
"options": {
"dataLinks": []
},
"percentage": false,
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "sum(rate(gatus_tasks{success=\"true\"}[30s])) by (endpoint)",
"instant": false,
"interval": "30s",
"legendFormat": "{{endpoint}}",
"refId": "A"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Successful tasks",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
}
],
"refresh": "10s",
"schemaVersion": 20,
"style": "dark",
"tags": [],
"templating": {
"list": []
},
"time": {
"from": "now-30m",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "Gatus",
"uid": "KPI7Qj1Wk",
"version": 3
}
@@ -6,7 +6,7 @@ services:
ports: ports:
- "8080:8080" - "8080:8080"
volumes: volumes:
- ./config.yaml:/config/config.yaml - ./config:/config
networks: networks:
- default - default
@@ -26,7 +26,7 @@ endpoints:
- "[STATUS] == 200" - "[STATUS] == 200"
- name: example-dns-query - name: example-dns-query
url: "8.8.8.8" # Address of the DNS server to use url: "1.1.1.1" # Address of the DNS server to use
interval: 5m interval: 5m
dns: dns:
query-name: "example.com" query-name: "example.com"
@@ -19,7 +19,7 @@ services:
ports: ports:
- "8080:8080" - "8080:8080"
volumes: volumes:
- ./config.yaml:/config/config.yaml - ./config:/config
networks: networks:
- web - web
depends_on: depends_on:
@@ -26,7 +26,7 @@ endpoints:
- "[STATUS] == 200" - "[STATUS] == 200"
- name: example-dns-query - name: example-dns-query
url: "8.8.8.8" # Address of the DNS server to use url: "1.1.1.1" # Address of the DNS server to use
interval: 5m interval: 5m
dns: dns:
query-name: "example.com" query-name: "example.com"
@@ -5,5 +5,5 @@ services:
ports: ports:
- "8080:8080" - "8080:8080"
volumes: volumes:
- ./config.yaml:/config/config.yaml - ./config:/config
- ./data:/data/ - ./data:/data/
+1 -1
View File
@@ -5,4 +5,4 @@ services:
ports: ports:
- 8080:8080 - 8080:8080
volumes: volumes:
- ./config.yaml:/config/config.yaml - ./config:/config
+18 -2
View File
@@ -54,10 +54,10 @@ spec:
app: gatus app: gatus
template: template:
metadata: metadata:
labels:
app: gatus
name: gatus name: gatus
namespace: kube-system namespace: kube-system
labels:
app: gatus
spec: spec:
serviceAccountName: gatus serviceAccountName: gatus
terminationGracePeriodSeconds: 5 terminationGracePeriodSeconds: 5
@@ -76,6 +76,22 @@ spec:
requests: requests:
cpu: 50m cpu: 50m
memory: 30M memory: 30M
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
successThreshold: 1
failureThreshold: 3
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
successThreshold: 1
failureThreshold: 5
volumeMounts: volumeMounts:
- mountPath: /config - mountPath: /config
name: gatus-config name: gatus-config
Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 17 KiB

+5
View File
@@ -5,3 +5,8 @@ ignore:
coverage: coverage:
status: status:
patch: off patch: off
project:
default:
target: 75%
threshold: null
+26
View File
@@ -0,0 +1,26 @@
name: benchmark
on:
workflow_dispatch:
inputs:
repository:
description: "Repository to checkout. Useful for benchmarking a fork. Format should be <owner>/<repository>."
required: true
default: "TwiN/gatus"
ref:
description: "Branch, tag or SHA to checkout"
required: true
default: "master"
jobs:
build:
name: benchmark
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/setup-go@v3
with:
go-version: 1.18
repository: "${{ github.event.inputs.repository }}"
ref: "${{ github.event.inputs.ref }}"
- uses: actions/checkout@v3
- name: Benchmark
run: go test -bench=. ./storage/store
+4 -6
View File
@@ -10,16 +10,14 @@ on:
- '*.md' - '*.md'
jobs: jobs:
build: build:
name: Build name: build
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 5 timeout-minutes: 5
steps: steps:
- name: Set up Go - uses: actions/setup-go@v3
uses: actions/setup-go@v2
with: with:
go-version: 1.17 go-version: 1.18
- name: Check out code into the Go module directory - uses: actions/checkout@v3
uses: actions/checkout@v2
- name: Build binary to make sure it works - name: Build binary to make sure it works
run: go build -mod vendor run: go build -mod vendor
- name: Test - name: Test
@@ -0,0 +1,27 @@
name: publish-experimental
on: [workflow_dispatch]
jobs:
publish-experimental:
name: publish-experimental
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v3
- name: Get image repository
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Login to Docker Registry
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push docker image
uses: docker/build-push-action@v3
with:
platforms: linux/amd64
pull: true
push: true
tags: ${{ env.IMAGE_REPOSITORY }}:experimental
+10 -9
View File
@@ -4,15 +4,17 @@ on:
workflows: ["build"] workflows: ["build"]
branches: [master] branches: [master]
types: [completed] types: [completed]
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
jobs: jobs:
publish-latest: publish-latest:
name: Publish latest name: publish-latest
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }} if: ${{ github.event.workflow_run.conclusion == 'success' }}
timeout-minutes: 30 timeout-minutes: 60
steps: steps:
- name: Check out code - uses: actions/checkout@v3
uses: actions/checkout@v2
- name: Get image repository - name: Get image repository
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Set up QEMU - name: Set up QEMU
@@ -20,15 +22,14 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1 uses: docker/setup-buildx-action@v1
- name: Login to Docker Registry - name: Login to Docker Registry
uses: docker/login-action@v1 uses: docker/login-action@v2
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push docker image - name: Build and push docker image
uses: docker/build-push-action@v2 uses: docker/build-push-action@v3
with: with:
platforms: linux/amd64 platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
pull: true pull: true
push: true push: true
tags: | tags: ${{ env.IMAGE_REPOSITORY }}:latest
${{ env.IMAGE_REPOSITORY }}:latest
+6 -8
View File
@@ -4,12 +4,11 @@ on:
types: [published] types: [published]
jobs: jobs:
publish-release: publish-release:
name: Publish release name: publish-release
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 60
steps: steps:
- name: Check out code - uses: actions/checkout@v3
uses: actions/checkout@v2
- name: Get image repository - name: Get image repository
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Get the release - name: Get the release
@@ -19,15 +18,14 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1 uses: docker/setup-buildx-action@v1
- name: Login to Docker Registry - name: Login to Docker Registry
uses: docker/login-action@v1 uses: docker/login-action@v2
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push docker image - name: Build and push docker image
uses: docker/build-push-action@v2 uses: docker/build-push-action@v3
with: with:
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64 platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
pull: true pull: true
push: true push: true
tags: | tags: ${{ env.IMAGE_REPOSITORY }}:${{ env.RELEASE }},${{ env.IMAGE_REPOSITORY }}:stable
${{ env.IMAGE_REPOSITORY }}:${{ env.RELEASE }}
+3 -4
View File
@@ -1,8 +1,7 @@
.idea .idea
.vscode .vscode
*.db
*.db-shm
*.db-wal
gatus gatus
db.db
config/config.yml config/config.yml
db.db-shm
db.db-wal
memory.db
-1
View File
@@ -13,7 +13,6 @@ RUN CGO_ENABLED=0 GOOS=linux go build -mod vendor -a -installsuffix cgo -o gatus
FROM scratch FROM scratch
COPY --from=builder /app/gatus . COPY --from=builder /app/gatus .
COPY --from=builder /app/config.yaml ./config/config.yaml COPY --from=builder /app/config.yaml ./config/config.yaml
COPY --from=builder /app/web/static ./web/static
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
ENV PORT=8080 ENV PORT=8080
EXPOSE ${PORT} EXPOSE ${PORT}
+1 -1
View File
@@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier same "printed page" as the copyright notice for easier
identification within third-party archives. identification within third-party archives.
Copyright 2021 TwiN Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
+4 -1
View File
@@ -1,5 +1,8 @@
BINARY=gatus BINARY=gatus
# Because there's a folder called "test", we need to make the target "test" phony
.PHONY: test
install: install:
go build -mod vendor -o $(BINARY) . go build -mod vendor -o $(BINARY) .
@@ -10,7 +13,7 @@ clean:
rm $(BINARY) rm $(BINARY)
test: test:
sudo go test ./alerting/... ./client/... ./config/... ./controller/... ./core/... ./jsonpath/... ./pattern/... ./security/... ./storage/... ./util/... ./watchdog/... -cover go test ./... -cover
########## ##########
+500 -80
View File
@@ -1,14 +1,13 @@
![Gatus](.github/assets/logo-with-name.png) ![Gatus](.github/assets/logo-with-dark-text.png)
![build](https://github.com/TwiN/gatus/workflows/build/badge.svg?branch=master) ![build](https://github.com/TwiN/gatus/workflows/build/badge.svg?branch=master)
[![Go Report Card](https://goreportcard.com/badge/github.com/TwiN/gatus?)](https://goreportcard.com/report/github.com/TwiN/gatus) [![Go Report Card](https://goreportcard.com/badge/github.com/TwiN/gatus?)](https://goreportcard.com/report/github.com/TwiN/gatus)
[![codecov](https://codecov.io/gh/TwiN/gatus/branch/master/graph/badge.svg)](https://codecov.io/gh/TwiN/gatus) [![codecov](https://codecov.io/gh/TwiN/gatus/branch/master/graph/badge.svg)](https://codecov.io/gh/TwiN/gatus)
[![Go version](https://img.shields.io/github/go-mod/go-version/TwiN/gatus.svg)](https://github.com/TwiN/gatus) [![Go version](https://img.shields.io/github/go-mod/go-version/TwiN/gatus.svg)](https://github.com/TwiN/gatus)
[![Docker pulls](https://img.shields.io/docker/pulls/twinproduction/gatus.svg)](https://cloud.docker.com/repository/docker/twinproduction/gatus) [![Docker pulls](https://img.shields.io/docker/pulls/twinproduction/gatus.svg)](https://cloud.docker.com/repository/docker/twinproduction/gatus)
[![Join Discord server](https://img.shields.io/discord/442432928614449155.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/TDfjeHmXDQ)
[![Follow TwiN](https://img.shields.io/github/followers/TwiN?label=Follow&style=social)](https://github.com/TwiN) [![Follow TwiN](https://img.shields.io/github/followers/TwiN?label=Follow&style=social)](https://github.com/TwiN)
Gatus is a health dashboard that gives you the ability to monitor your services using HTTP, ICMP, TCP, and even DNS Gatus is a developer-oriented health dashboard that gives you the ability to monitor your services using HTTP, ICMP, TCP, and even DNS
queries as well as evaluate the result of said queries by using a list of conditions on values like the status code, queries as well as evaluate the result of said queries by using a list of conditions on values like the status code,
the response time, the certificate expiration, the body and many others. The icing on top is that each of these health the response time, the certificate expiration, the body and many others. The icing on top is that each of these health
checks can be paired with alerting via Slack, PagerDuty, Discord, Twilio and more. checks can be paired with alerting via Slack, PagerDuty, Discord, Twilio and more.
@@ -27,7 +26,10 @@ For more details, see [Usage](#usage)
![Gatus dashboard conditions](.github/assets/dashboard-conditions.png) ![Gatus dashboard conditions](.github/assets/dashboard-conditions.png)
Have any feedback or want to share your good/bad experience with Gatus? Feel free to email me at [feedback@gatus.io](mailto:feedback@gatus.io) Have any feedback or questions? [Create a discussion](https://github.com/TwiN/gatus/discussions/new).
Like this project? Please consider [sponsoring me](https://github.com/sponsors/TwiN).
## Table of Contents ## Table of Contents
- [Why Gatus?](#why-gatus) - [Why Gatus?](#why-gatus)
@@ -41,8 +43,13 @@ Have any feedback or want to share your good/bad experience with Gatus? Feel fre
- [Client configuration](#client-configuration) - [Client configuration](#client-configuration)
- [Alerting](#alerting) - [Alerting](#alerting)
- [Configuring Discord alerts](#configuring-discord-alerts) - [Configuring Discord alerts](#configuring-discord-alerts)
- [Configuring Email alerts](#configuring-email-alerts)
- [Configuring Google Chat alerts](#configuring-google-chat-alerts)
- [Configuring Matrix alerts](#configuring-matrix-alerts)
- [Configuring Mattermost alerts](#configuring-mattermost-alerts) - [Configuring Mattermost alerts](#configuring-mattermost-alerts)
- [Configuring Messagebird alerts](#configuring-messagebird-alerts) - [Configuring Messagebird alerts](#configuring-messagebird-alerts)
- [Configuring Ntfy alerts](#configuring-ntfy-alerts)
- [Configuring Opsgenie alerts](#configuring-opsgenie-alerts)
- [Configuring PagerDuty alerts](#configuring-pagerduty-alerts) - [Configuring PagerDuty alerts](#configuring-pagerduty-alerts)
- [Configuring Slack alerts](#configuring-slack-alerts) - [Configuring Slack alerts](#configuring-slack-alerts)
- [Configuring Teams alerts](#configuring-teams-alerts) - [Configuring Teams alerts](#configuring-teams-alerts)
@@ -51,6 +58,11 @@ Have any feedback or want to share your good/bad experience with Gatus? Feel fre
- [Configuring custom alerts](#configuring-custom-alerts) - [Configuring custom alerts](#configuring-custom-alerts)
- [Setting a default alert](#setting-a-default-alert) - [Setting a default alert](#setting-a-default-alert)
- [Maintenance](#maintenance) - [Maintenance](#maintenance)
- [Security](#security)
- [Basic](#basic)
- [OIDC](#oidc)
- [Metrics](#metrics)
- [Remote instances (EXPERIMENTAL)](#remote-instances-experimental)
- [Deployment](#deployment) - [Deployment](#deployment)
- [Docker](#docker) - [Docker](#docker)
- [Helm Chart](#helm-chart) - [Helm Chart](#helm-chart)
@@ -66,18 +78,20 @@ Have any feedback or want to share your good/bad experience with Gatus? Feel fre
- [Monitoring an endpoint using DNS queries](#monitoring-an-endpoint-using-dns-queries) - [Monitoring an endpoint using DNS queries](#monitoring-an-endpoint-using-dns-queries)
- [Monitoring an endpoint using STARTTLS](#monitoring-an-endpoint-using-starttls) - [Monitoring an endpoint using STARTTLS](#monitoring-an-endpoint-using-starttls)
- [Monitoring an endpoint using TLS](#monitoring-an-endpoint-using-tls) - [Monitoring an endpoint using TLS](#monitoring-an-endpoint-using-tls)
- [Basic authentication](#basic-authentication) - [Monitoring domain expiration](#monitoring-domain-expiration)
- [disable-monitoring-lock](#disable-monitoring-lock) - [disable-monitoring-lock](#disable-monitoring-lock)
- [Reloading configuration on the fly](#reloading-configuration-on-the-fly) - [Reloading configuration on the fly](#reloading-configuration-on-the-fly)
- [Endpoint groups](#endpoint-groups) - [Endpoint groups](#endpoint-groups)
- [Exposing Gatus on a custom port](#exposing-gatus-on-a-custom-port) - [Exposing Gatus on a custom port](#exposing-gatus-on-a-custom-port)
- [Badges](#badges) - [Badges](#badges)
- [Uptime](#uptime) - [Uptime](#uptime)
- [Health](#health)
- [Response time](#response-time) - [Response time](#response-time)
- [API](#api) - [API](#api)
- [High level design overview](#high-level-design-overview) - [High level design overview](#high-level-design-overview)
- [Sponsors](#sponsors) - [Sponsors](#sponsors)
## Why Gatus? ## Why Gatus?
Before getting into the specifics, I want to address the most common question: Before getting into the specifics, I want to address the most common question:
> Why would I use Gatus when I can just use Prometheus Alertmanager, Cloudwatch or even Splunk? > Why would I use Gatus when I can just use Prometheus Alertmanager, Cloudwatch or even Splunk?
@@ -90,8 +104,8 @@ Gatus, on the other hand, allows you to configure health checks for each of your
monitor these features and potentially alert you before any clients are impacted. monitor these features and potentially alert you before any clients are impacted.
A sign you may want to look into Gatus is by simply asking yourself whether you'd receive an alert if your load balancer A sign you may want to look into Gatus is by simply asking yourself whether you'd receive an alert if your load balancer
was to go down right now. Will any of your existing alerts by triggered? Your metrics wont report an increase in errors was to go down right now. Will any of your existing alerts be triggered? Your metrics wont report an increase in errors
if theres no traffic that makes it to your applications. This puts you in a situation where your clients are the ones if no traffic makes it to your applications. This puts you in a situation where your clients are the ones
that will notify you about the degradation of your services rather than you reassuring them that you're working on that will notify you about the degradation of your services rather than you reassuring them that you're working on
fixing the issue before they even know about it. fixing the issue before they even know about it.
@@ -101,7 +115,7 @@ The main features of Gatus are:
- **Highly flexible health check conditions**: While checking the response status may be enough for some use cases, Gatus goes much further and allows you to add conditions on the response time, the response body and even the IP address. - **Highly flexible health check conditions**: While checking the response status may be enough for some use cases, Gatus goes much further and allows you to add conditions on the response time, the response body and even the IP address.
- **Ability to use Gatus for user acceptance tests**: Thanks to the point above, you can leverage this application to create automated user acceptance tests. - **Ability to use Gatus for user acceptance tests**: Thanks to the point above, you can leverage this application to create automated user acceptance tests.
- **Very easy to configure**: Not only is the configuration designed to be as readable as possible, it's also extremely easy to add a new service or a new endpoint to monitor. - **Very easy to configure**: Not only is the configuration designed to be as readable as possible, it's also extremely easy to add a new service or a new endpoint to monitor.
- **Alerting**: While having a pretty visual dashboard is useful to keep track of the state of your application(s), you probably don't want to stare at it all day. Thus, notifications via Slack, Mattermost, Messagebird, PagerDuty, Twilio and Teams are supported out of the box with the ability to configure a custom alerting provider for any needs you might have, whether it be a different provider or a custom application that manages automated rollbacks. - **Alerting**: While having a pretty visual dashboard is useful to keep track of the state of your application(s), you probably don't want to stare at it all day. Thus, notifications via Slack, Mattermost, Messagebird, PagerDuty, Twilio, Google chat and Teams are supported out of the box with the ability to configure a custom alerting provider for any needs you might have, whether it be a different provider or a custom application that manages automated rollbacks.
- **Metrics** - **Metrics**
- **Low resource consumption**: As with most Go applications, the resource footprint that this application requires is negligibly small. - **Low resource consumption**: As with most Go applications, the resource footprint that this application requires is negligibly small.
- **[Badges](#badges)**: ![Uptime 7d](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/7d/badge.svg) ![Response time 24h](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/24h/badge.svg) - **[Badges](#badges)**: ![Uptime 7d](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/7d/badge.svg) ![Response time 24h](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/24h/badge.svg)
@@ -109,6 +123,7 @@ The main features of Gatus are:
![Gatus dashboard dark mode](.github/assets/dashboard-dark.png) ![Gatus dashboard dark mode](.github/assets/dashboard-dark.png)
## Usage ## Usage
By default, the configuration file is expected to be at `config/config.yaml`. By default, the configuration file is expected to be at `config/config.yaml`.
@@ -142,7 +157,7 @@ If you want to test it locally, see [Docker](#docker).
## Configuration ## Configuration
| Parameter | Description | Default | | Parameter | Description | Default |
|:---------------------------------------- |:----------------------------------------------------------------------------- |:-------------- | |:------------------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------|:---------------------------|
| `debug` | Whether to enable debug logs. | `false` | | `debug` | Whether to enable debug logs. | `false` |
| `metrics` | Whether to expose metrics at /metrics. | `false` | | `metrics` | Whether to expose metrics at /metrics. | `false` |
| `storage` | [Storage configuration](#storage) | `{}` | | `storage` | [Storage configuration](#storage) | `{}` |
@@ -160,7 +175,7 @@ If you want to test it locally, see [Docker](#docker).
| `endpoints[].dns` | Configuration for an endpoint of type DNS. <br />See [Monitoring an endpoint using DNS queries](#monitoring-an-endpoint-using-dns-queries). | `""` | | `endpoints[].dns` | Configuration for an endpoint of type DNS. <br />See [Monitoring an endpoint using DNS queries](#monitoring-an-endpoint-using-dns-queries). | `""` |
| `endpoints[].dns.query-type` | Query type (e.g. MX) | `""` | | `endpoints[].dns.query-type` | Query type (e.g. MX) | `""` |
| `endpoints[].dns.query-name` | Query name (e.g. example.com) | `""` | | `endpoints[].dns.query-name` | Query name (e.g. example.com) | `""` |
| `endpoints[].alerts[].type` | Type of alert. <br />Valid types: `slack`, `discord`, `pagerduty`, `twilio`, `mattermost`, `messagebird`, `teams` `custom`. | Required `""` | | `endpoints[].alerts[].type` | Type of alert. <br />Valid types: `slack`, `discord`, `email`, `googlechat`, `pagerduty`, `twilio`, `mattermost`, `messagebird`, `teams` `custom`. | Required `""` |
| `endpoints[].alerts[].enabled` | Whether to enable the alert. | `false` | | `endpoints[].alerts[].enabled` | Whether to enable the alert. | `false` |
| `endpoints[].alerts[].failure-threshold` | Number of failures in a row needed before triggering the alert. | `3` | | `endpoints[].alerts[].failure-threshold` | Number of failures in a row needed before triggering the alert. | `3` |
| `endpoints[].alerts[].success-threshold` | Number of successes in a row before an ongoing incident is marked as resolved. | `2` | | `endpoints[].alerts[].success-threshold` | Number of successes in a row before an ongoing incident is marked as resolved. | `2` |
@@ -168,28 +183,33 @@ If you want to test it locally, see [Docker](#docker).
| `endpoints[].alerts[].description` | Description of the alert. Will be included in the alert sent. | `""` | | `endpoints[].alerts[].description` | Description of the alert. Will be included in the alert sent. | `""` |
| `endpoints[].client` | [Client configuration](#client-configuration). | `{}` | | `endpoints[].client` | [Client configuration](#client-configuration). | `{}` |
| `endpoints[].ui` | UI configuration at the endpoint level. | `{}` | | `endpoints[].ui` | UI configuration at the endpoint level. | `{}` |
| `endpoints[].ui.hide-hostname` | Whether to include the hostname in the result. | `false` | | `endpoints[].ui.hide-hostname` | Whether to hide the hostname in the result. | `false` |
| `endpoints[].ui.hide-url` | Whether to ensure the URL is not displayed in the results. Useful if the URL contains a token. | `false` |
| `endpoints[].ui.dont-resolve-failed-conditions` | Whether to resolve failed conditions for the UI. | `false` | | `endpoints[].ui.dont-resolve-failed-conditions` | Whether to resolve failed conditions for the UI. | `false` |
| `endpoints[].ui.badge.reponse-time` | List of response time thresholds. Each time a threshold is reached, the badge has a different color. | `[50, 200, 300, 500, 750]` |
| `alerting` | [Alerting configuration](#alerting). | `{}` | | `alerting` | [Alerting configuration](#alerting). | `{}` |
| `security` | Security configuration. | `{}` | | `security` | [Security configuration](#security). | `{}` |
| `security.basic` | Basic authentication security configuration. | `{}` |
| `security.basic.username` | Username for Basic authentication. | Required `""` |
| `security.basic.password-sha512` | Password's SHA512 hash for Basic authentication. | Required `""` |
| `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock). | `false` | | `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock). | `false` |
| `skip-invalid-config-update` | Whether to ignore invalid configuration update. <br />See [Reloading configuration on the fly](#reloading-configuration-on-the-fly). | `false` | | `skip-invalid-config-update` | Whether to ignore invalid configuration update. <br />See [Reloading configuration on the fly](#reloading-configuration-on-the-fly). | `false` |
| `web` | Web configuration. | `{}` | | `web` | Web configuration. | `{}` |
| `web.address` | Address to listen on. | `0.0.0.0` | | `web.address` | Address to listen on. | `0.0.0.0` |
| `web.port` | Port to listen on. | `8080` | | `web.port` | Port to listen on. | `8080` |
| `ui` | UI configuration. | `{}` | | `ui` | UI configuration. | `{}` |
| `ui.title` | Title of the page. | `Health Dashboard ǀ Gatus` | | `ui.title` | [Title of the document](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/title). | `Health Dashboard ǀ Gatus` |
| `ui.logo` | URL to the logo to display | `""` | | `ui.header` | Header at the top of the dashboard. | `Health Status` |
| `ui.logo` | URL to the logo to display. | `""` |
| `ui.link` | Link to open when the logo is clicked. | `""` |
| `ui.buttons` | List of buttons to display below the header. | `[]` |
| `ui.buttons[].name` | Text to display on the button. | Required `""` |
| `ui.buttons[].link` | Link to open when the button is clicked. | Required `""` |
| `maintenance` | [Maintenance configuration](#maintenance). | `{}` |
### Conditions ### Conditions
Here are some examples of conditions you can use: Here are some examples of conditions you can use:
| Condition | Description | Passing values | Failing values | | Condition | Description | Passing values | Failing values |
|:-----------------------------|:------------------------------------------------------- |:-------------------------- | -------------- | |:---------------------------------|:----------------------------------------------------|:---------------------------|------------------|
| `[STATUS] == 200` | Status must be equal to 200 | 200 | 201, 404, ... | | `[STATUS] == 200` | Status must be equal to 200 | 200 | 201, 404, ... |
| `[STATUS] < 300` | Status must lower than 300 | 200, 201, 299 | 301, 302, ... | | `[STATUS] < 300` | Status must lower than 300 | 200, 201, 299 | 301, 302, ... |
| `[STATUS] <= 299` | Status must be less than or equal to 299 | 200, 201, 299 | 301, 302, ... | | `[STATUS] <= 299` | Status must be less than or equal to 299 | 200, 201, 299 | 301, 302, ... |
@@ -209,37 +229,40 @@ Here are some examples of conditions you can use:
| `[BODY].name == pat(john*)` | String at JSONPath `$.name` matches pattern `john*` | `{"name":"john.doe"}` | `{"name":"bob"}` | | `[BODY].name == pat(john*)` | String at JSONPath `$.name` matches pattern `john*` | `{"name":"john.doe"}` | `{"name":"bob"}` |
| `[BODY].id == any(1, 2)` | Value at JSONPath `$.id` is equal to `1` or `2` | 1, 2 | 3, 4, 5 | | `[BODY].id == any(1, 2)` | Value at JSONPath `$.id` is equal to `1` or `2` | 1, 2 | 3, 4, 5 |
| `[CERTIFICATE_EXPIRATION] > 48h` | Certificate expiration is more than 48h away | 49h, 50h, 123h | 1h, 24h, ... | | `[CERTIFICATE_EXPIRATION] > 48h` | Certificate expiration is more than 48h away | 49h, 50h, 123h | 1h, 24h, ... |
| `[DOMAIN_EXPIRATION] > 720h` | The domain must expire in more than 720h | 4000h | 1h, 24h, ... |
#### Placeholders #### Placeholders
| Placeholder | Description | Example of resolved value | | Placeholder | Description | Example of resolved value |
|:-------------------------- |:--------------------------------------------------------------- |:------------------------- | |:---------------------------|:------------------------------------------------------------------------------------------|:---------------------------------------------|
| `[STATUS]` | Resolves into the HTTP status of the request | 404 | `[STATUS]` | Resolves into the HTTP status of the request | `404` |
| `[RESPONSE_TIME]` | Resolves into the response time the request took, in ms | 10 | `[RESPONSE_TIME]` | Resolves into the response time the request took, in ms | `10` |
| `[IP]` | Resolves into the IP of the target host | 192.168.0.232 | `[IP]` | Resolves into the IP of the target host | `192.168.0.232` |
| `[BODY]` | Resolves into the response body. Supports JSONPath. | `{"name":"john.doe"}` | `[BODY]` | Resolves into the response body. Supports JSONPath. | `{"name":"john.doe"}` |
| `[CONNECTED]` | Resolves into whether a connection could be established | `true` | `[CONNECTED]` | Resolves into whether a connection could be established | `true` |
| `[CERTIFICATE_EXPIRATION]` | Resolves into the duration before certificate expiration | `24h`, `48h`, 0 (if not protocol with certs) | `[CERTIFICATE_EXPIRATION]` | Resolves into the duration before certificate expiration (valid units are "s", "m", "h".) | `24h`, `48h`, 0 (if not protocol with certs) |
| `[DNS_RCODE]` | Resolves into the DNS status of the response | NOERROR | `[DOMAIN_EXPIRATION]` | Resolves into the duration before the domain expires (valid units are "s", "m", "h".) | `24h`, `48h`, `1234h56m78s` |
| `[DNS_RCODE]` | Resolves into the DNS status of the response | `NOERROR` |
#### Functions #### Functions
| Function | Description | Example | | Function | Description | Example |
|:-----------|:---------------------------------------------------------------------------------------------------------------- |:-------------------------- | |:---------|:---------------------------------------------------------------------------------------------------------------|:-----------------------------------|
| `len` | Returns the length of the object/slice. Works only with the `[BODY]` placeholder. | `len([BODY].username) > 8` | `len` | Returns the length of the object/slice. Works only with the `[BODY]` placeholder. | `len([BODY].username) > 8` |
| `has` | Returns `true` or `false` based on whether a given path is valid. Works only with the `[BODY]` placeholder. | `has([BODY].errors) == false` | `has` | Returns `true` or `false` based on whether a given path is valid. Works only with the `[BODY]` placeholder. | `has([BODY].errors) == false` |
| `pat` | Specifies that the string passed as parameter should be evaluated as a pattern. Works only with `==` and `!=`. | `[IP] == pat(192.168.*)` | `pat` | Specifies that the string passed as parameter should be evaluated as a pattern. Works only with `==` and `!=`. | `[IP] == pat(192.168.*)` |
| `any` | Specifies that any one of the values passed as parameters is a valid value. Works only with `==` and `!=`. | `[BODY].ip == any(127.0.0.1, ::1)` | `any` | Specifies that any one of the values passed as parameters is a valid value. Works only with `==` and `!=`. | `[BODY].ip == any(127.0.0.1, ::1)` |
**NOTE**: Use `pat` only when you need to. `[STATUS] == pat(2*)` is a lot more expensive than `[STATUS] < 300`. **NOTE**: Use `pat` only when you need to. `[STATUS] == pat(2*)` is a lot more expensive than `[STATUS] < 300`.
### Storage ### Storage
| Parameter | Description | Default | | Parameter | Description | Default |
|:------------------ |:-------------------------------------------------------------------------------------- |:-------------- | |:------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------|:-----------|
| `storage` | Storage configuration | `{}` | | `storage` | Storage configuration | `{}` |
| `storage.path` | Path to persist the data in. Only supported for types `sqlite` and `postgres`. | `""` | | `storage.path` | Path to persist the data in. Only supported for types `sqlite` and `postgres`. | `""` |
| `storage.type` | Type of storage. Valid types: `memory`, `sqlite`, `postgres`. | `"memory"` | | `storage.type` | Type of storage. Valid types: `memory`, `sqlite`, `postgres`. | `"memory"` |
| `storage.caching` | Whether to use write-through caching. Improves loading time for large dashboards. <br />Only supported if `storage.type` is `sqlite` or `postgres` | `false` |
- If `storage.type` is `memory` (default): - If `storage.type` is `memory` (default):
```yaml ```yaml
@@ -270,10 +293,16 @@ In order to support a wide range of environments, each monitored endpoint has a
the client used to send the request. the client used to send the request.
| Parameter | Description | Default | | Parameter | Description | Default |
|:-------------------------|:----------------------------------------------------------------------------- |:-------------- | |:------------------------------|:---------------------------------------------------------------------------|:----------------|
| `client.insecure` | Whether to skip verifying the server's certificate chain and host name. | `false` | | `client.insecure` | Whether to skip verifying the server's certificate chain and host name. | `false` |
| `client.ignore-redirect` | Whether to ignore redirects (true) or follow them (false, default). | `false` | | `client.ignore-redirect` | Whether to ignore redirects (true) or follow them (false, default). | `false` |
| `client.timeout` | Duration before timing out. | `10s` | | `client.timeout` | Duration before timing out. | `10s` |
| `client.dns-resolver` | Override the DNS resolver using the format `{proto}://{host}:{port}`. | `""` |
| `client.oauth2` | OAuth2 client configuration. | `{}` |
| `client.oauth2.token-url` | The token endpoint URL | required `""` |
| `client.oauth2.client-id` | The client id which should be used for the `Client credentials flow` | required `""` |
| `client.oauth2.client-secret` | The client secret which should be used for the `Client credentials flow` | required `""` |
| `client.oauth2.scopes[]` | A list of `scopes` which should be used for the `Client credentials flow`. | required `[""]` |
Note that some of these parameters are ignored based on the type of endpoint. For instance, there's no certificate involved Note that some of these parameters are ignored based on the type of endpoint. For instance, there's no certificate involved
in ICMP requests (ping), therefore, setting `client.insecure` to `true` for an endpoint of that type will not do anything. in ICMP requests (ping), therefore, setting `client.insecure` to `true` for an endpoint of that type will not do anything.
@@ -300,6 +329,32 @@ endpoints:
- "[STATUS] == 200" - "[STATUS] == 200"
``` ```
This example shows how you can specify a custom DNS resolver:
```yaml
endpoints:
- name: with-custom-dns-resolver
url: "https://your.health.api/health"
client:
dns-resolver: "tcp://1.1.1.1:53"
conditions:
- "[STATUS] == 200"
```
This example shows how you can use the `client.oauth2` configuration to query a backend API with `Bearer token`:
```yaml
endpoints:
- name: with-custom-oauth2
url: "https://your.health.api/health"
client:
oauth2:
token-url: https://your-token-server/token
client-id: 00000000-0000-0000-0000-000000000000
client-secret: your-client-secret
scopes: ['https://your.health.api/.default']
conditions:
- "[STATUS] == 200"
```
### Alerting ### Alerting
Gatus supports multiple alerting providers, such as Slack and PagerDuty, and supports different alerts for each Gatus supports multiple alerting providers, such as Slack and PagerDuty, and supports different alerts for each
@@ -309,10 +364,15 @@ Note that if an alerting provider is not properly configured, all alerts configu
ignored. ignored.
| Parameter | Description | Default | | Parameter | Description | Default |
|:-----------------------|:---------------------------------------------------------------------------------------------------------------------- |:-------| |:-----------------------|:-----------------------------------------------------------------------------------------------------------------------------|:--------|
| `alerting.discord` | Configuration for alerts of type `discord`. <br />See [Configuring Discord alerts](#configuring-discord-alerts). | `{}` | | `alerting.discord` | Configuration for alerts of type `discord`. <br />See [Configuring Discord alerts](#configuring-discord-alerts). | `{}` |
| `alerting.email` | Configuration for alerts of type `email`. <br />See [Configuring Email alerts](#configuring-email-alerts). | `{}` |
| `alerting.googlechat` | Configuration for alerts of type `googlechat`. <br />See [Configuring Google Chat alerts](#configuring-google-chat-alerts). | `{}` |
| `alerting.matrix` | Configuration for alerts of type `matrix`. <br />See [Configuring Matrix alerts](#configuring-matrix-alerts). | `{}` |
| `alerting.mattermost` | Configuration for alerts of type `mattermost`. <br />See [Configuring Mattermost alerts](#configuring-mattermost-alerts). | `{}` | | `alerting.mattermost` | Configuration for alerts of type `mattermost`. <br />See [Configuring Mattermost alerts](#configuring-mattermost-alerts). | `{}` |
| `alerting.messagebird` | Configuration for alerts of type `messagebird`. <br />See [Configuring Messagebird alerts](#configuring-messagebird-alerts). | `{}` | | `alerting.messagebird` | Configuration for alerts of type `messagebird`. <br />See [Configuring Messagebird alerts](#configuring-messagebird-alerts). | `{}` |
| `alerting.ntfy` | Configuration for alerts of type `ntfy`. <br />See [Configuring Ntfy alerts](#configuring-ntfy-alerts). | `{}` |
| `alerting.opsgenie` | Configuration for alerts of type `opsgenie`. <br />See [Configuring Opsgenie alerts](#configuring-opsgenie-alerts). | `{}` |
| `alerting.pagerduty` | Configuration for alerts of type `pagerduty`. <br />See [Configuring PagerDuty alerts](#configuring-pagerduty-alerts). | `{}` | | `alerting.pagerduty` | Configuration for alerts of type `pagerduty`. <br />See [Configuring PagerDuty alerts](#configuring-pagerduty-alerts). | `{}` |
| `alerting.slack` | Configuration for alerts of type `slack`. <br />See [Configuring Slack alerts](#configuring-slack-alerts). | `{}` | | `alerting.slack` | Configuration for alerts of type `slack`. <br />See [Configuring Slack alerts](#configuring-slack-alerts). | `{}` |
| `alerting.teams` | Configuration for alerts of type `teams`. <br />See [Configuring Teams alerts](#configuring-teams-alerts). | `{}` | | `alerting.teams` | Configuration for alerts of type `teams`. <br />See [Configuring Teams alerts](#configuring-teams-alerts). | `{}` |
@@ -322,11 +382,15 @@ ignored.
#### Configuring Discord alerts #### Configuring Discord alerts
| Parameter | Description | Default | | Parameter | Description | Default |
|:---------------------------------------- |:-------------------------------------------- |:-------------- | |:-------------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.discord` | Configuration for alerts of type `discord` | `{}` | | `alerting.discord` | Configuration for alerts of type `discord` | `{}` |
| `alerting.discord.webhook-url` | Discord Webhook URL | Required `""` | | `alerting.discord.webhook-url` | Discord Webhook URL | Required `""` |
| `alerting.discord.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A | | `alerting.discord.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.discord.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.discord.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.discord.overrides[].webhook-url` | Discord Webhook URL | `""` |
```yaml ```yaml
alerting: alerting:
@@ -336,7 +400,7 @@ alerting:
endpoints: endpoints:
- name: website - name: website
url: "https://twin.sh/health" url: "https://twin.sh/health"
interval: 30s interval: 5m
conditions: conditions:
- "[STATUS] == 200" - "[STATUS] == 200"
- "[BODY].status == UP" - "[BODY].status == UP"
@@ -349,13 +413,143 @@ endpoints:
``` ```
#### Configuring Mattermost alerts #### Configuring Email alerts
| Parameter | Description | Default | | Parameter | Description | Default |
|:-----------------------------------|:-------------------------------------------------------------------------------------------|:--------------| |:-----------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.email` | Configuration for alerts of type `email` | `{}` |
| `alerting.email.from` | Email used to send the alert | Required `""` |
| `alerting.email.username` | Username of the SMTP server used to send the alert. If empty, uses `alerting.email.from`. | `""` |
| `alerting.email.password` | Password of the SMTP server used to send the alert | Required `""` |
| `alerting.email.host` | Host of the mail server (e.g. `smtp.gmail.com`) | Required `""` |
| `alerting.email.port` | Port the mail server is listening to (e.g. `587`) | Required `0` |
| `alerting.email.to` | Email(s) to send the alerts to | Required `""` |
| `alerting.email.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.email.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.email.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.email.overrides[].to` | Email(s) to send the alerts to | `""` |
```yaml
alerting:
email:
from: "from@example.com"
username: "from@example.com"
password: "hunter2"
host: "mail.example.com"
port: 587
to: "recipient1@example.com,recipient2@example.com"
# You can also add group-specific to keys, which will
# override the to key above for the specified groups
overrides:
- group: "core"
to: "recipient3@example.com,recipient4@example.com"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
alerts:
- type: email
enabled: true
description: "healthcheck failed"
send-on-resolved: true
- name: back-end
group: core
url: "https://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[CERTIFICATE_EXPIRATION] > 48h"
alerts:
- type: email
enabled: true
description: "healthcheck failed"
send-on-resolved: true
```
**NOTE:** Some mail servers are painfully slow.
#### Configuring Google Chat alerts
| Parameter | Description | Default |
|:----------------------------------------------|:--------------------------------------------------------------------------------------------|:--------------|
| `alerting.googlechat` | Configuration for alerts of type `googlechat` | `{}` |
| `alerting.googlechat.webhook-url` | Google Chat Webhook URL | Required `""` |
| `alerting.googlechat.client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` |
| `alerting.googlechat.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert). | N/A |
| `alerting.googlechat.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.googlechat.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.googlechat.overrides[].webhook-url` | Teams Webhook URL | `""` |
```yaml
alerting:
googlechat:
webhook-url: "https://chat.googleapis.com/v1/spaces/*******/messages?key=**********&token=********"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
alerts:
- type: googlechat
enabled: true
description: "healthcheck failed"
send-on-resolved: true
```
#### Configuring Matrix alerts
| Parameter | Description | Default |
|:-----------------------------------------|:-------------------------------------------------------------------------------------------|:-----------------------------------|
| `alerting.matrix` | Configuration for alerts of type `matrix` | `{}` |
| `alerting.matrix.server-url` | Homeserver URL | `https://matrix-client.matrix.org` |
| `alerting.matrix.access-token` | Bot user access token (see https://webapps.stackexchange.com/q/131056) | Required `""` |
| `alerting.matrix.internal-room-id` | Internal room ID of room to send alerts to (can be found in Room Settings > Advanced) | Required `""` |
| `alerting.matrix.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
```yaml
alerting:
matrix:
server-url: "https://matrix-client.matrix.org"
access-token: "123456"
internal-room-id: "!example:matrix.org"
endpoints:
- name: website
interval: 5m
url: "https://twin.sh/health"
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
alerts:
- type: matrix
enabled: true
send-on-resolved: true
description: "healthcheck failed"
```
#### Configuring Mattermost alerts
| Parameter | Description | Default |
|:----------------------------------------------|:--------------------------------------------------------------------------------------------|:--------------|
| `alerting.mattermost` | Configuration for alerts of type `mattermost` | `{}` | | `alerting.mattermost` | Configuration for alerts of type `mattermost` | `{}` |
| `alerting.mattermost.webhook-url` | Mattermost Webhook URL | Required `""` | | `alerting.mattermost.webhook-url` | Mattermost Webhook URL | Required `""` |
| `alerting.mattermost.client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` | | `alerting.mattermost.client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` |
| `alerting.mattermost.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert). | N/A | | `alerting.mattermost.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert). | N/A |
| `alerting.mattermost.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.mattermost.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.mattermist.overrides[].webhook-url` | Mattermost Webhook URL | `""` |
```yaml ```yaml
alerting: alerting:
@@ -367,7 +561,7 @@ alerting:
endpoints: endpoints:
- name: website - name: website
url: "https://twin.sh/health" url: "https://twin.sh/health"
interval: 30s interval: 5m
conditions: conditions:
- "[STATUS] == 200" - "[STATUS] == 200"
- "[BODY].status == UP" - "[BODY].status == UP"
@@ -386,8 +580,8 @@ Here's an example of what the notifications look like:
#### Configuring Messagebird alerts #### Configuring Messagebird alerts
| Parameter | Description | Default | | Parameter | Description | Default |
|:-------------------------------------|:----------------------------------------------------------------------------- |:-------------- | |:-------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.messagebird` | Settings for alerts of type `messagebird` | `{}` | | `alerting.messagebird` | Configuration for alerts of type `messagebird` | `{}` |
| `alerting.messagebird.access-key` | Messagebird access key | Required `""` | | `alerting.messagebird.access-key` | Messagebird access key | Required `""` |
| `alerting.messagebird.originator` | The sender of the message | Required `""` | | `alerting.messagebird.originator` | The sender of the message | Required `""` |
| `alerting.messagebird.recipients` | The recipients of the message | Required `""` | | `alerting.messagebird.recipients` | The recipients of the message | Required `""` |
@@ -403,7 +597,7 @@ alerting:
endpoints: endpoints:
- name: website - name: website
interval: 30s interval: 5m
url: "https://twin.sh/health" url: "https://twin.sh/health"
conditions: conditions:
- "[STATUS] == 200" - "[STATUS] == 200"
@@ -418,26 +612,82 @@ endpoints:
``` ```
#### Configuring Ntfy alerts
| Parameter | Description | Default |
|:------------------------------|:-------------------------------------------------------------------------------------------|:------------------|
| `alerting.ntfy` | Configuration for alerts of type `ntfy` | `{}` |
| `alerting.ntfy.topic` | Topic at which the alert will be sent | Required `""` |
| `alerting.ntfy.url` | The URL of the target server | `https://ntfy.sh` |
| `alerting.ntfy.priority` | The priority of the alert | `3` |
| `alerting.ntfy.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
[ntfy](https://github.com/binwiederhier/ntfy) is an amazing project that allows you to subscribe to desktop
and mobile notifications, making it an awesome addition to Gatus.
Example:
```yaml
alerting:
ntfy:
topic: "gatus-test-topic"
priority: 2
default-alert:
enabled: true
failure-threshold: 3
send-on-resolved: true
endpoints:
- name: website
interval: 5m
url: "https://twin.sh/health"
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
alerts:
- type: ntfy
```
#### Configuring Opsgenie alerts
| Parameter | Description | Default |
|:----------------------------------|:-------------------------------------------------------------------------------------------|:---------------------|
| `alerting.opsgenie` | Configuration for alerts of type `opsgenie` | `{}` |
| `alerting.opsgenie.api-key` | Opsgenie API Key | Required `""` |
| `alerting.opsgenie.priority` | Priority level of the alert. | `P1` |
| `alerting.opsgenie.source` | Source field of the alert. | `gatus` |
| `alerting.opsgenie.entity-prefix` | Entity field prefix. | `gatus-` |
| `alerting.opsgenie.alias-prefix` | Alias field prefix. | `gatus-healthcheck-` |
| `alerting.opsgenie.tags` | Tags of alert. | `[]` |
| `alerting.opsgenie.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
Opsgenie provider will automatically open and close alerts.
```yaml
alerting:
opsgenie:
api-key: "00000000-0000-0000-0000-000000000000"
```
#### Configuring PagerDuty alerts #### Configuring PagerDuty alerts
| Parameter | Description | Default | | Parameter | Description | Default |
|:------------------------------------------------------ |:----------------------------------------------------------------------------- |:-------------- | |:-------------------------------------------------|:-------------------------------------------------------------------------------------------|:--------|
| `alerting.pagerduty` | Configuration for alerts of type `pagerduty` | `{}` | | `alerting.pagerduty` | Configuration for alerts of type `pagerduty` | `{}` |
| `alerting.pagerduty.integration-key` | PagerDuty Events API v2 integration key | `""` | | `alerting.pagerduty.integration-key` | PagerDuty Events API v2 integration key | `""` |
| `alerting.pagerduty.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.pagerduty.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | | `alerting.pagerduty.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.pagerduty.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | | `alerting.pagerduty.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.pagerduty.overrides[].integration-key` | PagerDuty Events API v2 integration key | `""` | | `alerting.pagerduty.overrides[].integration-key` | PagerDuty Events API v2 integration key | `""` |
| `alerting.pagerduty.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
It is highly recommended to set `endpoints[].alerts[].send-on-resolved` to `true` for alerts It is highly recommended to set `endpoints[].alerts[].send-on-resolved` to `true` for alerts
of type `pagerduty`, because unlike other alerts, the operation resulting from setting said of type `pagerduty`, because unlike other alerts, the operation resulting from setting said
parameter to `true` will not create another incident, but mark the incident as resolved on parameter to `true` will not create another incident but mark the incident as resolved on
PagerDuty instead. PagerDuty instead.
Behavior: Behavior:
- By default, `alerting.pagerduty.integration-key` is used as the integration key - By default, `alerting.pagerduty.integration-key` is used as the integration key
- If the endpoint being evaluated belongs to a group (`endpoints[].group`) matching the value of `alerting.pagerduty.overrides[].group`, the provider will use that override's integration key instead of `alerting.pagerduty.integration-key`'s - If the endpoint being evaluated belongs to a group (`endpoints[].group`) matching the value of `alerting.pagerduty.overrides[].group`, the provider will use that override's integration key instead of `alerting.pagerduty.integration-key`'s
```yaml ```yaml
alerting: alerting:
pagerduty: pagerduty:
@@ -483,11 +733,13 @@ endpoints:
#### Configuring Slack alerts #### Configuring Slack alerts
| Parameter | Description | Default | | Parameter | Description | Default |
|:-------------------------------- |:----------------------------------------------------------------------------- |:-------------- | |:------------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.slack` | Configuration for alerts of type `slack` | `{}` | | `alerting.slack` | Configuration for alerts of type `slack` | `{}` |
| `alerting.slack.webhook-url` | Slack Webhook URL | Required `""` | | `alerting.slack.webhook-url` | Slack Webhook URL | Required `""` |
| `alerting.slack.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A | | `alerting.slack.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.slack.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.slack.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.slack.overrides[].webhook-url` | Slack Webhook URL | `""` |
```yaml ```yaml
alerting: alerting:
slack: slack:
@@ -519,16 +771,25 @@ Here's an example of what the notifications look like:
#### Configuring Teams alerts #### Configuring Teams alerts
| Parameter | Description | Default | | Parameter | Description | Default |
|:-------------------------------- |:----------------------------------------------------------------------------- |:-------------- | |:-----------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.teams` | Configuration for alerts of type `teams` | `{}` | | `alerting.teams` | Configuration for alerts of type `teams` | `{}` |
| `alerting.teams.webhook-url` | Teams Webhook URL | Required `""` | | `alerting.teams.webhook-url` | Teams Webhook URL | Required `""` |
| `alerting.teams.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A | | `alerting.teams.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.teams.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.teams.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.teams.overrides[].webhook-url` | Teams Webhook URL | `""` |
```yaml ```yaml
alerting: alerting:
teams: teams:
webhook-url: "https://********.webhook.office.com/webhookb2/************" webhook-url: "https://********.webhook.office.com/webhookb2/************"
# You can also add group-specific to keys, which will
# override the to key above for the specified groups
overrides:
- group: "core"
webhook-url: "https://********.webhook.office.com/webhookb3/************"
endpoints: endpoints:
- name: website - name: website
@@ -543,18 +804,34 @@ endpoints:
enabled: true enabled: true
description: "healthcheck failed" description: "healthcheck failed"
send-on-resolved: true send-on-resolved: true
- name: back-end
group: core
url: "https://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[CERTIFICATE_EXPIRATION] > 48h"
alerts:
- type: teams
enabled: true
description: "healthcheck failed"
send-on-resolved: true
``` ```
Here's an example of what the notifications look like: Here's an example of what the notifications look like:
![Teams notifications](.github/assets/teams-alerts.png) ![Teams notifications](.github/assets/teams-alerts.png)
#### Configuring Telegram alerts #### Configuring Telegram alerts
| Parameter | Description | Default | | Parameter | Description | Default |
|:----------------------------------- |:----------------------------------------------------------------------------- |:-------------- | |:----------------------------------|:-------------------------------------------------------------------------------------------|:---------------------------|
| `alerting.telegram` | Configuration for alerts of type `telegram` | `{}` | | `alerting.telegram` | Configuration for alerts of type `telegram` | `{}` |
| `alerting.telegram.token` | Telegram Bot Token | Required `""` | | `alerting.telegram.token` | Telegram Bot Token | Required `""` |
| `alerting.telegram.id` | Telegram User ID | Required `""` | | `alerting.telegram.id` | Telegram User ID | Required `""` |
| `alerting.telegram.api-url` | Telegram API URL | `https://api.telegram.org` |
| `alerting.telegram.client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` |
| `alerting.telegram.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A | | `alerting.telegram.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
```yaml ```yaml
@@ -583,7 +860,7 @@ Here's an example of what the notifications look like:
#### Configuring Twilio alerts #### Configuring Twilio alerts
| Parameter | Description | Default | | Parameter | Description | Default |
|:--------------------------------- |:----------------------------------------------------------------------------- |:-------------- | |:--------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.twilio` | Settings for alerts of type `twilio` | `{}` | | `alerting.twilio` | Settings for alerts of type `twilio` | `{}` |
| `alerting.twilio.sid` | Twilio account SID | Required `""` | | `alerting.twilio.sid` | Twilio account SID | Required `""` |
| `alerting.twilio.token` | Twilio auth token | Required `""` | | `alerting.twilio.token` | Twilio auth token | Required `""` |
@@ -618,7 +895,7 @@ endpoints:
#### Configuring custom alerts #### Configuring custom alerts
| Parameter | Description | Default | | Parameter | Description | Default |
|:----------------------------------|:----------------------------------------------------------------------------- |:-------------- | |:--------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.custom` | Configuration for custom actions on failure or alerts | `{}` | | `alerting.custom` | Configuration for custom actions on failure or alerts | `{}` |
| `alerting.custom.url` | Custom alerting request url | Required `""` | | `alerting.custom.url` | Custom alerting request url | Required `""` |
| `alerting.custom.method` | Request method | `GET` | | `alerting.custom.method` | Request method | `GET` |
@@ -634,15 +911,18 @@ leveraging Gatus, you could have Gatus call that application endpoint when an en
would then check if the endpoint that started failing was part of the recently deployed application, and if it was, would then check if the endpoint that started failing was part of the recently deployed application, and if it was,
then automatically roll it back. then automatically roll it back.
The placeholders `[ALERT_DESCRIPTION]` and `[ENDPOINT_NAME]` are automatically substituted for the alert description and Furthermore, you may use the following placeholders in the body (`alerting.custom.body`) and in the url (`alerting.custom.url`):
the endpoint name. These placeholders can be used in the body (`alerting.custom.body`) and in the url (`alerting.custom.url`). - `[ALERT_DESCRIPTION]` (resolved from `endpoints[].alerts[].description`)
- `[ENDPOINT_NAME]` (resolved from `endpoints[].name`)
- `[ENDPOINT_GROUP]` (resolved from `endpoints[].group`)
- `[ENDPOINT_URL]` (resolved from `endpoints[].url`)
If you have an alert using the `custom` provider with `send-on-resolved` set to `true`, you can use the If you have an alert using the `custom` provider with `send-on-resolved` set to `true`, you can use the
`[ALERT_TRIGGERED_OR_RESOLVED]` placeholder to differentiate the notifications. `[ALERT_TRIGGERED_OR_RESOLVED]` placeholder to differentiate the notifications.
The aforementioned placeholder will be replaced by `TRIGGERED` or `RESOLVED` accordingly, though it can be modified The aforementioned placeholder will be replaced by `TRIGGERED` or `RESOLVED` accordingly, though it can be modified
(details at the end of this section). (details at the end of this section).
For all intents and purpose, we'll configure the custom alert with a Slack webhook, but you can call anything you want. For all intents and purposes, we'll configure the custom alert with a Slack webhook, but you can call anything you want.
```yaml ```yaml
alerting: alerting:
custom: custom:
@@ -650,7 +930,7 @@ alerting:
method: "POST" method: "POST"
body: | body: |
{ {
"text": "[ALERT_TRIGGERED_OR_RESOLVED]: [ENDPOINT_NAME] - [ALERT_DESCRIPTION]" "text": "[ALERT_TRIGGERED_OR_RESOLVED]: [ENDPOINT_GROUP] - [ENDPOINT_NAME] - [ALERT_DESCRIPTION]"
} }
endpoints: endpoints:
- name: website - name: website
@@ -773,12 +1053,13 @@ endpoints:
- type: pagerduty - type: pagerduty
``` ```
### Maintenance ### Maintenance
If you have maintenance windows, you may not want to be annoyed by alerts. If you have maintenance windows, you may not want to be annoyed by alerts.
To do that, you'll have to use the maintenance configuration: To do that, you'll have to use the maintenance configuration:
| Parameter | Description | Default | | Parameter | Description | Default |
|:----------------------- |:----------------------------------------------------------------------------- |:--------------- | |:-----------------------|:---------------------------------------------------------------------------------------------------------------------------------------|:--------------|
| `maintenance.enabled` | Whether the maintenance period is enabled | `true` | | `maintenance.enabled` | Whether the maintenance period is enabled | `true` |
| `maintenance.start` | Time at which the maintenance window starts in `hh:mm` format (e.g. `23:00`) | Required `""` | | `maintenance.start` | Time at which the maintenance window starts in `hh:mm` format (e.g. `23:00`) | Required `""` |
| `maintenance.duration` | Duration of the maintenance window (e.g. `1h`, `30m`) | Required `""` | | `maintenance.duration` | Duration of the maintenance window (e.g. `1h`, `30m`) | Required `""` |
@@ -804,6 +1085,99 @@ maintenance:
``` ```
### Security
| Parameter | Description | Default |
|:---------------------------------|:-----------------------------|:--------------|
| `security` | Security configuration | `{}` |
| `security.basic` | HTTP Basic configuration | `{}` |
| `security.oidc` | OpenID Connect configuration | `{}` |
#### Basic
| Parameter | Description | Default |
|:----------------------------------------|:-----------------------------------------------------------------------------------|:--------------|
| `security.basic` | HTTP Basic configuration | `{}` |
| `security.basic.username` | Username for Basic authentication. | Required `""` |
| `security.basic.password-bcrypt-base64` | Password hashed with Bcrypt and then encoded with base64 for Basic authentication. | Required `""` |
The example below will require that you authenticate with the username `john.doe` and the password `hunter2`:
```yaml
security:
basic:
username: "john.doe"
password-bcrypt-base64: "JDJhJDEwJHRiMnRFakxWazZLdXBzRERQazB1TE8vckRLY05Yb1hSdnoxWU0yQ1FaYXZRSW1McmladDYu"
```
**WARNING:** Make sure to carefully select to cost of the bcrypt hash. The higher the cost, the longer it takes to compute the hash,
and basic auth verifies the password against the hash on every request. As of 2022-01-08, I suggest a cost of 8.
#### OIDC
| Parameter | Description | Default |
|:---------------------------------|:---------------------------------------------------------------|:--------------|
| `security.oidc` | OpenID Connect configuration | `{}` |
| `security.oidc.issuer-url` | Issuer URL | Required `""` |
| `security.oidc.redirect-url` | Redirect URL. Must end with `/authorization-code/callback` | Required `""` |
| `security.oidc.client-id` | Client id | Required `""` |
| `security.oidc.client-secret` | Client secret | Required `""` |
| `security.oidc.scopes` | Scopes to request. The only scope you need is `openid`. | Required `[]` |
| `security.oidc.allowed-subjects` | List of subjects to allow. If empty, all subjects are allowed. | `[]` |
```yaml
security:
oidc:
issuer-url: "https://example.okta.com"
redirect-url: "https://status.example.com/authorization-code/callback"
client-id: "123456789"
client-secret: "abcdefghijk"
scopes: ["openid"]
# You may optionally specify a list of allowed subjects. If this is not specified, all subjects will be allowed.
#allowed-subjects: ["johndoe@example.com"]
```
Confused? Read [Securing Gatus with OIDC using Auth0](https://twin.sh/articles/56/securing-gatus-with-oidc-using-auth0).
### Metrics
To enable metrics, you must set `metrics` to `true`. Doing so will expose Prometheus-friendly metrics at the `/metrics`
endpoint on the same port your application is configured to run on (`web.port`).
| Metric name | Type | Description | Labels | Relevant endpoint types |
|:---------------------------------------------|:--------|:---------------------------------------------------------------------------|:--------------------------------|:------------------------|
| gatus_results_total | counter | Number of results per endpoint | key, group, name, type, success | All |
| gatus_results_code_total | counter | Total number of results by code | key, group, name, type, code | DNS, HTTP |
| gatus_results_connected_total | counter | Total number of results in which a connection was successfully established | key, group, name, type | All |
| gatus_results_duration_seconds | gauge | Duration of the request in seconds | key, group, name, type | All |
| gatus_results_certificate_expiration_seconds | gauge | Number of seconds until the certificate expires | key, group, name, type | HTTP, STARTTLS |
See [examples/docker-compose-grafana-prometheus](.examples/docker-compose-grafana-prometheus) for further documentation as well as an example.
### Remote instances (EXPERIMENTAL)
This feature allows you to retrieve endpoint statuses from a remote Gatus instance.
There are two main use cases for this:
- You have multiple Gatus instances running on different machines, and you wish to visually expose the statuses through a single dashboard
- You have one or more Gatus instances that are not publicly accessible (e.g. behind a firewall), and you wish to retrieve
This is an experimental feature. It may be removed or updated in a breaking manner at any time. Furthermore,
there are known issues with this feature. If you'd like to provide some feedback, please write a comment in [#64](https://github.com/TwiN/gatus/issues/64).
Use at your own risk.
| Parameter | Description | Default |
|:-----------------------------------|:---------------------------------------------|:---------------|
| `remote` | Remote configuration | `{}` |
| `remote.instances` | List of remote instances | Required `[]` |
| `remote.instances.endpoint-prefix` | String to prefix all endpoint names with | `""` |
| `remote.instances.url` | URL from which to retrieve endpoint statuses | Required `""` |
```yaml
remote:
instances:
- endpoint-prefix: "status.example.org-"
url: "https://status.example.org/api/v1/endpoints/statuses"
```
## Deployment ## Deployment
Many examples can be found in the [.examples](.examples) folder, but this section will focus on the most popular ways of deploying Gatus. Many examples can be found in the [.examples](.examples) folder, but this section will focus on the most popular ways of deploying Gatus.
@@ -851,7 +1225,6 @@ and [helmfile example](https://github.com/avakarev/gatus-chart#helmfileyaml-exam
Gatus can be deployed on Terraform by using the following module: [terraform-kubernetes-gatus](https://github.com/TwiN/terraform-kubernetes-gatus). Gatus can be deployed on Terraform by using the following module: [terraform-kubernetes-gatus](https://github.com/TwiN/terraform-kubernetes-gatus).
## Running the tests ## Running the tests
```console ```console
go test ./... -mod vendor go test ./... -mod vendor
@@ -898,7 +1271,7 @@ will send a `POST` request to `http://localhost:8080/playground` with the follow
> tells Gatus to only evaluate one endpoint at a time. > tells Gatus to only evaluate one endpoint at a time.
To ensure that Gatus provides reliable and accurate results (i.e. response time), Gatus only evaluates one endpoint at a time To ensure that Gatus provides reliable and accurate results (i.e. response time), Gatus only evaluates one endpoint at a time
In other words, even if you have multiple endpoints with the exact same interval, they will not execute at the same time. In other words, even if you have multiple endpoints with the same interval, they will not execute at the same time.
You can test this yourself by running Gatus with several endpoints configured with a very short, unrealistic interval, You can test this yourself by running Gatus with several endpoints configured with a very short, unrealistic interval,
such as 1ms. You'll notice that the response time does not fluctuate - that is because while endpoints are evaluated on such as 1ms. You'll notice that the response time does not fluctuate - that is because while endpoints are evaluated on
@@ -916,19 +1289,19 @@ to respect the configured interval, for instance:
- Endpoint B has an interval of 5s, and takes 1ms to complete - Endpoint B has an interval of 5s, and takes 1ms to complete
- Endpoint B will be unable to run every 5s, because endpoint A's health evaluation takes longer than its interval - Endpoint B will be unable to run every 5s, because endpoint A's health evaluation takes longer than its interval
To sum it up, while Gatus can really handle any interval you throw at it, you're better off having slow requests with To sum it up, while Gatus can handle any interval you throw at it, you're better off having slow requests with
higher interval. higher interval.
As a rule of the thumb, I personally set interval for more complex health checks to `5m` (5 minutes) and As a rule of thumb, I personally set the interval for more complex health checks to `5m` (5 minutes) and
simple health checks used for alerting (PagerDuty/Twilio) to `30s`. simple health checks used for alerting (PagerDuty/Twilio) to `30s`.
### Default timeouts ### Default timeouts
| Endpoint type | Timeout | | Endpoint type | Timeout |
|:------------- |:------- | |:---------------|:--------|
| HTTP | 10s | HTTP | 10s |
| TCP | 10s | TCP | 10s |
| ICMP | 10s | ICMP | 10s |
To modify the timeout, see [Client configuration](#client-configuration). To modify the timeout, see [Client configuration](#client-configuration).
@@ -948,6 +1321,8 @@ endpoints:
Placeholders `[STATUS]` and `[BODY]` as well as the fields `endpoints[].body`, `endpoints[].headers`, Placeholders `[STATUS]` and `[BODY]` as well as the fields `endpoints[].body`, `endpoints[].headers`,
`endpoints[].method` and `endpoints[].graphql` are not supported for TCP endpoints. `endpoints[].method` and `endpoints[].graphql` are not supported for TCP endpoints.
This works for applications such as databases (Postgres, MySQL, etc.) and caches (Redis, Memcached, etc.).
**NOTE**: `[CONNECTED] == true` does not guarantee that the endpoint itself is healthy - it only guarantees that there's **NOTE**: `[CONNECTED] == true` does not guarantee that the endpoint itself is healthy - it only guarantees that there's
something at the given address listening to the given port, and that a connection to that address was successfully something at the given address listening to the given port, and that a connection to that address was successfully
established. established.
@@ -1021,16 +1396,23 @@ endpoints:
``` ```
### Basic authentication ### Monitoring domain expiration
You can require Basic authentication by leveraging the `security.basic` configuration: You can monitor the expiration of a domain with all endpoint types except for DNS by using the `[DOMAIN_EXPIRATION]`
placeholder:
```yaml ```yaml
security: endpoints:
basic: - name: check-domain-and-certificate-expiration
username: "john.doe" url: "https://example.org"
password-sha512: "6b97ed68d14eb3f1aa959ce5d49c7dc612e1eb1dafd73b1e705847483fd6a6c809f2ceb4e8df6ff9984c6298ff0285cace6614bf8daa9f0070101b6c89899e22" interval: 1h
conditions:
- "[DOMAIN_EXPIRATION] > 720h"
- "[CERTIFICATE_EXPIRATION] > 240h"
``` ```
The example above will require that you authenticate with the username `john.doe` as well as the password `hunter2`. **NOTE**: The usage of the `[DOMAIN_EXPIRATION]` placeholder requires Gatus to send a request to the official IANA WHOIS service [through a library](https://github.com/TwiN/whois)
and in some cases, a secondary request to a TLD-specific WHOIS server (e.g. `whois.nic.sh`).
To prevent the WHOIS service from throttling your IP address if you send too many requests, Gatus will prevent you from
using the `[DOMAIN_EXPIRATION]` placeholder on an endpoint with an interval of less than `5m`.
### disable-monitoring-lock ### disable-monitoring-lock
@@ -1043,11 +1425,11 @@ There are three main reasons why you might want to disable the monitoring lock:
- You're using Gatus for load testing (each endpoint are periodically evaluated on a different goroutine, so - You're using Gatus for load testing (each endpoint are periodically evaluated on a different goroutine, so
technically, if you create 100 endpoints with a 1 seconds interval, Gatus will send 100 requests per second) technically, if you create 100 endpoints with a 1 seconds interval, Gatus will send 100 requests per second)
- You have a _lot_ of endpoints to monitor - You have a _lot_ of endpoints to monitor
- You want to test multiple endpoints at very short interval (< 5s) - You want to test multiple endpoints at very short intervals (< 5s)
### Reloading configuration on the fly ### Reloading configuration on the fly
For the sake on convenience, Gatus automatically reloads the configuration on the fly if the loaded configuration file For the sake of convenience, Gatus automatically reloads the configuration on the fly if the loaded configuration file
is updated while Gatus is running. is updated while Gatus is running.
By default, the application will exit if the updating configuration is invalid, but you can configure By default, the application will exit if the updating configuration is invalid, but you can configure
@@ -1104,7 +1486,7 @@ endpoints:
conditions: conditions:
- "[STATUS] == 200" - "[STATUS] == 200"
- name: random endpoint that isn't part of a group - name: random endpoint that is not part of a group
url: "https://example.org/" url: "https://example.org/"
interval: 5m interval: 5m
conditions: conditions:
@@ -1130,14 +1512,15 @@ web:
port: ${PORT} port: ${PORT}
``` ```
### Badges ### Badges
### Uptime #### Uptime
![Uptime 1h](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/1h/badge.svg) ![Uptime 1h](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/1h/badge.svg)
![Uptime 24h](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/24h/badge.svg) ![Uptime 24h](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/24h/badge.svg)
![Uptime 7d](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/7d/badge.svg) ![Uptime 7d](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/7d/badge.svg)
Gatus can automatically generate a SVG badge for one of your monitored endpoints. Gatus can automatically generate an SVG badge for one of your monitored endpoints.
This allows you to put badges in your individual applications' README or even create your own status page, if you This allows you to put badges in your individual applications' README or even create your own status page if you
desire. desire.
The path to generate a badge is the following: The path to generate a badge is the following:
@@ -1161,10 +1544,27 @@ Example:
``` ```
![Uptime 24h](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/24h/badge.svg) ![Uptime 24h](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/24h/badge.svg)
``` ```
If you'd like to see a visual example of each badges available, you can simply navigate to the endpoint's detail page. If you'd like to see a visual example of each badge available, you can simply navigate to the endpoint's detail page.
### Response time #### Health
![Health](https://status.twin.sh/api/v1/endpoints/core_blog-external/health/badge.svg)
The path to generate a badge is the following:
```
/api/v1/endpoints/{key}/health/badge.svg
```
Where:
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`.
For instance, if you want the current status of the endpoint `frontend` in the group `core`,
the URL would look like this:
```
https://example.com/api/v1/endpoints/core_frontend/health/badge.svg
```
#### Response time
![Response time 1h](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/1h/badge.svg) ![Response time 1h](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/1h/badge.svg)
![Response time 24h](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/24h/badge.svg) ![Response time 24h](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/24h/badge.svg)
![Response time 7d](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/7d/badge.svg) ![Response time 7d](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/7d/badge.svg)
@@ -1178,8 +1578,28 @@ Where:
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`. - `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`.
##### How to change the color thresholds of the response time badge
To change the response time badges' threshold, a corresponding configuration can be added to an endpoint.
The values in the array correspond to the levels [Awesome, Great, Good, Passable, Bad]
All five values must be given in milliseconds (ms).
```
endpoints:
- name: nas
group: internal
url: "https://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"
ui:
badge:
response-time:
thresholds: [550, 850, 1350, 1650, 1750]
```
### API ### API
Gatus provides a simple read-only API which can be queried in order to programmatically determine endpoint status and history. Gatus provides a simple read-only API that can be queried in order to programmatically determine endpoint status and history.
All endpoints are available via a GET request to the following endpoint: All endpoints are available via a GET request to the following endpoint:
``` ```
+27 -3
View File
@@ -1,5 +1,15 @@
package alert package alert
import (
"errors"
"strings"
)
var (
// ErrAlertWithInvalidDescription is the error with which Gatus will panic if an alert has an invalid character
ErrAlertWithInvalidDescription = errors.New("alert description must not have \" or \\")
)
// Alert is a core.Endpoint's alert configuration // Alert is a core.Endpoint's alert configuration
type Alert struct { type Alert struct {
// Type of alert (required) // Type of alert (required)
@@ -9,7 +19,7 @@ type Alert struct {
// //
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value // This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
// or not for provider.ParseWithDefaultAlert to work. // or not for provider.ParseWithDefaultAlert to work.
Enabled *bool `yaml:"enabled"` Enabled *bool `yaml:"enabled,omitempty"`
// FailureThreshold is the number of failures in a row needed before triggering the alert // FailureThreshold is the number of failures in a row needed before triggering the alert
FailureThreshold int `yaml:"failure-threshold"` FailureThreshold int `yaml:"failure-threshold"`
@@ -31,7 +41,7 @@ type Alert struct {
// ResolveKey is an optional field that is used by some providers (i.e. PagerDuty's dedup_key) to resolve // ResolveKey is an optional field that is used by some providers (i.e. PagerDuty's dedup_key) to resolve
// ongoing/triggered incidents // ongoing/triggered incidents
ResolveKey string ResolveKey string `yaml:"-"`
// Triggered is used to determine whether an alert has been triggered. When an alert is resolved, this value // Triggered is used to determine whether an alert has been triggered. When an alert is resolved, this value
// should be set back to false. It is used to prevent the same alert from going out twice. // should be set back to false. It is used to prevent the same alert from going out twice.
@@ -41,7 +51,21 @@ type Alert struct {
// applied for alerts that are already triggered and has become "healthy" again is to prevent a case where, for // applied for alerts that are already triggered and has become "healthy" again is to prevent a case where, for
// some reason, the alert provider always returns errors when trying to send the resolved notification // some reason, the alert provider always returns errors when trying to send the resolved notification
// (SendOnResolved). // (SendOnResolved).
Triggered bool Triggered bool `yaml:"-"`
}
// ValidateAndSetDefaults validates the alert's configuration and sets the default value of fields that have one
func (alert *Alert) ValidateAndSetDefaults() error {
if alert.FailureThreshold <= 0 {
alert.FailureThreshold = 3
}
if alert.SuccessThreshold <= 0 {
alert.SuccessThreshold = 2
}
if strings.ContainsAny(alert.GetDescription(), "\"\\") {
return ErrAlertWithInvalidDescription
}
return nil
} }
// GetDescription retrieves the description of the alert // GetDescription retrieves the description of the alert
+50 -1
View File
@@ -1,6 +1,55 @@
package alert package alert
import "testing" import (
"testing"
)
func TestAlert_ValidateAndSetDefaults(t *testing.T) {
invalidDescription := "\""
scenarios := []struct {
name string
alert Alert
expectedError error
expectedSuccessThreshold int
expectedFailureThreshold int
}{
{
name: "valid-empty",
alert: Alert{
Description: nil,
FailureThreshold: 0,
SuccessThreshold: 0,
},
expectedError: nil,
expectedFailureThreshold: 3,
expectedSuccessThreshold: 2,
},
{
name: "invalid-description",
alert: Alert{
Description: &invalidDescription,
FailureThreshold: 10,
SuccessThreshold: 5,
},
expectedError: ErrAlertWithInvalidDescription,
expectedFailureThreshold: 10,
expectedSuccessThreshold: 5,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
if err := scenario.alert.ValidateAndSetDefaults(); err != scenario.expectedError {
t.Errorf("expected error %v, got %v", scenario.expectedError, err)
}
if scenario.alert.SuccessThreshold != scenario.expectedSuccessThreshold {
t.Errorf("expected success threshold %v, got %v", scenario.expectedSuccessThreshold, scenario.alert.SuccessThreshold)
}
if scenario.alert.FailureThreshold != scenario.expectedFailureThreshold {
t.Errorf("expected failure threshold %v, got %v", scenario.expectedFailureThreshold, scenario.alert.FailureThreshold)
}
})
}
}
func TestAlert_IsEnabled(t *testing.T) { func TestAlert_IsEnabled(t *testing.T) {
if (Alert{Enabled: nil}).IsEnabled() { if (Alert{Enabled: nil}).IsEnabled() {
+15
View File
@@ -11,12 +11,27 @@ const (
// TypeDiscord is the Type for the discord alerting provider // TypeDiscord is the Type for the discord alerting provider
TypeDiscord Type = "discord" TypeDiscord Type = "discord"
// TypeEmail is the Type for the email alerting provider
TypeEmail Type = "email"
// TypeGoogleChat is the Type for the googlechat alerting provider
TypeGoogleChat Type = "googlechat"
// TypeMatrix is the Type for the matrix alerting provider
TypeMatrix Type = "matrix"
// TypeMattermost is the Type for the mattermost alerting provider // TypeMattermost is the Type for the mattermost alerting provider
TypeMattermost Type = "mattermost" TypeMattermost Type = "mattermost"
// TypeMessagebird is the Type for the messagebird alerting provider // TypeMessagebird is the Type for the messagebird alerting provider
TypeMessagebird Type = "messagebird" TypeMessagebird Type = "messagebird"
// TypeNtfy is the Type for the ntfy alerting provider
TypeNtfy Type = "ntfy"
// TypeOpsgenie is the Type for the opsgenie alerting provider
TypeOpsgenie Type = "opsgenie"
// TypePagerDuty is the Type for the pagerduty alerting provider // TypePagerDuty is the Type for the pagerduty alerting provider
TypePagerDuty Type = "pagerduty" TypePagerDuty Type = "pagerduty"
+70 -20
View File
@@ -1,47 +1,67 @@
package alerting package alerting
import ( import (
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/alerting/provider" "github.com/TwiN/gatus/v4/alerting/provider"
"github.com/TwiN/gatus/v3/alerting/provider/custom" "github.com/TwiN/gatus/v4/alerting/provider/custom"
"github.com/TwiN/gatus/v3/alerting/provider/discord" "github.com/TwiN/gatus/v4/alerting/provider/discord"
"github.com/TwiN/gatus/v3/alerting/provider/mattermost" "github.com/TwiN/gatus/v4/alerting/provider/email"
"github.com/TwiN/gatus/v3/alerting/provider/messagebird" "github.com/TwiN/gatus/v4/alerting/provider/googlechat"
"github.com/TwiN/gatus/v3/alerting/provider/pagerduty" "github.com/TwiN/gatus/v4/alerting/provider/matrix"
"github.com/TwiN/gatus/v3/alerting/provider/slack" "github.com/TwiN/gatus/v4/alerting/provider/mattermost"
"github.com/TwiN/gatus/v3/alerting/provider/teams" "github.com/TwiN/gatus/v4/alerting/provider/messagebird"
"github.com/TwiN/gatus/v3/alerting/provider/telegram" "github.com/TwiN/gatus/v4/alerting/provider/ntfy"
"github.com/TwiN/gatus/v3/alerting/provider/twilio" "github.com/TwiN/gatus/v4/alerting/provider/opsgenie"
"github.com/TwiN/gatus/v4/alerting/provider/pagerduty"
"github.com/TwiN/gatus/v4/alerting/provider/slack"
"github.com/TwiN/gatus/v4/alerting/provider/teams"
"github.com/TwiN/gatus/v4/alerting/provider/telegram"
"github.com/TwiN/gatus/v4/alerting/provider/twilio"
) )
// Config is the configuration for alerting providers // Config is the configuration for alerting providers
type Config struct { type Config struct {
// Custom is the configuration for the custom alerting provider // Custom is the configuration for the custom alerting provider
Custom *custom.AlertProvider `yaml:"custom"` Custom *custom.AlertProvider `yaml:"custom,omitempty"`
// googlechat is the configuration for the Google chat alerting provider
GoogleChat *googlechat.AlertProvider `yaml:"googlechat,omitempty"`
// Discord is the configuration for the discord alerting provider // Discord is the configuration for the discord alerting provider
Discord *discord.AlertProvider `yaml:"discord"` Discord *discord.AlertProvider `yaml:"discord,omitempty"`
// Email is the configuration for the email alerting provider
Email *email.AlertProvider `yaml:"email,omitempty"`
// Matrix is the configuration for the matrix alerting provider
Matrix *matrix.AlertProvider `yaml:"matrix,omitempty"`
// Mattermost is the configuration for the mattermost alerting provider // Mattermost is the configuration for the mattermost alerting provider
Mattermost *mattermost.AlertProvider `yaml:"mattermost"` Mattermost *mattermost.AlertProvider `yaml:"mattermost,omitempty"`
// Messagebird is the configuration for the messagebird alerting provider // Messagebird is the configuration for the messagebird alerting provider
Messagebird *messagebird.AlertProvider `yaml:"messagebird"` Messagebird *messagebird.AlertProvider `yaml:"messagebird,omitempty"`
// Ntfy is the configuration for the ntfy alerting provider
Ntfy *ntfy.AlertProvider `yaml:"ntfy,omitempty"`
// Opsgenie is the configuration for the opsgenie alerting provider
Opsgenie *opsgenie.AlertProvider `yaml:"opsgenie,omitempty"`
// PagerDuty is the configuration for the pagerduty alerting provider // PagerDuty is the configuration for the pagerduty alerting provider
PagerDuty *pagerduty.AlertProvider `yaml:"pagerduty"` PagerDuty *pagerduty.AlertProvider `yaml:"pagerduty,omitempty"`
// Slack is the configuration for the slack alerting provider // Slack is the configuration for the slack alerting provider
Slack *slack.AlertProvider `yaml:"slack"` Slack *slack.AlertProvider `yaml:"slack,omitempty"`
// Teams is the configuration for the teams alerting provider // Teams is the configuration for the teams alerting provider
Teams *teams.AlertProvider `yaml:"teams"` Teams *teams.AlertProvider `yaml:"teams,omitempty"`
// Telegram is the configuration for the telegram alerting provider // Telegram is the configuration for the telegram alerting provider
Telegram *telegram.AlertProvider `yaml:"telegram"` Telegram *telegram.AlertProvider `yaml:"telegram,omitempty"`
// Twilio is the configuration for the twilio alerting provider // Twilio is the configuration for the twilio alerting provider
Twilio *twilio.AlertProvider `yaml:"twilio"` Twilio *twilio.AlertProvider `yaml:"twilio,omitempty"`
} }
// GetAlertingProviderByAlertType returns an provider.AlertProvider by its corresponding alert.Type // GetAlertingProviderByAlertType returns an provider.AlertProvider by its corresponding alert.Type
@@ -59,6 +79,24 @@ func (config Config) GetAlertingProviderByAlertType(alertType alert.Type) provid
return nil return nil
} }
return config.Discord return config.Discord
case alert.TypeEmail:
if config.Email == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
return nil
}
return config.Email
case alert.TypeGoogleChat:
if config.GoogleChat == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
return nil
}
return config.GoogleChat
case alert.TypeMatrix:
if config.Matrix == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
return nil
}
return config.Matrix
case alert.TypeMattermost: case alert.TypeMattermost:
if config.Mattermost == nil { if config.Mattermost == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil // Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
@@ -71,6 +109,18 @@ func (config Config) GetAlertingProviderByAlertType(alertType alert.Type) provid
return nil return nil
} }
return config.Messagebird return config.Messagebird
case alert.TypeNtfy:
if config.Ntfy == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
return nil
}
return config.Ntfy
case alert.TypeOpsgenie:
if config.Opsgenie == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
return nil
}
return config.Opsgenie
case alert.TypePagerDuty: case alert.TypePagerDuty:
if config.PagerDuty == nil { if config.PagerDuty == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil // Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
+25 -62
View File
@@ -2,16 +2,14 @@ package custom
import ( import (
"bytes" "bytes"
"errors"
"fmt" "fmt"
"io/ioutil" "io"
"net/http" "net/http"
"os"
"strings" "strings"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/client" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
) )
// AlertProvider is the configuration necessary for sending an alert using a custom HTTP request // AlertProvider is the configuration necessary for sending an alert using a custom HTTP request
@@ -24,10 +22,10 @@ type AlertProvider struct {
Placeholders map[string]map[string]string `yaml:"placeholders,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 is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client"` ClientConfig *client.Config `yaml:"client,omitempty"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
} }
// IsValid returns whether the provider's configuration is valid // IsValid returns whether the provider's configuration is valid
@@ -38,11 +36,6 @@ func (provider *AlertProvider) IsValid() bool {
return len(provider.URL) > 0 && provider.ClientConfig != nil return len(provider.URL) > 0 && provider.ClientConfig != nil
} }
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) *AlertProvider {
return provider
}
// GetAlertStatePlaceholderValue returns the Placeholder value for ALERT_TRIGGERED_OR_RESOLVED if configured // GetAlertStatePlaceholderValue returns the Placeholder value for ALERT_TRIGGERED_OR_RESOLVED if configured
func (provider *AlertProvider) GetAlertStatePlaceholderValue(resolved bool) string { func (provider *AlertProvider) GetAlertStatePlaceholderValue(resolved bool) string {
status := "TRIGGERED" status := "TRIGGERED"
@@ -57,75 +50,45 @@ func (provider *AlertProvider) GetAlertStatePlaceholderValue(resolved bool) stri
return status return status
} }
func (provider *AlertProvider) buildHTTPRequest(endpointName, alertDescription string, resolved bool) *http.Request { func (provider *AlertProvider) buildHTTPRequest(endpoint *core.Endpoint, alert *alert.Alert, resolved bool) *http.Request {
body := provider.Body body, url, method := provider.Body, provider.URL, provider.Method
providerURL := provider.URL body = strings.ReplaceAll(body, "[ALERT_DESCRIPTION]", alert.GetDescription())
method := provider.Method url = strings.ReplaceAll(url, "[ALERT_DESCRIPTION]", alert.GetDescription())
body = strings.ReplaceAll(body, "[ENDPOINT_NAME]", endpoint.Name)
if strings.Contains(body, "[ALERT_DESCRIPTION]") { url = strings.ReplaceAll(url, "[ENDPOINT_NAME]", endpoint.Name)
body = strings.ReplaceAll(body, "[ALERT_DESCRIPTION]", alertDescription) body = strings.ReplaceAll(body, "[ENDPOINT_GROUP]", endpoint.Group)
} url = strings.ReplaceAll(url, "[ENDPOINT_GROUP]", endpoint.Group)
if strings.Contains(body, "[SERVICE_NAME]") { // XXX: Remove this in v4.0.0 body = strings.ReplaceAll(body, "[ENDPOINT_URL]", endpoint.URL)
body = strings.ReplaceAll(body, "[SERVICE_NAME]", endpointName) url = strings.ReplaceAll(url, "[ENDPOINT_URL]", endpoint.URL)
}
if strings.Contains(body, "[ENDPOINT_NAME]") {
body = strings.ReplaceAll(body, "[ENDPOINT_NAME]", endpointName)
}
if strings.Contains(body, "[ALERT_TRIGGERED_OR_RESOLVED]") {
if resolved { if resolved {
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true)) body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
} else { } else {
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(false)) body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(false))
} url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(false))
}
if strings.Contains(providerURL, "[ALERT_DESCRIPTION]") {
providerURL = strings.ReplaceAll(providerURL, "[ALERT_DESCRIPTION]", alertDescription)
}
if strings.Contains(providerURL, "[SERVICE_NAME]") { // XXX: Remove this in v4.0.0
providerURL = strings.ReplaceAll(providerURL, "[SERVICE_NAME]", endpointName)
}
if strings.Contains(providerURL, "[ENDPOINT_NAME]") {
providerURL = strings.ReplaceAll(providerURL, "[ENDPOINT_NAME]", endpointName)
}
if strings.Contains(providerURL, "[ALERT_TRIGGERED_OR_RESOLVED]") {
if resolved {
providerURL = strings.ReplaceAll(providerURL, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
} else {
providerURL = strings.ReplaceAll(providerURL, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(false))
}
} }
if len(method) == 0 { if len(method) == 0 {
method = http.MethodGet method = http.MethodGet
} }
bodyBuffer := bytes.NewBuffer([]byte(body)) bodyBuffer := bytes.NewBuffer([]byte(body))
request, _ := http.NewRequest(method, providerURL, bodyBuffer) request, _ := http.NewRequest(method, url, bodyBuffer)
for k, v := range provider.Headers { for k, v := range provider.Headers {
request.Header.Set(k, v) request.Header.Set(k, v)
} }
return request return request
} }
// Send a request to the alert provider and return the body func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
func (provider *AlertProvider) Send(endpointName, alertDescription string, resolved bool) ([]byte, error) { request := provider.buildHTTPRequest(endpoint, alert, resolved)
if os.Getenv("MOCK_ALERT_PROVIDER") == "true" {
if os.Getenv("MOCK_ALERT_PROVIDER_ERROR") == "true" {
return nil, errors.New("error")
}
return []byte("{}"), nil
}
request := provider.buildHTTPRequest(endpointName, alertDescription, resolved)
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request) response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
if err != nil { if err != nil {
return nil, err return err
} }
if response.StatusCode > 399 { if response.StatusCode > 399 {
body, err := ioutil.ReadAll(response.Body) body, _ := io.ReadAll(response.Body)
if err != nil { return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
return nil, fmt.Errorf("call to provider alert returned status code %d", response.StatusCode)
} }
return nil, fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body)) return err
}
return ioutil.ReadAll(response.Body)
} }
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
+169 -77
View File
@@ -1,106 +1,212 @@
package custom package custom
import ( import (
"io/ioutil" "fmt"
"io"
"net/http"
"testing" "testing"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/test"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertProvider_IsValid(t *testing.T) {
t.Run("invalid-provider", func(t *testing.T) {
invalidProvider := AlertProvider{URL: ""} invalidProvider := AlertProvider{URL: ""}
if invalidProvider.IsValid() { if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
})
t.Run("valid-provider", func(t *testing.T) {
validProvider := AlertProvider{URL: "https://example.com"} validProvider := AlertProvider{URL: "https://example.com"}
if validProvider.ClientConfig != nil {
t.Error("provider client config should have been nil prior to IsValid() being executed")
}
if !validProvider.IsValid() { if !validProvider.IsValid() {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
if validProvider.ClientConfig == nil {
t.Error("provider client config should have been set after IsValid() was executed")
}
})
} }
func TestAlertProvider_buildHTTPRequestWhenResolved(t *testing.T) { func TestAlertProvider_Send(t *testing.T) {
const ( defer client.InjectHTTPClient(nil)
ExpectedURL = "https://example.com/endpoint-name?event=RESOLVED&description=alert-description" firstDescription := "description-1"
ExpectedBody = "endpoint-name,alert-description,RESOLVED" secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
) )
customAlertProvider := &AlertProvider{ if scenario.ExpectedError && err == nil {
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]", t.Error("expected error, got none")
Body: "[ENDPOINT_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
Headers: nil,
} }
request := customAlertProvider.buildHTTPRequest("endpoint-name", "alert-description", true) if !scenario.ExpectedError && err != nil {
if request.URL.String() != ExpectedURL { t.Error("expected no error, got", err.Error())
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
} }
body, _ := ioutil.ReadAll(request.Body) })
if string(body) != ExpectedBody {
t.Error("expected body to be", ExpectedBody, "was", string(body))
} }
} }
func TestAlertProvider_buildHTTPRequestWhenTriggered(t *testing.T) { func TestAlertProvider_buildHTTPRequest(t *testing.T) {
const ( customAlertProvider := &AlertProvider{
ExpectedURL = "https://example.com/endpoint-name?event=TRIGGERED&description=alert-description" URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]&url=[ENDPOINT_URL]",
ExpectedBody = "endpoint-name,alert-description,TRIGGERED" Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ENDPOINT_URL],[ALERT_TRIGGERED_OR_RESOLVED]",
}
alertDescription := "alert-description"
scenarios := []struct {
AlertProvider *AlertProvider
Resolved bool
ExpectedURL string
ExpectedBody string
}{
{
AlertProvider: customAlertProvider,
Resolved: true,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=RESOLVED&description=alert-description&url=https://example.com",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,RESOLVED",
},
{
AlertProvider: customAlertProvider,
Resolved: false,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=TRIGGERED&description=alert-description&url=https://example.com",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,TRIGGERED",
},
}
for _, scenario := range scenarios {
t.Run(fmt.Sprintf("resolved-%v-with-default-placeholders", scenario.Resolved), func(t *testing.T) {
request := customAlertProvider.buildHTTPRequest(
&core.Endpoint{Name: "endpoint-name", Group: "endpoint-group", URL: "https://example.com"},
&alert.Alert{Description: &alertDescription},
scenario.Resolved,
) )
customAlertProvider := &AlertProvider{ if request.URL.String() != scenario.ExpectedURL {
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]", t.Error("expected URL to be", scenario.ExpectedURL, "got", request.URL.String())
Body: "[ENDPOINT_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
Headers: map[string]string{"Authorization": "Basic hunter2"},
} }
request := customAlertProvider.buildHTTPRequest("endpoint-name", "alert-description", false) body, _ := io.ReadAll(request.Body)
if request.URL.String() != ExpectedURL { if string(body) != scenario.ExpectedBody {
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String()) t.Error("expected body to be", scenario.ExpectedBody, "got", string(body))
} }
body, _ := ioutil.ReadAll(request.Body) })
if string(body) != ExpectedBody {
t.Error("expected body to be", ExpectedBody, "was", string(body))
}
}
func TestAlertProvider_ToCustomAlertProvider(t *testing.T) {
provider := AlertProvider{URL: "https://example.com"}
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{}, true)
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
}
if customAlertProvider.URL != "https://example.com" {
t.Error("expected URL to be https://example.com, got", customAlertProvider.URL)
} }
} }
func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) { func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
const (
ExpectedURL = "https://example.com/endpoint-name?event=test&description=alert-description"
ExpectedBody = "endpoint-name,alert-description,test"
)
customAlertProvider := &AlertProvider{ customAlertProvider := &AlertProvider{
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]", URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[ENDPOINT_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]", Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
Headers: nil, Headers: nil,
Placeholders: map[string]map[string]string{ Placeholders: map[string]map[string]string{
"ALERT_TRIGGERED_OR_RESOLVED": { "ALERT_TRIGGERED_OR_RESOLVED": {
"RESOLVED": "test", "RESOLVED": "fixed",
"TRIGGERED": "boom",
}, },
}, },
} }
request := customAlertProvider.buildHTTPRequest("endpoint-name", "alert-description", true) alertDescription := "alert-description"
if request.URL.String() != ExpectedURL { scenarios := []struct {
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String()) AlertProvider *AlertProvider
Resolved bool
ExpectedURL string
ExpectedBody string
}{
{
AlertProvider: customAlertProvider,
Resolved: true,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=fixed&description=alert-description",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,fixed",
},
{
AlertProvider: customAlertProvider,
Resolved: false,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=boom&description=alert-description",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,boom",
},
} }
body, _ := ioutil.ReadAll(request.Body) for _, scenario := range scenarios {
if string(body) != ExpectedBody { t.Run(fmt.Sprintf("resolved-%v-with-custom-placeholders", scenario.Resolved), func(t *testing.T) {
t.Error("expected body to be", ExpectedBody, "was", string(body)) request := customAlertProvider.buildHTTPRequest(
&core.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&alert.Alert{Description: &alertDescription},
scenario.Resolved,
)
if request.URL.String() != scenario.ExpectedURL {
t.Error("expected URL to be", scenario.ExpectedURL, "got", request.URL.String())
}
body, _ := io.ReadAll(request.Body)
if string(body) != scenario.ExpectedBody {
t.Error("expected body to be", scenario.ExpectedBody, "got", string(body))
}
})
} }
} }
func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) { func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) {
customAlertProvider := &AlertProvider{ customAlertProvider := &AlertProvider{
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]", URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[ENDPOINT_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]", Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
Headers: nil,
Placeholders: nil,
} }
if customAlertProvider.GetAlertStatePlaceholderValue(true) != "RESOLVED" { if customAlertProvider.GetAlertStatePlaceholderValue(true) != "RESOLVED" {
t.Error("expected RESOLVED, got", customAlertProvider.GetAlertStatePlaceholderValue(true)) t.Error("expected RESOLVED, got", customAlertProvider.GetAlertStatePlaceholderValue(true))
@@ -110,25 +216,11 @@ func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) {
} }
} }
// TestAlertProvider_isBackwardCompatibleWithServiceRename checks if the custom alerting provider still supports func TestAlertProvider_GetDefaultAlert(t *testing.T) {
// service placeholders after the migration from "service" to "endpoint" if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
// t.Error("expected default alert to be not nil")
// XXX: Remove this in v4.0.0
func TestAlertProvider_isBackwardCompatibleWithServiceRename(t *testing.T) {
const (
ExpectedURL = "https://example.com/endpoint-name?event=TRIGGERED&description=alert-description"
ExpectedBody = "endpoint-name,alert-description,TRIGGERED"
)
customAlertProvider := &AlertProvider{
URL: "https://example.com/[SERVICE_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[SERVICE_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
} }
request := customAlertProvider.buildHTTPRequest("endpoint-name", "alert-description", false) if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
if request.URL.String() != ExpectedURL { t.Error("expected default alert to be nil")
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
}
body, _ := ioutil.ReadAll(request.Body)
if string(body) != ExpectedBody {
t.Error("expected body to be", ExpectedBody, "was", string(body))
} }
} }
+60 -14
View File
@@ -1,12 +1,14 @@
package discord package discord
import ( import (
"bytes"
"fmt" "fmt"
"io"
"net/http" "net/http"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/alerting/provider/custom" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
) )
// AlertProvider is the configuration necessary for sending an alert using Discord // AlertProvider is the configuration necessary for sending an alert using Discord
@@ -14,23 +16,60 @@ type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"` WebhookURL string `yaml:"webhook-url"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
WebhookURL string `yaml:"webhook-url"`
} }
// IsValid returns whether the provider's configuration is valid // IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) IsValid() bool {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
return false
}
registeredGroups[override.Group] = true
}
}
return len(provider.WebhookURL) > 0 return len(provider.WebhookURL) > 0
} }
// ToCustomAlertProvider converts the provider into a custom.AlertProvider // Send an alert using the provider
func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider { func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
var message, results string var message, results string
var colorCode int var colorCode int
if resolved { if resolved {
message = fmt.Sprintf("An alert for **%s** has been resolved after passing successfully %d time(s) in a row", endpoint.Name, alert.SuccessThreshold) message = fmt.Sprintf("An alert for **%s** has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
colorCode = 3066993 colorCode = 3066993
} else { } else {
message = fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", endpoint.Name, alert.FailureThreshold) message = fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
colorCode = 15158332 colorCode = 15158332
} }
for _, conditionResult := range result.ConditionResults { for _, conditionResult := range result.ConditionResults {
@@ -46,10 +85,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, al
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\\n> " + alertDescription description = ":\\n> " + alertDescription
} }
return &custom.AlertProvider{ return fmt.Sprintf(`{
URL: provider.WebhookURL,
Method: http.MethodPost,
Body: fmt.Sprintf(`{
"content": "", "content": "",
"embeds": [ "embeds": [
{ {
@@ -65,9 +101,19 @@ func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, al
] ]
} }
] ]
}`, message, description, colorCode, results), }`, message, description, colorCode, results)
Headers: map[string]string{"Content-Type": "application/json"},
} }
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
return override.WebhookURL
}
}
}
return provider.WebhookURL
} }
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
+223 -35
View File
@@ -3,11 +3,12 @@ package discord
import ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strings"
"testing" "testing"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/test"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertProvider_IsValid(t *testing.T) {
@@ -21,50 +22,237 @@ func TestAlertProvider_IsValid(t *testing.T) {
} }
} }
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) { func TestAlertProvider_IsValidWithOverride(t *testing.T) {
provider := AlertProvider{WebhookURL: "http://example.com"} providerWithInvalidOverrideGroup := AlertProvider{
alertDescription := "test" Overrides: []Override{
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{Name: "svc"}, &alert.Alert{Description: &alertDescription}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true) {
if customAlertProvider == nil { WebhookURL: "http://example.com",
t.Fatal("customAlertProvider shouldn't have been nil") Group: "",
},
},
} }
if !strings.Contains(customAlertProvider.Body, "resolved") { if providerWithInvalidOverrideGroup.IsValid() {
t.Error("customAlertProvider.Body should've contained the substring resolved") t.Error("provider Group shouldn't have been valid")
} }
if customAlertProvider.URL != "http://example.com" { providerWithInvalidOverrideTo := AlertProvider{
t.Errorf("expected URL to be %s, got %s", "http://example.com", customAlertProvider.URL) Overrides: []Override{
{
WebhookURL: "",
Group: "group",
},
},
} }
if customAlertProvider.Method != http.MethodPost { if providerWithInvalidOverrideTo.IsValid() {
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method) t.Error("provider integration key shouldn't have been valid")
} }
body := make(map[string]interface{}) providerWithValidOverride := AlertProvider{
err := json.Unmarshal([]byte(customAlertProvider.Body), &body) WebhookURL: "http://example.com",
if err != nil { Overrides: []Override{
t.Error("expected body to be valid JSON, got error:", err.Error()) {
WebhookURL: "http://example.com",
Group: "group",
},
},
} }
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"] { if !providerWithValidOverride.IsValid() {
t.Errorf("expected $.embeds[0].description to be %s, got %s", expected, body["embeds"].([]interface{})[0].(map[string]interface{})["description"]) t.Error("provider should've been valid")
} }
} }
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) { func TestAlertProvider_Send(t *testing.T) {
provider := AlertProvider{WebhookURL: "http://example.com"} defer client.InjectHTTPClient(nil)
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false) firstDescription := "description-1"
if customAlertProvider == nil { secondDescription := "description-2"
t.Fatal("customAlertProvider shouldn't have been nil") scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
} }
if !strings.Contains(customAlertProvider.Body, "triggered") { for _, scenario := range scenarios {
t.Error("customAlertProvider.Body should've contained the substring triggered") t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
} }
if customAlertProvider.URL != "http://example.com" { if !scenario.ExpectedError && err != nil {
t.Errorf("expected URL to be %s, got %s", "http://example.com", customAlertProvider.URL) t.Error("expected no error, got", err.Error())
} }
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 { func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\n \"content\": \"\",\n \"embeds\": [\n {\n \"title\": \":helmet_with_white_cross: Gatus\",\n \"description\": \"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n> description-1\",\n \"color\": 15158332,\n \"fields\": [\n {\n \"name\": \"Condition results\",\n \"value\": \":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\n \"inline\": false\n }\n ]\n }\n ]\n}",
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\n \"content\": \"\",\n \"embeds\": [\n {\n \"title\": \":helmet_with_white_cross: Gatus\",\n \"description\": \"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row:\\n> description-2\",\n \"color\": 3066993,\n \"fields\": [\n {\n \"name\": \"Condition results\",\n \"value\": \":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\n \"inline\": false\n }\n ]\n }\n ]\n}",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if body != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal([]byte(body), &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error()) t.Error("expected body to be valid JSON, got error:", err.Error())
} }
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
tests := []struct {
Name string
Provider AlertProvider
InputGroup string
ExpectedOutput string
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: nil,
},
InputGroup: "",
ExpectedOutput: "http://example.com",
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: nil,
},
InputGroup: "group",
ExpectedOutput: "http://example.com",
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: []Override{
{
Group: "group",
WebhookURL: "http://example01.com",
},
},
},
InputGroup: "",
ExpectedOutput: "http://example.com",
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: []Override{
{
Group: "group",
WebhookURL: "http://example01.com",
},
},
},
InputGroup: "group",
ExpectedOutput: "http://example01.com",
},
}
for _, tt := range tests {
t.Run(tt.Name, func(t *testing.T) {
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
t.Errorf("AlertProvider.getWebhookURLForGroup() = %v, want %v", got, tt.ExpectedOutput)
}
})
}
} }
+109
View File
@@ -0,0 +1,109 @@
package email
import (
"fmt"
"math"
"strings"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/core"
gomail "gopkg.in/mail.v2"
)
// AlertProvider is the configuration necessary for sending an alert using SMTP
type AlertProvider struct {
From string `yaml:"from"`
Username string `yaml:"username"`
Password string `yaml:"password"`
Host string `yaml:"host"`
Port int `yaml:"port"`
To string `yaml:"to"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
To string `yaml:"to"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.To) == 0 {
return false
}
registeredGroups[override.Group] = true
}
}
return len(provider.From) > 0 && len(provider.Password) > 0 && len(provider.Host) > 0 && len(provider.To) > 0 && provider.Port > 0 && provider.Port < math.MaxUint16
}
// Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
var username string
if len(provider.Username) > 0 {
username = provider.Username
} else {
username = provider.From
}
subject, body := provider.buildMessageSubjectAndBody(endpoint, alert, result, resolved)
m := gomail.NewMessage()
m.SetHeader("From", provider.From)
m.SetHeader("To", strings.Split(provider.getToForGroup(endpoint.Group), ",")...)
m.SetHeader("Subject", subject)
m.SetBody("text/plain", body)
d := gomail.NewDialer(provider.Host, provider.Port, username, provider.Password)
return d.DialAndSend(m)
}
// buildMessageSubjectAndBody builds the message subject and body
func (provider *AlertProvider) buildMessageSubjectAndBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) (string, string) {
var subject, message, results string
if resolved {
subject = fmt.Sprintf("[%s] Alert resolved", endpoint.DisplayName())
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
} else {
subject = fmt.Sprintf("[%s] Alert triggered", endpoint.DisplayName())
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
}
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "✅"
} else {
prefix = "❌"
}
results += fmt.Sprintf("%s %s\n", prefix, conditionResult.Condition)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = "\n\nAlert description: " + alertDescription
}
return subject, message + description + "\n\nCondition results:\n" + results
}
// getToForGroup returns the appropriate email integration to for a given group
func (provider *AlertProvider) getToForGroup(group string) string {
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
return override.To
}
}
}
return provider.To
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
+183
View File
@@ -0,0 +1,183 @@
package email
import (
"testing"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/core"
)
func TestAlertDefaultProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{}
if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{From: "from@example.com", Password: "password", Host: "smtp.gmail.com", Port: 587, To: "to@example.com"}
if !validProvider.IsValid() {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
To: "to@example.com",
Group: "",
},
},
}
if providerWithInvalidOverrideGroup.IsValid() {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
To: "",
Group: "group",
},
},
}
if providerWithInvalidOverrideTo.IsValid() {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
From: "from@example.com",
Password: "password",
Host: "smtp.gmail.com",
Port: 587,
To: "to@example.com",
Overrides: []Override{
{
To: "to@example.com",
Group: "group",
},
},
}
if !providerWithValidOverride.IsValid() {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedSubject string
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedSubject: "[endpoint-name] Alert triggered",
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedSubject: "[endpoint-name] Alert resolved",
ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
subject, body := scenario.Provider.buildMessageSubjectAndBody(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if subject != scenario.ExpectedSubject {
t.Errorf("expected subject to be %s, got %s", scenario.ExpectedSubject, subject)
}
if body != scenario.ExpectedBody {
t.Errorf("expected body to be %s, got %s", scenario.ExpectedBody, body)
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_getToForGroup(t *testing.T) {
tests := []struct {
Name string
Provider AlertProvider
InputGroup string
ExpectedOutput string
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
To: "to@example.com",
Overrides: nil,
},
InputGroup: "",
ExpectedOutput: "to@example.com",
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
To: "to@example.com",
Overrides: nil,
},
InputGroup: "group",
ExpectedOutput: "to@example.com",
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
To: "to@example.com",
Overrides: []Override{
{
Group: "group",
To: "to01@example.com",
},
},
},
InputGroup: "",
ExpectedOutput: "to@example.com",
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
To: "to@example.com",
Overrides: []Override{
{
Group: "group",
To: "to01@example.com",
},
},
},
InputGroup: "group",
ExpectedOutput: "to01@example.com",
},
}
for _, tt := range tests {
t.Run(tt.Name, func(t *testing.T) {
if got := tt.Provider.getToForGroup(tt.InputGroup); got != tt.ExpectedOutput {
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
}
})
}
}
+156
View File
@@ -0,0 +1,156 @@
package googlechat
import (
"bytes"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
)
// AlertProvider is the configuration necessary for sending an alert using Google chat
type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"`
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
WebhookURL string `yaml:"webhook-url"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
if provider.ClientConfig == nil {
provider.ClientConfig = client.GetDefaultConfig()
}
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
return false
}
registeredGroups[override.Group] = true
}
}
return len(provider.WebhookURL) > 0
}
// Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
if err != nil {
return err
}
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
var message, color string
if resolved {
color = "#36A64F"
message = fmt.Sprintf("<font color='%s'>An alert has been resolved after passing successfully %d time(s) in a row</font>", color, alert.SuccessThreshold)
} else {
color = "#DD0000"
message = fmt.Sprintf("<font color='%s'>An alert has been triggered due to having failed %d time(s) in a row</font>", color, alert.FailureThreshold)
}
var results string
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "✅"
} else {
prefix = "❌"
}
results += fmt.Sprintf("%s %s<br>", prefix, conditionResult.Condition)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":: " + alertDescription
}
return fmt.Sprintf(`{
"cards": [
{
"sections": [
{
"widgets": [
{
"keyValue": {
"topLabel": "%s [%s]",
"content": "%s",
"contentMultiline": "true",
"bottomLabel": "%s",
"icon": "BOOKMARK"
}
},
{
"keyValue": {
"topLabel": "Condition results",
"content": "%s",
"contentMultiline": "true",
"icon": "DESCRIPTION"
}
},
{
"buttons": [
{
"textButton": {
"text": "URL",
"onClick": {
"openLink": {
"url": "%s"
}
}
}
}
]
}
]
}
]
}
]
}`, endpoint.Name, endpoint.Group, message, description, results, endpoint.URL)
}
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
return override.WebhookURL
}
}
}
return provider.WebhookURL
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
@@ -0,0 +1,260 @@
package googlechat
import (
"encoding/json"
"net/http"
"testing"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/test"
)
func TestAlertDefaultProvider_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_IsValidWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
WebhookURL: "http://example.com",
Group: "",
},
},
}
if providerWithInvalidOverrideGroup.IsValid() {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
WebhookURL: "",
Group: "group",
},
},
}
if providerWithInvalidOverrideTo.IsValid() {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
WebhookURL: "http://example.com",
Overrides: []Override{
{
WebhookURL: "http://example.com",
Group: "group",
},
},
}
if !providerWithValidOverride.IsValid() {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\n \"cards\": [\n {\n \"sections\": [\n {\n \"widgets\": [\n {\n \"keyValue\": {\n \"topLabel\": \"endpoint-name []\",\n \"content\": \"\u003cfont color='#DD0000'\u003eAn alert has been triggered due to having failed 3 time(s) in a row\u003c/font\u003e\",\n \"contentMultiline\": \"true\",\n \"bottomLabel\": \":: description-1\",\n \"icon\": \"BOOKMARK\"\n }\n },\n {\n \"keyValue\": {\n \"topLabel\": \"Condition results\",\n \"content\": \"❌ [CONNECTED] == true\u003cbr\u003e❌ [STATUS] == 200\u003cbr\u003e\",\n \"contentMultiline\": \"true\",\n \"icon\": \"DESCRIPTION\"\n }\n },\n {\n \"buttons\": [\n {\n \"textButton\": {\n \"text\": \"URL\",\n \"onClick\": {\n \"openLink\": {\n \"url\": \"\"\n }\n }\n }\n }\n ]\n }\n ]\n }\n ]\n }\n]\n}",
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\n \"cards\": [\n {\n \"sections\": [\n {\n \"widgets\": [\n {\n \"keyValue\": {\n \"topLabel\": \"endpoint-name []\",\n \"content\": \"\u003cfont color='#36A64F'\u003eAn alert has been resolved after passing successfully 5 time(s) in a row\u003c/font\u003e\",\n \"contentMultiline\": \"true\",\n \"bottomLabel\": \":: description-2\",\n \"icon\": \"BOOKMARK\"\n }\n },\n {\n \"keyValue\": {\n \"topLabel\": \"Condition results\",\n \"content\": \"✅ [CONNECTED] == true\u003cbr\u003e✅ [STATUS] == 200\u003cbr\u003e\",\n \"contentMultiline\": \"true\",\n \"icon\": \"DESCRIPTION\"\n }\n },\n {\n \"buttons\": [\n {\n \"textButton\": {\n \"text\": \"URL\",\n \"onClick\": {\n \"openLink\": {\n \"url\": \"\"\n }\n }\n }\n }\n ]\n }\n ]\n }\n ]\n }\n]\n}",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
b, _ := json.Marshal(body)
e, _ := json.Marshal(scenario.ExpectedBody)
if body != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", e, b)
}
out := make(map[string]interface{})
if err := json.Unmarshal([]byte(body), &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
tests := []struct {
Name string
Provider AlertProvider
InputGroup string
ExpectedOutput string
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: nil,
},
InputGroup: "",
ExpectedOutput: "http://example.com",
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: nil,
},
InputGroup: "group",
ExpectedOutput: "http://example.com",
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: []Override{
{
Group: "group",
WebhookURL: "http://example01.com",
},
},
},
InputGroup: "",
ExpectedOutput: "http://example.com",
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: []Override{
{
Group: "group",
WebhookURL: "http://example01.com",
},
},
},
InputGroup: "group",
ExpectedOutput: "http://example01.com",
},
}
for _, tt := range tests {
t.Run(tt.Name, func(t *testing.T) {
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
}
})
}
}
+183
View File
@@ -0,0 +1,183 @@
package matrix
import (
"bytes"
"fmt"
"io"
"math/rand"
"net/http"
"net/url"
"time"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
)
// AlertProvider is the configuration necessary for sending an alert using Matrix
type AlertProvider struct {
MatrixProviderConfig `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
MatrixProviderConfig `yaml:",inline"`
}
const defaultHomeserverURL = "https://matrix-client.matrix.org"
type MatrixProviderConfig struct {
// ServerURL is the custom homeserver to use (optional)
ServerURL string `yaml:"server-url"`
// AccessToken is the bot user's access token to send messages
AccessToken string `yaml:"access-token"`
// InternalRoomID is the room that the bot user has permissions to send messages to
InternalRoomID string `yaml:"internal-room-id"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.AccessToken) == 0 || len(override.InternalRoomID) == 0 {
return false
}
registeredGroups[override.Group] = true
}
}
return len(provider.AccessToken) > 0 && len(provider.InternalRoomID) > 0
}
// Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
config := provider.getConfigForGroup(endpoint.Group)
if config.ServerURL == "" {
config.ServerURL = defaultHomeserverURL
}
// The Matrix endpoint requires a unique transaction ID for each event sent
txnId := randStringBytes(24)
request, err := http.NewRequest(
http.MethodPut,
fmt.Sprintf("%s/_matrix/client/v3/rooms/%s/send/m.room.message/%s?access_token=%s",
config.ServerURL,
url.PathEscape(config.InternalRoomID),
txnId,
url.QueryEscape(config.AccessToken),
),
buffer,
)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
return fmt.Sprintf(`{
"msgtype": "m.text",
"format": "org.matrix.custom.html",
"body": "%s",
"formatted_body": "%s"
}`,
buildPlaintextMessageBody(endpoint, alert, result, resolved),
buildHTMLMessageBody(endpoint, alert, result, resolved),
)
}
// buildPlaintextMessageBody builds the message body in plaintext to include in request
func buildPlaintextMessageBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
var message, results string
if resolved {
message = fmt.Sprintf("An alert for `%s` has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
} else {
message = fmt.Sprintf("An alert for `%s` has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
}
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "✓"
} else {
prefix = "✕"
}
results += fmt.Sprintf("\\n%s - %s", prefix, conditionResult.Condition)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = "\\n" + alertDescription
}
return fmt.Sprintf("%s%s\\n%s", message, description, results)
}
// buildHTMLMessageBody builds the message body in HTML to include in request
func buildHTMLMessageBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
var message, results string
if resolved {
message = fmt.Sprintf("An alert for <code>%s</code> has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
} else {
message = fmt.Sprintf("An alert for <code>%s</code> has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
}
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "✅"
} else {
prefix = "❌"
}
results += fmt.Sprintf("<li>%s - <code>%s</code></li>", prefix, conditionResult.Condition)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = fmt.Sprintf("\\n<blockquote>%s</blockquote>", alertDescription)
}
return fmt.Sprintf("<h3>%s</h3>%s\\n<h5>Condition results</h5><ul>%s</ul>", message, description, results)
}
// getConfigForGroup returns the appropriate configuration for a given group
func (provider *AlertProvider) getConfigForGroup(group string) MatrixProviderConfig {
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
return override.MatrixProviderConfig
}
}
}
return provider.MatrixProviderConfig
}
func randStringBytes(n int) string {
// All the compatible characters to use in a transaction ID
const availableCharacterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, n)
rand.Seed(time.Now().UnixNano())
for i := range b {
b[i] = availableCharacterBytes[rand.Intn(len(availableCharacterBytes))]
}
return string(b)
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
+331
View File
@@ -0,0 +1,331 @@
package matrix
import (
"encoding/json"
"net/http"
"testing"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/test"
)
func TestAlertProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{
MatrixProviderConfig: MatrixProviderConfig{
AccessToken: "",
InternalRoomID: "",
},
}
if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{
MatrixProviderConfig: MatrixProviderConfig{
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
}
if !validProvider.IsValid() {
t.Error("provider should've been valid")
}
validProviderWithHomeserver := AlertProvider{
MatrixProviderConfig: MatrixProviderConfig{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
}
if !validProviderWithHomeserver.IsValid() {
t.Error("provider with homeserver should've been valid")
}
}
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
Group: "",
MatrixProviderConfig: MatrixProviderConfig{
AccessToken: "",
InternalRoomID: "",
},
},
},
}
if providerWithInvalidOverrideGroup.IsValid() {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
Group: "group",
MatrixProviderConfig: MatrixProviderConfig{
AccessToken: "",
InternalRoomID: "",
},
},
},
}
if providerWithInvalidOverrideTo.IsValid() {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
MatrixProviderConfig: MatrixProviderConfig{
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
Overrides: []Override{
{
Group: "group",
MatrixProviderConfig: MatrixProviderConfig{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
},
},
}
if !providerWithValidOverride.IsValid() {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\n\t\"msgtype\": \"m.text\",\n\t\"format\": \"org.matrix.custom.html\",\n\t\"body\": \"An alert for `endpoint-name` has been triggered due to having failed 3 time(s) in a row\\ndescription-1\\n\\n✕ - [CONNECTED] == true\\n✕ - [STATUS] == 200\",\n\t\"formatted_body\": \"<h3>An alert for <code>endpoint-name</code> has been triggered due to having failed 3 time(s) in a row</h3>\\n<blockquote>description-1</blockquote>\\n<h5>Condition results</h5><ul><li>❌ - <code>[CONNECTED] == true</code></li><li>❌ - <code>[STATUS] == 200</code></li></ul>\"\n}",
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\n\t\"msgtype\": \"m.text\",\n\t\"format\": \"org.matrix.custom.html\",\n\t\"body\": \"An alert for `endpoint-name` has been resolved after passing successfully 5 time(s) in a row\\ndescription-2\\n\\n✓ - [CONNECTED] == true\\n✓ - [STATUS] == 200\",\n\t\"formatted_body\": \"<h3>An alert for <code>endpoint-name</code> has been resolved after passing successfully 5 time(s) in a row</h3>\\n<blockquote>description-2</blockquote>\\n<h5>Condition results</h5><ul><li>✅ - <code>[CONNECTED] == true</code></li><li>✅ - <code>[STATUS] == 200</code></li></ul>\"\n}",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if body != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal([]byte(body), &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_getConfigForGroup(t *testing.T) {
tests := []struct {
Name string
Provider AlertProvider
InputGroup string
ExpectedOutput MatrixProviderConfig
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
MatrixProviderConfig: MatrixProviderConfig{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
Overrides: nil,
},
InputGroup: "",
ExpectedOutput: MatrixProviderConfig{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
MatrixProviderConfig: MatrixProviderConfig{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
Overrides: nil,
},
InputGroup: "group",
ExpectedOutput: MatrixProviderConfig{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
MatrixProviderConfig: MatrixProviderConfig{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
Overrides: []Override{
{
Group: "group",
MatrixProviderConfig: MatrixProviderConfig{
ServerURL: "https://example01.com",
AccessToken: "12",
InternalRoomID: "!a:example01.com",
},
},
},
},
InputGroup: "",
ExpectedOutput: MatrixProviderConfig{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
MatrixProviderConfig: MatrixProviderConfig{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
Overrides: []Override{
{
Group: "group",
MatrixProviderConfig: MatrixProviderConfig{
ServerURL: "https://example01.com",
AccessToken: "12",
InternalRoomID: "!a:example01.com",
},
},
},
},
InputGroup: "group",
ExpectedOutput: MatrixProviderConfig{
ServerURL: "https://example01.com",
AccessToken: "12",
InternalRoomID: "!a:example01.com",
},
},
}
for _, tt := range tests {
t.Run(tt.Name, func(t *testing.T) {
if got := tt.Provider.getConfigForGroup(tt.InputGroup); got != tt.ExpectedOutput {
t.Errorf("AlertProvider.getConfigForGroup() = %v, want %v", got, tt.ExpectedOutput)
}
})
}
}
+62 -19
View File
@@ -1,13 +1,14 @@
package mattermost package mattermost
import ( import (
"bytes"
"fmt" "fmt"
"io"
"net/http" "net/http"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/alerting/provider/custom" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v3/client" "github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v3/core"
) )
// AlertProvider is the configuration necessary for sending an alert using Mattermost // AlertProvider is the configuration necessary for sending an alert using Mattermost
@@ -15,10 +16,19 @@ type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"` WebhookURL string `yaml:"webhook-url"`
// ClientConfig is the configuration of the client used to communicate with the provider's target // ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client"` ClientConfig *client.Config `yaml:"client,omitempty"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
WebhookURL string `yaml:"webhook-url"`
} }
// IsValid returns whether the provider's configuration is valid // IsValid returns whether the provider's configuration is valid
@@ -26,18 +36,45 @@ func (provider *AlertProvider) IsValid() bool {
if provider.ClientConfig == nil { if provider.ClientConfig == nil {
provider.ClientConfig = client.GetDefaultConfig() provider.ClientConfig = client.GetDefaultConfig()
} }
if provider.Overrides != nil {
registeredGroups := make(map[string]bool)
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
return false
}
registeredGroups[override.Group] = true
}
}
return len(provider.WebhookURL) > 0 return len(provider.WebhookURL) > 0
} }
// ToCustomAlertProvider converts the provider into a custom.AlertProvider // Send an alert using the provider
func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider { func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
var message string buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
var color string request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
if err != nil {
return err
}
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
var message, color string
if resolved { if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.Name, alert.SuccessThreshold) message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
color = "#36A64F" color = "#36A64F"
} else { } else {
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.Name, alert.FailureThreshold) message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
color = "#DD0000" color = "#DD0000"
} }
var results string var results string
@@ -54,11 +91,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, al
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\\n> " + alertDescription description = ":\\n> " + alertDescription
} }
return &custom.AlertProvider{ return fmt.Sprintf(`{
URL: provider.WebhookURL,
Method: http.MethodPost,
ClientConfig: provider.ClientConfig,
Body: fmt.Sprintf(`{
"text": "", "text": "",
"username": "gatus", "username": "gatus",
"icon_url": "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png", "icon_url": "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png",
@@ -83,9 +116,19 @@ func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, al
] ]
} }
] ]
}`, message, message, description, color, endpoint.URL, results), }`, message, message, description, color, endpoint.URL, results)
Headers: map[string]string{"Content-Type": "application/json"},
} }
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
return override.WebhookURL
}
}
}
return provider.WebhookURL
} }
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
+227 -35
View File
@@ -3,11 +3,12 @@ package mattermost
import ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strings"
"testing" "testing"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/test"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertProvider_IsValid(t *testing.T) {
@@ -21,50 +22,241 @@ func TestAlertProvider_IsValid(t *testing.T) {
} }
} }
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) { func TestAlertProvider_IsValidWithOverride(t *testing.T) {
provider := AlertProvider{WebhookURL: "http://example.org"} providerWithInvalidOverrideGroup := AlertProvider{
alertDescription := "test" Overrides: []Override{
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{Name: "svc"}, &alert.Alert{Description: &alertDescription}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true) {
if customAlertProvider == nil { WebhookURL: "http://example.com",
t.Fatal("customAlertProvider shouldn't have been nil") Group: "",
},
},
} }
if !strings.Contains(customAlertProvider.Body, "resolved") {
t.Error("customAlertProvider.Body should've contained the substring resolved") if providerWithInvalidOverrideGroup.IsValid() {
t.Error("provider Group shouldn't have been valid")
} }
if customAlertProvider.URL != "http://example.org" {
t.Errorf("expected URL to be %s, got %s", "http://example.org", customAlertProvider.URL) providerWithInvalidOverrideWebHookUrl := AlertProvider{
Overrides: []Override{
{
WebhookURL: "",
Group: "group",
},
},
} }
if customAlertProvider.Method != http.MethodPost { if providerWithInvalidOverrideWebHookUrl.IsValid() {
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method) t.Error("provider WebHookURL shoudn't have been valid")
} }
body := make(map[string]interface{})
err := json.Unmarshal([]byte(customAlertProvider.Body), &body) providerWithValidOverride := AlertProvider{
if err != nil { WebhookURL: "http://example.com",
t.Error("expected body to be valid JSON, got error:", err.Error()) Overrides: []Override{
{
WebhookURL: "http://example.com",
Group: "group",
},
},
} }
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"] { if !providerWithValidOverride.IsValid() {
t.Errorf("expected $.attachments[0].description to be %s, got %s", expected, body["attachments"].([]interface{})[0].(map[string]interface{})["text"]) t.Error("provider should've been valid")
} }
} }
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) { func TestAlertProvider_Send(t *testing.T) {
provider := AlertProvider{WebhookURL: "http://example.org"} defer client.InjectHTTPClient(nil)
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false) firstDescription := "description-1"
if customAlertProvider == nil { secondDescription := "description-2"
t.Fatal("customAlertProvider shouldn't have been nil") scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
} }
if !strings.Contains(customAlertProvider.Body, "triggered") { for _, scenario := range scenarios {
t.Error("customAlertProvider.Body should've contained the substring triggered") t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
} }
if customAlertProvider.URL != "http://example.org" { if !scenario.ExpectedError && err != nil {
t.Errorf("expected URL to be %s, got %s", "http://example.org", customAlertProvider.URL) t.Error("expected no error, got", err.Error())
} }
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 { func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\n \"text\": \"\",\n \"username\": \"gatus\",\n \"icon_url\": \"https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png\",\n \"attachments\": [\n {\n \"title\": \":rescue_worker_helmet: Gatus\",\n \"fallback\": \"Gatus - An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row\",\n \"text\": \"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row:\\n> description-1\",\n \"short\": false,\n \"color\": \"#DD0000\",\n \"fields\": [\n {\n \"title\": \"URL\",\n \"value\": \"\",\n \"short\": false\n },\n {\n \"title\": \"Condition results\",\n \"value\": \":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\n \"short\": false\n }\n ]\n }\n ]\n}",
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\n \"text\": \"\",\n \"username\": \"gatus\",\n \"icon_url\": \"https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png\",\n \"attachments\": [\n {\n \"title\": \":rescue_worker_helmet: Gatus\",\n \"fallback\": \"Gatus - An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row\",\n \"text\": \"An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row:\\n> description-2\",\n \"short\": false,\n \"color\": \"#36A64F\",\n \"fields\": [\n {\n \"title\": \"URL\",\n \"value\": \"\",\n \"short\": false\n },\n {\n \"title\": \"Condition results\",\n \"value\": \":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\n \"short\": false\n }\n ]\n }\n ]\n}",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if body != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal([]byte(body), &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error()) t.Error("expected body to be valid JSON, got error:", err.Error())
} }
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
tests := []struct {
Name string
Provider AlertProvider
InputGroup string
ExpectedOutput string
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: nil,
},
InputGroup: "",
ExpectedOutput: "http://example.com",
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: nil,
},
InputGroup: "group",
ExpectedOutput: "http://example.com",
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: []Override{
{
Group: "group",
WebhookURL: "http://example01.com",
},
},
},
InputGroup: "",
ExpectedOutput: "http://example.com",
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: []Override{
{
Group: "group",
WebhookURL: "http://example01.com",
},
},
},
InputGroup: "group",
ExpectedOutput: "http://example01.com",
},
}
for _, tt := range tests {
t.Run(tt.Name, func(t *testing.T) {
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
}
})
}
} }
+44 -22
View File
@@ -1,12 +1,15 @@
package messagebird package messagebird
import ( import (
"bytes"
"encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/alerting/provider/custom" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
) )
const ( const (
@@ -20,7 +23,7 @@ type AlertProvider struct {
Recipients string `yaml:"recipients"` Recipients string `yaml:"recipients"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
} }
// IsValid returns whether the provider's configuration is valid // IsValid returns whether the provider's configuration is valid
@@ -28,28 +31,47 @@ func (provider *AlertProvider) IsValid() bool {
return len(provider.AccessKey) > 0 && len(provider.Originator) > 0 && len(provider.Recipients) > 0 return len(provider.AccessKey) > 0 && len(provider.Originator) > 0 && len(provider.Recipients) > 0
} }
// ToCustomAlertProvider converts the provider into a custom.AlertProvider // Send an alert using the provider
// Reference doc for messagebird https://developers.messagebird.com/api/sms-messaging/#send-outbound-sms // Reference doc for messagebird: https://developers.messagebird.com/api/sms-messaging/#send-outbound-sms
func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, _ *core.Result, resolved bool) *custom.AlertProvider { func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", fmt.Sprintf("AccessKey %s", provider.AccessKey))
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
type Body struct {
Originator string `json:"originator"`
Recipients string `json:"recipients"`
Body string `json:"body"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
var message string var message string
if resolved { if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription()) message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
} else { } else {
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.Name, alert.GetDescription()) message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
}
return &custom.AlertProvider{
URL: restAPIURL,
Method: http.MethodPost,
Body: fmt.Sprintf(`{
"originator": "%s",
"recipients": "%s",
"body": "%s"
}`, provider.Originator, provider.Recipients, message),
Headers: map[string]string{
"Content-Type": "application/json",
"Authorization": fmt.Sprintf("AccessKey %s", provider.AccessKey),
},
} }
body, _ := json.Marshal(Body{
Originator: provider.Originator,
Recipients: provider.Recipients,
Body: message,
})
return body
} }
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
+127 -43
View File
@@ -3,11 +3,12 @@ package messagebird
import ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strings"
"testing" "testing"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/test"
) )
func TestMessagebirdAlertProvider_IsValid(t *testing.T) { func TestMessagebirdAlertProvider_IsValid(t *testing.T) {
@@ -25,54 +26,137 @@ func TestMessagebirdAlertProvider_IsValid(t *testing.T) {
} }
} }
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) { func TestAlertProvider_Send(t *testing.T) {
provider := AlertProvider{ defer client.InjectHTTPClient(nil)
AccessKey: "1", firstDescription := "description-1"
Originator: "1", secondDescription := "description-2"
Recipients: "1", scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
} }
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{}, true) for _, scenario := range scenarios {
if customAlertProvider == nil { t.Run(scenario.Name, func(t *testing.T) {
t.Fatal("customAlertProvider shouldn't have been nil") client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
} }
if !strings.Contains(customAlertProvider.Body, "RESOLVED") { if !scenario.ExpectedError && err != nil {
t.Error("customAlertProvider.Body should've contained the substring RESOLVED") t.Error("expected no error, got", err.Error())
} }
if customAlertProvider.URL != "https://rest.messagebird.com/messages" { })
t.Errorf("expected URL to be %s, got %s", "https://rest.messagebird.com/messages", customAlertProvider.URL)
}
if customAlertProvider.Method != http.MethodPost {
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
}
body := make(map[string]interface{})
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
if err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
} }
} }
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) { func TestAlertProvider_buildRequestBody(t *testing.T) {
provider := AlertProvider{ firstDescription := "description-1"
AccessKey: "1", secondDescription := "description-2"
Originator: "1", scenarios := []struct {
Recipients: "1", Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{AccessKey: "1", Originator: "2", Recipients: "3"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"originator\":\"2\",\"recipients\":\"3\",\"body\":\"TRIGGERED: endpoint-name - description-1\"}",
},
{
Name: "resolved",
Provider: AlertProvider{AccessKey: "4", Originator: "5", Recipients: "6"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"originator\":\"5\",\"recipients\":\"6\",\"body\":\"RESOLVED: endpoint-name - description-2\"}",
},
} }
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{}, false) for _, scenario := range scenarios {
if customAlertProvider == nil { t.Run(scenario.Name, func(t *testing.T) {
t.Fatal("customAlertProvider shouldn't have been nil") body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
} }
if !strings.Contains(customAlertProvider.Body, "TRIGGERED") { out := make(map[string]interface{})
t.Error("customAlertProvider.Body should've contained the substring TRIGGERED") if err := json.Unmarshal([]byte(body), &out); err != nil {
}
if customAlertProvider.URL != "https://rest.messagebird.com/messages" {
t.Errorf("expected URL to be %s, got %s", "https://rest.messagebird.com/messages", customAlertProvider.URL)
}
if customAlertProvider.Method != http.MethodPost {
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
}
body := make(map[string]interface{})
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
if err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error()) t.Error("expected body to be valid JSON, got error:", err.Error())
} }
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
} }
+94
View File
@@ -0,0 +1,94 @@
package ntfy
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
)
const (
DefaultURL = "https://ntfy.sh"
DefaultPriority = 3
)
// AlertProvider is the configuration necessary for sending an alert using Slack
type AlertProvider struct {
Topic string `yaml:"topic"`
URL string `yaml:"url,omitempty"` // Defaults to DefaultURL
Priority int `yaml:"priority,omitempty"` // Defaults to DefaultPriority
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
if len(provider.URL) == 0 {
provider.URL = DefaultURL
}
if provider.Priority == 0 {
provider.Priority = DefaultPriority
}
return len(provider.URL) > 0 && len(provider.Topic) > 0 && provider.Priority > 0 && provider.Priority < 6
}
// Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
request, err := http.NewRequest(http.MethodPost, provider.URL, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
type Body struct {
Topic string `json:"topic"`
Title string `json:"title"`
Message string `json:"message"`
Tags []string `json:"tags"`
Priority int `json:"priority"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
var message, tag string
if len(alert.GetDescription()) > 0 {
message = endpoint.DisplayName() + " - " + alert.GetDescription()
} else {
message = endpoint.DisplayName()
}
if resolved {
tag = "white_check_mark"
} else {
tag = "x"
}
body, _ := json.Marshal(Body{
Topic: provider.Topic,
Title: "Gatus",
Message: message,
Tags: []string{tag},
Priority: provider.Priority,
})
return body
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
+104
View File
@@ -0,0 +1,104 @@
package ntfy
import (
"encoding/json"
"testing"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/core"
)
func TestAlertDefaultProvider_IsValid(t *testing.T) {
scenarios := []struct {
name string
provider AlertProvider
expected bool
}{
{
name: "valid",
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
expected: true,
},
{
name: "no-url-should-use-default-value",
provider: AlertProvider{Topic: "example", Priority: 1},
expected: true,
},
{
name: "invalid-topic",
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "", Priority: 1},
expected: false,
},
{
name: "invalid-priority-too-high",
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 6},
expected: false,
},
{
name: "invalid-priority-too-low",
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: -1},
expected: false,
},
{
name: "no-priority-should-use-default-value",
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example"},
expected: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
if scenario.provider.IsValid() != scenario.expected {
t.Errorf("expected %t, got %t", scenario.expected, scenario.provider.IsValid())
}
})
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"topic\":\"example\",\"title\":\"Gatus\",\"message\":\"endpoint-name - description-1\",\"tags\":[\"x\"],\"priority\":1}",
},
{
Name: "resolved",
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 2},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"topic\":\"example\",\"title\":\"Gatus\",\"message\":\"endpoint-name - description-2\",\"tags\":[\"white_check_mark\"],\"priority\":2}",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
}
}
+240
View File
@@ -0,0 +1,240 @@
package opsgenie
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
)
const (
restAPI = "https://api.opsgenie.com/v2/alerts"
)
type AlertProvider struct {
// APIKey to use for
APIKey string `yaml:"api-key"`
// Priority to be used in Opsgenie alert payload
//
// default: P1
Priority string `yaml:"priority"`
// Source define source to be used in Opsgenie alert payload
//
// default: gatus
Source string `yaml:"source"`
// EntityPrefix is a prefix to be used in entity argument in Opsgenie alert payload
//
// default: gatus-
EntityPrefix string `yaml:"entity-prefix"`
//AliasPrefix is a prefix to be used in alias argument in Opsgenie alert payload
//
// default: gatus-healthcheck-
AliasPrefix string `yaml:"alias-prefix"`
// Tags to be used in Opsgenie alert payload
//
// default: []
Tags []string `yaml:"tags"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
return len(provider.APIKey) > 0
}
// Send an alert using the provider
//
// Relevant: https://docs.opsgenie.com/docs/alert-api
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
err := provider.createAlert(endpoint, alert, result, resolved)
if err != nil {
return err
}
if resolved {
err = provider.closeAlert(endpoint, alert)
if err != nil {
return err
}
}
if alert.IsSendingOnResolved() {
if resolved {
// The alert has been resolved and there's no error, so we can clear the alert's ResolveKey
alert.ResolveKey = ""
} else {
alert.ResolveKey = provider.alias(buildKey(endpoint))
}
}
return nil
}
func (provider *AlertProvider) createAlert(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
payload := provider.buildCreateRequestBody(endpoint, alert, result, resolved)
_, err := provider.sendRequest(restAPI, http.MethodPost, payload)
return err
}
func (provider *AlertProvider) closeAlert(endpoint *core.Endpoint, alert *alert.Alert) error {
payload := provider.buildCloseRequestBody(endpoint, alert)
url := restAPI + "/" + provider.alias(buildKey(endpoint)) + "/close?identifierType=alias"
_, err := provider.sendRequest(url, http.MethodPost, payload)
return err
}
func (provider *AlertProvider) sendRequest(url, method string, payload interface{}) (*http.Response, error) {
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("fail to build alert payload: %v", payload)
}
request, err := http.NewRequest(method, url, bytes.NewBuffer(body))
if err != nil {
return nil, err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "GenieKey "+provider.APIKey)
res, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return nil, err
}
if res.StatusCode > 399 {
rBody, _ := io.ReadAll(res.Body)
return nil, fmt.Errorf("call to provider alert returned status code %d: %s", res.StatusCode, string(rBody))
}
return res, nil
}
func (provider *AlertProvider) buildCreateRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) alertCreateRequest {
var message, description, results string
if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription())
description = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
} else {
message = fmt.Sprintf("%s - %s", endpoint.Name, alert.GetDescription())
description = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
}
if endpoint.Group != "" {
message = fmt.Sprintf("[%s] %s", endpoint.Group, message)
}
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "▣"
} else {
prefix = "▢"
}
results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
}
description = description + "\n" + results
key := buildKey(endpoint)
details := map[string]string{
"endpoint:url": endpoint.URL,
"endpoint:group": endpoint.Group,
"result:hostname": result.Hostname,
"result:ip": result.IP,
"result:dns_code": result.DNSRCode,
"result:errors": strings.Join(result.Errors, ","),
}
for k, v := range details {
if v == "" {
delete(details, k)
}
}
if result.HTTPStatus > 0 {
details["result:http_status"] = strconv.Itoa(result.HTTPStatus)
}
return alertCreateRequest{
Message: message,
Description: description,
Source: provider.source(),
Priority: provider.priority(),
Alias: provider.alias(key),
Entity: provider.entity(key),
Tags: provider.Tags,
Details: details,
}
}
func (provider *AlertProvider) buildCloseRequestBody(endpoint *core.Endpoint, alert *alert.Alert) alertCloseRequest {
return alertCloseRequest{
Source: buildKey(endpoint),
Note: fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription()),
}
}
func (provider *AlertProvider) source() string {
source := provider.Source
if source == "" {
return "gatus"
}
return source
}
func (provider *AlertProvider) alias(key string) string {
alias := provider.AliasPrefix
if alias == "" {
alias = "gatus-healthcheck-"
}
return alias + key
}
func (provider *AlertProvider) entity(key string) string {
alias := provider.EntityPrefix
if alias == "" {
alias = "gatus-"
}
return alias + key
}
func (provider *AlertProvider) priority() string {
priority := provider.Priority
if priority == "" {
return "P1"
}
return priority
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
func buildKey(endpoint *core.Endpoint) string {
name := toKebabCase(endpoint.Name)
if endpoint.Group == "" {
return name
}
return toKebabCase(endpoint.Group) + "-" + name
}
func toKebabCase(val string) string {
return strings.ToLower(strings.ReplaceAll(val, " ", "-"))
}
type alertCreateRequest struct {
Message string `json:"message"`
Priority string `json:"priority"`
Source string `json:"source"`
Entity string `json:"entity"`
Alias string `json:"alias"`
Description string `json:"description"`
Tags []string `json:"tags,omitempty"`
Details map[string]string `json:"details"`
}
type alertCloseRequest struct {
Source string `json:"source"`
Note string `json:"note"`
}
+319
View File
@@ -0,0 +1,319 @@
package opsgenie
import (
"net/http"
"reflect"
"testing"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/test"
)
func TestAlertProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{APIKey: ""}
if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{APIKey: "00000000-0000-0000-0000-000000000000"}
if !validProvider.IsValid() {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
description := "my bad alert description"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &description, SuccessThreshold: 1, FailureThreshold: 1},
Resolved: false,
ExpectedError: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
},
{
Name: "triggered-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedError: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedError: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
},
{
Name: "resolved-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedError: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
t.Parallel()
description := "alert description"
scenarios := []struct {
Name string
Provider *AlertProvider
Alert *alert.Alert
Endpoint *core.Endpoint
Result *core.Result
Resolved bool
want alertCreateRequest
}{
{
Name: "missing all params (unresolved)",
Provider: &AlertProvider{},
Alert: &alert.Alert{},
Endpoint: &core.Endpoint{},
Result: &core.Result{},
Resolved: false,
want: alertCreateRequest{
Message: " - ",
Priority: "P1",
Source: "gatus",
Entity: "gatus-",
Alias: "gatus-healthcheck-",
Description: "An alert for ** has been triggered due to having failed 0 time(s) in a row\n",
Tags: nil,
Details: map[string]string{},
},
},
{
Name: "missing all params (resolved)",
Provider: &AlertProvider{},
Alert: &alert.Alert{},
Endpoint: &core.Endpoint{},
Result: &core.Result{},
Resolved: true,
want: alertCreateRequest{
Message: "RESOLVED: - ",
Priority: "P1",
Source: "gatus",
Entity: "gatus-",
Alias: "gatus-healthcheck-",
Description: "An alert for ** has been resolved after passing successfully 0 time(s) in a row\n",
Tags: nil,
Details: map[string]string{},
},
},
{
Name: "with default options (unresolved)",
Provider: &AlertProvider{},
Alert: &alert.Alert{
Description: &description,
FailureThreshold: 3,
},
Endpoint: &core.Endpoint{
Name: "my super app",
},
Result: &core.Result{
ConditionResults: []*core.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: true,
},
{
Condition: "[BODY] == OK",
Success: false,
},
},
},
Resolved: false,
want: alertCreateRequest{
Message: "my super app - " + description,
Priority: "P1",
Source: "gatus",
Entity: "gatus-my-super-app",
Alias: "gatus-healthcheck-my-super-app",
Description: "An alert for *my super app* has been triggered due to having failed 3 time(s) in a row\n▣ - `[STATUS] == 200`\n▢ - `[BODY] == OK`\n",
Tags: nil,
Details: map[string]string{},
},
},
{
Name: "with custom options (resolved)",
Provider: &AlertProvider{
Priority: "P5",
EntityPrefix: "oompa-",
AliasPrefix: "loompa-",
Source: "gatus-hc",
Tags: []string{"do-ba-dee-doo"},
},
Alert: &alert.Alert{
Description: &description,
SuccessThreshold: 4,
},
Endpoint: &core.Endpoint{
Name: "my mega app",
},
Result: &core.Result{
ConditionResults: []*core.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: true,
},
},
},
Resolved: true,
want: alertCreateRequest{
Message: "RESOLVED: my mega app - " + description,
Priority: "P5",
Source: "gatus-hc",
Entity: "oompa-my-mega-app",
Alias: "loompa-my-mega-app",
Description: "An alert for *my mega app* has been resolved after passing successfully 4 time(s) in a row\n▣ - `[STATUS] == 200`\n",
Tags: []string{"do-ba-dee-doo"},
Details: map[string]string{},
},
},
{
Name: "with default options and details (unresolved)",
Provider: &AlertProvider{
Tags: []string{"foo"},
},
Alert: &alert.Alert{
Description: &description,
FailureThreshold: 6,
},
Endpoint: &core.Endpoint{
Name: "my app",
Group: "end game",
URL: "https://my.go/app",
},
Result: &core.Result{
HTTPStatus: 400,
Hostname: "my.go",
Errors: []string{"error 01", "error 02"},
Success: false,
ConditionResults: []*core.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: false,
},
},
},
Resolved: false,
want: alertCreateRequest{
Message: "[end game] my app - " + description,
Priority: "P1",
Source: "gatus",
Entity: "gatus-end-game-my-app",
Alias: "gatus-healthcheck-end-game-my-app",
Description: "An alert for *end game/my app* has been triggered due to having failed 6 time(s) in a row\n▢ - `[STATUS] == 200`\n",
Tags: []string{"foo"},
Details: map[string]string{
"endpoint:url": "https://my.go/app",
"endpoint:group": "end game",
"result:hostname": "my.go",
"result:errors": "error 01,error 02",
"result:http_status": "400",
},
},
},
}
for _, scenario := range scenarios {
actual := scenario
t.Run(actual.Name, func(t *testing.T) {
if got := actual.Provider.buildCreateRequestBody(actual.Endpoint, actual.Alert, actual.Result, actual.Resolved); !reflect.DeepEqual(got, actual.want) {
t.Errorf("buildCreateRequestBody() = %v, want %v", got, actual.want)
}
})
}
}
func TestAlertProvider_buildCloseRequestBody(t *testing.T) {
t.Parallel()
description := "alert description"
scenarios := []struct {
Name string
Provider *AlertProvider
Alert *alert.Alert
Endpoint *core.Endpoint
want alertCloseRequest
}{
{
Name: "Missing all values",
Provider: &AlertProvider{},
Alert: &alert.Alert{},
Endpoint: &core.Endpoint{},
want: alertCloseRequest{
Source: "",
Note: "RESOLVED: - ",
},
},
{
Name: "Basic values",
Provider: &AlertProvider{},
Alert: &alert.Alert{
Description: &description,
},
Endpoint: &core.Endpoint{
Name: "endpoint name",
},
want: alertCloseRequest{
Source: "endpoint-name",
Note: "RESOLVED: endpoint name - alert description",
},
},
}
for _, scenario := range scenarios {
actual := scenario
t.Run(actual.Name, func(t *testing.T) {
if got := actual.Provider.buildCloseRequestBody(actual.Endpoint, actual.Alert); !reflect.DeepEqual(got, actual.want) {
t.Errorf("buildCloseRequestBody() = %v, want %v", got, actual.want)
}
})
}
}
+57 -19
View File
@@ -1,12 +1,16 @@
package pagerduty package pagerduty
import ( import (
"bytes"
"encoding/json"
"fmt" "fmt"
"io"
"log"
"net/http" "net/http"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/alerting/provider/custom" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
) )
const ( const (
@@ -18,10 +22,10 @@ type AlertProvider struct {
IntegrationKey string `yaml:"integration-key"` IntegrationKey string `yaml:"integration-key"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration // Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides"` Overrides []Override `yaml:"overrides,omitempty"`
} }
// Override is a case under which the default integration is overridden // Override is a case under which the default integration is overridden
@@ -45,24 +49,56 @@ func (provider *AlertProvider) IsValid() bool {
return len(provider.IntegrationKey) == 32 || len(provider.Overrides) != 0 return len(provider.IntegrationKey) == 32 || len(provider.Overrides) != 0
} }
// ToCustomAlertProvider converts the provider into a custom.AlertProvider // Send an alert using the provider
// //
// relevant: https://developer.pagerduty.com/docs/events-api-v2/trigger-events/ // Relevant: https://developer.pagerduty.com/docs/events-api-v2/trigger-events/
func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, _ *core.Result, resolved bool) *custom.AlertProvider { func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
if alert.IsSendingOnResolved() {
if resolved {
// The alert has been resolved and there's no error, so we can clear the alert's ResolveKey
alert.ResolveKey = ""
} else {
// We need to retrieve the resolve key from the response
body, err := io.ReadAll(response.Body)
var payload pagerDutyResponsePayload
if err = json.Unmarshal(body, &payload); err != nil {
// Silently fail. We don't want to create tons of alerts just because we failed to parse the body.
log.Printf("[pagerduty][Send] Ran into error unmarshaling pagerduty response: %s", err.Error())
} else {
alert.ResolveKey = payload.DedupKey
}
}
}
return nil
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
var message, eventAction, resolveKey string var message, eventAction, resolveKey string
if resolved { if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription()) message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
eventAction = "resolve" eventAction = "resolve"
resolveKey = alert.ResolveKey resolveKey = alert.ResolveKey
} else { } else {
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.Name, alert.GetDescription()) message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
eventAction = "trigger" eventAction = "trigger"
resolveKey = "" resolveKey = ""
} }
return &custom.AlertProvider{ return fmt.Sprintf(`{
URL: restAPIURL,
Method: http.MethodPost,
Body: fmt.Sprintf(`{
"routing_key": "%s", "routing_key": "%s",
"dedup_key": "%s", "dedup_key": "%s",
"event_action": "%s", "event_action": "%s",
@@ -71,11 +107,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, al
"source": "%s", "source": "%s",
"severity": "critical" "severity": "critical"
} }
}`, provider.getIntegrationKeyForGroup(endpoint.Group), resolveKey, eventAction, message, endpoint.Name), }`, provider.getIntegrationKeyForGroup(endpoint.Group), resolveKey, eventAction, message, endpoint.Name)
Headers: map[string]string{
"Content-Type": "application/json",
},
}
} }
// getIntegrationKeyForGroup returns the appropriate pagerduty integration key for a given group // getIntegrationKeyForGroup returns the appropriate pagerduty integration key for a given group
@@ -94,3 +126,9 @@ func (provider *AlertProvider) getIntegrationKeyForGroup(group string) string {
func (provider AlertProvider) GetDefaultAlert() *alert.Alert { func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert
} }
type pagerDutyResponsePayload struct {
Status string `json:"status"`
Message string `json:"message"`
DedupKey string `json:"dedup_key"`
}
+114 -93
View File
@@ -3,11 +3,12 @@ package pagerduty
import ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strings"
"testing" "testing"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/test"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertProvider_IsValid(t *testing.T) {
@@ -57,108 +58,119 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) {
} }
} }
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) { func TestAlertProvider_Send(t *testing.T) {
provider := AlertProvider{IntegrationKey: "00000000000000000000000000000000"} defer client.InjectHTTPClient(nil)
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{}, true) firstDescription := "description-1"
if customAlertProvider == nil { secondDescription := "description-2"
t.Fatal("customAlertProvider shouldn't have been nil") scenarios := []struct {
} Name string
if !strings.Contains(customAlertProvider.Body, "RESOLVED") { Provider AlertProvider
t.Error("customAlertProvider.Body should've contained the substring RESOLVED") Alert alert.Alert
} Resolved bool
if customAlertProvider.URL != "https://events.pagerduty.com/v2/enqueue" { MockRoundTripper test.MockRoundTripper
t.Errorf("expected URL to be %s, got %s", "https://events.pagerduty.com/v2/enqueue", customAlertProvider.URL) ExpectedError bool
} }{
if customAlertProvider.Method != http.MethodPost {
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
}
body := make(map[string]interface{})
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
if err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
}
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlertAndOverride(t *testing.T) {
provider := AlertProvider{
IntegrationKey: "",
Overrides: []Override{
{ {
IntegrationKey: "00000000000000000000000000000000", Name: "triggered",
Group: "group", Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
}, },
},
}
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{}, true)
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
}
if !strings.Contains(customAlertProvider.Body, "RESOLVED") {
t.Error("customAlertProvider.Body should've contained the substring RESOLVED")
}
if customAlertProvider.URL != "https://events.pagerduty.com/v2/enqueue" {
t.Errorf("expected URL to be %s, got %s", "https://events.pagerduty.com/v2/enqueue", customAlertProvider.URL)
}
if customAlertProvider.Method != http.MethodPost {
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
}
body := make(map[string]interface{})
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
if err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
}
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
provider := AlertProvider{IntegrationKey: "00000000000000000000000000000000"}
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{}, false)
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
}
if !strings.Contains(customAlertProvider.Body, "TRIGGERED") {
t.Error("customAlertProvider.Body should've contained the substring TRIGGERED")
}
if customAlertProvider.URL != "https://events.pagerduty.com/v2/enqueue" {
t.Errorf("expected URL to be %s, got %s", "https://events.pagerduty.com/v2/enqueue", customAlertProvider.URL)
}
if customAlertProvider.Method != http.MethodPost {
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
}
body := make(map[string]interface{})
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
if err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
}
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlertAndOverride(t *testing.T) {
provider := AlertProvider{
IntegrationKey: "",
Overrides: []Override{
{ {
IntegrationKey: "00000000000000000000000000000000", Name: "triggered-error",
Group: "group", Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
}, },
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
}, },
} }
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{}, false) for _, scenario := range scenarios {
if customAlertProvider == nil { t.Run(scenario.Name, func(t *testing.T) {
t.Fatal("customAlertProvider shouldn't have been nil") client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
} }
if !strings.Contains(customAlertProvider.Body, "TRIGGERED") { if !scenario.ExpectedError && err != nil {
t.Error("customAlertProvider.Body should've contained the substring TRIGGERED") t.Error("expected no error, got", err.Error())
} }
if customAlertProvider.URL != "https://events.pagerduty.com/v2/enqueue" { })
t.Errorf("expected URL to be %s, got %s", "https://events.pagerduty.com/v2/enqueue", customAlertProvider.URL)
} }
if customAlertProvider.Method != http.MethodPost {
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
} }
body := make(map[string]interface{})
err := json.Unmarshal([]byte(customAlertProvider.Body), &body) func TestAlertProvider_buildRequestBody(t *testing.T) {
if err != nil { description := "test"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{IntegrationKey: "00000000000000000000000000000000"},
Alert: alert.Alert{Description: &description},
Resolved: false,
ExpectedBody: "{\n \"routing_key\": \"00000000000000000000000000000000\",\n \"dedup_key\": \"\",\n \"event_action\": \"trigger\",\n \"payload\": {\n \"summary\": \"TRIGGERED: - test\",\n \"source\": \"\",\n \"severity\": \"critical\"\n }\n}",
},
{
Name: "resolved",
Provider: AlertProvider{IntegrationKey: "00000000000000000000000000000000"},
Alert: alert.Alert{Description: &description, ResolveKey: "key"},
Resolved: true,
ExpectedBody: "{\n \"routing_key\": \"00000000000000000000000000000000\",\n \"dedup_key\": \"key\",\n \"event_action\": \"resolve\",\n \"payload\": {\n \"summary\": \"RESOLVED: - test\",\n \"source\": \"\",\n \"severity\": \"critical\"\n }\n}",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(&core.Endpoint{}, &scenario.Alert, &core.Result{}, scenario.Resolved)
if body != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal([]byte(body), &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error()) t.Error("expected body to be valid JSON, got error:", err.Error())
} }
})
}
} }
func TestAlertProvider_getIntegrationKeyForGroup(t *testing.T) { func TestAlertProvider_getIntegrationKeyForGroup(t *testing.T) {
@@ -223,3 +235,12 @@ func TestAlertProvider_getIntegrationKeyForGroup(t *testing.T) {
}) })
} }
} }
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
+24 -14
View File
@@ -1,17 +1,22 @@
package provider package provider
import ( import (
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/alerting/provider/custom" "github.com/TwiN/gatus/v4/alerting/provider/custom"
"github.com/TwiN/gatus/v3/alerting/provider/discord" "github.com/TwiN/gatus/v4/alerting/provider/discord"
"github.com/TwiN/gatus/v3/alerting/provider/mattermost" "github.com/TwiN/gatus/v4/alerting/provider/email"
"github.com/TwiN/gatus/v3/alerting/provider/messagebird" "github.com/TwiN/gatus/v4/alerting/provider/googlechat"
"github.com/TwiN/gatus/v3/alerting/provider/pagerduty" "github.com/TwiN/gatus/v4/alerting/provider/matrix"
"github.com/TwiN/gatus/v3/alerting/provider/slack" "github.com/TwiN/gatus/v4/alerting/provider/mattermost"
"github.com/TwiN/gatus/v3/alerting/provider/teams" "github.com/TwiN/gatus/v4/alerting/provider/messagebird"
"github.com/TwiN/gatus/v3/alerting/provider/telegram" "github.com/TwiN/gatus/v4/alerting/provider/ntfy"
"github.com/TwiN/gatus/v3/alerting/provider/twilio" "github.com/TwiN/gatus/v4/alerting/provider/opsgenie"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/alerting/provider/pagerduty"
"github.com/TwiN/gatus/v4/alerting/provider/slack"
"github.com/TwiN/gatus/v4/alerting/provider/teams"
"github.com/TwiN/gatus/v4/alerting/provider/telegram"
"github.com/TwiN/gatus/v4/alerting/provider/twilio"
"github.com/TwiN/gatus/v4/core"
) )
// AlertProvider is the interface that each providers should implement // AlertProvider is the interface that each providers should implement
@@ -19,11 +24,11 @@ type AlertProvider interface {
// IsValid returns whether the provider's configuration is valid // IsValid returns whether the provider's configuration is valid
IsValid() bool IsValid() bool
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
GetDefaultAlert() *alert.Alert GetDefaultAlert() *alert.Alert
// Send an alert using the provider
Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error
} }
// ParseWithDefaultAlert parses an Endpoint alert by using the provider's default alert as a baseline // ParseWithDefaultAlert parses an Endpoint alert by using the provider's default alert as a baseline
@@ -52,8 +57,13 @@ var (
// Validate interface implementation on compile // Validate interface implementation on compile
_ AlertProvider = (*custom.AlertProvider)(nil) _ AlertProvider = (*custom.AlertProvider)(nil)
_ AlertProvider = (*discord.AlertProvider)(nil) _ AlertProvider = (*discord.AlertProvider)(nil)
_ AlertProvider = (*email.AlertProvider)(nil)
_ AlertProvider = (*googlechat.AlertProvider)(nil)
_ AlertProvider = (*matrix.AlertProvider)(nil)
_ AlertProvider = (*mattermost.AlertProvider)(nil) _ AlertProvider = (*mattermost.AlertProvider)(nil)
_ AlertProvider = (*messagebird.AlertProvider)(nil) _ AlertProvider = (*messagebird.AlertProvider)(nil)
_ AlertProvider = (*ntfy.AlertProvider)(nil)
_ AlertProvider = (*opsgenie.AlertProvider)(nil)
_ AlertProvider = (*pagerduty.AlertProvider)(nil) _ AlertProvider = (*pagerduty.AlertProvider)(nil)
_ AlertProvider = (*slack.AlertProvider)(nil) _ AlertProvider = (*slack.AlertProvider)(nil)
_ AlertProvider = (*teams.AlertProvider)(nil) _ AlertProvider = (*teams.AlertProvider)(nil)
+1 -1
View File
@@ -3,7 +3,7 @@ package provider
import ( import (
"testing" "testing"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
) )
func TestParseWithDefaultAlert(t *testing.T) { func TestParseWithDefaultAlert(t *testing.T) {
+59 -15
View File
@@ -1,35 +1,72 @@
package slack package slack
import ( import (
"bytes"
"fmt" "fmt"
"io"
"net/http" "net/http"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/alerting/provider/custom" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
) )
// AlertProvider is the configuration necessary for sending an alert using Slack // AlertProvider is the configuration necessary for sending an alert using Slack
type AlertProvider struct { type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"` // Slack webhook URL WebhookURL string `yaml:"webhook-url"` // Slack webhook URL
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
WebhookURL string `yaml:"webhook-url"`
} }
// IsValid returns whether the provider's configuration is valid // IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) IsValid() bool {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
return false
}
registeredGroups[override.Group] = true
}
}
return len(provider.WebhookURL) > 0 return len(provider.WebhookURL) > 0
} }
// ToCustomAlertProvider converts the provider into a custom.AlertProvider // Send an alert using the provider
func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider { func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
var message, color, results string var message, color, results string
if resolved { if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.Name, alert.SuccessThreshold) message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
color = "#36A64F" color = "#36A64F"
} else { } else {
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.Name, alert.FailureThreshold) message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
color = "#DD0000" color = "#DD0000"
} }
for _, conditionResult := range result.ConditionResults { for _, conditionResult := range result.ConditionResults {
@@ -45,10 +82,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, al
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\\n> " + alertDescription description = ":\\n> " + alertDescription
} }
return &custom.AlertProvider{ return fmt.Sprintf(`{
URL: provider.WebhookURL,
Method: http.MethodPost,
Body: fmt.Sprintf(`{
"text": "", "text": "",
"attachments": [ "attachments": [
{ {
@@ -65,9 +99,19 @@ func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, al
] ]
} }
] ]
}`, message, description, color, results), }`, message, description, color, results)
Headers: map[string]string{"Content-Type": "application/json"},
} }
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
return override.WebhookURL
}
}
}
return provider.WebhookURL
} }
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
+244 -37
View File
@@ -3,68 +3,275 @@ package slack
import ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strings"
"testing" "testing"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/test"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertDefaultProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{WebhookURL: ""} invalidProvider := AlertProvider{WebhookURL: ""}
if invalidProvider.IsValid() { if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{WebhookURL: "http://example.com"} validProvider := AlertProvider{WebhookURL: "https://example.com"}
if !validProvider.IsValid() { if !validProvider.IsValid() {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) { func TestAlertProvider_IsValidWithOverride(t *testing.T) {
provider := AlertProvider{WebhookURL: "http://example.com"} providerWithInvalidOverrideGroup := AlertProvider{
alertDescription := "test" Overrides: []Override{
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{Name: "svc"}, &alert.Alert{Description: &alertDescription}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true) {
if customAlertProvider == nil { WebhookURL: "http://example.com",
t.Fatal("customAlertProvider shouldn't have been nil") Group: "",
},
},
} }
if !strings.Contains(customAlertProvider.Body, "resolved") { if providerWithInvalidOverrideGroup.IsValid() {
t.Error("customAlertProvider.Body should've contained the substring resolved") t.Error("provider Group shouldn't have been valid")
} }
if customAlertProvider.URL != "http://example.com" { providerWithInvalidOverrideTo := AlertProvider{
t.Errorf("expected URL to be %s, got %s", "http://example.com", customAlertProvider.URL) Overrides: []Override{
{
WebhookURL: "",
Group: "group",
},
},
} }
if customAlertProvider.Method != http.MethodPost { if providerWithInvalidOverrideTo.IsValid() {
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method) t.Error("provider integration key shouldn't have been valid")
} }
body := make(map[string]interface{}) providerWithValidOverride := AlertProvider{
err := json.Unmarshal([]byte(customAlertProvider.Body), &body) WebhookURL: "http://example.com",
if err != nil { Overrides: []Override{
t.Error("expected body to be valid JSON, got error:", err.Error()) {
WebhookURL: "http://example.com",
Group: "group",
},
},
} }
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"] { if !providerWithValidOverride.IsValid() {
t.Errorf("expected $.attachments[0].description to be %s, got %s", expected, body["attachments"].([]interface{})[0].(map[string]interface{})["text"]) t.Error("provider should've been valid")
} }
} }
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) { func TestAlertProvider_Send(t *testing.T) {
provider := AlertProvider{WebhookURL: "http://example.com"} defer client.InjectHTTPClient(nil)
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false) firstDescription := "description-1"
if customAlertProvider == nil { secondDescription := "description-2"
t.Fatal("customAlertProvider shouldn't have been nil") scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
} }
if !strings.Contains(customAlertProvider.Body, "triggered") { for _, scenario := range scenarios {
t.Error("customAlertProvider.Body should've contained the substring triggered") t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
} }
if customAlertProvider.URL != "http://example.com" { if !scenario.ExpectedError && err != nil {
t.Errorf("expected URL to be %s, got %s", "http://example.com", customAlertProvider.URL) t.Error("expected no error, got", err.Error())
} }
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 { func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Endpoint core.Endpoint
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{},
Endpoint: core.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\n \"text\": \"\",\n \"attachments\": [\n {\n \"title\": \":helmet_with_white_cross: Gatus\",\n \"text\": \"An alert for *name* has been triggered due to having failed 3 time(s) in a row:\\n> description-1\",\n \"short\": false,\n \"color\": \"#DD0000\",\n \"fields\": [\n {\n \"title\": \"Condition results\",\n \"value\": \":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\n \"short\": false\n }\n ]\n }\n ]\n}",
},
{
Name: "triggered-with-group",
Provider: AlertProvider{},
Endpoint: core.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\n \"text\": \"\",\n \"attachments\": [\n {\n \"title\": \":helmet_with_white_cross: Gatus\",\n \"text\": \"An alert for *group/name* has been triggered due to having failed 3 time(s) in a row:\\n> description-1\",\n \"short\": false,\n \"color\": \"#DD0000\",\n \"fields\": [\n {\n \"title\": \"Condition results\",\n \"value\": \":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\n \"short\": false\n }\n ]\n }\n ]\n}",
},
{
Name: "resolved",
Provider: AlertProvider{},
Endpoint: core.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\n \"text\": \"\",\n \"attachments\": [\n {\n \"title\": \":helmet_with_white_cross: Gatus\",\n \"text\": \"An alert for *name* has been resolved after passing successfully 5 time(s) in a row:\\n> description-2\",\n \"short\": false,\n \"color\": \"#36A64F\",\n \"fields\": [\n {\n \"title\": \"Condition results\",\n \"value\": \":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\n \"short\": false\n }\n ]\n }\n ]\n}",
},
{
Name: "resolved-with-group",
Provider: AlertProvider{},
Endpoint: core.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\n \"text\": \"\",\n \"attachments\": [\n {\n \"title\": \":helmet_with_white_cross: Gatus\",\n \"text\": \"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row:\\n> description-2\",\n \"short\": false,\n \"color\": \"#36A64F\",\n \"fields\": [\n {\n \"title\": \"Condition results\",\n \"value\": \":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\n \"short\": false\n }\n ]\n }\n ]\n}",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&scenario.Endpoint,
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if body != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal([]byte(body), &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error()) t.Error("expected body to be valid JSON, got error:", err.Error())
} }
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
tests := []struct {
Name string
Provider AlertProvider
InputGroup string
ExpectedOutput string
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: nil,
},
InputGroup: "",
ExpectedOutput: "http://example.com",
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: nil,
},
InputGroup: "group",
ExpectedOutput: "http://example.com",
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: []Override{
{
Group: "group",
WebhookURL: "http://example01.com",
},
},
},
InputGroup: "",
ExpectedOutput: "http://example.com",
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: []Override{
{
Group: "group",
WebhookURL: "http://example01.com",
},
},
},
InputGroup: "group",
ExpectedOutput: "http://example01.com",
},
}
for _, tt := range tests {
t.Run(tt.Name, func(t *testing.T) {
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
t.Errorf("AlertProvider.getWebhookURLForGroup() = %v, want %v", got, tt.ExpectedOutput)
}
})
}
} }
+61 -16
View File
@@ -1,12 +1,14 @@
package teams package teams
import ( import (
"bytes"
"fmt" "fmt"
"io"
"net/http" "net/http"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/alerting/provider/custom" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
) )
// AlertProvider is the configuration necessary for sending an alert using Teams // AlertProvider is the configuration necessary for sending an alert using Teams
@@ -14,23 +16,59 @@ type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"` WebhookURL string `yaml:"webhook-url"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
WebhookURL string `yaml:"webhook-url"`
} }
// IsValid returns whether the provider's configuration is valid // IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) IsValid() bool {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
return false
}
registeredGroups[override.Group] = true
}
}
return len(provider.WebhookURL) > 0 return len(provider.WebhookURL) > 0
} }
// ToCustomAlertProvider converts the provider into a custom.AlertProvider // Send an alert using the provider
func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider { func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
var message string buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
var color string request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
var message, color string
if resolved { if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.Name, alert.SuccessThreshold) message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
color = "#36A64F" color = "#36A64F"
} else { } else {
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.Name, alert.FailureThreshold) message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
color = "#DD0000" color = "#DD0000"
} }
var results string var results string
@@ -47,10 +85,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, al
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\\n> " + alertDescription description = ":\\n> " + alertDescription
} }
return &custom.AlertProvider{ return fmt.Sprintf(`{
URL: provider.WebhookURL,
Method: http.MethodPost,
Body: fmt.Sprintf(`{
"@type": "MessageCard", "@type": "MessageCard",
"@context": "http://schema.org/extensions", "@context": "http://schema.org/extensions",
"themeColor": "%s", "themeColor": "%s",
@@ -66,9 +101,19 @@ func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, al
"text": "%s" "text": "%s"
} }
] ]
}`, color, message, description, endpoint.URL, results), }`, color, message, description, endpoint.URL, results)
Headers: map[string]string{"Content-Type": "application/json"},
} }
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
return override.WebhookURL
}
}
}
return provider.WebhookURL
} }
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
+224 -36
View File
@@ -3,14 +3,15 @@ package teams
import ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strings"
"testing" "testing"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/test"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertDefaultProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{WebhookURL: ""} invalidProvider := AlertProvider{WebhookURL: ""}
if invalidProvider.IsValid() { if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
@@ -21,50 +22,237 @@ func TestAlertProvider_IsValid(t *testing.T) {
} }
} }
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) { func TestAlertProvider_IsValidWithOverride(t *testing.T) {
provider := AlertProvider{WebhookURL: "http://example.org"} providerWithInvalidOverrideGroup := AlertProvider{
alertDescription := "test" Overrides: []Override{
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{Name: "svc"}, &alert.Alert{Description: &alertDescription}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true) {
if customAlertProvider == nil { WebhookURL: "http://example.com",
t.Fatal("customAlertProvider shouldn't have been nil") Group: "",
},
},
} }
if !strings.Contains(customAlertProvider.Body, "resolved") { if providerWithInvalidOverrideGroup.IsValid() {
t.Error("customAlertProvider.Body should've contained the substring resolved") t.Error("provider Group shouldn't have been valid")
} }
if customAlertProvider.URL != "http://example.org" { providerWithInvalidOverrideTo := AlertProvider{
t.Errorf("expected URL to be %s, got %s", "http://example.org", customAlertProvider.URL) Overrides: []Override{
{
WebhookURL: "",
Group: "group",
},
},
} }
if customAlertProvider.Method != http.MethodPost { if providerWithInvalidOverrideTo.IsValid() {
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method) t.Error("provider integration key shouldn't have been valid")
} }
body := make(map[string]interface{}) providerWithValidOverride := AlertProvider{
err := json.Unmarshal([]byte(customAlertProvider.Body), &body) WebhookURL: "http://example.com",
if err != nil { Overrides: []Override{
t.Error("expected body to be valid JSON, got error:", err.Error()) {
WebhookURL: "http://example.com",
Group: "group",
},
},
} }
if expected := "An alert for *svc* has been resolved after passing successfully 0 time(s) in a row:\n> test"; expected != body["text"] { if !providerWithValidOverride.IsValid() {
t.Errorf("expected $.text to be %s, got %s", expected, body["text"]) t.Error("provider should've been valid")
} }
} }
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) { func TestAlertProvider_Send(t *testing.T) {
provider := AlertProvider{WebhookURL: "http://example.org"} defer client.InjectHTTPClient(nil)
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false) firstDescription := "description-1"
if customAlertProvider == nil { secondDescription := "description-2"
t.Fatal("customAlertProvider shouldn't have been nil") scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
} }
if !strings.Contains(customAlertProvider.Body, "triggered") { for _, scenario := range scenarios {
t.Error("customAlertProvider.Body should've contained the substring triggered") t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
} }
if customAlertProvider.URL != "http://example.org" { if !scenario.ExpectedError && err != nil {
t.Errorf("expected URL to be %s, got %s", "http://example.org", customAlertProvider.URL) t.Error("expected no error, got", err.Error())
} }
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 { func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\n \"@type\": \"MessageCard\",\n \"@context\": \"http://schema.org/extensions\",\n \"themeColor\": \"#DD0000\",\n \"title\": \"&#x1F6A8; Gatus\",\n \"text\": \"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row:\\n> description-1\",\n \"sections\": [\n {\n \"activityTitle\": \"URL\",\n \"text\": \"\"\n },\n {\n \"activityTitle\": \"Condition results\",\n \"text\": \"&#x274C; - `[CONNECTED] == true`<br/>&#x274C; - `[STATUS] == 200`<br/>\"\n }\n ]\n}",
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\n \"@type\": \"MessageCard\",\n \"@context\": \"http://schema.org/extensions\",\n \"themeColor\": \"#36A64F\",\n \"title\": \"&#x1F6A8; Gatus\",\n \"text\": \"An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row:\\n> description-2\",\n \"sections\": [\n {\n \"activityTitle\": \"URL\",\n \"text\": \"\"\n },\n {\n \"activityTitle\": \"Condition results\",\n \"text\": \"&#x2705; - `[CONNECTED] == true`<br/>&#x2705; - `[STATUS] == 200`<br/>\"\n }\n ]\n}",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if body != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal([]byte(body), &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error()) t.Error("expected body to be valid JSON, got error:", err.Error())
} }
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
tests := []struct {
Name string
Provider AlertProvider
InputGroup string
ExpectedOutput string
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: nil,
},
InputGroup: "",
ExpectedOutput: "http://example.com",
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: nil,
},
InputGroup: "group",
ExpectedOutput: "http://example.com",
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: []Override{
{
Group: "group",
WebhookURL: "http://example01.com",
},
},
},
InputGroup: "",
ExpectedOutput: "http://example.com",
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: []Override{
{
Group: "group",
WebhookURL: "http://example01.com",
},
},
},
InputGroup: "group",
ExpectedOutput: "http://example01.com",
},
}
for _, tt := range tests {
t.Run(tt.Name, func(t *testing.T) {
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
}
})
}
} }
+43 -14
View File
@@ -1,35 +1,69 @@
package telegram package telegram
import ( import (
"bytes"
"fmt" "fmt"
"io"
"net/http" "net/http"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/alerting/provider/custom" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
) )
const defaultAPIURL = "https://api.telegram.org"
// AlertProvider is the configuration necessary for sending an alert using Telegram // AlertProvider is the configuration necessary for sending an alert using Telegram
type AlertProvider struct { type AlertProvider struct {
Token string `yaml:"token"` Token string `yaml:"token"`
ID string `yaml:"id"` ID string `yaml:"id"`
APIURL string `yaml:"api-url"`
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
} }
// IsValid returns whether the provider's configuration is valid // IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) IsValid() bool {
if provider.ClientConfig == nil {
provider.ClientConfig = client.GetDefaultConfig()
}
return len(provider.Token) > 0 && len(provider.ID) > 0 return len(provider.Token) > 0 && len(provider.ID) > 0
} }
// ToCustomAlertProvider converts the provider into a custom.AlertProvider // Send an alert using the provider
func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider { func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
apiURL := provider.APIURL
if apiURL == "" {
apiURL = defaultAPIURL
}
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/bot%s/sendMessage", apiURL, provider.Token), buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
if err != nil {
return err
}
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
var message, results string var message, results string
if resolved { if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved:\\n—\\n _healthcheck passing successfully %d time(s) in a row_\\n— ", endpoint.Name, alert.FailureThreshold) message = fmt.Sprintf("An alert for *%s* has been resolved:\\n—\\n _healthcheck passing successfully %d time(s) in a row_\\n— ", endpoint.DisplayName(), alert.FailureThreshold)
} else { } else {
message = fmt.Sprintf("An alert for *%s* has been triggered:\\n—\\n _healthcheck failed %d time(s) in a row_\\n— ", endpoint.Name, alert.FailureThreshold) message = fmt.Sprintf("An alert for *%s* has been triggered:\\n—\\n _healthcheck failed %d time(s) in a row_\\n— ", endpoint.DisplayName(), alert.FailureThreshold)
} }
for _, conditionResult := range result.ConditionResults { for _, conditionResult := range result.ConditionResults {
var prefix string var prefix string
@@ -46,12 +80,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, al
} else { } else {
text = fmt.Sprintf("⛑ *Gatus* \\n%s \\n*Condition results*\\n%s", message, results) text = fmt.Sprintf("⛑ *Gatus* \\n%s \\n*Condition results*\\n%s", message, results)
} }
return &custom.AlertProvider{ return fmt.Sprintf(`{"chat_id": "%s", "text": "%s", "parse_mode": "MARKDOWN"}`, provider.ID, text)
URL: fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token),
Method: http.MethodPost,
Body: fmt.Sprintf(`{"chat_id": "%s", "text": "%s", "parse_mode": "MARKDOWN"}`, provider.ID, text),
Headers: map[string]string{"Content-Type": "application/json"},
}
} }
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
+133 -56
View File
@@ -2,90 +2,167 @@ package telegram
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"strings"
"testing" "testing"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/test"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertProvider_IsValid(t *testing.T) {
t.Run("invalid-provider", func(t *testing.T) {
invalidProvider := AlertProvider{Token: "", ID: ""} invalidProvider := AlertProvider{Token: "", ID: ""}
if invalidProvider.IsValid() { if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
})
t.Run("valid-provider", func(t *testing.T) {
validProvider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"} validProvider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}
if validProvider.ClientConfig != nil {
t.Error("provider client config should have been nil prior to IsValid() being executed")
}
if !validProvider.IsValid() { if !validProvider.IsValid() {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
if validProvider.ClientConfig == nil {
t.Error("provider client config should have been set after IsValid() was executed")
}
})
} }
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) { func TestAlertProvider_Send(t *testing.T) {
provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"} defer client.InjectHTTPClient(nil)
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true) firstDescription := "description-1"
if customAlertProvider == nil { secondDescription := "description-2"
t.Fatal("customAlertProvider shouldn't have been nil") scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
} }
if !strings.Contains(customAlertProvider.Body, "resolved") { for _, scenario := range scenarios {
t.Error("customAlertProvider.Body should've contained the substring resolved") t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
} }
if customAlertProvider.URL != fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token) { if !scenario.ExpectedError && err != nil {
t.Errorf("expected URL to be %s, got %s", fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token), customAlertProvider.URL) t.Error("expected no error, got", err.Error())
} }
if customAlertProvider.Method != http.MethodPost { })
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
}
body := make(map[string]interface{})
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
//_, err := json.Marshal(customAlertProvider.Body)
if err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
} }
} }
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) { func TestAlertProvider_buildRequestBody(t *testing.T) {
provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "0123456789"} firstDescription := "description-1"
description := "Healthcheck Successful" secondDescription := "description-2"
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{Description: &description}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false) scenarios := []struct {
if customAlertProvider == nil { Name string
t.Fatal("customAlertProvider shouldn't have been nil") Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{ID: "123"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"chat_id\": \"123\", \"text\": \"⛑ *Gatus* \\nAn alert for *endpoint-name* has been triggered:\\n—\\n _healthcheck failed 3 time(s) in a row_\\n— \\n*Description* \\n_description-1_ \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\", \"parse_mode\": \"MARKDOWN\"}",
},
{
Name: "resolved",
Provider: AlertProvider{ID: "123"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"chat_id\": \"123\", \"text\": \"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 3 time(s) in a row_\\n— \\n*Description* \\n_description-2_ \\n\\n*Condition results*\\n✅ - `[CONNECTED] == true`\\n✅ - `[STATUS] == 200`\\n\", \"parse_mode\": \"MARKDOWN\"}",
},
} }
if !strings.Contains(customAlertProvider.Body, "triggered") { for _, scenario := range scenarios {
t.Error("customAlertProvider.Body should've contained the substring triggered") t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if body != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
} }
if customAlertProvider.URL != fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token) { out := make(map[string]interface{})
t.Errorf("expected URL to be %s, got %s", fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token), customAlertProvider.URL) if err := json.Unmarshal([]byte(body), &out); err != nil {
}
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()) t.Error("expected body to be valid JSON, got error:", err.Error())
} }
})
}
} }
func TestAlertProvider_ToCustomAlertProviderWithDescription(t *testing.T) { func TestAlertProvider_GetDefaultAlert(t *testing.T) {
provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "0123456789"} if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false) t.Error("expected default alert to be not nil")
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
} }
if !strings.Contains(customAlertProvider.Body, "triggered") { if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("customAlertProvider.Body should've contained the substring triggered") t.Error("expected default alert to be nil")
}
if customAlertProvider.URL != fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token) {
t.Errorf("expected URL to be %s, got %s", fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token), customAlertProvider.URL)
}
if customAlertProvider.Method != http.MethodPost {
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
}
body := make(map[string]interface{})
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
if err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
} }
} }
+32 -18
View File
@@ -1,14 +1,16 @@
package twilio package twilio
import ( import (
"bytes"
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/url" "net/url"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/alerting/provider/custom" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
) )
// AlertProvider is the configuration necessary for sending an alert using Twilio // AlertProvider is the configuration necessary for sending an alert using Twilio
@@ -19,7 +21,7 @@ type AlertProvider struct {
To string `yaml:"to"` To string `yaml:"to"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
} }
// IsValid returns whether the provider's configuration is valid // IsValid returns whether the provider's configuration is valid
@@ -27,27 +29,39 @@ func (provider *AlertProvider) IsValid() bool {
return len(provider.Token) > 0 && len(provider.SID) > 0 && len(provider.From) > 0 && len(provider.To) > 0 return len(provider.Token) > 0 && len(provider.SID) > 0 && len(provider.From) > 0 && len(provider.To) > 0
} }
// ToCustomAlertProvider converts the provider into a custom.AlertProvider // Send an alert using the provider
func (provider *AlertProvider) ToCustomAlertProvider(endpoint *core.Endpoint, alert *alert.Alert, _ *core.Result, resolved bool) *custom.AlertProvider { func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json", provider.SID), buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(provider.SID+":"+provider.Token))))
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
var message string var message string
if resolved { if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription()) message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
} else { } else {
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.Name, alert.GetDescription()) message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
} }
return &custom.AlertProvider{ return url.Values{
URL: fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json", provider.SID),
Method: http.MethodPost,
Body: url.Values{
"To": {provider.To}, "To": {provider.To},
"From": {provider.From}, "From": {provider.From},
"Body": {message}, "Body": {message},
}.Encode(), }.Encode()
Headers: map[string]string{
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", provider.SID, provider.Token)))),
},
}
} }
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
+47 -47
View File
@@ -1,12 +1,10 @@
package twilio package twilio
import ( import (
"net/http"
"strings"
"testing" "testing"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
) )
func TestTwilioAlertProvider_IsValid(t *testing.T) { func TestTwilioAlertProvider_IsValid(t *testing.T) {
@@ -25,54 +23,56 @@ func TestTwilioAlertProvider_IsValid(t *testing.T) {
} }
} }
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) { func TestAlertProvider_buildRequestBody(t *testing.T) {
provider := AlertProvider{ firstDescription := "description-1"
SID: "1", secondDescription := "description-2"
Token: "2", scenarios := []struct {
From: "3", Name string
To: "4", Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{SID: "1", Token: "2", From: "3", To: "4"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "Body=TRIGGERED%3A+endpoint-name+-+description-1&From=3&To=4",
},
{
Name: "resolved",
Provider: AlertProvider{SID: "1", Token: "2", From: "3", To: "4"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "Body=RESOLVED%3A+endpoint-name+-+description-2&From=3&To=4",
},
} }
description := "alert-description" for _, scenario := range scenarios {
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{Name: "endpoint-name"}, &alert.Alert{Description: &description}, &core.Result{}, true) t.Run(scenario.Name, func(t *testing.T) {
if customAlertProvider == nil { body := scenario.Provider.buildRequestBody(
t.Fatal("customAlertProvider shouldn't have been nil") &core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if body != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
} }
if !strings.Contains(customAlertProvider.Body, "RESOLVED") { })
t.Error("customAlertProvider.Body should've contained the substring RESOLVED")
}
if customAlertProvider.URL != "https://api.twilio.com/2010-04-01/Accounts/1/Messages.json" {
t.Errorf("expected URL to be %s, got %s", "https://api.twilio.com/2010-04-01/Accounts/1/Messages.json", customAlertProvider.URL)
}
if customAlertProvider.Method != http.MethodPost {
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
}
if customAlertProvider.Body != "Body=RESOLVED%3A+endpoint-name+-+alert-description&From=3&To=4" {
t.Errorf("expected body to be %s, got %s", "Body=RESOLVED%3A+endpoint-name+-+alert-description&From=3&To=4", customAlertProvider.Body)
} }
} }
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) { func TestAlertProvider_GetDefaultAlert(t *testing.T) {
provider := AlertProvider{ if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
SID: "4", t.Error("expected default alert to be not nil")
Token: "3",
From: "2",
To: "1",
} }
description := "alert-description" if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
customAlertProvider := provider.ToCustomAlertProvider(&core.Endpoint{Name: "endpoint-name"}, &alert.Alert{Description: &description}, &core.Result{}, false) t.Error("expected default alert to be nil")
if customAlertProvider == nil {
t.Fatal("customAlertProvider shouldn't have been nil")
}
if !strings.Contains(customAlertProvider.Body, "TRIGGERED") {
t.Error("customAlertProvider.Body should've contained the substring TRIGGERED")
}
if customAlertProvider.URL != "https://api.twilio.com/2010-04-01/Accounts/4/Messages.json" {
t.Errorf("expected URL to be %s, got %s", "https://api.twilio.com/2010-04-01/Accounts/4/Messages.json", customAlertProvider.URL)
}
if customAlertProvider.Method != http.MethodPost {
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
}
if customAlertProvider.Body != "Body=TRIGGERED%3A+endpoint-name+-+alert-description&From=2&To=1" {
t.Errorf("expected body to be %s, got %s", "Body=TRIGGERED%3A+endpoint-name+-+alert-description&From=2&To=1", customAlertProvider.Body)
} }
} }
+12 -1
View File
@@ -14,8 +14,14 @@ import (
"github.com/go-ping/ping" "github.com/go-ping/ping"
) )
// GetHTTPClient returns the shared HTTP client // injectedHTTPClient is used for testing purposes
var injectedHTTPClient *http.Client
// GetHTTPClient returns the shared HTTP client, or the client from the configuration passed
func GetHTTPClient(config *Config) *http.Client { func GetHTTPClient(config *Config) *http.Client {
if injectedHTTPClient != nil {
return injectedHTTPClient
}
if config == nil { if config == nil {
return defaultConfig.getHTTPClient() return defaultConfig.getHTTPClient()
} }
@@ -104,3 +110,8 @@ func Ping(address string, config *Config) (bool, time.Duration) {
} }
return true, 0 return true, 0
} }
// InjectHTTPClient is used to inject a custom HTTP client for testing purposes
func InjectHTTPClient(httpClient *http.Client) {
injectedHTTPClient = httpClient
}
+74 -1
View File
@@ -1,8 +1,13 @@
package client package client
import ( import (
"bytes"
"io"
"net/http"
"testing" "testing"
"time" "time"
"github.com/TwiN/gatus/v4/test"
) )
func TestGetHTTPClient(t *testing.T) { func TestGetHTTPClient(t *testing.T) {
@@ -10,8 +15,18 @@ func TestGetHTTPClient(t *testing.T) {
Insecure: false, Insecure: false,
IgnoreRedirect: false, IgnoreRedirect: false,
Timeout: 0, Timeout: 0,
DNSResolver: "tcp://1.1.1.1:53",
OAuth2Config: &OAuth2Config{
ClientID: "00000000-0000-0000-0000-000000000000",
ClientSecret: "secretsauce",
TokenURL: "https://token-server.local/token",
Scopes: []string{"https://application.local/.default"},
},
}
err := cfg.ValidateAndSetDefaults()
if err != nil {
t.Errorf("expected error to be nil, but got: `%s`", err)
} }
cfg.ValidateAndSetDefaults()
if GetHTTPClient(cfg) == nil { if GetHTTPClient(cfg) == nil {
t.Error("expected client to not be nil") t.Error("expected client to not be nil")
} }
@@ -146,3 +161,61 @@ func TestCanCreateTCPConnection(t *testing.T) {
t.Error("should've failed, because there's no port in the address") t.Error("should've failed, because there's no port in the address")
} }
} }
// This test checks if a HTTP client configured with `configureOAuth2()` automatically
// performs a Client Credentials OAuth2 flow and adds the obtained token as a `Authorization`
// header to all outgoing HTTP calls.
func TestHttpClientProvidesOAuth2BearerToken(t *testing.T) {
defer InjectHTTPClient(nil)
oAuth2Config := &OAuth2Config{
ClientID: "00000000-0000-0000-0000-000000000000",
ClientSecret: "secretsauce",
TokenURL: "https://token-server.local/token",
Scopes: []string{"https://application.local/.default"},
}
mockHttpClient := &http.Client{
Transport: test.MockRoundTripper(func(r *http.Request) *http.Response {
// if the mock HTTP client tries to get a token from the `token-server`
// we provide the expected token response
if r.Host == "token-server.local" {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(
[]byte(
`{"token_type":"Bearer","expires_in":3599,"ext_expires_in":3599,"access_token":"secret-token"}`,
),
)),
}
}
// to verify the headers were sent as expected, we echo them back in the
// `X-Org-Authorization` header and check if the token value matches our
// mocked `token-server` response
return &http.Response{
StatusCode: http.StatusOK,
Header: map[string][]string{
"X-Org-Authorization": {r.Header.Get("Authorization")},
},
Body: http.NoBody,
}
}),
}
mockHttpClientWithOAuth := configureOAuth2(mockHttpClient, *oAuth2Config)
InjectHTTPClient(mockHttpClientWithOAuth)
request, err := http.NewRequest(http.MethodPost, "http://127.0.0.1:8282", http.NoBody)
if err != nil {
t.Error("expected no error, got", err.Error())
}
response, err := mockHttpClientWithOAuth.Do(request)
if err != nil {
t.Error("expected no error, got", err.Error())
}
if response.Header == nil {
t.Error("expected response headers, but got nil")
}
// the mock response echos the Authorization header used in the request back
// to us as `X-Org-Authorization` header, we check here if the value matches
// our expected token `secret-token`
if response.Header.Get("X-Org-Authorization") != "Bearer secret-token" {
t.Error("exptected `secret-token` as Bearer token in the mocked response header `X-Org-Authorization`, but got", response.Header.Get("X-Org-Authorization"))
}
}
+130 -4
View File
@@ -1,9 +1,18 @@
package client package client
import ( import (
"context"
"crypto/tls" "crypto/tls"
"errors"
"log"
"net"
"net/http" "net/http"
"regexp"
"strconv"
"time" "time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/clientcredentials"
) )
const ( const (
@@ -11,7 +20,10 @@ const (
) )
var ( var (
// DefaultConfig is the default client configuration ErrInvalidDNSResolver = errors.New("invalid DNS resolver specified. Required format is {proto}://{ip}:{port}")
ErrInvalidDNSResolverPort = errors.New("invalid DNS resolver port")
ErrInvalidClientOAuth2Config = errors.New("invalid OAuth2 configuration, all fields are required")
defaultConfig = Config{ defaultConfig = Config{
Insecure: false, Insecure: false,
IgnoreRedirect: false, IgnoreRedirect: false,
@@ -28,22 +40,99 @@ func GetDefaultConfig() *Config {
// Config is the configuration for clients // Config is the configuration for clients
type Config struct { type Config struct {
// Insecure determines whether to skip verifying the server's certificate chain and host name // Insecure determines whether to skip verifying the server's certificate chain and host name
Insecure bool `yaml:"insecure"` Insecure bool `yaml:"insecure,omitempty"`
// IgnoreRedirect determines whether to ignore redirects (true) or follow them (false, default) // IgnoreRedirect determines whether to ignore redirects (true) or follow them (false, default)
IgnoreRedirect bool `yaml:"ignore-redirect"` IgnoreRedirect bool `yaml:"ignore-redirect,omitempty"`
// Timeout for the client // Timeout for the client
Timeout time.Duration `yaml:"timeout"` Timeout time.Duration `yaml:"timeout"`
// DNSResolver override for the HTTP client
// Expected format is {protocol}://{host}:{port}, e.g. tcp://1.1.1.1:53
DNSResolver string `yaml:"dns-resolver,omitempty"`
// OAuth2Config is the OAuth2 configuration used for the client.
//
// If non-nil, the http.Client returned by getHTTPClient will automatically retrieve a token if necessary.
// See configureOAuth2 for more details.
OAuth2Config *OAuth2Config `yaml:"oauth2,omitempty"`
httpClient *http.Client httpClient *http.Client
} }
// DNSResolverConfig is the parsed configuration from the DNSResolver config string.
type DNSResolverConfig struct {
Protocol string
Host string
Port string
}
// OAuth2Config is the configuration for the OAuth2 client credentials flow
type OAuth2Config struct {
TokenURL string `yaml:"token-url"` // e.g. https://dev-12345678.okta.com/token
ClientID string `yaml:"client-id"`
ClientSecret string `yaml:"client-secret"`
Scopes []string `yaml:"scopes"` // e.g. ["openid"]
}
// ValidateAndSetDefaults validates the client configuration and sets the default values if necessary // ValidateAndSetDefaults validates the client configuration and sets the default values if necessary
func (c *Config) ValidateAndSetDefaults() { func (c *Config) ValidateAndSetDefaults() error {
if c.Timeout < time.Millisecond { if c.Timeout < time.Millisecond {
c.Timeout = 10 * time.Second c.Timeout = 10 * time.Second
} }
if c.HasCustomDNSResolver() {
// Validate the DNS resolver now to make sure it will not return an error later.
if _, err := c.parseDNSResolver(); err != nil {
return err
}
}
if c.HasOAuth2Config() && !c.OAuth2Config.isValid() {
return ErrInvalidClientOAuth2Config
}
return nil
}
// HasCustomDNSResolver returns whether a custom DNSResolver is configured
func (c *Config) HasCustomDNSResolver() bool {
return len(c.DNSResolver) > 0
}
// parseDNSResolver parses the DNS resolver into the DNSResolverConfig struct
func (c *Config) parseDNSResolver() (*DNSResolverConfig, error) {
re := regexp.MustCompile(`^(?P<proto>(.*))://(?P<host>[A-Za-z0-9\-\.]+):(?P<port>[0-9]+)?(.*)$`)
matches := re.FindStringSubmatch(c.DNSResolver)
if len(matches) == 0 {
return nil, ErrInvalidDNSResolver
}
r := make(map[string]string)
for i, k := range re.SubexpNames() {
if i != 0 && k != "" {
r[k] = matches[i]
}
}
port, err := strconv.Atoi(r["port"])
if err != nil {
return nil, err
}
if port < 1 || port > 65535 {
return nil, ErrInvalidDNSResolverPort
}
return &DNSResolverConfig{
Protocol: r["proto"],
Host: r["host"],
Port: r["port"],
}, nil
}
// HasOAuth2Config returns true if the client has OAuth2 configuration parameters
func (c *Config) HasOAuth2Config() bool {
return c.OAuth2Config != nil
}
// isValid() returns true if the OAuth2 configuration is valid
func (c *OAuth2Config) isValid() bool {
return len(c.TokenURL) > 0 && len(c.ClientID) > 0 && len(c.ClientSecret) > 0 && len(c.Scopes) > 0
} }
// GetHTTPClient return an HTTP client matching the Config's parameters. // GetHTTPClient return an HTTP client matching the Config's parameters.
@@ -68,6 +157,43 @@ func (c *Config) getHTTPClient() *http.Client {
return nil return nil
}, },
} }
if c.HasCustomDNSResolver() {
dnsResolver, err := c.parseDNSResolver()
if err != nil {
// We're ignoring the error, because it should have been validated on startup ValidateAndSetDefaults.
// It shouldn't happen, but if it does, we'll log it... Better safe than sorry ;)
log.Println("[client][getHTTPClient] THIS SHOULD NOT HAPPEN. Silently ignoring invalid DNS resolver due to error:", err.Error())
} else {
dialer := &net.Dialer{
Resolver: &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
d := net.Dialer{}
return d.DialContext(ctx, dnsResolver.Protocol, dnsResolver.Host+":"+dnsResolver.Port)
},
},
}
c.httpClient.Transport.(*http.Transport).DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.DialContext(ctx, network, addr)
}
}
}
if c.HasOAuth2Config() {
c.httpClient = configureOAuth2(c.httpClient, *c.OAuth2Config)
}
} }
return c.httpClient return c.httpClient
} }
// configureOAuth2 returns an HTTP client that will obtain and refresh tokens as necessary.
// The returned Client and its Transport should not be modified.
func configureOAuth2(httpClient *http.Client, c OAuth2Config) *http.Client {
oauth2cfg := clientcredentials.Config{
ClientID: c.ClientID,
ClientSecret: c.ClientSecret,
Scopes: c.Scopes,
TokenURL: c.TokenURL,
}
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, httpClient)
return oauth2cfg.Client(ctx)
}
+44
View File
@@ -35,3 +35,47 @@ func TestConfig_getHTTPClient(t *testing.T) {
t.Error("expected Config.IgnoreRedirect set to true to cause the HTTP client's CheckRedirect to return http.ErrUseLastResponse") t.Error("expected Config.IgnoreRedirect set to true to cause the HTTP client's CheckRedirect to return http.ErrUseLastResponse")
} }
} }
func TestConfig_ValidateAndSetDefaults_withCustomDNSResolver(t *testing.T) {
type args struct {
dnsResolver string
}
tests := []struct {
name string
args args
wantErr bool
}{
{
name: "with-valid-resolver",
args: args{
dnsResolver: "tcp://1.1.1.1:53",
},
wantErr: false,
},
{
name: "with-invalid-resolver-port",
args: args{
dnsResolver: "tcp://127.0.0.1:99999",
},
wantErr: true,
},
{
name: "with-invalid-resolver-format",
args: args{
dnsResolver: "foobar",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &Config{
DNSResolver: tt.args.dnsResolver,
}
err := cfg.ValidateAndSetDefaults()
if (err != nil) != tt.wantErr {
t.Errorf("ValidateAndSetDefaults() error=%v, wantErr=%v", err, tt.wantErr)
}
})
}
}
+7 -1
View File
@@ -31,7 +31,7 @@ endpoints:
- "[STATUS] == 200" - "[STATUS] == 200"
- name: example-dns-query - name: example-dns-query
url: "8.8.8.8" # Address of the DNS server to use url: "1.1.1.1" # Address of the DNS server to use
interval: 5m interval: 5m
dns: dns:
query-name: "example.com" query-name: "example.com"
@@ -45,3 +45,9 @@ endpoints:
interval: 1m interval: 1m
conditions: conditions:
- "[CONNECTED] == true" - "[CONNECTED] == true"
- name: check-domain-expiration
url: "https://example.org/"
interval: 1h
conditions:
- "[DOMAIN_EXPIRATION] > 720h"
+45 -12
View File
@@ -2,20 +2,22 @@ package config
import ( import (
"errors" "errors"
"io/ioutil" "fmt"
"log" "log"
"os" "os"
"time" "time"
"github.com/TwiN/gatus/v3/alerting" "github.com/TwiN/gatus/v4/alerting"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/alerting/provider" "github.com/TwiN/gatus/v4/alerting/provider"
"github.com/TwiN/gatus/v3/config/maintenance" "github.com/TwiN/gatus/v4/config/maintenance"
"github.com/TwiN/gatus/v3/config/ui" "github.com/TwiN/gatus/v4/config/remote"
"github.com/TwiN/gatus/v3/config/web" "github.com/TwiN/gatus/v4/config/ui"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/config/web"
"github.com/TwiN/gatus/v3/security" "github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v3/storage" "github.com/TwiN/gatus/v4/security"
"github.com/TwiN/gatus/v4/storage"
"github.com/TwiN/gatus/v4/util"
"gopkg.in/yaml.v2" "gopkg.in/yaml.v2"
) )
@@ -86,10 +88,24 @@ type Config struct {
// Maintenance is the configuration for creating a maintenance window in which no alerts are sent // Maintenance is the configuration for creating a maintenance window in which no alerts are sent
Maintenance *maintenance.Config `yaml:"maintenance,omitempty"` Maintenance *maintenance.Config `yaml:"maintenance,omitempty"`
// Remote is the configuration for remote Gatus instances
// WARNING: This is in ALPHA and may change or be completely removed in the future
Remote *remote.Config `yaml:"remote,omitempty"`
filePath string // path to the file from which config was loaded from filePath string // path to the file from which config was loaded from
lastFileModTime time.Time // last modification time lastFileModTime time.Time // last modification time
} }
func (config *Config) GetEndpointByKey(key string) *core.Endpoint {
for i := 0; i < len(config.Endpoints); i++ {
ep := config.Endpoints[i]
if util.ConvertGroupAndEndpointNameToKey(ep.Group, ep.Name) == key {
return ep
}
}
return nil
}
// HasLoadedConfigurationFileBeenModified returns whether the file that the // HasLoadedConfigurationFileBeenModified returns whether the file that the
// configuration has been loaded from has been modified since it was last read // configuration has been loaded from has been modified since it was last read
func (config Config) HasLoadedConfigurationFileBeenModified() bool { func (config Config) HasLoadedConfigurationFileBeenModified() bool {
@@ -142,7 +158,7 @@ func LoadDefaultConfiguration() (*Config, error) {
func readConfigurationFile(fileName string) (config *Config, err error) { func readConfigurationFile(fileName string) (config *Config, err error) {
var bytes []byte var bytes []byte
if bytes, err = ioutil.ReadFile(fileName); err == nil { if bytes, err = os.ReadFile(fileName); err == nil {
// file exists, so we'll parse it and return it // file exists, so we'll parse it and return it
return parseAndValidateConfigBytes(bytes) return parseAndValidateConfigBytes(bytes)
} }
@@ -186,10 +202,22 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
if err := validateStorageConfig(config); err != nil { if err := validateStorageConfig(config); err != nil {
return nil, err return nil, err
} }
if err := validateRemoteConfig(config); err != nil {
return nil, err
}
} }
return return
} }
func validateRemoteConfig(config *Config) error {
if config.Remote != nil {
if err := config.Remote.ValidateAndSetDefaults(); err != nil {
return err
}
}
return nil
}
func validateStorageConfig(config *Config) error { func validateStorageConfig(config *Config) error {
if config.Storage == nil { if config.Storage == nil {
config.Storage = &storage.Config{ config.Storage = &storage.Config{
@@ -240,7 +268,7 @@ func validateEndpointsConfig(config *Config) error {
log.Printf("[config][validateEndpointsConfig] Validating endpoint '%s'", endpoint.Name) log.Printf("[config][validateEndpointsConfig] Validating endpoint '%s'", endpoint.Name)
} }
if err := endpoint.ValidateAndSetDefaults(); err != nil { if err := endpoint.ValidateAndSetDefaults(); err != nil {
return err return fmt.Errorf("invalid endpoint %s: %s", endpoint.DisplayName(), err)
} }
} }
log.Printf("[config][validateEndpointsConfig] Validated %d endpoints", len(config.Endpoints)) log.Printf("[config][validateEndpointsConfig] Validated %d endpoints", len(config.Endpoints))
@@ -274,8 +302,13 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*core.E
alertTypes := []alert.Type{ alertTypes := []alert.Type{
alert.TypeCustom, alert.TypeCustom,
alert.TypeDiscord, alert.TypeDiscord,
alert.TypeGoogleChat,
alert.TypeEmail,
alert.TypeMatrix,
alert.TypeMattermost, alert.TypeMattermost,
alert.TypeMessagebird, alert.TypeMessagebird,
alert.TypeNtfy,
alert.TypeOpsgenie,
alert.TypePagerDuty, alert.TypePagerDuty,
alert.TypeSlack, alert.TypeSlack,
alert.TypeTeams, alert.TypeTeams,
+30 -78
View File
@@ -5,22 +5,21 @@ import (
"testing" "testing"
"time" "time"
"github.com/TwiN/gatus/v3/alerting" "github.com/TwiN/gatus/v4/alerting"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/alerting/provider/custom" "github.com/TwiN/gatus/v4/alerting/provider/custom"
"github.com/TwiN/gatus/v3/alerting/provider/discord" "github.com/TwiN/gatus/v4/alerting/provider/discord"
"github.com/TwiN/gatus/v3/alerting/provider/mattermost" "github.com/TwiN/gatus/v4/alerting/provider/mattermost"
"github.com/TwiN/gatus/v3/alerting/provider/messagebird" "github.com/TwiN/gatus/v4/alerting/provider/messagebird"
"github.com/TwiN/gatus/v3/alerting/provider/pagerduty" "github.com/TwiN/gatus/v4/alerting/provider/pagerduty"
"github.com/TwiN/gatus/v3/alerting/provider/slack" "github.com/TwiN/gatus/v4/alerting/provider/slack"
"github.com/TwiN/gatus/v3/alerting/provider/teams" "github.com/TwiN/gatus/v4/alerting/provider/teams"
"github.com/TwiN/gatus/v3/alerting/provider/telegram" "github.com/TwiN/gatus/v4/alerting/provider/telegram"
"github.com/TwiN/gatus/v3/alerting/provider/twilio" "github.com/TwiN/gatus/v4/alerting/provider/twilio"
"github.com/TwiN/gatus/v3/client" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v3/config/ui" "github.com/TwiN/gatus/v4/config/web"
"github.com/TwiN/gatus/v3/config/web" "github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/storage"
"github.com/TwiN/gatus/v3/storage"
) )
func TestLoadFileThatDoesNotExist(t *testing.T) { func TestLoadFileThatDoesNotExist(t *testing.T) {
@@ -39,10 +38,6 @@ func TestLoadDefaultConfigurationFile(t *testing.T) {
func TestParseAndValidateConfigBytes(t *testing.T) { func TestParseAndValidateConfigBytes(t *testing.T) {
file := t.TempDir() + "/test.db" file := t.TempDir() + "/test.db"
ui.StaticFolder = "../web/static"
defer func() {
ui.StaticFolder = "./web/static"
}()
config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(` config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`
storage: storage:
type: sqlite type: sqlite
@@ -53,7 +48,14 @@ maintenance:
duration: 4h duration: 4h
every: [Monday, Thursday] every: [Monday, Thursday]
ui: ui:
title: Test title: T
header: H
link: https://example.org
buttons:
- name: "Home"
link: "https://example.org"
- name: "Status page"
link: "https://status.example.org"
endpoints: endpoints:
- name: website - name: website
url: https://twin.sh/health url: https://twin.sh/health
@@ -88,8 +90,8 @@ endpoints:
if config.Storage == nil || config.Storage.Path != file || config.Storage.Type != storage.TypeSQLite { if config.Storage == nil || config.Storage.Path != file || config.Storage.Type != storage.TypeSQLite {
t.Error("expected storage to be set to sqlite, got", config.Storage) t.Error("expected storage to be set to sqlite, got", config.Storage)
} }
if config.UI == nil || config.UI.Title != "Test" { if config.UI == nil || config.UI.Title != "T" || config.UI.Header != "H" || config.UI.Link != "https://example.org" || len(config.UI.Buttons) != 2 || config.UI.Buttons[0].Name != "Home" || config.UI.Buttons[0].Link != "https://example.org" || config.UI.Buttons[1].Name != "Status page" || config.UI.Buttons[1].Link != "https://status.example.org" {
t.Error("Expected Config.UI.Title to be Test") t.Error("expected ui to be set to T, H, https://example.org, 2 buttons, Home and Status page, got", config.UI)
} }
if mc := config.Maintenance; mc == nil || mc.Start != "00:00" || !mc.IsEnabled() || mc.Duration != 4*time.Hour || len(mc.Every) != 2 { 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") t.Error("Expected Config.Maintenance to be configured properly")
@@ -1118,7 +1120,7 @@ endpoints:
conditions: conditions:
- "[STATUS] == 200" - "[STATUS] == 200"
`)) `))
if err != core.ErrEndpointWithNoName { if err == nil {
t.Error("should've returned an error") t.Error("should've returned an error")
} }
} }
@@ -1172,12 +1174,12 @@ endpoints:
func TestParseAndValidateConfigBytesWithValidSecurityConfig(t *testing.T) { func TestParseAndValidateConfigBytesWithValidSecurityConfig(t *testing.T) {
const expectedUsername = "admin" const expectedUsername = "admin"
const expectedPasswordHash = "6b97ed68d14eb3f1aa959ce5d49c7dc612e1eb1dafd73b1e705847483fd6a6c809f2ceb4e8df6ff9984c6298ff0285cace6614bf8daa9f0070101b6c89899e22" const expectedPasswordHash = "JDJhJDEwJHRiMnRFakxWazZLdXBzRERQazB1TE8vckRLY05Yb1hSdnoxWU0yQ1FaYXZRSW1McmladDYu"
config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`debug: true config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`debug: true
security: security:
basic: basic:
username: "%s" username: "%s"
password-sha512: "%s" password-bcrypt-base64: "%s"
endpoints: endpoints:
- name: website - name: website
url: https://twin.sh/health url: https://twin.sh/health
@@ -1202,8 +1204,8 @@ endpoints:
if config.Security.Basic.Username != expectedUsername { if config.Security.Basic.Username != expectedUsername {
t.Errorf("config.Security.Basic.Username should've been %s, but was %s", expectedUsername, config.Security.Basic.Username) t.Errorf("config.Security.Basic.Username should've been %s, but was %s", expectedUsername, config.Security.Basic.Username)
} }
if config.Security.Basic.PasswordSha512Hash != expectedPasswordHash { if config.Security.Basic.PasswordBcryptHashBase64Encoded != expectedPasswordHash {
t.Errorf("config.Security.Basic.PasswordSha512Hash should've been %s, but was %s", expectedPasswordHash, config.Security.Basic.PasswordSha512Hash) t.Errorf("config.Security.Basic.PasswordBcryptHashBase64Encoded should've been %s, but was %s", expectedPasswordHash, config.Security.Basic.PasswordBcryptHashBase64Encoded)
} }
} }
@@ -1302,53 +1304,3 @@ endpoints:
t.Error("services should've been merged in endpoints") t.Error("services should've been merged in endpoints")
} }
} }
// XXX: Remove this in v4.0.0
func TestParseAndValidateConfigBytes_backwardCompatibleWithStorageFile(t *testing.T) {
file := t.TempDir() + "/test.db"
config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`
storage:
type: sqlite
file: %s
endpoints:
- name: website
url: https://twin.sh/actuator/health
conditions:
- "[STATUS] == 200"
`, file)))
if err != nil {
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
}
if config.Storage == nil || config.Storage.Path != file || config.Storage.Type != storage.TypeSQLite {
t.Error("expected storage to be set to sqlite, got", config.Storage)
}
}
// XXX: Remove this in v4.0.0
func TestParseAndValidateConfigBytes_backwardCompatibleWithStorageTypeMemoryAndFile(t *testing.T) {
file := t.TempDir() + "/test.db"
config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`
storage:
type: memory
file: %s
endpoints:
- name: website
url: https://twin.sh/actuator/health
conditions:
- "[STATUS] == 200"
`, file)))
if err != nil {
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
}
if config.Storage == nil || config.Storage.Path != file || config.Storage.Type != storage.TypeMemory {
t.Error("expected storage to be set to memory, got", config.Storage)
}
}
-1
View File
@@ -39,7 +39,6 @@ type Config struct {
Every []string `yaml:"every"` Every []string `yaml:"every"`
durationToStartFromMidnight time.Duration durationToStartFromMidnight time.Duration
timeLocation *time.Location
} }
func GetDefaultConfig() *Config { func GetDefaultConfig() *Config {
+35
View File
@@ -98,6 +98,15 @@ func TestConfig_ValidateAndSetDefaults(t *testing.T) {
}, },
expectedError: nil, expectedError: nil,
}, },
{
name: "every-day-explicitly-at-2300",
cfg: &Config{
Start: "23:00",
Duration: time.Hour,
Every: []string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"},
},
expectedError: nil,
},
{ {
name: "every-monday-at-0000", name: "every-monday-at-0000",
cfg: &Config{ cfg: &Config{
@@ -168,6 +177,24 @@ func TestConfig_IsUnderMaintenance(t *testing.T) {
}, },
expected: true, expected: true,
}, },
{
name: "under-maintenance-starting-now-for-8h-explicit-days",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", now.Hour()),
Duration: 8 * time.Hour,
Every: []string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"},
},
expected: true,
},
{
name: "under-maintenance-starting-now-for-23h-explicit-days",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", now.Hour()),
Duration: 23 * time.Hour,
Every: []string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"},
},
expected: true,
},
{ {
name: "under-maintenance-starting-4h-ago-for-8h", name: "under-maintenance-starting-4h-ago-for-8h",
cfg: &Config{ cfg: &Config{
@@ -176,6 +203,14 @@ func TestConfig_IsUnderMaintenance(t *testing.T) {
}, },
expected: true, expected: true,
}, },
{
name: "under-maintenance-starting-22h-ago-for-23h",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", normalizeHour(now.Hour()-22)),
Duration: 23 * time.Hour,
},
expected: true,
},
{ {
name: "under-maintenance-starting-4h-ago-for-3h", name: "under-maintenance-starting-4h-ago-for-3h",
cfg: &Config{ cfg: &Config{
+39
View File
@@ -0,0 +1,39 @@
package remote
import (
"log"
"github.com/TwiN/gatus/v4/client"
)
// NOTICE: This is an experimental alpha feature and may be updated/removed in future versions.
// For more information, see https://github.com/TwiN/gatus/issues/64
type Config struct {
// Instances is a list of remote instances to retrieve endpoint statuses from.
Instances []Instance `yaml:"instances,omitempty"`
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"`
}
type Instance struct {
EndpointPrefix string `yaml:"endpoint-prefix"`
URL string `yaml:"url"`
}
func (c *Config) ValidateAndSetDefaults() error {
if c.ClientConfig == nil {
c.ClientConfig = client.GetDefaultConfig()
} else {
if err := c.ClientConfig.ValidateAndSetDefaults(); err != nil {
return err
}
}
if len(c.Instances) > 0 {
log.Println("WARNING: Your configuration is using 'remote', which is in alpha and may be updated/removed in future versions.")
log.Println("WARNING: See https://github.com/TwiN/gatus/issues/64 for more information")
log.Println("WARNING: This feature is a candidate for removal in future versions. Please comment on the issue above if you need this feature.")
}
return nil
}
+40 -6
View File
@@ -2,31 +2,53 @@ package ui
import ( import (
"bytes" "bytes"
"errors"
"html/template" "html/template"
"github.com/TwiN/gatus/v4/web"
) )
const ( const (
defaultTitle = "Health Dashboard | Gatus" defaultTitle = "Health Dashboard | Gatus"
defaultHeader = "Health Status"
defaultLogo = "" defaultLogo = ""
defaultLink = ""
) )
var ( var (
// StaticFolder is the path to the location of the static folder from the root path of the project ErrButtonValidationFailed = errors.New("invalid button configuration: missing required name or link")
// 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 // Config is the configuration for the UI of Gatus
type Config struct { type Config struct {
Title string `yaml:"title"` // Title of the page Title string `yaml:"title,omitempty"` // Title of the page
Logo string `yaml:"logo"` // Logo to display on the page Header string `yaml:"header,omitempty"` // Header is the text at the top of the page
Logo string `yaml:"logo,omitempty"` // Logo to display on the page
Link string `yaml:"link,omitempty"` // Link to open when clicking on the logo
Buttons []Button `yaml:"buttons,omitempty"` // Buttons to display below the header
}
// Button is the configuration for a button on the UI
type Button struct {
Name string `yaml:"name,omitempty"` // Name is the text to display on the button
Link string `yaml:"link,omitempty"` // Link to open when the button is clicked.
}
// Validate validates the button configuration
func (btn *Button) Validate() error {
if len(btn.Name) == 0 || len(btn.Link) == 0 {
return ErrButtonValidationFailed
}
return nil
} }
// GetDefaultConfig returns a Config struct with the default values // GetDefaultConfig returns a Config struct with the default values
func GetDefaultConfig() *Config { func GetDefaultConfig() *Config {
return &Config{ return &Config{
Title: defaultTitle, Title: defaultTitle,
Header: defaultHeader,
Logo: defaultLogo, Logo: defaultLogo,
Link: defaultLink,
} }
} }
@@ -35,7 +57,19 @@ func (cfg *Config) ValidateAndSetDefaults() error {
if len(cfg.Title) == 0 { if len(cfg.Title) == 0 {
cfg.Title = defaultTitle cfg.Title = defaultTitle
} }
t, err := template.ParseFiles(StaticFolder + "/index.html") if len(cfg.Header) == 0 {
cfg.Header = defaultHeader
}
if len(cfg.Header) == 0 {
cfg.Header = defaultLink
}
for _, btn := range cfg.Buttons {
if err := btn.Validate(); err != nil {
return err
}
}
// Validate that the template works
t, err := template.ParseFS(static.FileSystem, static.IndexPath)
if err != nil { if err != nil {
return err return err
} }
+52 -5
View File
@@ -1,18 +1,65 @@
package ui package ui
import ( import (
"strconv"
"testing" "testing"
) )
func TestConfig_ValidateAndSetDefaults(t *testing.T) { func TestConfig_ValidateAndSetDefaults(t *testing.T) {
StaticFolder = "../../web/static" cfg := &Config{
defer func() { Title: "",
StaticFolder = "./web/static" Header: "",
}() Logo: "",
cfg := &Config{Title: ""} Link: "",
}
if err := cfg.ValidateAndSetDefaults(); err != nil { if err := cfg.ValidateAndSetDefaults(); err != nil {
t.Error("expected no error, got", err.Error()) t.Error("expected no error, got", err.Error())
} }
if cfg.Title != defaultTitle {
t.Errorf("expected title to be %s, got %s", defaultTitle, cfg.Title)
}
if cfg.Header != defaultHeader {
t.Errorf("expected header to be %s, got %s", defaultHeader, cfg.Header)
}
}
func TestButton_Validate(t *testing.T) {
scenarios := []struct {
Name, Link string
ExpectedError error
}{
{
Name: "",
Link: "",
ExpectedError: ErrButtonValidationFailed,
},
{
Name: "",
Link: "link",
ExpectedError: ErrButtonValidationFailed,
},
{
Name: "name",
Link: "",
ExpectedError: ErrButtonValidationFailed,
},
{
Name: "name",
Link: "link",
ExpectedError: nil,
},
}
for i, scenario := range scenarios {
t.Run(strconv.Itoa(i)+"_"+scenario.Name+"_"+scenario.Link, func(t *testing.T) {
button := &Button{
Name: scenario.Name,
Link: scenario.Link,
}
if err := button.Validate(); err != scenario.ExpectedError {
t.Errorf("expected error %v, got %v", scenario.ExpectedError, err)
}
})
}
} }
func TestGetDefaultConfig(t *testing.T) { func TestGetDefaultConfig(t *testing.T) {
+6 -8
View File
@@ -8,10 +8,8 @@ import (
"os" "os"
"time" "time"
"github.com/TwiN/gatus/v3/config/ui" "github.com/TwiN/gatus/v4/config"
"github.com/TwiN/gatus/v3/config/web" "github.com/TwiN/gatus/v4/controller/handler"
"github.com/TwiN/gatus/v3/controller/handler"
"github.com/TwiN/gatus/v3/security"
) )
var ( var (
@@ -21,19 +19,19 @@ var (
) )
// Handle creates the router and starts the server // Handle creates the router and starts the server
func Handle(securityConfig *security.Config, webConfig *web.Config, uiConfig *ui.Config, enableMetrics bool) { func Handle(cfg *config.Config) {
var router http.Handler = handler.CreateRouter(ui.StaticFolder, securityConfig, uiConfig, enableMetrics) var router http.Handler = handler.CreateRouter(cfg)
if os.Getenv("ENVIRONMENT") == "dev" { if os.Getenv("ENVIRONMENT") == "dev" {
router = handler.DevelopmentCORS(router) router = handler.DevelopmentCORS(router)
} }
server = &http.Server{ server = &http.Server{
Addr: fmt.Sprintf("%s:%d", webConfig.Address, webConfig.Port), Addr: fmt.Sprintf("%s:%d", cfg.Web.Address, cfg.Web.Port),
Handler: router, Handler: router,
ReadTimeout: 15 * time.Second, ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second,
IdleTimeout: 15 * time.Second, IdleTimeout: 15 * time.Second,
} }
log.Println("[controller][Handle] Listening on " + webConfig.SocketAddress()) log.Println("[controller][Handle] Listening on " + cfg.Web.SocketAddress())
if os.Getenv("ROUTER_TEST") == "true" { if os.Getenv("ROUTER_TEST") == "true" {
return return
} }
+4 -4
View File
@@ -7,9 +7,9 @@ import (
"os" "os"
"testing" "testing"
"github.com/TwiN/gatus/v3/config" "github.com/TwiN/gatus/v4/config"
"github.com/TwiN/gatus/v3/config/web" "github.com/TwiN/gatus/v4/config/web"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
) )
func TestHandle(t *testing.T) { func TestHandle(t *testing.T) {
@@ -32,7 +32,7 @@ func TestHandle(t *testing.T) {
_ = os.Setenv("ROUTER_TEST", "true") _ = os.Setenv("ROUTER_TEST", "true")
_ = os.Setenv("ENVIRONMENT", "dev") _ = os.Setenv("ENVIRONMENT", "dev")
defer os.Clearenv() defer os.Clearenv()
Handle(cfg.Security, cfg.Web, cfg.UI, cfg.Metrics) Handle(cfg)
defer Shutdown() defer Shutdown()
request, _ := http.NewRequest("GET", "/health", http.NoBody) request, _ := http.NewRequest("GET", "/health", http.NoBody)
responseRecorder := httptest.NewRecorder() responseRecorder := httptest.NewRecorder()
+122 -25
View File
@@ -7,8 +7,10 @@ import (
"strings" "strings"
"time" "time"
"github.com/TwiN/gatus/v3/storage/store" "github.com/TwiN/gatus/v4/config"
"github.com/TwiN/gatus/v3/storage/store/common" "github.com/TwiN/gatus/v4/storage/store"
"github.com/TwiN/gatus/v4/storage/store/common"
"github.com/TwiN/gatus/v4/storage/store/common/paging"
"github.com/gorilla/mux" "github.com/gorilla/mux"
) )
@@ -21,6 +23,16 @@ const (
badgeColorHexVeryBad = "#c7130a" badgeColorHexVeryBad = "#c7130a"
) )
const (
HealthStatusUp = "up"
HealthStatusDown = "down"
HealthStatusUnknown = "?"
)
var (
badgeColors = []string{badgeColorHexAwesome, badgeColorHexGreat, badgeColorHexGood, badgeColorHexPassable, badgeColorHexBad}
)
// UptimeBadge handles the automatic generation of badge based on the group name and endpoint name passed. // UptimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.
// //
// Valid values for {duration}: 7d, 24h, 1h // Valid values for {duration}: 7d, 24h, 1h
@@ -51,18 +63,18 @@ func UptimeBadge(writer http.ResponseWriter, request *http.Request) {
} }
return 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.Header().Set("Content-Type", "image/svg+xml")
writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
writer.Header().Set("Expires", "0")
writer.WriteHeader(http.StatusOK)
_, _ = writer.Write(generateUptimeBadgeSVG(duration, uptime)) _, _ = writer.Write(generateUptimeBadgeSVG(duration, uptime))
} }
// ResponseTimeBadge handles the automatic generation of badge based on the group name and endpoint name passed. // ResponseTimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.
// //
// Valid values for {duration}: 7d, 24h, 1h // Valid values for {duration}: 7d, 24h, 1h
func ResponseTimeBadge(writer http.ResponseWriter, request *http.Request) { func ResponseTimeBadge(config *config.Config) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
variables := mux.Vars(request) variables := mux.Vars(request)
duration := variables["duration"] duration := variables["duration"]
var from time.Time var from time.Time
@@ -89,12 +101,43 @@ func ResponseTimeBadge(writer http.ResponseWriter, request *http.Request) {
} }
return 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.Header().Set("Content-Type", "image/svg+xml")
_, _ = writer.Write(generateResponseTimeBadgeSVG(duration, averageResponseTime)) writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
writer.Header().Set("Expires", "0")
writer.WriteHeader(http.StatusOK)
_, _ = writer.Write(generateResponseTimeBadgeSVG(duration, averageResponseTime, key, config))
}
}
// HealthBadge handles the automatic generation of badge based on the group name and endpoint name passed.
func HealthBadge(writer http.ResponseWriter, request *http.Request) {
variables := mux.Vars(request)
key := variables["key"]
pagingConfig := paging.NewEndpointStatusParams()
status, err := store.Get().GetEndpointStatusByKey(key, pagingConfig.WithResults(1, 1))
if err != nil {
if err == common.ErrEndpointNotFound {
http.Error(writer, err.Error(), http.StatusNotFound)
} else if err == common.ErrInvalidTimeRange {
http.Error(writer, err.Error(), http.StatusBadRequest)
} else {
http.Error(writer, err.Error(), http.StatusInternalServerError)
}
return
}
healthStatus := HealthStatusUnknown
if len(status.Results) > 0 {
if status.Results[0].Success {
healthStatus = HealthStatusUp
} else {
healthStatus = HealthStatusDown
}
}
writer.Header().Set("Content-Type", "image/svg+xml")
writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
writer.Header().Set("Expires", "0")
writer.WriteHeader(http.StatusOK)
_, _ = writer.Write(generateHealthBadgeSVG(healthStatus))
} }
func generateUptimeBadgeSVG(duration string, uptime float64) []byte { func generateUptimeBadgeSVG(duration string, uptime float64) []byte {
@@ -163,7 +206,7 @@ func getBadgeColorFromUptime(uptime float64) string {
return badgeColorHexVeryBad return badgeColorHexVeryBad
} }
func generateResponseTimeBadgeSVG(duration string, averageResponseTime int) []byte { func generateResponseTimeBadgeSVG(duration string, averageResponseTime int, key string, cfg *config.Config) []byte {
var labelWidth, valueWidth int var labelWidth, valueWidth int
switch duration { switch duration {
case "7d": case "7d":
@@ -174,7 +217,7 @@ func generateResponseTimeBadgeSVG(duration string, averageResponseTime int) []by
labelWidth = 105 labelWidth = 105
default: default:
} }
color := getBadgeColorFromResponseTime(averageResponseTime) color := getBadgeColorFromResponseTime(averageResponseTime, key, cfg)
sanitizedValue := strconv.Itoa(averageResponseTime) + "ms" sanitizedValue := strconv.Itoa(averageResponseTime) + "ms"
valueWidth = len(sanitizedValue) * 11 valueWidth = len(sanitizedValue) * 11
width := labelWidth + valueWidth width := labelWidth + valueWidth
@@ -211,17 +254,71 @@ func generateResponseTimeBadgeSVG(duration string, averageResponseTime int) []by
return svg return svg
} }
func getBadgeColorFromResponseTime(responseTime int) string { func getBadgeColorFromResponseTime(responseTime int, key string, cfg *config.Config) string {
if responseTime <= 50 { endpoint := cfg.GetEndpointByKey(key)
return badgeColorHexAwesome // the threshold config requires 5 values, so we can be sure it's set here
} else if responseTime <= 200 { for i := 0; i < 5; i++ {
return badgeColorHexGreat if responseTime <= endpoint.UIConfig.Badge.ResponseTime.Thresholds[i] {
} else if responseTime <= 300 { return badgeColors[i]
return badgeColorHexGood }
} else if responseTime <= 500 {
return badgeColorHexPassable
} else if responseTime <= 750 {
return badgeColorHexBad
} }
return badgeColorHexVeryBad return badgeColorHexVeryBad
} }
func generateHealthBadgeSVG(healthStatus string) []byte {
var labelWidth, valueWidth int
switch healthStatus {
case HealthStatusUp:
valueWidth = 28
case HealthStatusDown:
valueWidth = 44
case HealthStatusUnknown:
valueWidth = 10
default:
}
color := getBadgeColorFromHealth(healthStatus)
labelWidth = 48
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">
health
</text>
<text x="%d" y="14">
health
</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, labelX, valueX, healthStatus, valueX, healthStatus))
return svg
}
func getBadgeColorFromHealth(healthStatus string) string {
if healthStatus == HealthStatusUp {
return badgeColorHexAwesome
} else if healthStatus == HealthStatusDown {
return badgeColorHexVeryBad
}
return badgeColorHexPassable
}
+164 -18
View File
@@ -7,13 +7,14 @@ import (
"testing" "testing"
"time" "time"
"github.com/TwiN/gatus/v3/config" "github.com/TwiN/gatus/v4/config"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v3/storage/store" "github.com/TwiN/gatus/v4/core/ui"
"github.com/TwiN/gatus/v3/watchdog" "github.com/TwiN/gatus/v4/storage/store"
"github.com/TwiN/gatus/v4/watchdog"
) )
func TestUptimeBadge(t *testing.T) { func TestBadge(t *testing.T) {
defer store.Get().Clear() defer store.Get().Clear()
defer cache.Clear() defer cache.Clear()
cfg := &config.Config{ cfg := &config.Config{
@@ -29,9 +30,39 @@ func TestUptimeBadge(t *testing.T) {
}, },
}, },
} }
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()}) testSuccessfulResult = core.Result{
router := CreateRouter("../../web/static", cfg.Security, nil, cfg.Metrics) 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,
},
},
}
cfg.Endpoints[0].UIConfig = ui.GetDefaultConfig()
cfg.Endpoints[1].UIConfig = ui.GetDefaultConfig()
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Connected: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Connected: false, Duration: time.Second, Timestamp: time.Now()})
router := CreateRouter(cfg)
type Scenario struct { type Scenario struct {
Name string Name string
Path string Path string
@@ -89,20 +120,35 @@ func TestUptimeBadge(t *testing.T) {
Path: "/api/v1/endpoints/invalid_key/response-times/7d/badge.svg", Path: "/api/v1/endpoints/invalid_key/response-times/7d/badge.svg",
ExpectedCode: http.StatusNotFound, ExpectedCode: http.StatusNotFound,
}, },
{
Name: "badge-health-up",
Path: "/api/v1/endpoints/core_frontend/health/badge.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-health-down",
Path: "/api/v1/endpoints/core_backend/health/badge.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-health-for-invalid-key",
Path: "/api/v1/endpoints/invalid_key/health/badge.svg",
ExpectedCode: http.StatusNotFound,
},
{ {
Name: "chart-response-time-24h", Name: "chart-response-time-24h",
Path: "/api/v1/endpoints/core_backend/response-times/24h/chart.svg", Path: "/api/v1/endpoints/core_backend/response-times/24h/chart.svg",
ExpectedCode: http.StatusOK, ExpectedCode: http.StatusOK,
}, },
{ // XXX: Remove this in v4.0.0 {
Name: "backward-compatible-services-badge-uptime-1h", Name: "chart-response-time-7d",
Path: "/api/v1/services/core_frontend/uptimes/1h/badge.svg", Path: "/api/v1/endpoints/core_frontend/response-times/7d/chart.svg",
ExpectedCode: http.StatusOK, ExpectedCode: http.StatusOK,
}, },
{ // XXX: Remove this in v4.0.0 {
Name: "backward-compatible-services-chart-response-time-24h", Name: "chart-response-time-with-invalid-duration",
Path: "/api/v1/services/core_backend/response-times/24h/chart.svg", Path: "/api/v1/endpoints/core_backend/response-times/3d/chart.svg",
ExpectedCode: http.StatusOK, ExpectedCode: http.StatusBadRequest,
}, },
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
@@ -176,55 +222,155 @@ func TestGetBadgeColorFromUptime(t *testing.T) {
} }
func TestGetBadgeColorFromResponseTime(t *testing.T) { func TestGetBadgeColorFromResponseTime(t *testing.T) {
defer store.Get().Clear()
defer cache.Clear()
var (
firstCondition = core.Condition("[STATUS] == 200")
secondCondition = core.Condition("[RESPONSE_TIME] < 500")
thirdCondition = core.Condition("[CERTIFICATE_EXPIRATION] < 72h")
)
firstTestEndpoint := core.Endpoint{
Name: "a",
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,
UIConfig: ui.GetDefaultConfig(),
}
secondTestEndpoint := core.Endpoint{
Name: "b",
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,
UIConfig: &ui.Config{
Badge: &ui.Badge{
ResponseTime: &ui.ResponseTime{
Thresholds: []int{100, 500, 1000, 2000, 3000},
},
},
},
}
cfg := &config.Config{
Metrics: true,
Endpoints: []*core.Endpoint{&firstTestEndpoint, &secondTestEndpoint},
}
store.Get().Insert(&firstTestEndpoint, &testSuccessfulResult)
store.Get().Insert(&secondTestEndpoint, &testSuccessfulResult)
scenarios := []struct { scenarios := []struct {
Key string
ResponseTime int ResponseTime int
ExpectedColor string ExpectedColor string
}{ }{
{ {
Key: firstTestEndpoint.Key(),
ResponseTime: 10, ResponseTime: 10,
ExpectedColor: badgeColorHexAwesome, ExpectedColor: badgeColorHexAwesome,
}, },
{ {
Key: firstTestEndpoint.Key(),
ResponseTime: 50, ResponseTime: 50,
ExpectedColor: badgeColorHexAwesome, ExpectedColor: badgeColorHexAwesome,
}, },
{ {
Key: firstTestEndpoint.Key(),
ResponseTime: 75, ResponseTime: 75,
ExpectedColor: badgeColorHexGreat, ExpectedColor: badgeColorHexGreat,
}, },
{ {
Key: firstTestEndpoint.Key(),
ResponseTime: 150, ResponseTime: 150,
ExpectedColor: badgeColorHexGreat, ExpectedColor: badgeColorHexGreat,
}, },
{ {
Key: firstTestEndpoint.Key(),
ResponseTime: 201, ResponseTime: 201,
ExpectedColor: badgeColorHexGood, ExpectedColor: badgeColorHexGood,
}, },
{ {
Key: firstTestEndpoint.Key(),
ResponseTime: 300, ResponseTime: 300,
ExpectedColor: badgeColorHexGood, ExpectedColor: badgeColorHexGood,
}, },
{ {
Key: firstTestEndpoint.Key(),
ResponseTime: 301, ResponseTime: 301,
ExpectedColor: badgeColorHexPassable, ExpectedColor: badgeColorHexPassable,
}, },
{ {
Key: firstTestEndpoint.Key(),
ResponseTime: 450, ResponseTime: 450,
ExpectedColor: badgeColorHexPassable, ExpectedColor: badgeColorHexPassable,
}, },
{ {
Key: firstTestEndpoint.Key(),
ResponseTime: 700, ResponseTime: 700,
ExpectedColor: badgeColorHexBad, ExpectedColor: badgeColorHexBad,
}, },
{ {
Key: firstTestEndpoint.Key(),
ResponseTime: 1500, ResponseTime: 1500,
ExpectedColor: badgeColorHexVeryBad, ExpectedColor: badgeColorHexVeryBad,
}, },
{
Key: secondTestEndpoint.Key(),
ResponseTime: 50,
ExpectedColor: badgeColorHexAwesome,
},
{
Key: secondTestEndpoint.Key(),
ResponseTime: 1500,
ExpectedColor: badgeColorHexPassable,
},
{
Key: secondTestEndpoint.Key(),
ResponseTime: 2222,
ExpectedColor: badgeColorHexBad,
},
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run("response-time-"+strconv.Itoa(scenario.ResponseTime), func(t *testing.T) { t.Run(scenario.Key+"-response-time-"+strconv.Itoa(scenario.ResponseTime), func(t *testing.T) {
if getBadgeColorFromResponseTime(scenario.ResponseTime) != scenario.ExpectedColor { if getBadgeColorFromResponseTime(scenario.ResponseTime, scenario.Key, cfg) != scenario.ExpectedColor {
t.Errorf("expected %s from %d, got %v", scenario.ExpectedColor, scenario.ResponseTime, getBadgeColorFromResponseTime(scenario.ResponseTime)) t.Errorf("expected %s from %d, got %v", scenario.ExpectedColor, scenario.ResponseTime, getBadgeColorFromResponseTime(scenario.ResponseTime, scenario.Key, cfg))
}
})
}
}
func TestGetBadgeColorFromHealth(t *testing.T) {
scenarios := []struct {
HealthStatus string
ExpectedColor string
}{
{
HealthStatus: HealthStatusUp,
ExpectedColor: badgeColorHexAwesome,
},
{
HealthStatus: HealthStatusDown,
ExpectedColor: badgeColorHexVeryBad,
},
{
HealthStatus: HealthStatusUnknown,
ExpectedColor: badgeColorHexPassable,
},
}
for _, scenario := range scenarios {
t.Run("health-"+scenario.HealthStatus, func(t *testing.T) {
if getBadgeColorFromHealth(scenario.HealthStatus) != scenario.ExpectedColor {
t.Errorf("expected %s from %s, got %v", scenario.ExpectedColor, scenario.HealthStatus, getBadgeColorFromHealth(scenario.HealthStatus))
} }
}) })
} }
+5 -2
View File
@@ -7,8 +7,8 @@ import (
"sort" "sort"
"time" "time"
"github.com/TwiN/gatus/v3/storage/store" "github.com/TwiN/gatus/v4/storage/store"
"github.com/TwiN/gatus/v3/storage/store/common" "github.com/TwiN/gatus/v4/storage/store/common"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/wcharczuk/go-chart/v2" "github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing" "github.com/wcharczuk/go-chart/v2/drawing"
@@ -112,6 +112,9 @@ func ResponseTimeChart(writer http.ResponseWriter, r *http.Request) {
Series: []chart.Series{series}, Series: []chart.Series{series},
} }
writer.Header().Set("Content-Type", "image/svg+xml") writer.Header().Set("Content-Type", "image/svg+xml")
writer.Header().Set("Cache-Control", "no-cache, no-store")
writer.Header().Set("Expires", "0")
writer.WriteHeader(http.StatusOK)
if err := graph.Render(chart.SVG, writer); err != nil { if err := graph.Render(chart.SVG, writer); err != nil {
log.Println("[handler][ResponseTimeChart] Failed to render response time chart:", err.Error()) log.Println("[handler][ResponseTimeChart] Failed to render response time chart:", err.Error())
return return
+5 -10
View File
@@ -6,10 +6,10 @@ import (
"testing" "testing"
"time" "time"
"github.com/TwiN/gatus/v3/config" "github.com/TwiN/gatus/v4/config"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v3/storage/store" "github.com/TwiN/gatus/v4/storage/store"
"github.com/TwiN/gatus/v3/watchdog" "github.com/TwiN/gatus/v4/watchdog"
) )
func TestResponseTimeChart(t *testing.T) { func TestResponseTimeChart(t *testing.T) {
@@ -30,7 +30,7 @@ func TestResponseTimeChart(t *testing.T) {
} }
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()}) watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()}) watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
router := CreateRouter("../../web/static", cfg.Security, nil, cfg.Metrics) router := CreateRouter(cfg)
type Scenario struct { type Scenario struct {
Name string Name string
Path string Path string
@@ -58,11 +58,6 @@ func TestResponseTimeChart(t *testing.T) {
Path: "/api/v1/endpoints/invalid_key/response-times/7d/chart.svg", Path: "/api/v1/endpoints/invalid_key/response-times/7d/chart.svg",
ExpectedCode: http.StatusNotFound, ExpectedCode: http.StatusNotFound,
}, },
{ // XXX: Remove this in v4.0.0
Name: "backward-compatible-services-chart-response-time-24h",
Path: "/api/v1/services/core_backend/response-times/24h/chart.svg",
ExpectedCode: http.StatusOK,
},
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
+26
View File
@@ -0,0 +1,26 @@
package handler
import (
"fmt"
"net/http"
"github.com/TwiN/gatus/v4/security"
)
// ConfigHandler is a handler that returns information for the front end of the application.
type ConfigHandler struct {
securityConfig *security.Config
}
func (handler ConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
hasOIDC := false
isAuthenticated := true // Default to true if no security config is set
if handler.securityConfig != nil {
hasOIDC = handler.securityConfig.OIDC != nil
isAuthenticated = handler.securityConfig.IsAuthenticated(r)
}
// Return the config
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(fmt.Sprintf(`{"oidc":%v,"authenticated":%v}`, hasOIDC, isAuthenticated)))
}
+34
View File
@@ -0,0 +1,34 @@
package handler
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/TwiN/gatus/v4/security"
"github.com/gorilla/mux"
)
func TestConfigHandler_ServeHTTP(t *testing.T) {
securityConfig := &security.Config{
OIDC: &security.OIDCConfig{
IssuerURL: "https://sso.gatus.io/",
RedirectURL: "http://localhost:80/authorization-code/callback",
Scopes: []string{"openid"},
AllowedSubjects: []string{"user1@example.com"},
},
}
handler := ConfigHandler{securityConfig: securityConfig}
// Create a fake router. We're doing this because I need the gate to be initialized.
securityConfig.ApplySecurityMiddleware(mux.NewRouter())
// Test the config handler
request, _ := http.NewRequest("GET", "/api/v1/config", http.NoBody)
responseRecorder := httptest.NewRecorder()
handler.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != http.StatusOK {
t.Error("expected code to be 200, but was", responseRecorder.Code)
}
if responseRecorder.Body.String() != `{"oidc":true,"authenticated":false}` {
t.Error("expected body to be `{\"oidc\":true,\"authenticated\":false}`, but was", responseRecorder.Body.String())
}
}
+4
View File
@@ -4,7 +4,11 @@ import "net/http"
func DevelopmentCORS(next http.Handler) http.Handler { func DevelopmentCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Origin", "http://localhost:8081") w.Header().Set("Access-Control-Allow-Origin", "http://localhost:8081")
if r.Method == "OPTIONS" {
return
}
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
}) })
} }
+51 -5
View File
@@ -5,15 +5,20 @@ import (
"compress/gzip" "compress/gzip"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"log" "log"
"net/http" "net/http"
"strings" "strings"
"time" "time"
"github.com/TwiN/gatus/v3/storage/store" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v3/storage/store/common" "github.com/TwiN/gatus/v4/config"
"github.com/TwiN/gatus/v3/storage/store/common/paging" "github.com/TwiN/gatus/v4/config/remote"
"github.com/TwiN/gocache" "github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/storage/store"
"github.com/TwiN/gatus/v4/storage/store/common"
"github.com/TwiN/gatus/v4/storage/store/common/paging"
"github.com/TwiN/gocache/v2"
"github.com/gorilla/mux" "github.com/gorilla/mux"
) )
@@ -28,7 +33,8 @@ var (
// EndpointStatuses handles requests to retrieve all EndpointStatus // EndpointStatuses handles requests to retrieve all EndpointStatus
// Due to the size of the response, this function leverages a cache. // Due to the size of the response, this function leverages a cache.
// Must not be wrapped by GzipHandler // Must not be wrapped by GzipHandler
func EndpointStatuses(writer http.ResponseWriter, r *http.Request) { func EndpointStatuses(cfg *config.Config) http.HandlerFunc {
return func(writer http.ResponseWriter, r *http.Request) {
page, pageSize := extractPageAndPageSizeFromRequest(r) page, pageSize := extractPageAndPageSizeFromRequest(r)
gzipped := strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") gzipped := strings.Contains(r.Header.Get("Accept-Encoding"), "gzip")
var exists bool var exists bool
@@ -50,6 +56,13 @@ func EndpointStatuses(writer http.ResponseWriter, r *http.Request) {
http.Error(writer, err.Error(), http.StatusInternalServerError) http.Error(writer, err.Error(), http.StatusInternalServerError)
return return
} }
// ALPHA: Retrieve endpoint statuses from remote instances
if endpointStatusesFromRemote, err := getEndpointStatusesFromRemoteInstances(cfg.Remote); err != nil {
log.Printf("[handler][EndpointStatuses] Silently failed to retrieve endpoint statuses from remote: %s", err.Error())
} else if endpointStatusesFromRemote != nil {
endpointStatuses = append(endpointStatuses, endpointStatusesFromRemote...)
}
// Marshal endpoint statuses to JSON
data, err = json.Marshal(endpointStatuses) data, err = json.Marshal(endpointStatuses)
if err != nil { if err != nil {
log.Printf("[handler][EndpointStatuses] Unable to marshal object to JSON: %s", err.Error()) log.Printf("[handler][EndpointStatuses] Unable to marshal object to JSON: %s", err.Error())
@@ -71,6 +84,39 @@ func EndpointStatuses(writer http.ResponseWriter, r *http.Request) {
writer.WriteHeader(http.StatusOK) writer.WriteHeader(http.StatusOK)
_, _ = writer.Write(data) _, _ = writer.Write(data)
} }
}
func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*core.EndpointStatus, error) {
if remoteConfig == nil || len(remoteConfig.Instances) == 0 {
return nil, nil
}
var endpointStatusesFromAllRemotes []*core.EndpointStatus
httpClient := client.GetHTTPClient(remoteConfig.ClientConfig)
for _, instance := range remoteConfig.Instances {
response, err := httpClient.Get(instance.URL)
if err != nil {
return nil, err
}
body, err := io.ReadAll(response.Body)
if err != nil {
_ = response.Body.Close()
log.Printf("[handler][getEndpointStatusesFromRemoteInstances] Silently failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error())
continue
}
var endpointStatuses []*core.EndpointStatus
if err = json.Unmarshal(body, &endpointStatuses); err != nil {
_ = response.Body.Close()
log.Printf("[handler][getEndpointStatusesFromRemoteInstances] Silently failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error())
continue
}
_ = response.Body.Close()
for _, endpointStatus := range endpointStatuses {
endpointStatus.Name = instance.EndpointPrefix + endpointStatus.Name
}
endpointStatusesFromAllRemotes = append(endpointStatusesFromAllRemotes, endpointStatuses...)
}
return endpointStatusesFromAllRemotes, nil
}
// EndpointStatus retrieves a single core.EndpointStatus by group and endpoint name // EndpointStatus retrieves a single core.EndpointStatus by group and endpoint name
func EndpointStatus(writer http.ResponseWriter, r *http.Request) { func EndpointStatus(writer http.ResponseWriter, r *http.Request) {
+7 -22
View File
@@ -6,17 +6,13 @@ import (
"testing" "testing"
"time" "time"
"github.com/TwiN/gatus/v3/config" "github.com/TwiN/gatus/v4/config"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v3/storage/store" "github.com/TwiN/gatus/v4/storage/store"
"github.com/TwiN/gatus/v3/watchdog" "github.com/TwiN/gatus/v4/watchdog"
) )
var ( var (
firstCondition = core.Condition("[STATUS] == 200")
secondCondition = core.Condition("[RESPONSE_TIME] < 500")
thirdCondition = core.Condition("[CERTIFICATE_EXPIRATION] < 72h")
timestamp = time.Now() timestamp = time.Now()
testEndpoint = core.Endpoint{ testEndpoint = core.Endpoint{
@@ -26,7 +22,7 @@ var (
Method: "GET", Method: "GET",
Body: "body", Body: "body",
Interval: 30 * time.Second, Interval: 30 * time.Second,
Conditions: []*core.Condition{&firstCondition, &secondCondition, &thirdCondition}, Conditions: []core.Condition{core.Condition("[STATUS] == 200"), core.Condition("[RESPONSE_TIME] < 500"), core.Condition("[CERTIFICATE_EXPIRATION] < 72h")},
Alerts: nil, Alerts: nil,
NumberOfFailuresInARow: 0, NumberOfFailuresInARow: 0,
NumberOfSuccessesInARow: 0, NumberOfSuccessesInARow: 0,
@@ -101,7 +97,7 @@ func TestEndpointStatus(t *testing.T) {
} }
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()}) watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()}) watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
router := CreateRouter("../../web/static", cfg.Security, nil, cfg.Metrics) router := CreateRouter(cfg)
type Scenario struct { type Scenario struct {
Name string Name string
@@ -131,11 +127,6 @@ func TestEndpointStatus(t *testing.T) {
Path: "/api/v1/endpoints/invalid_key/statuses", Path: "/api/v1/endpoints/invalid_key/statuses",
ExpectedCode: http.StatusNotFound, ExpectedCode: http.StatusNotFound,
}, },
{ // XXX: Remove this in v4.0.0
Name: "backward-compatible-service-status",
Path: "/api/v1/services/core_frontend/statuses",
ExpectedCode: http.StatusOK,
},
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
@@ -162,7 +153,7 @@ func TestEndpointStatuses(t *testing.T) {
// Can't be bothered dealing with timezone issues on the worker that runs the automated tests // Can't be bothered dealing with timezone issues on the worker that runs the automated tests
firstResult.Timestamp = time.Time{} firstResult.Timestamp = time.Time{}
secondResult.Timestamp = time.Time{} secondResult.Timestamp = time.Time{}
router := CreateRouter("../../web/static", nil, nil, false) router := CreateRouter(&config.Config{Metrics: true})
type Scenario struct { type Scenario struct {
Name string Name string
@@ -201,12 +192,6 @@ func TestEndpointStatuses(t *testing.T) {
ExpectedCode: http.StatusOK, ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}]}]`, ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}]}]`,
}, },
{ // XXX: Remove this in v4.0.0
Name: "backward-compatible-service-status",
Path: "/api/v1/services/statuses",
ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}]}]`,
},
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
-12
View File
@@ -1,12 +0,0 @@
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")
}
}
-33
View File
@@ -1,33 +0,0 @@
package handler
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestFavIcon(t *testing.T) {
router := CreateRouter("../../web/static", nil, nil, false)
type Scenario struct {
Name string
Path string
ExpectedCode int
}
scenarios := []Scenario{
{
Name: "favicon",
Path: "/favicon.ico",
ExpectedCode: http.StatusOK,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
request, _ := http.NewRequest("GET", scenario.Path, http.NoBody)
responseRecorder := httptest.NewRecorder()
router.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != scenario.ExpectedCode {
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
}
})
}
}
+1 -2
View File
@@ -3,7 +3,6 @@ package handler
import ( import (
"compress/gzip" "compress/gzip"
"io" "io"
"io/ioutil"
"net/http" "net/http"
"strings" "strings"
"sync" "sync"
@@ -11,7 +10,7 @@ import (
var gzPool = sync.Pool{ var gzPool = sync.Pool{
New: func() interface{} { New: func() interface{} {
return gzip.NewWriter(ioutil.Discard) return gzip.NewWriter(io.Discard)
}, },
} }
+32 -29
View File
@@ -1,47 +1,50 @@
package handler package handler
import ( import (
"io/fs"
"net/http" "net/http"
"github.com/TwiN/gatus/v3/config/ui" "github.com/TwiN/gatus/v4/config"
"github.com/TwiN/gatus/v3/security" "github.com/TwiN/gatus/v4/web"
"github.com/TwiN/health" "github.com/TwiN/health"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
) )
func CreateRouter(staticFolder string, securityConfig *security.Config, uiConfig *ui.Config, enabledMetrics bool) *mux.Router { func CreateRouter(cfg *config.Config) *mux.Router {
router := mux.NewRouter() router := mux.NewRouter()
if enabledMetrics { if cfg.Metrics {
router.Handle("/metrics", promhttp.Handler()).Methods("GET") router.Handle("/metrics", promhttp.Handler()).Methods("GET")
} }
router.Handle("/health", health.Handler().WithJSON(true)).Methods("GET") api := router.PathPrefix("/api").Subrouter()
router.HandleFunc("/favicon.ico", FavIcon(staticFolder)).Methods("GET") protected := api.PathPrefix("/").Subrouter()
unprotected := api.PathPrefix("/").Subrouter()
if cfg.Security != nil {
if err := cfg.Security.RegisterHandlers(router); err != nil {
panic(err)
}
if err := cfg.Security.ApplySecurityMiddleware(protected); err != nil {
panic(err)
}
}
// Endpoints // Endpoints
router.HandleFunc("/api/v1/endpoints/statuses", secureIfNecessary(securityConfig, EndpointStatuses)).Methods("GET") // No GzipHandler for this one, because we cache the content as Gzipped already unprotected.Handle("/v1/config", ConfigHandler{securityConfig: cfg.Security}).Methods("GET")
router.HandleFunc("/api/v1/endpoints/{key}/statuses", secureIfNecessary(securityConfig, GzipHandlerFunc(EndpointStatus))).Methods("GET") protected.HandleFunc("/v1/endpoints/statuses", EndpointStatuses(cfg)).Methods("GET") // No GzipHandler for this one, because we cache the content as Gzipped already
router.HandleFunc("/api/v1/endpoints/{key}/uptimes/{duration}/badge.svg", UptimeBadge).Methods("GET") protected.HandleFunc("/v1/endpoints/{key}/statuses", GzipHandlerFunc(EndpointStatus)).Methods("GET")
router.HandleFunc("/api/v1/endpoints/{key}/response-times/{duration}/badge.svg", ResponseTimeBadge).Methods("GET") unprotected.HandleFunc("/v1/endpoints/{key}/health/badge.svg", HealthBadge).Methods("GET")
router.HandleFunc("/api/v1/endpoints/{key}/response-times/{duration}/chart.svg", ResponseTimeChart).Methods("GET") unprotected.HandleFunc("/v1/endpoints/{key}/uptimes/{duration}/badge.svg", UptimeBadge).Methods("GET")
// XXX: Remove the lines between this and the next XXX comment in v4.0.0 unprotected.HandleFunc("/v1/endpoints/{key}/response-times/{duration}/badge.svg", ResponseTimeBadge(cfg)).Methods("GET")
router.HandleFunc("/api/v1/services/statuses", secureIfNecessary(securityConfig, EndpointStatuses)).Methods("GET") // No GzipHandler for this one, because we cache the content as Gzipped already unprotected.HandleFunc("/v1/endpoints/{key}/response-times/{duration}/chart.svg", ResponseTimeChart).Methods("GET")
router.HandleFunc("/api/v1/services/{key}/statuses", secureIfNecessary(securityConfig, GzipHandlerFunc(EndpointStatus))).Methods("GET") // Misc
router.HandleFunc("/api/v1/services/{key}/uptimes/{duration}/badge.svg", UptimeBadge).Methods("GET") router.Handle("/health", health.Handler().WithJSON(true)).Methods("GET")
router.HandleFunc("/api/v1/services/{key}/response-times/{duration}/badge.svg", ResponseTimeBadge).Methods("GET")
router.HandleFunc("/api/v1/services/{key}/response-times/{duration}/chart.svg", ResponseTimeChart).Methods("GET")
// XXX: Remove the lines between this and the previous XXX comment in v4.0.0
// SPA // SPA
router.HandleFunc("/services/{name}", SinglePageApplication(staticFolder, uiConfig)).Methods("GET") // XXX: Remove this in v4.0.0 router.HandleFunc("/endpoints/{name}", SinglePageApplication(cfg.UI)).Methods("GET")
router.HandleFunc("/endpoints/{name}", SinglePageApplication(staticFolder, uiConfig)).Methods("GET") router.HandleFunc("/", SinglePageApplication(cfg.UI)).Methods("GET")
router.HandleFunc("/", SinglePageApplication(staticFolder, uiConfig)).Methods("GET")
// Everything else falls back on static content // Everything else falls back on static content
router.PathPrefix("/").Handler(GzipHandler(http.FileServer(http.Dir(staticFolder)))) staticFileSystem, err := fs.Sub(static.FileSystem, static.RootPath)
if err != nil {
panic(err)
}
router.PathPrefix("/").Handler(GzipHandler(http.FileServer(http.FS(staticFileSystem))))
return router return router
} }
func secureIfNecessary(securityConfig *security.Config, handler http.HandlerFunc) http.HandlerFunc {
if securityConfig != nil && securityConfig.IsValid() {
return security.Handler(handler, securityConfig)
}
return handler
}
+21 -3
View File
@@ -4,10 +4,12 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"github.com/TwiN/gatus/v4/config"
) )
func TestCreateRouter(t *testing.T) { func TestCreateRouter(t *testing.T) {
router := CreateRouter("../../web/static", nil, nil, true) router := CreateRouter(&config.Config{Metrics: true})
type Scenario struct { type Scenario struct {
Name string Name string
Path string Path string
@@ -26,16 +28,32 @@ func TestCreateRouter(t *testing.T) {
ExpectedCode: http.StatusOK, ExpectedCode: http.StatusOK,
}, },
{ {
Name: "scripts", Name: "favicon.ico",
Path: "/favicon.ico",
ExpectedCode: http.StatusOK,
},
{
Name: "app.js",
Path: "/js/app.js", Path: "/js/app.js",
ExpectedCode: http.StatusOK, ExpectedCode: http.StatusOK,
}, },
{ {
Name: "scripts-gzipped", Name: "app.js-gzipped",
Path: "/js/app.js", Path: "/js/app.js",
ExpectedCode: http.StatusOK, ExpectedCode: http.StatusOK,
Gzip: true, Gzip: true,
}, },
{
Name: "chunk-vendors.js",
Path: "/js/chunk-vendors.js",
ExpectedCode: http.StatusOK,
},
{
Name: "chunk-vendors.js-gzipped",
Path: "/js/chunk-vendors.js",
ExpectedCode: http.StatusOK,
Gzip: true,
},
{ {
Name: "index-redirect", Name: "index-redirect",
Path: "/index.html", Path: "/index.html",
+11 -7
View File
@@ -1,26 +1,30 @@
package handler package handler
import ( import (
_ "embed"
"html/template" "html/template"
"log" "log"
"net/http" "net/http"
"github.com/TwiN/gatus/v3/config/ui" "github.com/TwiN/gatus/v4/config/ui"
"github.com/TwiN/gatus/v4/web"
) )
func SinglePageApplication(staticFolder string, ui *ui.Config) http.HandlerFunc { func SinglePageApplication(ui *ui.Config) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) { return func(writer http.ResponseWriter, request *http.Request) {
t, err := template.ParseFiles(staticFolder + "/index.html") t, err := template.ParseFS(static.FileSystem, static.IndexPath)
if err != nil { if err != nil {
log.Println("[handler][SinglePageApplication] Failed to parse template:", err.Error()) // This should never happen, because ui.ValidateAndSetDefaults validates that the template works.
http.ServeFile(writer, request, staticFolder+"/index.html") log.Println("[handler][SinglePageApplication] Failed to parse template. This should never happen, because the template is validated on start. Error:", err.Error())
http.Error(writer, "Failed to parse template. This should never happen, because the template is validated on start.", http.StatusInternalServerError)
return return
} }
writer.Header().Set("Content-Type", "text/html") writer.Header().Set("Content-Type", "text/html")
err = t.Execute(writer, ui) err = t.Execute(writer, ui)
if err != nil { if err != nil {
log.Println("[handler][SinglePageApplication] Failed to parse template:", err.Error()) // This should never happen, because ui.ValidateAndSetDefaults validates that the template works.
http.ServeFile(writer, request, staticFolder+"/index.html") log.Println("[handler][SinglePageApplication] Failed to execute template. This should never happen, because the template is validated on start. Error:", err.Error())
http.Error(writer, "Failed to execute template. This should never happen, because the template is validated on start.", http.StatusInternalServerError)
return return
} }
} }
+5 -10
View File
@@ -6,10 +6,10 @@ import (
"testing" "testing"
"time" "time"
"github.com/TwiN/gatus/v3/config" "github.com/TwiN/gatus/v4/config"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v3/storage/store" "github.com/TwiN/gatus/v4/storage/store"
"github.com/TwiN/gatus/v3/watchdog" "github.com/TwiN/gatus/v4/watchdog"
) )
func TestSinglePageApplication(t *testing.T) { func TestSinglePageApplication(t *testing.T) {
@@ -30,7 +30,7 @@ func TestSinglePageApplication(t *testing.T) {
} }
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()}) watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()}) watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
router := CreateRouter("../../web/static", cfg.Security, nil, cfg.Metrics) router := CreateRouter(cfg)
type Scenario struct { type Scenario struct {
Name string Name string
Path string Path string
@@ -48,11 +48,6 @@ func TestSinglePageApplication(t *testing.T) {
Path: "/endpoints/core_frontend", Path: "/endpoints/core_frontend",
ExpectedCode: http.StatusOK, ExpectedCode: http.StatusOK,
}, },
{ // XXX: Remove this in v4.0.0
Name: "frontend-service",
Path: "/services/core_frontend",
ExpectedCode: http.StatusOK,
},
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"github.com/TwiN/gatus/v3/storage/store/common" "github.com/TwiN/gatus/v4/storage/store/common"
) )
const ( const (
+20 -3
View File
@@ -6,8 +6,8 @@ import (
"strings" "strings"
"time" "time"
"github.com/TwiN/gatus/v3/jsonpath" "github.com/TwiN/gatus/v4/jsonpath"
"github.com/TwiN/gatus/v3/pattern" "github.com/TwiN/gatus/v4/pattern"
) )
const ( const (
@@ -46,6 +46,9 @@ const (
// Values that could replace the placeholder: 4461677039 (~52 days) // Values that could replace the placeholder: 4461677039 (~52 days)
CertificateExpirationPlaceholder = "[CERTIFICATE_EXPIRATION]" CertificateExpirationPlaceholder = "[CERTIFICATE_EXPIRATION]"
// DomainExpirationPlaceholder is a placeholder for the duration before the domain expires, in milliseconds.
DomainExpirationPlaceholder = "[DOMAIN_EXPIRATION]"
// LengthFunctionPrefix is the prefix for the length function // LengthFunctionPrefix is the prefix for the length function
// //
// Usage: len([BODY].articles) == 10, len([BODY].name) > 5 // Usage: len([BODY].articles) == 10, len([BODY].name) > 5
@@ -142,9 +145,21 @@ func (c Condition) hasBodyPlaceholder() bool {
return strings.Contains(string(c), BodyPlaceholder) return strings.Contains(string(c), BodyPlaceholder)
} }
// hasDomainExpirationPlaceholder checks whether the condition has a DomainExpirationPlaceholder
// Used for determining whether a whois operation is necessary
func (c Condition) hasDomainExpirationPlaceholder() bool {
return strings.Contains(string(c), DomainExpirationPlaceholder)
}
// hasIPPlaceholder checks whether the condition has an IPPlaceholder
// Used for determining whether an IP lookup is necessary
func (c Condition) hasIPPlaceholder() bool {
return strings.Contains(string(c), IPPlaceholder)
}
// isEqual compares two strings. // isEqual compares two strings.
// //
// Supports the pattern and the any functions. // Supports the "pat" and the "any" functions.
// i.e. if one of the parameters starts with PatternFunctionPrefix and ends with FunctionSuffix, it will be treated like // i.e. if one of the parameters starts with PatternFunctionPrefix and ends with FunctionSuffix, it will be treated like
// a pattern. // a pattern.
func isEqual(first, second string) bool { func isEqual(first, second string) bool {
@@ -219,6 +234,8 @@ func sanitizeAndResolve(elements []string, result *Result) ([]string, []string)
element = strconv.FormatBool(result.Connected) element = strconv.FormatBool(result.Connected)
case CertificateExpirationPlaceholder: case CertificateExpirationPlaceholder:
element = strconv.FormatInt(result.CertificateExpiration.Milliseconds(), 10) element = strconv.FormatInt(result.CertificateExpiration.Milliseconds(), 10)
case DomainExpirationPlaceholder:
element = strconv.FormatInt(result.DomainExpiration.Milliseconds(), 10)
default: default:
// if contains the BodyPlaceholder, then evaluate json path // if contains the BodyPlaceholder, then evaluate json path
if strings.Contains(element, BodyPlaceholder) { if strings.Contains(element, BodyPlaceholder) {
+13 -13
View File
@@ -2,8 +2,9 @@ package core
import ( import (
"testing" "testing"
"time"
"github.com/TwiN/gatus/v3/pattern" "github.com/TwiN/gatus/v4/pattern"
) )
func TestIntegrationQuery(t *testing.T) { func TestIntegrationQuery(t *testing.T) {
@@ -21,7 +22,7 @@ func TestIntegrationQuery(t *testing.T) {
QueryType: "A", QueryType: "A",
QueryName: "example.com.", QueryName: "example.com.",
}, },
inputURL: "8.8.8.8", inputURL: "1.1.1.1",
expectedDNSCode: "NOERROR", expectedDNSCode: "NOERROR",
expectedBody: "93.184.216.34", expectedBody: "93.184.216.34",
}, },
@@ -31,7 +32,7 @@ func TestIntegrationQuery(t *testing.T) {
QueryType: "AAAA", QueryType: "AAAA",
QueryName: "example.com.", QueryName: "example.com.",
}, },
inputURL: "8.8.8.8", inputURL: "1.1.1.1",
expectedDNSCode: "NOERROR", expectedDNSCode: "NOERROR",
expectedBody: "2606:2800:220:1:248:1893:25c8:1946", expectedBody: "2606:2800:220:1:248:1893:25c8:1946",
}, },
@@ -39,11 +40,11 @@ func TestIntegrationQuery(t *testing.T) {
name: "test DNS with type CNAME", name: "test DNS with type CNAME",
inputDNS: DNS{ inputDNS: DNS{
QueryType: "CNAME", QueryType: "CNAME",
QueryName: "doc.google.com.", QueryName: "en.wikipedia.org.",
}, },
inputURL: "8.8.8.8", inputURL: "1.1.1.1",
expectedDNSCode: "NOERROR", expectedDNSCode: "NOERROR",
expectedBody: "writely.l.google.com.", expectedBody: "dyna.wikimedia.org.",
}, },
{ {
name: "test DNS with type MX", name: "test DNS with type MX",
@@ -51,7 +52,7 @@ func TestIntegrationQuery(t *testing.T) {
QueryType: "MX", QueryType: "MX",
QueryName: "example.com.", QueryName: "example.com.",
}, },
inputURL: "8.8.8.8", inputURL: "1.1.1.1",
expectedDNSCode: "NOERROR", expectedDNSCode: "NOERROR",
expectedBody: ".", expectedBody: ".",
}, },
@@ -61,7 +62,7 @@ func TestIntegrationQuery(t *testing.T) {
QueryType: "NS", QueryType: "NS",
QueryName: "example.com.", QueryName: "example.com.",
}, },
inputURL: "8.8.8.8", inputURL: "1.1.1.1",
expectedDNSCode: "NOERROR", expectedDNSCode: "NOERROR",
expectedBody: "*.iana-servers.net.", expectedBody: "*.iana-servers.net.",
}, },
@@ -69,15 +70,14 @@ func TestIntegrationQuery(t *testing.T) {
name: "test DNS with fake type and retrieve error", name: "test DNS with fake type and retrieve error",
inputDNS: DNS{ inputDNS: DNS{
QueryType: "B", QueryType: "B",
QueryName: "google", QueryName: "example",
}, },
inputURL: "8.8.8.8", inputURL: "1.1.1.1",
isErrExpected: true, isErrExpected: true,
}, },
} }
for _, test := range tests { for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
dns := test.inputDNS dns := test.inputDNS
result := &Result{} result := &Result{}
@@ -86,9 +86,8 @@ func TestIntegrationQuery(t *testing.T) {
t.Errorf("there should be errors") t.Errorf("there should be errors")
} }
if result.DNSRCode != test.expectedDNSCode { if result.DNSRCode != test.expectedDNSCode {
t.Errorf("DNSRCodePlaceholder '%s' should have been %s", result.DNSRCode, test.expectedDNSCode) t.Errorf("expected DNSRCode to be %s, got %s", test.expectedDNSCode, result.DNSRCode)
} }
if test.inputDNS.QueryType == "NS" { if test.inputDNS.QueryType == "NS" {
// Because there are often multiple nameservers backing a single domain, we'll only look at the suffix // Because there are often multiple nameservers backing a single domain, we'll only look at the suffix
if !pattern.Match(test.expectedBody, string(result.body)) { if !pattern.Match(test.expectedBody, string(result.body)) {
@@ -100,6 +99,7 @@ func TestIntegrationQuery(t *testing.T) {
} }
} }
}) })
time.Sleep(5 * time.Millisecond)
} }
} }
+146 -38
View File
@@ -5,19 +5,22 @@ import (
"crypto/x509" "crypto/x509"
"encoding/json" "encoding/json"
"errors" "errors"
"io/ioutil" "io"
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"time" "time"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/client" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v3/core/ui" "github.com/TwiN/gatus/v4/core/ui"
"github.com/TwiN/gatus/v3/util" "github.com/TwiN/gatus/v4/util"
"github.com/TwiN/whois"
) )
type EndpointType string
const ( const (
// HostHeader is the name of the header used to specify the host // HostHeader is the name of the header used to specify the host
HostHeader = "Host" HostHeader = "Host"
@@ -30,6 +33,14 @@ const (
// GatusUserAgent is the default user agent that Gatus uses to send requests. // GatusUserAgent is the default user agent that Gatus uses to send requests.
GatusUserAgent = "Gatus/1.0" GatusUserAgent = "Gatus/1.0"
EndpointTypeDNS EndpointType = "DNS"
EndpointTypeTCP EndpointType = "TCP"
EndpointTypeICMP EndpointType = "ICMP"
EndpointTypeSTARTTLS EndpointType = "STARTTLS"
EndpointTypeTLS EndpointType = "TLS"
EndpointTypeHTTP EndpointType = "HTTP"
EndpointTypeUNKNOWN EndpointType = "UNKNOWN"
) )
var ( var (
@@ -41,6 +52,18 @@ var (
// ErrEndpointWithNoName is the error with which Gatus will panic if an endpoint is configured with no name // ErrEndpointWithNoName is the error with which Gatus will panic if an endpoint is configured with no name
ErrEndpointWithNoName = errors.New("you must specify a name for each endpoint") ErrEndpointWithNoName = errors.New("you must specify a name for each endpoint")
// ErrEndpointWithInvalidNameOrGroup is the error with which Gatus will panic if an endpoint has an invalid character where it shouldn't
ErrEndpointWithInvalidNameOrGroup = errors.New("endpoint name and group must not have \" or \\")
// ErrUnknownEndpointType is the error with which Gatus will panic if an endpoint has an unknown type
ErrUnknownEndpointType = errors.New("unknown endpoint type")
// ErrInvalidEndpointIntervalForDomainExpirationPlaceholder is the error with which Gatus will panic if an endpoint
// has both an interval smaller than 5 minutes and a condition with DomainExpirationPlaceholder.
// This is because the free whois service we are using should not be abused, especially considering the fact that
// the data takes a while to be updated.
ErrInvalidEndpointIntervalForDomainExpirationPlaceholder = errors.New("the minimum interval for an endpoint with a condition using the " + DomainExpirationPlaceholder + " placeholder is 300s (5m)")
) )
// Endpoint is the configuration of a monitored // Endpoint is the configuration of a monitored
@@ -76,7 +99,7 @@ type Endpoint struct {
Interval time.Duration `yaml:"interval,omitempty"` Interval time.Duration `yaml:"interval,omitempty"`
// Conditions used to determine the health of the endpoint // Conditions used to determine the health of the endpoint
Conditions []*Condition `yaml:"conditions"` Conditions []Condition `yaml:"conditions"`
// Alerts is the alerting configuration for the endpoint in case of failure // Alerts is the alerting configuration for the endpoint in case of failure
Alerts []*alert.Alert `yaml:"alerts,omitempty"` Alerts []*alert.Alert `yaml:"alerts,omitempty"`
@@ -102,16 +125,42 @@ func (endpoint Endpoint) IsEnabled() bool {
return *endpoint.Enabled return *endpoint.Enabled
} }
// ValidateAndSetDefaults validates the endpoint's configuration and sets the default value of fields that have one // Type returns the endpoint type
func (endpoint Endpoint) Type() EndpointType {
switch {
case endpoint.DNS != nil:
return EndpointTypeDNS
case strings.HasPrefix(endpoint.URL, "tcp://"):
return EndpointTypeTCP
case strings.HasPrefix(endpoint.URL, "icmp://"):
return EndpointTypeICMP
case strings.HasPrefix(endpoint.URL, "starttls://"):
return EndpointTypeSTARTTLS
case strings.HasPrefix(endpoint.URL, "tls://"):
return EndpointTypeTLS
case strings.HasPrefix(endpoint.URL, "http://") || strings.HasPrefix(endpoint.URL, "https://"):
return EndpointTypeHTTP
default:
return EndpointTypeUNKNOWN
}
}
// ValidateAndSetDefaults validates the endpoint's configuration and sets the default value of args that have one
func (endpoint *Endpoint) ValidateAndSetDefaults() error { func (endpoint *Endpoint) ValidateAndSetDefaults() error {
// Set default values // Set default values
if endpoint.ClientConfig == nil { if endpoint.ClientConfig == nil {
endpoint.ClientConfig = client.GetDefaultConfig() endpoint.ClientConfig = client.GetDefaultConfig()
} else { } else {
endpoint.ClientConfig.ValidateAndSetDefaults() if err := endpoint.ClientConfig.ValidateAndSetDefaults(); err != nil {
return err
}
} }
if endpoint.UIConfig == nil { if endpoint.UIConfig == nil {
endpoint.UIConfig = ui.GetDefaultConfig() endpoint.UIConfig = ui.GetDefaultConfig()
} else {
if err := endpoint.UIConfig.ValidateAndSetDefaults(); err != nil {
return err
}
} }
if endpoint.Interval == 0 { if endpoint.Interval == 0 {
endpoint.Interval = 1 * time.Minute endpoint.Interval = 1 * time.Minute
@@ -132,25 +181,35 @@ func (endpoint *Endpoint) ValidateAndSetDefaults() error {
endpoint.Headers[ContentTypeHeader] = "application/json" endpoint.Headers[ContentTypeHeader] = "application/json"
} }
for _, endpointAlert := range endpoint.Alerts { for _, endpointAlert := range endpoint.Alerts {
if endpointAlert.FailureThreshold <= 0 { if err := endpointAlert.ValidateAndSetDefaults(); err != nil {
endpointAlert.FailureThreshold = 3 return err
}
if endpointAlert.SuccessThreshold <= 0 {
endpointAlert.SuccessThreshold = 2
} }
} }
if len(endpoint.Name) == 0 { if len(endpoint.Name) == 0 {
return ErrEndpointWithNoName return ErrEndpointWithNoName
} }
if strings.ContainsAny(endpoint.Name, "\"\\") || strings.ContainsAny(endpoint.Group, "\"\\") {
return ErrEndpointWithInvalidNameOrGroup
}
if len(endpoint.URL) == 0 { if len(endpoint.URL) == 0 {
return ErrEndpointWithNoURL return ErrEndpointWithNoURL
} }
if len(endpoint.Conditions) == 0 { if len(endpoint.Conditions) == 0 {
return ErrEndpointWithNoCondition return ErrEndpointWithNoCondition
} }
if endpoint.Interval < 5*time.Minute {
for _, condition := range endpoint.Conditions {
if condition.hasDomainExpirationPlaceholder() {
return ErrInvalidEndpointIntervalForDomainExpirationPlaceholder
}
}
}
if endpoint.DNS != nil { if endpoint.DNS != nil {
return endpoint.DNS.validateAndSetDefault() return endpoint.DNS.validateAndSetDefault()
} }
if endpoint.Type() == EndpointTypeUNKNOWN {
return ErrUnknownEndpointType
}
// Make sure that the request can be created // Make sure that the request can be created
_, err := http.NewRequest(endpoint.Method, endpoint.URL, bytes.NewBuffer([]byte(endpoint.Body))) _, err := http.NewRequest(endpoint.Method, endpoint.URL, bytes.NewBuffer([]byte(endpoint.Body)))
if err != nil { if err != nil {
@@ -159,6 +218,14 @@ func (endpoint *Endpoint) ValidateAndSetDefaults() error {
return nil return nil
} }
// DisplayName returns an identifier made up of the Name and, if not empty, the Group.
func (endpoint Endpoint) DisplayName() string {
if len(endpoint.Group) > 0 {
return endpoint.Group + "/" + endpoint.Name
}
return endpoint.Name
}
// Key returns the unique key for the Endpoint // Key returns the unique key for the Endpoint
func (endpoint Endpoint) Key() string { func (endpoint Endpoint) Key() string {
return util.ConvertGroupAndEndpointNameToKey(endpoint.Group, endpoint.Name) return util.ConvertGroupAndEndpointNameToKey(endpoint.Group, endpoint.Name)
@@ -167,7 +234,26 @@ func (endpoint Endpoint) Key() string {
// EvaluateHealth sends a request to the endpoint's URL and evaluates the conditions of the endpoint. // EvaluateHealth sends a request to the endpoint's URL and evaluates the conditions of the endpoint.
func (endpoint *Endpoint) EvaluateHealth() *Result { func (endpoint *Endpoint) EvaluateHealth() *Result {
result := &Result{Success: true, Errors: []string{}} result := &Result{Success: true, Errors: []string{}}
// Parse or extract hostname from URL
if endpoint.DNS != nil {
result.Hostname = strings.TrimSuffix(endpoint.URL, ":53")
} else {
urlObject, err := url.Parse(endpoint.URL)
if err != nil {
result.AddError(err.Error())
} else {
result.Hostname = urlObject.Hostname()
}
}
// Retrieve IP if necessary
if endpoint.needsToRetrieveIP() {
endpoint.getIP(result) endpoint.getIP(result)
}
// Retrieve domain expiration if necessary
if endpoint.needsToRetrieveDomainExpiration() && len(result.Hostname) > 0 {
endpoint.getDomainExpiration(result)
}
//
if len(result.Errors) == 0 { if len(result.Errors) == 0 {
endpoint.call(result) endpoint.call(result)
} else { } else {
@@ -183,23 +269,21 @@ func (endpoint *Endpoint) EvaluateHealth() *Result {
// No need to keep the body after the endpoint has been evaluated // No need to keep the body after the endpoint has been evaluated
result.body = nil result.body = nil
// Clean up parameters that we don't need to keep in the results // Clean up parameters that we don't need to keep in the results
if endpoint.UIConfig.HideURL {
for errIdx, errorString := range result.Errors {
result.Errors[errIdx] = strings.ReplaceAll(errorString, endpoint.URL, "<redacted>")
}
}
if endpoint.UIConfig.HideHostname { if endpoint.UIConfig.HideHostname {
for errIdx, errorString := range result.Errors {
result.Errors[errIdx] = strings.ReplaceAll(errorString, result.Hostname, "<redacted>")
}
result.Hostname = "" result.Hostname = ""
} }
return result return result
} }
func (endpoint *Endpoint) getIP(result *Result) { func (endpoint *Endpoint) getIP(result *Result) {
if endpoint.DNS != nil {
result.Hostname = strings.TrimSuffix(endpoint.URL, ":53")
} else {
urlObject, err := url.Parse(endpoint.URL)
if err != nil {
result.AddError(err.Error())
return
}
result.Hostname = urlObject.Hostname()
}
ips, err := net.LookupIP(result.Hostname) ips, err := net.LookupIP(result.Hostname)
if err != nil { if err != nil {
result.AddError(err.Error()) result.AddError(err.Error())
@@ -208,26 +292,30 @@ func (endpoint *Endpoint) getIP(result *Result) {
result.IP = ips[0].String() result.IP = ips[0].String()
} }
func (endpoint *Endpoint) getDomainExpiration(result *Result) {
whoisClient := whois.NewClient()
if whoisResponse, err := whoisClient.QueryAndParse(result.Hostname); err != nil {
result.AddError("error querying and parsing hostname using whois client: " + err.Error())
} else {
result.DomainExpiration = time.Until(whoisResponse.ExpirationDate)
}
}
func (endpoint *Endpoint) call(result *Result) { func (endpoint *Endpoint) call(result *Result) {
var request *http.Request var request *http.Request
var response *http.Response var response *http.Response
var err error var err error
var certificate *x509.Certificate var certificate *x509.Certificate
isTypeDNS := endpoint.DNS != nil endpointType := endpoint.Type()
isTypeTCP := strings.HasPrefix(endpoint.URL, "tcp://") if endpointType == EndpointTypeHTTP {
isTypeICMP := strings.HasPrefix(endpoint.URL, "icmp://")
isTypeSTARTTLS := strings.HasPrefix(endpoint.URL, "starttls://")
isTypeTLS := strings.HasPrefix(endpoint.URL, "tls://")
isTypeHTTP := !isTypeDNS && !isTypeTCP && !isTypeICMP && !isTypeSTARTTLS && !isTypeTLS
if isTypeHTTP {
request = endpoint.buildHTTPRequest() request = endpoint.buildHTTPRequest()
} }
startTime := time.Now() startTime := time.Now()
if isTypeDNS { if endpointType == EndpointTypeDNS {
endpoint.DNS.query(endpoint.URL, result) endpoint.DNS.query(endpoint.URL, result)
result.Duration = time.Since(startTime) result.Duration = time.Since(startTime)
} else if isTypeSTARTTLS || isTypeTLS { } else if endpointType == EndpointTypeSTARTTLS || endpointType == EndpointTypeTLS {
if isTypeSTARTTLS { if endpointType == EndpointTypeSTARTTLS {
result.Connected, certificate, err = client.CanPerformStartTLS(strings.TrimPrefix(endpoint.URL, "starttls://"), endpoint.ClientConfig) result.Connected, certificate, err = client.CanPerformStartTLS(strings.TrimPrefix(endpoint.URL, "starttls://"), endpoint.ClientConfig)
} else { } else {
result.Connected, certificate, err = client.CanPerformTLS(strings.TrimPrefix(endpoint.URL, "tls://"), endpoint.ClientConfig) result.Connected, certificate, err = client.CanPerformTLS(strings.TrimPrefix(endpoint.URL, "tls://"), endpoint.ClientConfig)
@@ -238,10 +326,10 @@ func (endpoint *Endpoint) call(result *Result) {
} }
result.Duration = time.Since(startTime) result.Duration = time.Since(startTime)
result.CertificateExpiration = time.Until(certificate.NotAfter) result.CertificateExpiration = time.Until(certificate.NotAfter)
} else if isTypeTCP { } else if endpointType == EndpointTypeTCP {
result.Connected = client.CanCreateTCPConnection(strings.TrimPrefix(endpoint.URL, "tcp://"), endpoint.ClientConfig) result.Connected = client.CanCreateTCPConnection(strings.TrimPrefix(endpoint.URL, "tcp://"), endpoint.ClientConfig)
result.Duration = time.Since(startTime) result.Duration = time.Since(startTime)
} else if isTypeICMP { } else if endpointType == EndpointTypeICMP {
result.Connected, result.Duration = client.Ping(strings.TrimPrefix(endpoint.URL, "icmp://"), endpoint.ClientConfig) result.Connected, result.Duration = client.Ping(strings.TrimPrefix(endpoint.URL, "icmp://"), endpoint.ClientConfig)
} else { } else {
response, err = client.GetHTTPClient(endpoint.ClientConfig).Do(request) response, err = client.GetHTTPClient(endpoint.ClientConfig).Do(request)
@@ -259,9 +347,9 @@ func (endpoint *Endpoint) call(result *Result) {
result.Connected = response.StatusCode > 0 result.Connected = response.StatusCode > 0
// Only read the body if there's a condition that uses the BodyPlaceholder // Only read the body if there's a condition that uses the BodyPlaceholder
if endpoint.needsToReadBody() { if endpoint.needsToReadBody() {
result.body, err = ioutil.ReadAll(response.Body) result.body, err = io.ReadAll(response.Body)
if err != nil { if err != nil {
result.AddError(err.Error()) result.AddError("error reading response body:" + err.Error())
} }
} }
} }
@@ -288,7 +376,7 @@ func (endpoint *Endpoint) buildHTTPRequest() *http.Request {
return request return request
} }
// needsToReadBody checks if there's any conditions that requires the response body to be read // needsToReadBody checks if there's any condition that requires the response body to be read
func (endpoint *Endpoint) needsToReadBody() bool { func (endpoint *Endpoint) needsToReadBody() bool {
for _, condition := range endpoint.Conditions { for _, condition := range endpoint.Conditions {
if condition.hasBodyPlaceholder() { if condition.hasBodyPlaceholder() {
@@ -297,3 +385,23 @@ func (endpoint *Endpoint) needsToReadBody() bool {
} }
return false return false
} }
// needsToRetrieveDomainExpiration checks if there's any condition that requires a whois query to be performed
func (endpoint *Endpoint) needsToRetrieveDomainExpiration() bool {
for _, condition := range endpoint.Conditions {
if condition.hasDomainExpirationPlaceholder() {
return true
}
}
return false
}
// needsToRetrieveIP checks if there's any condition that requires an IP lookup
func (endpoint *Endpoint) needsToRetrieveIP() bool {
for _, condition := range endpoint.Conditions {
if condition.hasIPPlaceholder() {
return true
}
}
return false
}
+1 -1
View File
@@ -1,6 +1,6 @@
package core package core
import "github.com/TwiN/gatus/v3/util" import "github.com/TwiN/gatus/v4/util"
// EndpointStatus contains the evaluation Results of an Endpoint // EndpointStatus contains the evaluation Results of an Endpoint
type EndpointStatus struct { type EndpointStatus struct {
+455 -84
View File
@@ -1,15 +1,245 @@
package core package core
import ( import (
"io/ioutil" "bytes"
"crypto/tls"
"crypto/x509"
"io"
"net/http"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/client" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core/ui"
"github.com/TwiN/gatus/v4/test"
) )
func TestEndpoint(t *testing.T) {
defer client.InjectHTTPClient(nil)
scenarios := []struct {
Name string
Endpoint Endpoint
ExpectedResult *Result
MockRoundTripper test.MockRoundTripper
}{
{
Name: "success",
Endpoint: Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []Condition{"[STATUS] == 200", "[BODY].status == UP", "[CERTIFICATE_EXPIRATION] > 24h"},
},
ExpectedResult: &Result{
Success: true,
Connected: true,
Hostname: "twin.sh",
ConditionResults: []*ConditionResult{
{Condition: "[STATUS] == 200", Success: true},
{Condition: "[BODY].status == UP", Success: true},
{Condition: "[CERTIFICATE_EXPIRATION] > 24h", Success: true},
},
DomainExpiration: 0, // Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.
},
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(`{"status": "UP"}`)),
TLS: &tls.ConnectionState{PeerCertificates: []*x509.Certificate{{NotAfter: time.Now().Add(9999 * time.Hour)}}},
}
}),
},
{
Name: "failed-body-condition",
Endpoint: Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []Condition{"[STATUS] == 200", "[BODY].status == UP"},
},
ExpectedResult: &Result{
Success: false,
Connected: true,
Hostname: "twin.sh",
ConditionResults: []*ConditionResult{
{Condition: "[STATUS] == 200", Success: true},
{Condition: "[BODY].status (DOWN) == UP", Success: false},
},
DomainExpiration: 0, // Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.
},
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewBufferString(`{"status": "DOWN"}`))}
}),
},
{
Name: "failed-status-condition",
Endpoint: Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []Condition{"[STATUS] == 200"},
},
ExpectedResult: &Result{
Success: false,
Connected: true,
Hostname: "twin.sh",
ConditionResults: []*ConditionResult{
{Condition: "[STATUS] (502) == 200", Success: false},
},
DomainExpiration: 0, // Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.
},
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusBadGateway, Body: http.NoBody}
}),
},
{
Name: "condition-with-failed-certificate-expiration",
Endpoint: Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []Condition{"[CERTIFICATE_EXPIRATION] > 100h"},
UIConfig: &ui.Config{DontResolveFailedConditions: true},
},
ExpectedResult: &Result{
Success: false,
Connected: true,
Hostname: "twin.sh",
ConditionResults: []*ConditionResult{
// Because UIConfig.DontResolveFailedConditions is true, the values in the condition should not be resolved
{Condition: "[CERTIFICATE_EXPIRATION] > 100h", Success: false},
},
DomainExpiration: 0, // Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.
},
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{
StatusCode: http.StatusOK,
Body: http.NoBody,
TLS: &tls.ConnectionState{PeerCertificates: []*x509.Certificate{{NotAfter: time.Now().Add(5 * time.Hour)}}},
}
}),
},
{
Name: "domain-expiration",
Endpoint: Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []Condition{"[DOMAIN_EXPIRATION] > 100h"},
},
ExpectedResult: &Result{
Success: true,
Connected: true,
Hostname: "twin.sh",
ConditionResults: []*ConditionResult{
{Condition: "[DOMAIN_EXPIRATION] > 100h", Success: true},
},
DomainExpiration: 999999 * time.Hour, // Note that this test only checks if it's non-zero.
},
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
},
{
Name: "endpoint-that-will-time-out-and-hidden-hostname",
Endpoint: Endpoint{
Name: "endpoint-that-will-time-out",
URL: "https://twin.sh/health",
Conditions: []Condition{"[CONNECTED] == true"},
UIConfig: &ui.Config{HideHostname: true},
ClientConfig: &client.Config{Timeout: time.Millisecond},
},
ExpectedResult: &Result{
Success: false,
Connected: false,
Hostname: "", // Because Endpoint.UIConfig.HideHostname is true, this should be empty.
ConditionResults: []*ConditionResult{
{Condition: "[CONNECTED] (false) == true", Success: false},
},
// Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.
DomainExpiration: 0,
// Because Endpoint.UIConfig.HideHostname is true, the hostname should be replaced by <redacted>.
Errors: []string{`Get "https://<redacted>/health": context deadline exceeded (Client.Timeout exceeded while awaiting headers)`},
},
MockRoundTripper: nil,
},
{
Name: "endpoint-that-will-time-out-and-hidden-url",
Endpoint: Endpoint{
Name: "endpoint-that-will-time-out",
URL: "https://twin.sh/health",
Conditions: []Condition{"[CONNECTED] == true"},
UIConfig: &ui.Config{HideURL: true},
ClientConfig: &client.Config{Timeout: time.Millisecond},
},
ExpectedResult: &Result{
Success: false,
Connected: false,
Hostname: "twin.sh",
ConditionResults: []*ConditionResult{
{Condition: "[CONNECTED] (false) == true", Success: false},
},
// Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.
DomainExpiration: 0,
// Because Endpoint.UIConfig.HideURL is true, the URL should be replaced by <redacted>.
Errors: []string{`Get "<redacted>": context deadline exceeded (Client.Timeout exceeded while awaiting headers)`},
},
MockRoundTripper: nil,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
if scenario.MockRoundTripper != nil {
mockClient := &http.Client{Transport: scenario.MockRoundTripper}
if scenario.Endpoint.ClientConfig != nil && scenario.Endpoint.ClientConfig.Timeout > 0 {
mockClient.Timeout = scenario.Endpoint.ClientConfig.Timeout
}
client.InjectHTTPClient(mockClient)
} else {
client.InjectHTTPClient(nil)
}
scenario.Endpoint.ValidateAndSetDefaults()
result := scenario.Endpoint.EvaluateHealth()
if result.Success != scenario.ExpectedResult.Success {
t.Errorf("Expected success to be %v, got %v", scenario.ExpectedResult.Success, result.Success)
}
if result.Connected != scenario.ExpectedResult.Connected {
t.Errorf("Expected connected to be %v, got %v", scenario.ExpectedResult.Connected, result.Connected)
}
if result.Hostname != scenario.ExpectedResult.Hostname {
t.Errorf("Expected hostname to be %v, got %v", scenario.ExpectedResult.Hostname, result.Hostname)
}
if len(result.ConditionResults) != len(scenario.ExpectedResult.ConditionResults) {
t.Errorf("Expected %v condition results, got %v", len(scenario.ExpectedResult.ConditionResults), len(result.ConditionResults))
} else {
for i, conditionResult := range result.ConditionResults {
if conditionResult.Condition != scenario.ExpectedResult.ConditionResults[i].Condition {
t.Errorf("Expected condition to be %v, got %v", scenario.ExpectedResult.ConditionResults[i].Condition, conditionResult.Condition)
}
if conditionResult.Success != scenario.ExpectedResult.ConditionResults[i].Success {
t.Errorf("Expected success of condition '%s' to be %v, got %v", conditionResult.Condition, scenario.ExpectedResult.ConditionResults[i].Success, conditionResult.Success)
}
}
}
if len(result.Errors) != len(scenario.ExpectedResult.Errors) {
t.Errorf("Expected %v errors, got %v", len(scenario.ExpectedResult.Errors), len(result.Errors))
} else {
for i, err := range result.Errors {
if err != scenario.ExpectedResult.Errors[i] {
t.Errorf("Expected error to be %v, got %v", scenario.ExpectedResult.Errors[i], err)
}
}
}
if result.DomainExpiration != scenario.ExpectedResult.DomainExpiration {
// Note that DomainExpiration is only resolved if there's a condition with the DomainExpirationPlaceholder in it.
// In other words, if there's no condition with [DOMAIN_EXPIRATION] in it, the DomainExpiration field will be 0.
// Because this is a live call, mocking it would be too much of a pain, so we're just going to check if
// the actual value is non-zero when the expected result is non-zero.
if scenario.ExpectedResult.DomainExpiration.Hours() > 0 && !(result.DomainExpiration.Hours() > 0) {
t.Errorf("Expected domain expiration to be non-zero, got %v", result.DomainExpiration)
}
}
})
}
}
func TestEndpoint_IsEnabled(t *testing.T) { func TestEndpoint_IsEnabled(t *testing.T) {
if !(Endpoint{Enabled: nil}).IsEnabled() { if !(Endpoint{Enabled: nil}).IsEnabled() {
t.Error("endpoint.IsEnabled() should've returned true, because Enabled was set to nil") t.Error("endpoint.IsEnabled() should've returned true, because Enabled was set to nil")
@@ -22,12 +252,86 @@ func TestEndpoint_IsEnabled(t *testing.T) {
} }
} }
func TestEndpoint_Type(t *testing.T) {
type args struct {
URL string
DNS *DNS
}
tests := []struct {
args args
want EndpointType
}{
{
args: args{
URL: "1.1.1.1",
DNS: &DNS{
QueryType: "A",
QueryName: "example.com",
},
},
want: EndpointTypeDNS,
},
{
args: args{
URL: "tcp://127.0.0.1:6379",
},
want: EndpointTypeTCP,
},
{
args: args{
URL: "icmp://example.com",
},
want: EndpointTypeICMP,
},
{
args: args{
URL: "starttls://smtp.gmail.com:587",
},
want: EndpointTypeSTARTTLS,
},
{
args: args{
URL: "tls://example.com:443",
},
want: EndpointTypeTLS,
},
{
args: args{
URL: "https://twin.sh/health",
},
want: EndpointTypeHTTP,
},
{
args: args{
URL: "invalid://example.org",
},
want: EndpointTypeUNKNOWN,
},
{
args: args{
URL: "no-scheme",
},
want: EndpointTypeUNKNOWN,
},
}
for _, tt := range tests {
t.Run(string(tt.want), func(t *testing.T) {
endpoint := Endpoint{
URL: tt.args.URL,
DNS: tt.args.DNS,
}
if got := endpoint.Type(); got != tt.want {
t.Errorf("Endpoint.Type() = %v, want %v", got, tt.want)
}
})
}
}
func TestEndpoint_ValidateAndSetDefaults(t *testing.T) { func TestEndpoint_ValidateAndSetDefaults(t *testing.T) {
condition := Condition("[STATUS] == 200")
endpoint := Endpoint{ endpoint := Endpoint{
Name: "website-health", Name: "website-health",
URL: "https://twin.sh/health", URL: "https://twin.sh/health",
Conditions: []*Condition{&condition}, Conditions: []Condition{Condition("[STATUS] == 200")},
Alerts: []*alert.Alert{{Type: alert.TypePagerDuty}}, Alerts: []*alert.Alert{{Type: alert.TypePagerDuty}},
} }
endpoint.ValidateAndSetDefaults() endpoint.ValidateAndSetDefaults()
@@ -68,11 +372,10 @@ func TestEndpoint_ValidateAndSetDefaults(t *testing.T) {
} }
func TestEndpoint_ValidateAndSetDefaultsWithClientConfig(t *testing.T) { func TestEndpoint_ValidateAndSetDefaultsWithClientConfig(t *testing.T) {
condition := Condition("[STATUS] == 200")
endpoint := Endpoint{ endpoint := Endpoint{
Name: "website-health", Name: "website-health",
URL: "https://twin.sh/health", URL: "https://twin.sh/health",
Conditions: []*Condition{&condition}, Conditions: []Condition{Condition("[STATUS] == 200")},
ClientConfig: &client.Config{ ClientConfig: &client.Config{
Insecure: true, Insecure: true,
IgnoreRedirect: true, IgnoreRedirect: true,
@@ -95,73 +398,88 @@ func TestEndpoint_ValidateAndSetDefaultsWithClientConfig(t *testing.T) {
} }
} }
func TestEndpoint_ValidateAndSetDefaultsWithNoName(t *testing.T) {
defer func() { recover() }()
condition := Condition("[STATUS] == 200")
endpoint := &Endpoint{
Name: "",
URL: "http://example.com",
Conditions: []*Condition{&condition},
}
err := endpoint.ValidateAndSetDefaults()
if err == nil {
t.Fatal("Should've returned an error because endpoint didn't have a name, which is a mandatory field")
}
}
func TestEndpoint_ValidateAndSetDefaultsWithNoUrl(t *testing.T) {
defer func() { recover() }()
condition := Condition("[STATUS] == 200")
endpoint := &Endpoint{
Name: "example",
URL: "",
Conditions: []*Condition{&condition},
}
err := endpoint.ValidateAndSetDefaults()
if err == nil {
t.Fatal("Should've returned an error because endpoint didn't have an url, which is a mandatory field")
}
}
func TestEndpoint_ValidateAndSetDefaultsWithNoConditions(t *testing.T) {
defer func() { recover() }()
endpoint := &Endpoint{
Name: "example",
URL: "http://example.com",
Conditions: nil,
}
err := endpoint.ValidateAndSetDefaults()
if err == nil {
t.Fatal("Should've returned an error because endpoint didn't have at least 1 condition")
}
}
func TestEndpoint_ValidateAndSetDefaultsWithDNS(t *testing.T) { func TestEndpoint_ValidateAndSetDefaultsWithDNS(t *testing.T) {
conditionSuccess := Condition("[DNS_RCODE] == NOERROR")
endpoint := &Endpoint{ endpoint := &Endpoint{
Name: "dns-test", Name: "dns-test",
URL: "http://example.com", URL: "https://example.com",
DNS: &DNS{ DNS: &DNS{
QueryType: "A", QueryType: "A",
QueryName: "example.com", QueryName: "example.com",
}, },
Conditions: []*Condition{&conditionSuccess}, Conditions: []Condition{Condition("[DNS_RCODE] == NOERROR")},
} }
err := endpoint.ValidateAndSetDefaults() err := endpoint.ValidateAndSetDefaults()
if err != nil { if err != nil {
t.Error("did not expect an error, got", err)
} }
if endpoint.DNS.QueryName != "example.com." { if endpoint.DNS.QueryName != "example.com." {
t.Error("Endpoint.dns.query-name should be formatted with . suffix") t.Error("Endpoint.dns.query-name should be formatted with . suffix")
} }
} }
func TestEndpoint_ValidateAndSetDefaultsWithSimpleErrors(t *testing.T) {
scenarios := []struct {
endpoint *Endpoint
expectedErr error
}{
{
endpoint: &Endpoint{
Name: "",
URL: "https://example.com",
Conditions: []Condition{Condition("[STATUS] == 200")},
},
expectedErr: ErrEndpointWithNoName,
},
{
endpoint: &Endpoint{
Name: "endpoint-with-no-url",
URL: "",
Conditions: []Condition{Condition("[STATUS] == 200")},
},
expectedErr: ErrEndpointWithNoURL,
},
{
endpoint: &Endpoint{
Name: "endpoint-with-no-conditions",
URL: "https://example.com",
Conditions: nil,
},
expectedErr: ErrEndpointWithNoCondition,
},
{
endpoint: &Endpoint{
Name: "domain-expiration-with-bad-interval",
URL: "https://example.com",
Interval: time.Minute,
Conditions: []Condition{Condition("[DOMAIN_EXPIRATION] > 720h")},
},
expectedErr: ErrInvalidEndpointIntervalForDomainExpirationPlaceholder,
},
{
endpoint: &Endpoint{
Name: "domain-expiration-with-good-interval",
URL: "https://example.com",
Interval: 5 * time.Minute,
Conditions: []Condition{Condition("[DOMAIN_EXPIRATION] > 720h")},
},
expectedErr: nil,
},
}
for _, scenario := range scenarios {
t.Run(scenario.endpoint.Name, func(t *testing.T) {
if err := scenario.endpoint.ValidateAndSetDefaults(); err != scenario.expectedErr {
t.Errorf("Expected error %v, got %v", scenario.expectedErr, err)
}
})
}
}
func TestEndpoint_buildHTTPRequest(t *testing.T) { func TestEndpoint_buildHTTPRequest(t *testing.T) {
condition := Condition("[STATUS] == 200") condition := Condition("[STATUS] == 200")
endpoint := Endpoint{ endpoint := Endpoint{
Name: "website-health", Name: "website-health",
URL: "https://twin.sh/health", URL: "https://twin.sh/health",
Conditions: []*Condition{&condition}, Conditions: []Condition{condition},
} }
endpoint.ValidateAndSetDefaults() endpoint.ValidateAndSetDefaults()
request := endpoint.buildHTTPRequest() request := endpoint.buildHTTPRequest()
@@ -181,7 +499,7 @@ func TestEndpoint_buildHTTPRequestWithCustomUserAgent(t *testing.T) {
endpoint := Endpoint{ endpoint := Endpoint{
Name: "website-health", Name: "website-health",
URL: "https://twin.sh/health", URL: "https://twin.sh/health",
Conditions: []*Condition{&condition}, Conditions: []Condition{condition},
Headers: map[string]string{ Headers: map[string]string{
"User-Agent": "Test/2.0", "User-Agent": "Test/2.0",
}, },
@@ -205,7 +523,7 @@ func TestEndpoint_buildHTTPRequestWithHostHeader(t *testing.T) {
Name: "website-health", Name: "website-health",
URL: "https://twin.sh/health", URL: "https://twin.sh/health",
Method: "POST", Method: "POST",
Conditions: []*Condition{&condition}, Conditions: []Condition{condition},
Headers: map[string]string{ Headers: map[string]string{
"Host": "example.com", "Host": "example.com",
}, },
@@ -226,7 +544,7 @@ func TestEndpoint_buildHTTPRequestWithGraphQLEnabled(t *testing.T) {
Name: "website-graphql", Name: "website-graphql",
URL: "https://twin.sh/graphql", URL: "https://twin.sh/graphql",
Method: "POST", Method: "POST",
Conditions: []*Condition{&condition}, Conditions: []Condition{condition},
GraphQL: true, GraphQL: true,
Body: `{ Body: `{
users(gender: "female") { users(gender: "female") {
@@ -245,7 +563,7 @@ func TestEndpoint_buildHTTPRequestWithGraphQLEnabled(t *testing.T) {
if contentType := request.Header.Get(ContentTypeHeader); contentType != "application/json" { if contentType := request.Header.Get(ContentTypeHeader); contentType != "application/json" {
t.Error("request.Header.Content-Type should've been application/json, but was", contentType) t.Error("request.Header.Content-Type should've been application/json, but was", contentType)
} }
body, _ := ioutil.ReadAll(request.Body) body, _ := io.ReadAll(request.Body)
if !strings.HasPrefix(string(body), "{\"query\":") { if !strings.HasPrefix(string(body), "{\"query\":") {
t.Error("request.body should've started with '{\"query\":', but it didn't:", string(body)) t.Error("request.body should've started with '{\"query\":', but it didn't:", string(body))
} }
@@ -257,7 +575,7 @@ func TestIntegrationEvaluateHealth(t *testing.T) {
endpoint := Endpoint{ endpoint := Endpoint{
Name: "website-health", Name: "website-health",
URL: "https://twin.sh/health", URL: "https://twin.sh/health",
Conditions: []*Condition{&condition, &bodyCondition}, Conditions: []Condition{condition, bodyCondition},
} }
endpoint.ValidateAndSetDefaults() endpoint.ValidateAndSetDefaults()
result := endpoint.EvaluateHealth() result := endpoint.EvaluateHealth()
@@ -270,25 +588,53 @@ func TestIntegrationEvaluateHealth(t *testing.T) {
if !result.Success { if !result.Success {
t.Error("Because all conditions passed, this should have been a success") t.Error("Because all conditions passed, this should have been a success")
} }
if result.Hostname != "twin.sh" {
t.Error("result.Hostname should've been twin.sh, but was", result.Hostname)
}
} }
func TestIntegrationEvaluateHealthWithFailure(t *testing.T) { func TestIntegrationEvaluateHealthWithInvalidCondition(t *testing.T) {
condition := Condition("[STATUS] == 500") condition := Condition("[STATUS] invalid 200")
endpoint := Endpoint{ endpoint := Endpoint{
Name: "website-health", Name: "invalid-condition",
URL: "https://twin.sh/health", URL: "https://twin.sh/health",
Conditions: []*Condition{&condition}, Conditions: []Condition{condition},
}
if err := endpoint.ValidateAndSetDefaults(); err != nil {
// XXX: Should this really not return an error? After all, the condition is not valid and conditions are part of the endpoint...
t.Error("endpoint validation should've been successful, but wasn't")
}
result := endpoint.EvaluateHealth()
if result.Success {
t.Error("Because one of the conditions was invalid, result.Success should have been false")
}
if len(result.Errors) == 0 {
t.Error("There should've been an error")
}
}
func TestIntegrationEvaluateHealthWithErrorAndHideURL(t *testing.T) {
endpoint := Endpoint{
Name: "invalid-url",
URL: "https://httpstat.us/200?sleep=100",
Conditions: []Condition{Condition("[STATUS] == 200")},
ClientConfig: &client.Config{
Timeout: 1 * time.Millisecond,
},
UIConfig: &ui.Config{
HideURL: true,
},
} }
endpoint.ValidateAndSetDefaults() endpoint.ValidateAndSetDefaults()
result := endpoint.EvaluateHealth() result := endpoint.EvaluateHealth()
if result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a failure", condition)
}
if !result.Connected {
t.Error("Because the connection has been established, result.Connected should've been true")
}
if result.Success { if result.Success {
t.Error("Because one of the conditions failed, success should have been false") t.Error("Because one of the conditions was invalid, result.Success should have been false")
}
if len(result.Errors) == 0 {
t.Error("There should've been an error")
}
if !strings.Contains(result.Errors[0], "<redacted>") || strings.Contains(result.Errors[0], endpoint.URL) {
t.Error("result.Errors[0] should've had the URL redacted because ui.hide-url is set to true")
} }
} }
@@ -297,17 +643,17 @@ func TestIntegrationEvaluateHealthForDNS(t *testing.T) {
conditionBody := Condition("[BODY] == 93.184.216.34") conditionBody := Condition("[BODY] == 93.184.216.34")
endpoint := Endpoint{ endpoint := Endpoint{
Name: "example", Name: "example",
URL: "8.8.8.8", URL: "1.1.1.1",
DNS: &DNS{ DNS: &DNS{
QueryType: "A", QueryType: "A",
QueryName: "example.com.", QueryName: "example.com.",
}, },
Conditions: []*Condition{&conditionSuccess, &conditionBody}, Conditions: []Condition{conditionSuccess, conditionBody},
} }
endpoint.ValidateAndSetDefaults() endpoint.ValidateAndSetDefaults()
result := endpoint.EvaluateHealth() result := endpoint.EvaluateHealth()
if !result.ConditionResults[0].Success { if !result.ConditionResults[0].Success {
t.Errorf("Conditions '%s' and %s should have been a success", conditionSuccess, conditionBody) t.Errorf("Conditions '%s' and '%s' should have been a success", conditionSuccess, conditionBody)
} }
if !result.Connected { if !result.Connected {
t.Error("Because the connection has been established, result.Connected should've been true") t.Error("Because the connection has been established, result.Connected should've been true")
@@ -318,16 +664,15 @@ func TestIntegrationEvaluateHealthForDNS(t *testing.T) {
} }
func TestIntegrationEvaluateHealthForICMP(t *testing.T) { func TestIntegrationEvaluateHealthForICMP(t *testing.T) {
conditionSuccess := Condition("[CONNECTED] == true")
endpoint := Endpoint{ endpoint := Endpoint{
Name: "icmp-test", Name: "icmp-test",
URL: "icmp://127.0.0.1", URL: "icmp://127.0.0.1",
Conditions: []*Condition{&conditionSuccess}, Conditions: []Condition{"[CONNECTED] == true"},
} }
endpoint.ValidateAndSetDefaults() endpoint.ValidateAndSetDefaults()
result := endpoint.EvaluateHealth() result := endpoint.EvaluateHealth()
if !result.ConditionResults[0].Success { if !result.ConditionResults[0].Success {
t.Errorf("Conditions '%s' should have been a success", conditionSuccess) t.Errorf("Conditions '%s' should have been a success", endpoint.Conditions[0])
} }
if !result.Connected { if !result.Connected {
t.Error("Because the connection has been established, result.Connected should've been true") t.Error("Because the connection has been established, result.Connected should've been true")
@@ -337,12 +682,20 @@ func TestIntegrationEvaluateHealthForICMP(t *testing.T) {
} }
} }
func TestEndpoint_DisplayName(t *testing.T) {
if endpoint := (Endpoint{Name: "n"}); endpoint.DisplayName() != "n" {
t.Error("endpoint.DisplayName() should've been 'n', but was", endpoint.DisplayName())
}
if endpoint := (Endpoint{Group: "g", Name: "n"}); endpoint.DisplayName() != "g/n" {
t.Error("endpoint.DisplayName() should've been 'g/n', but was", endpoint.DisplayName())
}
}
func TestEndpoint_getIP(t *testing.T) { func TestEndpoint_getIP(t *testing.T) {
conditionSuccess := Condition("[CONNECTED] == true")
endpoint := Endpoint{ endpoint := Endpoint{
Name: "invalid-url-test", Name: "invalid-url-test",
URL: "", URL: "",
Conditions: []*Condition{&conditionSuccess}, Conditions: []Condition{"[CONNECTED] == true"},
} }
result := &Result{} result := &Result{}
endpoint.getIP(result) endpoint.getIP(result)
@@ -351,26 +704,44 @@ func TestEndpoint_getIP(t *testing.T) {
} }
} }
func TestEndpoint_NeedsToReadBody(t *testing.T) { func TestEndpoint_needsToReadBody(t *testing.T) {
statusCondition := Condition("[STATUS] == 200") statusCondition := Condition("[STATUS] == 200")
bodyCondition := Condition("[BODY].status == UP") bodyCondition := Condition("[BODY].status == UP")
bodyConditionWithLength := Condition("len([BODY].tags) > 0") bodyConditionWithLength := Condition("len([BODY].tags) > 0")
if (&Endpoint{Conditions: []*Condition{&statusCondition}}).needsToReadBody() { if (&Endpoint{Conditions: []Condition{statusCondition}}).needsToReadBody() {
t.Error("expected false, got true") t.Error("expected false, got true")
} }
if !(&Endpoint{Conditions: []*Condition{&bodyCondition}}).needsToReadBody() { if !(&Endpoint{Conditions: []Condition{bodyCondition}}).needsToReadBody() {
t.Error("expected true, got false") t.Error("expected true, got false")
} }
if !(&Endpoint{Conditions: []*Condition{&bodyConditionWithLength}}).needsToReadBody() { if !(&Endpoint{Conditions: []Condition{bodyConditionWithLength}}).needsToReadBody() {
t.Error("expected true, got false") t.Error("expected true, got false")
} }
if !(&Endpoint{Conditions: []*Condition{&statusCondition, &bodyCondition}}).needsToReadBody() { if !(&Endpoint{Conditions: []Condition{statusCondition, bodyCondition}}).needsToReadBody() {
t.Error("expected true, got false") t.Error("expected true, got false")
} }
if !(&Endpoint{Conditions: []*Condition{&bodyCondition, &statusCondition}}).needsToReadBody() { if !(&Endpoint{Conditions: []Condition{bodyCondition, statusCondition}}).needsToReadBody() {
t.Error("expected true, got false") t.Error("expected true, got false")
} }
if !(&Endpoint{Conditions: []*Condition{&bodyConditionWithLength, &statusCondition}}).needsToReadBody() { if !(&Endpoint{Conditions: []Condition{bodyConditionWithLength, statusCondition}}).needsToReadBody() {
t.Error("expected true, got false")
}
}
func TestEndpoint_needsToRetrieveDomainExpiration(t *testing.T) {
if (&Endpoint{Conditions: []Condition{"[STATUS] == 200"}}).needsToRetrieveDomainExpiration() {
t.Error("expected false, got true")
}
if !(&Endpoint{Conditions: []Condition{"[STATUS] == 200", "[DOMAIN_EXPIRATION] < 720h"}}).needsToRetrieveDomainExpiration() {
t.Error("expected true, got false")
}
}
func TestEndpoint_needsToRetrieveIP(t *testing.T) {
if (&Endpoint{Conditions: []Condition{"[STATUS] == 200"}}).needsToRetrieveIP() {
t.Error("expected false, got true")
}
if !(&Endpoint{Conditions: []Condition{"[STATUS] == 200", "[IP] == 127.0.0.1"}}).needsToRetrieveIP() {
t.Error("expected true, got false") t.Error("expected true, got false")
} }
} }

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