Compare commits

..
190 Commits
Author SHA1 Message Date
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
TwiN 08b31ba263 chore: Update frontend dependencies 2022-04-25 20:47:01 -04:00
TwiN 9ede992e4e feat(ui): Add support for buttons below header (#106) 2022-04-25 20:20:32 -04:00
TwiN dcb997f501 docs: Fix table format 2022-04-25 19:55:17 -04:00
TwiN c8efdac23a chore(ci): Update actions/setup-go to v3 2022-04-15 14:32:48 -04:00
e307d1ab35 feat(alerting): Add group-specific WebHook URL for Discord (#271)
* feat(alerting): Add group-specific webhook URL for discord

Add group-specific webhook URL for discord alert

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

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

ref: #96

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

* docs: update

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

* Update README.md

* Update README.md

* Update alerting/provider/discord/discord.go

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

* Update README.md

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

* test: revert testing name

* Update alerting/provider/discord/discord_test.go

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

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

Add group-specific webhook URL for teams alert

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

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

* Update README.md

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

Add group-specific to list for email alert

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

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

* docs: update

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

* Update README.md

* Update README.md

* Update README.md

* chore: update

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

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

update all exmaple in docker-compose file.

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

* Added OAuth2 support to `client` config

* Revert "Initial implementation"

This reverts commit 7f2f3a603a.

* Restore vendored clientcredentials

* configureOAuth2 is now a func (including tests)

* README update

* Use the same OAuth2Config in all related tests

* Cleanup & comments
2022-03-09 20:53:51 -05:00
JonahandGitHub fcf046cbe8 feat(alerting): Add support for custom Telegram API URL (#257) 2022-03-05 15:44:11 -05:00
TwiN 6932edc6d0 docs: Fix Google Chat alerting configuration example 2022-02-14 20:03:08 -05:00
587 changed files with 34578 additions and 30249 deletions
@@ -1,23 +1,41 @@
## Usage ## 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 ```console
docker-compose up docker-compose up
``` ```
Once you've done the above, you should be able to access the Grafana dashboard at `http://localhost:3000`. Once you've done the above, you should be able to access the Grafana dashboard at `http://localhost:3000`.
## Queries ![Gatus Grafana dashboard](../../.github/assets/grafana-dashboard.png)
Gatus uses Prometheus counters.
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) 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) 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: endpoints:
- name: website - name: website
url: https://twin.sh/health url: https://twin.sh/health
interval: 30s interval: 5m
conditions: conditions:
- "[STATUS] == 200" - "[STATUS] == 200"
- name: example
url: https://example.com/
interval: 5m
conditions:
- "[STATUS] == 200"
- name: github - name: github
url: https://api.github.com/healthz url: https://api.github.com/healthz
interval: 5m interval: 5m
conditions:
- "[STATUS] == 200"
- name: example
url: https://example.com/
conditions: conditions:
- "[STATUS] == 200" - "[STATUS] == 200"
@@ -7,7 +7,7 @@ services:
ports: ports:
- "8080:8080" - "8080:8080"
volumes: volumes:
- ./config.yaml:/config/config.yaml - ./config:/config
networks: networks:
- metrics - metrics
@@ -15,8 +15,266 @@
"editable": true, "editable": true,
"gnetId": null, "gnetId": null,
"graphTooltip": 0, "graphTooltip": 0,
"id": 3,
"links": [], "links": [],
"panels": [ "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": {}, "aliasColors": {},
"bars": false, "bars": false,
@@ -27,10 +285,10 @@
"fill": 1, "fill": 1,
"fillGradient": 0, "fillGradient": 0,
"gridPos": { "gridPos": {
"h": 7, "h": 8,
"w": 12, "w": 12,
"x": 0, "x": 12,
"y": 0 "y": 8
}, },
"id": 2, "id": 2,
"interval": "", "interval": "",
@@ -126,8 +384,8 @@
"gridPos": { "gridPos": {
"h": 7, "h": 7,
"w": 12, "w": 12,
"x": 12, "x": 0,
"y": 0 "y": 16
}, },
"id": 5, "id": 5,
"legend": { "legend": {
@@ -204,94 +462,6 @@
"alignLevel": null "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": {}, "aliasColors": {},
"bars": false, "bars": false,
@@ -304,7 +474,7 @@
"h": 7, "h": 7,
"w": 12, "w": 12,
"x": 12, "x": 12,
"y": 7 "y": 16
}, },
"id": 3, "id": 3,
"legend": { "legend": {
@@ -380,7 +550,7 @@
} }
} }
], ],
"refresh": "10s", "refresh": "1m",
"schemaVersion": 20, "schemaVersion": 20,
"style": "dark", "style": "dark",
"tags": [], "tags": [],
@@ -391,9 +561,22 @@
"from": "now-1h", "from": "now-1h",
"to": "now" "to": "now"
}, },
"timepicker": {}, "timepicker": {
"refresh_intervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
]
},
"timezone": "", "timezone": "",
"title": "Gatus", "title": "Gatus",
"uid": "KPI7Qj1Wk", "uid": "KPI7Qj1Wk",
"version": 1 "version": 2
} }
@@ -6,7 +6,7 @@ services:
ports: ports:
- "8080:8080" - "8080:8080"
volumes: volumes:
- ./config.yaml:/config/config.yaml - ./config:/config
networks: networks:
- default - default
@@ -19,11 +19,11 @@ services:
ports: ports:
- "8080:8080" - "8080:8080"
volumes: volumes:
- ./config.yaml:/config/config.yaml - ./config:/config
networks: networks:
- web - web
depends_on: depends_on:
- postgres - postgres
networks: networks:
web: web:
@@ -5,5 +5,5 @@ services:
ports: ports:
- "8080:8080" - "8080:8080"
volumes: volumes:
- ./config.yaml:/config/config.yaml - ./config:/config
- ./data:/data/ - ./data:/data/
+1 -1
View File
@@ -5,4 +5,4 @@ services:
ports: ports:
- 8080:8080 - 8080:8080
volumes: volumes:
- ./config.yaml:/config/config.yaml - ./config:/config
+18 -2
View File
@@ -54,10 +54,10 @@ spec:
app: gatus app: gatus
template: template:
metadata: metadata:
labels:
app: gatus
name: gatus name: gatus
namespace: kube-system namespace: kube-system
labels:
app: gatus
spec: spec:
serviceAccountName: gatus serviceAccountName: gatus
terminationGracePeriodSeconds: 5 terminationGracePeriodSeconds: 5
@@ -76,6 +76,22 @@ spec:
requests: requests:
cpu: 50m cpu: 50m
memory: 30M memory: 30M
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
successThreshold: 1
failureThreshold: 3
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
successThreshold: 1
failureThreshold: 5
volumeMounts: volumeMounts:
- mountPath: /config - mountPath: /config
name: gatus-config name: gatus-config
Binary file not shown.

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,27 @@
name: publish-experimental
on: [workflow_dispatch]
jobs:
publish-experimental:
name: publish-experimental
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v3
- name: Get image repository
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to Docker Registry
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push docker image
uses: docker/build-push-action@v3
with:
platforms: linux/amd64
pull: true
push: true
tags: ${{ env.IMAGE_REPOSITORY }}:experimental
+11 -10
View File
@@ -4,31 +4,32 @@ on:
workflows: ["build"] workflows: ["build"]
branches: [master] branches: [master]
types: [completed] types: [completed]
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
jobs: jobs:
publish-latest: publish-latest:
name: Publish latest name: publish-latest
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }} if: ${{ github.event.workflow_run.conclusion == 'success' }}
timeout-minutes: 45 timeout-minutes: 60
steps: steps:
- name: Check out code - uses: actions/checkout@v3
uses: actions/checkout@v2
- name: Get image repository - name: Get image repository
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v1 uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1 uses: docker/setup-buildx-action@v2
- name: Login to Docker Registry - name: Login to Docker Registry
uses: docker/login-action@v1 uses: docker/login-action@v2
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push docker image - name: Build and push docker image
uses: docker/build-push-action@v2 uses: docker/build-push-action@v3
with: with:
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64 platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
pull: true pull: true
push: true push: true
tags: | tags: ${{ env.IMAGE_REPOSITORY }}:latest
${{ env.IMAGE_REPOSITORY }}:latest
+8 -10
View File
@@ -4,30 +4,28 @@ on:
types: [published] types: [published]
jobs: jobs:
publish-release: publish-release:
name: Publish release name: publish-release
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 45 timeout-minutes: 60
steps: steps:
- name: Check out code - uses: actions/checkout@v3
uses: actions/checkout@v2
- name: Get image repository - name: Get image repository
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Get the release - name: Get the release
run: echo RELEASE=${GITHUB_REF/refs\/tags\//} >> $GITHUB_ENV run: echo RELEASE=${GITHUB_REF/refs\/tags\//} >> $GITHUB_ENV
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v1 uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1 uses: docker/setup-buildx-action@v2
- name: Login to Docker Registry - name: Login to Docker Registry
uses: docker/login-action@v1 uses: docker/login-action@v2
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push docker image - name: Build and push docker image
uses: docker/build-push-action@v2 uses: docker/build-push-action@v3
with: with:
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64 platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
pull: true pull: true
push: true push: true
tags: | tags: ${{ env.IMAGE_REPOSITORY }}:${{ env.RELEASE }},${{ env.IMAGE_REPOSITORY }}:stable
${{ env.IMAGE_REPOSITORY }}:${{ env.RELEASE }}
@@ -1,4 +1,4 @@
name: build name: test
on: on:
pull_request: pull_request:
paths-ignore: paths-ignore:
@@ -9,25 +9,22 @@ on:
paths-ignore: paths-ignore:
- '*.md' - '*.md'
jobs: jobs:
build: test:
name: Build
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 5 timeout-minutes: 5
steps: steps:
- name: Set up Go - uses: actions/setup-go@v3
uses: actions/setup-go@v2
with: with:
go-version: 1.17 go-version: 1.19
- name: Check out code into the Go module directory - uses: actions/checkout@v3
uses: actions/checkout@v2
- name: Build binary to make sure it works - name: Build binary to make sure it works
run: go build -mod vendor run: go build
- name: Test - name: Test
# We're using "sudo" because one of the tests leverages ping, which requires super-user privileges. # 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 # 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) # 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 - name: Codecov
uses: codecov/codecov-action@v2.1.0 uses: codecov/codecov-action@v3.1.1
with: with:
files: ./coverage.txt files: ./coverage.txt
+1 -2
View File
@@ -3,7 +3,7 @@ FROM golang:alpine as builder
RUN apk --update add ca-certificates RUN apk --update add ca-certificates
WORKDIR /app WORKDIR /app
COPY . ./ 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 Tests inside docker image if you don't have a configured go environment
#RUN apk update && apk add --virtual build-dependencies build-base gcc #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 FROM scratch
COPY --from=builder /app/gatus . COPY --from=builder /app/gatus .
COPY --from=builder /app/config.yaml ./config/config.yaml COPY --from=builder /app/config.yaml ./config/config.yaml
COPY --from=builder /app/web/static ./web/static
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
ENV PORT=8080 ENV PORT=8080
EXPOSE ${PORT} EXPOSE ${PORT}
+1 -1
View File
@@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier same "printed page" as the copyright notice for easier
identification within third-party archives. identification within third-party archives.
Copyright 2021 TwiN Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
+4 -1
View File
@@ -1,5 +1,8 @@
BINARY=gatus BINARY=gatus
# Because there's a folder called "test", we need to make the target "test" phony
.PHONY: test
install: install:
go build -mod vendor -o $(BINARY) . go build -mod vendor -o $(BINARY) .
@@ -10,7 +13,7 @@ clean:
rm $(BINARY) rm $(BINARY)
test: test:
sudo go test ./alerting/... ./client/... ./config/... ./controller/... ./core/... ./jsonpath/... ./pattern/... ./security/... ./storage/... ./util/... ./watchdog/... -cover go test ./... -cover
########## ##########
+530 -172
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -79,6 +79,7 @@ func (alert Alert) GetDescription() string {
// IsEnabled returns whether an alert is enabled or not // IsEnabled returns whether an alert is enabled or not
func (alert Alert) IsEnabled() bool { func (alert Alert) IsEnabled() bool {
if alert.Enabled == nil { if alert.Enabled == nil {
// TODO: Default to true in v5.0.0 (unless default-alert.enabled is set to false)
return false return false
} }
return *alert.Enabled return *alert.Enabled
+9 -3
View File
@@ -17,12 +17,21 @@ const (
// TypeGoogleChat is the Type for the googlechat alerting provider // TypeGoogleChat is the Type for the googlechat alerting provider
TypeGoogleChat Type = "googlechat" TypeGoogleChat Type = "googlechat"
// TypeMatrix is the Type for the matrix alerting provider
TypeMatrix Type = "matrix"
// TypeMattermost is the Type for the mattermost alerting provider // TypeMattermost is the Type for the mattermost alerting provider
TypeMattermost Type = "mattermost" TypeMattermost Type = "mattermost"
// TypeMessagebird is the Type for the messagebird alerting provider // TypeMessagebird is the Type for the messagebird alerting provider
TypeMessagebird Type = "messagebird" TypeMessagebird Type = "messagebird"
// TypeNtfy is the Type for the ntfy alerting provider
TypeNtfy Type = "ntfy"
// TypeOpsgenie is the Type for the opsgenie alerting provider
TypeOpsgenie Type = "opsgenie"
// TypePagerDuty is the Type for the pagerduty alerting provider // TypePagerDuty is the Type for the pagerduty alerting provider
TypePagerDuty Type = "pagerduty" TypePagerDuty Type = "pagerduty"
@@ -37,7 +46,4 @@ const (
// TypeTwilio is the Type for the twilio alerting provider // TypeTwilio is the Type for the twilio alerting provider
TypeTwilio Type = "twilio" TypeTwilio Type = "twilio"
// TypeOpsgenie is the Type for the opsgenie alerting provider
TypeOpsgenie Type = "opsgenie"
) )
+44 -24
View File
@@ -1,20 +1,22 @@
package alerting package alerting
import ( import (
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/alerting/provider" "github.com/TwiN/gatus/v4/alerting/provider"
"github.com/TwiN/gatus/v3/alerting/provider/custom" "github.com/TwiN/gatus/v4/alerting/provider/custom"
"github.com/TwiN/gatus/v3/alerting/provider/discord" "github.com/TwiN/gatus/v4/alerting/provider/discord"
"github.com/TwiN/gatus/v3/alerting/provider/email" "github.com/TwiN/gatus/v4/alerting/provider/email"
"github.com/TwiN/gatus/v3/alerting/provider/googlechat" "github.com/TwiN/gatus/v4/alerting/provider/googlechat"
"github.com/TwiN/gatus/v3/alerting/provider/mattermost" "github.com/TwiN/gatus/v4/alerting/provider/matrix"
"github.com/TwiN/gatus/v3/alerting/provider/messagebird" "github.com/TwiN/gatus/v4/alerting/provider/mattermost"
"github.com/TwiN/gatus/v3/alerting/provider/opsgenie" "github.com/TwiN/gatus/v4/alerting/provider/messagebird"
"github.com/TwiN/gatus/v3/alerting/provider/pagerduty" "github.com/TwiN/gatus/v4/alerting/provider/ntfy"
"github.com/TwiN/gatus/v3/alerting/provider/slack" "github.com/TwiN/gatus/v4/alerting/provider/opsgenie"
"github.com/TwiN/gatus/v3/alerting/provider/teams" "github.com/TwiN/gatus/v4/alerting/provider/pagerduty"
"github.com/TwiN/gatus/v3/alerting/provider/telegram" "github.com/TwiN/gatus/v4/alerting/provider/slack"
"github.com/TwiN/gatus/v3/alerting/provider/twilio" "github.com/TwiN/gatus/v4/alerting/provider/teams"
"github.com/TwiN/gatus/v4/alerting/provider/telegram"
"github.com/TwiN/gatus/v4/alerting/provider/twilio"
) )
// Config is the configuration for alerting providers // Config is the configuration for alerting providers
@@ -22,7 +24,7 @@ type Config struct {
// Custom is the configuration for the custom alerting provider // Custom is the configuration for the custom alerting provider
Custom *custom.AlertProvider `yaml:"custom,omitempty"` Custom *custom.AlertProvider `yaml:"custom,omitempty"`
// googlechat is the configuration for the Google chat alerting provider // GoogleChat is the configuration for the Google chat alerting provider
GoogleChat *googlechat.AlertProvider `yaml:"googlechat,omitempty"` GoogleChat *googlechat.AlertProvider `yaml:"googlechat,omitempty"`
// Discord is the configuration for the discord alerting provider // Discord is the configuration for the discord alerting provider
@@ -31,12 +33,21 @@ type Config struct {
// Email is the configuration for the email alerting provider // Email is the configuration for the email alerting provider
Email *email.AlertProvider `yaml:"email,omitempty"` Email *email.AlertProvider `yaml:"email,omitempty"`
// Matrix is the configuration for the matrix alerting provider
Matrix *matrix.AlertProvider `yaml:"matrix,omitempty"`
// Mattermost is the configuration for the mattermost alerting provider // Mattermost is the configuration for the mattermost alerting provider
Mattermost *mattermost.AlertProvider `yaml:"mattermost,omitempty"` Mattermost *mattermost.AlertProvider `yaml:"mattermost,omitempty"`
// Messagebird is the configuration for the messagebird alerting provider // Messagebird is the configuration for the messagebird alerting provider
Messagebird *messagebird.AlertProvider `yaml:"messagebird,omitempty"` Messagebird *messagebird.AlertProvider `yaml:"messagebird,omitempty"`
// Ntfy is the configuration for the ntfy alerting provider
Ntfy *ntfy.AlertProvider `yaml:"ntfy,omitempty"`
// Opsgenie is the configuration for the opsgenie alerting provider
Opsgenie *opsgenie.AlertProvider `yaml:"opsgenie,omitempty"`
// PagerDuty is the configuration for the pagerduty alerting provider // PagerDuty is the configuration for the pagerduty alerting provider
PagerDuty *pagerduty.AlertProvider `yaml:"pagerduty,omitempty"` PagerDuty *pagerduty.AlertProvider `yaml:"pagerduty,omitempty"`
@@ -51,9 +62,6 @@ type Config struct {
// Twilio is the configuration for the twilio alerting provider // Twilio is the configuration for the twilio alerting provider
Twilio *twilio.AlertProvider `yaml:"twilio,omitempty"` 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 // GetAlertingProviderByAlertType returns an provider.AlertProvider by its corresponding alert.Type
@@ -65,12 +73,6 @@ func (config Config) GetAlertingProviderByAlertType(alertType alert.Type) provid
return nil return nil
} }
return config.Custom 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: case alert.TypeDiscord:
if config.Discord == nil { if config.Discord == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil // Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
@@ -83,6 +85,18 @@ func (config Config) GetAlertingProviderByAlertType(alertType alert.Type) provid
return nil return nil
} }
return config.Email return config.Email
case alert.TypeGoogleChat:
if config.GoogleChat == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
return nil
}
return config.GoogleChat
case alert.TypeMatrix:
if config.Matrix == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
return nil
}
return config.Matrix
case alert.TypeMattermost: case alert.TypeMattermost:
if config.Mattermost == nil { if config.Mattermost == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil // Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
@@ -95,6 +109,12 @@ func (config Config) GetAlertingProviderByAlertType(alertType alert.Type) provid
return nil return nil
} }
return config.Messagebird return config.Messagebird
case alert.TypeNtfy:
if config.Ntfy == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
return nil
}
return config.Ntfy
case alert.TypeOpsgenie: case alert.TypeOpsgenie:
if config.Opsgenie == nil { if config.Opsgenie == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil // Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
+22 -41
View File
@@ -7,9 +7,9 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/client" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
) )
// AlertProvider is the configuration necessary for sending an alert using a custom HTTP request // AlertProvider is the configuration necessary for sending an alert using a custom HTTP request
@@ -50,48 +50,28 @@ func (provider *AlertProvider) GetAlertStatePlaceholderValue(resolved bool) stri
return status return status
} }
func (provider *AlertProvider) buildHTTPRequest(endpointName, alertDescription string, resolved bool) *http.Request { func (provider *AlertProvider) buildHTTPRequest(endpoint *core.Endpoint, alert *alert.Alert, resolved bool) *http.Request {
body := provider.Body body, url, method := provider.Body, provider.URL, provider.Method
providerURL := provider.URL body = strings.ReplaceAll(body, "[ALERT_DESCRIPTION]", alert.GetDescription())
method := provider.Method url = strings.ReplaceAll(url, "[ALERT_DESCRIPTION]", alert.GetDescription())
body = strings.ReplaceAll(body, "[ENDPOINT_NAME]", endpoint.Name)
if strings.Contains(body, "[ALERT_DESCRIPTION]") { url = strings.ReplaceAll(url, "[ENDPOINT_NAME]", endpoint.Name)
body = strings.ReplaceAll(body, "[ALERT_DESCRIPTION]", alertDescription) body = strings.ReplaceAll(body, "[ENDPOINT_GROUP]", endpoint.Group)
} url = strings.ReplaceAll(url, "[ENDPOINT_GROUP]", endpoint.Group)
if strings.Contains(body, "[SERVICE_NAME]") { // XXX: Remove this in v4.0.0 body = strings.ReplaceAll(body, "[ENDPOINT_URL]", endpoint.URL)
body = strings.ReplaceAll(body, "[SERVICE_NAME]", endpointName) url = strings.ReplaceAll(url, "[ENDPOINT_URL]", endpoint.URL)
} if resolved {
if strings.Contains(body, "[ENDPOINT_NAME]") { body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
body = strings.ReplaceAll(body, "[ENDPOINT_NAME]", endpointName) url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
} } else {
if strings.Contains(body, "[ALERT_TRIGGERED_OR_RESOLVED]") { body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(false))
if resolved { url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(false))
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))
}
} }
if len(method) == 0 { if len(method) == 0 {
method = http.MethodGet method = http.MethodGet
} }
bodyBuffer := bytes.NewBuffer([]byte(body)) bodyBuffer := bytes.NewBuffer([]byte(body))
request, _ := http.NewRequest(method, providerURL, bodyBuffer) request, _ := http.NewRequest(method, url, bodyBuffer)
for k, v := range provider.Headers { for k, v := range provider.Headers {
request.Header.Set(k, v) request.Header.Set(k, v)
} }
@@ -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 { 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) response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
if err != nil { if err != nil {
return err return err
} }
defer response.Body.Close()
if response.StatusCode > 399 { if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body) body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(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 package custom
import ( import (
"fmt"
"io" "io"
"net/http" "net/http"
"testing" "testing"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/client" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v3/test" "github.com/TwiN/gatus/v4/test"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{URL: ""} t.Run("invalid-provider", func(t *testing.T) {
if invalidProvider.IsValid() { invalidProvider := AlertProvider{URL: ""}
t.Error("provider shouldn't have been valid") 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("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) { func TestAlertProvider_Send(t *testing.T) {
@@ -99,77 +110,103 @@ func TestAlertProvider_Send(t *testing.T) {
} }
} }
func TestAlertProvider_buildHTTPRequestWhenResolved(t *testing.T) { func TestAlertProvider_buildHTTPRequest(t *testing.T) {
const (
ExpectedURL = "https://example.com/endpoint-name?event=RESOLVED&description=alert-description"
ExpectedBody = "endpoint-name,alert-description,RESOLVED"
)
customAlertProvider := &AlertProvider{ customAlertProvider := &AlertProvider{
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]", URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]&url=[ENDPOINT_URL]",
Body: "[ENDPOINT_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]", Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ENDPOINT_URL],[ALERT_TRIGGERED_OR_RESOLVED]",
Headers: nil,
} }
request := customAlertProvider.buildHTTPRequest("endpoint-name", "alert-description", true) alertDescription := "alert-description"
if request.URL.String() != ExpectedURL { scenarios := []struct {
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String()) AlertProvider *AlertProvider
Resolved bool
ExpectedURL string
ExpectedBody string
}{
{
AlertProvider: customAlertProvider,
Resolved: true,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=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) for _, scenario := range scenarios {
if string(body) != ExpectedBody { t.Run(fmt.Sprintf("resolved-%v-with-default-placeholders", scenario.Resolved), func(t *testing.T) {
t.Error("expected body to be", ExpectedBody, "was", string(body)) request := customAlertProvider.buildHTTPRequest(
} &core.Endpoint{Name: "endpoint-name", Group: "endpoint-group", URL: "https://example.com"},
} &alert.Alert{Description: &alertDescription},
scenario.Resolved,
func TestAlertProvider_buildHTTPRequestWhenTriggered(t *testing.T) { )
const ( if request.URL.String() != scenario.ExpectedURL {
ExpectedURL = "https://example.com/endpoint-name?event=TRIGGERED&description=alert-description" t.Error("expected URL to be", scenario.ExpectedURL, "got", request.URL.String())
ExpectedBody = "endpoint-name,alert-description,TRIGGERED" }
) body, _ := io.ReadAll(request.Body)
customAlertProvider := &AlertProvider{ if string(body) != scenario.ExpectedBody {
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]", t.Error("expected body to be", scenario.ExpectedBody, "got", string(body))
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))
} }
} }
func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) { func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
const (
ExpectedURL = "https://example.com/endpoint-name?event=test&description=alert-description"
ExpectedBody = "endpoint-name,alert-description,test"
)
customAlertProvider := &AlertProvider{ customAlertProvider := &AlertProvider{
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]", URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[ENDPOINT_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]", Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
Headers: nil, Headers: nil,
Placeholders: map[string]map[string]string{ Placeholders: map[string]map[string]string{
"ALERT_TRIGGERED_OR_RESOLVED": { "ALERT_TRIGGERED_OR_RESOLVED": {
"RESOLVED": "test", "RESOLVED": "fixed",
"TRIGGERED": "boom",
}, },
}, },
} }
request := customAlertProvider.buildHTTPRequest("endpoint-name", "alert-description", true) alertDescription := "alert-description"
if request.URL.String() != ExpectedURL { scenarios := []struct {
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String()) AlertProvider *AlertProvider
Resolved bool
ExpectedURL string
ExpectedBody string
}{
{
AlertProvider: customAlertProvider,
Resolved: true,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=fixed&description=alert-description",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,fixed",
},
{
AlertProvider: customAlertProvider,
Resolved: false,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=boom&description=alert-description",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,boom",
},
} }
body, _ := io.ReadAll(request.Body) for _, scenario := range scenarios {
if string(body) != ExpectedBody { t.Run(fmt.Sprintf("resolved-%v-with-custom-placeholders", scenario.Resolved), func(t *testing.T) {
t.Error("expected body to be", ExpectedBody, "was", string(body)) request := customAlertProvider.buildHTTPRequest(
&core.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&alert.Alert{Description: &alertDescription},
scenario.Resolved,
)
if request.URL.String() != scenario.ExpectedURL {
t.Error("expected URL to be", scenario.ExpectedURL, "got", request.URL.String())
}
body, _ := io.ReadAll(request.Body)
if string(body) != scenario.ExpectedBody {
t.Error("expected body to be", scenario.ExpectedBody, "got", string(body))
}
})
} }
} }
func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) { func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) {
customAlertProvider := &AlertProvider{ customAlertProvider := &AlertProvider{
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]", URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[ENDPOINT_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]", Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
Headers: nil,
Placeholders: nil,
} }
if customAlertProvider.GetAlertStatePlaceholderValue(true) != "RESOLVED" { if customAlertProvider.GetAlertStatePlaceholderValue(true) != "RESOLVED" {
t.Error("expected RESOLVED, got", customAlertProvider.GetAlertStatePlaceholderValue(true)) t.Error("expected RESOLVED, got", customAlertProvider.GetAlertStatePlaceholderValue(true))
@@ -187,26 +224,3 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
t.Error("expected default alert to be nil") 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))
}
}
+76 -25
View File
@@ -2,13 +2,14 @@ package discord
import ( import (
"bytes" "bytes"
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/client" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
) )
// AlertProvider is the configuration necessary for sending an alert using Discord // AlertProvider is the configuration necessary for sending an alert using Discord
@@ -17,17 +18,35 @@ type AlertProvider struct {
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
WebhookURL string `yaml:"webhook-url"`
} }
// IsValid returns whether the provider's configuration is valid // IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) IsValid() bool {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
return false
}
registeredGroups[override.Group] = true
}
}
return len(provider.WebhookURL) > 0 return len(provider.WebhookURL) > 0
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { 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.WebhookURL, buffer) request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer)
if err != nil { if err != nil {
return err return err
} }
@@ -36,6 +55,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
if err != nil { if err != nil {
return err return err
} }
defer response.Body.Close()
if response.StatusCode > 399 { if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body) body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body)) return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
@@ -43,8 +63,26 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
return err 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 // 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 message, results string
var colorCode int var colorCode int
if resolved { if resolved {
@@ -61,29 +99,42 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
} else { } else {
prefix = ":x:" prefix = ":x:"
} }
results += fmt.Sprintf("%s - `%s`\\n", prefix, conditionResult.Condition) results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
} }
var description string var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\\n> " + alertDescription description = ":\n> " + alertDescription
} }
return fmt.Sprintf(`{ body, _ := json.Marshal(Body{
"content": "", Content: "",
"embeds": [ Embeds: []Embed{
{ {
"title": ":helmet_with_white_cross: Gatus", Title: ":helmet_with_white_cross: Gatus",
"description": "%s%s", Description: message + description,
"color": %d, Color: colorCode,
"fields": [ Fields: []Field{
{ {
"name": "Condition results", Name: "Condition results",
"value": "%s", Value: results,
"inline": false Inline: false,
} },
] },
} },
] },
}`, message, description, colorCode, results) })
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 // GetDefaultAlert returns the provider's default alert configuration
+110 -9
View File
@@ -5,10 +5,10 @@ import (
"net/http" "net/http"
"testing" "testing"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/client" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v3/test" "github.com/TwiN/gatus/v4/test"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertProvider_IsValid(t *testing.T) {
@@ -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) { func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil) defer client.InjectHTTPClient(nil)
firstDescription := "description-1" firstDescription := "description-1"
@@ -114,14 +151,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider: AlertProvider{}, Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, 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", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, 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 { for _, scenario := range scenarios {
@@ -133,15 +170,16 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
ConditionResults: []*core.ConditionResult{ ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
{Condition: "[BODY] != \"\"", Success: scenario.Resolved},
}, },
}, },
scenario.Resolved, scenario.Resolved,
) )
if body != scenario.ExpectedBody { if string(body) != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body) t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
} }
out := make(map[string]interface{}) 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()) t.Error("expected body to be valid JSON, got error:", err.Error())
} }
}) })
@@ -156,3 +194,66 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
t.Error("expected default alert to be nil") t.Error("expected default alert to be nil")
} }
} }
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
tests := []struct {
Name string
Provider AlertProvider
InputGroup string
ExpectedOutput string
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: nil,
},
InputGroup: "",
ExpectedOutput: "http://example.com",
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: nil,
},
InputGroup: "group",
ExpectedOutput: "http://example.com",
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: []Override{
{
Group: "group",
WebhookURL: "http://example01.com",
},
},
},
InputGroup: "",
ExpectedOutput: "http://example.com",
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: []Override{
{
Group: "group",
WebhookURL: "http://example01.com",
},
},
},
InputGroup: "group",
ExpectedOutput: "http://example01.com",
},
}
for _, tt := range tests {
t.Run(tt.Name, func(t *testing.T) {
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
t.Errorf("AlertProvider.getWebhookURLForGroup() = %v, want %v", got, tt.ExpectedOutput)
}
})
}
}
+34 -3
View File
@@ -5,8 +5,8 @@ import (
"math" "math"
"strings" "strings"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
gomail "gopkg.in/mail.v2" gomail "gopkg.in/mail.v2"
) )
@@ -21,10 +21,29 @@ type AlertProvider struct {
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
To string `yaml:"to"`
} }
// IsValid returns whether the provider's configuration is valid // IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) IsValid() bool {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.To) == 0 {
return false
}
registeredGroups[override.Group] = true
}
}
return len(provider.From) > 0 && len(provider.Password) > 0 && len(provider.Host) > 0 && len(provider.To) > 0 && provider.Port > 0 && provider.Port < math.MaxUint16 return len(provider.From) > 0 && len(provider.Password) > 0 && len(provider.Host) > 0 && len(provider.To) > 0 && provider.Port > 0 && provider.Port < math.MaxUint16
} }
@@ -39,7 +58,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
subject, body := provider.buildMessageSubjectAndBody(endpoint, alert, result, resolved) subject, body := provider.buildMessageSubjectAndBody(endpoint, alert, result, resolved)
m := gomail.NewMessage() m := gomail.NewMessage()
m.SetHeader("From", provider.From) m.SetHeader("From", provider.From)
m.SetHeader("To", strings.Split(provider.To, ",")...) m.SetHeader("To", strings.Split(provider.getToForGroup(endpoint.Group), ",")...)
m.SetHeader("Subject", subject) m.SetHeader("Subject", subject)
m.SetBody("text/plain", body) m.SetBody("text/plain", body)
d := gomail.NewDialer(provider.Host, provider.Port, username, provider.Password) d := gomail.NewDialer(provider.Host, provider.Port, username, provider.Password)
@@ -72,6 +91,18 @@ func (provider *AlertProvider) buildMessageSubjectAndBody(endpoint *core.Endpoin
return subject, message + description + "\n\nCondition results:\n" + results return subject, message + description + "\n\nCondition results:\n" + results
} }
// getToForGroup returns the appropriate email integration to for a given group
func (provider *AlertProvider) getToForGroup(group string) string {
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
return override.To
}
}
}
return provider.To
}
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert { func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert
+107 -3
View File
@@ -3,11 +3,11 @@ package email
import ( import (
"testing" "testing"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertDefaultProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{} invalidProvider := AlertProvider{}
if invalidProvider.IsValid() { if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
@@ -18,6 +18,47 @@ func TestAlertProvider_IsValid(t *testing.T) {
} }
} }
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
To: "to@example.com",
Group: "",
},
},
}
if providerWithInvalidOverrideGroup.IsValid() {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
To: "",
Group: "group",
},
},
}
if providerWithInvalidOverrideTo.IsValid() {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
From: "from@example.com",
Password: "password",
Host: "smtp.gmail.com",
Port: 587,
To: "to@example.com",
Overrides: []Override{
{
To: "to@example.com",
Group: "group",
},
},
}
if !providerWithValidOverride.IsValid() {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) { func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1" firstDescription := "description-1"
secondDescription := "description-2" secondDescription := "description-2"
@@ -77,3 +118,66 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
t.Error("expected default alert to be nil") t.Error("expected default alert to be nil")
} }
} }
func TestAlertProvider_getToForGroup(t *testing.T) {
tests := []struct {
Name string
Provider AlertProvider
InputGroup string
ExpectedOutput string
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
To: "to@example.com",
Overrides: nil,
},
InputGroup: "",
ExpectedOutput: "to@example.com",
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
To: "to@example.com",
Overrides: nil,
},
InputGroup: "group",
ExpectedOutput: "to@example.com",
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
To: "to@example.com",
Overrides: []Override{
{
Group: "group",
To: "to01@example.com",
},
},
},
InputGroup: "",
ExpectedOutput: "to@example.com",
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
To: "to@example.com",
Overrides: []Override{
{
Group: "group",
To: "to01@example.com",
},
},
},
InputGroup: "group",
ExpectedOutput: "to01@example.com",
},
}
for _, tt := range tests {
t.Run(tt.Name, func(t *testing.T) {
if got := tt.Provider.getToForGroup(tt.InputGroup); got != tt.ExpectedOutput {
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
}
})
}
}
+126 -49
View File
@@ -2,13 +2,14 @@ package googlechat
import ( import (
"bytes" "bytes"
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/client" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
) )
// AlertProvider is the configuration necessary for sending an alert using Google chat // 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 is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
WebhookURL string `yaml:"webhook-url"`
} }
// IsValid returns whether the provider's configuration is valid // IsValid returns whether the provider's configuration is valid
@@ -27,13 +37,22 @@ func (provider *AlertProvider) IsValid() bool {
if provider.ClientConfig == nil { if provider.ClientConfig == nil {
provider.ClientConfig = client.GetDefaultConfig() 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 return len(provider.WebhookURL) > 0
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { 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.WebhookURL, buffer) request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer)
if err != nil { if err != nil {
return err return err
} }
@@ -42,6 +61,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
if err != nil { if err != nil {
return err return err
} }
defer response.Body.Close()
if response.StatusCode > 399 { if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body) body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(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 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 // 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 var message, color string
if resolved { if resolved {
color = "#36A64F" color = "#36A64F"
@@ -73,49 +135,64 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":: " + alertDescription description = ":: " + alertDescription
} }
return fmt.Sprintf(`{ payload := Body{
"cards": [ Cards: []Cards{
{ {
"sections": [ Sections: []Sections{
{ {
"widgets": [ Widgets: []Widgets{
{ {
"keyValue": { KeyValue: &KeyValue{
"topLabel": "%s [%s]", TopLabel: endpoint.DisplayName(),
"content": "%s", Content: message,
"contentMultiline": "true", ContentMultiline: "true",
"bottomLabel": "%s", BottomLabel: description,
"icon": "BOOKMARK" Icon: "BOOKMARK",
} },
}, },
{ {
"keyValue": { KeyValue: &KeyValue{
"topLabel": "Condition results", TopLabel: "Condition results",
"content": "%s", Content: results,
"contentMultiline": "true", ContentMultiline: "true",
"icon": "DESCRIPTION" Icon: "DESCRIPTION",
} },
}, },
{ },
"buttons": [ },
{ },
"textButton": { },
"text": "URL", },
"onClick": { }
"openLink": { if endpoint.Type() == core.EndpointTypeHTTP {
"url": "%s" // 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}},
] },
} },
] },
}`, endpoint.Name, endpoint.Group, message, description, results, 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 // GetDefaultAlert returns the provider's default alert configuration
+130 -13
View File
@@ -5,13 +5,13 @@ import (
"net/http" "net/http"
"testing" "testing"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/client" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v3/test" "github.com/TwiN/gatus/v4/test"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertDefaultProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{WebhookURL: ""} invalidProvider := AlertProvider{WebhookURL: ""}
if invalidProvider.IsValid() { if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
@@ -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) { func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil) defer client.InjectHTTPClient(nil)
firstDescription := "description-1" firstDescription := "description-1"
@@ -104,6 +141,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
secondDescription := "description-2" secondDescription := "description-2"
scenarios := []struct { scenarios := []struct {
Name string Name string
Endpoint core.Endpoint
Provider AlertProvider Provider AlertProvider
Alert alert.Alert Alert alert.Alert
Resolved bool Resolved bool
@@ -111,23 +149,41 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{}, Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, 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", Name: "resolved",
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{}, Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, 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 { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"}, &scenario.Endpoint,
&scenario.Alert, &scenario.Alert,
&core.Result{ &core.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*core.ConditionResult{
@@ -137,13 +193,11 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}, },
scenario.Resolved, scenario.Resolved,
) )
b, _ := json.Marshal(body) if string(body) != scenario.ExpectedBody {
e, _ := json.Marshal(scenario.ExpectedBody) t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
if body != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", e, b)
} }
out := make(map[string]interface{}) 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()) 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") 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/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
)
// AlertProvider is the configuration necessary for sending an alert using Matrix
type AlertProvider struct {
MatrixProviderConfig `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
MatrixProviderConfig `yaml:",inline"`
}
const defaultHomeserverURL = "https://matrix-client.matrix.org"
type MatrixProviderConfig struct {
// ServerURL is the custom homeserver to use (optional)
ServerURL string `yaml:"server-url"`
// AccessToken is the bot user's access token to send messages
AccessToken string `yaml:"access-token"`
// InternalRoomID is the room that the bot user has permissions to send messages to
InternalRoomID string `yaml:"internal-room-id"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.AccessToken) == 0 || len(override.InternalRoomID) == 0 {
return false
}
registeredGroups[override.Group] = true
}
}
return len(provider.AccessToken) > 0 && len(provider.InternalRoomID) > 0
}
// Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer(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/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/test"
)
func TestAlertProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{
MatrixProviderConfig: MatrixProviderConfig{
AccessToken: "",
InternalRoomID: "",
},
}
if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{
MatrixProviderConfig: MatrixProviderConfig{
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
}
if !validProvider.IsValid() {
t.Error("provider should've been valid")
}
validProviderWithHomeserver := AlertProvider{
MatrixProviderConfig: MatrixProviderConfig{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
}
if !validProviderWithHomeserver.IsValid() {
t.Error("provider with homeserver should've been valid")
}
}
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
Group: "",
MatrixProviderConfig: MatrixProviderConfig{
AccessToken: "",
InternalRoomID: "",
},
},
},
}
if providerWithInvalidOverrideGroup.IsValid() {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
Group: "group",
MatrixProviderConfig: MatrixProviderConfig{
AccessToken: "",
InternalRoomID: "",
},
},
},
}
if providerWithInvalidOverrideTo.IsValid() {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
MatrixProviderConfig: MatrixProviderConfig{
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
Overrides: []Override{
{
Group: "group",
MatrixProviderConfig: MatrixProviderConfig{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
},
},
}
if !providerWithValidOverride.IsValid() {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"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 ( import (
"bytes" "bytes"
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/client" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
) )
// AlertProvider is the configuration necessary for sending an alert using Mattermost // 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 is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
WebhookURL string `yaml:"webhook-url"`
} }
// IsValid returns whether the provider's configuration is valid // IsValid returns whether the provider's configuration is valid
@@ -27,13 +37,22 @@ func (provider *AlertProvider) IsValid() bool {
if provider.ClientConfig == nil { if provider.ClientConfig == nil {
provider.ClientConfig = client.GetDefaultConfig() provider.ClientConfig = client.GetDefaultConfig()
} }
if provider.Overrides != nil {
registeredGroups := make(map[string]bool)
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
return false
}
registeredGroups[override.Group] = true
}
}
return len(provider.WebhookURL) > 0 return len(provider.WebhookURL) > 0
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { 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([]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 { if err != nil {
return err return err
} }
@@ -42,6 +61,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
if err != nil { if err != nil {
return err return err
} }
defer response.Body.Close()
if response.StatusCode > 399 { if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body) body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(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 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 // 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 var message, color, results string
if resolved { if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold) message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
color = "#36A64F" color = "#36A64F"
@@ -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) message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
color = "#DD0000" color = "#DD0000"
} }
var results string
for _, conditionResult := range result.ConditionResults { for _, conditionResult := range result.ConditionResults {
var prefix string var prefix string
if conditionResult.Success { if conditionResult.Success {
@@ -67,38 +108,46 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
} else { } else {
prefix = ":x:" prefix = ":x:"
} }
results += fmt.Sprintf("%s - `%s`\\n", prefix, conditionResult.Condition) results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
} }
var description string var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\\n> " + alertDescription description = ":\n> " + alertDescription
} }
return fmt.Sprintf(`{ body, _ := json.Marshal(Body{
"text": "", Text: "",
"username": "gatus", Username: "gatus",
"icon_url": "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png", IconURL: "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png",
"attachments": [ Attachments: []Attachment{
{ {
"title": ":rescue_worker_helmet: Gatus", Title: ":helmet_with_white_cross: Gatus",
"fallback": "Gatus - %s", Fallback: "Gatus - " + message,
"text": "%s%s", Text: message + description,
"short": false, Short: false,
"color": "%s", Color: color,
"fields": [ Fields: []Field{
{ {
"title": "URL", Title: "Condition results",
"value": "%s", Value: results,
"short": false Short: false,
}, },
{ },
"title": "Condition results", },
"value": "%s", },
"short": false })
} return body
] }
}
] // getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
}`, message, message, description, color, endpoint.URL, results) func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
return override.WebhookURL
}
}
}
return provider.WebhookURL
} }
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
+113 -9
View File
@@ -5,10 +5,10 @@ import (
"net/http" "net/http"
"testing" "testing"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/client" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v3/test" "github.com/TwiN/gatus/v4/test"
) )
func TestAlertProvider_IsValid(t *testing.T) { 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) { func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil) defer client.InjectHTTPClient(nil)
firstDescription := "description-1" firstDescription := "description-1"
@@ -114,14 +155,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider: AlertProvider{}, Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, 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", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, 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 { for _, scenario := range scenarios {
@@ -137,11 +178,11 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}, },
scenario.Resolved, scenario.Resolved,
) )
if body != scenario.ExpectedBody { if string(body) != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body) t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
} }
out := make(map[string]interface{}) 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()) 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") 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 ( import (
"bytes" "bytes"
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/client" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
) )
const ( const (
@@ -33,7 +34,7 @@ func (provider *AlertProvider) IsValid() bool {
// Send an alert using the provider // Send an alert using the provider
// Reference doc for messagebird: https://developers.messagebird.com/api/sms-messaging/#send-outbound-sms // Reference doc for messagebird: https://developers.messagebird.com/api/sms-messaging/#send-outbound-sms
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { 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) request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
if err != nil { if err != nil {
return err return err
@@ -44,6 +45,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
if err != nil { if err != nil {
return err return err
} }
defer response.Body.Close()
if response.StatusCode > 399 { if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body) body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(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 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 // 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 var message string
if resolved { if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription()) message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
} else { } else {
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.DisplayName(), alert.GetDescription()) message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
} }
return fmt.Sprintf(`{ body, _ := json.Marshal(Body{
"originator": "%s", Originator: provider.Originator,
"recipients": "%s", Recipients: provider.Recipients,
"body": "%s" Body: message,
}`, provider.Originator, provider.Recipients, message) })
return body
} }
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
@@ -5,10 +5,10 @@ import (
"net/http" "net/http"
"testing" "testing"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/client" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v3/test" "github.com/TwiN/gatus/v4/test"
) )
func TestMessagebirdAlertProvider_IsValid(t *testing.T) { func TestMessagebirdAlertProvider_IsValid(t *testing.T) {
@@ -118,14 +118,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider: AlertProvider{AccessKey: "1", Originator: "2", Recipients: "3"}, Provider: AlertProvider{AccessKey: "1", Originator: "2", Recipients: "3"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, 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", Name: "resolved",
Provider: AlertProvider{AccessKey: "4", Originator: "5", Recipients: "6"}, Provider: AlertProvider{AccessKey: "4", Originator: "5", Recipients: "6"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, 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 { for _, scenario := range scenarios {
@@ -141,8 +141,8 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}, },
scenario.Resolved, scenario.Resolved,
) )
if body != scenario.ExpectedBody { if string(body) != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body) t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
} }
out := make(map[string]interface{}) out := make(map[string]interface{})
if err := json.Unmarshal([]byte(body), &out); err != nil { if err := json.Unmarshal([]byte(body), &out); err != nil {
+95
View File
@@ -0,0 +1,95 @@
package ntfy
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
)
const (
DefaultURL = "https://ntfy.sh"
DefaultPriority = 3
)
// AlertProvider is the configuration necessary for sending an alert using Slack
type AlertProvider struct {
Topic string `yaml:"topic"`
URL string `yaml:"url,omitempty"` // Defaults to DefaultURL
Priority int `yaml:"priority,omitempty"` // Defaults to DefaultPriority
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
if len(provider.URL) == 0 {
provider.URL = DefaultURL
}
if provider.Priority == 0 {
provider.Priority = DefaultPriority
}
return len(provider.URL) > 0 && len(provider.Topic) > 0 && provider.Priority > 0 && provider.Priority < 6
}
// Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer(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 len(alert.GetDescription()) > 0 {
message = endpoint.DisplayName() + " - " + alert.GetDescription()
} else {
message = endpoint.DisplayName()
}
if resolved {
tag = "white_check_mark"
} else {
tag = "x"
}
body, _ := json.Marshal(Body{
Topic: provider.Topic,
Title: "Gatus",
Message: message,
Tags: []string{tag},
Priority: provider.Priority,
})
return body
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
+104
View File
@@ -0,0 +1,104 @@
package ntfy
import (
"encoding/json"
"testing"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/core"
)
func TestAlertDefaultProvider_IsValid(t *testing.T) {
scenarios := []struct {
name string
provider AlertProvider
expected bool
}{
{
name: "valid",
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
expected: true,
},
{
name: "no-url-should-use-default-value",
provider: AlertProvider{Topic: "example", Priority: 1},
expected: true,
},
{
name: "invalid-topic",
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "", Priority: 1},
expected: false,
},
{
name: "invalid-priority-too-high",
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 6},
expected: false,
},
{
name: "invalid-priority-too-low",
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: -1},
expected: false,
},
{
name: "no-priority-should-use-default-value",
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example"},
expected: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
if scenario.provider.IsValid() != scenario.expected {
t.Errorf("expected %t, got %t", scenario.expected, scenario.provider.IsValid())
}
})
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"topic\":\"example\",\"title\":\"Gatus\",\"message\":\"endpoint-name - description-1\",\"tags\":[\"x\"],\"priority\":1}",
},
{
Name: "resolved",
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 2},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"topic\":\"example\",\"title\":\"Gatus\",\"message\":\"endpoint-name - description-2\",\"tags\":[\"white_check_mark\"],\"priority\":2}",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\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" "strconv"
"strings" "strings"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/client" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
) )
const ( 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 { func (provider *AlertProvider) createAlert(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
payload := provider.buildCreateRequestBody(endpoint, alert, result, resolved) payload := provider.buildCreateRequestBody(endpoint, alert, result, resolved)
_, err := provider.sendRequest(restAPI, http.MethodPost, payload) return provider.sendRequest(restAPI, http.MethodPost, payload)
return err
} }
func (provider *AlertProvider) closeAlert(endpoint *core.Endpoint, alert *alert.Alert) error { func (provider *AlertProvider) closeAlert(endpoint *core.Endpoint, alert *alert.Alert) error {
payload := provider.buildCloseRequestBody(endpoint, alert) payload := provider.buildCloseRequestBody(endpoint, alert)
url := restAPI + "/" + provider.alias(buildKey(endpoint)) + "/close?identifierType=alias" url := restAPI + "/" + provider.alias(buildKey(endpoint)) + "/close?identifierType=alias"
_, err := provider.sendRequest(url, http.MethodPost, payload) return provider.sendRequest(url, http.MethodPost, payload)
return err
} }
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) body, err := json.Marshal(payload)
if err != nil { 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)) request, err := http.NewRequest(method, url, bytes.NewBuffer(body))
if err != nil { if err != nil {
return nil, err return err
} }
request.Header.Set("Content-Type", "application/json") request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "GenieKey "+provider.APIKey) request.Header.Set("Authorization", "GenieKey "+provider.APIKey)
res, err := client.GetHTTPClient(nil).Do(request) response, err := client.GetHTTPClient(nil).Do(request)
if err != nil { if err != nil {
return nil, err return err
} }
if res.StatusCode > 399 { defer response.Body.Close()
rBody, _ := io.ReadAll(res.Body) if response.StatusCode > 399 {
return nil, fmt.Errorf("call to provider alert returned status code %d: %s", res.StatusCode, string(rBody)) 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 { 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" "reflect"
"testing" "testing"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/client" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v3/test" "github.com/TwiN/gatus/v4/test"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertProvider_IsValid(t *testing.T) {
+30 -15
View File
@@ -8,9 +8,9 @@ import (
"log" "log"
"net/http" "net/http"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/client" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
) )
const ( const (
@@ -53,7 +53,7 @@ func (provider *AlertProvider) IsValid() bool {
// //
// Relevant: https://developer.pagerduty.com/docs/events-api-v2/trigger-events/ // Relevant: https://developer.pagerduty.com/docs/events-api-v2/trigger-events/
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { 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) request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
if err != nil { if err != nil {
return err return err
@@ -63,6 +63,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
if err != nil { if err != nil {
return err return err
} }
defer response.Body.Close()
if response.StatusCode > 399 { if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body) body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(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 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 // 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 var message, eventAction, resolveKey string
if resolved { if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription()) message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
@@ -98,16 +112,17 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
eventAction = "trigger" eventAction = "trigger"
resolveKey = "" resolveKey = ""
} }
return fmt.Sprintf(`{ body, _ := json.Marshal(Body{
"routing_key": "%s", RoutingKey: provider.getIntegrationKeyForGroup(endpoint.Group),
"dedup_key": "%s", DedupKey: resolveKey,
"event_action": "%s", EventAction: eventAction,
"payload": { Payload: Payload{
"summary": "%s", Summary: message,
"source": "%s", Source: "Gatus",
"severity": "critical" Severity: "critical",
} },
}`, provider.getIntegrationKeyForGroup(endpoint.Group), resolveKey, eventAction, message, endpoint.Name) })
return body
} }
// getIntegrationKeyForGroup returns the appropriate pagerduty integration key for a given group // getIntegrationKeyForGroup returns the appropriate pagerduty integration key for a given group
+10 -10
View File
@@ -5,10 +5,10 @@ import (
"net/http" "net/http"
"testing" "testing"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/client" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v3/test" "github.com/TwiN/gatus/v4/test"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertProvider_IsValid(t *testing.T) {
@@ -149,24 +149,24 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider: AlertProvider{IntegrationKey: "00000000000000000000000000000000"}, Provider: AlertProvider{IntegrationKey: "00000000000000000000000000000000"},
Alert: alert.Alert{Description: &description}, Alert: alert.Alert{Description: &description},
Resolved: false, 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", Name: "resolved",
Provider: AlertProvider{IntegrationKey: "00000000000000000000000000000000"}, Provider: AlertProvider{IntegrationKey: "00000000000000000000000000000000"},
Alert: alert.Alert{Description: &description, ResolveKey: "key"}, Alert: alert.Alert{Description: &description, ResolveKey: "key"},
Resolved: true, 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 { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(&core.Endpoint{}, &scenario.Alert, &core.Result{}, scenario.Resolved) body := scenario.Provider.buildRequestBody(&core.Endpoint{Name: "endpoint-name"}, &scenario.Alert, &core.Result{}, scenario.Resolved)
if body != scenario.ExpectedBody { if string(body) != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body) t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
} }
out := make(map[string]interface{}) 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()) t.Error("expected body to be valid JSON, got error:", err.Error())
} }
}) })
+19 -13
View File
@@ -1,19 +1,22 @@
package provider package provider
import ( import (
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/alerting/provider/custom" "github.com/TwiN/gatus/v4/alerting/provider/custom"
"github.com/TwiN/gatus/v3/alerting/provider/discord" "github.com/TwiN/gatus/v4/alerting/provider/discord"
"github.com/TwiN/gatus/v3/alerting/provider/email" "github.com/TwiN/gatus/v4/alerting/provider/email"
"github.com/TwiN/gatus/v3/alerting/provider/googlechat" "github.com/TwiN/gatus/v4/alerting/provider/googlechat"
"github.com/TwiN/gatus/v3/alerting/provider/mattermost" "github.com/TwiN/gatus/v4/alerting/provider/matrix"
"github.com/TwiN/gatus/v3/alerting/provider/messagebird" "github.com/TwiN/gatus/v4/alerting/provider/mattermost"
"github.com/TwiN/gatus/v3/alerting/provider/pagerduty" "github.com/TwiN/gatus/v4/alerting/provider/messagebird"
"github.com/TwiN/gatus/v3/alerting/provider/slack" "github.com/TwiN/gatus/v4/alerting/provider/ntfy"
"github.com/TwiN/gatus/v3/alerting/provider/teams" "github.com/TwiN/gatus/v4/alerting/provider/opsgenie"
"github.com/TwiN/gatus/v3/alerting/provider/telegram" "github.com/TwiN/gatus/v4/alerting/provider/pagerduty"
"github.com/TwiN/gatus/v3/alerting/provider/twilio" "github.com/TwiN/gatus/v4/alerting/provider/slack"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/alerting/provider/teams"
"github.com/TwiN/gatus/v4/alerting/provider/telegram"
"github.com/TwiN/gatus/v4/alerting/provider/twilio"
"github.com/TwiN/gatus/v4/core"
) )
// AlertProvider is the interface that each providers should implement // AlertProvider is the interface that each providers should implement
@@ -56,8 +59,11 @@ var (
_ AlertProvider = (*discord.AlertProvider)(nil) _ AlertProvider = (*discord.AlertProvider)(nil)
_ AlertProvider = (*email.AlertProvider)(nil) _ AlertProvider = (*email.AlertProvider)(nil)
_ AlertProvider = (*googlechat.AlertProvider)(nil) _ AlertProvider = (*googlechat.AlertProvider)(nil)
_ AlertProvider = (*matrix.AlertProvider)(nil)
_ AlertProvider = (*mattermost.AlertProvider)(nil) _ AlertProvider = (*mattermost.AlertProvider)(nil)
_ AlertProvider = (*messagebird.AlertProvider)(nil) _ AlertProvider = (*messagebird.AlertProvider)(nil)
_ AlertProvider = (*ntfy.AlertProvider)(nil)
_ AlertProvider = (*opsgenie.AlertProvider)(nil)
_ AlertProvider = (*pagerduty.AlertProvider)(nil) _ AlertProvider = (*pagerduty.AlertProvider)(nil)
_ AlertProvider = (*slack.AlertProvider)(nil) _ AlertProvider = (*slack.AlertProvider)(nil)
_ AlertProvider = (*teams.AlertProvider)(nil) _ AlertProvider = (*teams.AlertProvider)(nil)
+1 -1
View File
@@ -3,7 +3,7 @@ package provider
import ( import (
"testing" "testing"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
) )
func TestParseWithDefaultAlert(t *testing.T) { func TestParseWithDefaultAlert(t *testing.T) {
+77 -27
View File
@@ -2,32 +2,49 @@ package slack
import ( import (
"bytes" "bytes"
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/client" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
) )
// AlertProvider is the configuration necessary for sending an alert using Slack // AlertProvider is the configuration necessary for sending an alert using Slack
type AlertProvider struct { type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"` // Slack webhook URL WebhookURL string `yaml:"webhook-url"` // Slack webhook URL
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
WebhookURL string `yaml:"webhook-url"`
} }
// IsValid returns whether the provider's configuration is valid // IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) IsValid() bool {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
return false
}
registeredGroups[override.Group] = true
}
}
return len(provider.WebhookURL) > 0 return len(provider.WebhookURL) > 0
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { 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.WebhookURL, buffer) request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer)
if err != nil { if err != nil {
return err return err
} }
@@ -36,6 +53,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
if err != nil { if err != nil {
return err return err
} }
defer response.Body.Close()
if response.StatusCode > 399 { if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body) body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(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 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 // 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 var message, color, results string
if resolved { if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold) message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
@@ -60,30 +97,43 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
} else { } else {
prefix = ":x:" prefix = ":x:"
} }
results += fmt.Sprintf("%s - `%s`\\n", prefix, conditionResult.Condition) results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
} }
var description string var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\\n> " + alertDescription description = ":\n> " + alertDescription
} }
return fmt.Sprintf(`{ body, _ := json.Marshal(Body{
"text": "", Text: "",
"attachments": [ Attachments: []Attachment{
{ {
"title": ":helmet_with_white_cross: Gatus", Title: ":helmet_with_white_cross: Gatus",
"text": "%s%s", Text: message + description,
"short": false, Short: false,
"color": "%s", Color: color,
"fields": [ Fields: []Field{
{ {
"title": "Condition results", Title: "Condition results",
"value": "%s", Value: results,
"short": false Short: false,
} },
] },
} },
] },
}`, message, description, color, results) })
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 // GetDefaultAlert returns the provider's default alert configuration
+113 -13
View File
@@ -5,13 +5,13 @@ import (
"net/http" "net/http"
"testing" "testing"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/client" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v3/test" "github.com/TwiN/gatus/v4/test"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertDefaultProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{WebhookURL: ""} invalidProvider := AlertProvider{WebhookURL: ""}
if invalidProvider.IsValid() { if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
@@ -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) { func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil) defer client.InjectHTTPClient(nil)
firstDescription := "description-1" firstDescription := "description-1"
@@ -79,7 +116,7 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send( err := scenario.Provider.Send(
&core.Endpoint{Name: "name"}, &core.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &core.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*core.ConditionResult{
@@ -116,7 +153,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Endpoint: core.Endpoint{Name: "name"}, Endpoint: core.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, 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", Name: "triggered-with-group",
@@ -124,7 +161,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Endpoint: core.Endpoint{Name: "name", Group: "group"}, Endpoint: core.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, 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", Name: "resolved",
@@ -132,7 +169,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Endpoint: core.Endpoint{Name: "name"}, Endpoint: core.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, 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", Name: "resolved-with-group",
@@ -140,7 +177,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Endpoint: core.Endpoint{Name: "name", Group: "group"}, Endpoint: core.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, 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 { for _, scenario := range scenarios {
@@ -156,11 +193,11 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}, },
scenario.Resolved, scenario.Resolved,
) )
if body != scenario.ExpectedBody { if string(body) != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body) t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
} }
out := make(map[string]interface{}) 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()) 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") 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)
}
})
}
}
+67 -24
View File
@@ -2,13 +2,14 @@ package teams
import ( import (
"bytes" "bytes"
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/client" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
) )
// AlertProvider is the configuration necessary for sending an alert using Teams // AlertProvider is the configuration necessary for sending an alert using Teams
@@ -17,17 +18,35 @@ type AlertProvider struct {
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
WebhookURL string `yaml:"webhook-url"`
} }
// IsValid returns whether the provider's configuration is valid // IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) IsValid() bool {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
return false
}
registeredGroups[override.Group] = true
}
}
return len(provider.WebhookURL) > 0 return len(provider.WebhookURL) > 0
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { 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.WebhookURL, buffer) request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer)
if err != nil { if err != nil {
return err return err
} }
@@ -36,6 +55,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
if err != nil { if err != nil {
return err return err
} }
defer response.Body.Close()
if response.StatusCode > 399 { if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body) body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body)) return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
@@ -43,8 +63,22 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
return err 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 // 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 var message, color string
if resolved { if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold) message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
@@ -65,25 +99,34 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
} }
var description string var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\\n> " + alertDescription description = ": " + alertDescription
} }
return fmt.Sprintf(`{ body, _ := json.Marshal(Body{
"@type": "MessageCard", Type: "MessageCard",
"@context": "http://schema.org/extensions", Context: "http://schema.org/extensions",
"themeColor": "%s", ThemeColor: color,
"title": "&#x1F6A8; Gatus", Title: "&#x1F6A8; Gatus",
"text": "%s%s", Text: message + description,
"sections": [ Sections: []Section{
{ {
"activityTitle": "URL", ActivityTitle: "Condition results",
"text": "%s" Text: results,
}, },
{ },
"activityTitle": "Condition results", })
"text": "%s" return body
} }
]
}`, color, message, description, endpoint.URL, results) // getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
return override.WebhookURL
}
}
}
return provider.WebhookURL
} }
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
+110 -10
View File
@@ -5,13 +5,13 @@ import (
"net/http" "net/http"
"testing" "testing"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/client" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v3/test" "github.com/TwiN/gatus/v4/test"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertDefaultProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{WebhookURL: ""} invalidProvider := AlertProvider{WebhookURL: ""}
if invalidProvider.IsValid() { if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
@@ -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) { func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil) defer client.InjectHTTPClient(nil)
firstDescription := "description-1" firstDescription := "description-1"
@@ -114,14 +151,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider: AlertProvider{}, Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, 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", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, 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 { for _, scenario := range scenarios {
@@ -137,11 +174,11 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}, },
scenario.Resolved, scenario.Resolved,
) )
if body != scenario.ExpectedBody { if string(body) != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body) t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
} }
out := make(map[string]interface{}) 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()) t.Error("expected body to be valid JSON, got error:", err.Error())
} }
}) })
@@ -156,3 +193,66 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
t.Error("expected default alert to be nil") t.Error("expected default alert to be nil")
} }
} }
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
tests := []struct {
Name string
Provider AlertProvider
InputGroup string
ExpectedOutput string
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: nil,
},
InputGroup: "",
ExpectedOutput: "http://example.com",
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: nil,
},
InputGroup: "group",
ExpectedOutput: "http://example.com",
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: []Override{
{
Group: "group",
WebhookURL: "http://example01.com",
},
},
},
InputGroup: "",
ExpectedOutput: "http://example.com",
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: []Override{
{
Group: "group",
WebhookURL: "http://example01.com",
},
},
},
InputGroup: "group",
ExpectedOutput: "http://example01.com",
},
}
for _, tt := range tests {
t.Run(tt.Name, func(t *testing.T) {
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
}
})
}
}
+41 -15
View File
@@ -2,19 +2,26 @@ package telegram
import ( import (
"bytes" "bytes"
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/client" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
) )
const defaultAPIURL = "https://api.telegram.org"
// AlertProvider is the configuration necessary for sending an alert using Telegram // AlertProvider is the configuration necessary for sending an alert using Telegram
type AlertProvider struct { type AlertProvider struct {
Token string `yaml:"token"` Token string `yaml:"token"`
ID string `yaml:"id"` ID string `yaml:"id"`
APIURL string `yaml:"api-url"`
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
@@ -22,21 +29,29 @@ type AlertProvider struct {
// IsValid returns whether the provider's configuration is valid // IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) IsValid() bool {
if provider.ClientConfig == nil {
provider.ClientConfig = client.GetDefaultConfig()
}
return len(provider.Token) > 0 && len(provider.ID) > 0 return len(provider.Token) > 0 && len(provider.ID) > 0
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { 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, fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token), buffer) apiURL := provider.APIURL
if apiURL == "" {
apiURL = defaultAPIURL
}
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/bot%s/sendMessage", apiURL, provider.Token), buffer)
if err != nil { if err != nil {
return err return err
} }
request.Header.Set("Content-Type", "application/json") 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 { if err != nil {
return err return err
} }
defer response.Body.Close()
if response.StatusCode > 399 { if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body) body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body)) return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
@@ -44,13 +59,19 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
return err 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 // 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 message, results string
if resolved { if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved:\\n—\\n _healthcheck passing successfully %d time(s) in a row_\\n— ", endpoint.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 { } 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 { for _, conditionResult := range result.ConditionResults {
var prefix string var prefix string
@@ -59,15 +80,20 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
} else { } else {
prefix = "❌" prefix = "❌"
} }
results += fmt.Sprintf("%s - `%s`\\n", prefix, conditionResult.Condition) results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
} }
var text string var text string
if len(alert.GetDescription()) > 0 { 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 { } 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 // GetDefaultAlert returns the provider's default alert configuration
+27 -17
View File
@@ -5,21 +5,31 @@ import (
"net/http" "net/http"
"testing" "testing"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/client" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v3/test" "github.com/TwiN/gatus/v4/test"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{Token: "", ID: ""} t.Run("invalid-provider", func(t *testing.T) {
if invalidProvider.IsValid() { invalidProvider := AlertProvider{Token: "", ID: ""}
t.Error("provider shouldn't have been valid") 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("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) { func TestAlertProvider_Send(t *testing.T) {
@@ -114,14 +124,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider: AlertProvider{ID: "123"}, Provider: AlertProvider{ID: "123"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, 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", Name: "resolved",
Provider: AlertProvider{ID: "123"}, Provider: AlertProvider{ID: "123"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, 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 { for _, scenario := range scenarios {
@@ -137,11 +147,11 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}, },
scenario.Resolved, scenario.Resolved,
) )
if body != scenario.ExpectedBody { if string(body) != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body) t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
} }
out := make(map[string]interface{}) 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()) t.Error("expected body to be valid JSON, got error:", err.Error())
} }
}) })
+4 -3
View File
@@ -8,9 +8,9 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/client" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
) )
// AlertProvider is the configuration necessary for sending an alert using Twilio // 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 { if err != nil {
return err return err
} }
defer response.Body.Close()
if response.StatusCode > 399 { if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body) body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(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 ( import (
"testing" "testing"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
) )
func TestTwilioAlertProvider_IsValid(t *testing.T) { func TestTwilioAlertProvider_IsValid(t *testing.T) {
+76 -3
View File
@@ -4,6 +4,7 @@ import (
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"errors" "errors"
"fmt"
"net" "net"
"net/http" "net/http"
"net/smtp" "net/smtp"
@@ -11,13 +12,21 @@ import (
"strings" "strings"
"time" "time"
"github.com/TwiN/gocache/v2"
"github.com/TwiN/whois"
"github.com/go-ping/ping" "github.com/go-ping/ping"
"github.com/ishidawataru/sctp"
) )
// injectedHTTPClient is used for testing purposes var (
var injectedHTTPClient *http.Client // 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 { func GetHTTPClient(config *Config) *http.Client {
if injectedHTTPClient != nil { if injectedHTTPClient != nil {
return injectedHTTPClient return injectedHTTPClient
@@ -28,6 +37,35 @@ func GetHTTPClient(config *Config) *http.Client {
return config.getHTTPClient() 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 // CanCreateTCPConnection checks whether a connection can be established with a TCP endpoint
func CanCreateTCPConnection(address string, config *Config) bool { func CanCreateTCPConnection(address string, config *Config) bool {
conn, err := net.DialTimeout("tcp", address, config.Timeout) conn, err := net.DialTimeout("tcp", address, config.Timeout)
@@ -38,6 +76,41 @@ func CanCreateTCPConnection(address string, config *Config) bool {
return true 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 // 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) { func CanPerformStartTLS(address string, config *Config) (connected bool, certificate *x509.Certificate, err error) {
hostAndPort := strings.Split(address, ":") hostAndPort := strings.Split(address, ":")
+101 -1
View File
@@ -1,8 +1,13 @@
package client package client
import ( import (
"bytes"
"io"
"net/http"
"testing" "testing"
"time" "time"
"github.com/TwiN/gatus/v4/test"
) )
func TestGetHTTPClient(t *testing.T) { func TestGetHTTPClient(t *testing.T) {
@@ -10,8 +15,18 @@ func TestGetHTTPClient(t *testing.T) {
Insecure: false, Insecure: false,
IgnoreRedirect: false, IgnoreRedirect: false,
Timeout: 0, Timeout: 0,
DNSResolver: "tcp://1.1.1.1:53",
OAuth2Config: &OAuth2Config{
ClientID: "00000000-0000-0000-0000-000000000000",
ClientSecret: "secretsauce",
TokenURL: "https://token-server.local/token",
Scopes: []string{"https://application.local/.default"},
},
}
err := cfg.ValidateAndSetDefaults()
if err != nil {
t.Errorf("expected error to be nil, but got: `%s`", err)
} }
cfg.ValidateAndSetDefaults()
if GetHTTPClient(cfg) == nil { if GetHTTPClient(cfg) == nil {
t.Error("expected client to not be nil") t.Error("expected client to not be nil")
} }
@@ -20,6 +35,33 @@ func TestGetHTTPClient(t *testing.T) {
} }
} }
func TestGetDomainExpiration(t *testing.T) {
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) { func TestPing(t *testing.T) {
if success, rtt := Ping("127.0.0.1", &Config{Timeout: 500 * time.Millisecond}); !success { if success, rtt := Ping("127.0.0.1", &Config{Timeout: 500 * time.Millisecond}); !success {
t.Error("expected true") t.Error("expected true")
@@ -146,3 +188,61 @@ func TestCanCreateTCPConnection(t *testing.T) {
t.Error("should've failed, because there's no port in the address") t.Error("should've failed, because there's no port in the address")
} }
} }
// This test checks if a HTTP client configured with `configureOAuth2()` automatically
// performs a Client Credentials OAuth2 flow and adds the obtained token as a `Authorization`
// header to all outgoing HTTP calls.
func TestHttpClientProvidesOAuth2BearerToken(t *testing.T) {
defer InjectHTTPClient(nil)
oAuth2Config := &OAuth2Config{
ClientID: "00000000-0000-0000-0000-000000000000",
ClientSecret: "secretsauce",
TokenURL: "https://token-server.local/token",
Scopes: []string{"https://application.local/.default"},
}
mockHttpClient := &http.Client{
Transport: test.MockRoundTripper(func(r *http.Request) *http.Response {
// if the mock HTTP client tries to get a token from the `token-server`
// we provide the expected token response
if r.Host == "token-server.local" {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(
[]byte(
`{"token_type":"Bearer","expires_in":3599,"ext_expires_in":3599,"access_token":"secret-token"}`,
),
)),
}
}
// to verify the headers were sent as expected, we echo them back in the
// `X-Org-Authorization` header and check if the token value matches our
// mocked `token-server` response
return &http.Response{
StatusCode: http.StatusOK,
Header: map[string][]string{
"X-Org-Authorization": {r.Header.Get("Authorization")},
},
Body: http.NoBody,
}
}),
}
mockHttpClientWithOAuth := configureOAuth2(mockHttpClient, *oAuth2Config)
InjectHTTPClient(mockHttpClientWithOAuth)
request, err := http.NewRequest(http.MethodPost, "http://127.0.0.1:8282", http.NoBody)
if err != nil {
t.Error("expected no error, got", err.Error())
}
response, err := mockHttpClientWithOAuth.Do(request)
if err != nil {
t.Error("expected no error, got", err.Error())
}
if response.Header == nil {
t.Error("expected response headers, but got nil")
}
// the mock response echos the Authorization header used in the request back
// to us as `X-Org-Authorization` header, we check here if the value matches
// our expected token `secret-token`
if response.Header.Get("X-Org-Authorization") != "Bearer secret-token" {
t.Error("exptected `secret-token` as Bearer token in the mocked response header `X-Org-Authorization`, but got", response.Header.Get("X-Org-Authorization"))
}
}
+130 -4
View File
@@ -1,9 +1,18 @@
package client package client
import ( import (
"context"
"crypto/tls" "crypto/tls"
"errors"
"log"
"net"
"net/http" "net/http"
"regexp"
"strconv"
"time" "time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/clientcredentials"
) )
const ( const (
@@ -11,7 +20,10 @@ const (
) )
var ( var (
// DefaultConfig is the default client configuration ErrInvalidDNSResolver = errors.New("invalid DNS resolver specified. Required format is {proto}://{ip}:{port}")
ErrInvalidDNSResolverPort = errors.New("invalid DNS resolver port")
ErrInvalidClientOAuth2Config = errors.New("invalid OAuth2 configuration, all fields are required")
defaultConfig = Config{ defaultConfig = Config{
Insecure: false, Insecure: false,
IgnoreRedirect: false, IgnoreRedirect: false,
@@ -28,22 +40,99 @@ func GetDefaultConfig() *Config {
// Config is the configuration for clients // Config is the configuration for clients
type Config struct { type Config struct {
// Insecure determines whether to skip verifying the server's certificate chain and host name // Insecure determines whether to skip verifying the server's certificate chain and host name
Insecure bool `yaml:"insecure"` Insecure bool `yaml:"insecure,omitempty"`
// IgnoreRedirect determines whether to ignore redirects (true) or follow them (false, default) // IgnoreRedirect determines whether to ignore redirects (true) or follow them (false, default)
IgnoreRedirect bool `yaml:"ignore-redirect"` IgnoreRedirect bool `yaml:"ignore-redirect,omitempty"`
// Timeout for the client // Timeout for the client
Timeout time.Duration `yaml:"timeout"` Timeout time.Duration `yaml:"timeout"`
// DNSResolver override for the HTTP client
// Expected format is {protocol}://{host}:{port}, e.g. tcp://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.
// See configureOAuth2 for more details.
OAuth2Config *OAuth2Config `yaml:"oauth2,omitempty"`
httpClient *http.Client httpClient *http.Client
} }
// DNSResolverConfig is the parsed configuration from the DNSResolver config string.
type DNSResolverConfig struct {
Protocol string
Host string
Port string
}
// OAuth2Config is the configuration for the OAuth2 client credentials flow
type OAuth2Config struct {
TokenURL string `yaml:"token-url"` // e.g. https://dev-12345678.okta.com/token
ClientID string `yaml:"client-id"`
ClientSecret string `yaml:"client-secret"`
Scopes []string `yaml:"scopes"` // e.g. ["openid"]
}
// ValidateAndSetDefaults validates the client configuration and sets the default values if necessary // ValidateAndSetDefaults validates the client configuration and sets the default values if necessary
func (c *Config) ValidateAndSetDefaults() { func (c *Config) ValidateAndSetDefaults() error {
if c.Timeout < time.Millisecond { if c.Timeout < time.Millisecond {
c.Timeout = 10 * time.Second c.Timeout = 10 * time.Second
} }
if c.HasCustomDNSResolver() {
// Validate the DNS resolver now to make sure it will not return an error later.
if _, err := c.parseDNSResolver(); err != nil {
return err
}
}
if c.HasOAuth2Config() && !c.OAuth2Config.isValid() {
return ErrInvalidClientOAuth2Config
}
return nil
}
// HasCustomDNSResolver returns whether a custom DNSResolver is configured
func (c *Config) HasCustomDNSResolver() bool {
return len(c.DNSResolver) > 0
}
// parseDNSResolver parses the DNS resolver into the DNSResolverConfig struct
func (c *Config) parseDNSResolver() (*DNSResolverConfig, error) {
re := regexp.MustCompile(`^(?P<proto>(.*))://(?P<host>[A-Za-z0-9\-\.]+):(?P<port>[0-9]+)?(.*)$`)
matches := re.FindStringSubmatch(c.DNSResolver)
if len(matches) == 0 {
return nil, ErrInvalidDNSResolver
}
r := make(map[string]string)
for i, k := range re.SubexpNames() {
if i != 0 && k != "" {
r[k] = matches[i]
}
}
port, err := strconv.Atoi(r["port"])
if err != nil {
return nil, err
}
if port < 1 || port > 65535 {
return nil, ErrInvalidDNSResolverPort
}
return &DNSResolverConfig{
Protocol: r["proto"],
Host: r["host"],
Port: r["port"],
}, nil
}
// HasOAuth2Config returns true if the client has OAuth2 configuration parameters
func (c *Config) HasOAuth2Config() bool {
return c.OAuth2Config != nil
}
// isValid() returns true if the OAuth2 configuration is valid
func (c *OAuth2Config) isValid() bool {
return len(c.TokenURL) > 0 && len(c.ClientID) > 0 && len(c.ClientSecret) > 0 && len(c.Scopes) > 0
} }
// GetHTTPClient return an HTTP client matching the Config's parameters. // GetHTTPClient return an HTTP client matching the Config's parameters.
@@ -68,6 +157,43 @@ func (c *Config) getHTTPClient() *http.Client {
return nil return nil
}, },
} }
if c.HasCustomDNSResolver() {
dnsResolver, err := c.parseDNSResolver()
if err != nil {
// We're ignoring the error, because it should have been validated on startup ValidateAndSetDefaults.
// It shouldn't happen, but if it does, we'll log it... Better safe than sorry ;)
log.Println("[client][getHTTPClient] THIS SHOULD NOT HAPPEN. Silently ignoring invalid DNS resolver due to error:", err.Error())
} else {
dialer := &net.Dialer{
Resolver: &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
d := net.Dialer{}
return d.DialContext(ctx, dnsResolver.Protocol, dnsResolver.Host+":"+dnsResolver.Port)
},
},
}
c.httpClient.Transport.(*http.Transport).DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.DialContext(ctx, network, addr)
}
}
}
if c.HasOAuth2Config() {
c.httpClient = configureOAuth2(c.httpClient, *c.OAuth2Config)
}
} }
return c.httpClient return c.httpClient
} }
// configureOAuth2 returns an HTTP client that will obtain and refresh tokens as necessary.
// The returned Client and its Transport should not be modified.
func configureOAuth2(httpClient *http.Client, c OAuth2Config) *http.Client {
oauth2cfg := clientcredentials.Config{
ClientID: c.ClientID,
ClientSecret: c.ClientSecret,
Scopes: c.Scopes,
TokenURL: c.TokenURL,
}
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, httpClient)
return oauth2cfg.Client(ctx)
}
+44
View File
@@ -35,3 +35,47 @@ func TestConfig_getHTTPClient(t *testing.T) {
t.Error("expected Config.IgnoreRedirect set to true to cause the HTTP client's CheckRedirect to return http.ErrUseLastResponse") t.Error("expected Config.IgnoreRedirect set to true to cause the HTTP client's CheckRedirect to return http.ErrUseLastResponse")
} }
} }
func TestConfig_ValidateAndSetDefaults_withCustomDNSResolver(t *testing.T) {
type args struct {
dnsResolver string
}
tests := []struct {
name string
args args
wantErr bool
}{
{
name: "with-valid-resolver",
args: args{
dnsResolver: "tcp://1.1.1.1:53",
},
wantErr: false,
},
{
name: "with-invalid-resolver-port",
args: args{
dnsResolver: "tcp://127.0.0.1:99999",
},
wantErr: true,
},
{
name: "with-invalid-resolver-format",
args: args{
dnsResolver: "foobar",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &Config{
DNSResolver: tt.args.dnsResolver,
}
err := cfg.ValidateAndSetDefaults()
if (err != nil) != tt.wantErr {
t.Errorf("ValidateAndSetDefaults() error=%v, wantErr=%v", err, tt.wantErr)
}
})
}
}
+6
View File
@@ -45,3 +45,9 @@ endpoints:
interval: 1m interval: 1m
conditions: conditions:
- "[CONNECTED] == true" - "[CONNECTED] == true"
- name: check-domain-expiration
url: "https://example.org/"
interval: 1h
conditions:
- "[DOMAIN_EXPIRATION] > 720h"
+42 -10
View File
@@ -2,19 +2,22 @@ package config
import ( import (
"errors" "errors"
"fmt"
"log" "log"
"os" "os"
"time" "time"
"github.com/TwiN/gatus/v3/alerting" "github.com/TwiN/gatus/v4/alerting"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/alerting/provider" "github.com/TwiN/gatus/v4/alerting/provider"
"github.com/TwiN/gatus/v3/config/maintenance" "github.com/TwiN/gatus/v4/config/maintenance"
"github.com/TwiN/gatus/v3/config/ui" "github.com/TwiN/gatus/v4/config/remote"
"github.com/TwiN/gatus/v3/config/web" "github.com/TwiN/gatus/v4/config/ui"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/config/web"
"github.com/TwiN/gatus/v3/security" "github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v3/storage" "github.com/TwiN/gatus/v4/security"
"github.com/TwiN/gatus/v4/storage"
"github.com/TwiN/gatus/v4/util"
"gopkg.in/yaml.v2" "gopkg.in/yaml.v2"
) )
@@ -85,10 +88,24 @@ type Config struct {
// Maintenance is the configuration for creating a maintenance window in which no alerts are sent // Maintenance is the configuration for creating a maintenance window in which no alerts are sent
Maintenance *maintenance.Config `yaml:"maintenance,omitempty"` Maintenance *maintenance.Config `yaml:"maintenance,omitempty"`
// Remote is the configuration for remote Gatus instances
// WARNING: This is in ALPHA and may change or be completely removed in the future
Remote *remote.Config `yaml:"remote,omitempty"`
filePath string // path to the file from which config was loaded from filePath string // path to the file from which config was loaded from
lastFileModTime time.Time // last modification time lastFileModTime time.Time // last modification time
} }
func (config *Config) GetEndpointByKey(key string) *core.Endpoint {
for i := 0; i < len(config.Endpoints); i++ {
ep := config.Endpoints[i]
if util.ConvertGroupAndEndpointNameToKey(ep.Group, ep.Name) == key {
return ep
}
}
return nil
}
// HasLoadedConfigurationFileBeenModified returns whether the file that the // HasLoadedConfigurationFileBeenModified returns whether the file that the
// configuration has been loaded from has been modified since it was last read // configuration has been loaded from has been modified since it was last read
func (config Config) HasLoadedConfigurationFileBeenModified() bool { func (config Config) HasLoadedConfigurationFileBeenModified() bool {
@@ -185,10 +202,22 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
if err := validateStorageConfig(config); err != nil { if err := validateStorageConfig(config); err != nil {
return nil, err return nil, err
} }
if err := validateRemoteConfig(config); err != nil {
return nil, err
}
} }
return return
} }
func validateRemoteConfig(config *Config) error {
if config.Remote != nil {
if err := config.Remote.ValidateAndSetDefaults(); err != nil {
return err
}
}
return nil
}
func validateStorageConfig(config *Config) error { func validateStorageConfig(config *Config) error {
if config.Storage == nil { if config.Storage == nil {
config.Storage = &storage.Config{ config.Storage = &storage.Config{
@@ -239,7 +268,7 @@ func validateEndpointsConfig(config *Config) error {
log.Printf("[config][validateEndpointsConfig] Validating endpoint '%s'", endpoint.Name) log.Printf("[config][validateEndpointsConfig] Validating endpoint '%s'", endpoint.Name)
} }
if err := endpoint.ValidateAndSetDefaults(); err != nil { if err := endpoint.ValidateAndSetDefaults(); err != nil {
return err return fmt.Errorf("invalid endpoint %s: %w", endpoint.DisplayName(), err)
} }
} }
log.Printf("[config][validateEndpointsConfig] Validated %d endpoints", len(config.Endpoints)) log.Printf("[config][validateEndpointsConfig] Validated %d endpoints", len(config.Endpoints))
@@ -273,9 +302,12 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*core.E
alertTypes := []alert.Type{ alertTypes := []alert.Type{
alert.TypeCustom, alert.TypeCustom,
alert.TypeDiscord, alert.TypeDiscord,
alert.TypeGoogleChat,
alert.TypeEmail, alert.TypeEmail,
alert.TypeMatrix,
alert.TypeMattermost, alert.TypeMattermost,
alert.TypeMessagebird, alert.TypeMessagebird,
alert.TypeNtfy,
alert.TypeOpsgenie, alert.TypeOpsgenie,
alert.TypePagerDuty, alert.TypePagerDuty,
alert.TypeSlack, alert.TypeSlack,
+27 -75
View File
@@ -5,22 +5,21 @@ import (
"testing" "testing"
"time" "time"
"github.com/TwiN/gatus/v3/alerting" "github.com/TwiN/gatus/v4/alerting"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/alerting/provider/custom" "github.com/TwiN/gatus/v4/alerting/provider/custom"
"github.com/TwiN/gatus/v3/alerting/provider/discord" "github.com/TwiN/gatus/v4/alerting/provider/discord"
"github.com/TwiN/gatus/v3/alerting/provider/mattermost" "github.com/TwiN/gatus/v4/alerting/provider/mattermost"
"github.com/TwiN/gatus/v3/alerting/provider/messagebird" "github.com/TwiN/gatus/v4/alerting/provider/messagebird"
"github.com/TwiN/gatus/v3/alerting/provider/pagerduty" "github.com/TwiN/gatus/v4/alerting/provider/pagerduty"
"github.com/TwiN/gatus/v3/alerting/provider/slack" "github.com/TwiN/gatus/v4/alerting/provider/slack"
"github.com/TwiN/gatus/v3/alerting/provider/teams" "github.com/TwiN/gatus/v4/alerting/provider/teams"
"github.com/TwiN/gatus/v3/alerting/provider/telegram" "github.com/TwiN/gatus/v4/alerting/provider/telegram"
"github.com/TwiN/gatus/v3/alerting/provider/twilio" "github.com/TwiN/gatus/v4/alerting/provider/twilio"
"github.com/TwiN/gatus/v3/client" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v3/config/ui" "github.com/TwiN/gatus/v4/config/web"
"github.com/TwiN/gatus/v3/config/web" "github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/storage"
"github.com/TwiN/gatus/v3/storage"
) )
func TestLoadFileThatDoesNotExist(t *testing.T) { func TestLoadFileThatDoesNotExist(t *testing.T) {
@@ -39,10 +38,6 @@ func TestLoadDefaultConfigurationFile(t *testing.T) {
func TestParseAndValidateConfigBytes(t *testing.T) { func TestParseAndValidateConfigBytes(t *testing.T) {
file := t.TempDir() + "/test.db" file := t.TempDir() + "/test.db"
ui.StaticFolder = "../web/static"
defer func() {
ui.StaticFolder = "./web/static"
}()
config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(` config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`
storage: storage:
type: sqlite type: sqlite
@@ -53,7 +48,14 @@ maintenance:
duration: 4h duration: 4h
every: [Monday, Thursday] every: [Monday, Thursday]
ui: ui:
title: Test title: T
header: H
link: https://example.org
buttons:
- name: "Home"
link: "https://example.org"
- name: "Status page"
link: "https://status.example.org"
endpoints: endpoints:
- name: website - name: website
url: https://twin.sh/health url: https://twin.sh/health
@@ -88,8 +90,8 @@ endpoints:
if config.Storage == nil || config.Storage.Path != file || config.Storage.Type != storage.TypeSQLite { if config.Storage == nil || config.Storage.Path != file || config.Storage.Type != storage.TypeSQLite {
t.Error("expected storage to be set to sqlite, got", config.Storage) t.Error("expected storage to be set to sqlite, got", config.Storage)
} }
if config.UI == nil || config.UI.Title != "Test" { if config.UI == nil || config.UI.Title != "T" || config.UI.Header != "H" || config.UI.Link != "https://example.org" || len(config.UI.Buttons) != 2 || config.UI.Buttons[0].Name != "Home" || config.UI.Buttons[0].Link != "https://example.org" || config.UI.Buttons[1].Name != "Status page" || config.UI.Buttons[1].Link != "https://status.example.org" {
t.Error("Expected Config.UI.Title to be Test") t.Error("expected ui to be set to T, H, https://example.org, 2 buttons, Home and Status page, got", config.UI)
} }
if mc := config.Maintenance; mc == nil || mc.Start != "00:00" || !mc.IsEnabled() || mc.Duration != 4*time.Hour || len(mc.Every) != 2 { if mc := config.Maintenance; mc == nil || mc.Start != "00:00" || !mc.IsEnabled() || mc.Duration != 4*time.Hour || len(mc.Every) != 2 {
t.Error("Expected Config.Maintenance to be configured properly") t.Error("Expected Config.Maintenance to be configured properly")
@@ -1118,7 +1120,7 @@ endpoints:
conditions: conditions:
- "[STATUS] == 200" - "[STATUS] == 200"
`)) `))
if err != core.ErrEndpointWithNoName { if err == nil {
t.Error("should've returned an error") t.Error("should've returned an error")
} }
} }
@@ -1203,7 +1205,7 @@ endpoints:
t.Errorf("config.Security.Basic.Username should've been %s, but was %s", expectedUsername, config.Security.Basic.Username) t.Errorf("config.Security.Basic.Username should've been %s, but was %s", expectedUsername, config.Security.Basic.Username)
} }
if config.Security.Basic.PasswordBcryptHashBase64Encoded != expectedPasswordHash { 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)
} }
} }
@@ -1302,53 +1304,3 @@ endpoints:
t.Error("services should've been merged in endpoints") t.Error("services should've been merged in endpoints")
} }
} }
// XXX: Remove this in v4.0.0
func TestParseAndValidateConfigBytes_backwardCompatibleWithStorageFile(t *testing.T) {
file := t.TempDir() + "/test.db"
config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`
storage:
type: sqlite
file: %s
endpoints:
- name: website
url: https://twin.sh/actuator/health
conditions:
- "[STATUS] == 200"
`, file)))
if err != nil {
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
}
if config.Storage == nil || config.Storage.Path != file || config.Storage.Type != storage.TypeSQLite {
t.Error("expected storage to be set to sqlite, got", config.Storage)
}
}
// XXX: Remove this in v4.0.0
func TestParseAndValidateConfigBytes_backwardCompatibleWithStorageTypeMemoryAndFile(t *testing.T) {
file := t.TempDir() + "/test.db"
config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`
storage:
type: memory
file: %s
endpoints:
- name: website
url: https://twin.sh/actuator/health
conditions:
- "[STATUS] == 200"
`, file)))
if err != nil {
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
}
if config.Storage == nil || config.Storage.Path != file || config.Storage.Type != storage.TypeMemory {
t.Error("expected storage to be set to memory, got", config.Storage)
}
}
+35
View File
@@ -98,6 +98,15 @@ func TestConfig_ValidateAndSetDefaults(t *testing.T) {
}, },
expectedError: nil, expectedError: nil,
}, },
{
name: "every-day-explicitly-at-2300",
cfg: &Config{
Start: "23:00",
Duration: time.Hour,
Every: []string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"},
},
expectedError: nil,
},
{ {
name: "every-monday-at-0000", name: "every-monday-at-0000",
cfg: &Config{ cfg: &Config{
@@ -168,6 +177,24 @@ func TestConfig_IsUnderMaintenance(t *testing.T) {
}, },
expected: true, expected: true,
}, },
{
name: "under-maintenance-starting-now-for-8h-explicit-days",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", now.Hour()),
Duration: 8 * time.Hour,
Every: []string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"},
},
expected: true,
},
{
name: "under-maintenance-starting-now-for-23h-explicit-days",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", now.Hour()),
Duration: 23 * time.Hour,
Every: []string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"},
},
expected: true,
},
{ {
name: "under-maintenance-starting-4h-ago-for-8h", name: "under-maintenance-starting-4h-ago-for-8h",
cfg: &Config{ cfg: &Config{
@@ -176,6 +203,14 @@ func TestConfig_IsUnderMaintenance(t *testing.T) {
}, },
expected: true, expected: true,
}, },
{
name: "under-maintenance-starting-22h-ago-for-23h",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", normalizeHour(now.Hour()-22)),
Duration: 23 * time.Hour,
},
expected: true,
},
{ {
name: "under-maintenance-starting-4h-ago-for-3h", name: "under-maintenance-starting-4h-ago-for-3h",
cfg: &Config{ cfg: &Config{
+39
View File
@@ -0,0 +1,39 @@
package remote
import (
"log"
"github.com/TwiN/gatus/v4/client"
)
// NOTICE: This is an experimental alpha feature and may be updated/removed in future versions.
// For more information, see https://github.com/TwiN/gatus/issues/64
type Config struct {
// Instances is a list of remote instances to retrieve endpoint statuses from.
Instances []Instance `yaml:"instances,omitempty"`
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"`
}
type Instance struct {
EndpointPrefix string `yaml:"endpoint-prefix"`
URL string `yaml:"url"`
}
func (c *Config) ValidateAndSetDefaults() error {
if c.ClientConfig == nil {
c.ClientConfig = client.GetDefaultConfig()
} else {
if err := c.ClientConfig.ValidateAndSetDefaults(); err != nil {
return err
}
}
if len(c.Instances) > 0 {
log.Println("WARNING: Your configuration is using 'remote', which is in alpha and may be updated/removed in future versions.")
log.Println("WARNING: See https://github.com/TwiN/gatus/issues/64 for more information")
log.Println("WARNING: This feature is a candidate for removal in future versions. Please comment on the issue above if you need this feature.")
}
return nil
}
+44 -16
View File
@@ -2,37 +2,56 @@ package ui
import ( import (
"bytes" "bytes"
"errors"
"html/template" "html/template"
"github.com/TwiN/gatus/v4/web"
) )
const ( const (
defaultTitle = "Health Dashboard | Gatus" defaultTitle = "Health Dashboard | Gatus"
defaultHeader = "Health Status" 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"
defaultLogo = "" defaultHeader = "Health Status"
defaultLink = "" defaultLogo = ""
defaultLink = ""
) )
var ( var (
// StaticFolder is the path to the location of the static folder from the root path of the project ErrButtonValidationFailed = errors.New("invalid button configuration: missing required name or link")
// The only reason this is exposed is to allow running tests from a different path than the root path of the project
StaticFolder = "./web/static"
) )
// Config is the configuration for the UI of Gatus // Config is the configuration for the UI of Gatus
type Config struct { type Config struct {
Title string `yaml:"title,omitempty"` // Title of the page Title string `yaml:"title,omitempty"` // Title of the page
Header string `yaml:"header,omitempty"` // Header is the text at the top of the page Description string `yaml:"description,omitempty"` // Meta description of the page
Logo string `yaml:"logo,omitempty"` // Logo to display on the page Header string `yaml:"header,omitempty"` // Header is the text at the top of the page
Link string `yaml:"link,omitempty"` // Link to open when clicking on the logo Logo string `yaml:"logo,omitempty"` // Logo to display on the page
Link string `yaml:"link,omitempty"` // Link to open when clicking on the logo
Buttons []Button `yaml:"buttons,omitempty"` // Buttons to display below the header
}
// Button is the configuration for a button on the UI
type Button struct {
Name string `yaml:"name,omitempty"` // Name is the text to display on the button
Link string `yaml:"link,omitempty"` // Link to open when the button is clicked.
}
// Validate validates the button configuration
func (btn *Button) Validate() error {
if len(btn.Name) == 0 || len(btn.Link) == 0 {
return ErrButtonValidationFailed
}
return nil
} }
// GetDefaultConfig returns a Config struct with the default values // GetDefaultConfig returns a Config struct with the default values
func GetDefaultConfig() *Config { func GetDefaultConfig() *Config {
return &Config{ return &Config{
Title: defaultTitle, Title: defaultTitle,
Header: defaultHeader, Description: defaultDescription,
Logo: defaultLogo, Header: defaultHeader,
Link: defaultLink, Logo: defaultLogo,
Link: defaultLink,
} }
} }
@@ -41,13 +60,22 @@ func (cfg *Config) ValidateAndSetDefaults() error {
if len(cfg.Title) == 0 { if len(cfg.Title) == 0 {
cfg.Title = defaultTitle cfg.Title = defaultTitle
} }
if len(cfg.Description) == 0 {
cfg.Description = defaultDescription
}
if len(cfg.Header) == 0 { if len(cfg.Header) == 0 {
cfg.Header = defaultHeader cfg.Header = defaultHeader
} }
if len(cfg.Header) == 0 { if len(cfg.Header) == 0 {
cfg.Header = defaultLink cfg.Header = defaultLink
} }
t, err := template.ParseFiles(StaticFolder + "/index.html") for _, btn := range cfg.Buttons {
if err := btn.Validate(); err != nil {
return err
}
}
// Validate that the template works
t, err := template.ParseFS(static.FileSystem, static.IndexPath)
if err != nil { if err != nil {
return err return err
} }
+48 -8
View File
@@ -1,19 +1,17 @@
package ui package ui
import ( import (
"strconv"
"testing" "testing"
) )
func TestConfig_ValidateAndSetDefaults(t *testing.T) { func TestConfig_ValidateAndSetDefaults(t *testing.T) {
StaticFolder = "../../web/static"
defer func() {
StaticFolder = "./web/static"
}()
cfg := &Config{ cfg := &Config{
Title: "", Title: "",
Header: "", Description: "",
Logo: "", Header: "",
Link: "", Logo: "",
Link: "",
} }
if err := cfg.ValidateAndSetDefaults(); err != nil { if err := cfg.ValidateAndSetDefaults(); err != nil {
t.Error("expected no error, got", err.Error()) t.Error("expected no error, got", err.Error())
@@ -21,11 +19,53 @@ func TestConfig_ValidateAndSetDefaults(t *testing.T) {
if cfg.Title != defaultTitle { if cfg.Title != defaultTitle {
t.Errorf("expected title to be %s, got %s", defaultTitle, cfg.Title) 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 { if cfg.Header != defaultHeader {
t.Errorf("expected header to be %s, got %s", defaultHeader, cfg.Header) t.Errorf("expected header to be %s, got %s", defaultHeader, cfg.Header)
} }
} }
func TestButton_Validate(t *testing.T) {
scenarios := []struct {
Name, Link string
ExpectedError error
}{
{
Name: "",
Link: "",
ExpectedError: ErrButtonValidationFailed,
},
{
Name: "",
Link: "link",
ExpectedError: ErrButtonValidationFailed,
},
{
Name: "name",
Link: "",
ExpectedError: ErrButtonValidationFailed,
},
{
Name: "name",
Link: "link",
ExpectedError: nil,
},
}
for i, scenario := range scenarios {
t.Run(strconv.Itoa(i)+"_"+scenario.Name+"_"+scenario.Link, func(t *testing.T) {
button := &Button{
Name: scenario.Name,
Link: scenario.Link,
}
if err := button.Validate(); err != scenario.ExpectedError {
t.Errorf("expected error %v, got %v", scenario.ExpectedError, err)
}
})
}
}
func TestGetDefaultConfig(t *testing.T) { func TestGetDefaultConfig(t *testing.T) {
defaultConfig := GetDefaultConfig() defaultConfig := GetDefaultConfig()
if defaultConfig.Title != defaultTitle { if defaultConfig.Title != defaultTitle {
+6 -8
View File
@@ -8,10 +8,8 @@ import (
"os" "os"
"time" "time"
"github.com/TwiN/gatus/v3/config/ui" "github.com/TwiN/gatus/v4/config"
"github.com/TwiN/gatus/v3/config/web" "github.com/TwiN/gatus/v4/controller/handler"
"github.com/TwiN/gatus/v3/controller/handler"
"github.com/TwiN/gatus/v3/security"
) )
var ( var (
@@ -21,19 +19,19 @@ var (
) )
// Handle creates the router and starts the server // Handle creates the router and starts the server
func Handle(securityConfig *security.Config, webConfig *web.Config, uiConfig *ui.Config, enableMetrics bool) { func Handle(cfg *config.Config) {
var router http.Handler = handler.CreateRouter(ui.StaticFolder, securityConfig, uiConfig, enableMetrics) var router http.Handler = handler.CreateRouter(cfg)
if os.Getenv("ENVIRONMENT") == "dev" { if os.Getenv("ENVIRONMENT") == "dev" {
router = handler.DevelopmentCORS(router) router = handler.DevelopmentCORS(router)
} }
server = &http.Server{ server = &http.Server{
Addr: fmt.Sprintf("%s:%d", webConfig.Address, webConfig.Port), Addr: fmt.Sprintf("%s:%d", cfg.Web.Address, cfg.Web.Port),
Handler: router, Handler: router,
ReadTimeout: 15 * time.Second, ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second,
IdleTimeout: 15 * time.Second, IdleTimeout: 15 * time.Second,
} }
log.Println("[controller][Handle] Listening on " + webConfig.SocketAddress()) log.Println("[controller][Handle] Listening on " + cfg.Web.SocketAddress())
if os.Getenv("ROUTER_TEST") == "true" { if os.Getenv("ROUTER_TEST") == "true" {
return return
} }
+4 -4
View File
@@ -7,9 +7,9 @@ import (
"os" "os"
"testing" "testing"
"github.com/TwiN/gatus/v3/config" "github.com/TwiN/gatus/v4/config"
"github.com/TwiN/gatus/v3/config/web" "github.com/TwiN/gatus/v4/config/web"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
) )
func TestHandle(t *testing.T) { func TestHandle(t *testing.T) {
@@ -32,7 +32,7 @@ func TestHandle(t *testing.T) {
_ = os.Setenv("ROUTER_TEST", "true") _ = os.Setenv("ROUTER_TEST", "true")
_ = os.Setenv("ENVIRONMENT", "dev") _ = os.Setenv("ENVIRONMENT", "dev")
defer os.Clearenv() defer os.Clearenv()
Handle(cfg.Security, cfg.Web, cfg.UI, cfg.Metrics) Handle(cfg)
defer Shutdown() defer Shutdown()
request, _ := http.NewRequest("GET", "/health", http.NoBody) request, _ := http.NewRequest("GET", "/health", http.NoBody)
responseRecorder := httptest.NewRecorder() responseRecorder := httptest.NewRecorder()
+130 -31
View File
@@ -7,8 +7,10 @@ import (
"strings" "strings"
"time" "time"
"github.com/TwiN/gatus/v3/storage/store" "github.com/TwiN/gatus/v4/config"
"github.com/TwiN/gatus/v3/storage/store/common" "github.com/TwiN/gatus/v4/storage/store"
"github.com/TwiN/gatus/v4/storage/store/common"
"github.com/TwiN/gatus/v4/storage/store/common/paging"
"github.com/gorilla/mux" "github.com/gorilla/mux"
) )
@@ -21,6 +23,16 @@ const (
badgeColorHexVeryBad = "#c7130a" badgeColorHexVeryBad = "#c7130a"
) )
const (
HealthStatusUp = "up"
HealthStatusDown = "down"
HealthStatusUnknown = "?"
)
var (
badgeColors = []string{badgeColorHexAwesome, badgeColorHexGreat, badgeColorHexGood, badgeColorHexPassable, badgeColorHexBad}
)
// UptimeBadge handles the automatic generation of badge based on the group name and endpoint name passed. // UptimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.
// //
// Valid values for {duration}: 7d, 24h, 1h // Valid values for {duration}: 7d, 24h, 1h
@@ -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. // ResponseTimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.
// //
// Valid values for {duration}: 7d, 24h, 1h // Valid values for {duration}: 7d, 24h, 1h
func ResponseTimeBadge(writer http.ResponseWriter, request *http.Request) { func ResponseTimeBadge(config *config.Config) http.HandlerFunc {
variables := mux.Vars(request) return func(writer http.ResponseWriter, request *http.Request) {
duration := variables["duration"] variables := mux.Vars(request)
var from time.Time duration := variables["duration"]
switch duration { var from time.Time
case "7d": switch duration {
from = time.Now().Add(-7 * 24 * time.Hour) case "7d":
case "24h": from = time.Now().Add(-7 * 24 * time.Hour)
from = time.Now().Add(-24 * time.Hour) case "24h":
case "1h": from = time.Now().Add(-24 * time.Hour)
from = time.Now().Add(-2 * time.Hour) // Because response time metrics are stored by hour, we have to cheat a little case "1h":
default: from = time.Now().Add(-2 * time.Hour) // Because response time metrics are stored by hour, we have to cheat a little
http.Error(writer, "Durations supported: 7d, 24h, 1h", http.StatusBadRequest) default:
return 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"] 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 != nil {
if err == common.ErrEndpointNotFound { if err == common.ErrEndpointNotFound {
http.Error(writer, err.Error(), http.StatusNotFound) http.Error(writer, err.Error(), http.StatusNotFound)
@@ -88,11 +125,19 @@ func ResponseTimeBadge(writer http.ResponseWriter, request *http.Request) {
} }
return 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("Content-Type", "image/svg+xml")
writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
writer.Header().Set("Expires", "0") writer.Header().Set("Expires", "0")
writer.WriteHeader(http.StatusOK) writer.WriteHeader(http.StatusOK)
_, _ = writer.Write(generateResponseTimeBadgeSVG(duration, averageResponseTime)) _, _ = writer.Write(generateHealthBadgeSVG(healthStatus))
} }
func generateUptimeBadgeSVG(duration string, uptime float64) []byte { func generateUptimeBadgeSVG(duration string, uptime float64) []byte {
@@ -161,7 +206,7 @@ func getBadgeColorFromUptime(uptime float64) string {
return badgeColorHexVeryBad return badgeColorHexVeryBad
} }
func generateResponseTimeBadgeSVG(duration string, averageResponseTime int) []byte { func generateResponseTimeBadgeSVG(duration string, averageResponseTime int, key string, cfg *config.Config) []byte {
var labelWidth, valueWidth int var labelWidth, valueWidth int
switch duration { switch duration {
case "7d": case "7d":
@@ -172,7 +217,7 @@ func generateResponseTimeBadgeSVG(duration string, averageResponseTime int) []by
labelWidth = 105 labelWidth = 105
default: default:
} }
color := getBadgeColorFromResponseTime(averageResponseTime) color := getBadgeColorFromResponseTime(averageResponseTime, key, cfg)
sanitizedValue := strconv.Itoa(averageResponseTime) + "ms" sanitizedValue := strconv.Itoa(averageResponseTime) + "ms"
valueWidth = len(sanitizedValue) * 11 valueWidth = len(sanitizedValue) * 11
width := labelWidth + valueWidth width := labelWidth + valueWidth
@@ -209,17 +254,71 @@ func generateResponseTimeBadgeSVG(duration string, averageResponseTime int) []by
return svg return svg
} }
func getBadgeColorFromResponseTime(responseTime int) string { func getBadgeColorFromResponseTime(responseTime int, key string, cfg *config.Config) string {
if responseTime <= 50 { endpoint := cfg.GetEndpointByKey(key)
return badgeColorHexAwesome // the threshold config requires 5 values, so we can be sure it's set here
} else if responseTime <= 200 { for i := 0; i < 5; i++ {
return badgeColorHexGreat if responseTime <= endpoint.UIConfig.Badge.ResponseTime.Thresholds[i] {
} else if responseTime <= 300 { return badgeColors[i]
return badgeColorHexGood }
} else if responseTime <= 500 {
return badgeColorHexPassable
} else if responseTime <= 750 {
return badgeColorHexBad
} }
return badgeColorHexVeryBad return badgeColorHexVeryBad
} }
func generateHealthBadgeSVG(healthStatus string) []byte {
var labelWidth, valueWidth int
switch healthStatus {
case HealthStatusUp:
valueWidth = 28
case HealthStatusDown:
valueWidth = 44
case HealthStatusUnknown:
valueWidth = 10
default:
}
color := getBadgeColorFromHealth(healthStatus)
labelWidth = 48
width := labelWidth + valueWidth
labelX := labelWidth / 2
valueX := labelWidth + (valueWidth / 2)
svg := []byte(fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="20">
<linearGradient id="b" x2="0" y2="100%%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<mask id="a">
<rect width="%d" height="20" rx="3" fill="#fff"/>
</mask>
<g mask="url(#a)">
<path fill="#555" d="M0 0h%dv20H0z"/>
<path fill="%s" d="M%d 0h%dv20H%dz"/>
<path fill="url(#b)" d="M0 0h%dv20H0z"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
<text x="%d" y="15" fill="#010101" fill-opacity=".3">
health
</text>
<text x="%d" y="14">
health
</text>
<text x="%d" y="15" fill="#010101" fill-opacity=".3">
%s
</text>
<text x="%d" y="14">
%s
</text>
</g>
</svg>`, width, width, labelWidth, color, labelWidth, valueWidth, labelWidth, width, labelX, labelX, valueX, healthStatus, valueX, healthStatus))
return svg
}
func getBadgeColorFromHealth(healthStatus string) string {
if healthStatus == HealthStatusUp {
return badgeColorHexAwesome
} else if healthStatus == HealthStatusDown {
return badgeColorHexVeryBad
}
return badgeColorHexPassable
}
+164 -18
View File
@@ -7,13 +7,14 @@ import (
"testing" "testing"
"time" "time"
"github.com/TwiN/gatus/v3/config" "github.com/TwiN/gatus/v4/config"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v3/storage/store" "github.com/TwiN/gatus/v4/core/ui"
"github.com/TwiN/gatus/v3/watchdog" "github.com/TwiN/gatus/v4/storage/store"
"github.com/TwiN/gatus/v4/watchdog"
) )
func TestUptimeBadge(t *testing.T) { func TestBadge(t *testing.T) {
defer store.Get().Clear() defer store.Get().Clear()
defer cache.Clear() defer cache.Clear()
cfg := &config.Config{ cfg := &config.Config{
@@ -29,9 +30,39 @@ func TestUptimeBadge(t *testing.T) {
}, },
}, },
} }
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()}) testSuccessfulResult = core.Result{
router := CreateRouter("../../web/static", cfg.Security, nil, cfg.Metrics) Hostname: "example.org",
IP: "127.0.0.1",
HTTPStatus: 200,
Errors: nil,
Connected: true,
Success: true,
Timestamp: timestamp,
Duration: 150 * time.Millisecond,
CertificateExpiration: 10 * time.Hour,
ConditionResults: []*core.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: true,
},
{
Condition: "[RESPONSE_TIME] < 500",
Success: true,
},
{
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
Success: true,
},
},
}
cfg.Endpoints[0].UIConfig = ui.GetDefaultConfig()
cfg.Endpoints[1].UIConfig = ui.GetDefaultConfig()
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Connected: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Connected: false, Duration: time.Second, Timestamp: time.Now()})
router := CreateRouter(cfg)
type Scenario struct { type Scenario struct {
Name string Name string
Path string Path string
@@ -89,20 +120,35 @@ func TestUptimeBadge(t *testing.T) {
Path: "/api/v1/endpoints/invalid_key/response-times/7d/badge.svg", Path: "/api/v1/endpoints/invalid_key/response-times/7d/badge.svg",
ExpectedCode: http.StatusNotFound, ExpectedCode: http.StatusNotFound,
}, },
{
Name: "badge-health-up",
Path: "/api/v1/endpoints/core_frontend/health/badge.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-health-down",
Path: "/api/v1/endpoints/core_backend/health/badge.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-health-for-invalid-key",
Path: "/api/v1/endpoints/invalid_key/health/badge.svg",
ExpectedCode: http.StatusNotFound,
},
{ {
Name: "chart-response-time-24h", Name: "chart-response-time-24h",
Path: "/api/v1/endpoints/core_backend/response-times/24h/chart.svg", Path: "/api/v1/endpoints/core_backend/response-times/24h/chart.svg",
ExpectedCode: http.StatusOK, ExpectedCode: http.StatusOK,
}, },
{ // XXX: Remove this in v4.0.0 {
Name: "backward-compatible-services-badge-uptime-1h", Name: "chart-response-time-7d",
Path: "/api/v1/services/core_frontend/uptimes/1h/badge.svg", Path: "/api/v1/endpoints/core_frontend/response-times/7d/chart.svg",
ExpectedCode: http.StatusOK, ExpectedCode: http.StatusOK,
}, },
{ // XXX: Remove this in v4.0.0 {
Name: "backward-compatible-services-chart-response-time-24h", Name: "chart-response-time-with-invalid-duration",
Path: "/api/v1/services/core_backend/response-times/24h/chart.svg", Path: "/api/v1/endpoints/core_backend/response-times/3d/chart.svg",
ExpectedCode: http.StatusOK, ExpectedCode: http.StatusBadRequest,
}, },
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
@@ -176,55 +222,155 @@ func TestGetBadgeColorFromUptime(t *testing.T) {
} }
func TestGetBadgeColorFromResponseTime(t *testing.T) { func TestGetBadgeColorFromResponseTime(t *testing.T) {
defer store.Get().Clear()
defer cache.Clear()
var (
firstCondition = core.Condition("[STATUS] == 200")
secondCondition = core.Condition("[RESPONSE_TIME] < 500")
thirdCondition = core.Condition("[CERTIFICATE_EXPIRATION] < 72h")
)
firstTestEndpoint := core.Endpoint{
Name: "a",
URL: "https://example.org/what/ever",
Method: "GET",
Body: "body",
Interval: 30 * time.Second,
Conditions: []core.Condition{firstCondition, secondCondition, thirdCondition},
Alerts: nil,
NumberOfFailuresInARow: 0,
NumberOfSuccessesInARow: 0,
UIConfig: ui.GetDefaultConfig(),
}
secondTestEndpoint := core.Endpoint{
Name: "b",
URL: "https://example.org/what/ever",
Method: "GET",
Body: "body",
Interval: 30 * time.Second,
Conditions: []core.Condition{firstCondition, secondCondition, thirdCondition},
Alerts: nil,
NumberOfFailuresInARow: 0,
NumberOfSuccessesInARow: 0,
UIConfig: &ui.Config{
Badge: &ui.Badge{
ResponseTime: &ui.ResponseTime{
Thresholds: []int{100, 500, 1000, 2000, 3000},
},
},
},
}
cfg := &config.Config{
Metrics: true,
Endpoints: []*core.Endpoint{&firstTestEndpoint, &secondTestEndpoint},
}
store.Get().Insert(&firstTestEndpoint, &testSuccessfulResult)
store.Get().Insert(&secondTestEndpoint, &testSuccessfulResult)
scenarios := []struct { scenarios := []struct {
Key string
ResponseTime int ResponseTime int
ExpectedColor string ExpectedColor string
}{ }{
{ {
Key: firstTestEndpoint.Key(),
ResponseTime: 10, ResponseTime: 10,
ExpectedColor: badgeColorHexAwesome, ExpectedColor: badgeColorHexAwesome,
}, },
{ {
Key: firstTestEndpoint.Key(),
ResponseTime: 50, ResponseTime: 50,
ExpectedColor: badgeColorHexAwesome, ExpectedColor: badgeColorHexAwesome,
}, },
{ {
Key: firstTestEndpoint.Key(),
ResponseTime: 75, ResponseTime: 75,
ExpectedColor: badgeColorHexGreat, ExpectedColor: badgeColorHexGreat,
}, },
{ {
Key: firstTestEndpoint.Key(),
ResponseTime: 150, ResponseTime: 150,
ExpectedColor: badgeColorHexGreat, ExpectedColor: badgeColorHexGreat,
}, },
{ {
Key: firstTestEndpoint.Key(),
ResponseTime: 201, ResponseTime: 201,
ExpectedColor: badgeColorHexGood, ExpectedColor: badgeColorHexGood,
}, },
{ {
Key: firstTestEndpoint.Key(),
ResponseTime: 300, ResponseTime: 300,
ExpectedColor: badgeColorHexGood, ExpectedColor: badgeColorHexGood,
}, },
{ {
Key: firstTestEndpoint.Key(),
ResponseTime: 301, ResponseTime: 301,
ExpectedColor: badgeColorHexPassable, ExpectedColor: badgeColorHexPassable,
}, },
{ {
Key: firstTestEndpoint.Key(),
ResponseTime: 450, ResponseTime: 450,
ExpectedColor: badgeColorHexPassable, ExpectedColor: badgeColorHexPassable,
}, },
{ {
Key: firstTestEndpoint.Key(),
ResponseTime: 700, ResponseTime: 700,
ExpectedColor: badgeColorHexBad, ExpectedColor: badgeColorHexBad,
}, },
{ {
Key: firstTestEndpoint.Key(),
ResponseTime: 1500, ResponseTime: 1500,
ExpectedColor: badgeColorHexVeryBad, ExpectedColor: badgeColorHexVeryBad,
}, },
{
Key: secondTestEndpoint.Key(),
ResponseTime: 50,
ExpectedColor: badgeColorHexAwesome,
},
{
Key: secondTestEndpoint.Key(),
ResponseTime: 1500,
ExpectedColor: badgeColorHexPassable,
},
{
Key: secondTestEndpoint.Key(),
ResponseTime: 2222,
ExpectedColor: badgeColorHexBad,
},
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run("response-time-"+strconv.Itoa(scenario.ResponseTime), func(t *testing.T) { t.Run(scenario.Key+"-response-time-"+strconv.Itoa(scenario.ResponseTime), func(t *testing.T) {
if getBadgeColorFromResponseTime(scenario.ResponseTime) != scenario.ExpectedColor { if getBadgeColorFromResponseTime(scenario.ResponseTime, scenario.Key, cfg) != scenario.ExpectedColor {
t.Errorf("expected %s from %d, got %v", scenario.ExpectedColor, scenario.ResponseTime, getBadgeColorFromResponseTime(scenario.ResponseTime)) t.Errorf("expected %s from %d, got %v", scenario.ExpectedColor, scenario.ResponseTime, getBadgeColorFromResponseTime(scenario.ResponseTime, scenario.Key, cfg))
}
})
}
}
func TestGetBadgeColorFromHealth(t *testing.T) {
scenarios := []struct {
HealthStatus string
ExpectedColor string
}{
{
HealthStatus: HealthStatusUp,
ExpectedColor: badgeColorHexAwesome,
},
{
HealthStatus: HealthStatusDown,
ExpectedColor: badgeColorHexVeryBad,
},
{
HealthStatus: HealthStatusUnknown,
ExpectedColor: badgeColorHexPassable,
},
}
for _, scenario := range scenarios {
t.Run("health-"+scenario.HealthStatus, func(t *testing.T) {
if getBadgeColorFromHealth(scenario.HealthStatus) != scenario.ExpectedColor {
t.Errorf("expected %s from %s, got %v", scenario.ExpectedColor, scenario.HealthStatus, getBadgeColorFromHealth(scenario.HealthStatus))
} }
}) })
} }
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"sort" "sort"
"time" "time"
"github.com/TwiN/gatus/v3/storage/store" "github.com/TwiN/gatus/v4/storage/store"
"github.com/TwiN/gatus/v3/storage/store/common" "github.com/TwiN/gatus/v4/storage/store/common"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/wcharczuk/go-chart/v2" "github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing" "github.com/wcharczuk/go-chart/v2/drawing"
+5 -10
View File
@@ -6,10 +6,10 @@ import (
"testing" "testing"
"time" "time"
"github.com/TwiN/gatus/v3/config" "github.com/TwiN/gatus/v4/config"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v3/storage/store" "github.com/TwiN/gatus/v4/storage/store"
"github.com/TwiN/gatus/v3/watchdog" "github.com/TwiN/gatus/v4/watchdog"
) )
func TestResponseTimeChart(t *testing.T) { func TestResponseTimeChart(t *testing.T) {
@@ -30,7 +30,7 @@ func TestResponseTimeChart(t *testing.T) {
} }
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()}) watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()}) watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
router := CreateRouter("../../web/static", cfg.Security, nil, cfg.Metrics) router := CreateRouter(cfg)
type Scenario struct { type Scenario struct {
Name string Name string
Path string Path string
@@ -58,11 +58,6 @@ func TestResponseTimeChart(t *testing.T) {
Path: "/api/v1/endpoints/invalid_key/response-times/7d/chart.svg", Path: "/api/v1/endpoints/invalid_key/response-times/7d/chart.svg",
ExpectedCode: http.StatusNotFound, ExpectedCode: http.StatusNotFound,
}, },
{ // XXX: Remove this in v4.0.0
Name: "backward-compatible-services-chart-response-time-24h",
Path: "/api/v1/services/core_backend/response-times/24h/chart.svg",
ExpectedCode: http.StatusOK,
},
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"github.com/TwiN/gatus/v3/security" "github.com/TwiN/gatus/v4/security"
) )
// ConfigHandler is a handler that returns information for the front end of the application. // 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" "net/http/httptest"
"testing" "testing"
"github.com/TwiN/gatus/v3/security" "github.com/TwiN/gatus/v4/security"
"github.com/gorilla/mux" "github.com/gorilla/mux"
) )
+89 -43
View File
@@ -5,15 +5,20 @@ import (
"compress/gzip" "compress/gzip"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"log" "log"
"net/http" "net/http"
"strings" "strings"
"time" "time"
"github.com/TwiN/gatus/v3/storage/store" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v3/storage/store/common" "github.com/TwiN/gatus/v4/config"
"github.com/TwiN/gatus/v3/storage/store/common/paging" "github.com/TwiN/gatus/v4/config/remote"
"github.com/TwiN/gocache" "github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/storage/store"
"github.com/TwiN/gatus/v4/storage/store/common"
"github.com/TwiN/gatus/v4/storage/store/common/paging"
"github.com/TwiN/gocache/v2"
"github.com/gorilla/mux" "github.com/gorilla/mux"
) )
@@ -28,48 +33,89 @@ var (
// EndpointStatuses handles requests to retrieve all EndpointStatus // EndpointStatuses handles requests to retrieve all EndpointStatus
// Due to the size of the response, this function leverages a cache. // Due to the size of the response, this function leverages a cache.
// Must not be wrapped by GzipHandler // Must not be wrapped by GzipHandler
func EndpointStatuses(writer http.ResponseWriter, r *http.Request) { func EndpointStatuses(cfg *config.Config) http.HandlerFunc {
page, pageSize := extractPageAndPageSizeFromRequest(r) return func(writer http.ResponseWriter, r *http.Request) {
gzipped := strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") page, pageSize := extractPageAndPageSizeFromRequest(r)
var exists bool gzipped := strings.Contains(r.Header.Get("Accept-Encoding"), "gzip")
var value interface{} var exists bool
if gzipped { var value interface{}
writer.Header().Set("Content-Encoding", "gzip")
value, exists = cache.Get(fmt.Sprintf("endpoint-status-%d-%d-gzipped", page, pageSize))
} else {
value, exists = cache.Get(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize))
}
var data []byte
if !exists {
var err error
buffer := &bytes.Buffer{}
gzipWriter := gzip.NewWriter(buffer)
endpointStatuses, err := store.Get().GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(page, pageSize))
if err != nil {
log.Printf("[handler][EndpointStatuses] Failed to retrieve endpoint statuses: %s", err.Error())
http.Error(writer, err.Error(), http.StatusInternalServerError)
return
}
data, err = json.Marshal(endpointStatuses)
if err != nil {
log.Printf("[handler][EndpointStatuses] Unable to marshal object to JSON: %s", err.Error())
http.Error(writer, "unable to marshal object to JSON", http.StatusInternalServerError)
return
}
_, _ = gzipWriter.Write(data)
_ = gzipWriter.Close()
gzippedData := buffer.Bytes()
cache.SetWithTTL(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize), data, cacheTTL)
cache.SetWithTTL(fmt.Sprintf("endpoint-status-%d-%d-gzipped", page, pageSize), gzippedData, cacheTTL)
if gzipped { if gzipped {
data = gzippedData 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))
} }
} else { var data []byte
data = value.([]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
}
// 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
}
_, _ = gzipWriter.Write(data)
_ = gzipWriter.Close()
gzippedData := buffer.Bytes()
cache.SetWithTTL(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize), data, cacheTTL)
cache.SetWithTTL(fmt.Sprintf("endpoint-status-%d-%d-gzipped", page, pageSize), gzippedData, cacheTTL)
if gzipped {
data = gzippedData
}
} else {
data = value.([]byte)
}
writer.Header().Add("Content-Type", "application/json")
writer.WriteHeader(http.StatusOK)
_, _ = writer.Write(data)
} }
writer.Header().Add("Content-Type", "application/json") }
writer.WriteHeader(http.StatusOK)
_, _ = writer.Write(data) func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*core.EndpointStatus, error) {
if remoteConfig == nil || len(remoteConfig.Instances) == 0 {
return nil, nil
}
var endpointStatusesFromAllRemotes []*core.EndpointStatus
httpClient := client.GetHTTPClient(remoteConfig.ClientConfig)
for _, instance := range remoteConfig.Instances {
response, err := httpClient.Get(instance.URL)
if err != nil {
return nil, err
}
body, err := io.ReadAll(response.Body)
if err != nil {
_ = response.Body.Close()
log.Printf("[handler][getEndpointStatusesFromRemoteInstances] Silently failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error())
continue
}
var endpointStatuses []*core.EndpointStatus
if err = json.Unmarshal(body, &endpointStatuses); err != nil {
_ = response.Body.Close()
log.Printf("[handler][getEndpointStatusesFromRemoteInstances] Silently failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error())
continue
}
_ = response.Body.Close()
for _, endpointStatus := range endpointStatuses {
endpointStatus.Name = instance.EndpointPrefix + endpointStatus.Name
}
endpointStatusesFromAllRemotes = append(endpointStatusesFromAllRemotes, endpointStatuses...)
}
return endpointStatusesFromAllRemotes, nil
} }
// EndpointStatus retrieves a single core.EndpointStatus by group and endpoint name // EndpointStatus retrieves a single core.EndpointStatus by group and endpoint name
+7 -22
View File
@@ -6,17 +6,13 @@ import (
"testing" "testing"
"time" "time"
"github.com/TwiN/gatus/v3/config" "github.com/TwiN/gatus/v4/config"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v3/storage/store" "github.com/TwiN/gatus/v4/storage/store"
"github.com/TwiN/gatus/v3/watchdog" "github.com/TwiN/gatus/v4/watchdog"
) )
var ( var (
firstCondition = core.Condition("[STATUS] == 200")
secondCondition = core.Condition("[RESPONSE_TIME] < 500")
thirdCondition = core.Condition("[CERTIFICATE_EXPIRATION] < 72h")
timestamp = time.Now() timestamp = time.Now()
testEndpoint = core.Endpoint{ testEndpoint = core.Endpoint{
@@ -26,7 +22,7 @@ var (
Method: "GET", Method: "GET",
Body: "body", Body: "body",
Interval: 30 * time.Second, Interval: 30 * time.Second,
Conditions: []*core.Condition{&firstCondition, &secondCondition, &thirdCondition}, Conditions: []core.Condition{core.Condition("[STATUS] == 200"), core.Condition("[RESPONSE_TIME] < 500"), core.Condition("[CERTIFICATE_EXPIRATION] < 72h")},
Alerts: nil, Alerts: nil,
NumberOfFailuresInARow: 0, NumberOfFailuresInARow: 0,
NumberOfSuccessesInARow: 0, NumberOfSuccessesInARow: 0,
@@ -101,7 +97,7 @@ func TestEndpointStatus(t *testing.T) {
} }
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()}) watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()}) watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
router := CreateRouter("../../web/static", cfg.Security, nil, cfg.Metrics) router := CreateRouter(cfg)
type Scenario struct { type Scenario struct {
Name string Name string
@@ -131,11 +127,6 @@ func TestEndpointStatus(t *testing.T) {
Path: "/api/v1/endpoints/invalid_key/statuses", Path: "/api/v1/endpoints/invalid_key/statuses",
ExpectedCode: http.StatusNotFound, ExpectedCode: http.StatusNotFound,
}, },
{ // XXX: Remove this in v4.0.0
Name: "backward-compatible-service-status",
Path: "/api/v1/services/core_frontend/statuses",
ExpectedCode: http.StatusOK,
},
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
@@ -162,7 +153,7 @@ func TestEndpointStatuses(t *testing.T) {
// Can't be bothered dealing with timezone issues on the worker that runs the automated tests // Can't be bothered dealing with timezone issues on the worker that runs the automated tests
firstResult.Timestamp = time.Time{} firstResult.Timestamp = time.Time{}
secondResult.Timestamp = time.Time{} secondResult.Timestamp = time.Time{}
router := CreateRouter("../../web/static", nil, nil, false) router := CreateRouter(&config.Config{Metrics: true})
type Scenario struct { type Scenario struct {
Name string Name string
@@ -201,12 +192,6 @@ func TestEndpointStatuses(t *testing.T) {
ExpectedCode: http.StatusOK, ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}]}]`, ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}]}]`,
}, },
{ // XXX: Remove this in v4.0.0
Name: "backward-compatible-service-status",
Path: "/api/v1/services/statuses",
ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}]}]`,
},
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
-12
View File
@@ -1,12 +0,0 @@
package handler
import (
"net/http"
)
// FavIcon handles requests for /favicon.ico
func FavIcon(staticFolder string) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
http.ServeFile(writer, request, staticFolder+"/favicon.ico")
}
}
-33
View File
@@ -1,33 +0,0 @@
package handler
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestFavIcon(t *testing.T) {
router := CreateRouter("../../web/static", nil, nil, false)
type Scenario struct {
Name string
Path string
ExpectedCode int
}
scenarios := []Scenario{
{
Name: "favicon",
Path: "/favicon.ico",
ExpectedCode: http.StatusOK,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
request, _ := http.NewRequest("GET", scenario.Path, http.NoBody)
responseRecorder := httptest.NewRecorder()
router.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != scenario.ExpectedCode {
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
}
})
}
}
+19 -22
View File
@@ -1,53 +1,50 @@
package handler package handler
import ( import (
"io/fs"
"net/http" "net/http"
"github.com/TwiN/gatus/v3/config/ui" "github.com/TwiN/gatus/v4/config"
"github.com/TwiN/gatus/v3/security" "github.com/TwiN/gatus/v4/web"
"github.com/TwiN/health" "github.com/TwiN/health"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
) )
func CreateRouter(staticFolder string, securityConfig *security.Config, uiConfig *ui.Config, enabledMetrics bool) *mux.Router { func CreateRouter(cfg *config.Config) *mux.Router {
router := mux.NewRouter() router := mux.NewRouter()
if enabledMetrics { if cfg.Metrics {
router.Handle("/metrics", promhttp.Handler()).Methods("GET") router.Handle("/metrics", promhttp.Handler()).Methods("GET")
} }
api := router.PathPrefix("/api").Subrouter() api := router.PathPrefix("/api").Subrouter()
protected := api.PathPrefix("/").Subrouter() protected := api.PathPrefix("/").Subrouter()
unprotected := api.PathPrefix("/").Subrouter() unprotected := api.PathPrefix("/").Subrouter()
if securityConfig != nil { if cfg.Security != nil {
if err := securityConfig.RegisterHandlers(router); err != nil { if err := cfg.Security.RegisterHandlers(router); err != nil {
panic(err) panic(err)
} }
if err := securityConfig.ApplySecurityMiddleware(protected); err != nil { if err := cfg.Security.ApplySecurityMiddleware(protected); err != nil {
panic(err) panic(err)
} }
} }
// Endpoints // Endpoints
unprotected.Handle("/v1/config", ConfigHandler{securityConfig: securityConfig}).Methods("GET") unprotected.Handle("/v1/config", ConfigHandler{securityConfig: cfg.Security}).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/statuses", EndpointStatuses(cfg)).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") protected.HandleFunc("/v1/endpoints/{key}/statuses", GzipHandlerFunc(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}/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") 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 // Misc
router.Handle("/health", health.Handler().WithJSON(true)).Methods("GET") router.Handle("/health", health.Handler().WithJSON(true)).Methods("GET")
router.HandleFunc("/favicon.ico", FavIcon(staticFolder)).Methods("GET")
// SPA // SPA
router.HandleFunc("/services/{name}", SinglePageApplication(staticFolder, uiConfig)).Methods("GET") // XXX: Remove this in v4.0.0 router.HandleFunc("/endpoints/{name}", SinglePageApplication(cfg.UI)).Methods("GET")
router.HandleFunc("/endpoints/{name}", SinglePageApplication(staticFolder, uiConfig)).Methods("GET") router.HandleFunc("/", SinglePageApplication(cfg.UI)).Methods("GET")
router.HandleFunc("/", SinglePageApplication(staticFolder, uiConfig)).Methods("GET")
// Everything else falls back on static content // Everything else falls back on static content
router.PathPrefix("/").Handler(GzipHandler(http.FileServer(http.Dir(staticFolder)))) staticFileSystem, err := fs.Sub(static.FileSystem, static.RootPath)
if err != nil {
panic(err)
}
router.PathPrefix("/").Handler(GzipHandler(http.FileServer(http.FS(staticFileSystem))))
return router return router
} }
+21 -3
View File
@@ -4,10 +4,12 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"github.com/TwiN/gatus/v4/config"
) )
func TestCreateRouter(t *testing.T) { func TestCreateRouter(t *testing.T) {
router := CreateRouter("../../web/static", nil, nil, true) router := CreateRouter(&config.Config{Metrics: true})
type Scenario struct { type Scenario struct {
Name string Name string
Path string Path string
@@ -26,16 +28,32 @@ func TestCreateRouter(t *testing.T) {
ExpectedCode: http.StatusOK, ExpectedCode: http.StatusOK,
}, },
{ {
Name: "scripts", Name: "favicon.ico",
Path: "/favicon.ico",
ExpectedCode: http.StatusOK,
},
{
Name: "app.js",
Path: "/js/app.js", Path: "/js/app.js",
ExpectedCode: http.StatusOK, ExpectedCode: http.StatusOK,
}, },
{ {
Name: "scripts-gzipped", Name: "app.js-gzipped",
Path: "/js/app.js", Path: "/js/app.js",
ExpectedCode: http.StatusOK, ExpectedCode: http.StatusOK,
Gzip: true, Gzip: true,
}, },
{
Name: "chunk-vendors.js",
Path: "/js/chunk-vendors.js",
ExpectedCode: http.StatusOK,
},
{
Name: "chunk-vendors.js-gzipped",
Path: "/js/chunk-vendors.js",
ExpectedCode: http.StatusOK,
Gzip: true,
},
{ {
Name: "index-redirect", Name: "index-redirect",
Path: "/index.html", Path: "/index.html",
+11 -7
View File
@@ -1,26 +1,30 @@
package handler package handler
import ( import (
_ "embed"
"html/template" "html/template"
"log" "log"
"net/http" "net/http"
"github.com/TwiN/gatus/v3/config/ui" "github.com/TwiN/gatus/v4/config/ui"
"github.com/TwiN/gatus/v4/web"
) )
func SinglePageApplication(staticFolder string, ui *ui.Config) http.HandlerFunc { func SinglePageApplication(ui *ui.Config) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) { return func(writer http.ResponseWriter, request *http.Request) {
t, err := template.ParseFiles(staticFolder + "/index.html") t, err := template.ParseFS(static.FileSystem, static.IndexPath)
if err != nil { if err != nil {
log.Println("[handler][SinglePageApplication] Failed to parse template:", err.Error()) // This should never happen, because ui.ValidateAndSetDefaults validates that the template works.
http.ServeFile(writer, request, staticFolder+"/index.html") log.Println("[handler][SinglePageApplication] Failed to parse template. This should never happen, because the template is validated on start. Error:", err.Error())
http.Error(writer, "Failed to parse template. This should never happen, because the template is validated on start.", http.StatusInternalServerError)
return return
} }
writer.Header().Set("Content-Type", "text/html") writer.Header().Set("Content-Type", "text/html")
err = t.Execute(writer, ui) err = t.Execute(writer, ui)
if err != nil { if err != nil {
log.Println("[handler][SinglePageApplication] Failed to parse template:", err.Error()) // This should never happen, because ui.ValidateAndSetDefaults validates that the template works.
http.ServeFile(writer, request, staticFolder+"/index.html") log.Println("[handler][SinglePageApplication] Failed to execute template. This should never happen, because the template is validated on start. Error:", err.Error())
http.Error(writer, "Failed to execute template. This should never happen, because the template is validated on start.", http.StatusInternalServerError)
return return
} }
} }
+5 -10
View File
@@ -6,10 +6,10 @@ import (
"testing" "testing"
"time" "time"
"github.com/TwiN/gatus/v3/config" "github.com/TwiN/gatus/v4/config"
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v3/storage/store" "github.com/TwiN/gatus/v4/storage/store"
"github.com/TwiN/gatus/v3/watchdog" "github.com/TwiN/gatus/v4/watchdog"
) )
func TestSinglePageApplication(t *testing.T) { func TestSinglePageApplication(t *testing.T) {
@@ -30,7 +30,7 @@ func TestSinglePageApplication(t *testing.T) {
} }
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()}) watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()}) watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
router := CreateRouter("../../web/static", cfg.Security, nil, cfg.Metrics) router := CreateRouter(cfg)
type Scenario struct { type Scenario struct {
Name string Name string
Path string Path string
@@ -48,11 +48,6 @@ func TestSinglePageApplication(t *testing.T) {
Path: "/endpoints/core_frontend", Path: "/endpoints/core_frontend",
ExpectedCode: http.StatusOK, ExpectedCode: http.StatusOK,
}, },
{ // XXX: Remove this in v4.0.0
Name: "frontend-service",
Path: "/services/core_frontend",
ExpectedCode: http.StatusOK,
},
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"github.com/TwiN/gatus/v3/storage/store/common" "github.com/TwiN/gatus/v4/storage/store/common"
) )
const ( const (
+20 -3
View File
@@ -6,8 +6,8 @@ import (
"strings" "strings"
"time" "time"
"github.com/TwiN/gatus/v3/jsonpath" "github.com/TwiN/gatus/v4/jsonpath"
"github.com/TwiN/gatus/v3/pattern" "github.com/TwiN/gatus/v4/pattern"
) )
const ( const (
@@ -46,6 +46,9 @@ const (
// Values that could replace the placeholder: 4461677039 (~52 days) // Values that could replace the placeholder: 4461677039 (~52 days)
CertificateExpirationPlaceholder = "[CERTIFICATE_EXPIRATION]" CertificateExpirationPlaceholder = "[CERTIFICATE_EXPIRATION]"
// DomainExpirationPlaceholder is a placeholder for the duration before the domain expires, in milliseconds.
DomainExpirationPlaceholder = "[DOMAIN_EXPIRATION]"
// LengthFunctionPrefix is the prefix for the length function // LengthFunctionPrefix is the prefix for the length function
// //
// Usage: len([BODY].articles) == 10, len([BODY].name) > 5 // Usage: len([BODY].articles) == 10, len([BODY].name) > 5
@@ -142,9 +145,21 @@ func (c Condition) hasBodyPlaceholder() bool {
return strings.Contains(string(c), BodyPlaceholder) return strings.Contains(string(c), BodyPlaceholder)
} }
// hasDomainExpirationPlaceholder checks whether the condition has a DomainExpirationPlaceholder
// Used for determining whether a whois operation is necessary
func (c Condition) hasDomainExpirationPlaceholder() bool {
return strings.Contains(string(c), DomainExpirationPlaceholder)
}
// hasIPPlaceholder checks whether the condition has an IPPlaceholder
// Used for determining whether an IP lookup is necessary
func (c Condition) hasIPPlaceholder() bool {
return strings.Contains(string(c), IPPlaceholder)
}
// isEqual compares two strings. // isEqual compares two strings.
// //
// Supports the pattern and the any functions. // Supports the "pat" and the "any" functions.
// i.e. if one of the parameters starts with PatternFunctionPrefix and ends with FunctionSuffix, it will be treated like // i.e. if one of the parameters starts with PatternFunctionPrefix and ends with FunctionSuffix, it will be treated like
// a pattern. // a pattern.
func isEqual(first, second string) bool { func isEqual(first, second string) bool {
@@ -219,6 +234,8 @@ func sanitizeAndResolve(elements []string, result *Result) ([]string, []string)
element = strconv.FormatBool(result.Connected) element = strconv.FormatBool(result.Connected)
case CertificateExpirationPlaceholder: case CertificateExpirationPlaceholder:
element = strconv.FormatInt(result.CertificateExpiration.Milliseconds(), 10) element = strconv.FormatInt(result.CertificateExpiration.Milliseconds(), 10)
case DomainExpirationPlaceholder:
element = strconv.FormatInt(result.DomainExpiration.Milliseconds(), 10)
default: default:
// if contains the BodyPlaceholder, then evaluate json path // if contains the BodyPlaceholder, then evaluate json path
if strings.Contains(element, BodyPlaceholder) { if strings.Contains(element, BodyPlaceholder) {
+232 -162
View File
@@ -155,13 +155,6 @@ func TestCondition_evaluate(t *testing.T) {
ExpectedSuccess: false, ExpectedSuccess: false,
ExpectedOutput: "[BODY].data.name (INVALID) == john", 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", Name: "body-jsonpath-complex-len-invalid",
Condition: Condition("len([BODY].data.name) == john"), Condition: Condition("len([BODY].data.name) == john"),
@@ -232,154 +225,6 @@ func TestCondition_evaluate(t *testing.T) {
ExpectedSuccess: false, ExpectedSuccess: false,
ExpectedOutput: "[BODY].data.id (10) < 5", 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", Name: "connected",
Condition: Condition("[CONNECTED] == true"), Condition: Condition("[CONNECTED] == true"),
@@ -429,6 +274,238 @@ func TestCondition_evaluate(t *testing.T) {
ExpectedSuccess: false, ExpectedSuccess: false,
ExpectedOutput: "[CERTIFICATE_EXPIRATION] (86400000) > 48h (172800000)", 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", Name: "has",
Condition: Condition("has([BODY].errors) == false"), Condition: Condition("has([BODY].errors) == false"),
@@ -451,13 +528,6 @@ func TestCondition_evaluate(t *testing.T) {
ExpectedSuccess: false, ExpectedSuccess: false,
ExpectedOutput: "has([BODY].errors) == false", ExpectedOutput: "has([BODY].errors) == false",
}, },
{
Name: "no-placeholders",
Condition: Condition("1 == 2"),
Result: &Result{},
ExpectedSuccess: false,
ExpectedOutput: "1 == 2",
},
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
+7 -7
View File
@@ -2,8 +2,9 @@ package core
import ( import (
"testing" "testing"
"time"
"github.com/TwiN/gatus/v3/pattern" "github.com/TwiN/gatus/v4/pattern"
) )
func TestIntegrationQuery(t *testing.T) { func TestIntegrationQuery(t *testing.T) {
@@ -39,11 +40,11 @@ func TestIntegrationQuery(t *testing.T) {
name: "test DNS with type CNAME", name: "test DNS with type CNAME",
inputDNS: DNS{ inputDNS: DNS{
QueryType: "CNAME", QueryType: "CNAME",
QueryName: "doc.google.com.", QueryName: "en.wikipedia.org.",
}, },
inputURL: "8.8.8.8", inputURL: "8.8.8.8",
expectedDNSCode: "NOERROR", expectedDNSCode: "NOERROR",
expectedBody: "writely.l.google.com.", expectedBody: "dyna.wikimedia.org.",
}, },
{ {
name: "test DNS with type MX", name: "test DNS with type MX",
@@ -69,7 +70,7 @@ func TestIntegrationQuery(t *testing.T) {
name: "test DNS with fake type and retrieve error", name: "test DNS with fake type and retrieve error",
inputDNS: DNS{ inputDNS: DNS{
QueryType: "B", QueryType: "B",
QueryName: "google", QueryName: "example",
}, },
inputURL: "8.8.8.8", inputURL: "8.8.8.8",
isErrExpected: true, isErrExpected: true,
@@ -77,7 +78,6 @@ func TestIntegrationQuery(t *testing.T) {
} }
for _, test := range tests { for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
dns := test.inputDNS dns := test.inputDNS
result := &Result{} result := &Result{}
@@ -86,9 +86,8 @@ func TestIntegrationQuery(t *testing.T) {
t.Errorf("there should be errors") t.Errorf("there should be errors")
} }
if result.DNSRCode != test.expectedDNSCode { if result.DNSRCode != test.expectedDNSCode {
t.Errorf("DNSRCodePlaceholder '%s' should have been %s", result.DNSRCode, test.expectedDNSCode) t.Errorf("expected DNSRCode to be %s, got %s", test.expectedDNSCode, result.DNSRCode)
} }
if test.inputDNS.QueryType == "NS" { if test.inputDNS.QueryType == "NS" {
// Because there are often multiple nameservers backing a single domain, we'll only look at the suffix // Because there are often multiple nameservers backing a single domain, we'll only look at the suffix
if !pattern.Match(test.expectedBody, string(result.body)) { if !pattern.Match(test.expectedBody, string(result.body)) {
@@ -100,6 +99,7 @@ func TestIntegrationQuery(t *testing.T) {
} }
} }
}) })
time.Sleep(5 * time.Millisecond)
} }
} }
+138 -35
View File
@@ -12,12 +12,14 @@ import (
"strings" "strings"
"time" "time"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/client" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v3/core/ui" "github.com/TwiN/gatus/v4/core/ui"
"github.com/TwiN/gatus/v3/util" "github.com/TwiN/gatus/v4/util"
) )
type EndpointType string
const ( const (
// HostHeader is the name of the header used to specify the host // HostHeader is the name of the header used to specify the host
HostHeader = "Host" HostHeader = "Host"
@@ -30,6 +32,16 @@ const (
// GatusUserAgent is the default user agent that Gatus uses to send requests. // GatusUserAgent is the default user agent that Gatus uses to send requests.
GatusUserAgent = "Gatus/1.0" GatusUserAgent = "Gatus/1.0"
EndpointTypeDNS EndpointType = "DNS"
EndpointTypeTCP EndpointType = "TCP"
EndpointTypeSCTP EndpointType = "SCTP"
EndpointTypeUDP EndpointType = "UDP"
EndpointTypeICMP EndpointType = "ICMP"
EndpointTypeSTARTTLS EndpointType = "STARTTLS"
EndpointTypeTLS EndpointType = "TLS"
EndpointTypeHTTP EndpointType = "HTTP"
EndpointTypeUNKNOWN EndpointType = "UNKNOWN"
) )
var ( var (
@@ -44,6 +56,15 @@ var (
// ErrEndpointWithInvalidNameOrGroup is the error with which Gatus will panic if an endpoint has an invalid character where it shouldn't // 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 \\") ErrEndpointWithInvalidNameOrGroup = errors.New("endpoint name and group must not have \" or \\")
// ErrUnknownEndpointType is the error with which Gatus will panic if an endpoint has an unknown type
ErrUnknownEndpointType = errors.New("unknown endpoint type")
// ErrInvalidEndpointIntervalForDomainExpirationPlaceholder is the error with which Gatus will panic if an endpoint
// has both an interval smaller than 5 minutes and a condition with DomainExpirationPlaceholder.
// This is because the free whois service we are using should not be abused, especially considering the fact that
// the data takes a while to be updated.
ErrInvalidEndpointIntervalForDomainExpirationPlaceholder = errors.New("the minimum interval for an endpoint with a condition using the " + DomainExpirationPlaceholder + " placeholder is 300s (5m)")
) )
// Endpoint is the configuration of a monitored // Endpoint is the configuration of a monitored
@@ -79,7 +100,7 @@ type Endpoint struct {
Interval time.Duration `yaml:"interval,omitempty"` Interval time.Duration `yaml:"interval,omitempty"`
// Conditions used to determine the health of the endpoint // Conditions used to determine the health of the endpoint
Conditions []*Condition `yaml:"conditions"` Conditions []Condition `yaml:"conditions"`
// Alerts is the alerting configuration for the endpoint in case of failure // Alerts is the alerting configuration for the endpoint in case of failure
Alerts []*alert.Alert `yaml:"alerts,omitempty"` Alerts []*alert.Alert `yaml:"alerts,omitempty"`
@@ -105,16 +126,46 @@ func (endpoint Endpoint) IsEnabled() bool {
return *endpoint.Enabled return *endpoint.Enabled
} }
// ValidateAndSetDefaults validates the endpoint's configuration and sets the default value of fields that have one // Type returns the endpoint type
func (endpoint Endpoint) Type() EndpointType {
switch {
case endpoint.DNS != nil:
return EndpointTypeDNS
case strings.HasPrefix(endpoint.URL, "tcp://"):
return EndpointTypeTCP
case strings.HasPrefix(endpoint.URL, "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 { func (endpoint *Endpoint) ValidateAndSetDefaults() error {
// Set default values // Set default values
if endpoint.ClientConfig == nil { if endpoint.ClientConfig == nil {
endpoint.ClientConfig = client.GetDefaultConfig() endpoint.ClientConfig = client.GetDefaultConfig()
} else { } else {
endpoint.ClientConfig.ValidateAndSetDefaults() if err := endpoint.ClientConfig.ValidateAndSetDefaults(); err != nil {
return err
}
} }
if endpoint.UIConfig == nil { if endpoint.UIConfig == nil {
endpoint.UIConfig = ui.GetDefaultConfig() endpoint.UIConfig = ui.GetDefaultConfig()
} else {
if err := endpoint.UIConfig.ValidateAndSetDefaults(); err != nil {
return err
}
} }
if endpoint.Interval == 0 { if endpoint.Interval == 0 {
endpoint.Interval = 1 * time.Minute endpoint.Interval = 1 * time.Minute
@@ -151,9 +202,19 @@ func (endpoint *Endpoint) ValidateAndSetDefaults() error {
if len(endpoint.Conditions) == 0 { if len(endpoint.Conditions) == 0 {
return ErrEndpointWithNoCondition return ErrEndpointWithNoCondition
} }
if endpoint.Interval < 5*time.Minute {
for _, condition := range endpoint.Conditions {
if condition.hasDomainExpirationPlaceholder() {
return ErrInvalidEndpointIntervalForDomainExpirationPlaceholder
}
}
}
if endpoint.DNS != nil { if endpoint.DNS != nil {
return endpoint.DNS.validateAndSetDefault() return endpoint.DNS.validateAndSetDefault()
} }
if endpoint.Type() == EndpointTypeUNKNOWN {
return ErrUnknownEndpointType
}
// Make sure that the request can be created // Make sure that the request can be created
_, err := http.NewRequest(endpoint.Method, endpoint.URL, bytes.NewBuffer([]byte(endpoint.Body))) _, err := http.NewRequest(endpoint.Method, endpoint.URL, bytes.NewBuffer([]byte(endpoint.Body)))
if err != nil { if err != nil {
@@ -178,12 +239,35 @@ func (endpoint Endpoint) Key() string {
// EvaluateHealth sends a request to the endpoint's URL and evaluates the conditions of the endpoint. // EvaluateHealth sends a request to the endpoint's URL and evaluates the conditions of the endpoint.
func (endpoint *Endpoint) EvaluateHealth() *Result { func (endpoint *Endpoint) EvaluateHealth() *Result {
result := &Result{Success: true, Errors: []string{}} result := &Result{Success: true, Errors: []string{}}
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 { if len(result.Errors) == 0 {
endpoint.call(result) endpoint.call(result)
} else { } else {
result.Success = false result.Success = false
} }
// Evaluate the conditions
for _, condition := range endpoint.Conditions { for _, condition := range endpoint.Conditions {
success := condition.evaluate(result, endpoint.UIConfig.DontResolveFailedConditions) success := condition.evaluate(result, endpoint.UIConfig.DontResolveFailedConditions)
if !success { if !success {
@@ -194,29 +278,27 @@ func (endpoint *Endpoint) EvaluateHealth() *Result {
// No need to keep the body after the endpoint has been evaluated // No need to keep the body after the endpoint has been evaluated
result.body = nil result.body = nil
// Clean up parameters that we don't need to keep in the results // Clean up parameters that we don't need to keep in the results
if endpoint.UIConfig.HideURL {
for errIdx, errorString := range result.Errors {
result.Errors[errIdx] = strings.ReplaceAll(errorString, endpoint.URL, "<redacted>")
}
}
if endpoint.UIConfig.HideHostname { if endpoint.UIConfig.HideHostname {
for errIdx, errorString := range result.Errors {
result.Errors[errIdx] = strings.ReplaceAll(errorString, result.Hostname, "<redacted>")
}
result.Hostname = "" result.Hostname = ""
} }
return result return result
} }
func (endpoint *Endpoint) getIP(result *Result) { func (endpoint *Endpoint) getIP(result *Result) {
if endpoint.DNS != nil { if ips, err := net.LookupIP(result.Hostname); err != 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 {
result.AddError(err.Error()) result.AddError(err.Error())
return return
} else {
result.IP = ips[0].String()
} }
result.IP = ips[0].String()
} }
func (endpoint *Endpoint) call(result *Result) { func (endpoint *Endpoint) call(result *Result) {
@@ -224,21 +306,16 @@ func (endpoint *Endpoint) call(result *Result) {
var response *http.Response var response *http.Response
var err error var err error
var certificate *x509.Certificate var certificate *x509.Certificate
isTypeDNS := endpoint.DNS != nil endpointType := endpoint.Type()
isTypeTCP := strings.HasPrefix(endpoint.URL, "tcp://") if endpointType == EndpointTypeHTTP {
isTypeICMP := strings.HasPrefix(endpoint.URL, "icmp://")
isTypeSTARTTLS := strings.HasPrefix(endpoint.URL, "starttls://")
isTypeTLS := strings.HasPrefix(endpoint.URL, "tls://")
isTypeHTTP := !isTypeDNS && !isTypeTCP && !isTypeICMP && !isTypeSTARTTLS && !isTypeTLS
if isTypeHTTP {
request = endpoint.buildHTTPRequest() request = endpoint.buildHTTPRequest()
} }
startTime := time.Now() startTime := time.Now()
if isTypeDNS { if endpointType == EndpointTypeDNS {
endpoint.DNS.query(endpoint.URL, result) endpoint.DNS.query(endpoint.URL, result)
result.Duration = time.Since(startTime) result.Duration = time.Since(startTime)
} else if isTypeSTARTTLS || isTypeTLS { } else if endpointType == EndpointTypeSTARTTLS || endpointType == EndpointTypeTLS {
if isTypeSTARTTLS { if endpointType == EndpointTypeSTARTTLS {
result.Connected, certificate, err = client.CanPerformStartTLS(strings.TrimPrefix(endpoint.URL, "starttls://"), endpoint.ClientConfig) result.Connected, certificate, err = client.CanPerformStartTLS(strings.TrimPrefix(endpoint.URL, "starttls://"), endpoint.ClientConfig)
} else { } else {
result.Connected, certificate, err = client.CanPerformTLS(strings.TrimPrefix(endpoint.URL, "tls://"), endpoint.ClientConfig) result.Connected, certificate, err = client.CanPerformTLS(strings.TrimPrefix(endpoint.URL, "tls://"), endpoint.ClientConfig)
@@ -249,10 +326,16 @@ func (endpoint *Endpoint) call(result *Result) {
} }
result.Duration = time.Since(startTime) result.Duration = time.Since(startTime)
result.CertificateExpiration = time.Until(certificate.NotAfter) result.CertificateExpiration = time.Until(certificate.NotAfter)
} else if isTypeTCP { } else if endpointType == EndpointTypeTCP {
result.Connected = client.CanCreateTCPConnection(strings.TrimPrefix(endpoint.URL, "tcp://"), endpoint.ClientConfig) result.Connected = client.CanCreateTCPConnection(strings.TrimPrefix(endpoint.URL, "tcp://"), endpoint.ClientConfig)
result.Duration = time.Since(startTime) result.Duration = time.Since(startTime)
} else if isTypeICMP { } else if endpointType == 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) result.Connected, result.Duration = client.Ping(strings.TrimPrefix(endpoint.URL, "icmp://"), endpoint.ClientConfig)
} else { } else {
response, err = client.GetHTTPClient(endpoint.ClientConfig).Do(request) response, err = client.GetHTTPClient(endpoint.ClientConfig).Do(request)
@@ -272,7 +355,7 @@ func (endpoint *Endpoint) call(result *Result) {
if endpoint.needsToReadBody() { if endpoint.needsToReadBody() {
result.body, err = io.ReadAll(response.Body) result.body, err = io.ReadAll(response.Body)
if err != nil { if err != nil {
result.AddError(err.Error()) result.AddError("error reading response body:" + err.Error())
} }
} }
} }
@@ -299,7 +382,7 @@ func (endpoint *Endpoint) buildHTTPRequest() *http.Request {
return request return request
} }
// needsToReadBody checks if there's any conditions that requires the response body to be read // needsToReadBody checks if there's any condition that requires the response body to be read
func (endpoint *Endpoint) needsToReadBody() bool { func (endpoint *Endpoint) needsToReadBody() bool {
for _, condition := range endpoint.Conditions { for _, condition := range endpoint.Conditions {
if condition.hasBodyPlaceholder() { if condition.hasBodyPlaceholder() {
@@ -308,3 +391,23 @@ func (endpoint *Endpoint) needsToReadBody() bool {
} }
return false return false
} }
// needsToRetrieveDomainExpiration checks if there's any condition that requires a whois query to be performed
func (endpoint *Endpoint) needsToRetrieveDomainExpiration() bool {
for _, condition := range endpoint.Conditions {
if condition.hasDomainExpirationPlaceholder() {
return true
}
}
return false
}
// needsToRetrieveIP checks if there's any condition that requires an IP lookup
func (endpoint *Endpoint) needsToRetrieveIP() bool {
for _, condition := range endpoint.Conditions {
if condition.hasIPPlaceholder() {
return true
}
}
return false
}
+1 -1
View File
@@ -1,6 +1,6 @@
package core package core
import "github.com/TwiN/gatus/v3/util" import "github.com/TwiN/gatus/v4/util"
// EndpointStatus contains the evaluation Results of an Endpoint // EndpointStatus contains the evaluation Results of an Endpoint
type EndpointStatus struct { type EndpointStatus struct {
+464 -81
View File
@@ -1,15 +1,245 @@
package core package core
import ( import (
"bytes"
"crypto/tls"
"crypto/x509"
"io" "io"
"net/http"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v3/client" "github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core/ui"
"github.com/TwiN/gatus/v4/test"
) )
func TestEndpoint(t *testing.T) {
defer client.InjectHTTPClient(nil)
scenarios := []struct {
Name string
Endpoint Endpoint
ExpectedResult *Result
MockRoundTripper test.MockRoundTripper
}{
{
Name: "success",
Endpoint: Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []Condition{"[STATUS] == 200", "[BODY].status == UP", "[CERTIFICATE_EXPIRATION] > 24h"},
},
ExpectedResult: &Result{
Success: true,
Connected: true,
Hostname: "twin.sh",
ConditionResults: []*ConditionResult{
{Condition: "[STATUS] == 200", Success: true},
{Condition: "[BODY].status == UP", Success: true},
{Condition: "[CERTIFICATE_EXPIRATION] > 24h", Success: true},
},
DomainExpiration: 0, // Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.
},
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(`{"status": "UP"}`)),
TLS: &tls.ConnectionState{PeerCertificates: []*x509.Certificate{{NotAfter: time.Now().Add(9999 * time.Hour)}}},
}
}),
},
{
Name: "failed-body-condition",
Endpoint: Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []Condition{"[STATUS] == 200", "[BODY].status == UP"},
},
ExpectedResult: &Result{
Success: false,
Connected: true,
Hostname: "twin.sh",
ConditionResults: []*ConditionResult{
{Condition: "[STATUS] == 200", Success: true},
{Condition: "[BODY].status (DOWN) == UP", Success: false},
},
DomainExpiration: 0, // Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.
},
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewBufferString(`{"status": "DOWN"}`))}
}),
},
{
Name: "failed-status-condition",
Endpoint: Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []Condition{"[STATUS] == 200"},
},
ExpectedResult: &Result{
Success: false,
Connected: true,
Hostname: "twin.sh",
ConditionResults: []*ConditionResult{
{Condition: "[STATUS] (502) == 200", Success: false},
},
DomainExpiration: 0, // Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.
},
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusBadGateway, Body: http.NoBody}
}),
},
{
Name: "condition-with-failed-certificate-expiration",
Endpoint: Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []Condition{"[CERTIFICATE_EXPIRATION] > 100h"},
UIConfig: &ui.Config{DontResolveFailedConditions: true},
},
ExpectedResult: &Result{
Success: false,
Connected: true,
Hostname: "twin.sh",
ConditionResults: []*ConditionResult{
// Because UIConfig.DontResolveFailedConditions is true, the values in the condition should not be resolved
{Condition: "[CERTIFICATE_EXPIRATION] > 100h", Success: false},
},
DomainExpiration: 0, // Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.
},
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{
StatusCode: http.StatusOK,
Body: http.NoBody,
TLS: &tls.ConnectionState{PeerCertificates: []*x509.Certificate{{NotAfter: time.Now().Add(5 * time.Hour)}}},
}
}),
},
{
Name: "domain-expiration",
Endpoint: Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []Condition{"[DOMAIN_EXPIRATION] > 100h"},
},
ExpectedResult: &Result{
Success: true,
Connected: true,
Hostname: "twin.sh",
ConditionResults: []*ConditionResult{
{Condition: "[DOMAIN_EXPIRATION] > 100h", Success: true},
},
DomainExpiration: 999999 * time.Hour, // Note that this test only checks if it's non-zero.
},
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
},
{
Name: "endpoint-that-will-time-out-and-hidden-hostname",
Endpoint: Endpoint{
Name: "endpoint-that-will-time-out",
URL: "https://twin.sh/health",
Conditions: []Condition{"[CONNECTED] == true"},
UIConfig: &ui.Config{HideHostname: true},
ClientConfig: &client.Config{Timeout: time.Millisecond},
},
ExpectedResult: &Result{
Success: false,
Connected: false,
Hostname: "", // Because Endpoint.UIConfig.HideHostname is true, this should be empty.
ConditionResults: []*ConditionResult{
{Condition: "[CONNECTED] (false) == true", Success: false},
},
// Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.
DomainExpiration: 0,
// Because Endpoint.UIConfig.HideHostname is true, the hostname should be replaced by <redacted>.
Errors: []string{`Get "https://<redacted>/health": context deadline exceeded (Client.Timeout exceeded while awaiting headers)`},
},
MockRoundTripper: nil,
},
{
Name: "endpoint-that-will-time-out-and-hidden-url",
Endpoint: Endpoint{
Name: "endpoint-that-will-time-out",
URL: "https://twin.sh/health",
Conditions: []Condition{"[CONNECTED] == true"},
UIConfig: &ui.Config{HideURL: true},
ClientConfig: &client.Config{Timeout: time.Millisecond},
},
ExpectedResult: &Result{
Success: false,
Connected: false,
Hostname: "twin.sh",
ConditionResults: []*ConditionResult{
{Condition: "[CONNECTED] (false) == true", Success: false},
},
// Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.
DomainExpiration: 0,
// Because Endpoint.UIConfig.HideURL is true, the URL should be replaced by <redacted>.
Errors: []string{`Get "<redacted>": context deadline exceeded (Client.Timeout exceeded while awaiting headers)`},
},
MockRoundTripper: nil,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
if scenario.MockRoundTripper != nil {
mockClient := &http.Client{Transport: scenario.MockRoundTripper}
if scenario.Endpoint.ClientConfig != nil && scenario.Endpoint.ClientConfig.Timeout > 0 {
mockClient.Timeout = scenario.Endpoint.ClientConfig.Timeout
}
client.InjectHTTPClient(mockClient)
} else {
client.InjectHTTPClient(nil)
}
scenario.Endpoint.ValidateAndSetDefaults()
result := scenario.Endpoint.EvaluateHealth()
if result.Success != scenario.ExpectedResult.Success {
t.Errorf("Expected success to be %v, got %v", scenario.ExpectedResult.Success, result.Success)
}
if result.Connected != scenario.ExpectedResult.Connected {
t.Errorf("Expected connected to be %v, got %v", scenario.ExpectedResult.Connected, result.Connected)
}
if result.Hostname != scenario.ExpectedResult.Hostname {
t.Errorf("Expected hostname to be %v, got %v", scenario.ExpectedResult.Hostname, result.Hostname)
}
if len(result.ConditionResults) != len(scenario.ExpectedResult.ConditionResults) {
t.Errorf("Expected %v condition results, got %v", len(scenario.ExpectedResult.ConditionResults), len(result.ConditionResults))
} else {
for i, conditionResult := range result.ConditionResults {
if conditionResult.Condition != scenario.ExpectedResult.ConditionResults[i].Condition {
t.Errorf("Expected condition to be %v, got %v", scenario.ExpectedResult.ConditionResults[i].Condition, conditionResult.Condition)
}
if conditionResult.Success != scenario.ExpectedResult.ConditionResults[i].Success {
t.Errorf("Expected success of condition '%s' to be %v, got %v", conditionResult.Condition, scenario.ExpectedResult.ConditionResults[i].Success, conditionResult.Success)
}
}
}
if len(result.Errors) != len(scenario.ExpectedResult.Errors) {
t.Errorf("Expected %v errors, got %v", len(scenario.ExpectedResult.Errors), len(result.Errors))
} else {
for i, err := range result.Errors {
if err != scenario.ExpectedResult.Errors[i] {
t.Errorf("Expected error to be %v, got %v", scenario.ExpectedResult.Errors[i], err)
}
}
}
if result.DomainExpiration != scenario.ExpectedResult.DomainExpiration {
// Note that DomainExpiration is only resolved if there's a condition with the DomainExpirationPlaceholder in it.
// In other words, if there's no condition with [DOMAIN_EXPIRATION] in it, the DomainExpiration field will be 0.
// Because this is a live call, mocking it would be too much of a pain, so we're just going to check if
// the actual value is non-zero when the expected result is non-zero.
if scenario.ExpectedResult.DomainExpiration.Hours() > 0 && !(result.DomainExpiration.Hours() > 0) {
t.Errorf("Expected domain expiration to be non-zero, got %v", result.DomainExpiration)
}
}
})
}
}
func TestEndpoint_IsEnabled(t *testing.T) { func TestEndpoint_IsEnabled(t *testing.T) {
if !(Endpoint{Enabled: nil}).IsEnabled() { if !(Endpoint{Enabled: nil}).IsEnabled() {
t.Error("endpoint.IsEnabled() should've returned true, because Enabled was set to nil") t.Error("endpoint.IsEnabled() should've returned true, because Enabled was set to nil")
@@ -22,12 +252,98 @@ 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) { func TestEndpoint_ValidateAndSetDefaults(t *testing.T) {
condition := Condition("[STATUS] == 200")
endpoint := Endpoint{ endpoint := Endpoint{
Name: "website-health", Name: "website-health",
URL: "https://twin.sh/health", URL: "https://twin.sh/health",
Conditions: []*Condition{&condition}, Conditions: []Condition{Condition("[STATUS] == 200")},
Alerts: []*alert.Alert{{Type: alert.TypePagerDuty}}, Alerts: []*alert.Alert{{Type: alert.TypePagerDuty}},
} }
endpoint.ValidateAndSetDefaults() endpoint.ValidateAndSetDefaults()
@@ -68,11 +384,10 @@ func TestEndpoint_ValidateAndSetDefaults(t *testing.T) {
} }
func TestEndpoint_ValidateAndSetDefaultsWithClientConfig(t *testing.T) { func TestEndpoint_ValidateAndSetDefaultsWithClientConfig(t *testing.T) {
condition := Condition("[STATUS] == 200")
endpoint := Endpoint{ endpoint := Endpoint{
Name: "website-health", Name: "website-health",
URL: "https://twin.sh/health", URL: "https://twin.sh/health",
Conditions: []*Condition{&condition}, Conditions: []Condition{Condition("[STATUS] == 200")},
ClientConfig: &client.Config{ ClientConfig: &client.Config{
Insecure: true, Insecure: true,
IgnoreRedirect: true, IgnoreRedirect: true,
@@ -95,73 +410,88 @@ func TestEndpoint_ValidateAndSetDefaultsWithClientConfig(t *testing.T) {
} }
} }
func TestEndpoint_ValidateAndSetDefaultsWithNoName(t *testing.T) {
defer func() { recover() }()
condition := Condition("[STATUS] == 200")
endpoint := &Endpoint{
Name: "",
URL: "http://example.com",
Conditions: []*Condition{&condition},
}
err := endpoint.ValidateAndSetDefaults()
if err == nil {
t.Fatal("Should've returned an error because endpoint didn't have a name, which is a mandatory field")
}
}
func TestEndpoint_ValidateAndSetDefaultsWithNoUrl(t *testing.T) {
defer func() { recover() }()
condition := Condition("[STATUS] == 200")
endpoint := &Endpoint{
Name: "example",
URL: "",
Conditions: []*Condition{&condition},
}
err := endpoint.ValidateAndSetDefaults()
if err == nil {
t.Fatal("Should've returned an error because endpoint didn't have an url, which is a mandatory field")
}
}
func TestEndpoint_ValidateAndSetDefaultsWithNoConditions(t *testing.T) {
defer func() { recover() }()
endpoint := &Endpoint{
Name: "example",
URL: "http://example.com",
Conditions: nil,
}
err := endpoint.ValidateAndSetDefaults()
if err == nil {
t.Fatal("Should've returned an error because endpoint didn't have at least 1 condition")
}
}
func TestEndpoint_ValidateAndSetDefaultsWithDNS(t *testing.T) { func TestEndpoint_ValidateAndSetDefaultsWithDNS(t *testing.T) {
conditionSuccess := Condition("[DNS_RCODE] == NOERROR")
endpoint := &Endpoint{ endpoint := &Endpoint{
Name: "dns-test", Name: "dns-test",
URL: "http://example.com", URL: "https://example.com",
DNS: &DNS{ DNS: &DNS{
QueryType: "A", QueryType: "A",
QueryName: "example.com", QueryName: "example.com",
}, },
Conditions: []*Condition{&conditionSuccess}, Conditions: []Condition{Condition("[DNS_RCODE] == NOERROR")},
} }
err := endpoint.ValidateAndSetDefaults() err := endpoint.ValidateAndSetDefaults()
if err != nil { if err != nil {
t.Error("did not expect an error, got", err)
} }
if endpoint.DNS.QueryName != "example.com." { if endpoint.DNS.QueryName != "example.com." {
t.Error("Endpoint.dns.query-name should be formatted with . suffix") t.Error("Endpoint.dns.query-name should be formatted with . suffix")
} }
} }
func TestEndpoint_ValidateAndSetDefaultsWithSimpleErrors(t *testing.T) {
scenarios := []struct {
endpoint *Endpoint
expectedErr error
}{
{
endpoint: &Endpoint{
Name: "",
URL: "https://example.com",
Conditions: []Condition{Condition("[STATUS] == 200")},
},
expectedErr: ErrEndpointWithNoName,
},
{
endpoint: &Endpoint{
Name: "endpoint-with-no-url",
URL: "",
Conditions: []Condition{Condition("[STATUS] == 200")},
},
expectedErr: ErrEndpointWithNoURL,
},
{
endpoint: &Endpoint{
Name: "endpoint-with-no-conditions",
URL: "https://example.com",
Conditions: nil,
},
expectedErr: ErrEndpointWithNoCondition,
},
{
endpoint: &Endpoint{
Name: "domain-expiration-with-bad-interval",
URL: "https://example.com",
Interval: time.Minute,
Conditions: []Condition{Condition("[DOMAIN_EXPIRATION] > 720h")},
},
expectedErr: ErrInvalidEndpointIntervalForDomainExpirationPlaceholder,
},
{
endpoint: &Endpoint{
Name: "domain-expiration-with-good-interval",
URL: "https://example.com",
Interval: 5 * time.Minute,
Conditions: []Condition{Condition("[DOMAIN_EXPIRATION] > 720h")},
},
expectedErr: nil,
},
}
for _, scenario := range scenarios {
t.Run(scenario.endpoint.Name, func(t *testing.T) {
if err := scenario.endpoint.ValidateAndSetDefaults(); err != scenario.expectedErr {
t.Errorf("Expected error %v, got %v", scenario.expectedErr, err)
}
})
}
}
func TestEndpoint_buildHTTPRequest(t *testing.T) { func TestEndpoint_buildHTTPRequest(t *testing.T) {
condition := Condition("[STATUS] == 200") condition := Condition("[STATUS] == 200")
endpoint := Endpoint{ endpoint := Endpoint{
Name: "website-health", Name: "website-health",
URL: "https://twin.sh/health", URL: "https://twin.sh/health",
Conditions: []*Condition{&condition}, Conditions: []Condition{condition},
} }
endpoint.ValidateAndSetDefaults() endpoint.ValidateAndSetDefaults()
request := endpoint.buildHTTPRequest() request := endpoint.buildHTTPRequest()
@@ -181,7 +511,7 @@ func TestEndpoint_buildHTTPRequestWithCustomUserAgent(t *testing.T) {
endpoint := Endpoint{ endpoint := Endpoint{
Name: "website-health", Name: "website-health",
URL: "https://twin.sh/health", URL: "https://twin.sh/health",
Conditions: []*Condition{&condition}, Conditions: []Condition{condition},
Headers: map[string]string{ Headers: map[string]string{
"User-Agent": "Test/2.0", "User-Agent": "Test/2.0",
}, },
@@ -205,7 +535,7 @@ func TestEndpoint_buildHTTPRequestWithHostHeader(t *testing.T) {
Name: "website-health", Name: "website-health",
URL: "https://twin.sh/health", URL: "https://twin.sh/health",
Method: "POST", Method: "POST",
Conditions: []*Condition{&condition}, Conditions: []Condition{condition},
Headers: map[string]string{ Headers: map[string]string{
"Host": "example.com", "Host": "example.com",
}, },
@@ -226,7 +556,7 @@ func TestEndpoint_buildHTTPRequestWithGraphQLEnabled(t *testing.T) {
Name: "website-graphql", Name: "website-graphql",
URL: "https://twin.sh/graphql", URL: "https://twin.sh/graphql",
Method: "POST", Method: "POST",
Conditions: []*Condition{&condition}, Conditions: []Condition{condition},
GraphQL: true, GraphQL: true,
Body: `{ Body: `{
users(gender: "female") { users(gender: "female") {
@@ -257,7 +587,7 @@ func TestIntegrationEvaluateHealth(t *testing.T) {
endpoint := Endpoint{ endpoint := Endpoint{
Name: "website-health", Name: "website-health",
URL: "https://twin.sh/health", URL: "https://twin.sh/health",
Conditions: []*Condition{&condition, &bodyCondition}, Conditions: []Condition{condition, bodyCondition},
} }
endpoint.ValidateAndSetDefaults() endpoint.ValidateAndSetDefaults()
result := endpoint.EvaluateHealth() result := endpoint.EvaluateHealth()
@@ -270,25 +600,53 @@ func TestIntegrationEvaluateHealth(t *testing.T) {
if !result.Success { if !result.Success {
t.Error("Because all conditions passed, this should have been a success") t.Error("Because all conditions passed, this should have been a success")
} }
if result.Hostname != "twin.sh" {
t.Error("result.Hostname should've been twin.sh, but was", result.Hostname)
}
} }
func TestIntegrationEvaluateHealthWithFailure(t *testing.T) { func TestIntegrationEvaluateHealthWithInvalidCondition(t *testing.T) {
condition := Condition("[STATUS] == 500") condition := Condition("[STATUS] invalid 200")
endpoint := Endpoint{ endpoint := Endpoint{
Name: "website-health", Name: "invalid-condition",
URL: "https://twin.sh/health", URL: "https://twin.sh/health",
Conditions: []*Condition{&condition}, Conditions: []Condition{condition},
}
if err := endpoint.ValidateAndSetDefaults(); err != nil {
// XXX: Should this really not return an error? After all, the condition is not valid and conditions are part of the endpoint...
t.Error("endpoint validation should've been successful, but wasn't")
}
result := endpoint.EvaluateHealth()
if result.Success {
t.Error("Because one of the conditions was invalid, result.Success should have been false")
}
if len(result.Errors) == 0 {
t.Error("There should've been an error")
}
}
func TestIntegrationEvaluateHealthWithErrorAndHideURL(t *testing.T) {
endpoint := Endpoint{
Name: "invalid-url",
URL: "https://httpstat.us/200?sleep=100",
Conditions: []Condition{Condition("[STATUS] == 200")},
ClientConfig: &client.Config{
Timeout: 1 * time.Millisecond,
},
UIConfig: &ui.Config{
HideURL: true,
},
} }
endpoint.ValidateAndSetDefaults() endpoint.ValidateAndSetDefaults()
result := endpoint.EvaluateHealth() result := endpoint.EvaluateHealth()
if result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a failure", condition)
}
if !result.Connected {
t.Error("Because the connection has been established, result.Connected should've been true")
}
if result.Success { if result.Success {
t.Error("Because one of the conditions failed, success should have been false") t.Error("Because one of the conditions was invalid, result.Success should have been false")
}
if len(result.Errors) == 0 {
t.Error("There should've been an error")
}
if !strings.Contains(result.Errors[0], "<redacted>") || strings.Contains(result.Errors[0], endpoint.URL) {
t.Error("result.Errors[0] should've had the URL redacted because ui.hide-url is set to true")
} }
} }
@@ -302,12 +660,12 @@ func TestIntegrationEvaluateHealthForDNS(t *testing.T) {
QueryType: "A", QueryType: "A",
QueryName: "example.com.", QueryName: "example.com.",
}, },
Conditions: []*Condition{&conditionSuccess, &conditionBody}, Conditions: []Condition{conditionSuccess, conditionBody},
} }
endpoint.ValidateAndSetDefaults() endpoint.ValidateAndSetDefaults()
result := endpoint.EvaluateHealth() result := endpoint.EvaluateHealth()
if !result.ConditionResults[0].Success { if !result.ConditionResults[0].Success {
t.Errorf("Conditions '%s' and %s should have been a success", conditionSuccess, conditionBody) t.Errorf("Conditions '%s' and '%s' should have been a success", conditionSuccess, conditionBody)
} }
if !result.Connected { if !result.Connected {
t.Error("Because the connection has been established, result.Connected should've been true") t.Error("Because the connection has been established, result.Connected should've been true")
@@ -318,16 +676,15 @@ func TestIntegrationEvaluateHealthForDNS(t *testing.T) {
} }
func TestIntegrationEvaluateHealthForICMP(t *testing.T) { func TestIntegrationEvaluateHealthForICMP(t *testing.T) {
conditionSuccess := Condition("[CONNECTED] == true")
endpoint := Endpoint{ endpoint := Endpoint{
Name: "icmp-test", Name: "icmp-test",
URL: "icmp://127.0.0.1", URL: "icmp://127.0.0.1",
Conditions: []*Condition{&conditionSuccess}, Conditions: []Condition{"[CONNECTED] == true"},
} }
endpoint.ValidateAndSetDefaults() endpoint.ValidateAndSetDefaults()
result := endpoint.EvaluateHealth() result := endpoint.EvaluateHealth()
if !result.ConditionResults[0].Success { if !result.ConditionResults[0].Success {
t.Errorf("Conditions '%s' should have been a success", conditionSuccess) t.Errorf("Conditions '%s' should have been a success", endpoint.Conditions[0])
} }
if !result.Connected { if !result.Connected {
t.Error("Because the connection has been established, result.Connected should've been true") t.Error("Because the connection has been established, result.Connected should've been true")
@@ -337,12 +694,20 @@ func TestIntegrationEvaluateHealthForICMP(t *testing.T) {
} }
} }
func TestEndpoint_DisplayName(t *testing.T) {
if endpoint := (Endpoint{Name: "n"}); endpoint.DisplayName() != "n" {
t.Error("endpoint.DisplayName() should've been 'n', but was", endpoint.DisplayName())
}
if endpoint := (Endpoint{Group: "g", Name: "n"}); endpoint.DisplayName() != "g/n" {
t.Error("endpoint.DisplayName() should've been 'g/n', but was", endpoint.DisplayName())
}
}
func TestEndpoint_getIP(t *testing.T) { func TestEndpoint_getIP(t *testing.T) {
conditionSuccess := Condition("[CONNECTED] == true")
endpoint := Endpoint{ endpoint := Endpoint{
Name: "invalid-url-test", Name: "invalid-url-test",
URL: "", URL: "",
Conditions: []*Condition{&conditionSuccess}, Conditions: []Condition{"[CONNECTED] == true"},
} }
result := &Result{} result := &Result{}
endpoint.getIP(result) endpoint.getIP(result)
@@ -351,26 +716,44 @@ func TestEndpoint_getIP(t *testing.T) {
} }
} }
func TestEndpoint_NeedsToReadBody(t *testing.T) { func TestEndpoint_needsToReadBody(t *testing.T) {
statusCondition := Condition("[STATUS] == 200") statusCondition := Condition("[STATUS] == 200")
bodyCondition := Condition("[BODY].status == UP") bodyCondition := Condition("[BODY].status == UP")
bodyConditionWithLength := Condition("len([BODY].tags) > 0") bodyConditionWithLength := Condition("len([BODY].tags) > 0")
if (&Endpoint{Conditions: []*Condition{&statusCondition}}).needsToReadBody() { if (&Endpoint{Conditions: []Condition{statusCondition}}).needsToReadBody() {
t.Error("expected false, got true") t.Error("expected false, got true")
} }
if !(&Endpoint{Conditions: []*Condition{&bodyCondition}}).needsToReadBody() { if !(&Endpoint{Conditions: []Condition{bodyCondition}}).needsToReadBody() {
t.Error("expected true, got false") t.Error("expected true, got false")
} }
if !(&Endpoint{Conditions: []*Condition{&bodyConditionWithLength}}).needsToReadBody() { if !(&Endpoint{Conditions: []Condition{bodyConditionWithLength}}).needsToReadBody() {
t.Error("expected true, got false") t.Error("expected true, got false")
} }
if !(&Endpoint{Conditions: []*Condition{&statusCondition, &bodyCondition}}).needsToReadBody() { if !(&Endpoint{Conditions: []Condition{statusCondition, bodyCondition}}).needsToReadBody() {
t.Error("expected true, got false") t.Error("expected true, got false")
} }
if !(&Endpoint{Conditions: []*Condition{&bodyCondition, &statusCondition}}).needsToReadBody() { if !(&Endpoint{Conditions: []Condition{bodyCondition, statusCondition}}).needsToReadBody() {
t.Error("expected true, got false") t.Error("expected true, got false")
} }
if !(&Endpoint{Conditions: []*Condition{&bodyConditionWithLength, &statusCondition}}).needsToReadBody() { if !(&Endpoint{Conditions: []Condition{bodyConditionWithLength, statusCondition}}).needsToReadBody() {
t.Error("expected true, got false")
}
}
func TestEndpoint_needsToRetrieveDomainExpiration(t *testing.T) {
if (&Endpoint{Conditions: []Condition{"[STATUS] == 200"}}).needsToRetrieveDomainExpiration() {
t.Error("expected false, got true")
}
if !(&Endpoint{Conditions: []Condition{"[STATUS] == 200", "[DOMAIN_EXPIRATION] < 720h"}}).needsToRetrieveDomainExpiration() {
t.Error("expected true, got false")
}
}
func TestEndpoint_needsToRetrieveIP(t *testing.T) {
if (&Endpoint{Conditions: []Condition{"[STATUS] == 200"}}).needsToRetrieveIP() {
t.Error("expected false, got true")
}
if !(&Endpoint{Conditions: []Condition{"[STATUS] == 200", "[IP] == 127.0.0.1"}}).needsToRetrieveIP() {
t.Error("expected true, got false") t.Error("expected true, got false")
} }
} }
-11
View File
@@ -1,11 +0,0 @@
package core
// HealthStatus is the status of Gatus
type HealthStatus struct {
// Status is the state of Gatus (UP/DOWN)
Status string `json:"status"`
// Message is an accompanying description of why the status is as reported.
// If the Status is UP, no message will be provided
Message string `json:"message,omitempty"`
}
+5
View File
@@ -10,6 +10,8 @@ type Result struct {
HTTPStatus int `json:"status"` HTTPStatus int `json:"status"`
// DNSRCode is the response code of a DNS query in a human-readable format // DNSRCode is the response code of a DNS query in a human-readable format
//
// Possible values: NOERROR, FORMERR, SERVFAIL, NXDOMAIN, NOTIMP, REFUSED
DNSRCode string `json:"-"` DNSRCode string `json:"-"`
// Hostname extracted from Endpoint.URL // Hostname extracted from Endpoint.URL
@@ -39,6 +41,9 @@ type Result struct {
// CertificateExpiration is the duration before the certificate expires // CertificateExpiration is the duration before the certificate expires
CertificateExpiration time.Duration `json:"-"` CertificateExpiration time.Duration `json:"-"`
// DomainExpiration is the duration before the domain expires
DomainExpiration time.Duration `json:"-"`
// body is the response body // body is the response body
// //
// Note that this variable is only used during the evaluation of an Endpoint's health. // Note that this variable is only used during the evaluation of an Endpoint's health.
+43
View File
@@ -1,17 +1,60 @@
package ui package ui
import "errors"
// Config is the UI configuration for core.Endpoint // Config is the UI configuration for core.Endpoint
type Config struct { type Config struct {
// HideHostname whether to hide the hostname in the Result // HideHostname whether to hide the hostname in the Result
HideHostname bool `yaml:"hide-hostname"` HideHostname bool `yaml:"hide-hostname"`
// HideURL whether to ensure the URL is not displayed in the results. Useful if the URL contains a token.
HideURL bool `yaml:"hide-url"`
// DontResolveFailedConditions whether to resolve failed conditions in the Result for display in the UI // DontResolveFailedConditions whether to resolve failed conditions in the Result for display in the UI
DontResolveFailedConditions bool `yaml:"dont-resolve-failed-conditions"` DontResolveFailedConditions bool `yaml:"dont-resolve-failed-conditions"`
// Badge is the configuration for the badges generated
Badge *Badge `yaml:"badge"`
}
type Badge struct {
ResponseTime *ResponseTime `yaml:"response-time"`
}
type ResponseTime struct {
Thresholds []int `yaml:"thresholds"`
}
var (
ErrInvalidBadgeResponseTimeConfig = errors.New("invalid response time badge configuration: expected parameter 'response-time' to have 5 ascending numerical values")
)
func (config *Config) ValidateAndSetDefaults() error {
if config.Badge != nil {
if len(config.Badge.ResponseTime.Thresholds) != 5 {
return ErrInvalidBadgeResponseTimeConfig
}
for i := 4; i > 0; i-- {
if config.Badge.ResponseTime.Thresholds[i] < config.Badge.ResponseTime.Thresholds[i-1] {
return ErrInvalidBadgeResponseTimeConfig
}
}
} else {
config.Badge = GetDefaultConfig().Badge
}
return nil
} }
// GetDefaultConfig retrieves the default UI configuration // GetDefaultConfig retrieves the default UI configuration
func GetDefaultConfig() *Config { func GetDefaultConfig() *Config {
return &Config{ return &Config{
HideHostname: false, HideHostname: false,
HideURL: false,
DontResolveFailedConditions: false, DontResolveFailedConditions: false,
Badge: &Badge{
ResponseTime: &ResponseTime{
Thresholds: []int{50, 200, 300, 500, 750},
},
},
} }
} }
+18 -17
View File
@@ -1,22 +1,22 @@
module github.com/TwiN/gatus/v3 module github.com/TwiN/gatus/v4
go 1.17 go 1.19
require ( require (
github.com/TwiN/g8 v1.3.0 github.com/TwiN/g8 v1.4.0
github.com/TwiN/gocache v1.2.4 github.com/TwiN/gocache/v2 v2.2.0
github.com/TwiN/gocache/v2 v2.0.0 github.com/TwiN/health v1.5.0
github.com/TwiN/health v1.3.0 github.com/TwiN/whois v1.1.0
github.com/coreos/go-oidc/v3 v3.1.0 github.com/coreos/go-oidc/v3 v3.1.0
github.com/go-ping/ping v0.0.0-20210911151512-381826476871 github.com/go-ping/ping v0.0.0-20210911151512-381826476871
github.com/google/uuid v1.3.0 github.com/google/uuid v1.3.0
github.com/gorilla/mux v1.8.0 github.com/gorilla/mux v1.8.0
github.com/lib/pq v1.10.3 github.com/lib/pq v1.10.7
github.com/miekg/dns v1.1.43 github.com/miekg/dns v1.1.43
github.com/prometheus/client_golang v1.11.0 github.com/prometheus/client_golang v1.14.0
github.com/wcharczuk/go-chart/v2 v2.1.0 github.com/wcharczuk/go-chart/v2 v2.1.0
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b
gopkg.in/mail.v2 v2.3.1 gopkg.in/mail.v2 v2.3.1
gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v2 v2.4.0
modernc.org/sqlite v1.13.1 modernc.org/sqlite v1.13.1
@@ -25,25 +25,26 @@ require (
require ( require (
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/golang/protobuf v1.5.2 // indirect github.com/golang/protobuf v1.5.2 // indirect
github.com/ishidawataru/sctp v0.0.0-20210707070123-9a39160e9062
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect github.com/mattn/go-isatty v0.0.14 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/prometheus/client_model v0.2.0 // indirect github.com/prometheus/client_model v0.3.0 // indirect
github.com/prometheus/common v0.31.1 // indirect github.com/prometheus/common v0.37.0 // indirect
github.com/prometheus/procfs v0.7.3 // indirect github.com/prometheus/procfs v0.8.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
go.etcd.io/bbolt v1.3.6 // indirect
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d // indirect golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d // indirect
golang.org/x/mod v0.5.1 // indirect golang.org/x/mod v0.5.1 // indirect
golang.org/x/net v0.0.0-20211209124913-491a49abca63 // indirect golang.org/x/net v0.0.0-20220225172249-27dd8689420f // indirect
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f // indirect
golang.org/x/sys v0.0.0-20211003122950-b1ebd4e1001c // indirect golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect
golang.org/x/tools v0.1.7 // indirect golang.org/x/tools v0.1.7 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
google.golang.org/appengine v1.6.6 // indirect google.golang.org/appengine v1.6.6 // indirect
google.golang.org/protobuf v1.27.1 // indirect google.golang.org/protobuf v1.28.1 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/square/go-jose.v2 v2.5.1 // indirect gopkg.in/square/go-jose.v2 v2.5.1 // indirect
lukechampine.com/uint128 v1.1.1 // indirect lukechampine.com/uint128 v1.1.1 // indirect
+43 -53
View File
@@ -33,14 +33,14 @@ cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/TwiN/g8 v1.3.0 h1:mNv3R35GhDn1gEV0BKMl1oupZ1tDtOWPTHUKu+W/k3U= github.com/TwiN/g8 v1.4.0 h1:RUk5xTtxKCdMo0GGSbBVyjtAAfi2nqVbA9E0C4u5Cxo=
github.com/TwiN/g8 v1.3.0/go.mod h1:SiIdItS0agSUloFqdQQt/RObB2jGSq+nnE9WfFv3RIo= github.com/TwiN/g8 v1.4.0/go.mod h1:ECyGJsoIb99klUfvVQoS1StgRLte9yvvPigGrHdy284=
github.com/TwiN/gocache v1.2.4 h1:AfJ1YRcxtQ/zZEN61URDwk/dwFG7LSRenU5qIm9dQzo= github.com/TwiN/gocache/v2 v2.2.0 h1:M3B36KyH24BntxLrLaUb2kgTdq8DzCnfod0IekLG57w=
github.com/TwiN/gocache v1.2.4/go.mod h1:BjabsQQy6z5uHDorHa4LJVPEzFeitLIDbCtdv3gc1gA= github.com/TwiN/gocache/v2 v2.2.0/go.mod h1:SnUuBsrwGQeNcDG6vhkOMJnqErZM0JGjgIkuKryokYA=
github.com/TwiN/gocache/v2 v2.0.0 h1:CPbDNKdSJpmBkh7aWcO7D3KK1yWaMlwX+3dsBPE8/so= github.com/TwiN/health v1.5.0 h1:ETTtbQfUbiiIiVTSpAiNzesHQvm8qarV/8ctlZsVhwA=
github.com/TwiN/gocache/v2 v2.0.0/go.mod h1:j4MABVaia2Tp53ERWc/3l4YxkswtPjB2hQzmL/kD/VQ= github.com/TwiN/health v1.5.0/go.mod h1:Z6TszwQPMvtSiVx1QMidVRgvVr4KZGfiwqcD7/Z+3iw=
github.com/TwiN/health v1.3.0 h1:xw90rZqg0NH5MRkVHzlgtDdP+EQd43v3yMqQVtYlGHg= github.com/TwiN/whois v1.1.0 h1:lhyrC/9yIXntEnbJ+0IBy9Z5NBcreieYyamlvniwq88=
github.com/TwiN/health v1.3.0/go.mod h1:Bt+lEvSi6C/9NWb7OoGmUmgtS4dfPeMM9EINnURv5dE= github.com/TwiN/whois v1.1.0/go.mod h1:9WbCzYlR+r5eq9vbgJVh7A4H2uR2ct4wKEB0/QITJ/c=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
@@ -70,20 +70,19 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/go-ping/ping v0.0.0-20210911151512-381826476871 h1:wtjTfjwAR/BYYMJ+QOLI/3J/qGEI0fgrkZvgsEWK2/Q= github.com/go-ping/ping v0.0.0-20210911151512-381826476871 h1:wtjTfjwAR/BYYMJ+QOLI/3J/qGEI0fgrkZvgsEWK2/Q=
github.com/go-ping/ping v0.0.0-20210911151512-381826476871/go.mod h1:xIFjORFzTxqIV/tDVGO4eDy/bLuSyawEeojSm3GfRGk= github.com/go-ping/ping v0.0.0-20210911151512-381826476871/go.mod h1:xIFjORFzTxqIV/tDVGO4eDy/bLuSyawEeojSm3GfRGk=
github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
@@ -127,8 +126,8 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
@@ -149,12 +148,14 @@ github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ishidawataru/sctp v0.0.0-20210707070123-9a39160e9062 h1:G1+wBT0dwjIrBdLy0MIG0i+E4CQxEnedHXdauJEIH6g=
github.com/ishidawataru/sctp v0.0.0-20210707070123-9a39160e9062/go.mod h1:co9pwDoBCm1kGxawmb4sPq0cSIOOWNPT4KnHotMP1Zg=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
@@ -170,8 +171,8 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lib/pq v1.10.3 h1:v9QZf2Sn6AmjXtQeFpdoq/eaNtYP6IN+7lcrygsIAtg= github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw=
github.com/lib/pq v1.10.3/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
@@ -185,15 +186,9 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJ
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.14.1/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.10.2/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -202,24 +197,29 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
github.com/prometheus/client_golang v1.11.0 h1:HNkLOAEQMIDv/K+04rukrLx6ch7msSRwf3/SASFAGtQ=
github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
github.com/prometheus/client_golang v1.14.0 h1:nJdhIvne2eSX/XRAFV9PcvFFRbrjbcTUj0VP62TMhnw=
github.com/prometheus/client_golang v1.14.0/go.mod h1:8vpkKitgIVNcqrRBWh1C4TIUQgYNtG/XQE4E/Zae36Y=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M=
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4=
github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
github.com/prometheus/common v0.31.1 h1:d18hG4PkHnNAKNMOmFuXFaiY8Us0nird/2m60uS1AMs= github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
github.com/prometheus/common v0.31.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= github.com/prometheus/common v0.37.0 h1:ccBbHCgIiT9uSoFY0vX8H3zsNR5eLt17/RQLUvn8pXE=
github.com/prometheus/common v0.37.0/go.mod h1:phzohg0JFMnBEFGxTDbfu3QyL5GI8gTQJFhYO5B3mfA=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0VU=
github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo=
github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
@@ -233,17 +233,12 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/tidwall/redcon v1.3.2/go.mod h1:bdYBm4rlcWpst2XMwKVzWDF9CoUxEbUmM7CQrKeOZas=
github.com/wcharczuk/go-chart/v2 v2.1.0 h1:tY2slqVQ6bN+yHSnDYwZebLQFkphK4WNrVwnt7CJZ2I= github.com/wcharczuk/go-chart/v2 v2.1.0 h1:tY2slqVQ6bN+yHSnDYwZebLQFkphK4WNrVwnt7CJZ2I=
github.com/wcharczuk/go-chart/v2 v2.1.0/go.mod h1:yx7MvAVNcP/kN9lKXM/NTce4au4DFN99j6i1OwDclNA= github.com/wcharczuk/go-chart/v2 v2.1.0/go.mod h1:yx7MvAVNcP/kN9lKXM/NTce4au4DFN99j6i1OwDclNA=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU=
go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
@@ -289,12 +284,10 @@ golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzB
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.5.1 h1:OJxoQ/rynoF0dcCdI7cLPktw/hR2cueqYfjm43oqK38= golang.org/x/mod v0.5.1 h1:OJxoQ/rynoF0dcCdI7cLPktw/hR2cueqYfjm43oqK38=
golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -318,7 +311,6 @@ golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/
golang.org/x/net v0.0.0-20200505041828-1ed23360d12c/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200505041828-1ed23360d12c/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
@@ -327,16 +319,17 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20211209124913-491a49abca63 h1:iocB37TsdFuN6IBRZ+ry36wrkoV51/tl5vOWqkcPGvY= golang.org/x/net v0.0.0-20220225172249-27dd8689420f h1:oA4XRj0qtSt8Yo1Zms0CUlsT3KG69V2UGQWPBxujDmc=
golang.org/x/net v0.0.0-20211209124913-491a49abca63/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c h1:pkQiBZBvdos9qq4wBAHqlzuZHEXo07pqV06ef90u1WI=
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b h1:clP8eMhB30EHdc0bd2Twtq6kgU7yl5ub2cQLSdrv1Dg=
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -347,11 +340,11 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f h1:Ax0t5p6N38Ga0dThY21weqDEyz2oklo4IvDkpigvkD8=
golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -362,10 +355,7 @@ golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -381,12 +371,10 @@ golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201126233918-771906719818/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201126233918-771906719818/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -395,18 +383,22 @@ golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210902050250-f475640dd07b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210902050250-f475640dd07b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211003122950-b1ebd4e1001c h1:EyJTLQbOxvk8V6oDdD8ILR1BOs3nEJXThD6aqsiPNkM= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211003122950-b1ebd4e1001c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -534,8 +526,8 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ= google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
@@ -544,12 +536,10 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk= gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk=
gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw= gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
gopkg.in/square/go-jose.v2 v2.5.1 h1:7odma5RETjNHWJnR32wx8t+Io4djHE1PqxCFx3iiZ2w= gopkg.in/square/go-jose.v2 v2.5.1 h1:7odma5RETjNHWJnR32wx8t+Io4djHE1PqxCFx3iiZ2w=
gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+67 -20
View File
@@ -9,21 +9,48 @@ import (
// Eval is a half-baked json path implementation that needs some love // Eval is a half-baked json path implementation that needs some love
func Eval(path string, b []byte) (string, int, error) { func Eval(path string, b []byte) (string, int, error) {
if len(path) == 0 && !(len(b) != 0 && b[0] == '[' && b[len(b)-1] == ']') {
// if there's no path AND the value is not a JSON array, then there's nothing to walk
return string(b), len(b), nil
}
var object interface{} var object interface{}
err := json.Unmarshal(b, &object) if err := json.Unmarshal(b, &object); err != nil {
if err != nil {
// Try to unmarshal it into an array instead
return "", 0, err return "", 0, err
} }
return walk(path, object) return walk(path, object)
} }
// walk traverses the object and returns the value as a string as well as its length
func walk(path string, object interface{}) (string, int, error) { func walk(path string, object interface{}) (string, int, error) {
keys := strings.Split(path, ".") var keys []string
startOfCurrentKey, bracketDepth := 0, 0
for i := range path {
if path[i] == '[' {
bracketDepth++
} else if path[i] == ']' {
bracketDepth--
}
// If we encounter a dot, we've reached the end of a key unless we're inside a bracket
if path[i] == '.' && bracketDepth == 0 {
keys = append(keys, path[startOfCurrentKey:i])
startOfCurrentKey = i + 1
}
}
if startOfCurrentKey <= len(path) {
keys = append(keys, path[startOfCurrentKey:])
}
currentKey := keys[0] currentKey := keys[0]
switch value := extractValue(currentKey, object).(type) { switch value := extractValue(currentKey, object).(type) {
case map[string]interface{}: case map[string]interface{}:
return walk(strings.Replace(path, fmt.Sprintf("%s.", currentKey), "", 1), value) newPath := strings.Replace(path, fmt.Sprintf("%s.", currentKey), "", 1)
if path == newPath {
// If the path hasn't changed, it means we're at the end of the path
// So we'll treat it as a string by re-marshaling it to JSON since it's a map.
// Note that the output JSON will be minified.
b, err := json.Marshal(value)
return string(b), len(b), err
}
return walk(newPath, value)
case string: case string:
if len(keys) > 1 { if len(keys) > 1 {
return "", 0, fmt.Errorf("couldn't walk through '%s', because '%s' was a string instead of an object", keys[1], currentKey) return "", 0, fmt.Errorf("couldn't walk through '%s', because '%s' was a string instead of an object", keys[1], currentKey)
@@ -32,7 +59,8 @@ func walk(path string, object interface{}) (string, int, error) {
case []interface{}: case []interface{}:
return fmt.Sprintf("%v", value), len(value), nil return fmt.Sprintf("%v", value), len(value), nil
case interface{}: case interface{}:
return fmt.Sprintf("%v", value), 1, nil newValue := fmt.Sprintf("%v", value)
return newValue, len(newValue), nil
default: default:
return "", 0, fmt.Errorf("couldn't walk through '%s' because type was '%T', but expected 'map[string]interface{}'", currentKey, value) return "", 0, fmt.Errorf("couldn't walk through '%s' because type was '%T', but expected 'map[string]interface{}'", currentKey, value)
} }
@@ -41,37 +69,56 @@ func walk(path string, object interface{}) (string, int, error) {
func extractValue(currentKey string, value interface{}) interface{} { func extractValue(currentKey string, value interface{}) interface{} {
// Check if the current key ends with [#] // Check if the current key ends with [#]
if strings.HasSuffix(currentKey, "]") && strings.Contains(currentKey, "[") { if strings.HasSuffix(currentKey, "]") && strings.Contains(currentKey, "[") {
tmp := strings.SplitN(currentKey, "[", 3) var isNestedArray bool
arrayIndex, err := strconv.Atoi(strings.Replace(tmp[1], "]", "", 1)) var index string
startOfBracket, endOfBracket, bracketDepth := 0, 0, 0
for i := range currentKey {
if currentKey[i] == '[' {
startOfBracket = i
bracketDepth++
} else if currentKey[i] == ']' && bracketDepth == 1 {
bracketDepth--
endOfBracket = i
index = currentKey[startOfBracket+1 : i]
if len(currentKey) > i+1 && currentKey[i+1] == '[' {
isNestedArray = true // there's more keys.
}
break
}
}
arrayIndex, err := strconv.Atoi(index)
if err != nil { if err != nil {
return nil return nil
} }
currentKey := tmp[0] currentKeyWithoutIndex := currentKey[:startOfBracket]
// if currentKey contains only an index (i.e. [0] or 0) // if currentKeyWithoutIndex contains only an index (i.e. [0] or 0)
if len(currentKey) == 0 { if len(currentKeyWithoutIndex) == 0 {
array := value.([]interface{}) array := value.([]interface{})
if len(array) > arrayIndex { if len(array) > arrayIndex {
if len(tmp) > 2 { if isNestedArray {
// Nested array? Go deeper. return extractValue(currentKey[endOfBracket+1:], array[arrayIndex])
return extractValue(fmt.Sprintf("%s[%s", currentKey, tmp[2]), array[arrayIndex])
} }
return array[arrayIndex] return array[arrayIndex]
} }
return nil return nil
} }
if value == nil || value.(map[string]interface{})[currentKey] == nil { if value == nil || value.(map[string]interface{})[currentKeyWithoutIndex] == nil {
return nil return nil
} }
// if currentKey contains both a key and an index (i.e. data[0]) // if currentKeyWithoutIndex contains both a key and an index (i.e. data[0])
array := value.(map[string]interface{})[currentKey].([]interface{}) array := value.(map[string]interface{})[currentKeyWithoutIndex].([]interface{})
if len(array) > arrayIndex { if len(array) > arrayIndex {
if len(tmp) > 2 { if isNestedArray {
// Nested array? Go deeper. return extractValue(currentKey[endOfBracket+1:], array[arrayIndex])
return extractValue(fmt.Sprintf("[%s", tmp[2]), array[arrayIndex])
} }
return array[arrayIndex] return array[arrayIndex]
} }
return nil return nil
} }
if valueAsSlice, ok := value.([]interface{}); ok {
// If the type is a slice, return it
return valueAsSlice
}
// otherwise, it's a map
return value.(map[string]interface{})[currentKey] return value.(map[string]interface{})[currentKey]
} }
+11
View File
@@ -0,0 +1,11 @@
package jsonpath
import "testing"
func BenchmarkEval(b *testing.B) {
for i := 0; i < b.N; i++ {
Eval("ids[0]", []byte(`{"ids": [1, 2]}`))
Eval("long.simple.walk", []byte(`{"long": {"simple": {"walk": "value"}}}`))
Eval("data[0].apps[1].name", []byte(`{"data": [{"apps": [{"name":"app1"}, {"name":"app2"}, {"name":"app3"}]}]}`))
}
}
+36 -4
View File
@@ -47,7 +47,7 @@ func TestEval(t *testing.T) {
ExpectedError: false, ExpectedError: false,
}, },
{ {
Name: "array-of-maps", Name: "array-of-objects",
Path: "ids[1].id", Path: "ids[1].id",
Data: `{"ids": [{"id": 1}, {"id": 2}]}`, Data: `{"ids": [{"id": 1}, {"id": 2}]}`,
ExpectedOutput: "2", ExpectedOutput: "2",
@@ -62,6 +62,14 @@ func TestEval(t *testing.T) {
ExpectedOutputLength: 1, ExpectedOutputLength: 1,
ExpectedError: false, ExpectedError: false,
}, },
{
Name: "array-of-values-with-no-path",
Path: "",
Data: `[1, 2]`,
ExpectedOutput: "[1 2]", // the output is an array
ExpectedOutputLength: 2,
ExpectedError: false,
},
{ {
Name: "array-of-values-and-invalid-index", Name: "array-of-values-and-invalid-index",
Path: "ids[wat]", Path: "ids[wat]",
@@ -79,7 +87,15 @@ func TestEval(t *testing.T) {
ExpectedError: false, ExpectedError: false,
}, },
{ {
Name: "array-of-maps-at-root", Name: "array-of-objects-at-root",
Path: "[0]",
Data: `[{"id": 1}, {"id": 2}]`,
ExpectedOutput: `{"id":1}`,
ExpectedOutputLength: 8,
ExpectedError: false,
},
{
Name: "array-of-objects-with-int-at-root",
Path: "[0].id", Path: "[0].id",
Data: `[{"id": 1}, {"id": 2}]`, Data: `[{"id": 1}, {"id": 2}]`,
ExpectedOutput: "1", ExpectedOutput: "1",
@@ -87,7 +103,7 @@ func TestEval(t *testing.T) {
ExpectedError: false, ExpectedError: false,
}, },
{ {
Name: "array-of-maps-at-root-and-invalid-index", Name: "array-of-objects-at-root-and-invalid-index",
Path: "[5].id", Path: "[5].id",
Data: `[{"id": 1}, {"id": 2}]`, Data: `[{"id": 1}, {"id": 2}]`,
ExpectedOutput: "", ExpectedOutput: "",
@@ -111,13 +127,29 @@ func TestEval(t *testing.T) {
ExpectedError: false, ExpectedError: false,
}, },
{ {
Name: "map-of-nested-arrays", Name: "object-with-nested-arrays",
Path: "data[1][1]", Path: "data[1][1]",
Data: `{"data": [["a", "b", "c"], ["d", "eeeee", "f"]]}`, Data: `{"data": [["a", "b", "c"], ["d", "eeeee", "f"]]}`,
ExpectedOutput: "eeeee", ExpectedOutput: "eeeee",
ExpectedOutputLength: 5, ExpectedOutputLength: 5,
ExpectedError: false, ExpectedError: false,
}, },
{
Name: "object-with-arrays-of-objects",
Path: "data[0].apps[1].name",
Data: `{"data": [{"apps": [{"name":"app1"}, {"name":"app2"}, {"name":"app3"}]}]}`,
ExpectedOutput: "app2",
ExpectedOutputLength: 4,
ExpectedError: false,
},
{
Name: "object-with-arrays-of-objects-with-missing-element",
Path: "data[0].apps[1].name",
Data: `{"data": [{"apps": []}]}`,
ExpectedOutput: "",
ExpectedOutputLength: 0,
ExpectedError: true,
},
{ {
Name: "partially-invalid-path-issue122", Name: "partially-invalid-path-issue122",
Path: "data.name.invalid", Path: "data.name.invalid",

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