Compare commits

..
233 Commits
Author SHA1 Message Date
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a29cf158dd chore(deps): bump golang.org/x/oauth2 from 0.3.0 to 0.4.0 (#423)
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.3.0 to 0.4.0.
- [Release notes](https://github.com/golang/oauth2/releases)
- [Commits](https://github.com/golang/oauth2/compare/v0.3.0...v0.4.0)

---
updated-dependencies:
- dependency-name: golang.org/x/oauth2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-09 20:30:30 -05:00
TwiN 9d14e3011b docs: Add link to banner 2023-02-08 20:50:30 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d13998d13d chore(deps): bump modernc.org/sqlite from 1.19.5 to 1.20.3 (#409)
---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-05 22:39:11 -05:00
TwiN f6a39f6df0 docs(alerting): Update pushover documentation 2023-02-05 21:58:41 -05:00
Marc BruggerandGitHub 9e2006910d feat(alerting): Add Pushover sound support (#417) 2023-02-05 21:47:39 -05:00
tesibeldaandGitHub 6e4b88dc6e fix(test): flaky TestStore_InsertCleansUpOldUptimeEntriesProperly (#418) 2023-02-01 22:59:31 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
277e805dbb chore(deps): bump docker/build-push-action from 3 to 4 (#415)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 3 to 4.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-01 01:16:25 -05:00
TwiNandGitHub 941c10ca45 chore: Remove vendor folder (#414)
* chore: Remove vendor folder

* chore: Add vendor folder to .gitignore
2023-01-30 22:29:30 -05:00
21f62f362f feat(alerting): Add Pushover provider (#405)
* Add a new Pushover provider (#129)
- Adds new provider named Pushover with corresponding tests
- Adds Pushover as a provider to the configuration and adjusts test accordingly
- Adds Pushover to alerting_test.go, provider.go and type.go
- Updates the readme with configuration details

* Correct import order

* Fix some missing pushover references

* Apply suggestions from code review

* Rename application-key to application-token for Pushover

---------

Co-authored-by: TwiN <twin@linux.com>
2023-01-29 17:32:16 -05:00
TwiN d75180c341 docs: Clarify what the storage is used for
Resolves #411
2023-01-29 16:47:25 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a82b883276 chore(deps): bump github.com/coreos/go-oidc/v3 from 3.4.0 to 3.5.0 (#404)
Bumps [github.com/coreos/go-oidc/v3](https://github.com/coreos/go-oidc) from 3.4.0 to 3.5.0.
- [Release notes](https://github.com/coreos/go-oidc/releases)
- [Commits](https://github.com/coreos/go-oidc/compare/v3.4.0...v3.5.0)

---
updated-dependencies:
- dependency-name: github.com/coreos/go-oidc/v3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-22 16:54:53 -05:00
TwiN 24e207c0c6 fix(metrics): Disable compression on prometheus/client_golang's handler
Fixes #406

Root cause seems to be that promhttp.Handler() has its own gzip compression https://github.com/prometheus/client_golang/issues/622
2023-01-19 23:06:38 -05:00
TwiN 90bb8f7b5f test: Don't run tests in parallel 2023-01-19 01:53:52 -05:00
TwiN 0db92f46da test: Add several tests for numerical conditions 2023-01-19 01:37:21 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
0ffa03f42d chore(deps): bump github.com/TwiN/deepmerge from 0.1.0 to 0.2.0 (#401)
Bumps [github.com/TwiN/deepmerge](https://github.com/TwiN/deepmerge) from 0.1.0 to 0.2.0.
- [Release notes](https://github.com/TwiN/deepmerge/releases)
- [Commits](https://github.com/TwiN/deepmerge/compare/v0.1.0...v0.2.0)

---
updated-dependencies:
- dependency-name: github.com/TwiN/deepmerge
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-11 01:05:17 -05:00
TwiN e61a42220c fix: Log GATUS_CONFIG_FILE deprecation message only if it's non-empty 2023-01-10 19:04:19 -05:00
TwiN 78dccc90e1 fix(#400): Use proper configuration path when iterating over options 2023-01-10 00:24:56 -05:00
TwiN 6bdd3c94fe chore: Add deprecation message for GATUS_CONFIG_FILE 2023-01-09 23:24:20 -05:00
TwiN 4225d22369 docs: Add example of docker-compose with multiple configuration files 2023-01-08 18:34:13 -05:00
TwiNandGitHub 3059e3e028 feat: Support multiple configuration files (#396)
* Revert "Revert "feat: Support multiple configuration files" (#395)"

This reverts commit 87740e74a6.

* feat: Properly implement support for config directory
2023-01-08 17:53:37 -05:00
TwiNandGitHub 87740e74a6 Revert "feat: Support multiple configuration files" (#395)
Revert "feat: Support multiple configuration files (#389)"

This reverts commit 8e14302765.
2023-01-07 03:45:43 -05:00
8e14302765 feat: Support multiple configuration files (#389)
* Allow configuration to be distributed

* catch iteration errors when collecting config files

* rm unused func

* Fix suffix check for config loading

* test configuration loading

* GATUS_CONFIG_PATH can be a file or a directory now

* Add deprecation note

* Fix cs

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

* cs fixes

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

* cs fixes

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

* cs fixes

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

* cs + rm useless line

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

* Update config/config.go

Co-authored-by: TwiN <twin@linux.com>
2023-01-06 23:46:19 -05:00
TwiN 844f417ea1 docs: Update sponsor list 2023-01-06 20:06:56 -05:00
TwiN 2f7f782f11 docs: Improve documentation and add ghcr Docker image link 2023-01-06 20:04:21 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
37bea336ca chore(deps): bump github.com/TwiN/health from 1.5.0 to 1.6.0 (#394)
Bumps [github.com/TwiN/health](https://github.com/TwiN/health) from 1.5.0 to 1.6.0.
- [Release notes](https://github.com/TwiN/health/releases)
- [Commits](https://github.com/TwiN/health/compare/v1.5.0...v1.6.0)

---
updated-dependencies:
- dependency-name: github.com/TwiN/health
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-04 01:37:24 -05:00
TwiN 616a654b27 fix: Compress everything with Gzip 2022-12-30 21:37:52 -05:00
TwiN a1c8422c2f ci: Make release also push latest tag 2022-12-23 10:00:17 -05:00
TwiNandGitHub 947173bf71 fix: Prevent jsonpath from causing panic when body is expected to be array but isn't (#392)
* fix: Prevent jsonpath from causing panic when body is expected to be array but isn't

Fixes #391
2022-12-23 09:55:17 -05:00
TwiN a81a83e2d4 docs: Update example 2022-12-22 17:38:27 -05:00
Salim BandTwiN 4599fe4da7 Clarify description of len() function 2022-12-21 22:37:49 -05:00
TwiN 19e90cdf31 test(condition): Add has-key-of-map test case 2022-12-17 23:58:01 -05:00
TwiN ecc0636a59 feat(alerting): Implement GitHub alerting provider 2022-12-15 23:38:44 -05:00
TwiN 27502acd10 chore: Improve oauth2 configuration error 2022-12-15 23:25:37 -05:00
TwiN 51255e33ea refactor(alerting): Use reflection to retrieve provider based on alert type 2022-12-15 21:37:34 -05:00
TwiN be0962112e refactor: Remove unnecessary logs 2022-12-15 21:37:34 -05:00
TwiN dfcea93080 fix(alerting): Use reflection to set invalid providers to nil instead of re-validating on every alert trigger/resolve 2022-12-15 21:37:34 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a5f135c675 chore(deps): bump github.com/miekg/dns from 1.1.43 to 1.1.50 (#385)
Bumps [github.com/miekg/dns](https://github.com/miekg/dns) from 1.1.43 to 1.1.50.
- [Release notes](https://github.com/miekg/dns/releases)
- [Changelog](https://github.com/miekg/dns/blob/master/Makefile.release)
- [Commits](https://github.com/miekg/dns/compare/v1.1.43...v1.1.50)

---
updated-dependencies:
- dependency-name: github.com/miekg/dns
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-12-14 23:51:19 -05:00
TwiN 9acace7d37 Revert "ci: Allow triggering releases manually"
This reverts commit 184c7f23ad.
2022-12-10 16:29:07 -05:00
TwiN 184c7f23ad ci: Allow triggering releases manually 2022-12-10 16:24:35 -05:00
TwiN 5ce890bbff ci: Don't run tests for workflow changes 2022-12-10 16:24:01 -05:00
TwiN b0bec5ff94 fix(ci): Don't prevent publish-release-to-ghcr from running 2022-12-10 16:08:46 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e503dd3861 chore(deps): bump github.com/coreos/go-oidc/v3 from 3.1.0 to 3.4.0 (#383)
Bumps [github.com/coreos/go-oidc/v3](https://github.com/coreos/go-oidc) from 3.1.0 to 3.4.0.
- [Release notes](https://github.com/coreos/go-oidc/releases)
- [Commits](https://github.com/coreos/go-oidc/compare/v3.1.0...v3.4.0)

---
updated-dependencies:
- dependency-name: github.com/coreos/go-oidc/v3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-12-08 23:20:27 -05:00
TwiN f2d51f3e50 docs: Update Gatus diagram 2022-12-06 23:03:27 -05:00
TwiN a1a2fba326 docs: Link Alerting section in valid alert types 2022-12-06 22:36:33 -05:00
TwiN fdd51869a3 test: Improve condition validation test coverage 2022-12-06 21:57:24 -05:00
TwiN f6a621da28 chore: Bump module version to v5 2022-12-06 01:41:18 -05:00
TwiNandGitHub 2346a6ee4f fix!: Enforce mandatory space around condition operator (#382)
BREAKING CHANGE: The comparator in each condition must now be wrapped by a space (e.g. [STATUS] == 200) or the condition will not be valid.
2022-12-06 01:37:05 -05:00
TwiNandGitHub 741109f25d chore!: Remove deprecated "services" in favor of "endpoints" (#381)
BREAKING CHANGE: `services` has been replaced by `endpoints` in v3.3.0, but backward compatibility has been retained until now. v5.0.0, however, will no longer support `services`, so make sure to update your configuration to use `endpoints` instead.

See #374 for more information
2022-12-06 01:27:31 -05:00
TwiNandGitHub d058d7a54b fix!: Default Alert.Enabled to true (#380)
BREAKING CHANGE: It used to default to false, which meant that users had to explicitly set endpoints[].alerts[].enabled to true
2022-12-05 23:15:19 -05:00
TwiN 7dccf5f08c docs: Clarify that alerts still have to be defined when using default-alerts 2022-12-05 22:40:25 -05:00
9e46e3972d feat: Add necessary files for PWA (#347)
* feat: PWA Implementation

* feat: PWA Implementation. fix comments

* feat: PWA Implementation. fix comments

Co-authored-by: Viacheslav Bondar <bondarslavik@gmail.com>
2022-12-04 15:11:26 -05:00
TwiN 9fc8374a4d docs: Fix typos 2022-12-03 21:26:46 -05:00
TwiN 1aeb045703 test: Fix failing tests 2022-12-01 20:26:14 -05:00
TwiN cdec353744 chore(deps): Update sqlite dependencies 2022-12-01 20:19:56 -05:00
TwiN 080563bd4f fix(alerting): Improve ntfy alert 2022-12-01 20:19:11 -05:00
TwiN bcb565ba37 ci: Remove workflow_dispatch 2022-11-23 18:15:01 -05:00
TwiN 2327854641 ci: Update workflows 2022-11-23 18:12:55 -05:00
TwiN 79eacc5e50 ci: Re-add other platforms 2022-11-22 22:02:38 -05:00
TwiN 048a1d4a88 ci: Only publish for linux/amd64 during tests 2022-11-22 21:53:28 -05:00
TwiN c09ee0b82f ci: Create workflow to publish latest to GHCR 2022-11-22 21:08:40 -05:00
TwiN 7908eea2df ci: Update workflow dependency name 2022-11-22 20:56:49 -05:00
TwiNandGitHub f8140e0d96 fix(alerting): Resolve issue with blank GoogleChat messages (#364)
* debug: Print GoogleChat request body

* chore: Update TwiN/whois to v1.1.0

* fix: Add missing client changes

* test: Improve DNS tests

* chore: Remove accidental change

* docs: Add note for future change to default behavior

* fix(alerting): Don't include URL in Google Chat alert if endpoint type isn't HTTP

Fixes #362
2022-11-22 20:12:26 -05:00
TwiNandGitHub 4f569b7a0e fix(jsonpath): Properly handle len of object in array, len of int and len of bool (#372) 2022-11-19 17:25:40 -05:00
dependabot[bot]andTwiN e9f46c58f8 chore(deps): bump github.com/prometheus/client_golang
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.13.0 to 1.14.0.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.13.0...v1.14.0)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-11-16 21:58:14 -05:00
TwiN 502e159dca test: Add case for making sure pat() works inside a JSON array 2022-11-16 18:27:28 -05:00
TwiN cdbf5902c7 build: Remove -mod vendor flag from Dockerfile 2022-11-15 21:58:24 -05:00
TwiN c7f80f1301 ci: Rename build.yml to test.yml 2022-11-15 21:51:40 -05:00
TwiN eb4e22e76b chore: Replace 1.1.1.1 by 8.8.8.8 everywhere due to 1.1.1.1 being unreliable 2022-11-15 21:50:54 -05:00
TwiN f37a0ef2d7 test: Replace DNS 1.1.1.1 by 8.8.8.8 2022-11-15 21:50:54 -05:00
TwiN 114b78c75c test: Replace DNS 1.1.1.1 by 8.8.8.8 2022-11-15 21:50:54 -05:00
TwiN d24ff5bd07 refactor: Move whois to client package and implement caching 2022-11-15 21:50:54 -05:00
TwiN c172e733be chore(deps): Update TwiN/gocache to v2.2.0 2022-11-15 21:50:54 -05:00
TwiN f1ce83c211 chore(deps): Update TwiN/whois to v1.1.0 2022-11-15 00:25:02 -05:00
TwiN 64f4dac705 fix: Wrap error properly (%s -> %w) 2022-11-12 14:56:25 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
861c443842 chore(deps): bump loader-utils from 1.4.0 to 1.4.2 in /web/app (#365)
Bumps [loader-utils](https://github.com/webpack/loader-utils) from 1.4.0 to 1.4.2.
- [Release notes](https://github.com/webpack/loader-utils/releases)
- [Changelog](https://github.com/webpack/loader-utils/blob/v1.4.2/CHANGELOG.md)
- [Commits](https://github.com/webpack/loader-utils/compare/v1.4.0...v1.4.2)

---
updated-dependencies:
- dependency-name: loader-utils
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-11-12 14:51:39 -05:00
TwiN b801cc5801 fix(alerting): Prevent empty keyValue from being marshalled 2022-11-10 22:12:20 -05:00
TwiN f1711b5c0b fix(alerting): Omit empty KeyValue parameters when marshalling to JSON 2022-11-10 22:12:20 -05:00
TwiN 0ebd6c7a67 chore: Clean up old commented code 2022-11-10 22:12:20 -05:00
TwiN 967124eb43 fix(alerting): Resolve GoogleChat issue with bad payload when condition has " in it
Fixes #362
2022-11-10 22:12:20 -05:00
Ian ChenandGitHub fa47a199e5 feat: support SCTP & UDP as endpoint type (#352)
* feat: support SCTP & UDP as endpoint type

* update README

* modify endpoint type test for sctp & udp
2022-11-09 19:22:13 -05:00
TwiN 1f84f2afa0 fix: Make sure len([BODY]) works if the body is a JSON array
Fixes #359
2022-11-03 20:50:40 -04:00
dependabot[bot]andTwiN ed3683cb32 chore(deps): bump github.com/lib/pq from 1.10.3 to 1.10.7
Bumps [github.com/lib/pq](https://github.com/lib/pq) from 1.10.3 to 1.10.7.
- [Release notes](https://github.com/lib/pq/releases)
- [Commits](https://github.com/lib/pq/compare/v1.10.3...v1.10.7)

---
updated-dependencies:
- dependency-name: github.com/lib/pq
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-11-02 19:40:52 -04:00
dependabot[bot]andTwiN 6e92c0eb40 chore(deps): bump github.com/prometheus/client_golang
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.11.0 to 1.13.0.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.11.0...v1.13.0)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-11-01 23:48:39 -04:00
TwiNandGitHub cd927f630b ci: Remove unnecessary dependabot labels 2022-11-01 23:41:35 -04:00
TwiNandGitHub c6c9bc8fa5 ci: Update dependabot labels 2022-11-01 23:36:40 -04:00
dependabot[bot]andTwiN a3facc3887 chore(deps): bump docker/setup-qemu-action from 1 to 2
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 1 to 2.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v1...v2)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-11-01 23:34:49 -04:00
dependabot[bot]andTwiN 991d7e876d chore(deps): bump docker/setup-buildx-action from 1 to 2
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 1 to 2.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v1...v2)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-11-01 23:34:40 -04:00
dependabot[bot]andTwiN 3b7fb083ca chore(deps): bump codecov/codecov-action from 2.1.0 to 3.1.1
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 2.1.0 to 3.1.1.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v2.1.0...v3.1.1)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-11-01 23:34:14 -04:00
dependabot[bot]andTwiN ebdf5bde49 chore(deps): bump github.com/TwiN/health from 1.4.0 to 1.5.0
Bumps [github.com/TwiN/health](https://github.com/TwiN/health) from 1.4.0 to 1.5.0.
- [Release notes](https://github.com/TwiN/health/releases)
- [Commits](https://github.com/TwiN/health/compare/v1.4.0...v1.5.0)

---
updated-dependencies:
- dependency-name: github.com/TwiN/health
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-11-01 23:33:21 -04:00
TwiNandGitHub d4983733f5 ci: Add dependabot.yml 2022-11-01 23:30:35 -04:00
TwiN fed021826a ui: Generate static assets 2022-11-01 00:45:04 -04:00
TwiN 8f9eca51c0 ui: Show "now" if the pretty time difference is less than 500ms 2022-11-01 00:44:41 -04:00
TwiN e13730f119 docs: Fix table format 2022-11-01 00:43:42 -04:00
22d74a5ea8 feat(ui): Allow configuring meta description (#342)
* feat: add description prop to HTML template

* feat: add desc property to backend config validation

* test: add desc field to ui config test

* chore: add default description text

* test: add test for description default

* docs: add description config option explanation

* Update README.md

* Update config/ui/ui_test.go

Co-authored-by: TwiN <twin@linux.com>
2022-11-01 00:33:19 -04:00
TwiN fe4d9821f3 fix(alerting): Fix Discord alert payload missing required field 2022-10-20 20:23:10 -04:00
TwiN d01a5d418b test: Improve error readability 2022-10-20 20:23:10 -04:00
TwiN 34f8cd1eca docs: Update screenshot of Mattermost alerts 2022-10-20 20:23:10 -04:00
TwiN d101c17136 fix(alerting): Resolve Mattermost issue with bad payload when condition has " in it 2022-10-20 20:23:10 -04:00
TwiN ade3d05983 fix(alerting): Use required Field.Title in Slack provider even if it's not enforced
Just to prevent future issues
2022-10-20 20:23:10 -04:00
TwiN fbab0ef7ca fix(alerting): Resolve Discord issue with bad payload when condition has " in it 2022-10-20 20:23:10 -04:00
TwiN 9121ec1cc8 fix(alerting): Resolve Matrix issue with bad payload when condition has " in it 2022-10-20 20:23:10 -04:00
TwiN 6ddf1258e5 fix(alerting): Resolve PagerDuty issue with bad payload when alert description has " in it 2022-10-20 20:23:10 -04:00
TwiN 490610ccfd fix(alerting): Resolve Teams issue with bad payload when condition has " in it 2022-10-20 20:23:10 -04:00
TwiN 0eb6958085 fix(alerting): Resolve Telegram issue with bad payload when condition has " in it 2022-10-20 20:23:10 -04:00
TwiN d20a41c7a7 fix(alerting): Make sure to close response body 2022-10-20 20:23:10 -04:00
TwiN 4c18e0d602 chore(alerting): Remove unnecessary cast 2022-10-20 20:23:10 -04:00
TwiN da24b7e8ac fix(alerting): Resolve Slack issue with bad payload when condition has " in it 2022-10-20 20:23:10 -04:00
TwiN c619066e25 docs: Swap conditions/dark screenshots 2022-10-19 18:00:53 -04:00
TwiN 3688dd6e6f chore: Clean up unused assets 2022-10-19 17:53:43 -04:00
TwiN fc778300be ci: Update Go to 1.19 2022-10-19 17:52:53 -04:00
TwiNandGitHub df560ad872 ui: Replace and reposition old icons by SVG icons (#349) 2022-10-19 17:38:32 -04:00
TwiN de9c366777 docs: Add Keeping your configuration small section 2022-10-19 16:51:14 -04:00
TwiNandGitHub 6a5fec2c55 perf: Improve jsonpath speed (#348) 2022-10-19 15:52:20 -04:00
TwiN 01d2ed3f02 ui: Make it more obvious that the response time can be toggled between avg and min-max 2022-10-17 00:49:33 -04:00
TwiN 92b85ee1ab ui: Improve login page 2022-10-17 00:48:40 -04:00
TwiNandGitHub a789deb8c2 ui: Render div instead of a when link is blank (#346)
Fixes #343
2022-10-17 00:03:15 -04:00
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
2225 changed files with 10133 additions and 2884714 deletions
@@ -1,23 +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`.
## Queries
Gatus uses Prometheus counters.
![Gatus Grafana dashboard](../../.github/assets/grafana-dashboard.png)
Total results per minute:
## 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:
### Total successful results per minute
```
sum(rate(gatus_results_total{success="true"}[5m])*60) by (key)
```
Total unsuccessful results per minute:
### Total unsuccessful results per minute
```
sum(rate(gatus_results_total{success="false"}[5m])*60) by (key)
```
sum(rate(gatus_results_total{success="true"}[5m])*60) by (key)
```
@@ -2,15 +2,18 @@ metrics: true
endpoints:
- name: website
url: https://twin.sh/health
interval: 30s
interval: 5m
conditions:
- "[STATUS] == 200"
- name: example
url: https://example.com/
interval: 5m
conditions:
- "[STATUS] == 200"
- name: github
url: https://api.github.com/healthz
interval: 5m
conditions:
- "[STATUS] == 200"
- name: example
url: https://example.com/
conditions:
- "[STATUS] == 200"
@@ -15,8 +15,266 @@
"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,
@@ -27,10 +285,10 @@
"fill": 1,
"fillGradient": 0,
"gridPos": {
"h": 7,
"h": 8,
"w": 12,
"x": 0,
"y": 0
"x": 12,
"y": 8
},
"id": 2,
"interval": "",
@@ -126,8 +384,8 @@
"gridPos": {
"h": 7,
"w": 12,
"x": 12,
"y": 0
"x": 0,
"y": 16
},
"id": 5,
"legend": {
@@ -204,94 +462,6 @@
"alignLevel": null
}
},
{
"cacheTimeout": null,
"colorBackground": false,
"colorPostfix": false,
"colorPrefix": false,
"colorValue": true,
"colors": [
"#299c46",
"rgba(237, 129, 40, 0.89)",
"#d44a3a"
],
"datasource": null,
"format": "none",
"gauge": {
"maxValue": 100,
"minValue": 0,
"show": false,
"thresholdLabels": false,
"thresholdMarkers": true
},
"gridPos": {
"h": 7,
"w": 12,
"x": 0,
"y": 7
},
"id": 7,
"interval": "",
"links": [],
"mappingType": 1,
"mappingTypes": [
{
"name": "value to text",
"value": 1
},
{
"name": "range to text",
"value": 2
}
],
"maxDataPoints": 100,
"nullPointMode": "connected",
"nullText": null,
"options": {},
"postfix": "",
"postfixFontSize": "50%",
"prefix": "",
"prefixFontSize": "50%",
"rangeMaps": [
{
"from": "null",
"text": "N/A",
"to": "null"
}
],
"sparkline": {
"fillColor": "rgba(31, 118, 189, 0.18)",
"full": false,
"lineColor": "rgb(31, 120, 193)",
"show": true,
"ymax": null,
"ymin": null
},
"tableColumn": "",
"targets": [
{
"expr": "rate(gatus_results_total{success=\"false\"}[1m])*60",
"format": "time_series",
"instant": false,
"intervalFactor": 1,
"refId": "A"
}
],
"thresholds": "1,2",
"timeFrom": null,
"timeShift": null,
"title": "Unsuccessful results",
"type": "singlestat",
"valueFontSize": "150%",
"valueMaps": [
{
"op": "=",
"text": "N/A",
"value": "null"
}
],
"valueName": "current"
},
{
"aliasColors": {},
"bars": false,
@@ -304,7 +474,7 @@
"h": 7,
"w": 12,
"x": 12,
"y": 7
"y": 16
},
"id": 3,
"legend": {
@@ -380,7 +550,7 @@
}
}
],
"refresh": "10s",
"refresh": "1m",
"schemaVersion": 20,
"style": "dark",
"tags": [],
@@ -391,9 +561,22 @@
"from": "now-1h",
"to": "now"
},
"timepicker": {},
"timepicker": {
"refresh_intervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
]
},
"timezone": "",
"title": "Gatus",
"uid": "KPI7Qj1Wk",
"version": 1
"version": 2
}
@@ -7,10 +7,9 @@ endpoints:
- name: example
url: https://example.org
interval: 1m
alerts:
- type: mattermost
enabled: true
description: "health check failed 3 times in a row"
send-on-resolved: true
conditions:
- "[STATUS] == 200"
alerts:
- type: mattermost
description: "health check failed 3 times in a row"
send-on-resolved: true
@@ -0,0 +1,21 @@
endpoints:
- name: check-if-api-is-healthy
group: backend
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 1000"
- name: check-if-website-is-pingable
url: "icmp://example.org"
interval: 1m
conditions:
- "[CONNECTED] == true"
- name: check-domain-expiration
url: "https://example.org"
interval: 6h
conditions:
- "[DOMAIN_EXPIRATION] > 720h"
@@ -0,0 +1,8 @@
endpoints:
- name: make-sure-html-rendering-works
group: frontend
url: "https://example.org"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY] == pat(*<h1>Example Domain</h1>*)" # Check for header in HTML page
@@ -0,0 +1,8 @@
metrics: true
debug: false
ui:
header: Example Company
link: https://example.org
buttons:
- name: "Home"
link: "https://example.org"
@@ -0,0 +1,10 @@
version: "3.8"
services:
gatus:
image: twinproduction/gatus:latest
ports:
- "8080:8080"
environment:
- GATUS_CONFIG_PATH=/config
volumes:
- ./config:/config
+18 -2
View File
@@ -54,10 +54,10 @@ spec:
app: gatus
template:
metadata:
labels:
app: gatus
name: gatus
namespace: kube-system
labels:
app: gatus
spec:
serviceAccountName: gatus
terminationGracePeriodSeconds: 5
@@ -76,6 +76,22 @@ spec:
requests:
cpu: 50m
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:
- mountPath: /config
name: gatus-config
+1 -1
View File
@@ -1 +1 @@
<mxfile host="app.diagrams.net" modified="2021-09-12T22:49:28.336Z" agent="5.0 (Windows)" etag="r9FJ6Bphqwq-LaTO-jp3" version="15.0.6" type="device"><diagram id="FBbfVOMCjf6Z2LK8Yagy" name="Page-1">7Vtdb5swFP01edwEOCHJY5t03aR1q9RWbR9d8MCb4TJj8rFfP9OYBOI2YVrTi5S8RPjaxOack2sf7PTIJFlcSprFVxAy0fOccNEj057nDceu/iwDy1WADEarQCR5uAq5m8AN/8NM0DHRgocsbzRUAELxrBkMIE1ZoBoxKiXMm81+gGj2mtGIWYGbgAo7es9DFZuo5zibis+MR3HV9dgzNQmtWptAHtMQ5rUQueiRiQRQq6tkMWGiBK8CZnXfp1dq1yOTLFVtbvj2/UGwZFq4GpXZfXYJ8Yh9MN8yo6IwT2wGq5YVBCzUiJgiSBVDBCkVF5vouYQiDVnZjaNLmzZfATIddHXwJ1NqaeilhQIdilUiTO2qz7KjV5/NhHIoZMB2PJAZv6IyYmrXgw/XFGjtMkiYkkt9o2SCKj5rDoQaFUXrdhuc9YWB+h9g9yzYteKk4mlkwb8Bt0RqHnPFbjL6jMFc/+ReAnKmv4otdkNpP7m5YTQ2ejW/WM8xg5039L+KxTXp+86BwCIYktRoyeWDuf+58FgWPg6q4nRRr5wuTekNpey3lPIIU8n9Ezk7yfEwyRngkrPh47Fe1x1yiIPJjm9NAnOqgjgE/Elg6DYnAUKwJ4HhkeaZUds842NKeWRJOVcgy7U1tpK3lzN9H1vJYwurI5W2O2yrbdQ0XaXCEz+vNhyj8uOe+NnDD6pDcE/5bV9+e2UKeyd+jtVft+eHoPJzrBa7PT+oJttFdtnd54fg5jf/xM8efnB/P2aYNUOUSZjxkEmLuD1+sWkue//vHl1n2LW34a7tta9Ynmuv/cRliI8Y2Xp15A5H7RDzDoaY7bhvBA1+dQ8rj2BjVX1xDatrLS05LdSyg3g56Hi9tIHqC93techn+jIqL28ZTfIqrvupVXUQVB8dVM9OcVQpJhPIVfcAc/tjbMCIBdgtEyySNOkeXN4YXV/H6qi81ruWuKcjjtVRtecHd8//WN+It+aHoL4R9+yd5XI7Dn8zzrJT+Ltxnu0+899CA9G9ibM/agnW4SZO23vyNGEJyA6ag/7AwYZrjJsnm+dvnPfLk8RpmSdRDy0Q2+vmTM54wPISiDKsRwEpetrcPo2D/xKK2LZ3yvMAZNi9PLBGBi0PVIen6vZszgWH7oFFqvkQDyzk7Ui0Q4uk3zJpoq79q1HWtByAhgSEeOF9PfZpr8EQPVMeq5clrb1SH1XPtle6+4Ku4+0ZvwM6tn0ShrDfUqBtj9YS3FWq7bnu8hdSbR7TrLwsEnEWaLevwSolyAONLn1i4hpy/ryaJdMnUAqSWoMzwaOyQsGWlKFQgqdssv53nfM2+vbJfn2Td5W3vUeYQa4iWXqBri3SBtWRrQMs0nRx85fE57raHzvJxV8=</diagram></mxfile>
<mxfile host="app.diagrams.net" modified="2022-12-07T04:00:31.242Z" agent="5.0 (Windows)" etag="4-CttOJPoGYGt_6RMEMf" version="20.5.3" type="device"><diagram id="oCf8YAkR0GE5Fy88uv5t" name="Page-1">7Vxbc6M2FP41frQHxDWPuW13Z9JpZtKddPdNAQWrBcQKObH76ytsyVxkHOxgC6bOS+BICPl856ZzJCbWbbL8jcJs/jsJUTwBRricWHcTAEzDBfxfQVkJiu9ZG0pEcShoJeEJ/4vko4K6wCHKax0ZITHDWZ0YkDRFAavRIKXkvd7tlcT1t2YwQgrhKYCxSn3GIZsLKjCMsuErwtFcvvoKiJYEyt6CkM9hSN4rJOt+Yt1SQtjmKlneorhgn2TM5rkvLa3bmVGUsi4PfMHmwwP9av/5bDqPP9Obbw/4j6kn5sZW8hejkDNA3BLK5iQiKYzvS+oNJYs0RMWoBr8r+zwQknGiyYl/I8ZWAk24YIST5iyJRSufMF39JZ5f3/wobmaOvL1bVhvvVuLulaRMDGr6/H4z92LCrSwRpJwsaID28EHKFqQRYnv6uVvguMwjkiA+P/4cRTFk+K0+DyhkL9r2K9HhFwKgA8AS477BeCHe9D1HVEGQC1pWXC6S+DpghHJOvSHKMJfqB/iC4keSY4ZJyru8EMZIUulwHeOoaGAFlFXMyILFOEW3Wz0ztgAUz6LlfghUlokHHKEdwkA4UvHfS22zBGle0TPXOBGPry4KUQp6B4XwdSoEsBSNmAA3ZoI1NRzdXwsiG6b5mmnXvINpZ8s152Q7v4qK/zgvWLnMSM6RBcbLSo7MZ7oZfNNvp7ys9ayOMRSaFXAoEN2hcgkOw404IT49+LIer0A5Izhla9Y5NxPnrhX3HXoo3KIYrPRFVYloV4NWpZ0aM9Mz7c1YnaEWwz0WP6ccywQ1AzCV93IE8vqacwlsisp2UsdLj/t54fFbhEcOlGcwlbTv3yoSVG3oKlh1M/M+xww9ZXCtxe88/KqL21pExLNmq3x0t9Om/bGhNsE5LbWpukPKB8boDRWqG0IGi4iPcv+m1aSXVvxHte38Jt3vaNJNY7ckHKbo15TCVaWDsGKtdsB26wJmXzXC2cP684vNDHo1Gb4icjwgYpTEMaJ5DwpbAb5vBbaNoSkwcMcaa9k9Lz6MjpoJHK3Rlvd5hwn2RVs4yWKUFPHRoCOuEv0+Iy7QsrQsQy4LeE4jVuonAvMbozZGOF0EJgW/IlFPfMFa5GRGZUstX7ctNR2Fk6i4hgypfun/Gf7IhcXH8Y+t08pKbRyfV9SGl6UVr0vG6FC8Pru++FwUo/qcjAfvOGfrECTVHV34a/FgUGRo96ZZjw42zI/zO7ZfDzZAL7HGtJ7tOV+yR76pAvszZME8JNGwY41m4mWbMdcWa1haLN4pLZfV0XJZWrPdpprtvo4Lo5MOXISb4fJWXPWFy7bCyvs0LFNUw+Vl0xzo56X8DRVeMoqjaFdGbOS+dD80ltWw1KYxMwzbND3X9gzHd+zDvSUfoPZn197gnst7WpYOkz/AIBc4HV0FaImwzuMqLPuC12F4WS26fSa8nAteB+KldxGpptt6qx0XGVB0eE3YPCw22ZvE7u7zXHtomVBwdTpoEpQQ2rkscXTcCPrBxm9i43XEBpxs5ajmXnrDJiM5i2iR7R4nOralHR01kO8NnfxXzBk8WmwM3dg4ow3oSkh6CRCsrrtYbK1VHGe0AZ02vLTubXBGuxdFG15at8I7oz24oA0vTyteo61qa8NLa63BGW1VWxteWhN+cjl9wasrXo7WBJJrXvA6EC+tCVp5kPWCV2e8gFa8Rluw0oaX1l10lnqy4pGSNxzuON96dM5771G543Pe+kvwdg/nIC878z8E3vHcOvD2yQBV96cEi5ztOMo2tFRpV104WabUVktzIc4DQsPB867rzr7T8U49EosSiOPBc65r1fF0nFPPRkWERDEK5pANnn1m17OAp+Of6v8TyCheDp93tnbeqRVvzjvuWBOSj0D2tFelpdmt8C9lr6vBcw7or0qqFePig0g0XLARsE97wd0BCvvyGAb/DJ91rnbWqSsexILZbDZ83l3p5p2ruRZz/InQnnMjrlhlDfurX57qnpqLe/nZPLl2t3Zsj6KE8dVv5+1RZ16571W2o4+5uS01NDF8ccrtCtS0c9rPR42m9X36Z/yokae5sDCY496dlVtrndVT/VipqXpV8jQfFvP2e0tjBky/oTzW51RSDnO2wzKumrx7QsGC4j6D4ubZMqMVqu6hCvDqoYrbdWl7RGKb35afPt1wvvyErHX/Hw==</diagram></mxfile>
Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 48 KiB

+15
View File
@@ -0,0 +1,15 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
labels: ["dependencies"]
schedule:
interval: "weekly"
day: "wednesday"
- package-ecosystem: "gomod"
directory: "/"
open-pull-requests-limit: 1
labels: ["dependencies"]
schedule:
interval: "weekly"
day: "wednesday"
+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.19
repository: "${{ github.event.inputs.repository }}"
ref: "${{ github.event.inputs.ref }}"
- uses: actions/checkout@v3
- name: Benchmark
run: go test -bench=. ./storage/store
@@ -0,0 +1,26 @@
name: publish-experimental
on: [workflow_dispatch]
jobs:
publish-experimental:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Get image repository
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- 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@v4
with:
platforms: linux/amd64
pull: true
push: true
tags: ${{ env.IMAGE_REPOSITORY }}:experimental
@@ -0,0 +1,38 @@
name: publish-latest-to-ghcr
on:
workflow_run:
workflows: [test]
branches: [master]
types: [completed]
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
jobs:
publish-latest-to-ghcr:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
permissions:
contents: read
packages: write
timeout-minutes: 60
steps:
- uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Get image repository
run: echo IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Login to Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
pull: true
push: true
tags: ${{ env.IMAGE_REPOSITORY }}:latest
+14 -14
View File
@@ -1,34 +1,34 @@
name: publish-latest
on:
workflow_run:
workflows: ["build"]
workflows: [test]
branches: [master]
types: [completed]
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
jobs:
publish-latest:
name: Publish latest
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
timeout-minutes: 45
timeout-minutes: 60
steps:
- name: Check out code
uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Get image repository
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Login to Docker Registry
uses: docker/login-action@v1
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@v2
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
pull: true
push: true
tags: |
${{ env.IMAGE_REPOSITORY }}:latest
tags: ${{ env.IMAGE_REPOSITORY }}:latest
@@ -0,0 +1,37 @@
name: publish-release-to-ghcr
on:
release:
types: [published]
jobs:
publish-release-to-ghcr:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
timeout-minutes: 60
steps:
- uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Get image repository
run: echo IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Get the release
run: echo RELEASE=${GITHUB_REF/refs\/tags\//} >> $GITHUB_ENV
- name: Login to Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
pull: true
push: true
tags: |
${{ env.IMAGE_REPOSITORY }}:${{ env.RELEASE }}
${{ env.IMAGE_REPOSITORY }}:stable
${{ env.IMAGE_REPOSITORY }}:latest
+12 -11
View File
@@ -4,30 +4,31 @@ on:
types: [published]
jobs:
publish-release:
name: Publish release
name: publish-release
runs-on: ubuntu-latest
timeout-minutes: 45
timeout-minutes: 60
steps:
- name: Check out code
uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Get image repository
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Get the release
run: echo RELEASE=${GITHUB_REF/refs\/tags\//} >> $GITHUB_ENV
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Login to Docker Registry
uses: docker/login-action@v1
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@v2
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
pull: true
push: true
tags: |
${{ env.IMAGE_REPOSITORY }}:${{ env.RELEASE }}
${{ env.IMAGE_REPOSITORY }}:stable
${{ env.IMAGE_REPOSITORY }}:latest
@@ -1,33 +1,33 @@
name: build
name: test
on:
pull_request:
paths-ignore:
- '*.md'
- '.examples/*'
push:
branches:
- master
paths-ignore:
- '*.md'
- '.github/*'
- '.examples/*'
jobs:
build:
name: Build
test:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Set up Go
uses: actions/setup-go@v3
- uses: actions/setup-go@v3
with:
go-version: 1.18
- name: Check out code into the Go module directory
uses: actions/checkout@v2
go-version: 1.19
- uses: actions/checkout@v3
- name: Build binary to make sure it works
run: go build -mod vendor
run: go build
- name: Test
# We're using "sudo" because one of the tests leverages ping, which requires super-user privileges.
# As for the 'env "PATH=$PATH" "GOROOT=$GOROOT"', we need it to use the same "go" executable that
# was configured by the "Set up Go" step (otherwise, it'd use sudo's "go" executable)
run: sudo env "PATH=$PATH" "GOROOT=$GOROOT" go test -mod vendor ./... -race -coverprofile=coverage.txt -covermode=atomic
run: sudo env "PATH=$PATH" "GOROOT=$GOROOT" go test ./... -race -coverprofile=coverage.txt -covermode=atomic
- name: Codecov
uses: codecov/codecov-action@v2.1.0
uses: codecov/codecov-action@v3.1.1
with:
files: ./coverage.txt
+13
View File
@@ -1,5 +1,18 @@
# IDE
*.iml
.idea
.vscode
# OS
.DS_Store
# JS
node_modules
# Go
/vendor
# Misc
*.db
*.db-shm
*.db-wal
+1 -2
View File
@@ -3,7 +3,7 @@ FROM golang:alpine as builder
RUN apk --update add ca-certificates
WORKDIR /app
COPY . ./
RUN CGO_ENABLED=0 GOOS=linux go build -mod vendor -a -installsuffix cgo -o gatus .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o gatus .
# Run Tests inside docker image if you don't have a configured go environment
#RUN apk update && apk add --virtual build-dependencies build-base gcc
@@ -13,7 +13,6 @@ RUN CGO_ENABLED=0 GOOS=linux go build -mod vendor -a -installsuffix cgo -o gatus
FROM scratch
COPY --from=builder /app/gatus .
COPY --from=builder /app/config.yaml ./config/config.yaml
COPY --from=builder /app/web/static ./web/static
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
ENV PORT=8080
EXPOSE ${PORT}
+1 -1
View File
@@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2021 TwiN
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
+5 -2
View File
@@ -1,16 +1,19 @@
BINARY=gatus
# Because there's a folder called "test", we need to make the target "test" phony
.PHONY: test
install:
go build -mod vendor -o $(BINARY) .
run:
GATUS_CONFIG_FILE=./config.yaml ./$(BINARY)
GATUS_CONFIG_PATH=./config.yaml ./$(BINARY)
clean:
rm $(BINARY)
test:
sudo go test ./alerting/... ./client/... ./config/... ./controller/... ./core/... ./jsonpath/... ./pattern/... ./security/... ./storage/... ./util/... ./watchdog/... -cover
go test ./... -cover
##########
+556 -183
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -17,6 +17,8 @@ type Alert struct {
// Enabled defines whether the alert is enabled
//
// Use Alert.IsEnabled() to retrieve the value of this field.
//
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
// or not for provider.ParseWithDefaultAlert to work.
Enabled *bool `yaml:"enabled,omitempty"`
@@ -77,9 +79,10 @@ func (alert Alert) GetDescription() string {
}
// IsEnabled returns whether an alert is enabled or not
// Returns true if not set
func (alert Alert) IsEnabled() bool {
if alert.Enabled == nil {
return false
return true
}
return *alert.Enabled
}
+2 -2
View File
@@ -52,8 +52,8 @@ func TestAlert_ValidateAndSetDefaults(t *testing.T) {
}
func TestAlert_IsEnabled(t *testing.T) {
if (Alert{Enabled: nil}).IsEnabled() {
t.Error("alert.IsEnabled() should've returned false, because Enabled was set to nil")
if !(Alert{Enabled: nil}).IsEnabled() {
t.Error("alert.IsEnabled() should've returned true, because Enabled was set to nil")
}
if value := false; (Alert{Enabled: &value}).IsEnabled() {
t.Error("alert.IsEnabled() should've returned false, because Enabled was set to false")
+15 -3
View File
@@ -14,18 +14,33 @@ const (
// TypeEmail is the Type for the email alerting provider
TypeEmail Type = "email"
// TypeGitHub is the Type for the github alerting provider
TypeGitHub Type = "github"
// 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 Type = "mattermost"
// TypeMessagebird is the Type for the messagebird alerting provider
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 Type = "pagerduty"
// TypePushover is the Type for the pushover alerting provider
TypePushover Type = "pushover"
// TypeSlack is the Type for the slack alerting provider
TypeSlack Type = "slack"
@@ -37,7 +52,4 @@ const (
// TypeTwilio is the Type for the twilio alerting provider
TypeTwilio Type = "twilio"
// TypeOpsgenie is the Type for the opsgenie alerting provider
TypeOpsgenie Type = "opsgenie"
)
+63 -93
View File
@@ -1,20 +1,28 @@
package alerting
import (
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/alerting/provider"
"github.com/TwiN/gatus/v3/alerting/provider/custom"
"github.com/TwiN/gatus/v3/alerting/provider/discord"
"github.com/TwiN/gatus/v3/alerting/provider/email"
"github.com/TwiN/gatus/v3/alerting/provider/googlechat"
"github.com/TwiN/gatus/v3/alerting/provider/mattermost"
"github.com/TwiN/gatus/v3/alerting/provider/messagebird"
"github.com/TwiN/gatus/v3/alerting/provider/opsgenie"
"github.com/TwiN/gatus/v3/alerting/provider/pagerduty"
"github.com/TwiN/gatus/v3/alerting/provider/slack"
"github.com/TwiN/gatus/v3/alerting/provider/teams"
"github.com/TwiN/gatus/v3/alerting/provider/telegram"
"github.com/TwiN/gatus/v3/alerting/provider/twilio"
"log"
"reflect"
"strings"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/alerting/provider"
"github.com/TwiN/gatus/v5/alerting/provider/custom"
"github.com/TwiN/gatus/v5/alerting/provider/discord"
"github.com/TwiN/gatus/v5/alerting/provider/email"
"github.com/TwiN/gatus/v5/alerting/provider/github"
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
"github.com/TwiN/gatus/v5/alerting/provider/ntfy"
"github.com/TwiN/gatus/v5/alerting/provider/opsgenie"
"github.com/TwiN/gatus/v5/alerting/provider/pagerduty"
"github.com/TwiN/gatus/v5/alerting/provider/pushover"
"github.com/TwiN/gatus/v5/alerting/provider/slack"
"github.com/TwiN/gatus/v5/alerting/provider/teams"
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
)
// Config is the configuration for alerting providers
@@ -22,24 +30,39 @@ type Config struct {
// Custom is the configuration for the custom alerting provider
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 *discord.AlertProvider `yaml:"discord,omitempty"`
// Email is the configuration for the email alerting provider
Email *email.AlertProvider `yaml:"email,omitempty"`
// GitHub is the configuration for the github alerting provider
GitHub *github.AlertProvider `yaml:"github,omitempty"`
// GoogleChat is the configuration for the googlechat alerting provider
GoogleChat *googlechat.AlertProvider `yaml:"googlechat,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 *mattermost.AlertProvider `yaml:"mattermost,omitempty"`
// Messagebird is the configuration for the messagebird alerting provider
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 *pagerduty.AlertProvider `yaml:"pagerduty,omitempty"`
// Pushover is the configuration for the pushover alerting provider
Pushover *pushover.AlertProvider `yaml:"pushover,omitempty"`
// Slack is the configuration for the slack alerting provider
Slack *slack.AlertProvider `yaml:"slack,omitempty"`
@@ -51,86 +74,33 @@ type Config struct {
// Twilio is the configuration for the twilio alerting provider
Twilio *twilio.AlertProvider `yaml:"twilio,omitempty"`
// Opsgenie is the configuration for the opsgenie alerting provider
Opsgenie *opsgenie.AlertProvider `yaml:"opsgenie,omitempty"`
}
// GetAlertingProviderByAlertType returns an provider.AlertProvider by its corresponding alert.Type
func (config Config) GetAlertingProviderByAlertType(alertType alert.Type) provider.AlertProvider {
switch alertType {
case alert.TypeCustom:
if config.Custom == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
return nil
func (config *Config) GetAlertingProviderByAlertType(alertType alert.Type) provider.AlertProvider {
entityType := reflect.TypeOf(config).Elem()
for i := 0; i < entityType.NumField(); i++ {
field := entityType.Field(i)
if strings.ToLower(field.Name) == string(alertType) {
fieldValue := reflect.ValueOf(config).Elem().Field(i)
if fieldValue.IsNil() {
return nil
}
return fieldValue.Interface().(provider.AlertProvider)
}
return config.Custom
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.TypeDiscord:
if config.Discord == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
return nil
}
return config.Discord
case alert.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.TypeMattermost:
if config.Mattermost == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
return nil
}
return config.Mattermost
case alert.TypeMessagebird:
if config.Messagebird == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
return nil
}
return config.Messagebird
case alert.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:
if config.PagerDuty == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
return nil
}
return config.PagerDuty
case alert.TypeSlack:
if config.Slack == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
return nil
}
return config.Slack
case alert.TypeTeams:
if config.Teams == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
return nil
}
return config.Teams
case alert.TypeTelegram:
if config.Telegram == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
return nil
}
return config.Telegram
case alert.TypeTwilio:
if config.Twilio == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
return nil
}
return config.Twilio
}
log.Printf("[alerting][GetAlertingProviderByAlertType] No alerting provider found for alert type %s", alertType)
return nil
}
// SetAlertingProviderToNil Sets an alerting provider to nil to avoid having to revalidate it every time an
// alert of its corresponding type is sent.
func (config *Config) SetAlertingProviderToNil(p provider.AlertProvider) {
entityType := reflect.TypeOf(config).Elem()
for i := 0; i < entityType.NumField(); i++ {
field := entityType.Field(i)
if field.Type == reflect.TypeOf(p) {
reflect.ValueOf(config).Elem().Field(i).Set(reflect.Zero(field.Type))
}
}
}
+22 -41
View File
@@ -7,9 +7,9 @@ import (
"net/http"
"strings"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
)
// AlertProvider is the configuration necessary for sending an alert using a custom HTTP request
@@ -50,48 +50,28 @@ func (provider *AlertProvider) GetAlertStatePlaceholderValue(resolved bool) stri
return status
}
func (provider *AlertProvider) buildHTTPRequest(endpointName, alertDescription string, resolved bool) *http.Request {
body := provider.Body
providerURL := provider.URL
method := provider.Method
if strings.Contains(body, "[ALERT_DESCRIPTION]") {
body = strings.ReplaceAll(body, "[ALERT_DESCRIPTION]", alertDescription)
}
if strings.Contains(body, "[SERVICE_NAME]") { // XXX: Remove this in v4.0.0
body = strings.ReplaceAll(body, "[SERVICE_NAME]", endpointName)
}
if strings.Contains(body, "[ENDPOINT_NAME]") {
body = strings.ReplaceAll(body, "[ENDPOINT_NAME]", endpointName)
}
if strings.Contains(body, "[ALERT_TRIGGERED_OR_RESOLVED]") {
if resolved {
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
} else {
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(false))
}
}
if strings.Contains(providerURL, "[ALERT_DESCRIPTION]") {
providerURL = strings.ReplaceAll(providerURL, "[ALERT_DESCRIPTION]", alertDescription)
}
if strings.Contains(providerURL, "[SERVICE_NAME]") { // 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))
}
func (provider *AlertProvider) buildHTTPRequest(endpoint *core.Endpoint, alert *alert.Alert, resolved bool) *http.Request {
body, url, method := provider.Body, provider.URL, provider.Method
body = strings.ReplaceAll(body, "[ALERT_DESCRIPTION]", alert.GetDescription())
url = strings.ReplaceAll(url, "[ALERT_DESCRIPTION]", alert.GetDescription())
body = strings.ReplaceAll(body, "[ENDPOINT_NAME]", endpoint.Name)
url = strings.ReplaceAll(url, "[ENDPOINT_NAME]", endpoint.Name)
body = strings.ReplaceAll(body, "[ENDPOINT_GROUP]", endpoint.Group)
url = strings.ReplaceAll(url, "[ENDPOINT_GROUP]", endpoint.Group)
body = strings.ReplaceAll(body, "[ENDPOINT_URL]", endpoint.URL)
url = strings.ReplaceAll(url, "[ENDPOINT_URL]", endpoint.URL)
if resolved {
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
} else {
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(false))
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(false))
}
if len(method) == 0 {
method = http.MethodGet
}
bodyBuffer := bytes.NewBuffer([]byte(body))
request, _ := http.NewRequest(method, providerURL, bodyBuffer)
request, _ := http.NewRequest(method, url, bodyBuffer)
for k, v := range provider.Headers {
request.Header.Set(k, v)
}
@@ -99,11 +79,12 @@ func (provider *AlertProvider) buildHTTPRequest(endpointName, alertDescription s
}
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
request := provider.buildHTTPRequest(endpoint.Name, alert.GetDescription(), resolved)
request := provider.buildHTTPRequest(endpoint, alert, resolved)
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
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))
+100 -86
View File
@@ -1,25 +1,36 @@
package custom
import (
"fmt"
"io"
"net/http"
"testing"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/test"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{URL: ""}
if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{URL: "https://example.com"}
if !validProvider.IsValid() {
t.Error("provider should've been valid")
}
t.Run("invalid-provider", func(t *testing.T) {
invalidProvider := AlertProvider{URL: ""}
if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid")
}
})
t.Run("valid-provider", func(t *testing.T) {
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() {
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_Send(t *testing.T) {
@@ -99,77 +110,103 @@ func TestAlertProvider_Send(t *testing.T) {
}
}
func TestAlertProvider_buildHTTPRequestWhenResolved(t *testing.T) {
const (
ExpectedURL = "https://example.com/endpoint-name?event=RESOLVED&description=alert-description"
ExpectedBody = "endpoint-name,alert-description,RESOLVED"
)
func TestAlertProvider_buildHTTPRequest(t *testing.T) {
customAlertProvider := &AlertProvider{
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[ENDPOINT_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
Headers: nil,
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]&url=[ENDPOINT_URL]",
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ENDPOINT_URL],[ALERT_TRIGGERED_OR_RESOLVED]",
}
request := customAlertProvider.buildHTTPRequest("endpoint-name", "alert-description", true)
if request.URL.String() != ExpectedURL {
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
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",
},
}
body, _ := io.ReadAll(request.Body)
if string(body) != ExpectedBody {
t.Error("expected body to be", ExpectedBody, "was", string(body))
}
}
func TestAlertProvider_buildHTTPRequestWhenTriggered(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/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[ENDPOINT_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
Headers: map[string]string{"Authorization": "Basic hunter2"},
}
request := customAlertProvider.buildHTTPRequest("endpoint-name", "alert-description", false)
if request.URL.String() != ExpectedURL {
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
}
body, _ := io.ReadAll(request.Body)
if string(body) != ExpectedBody {
t.Error("expected body to be", ExpectedBody, "was", string(body))
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,
)
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_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
const (
ExpectedURL = "https://example.com/endpoint-name?event=test&description=alert-description"
ExpectedBody = "endpoint-name,alert-description,test"
)
customAlertProvider := &AlertProvider{
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[ENDPOINT_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
Headers: nil,
Placeholders: map[string]map[string]string{
"ALERT_TRIGGERED_OR_RESOLVED": {
"RESOLVED": "test",
"RESOLVED": "fixed",
"TRIGGERED": "boom",
},
},
}
request := customAlertProvider.buildHTTPRequest("endpoint-name", "alert-description", true)
if request.URL.String() != ExpectedURL {
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
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=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, _ := io.ReadAll(request.Body)
if string(body) != ExpectedBody {
t.Error("expected body to be", ExpectedBody, "was", string(body))
for _, scenario := range scenarios {
t.Run(fmt.Sprintf("resolved-%v-with-custom-placeholders", scenario.Resolved), func(t *testing.T) {
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) {
customAlertProvider := &AlertProvider{
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[ENDPOINT_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
Headers: nil,
Placeholders: nil,
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
}
if customAlertProvider.GetAlertStatePlaceholderValue(true) != "RESOLVED" {
t.Error("expected RESOLVED, got", customAlertProvider.GetAlertStatePlaceholderValue(true))
@@ -187,26 +224,3 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
t.Error("expected default alert to be nil")
}
}
// TestAlertProvider_isBackwardCompatibleWithServiceRename checks if the custom alerting provider still supports
// service placeholders after the migration from "service" to "endpoint"
//
// XXX: Remove this in v4.0.0
func TestAlertProvider_isBackwardCompatibleWithServiceRename(t *testing.T) {
const (
ExpectedURL = "https://example.com/endpoint-name?event=TRIGGERED&description=alert-description"
ExpectedBody = "endpoint-name,alert-description,TRIGGERED"
)
customAlertProvider := &AlertProvider{
URL: "https://example.com/[SERVICE_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[SERVICE_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
}
request := customAlertProvider.buildHTTPRequest("endpoint-name", "alert-description", false)
if request.URL.String() != ExpectedURL {
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
}
body, _ := io.ReadAll(request.Body)
if string(body) != ExpectedBody {
t.Error("expected body to be", ExpectedBody, "was", string(body))
}
}
+45 -24
View File
@@ -2,13 +2,14 @@ package discord
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
)
// AlertProvider is the configuration necessary for sending an alert using Discord
@@ -44,7 +45,7 @@ func (provider *AlertProvider) IsValid() bool {
// 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)))
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer)
if err != nil {
return err
@@ -54,6 +55,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
if err != nil {
return err
}
defer response.Body.Close()
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))
@@ -61,8 +63,26 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
return err
}
type Body struct {
Content string `json:"content"`
Embeds []Embed `json:"embeds"`
}
type Embed struct {
Title string `json:"title"`
Description string `json:"description"`
Color int `json:"color"`
Fields []Field `json:"fields"`
}
type Field struct {
Name string `json:"name"`
Value string `json:"value"`
Inline bool `json:"inline"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
var message, results string
var colorCode int
if resolved {
@@ -79,29 +99,30 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
} else {
prefix = ":x:"
}
results += fmt.Sprintf("%s - `%s`\\n", prefix, conditionResult.Condition)
results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\\n> " + alertDescription
description = ":\n> " + alertDescription
}
return fmt.Sprintf(`{
"content": "",
"embeds": [
{
"title": ":helmet_with_white_cross: Gatus",
"description": "%s%s",
"color": %d,
"fields": [
{
"name": "Condition results",
"value": "%s",
"inline": false
}
]
}
]
}`, message, description, colorCode, results)
body, _ := json.Marshal(Body{
Content: "",
Embeds: []Embed{
{
Title: ":helmet_with_white_cross: Gatus",
Description: message + description,
Color: colorCode,
Fields: []Field{
{
Name: "Condition results",
Value: results,
Inline: false,
},
},
},
},
})
return body
}
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
+10 -9
View File
@@ -5,10 +5,10 @@ import (
"net/http"
"testing"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/test"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_IsValid(t *testing.T) {
@@ -151,14 +151,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
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}",
ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"description\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"color\":15158332,\"fields\":[{\"name\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n:x: - `[BODY] != \\\"\\\"`\\n\",\"inline\":false}]}]}",
},
{
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}",
ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"description\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"color\":3066993,\"fields\":[{\"name\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n:white_check_mark: - `[BODY] != \\\"\\\"`\\n\",\"inline\":false}]}]}",
},
}
for _, scenario := range scenarios {
@@ -170,15 +170,16 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
{Condition: "[BODY] != \"\"", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if body != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal([]byte(body), &out); err != nil {
if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"math"
"strings"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/core"
gomail "gopkg.in/mail.v2"
)
+2 -2
View File
@@ -3,8 +3,8 @@ package email
import (
"testing"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/core"
)
func TestAlertDefaultProvider_IsValid(t *testing.T) {
+129
View File
@@ -0,0 +1,129 @@
package github
import (
"context"
"fmt"
"net/url"
"strings"
"time"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/core"
"github.com/google/go-github/v48/github"
"golang.org/x/oauth2"
)
// AlertProvider is the configuration necessary for sending an alert using Discord
type AlertProvider struct {
RepositoryURL string `yaml:"repository-url"` // The URL of the GitHub repository to create issues in
Token string `yaml:"token"` // Token requires at least RW on issues and RO on metadata
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
username string
repositoryOwner string
repositoryName string
githubClient *github.Client
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
if len(provider.Token) == 0 || len(provider.RepositoryURL) == 0 {
return false
}
// Validate format of the repository URL
repositoryURL, err := url.Parse(provider.RepositoryURL)
if err != nil {
return false
}
baseURL := repositoryURL.Scheme + "://" + repositoryURL.Host
pathParts := strings.Split(repositoryURL.Path, "/")
if len(pathParts) != 3 {
return false
}
provider.repositoryOwner = pathParts[1]
provider.repositoryName = pathParts[2]
// Create oauth2 HTTP client with GitHub token
httpClientWithStaticTokenSource := oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(&oauth2.Token{
AccessToken: provider.Token,
}))
// Create GitHub client
if baseURL == "https://github.com" {
provider.githubClient = github.NewClient(httpClientWithStaticTokenSource)
} else {
provider.githubClient, err = github.NewEnterpriseClient(baseURL, baseURL, httpClientWithStaticTokenSource)
if err != nil {
return false
}
}
// Retrieve the username once to validate that the token is valid
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
user, _, err := provider.githubClient.Users.Get(ctx, "")
if err != nil {
return false
}
provider.username = *user.Login
return true
}
// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,
// or closes the relevant issue(s) if the resolved parameter passed is true.
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
title := "alert(gatus): " + endpoint.DisplayName()
if !resolved {
_, _, err := provider.githubClient.Issues.Create(context.Background(), provider.repositoryOwner, provider.repositoryName, &github.IssueRequest{
Title: github.String(title),
Body: github.String(provider.buildIssueBody(endpoint, alert, result)),
})
if err != nil {
return fmt.Errorf("failed to create issue: %w", err)
}
} else {
issues, _, err := provider.githubClient.Issues.ListByRepo(context.Background(), provider.repositoryOwner, provider.repositoryName, &github.IssueListByRepoOptions{
State: "open",
Creator: provider.username,
ListOptions: github.ListOptions{PerPage: 100},
})
if err != nil {
return fmt.Errorf("failed to list issues: %w", err)
}
for _, issue := range issues {
if *issue.Title == title {
_, _, err = provider.githubClient.Issues.Edit(context.Background(), provider.repositoryOwner, provider.repositoryName, *issue.Number, &github.IssueRequest{
State: github.String("closed"),
})
if err != nil {
return fmt.Errorf("failed to close issue: %w", err)
}
}
}
}
return nil
}
// buildIssueBody builds the body of the issue
func (provider *AlertProvider) buildIssueBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result) string {
var results string
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = ":white_check_mark:"
} else {
prefix = ":x:"
}
results += fmt.Sprintf("- %s - `%s`\n", prefix, conditionResult.Condition)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\n> " + alertDescription
}
message := fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
return message + description + "\n\n## Condition results\n" + results
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
+158
View File
@@ -0,0 +1,158 @@
package github
import (
"net/http"
"strings"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/test"
"github.com/google/go-github/v48/github"
)
func TestAlertDefaultProvider_IsValid(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
Expected bool
}{
{
Name: "invalid",
Provider: AlertProvider{RepositoryURL: "", Token: ""},
Expected: false,
},
{
Name: "invalid-token",
Provider: AlertProvider{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"},
Expected: false,
},
{
Name: "missing-repository-name",
Provider: AlertProvider{RepositoryURL: "https://github.com/TwiN", Token: "12345"},
Expected: false,
},
{
Name: "enterprise-client",
Provider: AlertProvider{RepositoryURL: "https://github.example.com/TwiN/test", Token: "12345"},
Expected: false,
},
{
Name: "invalid-url",
Provider: AlertProvider{RepositoryURL: "github.com/TwiN/test", Token: "12345"},
Expected: false,
},
}
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_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-error",
Provider: AlertProvider{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedError: true,
},
{
Name: "resolved-error",
Provider: AlertProvider{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
scenario.Provider.githubClient = github.NewClient(nil)
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"
scenarios := []struct {
Name string
Endpoint core.Endpoint
Provider AlertProvider
Alert alert.Alert
ExpectedBody string
}{
{
Name: "triggered",
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 3},
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\n> description-1\n\n## Condition results\n- :white_check_mark: - `[CONNECTED] == true`\n- :x: - `[STATUS] == 200`",
},
{
Name: "no-description",
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{},
Alert: alert.Alert{FailureThreshold: 10},
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 10 time(s) in a row\n\n## Condition results\n- :white_check_mark: - `[CONNECTED] == true`\n- :x: - `[STATUS] == 200`",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildIssueBody(
&scenario.Endpoint,
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: true},
{Condition: "[STATUS] == 200", Success: false},
},
},
)
if strings.TrimSpace(body) != strings.TrimSpace(scenario.ExpectedBody) {
t.Errorf("expected:\n%s\ngot:\n%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")
}
}
+126 -49
View File
@@ -2,13 +2,14 @@ package googlechat
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
)
// AlertProvider is the configuration necessary for sending an alert using Google chat
@@ -20,6 +21,15 @@ type AlertProvider struct {
// 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
@@ -27,13 +37,22 @@ 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.WebhookURL, buffer)
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer)
if err != nil {
return err
}
@@ -42,6 +61,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
if err != nil {
return err
}
defer response.Body.Close()
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))
@@ -49,8 +69,50 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
return err
}
type Body struct {
Cards []Cards `json:"cards"`
}
type Cards struct {
Sections []Sections `json:"sections"`
}
type Sections struct {
Widgets []Widgets `json:"widgets"`
}
type Widgets struct {
KeyValue *KeyValue `json:"keyValue,omitempty"`
Buttons []Buttons `json:"buttons,omitempty"`
}
type KeyValue struct {
TopLabel string `json:"topLabel,omitempty"`
Content string `json:"content,omitempty"`
ContentMultiline string `json:"contentMultiline,omitempty"`
BottomLabel string `json:"bottomLabel,omitempty"`
Icon string `json:"icon,omitempty"`
}
type Buttons struct {
TextButton TextButton `json:"textButton"`
}
type TextButton struct {
Text string `json:"text"`
OnClick OnClick `json:"onClick"`
}
type OnClick struct {
OpenLink OpenLink `json:"openLink"`
}
type OpenLink struct {
URL string `json:"url"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
var message, color string
if resolved {
color = "#36A64F"
@@ -73,49 +135,64 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
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)
payload := Body{
Cards: []Cards{
{
Sections: []Sections{
{
Widgets: []Widgets{
{
KeyValue: &KeyValue{
TopLabel: endpoint.DisplayName(),
Content: message,
ContentMultiline: "true",
BottomLabel: description,
Icon: "BOOKMARK",
},
},
{
KeyValue: &KeyValue{
TopLabel: "Condition results",
Content: results,
ContentMultiline: "true",
Icon: "DESCRIPTION",
},
},
},
},
},
},
},
}
if endpoint.Type() == core.EndpointTypeHTTP {
// We only include a button targeting the URL if the endpoint is an HTTP endpoint
// If the URL isn't prefixed with https://, Google Chat will just display a blank message aynways.
// See https://github.com/TwiN/gatus/issues/362
payload.Cards[0].Sections[0].Widgets = append(payload.Cards[0].Sections[0].Widgets, Widgets{
Buttons: []Buttons{
{
TextButton: TextButton{
Text: "URL",
OnClick: OnClick{OpenLink: OpenLink{URL: endpoint.URL}},
},
},
},
})
}
body, _ := json.Marshal(payload)
return body
}
// 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
+130 -13
View File
@@ -5,13 +5,13 @@ import (
"net/http"
"testing"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/test"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_IsValid(t *testing.T) {
func TestAlertDefaultProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{WebhookURL: ""}
if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid")
@@ -22,6 +22,43 @@ func TestAlertProvider_IsValid(t *testing.T) {
}
}
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"
@@ -104,6 +141,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
secondDescription := "description-2"
scenarios := []struct {
Name string
Endpoint core.Endpoint
Provider AlertProvider
Alert alert.Alert
Resolved bool
@@ -111,23 +149,41 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{
{
Name: "triggered",
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
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}",
ExpectedBody: `{"cards":[{"sections":[{"widgets":[{"keyValue":{"topLabel":"endpoint-name","content":"\u003cfont color='#DD0000'\u003eAn alert has been triggered due to having failed 3 time(s) in a row\u003c/font\u003e","contentMultiline":"true","bottomLabel":":: description-1","icon":"BOOKMARK"}},{"keyValue":{"topLabel":"Condition results","content":"❌ [CONNECTED] == true\u003cbr\u003e❌ [STATUS] == 200\u003cbr\u003e","contentMultiline":"true","icon":"DESCRIPTION"}},{"buttons":[{"textButton":{"text":"URL","onClick":{"openLink":{"url":"https://example.org"}}}}]}]}]}]}`,
},
{
Name: "resolved",
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
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}",
ExpectedBody: `{"cards":[{"sections":[{"widgets":[{"keyValue":{"topLabel":"endpoint-name","content":"\u003cfont color='#36A64F'\u003eAn alert has been resolved after passing successfully 5 time(s) in a row\u003c/font\u003e","contentMultiline":"true","bottomLabel":":: description-2","icon":"BOOKMARK"}},{"keyValue":{"topLabel":"Condition results","content":"✅ [CONNECTED] == true\u003cbr\u003e✅ [STATUS] == 200\u003cbr\u003e","contentMultiline":"true","icon":"DESCRIPTION"}},{"buttons":[{"textButton":{"text":"URL","onClick":{"openLink":{"url":"https://example.org"}}}}]}]}]}]}`,
},
{
Name: "icmp-should-not-include-url", // See https://github.com/TwiN/gatus/issues/362
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "icmp://example.org"},
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: `{"cards":[{"sections":[{"widgets":[{"keyValue":{"topLabel":"endpoint-name","content":"\u003cfont color='#DD0000'\u003eAn alert has been triggered due to having failed 3 time(s) in a row\u003c/font\u003e","contentMultiline":"true","bottomLabel":":: description-1","icon":"BOOKMARK"}},{"keyValue":{"topLabel":"Condition results","content":"❌ [CONNECTED] == true\u003cbr\u003e❌ [STATUS] == 200\u003cbr\u003e","contentMultiline":"true","icon":"DESCRIPTION"}}]}]}]}`,
},
{
Name: "tcp-should-not-include-url", // See https://github.com/TwiN/gatus/issues/362
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "tcp://example.org"},
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: `{"cards":[{"sections":[{"widgets":[{"keyValue":{"topLabel":"endpoint-name","content":"\u003cfont color='#DD0000'\u003eAn alert has been triggered due to having failed 3 time(s) in a row\u003c/font\u003e","contentMultiline":"true","bottomLabel":":: description-1","icon":"BOOKMARK"}},{"keyValue":{"topLabel":"Condition results","content":"❌ [CONNECTED] == true\u003cbr\u003e❌ [STATUS] == 200\u003cbr\u003e","contentMultiline":"true","icon":"DESCRIPTION"}}]}]}]}`,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Endpoint,
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
@@ -137,13 +193,11 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
},
scenario.Resolved,
)
b, _ := json.Marshal(body)
e, _ := json.Marshal(scenario.ExpectedBody)
if body != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", e, b)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal([]byte(body), &out); err != nil {
if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
@@ -158,3 +212,66 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
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)
}
})
}
}
+190
View File
@@ -0,0 +1,190 @@
package matrix
import (
"bytes"
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"net/url"
"time"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/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(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
}
defer response.Body.Close()
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 {
MsgType string `json:"msgtype"`
Format string `json:"format"`
Body string `json:"body"`
FormattedBody string `json:"formatted_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 {
body, _ := json.Marshal(Body{
MsgType: "m.text",
Format: "org.matrix.custom.html",
Body: buildPlaintextMessageBody(endpoint, alert, result, resolved),
FormattedBody: buildHTMLMessageBody(endpoint, alert, result, resolved),
})
return body
}
// 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/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/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: "{\"msgtype\":\"m.text\",\"format\":\"org.matrix.custom.html\",\"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\",\"formatted_body\":\"\\u003ch3\\u003eAn alert for \\u003ccode\\u003eendpoint-name\\u003c/code\\u003e has been triggered due to having failed 3 time(s) in a row\\u003c/h3\\u003e\\n\\u003cblockquote\\u003edescription-1\\u003c/blockquote\\u003e\\n\\u003ch5\\u003eCondition results\\u003c/h5\\u003e\\u003cul\\u003e\\u003cli\\u003e❌ - \\u003ccode\\u003e[CONNECTED] == true\\u003c/code\\u003e\\u003c/li\\u003e\\u003cli\\u003e❌ - \\u003ccode\\u003e[STATUS] == 200\\u003c/code\\u003e\\u003c/li\\u003e\\u003c/ul\\u003e\"}",
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"msgtype\":\"m.text\",\"format\":\"org.matrix.custom.html\",\"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\",\"formatted_body\":\"\\u003ch3\\u003eAn alert for \\u003ccode\\u003eendpoint-name\\u003c/code\\u003e has been resolved after passing successfully 5 time(s) in a row\\u003c/h3\\u003e\\n\\u003cblockquote\\u003edescription-2\\u003c/blockquote\\u003e\\n\\u003ch5\\u003eCondition results\\u003c/h5\\u003e\\u003cul\\u003e\\u003cli\\u003e✅ - \\u003ccode\\u003e[CONNECTED] == true\\u003c/code\\u003e\\u003c/li\\u003e\\u003cli\\u003e✅ - \\u003ccode\\u003e[STATUS] == 200\\u003c/code\\u003e\\u003c/li\\u003e\\u003c/ul\\u003e\"}",
},
}
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:\n%s\ngot:\n%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())
}
})
}
}
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)
}
})
}
}
+84 -35
View File
@@ -2,13 +2,14 @@ package mattermost
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
)
// AlertProvider is the configuration necessary for sending an alert using Mattermost
@@ -20,6 +21,15 @@ type AlertProvider struct {
// 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
@@ -27,13 +37,22 @@ func (provider *AlertProvider) IsValid() bool {
if provider.ClientConfig == nil {
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
}
// 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.WebhookURL, buffer)
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer)
if err != nil {
return err
}
@@ -42,6 +61,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
if err != nil {
return err
}
defer response.Body.Close()
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))
@@ -49,9 +69,31 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
return err
}
type Body struct {
Text string `json:"text"`
Username string `json:"username"`
IconURL string `json:"icon_url"`
Attachments []Attachment `json:"attachments"`
}
type Attachment struct {
Title string `json:"title"`
Fallback string `json:"fallback"`
Text string `json:"text"`
Short bool `json:"short"`
Color string `json:"color"`
Fields []Field `json:"fields"`
}
type Field struct {
Title string `json:"title"`
Value string `json:"value"`
Short bool `json:"short"`
}
// 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
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
var message, color, results string
if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
color = "#36A64F"
@@ -59,7 +101,6 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
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"
}
var results string
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
@@ -67,38 +108,46 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
} else {
prefix = ":x:"
}
results += fmt.Sprintf("%s - `%s`\\n", prefix, conditionResult.Condition)
results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\\n> " + alertDescription
description = ":\n> " + alertDescription
}
return fmt.Sprintf(`{
"text": "",
"username": "gatus",
"icon_url": "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png",
"attachments": [
{
"title": ":rescue_worker_helmet: Gatus",
"fallback": "Gatus - %s",
"text": "%s%s",
"short": false,
"color": "%s",
"fields": [
{
"title": "URL",
"value": "%s",
"short": false
},
{
"title": "Condition results",
"value": "%s",
"short": false
}
]
}
]
}`, message, message, description, color, endpoint.URL, results)
body, _ := json.Marshal(Body{
Text: "",
Username: "gatus",
IconURL: "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png",
Attachments: []Attachment{
{
Title: ":helmet_with_white_cross: Gatus",
Fallback: "Gatus - " + message,
Text: message + description,
Short: false,
Color: color,
Fields: []Field{
{
Title: "Condition results",
Value: results,
Short: false,
},
},
},
},
})
return body
}
// 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
+113 -9
View File
@@ -5,10 +5,10 @@ import (
"net/http"
"testing"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/test"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_IsValid(t *testing.T) {
@@ -22,6 +22,47 @@ func TestAlertProvider_IsValid(t *testing.T) {
}
}
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")
}
providerWithInvalidOverrideWebHookUrl := AlertProvider{
Overrides: []Override{
{
WebhookURL: "",
Group: "group",
},
},
}
if providerWithInvalidOverrideWebHookUrl.IsValid() {
t.Error("provider WebHookURL shoudn'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"
@@ -114,14 +155,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
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}",
ExpectedBody: "{\"text\":\"\",\"username\":\"gatus\",\"icon_url\":\"https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"fallback\":\"Gatus - An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row\",\"text\":\"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"short\":false,\"color\":\"#DD0000\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
},
{
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}",
ExpectedBody: "{\"text\":\"\",\"username\":\"gatus\",\"icon_url\":\"https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"fallback\":\"Gatus - An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row\",\"text\":\"An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
},
}
for _, scenario := range scenarios {
@@ -137,11 +178,11 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
},
scenario.Resolved,
)
if body != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal([]byte(body), &out); err != nil {
if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
@@ -156,3 +197,66 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
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)
}
})
}
}
+19 -10
View File
@@ -2,13 +2,14 @@ package messagebird
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
)
const (
@@ -33,7 +34,7 @@ func (provider *AlertProvider) IsValid() bool {
// Send an alert using the provider
// Reference doc for messagebird: https://developers.messagebird.com/api/sms-messaging/#send-outbound-sms
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)))
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
if err != nil {
return err
@@ -44,6 +45,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
if err != nil {
return err
}
defer response.Body.Close()
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))
@@ -51,19 +53,26 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
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) string {
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
var message string
if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
} else {
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
}
return fmt.Sprintf(`{
"originator": "%s",
"recipients": "%s",
"body": "%s"
}`, provider.Originator, provider.Recipients, message)
body, _ := json.Marshal(Body{
Originator: provider.Originator,
Recipients: provider.Recipients,
Body: message,
})
return body
}
// GetDefaultAlert returns the provider's default alert configuration
@@ -5,10 +5,10 @@ import (
"net/http"
"testing"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/test"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/test"
)
func TestMessagebirdAlertProvider_IsValid(t *testing.T) {
@@ -118,14 +118,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider: AlertProvider{AccessKey: "1", Originator: "2", Recipients: "3"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\n \"originator\": \"2\",\n \"recipients\": \"3\",\n \"body\": \"TRIGGERED: endpoint-name - description-1\"\n}",
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: "{\n \"originator\": \"5\",\n \"recipients\": \"6\",\n \"body\": \"RESOLVED: endpoint-name - description-2\"\n}",
ExpectedBody: "{\"originator\":\"5\",\"recipients\":\"6\",\"body\":\"RESOLVED: endpoint-name - description-2\"}",
},
}
for _, scenario := range scenarios {
@@ -141,8 +141,8 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
},
scenario.Resolved,
)
if body != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal([]byte(body), &out); err != nil {
+96
View File
@@ -0,0 +1,96 @@
package ntfy
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/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(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
}
defer response.Body.Close()
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 resolved {
tag = "white_check_mark"
message = "An alert has been resolved after passing successfully " + strconv.Itoa(alert.SuccessThreshold) + " time(s) in a row"
} else {
tag = "x"
message = "An alert has been triggered due to having failed " + strconv.Itoa(alert.FailureThreshold) + " time(s) in a row"
}
if len(alert.GetDescription()) > 0 {
message += " with the following description: " + alert.GetDescription()
}
body, _ := json.Marshal(Body{
Topic: provider.Topic,
Title: "Gatus: " + endpoint.DisplayName(),
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/v5/alerting/alert"
"github.com/TwiN/gatus/v5/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: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: 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: endpoint-name","message":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: 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:\n%s\ngot:\n%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())
}
})
}
}
+15 -16
View File
@@ -9,9 +9,9 @@ import (
"strconv"
"strings"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
)
const (
@@ -83,37 +83,36 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
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
return provider.sendRequest(restAPI, http.MethodPost, payload)
}
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
return provider.sendRequest(url, http.MethodPost, payload)
}
func (provider *AlertProvider) sendRequest(url, method string, payload interface{}) (*http.Response, error) {
func (provider *AlertProvider) sendRequest(url, method string, payload interface{}) error {
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("fail to build alert payload: %v", payload)
return fmt.Errorf("error build alert with payload %v: %w", payload, err)
}
request, err := http.NewRequest(method, url, bytes.NewBuffer(body))
if err != nil {
return nil, err
return err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "GenieKey "+provider.APIKey)
res, err := client.GetHTTPClient(nil).Do(request)
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return nil, err
return 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))
defer response.Body.Close()
if response.StatusCode > 399 {
rBody, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(rBody))
}
return res, nil
return nil
}
func (provider *AlertProvider) buildCreateRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) alertCreateRequest {
+4 -4
View File
@@ -5,10 +5,10 @@ import (
"reflect"
"testing"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/test"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_IsValid(t *testing.T) {
+30 -15
View File
@@ -8,9 +8,9 @@ import (
"log"
"net/http"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
)
const (
@@ -53,7 +53,7 @@ func (provider *AlertProvider) IsValid() bool {
//
// Relevant: https://developer.pagerduty.com/docs/events-api-v2/trigger-events/
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)))
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
if err != nil {
return err
@@ -63,6 +63,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
if err != nil {
return err
}
defer response.Body.Close()
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))
@@ -86,8 +87,21 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
return nil
}
type Body struct {
RoutingKey string `json:"routing_key"`
DedupKey string `json:"dedup_key"`
EventAction string `json:"event_action"`
Payload Payload `json:"payload"`
}
type Payload struct {
Summary string `json:"summary"`
Source string `json:"source"`
Severity string `json:"severity"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
var message, eventAction, resolveKey string
if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
@@ -98,16 +112,17 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
eventAction = "trigger"
resolveKey = ""
}
return fmt.Sprintf(`{
"routing_key": "%s",
"dedup_key": "%s",
"event_action": "%s",
"payload": {
"summary": "%s",
"source": "%s",
"severity": "critical"
}
}`, provider.getIntegrationKeyForGroup(endpoint.Group), resolveKey, eventAction, message, endpoint.Name)
body, _ := json.Marshal(Body{
RoutingKey: provider.getIntegrationKeyForGroup(endpoint.Group),
DedupKey: resolveKey,
EventAction: eventAction,
Payload: Payload{
Summary: message,
Source: "Gatus",
Severity: "critical",
},
})
return body
}
// getIntegrationKeyForGroup returns the appropriate pagerduty integration key for a given group
+10 -10
View File
@@ -5,10 +5,10 @@ import (
"net/http"
"testing"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/test"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_IsValid(t *testing.T) {
@@ -149,24 +149,24 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
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}",
ExpectedBody: "{\"routing_key\":\"00000000000000000000000000000000\",\"dedup_key\":\"\",\"event_action\":\"trigger\",\"payload\":{\"summary\":\"TRIGGERED: endpoint-name - test\",\"source\":\"Gatus\",\"severity\":\"critical\"}}",
},
{
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}",
ExpectedBody: "{\"routing_key\":\"00000000000000000000000000000000\",\"dedup_key\":\"key\",\"event_action\":\"resolve\",\"payload\":{\"summary\":\"RESOLVED: endpoint-name - test\",\"source\":\"Gatus\",\"severity\":\"critical\"}}",
},
}
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)
body := scenario.Provider.buildRequestBody(&core.Endpoint{Name: "endpoint-name"}, &scenario.Alert, &core.Result{}, scenario.Resolved)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal([]byte(body), &out); err != nil {
if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
+23 -13
View File
@@ -1,19 +1,24 @@
package provider
import (
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/alerting/provider/custom"
"github.com/TwiN/gatus/v3/alerting/provider/discord"
"github.com/TwiN/gatus/v3/alerting/provider/email"
"github.com/TwiN/gatus/v3/alerting/provider/googlechat"
"github.com/TwiN/gatus/v3/alerting/provider/mattermost"
"github.com/TwiN/gatus/v3/alerting/provider/messagebird"
"github.com/TwiN/gatus/v3/alerting/provider/pagerduty"
"github.com/TwiN/gatus/v3/alerting/provider/slack"
"github.com/TwiN/gatus/v3/alerting/provider/teams"
"github.com/TwiN/gatus/v3/alerting/provider/telegram"
"github.com/TwiN/gatus/v3/alerting/provider/twilio"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/alerting/provider/custom"
"github.com/TwiN/gatus/v5/alerting/provider/discord"
"github.com/TwiN/gatus/v5/alerting/provider/email"
"github.com/TwiN/gatus/v5/alerting/provider/github"
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
"github.com/TwiN/gatus/v5/alerting/provider/ntfy"
"github.com/TwiN/gatus/v5/alerting/provider/opsgenie"
"github.com/TwiN/gatus/v5/alerting/provider/pagerduty"
"github.com/TwiN/gatus/v5/alerting/provider/pushover"
"github.com/TwiN/gatus/v5/alerting/provider/slack"
"github.com/TwiN/gatus/v5/alerting/provider/teams"
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
"github.com/TwiN/gatus/v5/core"
)
// AlertProvider is the interface that each providers should implement
@@ -55,10 +60,15 @@ var (
_ AlertProvider = (*custom.AlertProvider)(nil)
_ AlertProvider = (*discord.AlertProvider)(nil)
_ AlertProvider = (*email.AlertProvider)(nil)
_ AlertProvider = (*github.AlertProvider)(nil)
_ AlertProvider = (*googlechat.AlertProvider)(nil)
_ AlertProvider = (*matrix.AlertProvider)(nil)
_ AlertProvider = (*mattermost.AlertProvider)(nil)
_ AlertProvider = (*messagebird.AlertProvider)(nil)
_ AlertProvider = (*ntfy.AlertProvider)(nil)
_ AlertProvider = (*opsgenie.AlertProvider)(nil)
_ AlertProvider = (*pagerduty.AlertProvider)(nil)
_ AlertProvider = (*pushover.AlertProvider)(nil)
_ AlertProvider = (*slack.AlertProvider)(nil)
_ AlertProvider = (*teams.AlertProvider)(nil)
_ AlertProvider = (*telegram.AlertProvider)(nil)
+1 -1
View File
@@ -3,7 +3,7 @@ package provider
import (
"testing"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v5/alerting/alert"
)
func TestParseWithDefaultAlert(t *testing.T) {
+112
View File
@@ -0,0 +1,112 @@
package pushover
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
)
const (
restAPIURL = "https://api.pushover.net/1/messages.json"
defaultPriority = 0
)
// AlertProvider is the configuration necessary for sending an alert using Pushover
type AlertProvider struct {
// Key used to authenticate the application sending
// See "Your Applications" on the dashboard, or add a new one: https://pushover.net/apps/build
ApplicationToken string `yaml:"application-token"`
// Key of the user or group the messages should be sent to
UserKey string `yaml:"user-key"`
// The title of your message, likely the application name
// default: the name of your application in Pushover
Title string `yaml:"title,omitempty"`
// Priority of all messages, ranging from -2 (very low) to 2 (Emergency)
// default: 0
Priority int `yaml:"priority,omitempty"`
// Sound of the messages (see: https://pushover.net/api#sounds)
// default: "" (pushover)
Sound string `yaml:"sound,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"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
if provider.Priority == 0 {
provider.Priority = defaultPriority
}
return len(provider.ApplicationToken) == 30 && len(provider.UserKey) == 30 && provider.Priority >= -2 && provider.Priority <= 2
}
// Send an alert using the provider
// Reference doc for pushover: https://pushover.net/api
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")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
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 {
Token string `json:"token"`
User string `json:"user"`
Title string `json:"title,omitempty"`
Message string `json:"message"`
Priority int `json:"priority"`
Sound string `json:"sound,omitempty"`
}
// 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
if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
} else {
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
}
body, _ := json.Marshal(Body{
Token: provider.ApplicationToken,
User: provider.UserKey,
Title: provider.Title,
Message: message,
Priority: provider.priority(),
Sound: provider.Sound,
})
return body
}
func (provider *AlertProvider) priority() int {
if provider.Priority == 0 {
return defaultPriority
}
return provider.Priority
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
+181
View File
@@ -0,0 +1,181 @@
package pushover
import (
"encoding/json"
"net/http"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/test"
)
func TestPushoverAlertProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{}
if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{
ApplicationToken: "aTokenWithLengthOf30characters",
UserKey: "aTokenWithLengthOf30characters",
Title: "Gatus Notification",
Priority: 1,
}
if !validProvider.IsValid() {
t.Error("provider should've been valid")
}
}
func TestPushoverAlertProvider_IsInvalid(t *testing.T) {
invalidProvider := AlertProvider{
ApplicationToken: "aTokenWithLengthOfMoreThan30characters",
UserKey: "aTokenWithLengthOfMoreThan30characters",
Priority: 5,
}
if invalidProvider.IsValid() {
t.Error("provider should've been invalid")
}
}
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{ApplicationToken: "TokenWithLengthOf30Characters1", UserKey: "TokenWithLengthOf30Characters4"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters1\",\"user\":\"TokenWithLengthOf30Characters4\",\"message\":\"TRIGGERED: endpoint-name - description-1\",\"priority\":0}",
},
{
Name: "resolved",
Provider: AlertProvider{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"RESOLVED: endpoint-name - description-2\",\"priority\":2}",
},
{
Name: "with-sound",
Provider: AlertProvider{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2, Sound: "falling"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"RESOLVED: endpoint-name - description-2\",\"priority\":2,\"sound\":\"falling\"}",
},
}
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:\n%s\ngot:\n%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())
}
})
}
}
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")
}
}
+77 -27
View File
@@ -2,32 +2,49 @@ package slack
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
)
// AlertProvider is the configuration necessary for sending an alert using Slack
type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"` // Slack webhook URL
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,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 {
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.WebhookURL, buffer)
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer)
if err != nil {
return err
}
@@ -36,6 +53,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
if err != nil {
return err
}
defer response.Body.Close()
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))
@@ -43,8 +61,27 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
return err
}
type Body struct {
Text string `json:"text"`
Attachments []Attachment `json:"attachments"`
}
type Attachment struct {
Title string `json:"title"`
Text string `json:"text"`
Short bool `json:"short"`
Color string `json:"color"`
Fields []Field `json:"fields"`
}
type Field struct {
Title string `json:"title"`
Value string `json:"value"`
Short bool `json:"short"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
var message, color, results string
if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
@@ -60,30 +97,43 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
} else {
prefix = ":x:"
}
results += fmt.Sprintf("%s - `%s`\\n", prefix, conditionResult.Condition)
results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\\n> " + alertDescription
description = ":\n> " + alertDescription
}
return fmt.Sprintf(`{
"text": "",
"attachments": [
{
"title": ":helmet_with_white_cross: Gatus",
"text": "%s%s",
"short": false,
"color": "%s",
"fields": [
{
"title": "Condition results",
"value": "%s",
"short": false
}
]
}
]
}`, message, description, color, results)
body, _ := json.Marshal(Body{
Text: "",
Attachments: []Attachment{
{
Title: ":helmet_with_white_cross: Gatus",
Text: message + description,
Short: false,
Color: color,
Fields: []Field{
{
Title: "Condition results",
Value: results,
Short: false,
},
},
},
},
})
return body
}
// 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
+113 -13
View File
@@ -5,13 +5,13 @@ import (
"net/http"
"testing"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/test"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_IsValid(t *testing.T) {
func TestAlertDefaultProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{WebhookURL: ""}
if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid")
@@ -22,6 +22,43 @@ func TestAlertProvider_IsValid(t *testing.T) {
}
}
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"
@@ -79,7 +116,7 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&core.Endpoint{Name: "name"},
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
@@ -116,7 +153,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
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}",
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"short\":false,\"color\":\"#DD0000\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
},
{
Name: "triggered-with-group",
@@ -124,7 +161,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
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}",
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *group/name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"short\":false,\"color\":\"#DD0000\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
},
{
Name: "resolved",
@@ -132,7 +169,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
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}",
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
},
{
Name: "resolved-with-group",
@@ -140,7 +177,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
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}",
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
},
}
for _, scenario := range scenarios {
@@ -156,11 +193,11 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
},
scenario.Resolved,
)
if body != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal([]byte(body), &out); err != nil {
if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
@@ -175,3 +212,66 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
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)
}
})
}
}
+36 -23
View File
@@ -2,13 +2,14 @@ package teams
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
)
// AlertProvider is the configuration necessary for sending an alert using Teams
@@ -44,7 +45,7 @@ func (provider *AlertProvider) IsValid() bool {
// 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)))
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer)
if err != nil {
return err
@@ -54,6 +55,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
if err != nil {
return err
}
defer response.Body.Close()
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))
@@ -61,8 +63,22 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
return err
}
type Body struct {
Type string `json:"@type"`
Context string `json:"@context"`
ThemeColor string `json:"themeColor"`
Title string `json:"title"`
Text string `json:"text"`
Sections []Section `json:"sections"`
}
type Section struct {
ActivityTitle string `json:"activityTitle"`
Text string `json:"text"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
var message, color 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)
@@ -83,25 +99,22 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\\n> " + alertDescription
description = ": " + alertDescription
}
return fmt.Sprintf(`{
"@type": "MessageCard",
"@context": "http://schema.org/extensions",
"themeColor": "%s",
"title": "&#x1F6A8; Gatus",
"text": "%s%s",
"sections": [
{
"activityTitle": "URL",
"text": "%s"
},
{
"activityTitle": "Condition results",
"text": "%s"
}
]
}`, color, message, description, endpoint.URL, results)
body, _ := json.Marshal(Body{
Type: "MessageCard",
Context: "http://schema.org/extensions",
ThemeColor: color,
Title: "&#x1F6A8; Gatus",
Text: message + description,
Sections: []Section{
{
ActivityTitle: "Condition results",
Text: results,
},
},
})
return body
}
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
+9 -9
View File
@@ -5,10 +5,10 @@ import (
"net/http"
"testing"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/test"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertDefaultProvider_IsValid(t *testing.T) {
@@ -151,14 +151,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
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}",
ExpectedBody: "{\"@type\":\"MessageCard\",\"@context\":\"http://schema.org/extensions\",\"themeColor\":\"#DD0000\",\"title\":\"\\u0026#x1F6A8; Gatus\",\"text\":\"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row: description-1\",\"sections\":[{\"activityTitle\":\"Condition results\",\"text\":\"\\u0026#x274C; - `[CONNECTED] == true`\\u003cbr/\\u003e\\u0026#x274C; - `[STATUS] == 200`\\u003cbr/\\u003e\"}]}",
},
{
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}",
ExpectedBody: "{\"@type\":\"MessageCard\",\"@context\":\"http://schema.org/extensions\",\"themeColor\":\"#36A64F\",\"title\":\"\\u0026#x1F6A8; Gatus\",\"text\":\"An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row: description-2\",\"sections\":[{\"activityTitle\":\"Condition results\",\"text\":\"\\u0026#x2705; - `[CONNECTED] == true`\\u003cbr/\\u003e\\u0026#x2705; - `[STATUS] == 200`\\u003cbr/\\u003e\"}]}",
},
}
for _, scenario := range scenarios {
@@ -174,11 +174,11 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
},
scenario.Resolved,
)
if body != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal([]byte(body), &out); err != nil {
if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
+31 -12
View File
@@ -2,13 +2,14 @@ package telegram
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
)
const defaultAPIURL = "https://api.telegram.org"
@@ -19,18 +20,24 @@ type AlertProvider struct {
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 *alert.Alert `yaml:"default-alert,omitempty"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
if provider.ClientConfig == nil {
provider.ClientConfig = client.GetDefaultConfig()
}
return len(provider.Token) > 0 && len(provider.ID) > 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)))
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
apiURL := provider.APIURL
if apiURL == "" {
apiURL = defaultAPIURL
@@ -40,10 +47,11 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
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))
@@ -51,13 +59,19 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
return err
}
type Body struct {
ChatID string `json:"chat_id"`
Text string `json:"text"`
ParseMode string `json:"parse_mode"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
var message, results string
if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved:\\n—\\n _healthcheck passing successfully %d time(s) in a row_\\n— ", endpoint.DisplayName(), 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 {
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)
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 {
var prefix string
@@ -66,15 +80,20 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
} else {
prefix = "❌"
}
results += fmt.Sprintf("%s - `%s`\\n", prefix, conditionResult.Condition)
results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
}
var text string
if len(alert.GetDescription()) > 0 {
text = fmt.Sprintf("⛑ *Gatus* \\n%s \\n*Description* \\n_%s_ \\n\\n*Condition results*\\n%s", message, alert.GetDescription(), results)
text = fmt.Sprintf("⛑ *Gatus* \n%s \n*Description* \n_%s_ \n\n*Condition results*\n%s", message, alert.GetDescription(), results)
} else {
text = fmt.Sprintf("⛑ *Gatus* \\n%s \\n*Condition results*\\n%s", message, results)
text = fmt.Sprintf("⛑ *Gatus* \n%s \n*Condition results*\n%s", message, results)
}
return fmt.Sprintf(`{"chat_id": "%s", "text": "%s", "parse_mode": "MARKDOWN"}`, provider.ID, text)
body, _ := json.Marshal(Body{
ChatID: provider.ID,
Text: text,
ParseMode: "MARKDOWN",
})
return body
}
// GetDefaultAlert returns the provider's default alert configuration
+27 -17
View File
@@ -5,21 +5,31 @@ import (
"net/http"
"testing"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/test"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{Token: "", ID: ""}
if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}
if !validProvider.IsValid() {
t.Error("provider should've been valid")
}
t.Run("invalid-provider", func(t *testing.T) {
invalidProvider := AlertProvider{Token: "", ID: ""}
if invalidProvider.IsValid() {
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"}
if validProvider.ClientConfig != nil {
t.Error("provider client config should have been nil prior to IsValid() being executed")
}
if !validProvider.IsValid() {
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_Send(t *testing.T) {
@@ -114,14 +124,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
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\"}",
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\"}",
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\"}",
},
}
for _, scenario := range scenarios {
@@ -137,11 +147,11 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
},
scenario.Resolved,
)
if body != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal([]byte(body), &out); err != nil {
if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
+4 -3
View File
@@ -8,9 +8,9 @@ import (
"net/http"
"net/url"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
)
// AlertProvider is the configuration necessary for sending an alert using Twilio
@@ -42,6 +42,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
if err != nil {
return err
}
defer response.Body.Close()
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))
+2 -2
View File
@@ -3,8 +3,8 @@ package twilio
import (
"testing"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/core"
)
func TestTwilioAlertProvider_IsValid(t *testing.T) {
+76 -3
View File
@@ -4,6 +4,7 @@ import (
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"net"
"net/http"
"net/smtp"
@@ -11,13 +12,21 @@ import (
"strings"
"time"
"github.com/TwiN/gocache/v2"
"github.com/TwiN/whois"
"github.com/go-ping/ping"
"github.com/ishidawataru/sctp"
)
// injectedHTTPClient is used for testing purposes
var injectedHTTPClient *http.Client
var (
// injectedHTTPClient is used for testing purposes
injectedHTTPClient *http.Client
// GetHTTPClient returns the shared HTTP client
whoisClient = whois.NewClient().WithReferralCache(true)
whoisExpirationDateCache = gocache.NewCache().WithMaxSize(10000).WithDefaultTTL(24 * time.Hour)
)
// GetHTTPClient returns the shared HTTP client, or the client from the configuration passed
func GetHTTPClient(config *Config) *http.Client {
if injectedHTTPClient != nil {
return injectedHTTPClient
@@ -28,6 +37,35 @@ func GetHTTPClient(config *Config) *http.Client {
return config.getHTTPClient()
}
// GetDomainExpiration retrieves the duration until the domain provided expires
func GetDomainExpiration(hostname string) (domainExpiration time.Duration, err error) {
var retrievedCachedValue bool
if v, exists := whoisExpirationDateCache.Get(hostname); exists {
domainExpiration = time.Until(v.(time.Time))
retrievedCachedValue = true
// If the domain OR the TTL is not going to expire in less than 24 hours
// we don't have to refresh the cache. Otherwise, we'll refresh it.
cacheEntryTTL, _ := whoisExpirationDateCache.TTL(hostname)
if cacheEntryTTL > 24*time.Hour && domainExpiration > 24*time.Hour {
// No need to refresh, so we'll just return the cached values
return domainExpiration, nil
}
}
if whoisResponse, err := whoisClient.QueryAndParse(hostname); err != nil {
if !retrievedCachedValue { // Add an error unless we already retrieved a cached value
return 0, fmt.Errorf("error querying and parsing hostname using whois client: %w", err)
}
} else {
domainExpiration = time.Until(whoisResponse.ExpirationDate)
if domainExpiration > 720*time.Hour {
whoisExpirationDateCache.SetWithTTL(hostname, whoisResponse.ExpirationDate, 240*time.Hour)
} else {
whoisExpirationDateCache.SetWithTTL(hostname, whoisResponse.ExpirationDate, 72*time.Hour)
}
}
return domainExpiration, nil
}
// CanCreateTCPConnection checks whether a connection can be established with a TCP endpoint
func CanCreateTCPConnection(address string, config *Config) bool {
conn, err := net.DialTimeout("tcp", address, config.Timeout)
@@ -38,6 +76,41 @@ func CanCreateTCPConnection(address string, config *Config) bool {
return true
}
// CanCreateUDPConnection checks whether a connection can be established with a UDP endpoint
func CanCreateUDPConnection(address string, config *Config) bool {
conn, err := net.DialTimeout("udp", address, config.Timeout)
if err != nil {
return false
}
_ = conn.Close()
return true
}
// CanCreateSCTPConnection checks whether a connection can be established with a SCTP endpoint
func CanCreateSCTPConnection(address string, config *Config) bool {
ch := make(chan bool)
go (func(res chan bool) {
addr, err := sctp.ResolveSCTPAddr("sctp", address)
if err != nil {
res <- false
}
conn, err := sctp.DialSCTP("sctp", nil, addr)
if err != nil {
res <- false
}
_ = conn.Close()
res <- true
})(ch)
select {
case result := <-ch:
return result
case <-time.After(config.Timeout):
return false
}
}
// CanPerformStartTLS checks whether a connection can be established to an address using the STARTTLS protocol
func CanPerformStartTLS(address string, config *Config) (connected bool, certificate *x509.Certificate, err error) {
hostAndPort := strings.Split(address, ":")
+39 -14
View File
@@ -2,12 +2,12 @@ package client
import (
"bytes"
"io/ioutil"
"io"
"net/http"
"testing"
"time"
"github.com/TwiN/gatus/v3/test"
"github.com/TwiN/gatus/v5/test"
)
func TestGetHTTPClient(t *testing.T) {
@@ -15,6 +15,7 @@ func TestGetHTTPClient(t *testing.T) {
Insecure: false,
IgnoreRedirect: false,
Timeout: 0,
DNSResolver: "tcp://1.1.1.1:53",
OAuth2Config: &OAuth2Config{
ClientID: "00000000-0000-0000-0000-000000000000",
ClientSecret: "secretsauce",
@@ -22,7 +23,10 @@ func TestGetHTTPClient(t *testing.T) {
Scopes: []string{"https://application.local/.default"},
},
}
cfg.ValidateAndSetDefaults()
err := cfg.ValidateAndSetDefaults()
if err != nil {
t.Errorf("expected error to be nil, but got: `%s`", err)
}
if GetHTTPClient(cfg) == nil {
t.Error("expected client to not be nil")
}
@@ -31,7 +35,36 @@ func TestGetHTTPClient(t *testing.T) {
}
}
func TestGetDomainExpiration(t *testing.T) {
t.Parallel()
if domainExpiration, err := GetDomainExpiration("example.com"); err != nil {
t.Fatalf("expected error to be nil, but got: `%s`", err)
} else if domainExpiration <= 0 {
t.Error("expected domain expiration to be higher than 0")
}
if domainExpiration, err := GetDomainExpiration("example.com"); err != nil {
t.Errorf("expected error to be nil, but got: `%s`", err)
} else if domainExpiration <= 0 {
t.Error("expected domain expiration to be higher than 0")
}
// Hack to pretend like the domain is expiring in 1 hour, which should trigger a refresh
whoisExpirationDateCache.SetWithTTL("example.com", time.Now().Add(time.Hour), 25*time.Hour)
if domainExpiration, err := GetDomainExpiration("example.com"); err != nil {
t.Errorf("expected error to be nil, but got: `%s`", err)
} else if domainExpiration <= 0 {
t.Error("expected domain expiration to be higher than 0")
}
// Make sure the refresh works when the ttl is <24 hours
whoisExpirationDateCache.SetWithTTL("example.com", time.Now().Add(35*time.Hour), 23*time.Hour)
if domainExpiration, err := GetDomainExpiration("example.com"); err != nil {
t.Errorf("expected error to be nil, but got: `%s`", err)
} else if domainExpiration <= 0 {
t.Error("expected domain expiration to be higher than 0")
}
}
func TestPing(t *testing.T) {
t.Parallel()
if success, rtt := Ping("127.0.0.1", &Config{Timeout: 500 * time.Millisecond}); !success {
t.Error("expected true")
if rtt == 0 {
@@ -90,6 +123,7 @@ func TestCanPerformStartTLS(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
connected, _, err := CanPerformStartTLS(tt.args.address, &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second})
if (err != nil) != tt.wantErr {
t.Errorf("CanPerformStartTLS() err=%v, wantErr=%v", err, tt.wantErr)
@@ -140,6 +174,7 @@ func TestCanPerformTLS(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
connected, _, err := CanPerformTLS(tt.args.address, &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second})
if (err != nil) != tt.wantErr {
t.Errorf("CanPerformTLS() err=%v, wantErr=%v", err, tt.wantErr)
@@ -162,32 +197,27 @@ func TestCanCreateTCPConnection(t *testing.T) {
// 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: ioutil.NopCloser(bytes.NewReader(
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
@@ -200,24 +230,19 @@ func TestHttpClientProvidesOAuth2BearerToken(t *testing.T) {
}
}),
}
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`
+77 -1
View File
@@ -4,7 +4,11 @@ import (
"context"
"crypto/tls"
"errors"
"log"
"net"
"net/http"
"regexp"
"strconv"
"time"
"golang.org/x/oauth2"
@@ -16,7 +20,9 @@ const (
)
var (
ErrInvalidClientOAuth2Config = errors.New("invalid OAuth2 configuration, all fields are required")
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: must define all fields for client credentials flow (token-url, client-id, client-secret, scopes)")
defaultConfig = Config{
Insecure: false,
@@ -42,6 +48,10 @@ type Config struct {
// Timeout for the client
Timeout time.Duration `yaml:"timeout"`
// DNSResolver override for the HTTP client
// Expected format is {protocol}://{host}:{port}, e.g. tcp://8.8.8.8: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.
@@ -51,6 +61,13 @@ type Config struct {
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
@@ -64,12 +81,50 @@ func (c *Config) ValidateAndSetDefaults() error {
if c.Timeout < time.Millisecond {
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
@@ -102,6 +157,27 @@ func (c *Config) getHTTPClient() *http.Client {
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)
}
+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")
}
}
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)
}
})
}
}
+6
View File
@@ -45,3 +45,9 @@ endpoints:
interval: 1m
conditions:
- "[CONNECTED] == true"
- name: check-domain-expiration
url: "https://example.org/"
interval: 1h
conditions:
- "[DOMAIN_EXPIRATION] > 720h"
+149 -73
View File
@@ -2,25 +2,31 @@ package config
import (
"errors"
"fmt"
"io/fs"
"log"
"os"
"path/filepath"
"time"
"github.com/TwiN/gatus/v3/alerting"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/alerting/provider"
"github.com/TwiN/gatus/v3/config/maintenance"
"github.com/TwiN/gatus/v3/config/ui"
"github.com/TwiN/gatus/v3/config/web"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/security"
"github.com/TwiN/gatus/v3/storage"
"gopkg.in/yaml.v2"
"github.com/TwiN/deepmerge"
"github.com/TwiN/gatus/v5/alerting"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/alerting/provider"
"github.com/TwiN/gatus/v5/config/maintenance"
"github.com/TwiN/gatus/v5/config/remote"
"github.com/TwiN/gatus/v5/config/ui"
"github.com/TwiN/gatus/v5/config/web"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/security"
"github.com/TwiN/gatus/v5/storage"
"github.com/TwiN/gatus/v5/util"
"gopkg.in/yaml.v3"
)
const (
// DefaultConfigurationFilePath is the default path that will be used to search for the configuration file
// if a custom path isn't configured through the GATUS_CONFIG_FILE environment variable
// if a custom path isn't configured through the GATUS_CONFIG_PATH environment variable
DefaultConfigurationFilePath = "config/config.yaml"
// DefaultFallbackConfigurationFilePath is the default fallback path that will be used to search for the
@@ -29,14 +35,17 @@ const (
)
var (
// ErrNoEndpointInConfig is an error returned when a configuration file has no endpoints configured
ErrNoEndpointInConfig = errors.New("configuration file should contain at least 1 endpoint")
// ErrNoEndpointInConfig is an error returned when a configuration file or directory has no endpoints configured
ErrNoEndpointInConfig = errors.New("configuration should contain at least 1 endpoint")
// ErrConfigFileNotFound is an error returned when the configuration file could not be found
// ErrConfigFileNotFound is an error returned when a configuration file could not be found
ErrConfigFileNotFound = errors.New("configuration file not found")
// ErrInvalidSecurityConfig is an error returned when the security configuration is invalid
ErrInvalidSecurityConfig = errors.New("invalid security configuration")
// errEarlyReturn is returned to break out of a loop from a callback early
errEarlyReturn = errors.New("early escape")
)
// Config is the main configuration structure
@@ -65,14 +74,6 @@ type Config struct {
// Endpoints List of endpoints to monitor
Endpoints []*core.Endpoint `yaml:"endpoints,omitempty"`
// Services List of endpoints to monitor
//
// XXX: Remove this in v5.0.0
// XXX: This is not a typo -- not v4.0.0, but v5.0.0 -- I want to give enough time for people to migrate
//
// Deprecated in favor of Endpoints
Services []*core.Endpoint `yaml:"services,omitempty"`
// Storage is the configuration for how the data is stored
Storage *storage.Config `yaml:"storage,omitempty"`
@@ -85,67 +86,130 @@ type Config struct {
// Maintenance is the configuration for creating a maintenance window in which no alerts are sent
Maintenance *maintenance.Config `yaml:"maintenance,omitempty"`
filePath string // path to the file from which config was loaded from
// 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"`
configPath string // path to the file or directory from which config was loaded
lastFileModTime time.Time // last modification time
}
// HasLoadedConfigurationFileBeenModified returns whether the file that the
// configuration has been loaded from has been modified since it was last read
func (config Config) HasLoadedConfigurationFileBeenModified() bool {
if fileInfo, err := os.Stat(config.filePath); err == nil {
if !fileInfo.ModTime().IsZero() {
return config.lastFileModTime.Unix() != fileInfo.ModTime().Unix()
func (config *Config) GetEndpointByKey(key string) *core.Endpoint {
// TODO: Should probably add a mutex here to prevent concurrent access
for i := 0; i < len(config.Endpoints); i++ {
ep := config.Endpoints[i]
if util.ConvertGroupAndEndpointNameToKey(ep.Group, ep.Name) == key {
return ep
}
}
return false
return nil
}
// HasLoadedConfigurationBeenModified returns whether one of the file that the
// configuration has been loaded from has been modified since it was last read
func (config Config) HasLoadedConfigurationBeenModified() bool {
lastMod := config.lastFileModTime.Unix()
fileInfo, err := os.Stat(config.configPath)
if err != nil {
return false
}
if fileInfo.IsDir() {
err = walkConfigDir(config.configPath, func(path string, d fs.DirEntry, err error) error {
if info, err := d.Info(); err == nil && lastMod < info.ModTime().Unix() {
return errEarlyReturn
}
return nil
})
return err == errEarlyReturn
}
return !fileInfo.ModTime().IsZero() && config.lastFileModTime.Unix() < fileInfo.ModTime().Unix()
}
// UpdateLastFileModTime refreshes Config.lastFileModTime
func (config *Config) UpdateLastFileModTime() {
if fileInfo, err := os.Stat(config.filePath); err == nil {
if !fileInfo.ModTime().IsZero() {
config.lastFileModTime = fileInfo.ModTime()
config.lastFileModTime = time.Now()
}
// LoadConfiguration loads the full configuration composed from the main configuration file
// and all composed configuration files
func LoadConfiguration(configPath string) (*Config, error) {
var configBytes []byte
var fileInfo os.FileInfo
var usedConfigPath string
// Figure out what config path we'll use (either configPath or the default config path)
for _, configurationPath := range []string{configPath, DefaultConfigurationFilePath, DefaultFallbackConfigurationFilePath} {
if len(configurationPath) == 0 {
continue
}
var err error
fileInfo, err = os.Stat(configurationPath)
if err != nil {
continue
}
usedConfigPath = configurationPath
break
}
if len(usedConfigPath) == 0 {
return nil, ErrConfigFileNotFound
}
var config *Config
if fileInfo.IsDir() {
err := walkConfigDir(configPath, func(path string, d fs.DirEntry, err error) error {
if err != nil {
log.Printf("[config][LoadConfiguration] Error walking path=%s: %s", path, err)
return err
}
log.Printf("[config][LoadConfiguration] Reading configuration from %s", path)
data, err := os.ReadFile(path)
if err != nil {
log.Printf("[config][LoadConfiguration] Error reading configuration from %s: %s", path, err)
return fmt.Errorf("error reading configuration from file %s: %w", path, err)
}
configBytes, err = deepmerge.YAML(configBytes, data)
return err
})
if err != nil {
return nil, fmt.Errorf("error reading configuration from directory %s: %w", usedConfigPath, err)
}
} else {
log.Println("[config][UpdateLastFileModTime] Ran into error updating lastFileModTime:", err.Error())
}
}
// Load loads a custom configuration file
// Note that the misconfiguration of some fields may lead to panics. This is on purpose.
func Load(configFile string) (*Config, error) {
log.Printf("[config][Load] Reading configuration from configFile=%s", configFile)
cfg, err := readConfigurationFile(configFile)
if err != nil {
if os.IsNotExist(err) {
return nil, ErrConfigFileNotFound
log.Printf("[config][LoadConfiguration] Reading configuration from configFile=%s", configPath)
if data, err := os.ReadFile(usedConfigPath); err != nil {
return nil, err
} else {
configBytes = data
}
}
if len(configBytes) == 0 {
return nil, ErrConfigFileNotFound
}
config, err := parseAndValidateConfigBytes(configBytes)
if err != nil {
return nil, err
}
cfg.filePath = configFile
cfg.UpdateLastFileModTime()
return cfg, nil
config.configPath = usedConfigPath
config.UpdateLastFileModTime()
return config, err
}
// LoadDefaultConfiguration loads the default configuration file
func LoadDefaultConfiguration() (*Config, error) {
cfg, err := Load(DefaultConfigurationFilePath)
if err != nil {
if err == ErrConfigFileNotFound {
return Load(DefaultFallbackConfigurationFilePath)
// walkConfigDir is a wrapper for filepath.WalkDir that strips directories and non-config files
func walkConfigDir(path string, fn fs.WalkDirFunc) error {
if len(path) == 0 {
// If the user didn't provide a directory, we'll just use the default config file, so we can return nil now.
return nil
}
return filepath.WalkDir(path, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
return nil, err
}
return cfg, nil
}
func readConfigurationFile(fileName string) (config *Config, err error) {
var bytes []byte
if bytes, err = os.ReadFile(fileName); err == nil {
// file exists, so we'll parse it and return it
return parseAndValidateConfigBytes(bytes)
}
return
if d == nil || d.IsDir() {
return nil
}
ext := filepath.Ext(path)
if ext != ".yml" && ext != ".yaml" {
return nil
}
return fn(path, d, err)
})
}
// parseAndValidateConfigBytes parses a Gatus configuration file into a Config struct and validates its parameters
@@ -156,12 +220,6 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
if err = yaml.Unmarshal(yamlBytes, &config); err != nil {
return
}
if config != nil && len(config.Services) > 0 { // XXX: Remove this in v5.0.0
log.Println("WARNING: Your configuration is using 'services:', which is deprecated in favor of 'endpoints:'.")
log.Println("WARNING: See https://github.com/TwiN/gatus/issues/191 for more information")
config.Endpoints = append(config.Endpoints, config.Services...)
config.Services = nil
}
// Check if the configuration file at least has endpoints configured
if config == nil || config.Endpoints == nil || len(config.Endpoints) == 0 {
err = ErrNoEndpointInConfig
@@ -185,10 +243,22 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
if err := validateStorageConfig(config); err != nil {
return nil, err
}
if err := validateRemoteConfig(config); err != nil {
return nil, err
}
}
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 {
if config.Storage == nil {
config.Storage = &storage.Config{
@@ -239,7 +309,7 @@ func validateEndpointsConfig(config *Config) error {
log.Printf("[config][validateEndpointsConfig] Validating endpoint '%s'", endpoint.Name)
}
if err := endpoint.ValidateAndSetDefaults(); err != nil {
return err
return fmt.Errorf("invalid endpoint %s: %w", endpoint.DisplayName(), err)
}
}
log.Printf("[config][validateEndpointsConfig] Validated %d endpoints", len(config.Endpoints))
@@ -273,11 +343,16 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*core.E
alertTypes := []alert.Type{
alert.TypeCustom,
alert.TypeDiscord,
alert.TypeGitHub,
alert.TypeGoogleChat,
alert.TypeEmail,
alert.TypeMatrix,
alert.TypeMattermost,
alert.TypeMessagebird,
alert.TypeNtfy,
alert.TypeOpsgenie,
alert.TypePagerDuty,
alert.TypePushover,
alert.TypeSlack,
alert.TypeTeams,
alert.TypeTelegram,
@@ -305,6 +380,7 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*core.E
} else {
log.Printf("[config][validateAlertingConfig] Ignoring provider=%s because configuration is invalid", alertType)
invalidProviders = append(invalidProviders, alertType)
alertingConfig.SetAlertingProviderToNil(alertProvider)
}
} else {
invalidProviders = append(invalidProviders, alertType)
+401 -171
View File
@@ -1,48 +1,304 @@
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/TwiN/gatus/v3/alerting"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/alerting/provider/custom"
"github.com/TwiN/gatus/v3/alerting/provider/discord"
"github.com/TwiN/gatus/v3/alerting/provider/mattermost"
"github.com/TwiN/gatus/v3/alerting/provider/messagebird"
"github.com/TwiN/gatus/v3/alerting/provider/pagerduty"
"github.com/TwiN/gatus/v3/alerting/provider/slack"
"github.com/TwiN/gatus/v3/alerting/provider/teams"
"github.com/TwiN/gatus/v3/alerting/provider/telegram"
"github.com/TwiN/gatus/v3/alerting/provider/twilio"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/config/ui"
"github.com/TwiN/gatus/v3/config/web"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/storage"
"github.com/TwiN/gatus/v5/alerting"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/alerting/provider"
"github.com/TwiN/gatus/v5/alerting/provider/custom"
"github.com/TwiN/gatus/v5/alerting/provider/discord"
"github.com/TwiN/gatus/v5/alerting/provider/email"
"github.com/TwiN/gatus/v5/alerting/provider/github"
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
"github.com/TwiN/gatus/v5/alerting/provider/ntfy"
"github.com/TwiN/gatus/v5/alerting/provider/opsgenie"
"github.com/TwiN/gatus/v5/alerting/provider/pagerduty"
"github.com/TwiN/gatus/v5/alerting/provider/pushover"
"github.com/TwiN/gatus/v5/alerting/provider/slack"
"github.com/TwiN/gatus/v5/alerting/provider/teams"
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/web"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/storage"
"gopkg.in/yaml.v3"
)
func TestLoadFileThatDoesNotExist(t *testing.T) {
_, err := Load("file-that-does-not-exist.yaml")
if err == nil {
t.Error("Should've returned an error, because the file specified doesn't exist")
func TestLoadConfiguration(t *testing.T) {
dir := t.TempDir()
scenarios := []struct {
name string
configPath string // value to pass as the configPath parameter in LoadConfiguration
pathAndFiles map[string]string // files to create in dir
expectedConfig *Config
expectedError error
}{
{
name: "empty-config-file",
configPath: filepath.Join(dir, "config.yaml"),
pathAndFiles: map[string]string{
"config.yaml": "",
},
expectedError: ErrConfigFileNotFound,
},
{
name: "config-file-that-does-not-exist",
configPath: filepath.Join(dir, "config.yaml"),
expectedError: ErrConfigFileNotFound,
},
{
name: "config-file-with-endpoint-that-has-no-url",
configPath: filepath.Join(dir, "config.yaml"),
pathAndFiles: map[string]string{
"config.yaml": `
endpoints:
- name: website`,
},
expectedError: core.ErrEndpointWithNoURL,
},
{
name: "config-file-with-endpoint-that-has-no-conditions",
configPath: filepath.Join(dir, "config.yaml"),
pathAndFiles: map[string]string{
"config.yaml": `
endpoints:
- name: website
url: https://twin.sh/health`,
},
expectedError: core.ErrEndpointWithNoCondition,
},
{
name: "config-file",
configPath: filepath.Join(dir, "config.yaml"),
pathAndFiles: map[string]string{
"config.yaml": `
endpoints:
- name: website
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"`,
},
expectedConfig: &Config{
Endpoints: []*core.Endpoint{
{
Name: "website",
URL: "https://twin.sh/health",
Conditions: []core.Condition{"[STATUS] == 200"},
},
},
},
},
{
name: "empty-dir",
configPath: dir,
pathAndFiles: map[string]string{},
expectedError: ErrConfigFileNotFound,
},
{
name: "dir-with-empty-config-file",
configPath: dir,
pathAndFiles: map[string]string{
"config.yaml": "",
},
expectedError: ErrNoEndpointInConfig,
},
{
name: "dir-with-two-config-files",
configPath: dir,
pathAndFiles: map[string]string{
"config.yaml": `endpoints:
- name: one
url: https://example.com
conditions:
- "[CONNECTED] == true"
- "[STATUS] == 200"
- name: two
url: https://example.org
conditions:
- "len([BODY]) > 0"`,
"config.yml": `endpoints:
- name: three
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"`,
},
expectedConfig: &Config{
Endpoints: []*core.Endpoint{
{
Name: "one",
URL: "https://example.com",
Conditions: []core.Condition{"[CONNECTED] == true", "[STATUS] == 200"},
},
{
Name: "two",
URL: "https://example.org",
Conditions: []core.Condition{"len([BODY]) > 0"},
},
{
Name: "three",
URL: "https://twin.sh/health",
Conditions: []core.Condition{"[STATUS] == 200", "[BODY].status == UP"},
},
},
},
},
{
name: "dir-with-2-config-files-deep-merge-with-map-slice-and-primitive",
configPath: dir,
pathAndFiles: map[string]string{
"a.yaml": `
metrics: true
alerting:
slack:
webhook-url: https://hooks.slack.com/services/xxx/yyy/zzz
endpoints:
- name: example
url: https://example.org
interval: 5s
conditions:
- "[STATUS] == 200"`,
"b.yaml": `
debug: true
alerting:
discord:
webhook-url: https://discord.com/api/webhooks/xxx/yyy
endpoints:
- name: frontend
url: https://example.com
conditions:
- "[STATUS] == 200"`,
},
expectedConfig: &Config{
Debug: true,
Metrics: true,
Alerting: &alerting.Config{
Discord: &discord.AlertProvider{WebhookURL: "https://discord.com/api/webhooks/xxx/yyy"},
Slack: &slack.AlertProvider{WebhookURL: "https://hooks.slack.com/services/xxx/yyy/zzz"},
},
Endpoints: []*core.Endpoint{
{
Name: "example",
URL: "https://example.org",
Interval: 5 * time.Second,
Conditions: []core.Condition{"[STATUS] == 200"},
},
{
Name: "frontend",
URL: "https://example.com",
Conditions: []core.Condition{"[STATUS] == 200"},
},
},
},
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
for path, content := range scenario.pathAndFiles {
if err := os.WriteFile(filepath.Join(dir, path), []byte(content), 0644); err != nil {
t.Fatalf("[%s] failed to write file: %v", scenario.name, err)
}
}
defer func(pathAndFiles map[string]string) {
for path := range pathAndFiles {
_ = os.Remove(filepath.Join(dir, path))
}
}(scenario.pathAndFiles)
config, err := LoadConfiguration(scenario.configPath)
if !errors.Is(err, scenario.expectedError) {
t.Errorf("[%s] expected error %v, got %v", scenario.name, scenario.expectedError, err)
return
} else if err != nil && errors.Is(err, scenario.expectedError) {
return
}
// parse the expected output so that expectations are closer to reality (under the right circumstances, even I can be poetic)
expectedConfigAsYAML, _ := yaml.Marshal(scenario.expectedConfig)
expectedConfigAfterBeingParsedAndValidated, err := parseAndValidateConfigBytes(expectedConfigAsYAML)
if err != nil {
t.Fatalf("[%s] failed to parse expected config: %v", scenario.name, err)
}
// Marshal em' before comparing em' so that we don't have to deal with formatting and ordering
actualConfigAsYAML, err := yaml.Marshal(config)
if err != nil {
t.Fatalf("[%s] failed to marshal actual config: %v", scenario.name, err)
}
expectedConfigAfterBeingParsedAndValidatedAsYAML, _ := yaml.Marshal(expectedConfigAfterBeingParsedAndValidated)
if string(actualConfigAsYAML) != string(expectedConfigAfterBeingParsedAndValidatedAsYAML) {
t.Errorf("[%s] expected config %s, got %s", scenario.name, string(expectedConfigAfterBeingParsedAndValidatedAsYAML), string(actualConfigAsYAML))
}
})
}
}
func TestLoadDefaultConfigurationFile(t *testing.T) {
_, err := LoadDefaultConfiguration()
if err == nil {
t.Error("Should've returned an error, because there's no configuration files at the default path nor the default fallback path")
}
func TestConfig_HasLoadedConfigurationBeenModified(t *testing.T) {
t.Parallel()
dir := t.TempDir()
configFilePath := filepath.Join(dir, "config.yaml")
_ = os.WriteFile(configFilePath, []byte(`endpoints:
- name: website
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"
`), 0644)
t.Run("config-file-as-config-path", func(t *testing.T) {
config, err := LoadConfiguration(configFilePath)
if err != nil {
t.Fatalf("failed to load configuration: %v", err)
}
if config.HasLoadedConfigurationBeenModified() {
t.Errorf("expected config.HasLoadedConfigurationBeenModified() to return false because nothing has happened since it was created")
}
time.Sleep(time.Second) // Because the file mod time only has second precision, we have to wait for a second
// Update the config file
if err = os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(`endpoints:
- name: website
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"`), 0644); err != nil {
t.Fatalf("failed to overwrite config file: %v", err)
}
if !config.HasLoadedConfigurationBeenModified() {
t.Errorf("expected config.HasLoadedConfigurationBeenModified() to return true because a new file has been added in the directory")
}
})
t.Run("config-directory-as-config-path", func(t *testing.T) {
config, err := LoadConfiguration(dir)
if err != nil {
t.Fatalf("failed to load configuration: %v", err)
}
if config.HasLoadedConfigurationBeenModified() {
t.Errorf("expected config.HasLoadedConfigurationBeenModified() to return false because nothing has happened since it was created")
}
time.Sleep(time.Second) // Because the file mod time only has second precision, we have to wait for a second
// Update the config file
if err = os.WriteFile(filepath.Join(dir, "metrics.yaml"), []byte(`metrics: true`), 0644); err != nil {
t.Fatalf("failed to overwrite config file: %v", err)
}
if !config.HasLoadedConfigurationBeenModified() {
t.Errorf("expected config.HasLoadedConfigurationBeenModified() to return true because a new file has been added in the directory")
}
})
}
func TestParseAndValidateConfigBytes(t *testing.T) {
file := t.TempDir() + "/test.db"
ui.StaticFolder = "../web/static"
defer func() {
ui.StaticFolder = "./web/static"
}()
config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`
storage:
type: sqlite
@@ -429,6 +685,9 @@ alerting:
webhook-url: "http://example.org"
pagerduty:
integration-key: "00000000000000000000000000000000"
pushover:
application-token: "000000000000000000000000000000"
user-key: "000000000000000000000000000000"
mattermost:
webhook-url: "http://example.com"
client:
@@ -453,26 +712,22 @@ endpoints:
url: https://twin.sh/health
alerts:
- type: slack
enabled: true
- type: pagerduty
enabled: true
failure-threshold: 7
success-threshold: 5
description: "Healthcheck failed 7 times in a row"
- type: mattermost
enabled: true
- type: messagebird
enabled: false
- type: discord
enabled: true
failure-threshold: 10
- type: telegram
enabled: true
- type: twilio
enabled: true
failure-threshold: 12
success-threshold: 15
- type: teams
enabled: true
- type: pushover
conditions:
- "[STATUS] == 200"
`))
@@ -499,8 +754,8 @@ endpoints:
if config.Endpoints[0].Interval != 60*time.Second {
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
}
if len(config.Endpoints[0].Alerts) != 8 {
t.Fatal("There should've been 8 alerts configured")
if len(config.Endpoints[0].Alerts) != 9 {
t.Fatal("There should've been 9 alerts configured")
}
if config.Endpoints[0].Alerts[0].Type != alert.TypeSlack {
@@ -600,6 +855,13 @@ endpoints:
if config.Endpoints[0].Alerts[7].SuccessThreshold != 2 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[7].SuccessThreshold)
}
if config.Endpoints[0].Alerts[8].Type != alert.TypePushover {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypePushover, config.Endpoints[0].Alerts[8].Type)
}
if !config.Endpoints[0].Alerts[8].IsEnabled() {
t.Error("The alert should've been enabled")
}
}
func TestParseAndValidateConfigBytesWithAlertingAndDefaultAlert(t *testing.T) {
@@ -624,6 +886,14 @@ alerting:
description: default description
failure-threshold: 7
success-threshold: 5
pushover:
application-token: "000000000000000000000000000000"
user-key: "000000000000000000000000000000"
default-alert:
enabled: true
description: default description
failure-threshold: 5
success-threshold: 3
mattermost:
webhook-url: "http://example.com"
default-alert:
@@ -667,6 +937,7 @@ endpoints:
- type: telegram
- type: twilio
- type: teams
- type: pushover
conditions:
- "[STATUS] == 200"
`))
@@ -703,6 +974,19 @@ endpoints:
t.Errorf("PagerDuty integration key should've been %s, but was %s", "00000000000000000000000000000000", config.Alerting.PagerDuty.IntegrationKey)
}
if config.Alerting.Pushover == nil || !config.Alerting.Pushover.IsValid() {
t.Fatal("Pushover alerting config should've been valid")
}
if config.Alerting.Pushover.GetDefaultAlert() == nil {
t.Fatal("Pushover.GetDefaultAlert() shouldn't have returned nil")
}
if config.Alerting.Pushover.ApplicationToken != "000000000000000000000000000000" {
t.Errorf("Pushover application token should've been %s, but was %s", "000000000000000000000000000000", config.Alerting.Pushover.ApplicationToken)
}
if config.Alerting.Pushover.UserKey != "000000000000000000000000000000" {
t.Errorf("Pushover user key should've been %s, but was %s", "000000000000000000000000000000", config.Alerting.Pushover.UserKey)
}
if config.Alerting.Mattermost == nil || !config.Alerting.Mattermost.IsValid() {
t.Fatal("Mattermost alerting config should've been valid")
}
@@ -776,8 +1060,8 @@ endpoints:
if config.Endpoints[0].Interval != 60*time.Second {
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
}
if len(config.Endpoints[0].Alerts) != 8 {
t.Fatal("There should've been 8 alerts configured")
if len(config.Endpoints[0].Alerts) != 9 {
t.Fatal("There should've been 9 alerts configured")
}
if config.Endpoints[0].Alerts[0].Type != alert.TypeSlack {
@@ -881,6 +1165,19 @@ endpoints:
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[7].SuccessThreshold)
}
if config.Endpoints[0].Alerts[8].Type != alert.TypePushover {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypePushover, config.Endpoints[0].Alerts[8].Type)
}
if !config.Endpoints[0].Alerts[8].IsEnabled() {
t.Error("The alert should've been enabled")
}
if config.Endpoints[0].Alerts[8].FailureThreshold != 5 {
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[8].FailureThreshold)
}
if config.Endpoints[0].Alerts[8].SuccessThreshold != 3 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[8].SuccessThreshold)
}
}
func TestParseAndValidateConfigBytesWithAlertingAndDefaultAlertAndMultipleAlertsOfSameTypeWithOverriddenParameters(t *testing.T) {
@@ -984,11 +1281,34 @@ endpoints:
if config.Alerting == nil {
t.Fatal("config.Alerting shouldn't have been nil")
}
if config.Alerting.PagerDuty == nil {
t.Fatal("PagerDuty alerting config shouldn't have been nil")
if config.Alerting.PagerDuty != nil {
t.Fatal("PagerDuty alerting config should've been set to nil, because its IsValid() method returned false and therefore alerting.Config.SetAlertingProviderToNil() should've been called")
}
if config.Alerting.PagerDuty.IsValid() {
t.Fatal("PagerDuty alerting config should've been invalid")
}
func TestParseAndValidateConfigBytesWithInvalidPushoverAlertingConfig(t *testing.T) {
config, err := parseAndValidateConfigBytes([]byte(`
alerting:
pushover:
application-token: "INVALID_TOKEN"
endpoints:
- name: website
url: https://twin.sh/health
alerts:
- type: pushover
conditions:
- "[STATUS] == 200"
`))
if err != nil {
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
}
if config.Alerting == nil {
t.Fatal("config.Alerting shouldn't have been nil")
}
if config.Alerting.Pushover != nil {
t.Fatal("Pushover alerting config should've been set to nil, because its IsValid() method returned false and therefore alerting.Config.SetAlertingProviderToNil() should've been called")
}
}
@@ -1019,7 +1339,7 @@ endpoints:
t.Fatal("config.Alerting shouldn't have been nil")
}
if config.Alerting.Custom == nil {
t.Fatal("PagerDuty alerting config shouldn't have been nil")
t.Fatal("Custom alerting config shouldn't have been nil")
}
if !config.Alerting.Custom.IsValid() {
t.Fatal("Custom alerting config should've been valid")
@@ -1064,7 +1384,7 @@ endpoints:
t.Fatal("config.Alerting shouldn't have been nil")
}
if config.Alerting.Custom == nil {
t.Fatal("PagerDuty alerting config shouldn't have been nil")
t.Fatal("Custom alerting config shouldn't have been nil")
}
if !config.Alerting.Custom.IsValid() {
t.Fatal("Custom alerting config should've been valid")
@@ -1104,7 +1424,7 @@ endpoints:
t.Fatal("config.Alerting shouldn't have been nil")
}
if config.Alerting.Custom == nil {
t.Fatal("PagerDuty alerting config shouldn't have been nil")
t.Fatal("Custom alerting config shouldn't have been nil")
}
if !config.Alerting.Custom.IsValid() {
t.Fatal("Custom alerting config should've been valid")
@@ -1125,7 +1445,7 @@ endpoints:
conditions:
- "[STATUS] == 200"
`))
if err != core.ErrEndpointWithNoName {
if err == nil {
t.Error("should've returned an error")
}
}
@@ -1210,7 +1530,7 @@ endpoints:
t.Errorf("config.Security.Basic.Username should've been %s, but was %s", expectedUsername, config.Security.Basic.Username)
}
if config.Security.Basic.PasswordBcryptHashBase64Encoded != expectedPasswordHash {
t.Errorf("config.Security.Basic.PasswordBcryptHashBase64Encoded 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)
}
}
@@ -1225,137 +1545,47 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
alertingConfig := &alerting.Config{
Custom: &custom.AlertProvider{},
Discord: &discord.AlertProvider{},
Email: &email.AlertProvider{},
GitHub: &github.AlertProvider{},
GoogleChat: &googlechat.AlertProvider{},
Matrix: &matrix.AlertProvider{},
Mattermost: &mattermost.AlertProvider{},
Messagebird: &messagebird.AlertProvider{},
Ntfy: &ntfy.AlertProvider{},
Opsgenie: &opsgenie.AlertProvider{},
PagerDuty: &pagerduty.AlertProvider{},
Pushover: &pushover.AlertProvider{},
Slack: &slack.AlertProvider{},
Telegram: &telegram.AlertProvider{},
Twilio: &twilio.AlertProvider{},
Teams: &teams.AlertProvider{},
}
if alertingConfig.GetAlertingProviderByAlertType(alert.TypeCustom) != alertingConfig.Custom {
t.Error("expected Custom configuration")
scenarios := []struct {
alertType alert.Type
expected provider.AlertProvider
}{
{alertType: alert.TypeCustom, expected: alertingConfig.Custom},
{alertType: alert.TypeDiscord, expected: alertingConfig.Discord},
{alertType: alert.TypeEmail, expected: alertingConfig.Email},
{alertType: alert.TypeGitHub, expected: alertingConfig.GitHub},
{alertType: alert.TypeGoogleChat, expected: alertingConfig.GoogleChat},
{alertType: alert.TypeMatrix, expected: alertingConfig.Matrix},
{alertType: alert.TypeMattermost, expected: alertingConfig.Mattermost},
{alertType: alert.TypeMessagebird, expected: alertingConfig.Messagebird},
{alertType: alert.TypeNtfy, expected: alertingConfig.Ntfy},
{alertType: alert.TypeOpsgenie, expected: alertingConfig.Opsgenie},
{alertType: alert.TypePagerDuty, expected: alertingConfig.PagerDuty},
{alertType: alert.TypePushover, expected: alertingConfig.Pushover},
{alertType: alert.TypeSlack, expected: alertingConfig.Slack},
{alertType: alert.TypeTelegram, expected: alertingConfig.Telegram},
{alertType: alert.TypeTwilio, expected: alertingConfig.Twilio},
{alertType: alert.TypeTeams, expected: alertingConfig.Teams},
}
if alertingConfig.GetAlertingProviderByAlertType(alert.TypeDiscord) != alertingConfig.Discord {
t.Error("expected Discord configuration")
}
if alertingConfig.GetAlertingProviderByAlertType(alert.TypeMattermost) != alertingConfig.Mattermost {
t.Error("expected Mattermost configuration")
}
if alertingConfig.GetAlertingProviderByAlertType(alert.TypeMessagebird) != alertingConfig.Messagebird {
t.Error("expected Messagebird configuration")
}
if alertingConfig.GetAlertingProviderByAlertType(alert.TypePagerDuty) != alertingConfig.PagerDuty {
t.Error("expected PagerDuty configuration")
}
if alertingConfig.GetAlertingProviderByAlertType(alert.TypeSlack) != alertingConfig.Slack {
t.Error("expected Slack configuration")
}
if alertingConfig.GetAlertingProviderByAlertType(alert.TypeTelegram) != alertingConfig.Telegram {
t.Error("expected Telegram configuration")
}
if alertingConfig.GetAlertingProviderByAlertType(alert.TypeTwilio) != alertingConfig.Twilio {
t.Error("expected Twilio configuration")
}
if alertingConfig.GetAlertingProviderByAlertType(alert.TypeTeams) != alertingConfig.Teams {
t.Error("expected Teams configuration")
}
}
// XXX: Remove this in v5.0.0
func TestParseAndValidateConfigBytes_backwardCompatibleWithServices(t *testing.T) {
config, err := parseAndValidateConfigBytes([]byte(`
services:
- name: website
url: https://twin.sh/actuator/health
conditions:
- "[STATUS] == 200"
`))
if err != nil {
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
}
if config.Endpoints[0].URL != "https://twin.sh/actuator/health" {
t.Errorf("URL should have been %s", "https://twin.sh/actuator/health")
}
if config.Endpoints[0].Interval != 60*time.Second {
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
}
}
// XXX: Remove this in v5.0.0
func TestParseAndValidateConfigBytes_backwardCompatibleMergeServicesInEndpoints(t *testing.T) {
config, err := parseAndValidateConfigBytes([]byte(`
services:
- name: website1
url: https://twin.sh/actuator/health
conditions:
- "[STATUS] == 200"
endpoints:
- name: website2
url: https://twin.sh/actuator/health
conditions:
- "[STATUS] == 200"
`))
if err != nil {
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
}
if len(config.Endpoints) != 2 {
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)
for _, scenario := range scenarios {
t.Run(string(scenario.alertType), func(t *testing.T) {
if alertingConfig.GetAlertingProviderByAlertType(scenario.alertType) != scenario.expected {
t.Errorf("expected %s configuration", scenario.alertType)
}
})
}
}
+35
View File
@@ -98,6 +98,15 @@ func TestConfig_ValidateAndSetDefaults(t *testing.T) {
},
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",
cfg: &Config{
@@ -168,6 +177,24 @@ func TestConfig_IsUnderMaintenance(t *testing.T) {
},
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",
cfg: &Config{
@@ -176,6 +203,14 @@ func TestConfig_IsUnderMaintenance(t *testing.T) {
},
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",
cfg: &Config{
+39
View File
@@ -0,0 +1,39 @@
package remote
import (
"log"
"github.com/TwiN/gatus/v5/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
}
+22 -18
View File
@@ -4,30 +4,30 @@ import (
"bytes"
"errors"
"html/template"
static "github.com/TwiN/gatus/v5/web"
)
const (
defaultTitle = "Health Dashboard | Gatus"
defaultHeader = "Health Status"
defaultLogo = ""
defaultLink = ""
defaultTitle = "Health Dashboard | Gatus"
defaultDescription = "Gatus is an advanced automated status page that lets you monitor your applications and configure alerts to notify you if there's an issue"
defaultHeader = "Health Status"
defaultLogo = ""
defaultLink = ""
)
var (
// StaticFolder is the path to the location of the static folder from the root path of the project
// The only reason this is exposed is to allow running tests from a different path than the root path of the project
StaticFolder = "./web/static"
ErrButtonValidationFailed = errors.New("invalid button configuration: missing required name or link")
)
// Config is the configuration for the UI of Gatus
type Config struct {
Title string `yaml:"title,omitempty"` // Title of 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
Title string `yaml:"title,omitempty"` // Title of the page
Description string `yaml:"description,omitempty"` // Meta description of 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
@@ -47,10 +47,11 @@ func (btn *Button) Validate() error {
// GetDefaultConfig returns a Config struct with the default values
func GetDefaultConfig() *Config {
return &Config{
Title: defaultTitle,
Header: defaultHeader,
Logo: defaultLogo,
Link: defaultLink,
Title: defaultTitle,
Description: defaultDescription,
Header: defaultHeader,
Logo: defaultLogo,
Link: defaultLink,
}
}
@@ -59,6 +60,9 @@ func (cfg *Config) ValidateAndSetDefaults() error {
if len(cfg.Title) == 0 {
cfg.Title = defaultTitle
}
if len(cfg.Description) == 0 {
cfg.Description = defaultDescription
}
if len(cfg.Header) == 0 {
cfg.Header = defaultHeader
}
@@ -71,7 +75,7 @@ func (cfg *Config) ValidateAndSetDefaults() error {
}
}
// Validate that the template works
t, err := template.ParseFiles(StaticFolder + "/index.html")
t, err := template.ParseFS(static.FileSystem, static.IndexPath)
if err != nil {
return err
}
+8 -8
View File
@@ -6,15 +6,12 @@ import (
)
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
StaticFolder = "../../web/static"
defer func() {
StaticFolder = "./web/static"
}()
cfg := &Config{
Title: "",
Header: "",
Logo: "",
Link: "",
Title: "",
Description: "",
Header: "",
Logo: "",
Link: "",
}
if err := cfg.ValidateAndSetDefaults(); err != nil {
t.Error("expected no error, got", err.Error())
@@ -22,6 +19,9 @@ func TestConfig_ValidateAndSetDefaults(t *testing.T) {
if cfg.Title != defaultTitle {
t.Errorf("expected title to be %s, got %s", defaultTitle, cfg.Title)
}
if cfg.Description != defaultDescription {
t.Errorf("expected description to be %s, got %s", defaultDescription, cfg.Description)
}
if cfg.Header != defaultHeader {
t.Errorf("expected header to be %s, got %s", defaultHeader, cfg.Header)
}
+6 -8
View File
@@ -8,10 +8,8 @@ import (
"os"
"time"
"github.com/TwiN/gatus/v3/config/ui"
"github.com/TwiN/gatus/v3/config/web"
"github.com/TwiN/gatus/v3/controller/handler"
"github.com/TwiN/gatus/v3/security"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/controller/handler"
)
var (
@@ -21,19 +19,19 @@ var (
)
// Handle creates the router and starts the server
func Handle(securityConfig *security.Config, webConfig *web.Config, uiConfig *ui.Config, enableMetrics bool) {
var router http.Handler = handler.CreateRouter(ui.StaticFolder, securityConfig, uiConfig, enableMetrics)
func Handle(cfg *config.Config) {
var router http.Handler = handler.CreateRouter(cfg)
if os.Getenv("ENVIRONMENT") == "dev" {
router = handler.DevelopmentCORS(router)
}
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,
ReadTimeout: 15 * time.Second,
WriteTimeout: 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" {
return
}
+4 -4
View File
@@ -7,9 +7,9 @@ import (
"os"
"testing"
"github.com/TwiN/gatus/v3/config"
"github.com/TwiN/gatus/v3/config/web"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/web"
"github.com/TwiN/gatus/v5/core"
)
func TestHandle(t *testing.T) {
@@ -32,7 +32,7 @@ func TestHandle(t *testing.T) {
_ = os.Setenv("ROUTER_TEST", "true")
_ = os.Setenv("ENVIRONMENT", "dev")
defer os.Clearenv()
Handle(cfg.Security, cfg.Web, cfg.UI, cfg.Metrics)
Handle(cfg)
defer Shutdown()
request, _ := http.NewRequest("GET", "/health", http.NoBody)
responseRecorder := httptest.NewRecorder()
+130 -31
View File
@@ -7,8 +7,10 @@ import (
"strings"
"time"
"github.com/TwiN/gatus/v3/storage/store"
"github.com/TwiN/gatus/v3/storage/store/common"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/storage/store/common"
"github.com/TwiN/gatus/v5/storage/store/common/paging"
"github.com/gorilla/mux"
)
@@ -21,6 +23,16 @@ const (
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.
//
// Valid values for {duration}: 7d, 24h, 1h
@@ -61,23 +73,48 @@ func UptimeBadge(writer http.ResponseWriter, request *http.Request) {
// ResponseTimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.
//
// Valid values for {duration}: 7d, 24h, 1h
func ResponseTimeBadge(writer http.ResponseWriter, request *http.Request) {
variables := mux.Vars(request)
duration := variables["duration"]
var from time.Time
switch duration {
case "7d":
from = time.Now().Add(-7 * 24 * time.Hour)
case "24h":
from = time.Now().Add(-24 * time.Hour)
case "1h":
from = time.Now().Add(-2 * time.Hour) // Because response time metrics are stored by hour, we have to cheat a little
default:
http.Error(writer, "Durations supported: 7d, 24h, 1h", http.StatusBadRequest)
return
func ResponseTimeBadge(config *config.Config) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
variables := mux.Vars(request)
duration := variables["duration"]
var from time.Time
switch duration {
case "7d":
from = time.Now().Add(-7 * 24 * time.Hour)
case "24h":
from = time.Now().Add(-24 * time.Hour)
case "1h":
from = time.Now().Add(-2 * time.Hour) // Because response time metrics are stored by hour, we have to cheat a little
default:
http.Error(writer, "Durations supported: 7d, 24h, 1h", http.StatusBadRequest)
return
}
key := variables["key"]
averageResponseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now())
if err != nil {
if err == common.ErrEndpointNotFound {
http.Error(writer, err.Error(), http.StatusNotFound)
} else if err == common.ErrInvalidTimeRange {
http.Error(writer, err.Error(), http.StatusBadRequest)
} else {
http.Error(writer, err.Error(), http.StatusInternalServerError)
}
return
}
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(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"]
averageResponseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now())
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)
@@ -88,11 +125,19 @@ func ResponseTimeBadge(writer http.ResponseWriter, request *http.Request) {
}
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(generateResponseTimeBadgeSVG(duration, averageResponseTime))
_, _ = writer.Write(generateHealthBadgeSVG(healthStatus))
}
func generateUptimeBadgeSVG(duration string, uptime float64) []byte {
@@ -161,7 +206,7 @@ func getBadgeColorFromUptime(uptime float64) string {
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
switch duration {
case "7d":
@@ -172,7 +217,7 @@ func generateResponseTimeBadgeSVG(duration string, averageResponseTime int) []by
labelWidth = 105
default:
}
color := getBadgeColorFromResponseTime(averageResponseTime)
color := getBadgeColorFromResponseTime(averageResponseTime, key, cfg)
sanitizedValue := strconv.Itoa(averageResponseTime) + "ms"
valueWidth = len(sanitizedValue) * 11
width := labelWidth + valueWidth
@@ -209,17 +254,71 @@ func generateResponseTimeBadgeSVG(duration string, averageResponseTime int) []by
return svg
}
func getBadgeColorFromResponseTime(responseTime int) string {
if responseTime <= 50 {
return badgeColorHexAwesome
} else if responseTime <= 200 {
return badgeColorHexGreat
} else if responseTime <= 300 {
return badgeColorHexGood
} else if responseTime <= 500 {
return badgeColorHexPassable
} else if responseTime <= 750 {
return badgeColorHexBad
func getBadgeColorFromResponseTime(responseTime int, key string, cfg *config.Config) string {
endpoint := cfg.GetEndpointByKey(key)
// the threshold config requires 5 values, so we can be sure it's set here
for i := 0; i < 5; i++ {
if responseTime <= endpoint.UIConfig.Badge.ResponseTime.Thresholds[i] {
return badgeColors[i]
}
}
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"
"time"
"github.com/TwiN/gatus/v3/config"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/storage/store"
"github.com/TwiN/gatus/v3/watchdog"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/core/ui"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/watchdog"
)
func TestUptimeBadge(t *testing.T) {
func TestBadge(t *testing.T) {
defer store.Get().Clear()
defer cache.Clear()
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()})
router := CreateRouter("../../web/static", cfg.Security, nil, cfg.Metrics)
testSuccessfulResult = core.Result{
Hostname: "example.org",
IP: "127.0.0.1",
HTTPStatus: 200,
Errors: nil,
Connected: true,
Success: true,
Timestamp: timestamp,
Duration: 150 * time.Millisecond,
CertificateExpiration: 10 * time.Hour,
ConditionResults: []*core.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: true,
},
{
Condition: "[RESPONSE_TIME] < 500",
Success: true,
},
{
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
Success: true,
},
},
}
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 {
Name string
Path string
@@ -89,20 +120,35 @@ func TestUptimeBadge(t *testing.T) {
Path: "/api/v1/endpoints/invalid_key/response-times/7d/badge.svg",
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",
Path: "/api/v1/endpoints/core_backend/response-times/24h/chart.svg",
ExpectedCode: http.StatusOK,
},
{ // XXX: Remove this in v4.0.0
Name: "backward-compatible-services-badge-uptime-1h",
Path: "/api/v1/services/core_frontend/uptimes/1h/badge.svg",
{
Name: "chart-response-time-7d",
Path: "/api/v1/endpoints/core_frontend/response-times/7d/chart.svg",
ExpectedCode: http.StatusOK,
},
{ // XXX: Remove this in v4.0.0
Name: "backward-compatible-services-chart-response-time-24h",
Path: "/api/v1/services/core_backend/response-times/24h/chart.svg",
ExpectedCode: http.StatusOK,
{
Name: "chart-response-time-with-invalid-duration",
Path: "/api/v1/endpoints/core_backend/response-times/3d/chart.svg",
ExpectedCode: http.StatusBadRequest,
},
}
for _, scenario := range scenarios {
@@ -176,55 +222,155 @@ func TestGetBadgeColorFromUptime(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 {
Key string
ResponseTime int
ExpectedColor string
}{
{
Key: firstTestEndpoint.Key(),
ResponseTime: 10,
ExpectedColor: badgeColorHexAwesome,
},
{
Key: firstTestEndpoint.Key(),
ResponseTime: 50,
ExpectedColor: badgeColorHexAwesome,
},
{
Key: firstTestEndpoint.Key(),
ResponseTime: 75,
ExpectedColor: badgeColorHexGreat,
},
{
Key: firstTestEndpoint.Key(),
ResponseTime: 150,
ExpectedColor: badgeColorHexGreat,
},
{
Key: firstTestEndpoint.Key(),
ResponseTime: 201,
ExpectedColor: badgeColorHexGood,
},
{
Key: firstTestEndpoint.Key(),
ResponseTime: 300,
ExpectedColor: badgeColorHexGood,
},
{
Key: firstTestEndpoint.Key(),
ResponseTime: 301,
ExpectedColor: badgeColorHexPassable,
},
{
Key: firstTestEndpoint.Key(),
ResponseTime: 450,
ExpectedColor: badgeColorHexPassable,
},
{
Key: firstTestEndpoint.Key(),
ResponseTime: 700,
ExpectedColor: badgeColorHexBad,
},
{
Key: firstTestEndpoint.Key(),
ResponseTime: 1500,
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 {
t.Run("response-time-"+strconv.Itoa(scenario.ResponseTime), func(t *testing.T) {
if getBadgeColorFromResponseTime(scenario.ResponseTime) != scenario.ExpectedColor {
t.Errorf("expected %s from %d, got %v", scenario.ExpectedColor, scenario.ResponseTime, getBadgeColorFromResponseTime(scenario.ResponseTime))
t.Run(scenario.Key+"-response-time-"+strconv.Itoa(scenario.ResponseTime), func(t *testing.T) {
if getBadgeColorFromResponseTime(scenario.ResponseTime, scenario.Key, cfg) != scenario.ExpectedColor {
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))
}
})
}
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"sort"
"time"
"github.com/TwiN/gatus/v3/storage/store"
"github.com/TwiN/gatus/v3/storage/store/common"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/storage/store/common"
"github.com/gorilla/mux"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
+5 -10
View File
@@ -6,10 +6,10 @@ import (
"testing"
"time"
"github.com/TwiN/gatus/v3/config"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/storage/store"
"github.com/TwiN/gatus/v3/watchdog"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/watchdog"
)
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[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 {
Name string
Path string
@@ -58,11 +58,6 @@ func TestResponseTimeChart(t *testing.T) {
Path: "/api/v1/endpoints/invalid_key/response-times/7d/chart.svg",
ExpectedCode: http.StatusNotFound,
},
{ // XXX: Remove this in v4.0.0
Name: "backward-compatible-services-chart-response-time-24h",
Path: "/api/v1/services/core_backend/response-times/24h/chart.svg",
ExpectedCode: http.StatusOK,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"fmt"
"net/http"
"github.com/TwiN/gatus/v3/security"
"github.com/TwiN/gatus/v5/security"
)
// ConfigHandler is a handler that returns information for the front end of the application.
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"net/http/httptest"
"testing"
"github.com/TwiN/gatus/v3/security"
"github.com/TwiN/gatus/v5/security"
"github.com/gorilla/mux"
)
+74 -49
View File
@@ -1,19 +1,21 @@
package handler
import (
"bytes"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"github.com/TwiN/gatus/v3/storage/store"
"github.com/TwiN/gatus/v3/storage/store/common"
"github.com/TwiN/gatus/v3/storage/store/common/paging"
"github.com/TwiN/gocache"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/remote"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/storage/store/common"
"github.com/TwiN/gatus/v5/storage/store/common/paging"
"github.com/TwiN/gocache/v2"
"github.com/gorilla/mux"
)
@@ -26,50 +28,73 @@ var (
)
// EndpointStatuses handles requests to retrieve all EndpointStatus
// Due to the size of the response, this function leverages a cache.
// Must not be wrapped by GzipHandler
func EndpointStatuses(writer http.ResponseWriter, r *http.Request) {
page, pageSize := extractPageAndPageSizeFromRequest(r)
gzipped := strings.Contains(r.Header.Get("Accept-Encoding"), "gzip")
var exists bool
var value interface{}
if gzipped {
writer.Header().Set("Content-Encoding", "gzip")
value, exists = cache.Get(fmt.Sprintf("endpoint-status-%d-%d-gzipped", page, pageSize))
} else {
value, exists = cache.Get(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize))
// Due to how intensive this operation can be on the storage, this function leverages a cache.
func EndpointStatuses(cfg *config.Config) http.HandlerFunc {
return func(writer http.ResponseWriter, r *http.Request) {
page, pageSize := extractPageAndPageSizeFromRequest(r)
value, exists := cache.Get(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize))
var data []byte
if !exists {
var err error
endpointStatuses, err := store.Get().GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(page, pageSize))
if err != nil {
log.Printf("[handler][EndpointStatuses] Failed to retrieve endpoint statuses: %s", err.Error())
http.Error(writer, err.Error(), http.StatusInternalServerError)
return
}
// 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)
if err != nil {
log.Printf("[handler][EndpointStatuses] Unable to marshal object to JSON: %s", err.Error())
http.Error(writer, "unable to marshal object to JSON", http.StatusInternalServerError)
return
}
cache.SetWithTTL(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize), data, cacheTTL)
} else {
data = value.([]byte)
}
writer.Header().Add("Content-Type", "application/json")
writer.WriteHeader(http.StatusOK)
_, _ = writer.Write(data)
}
var data []byte
if !exists {
var err error
buffer := &bytes.Buffer{}
gzipWriter := gzip.NewWriter(buffer)
endpointStatuses, err := store.Get().GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(page, pageSize))
if err != nil {
log.Printf("[handler][EndpointStatuses] Failed to retrieve endpoint statuses: %s", err.Error())
http.Error(writer, err.Error(), http.StatusInternalServerError)
return
}
data, err = json.Marshal(endpointStatuses)
if err != nil {
log.Printf("[handler][EndpointStatuses] Unable to marshal object to JSON: %s", err.Error())
http.Error(writer, "unable to marshal object to JSON", http.StatusInternalServerError)
return
}
_, _ = gzipWriter.Write(data)
_ = gzipWriter.Close()
gzippedData := buffer.Bytes()
cache.SetWithTTL(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize), data, cacheTTL)
cache.SetWithTTL(fmt.Sprintf("endpoint-status-%d-%d-gzipped", page, pageSize), gzippedData, cacheTTL)
if gzipped {
data = gzippedData
}
} else {
data = value.([]byte)
}
func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*core.EndpointStatus, error) {
if remoteConfig == nil || len(remoteConfig.Instances) == 0 {
return nil, nil
}
writer.Header().Add("Content-Type", "application/json")
writer.WriteHeader(http.StatusOK)
_, _ = writer.Write(data)
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
+7 -22
View File
@@ -6,17 +6,13 @@ import (
"testing"
"time"
"github.com/TwiN/gatus/v3/config"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/storage/store"
"github.com/TwiN/gatus/v3/watchdog"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/watchdog"
)
var (
firstCondition = core.Condition("[STATUS] == 200")
secondCondition = core.Condition("[RESPONSE_TIME] < 500")
thirdCondition = core.Condition("[CERTIFICATE_EXPIRATION] < 72h")
timestamp = time.Now()
testEndpoint = core.Endpoint{
@@ -26,7 +22,7 @@ var (
Method: "GET",
Body: "body",
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,
NumberOfFailuresInARow: 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[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 {
Name string
@@ -131,11 +127,6 @@ func TestEndpointStatus(t *testing.T) {
Path: "/api/v1/endpoints/invalid_key/statuses",
ExpectedCode: http.StatusNotFound,
},
{ // XXX: Remove this in v4.0.0
Name: "backward-compatible-service-status",
Path: "/api/v1/services/core_frontend/statuses",
ExpectedCode: http.StatusOK,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
@@ -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
firstResult.Timestamp = time.Time{}
secondResult.Timestamp = time.Time{}
router := CreateRouter("../../web/static", nil, nil, false)
router := CreateRouter(&config.Config{Metrics: true})
type Scenario struct {
Name string
@@ -201,12 +192,6 @@ func TestEndpointStatuses(t *testing.T) {
ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}]}]`,
},
{ // XXX: Remove this in v4.0.0
Name: "backward-compatible-service-status",
Path: "/api/v1/services/statuses",
ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}]}]`,
},
}
for _, scenario := range scenarios {
-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)
}
})
}
}
+25 -24
View File
@@ -1,53 +1,54 @@
package handler
import (
"io/fs"
"net/http"
"github.com/TwiN/gatus/v3/config/ui"
"github.com/TwiN/gatus/v3/security"
"github.com/TwiN/gatus/v5/config"
static "github.com/TwiN/gatus/v5/web"
"github.com/TwiN/health"
"github.com/gorilla/mux"
"github.com/prometheus/client_golang/prometheus"
"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()
if enabledMetrics {
router.Handle("/metrics", promhttp.Handler()).Methods("GET")
if cfg.Metrics {
router.Handle("/metrics", promhttp.InstrumentMetricHandler(prometheus.DefaultRegisterer, promhttp.HandlerFor(prometheus.DefaultGatherer, promhttp.HandlerOpts{
DisableCompression: true,
}))).Methods("GET")
}
router.Use(GzipHandler)
api := router.PathPrefix("/api").Subrouter()
protected := api.PathPrefix("/").Subrouter()
unprotected := api.PathPrefix("/").Subrouter()
if securityConfig != nil {
if err := securityConfig.RegisterHandlers(router); err != nil {
if cfg.Security != nil {
if err := cfg.Security.RegisterHandlers(router); err != nil {
panic(err)
}
if err := securityConfig.ApplySecurityMiddleware(protected); err != nil {
if err := cfg.Security.ApplySecurityMiddleware(protected); err != nil {
panic(err)
}
}
// Endpoints
unprotected.Handle("/v1/config", ConfigHandler{securityConfig: securityConfig}).Methods("GET")
protected.HandleFunc("/v1/endpoints/statuses", EndpointStatuses).Methods("GET") // No GzipHandler for this one, because we cache the content as Gzipped already
protected.HandleFunc("/v1/endpoints/{key}/statuses", GzipHandlerFunc(EndpointStatus)).Methods("GET")
unprotected.Handle("/v1/config", ConfigHandler{securityConfig: cfg.Security}).Methods("GET")
protected.HandleFunc("/v1/endpoints/statuses", EndpointStatuses(cfg)).Methods("GET")
protected.HandleFunc("/v1/endpoints/{key}/statuses", EndpointStatus).Methods("GET")
unprotected.HandleFunc("/v1/endpoints/{key}/health/badge.svg", HealthBadge).Methods("GET")
unprotected.HandleFunc("/v1/endpoints/{key}/uptimes/{duration}/badge.svg", UptimeBadge).Methods("GET")
unprotected.HandleFunc("/v1/endpoints/{key}/response-times/{duration}/badge.svg", ResponseTimeBadge).Methods("GET")
unprotected.HandleFunc("/v1/endpoints/{key}/response-times/{duration}/badge.svg", ResponseTimeBadge(cfg)).Methods("GET")
unprotected.HandleFunc("/v1/endpoints/{key}/response-times/{duration}/chart.svg", ResponseTimeChart).Methods("GET")
// XXX: Remove the lines between this and the next XXX comment in v4.0.0
protected.HandleFunc("/v1/services/statuses", EndpointStatuses).Methods("GET") // No GzipHandler for this one, because we cache the content as Gzipped already
protected.HandleFunc("/v1/services/{key}/statuses", GzipHandlerFunc(EndpointStatus)).Methods("GET")
unprotected.HandleFunc("/v1/services/{key}/uptimes/{duration}/badge.svg", UptimeBadge).Methods("GET")
unprotected.HandleFunc("/v1/services/{key}/response-times/{duration}/badge.svg", ResponseTimeBadge).Methods("GET")
unprotected.HandleFunc("/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
// Misc
router.Handle("/health", health.Handler().WithJSON(true)).Methods("GET")
router.HandleFunc("/favicon.ico", FavIcon(staticFolder)).Methods("GET")
// SPA
router.HandleFunc("/services/{name}", SinglePageApplication(staticFolder, uiConfig)).Methods("GET") // XXX: Remove this in v4.0.0
router.HandleFunc("/endpoints/{name}", SinglePageApplication(staticFolder, uiConfig)).Methods("GET")
router.HandleFunc("/", SinglePageApplication(staticFolder, uiConfig)).Methods("GET")
router.HandleFunc("/endpoints/{name}", SinglePageApplication(cfg.UI)).Methods("GET")
router.HandleFunc("/", SinglePageApplication(cfg.UI)).Methods("GET")
// 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(http.FileServer(http.FS(staticFileSystem)))
return router
}
+21 -3
View File
@@ -4,10 +4,12 @@ import (
"net/http"
"net/http/httptest"
"testing"
"github.com/TwiN/gatus/v5/config"
)
func TestCreateRouter(t *testing.T) {
router := CreateRouter("../../web/static", nil, nil, true)
router := CreateRouter(&config.Config{Metrics: true})
type Scenario struct {
Name string
Path string
@@ -26,16 +28,32 @@ func TestCreateRouter(t *testing.T) {
ExpectedCode: http.StatusOK,
},
{
Name: "scripts",
Name: "favicon.ico",
Path: "/favicon.ico",
ExpectedCode: http.StatusOK,
},
{
Name: "app.js",
Path: "/js/app.js",
ExpectedCode: http.StatusOK,
},
{
Name: "scripts-gzipped",
Name: "app.js-gzipped",
Path: "/js/app.js",
ExpectedCode: http.StatusOK,
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",
Path: "/index.html",
+11 -7
View File
@@ -1,26 +1,30 @@
package handler
import (
_ "embed"
"html/template"
"log"
"net/http"
"github.com/TwiN/gatus/v3/config/ui"
"github.com/TwiN/gatus/v5/config/ui"
static "github.com/TwiN/gatus/v5/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) {
t, err := template.ParseFiles(staticFolder + "/index.html")
t, err := template.ParseFS(static.FileSystem, static.IndexPath)
if err != nil {
log.Println("[handler][SinglePageApplication] Failed to parse template:", err.Error())
http.ServeFile(writer, request, staticFolder+"/index.html")
// This should never happen, because ui.ValidateAndSetDefaults validates that the template works.
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
}
writer.Header().Set("Content-Type", "text/html")
err = t.Execute(writer, ui)
if err != nil {
log.Println("[handler][SinglePageApplication] Failed to parse template:", err.Error())
http.ServeFile(writer, request, staticFolder+"/index.html")
// This should never happen, because ui.ValidateAndSetDefaults validates that the template works.
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
}
}
+5 -10
View File
@@ -6,10 +6,10 @@ import (
"testing"
"time"
"github.com/TwiN/gatus/v3/config"
"github.com/TwiN/gatus/v3/core"
"github.com/TwiN/gatus/v3/storage/store"
"github.com/TwiN/gatus/v3/watchdog"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/watchdog"
)
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[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 {
Name string
Path string
@@ -48,11 +48,6 @@ func TestSinglePageApplication(t *testing.T) {
Path: "/endpoints/core_frontend",
ExpectedCode: http.StatusOK,
},
{ // XXX: Remove this in v4.0.0
Name: "frontend-service",
Path: "/services/core_frontend",
ExpectedCode: http.StatusOK,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"net/http"
"strconv"
"github.com/TwiN/gatus/v3/storage/store/common"
"github.com/TwiN/gatus/v5/storage/store/common"
)
const (
+52 -18
View File
@@ -1,15 +1,17 @@
package core
import (
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/TwiN/gatus/v3/jsonpath"
"github.com/TwiN/gatus/v3/pattern"
"github.com/TwiN/gatus/v5/jsonpath"
"github.com/TwiN/gatus/v5/pattern"
)
// Placeholders
const (
// StatusPlaceholder is a placeholder for a HTTP status.
//
@@ -21,7 +23,7 @@ const (
// Values that could replace the placeholder: 127.0.0.1, 10.0.0.1, ...
IPPlaceholder = "[IP]"
// DNSRCodePlaceholder is a place holder for DNS_RCODE
// DNSRCodePlaceholder is a placeholder for DNS_RCODE
//
// Values that could replace the placeholder: NOERROR, FORMERR, SERVFAIL, NXDOMAIN, NOTIMP, REFUSED
DNSRCodePlaceholder = "[DNS_RCODE]"
@@ -46,6 +48,12 @@ const (
// Values that could replace the placeholder: 4461677039 (~52 days)
CertificateExpirationPlaceholder = "[CERTIFICATE_EXPIRATION]"
// DomainExpirationPlaceholder is a placeholder for the duration before the domain expires, in milliseconds.
DomainExpirationPlaceholder = "[DOMAIN_EXPIRATION]"
)
// Functions
const (
// LengthFunctionPrefix is the prefix for the length function
//
// Usage: len([BODY].articles) == 10, len([BODY].name) > 5
@@ -68,7 +76,10 @@ const (
// FunctionSuffix is the suffix for all functions
FunctionSuffix = ")"
)
// Other constants
const (
// InvalidConditionElementSuffix is the suffix that will be appended to an invalid condition
InvalidConditionElementSuffix = "(INVALID)"
@@ -83,50 +94,59 @@ const (
// Condition is a condition that needs to be met in order for an Endpoint to be considered healthy.
type Condition string
// Validate checks if the Condition is valid
func (c Condition) Validate() error {
r := &Result{}
c.evaluate(r, false)
if len(r.Errors) != 0 {
return errors.New(r.Errors[0])
}
return nil
}
// evaluate the Condition with the Result of the health check
// TODO: Add a mandatory space between each operators (e.g. " == " instead of "==") (BREAKING CHANGE)
func (c Condition) evaluate(result *Result, dontResolveFailedConditions bool) bool {
condition := string(c)
success := false
conditionToDisplay := condition
if strings.Contains(condition, "==") {
parameters, resolvedParameters := sanitizeAndResolve(strings.Split(condition, "=="), result)
if strings.Contains(condition, " == ") {
parameters, resolvedParameters := sanitizeAndResolve(strings.Split(condition, " == "), result)
success = isEqual(resolvedParameters[0], resolvedParameters[1])
if !success && !dontResolveFailedConditions {
conditionToDisplay = prettify(parameters, resolvedParameters, "==")
}
} else if strings.Contains(condition, "!=") {
parameters, resolvedParameters := sanitizeAndResolve(strings.Split(condition, "!="), result)
} else if strings.Contains(condition, " != ") {
parameters, resolvedParameters := sanitizeAndResolve(strings.Split(condition, " != "), result)
success = !isEqual(resolvedParameters[0], resolvedParameters[1])
if !success && !dontResolveFailedConditions {
conditionToDisplay = prettify(parameters, resolvedParameters, "!=")
}
} else if strings.Contains(condition, "<=") {
parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, "<="), result)
} else if strings.Contains(condition, " <= ") {
parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, " <= "), result)
success = resolvedParameters[0] <= resolvedParameters[1]
if !success && !dontResolveFailedConditions {
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, "<=")
}
} else if strings.Contains(condition, ">=") {
parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, ">="), result)
} else if strings.Contains(condition, " >= ") {
parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, " >= "), result)
success = resolvedParameters[0] >= resolvedParameters[1]
if !success && !dontResolveFailedConditions {
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, ">=")
}
} else if strings.Contains(condition, ">") {
parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, ">"), result)
} else if strings.Contains(condition, " > ") {
parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, " > "), result)
success = resolvedParameters[0] > resolvedParameters[1]
if !success && !dontResolveFailedConditions {
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, ">")
}
} else if strings.Contains(condition, "<") {
parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, "<"), result)
} else if strings.Contains(condition, " < ") {
parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, " < "), result)
success = resolvedParameters[0] < resolvedParameters[1]
if !success && !dontResolveFailedConditions {
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, "<")
}
} else {
result.AddError(fmt.Sprintf("invalid condition '%s' has been provided", condition))
result.AddError(fmt.Sprintf("invalid condition: %s", condition))
return false
}
if !success {
@@ -142,9 +162,21 @@ func (c Condition) hasBodyPlaceholder() bool {
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.
//
// 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
// a pattern.
func isEqual(first, second string) bool {
@@ -219,6 +251,8 @@ func sanitizeAndResolve(elements []string, result *Result) ([]string, []string)
element = strconv.FormatBool(result.Connected)
case CertificateExpirationPlaceholder:
element = strconv.FormatInt(result.CertificateExpiration.Milliseconds(), 10)
case DomainExpirationPlaceholder:
element = strconv.FormatInt(result.DomainExpiration.Milliseconds(), 10)
default:
// if contains the BodyPlaceholder, then evaluate json path
if strings.Contains(element, BodyPlaceholder) {
+323 -162
View File
@@ -1,11 +1,59 @@
package core
import (
"errors"
"fmt"
"strconv"
"testing"
"time"
)
func TestCondition_Validate(t *testing.T) {
scenarios := []struct {
condition Condition
expectedErr error
}{
{condition: "[STATUS] == 200", expectedErr: nil},
{condition: "[STATUS] != 200", expectedErr: nil},
{condition: "[STATUS] <= 200", expectedErr: nil},
{condition: "[STATUS] >= 200", expectedErr: nil},
{condition: "[STATUS] < 200", expectedErr: nil},
{condition: "[STATUS] > 200", expectedErr: nil},
{condition: "[STATUS] == any(200, 201, 202, 203)", expectedErr: nil},
{condition: "[STATUS] == [BODY].status", expectedErr: nil},
{condition: "[CONNECTED] == true", expectedErr: nil},
{condition: "[RESPONSE_TIME] < 500", expectedErr: nil},
{condition: "[IP] == 127.0.0.1", expectedErr: nil},
{condition: "[BODY] == 1", expectedErr: nil},
{condition: "[BODY].test == wat", expectedErr: nil},
{condition: "[BODY].test.wat == wat", expectedErr: nil},
{condition: "[BODY].age == [BODY].id", expectedErr: nil},
{condition: "[BODY].users[0].id == 1", expectedErr: nil},
{condition: "len([BODY].users) == 100", expectedErr: nil},
{condition: "len([BODY].data) < 5", expectedErr: nil},
{condition: "has([BODY].errors) == false", expectedErr: nil},
{condition: "has([BODY].users[0].name) == true", expectedErr: nil},
{condition: "[BODY].name == pat(john*)", expectedErr: nil},
{condition: "[CERTIFICATE_EXPIRATION] > 48h", expectedErr: nil},
{condition: "[DOMAIN_EXPIRATION] > 720h", expectedErr: nil},
{condition: "raw == raw", expectedErr: nil},
{condition: "[STATUS] ? 201", expectedErr: errors.New("invalid condition: [STATUS] ? 201")},
{condition: "[STATUS]==201", expectedErr: errors.New("invalid condition: [STATUS]==201")},
{condition: "[STATUS] = = 201", expectedErr: errors.New("invalid condition: [STATUS] = = 201")},
{condition: "[STATUS] ==", expectedErr: errors.New("invalid condition: [STATUS] ==")},
{condition: "[STATUS]", expectedErr: errors.New("invalid condition: [STATUS]")},
// FIXME: Should return an error, but doesn't because jsonpath isn't evaluated due to body being empty in Condition.Validate()
//{condition: "len([BODY].users == 100", expectedErr: nil},
}
for _, scenario := range scenarios {
t.Run(string(scenario.condition), func(t *testing.T) {
if err := scenario.condition.Validate(); fmt.Sprint(err) != fmt.Sprint(scenario.expectedErr) {
t.Errorf("expected err %v, got %v", scenario.expectedErr, err)
}
})
}
}
func TestCondition_evaluate(t *testing.T) {
scenarios := []struct {
Name string
@@ -134,6 +182,34 @@ func TestCondition_evaluate(t *testing.T) {
ExpectedSuccess: true,
ExpectedOutput: "[BODY] == test",
},
{
Name: "body-numerical-equal",
Condition: Condition("[BODY] == 123"),
Result: &Result{body: []byte("123")},
ExpectedSuccess: true,
ExpectedOutput: "[BODY] == 123",
},
{
Name: "body-numerical-less-than",
Condition: Condition("[BODY] < 124"),
Result: &Result{body: []byte("123")},
ExpectedSuccess: true,
ExpectedOutput: "[BODY] < 124",
},
{
Name: "body-numerical-greater-than",
Condition: Condition("[BODY] > 122"),
Result: &Result{body: []byte("123")},
ExpectedSuccess: true,
ExpectedOutput: "[BODY] > 122",
},
{
Name: "body-numerical-greater-than-failure",
Condition: Condition("[BODY] > 123"),
Result: &Result{body: []byte("100")},
ExpectedSuccess: false,
ExpectedOutput: "[BODY] (100) > 123",
},
{
Name: "body-jsonpath",
Condition: Condition("[BODY].status == UP"),
@@ -155,13 +231,6 @@ func TestCondition_evaluate(t *testing.T) {
ExpectedSuccess: false,
ExpectedOutput: "[BODY].data.name (INVALID) == john",
},
{
Name: "body-jsonpath-complex-len",
Condition: Condition("len([BODY].data.name) == 4"),
Result: &Result{body: []byte("{\"data\": {\"name\": \"john\"}}")},
ExpectedSuccess: true,
ExpectedOutput: "len([BODY].data.name) == 4",
},
{
Name: "body-jsonpath-complex-len-invalid",
Condition: Condition("len([BODY].data.name) == john"),
@@ -190,6 +259,13 @@ func TestCondition_evaluate(t *testing.T) {
ExpectedSuccess: true,
ExpectedOutput: "[BODY][0].id == 1",
},
{
Name: "body-jsonpath-when-body-is-array-but-actual-body-is-not",
Condition: Condition("[BODY][0].name == test"),
Result: &Result{body: []byte("{\"statusCode\": 500, \"message\": \"Internal Server Error\"}")},
ExpectedSuccess: false,
ExpectedOutput: "[BODY][0].name (INVALID) == test",
},
{
Name: "body-jsonpath-complex-int",
Condition: Condition("[BODY].data.id == 1"),
@@ -232,154 +308,6 @@ func TestCondition_evaluate(t *testing.T) {
ExpectedSuccess: false,
ExpectedOutput: "[BODY].data.id (10) < 5",
},
{
Name: "body-len-array",
Condition: Condition("len([BODY].data) == 3"),
Result: &Result{body: []byte("{\"data\": [{\"id\": 1}, {\"id\": 2}, {\"id\": 3}]}")},
ExpectedSuccess: true,
ExpectedOutput: "len([BODY].data) == 3",
},
{
Name: "body-len-array-invalid",
Condition: Condition("len([BODY].data) == 8"),
Result: &Result{body: []byte("{\"name\": \"john.doe\"}")},
ExpectedSuccess: false,
ExpectedOutput: "len([BODY].data) (INVALID) == 8",
},
{
Name: "body-len-string",
Condition: Condition("len([BODY].name) == 8"),
Result: &Result{body: []byte("{\"name\": \"john.doe\"}")},
ExpectedSuccess: true,
ExpectedOutput: "len([BODY].name) == 8",
},
{
Name: "body-pattern",
Condition: Condition("[BODY] == pat(*john*)"),
Result: &Result{body: []byte("{\"name\": \"john.doe\"}")},
ExpectedSuccess: true,
ExpectedOutput: "[BODY] == pat(*john*)",
},
{
Name: "body-pattern-2",
Condition: Condition("[BODY].name == pat(john*)"),
Result: &Result{body: []byte("{\"name\": \"john.doe\"}")},
ExpectedSuccess: true,
ExpectedOutput: "[BODY].name == pat(john*)",
},
{
Name: "body-pattern-failure",
Condition: Condition("[BODY].name == pat(bob*)"),
Result: &Result{body: []byte("{\"name\": \"john.doe\"}")},
ExpectedSuccess: false,
ExpectedOutput: "[BODY].name (john.doe) == pat(bob*)",
},
{
Name: "body-pattern-html",
Condition: Condition("[BODY] == pat(*<div id=\"user\">john.doe</div>*)"),
Result: &Result{body: []byte(`<!DOCTYPE html><html lang="en"><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /></head><body><div id="user">john.doe</div></body></html>`)},
ExpectedSuccess: true,
ExpectedOutput: "[BODY] == pat(*<div id=\"user\">john.doe</div>*)",
},
{
Name: "body-pattern-html-failure",
Condition: Condition("[BODY] == pat(*<div id=\"user\">john.doe</div>*)"),
Result: &Result{body: []byte(`<!DOCTYPE html><html lang="en"><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /></head><body><div id="user">jane.doe</div></body></html>`)},
ExpectedSuccess: false,
ExpectedOutput: "[BODY] (<!DOCTYPE html><html lang...(truncated)) == pat(*<div id=\"user\">john.doe</div>*)",
},
{
Name: "body-pattern-html-failure-alt",
Condition: Condition("pat(*<div id=\"user\">john.doe</div>*) == [BODY]"),
Result: &Result{body: []byte(`<!DOCTYPE html><html lang="en"><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /></head><body><div id="user">jane.doe</div></body></html>`)},
ExpectedSuccess: false,
ExpectedOutput: "pat(*<div id=\"user\">john.doe</div>*) == [BODY] (<!DOCTYPE html><html lang...(truncated))",
},
{
Name: "ip-pattern",
Condition: Condition("[IP] == pat(10.*)"),
Result: &Result{IP: "10.0.0.0"},
ExpectedSuccess: true,
ExpectedOutput: "[IP] == pat(10.*)",
},
{
Name: "ip-pattern-failure",
Condition: Condition("[IP] == pat(10.*)"),
Result: &Result{IP: "255.255.255.255"},
ExpectedSuccess: false,
ExpectedOutput: "[IP] (255.255.255.255) == pat(10.*)",
},
{
Name: "status-pattern",
Condition: Condition("[STATUS] == pat(4*)"),
Result: &Result{HTTPStatus: 404},
ExpectedSuccess: true,
ExpectedOutput: "[STATUS] == pat(4*)",
},
{
Name: "status-pattern-failure",
Condition: Condition("[STATUS] == pat(4*)"),
Result: &Result{HTTPStatus: 200},
ExpectedSuccess: false,
ExpectedOutput: "[STATUS] (200) == pat(4*)",
},
{
Name: "body-any",
Condition: Condition("[BODY].name == any(john.doe, jane.doe)"),
Result: &Result{body: []byte("{\"name\": \"john.doe\"}")},
ExpectedSuccess: true,
ExpectedOutput: "[BODY].name == any(john.doe, jane.doe)",
},
{
Name: "body-any-2",
Condition: Condition("[BODY].name == any(john.doe, jane.doe)"),
Result: &Result{body: []byte("{\"name\": \"jane.doe\"}")},
ExpectedSuccess: true,
ExpectedOutput: "[BODY].name == any(john.doe, jane.doe)",
},
{
Name: "body-any-failure",
Condition: Condition("[BODY].name == any(john.doe, jane.doe)"),
Result: &Result{body: []byte("{\"name\": \"bob\"}")},
ExpectedSuccess: false,
ExpectedOutput: "[BODY].name (bob) == any(john.doe, jane.doe)",
},
{
Name: "status-any",
Condition: Condition("[STATUS] == any(200, 429)"),
Result: &Result{HTTPStatus: 200},
ExpectedSuccess: true,
ExpectedOutput: "[STATUS] == any(200, 429)",
},
{
Name: "status-any-2",
Condition: Condition("[STATUS] == any(200, 429)"),
Result: &Result{HTTPStatus: 429},
ExpectedSuccess: true,
ExpectedOutput: "[STATUS] == any(200, 429)",
},
{
Name: "status-any-reverse",
Condition: Condition("any(200, 429) == [STATUS]"),
Result: &Result{HTTPStatus: 429},
ExpectedSuccess: true,
ExpectedOutput: "any(200, 429) == [STATUS]",
},
{
Name: "status-any-failure",
Condition: Condition("[STATUS] == any(200, 429)"),
Result: &Result{HTTPStatus: 404},
ExpectedSuccess: false,
ExpectedOutput: "[STATUS] (404) == any(200, 429)",
},
{
Name: "status-any-failure-but-dont-resolve",
Condition: Condition("[STATUS] == any(200, 429)"),
Result: &Result{HTTPStatus: 404},
DontResolveFailedConditions: true,
ExpectedSuccess: false,
ExpectedOutput: "[STATUS] == any(200, 429)",
},
{
Name: "connected",
Condition: Condition("[CONNECTED] == true"),
@@ -429,6 +357,238 @@ func TestCondition_evaluate(t *testing.T) {
ExpectedSuccess: false,
ExpectedOutput: "[CERTIFICATE_EXPIRATION] (86400000) > 48h (172800000)",
},
{
Name: "no-placeholders",
Condition: Condition("1 == 2"),
Result: &Result{},
ExpectedSuccess: false,
ExpectedOutput: "1 == 2",
},
///////////////
// Functions //
///////////////
// len
{
Name: "len-body-jsonpath-complex",
Condition: Condition("len([BODY].data.name) == 4"),
Result: &Result{body: []byte("{\"data\": {\"name\": \"john\"}}")},
ExpectedSuccess: true,
ExpectedOutput: "len([BODY].data.name) == 4",
},
{
Name: "len-body-array",
Condition: Condition("len([BODY]) == 3"),
Result: &Result{body: []byte("[{\"id\": 1}, {\"id\": 2}, {\"id\": 3}]")},
ExpectedSuccess: true,
ExpectedOutput: "len([BODY]) == 3",
},
{
Name: "len-body-keyed-array",
Condition: Condition("len([BODY].data) == 3"),
Result: &Result{body: []byte("{\"data\": [{\"id\": 1}, {\"id\": 2}, {\"id\": 3}]}")},
ExpectedSuccess: true,
ExpectedOutput: "len([BODY].data) == 3",
},
{
Name: "len-body-array-invalid",
Condition: Condition("len([BODY].data) == 8"),
Result: &Result{body: []byte("{\"name\": \"john.doe\"}")},
ExpectedSuccess: false,
ExpectedOutput: "len([BODY].data) (INVALID) == 8",
},
{
Name: "len-body-string",
Condition: Condition("len([BODY]) == 8"),
Result: &Result{body: []byte("john.doe")},
ExpectedSuccess: true,
ExpectedOutput: "len([BODY]) == 8",
},
{
Name: "len-body-keyed-string",
Condition: Condition("len([BODY].name) == 8"),
Result: &Result{body: []byte("{\"name\": \"john.doe\"}")},
ExpectedSuccess: true,
ExpectedOutput: "len([BODY].name) == 8",
},
{
Name: "len-body-keyed-int",
Condition: Condition("len([BODY].age) == 2"),
Result: &Result{body: []byte(`{"age":18}`)},
ExpectedSuccess: true,
ExpectedOutput: "len([BODY].age) == 2",
},
{
Name: "len-body-keyed-bool",
Condition: Condition("len([BODY].adult) == 4"),
Result: &Result{body: []byte(`{"adult":true}`)},
ExpectedSuccess: true,
ExpectedOutput: "len([BODY].adult) == 4",
},
{
Name: "len-body-object-inside-array",
Condition: Condition("len([BODY][0]) == 23"),
Result: &Result{body: []byte(`[{"age":18,"adult":true}]`)},
ExpectedSuccess: true,
ExpectedOutput: "len([BODY][0]) == 23",
},
{
Name: "len-body-object-keyed-int-inside-array",
Condition: Condition("len([BODY][0].age) == 2"),
Result: &Result{body: []byte(`[{"age":18,"adult":true}]`)},
ExpectedSuccess: true,
ExpectedOutput: "len([BODY][0].age) == 2",
},
{
Name: "len-body-keyed-bool-inside-array",
Condition: Condition("len([BODY][0].adult) == 4"),
Result: &Result{body: []byte(`[{"age":18,"adult":true}]`)},
ExpectedSuccess: true,
ExpectedOutput: "len([BODY][0].adult) == 4",
},
{
Name: "len-body-object",
Condition: Condition("len([BODY]) == 20"),
Result: &Result{body: []byte("{\"name\": \"john.doe\"}")},
ExpectedSuccess: true,
ExpectedOutput: "len([BODY]) == 20",
},
// pat
{
Name: "pat-body-1",
Condition: Condition("[BODY] == pat(*john*)"),
Result: &Result{body: []byte("{\"name\": \"john.doe\"}")},
ExpectedSuccess: true,
ExpectedOutput: "[BODY] == pat(*john*)",
},
{
Name: "pat-body-2",
Condition: Condition("[BODY].name == pat(john*)"),
Result: &Result{body: []byte("{\"name\": \"john.doe\"}")},
ExpectedSuccess: true,
ExpectedOutput: "[BODY].name == pat(john*)",
},
{
Name: "pat-body-failure",
Condition: Condition("[BODY].name == pat(bob*)"),
Result: &Result{body: []byte("{\"name\": \"john.doe\"}")},
ExpectedSuccess: false,
ExpectedOutput: "[BODY].name (john.doe) == pat(bob*)",
},
{
Name: "pat-body-html",
Condition: Condition("[BODY] == pat(*<div id=\"user\">john.doe</div>*)"),
Result: &Result{body: []byte(`<!DOCTYPE html><html lang="en"><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /></head><body><div id="user">john.doe</div></body></html>`)},
ExpectedSuccess: true,
ExpectedOutput: "[BODY] == pat(*<div id=\"user\">john.doe</div>*)",
},
{
Name: "pat-body-html-failure",
Condition: Condition("[BODY] == pat(*<div id=\"user\">john.doe</div>*)"),
Result: &Result{body: []byte(`<!DOCTYPE html><html lang="en"><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /></head><body><div id="user">jane.doe</div></body></html>`)},
ExpectedSuccess: false,
ExpectedOutput: "[BODY] (<!DOCTYPE html><html lang...(truncated)) == pat(*<div id=\"user\">john.doe</div>*)",
},
{
Name: "pat-body-html-failure-alt",
Condition: Condition("pat(*<div id=\"user\">john.doe</div>*) == [BODY]"),
Result: &Result{body: []byte(`<!DOCTYPE html><html lang="en"><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /></head><body><div id="user">jane.doe</div></body></html>`)},
ExpectedSuccess: false,
ExpectedOutput: "pat(*<div id=\"user\">john.doe</div>*) == [BODY] (<!DOCTYPE html><html lang...(truncated))",
},
{
Name: "pat-body-in-array",
Condition: Condition("[BODY].data == pat(*Whatever*)"),
Result: &Result{body: []byte("{\"data\": [\"hello\", \"world\", \"Whatever\"]}")},
ExpectedSuccess: true,
ExpectedOutput: "[BODY].data == pat(*Whatever*)",
},
{
Name: "pat-ip",
Condition: Condition("[IP] == pat(10.*)"),
Result: &Result{IP: "10.0.0.0"},
ExpectedSuccess: true,
ExpectedOutput: "[IP] == pat(10.*)",
},
{
Name: "pat-ip-failure",
Condition: Condition("[IP] == pat(10.*)"),
Result: &Result{IP: "255.255.255.255"},
ExpectedSuccess: false,
ExpectedOutput: "[IP] (255.255.255.255) == pat(10.*)",
},
{
Name: "pat-status",
Condition: Condition("[STATUS] == pat(4*)"),
Result: &Result{HTTPStatus: 404},
ExpectedSuccess: true,
ExpectedOutput: "[STATUS] == pat(4*)",
},
{
Name: "pat-status-failure",
Condition: Condition("[STATUS] == pat(4*)"),
Result: &Result{HTTPStatus: 200},
ExpectedSuccess: false,
ExpectedOutput: "[STATUS] (200) == pat(4*)",
},
// any
{
Name: "any-body-1",
Condition: Condition("[BODY].name == any(john.doe, jane.doe)"),
Result: &Result{body: []byte("{\"name\": \"john.doe\"}")},
ExpectedSuccess: true,
ExpectedOutput: "[BODY].name == any(john.doe, jane.doe)",
},
{
Name: "any-body-2",
Condition: Condition("[BODY].name == any(john.doe, jane.doe)"),
Result: &Result{body: []byte("{\"name\": \"jane.doe\"}")},
ExpectedSuccess: true,
ExpectedOutput: "[BODY].name == any(john.doe, jane.doe)",
},
{
Name: "any-body-failure",
Condition: Condition("[BODY].name == any(john.doe, jane.doe)"),
Result: &Result{body: []byte("{\"name\": \"bob\"}")},
ExpectedSuccess: false,
ExpectedOutput: "[BODY].name (bob) == any(john.doe, jane.doe)",
},
{
Name: "any-status-1",
Condition: Condition("[STATUS] == any(200, 429)"),
Result: &Result{HTTPStatus: 200},
ExpectedSuccess: true,
ExpectedOutput: "[STATUS] == any(200, 429)",
},
{
Name: "any-status-2",
Condition: Condition("[STATUS] == any(200, 429)"),
Result: &Result{HTTPStatus: 429},
ExpectedSuccess: true,
ExpectedOutput: "[STATUS] == any(200, 429)",
},
{
Name: "any-status-reverse",
Condition: Condition("any(200, 429) == [STATUS]"),
Result: &Result{HTTPStatus: 429},
ExpectedSuccess: true,
ExpectedOutput: "any(200, 429) == [STATUS]",
},
{
Name: "any-status-failure",
Condition: Condition("[STATUS] == any(200, 429)"),
Result: &Result{HTTPStatus: 404},
ExpectedSuccess: false,
ExpectedOutput: "[STATUS] (404) == any(200, 429)",
},
{
Name: "any-status-failure-but-dont-resolve",
Condition: Condition("[STATUS] == any(200, 429)"),
Result: &Result{HTTPStatus: 404},
DontResolveFailedConditions: true,
ExpectedSuccess: false,
ExpectedOutput: "[STATUS] == any(200, 429)",
},
// has
{
Name: "has",
Condition: Condition("has([BODY].errors) == false"),
@@ -436,6 +596,14 @@ func TestCondition_evaluate(t *testing.T) {
ExpectedSuccess: true,
ExpectedOutput: "has([BODY].errors) == false",
},
{
Name: "has-key-of-map",
Condition: Condition("has([BODY].article) == true"),
Result: &Result{body: []byte("{\n \"article\": {\n \"id\": 123,\n \"title\": \"Hello, world!\",\n \"author\": \"John Doe\",\n \"tags\": [\"hello\", \"world\"],\n \"content\": \"I really like Gatus!\"\n }\n}")},
DontResolveFailedConditions: false,
ExpectedSuccess: true,
ExpectedOutput: "has([BODY].article) == true",
},
{
Name: "has-failure",
Condition: Condition("has([BODY].errors) == false"),
@@ -451,13 +619,6 @@ func TestCondition_evaluate(t *testing.T) {
ExpectedSuccess: false,
ExpectedOutput: "has([BODY].errors) == false",
},
{
Name: "no-placeholders",
Condition: Condition("1 == 2"),
Result: &Result{},
ExpectedSuccess: false,
ExpectedOutput: "1 == 2",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
+7 -7
View File
@@ -2,8 +2,9 @@ package core
import (
"testing"
"time"
"github.com/TwiN/gatus/v3/pattern"
"github.com/TwiN/gatus/v5/pattern"
)
func TestIntegrationQuery(t *testing.T) {
@@ -39,11 +40,11 @@ func TestIntegrationQuery(t *testing.T) {
name: "test DNS with type CNAME",
inputDNS: DNS{
QueryType: "CNAME",
QueryName: "doc.google.com.",
QueryName: "en.wikipedia.org.",
},
inputURL: "8.8.8.8",
expectedDNSCode: "NOERROR",
expectedBody: "writely.l.google.com.",
expectedBody: "dyna.wikimedia.org.",
},
{
name: "test DNS with type MX",
@@ -69,7 +70,7 @@ func TestIntegrationQuery(t *testing.T) {
name: "test DNS with fake type and retrieve error",
inputDNS: DNS{
QueryType: "B",
QueryName: "google",
QueryName: "example",
},
inputURL: "8.8.8.8",
isErrExpected: true,
@@ -77,7 +78,6 @@ func TestIntegrationQuery(t *testing.T) {
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
dns := test.inputDNS
result := &Result{}
@@ -86,9 +86,8 @@ func TestIntegrationQuery(t *testing.T) {
t.Errorf("there should be errors")
}
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" {
// 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)) {
@@ -100,6 +99,7 @@ func TestIntegrationQuery(t *testing.T) {
}
}
})
time.Sleep(5 * time.Millisecond)
}
}
+137 -34
View File
@@ -5,6 +5,7 @@ import (
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
@@ -12,12 +13,14 @@ import (
"strings"
"time"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core/ui"
"github.com/TwiN/gatus/v3/util"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core/ui"
"github.com/TwiN/gatus/v5/util"
)
type EndpointType string
const (
// HostHeader is the name of the header used to specify the host
HostHeader = "Host"
@@ -30,6 +33,16 @@ const (
// GatusUserAgent is the default user agent that Gatus uses to send requests.
GatusUserAgent = "Gatus/1.0"
EndpointTypeDNS EndpointType = "DNS"
EndpointTypeTCP EndpointType = "TCP"
EndpointTypeSCTP EndpointType = "SCTP"
EndpointTypeUDP EndpointType = "UDP"
EndpointTypeICMP EndpointType = "ICMP"
EndpointTypeSTARTTLS EndpointType = "STARTTLS"
EndpointTypeTLS EndpointType = "TLS"
EndpointTypeHTTP EndpointType = "HTTP"
EndpointTypeUNKNOWN EndpointType = "UNKNOWN"
)
var (
@@ -44,6 +57,18 @@ var (
// 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")
// ErrInvalidConditionFormat is the error with which Gatus will panic if a condition has an invalid format
ErrInvalidConditionFormat = errors.New("invalid condition format: does not match '<VALUE> <COMPARATOR> <VALUE>'")
// 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
@@ -79,7 +104,7 @@ type Endpoint struct {
Interval time.Duration `yaml:"interval,omitempty"`
// 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 []*alert.Alert `yaml:"alerts,omitempty"`
@@ -105,7 +130,31 @@ func (endpoint Endpoint) IsEnabled() bool {
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, "sctp://"):
return EndpointTypeSCTP
case strings.HasPrefix(endpoint.URL, "udp://"):
return EndpointTypeUDP
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 {
// Set default values
if endpoint.ClientConfig == nil {
@@ -117,6 +166,10 @@ func (endpoint *Endpoint) ValidateAndSetDefaults() error {
}
if endpoint.UIConfig == nil {
endpoint.UIConfig = ui.GetDefaultConfig()
} else {
if err := endpoint.UIConfig.ValidateAndSetDefaults(); err != nil {
return err
}
}
if endpoint.Interval == 0 {
endpoint.Interval = 1 * time.Minute
@@ -153,9 +206,20 @@ func (endpoint *Endpoint) ValidateAndSetDefaults() error {
if len(endpoint.Conditions) == 0 {
return ErrEndpointWithNoCondition
}
for _, c := range endpoint.Conditions {
if endpoint.Interval < 5*time.Minute && c.hasDomainExpirationPlaceholder() {
return ErrInvalidEndpointIntervalForDomainExpirationPlaceholder
}
if err := c.Validate(); err != nil {
return fmt.Errorf("%v: %w", ErrInvalidConditionFormat, err)
}
}
if endpoint.DNS != nil {
return endpoint.DNS.validateAndSetDefault()
}
if endpoint.Type() == EndpointTypeUNKNOWN {
return ErrUnknownEndpointType
}
// Make sure that the request can be created
_, err := http.NewRequest(endpoint.Method, endpoint.URL, bytes.NewBuffer([]byte(endpoint.Body)))
if err != nil {
@@ -180,12 +244,35 @@ func (endpoint Endpoint) Key() string {
// EvaluateHealth sends a request to the endpoint's URL and evaluates the conditions of the endpoint.
func (endpoint *Endpoint) EvaluateHealth() *Result {
result := &Result{Success: true, Errors: []string{}}
endpoint.getIP(result)
// 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)
}
// Retrieve domain expiration if necessary
if endpoint.needsToRetrieveDomainExpiration() && len(result.Hostname) > 0 {
var err error
if result.DomainExpiration, err = client.GetDomainExpiration(result.Hostname); err != nil {
result.AddError(err.Error())
}
}
// Call the endpoint (if there's no errors)
if len(result.Errors) == 0 {
endpoint.call(result)
} else {
result.Success = false
}
// Evaluate the conditions
for _, condition := range endpoint.Conditions {
success := condition.evaluate(result, endpoint.UIConfig.DontResolveFailedConditions)
if !success {
@@ -196,6 +283,11 @@ func (endpoint *Endpoint) EvaluateHealth() *Result {
// No need to keep the body after the endpoint has been evaluated
result.body = nil
// 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 {
for errIdx, errorString := range result.Errors {
result.Errors[errIdx] = strings.ReplaceAll(errorString, result.Hostname, "<redacted>")
@@ -206,22 +298,12 @@ func (endpoint *Endpoint) EvaluateHealth() *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)
if err != nil {
if ips, err := net.LookupIP(result.Hostname); err != nil {
result.AddError(err.Error())
return
} else {
result.IP = ips[0].String()
}
result.IP = ips[0].String()
}
func (endpoint *Endpoint) call(result *Result) {
@@ -229,21 +311,16 @@ func (endpoint *Endpoint) call(result *Result) {
var response *http.Response
var err error
var certificate *x509.Certificate
isTypeDNS := endpoint.DNS != nil
isTypeTCP := strings.HasPrefix(endpoint.URL, "tcp://")
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 {
endpointType := endpoint.Type()
if endpointType == EndpointTypeHTTP {
request = endpoint.buildHTTPRequest()
}
startTime := time.Now()
if isTypeDNS {
if endpointType == EndpointTypeDNS {
endpoint.DNS.query(endpoint.URL, result)
result.Duration = time.Since(startTime)
} else if isTypeSTARTTLS || isTypeTLS {
if isTypeSTARTTLS {
} else if endpointType == EndpointTypeSTARTTLS || endpointType == EndpointTypeTLS {
if endpointType == EndpointTypeSTARTTLS {
result.Connected, certificate, err = client.CanPerformStartTLS(strings.TrimPrefix(endpoint.URL, "starttls://"), endpoint.ClientConfig)
} else {
result.Connected, certificate, err = client.CanPerformTLS(strings.TrimPrefix(endpoint.URL, "tls://"), endpoint.ClientConfig)
@@ -254,10 +331,16 @@ func (endpoint *Endpoint) call(result *Result) {
}
result.Duration = time.Since(startTime)
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.Duration = time.Since(startTime)
} else if isTypeICMP {
} else if endpointType == EndpointTypeUDP {
result.Connected = client.CanCreateUDPConnection(strings.TrimPrefix(endpoint.URL, "udp://"), endpoint.ClientConfig)
result.Duration = time.Since(startTime)
} else if endpointType == EndpointTypeSCTP {
result.Connected = client.CanCreateSCTPConnection(strings.TrimPrefix(endpoint.URL, "sctp://"), endpoint.ClientConfig)
result.Duration = time.Since(startTime)
} else if endpointType == EndpointTypeICMP {
result.Connected, result.Duration = client.Ping(strings.TrimPrefix(endpoint.URL, "icmp://"), endpoint.ClientConfig)
} else {
response, err = client.GetHTTPClient(endpoint.ClientConfig).Do(request)
@@ -277,7 +360,7 @@ func (endpoint *Endpoint) call(result *Result) {
if endpoint.needsToReadBody() {
result.body, err = io.ReadAll(response.Body)
if err != nil {
result.AddError(err.Error())
result.AddError("error reading response body:" + err.Error())
}
}
}
@@ -304,7 +387,7 @@ func (endpoint *Endpoint) buildHTTPRequest() *http.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 {
for _, condition := range endpoint.Conditions {
if condition.hasBodyPlaceholder() {
@@ -313,3 +396,23 @@ func (endpoint *Endpoint) needsToReadBody() bool {
}
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
import "github.com/TwiN/gatus/v3/util"
import "github.com/TwiN/gatus/v5/util"
// EndpointStatus contains the evaluation Results of an Endpoint
type EndpointStatus struct {
+451 -125
View File
@@ -1,16 +1,245 @@
package core
import (
"bytes"
"crypto/tls"
"crypto/x509"
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core/ui"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core/ui"
"github.com/TwiN/gatus/v5/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) {
if !(Endpoint{Enabled: nil}).IsEnabled() {
t.Error("endpoint.IsEnabled() should've returned true, because Enabled was set to nil")
@@ -23,15 +252,103 @@ 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: "8.8.8.8",
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: "sctp://example.com",
},
want: EndpointTypeSCTP,
},
{
args: args{
URL: "udp://example.com",
},
want: EndpointTypeUDP,
},
{
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) {
condition := Condition("[STATUS] == 200")
endpoint := Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []*Condition{&condition},
Conditions: []Condition{Condition("[STATUS] == 200")},
Alerts: []*alert.Alert{{Type: alert.TypePagerDuty}},
}
endpoint.ValidateAndSetDefaults()
if err := endpoint.ValidateAndSetDefaults(); err != nil {
t.Errorf("Expected no error, got %v", err)
}
if endpoint.ClientConfig == nil {
t.Error("client configuration should've been set to the default configuration")
} else {
@@ -57,8 +374,8 @@ func TestEndpoint_ValidateAndSetDefaults(t *testing.T) {
if len(endpoint.Alerts) != 1 {
t.Error("Endpoint should've had 1 alert")
}
if endpoint.Alerts[0].IsEnabled() {
t.Error("Endpoint alert should've defaulted to disabled")
if !endpoint.Alerts[0].IsEnabled() {
t.Error("Endpoint alert should've defaulted to true")
}
if endpoint.Alerts[0].SuccessThreshold != 2 {
t.Error("Endpoint alert should've defaulted to a success threshold of 2")
@@ -68,12 +385,22 @@ func TestEndpoint_ValidateAndSetDefaults(t *testing.T) {
}
}
func TestEndpoint_ValidateAndSetDefaultsWithInvalidCondition(t *testing.T) {
endpoint := Endpoint{
Name: "invalid-condition",
URL: "https://twin.sh/health",
Conditions: []Condition{"[STATUS] invalid 200"},
}
if err := endpoint.ValidateAndSetDefaults(); err == nil {
t.Error("endpoint validation should've returned an error, but didn't")
}
}
func TestEndpoint_ValidateAndSetDefaultsWithClientConfig(t *testing.T) {
condition := Condition("[STATUS] == 200")
endpoint := Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []*Condition{&condition},
Conditions: []Condition{Condition("[STATUS] == 200")},
ClientConfig: &client.Config{
Insecure: true,
IgnoreRedirect: true,
@@ -96,73 +423,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) {
conditionSuccess := Condition("[DNS_RCODE] == NOERROR")
endpoint := &Endpoint{
Name: "dns-test",
URL: "http://example.com",
URL: "https://example.com",
DNS: &DNS{
QueryType: "A",
QueryName: "example.com",
},
Conditions: []*Condition{&conditionSuccess},
Conditions: []Condition{Condition("[DNS_RCODE] == NOERROR")},
}
err := endpoint.ValidateAndSetDefaults()
if err != nil {
t.Error("did not expect an error, got", err)
}
if endpoint.DNS.QueryName != "example.com." {
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) {
condition := Condition("[STATUS] == 200")
endpoint := Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []*Condition{&condition},
Conditions: []Condition{condition},
}
endpoint.ValidateAndSetDefaults()
request := endpoint.buildHTTPRequest()
@@ -182,7 +524,7 @@ func TestEndpoint_buildHTTPRequestWithCustomUserAgent(t *testing.T) {
endpoint := Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []*Condition{&condition},
Conditions: []Condition{condition},
Headers: map[string]string{
"User-Agent": "Test/2.0",
},
@@ -206,7 +548,7 @@ func TestEndpoint_buildHTTPRequestWithHostHeader(t *testing.T) {
Name: "website-health",
URL: "https://twin.sh/health",
Method: "POST",
Conditions: []*Condition{&condition},
Conditions: []Condition{condition},
Headers: map[string]string{
"Host": "example.com",
},
@@ -227,7 +569,7 @@ func TestEndpoint_buildHTTPRequestWithGraphQLEnabled(t *testing.T) {
Name: "website-graphql",
URL: "https://twin.sh/graphql",
Method: "POST",
Conditions: []*Condition{&condition},
Conditions: []Condition{condition},
GraphQL: true,
Body: `{
users(gender: "female") {
@@ -258,7 +600,7 @@ func TestIntegrationEvaluateHealth(t *testing.T) {
endpoint := Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []*Condition{&condition, &bodyCondition},
Conditions: []Condition{condition, bodyCondition},
}
endpoint.ValidateAndSetDefaults()
result := endpoint.EvaluateHealth()
@@ -276,54 +618,16 @@ func TestIntegrationEvaluateHealth(t *testing.T) {
}
}
func TestIntegrationEvaluateHealthWithFailure(t *testing.T) {
condition := Condition("[STATUS] == 500")
func TestIntegrationEvaluateHealthWithErrorAndHideURL(t *testing.T) {
endpoint := Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []*Condition{&condition},
}
endpoint.ValidateAndSetDefaults()
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 {
t.Error("Because one of the conditions failed, result.Success should have been false")
}
}
func TestIntegrationEvaluateHealthWithInvalidCondition(t *testing.T) {
condition := Condition("[STATUS] invalid 200")
endpoint := Endpoint{
Name: "invalid-condition",
URL: "https://twin.sh/health",
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 TestIntegrationEvaluateHealthWithError(t *testing.T) {
condition := Condition("[STATUS] == 200")
endpoint := Endpoint{
Name: "invalid-host",
URL: "http://invalid/health",
Conditions: []*Condition{&condition},
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{
HideHostname: true,
HideURL: true,
},
}
endpoint.ValidateAndSetDefaults()
@@ -334,11 +638,8 @@ func TestIntegrationEvaluateHealthWithError(t *testing.T) {
if len(result.Errors) == 0 {
t.Error("There should've been an error")
}
if !strings.Contains(result.Errors[0], "<redacted>") {
t.Error("result.Errors[0] should've had the hostname redacted because ui.hide-hostname is set to true")
}
if result.Hostname != "" {
t.Error("result.Hostname should've been empty because ui.hide-hostname is set to true")
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")
}
}
@@ -352,12 +653,12 @@ func TestIntegrationEvaluateHealthForDNS(t *testing.T) {
QueryType: "A",
QueryName: "example.com.",
},
Conditions: []*Condition{&conditionSuccess, &conditionBody},
Conditions: []Condition{conditionSuccess, conditionBody},
}
endpoint.ValidateAndSetDefaults()
result := endpoint.EvaluateHealth()
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 {
t.Error("Because the connection has been established, result.Connected should've been true")
@@ -368,16 +669,15 @@ func TestIntegrationEvaluateHealthForDNS(t *testing.T) {
}
func TestIntegrationEvaluateHealthForICMP(t *testing.T) {
conditionSuccess := Condition("[CONNECTED] == true")
endpoint := Endpoint{
Name: "icmp-test",
URL: "icmp://127.0.0.1",
Conditions: []*Condition{&conditionSuccess},
Conditions: []Condition{"[CONNECTED] == true"},
}
endpoint.ValidateAndSetDefaults()
result := endpoint.EvaluateHealth()
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 {
t.Error("Because the connection has been established, result.Connected should've been true")
@@ -387,12 +687,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) {
conditionSuccess := Condition("[CONNECTED] == true")
endpoint := Endpoint{
Name: "invalid-url-test",
URL: "",
Conditions: []*Condition{&conditionSuccess},
Conditions: []Condition{"[CONNECTED] == true"},
}
result := &Result{}
endpoint.getIP(result)
@@ -401,26 +709,44 @@ func TestEndpoint_getIP(t *testing.T) {
}
}
func TestEndpoint_NeedsToReadBody(t *testing.T) {
func TestEndpoint_needsToReadBody(t *testing.T) {
statusCondition := Condition("[STATUS] == 200")
bodyCondition := Condition("[BODY].status == UP")
bodyConditionWithLength := Condition("len([BODY].tags) > 0")
if (&Endpoint{Conditions: []*Condition{&statusCondition}}).needsToReadBody() {
if (&Endpoint{Conditions: []Condition{statusCondition}}).needsToReadBody() {
t.Error("expected false, got true")
}
if !(&Endpoint{Conditions: []*Condition{&bodyCondition}}).needsToReadBody() {
if !(&Endpoint{Conditions: []Condition{bodyCondition}}).needsToReadBody() {
t.Error("expected true, got false")
}
if !(&Endpoint{Conditions: []*Condition{&bodyConditionWithLength}}).needsToReadBody() {
if !(&Endpoint{Conditions: []Condition{bodyConditionWithLength}}).needsToReadBody() {
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")
}
if !(&Endpoint{Conditions: []*Condition{&bodyCondition, &statusCondition}}).needsToReadBody() {
if !(&Endpoint{Conditions: []Condition{bodyCondition, statusCondition}}).needsToReadBody() {
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")
}
}

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