Compare commits

...
166 Commits
Author SHA1 Message Date
TwiNandGitHub 65db65e052 revert: fix(client): Use libcap to fix icmp (#1127)
This reverts #1056

Fixes #1126
2025-05-31 19:04:50 -04:00
0a9f5d8838 fix(api): Escape endpoint key in URL (#1114)
ISSUE #1100 | Fix bug with URL encoding

Co-authored-by: Семисохин Андрей <semisohin@kinoplan.ru>
Co-authored-by: TwiN <twin@linux.com>
2025-05-31 16:22:49 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c449738844 chore(deps): bump codecov/codecov-action from 5.4.2 to 5.4.3 (#1108)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.4.2 to 5.4.3.
- [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/v5.4.2...v5.4.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-30 21:11:06 -04:00
migglandandGitHub 35985017a8 fix(alerting): Gitea issues not being closed (#1122)
Fix Gitea issues not being closed: do not ask for page 100, but pagesize (limit) 100. URL must use Index, not ID.
2025-05-28 19:11:25 -04:00
d9d5815488 fix(alerting): Support Twilio overrides for text-twilio-triggered and text-twilio-resolved (#1120)
* Make twilio alert provider translatable

* Update alerting/provider/twilio/twilio.go

---------

Co-authored-by: Imre Laszlo <imre.laszlo@matrixonline.hu>
Co-authored-by: TwiN <twin@linux.com>
2025-05-27 19:28:20 -04:00
TwiNandGitHub 04692d15ba feat: Make maximum number of results and events configurable (#1110) 2025-05-17 16:10:28 -04:00
c411b001eb perf(sqlite): Create indices to fix performance issue (#1106)
* introduce sqlite indices to fix performance issue

* Update storage/store/sql/specific_sqlite.go

---------

Co-authored-by: Gary Hodgson <gary.hodgson@tup.com>
Co-authored-by: TwiN <twin@linux.com>
2025-05-15 14:44:54 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ce1777c680 chore(deps): bump golang.org/x/net from 0.39.0 to 0.40.0 (#1098)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.39.0 to 0.40.0.
- [Commits](https://github.com/golang/net/compare/v0.39.0...v0.40.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-14 23:03:27 -04:00
f3a346d91c docs: Update list of characters sanitised in endpoint key (#1099)
The hash was missing.

Co-authored-by: TwiN <twin@linux.com>
2025-05-13 19:07:38 -04:00
Duarte AragãoandGitHub fca4e2170a fix(alerting): Escape custom result errors (#1095)
* escape result errors

* add specific test to check result_errors are escaped
2025-05-13 18:55:35 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b388cc87aa chore(deps): bump modernc.org/sqlite from 1.34.4 to 1.37.0 (#1062)
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.34.4 to 1.37.0.
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.34.4...v1.37.0)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-version: 1.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-11 23:52:39 -04:00
TwiNandGitHub fc8868d996 docs(alerting): Move misplaced note about AWS SES 2025-05-10 14:06:34 -04:00
021a28a8d9 feat(alerting): Add device support for pushover alerts (#1089)
* Add Pushover device setting

* Update device link for Pushover

* Update alerting/provider/pushover/pushover.go

* Update alerting/provider/pushover/pushover.go

---------

Co-authored-by: TwiN <twin@linux.com>
2025-05-07 18:53:08 -04:00
fe214e9e25 feat(api): Add endpoint to retrieve response time (#1070)
Add in the API the ability to get Response Times

Co-authored-by: Adrian Almenar <adrian@tecnocratica.net>
2025-04-29 17:21:18 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
493c7165fe chore(deps): bump github.com/miekg/dns from 1.1.64 to 1.1.65 (#1076)
Bumps [github.com/miekg/dns](https://github.com/miekg/dns) from 1.1.64 to 1.1.65.
- [Changelog](https://github.com/miekg/dns/blob/master/Makefile.release)
- [Commits](https://github.com/miekg/dns/compare/v1.1.64...v1.1.65)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2025-04-28 22:15:31 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ac44c1f2d6 chore(deps): bump codecov/codecov-action from 5.4.0 to 5.4.2 (#1078)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.4.0 to 5.4.2.
- [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/v5.4.0...v5.4.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-21 22:01:03 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
5212b656a2 chore(deps): bump github.com/valyala/fasthttp from 1.58.0 to 1.60.0 (#1061)
Bumps [github.com/valyala/fasthttp](https://github.com/valyala/fasthttp) from 1.58.0 to 1.60.0.
- [Release notes](https://github.com/valyala/fasthttp/releases)
- [Commits](https://github.com/valyala/fasthttp/compare/v1.58.0...v1.60.0)

---
updated-dependencies:
- dependency-name: github.com/valyala/fasthttp
  dependency-version: 1.60.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-13 22:14:01 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8f0a11a9e4 chore(deps): bump golang.org/x/net from 0.37.0 to 0.39.0 (#1067)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.37.0 to 0.39.0.
- [Commits](https://github.com/golang/net/compare/v0.37.0...v0.39.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-12 22:03:26 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
d576b3d72c chore(deps): bump github.com/coreos/go-oidc/v3 from 3.12.0 to 3.14.1 (#1063)
Bumps [github.com/coreos/go-oidc/v3](https://github.com/coreos/go-oidc) from 3.12.0 to 3.14.1.
- [Release notes](https://github.com/coreos/go-oidc/releases)
- [Commits](https://github.com/coreos/go-oidc/compare/v3.12.0...v3.14.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2025-04-05 23:08:53 -04:00
33120bee52 feat(alerting): Add optional ttl parameter to pushover notifications (#1050)
Add optional ttl parameter to pushover notifications

Co-authored-by: Adrian Almenar <adrian@tecnocratica.net>
2025-04-04 20:58:16 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
53b785b581 chore(deps): bump github.com/prometheus-community/pro-bing from 0.5.0 to 0.6.1 (#1048)
chore(deps): bump github.com/prometheus-community/pro-bing

Bumps [github.com/prometheus-community/pro-bing](https://github.com/prometheus-community/pro-bing) from 0.5.0 to 0.6.1.
- [Release notes](https://github.com/prometheus-community/pro-bing/releases)
- [Changelog](https://github.com/prometheus-community/pro-bing/blob/main/.goreleaser.yaml)
- [Commits](https://github.com/prometheus-community/pro-bing/compare/v0.5.0...v0.6.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-03 22:46:18 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
0d11b0ef82 chore(deps): bump github.com/prometheus/client_golang from 1.20.5 to 1.21.1 (#1027)
chore(deps): bump github.com/prometheus/client_golang

Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.20.5 to 1.21.1.
- [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.20.5...v1.21.1)

---
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>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2025-04-02 21:39:21 -04:00
8a62eb0dcc fix(client): Use libcap to fix icmp not working in containers as non-root (#1056)
Fix ping monitors not working in containers as non-root

Co-authored-by: TwiN <twin@linux.com>
2025-04-01 19:05:27 -04:00
TwiNandGitHub c9c2639f67 ci: Push to ghcr instead of DockerHub for custom tags 2025-04-01 18:28:07 -04:00
TwiN 76a8710e0b test: Revert change to maintenance test 2025-04-01 18:20:29 -04:00
TwiN d5fe682f9a test: Fix flaky maintenance test 2025-03-31 22:20:21 -04:00
TwiNandGitHub 35a3238bc9 ci: Fix feat and fix pull request title detection 2025-03-31 22:07:43 -04:00
TwiN 216dffa1a6 fix(deps): Rollback fasthttp to v1.58.0 due to overfow error
```
/go/pkg/mod/github.com/valyala/fasthttp@v1.59.0/tcplisten/tcplisten.go:187:9: math.MaxUint32 (untyped int constant 4294967295) overflows int
```
2025-03-30 13:56:48 -04:00
TwiNandGitHub 3191552343 fix(ci,deps): Bump Go version to 1.24.1 and update some dependencies (#1047) 2025-03-30 13:23:03 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
fb98e853d4 chore(deps): bump github.com/valyala/fasthttp from 1.58.0 to 1.59.0 (#1044)
Bumps [github.com/valyala/fasthttp](https://github.com/valyala/fasthttp) from 1.58.0 to 1.59.0.
- [Release notes](https://github.com/valyala/fasthttp/releases)
- [Commits](https://github.com/valyala/fasthttp/compare/v1.58.0...v1.59.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-29 19:49:08 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e608950f98 chore(deps): bump google.golang.org/api from 0.214.0 to 0.228.0 (#1041)
Bumps [google.golang.org/api](https://github.com/googleapis/google-api-go-client) from 0.214.0 to 0.228.0.
- [Release notes](https://github.com/googleapis/google-api-go-client/releases)
- [Changelog](https://github.com/googleapis/google-api-go-client/blob/main/CHANGES.md)
- [Commits](https://github.com/googleapis/google-api-go-client/compare/v0.214.0...v0.228.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-28 20:18:59 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
39a623a349 chore(deps): bump golang.org/x/oauth2 from 0.25.0 to 0.28.0 (#1026)
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.25.0 to 0.28.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.25.0...v0.28.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-27 22:13:10 -04:00
TwiNandGitHub 75b99d3072 fix(client): Correctly parse hostname for ICMP endpoint when using IPv6 (#1043)
Fixes #1042
2025-03-26 18:46:46 -04:00
TwiNandGitHub 55d7bcdf2e feat(ui): Implement ability to hide port from endpoint results via endpoints[].ui.hide-port (#1038)
* feat(ui): Implement ability to hide port from endpoint results via `endpoints[].ui.hide-port`

Fixes #1036

* Add test
2025-03-20 16:31:14 -04:00
TwiN b79fb09fe5 fix: Replace # by - for endpoint key 2025-03-17 20:54:36 -04:00
TwiNandGitHub 46499e13a0 fix(alerting): Ensure that alerting.incident-io.url has the correct prefix (#1034)
* fix(alerting): Ensure that alerting.incident-io.url has the correct prefix

* Fix test
2025-03-17 19:41:50 -04:00
TwiN 43b0772e7d build: Fix typo in Dockerfile 2025-03-14 19:33:27 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
6d807e322e chore(deps): bump codecov/codecov-action from 5.3.1 to 5.4.0 (#1014)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.3.1 to 5.4.0.
- [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/v5.3.1...v5.4.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2025-03-10 20:59:51 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
efa25c3498 chore(deps): bump golang.org/x/net from 0.33.0 to 0.37.0 (#1020)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.33.0 to 0.37.0.
- [Commits](https://github.com/golang/net/compare/v0.33.0...v0.37.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-09 20:33:50 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ee96ba8721 chore(deps): bump github.com/aws/aws-sdk-go from 1.55.5 to 1.55.6 (#971)
Bumps [github.com/aws/aws-sdk-go](https://github.com/aws/aws-sdk-go) from 1.55.5 to 1.55.6.
- [Release notes](https://github.com/aws/aws-sdk-go/releases)
- [Changelog](https://github.com/aws/aws-sdk-go/blob/main/CHANGELOG_PENDING.md)
- [Commits](https://github.com/aws/aws-sdk-go/compare/v1.55.5...v1.55.6)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-08 22:11:36 -05:00
e0bdda5225 feat(ui): Allow configuring default dark-mode value (#1015)
* fix: theme flickering

* chore(ui): added dark mode tests

* feat(ui): Expose new ui.dark-mode parameter to set default theme

* refactor(ui): Rename theme variable to themeFromCookie for clarity

---------

Co-authored-by: TwiN <twin@linux.com>
Co-authored-by: TwiN <chris@twin.sh>
2025-03-07 20:32:05 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
fc07f15b67 chore(deps): bump golang.org/x/crypto from 0.31.0 to 0.36.0 (#1018)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.31.0 to 0.36.0.
- [Commits](https://github.com/golang/crypto/compare/v0.31.0...v0.36.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2025-03-07 19:02:25 -05:00
TwiNandGitHub 168dafe5bb ci(labeler): Set name to labeler 2025-03-06 22:18:12 -05:00
TwiNandGitHub ca2c6899ed ci(labeler): Don't bother checking the body 2025-03-06 21:48:42 -05:00
TwiNandGitHub da3607106c ci(labeler): Fix permission issue and continue on failure 2025-03-06 21:44:41 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e3e951aec8 chore(deps): bump github.com/coreos/go-oidc/v3 from 3.11.0 to 3.12.0 (#1004)
Bumps [github.com/coreos/go-oidc/v3](https://github.com/coreos/go-oidc) from 3.11.0 to 3.12.0.
- [Release notes](https://github.com/coreos/go-oidc/releases)
- [Commits](https://github.com/coreos/go-oidc/compare/v3.11.0...v3.12.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-06 21:38:09 -05:00
a4cb2acd24 docs: Clarify that disable-monitoring-lock is akin to enabling parallel execution (#1017)
* add keyword to doc

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

* Update README.md

---------

Co-authored-by: TwiN <twin@linux.com>
2025-03-06 21:16:22 -05:00
TwiNandGitHub 467e811fd5 ci(labeler): Support area/storage label 2025-03-06 21:10:03 -05:00
TwiNandGitHub 11292b5f1e ci: Bump publish-latest's timeout-minutes 2025-03-04 19:50:02 -05:00
TwiNandGitHub 4cd35ef51d fix(dev): Add run-binary target in Makefile 2025-03-03 21:40:46 -05:00
TwiNandGitHub bb62a400f1 ci(labeler): Support pull request and issue number 2025-03-02 20:10:47 -05:00
TwiN b750fcd209 fix(dev): Add missing ENVIRONMENT=dev instruction in Makefile 2025-03-02 17:42:15 -05:00
TwiNandGitHub 62af9e842f ci: Add labeler workflow (#1010) 2025-02-21 23:39:48 -05:00
TwiNandGitHub 2049601be5 ci: Add publish-custom workflow 2025-02-18 20:59:40 -05:00
Lisovskii StanandGitHub abf5fbee9e chore(deps): Update TwiN/whois dependency to v1.1.10 (#1006)
Update whois dependency to v1.1.10
2025-02-18 20:41:18 -05:00
TwiN 8ad8a05535 docs(alerting): Add missing source-url parameter in incident-io provider 2025-02-17 15:09:46 -05:00
TwiN 3f94e16950 docs(alerting): Fix formatting in incident-io section 2025-02-17 15:04:19 -05:00
TwiNandGitHub 46b24b849f feat(dns): Support SRV query type (#1005) 2025-02-17 10:22:44 -05:00
a1f7bd7b73 feat(maintenance): Per-endpoint maintenance configuration (#982)
* feat: add endpoint.maintenance-windows array for per-endpoint maintenance configuration

* doc: initial entry for maintenance windows in endpoint config

* doc: example documentation for per-endpoint configuration of maintenance
windows

* chore: var => :=

* test: add checks for maintenance window defaults in endpoint configuration

* chore: clean up new-lines

---------

Co-authored-by: TwiN <twin@linux.com>
2025-02-16 23:05:13 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
7e122a9fd9 chore(deps): bump github.com/gofiber/fiber/v2 from 2.52.5 to 2.52.6 (#952)
Bumps [github.com/gofiber/fiber/v2](https://github.com/gofiber/fiber) from 2.52.5 to 2.52.6.
- [Release notes](https://github.com/gofiber/fiber/releases)
- [Commits](https://github.com/gofiber/fiber/compare/v2.52.5...v2.52.6)

---
updated-dependencies:
- dependency-name: github.com/gofiber/fiber/v2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2025-02-16 22:55:53 -05:00
TwiN 52c142ec81 ci(test-ui): Fix push trigger 2025-02-10 21:47:06 -05:00
TwiNandGitHub 18e90d0e63 fix(ui): Don't override body/html background-color (#1002)
* fix(ui): Don't override body/html background-color

* fix(ui): Don't override body/html background-color
2025-02-10 21:45:44 -05:00
Tore Stendal LønøyandGitHub 64b4c53b4e feat(ui): expose 30d badges and response chart selector for duration (#994)
* chore: initial commit for 30d badge/chart UI support

* chore revert some changes

* chore build frontend

* chore remove old line

* rebuild frontend

* re-order list

* feat: add support for 1h response chart

* chore(docs): add section about response time chart

* chore(frontend): add missing select

* chore: code format

* chore: new web build

* Revert "chore: code format"

This reverts commit 517f0ce3c8.

* chore(responsechart): remove support for 1h charts

response time is only stored at 1h intervals, having a chart for the past 1h does not make sense.
2025-02-10 20:55:19 -05:00
TwiN 9fd134ca9c ci: Don't comment on pull requests 2025-02-08 11:52:31 -05:00
TwiN d9e0ee04f9 ci: Run test-ui workflow on push event as well 2025-02-06 19:36:54 -05:00
TwiNandGitHub 5227da3407 ci: Add workflow to test UI changes (#998)
* ci: Add workflow to test UI changes

* test

* remove test
2025-02-06 19:33:37 -05:00
541a70584d feat(alerting): Add Incident.io alerting provider (#972)
* feat(alerting): added incident.io provider alerting.

* Tests: added incident.io provider unit tests.

* Documentation: added incidentio documentation.

* Refactor: Changed documentation + types to an alphabetical order.

* Refactor: change wrong comment.

Co-authored-by: Maksim Zhylinski <maksim@zhylinski.me>

* Update README.md

Co-authored-by: Maksim Zhylinski <maksim@zhylinski.me>

* Update alerting/provider/incidentio/incident_io.go

Co-authored-by: Julien Limoges <limoges@users.noreply.github.com>

* Update alerting/provider/incidentio/incident_io.go

Co-authored-by: Julien Limoges <limoges@users.noreply.github.com>

* Update alerting/provider/incidentio/incident_io.go

Co-authored-by: Julien Limoges <limoges@users.noreply.github.com>

* Update alerting/provider/incidentio/incident_io.go

Co-authored-by: Julien Limoges <limoges@users.noreply.github.com>

* Refactor: changed alertSourceID to url.

* Refactor: changed documentation.

* Refactor: refactored tests, removed status from config.

* Readme: updated docs.

* Refactor: removed duplication key in favor of ResolveKey.

* Refactor: change variable format.

* Feat + Test: added support for passing metadata and source url, added resolved-with-metadata-source-url test case.

* Refactor: chaned variable naming

* Update alerting/config.go

* Update README.md

* Update README.md

* Update README.md

* Apply suggestions from code review

* Refactor: sort var by abc

---------

Co-authored-by: Maksim Zhylinski <maksim@zhylinski.me>
Co-authored-by: Julien Limoges <limoges@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2025-02-06 19:21:35 -05:00
ea2b7c4bdf fix(remote): Support partial remote instance failure (#990)
Failing remotes allowed and logged

Co-authored-by: Oleh Rakovych <oleh@amberoad.ai>
2025-02-05 22:04:13 -05:00
Victor LaborieandGitHub 9d8928dee0 fix(config): Ignore config files with double dot in it (#696) (#984) 2025-02-02 20:37:55 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
41085757f9 chore(deps): bump codecov/codecov-action from 5.1.2 to 5.3.1 (#980)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.1.2 to 5.3.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/v5.1.2...v5.3.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-01 13:58:45 -05:00
Bo-Yi WuandGitHub 5b3e0c8074 refactor: periodic operations to use time.Ticker (#978)
- Use `time.Ticker` instead of `time.After` for periodic operations in `autoSave` function
- Use `time.Ticker` instead of `time.After` for periodic operations in `monitor` function

Signed-off-by: appleboy <appleboy.tw@gmail.com>
2025-01-28 22:23:16 -05:00
Alex MarasandGitHub 975ac3592e fix(maintenance): timezone handling offset issue (#981)
* fix: uses time.Date to construct time at midnight in Timezone instead of Truncate

* test: check for Perth validity using adjusted timezone handling

* chore: remove newlines around day adjustment block

* chore: remove separate define of dayWhereMaintenancePeriodWouldStart
2025-01-28 22:15:35 -05:00
TwiN dd839be918 chore(logging): Fix typo in log
relates to #688
2025-01-20 18:28:34 -05:00
TwiN 1c99386807 fix(logging): Add debug log when skipping alert due to send-on-resolved: false
relates to #688
2025-01-20 18:14:59 -05:00
RaniandGitHub fa3e5dcc6e feat(ssh): Support authless SSH health check (#956)
* Feature + Test +  Documentation: added no-auth ssh health cheack feature, changed documentation to fit new behavior, added ssh test cases.

* Refactor: refactored authenticate field to infer from username and password insted of specifying it inside config.

* Refactor: removed non used field.

* Refactor: changed error, removed spaces.

* Refactor: added comments.
2025-01-19 16:22:41 -05:00
Devin BuhlandGitHub 0bba77ab2b feat(metrics): add gatus_results_endpoint_success (#970)
* feat(metrics): add gatus_endpoint_success

Signed-off-by: Devin Buhl <devin@buhl.casa>

* feat(metrics): add gatus_endpoint_success

Signed-off-by: Devin Buhl <devin@buhl.casa>

* chore: update readme

Signed-off-by: Devin Buhl <devin@buhl.casa>

* chore: update readme

Signed-off-by: Devin Buhl <devin@buhl.casa>

* chore: update readme

Signed-off-by: Devin Buhl <devin@buhl.casa>

* chore: update readme

Signed-off-by: Devin Buhl <devin@buhl.casa>

* chore: update readme

Signed-off-by: Devin Buhl <devin@buhl.casa>

---------

Signed-off-by: Devin Buhl <devin@buhl.casa>
2025-01-19 16:12:52 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
31073365ed chore(deps): bump github.com/prometheus-community/pro-bing from 0.4.0 to 0.5.0 (#949)
chore(deps): bump github.com/prometheus-community/pro-bing

Bumps [github.com/prometheus-community/pro-bing](https://github.com/prometheus-community/pro-bing) from 0.4.0 to 0.5.0.
- [Release notes](https://github.com/prometheus-community/pro-bing/releases)
- [Changelog](https://github.com/prometheus-community/pro-bing/blob/main/.goreleaser.yaml)
- [Commits](https://github.com/prometheus-community/pro-bing/compare/v0.4.0...v0.5.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2025-01-19 00:30:21 -05:00
TwiNandGitHub 69dbe4fa23 test: Fix flaky DNS tests that check for valid IP (#968) 2025-01-18 20:26:25 -05:00
9157b5bf67 fix(client): Potential channel blocking issue for SCTP endpoints (#962)
fixing channel blocking in client.go

Co-authored-by: TwiN <twin@linux.com>
2025-01-13 23:30:15 -05:00
1ddaf5f3e5 fix(alerting): replace HTML entities with emojis in teams-workflows provider to prevent rendering issues (#941)
* replace html entities with emojis to prevent rendering issues

* update teamsworklfows tests as well

---------

Co-authored-by: Sascha Voth <voth@jacob.de>
Co-authored-by: TwiN <twin@linux.com>
2025-01-13 23:18:47 -05:00
TwiNandGitHub e1675cc747 docs: Fix test badge 2025-01-13 23:04:45 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
47246ddcf7 chore(deps): bump golang.org/x/oauth2 from 0.24.0 to 0.25.0 (#959)
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.24.0 to 0.25.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.24.0...v0.25.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-12 21:19:22 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c259364edf chore(deps): bump modernc.org/sqlite from 1.34.2 to 1.34.4 (#948)
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.34.2 to 1.34.4.
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.34.2...v1.34.4)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-07 22:23:37 -05:00
tigattackandGitHub b650518ccc ci: Add image labels, merge publish to Docker Hub/GHCR workflows (#954)
* Merge publish to Docker Hub/GHCR workflows

* Add labels to images built by publish workflows
2025-01-05 23:03:14 -05:00
TwiNandGitHub d69844dc13 docs: Use stable tag in quick start instructions 2024-12-29 10:07:03 -05:00
TwiNandGitHub 8aae277113 Revert "feat(metrics): Add URL to metrics labels" (#947)
Revert "feat(metrics): Add URL to metrics labels (#875)"

This reverts commit e88f47f0f4.
2024-12-28 16:48:00 -05:00
c44d998fb3 feat(api): Expose uptime data as text via API (#758)
* Expose Raw Uptime Data via API

Signed-off-by: James Hillyard <james.hillyard@payara.fish>

* Add Test for Raw Uptime Data API Endpoint

Signed-off-by: James Hillyard <james.hillyard@payara.fish>

* Document Raw Uptime Data API Endpoint

Signed-off-by: James Hillyard <james.hillyard@payara.fish>

* Fix Test after #759 Core Refactor

Signed-off-by: James Hillyard <james.hillyard@payara.fish>

* Update Raw Data Content Type

Signed-off-by: James Hillyard <james.hillyard@payara.fish>

* Support 30d Data from Raw Uptime Endpoint

Signed-off-by: James Hillyard <james.hillyard@payara.fish>

* Update README.md

* Update README.md

---------

Signed-off-by: James Hillyard <james.hillyard@payara.fish>
Co-authored-by: TwiN <twin@linux.com>
2024-12-28 15:59:28 -05:00
e88f47f0f4 feat(metrics): Add URL to metrics labels (#875)
Co-authored-by: TwiN <twin@linux.com>
2024-12-28 14:07:50 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
19713c2d79 chore(deps): bump github.com/prometheus/client_golang from 1.20.4 to 1.20.5 (#923)
chore(deps): bump github.com/prometheus/client_golang

Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.20.4 to 1.20.5.
- [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.20.4...v1.20.5)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-28 14:01:09 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
6eca0201ce chore(deps): bump github.com/valyala/fasthttp from 1.56.0 to 1.58.0 (#940)
Bumps [github.com/valyala/fasthttp](https://github.com/valyala/fasthttp) from 1.56.0 to 1.58.0.
- [Release notes](https://github.com/valyala/fasthttp/releases)
- [Commits](https://github.com/valyala/fasthttp/compare/v1.56.0...v1.58.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2024-12-28 13:39:22 -05:00
f2a82e0681 feat(alerting): Add condition results to Pushover (#945)
Co-authored-by: TwiN <twin@linux.com>
2024-12-28 11:35:19 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
9a12adbaf5 chore(deps): bump google.golang.org/api from 0.209.0 to 0.214.0 (#939)
Bumps [google.golang.org/api](https://github.com/googleapis/google-api-go-client) from 0.209.0 to 0.214.0.
- [Release notes](https://github.com/googleapis/google-api-go-client/releases)
- [Changelog](https://github.com/googleapis/google-api-go-client/blob/main/CHANGES.md)
- [Commits](https://github.com/googleapis/google-api-go-client/compare/v0.209.0...v0.214.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2024-12-28 11:28:58 -05:00
TwiNandGitHub efbb739a44 feat(ui): Implement Custom CSS configuration (#943)
* feat(ui): Implement Custom CSS configuration

* Update web/app/public/index.html
2024-12-26 23:08:16 -05:00
Paul NikonowiczandGitHub 78c9a1bd41 test(client): add test for insecure flag (#942)
adds tests to complement work done in
https://github.com/TwiN/gatus/pull/547

it was rumored that tls insecure was not working,
although tls insecure appears to be working fine,
no test was found originally.

this adds a test to ensure that tls insecure works
as expected. the domain badssl.com is used to
verify that the insecure flag works as expected
2024-12-26 22:56:36 -05:00
Ikko Eltociear AshimineandGitHub 0440deb6b5 docs: Fix typo in README (#946)
docs: update README.md

`endpoints[].ui.badge.reponse-time` -> `endpoints[].ui.badge.response-time`
2024-12-25 14:11:02 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
83a555e334 chore(deps): bump github.com/TwiN/deepmerge from 0.2.1 to 0.2.2 (#922)
Bumps [github.com/TwiN/deepmerge](https://github.com/TwiN/deepmerge) from 0.2.1 to 0.2.2.
- [Release notes](https://github.com/TwiN/deepmerge/releases)
- [Commits](https://github.com/TwiN/deepmerge/compare/v0.2.1...v0.2.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-19 18:24:05 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
fcb4c71cda chore(deps): bump codecov/codecov-action from 5.1.1 to 5.1.2 (#935)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.1.1 to 5.1.2.
- [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/v5.1.1...v5.1.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-19 18:23:39 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
5169c1a91a chore(deps): bump golang.org/x/net from 0.31.0 to 0.33.0 (#936)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.31.0 to 0.33.0.
- [Commits](https://github.com/golang/net/compare/v0.31.0...v0.33.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-19 18:23:20 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
126b78fc0e chore(deps): bump codecov/codecov-action from 5.0.7 to 5.1.1 (#926)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.0.7 to 5.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/v5.0.7...v5.1.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-17 23:45:12 -05:00
TwiN 8b2cd2e4b3 docs: Improve alerting documentation 2024-12-17 21:23:19 -05:00
TwiNandGitHub 79c9f24c15 feat(alerting): Implement alert-level provider overrides (#929)
* feat(alerting): Implement alert-level provider overrides

Fixes #96

* Fix tests

* Add missing test cases for alerting providers

* feat(alerting): Implement alert-level overrides on all providers

* chore: Add config.yaml to .gitignore

* fix typo in discord provider

* test: Start fixing tests for alerting providers

* test: Fix GitLab tests

* Fix all tests

* test: Improve coverage

* test: Improve coverage

* Rename override to provider-override

* docs: Mention new provider-override config

* test: Improve coverage

* test: Improve coverage

* chore: Rename Alert.OverrideAsBytes to Alert.ProviderOverrideAsBytes
2024-12-16 20:32:13 -05:00
be9ae6f55d docs: Fix typo (#931)
chore: typo fix

Co-authored-by: TwiN <twin@linux.com>
2024-12-14 13:28:57 -05:00
Philip WhitesideandGitHub 539c7635a1 docs: Change protocol backslash (\\) to forwardslash (//) in README.md (#933)
Change protocol backslash to forwardslash in README.md

Some of the text examples used `proto:\\` instead of `proto://`. The backslash is not correct and results in "panic: invalid endpoint testgroup-|-testendpoint: unknown endpoint type".
2024-12-14 08:20:47 -05:00
TwiN d56a8487eb refactor: Rename HasTlsConfig to HasTLSConfig 2024-12-06 21:35:26 -05:00
TwiN a1bbc41b62 chore: Remove unnecessary newline 2024-12-06 21:34:44 -05:00
TwiN cdac5790cb docs: Fix typo in comment 2024-12-06 21:34:13 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ff318efb67 chore(deps): bump modernc.org/sqlite from 1.33.1 to 1.34.2 (#921)
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.33.1 to 1.34.2.
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.33.1...v1.34.2)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-05 01:05:12 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
504bd8f66c chore(deps): bump google.golang.org/api from 0.183.0 to 0.209.0 (#912)
Bumps [google.golang.org/api](https://github.com/googleapis/google-api-go-client) from 0.183.0 to 0.209.0.
- [Release notes](https://github.com/googleapis/google-api-go-client/releases)
- [Changelog](https://github.com/googleapis/google-api-go-client/blob/main/CHANGES.md)
- [Commits](https://github.com/googleapis/google-api-go-client/compare/v0.183.0...v0.209.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-03 23:12:25 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
917ad59306 chore(deps): bump github.com/aws/aws-sdk-go from 1.54.10 to 1.55.5 (#890)
Bumps [github.com/aws/aws-sdk-go](https://github.com/aws/aws-sdk-go) from 1.54.10 to 1.55.5.
- [Release notes](https://github.com/aws/aws-sdk-go/releases)
- [Commits](https://github.com/aws/aws-sdk-go/compare/v1.54.10...v1.55.5)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2024-12-03 19:13:57 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
f04dbfa516 chore(deps): bump golang.org/x/net from 0.29.0 to 0.31.0 (#894)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.29.0 to 0.31.0.
- [Commits](https://github.com/golang/net/compare/v0.29.0...v0.31.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2024-12-03 19:12:35 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
4e6e57747f chore(deps): bump codecov/codecov-action from 4.6.0 to 5.0.7 (#910)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4.6.0 to 5.0.7.
- [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/v4.6.0...v5.0.7)

---
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>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-03 19:04:46 -05:00
778019590d feat(alerting): Add overrides for Ntfy provider (#918)
* Add overrides to Ntfy alert provider

* Update alerting/provider/ntfy/ntfy.go

---------

Co-authored-by: TwiN <twin@linux.com>
2024-11-30 22:09:20 -05:00
29cbff6774 feat(client): Add support for PTR DNS records (#886)
Co-authored-by: TwiN <twin@linux.com>
2024-11-28 18:21:44 -05:00
TwiN 2fe991319e docs: Remove duplicate "Proxy client configuration" section 2024-11-19 20:45:54 -05:00
52a73125f9 feat(alerting): Update Adaptive Cards design for teams-workflows (#903)
Update adaptive cards design for msteams

Co-authored-by: Tim-orius <timorius@gmx.de>
2024-11-19 20:24:17 -05:00
TwiNandGitHub 01131755bc fix(logging): Replace log-level parameter by GATUS_LOG_LEVEL env var (#895)
* fix(logging): Replace log-level parameter by GATUS_LOG_LEVEL env var

* Improve log message if GATUS_LOG_LEVEL isn't set
2024-11-13 23:54:00 -05:00
8060a77b1f feat(logging): Allow configuring logging verbosity level (#872)
* introduces TwiN/logr library

* use new features of logr library

* minor tweaks and formatting

* Apply suggestions from code review

---------

Co-authored-by: TwiN <twin@linux.com>
2024-11-13 19:02:53 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
92bb42d444 chore(deps): bump golang.org/x/oauth2 from 0.23.0 to 0.24.0 (#893)
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.23.0 to 0.24.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.23.0...v0.24.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-12 20:33:13 -05:00
TwiNandGitHub c758af8791 docs: Fix order of alerting config params 2024-11-10 17:48:30 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
0d38132a15 chore(deps): bump golang.org/x/oauth2 from 0.21.0 to 0.23.0 (#891)
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.21.0 to 0.23.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.21.0...v0.23.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2024-11-07 23:53:46 -05:00
177feba75b feat(pushover): priority on resolved (#879)
* feat(pushover): priority on resolved

Signed-off-by: Devin Buhl <devin@buhl.casa>

* Update README.md

* Update README.md

* Rename ResolvedPriority

* Update README.md

* Update alerting/provider/pushover/pushover.go

* Update README.md

* Update pushover.go

* Update pushover_test.go

* fix: update tests

Signed-off-by: Devin Buhl <devin@buhl.casa>

* fix: update tests

Signed-off-by: Devin Buhl <devin@buhl.casa>

---------

Signed-off-by: Devin Buhl <devin@buhl.casa>
Co-authored-by: TwiN <twin@linux.com>
2024-11-05 19:57:33 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c6ff6ec583 chore(deps): bump github.com/gofiber/fiber/v2 from 2.52.4 to 2.52.5 (#888)
Bumps [github.com/gofiber/fiber/v2](https://github.com/gofiber/fiber) from 2.52.4 to 2.52.5.
- [Release notes](https://github.com/gofiber/fiber/releases)
- [Commits](https://github.com/gofiber/fiber/compare/v2.52.4...v2.52.5)

---
updated-dependencies:
- dependency-name: github.com/gofiber/fiber/v2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-04 18:52:17 -05:00
TwiNandGitHub e061c788de ci: Update open-pull-requests-limit from 1 to 3 2024-11-04 18:37:31 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e537758590 chore(deps): bump github.com/coreos/go-oidc/v3 from 3.10.0 to 3.11.0 (#870)
Bumps [github.com/coreos/go-oidc/v3](https://github.com/coreos/go-oidc) from 3.10.0 to 3.11.0.
- [Release notes](https://github.com/coreos/go-oidc/releases)
- [Commits](https://github.com/coreos/go-oidc/compare/v3.10.0...v3.11.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-04 18:34:33 -05:00
Martin StoneandGitHub 1c78f659c2 docs: Fix typo in README (#885) 2024-10-31 20:29:48 -04:00
Liam JonesandGitHub 40cbb4b1d4 fix(alerting): Add missing support for default-alert on teams-workflows (#883)
Fix default-alert on teams-workflows
2024-10-31 19:59:29 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
7103d0a9ae chore(deps): bump codecov/codecov-action from 4.5.0 to 4.6.0 (#873)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4.5.0 to 4.6.0.
- [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/v4.5.0...v4.6.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-21 21:50:03 -04:00
ff4b09dff8 feat(alerting): Implement new Teams Workflow alert (#847)
* POC Teams Workflow Alerting

Signed-off-by: James Hillyard <james.hillyard@payara.fish>

* Document Teams Workflow Alert

Signed-off-by: James Hillyard <james.hillyard@payara.fish>

* Rename 'teamsworkflow' to 'teams-workflows'

Signed-off-by: James Hillyard <james.hillyard@payara.fish>

* Fix README Table Format

Signed-off-by: James Hillyard <james.hillyard@payara.fish>

* Fix Test to Expect Correct Emoji

Signed-off-by: James Hillyard <james.hillyard@payara.fish>

---------

Signed-off-by: James Hillyard <james.hillyard@payara.fish>
Co-authored-by: TwiN <twin@linux.com>
2024-10-15 17:25:02 -04:00
Simon ErhardtandGitHub 29072da23e docs: change the url of the helm chart to your repo (#878)
fix: change the url of the helm chart to your repo
2024-10-14 11:27:37 -04:00
stendlerandGitHub bb973979d2 feat(alerting): add email and click action to ntfy provider (#778)
* feat(alerting): add optional email to ntfy provider

https://docs.ntfy.sh/publish/#e-mail-notifications

* feat(alerting): add optional click action to ntfy provider

https://docs.ntfy.sh/publish/#click-action

* feat(alerting): add option to disable firebase in ntfy provider

https://docs.ntfy.sh/publish/#disable-firebase

* feat(alerting): add option to disable message caching in ntfy provider

https://docs.ntfy.sh/publish/#message-caching

* test(alerting): add buildRequestBody tests for email and click properties

* test(alerting): add tests for Send to verify request headers

* feat(alerting): refactor to prefix firebase & cache with "disable"

This avoids the need for a pointer, as omitting these bools in the config defaults to false
and omitting to set these headers will use the server's default - which is enabled on ntfy.sh
2024-10-04 20:23:07 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
3a7be5caff chore(deps): bump github.com/valyala/fasthttp from 1.54.0 to 1.56.0 (#869)
Bumps [github.com/valyala/fasthttp](https://github.com/valyala/fasthttp) from 1.54.0 to 1.56.0.
- [Release notes](https://github.com/valyala/fasthttp/releases)
- [Commits](https://github.com/valyala/fasthttp/compare/1.54.0...v1.56.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-25 23:57:19 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
bd1a544cf7 chore(deps): bump github.com/prometheus/client_golang from 1.19.1 to 1.20.4 (#867)
chore(deps): bump github.com/prometheus/client_golang

Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.19.1 to 1.20.4.
- [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.19.1...v1.20.4)

---
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>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-23 20:43:27 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
aa51b0ad70 chore(deps): bump github.com/miekg/dns from 1.1.61 to 1.1.62 (#866)
Bumps [github.com/miekg/dns](https://github.com/miekg/dns) from 1.1.61 to 1.1.62.
- [Changelog](https://github.com/miekg/dns/blob/master/Makefile.release)
- [Commits](https://github.com/miekg/dns/compare/v1.1.61...v1.1.62)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-22 21:39:41 -04:00
fc09981779 feat(alerting): alert teams add client insecure option (#864)
* feat: alert teams add client insecure option

* feat(docs): add comment for `ClientConfig` field

* fix typo

---------

Co-authored-by: raojinlin <raojinlin302@gmail.com>
Co-authored-by: TwiN <twin@linux.com>
2024-09-21 20:26:30 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
de2d41ed88 chore(deps): bump github.com/wcharczuk/go-chart/v2 from 2.1.1 to 2.1.2 (#862)
Bumps [github.com/wcharczuk/go-chart/v2](https://github.com/wcharczuk/go-chart) from 2.1.1 to 2.1.2.
- [Release notes](https://github.com/wcharczuk/go-chart/releases)
- [Commits](https://github.com/wcharczuk/go-chart/compare/v2.1.1...v2.1.2)

---
updated-dependencies:
- dependency-name: github.com/wcharczuk/go-chart/v2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-19 22:15:21 -04:00
59842d5e88 feat(alerting): custom alert support endpoint errors (#844)
* feat(alerting): add support for including endpoint errors in custom alerts

- Updated `buildHTTPRequest` method in `AlertProvider` to accept a `result` parameter.
- Added support for including `[ENDPOINT_ERRORS]` in both the request body and URL, which will be replaced by the errors from `Result.Errors[]`.
- Adjusted `CreateExternalEndpointResult` to capture and store errors from query parameters.
- This allows custom alerts to include detailed error information, enhancing the flexibility of alert notifications.

* feat: add ENDPOINT_ERRORS example

* feat: add tests

* Refactor: code review feedback

* delete unsed errors

* Update README.md

* Apply suggestions from code review

---------

Co-authored-by: raojinlin <raojinlin302@gmail.com>
Co-authored-by: TwiN <twin@linux.com>
2024-09-17 22:26:21 -04:00
b0c2f1eba9 feat(alerting): Add optional field alerting.mattermost.channel (#823)
* Add optional field alerting.mattermost.channel

* Added omitempty to channel in Body for Mattermost

* Update README.md

* Update alerting/provider/mattermost/mattermost.go

* Expose channel configuration parameter through alerting.mattermost.channel

---------

Co-authored-by: Gerrit <gerrit.abma@logius.nl>
Co-authored-by: TwiN <twin@linux.com>
2024-09-17 21:02:31 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
f55f39fa33 chore(deps): bump golang.org/x/net from 0.26.0 to 0.29.0 (#861)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.26.0 to 0.29.0.
- [Commits](https://github.com/golang/net/compare/v0.26.0...v0.29.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-17 19:48:51 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1a0c0af403 chore(deps): bump modernc.org/sqlite from 1.31.1 to 1.33.1 (#860)
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.31.1 to 1.33.1.
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.31.1...v1.33.1)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-16 19:04:13 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
12b04802ef chore(deps): bump golang.org/x/crypto from 0.24.0 to 0.27.0 (#857)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.24.0 to 0.27.0.
- [Commits](https://github.com/golang/crypto/compare/v0.24.0...v0.27.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-11 21:15:34 -04:00
d947a6b6f5 feat(alerting): Implement Zulip's alerts (#845)
* feat(alerting): Add alert type for Zulip

* feat(alerting): Implement Zulip alert provider

* feat(alerting): Add Zulip to alerting/config.go

* docs: Add Zulip alerts to README.md

* fix(alerting): Include alert description in message

* fix(alerting): validate Zuilip interface on compile

* chore(alerting): fix import order

* fix(alerting): rename ChannelId to ChannelID

* Update alerting/provider/zulip/zulip_test.go

---------

Co-authored-by: TwiN <twin@linux.com>
2024-09-03 23:21:08 -04:00
TwiNandGitHub 54221eff9b fix(maintenance): Import time/tzdata to support timezones (#849)
Fix #848
2024-08-28 18:41:21 -04:00
TwiN d04b68979a fix(remote): Stream endpoint statuses from remote instances instead of loading them all into memory 2024-08-26 21:30:53 -04:00
7de5a1fe48 feat(alerting): implement Gitea alerting provider (#842)
* feat: implement Gitea alerting provider integration

- Add TypeGitea for the gitea alerting provider
- Introduce a new file for the gitea alerting provider implementation
- Implement the AlertProvider struct with necessary fields for gitea integration
- Add validation logic for the AlertProvider configuration
- Create tests for the AlertProvider's validation and sending functionality
- Update go.mod to include the gitea SDK as a dependency
- Modify the alerting configuration validation to recognize TypeGitea

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

* chore: integrate Gitea alerting provider configuration

- Add Gitea alerting provider import to the configuration file
- Update the comment for the RepositoryURL field to reflect Gitea instead of GitHub

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

* feat: add Assignees support to AlertProvider functionality

- Add a field for Assignees to the AlertProvider struct
- Update the Send function to include Assignees in the alert payload

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

* feat: implement Gitea alerting configuration and documentation

- Add a new image asset for Gitea alerts
- Update the README to include configuration details for Gitea alerts
- Introduce parameters for Gitea alerting, including repository URL and personal access token
- Document the behavior of the Gitea alerting provider regarding issue creation and resolution
- Include an example YAML configuration for Gitea alerts

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

* Update README.md

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

* Update README.md

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

* Update README.md

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

* feat: refactor AlertProvider for improved client configuration

- Add import for the Gatus client library
- Remove the SkipVerify field from the AlertProvider struct
- Introduce ClientConfig field in the AlertProvider struct for client configuration
- Update validation logic to check for ClientConfig instead of SkipVerify

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

* Update README.md

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

* chore: update configuration for Gitea integration

- Change references from GitHub to Gitea in the configuration section
- Update alerting provider descriptions to reflect the correct platform
- Swap the order of GitHub and Gitea configurations
- Replace Gitea alert image with GitHub alert image
- Adjust the type field from gitea to github in the relevant sections

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

* fix: ensure ClientConfig is validated and defaults set

- Add a check for nil ClientConfig in the IsValid function
- Set ClientConfig to a default configuration if it is nil

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

---------

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
Co-authored-by: TwiN <twin@linux.com>
2024-08-21 17:51:45 -04:00
TwiNandGitHub dd435a8eaf feat(storage): Support 30d badges (#836)
* feat(storage): Add support for 30d uptime badge

Fix #714

* Fix typo

* Fix test

* Fix typo

* Improve implementation

* Add check in existing test

* Add extra test to ensure functionality works

* Add support for 30d response time chart too
2024-08-11 22:40:19 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
90ffea9fb6 chore(deps): bump github.com/miekg/dns from 1.1.59 to 1.1.61 (#829)
Bumps [github.com/miekg/dns](https://github.com/miekg/dns) from 1.1.59 to 1.1.61.
- [Changelog](https://github.com/miekg/dns/blob/master/Makefile.release)
- [Commits](https://github.com/miekg/dns/compare/v1.1.59...v1.1.61)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-04 13:13:19 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c23d0b6f3b chore(deps): bump modernc.org/sqlite from 1.30.0 to 1.31.1 (#824)
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.30.0 to 1.31.1.
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.30.0...v1.31.1)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-31 00:00:47 -04:00
2c8c456512 feat(alerting): add telegram overriding token and id (#826)
* feat(alerting): add telegram overriding token and id

* Update alerting/provider/telegram/telegram_test.go

* Update alerting/provider/telegram/telegram_test.go

* Update alerting/provider/telegram/telegram_test.go

---------

Co-authored-by: TwiN <twin@linux.com>
2024-07-27 19:05:17 -04:00
af00dfdb73 feat(alerting): add timezone for maintenance (#653)
* feat(alerting): add timezone for maintenance

* Update config/maintenance/maintenance.go

* docs: Add example of maintenance.timezone in readme.md

* fix: Only set time to timezone location if the location is set

* fix: Include the original error in the message

---------

Co-authored-by: TwiN <twin@linux.com>
2024-07-01 19:41:33 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b2191391f6 chore(deps): bump github.com/aws/aws-sdk-go from 1.53.19 to 1.54.10 (#814)
Bumps [github.com/aws/aws-sdk-go](https://github.com/aws/aws-sdk-go) from 1.53.19 to 1.54.10.
- [Release notes](https://github.com/aws/aws-sdk-go/releases)
- [Commits](https://github.com/aws/aws-sdk-go/compare/v1.53.19...v1.54.10)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-29 23:57:20 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
5399b8a2fc chore(deps): bump docker/build-push-action from 5 to 6 (#806)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 5 to 6.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v5...v6)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-22 22:39:52 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
65b77e9525 chore(deps): bump codecov/codecov-action from 4.4.1 to 4.5.0 (#800)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4.4.1 to 4.5.0.
- [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/v4.4.1...v4.5.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-18 23:53:28 -04:00
Chris SmithandGitHub 23a5da8dc6 docs: Add mTLS docker-compose example (#744)
Add mTLS docker-compose example
2024-06-10 18:47:47 -04:00
TwiNandGitHub 11aeec45c9 chore(deps): Update Go to 1.22 and update all Go dependencies (#794)
* fix: Print the right variable when logging used config path on start

* chore(deps): Update Go to 1.22 and update all Go dependencies
2024-06-08 12:39:26 -04:00
TwiNandGitHub 714dd4ba09 build: Drop support for linux/arm/v6 (#773) 2024-05-21 22:43:59 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
4a41f8c504 chore(deps): bump codecov/codecov-action from 4.4.0 to 4.4.1 (#771)
---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2024-05-21 22:30:55 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
7213add6b7 chore(deps): bump github.com/TwiN/gocache/v2 from 2.2.0 to 2.2.2 (#772)
---
updated-dependencies:
- dependency-name: github.com/TwiN/gocache/v2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-21 20:17:55 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
ea7bf2c194 chore(deps): bump codecov/codecov-action from 4.3.0 to 4.4.0 (#765)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4.3.0 to 4.4.0.
- [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/v4.3.0...v4.4.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2024-05-20 21:51:58 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
db8535c3f3 chore(deps): bump github.com/gofiber/fiber/v2 from 2.52.1 to 2.52.4 (#739)
Bumps [github.com/gofiber/fiber/v2](https://github.com/gofiber/fiber) from 2.52.1 to 2.52.4.
- [Release notes](https://github.com/gofiber/fiber/releases)
- [Commits](https://github.com/gofiber/fiber/compare/v2.52.1...v2.52.4)

---
updated-dependencies:
- dependency-name: github.com/gofiber/fiber/v2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2024-05-20 21:48:02 -04:00
TwiN fdd00e7851 fix(alerting): Remove duplicate code and improve testing
Related to #768
2024-05-19 18:31:01 -04:00
TwiN 2a94f76244 fix(alerting): Make sure alert is triggered before attempting to persist it to the database
Related to #764 and #679
2024-05-17 18:18:06 -04:00
TwiNandGitHub f2c5f5911c feat(alerting): Persist triggered alerts across application restart (#764)
* feat(alerting): Persist triggered alerts across application restart

Fixes #679

* test(alerting): Add numerous tests related to alerts
2024-05-15 21:29:45 -04:00
TwiNandGitHub 9d151fcdb4 refactor: Break core package into multiple packages under config/endpoint (#759)
* refactor: Partially break core package into dns, result and ssh packages

* refactor: Move core package to config/endpoint

* refactor: Fix warning about overlapping imported package name with endpoint variable

* refactor: Rename EndpointStatus to Status

* refactor: Merge result pkg back into endpoint pkg, because it makes more sense

* refactor: Rename parameter r to result in Condition.evaluate

* refactor: Rename parameter r to result

* refactor: Revert accidental change to endpoint.TypeDNS

* refactor: Rename parameter r to result

* refactor: Merge util package into endpoint package

* refactor: Rename parameter r to result
2024-05-09 22:56:16 -04:00
TwiN 4397dcb5fc docs: Improve Result.ConditionResults description 2024-05-04 20:05:06 -04:00
739e6c75a6 fix(alerting): Make Microsoft Teams title customizable (#731)
* Teams Custom Title

* Update README.md

* Small adjustment

---------

Co-authored-by: TwiN <twin@linux.com>
2024-05-04 19:24:25 -04:00
TwiNandGitHub dcfdfd423e docs: Remove unnecessary badge 2024-04-27 14:39:42 -04:00
178 changed files with 11152 additions and 3638 deletions
@@ -0,0 +1,29 @@
-----BEGIN CERTIFICATE-----
MIIFBjCCAu6gAwIBAgIUHJXHAqywj2v25AgX7pDSZ+LX4iAwDQYJKoZIhvcNAQEL
BQAwEjEQMA4GA1UEAwwHZXhhbXBsZTAeFw0yNDA0MjUwMTQ1MDFaFw0yOTA0MjQw
MTQ1MDFaMBExDzANBgNVBAMMBmNsaWVudDCCAiIwDQYJKoZIhvcNAQEBBQADggIP
ADCCAgoCggIBANTmRlS5BNG82mOdrhtRPIBD5U40nEW4CVFm85ZJ4Bge4Ty86juf
aoCnI6AEfwpVnJhXPzjUsMBxJFMbiCB+QTJRpxTphtK7orpbwRHjaDZNaLr1MrUO
ieADGiHw93zVDikD8FP5vG+2XWWA56hY84Ac0TR9GqPjsW0nobMgBNgsRtbYUD0B
T5QOItK180xQRn4jbys5jRnr161S+Sbg6mglz1LBFBCLmZnhZFZ8FAn87gumbnWN
etSnu9kX6iOXBIaB+3nuHOL4xmAan8tAyen6mPfkXrE5ogovjqFFMTUJOKQoJVp3
zzm/0XYANxoItFGtdjGMTl5IgI220/6kfpn6PYN7y1kYn5EI+UbobD/CuAhd94p6
aQwOXU53/l+eNH/XnTsL/32QQ6qdq8sYqevlslk1M39kKNewWYCeRzYlCVscQk14
O3fkyXrtRkz30xrzfjvJQ/VzMi+e5UlemsCuCXTVZ5YyBnuWyY+mI6lZICltZSSX
VinKzpz+t4Jl7glhKiGHaNAkBX2oLddyf280zw4Cx7nDMPs4uOHONYpm90IxEOJe
zgJ9YxPK9aaKv2AoYLbvhYyKrVT+TFqoEsbQk4vK0t0Gc1j5z4dET31CSOuxVnnU
LYwtbILFc0uZrbuOAbEbXtjPpw2OGqWagD0QpkE8TjN0Hd0ibyXyUuz5AgMBAAGj
VTBTMBEGA1UdEQQKMAiCBmNsaWVudDAdBgNVHQ4EFgQUleILTHG5lT2RhSe9H4fV
xUh0bNUwHwYDVR0jBBgwFoAUbh9Tg4oxxnHJTSaa0WLBTesYwxEwDQYJKoZIhvcN
AQELBQADggIBABq8zjRrDaljl867MXAlmbV7eJkSnaWRFct+N//jCVNnKMYaxyQm
+UG12xYP0U9Zr9vhsqwyTZTQFx/ZFiiz2zfXPtUAppV3AjE67IlKRbec3qmUhj0H
Rv20eNNWXTl1XTX5WDV5887TF+HLZm/4W2ZSBbS3V89cFhBLosy7HnBGrP0hACne
ZbdQWnnLHJMDKXkZey1H1ZLQQCQdAKGS147firj29M8uzSRHgrR6pvsNQnRT0zDL
TlTJoxyGTMaoj+1IZvRsAYMZCRb8Yct/v2i/ukIykFWUJZ+1Z3UZhGrX+gdhLfZM
jAP4VQ+vFgwD6NEXAA2DatoRqxbN1ZGJQkvnobWJdZDiYu4hBCs8ugKUTE+0iXWt
hSyrAVUspFCIeDN4xsXT5b0j2Ps4bpSAiGx+aDDTPUnd881I6JGCiIavgvdFMLCW
yOXJOZvXcNQwsndkob5fZAEqetjrARsHhQuygEq/LnPc6lWsO8O6UzYArEiKWTMx
N/5hx12Pb7aaQd1f4P3gmmHMb/YiCQK1Qy5d4v68POeqyrLvAHbvCwEMhBAbnLvw
gne3psql8s5wxhnzwYltcBUmmAw1t33CwzRBGEKifRdLGtA9pbua4G/tomcDDjVS
ChsHGebJvNxOnsQqoGgozqM2x8ScxmJzIflGxrKmEA8ybHpU0d02Xp3b
-----END CERTIFICATE-----
@@ -0,0 +1,51 @@
-----BEGIN RSA PRIVATE KEY-----
MIIJKQIBAAKCAgEA1OZGVLkE0bzaY52uG1E8gEPlTjScRbgJUWbzlkngGB7hPLzq
O59qgKcjoAR/ClWcmFc/ONSwwHEkUxuIIH5BMlGnFOmG0ruiulvBEeNoNk1ouvUy
tQ6J4AMaIfD3fNUOKQPwU/m8b7ZdZYDnqFjzgBzRNH0ao+OxbSehsyAE2CxG1thQ
PQFPlA4i0rXzTFBGfiNvKzmNGevXrVL5JuDqaCXPUsEUEIuZmeFkVnwUCfzuC6Zu
dY161Ke72RfqI5cEhoH7ee4c4vjGYBqfy0DJ6fqY9+ResTmiCi+OoUUxNQk4pCgl
WnfPOb/RdgA3Ggi0Ua12MYxOXkiAjbbT/qR+mfo9g3vLWRifkQj5RuhsP8K4CF33
inppDA5dTnf+X540f9edOwv/fZBDqp2ryxip6+WyWTUzf2Qo17BZgJ5HNiUJWxxC
TXg7d+TJeu1GTPfTGvN+O8lD9XMyL57lSV6awK4JdNVnljIGe5bJj6YjqVkgKW1l
JJdWKcrOnP63gmXuCWEqIYdo0CQFfagt13J/bzTPDgLHucMw+zi44c41imb3QjEQ
4l7OAn1jE8r1poq/YChgtu+FjIqtVP5MWqgSxtCTi8rS3QZzWPnPh0RPfUJI67FW
edQtjC1sgsVzS5mtu44BsRte2M+nDY4apZqAPRCmQTxOM3Qd3SJvJfJS7PkCAwEA
AQKCAgAPwAALUStib3aMkLlfpfve1VGyc8FChcySrBYbKS3zOt2Y27T3DOJuesRE
7fA5Yyn+5H1129jo87XR5s3ZnDLV4SUw2THd3H8RCwFWgcdPinHUBZhnEpial5V9
q1DzzY3gSj1OSRcVVfLE3pYaEIflvhFasQ1L0JLAq4I9OSzX5+FPEEOnWmB5Ey6k
/fbuJLDXsLwPAOadDfiFBwgNm0KxdRKdtvugBGPW9s4Fzo9rnxLmjmfKOdmQv96Y
FI/Vat0Cgmfd661RZpbDvKnTpIsLdzw3zTpAIYOzqImvCT+3AmP2qPhSdV3sPMeR
047qqyLZOVxEFXLQFiGvL4uxYUPy8k0ZI9xkgOfZ/uASozMWsHkaD04+UDi1+kw5
nfasZLvOWBW/WE/E1Rfz8IiYTeZbgTnY4CraiLrIRc0LGgD1Df4gNr25+P+LKLyK
/WW89dl6/397HOFnA7CHi7DaA8+9uZAjOWhoCNDdqAVa3QpDD/3/iRiih26bjJfH
2+sarxU8GovDZFxWd59BUP3jkukCFH+CliQy72JtLXiuPNPAWeGV9UXxtIu40sRX
Sax/TQytYi2J9NJFZFMTwVueIfzsWc8dyM+IPAYJQxN94xYKQU4+Rb/wqqHgUfjT
1ZQJb8Cmg56IDY/0EPJWQ0qgnE7TZbY2BOEYbpOzdccwUbcEjQKCAQEA8kVyw4Hw
nqcDWXjzMhOOoRoF8CNwXBvE2KBzpuAioivGcSkjkm8vLGfQYAbDOVMPFt3xlZS0
0lQm894176Kk8BiMqtyPRWWOsv4vYMBTqbehKn09Kbh6lM7d7jO7sh5iWf4jt3Bw
Sk4XhZ9oQ/kpnEKiHPymHQY3pVYEyFCGJ8mdS6g/TWiYmjMjkQDVFA4xkiyJ0S5J
NGYxI+YXtHVTVNSePKvY0h51EqTxsexAphGjXnQ3xoe6e3tVGBkeEkcZlESFD/91
0iqdc5VtKQOwy6Tj4Awk7oK5/u3tfpyIyo31LQIqreTqMO534838lpyp3CbRdvCF
QdCNpKFX1gZgmwKCAQEA4Pa9VKO3Aw95fpp0T81xNi+Js/NhdsvQyv9NI9xOKKQU
hiWxmYmyyna3zliDGlqtlw113JFTNQYl1k1yi4JQPu2gnj8te9nB0yv0RVxvbTOq
u8K1j9Xmj8XVpcKftusQsZ2xu52ONj3ZOOf22wE4Y6mdQcps+rN6XTHRBn7a5b0v
ZCvWf4CIttdIh51pZUIbZKHTU51uU7AhTCY/wEUtiHwYTT9Wiy9Lmay5Lh2s2PCz
yPE5Y970nOzlSCUl3bVgY1t0xbQtaO5AJ/iuw/vNw+YAiAIPNDUcbcK5njb//+0E
uTEtDA6SHeYfsNXGDzxipueKXFHfJLCTXnnT5/1v+wKCAQEA0pF78uNAQJSGe8B9
F3waDnmwyYvzv4q/J00l19edIniLrJUF/uM2DBFa8etOyMchKU3UCJ9MHjbX+EOd
e19QngGoWWUD/VwMkBQPF7dxv+QDZwudGmLl3+qAx+Uc8O4pq3AQmQJYBq0jEpd/
Jv0rpk3f2vPYaQebW8+MrpIWWASK+1QLWPtdD0D9W61uhVTkzth5HF9vbuSXN01o
Mwd6WxPFSJRQCihAtui3zV26vtw7sv+t7pbPhT2nsx85nMdBOzXmtQXi4Lz7RpeM
XgaAJi91g6jqfIcQo7smHVJuLib9/pWQhL2estLBTzUcocced2Mh0Y+xMofSZFF7
J2E5mwKCAQAO9npbUdRPYM0c7ZsE385C42COVobKBv5pMhfoZbPRIjC3R3SLmMwK
iWDqWZrGuvdGz79iH0xgf3suyNHwk4dQ2C9RtzQIQ9CPgiHqJx7GLaSSfn3jBkAi
me7+6nYDDZl7pth2eSFHXE/BaDRUFr2wa0ypXpRnDF78Kd8URoW6uB2Z1QycSGlP
d/w8AO1Mrdvykozix9rZuCJO1VByMme350EaijbwZQHrQ8DBX3nqp//dQqYljWPJ
uDv703S0TWcO1LtslvJaQ1aDEhhVsr7Z48dvRGvMdifg6Q29hzz5wcMJqkqrvaBc
Wr0K3v0gcEzDey0JvOxRnWj/5KyChqnXAoIBAQDq6Dsks6BjVP4Y1HaA/NWcZxUU
EZfNCTA19jIHSUiPbWzWHNdndrUq33HkPorNmFaEIrTqd/viqahr2nXpYiY/7E+V
cpn9eSxot5J8DB4VI92UG9kixxY4K7QTMKvV43Rt6BLosW/cHxW5XTNhB4JDK+TO
NlHH48fUp2qJh7/qwSikDG130RVHKwK/5Fv3NQyXTw1/n9bhnaC4eSvV39CNSeb5
rWNEZcnc9zHT2z1UespzVTxVy4hscrkssXxcCq4bOF4bnDFjfblE43o/KrVr2/Ub
jzpXQrAwXNq7pAkIpin0v40lCeTMosSgQLFqMWmtmlCpBVkyEAc9ZYXc3Vs0
-----END RSA PRIVATE KEY-----
@@ -0,0 +1,29 @@
-----BEGIN CERTIFICATE-----
MIIE9DCCAtygAwIBAgIUCXgA3IbeA2mn8DQ0E5IxaKBLtf8wDQYJKoZIhvcNAQEL
BQAwEjEQMA4GA1UEAwwHZXhhbXBsZTAeFw0yNDA0MjUwMTE5MzRaFw0zNDA0MjMw
MTE5MzRaMBIxEDAOBgNVBAMMB2V4YW1wbGUwggIiMA0GCSqGSIb3DQEBAQUAA4IC
DwAwggIKAoICAQDLE4aTrVJrAVYksFJt5fIVhEJT5T0cLqvtDRf9hXA5Gowremsl
VJPBm4qbdImzJZCfCcbVjFEBw8h9xID1JUqRWjJ8BfTnpa4qc1e+xRtnvC+OsUeT
CCgZvK3TZ5vFsaEbRoNGuiaNq9WSTfjLwTxkK6C3Xogm9uDx73PdRob1TNK5A9mE
Ws3ZyV91+g1phKdlNMRaK+wUrjUjEMLgr0t5A5t6WKefsGrFUDaT3sye3ZxDYuEa
ljt+F8hLVyvkDBAhh6B4S5dQILjp7L3VgOsG7Hx9py1TwCbpWXZEuee/1/2OD8tA
ALsxkvRE1w4AZzLPYRL/dOMllLjROQ4VugU8GVpNU7saK5SeWBw3XHyJ9m8vne3R
cPWaZTfkwfj8NjCgi9BzBPW8/uw7XZMmQFyTj494OKM3T5JQ5jZ5XD97ONm9h+C/
oOmkcWHz6IwEUu7XV5IESxiFlrq8ByAYF98XPhn2wMMrm2OvHMOwrfw2+5U8je5C
z70p9kpiGK8qCyjbOl9im975jwFCbl7LSj3Y+0+vRlTG/JA4jNZhXsMJcAxeJpvr
pmm/IzN+uXNQzmKzBHVDw+mTUMPziRsUq4q6WrcuQFZa6kQFGNYWI/eWV8o4AAvp
HtrOGdSyU19w0QqPW0wHmhsV2XFcn6H/E1Qg6sxWpl45YWJFhNaITxm1EQIDAQAB
o0IwQDAOBgNVHQ8BAf8EBAMCAgQwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU
bh9Tg4oxxnHJTSaa0WLBTesYwxEwDQYJKoZIhvcNAQELBQADggIBAKvOh81Gag0r
0ipYS9aK6rp58b6jPpF6shr3xFiJQVovgSvxNS3aWolh+ZupTCC3H2Q1ZUgatak0
VyEJVO4a7Tz+1XlA6KErhnORC6HB/fgr5KEGraO3Q1uWonPal5QU8xHFStbRaXfx
hl/k4LLhIdJqcJE+XX/AL8ekZ3NPDtf9+k4V+RBuarLGuKgOtBB8+1qjSpClmW2B
DaWPlrLPOr2Sd29WOeWHifwVc6kBGpwM3g5VGdDsNX4Ba5eIG3lX2kUzJ8wNGEf0
bZxcVbTBY+D4JaV4WXoeFmajjK3EdizRpJRZw3fM0ZIeqVYysByNu/TovYLJnBPs
5AybnO4RzYONKJtZ1GtQgJyG+80/VffDJeBmHKEiYvE6mvOFEBAcU4VLU6sfwfT1
y1dZq5G9Km72Fg5kCuYDXTT+PB5VAV3Z6k819tG3TyI4hPlEphpoidRbZ+QS9tK5
RgHah9EJoM7tDAN/mUVHJHQhhLJDBn+iCBYgSJVLwoE+F39NO9oFPD/ZxhJkbk9b
LkFnpjrVbwD1CNnawX3I2Eytg1IbbzyviQIbpSAEpotk9pCLMAxTR3a08wrVMwst
2XVSrgK0uUKsZhCIc+q21k98aeNIINor15humizngyBWYOk8SqV84ZNcD6VlM3Qv
ShSKoAkdKxcGG1+MKPt5b7zqvTo8BBPM
-----END CERTIFICATE-----
@@ -0,0 +1,30 @@
-----BEGIN CERTIFICATE-----
MIIFDjCCAvagAwIBAgITc5Ejz7RzBJ2/PcUMsVhj41RtQDANBgkqhkiG9w0BAQsF
ADASMRAwDgYDVQQDDAdleGFtcGxlMB4XDTI0MDQyNTAxNDQ1N1oXDTI5MDQyNDAx
NDQ1N1owEDEOMAwGA1UEAwwFbmdpbngwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAw
ggIKAoICAQCgbLBnVrBdRkBF2XmJgDTiRqWFPQledzCrkHF4eiUvtEytJhkpoRv2
+SiRPsjCo3XjwcgQIgSy1sHUV8Sazn7V5ux/XBRovhdhUivzI8JSRYj6qwqdUnOy
dG1ZEy/VRLsIVfoFB0jKJrZCXMT256xkYTlsgPePDsduO7IPPrTN0/I/qBvINFet
zgWCl2qlZgF4c/MHljo2TR1KlBv0RJUZbfXPwemUazyMrh/MfQHaHE5pfrmMWFGA
6yLYHEhG+fy5d3F/1+4J24D2j7deIFmmuJMPSlAPt1UjDm7M/bmoTxDG+1MRXSnN
647EzzS0TFZspHe2+yBbw6j0MMiWMzNZX2iXGVcswXwrphe7ro6OITynM76gDTuM
ISYXKYHayqW0rHFRlKxMcnmrpf5tBuK7XKyoQv/LbFKI1e+j1bNVe7OZtC88EWRc
SD8WDLqo/3rsxJkRXRW/49hO1nynHrknXJEpZeRnTyglS+VCzXYD0XzwzPKN7CyN
CHpYpOcWrAMF+EJnE4WRVyJAAt4C1pGhiwn0yCvLEGXXedI/rR5zmUBKitSe7oMT
J82H/VaGtwH0lOD9Jjsv9cb+s1c3tChPDKvgGGDaFnlehKg9TM7p+xc9mnEsitfv
ovSGzYHk29nQu/S4QrPfWuCNwM2vP9OQ+VJyzDzSyH8iuPPmkfmK5wIDAQABo18w
XTAbBgNVHREEFDASggVuZ2lueIIJbG9jYWxob3N0MB0GA1UdDgQWBBT89oboWPBC
oNsSbaNquzrjTza6xDAfBgNVHSMEGDAWgBRuH1ODijHGcclNJprRYsFN6xjDETAN
BgkqhkiG9w0BAQsFAAOCAgEAeg8QwBTne1IGZMDvIGgs95lifzuTXGVQWEid7VVp
MmXGRYsweb0MwTUq3gSUc+3OPibR0i5HCJRR04H4U+cIjR6em1foIV/bW6nTaSls
xQAj92eMmzOo/KtOYqMnk//+Da5NvY0myWa/8FgJ7rK1tOZYiTZqFOlIsaiQMHgp
/PEkZBP5V57h0PY7T7tEj4SCw3DJ6qzzIdpD8T3+9kXd9dcrrjbivBkkJ23agcG5
wBcI862ELNJOD7p7+OFsv7IRsoXXYrydaDg8OJQovh4RccRqVEQu3hZdi7cPb8xJ
G7Gxn8SfSVcPg/UObiggydMl8E8QwqWAzJHvl1KUECd5QG6eq984JTR7zQB2iGb6
1qq+/d9uciuB2YY2h/0rl3Fjy6J6k3fpQK577TlJjZc0F4WH8fW5bcsyGTszxQLI
jQ6FuSOr55lZ9O3R3+95tAdJTrWsxX7j7xMIAXSYrfNt5HM91XNhqISF4SIZOBB6
enVrrJ/oCFqVSbYf6RVQz3XmPEEMh+k9KdwvIvwoS9NivLD3QH0RjhTyzHbf+LlR
rWM46XhmBwajlpnIuuMp6jZcXnbhTO1SheoRVMdijcnW+zrmx5oyn3peCfPqOVLz
95YfJUIFCt+0p/87/0Mm76uVemK6kFKZJQPnfbAdsKF7igPZfUQx6wZZP1qK9ZEU
eOk=
-----END CERTIFICATE-----
@@ -0,0 +1,51 @@
-----BEGIN RSA PRIVATE KEY-----
MIIJKQIBAAKCAgEAoGywZ1awXUZARdl5iYA04kalhT0JXncwq5BxeHolL7RMrSYZ
KaEb9vkokT7IwqN148HIECIEstbB1FfEms5+1ebsf1wUaL4XYVIr8yPCUkWI+qsK
nVJzsnRtWRMv1US7CFX6BQdIyia2QlzE9uesZGE5bID3jw7HbjuyDz60zdPyP6gb
yDRXrc4FgpdqpWYBeHPzB5Y6Nk0dSpQb9ESVGW31z8HplGs8jK4fzH0B2hxOaX65
jFhRgOsi2BxIRvn8uXdxf9fuCduA9o+3XiBZpriTD0pQD7dVIw5uzP25qE8QxvtT
EV0pzeuOxM80tExWbKR3tvsgW8Oo9DDIljMzWV9olxlXLMF8K6YXu66OjiE8pzO+
oA07jCEmFymB2sqltKxxUZSsTHJ5q6X+bQbiu1ysqEL/y2xSiNXvo9WzVXuzmbQv
PBFkXEg/Fgy6qP967MSZEV0Vv+PYTtZ8px65J1yRKWXkZ08oJUvlQs12A9F88Mzy
jewsjQh6WKTnFqwDBfhCZxOFkVciQALeAtaRoYsJ9MgryxBl13nSP60ec5lASorU
nu6DEyfNh/1WhrcB9JTg/SY7L/XG/rNXN7QoTwyr4Bhg2hZ5XoSoPUzO6fsXPZpx
LIrX76L0hs2B5NvZ0Lv0uEKz31rgjcDNrz/TkPlScsw80sh/Irjz5pH5iucCAwEA
AQKCAgADiEEeFV+OvjQ+FXrCl0sSzGFqnJxvMwqkTGrjLzVQZpTlnxggvYZjGrtU
71/2QSkgWazxBf66fVYJOeF/Uxqh1RLR/xIH+F+FagzDrr7hltxcQJXcPuuDO2MI
+g4skPXZSiNWJwHoSY/ryCUiFpnKIAXmqLRKtxWXDMNv6H6MpaUI18e80cI4dnfS
l0jm2Wcg4tSwDxO7DFmfwcEX0MbDp5Mo/ukIto+/vTnAA+Sdi9ACLKMjPvKUdxju
TzkcLvbskn+yQ+ve1bFyPFnaPbYboKbESGuY3P2H5xJzewayeQMyjmgW0slP2mbr
WHCdo6ynebuVENR2kMlQjx5riDcSMMX5TLGPgNL7ZBf2b52mUgFyQb27eO2WXeyH
YLtInlKA44bdi76sDK+s8zYywZnxsUy7xrKhHE5rqz964EfoLRcY/fCm7XnMo6uK
VviBtdPebsMqkZOUKSaYSRpUgXILTud5FD+m68FeVjUvQFQqHYEa3gx+rAIjKBIn
082NzfDZSHVsvG+iB5q+37R8C0/YUzSb3TXys5pA82YsjIFeQiVE4hrV1yeNIZf6
2iaPD/r5H3vt0rFEDINZafC+6bTTRQoq8TOCZFh/Lu+ynXKOPrVUF8/y3sd8+T2v
kRDOL37reUotjE1lbO4RhLgHbeWHlT/PPnF7RDKCe6/erg2MqQKCAQEAy3f8B6I8
7CP4CZmMDWwHWsjMS/HGZgvPPbmWhaeZZmFyYi7I8MruJPhlhlw6YoUIV9Vvp8zE
eLtDvZ5WXuL38aRElWzNyrhrU1/vH4pkaFk+OgRcaleGUof+go0lE8BIYnWoWovo
/F7lQMQmHY4SuwF4oj6dpus7jMm41PQqDTsjofdLgwVAGy30LIkVt8qYha77sL8N
0ohXomDGik0nVa+i2mOJ0UuooGYF8WhujzVcELcerYvvg9kFDqJaEXdfTx4DRwiz
6f5gSbZHME7moqEkcJRtwj8TXSJYRHTI8ngS0xzyV0u2RL3FOxTcgikJIkmU6W3L
IcbP6XVlrCdoswKCAQEAydfBcsYcS2mMqCOdKkGVj6zBriT78/5dtPYeId9WkrnX
1vz6ErjHQ8vZkduvCm3KkijQvva+DFV0sv24qTyA2BIoDUJdk7cY962nR4Q9FHTX
Dkn1kgeKg4TtNdgo2KsIUn7bCibKASCExo6rO3PWiQyF+jTJVDD3rXx7+7N7WJaz
zTVt6BNOWoIjTufdXfRWt3wi0H6sSkqvRWoIAaguXkKXH7oBx0gKs+oAVovFvg7A
LLEtTszsv2LmbpGWaiT3Ny215mA0ZGI9T4utK7oUgd+DlV0+vj5tFfsye4COpCyG
V/ZQ7CBbxHDDak3R3fYy5pOwmh6814wHMyKKfdGm/QKCAQEAiW4Pk3BnyfA5lvJZ
gK9ZAF7kbt9tbHvJjR2Pp9Meb+KeCecj3lCTLfGBUZF19hl5GyqU8jgC9LE3/hm2
qPyREGwtzufg0G5kP7pqn1kwnLK6ryFG8qUPmys0IyYGxyJ3QdnKzu31fpDyNB7I
x+mwiRNjUeMNRTNZ06xk5aHNzYYGeV25aVPgivstE++79ZooDxOz+Rvy0CM7XfgT
4lJeoSeyzeOxsOZzjXObzAUHuD8IYlntpLcCHoI1Qj8yqt2ASMYy3IXqT8B7dQ5j
YyPH8Ez7efcnc656+8s453QiTnP/8wx4O7Jt+FxdnZxnnJrvCnO82zZHoBbTVBLx
i6hKtQKCAQA0j3SWmLRBhwjTuAJzQITb1xbQbF0X2oM4XmbWVzxKFQ75swLD4U4y
f2D2tIhOZOy9RtelAsfWmmI7QgrWNyUuHvxDB6cqkiF0Tcoju3HUY+CknenOzxvo
x7KltNZeJZuTL+mGKTetN3Sb6Ab7Al05bwNsdlZ/EAlPKf13O/PAy+2iYGlwZ6ad
twnOwF5K2xfBzBecx3/CENS3dLcFB3CbpyeHYX6ZEE+JLkRMRTWHGnw8px6vSHnW
FMEAxfSvS1T9D3Awv5ilE1f34N2FZ31znGq9eHygOc1aTgGFW6LJabbKLSBBfOOo
sdyRUBZ4gGYc2RTB7YMrdhFh5Xq+7NtZAoIBAQCOJ3CLecp/rS+lGy7oyx4f6QDd
zH/30Y/uvXLPUj+Ljg9bMTG9chjaKfyApXv6rcQI0d6wrqAunNl1b3opBQjsGCSt
bpBV/rGg3sl752og6KU1PCZ2KkVYPjugNhqPGonNh8tlw+1xFyBdt0c68g/auIHq
WaT5tWVfP01Ri43RjyCgNtJ2TJUzbA40BteDHPWKeM1lZ6e92fJTp5IjQ/Okc41u
Elr7p22fx/N04JTX9G6oGdxM7Gh2Uf4i4PnNOi+C3xqLrtUEi/OLof2UHlatypt9
pix0bXJtZE7WfFfesQIxGffVBhgN3UgqhAf2wquHgm1O17JXrmkR6JSYNpKc
-----END RSA PRIVATE KEY-----
@@ -0,0 +1,13 @@
endpoints:
- name: example
url: https://nginx
interval: 30s
conditions:
- "[STATUS] == 200"
client:
# mtls
insecure: true
tls:
certificate-file: /certs/client.crt
private-key-file: /certs/client.key
renegotiation: once
@@ -0,0 +1,27 @@
version: "3.9"
services:
nginx:
image: nginx:stable
volumes:
- ./certs/server:/etc/nginx/certs
- ./nginx:/etc/nginx/conf.d
ports:
- "8443:443"
networks:
- mtls
gatus:
image: twinproduction/gatus:latest
restart: always
ports:
- "8080:8080"
volumes:
- ./config:/config
- ./certs/client:/certs
environment:
- GATUS_CONFIG_PATH=/config
networks:
- mtls
networks:
mtls:
@@ -0,0 +1,16 @@
server {
listen 443 ssl;
ssl_certificate /etc/nginx/certs/server.crt;
ssl_certificate_key /etc/nginx/certs/server.key;
ssl_client_certificate /etc/nginx/certs/ca.crt;
ssl_verify_client on;
location / {
if ($ssl_client_verify != SUCCESS) {
return 403;
}
root /usr/share/nginx/html;
index index.html index.htm;
}
}
@@ -1,5 +1,4 @@
metrics: true metrics: true
debug: false
ui: ui:
header: Example Company header: Example Company
link: https://example.org link: https://example.org
Binary file not shown.

After

Width:  |  Height:  |  Size: 638 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+1 -1
View File
@@ -1,7 +1,7 @@
ignore: ignore:
- "watchdog/watchdog.go" - "watchdog/watchdog.go"
- "storage/store/sql/specific_postgres.go" # Can't test for postgres - "storage/store/sql/specific_postgres.go" # Can't test for postgres
comment: false
coverage: coverage:
status: status:
patch: off patch: off
+1 -1
View File
@@ -7,7 +7,7 @@ updates:
interval: "daily" interval: "daily"
- package-ecosystem: "gomod" - package-ecosystem: "gomod"
directory: "/" directory: "/"
open-pull-requests-limit: 1 open-pull-requests-limit: 3
labels: ["dependencies"] labels: ["dependencies"]
schedule: schedule:
interval: "daily" interval: "daily"
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
steps: steps:
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: with:
go-version: 1.19 go-version: 1.24.1
repository: "${{ github.event.inputs.repository || 'TwiN/gatus' }}" repository: "${{ github.event.inputs.repository || 'TwiN/gatus' }}"
ref: "${{ github.event.inputs.ref || 'master' }}" ref: "${{ github.event.inputs.ref || 'master' }}"
- uses: actions/checkout@v4 - uses: actions/checkout@v4
+42
View File
@@ -0,0 +1,42 @@
name: labeler
on:
pull_request_target:
types:
- opened
issues:
types:
- opened
jobs:
labeler:
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
issues: write
pull-requests: write
steps:
- name: Label
continue-on-error: true
env:
TITLE: ${{ github.event.issue.title }}${{ github.event.pull_request.title }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
NUMBER: ${{ github.event.issue.number }}${{ github.event.pull_request.number }}
run: |
if [[ $TITLE == "feat"* ]]; then
gh issue edit "$NUMBER" --add-label "feature"
elif [[ $TITLE == "fix"* ]]; then
gh issue edit "$NUMBER" --add-label "bug"
fi
if [[ $TITLE == *"alerting"* || $TITLE == *"provider"* || $TITLE == *"alert"* ]]; then
gh issue edit "$NUMBER" --add-label "area/alerting"
fi
if [[ $TITLE == *"storage"* || $TITLE == *"postgres"* || $TITLE == *"sqlite"* ]]; then
gh issue edit "$NUMBER" --add-label "area/storage"
fi
if [[ $TITLE == *"security"* || $TITLE == *"oidc"* || $TITLE == *"oauth2"* ]]; then
gh issue edit "$NUMBER" --add-label "area/security"
fi
if [[ $TITLE == *"metric"* || $TITLE == *"prometheus"* ]]; then
gh issue edit "$NUMBER" --add-label "area/metrics"
fi
+40
View File
@@ -0,0 +1,40 @@
name: publish-custom
run-name: "${{ inputs.tag }}"
on:
workflow_dispatch:
inputs:
tag:
description: Custom tag to publish
jobs:
publish-custom:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Get image repository
run: echo GHCR_IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.GHCR_IMAGE_REPOSITORY }}
tags: |
type=raw,value=${{ inputs.tag }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
platforms: linux/amd64
pull: true
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+10 -2
View File
@@ -17,10 +17,18 @@ jobs:
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_REPOSITORY }}
tags: |
type=raw,value=experimental
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v6
with: with:
platforms: linux/amd64 platforms: linux/amd64
pull: true pull: true
push: true push: true
tags: ${{ env.IMAGE_REPOSITORY }}:experimental tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
@@ -1,38 +0,0 @@
name: publish-latest-to-ghcr
on:
workflow_run:
workflows: [test]
branches: [master]
types: [completed]
concurrency:
group: ${{ github.event.workflow_run.head_repository.full_name }}::${{ github.event.workflow_run.head_branch }}::${{ github.workflow }}
cancel-in-progress: true
jobs:
publish-latest-to-ghcr:
runs-on: ubuntu-latest
if: ${{ (github.event.workflow_run.conclusion == 'success') && (github.event.workflow_run.head_repository.full_name == github.repository) }}
permissions:
contents: read
packages: write
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Get image repository
run: echo IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Login to Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
pull: true
push: true
tags: ${{ env.IMAGE_REPOSITORY }}:latest
+24 -6
View File
@@ -11,7 +11,7 @@ jobs:
publish-latest: publish-latest:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ (github.event.workflow_run.conclusion == 'success') && (github.event.workflow_run.head_repository.full_name == github.repository) }} if: ${{ (github.event.workflow_run.conclusion == 'success') && (github.event.workflow_run.head_repository.full_name == github.repository) }}
timeout-minutes: 60 timeout-minutes: 90
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up QEMU - name: Set up QEMU
@@ -19,16 +19,34 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- 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 DOCKER_IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
echo GHCR_IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Login to Docker Registry - name: Login to Docker Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
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: Login to GitHub Container Registry
uses: docker/build-push-action@v5 uses: docker/login-action@v3
with: with:
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64 registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.DOCKER_IMAGE_REPOSITORY }}
${{ env.GHCR_IMAGE_REPOSITORY }}
tags: |
type=raw,value=latest
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
platforms: linux/amd64,linux/arm/v7,linux/arm64
pull: true pull: true
push: true push: true
tags: ${{ env.IMAGE_REPOSITORY }}:latest tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
@@ -1,37 +0,0 @@
name: publish-release-to-ghcr
on:
release:
types: [published]
jobs:
publish-release-to-ghcr:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Get image repository
run: echo IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Get the release
run: echo RELEASE=${GITHUB_REF/refs\/tags\//} >> $GITHUB_ENV
- name: Login to Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
pull: true
push: true
tags: |
${{ env.IMAGE_REPOSITORY }}:${{ env.RELEASE }}
${{ env.IMAGE_REPOSITORY }}:stable
${{ env.IMAGE_REPOSITORY }}:latest
+25 -8
View File
@@ -14,7 +14,9 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- 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 DOCKER_IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
echo GHCR_IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ 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: Login to Docker Registry - name: Login to Docker Registry
@@ -22,13 +24,28 @@ jobs:
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: Login to GitHub Container Registry
uses: docker/build-push-action@v5 uses: docker/login-action@v3
with: with:
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64 registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.DOCKER_IMAGE_REPOSITORY }}
${{ env.GHCR_IMAGE_REPOSITORY }}
tags: |
type=raw,value=${{ env.RELEASE }}
type=raw,value=stable
type=raw,value=latest
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
platforms: linux/amd64,linux/arm/v7,linux/arm64
pull: true pull: true
push: true push: true
tags: | tags: ${{ steps.meta.outputs.tags }}
${{ env.IMAGE_REPOSITORY }}:${{ env.RELEASE }} labels: ${{ steps.meta.outputs.labels }}
${{ env.IMAGE_REPOSITORY }}:stable
${{ env.IMAGE_REPOSITORY }}:latest
+18
View File
@@ -0,0 +1,18 @@
name: test-ui
on:
pull_request:
paths:
- 'web/**'
push:
branches:
- master
paths:
- 'web/**'
jobs:
test-ui:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- run: make frontend-install-dependencies
- run: make frontend-build
+2 -2
View File
@@ -18,7 +18,7 @@ jobs:
steps: steps:
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: with:
go-version: 1.21 go-version: 1.24.1
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Build binary to make sure it works - name: Build binary to make sure it works
run: go build run: go build
@@ -28,7 +28,7 @@ jobs:
# 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 ./... -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@v4.3.0 uses: codecov/codecov-action@v5.4.3
with: with:
files: ./coverage.txt files: ./coverage.txt
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
+2 -1
View File
@@ -17,4 +17,5 @@ node_modules
*.db-shm *.db-shm
*.db-wal *.db-wal
gatus gatus
config/config.yml config/config.yml
config.yaml
+4 -2
View File
@@ -1,5 +1,5 @@
# Build the go application into a binary # Build the go application into a binary
FROM golang:alpine as builder FROM golang:alpine AS builder
RUN apk --update add ca-certificates RUN apk --update add ca-certificates
WORKDIR /app WORKDIR /app
COPY . ./ COPY . ./
@@ -15,6 +15,8 @@ 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 /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 GATUS_CONFIG_PATH=""
ENV GATUS_LOG_LEVEL="INFO"
ENV PORT="8080"
EXPOSE ${PORT} EXPOSE ${PORT}
ENTRYPOINT ["/gatus"] ENTRYPOINT ["/gatus"]
+8 -1
View File
@@ -6,7 +6,11 @@ install:
.PHONY: run .PHONY: run
run: run:
GATUS_CONFIG_PATH=./config.yaml ./$(BINARY) ENVIRONMENT=dev GATUS_CONFIG_PATH=./config.yaml go run main.go
.PHONY: run-binary
run-binary:
ENVIRONMENT=dev GATUS_CONFIG_PATH=./config.yaml ./$(BINARY)
.PHONY: clean .PHONY: clean
clean: clean:
@@ -34,6 +38,9 @@ docker-build-and-run: docker-build docker-run
# Front end # # Front end #
############# #############
frontend-install-dependencies:
npm --prefix web/app install
frontend-build: frontend-build:
npm --prefix web/app run build npm --prefix web/app run build
+493 -193
View File
File diff suppressed because it is too large Load Diff
+37 -5
View File
@@ -1,8 +1,14 @@
package alert package alert
import ( import (
"crypto/sha256"
"encoding/hex"
"errors" "errors"
"strconv"
"strings" "strings"
"github.com/TwiN/logr"
"gopkg.in/yaml.v3"
) )
var ( var (
@@ -10,7 +16,7 @@ var (
ErrAlertWithInvalidDescription = errors.New("alert description must not have \" or \\") ErrAlertWithInvalidDescription = errors.New("alert description must not have \" or \\")
) )
// Alert is a core.Endpoint's alert configuration // Alert is endpoint.Endpoint's alert configuration
type Alert struct { type Alert struct {
// Type of alert (required) // Type of alert (required)
Type Type `yaml:"type"` Type Type `yaml:"type"`
@@ -26,20 +32,24 @@ type Alert struct {
// FailureThreshold is the number of failures in a row needed before triggering the alert // FailureThreshold is the number of failures in a row needed before triggering the alert
FailureThreshold int `yaml:"failure-threshold"` FailureThreshold int `yaml:"failure-threshold"`
// SuccessThreshold defines how many successful executions must happen in a row before an ongoing incident is marked as resolved
SuccessThreshold int `yaml:"success-threshold"`
// Description of the alert. Will be included in the alert sent. // Description of the alert. Will be included in the alert sent.
// //
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value // This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
// or not for provider.ParseWithDefaultAlert to work. // or not for provider.ParseWithDefaultAlert to work.
Description *string `yaml:"description"` Description *string `yaml:"description,omitempty"`
// SendOnResolved defines whether to send a second notification when the issue has been resolved // SendOnResolved defines whether to send a second notification when the issue has been resolved
// //
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value // This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
// or not for provider.ParseWithDefaultAlert to work. Use Alert.IsSendingOnResolved() for a non-pointer // or not for provider.ParseWithDefaultAlert to work. Use Alert.IsSendingOnResolved() for a non-pointer
SendOnResolved *bool `yaml:"send-on-resolved"` SendOnResolved *bool `yaml:"send-on-resolved,omitempty"`
// SuccessThreshold defines how many successful executions must happen in a row before an ongoing incident is marked as resolved // ProviderOverride is an optional field that can be used to override the provider's configuration
SuccessThreshold int `yaml:"success-threshold"` // It is freeform so that it can be used for any provider-specific configuration.
ProviderOverride map[string]any `yaml:"provider-override,omitempty"`
// ResolveKey is an optional field that is used by some providers (i.e. PagerDuty's dedup_key) to resolve // ResolveKey is an optional field that is used by some providers (i.e. PagerDuty's dedup_key) to resolve
// ongoing/triggered incidents // ongoing/triggered incidents
@@ -94,3 +104,25 @@ func (alert *Alert) IsSendingOnResolved() bool {
} }
return *alert.SendOnResolved return *alert.SendOnResolved
} }
// Checksum returns a checksum of the alert
// Used to determine which persisted triggered alert should be deleted on application start
func (alert *Alert) Checksum() string {
hash := sha256.New()
hash.Write([]byte(string(alert.Type) + "_" +
strconv.FormatBool(alert.IsEnabled()) + "_" +
strconv.FormatBool(alert.IsSendingOnResolved()) + "_" +
strconv.Itoa(alert.SuccessThreshold) + "_" +
strconv.Itoa(alert.FailureThreshold) + "_" +
alert.GetDescription()),
)
return hex.EncodeToString(hash.Sum(nil))
}
func (alert *Alert) ProviderOverrideAsBytes() []byte {
yamlBytes, err := yaml.Marshal(alert.ProviderOverride)
if err != nil {
logr.Warnf("[alert.ProviderOverrideAsBytes] Failed to marshal alert override of type=%s as bytes: %v", alert.Type, err)
}
return yamlBytes
}
+106
View File
@@ -84,3 +84,109 @@ func TestAlert_IsSendingOnResolved(t *testing.T) {
t.Error("alert.IsSendingOnResolved() should've returned true, because SendOnResolved was set to true") t.Error("alert.IsSendingOnResolved() should've returned true, because SendOnResolved was set to true")
} }
} }
func TestAlert_Checksum(t *testing.T) {
description1, description2 := "a", "b"
yes, no := true, false
scenarios := []struct {
name string
alert Alert
expected string
}{
{
name: "barebone",
alert: Alert{
Type: TypeDiscord,
},
expected: "fed0580e44ed5701dbba73afa1f14b2c53ca5a7b8067a860441c212916057fe3",
},
{
name: "with-description-1",
alert: Alert{
Type: TypeDiscord,
Description: &description1,
},
expected: "005f407ebe506e74a4aeb46f74c28b376debead7011e1b085da3840f72ba9707",
},
{
name: "with-description-2",
alert: Alert{
Type: TypeDiscord,
Description: &description2,
},
expected: "3c2c4a9570cdc614006993c21f79a860a7f5afea10cf70d1a79d3c49342ef2c8",
},
{
name: "with-description-2-and-enabled-false",
alert: Alert{
Type: TypeDiscord,
Enabled: &no,
Description: &description2,
},
expected: "837945c2b4cd5e961db3e63e10c348d4f1c3446ba68cf5a48e35a1ae22cf0c22",
},
{
name: "with-description-2-and-enabled-true",
alert: Alert{
Type: TypeDiscord,
Enabled: &yes, // it defaults to true if not set, but just to make sure
Description: &description2,
},
expected: "3c2c4a9570cdc614006993c21f79a860a7f5afea10cf70d1a79d3c49342ef2c8",
},
{
name: "with-description-2-and-enabled-true-and-send-on-resolved-true",
alert: Alert{
Type: TypeDiscord,
Enabled: &yes,
SendOnResolved: &yes,
Description: &description2,
},
expected: "bf1436995a880eb4a352c74c5dfee1f1b5ff6b9fc55aef9bf411b3631adfd80c",
},
{
name: "with-description-2-and-failure-threshold-7",
alert: Alert{
Type: TypeSlack,
FailureThreshold: 7,
Description: &description2,
},
expected: "8bd479e18bda393d4c924f5a0d962e825002168dedaa88b445e435db7bacffd3",
},
{
name: "with-description-2-and-failure-threshold-9",
alert: Alert{
Type: TypeSlack,
FailureThreshold: 9,
Description: &description2,
},
expected: "5abdfce5236e344996d264d526e769c07cb0d3d329a999769a1ff84b157ca6f1",
},
{
name: "with-description-2-and-success-threshold-5",
alert: Alert{
Type: TypeSlack,
SuccessThreshold: 7,
Description: &description2,
},
expected: "c0000e73626b80e212cfc24830de7094568f648e37f3e16f9e68c7f8ef75c34c",
},
{
name: "with-description-2-and-success-threshold-1",
alert: Alert{
Type: TypeSlack,
SuccessThreshold: 1,
Description: &description2,
},
expected: "5c28963b3a76104cfa4a0d79c89dd29ec596c8cfa4b1af210ec83d6d41587b5f",
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
scenario.alert.ValidateAndSetDefaults()
if checksum := scenario.alert.Checksum(); checksum != scenario.expected {
t.Errorf("expected checksum %v, got %v", scenario.expected, checksum)
}
})
}
}
+12
View File
@@ -23,12 +23,18 @@ const (
// TypeGitLab is the Type for the gitlab alerting provider // TypeGitLab is the Type for the gitlab alerting provider
TypeGitLab Type = "gitlab" TypeGitLab Type = "gitlab"
// TypeGitea is the Type for the gitea alerting provider
TypeGitea Type = "gitea"
// TypeGoogleChat is the Type for the googlechat alerting provider // TypeGoogleChat is the Type for the googlechat alerting provider
TypeGoogleChat Type = "googlechat" TypeGoogleChat Type = "googlechat"
// TypeGotify is the Type for the gotify alerting provider // TypeGotify is the Type for the gotify alerting provider
TypeGotify Type = "gotify" TypeGotify Type = "gotify"
// TypeIncidentIO is the Type for the incident-io alerting provider
TypeIncidentIO Type = "incident-io"
// TypeJetBrainsSpace is the Type for the jetbrains alerting provider // TypeJetBrainsSpace is the Type for the jetbrains alerting provider
TypeJetBrainsSpace Type = "jetbrainsspace" TypeJetBrainsSpace Type = "jetbrainsspace"
@@ -59,9 +65,15 @@ const (
// TypeTeams is the Type for the teams alerting provider // TypeTeams is the Type for the teams alerting provider
TypeTeams Type = "teams" TypeTeams Type = "teams"
// TypeTeamsWorkflows is the Type for the teams-workflows alerting provider
TypeTeamsWorkflows Type = "teams-workflows"
// TypeTelegram is the Type for the telegram alerting provider // TypeTelegram is the Type for the telegram alerting provider
TypeTelegram Type = "telegram" TypeTelegram Type = "telegram"
// TypeTwilio is the Type for the twilio alerting provider // TypeTwilio is the Type for the twilio alerting provider
TypeTwilio Type = "twilio" TypeTwilio Type = "twilio"
// TypeZulip is the Type for the Zulip alerting provider
TypeZulip Type = "zulip"
) )
+18 -2
View File
@@ -1,7 +1,6 @@
package alerting package alerting
import ( import (
"log"
"reflect" "reflect"
"strings" "strings"
@@ -11,10 +10,12 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/custom" "github.com/TwiN/gatus/v5/alerting/provider/custom"
"github.com/TwiN/gatus/v5/alerting/provider/discord" "github.com/TwiN/gatus/v5/alerting/provider/discord"
"github.com/TwiN/gatus/v5/alerting/provider/email" "github.com/TwiN/gatus/v5/alerting/provider/email"
"github.com/TwiN/gatus/v5/alerting/provider/gitea"
"github.com/TwiN/gatus/v5/alerting/provider/github" "github.com/TwiN/gatus/v5/alerting/provider/github"
"github.com/TwiN/gatus/v5/alerting/provider/gitlab" "github.com/TwiN/gatus/v5/alerting/provider/gitlab"
"github.com/TwiN/gatus/v5/alerting/provider/googlechat" "github.com/TwiN/gatus/v5/alerting/provider/googlechat"
"github.com/TwiN/gatus/v5/alerting/provider/gotify" "github.com/TwiN/gatus/v5/alerting/provider/gotify"
"github.com/TwiN/gatus/v5/alerting/provider/incidentio"
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace" "github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
"github.com/TwiN/gatus/v5/alerting/provider/matrix" "github.com/TwiN/gatus/v5/alerting/provider/matrix"
"github.com/TwiN/gatus/v5/alerting/provider/mattermost" "github.com/TwiN/gatus/v5/alerting/provider/mattermost"
@@ -25,8 +26,11 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/pushover" "github.com/TwiN/gatus/v5/alerting/provider/pushover"
"github.com/TwiN/gatus/v5/alerting/provider/slack" "github.com/TwiN/gatus/v5/alerting/provider/slack"
"github.com/TwiN/gatus/v5/alerting/provider/teams" "github.com/TwiN/gatus/v5/alerting/provider/teams"
"github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows"
"github.com/TwiN/gatus/v5/alerting/provider/telegram" "github.com/TwiN/gatus/v5/alerting/provider/telegram"
"github.com/TwiN/gatus/v5/alerting/provider/twilio" "github.com/TwiN/gatus/v5/alerting/provider/twilio"
"github.com/TwiN/gatus/v5/alerting/provider/zulip"
"github.com/TwiN/logr"
) )
// Config is the configuration for alerting providers // Config is the configuration for alerting providers
@@ -49,12 +53,18 @@ type Config struct {
// GitLab is the configuration for the gitlab alerting provider // GitLab is the configuration for the gitlab alerting provider
GitLab *gitlab.AlertProvider `yaml:"gitlab,omitempty"` GitLab *gitlab.AlertProvider `yaml:"gitlab,omitempty"`
// Gitea is the configuration for the gitea alerting provider
Gitea *gitea.AlertProvider `yaml:"gitea,omitempty"`
// GoogleChat is the configuration for the googlechat alerting provider // GoogleChat is the configuration for the googlechat alerting provider
GoogleChat *googlechat.AlertProvider `yaml:"googlechat,omitempty"` GoogleChat *googlechat.AlertProvider `yaml:"googlechat,omitempty"`
// Gotify is the configuration for the gotify alerting provider // Gotify is the configuration for the gotify alerting provider
Gotify *gotify.AlertProvider `yaml:"gotify,omitempty"` Gotify *gotify.AlertProvider `yaml:"gotify,omitempty"`
// IncidentIO is the configuration for the incident-io alerting provider
IncidentIO *incidentio.AlertProvider `yaml:"incident-io,omitempty"`
// JetBrainsSpace is the configuration for the jetbrains space alerting provider // JetBrainsSpace is the configuration for the jetbrains space alerting provider
JetBrainsSpace *jetbrainsspace.AlertProvider `yaml:"jetbrainsspace,omitempty"` JetBrainsSpace *jetbrainsspace.AlertProvider `yaml:"jetbrainsspace,omitempty"`
@@ -85,11 +95,17 @@ type Config struct {
// Teams is the configuration for the teams alerting provider // Teams is the configuration for the teams alerting provider
Teams *teams.AlertProvider `yaml:"teams,omitempty"` Teams *teams.AlertProvider `yaml:"teams,omitempty"`
// TeamsWorkflows is the configuration for the teams alerting provider using the new Workflow App Webhook Connector
TeamsWorkflows *teamsworkflows.AlertProvider `yaml:"teams-workflows,omitempty"`
// Telegram is the configuration for the telegram alerting provider // Telegram is the configuration for the telegram alerting provider
Telegram *telegram.AlertProvider `yaml:"telegram,omitempty"` Telegram *telegram.AlertProvider `yaml:"telegram,omitempty"`
// Twilio is the configuration for the twilio alerting provider // Twilio is the configuration for the twilio alerting provider
Twilio *twilio.AlertProvider `yaml:"twilio,omitempty"` Twilio *twilio.AlertProvider `yaml:"twilio,omitempty"`
// Zulip is the configuration for the zulip alerting provider
Zulip *zulip.AlertProvider `yaml:"zulip,omitempty"`
} }
// GetAlertingProviderByAlertType returns an provider.AlertProvider by its corresponding alert.Type // GetAlertingProviderByAlertType returns an provider.AlertProvider by its corresponding alert.Type
@@ -106,7 +122,7 @@ func (config *Config) GetAlertingProviderByAlertType(alertType alert.Type) provi
return fieldValue.Interface().(provider.AlertProvider) return fieldValue.Interface().(provider.AlertProvider)
} }
} }
log.Printf("[alerting.GetAlertingProviderByAlertType] No alerting provider found for alert type %s", alertType) logr.Infof("[alerting.GetAlertingProviderByAlertType] No alerting provider found for alert type %s", alertType)
return nil return nil
} }
+110 -49
View File
@@ -1,30 +1,73 @@
package awsses package awsses
import ( import (
"errors"
"fmt" "fmt"
"strings" "strings"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/logr"
"github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/awserr" "github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/aws/credentials" "github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/ses" "github.com/aws/aws-sdk-go/service/ses"
"gopkg.in/yaml.v3"
) )
const ( const (
CharSet = "UTF-8" CharSet = "UTF-8"
) )
// AlertProvider is the configuration necessary for sending an alert using AWS Simple Email Service var (
type AlertProvider struct { ErrDuplicateGroupOverride = errors.New("duplicate group override")
ErrMissingFromOrToFields = errors.New("from and to fields are required")
ErrInvalidAWSAuthConfig = errors.New("either both or neither of access-key-id and secret-access-key must be specified")
)
type Config struct {
AccessKeyID string `yaml:"access-key-id"` AccessKeyID string `yaml:"access-key-id"`
SecretAccessKey string `yaml:"secret-access-key"` SecretAccessKey string `yaml:"secret-access-key"`
Region string `yaml:"region"` Region string `yaml:"region"`
From string `yaml:"from"` From string `yaml:"from"`
To string `yaml:"to"` To string `yaml:"to"`
}
func (cfg *Config) Validate() error {
if len(cfg.From) == 0 || len(cfg.To) == 0 {
return ErrMissingFromOrToFields
}
if !((len(cfg.AccessKeyID) == 0 && len(cfg.SecretAccessKey) == 0) || (len(cfg.AccessKeyID) > 0 && len(cfg.SecretAccessKey) > 0)) {
// if both AccessKeyID and SecretAccessKey are specified, we'll use these to authenticate,
// otherwise if neither are specified, then we'll fall back on IAM authentication.
return ErrInvalidAWSAuthConfig
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.AccessKeyID) > 0 {
cfg.AccessKeyID = override.AccessKeyID
}
if len(override.SecretAccessKey) > 0 {
cfg.SecretAccessKey = override.SecretAccessKey
}
if len(override.Region) > 0 {
cfg.Region = override.Region
}
if len(override.From) > 0 {
cfg.From = override.From
}
if len(override.To) > 0 {
cfg.To = override.To
}
}
// AlertProvider is the configuration necessary for sending an alert using AWS Simple Email Service
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// 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"`
@@ -35,36 +78,37 @@ type AlertProvider struct {
// Override is a case under which the default integration is overridden // Override is a case under which the default integration is overridden
type Override struct { type Override struct {
Group string `yaml:"group"` Group string `yaml:"group"`
To string `yaml:"to"` Config `yaml:",inline"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool) registeredGroups := make(map[string]bool)
if provider.Overrides != nil { if provider.Overrides != nil {
for _, override := range provider.Overrides { for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.To) == 0 { if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.To) == 0 {
return false return ErrDuplicateGroupOverride
} }
registeredGroups[override.Group] = true registeredGroups[override.Group] = true
} }
} }
// if both AccessKeyID and SecretAccessKey are specified, we'll use these to authenticate, return provider.DefaultConfig.Validate()
// otherwise if neither are specified, then we'll fall back on IAM authentication.
return len(provider.From) > 0 && len(provider.To) > 0 &&
((len(provider.AccessKeyID) == 0 && len(provider.SecretAccessKey) == 0) || (len(provider.AccessKeyID) > 0 && len(provider.SecretAccessKey) > 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(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
sess, err := provider.createSession() cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil { if err != nil {
return err return err
} }
svc := ses.New(sess) awsSession, err := provider.createSession(cfg)
subject, body := provider.buildMessageSubjectAndBody(endpoint, alert, result, resolved) if err != nil {
emails := strings.Split(provider.getToForGroup(endpoint.Group), ",") return err
}
svc := ses.New(awsSession)
subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)
emails := strings.Split(cfg.To, ",")
input := &ses.SendEmailInput{ input := &ses.SendEmailInput{
Destination: &ses.Destination{ Destination: &ses.Destination{
@@ -82,26 +126,24 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
Data: aws.String(subject), Data: aws.String(subject),
}, },
}, },
Source: aws.String(provider.From), Source: aws.String(cfg.From),
} }
_, err = svc.SendEmail(input) if _, err = svc.SendEmail(input); err != nil {
if err != nil {
if aerr, ok := err.(awserr.Error); ok { if aerr, ok := err.(awserr.Error); ok {
switch aerr.Code() { switch aerr.Code() {
case ses.ErrCodeMessageRejected: case ses.ErrCodeMessageRejected:
fmt.Println(ses.ErrCodeMessageRejected, aerr.Error()) logr.Error(ses.ErrCodeMessageRejected + ": " + aerr.Error())
case ses.ErrCodeMailFromDomainNotVerifiedException: case ses.ErrCodeMailFromDomainNotVerifiedException:
fmt.Println(ses.ErrCodeMailFromDomainNotVerifiedException, aerr.Error()) logr.Error(ses.ErrCodeMailFromDomainNotVerifiedException + ": " + aerr.Error())
case ses.ErrCodeConfigurationSetDoesNotExistException: case ses.ErrCodeConfigurationSetDoesNotExistException:
fmt.Println(ses.ErrCodeConfigurationSetDoesNotExistException, aerr.Error()) logr.Error(ses.ErrCodeConfigurationSetDoesNotExistException + ": " + aerr.Error())
default: default:
fmt.Println(aerr.Error()) logr.Error(aerr.Error())
} }
} else { } else {
// Print the error, cast err to awserr.Error to get the Code and // Print the error, cast err to awserr.Error to get the Code and
// Message from an error. // Message from an error.
fmt.Println(err.Error()) logr.Error(err.Error())
} }
return err return err
@@ -109,15 +151,25 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
return nil return nil
} }
func (provider *AlertProvider) createSession(cfg *Config) (*session.Session, error) {
awsConfig := &aws.Config{
Region: aws.String(cfg.Region),
}
if len(cfg.AccessKeyID) > 0 && len(cfg.SecretAccessKey) > 0 {
awsConfig.Credentials = credentials.NewStaticCredentials(cfg.AccessKeyID, cfg.SecretAccessKey, "")
}
return session.NewSession(awsConfig)
}
// buildMessageSubjectAndBody builds the message subject and body // buildMessageSubjectAndBody builds the message subject and body
func (provider *AlertProvider) buildMessageSubjectAndBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) (string, string) { func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) (string, string) {
var subject, message string var subject, message string
if resolved { if resolved {
subject = fmt.Sprintf("[%s] Alert resolved", endpoint.DisplayName()) subject = fmt.Sprintf("[%s] Alert resolved", ep.DisplayName())
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", ep.DisplayName(), alert.SuccessThreshold)
} else { } else {
subject = fmt.Sprintf("[%s] Alert triggered", endpoint.DisplayName()) subject = fmt.Sprintf("[%s] Alert triggered", ep.DisplayName())
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold) message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
} }
var formattedConditionResults string var formattedConditionResults string
if len(result.ConditionResults) > 0 { if len(result.ConditionResults) > 0 {
@@ -139,29 +191,38 @@ func (provider *AlertProvider) buildMessageSubjectAndBody(endpoint *core.Endpoin
return subject, message + description + formattedConditionResults return subject, message + description + formattedConditionResults
} }
// 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
} }
func (provider *AlertProvider) createSession() (*session.Session, error) { // GetConfig returns the configuration for the provider with the overrides applied
config := &aws.Config{ func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
Region: aws.String(provider.Region), cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
} }
if len(provider.AccessKeyID) > 0 && len(provider.SecretAccessKey) > 0 { // Handle alert overrides
config.Credentials = credentials.NewStaticCredentials(provider.AccessKeyID, provider.SecretAccessKey, "") if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
} }
return session.NewSession(config) // Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
} }
+105 -43
View File
@@ -4,62 +4,64 @@ import (
"testing" "testing"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
func TestAlertDefaultProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{} invalidProvider := AlertProvider{}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
invalidProviderWithOneKey := AlertProvider{From: "from@example.com", To: "to@example.com", AccessKeyID: "1"} invalidProviderWithOneKey := AlertProvider{DefaultConfig: Config{From: "from@example.com", To: "to@example.com", AccessKeyID: "1"}}
if invalidProviderWithOneKey.IsValid() { if err := invalidProviderWithOneKey.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{From: "from@example.com", To: "to@example.com"} validProvider := AlertProvider{DefaultConfig: Config{From: "from@example.com", To: "to@example.com"}}
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
validProviderWithKeys := AlertProvider{From: "from@example.com", To: "to@example.com", AccessKeyID: "1", SecretAccessKey: "1"} validProviderWithKeys := AlertProvider{DefaultConfig: Config{From: "from@example.com", To: "to@example.com", AccessKeyID: "1", SecretAccessKey: "1"}}
if !validProviderWithKeys.IsValid() { if err := validProviderWithKeys.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
func TestAlertProvider_IsValidWithOverride(t *testing.T) { func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{ providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
To: "to@example.com", Config: Config{To: "to@example.com"},
Group: "", Group: "",
}, },
}, },
} }
if providerWithInvalidOverrideGroup.IsValid() { if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid") t.Error("provider Group shouldn't have been valid")
} }
providerWithInvalidOverrideTo := AlertProvider{ providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
To: "", Config: Config{To: ""},
Group: "group", Group: "group",
}, },
}, },
} }
if providerWithInvalidOverrideTo.IsValid() { if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid") t.Error("provider integration key shouldn't have been valid")
} }
providerWithValidOverride := AlertProvider{ providerWithValidOverride := AlertProvider{
From: "from@example.com", DefaultConfig: Config{
To: "to@example.com", From: "from@example.com",
To: "to@example.com",
},
Overrides: []Override{ Overrides: []Override{
{ {
To: "to@example.com", Config: Config{To: "to@example.com"},
Group: "group", Group: "group",
}, },
}, },
} }
if !providerWithValidOverride.IsValid() { if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
@@ -95,10 +97,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
subject, body := scenario.Provider.buildMessageSubjectAndBody( subject, body := scenario.Provider.buildMessageSubjectAndBody(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.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},
}, },
@@ -124,64 +126,124 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
} }
} }
func TestAlertProvider_getToForGroup(t *testing.T) { func TestAlertProvider_getConfigWithOverrides(t *testing.T) {
tests := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
InputGroup string InputGroup string
ExpectedOutput string InputAlert alert.Alert
ExpectedOutput Config
}{ }{
{ {
Name: "provider-no-override-specify-no-group-should-default", Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
To: "to@example.com", DefaultConfig: Config{
From: "from@example.com",
To: "to@example.com",
},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "to@example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{From: "from@example.com", To: "to@example.com"},
}, },
{ {
Name: "provider-no-override-specify-group-should-default", Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
To: "to@example.com", DefaultConfig: Config{
From: "from@example.com",
To: "to@example.com",
},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "to@example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{From: "from@example.com", To: "to@example.com"},
}, },
{ {
Name: "provider-with-override-specify-no-group-should-default", Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
To: "to@example.com", DefaultConfig: Config{
From: "from@example.com",
To: "to@example.com",
},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
To: "to01@example.com", Config: Config{To: "groupto@example.com"},
}, },
}, },
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "to@example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{From: "from@example.com", To: "to@example.com"},
}, },
{ {
Name: "provider-with-override-specify-group-should-override", Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{ Provider: AlertProvider{
To: "to@example.com", DefaultConfig: Config{
From: "from@example.com",
To: "to@example.com",
},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
To: "to01@example.com", Config: Config{To: "groupto@example.com", SecretAccessKey: "wow", AccessKeyID: "noway"},
}, },
}, },
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "to01@example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{From: "from@example.com", To: "groupto@example.com", SecretAccessKey: "wow", AccessKeyID: "noway"},
},
{
Name: "provider-with-override-specify-group-but-alert-override-should-override-group-override",
Provider: AlertProvider{
DefaultConfig: Config{
From: "from@example.com",
To: "to@example.com",
},
Overrides: []Override{
{
Group: "group",
Config: Config{From: "from@example.com", To: "groupto@example.com", SecretAccessKey: "sekrit"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{
ProviderOverride: map[string]any{
"to": "alertto@example.com",
"access-key-id": 123,
},
},
ExpectedOutput: Config{To: "alertto@example.com", From: "from@example.com", AccessKeyID: "123", SecretAccessKey: "sekrit"},
}, },
} }
for _, tt := range tests { for _, scenario := range scenarios {
t.Run(tt.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
if got := tt.Provider.getToForGroup(tt.InputGroup); got != tt.ExpectedOutput { got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput) if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.From != scenario.ExpectedOutput.From {
t.Errorf("expected From to be %s, got %s", scenario.ExpectedOutput.From, got.From)
}
if got.To != scenario.ExpectedOutput.To {
t.Errorf("expected To to be %s, got %s", scenario.ExpectedOutput.To, got.To)
}
if got.AccessKeyID != scenario.ExpectedOutput.AccessKeyID {
t.Errorf("expected AccessKeyID to be %s, got %s", scenario.ExpectedOutput.AccessKeyID, got.AccessKeyID)
}
if got.SecretAccessKey != scenario.ExpectedOutput.SecretAccessKey {
t.Errorf("expected SecretAccessKey to be %s, got %s", scenario.ExpectedOutput.SecretAccessKey, got.SecretAccessKey)
}
if got.Region != scenario.ExpectedOutput.Region {
t.Errorf("expected Region to be %s, got %s", scenario.ExpectedOutput.Region, got.Region)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
} }
}) })
} }
+134 -52
View File
@@ -2,6 +2,7 @@ package custom
import ( import (
"bytes" "bytes"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -9,12 +10,15 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
) )
// AlertProvider is the configuration necessary for sending an alert using a custom HTTP request var (
// Technically, all alert providers should be reachable using the custom alert provider ErrURLNotSet = errors.New("url not set")
type AlertProvider struct { )
type Config struct {
URL string `yaml:"url"` URL string `yaml:"url"`
Method string `yaml:"method,omitempty"` Method string `yaml:"method,omitempty"`
Body string `yaml:"body,omitempty"` Body string `yaml:"body,omitempty"`
@@ -23,64 +27,66 @@ type AlertProvider struct {
// ClientConfig is the configuration of the client used to communicate with the provider's target // ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"` ClientConfig *client.Config `yaml:"client,omitempty"`
}
func (cfg *Config) Validate() error {
if len(cfg.URL) == 0 {
return ErrURLNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if override.ClientConfig != nil {
cfg.ClientConfig = override.ClientConfig
}
if len(override.URL) > 0 {
cfg.URL = override.URL
}
if len(override.Method) > 0 {
cfg.Method = override.Method
}
if len(override.Body) > 0 {
cfg.Body = override.Body
}
if len(override.Headers) > 0 {
cfg.Headers = override.Headers
}
if len(override.Placeholders) > 0 {
cfg.Placeholders = override.Placeholders
}
}
// AlertProvider is the configuration necessary for sending an alert using a custom HTTP request
// Technically, all alert providers should be reachable using the custom alert provider
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// 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"`
} }
// IsValid returns whether the provider's configuration is valid // Override is a case under which the default integration is overridden
func (provider *AlertProvider) IsValid() bool { type Override struct {
if provider.ClientConfig == nil { Group string `yaml:"group"`
provider.ClientConfig = client.GetDefaultConfig() Config `yaml:",inline"`
}
return len(provider.URL) > 0 && provider.ClientConfig != nil
} }
// GetAlertStatePlaceholderValue returns the Placeholder value for ALERT_TRIGGERED_OR_RESOLVED if configured // Validate the provider's configuration
func (provider *AlertProvider) GetAlertStatePlaceholderValue(resolved bool) string { func (provider *AlertProvider) Validate() error {
status := "TRIGGERED" return provider.DefaultConfig.Validate()
if resolved {
status = "RESOLVED"
}
if _, ok := provider.Placeholders["ALERT_TRIGGERED_OR_RESOLVED"]; ok {
if val, ok := provider.Placeholders["ALERT_TRIGGERED_OR_RESOLVED"][status]; ok {
return val
}
}
return status
} }
func (provider *AlertProvider) buildHTTPRequest(endpoint *core.Endpoint, alert *alert.Alert, resolved bool) *http.Request { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
body, url, method := provider.Body, provider.URL, provider.Method cfg, err := provider.GetConfig(ep.Group, alert)
body = strings.ReplaceAll(body, "[ALERT_DESCRIPTION]", alert.GetDescription()) if err != nil {
url = strings.ReplaceAll(url, "[ALERT_DESCRIPTION]", alert.GetDescription()) return err
body = strings.ReplaceAll(body, "[ENDPOINT_NAME]", endpoint.Name)
url = strings.ReplaceAll(url, "[ENDPOINT_NAME]", endpoint.Name)
body = strings.ReplaceAll(body, "[ENDPOINT_GROUP]", endpoint.Group)
url = strings.ReplaceAll(url, "[ENDPOINT_GROUP]", endpoint.Group)
body = strings.ReplaceAll(body, "[ENDPOINT_URL]", endpoint.URL)
url = strings.ReplaceAll(url, "[ENDPOINT_URL]", endpoint.URL)
if resolved {
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
} else {
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(false))
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(false))
} }
if len(method) == 0 { request := provider.buildHTTPRequest(cfg, ep, alert, result, resolved)
method = http.MethodGet response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
}
bodyBuffer := bytes.NewBuffer([]byte(body))
request, _ := http.NewRequest(method, url, bodyBuffer)
for k, v := range provider.Headers {
request.Header.Set(k, v)
}
return request
}
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
request := provider.buildHTTPRequest(endpoint, alert, resolved)
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
if err != nil { if err != nil {
return err return err
} }
@@ -92,7 +98,83 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
return err return err
} }
func (provider *AlertProvider) buildHTTPRequest(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) *http.Request {
body, url, method := cfg.Body, cfg.URL, cfg.Method
body = strings.ReplaceAll(body, "[ALERT_DESCRIPTION]", alert.GetDescription())
url = strings.ReplaceAll(url, "[ALERT_DESCRIPTION]", alert.GetDescription())
body = strings.ReplaceAll(body, "[ENDPOINT_NAME]", ep.Name)
url = strings.ReplaceAll(url, "[ENDPOINT_NAME]", ep.Name)
body = strings.ReplaceAll(body, "[ENDPOINT_GROUP]", ep.Group)
url = strings.ReplaceAll(url, "[ENDPOINT_GROUP]", ep.Group)
body = strings.ReplaceAll(body, "[ENDPOINT_URL]", ep.URL)
url = strings.ReplaceAll(url, "[ENDPOINT_URL]", ep.URL)
resultErrors := strings.ReplaceAll(strings.Join(result.Errors, ","), "\"", "\\\"")
body = strings.ReplaceAll(body, "[RESULT_ERRORS]", resultErrors)
url = strings.ReplaceAll(url, "[RESULT_ERRORS]", resultErrors)
if resolved {
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(cfg, true))
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(cfg, true))
} else {
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(cfg, false))
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(cfg, false))
}
if len(method) == 0 {
method = http.MethodGet
}
bodyBuffer := bytes.NewBuffer([]byte(body))
request, _ := http.NewRequest(method, url, bodyBuffer)
for k, v := range cfg.Headers {
request.Header.Set(k, v)
}
return request
}
// GetAlertStatePlaceholderValue returns the Placeholder value for ALERT_TRIGGERED_OR_RESOLVED if configured
func (provider *AlertProvider) GetAlertStatePlaceholderValue(cfg *Config, resolved bool) string {
status := "TRIGGERED"
if resolved {
status = "RESOLVED"
}
if _, ok := cfg.Placeholders["ALERT_TRIGGERED_OR_RESOLVED"]; ok {
if val, ok := cfg.Placeholders["ALERT_TRIGGERED_OR_RESOLVED"][status]; ok {
return val
}
}
return status
}
// 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
} }
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
+221 -45
View File
@@ -8,28 +8,22 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
t.Run("invalid-provider", func(t *testing.T) { t.Run("invalid-provider", func(t *testing.T) {
invalidProvider := AlertProvider{URL: ""} invalidProvider := AlertProvider{DefaultConfig: Config{URL: ""}}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
}) })
t.Run("valid-provider", func(t *testing.T) { t.Run("valid-provider", func(t *testing.T) {
validProvider := AlertProvider{URL: "https://example.com"} validProvider := AlertProvider{DefaultConfig: Config{URL: "https://example.com"}}
if validProvider.ClientConfig != nil { if err := validProvider.Validate(); err != 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") t.Error("provider should've been valid")
} }
if validProvider.ClientConfig == nil {
t.Error("provider client config should have been set after IsValid() was executed")
}
}) })
} }
@@ -47,7 +41,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{URL: "https://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -57,7 +51,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{URL: "https://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -67,7 +61,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{URL: "https://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -77,7 +71,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{URL: "https://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -90,10 +84,10 @@ 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: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.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},
}, },
@@ -111,9 +105,11 @@ func TestAlertProvider_Send(t *testing.T) {
} }
func TestAlertProvider_buildHTTPRequest(t *testing.T) { func TestAlertProvider_buildHTTPRequest(t *testing.T) {
customAlertProvider := &AlertProvider{ alertProvider := &AlertProvider{
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]&url=[ENDPOINT_URL]", DefaultConfig: Config{
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ENDPOINT_URL],[ALERT_TRIGGERED_OR_RESOLVED]", URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]&url=[ENDPOINT_URL]",
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ENDPOINT_URL],[ALERT_TRIGGERED_OR_RESOLVED]",
},
} }
alertDescription := "alert-description" alertDescription := "alert-description"
scenarios := []struct { scenarios := []struct {
@@ -123,13 +119,13 @@ func TestAlertProvider_buildHTTPRequest(t *testing.T) {
ExpectedBody string ExpectedBody string
}{ }{
{ {
AlertProvider: customAlertProvider, AlertProvider: alertProvider,
Resolved: true, Resolved: true,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=RESOLVED&description=alert-description&url=https://example.com", 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", ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,RESOLVED",
}, },
{ {
AlertProvider: customAlertProvider, AlertProvider: alertProvider,
Resolved: false, Resolved: false,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=TRIGGERED&description=alert-description&url=https://example.com", 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", ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,TRIGGERED",
@@ -137,9 +133,67 @@ func TestAlertProvider_buildHTTPRequest(t *testing.T) {
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(fmt.Sprintf("resolved-%v-with-default-placeholders", scenario.Resolved), func(t *testing.T) { t.Run(fmt.Sprintf("resolved-%v-with-default-placeholders", scenario.Resolved), func(t *testing.T) {
request := customAlertProvider.buildHTTPRequest( request := alertProvider.buildHTTPRequest(
&core.Endpoint{Name: "endpoint-name", Group: "endpoint-group", URL: "https://example.com"}, &alertProvider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group", URL: "https://example.com"},
&alert.Alert{Description: &alertDescription}, &alert.Alert{Description: &alertDescription},
&endpoint.Result{Errors: []string{}},
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 TestAlertProviderWithResultErrors_buildHTTPRequest(t *testing.T) {
alertProvider := &AlertProvider{
DefaultConfig: Config{
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]&url=[ENDPOINT_URL]&error=[RESULT_ERRORS]",
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ENDPOINT_URL],[ALERT_TRIGGERED_OR_RESOLVED],[RESULT_ERRORS]",
},
}
alertDescription := "alert-description"
scenarios := []struct {
AlertProvider *AlertProvider
Resolved bool
ExpectedURL string
ExpectedBody string
Errors []string
}{
{
AlertProvider: alertProvider,
Resolved: true,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=RESOLVED&description=alert-description&url=https://example.com&error=",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,RESOLVED,",
},
{
AlertProvider: alertProvider,
Resolved: false,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=TRIGGERED&description=alert-description&url=https://example.com&error=error1,error2",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,TRIGGERED,error1,error2",
Errors: []string{"error1", "error2"},
},
{
AlertProvider: alertProvider,
Resolved: false,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=TRIGGERED&description=alert-description&url=https://example.com&error=test \\\"error with quotes\\\"",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,TRIGGERED,test \\\"error with quotes\\\"",
Errors: []string{"test \"error with quotes\""},
},
}
for _, scenario := range scenarios {
t.Run(fmt.Sprintf("resolved-%v-with-default-placeholders-and-result-errors", scenario.Resolved), func(t *testing.T) {
request := alertProvider.buildHTTPRequest(
&alertProvider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group", URL: "https://example.com"},
&alert.Alert{Description: &alertDescription},
&endpoint.Result{Errors: scenario.Errors},
scenario.Resolved, scenario.Resolved,
) )
if request.URL.String() != scenario.ExpectedURL { if request.URL.String() != scenario.ExpectedURL {
@@ -154,14 +208,16 @@ func TestAlertProvider_buildHTTPRequest(t *testing.T) {
} }
func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) { func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
customAlertProvider := &AlertProvider{ alertProvider := &AlertProvider{
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]", DefaultConfig: Config{
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]", URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Headers: nil, Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
Placeholders: map[string]map[string]string{ Headers: nil,
"ALERT_TRIGGERED_OR_RESOLVED": { Placeholders: map[string]map[string]string{
"RESOLVED": "fixed", "ALERT_TRIGGERED_OR_RESOLVED": {
"TRIGGERED": "boom", "RESOLVED": "fixed",
"TRIGGERED": "boom",
},
}, },
}, },
} }
@@ -173,13 +229,13 @@ func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
ExpectedBody string ExpectedBody string
}{ }{
{ {
AlertProvider: customAlertProvider, AlertProvider: alertProvider,
Resolved: true, Resolved: true,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=fixed&description=alert-description", ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=fixed&description=alert-description",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,fixed", ExpectedBody: "endpoint-name,endpoint-group,alert-description,fixed",
}, },
{ {
AlertProvider: customAlertProvider, AlertProvider: alertProvider,
Resolved: false, Resolved: false,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=boom&description=alert-description", ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=boom&description=alert-description",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,boom", ExpectedBody: "endpoint-name,endpoint-group,alert-description,boom",
@@ -187,9 +243,11 @@ func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(fmt.Sprintf("resolved-%v-with-custom-placeholders", scenario.Resolved), func(t *testing.T) { t.Run(fmt.Sprintf("resolved-%v-with-custom-placeholders", scenario.Resolved), func(t *testing.T) {
request := customAlertProvider.buildHTTPRequest( request := alertProvider.buildHTTPRequest(
&core.Endpoint{Name: "endpoint-name", Group: "endpoint-group"}, &alertProvider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&alert.Alert{Description: &alertDescription}, &alert.Alert{Description: &alertDescription},
&endpoint.Result{},
scenario.Resolved, scenario.Resolved,
) )
if request.URL.String() != scenario.ExpectedURL { if request.URL.String() != scenario.ExpectedURL {
@@ -204,15 +262,17 @@ func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
} }
func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) { func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) {
customAlertProvider := &AlertProvider{ alertProvider := &AlertProvider{
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]", DefaultConfig: Config{
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]", URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
},
} }
if customAlertProvider.GetAlertStatePlaceholderValue(true) != "RESOLVED" { if alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, true) != "RESOLVED" {
t.Error("expected RESOLVED, got", customAlertProvider.GetAlertStatePlaceholderValue(true)) t.Error("expected RESOLVED, got", alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, true))
} }
if customAlertProvider.GetAlertStatePlaceholderValue(false) != "TRIGGERED" { if alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, false) != "TRIGGERED" {
t.Error("expected TRIGGERED, got", customAlertProvider.GetAlertStatePlaceholderValue(false)) t.Error("expected TRIGGERED, got", alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, false))
} }
} }
@@ -224,3 +284,119 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
t.Error("expected default alert to be nil") t.Error("expected default alert to be nil")
} }
} }
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{URL: "http://example.com", Body: "default-body"},
Overrides: nil,
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "http://example.com", Body: "default-body"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{URL: "http://example.com"},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "http://example.com"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{URL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{URL: "http://group-example.com", Headers: map[string]string{"Cache": "true"}},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "http://example.com", Headers: map[string]string{"Cache": "true"}},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{URL: "http://example.com", Body: "default-body"},
Overrides: []Override{
{
Group: "group",
Config: Config{URL: "http://group-example.com", Body: "group-body"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "http://group-example.com", Body: "group-body"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{URL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{URL: "http://group-example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"url": "http://alert-example.com", "body": "alert-body"}},
ExpectedOutput: Config{URL: "http://alert-example.com", Body: "alert-body"},
},
{
Name: "provider-with-partial-overrides",
Provider: AlertProvider{
DefaultConfig: Config{URL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{Method: "POST"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"body": "alert-body"}},
ExpectedOutput: Config{URL: "http://example.com", Body: "alert-body", Method: "POST"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.URL != scenario.ExpectedOutput.URL {
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.URL, got.URL)
}
if got.Body != scenario.ExpectedOutput.Body {
t.Errorf("expected body to be %s, got %s", scenario.ExpectedOutput.Body, got.Body)
}
if got.Headers != nil {
for key, value := range scenario.ExpectedOutput.Headers {
if got.Headers[key] != value {
t.Errorf("expected header %s to be %s, got %s", key, value, got.Headers[key])
}
}
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
}
})
}
}
+79 -32
View File
@@ -3,53 +3,81 @@ package discord
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
) )
var (
ErrWebhookURLNotSet = errors.New("webhook-url not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
WebhookURL string `yaml:"webhook-url"`
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
}
func (cfg *Config) Validate() error {
if len(cfg.WebhookURL) == 0 {
return ErrWebhookURLNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.WebhookURL) > 0 {
cfg.WebhookURL = override.WebhookURL
}
if len(override.Title) > 0 {
cfg.Title = override.Title
}
}
// AlertProvider is the configuration necessary for sending an alert using Discord // AlertProvider is the configuration necessary for sending an alert using Discord
type AlertProvider struct { type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"` DefaultConfig Config `yaml:",inline"`
// 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 is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"` Overrides []Override `yaml:"overrides,omitempty"`
// Title is the title of the message that will be sent
Title string `yaml:"title,omitempty"`
} }
// Override is a case under which the default integration is overridden
type Override struct { type Override struct {
Group string `yaml:"group"` Group string `yaml:"group"`
WebhookURL string `yaml:"webhook-url"` Config `yaml:",inline"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool) registeredGroups := make(map[string]bool)
if provider.Overrides != nil { if provider.Overrides != nil {
for _, override := range provider.Overrides { for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 { if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
return false return ErrDuplicateGroupOverride
} }
registeredGroups[override.Group] = true registeredGroups[override.Group] = true
} }
} }
return len(provider.WebhookURL) > 0 return provider.DefaultConfig.Validate()
} }
// 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(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) cfg, err := provider.GetConfig(ep.Group, alert)
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer) if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
if err != nil { if err != nil {
return err return err
} }
@@ -85,14 +113,14 @@ type Field struct {
} }
// 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) []byte { func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message string var message string
var colorCode int var colorCode int
if resolved { if resolved {
message = fmt.Sprintf("An alert for **%s** has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold) message = fmt.Sprintf("An alert for **%s** has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
colorCode = 3066993 colorCode = 3066993
} else { } else {
message = fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold) message = fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
colorCode = 15158332 colorCode = 15158332
} }
var formattedConditionResults string var formattedConditionResults string
@@ -110,8 +138,8 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
description = ":\n> " + alertDescription description = ":\n> " + alertDescription
} }
title := ":helmet_with_white_cross: Gatus" title := ":helmet_with_white_cross: Gatus"
if provider.Title != "" { if cfg.Title != "" {
title = provider.Title title = cfg.Title
} }
body := Body{ body := Body{
Content: "", Content: "",
@@ -134,19 +162,38 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
return bodyAsJSON return bodyAsJSON
} }
// 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
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert
} }
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
+93 -52
View File
@@ -7,54 +7,56 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{WebhookURL: ""} invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{WebhookURL: "http://example.com"} validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
func TestAlertProvider_IsValidWithOverride(t *testing.T) { func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{ providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "http://example.com", Config: Config{WebhookURL: "http://example.com"},
Group: "", Group: "",
}, },
}, },
} }
if providerWithInvalidOverrideGroup.IsValid() { if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid") t.Error("provider Group shouldn't have been valid")
} }
providerWithInvalidOverrideTo := AlertProvider{ providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "", Config: Config{WebhookURL: ""},
Group: "group", Group: "group",
}, },
}, },
} }
if providerWithInvalidOverrideTo.IsValid() { if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid") t.Error("provider integration key shouldn't have been valid")
} }
providerWithValidOverride := AlertProvider{ providerWithValidOverride := AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{
WebhookURL: "http://example.com",
},
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "http://example.com", Config: Config{WebhookURL: "http://example.com"},
Group: "group", Group: "group",
}, },
}, },
} }
if !providerWithValidOverride.IsValid() { if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
@@ -74,7 +76,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -84,7 +86,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -94,7 +96,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -104,7 +106,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -114,7 +116,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "triggered-with-modified-title", Name: "triggered-with-modified-title",
Provider: AlertProvider{Title: title}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com", Title: title}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -122,15 +124,25 @@ func TestAlertProvider_Send(t *testing.T) {
}), }),
ExpectedError: false, ExpectedError: false,
}, },
{
Name: "triggered-with-webhook-override",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3, ProviderOverride: map[string]any{"webhook-url": "http://example01.com"}},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
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: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.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},
}, },
@@ -175,7 +187,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}, },
{ {
Name: "triggered-with-modified-title", Name: "triggered-with-modified-title",
Provider: AlertProvider{Title: title}, Provider: AlertProvider{DefaultConfig: Config{Title: title}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\"provider-title\",\"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}]}]}", ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\"provider-title\",\"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}]}]}",
@@ -183,7 +195,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
{ {
Name: "triggered-with-no-conditions", Name: "triggered-with-no-conditions",
NoConditions: true, NoConditions: true,
Provider: AlertProvider{Title: title}, Provider: AlertProvider{DefaultConfig: Config{Title: title}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\"provider-title\",\"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}]}", ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\"provider-title\",\"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}]}",
@@ -191,18 +203,19 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
var conditionResults []*core.ConditionResult var conditionResults []*endpoint.ConditionResult
if !scenario.NoConditions { if !scenario.NoConditions {
conditionResults = []*core.ConditionResult{ conditionResults = []*endpoint.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}, {Condition: "[BODY] != \"\"", Success: scenario.Resolved},
} }
} }
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"}, &scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: conditionResults, ConditionResults: conditionResults,
}, },
scenario.Resolved, scenario.Resolved,
@@ -227,64 +240,92 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
} }
} }
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) { func TestAlertProvider_GetConfig(t *testing.T) {
tests := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
InputGroup string InputGroup string
ExpectedOutput string InputAlert alert.Alert
ExpectedOutput Config
}{ }{
{ {
Name: "provider-no-override-specify-no-group-should-default", Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-no-override-specify-group-should-default", Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-with-override-specify-no-group-should-default", Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
WebhookURL: "http://example01.com", Config: Config{WebhookURL: "http://group-example.com"},
}, },
}, },
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-with-override-specify-group-should-override", Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
WebhookURL: "http://example01.com", Config: Config{WebhookURL: "http://group-example.com"},
}, },
}, },
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "http://example01.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://group-example.com"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
ExpectedOutput: Config{WebhookURL: "http://alert-example.com"},
}, },
} }
for _, tt := range tests { for _, scenario := range scenarios {
t.Run(tt.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput { got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
t.Errorf("AlertProvider.getWebhookURLForGroup() = %v, want %v", got, tt.ExpectedOutput) if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
} }
}) })
} }
+113 -41
View File
@@ -2,18 +2,26 @@ package email
import ( import (
"crypto/tls" "crypto/tls"
"errors"
"fmt" "fmt"
"math" "math"
"strings" "strings"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
gomail "gopkg.in/mail.v2" gomail "gopkg.in/mail.v2"
"gopkg.in/yaml.v3"
) )
// AlertProvider is the configuration necessary for sending an alert using SMTP var (
type AlertProvider struct { ErrDuplicateGroupOverride = errors.New("duplicate group override")
ErrMissingFromOrToFields = errors.New("from and to fields are required")
ErrInvalidPort = errors.New("port must be between 1 and 65535 inclusively")
ErrMissingHost = errors.New("host is required")
)
type Config struct {
From string `yaml:"from"` From string `yaml:"from"`
Username string `yaml:"username"` Username string `yaml:"username"`
Password string `yaml:"password"` Password string `yaml:"password"`
@@ -23,6 +31,48 @@ type AlertProvider struct {
// ClientConfig is the configuration of the client used to communicate with the provider's target // ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"` ClientConfig *client.Config `yaml:"client,omitempty"`
}
func (cfg *Config) Validate() error {
if len(cfg.From) == 0 || len(cfg.To) == 0 {
return ErrMissingFromOrToFields
}
if cfg.Port < 1 || cfg.Port > math.MaxUint16 {
return ErrInvalidPort
}
if len(cfg.Host) == 0 {
return ErrMissingHost
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if override.ClientConfig != nil {
cfg.ClientConfig = override.ClientConfig
}
if len(override.From) > 0 {
cfg.From = override.From
}
if len(override.Username) > 0 {
cfg.Username = override.Username
}
if len(override.Password) > 0 {
cfg.Password = override.Password
}
if len(override.Host) > 0 {
cfg.Host = override.Host
}
if override.Port > 0 {
cfg.Port = override.Port
}
if len(override.To) > 0 {
cfg.To = override.To
}
}
// AlertProvider is the configuration necessary for sending an alert using SMTP
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// 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"`
@@ -33,68 +83,71 @@ type AlertProvider struct {
// Override is a case under which the default integration is overridden // Override is a case under which the default integration is overridden
type Override struct { type Override struct {
Group string `yaml:"group"` Group string `yaml:"group"`
To string `yaml:"to"` Config `yaml:",inline"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool) registeredGroups := make(map[string]bool)
if provider.Overrides != nil { if provider.Overrides != nil {
for _, override := range provider.Overrides { for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.To) == 0 { if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.To) == 0 {
return false return ErrDuplicateGroupOverride
} }
registeredGroups[override.Group] = true registeredGroups[override.Group] = true
} }
} }
return provider.DefaultConfig.Validate()
return len(provider.From) > 0 && len(provider.Host) > 0 && len(provider.To) > 0 && provider.Port > 0 && provider.Port < math.MaxUint16
} }
// 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(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
var username string cfg, err := provider.GetConfig(ep.Group, alert)
if len(provider.Username) > 0 { if err != nil {
username = provider.Username return err
} else {
username = provider.From
} }
subject, body := provider.buildMessageSubjectAndBody(endpoint, alert, result, resolved) var username string
if len(cfg.Username) > 0 {
username = cfg.Username
} else {
username = cfg.From
}
subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)
m := gomail.NewMessage() m := gomail.NewMessage()
m.SetHeader("From", provider.From) m.SetHeader("From", cfg.From)
m.SetHeader("To", strings.Split(provider.getToForGroup(endpoint.Group), ",")...) m.SetHeader("To", strings.Split(cfg.To, ",")...)
m.SetHeader("Subject", subject) m.SetHeader("Subject", subject)
m.SetBody("text/plain", body) m.SetBody("text/plain", body)
var d *gomail.Dialer var d *gomail.Dialer
if len(provider.Password) == 0 { if len(cfg.Password) == 0 {
// Get the domain in the From address // Get the domain in the From address
localName := "localhost" localName := "localhost"
fromParts := strings.Split(provider.From, `@`) fromParts := strings.Split(cfg.From, `@`)
if len(fromParts) == 2 { if len(fromParts) == 2 {
localName = fromParts[1] localName = fromParts[1]
} }
// Create a dialer with no authentication // Create a dialer with no authentication
d = &gomail.Dialer{Host: provider.Host, Port: provider.Port, LocalName: localName} d = &gomail.Dialer{Host: cfg.Host, Port: cfg.Port, LocalName: localName}
} else { } else {
// Create an authenticated dialer // Create an authenticated dialer
d = gomail.NewDialer(provider.Host, provider.Port, username, provider.Password) d = gomail.NewDialer(cfg.Host, cfg.Port, username, cfg.Password)
} }
if provider.ClientConfig != nil && provider.ClientConfig.Insecure { if cfg.ClientConfig != nil && cfg.ClientConfig.Insecure {
d.TLSConfig = &tls.Config{InsecureSkipVerify: true} d.TLSConfig = &tls.Config{InsecureSkipVerify: true}
} }
return d.DialAndSend(m) return d.DialAndSend(m)
} }
// buildMessageSubjectAndBody builds the message subject and body // buildMessageSubjectAndBody builds the message subject and body
func (provider *AlertProvider) buildMessageSubjectAndBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) (string, string) { func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) (string, string) {
var subject, message string var subject, message string
if resolved { if resolved {
subject = fmt.Sprintf("[%s] Alert resolved", endpoint.DisplayName()) subject = fmt.Sprintf("[%s] Alert resolved", ep.DisplayName())
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", ep.DisplayName(), alert.SuccessThreshold)
} else { } else {
subject = fmt.Sprintf("[%s] Alert triggered", endpoint.DisplayName()) subject = fmt.Sprintf("[%s] Alert triggered", ep.DisplayName())
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold) message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
} }
var formattedConditionResults string var formattedConditionResults string
if len(result.ConditionResults) > 0 { if len(result.ConditionResults) > 0 {
@@ -116,19 +169,38 @@ func (provider *AlertProvider) buildMessageSubjectAndBody(endpoint *core.Endpoin
return subject, message + description + formattedConditionResults return subject, message + description + formattedConditionResults
} }
// 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
} }
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
+89 -47
View File
@@ -4,64 +4,66 @@ import (
"testing" "testing"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
func TestAlertDefaultProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{} invalidProvider := AlertProvider{}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{From: "from@example.com", Password: "password", Host: "smtp.gmail.com", Port: 587, To: "to@example.com"} validProvider := AlertProvider{DefaultConfig: Config{From: "from@example.com", Password: "password", Host: "smtp.gmail.com", Port: 587, To: "to@example.com"}}
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
func TestAlertProvider_IsValidWithNoCredentials(t *testing.T) { func TestAlertProvider_ValidateWithNoCredentials(t *testing.T) {
validProvider := AlertProvider{From: "from@example.com", Host: "smtp-relay.gmail.com", Port: 587, To: "to@example.com"} validProvider := AlertProvider{DefaultConfig: Config{From: "from@example.com", Host: "smtp-relay.gmail.com", Port: 587, To: "to@example.com"}}
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
func TestAlertProvider_IsValidWithOverride(t *testing.T) { func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{ providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
To: "to@example.com", Config: Config{To: "to@example.com"},
Group: "", Group: "",
}, },
}, },
} }
if providerWithInvalidOverrideGroup.IsValid() { if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid") t.Error("provider Group shouldn't have been valid")
} }
providerWithInvalidOverrideTo := AlertProvider{ providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
To: "", Config: Config{To: ""},
Group: "group", Group: "group",
}, },
}, },
} }
if providerWithInvalidOverrideTo.IsValid() { if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid") t.Error("provider integration key shouldn't have been valid")
} }
providerWithValidOverride := AlertProvider{ providerWithValidOverride := AlertProvider{
From: "from@example.com", DefaultConfig: Config{
Password: "password", From: "from@example.com",
Host: "smtp.gmail.com", Password: "password",
Port: 587, Host: "smtp.gmail.com",
To: "to@example.com", Port: 587,
To: "to@example.com",
},
Overrides: []Override{ Overrides: []Override{
{ {
To: "to@example.com", Config: Config{To: "to@example.com"},
Group: "group", Group: "group",
}, },
}, },
} }
if !providerWithValidOverride.IsValid() { if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
@@ -97,10 +99,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
subject, body := scenario.Provider.buildMessageSubjectAndBody( subject, body := scenario.Provider.buildMessageSubjectAndBody(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.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},
}, },
@@ -126,64 +128,104 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
} }
} }
func TestAlertProvider_getToForGroup(t *testing.T) { func TestAlertProvider_GetConfig(t *testing.T) {
tests := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
InputGroup string InputGroup string
ExpectedOutput string InputAlert alert.Alert
ExpectedOutput Config
}{ }{
{ {
Name: "provider-no-override-specify-no-group-should-default", Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
To: "to@example.com", DefaultConfig: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "to@example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
}, },
{ {
Name: "provider-no-override-specify-group-should-default", Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
To: "to@example.com", DefaultConfig: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "to@example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
}, },
{ {
Name: "provider-with-override-specify-no-group-should-default", Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
To: "to@example.com", DefaultConfig: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
To: "to01@example.com", Config: Config{To: "to01@example.com"},
}, },
}, },
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "to@example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
}, },
{ {
Name: "provider-with-override-specify-group-should-override", Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{ Provider: AlertProvider{
To: "to@example.com", DefaultConfig: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
To: "to01@example.com", Config: Config{To: "group-to@example.com"},
}, },
}, },
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "to01@example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{From: "from@example.com", To: "group-to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
Overrides: []Override{
{
Group: "group",
Config: Config{To: "group-to@example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"to": "alert-to@example.com", "host": "smtp.example.com", "port": 588, "password": "hunter2"}},
ExpectedOutput: Config{From: "from@example.com", To: "alert-to@example.com", Host: "smtp.example.com", Port: 588, Password: "hunter2"},
}, },
} }
for _, tt := range tests { for _, scenario := range scenarios {
t.Run(tt.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
if got := tt.Provider.getToForGroup(tt.InputGroup); got != tt.ExpectedOutput { got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput) if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.From != scenario.ExpectedOutput.From {
t.Errorf("expected from to be %s, got %s", scenario.ExpectedOutput.From, got.From)
}
if got.To != scenario.ExpectedOutput.To {
t.Errorf("expected to be %s, got %s", scenario.ExpectedOutput.To, got.To)
}
if got.Host != scenario.ExpectedOutput.Host {
t.Errorf("expected host to be %s, got %s", scenario.ExpectedOutput.Host, got.Host)
}
if got.Port != scenario.ExpectedOutput.Port {
t.Errorf("expected port to be %d, got %d", scenario.ExpectedOutput.Port, got.Port)
}
if got.Password != scenario.ExpectedOutput.Password {
t.Errorf("expected password to be %s, got %s", scenario.ExpectedOutput.Password, got.Password)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
} }
}) })
} }
+217
View File
@@ -0,0 +1,217 @@
package gitea
import (
"crypto/tls"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"code.gitea.io/sdk/gitea"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrRepositoryURLNotSet = errors.New("repository-url not set")
ErrInvalidRepositoryURL = errors.New("invalid repository-url")
ErrTokenNotSet = errors.New("token not set")
)
type Config struct {
RepositoryURL string `yaml:"repository-url"` // The URL of the Gitea repository to create issues in
Token string `yaml:"token"` // Token requires at least RW on issues and RO on metadata
Assignees []string `yaml:"assignees,omitempty"` // Assignees is a list of users to assign the issue to
username string
repositoryOwner string
repositoryName string
giteaClient *gitea.Client
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"`
}
func (cfg *Config) Validate() error {
if len(cfg.RepositoryURL) == 0 {
return ErrRepositoryURLNotSet
}
if len(cfg.Token) == 0 {
return ErrTokenNotSet
}
// Validate format of the repository URL
repositoryURL, err := url.Parse(cfg.RepositoryURL)
if err != nil {
return err
}
baseURL := repositoryURL.Scheme + "://" + repositoryURL.Host
pathParts := strings.Split(repositoryURL.Path, "/")
if len(pathParts) != 3 {
return ErrInvalidRepositoryURL
}
if cfg.repositoryOwner == pathParts[1] && cfg.repositoryName == pathParts[2] && cfg.giteaClient != nil {
// Already validated, let's skip the rest of the validation to avoid unnecessary API calls
return nil
}
cfg.repositoryOwner = pathParts[1]
cfg.repositoryName = pathParts[2]
opts := []gitea.ClientOption{
gitea.SetToken(cfg.Token),
}
if cfg.ClientConfig != nil && cfg.ClientConfig.Insecure {
// add new http client for skip verify
httpClient := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
opts = append(opts, gitea.SetHTTPClient(httpClient))
}
cfg.giteaClient, err = gitea.NewClient(baseURL, opts...)
if err != nil {
return err
}
user, _, err := cfg.giteaClient.GetMyUserInfo()
if err != nil {
return err
}
cfg.username = user.UserName
return nil
}
func (cfg *Config) Merge(override *Config) {
if override.ClientConfig != nil {
cfg.ClientConfig = override.ClientConfig
}
if len(override.RepositoryURL) > 0 {
cfg.RepositoryURL = override.RepositoryURL
}
if len(override.Token) > 0 {
cfg.Token = override.Token
}
if len(override.Assignees) > 0 {
cfg.Assignees = override.Assignees
}
}
// AlertProvider is the configuration necessary for sending an alert using Discord
type AlertProvider struct {
DefaultConfig Config `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"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
return provider.DefaultConfig.Validate()
}
// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,
// or closes the relevant issue(s) if the resolved parameter passed is true.
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
title := "alert(gatus): " + ep.DisplayName()
if !resolved {
_, _, err = cfg.giteaClient.CreateIssue(
cfg.repositoryOwner,
cfg.repositoryName,
gitea.CreateIssueOption{
Title: title,
Body: provider.buildIssueBody(ep, alert, result),
Assignees: cfg.Assignees,
},
)
if err != nil {
return fmt.Errorf("failed to create issue: %w", err)
}
return nil
}
issues, _, err := cfg.giteaClient.ListRepoIssues(
cfg.repositoryOwner,
cfg.repositoryName,
gitea.ListIssueOption{
State: gitea.StateOpen,
CreatedBy: cfg.username,
ListOptions: gitea.ListOptions{
PageSize: 100,
},
},
)
if err != nil {
return fmt.Errorf("failed to list issues: %w", err)
}
for _, issue := range issues {
if issue.Title == title {
stateClosed := gitea.StateClosed
_, _, err = cfg.giteaClient.EditIssue(
cfg.repositoryOwner,
cfg.repositoryName,
issue.Index,
gitea.EditIssueOption{
State: &stateClosed,
},
)
if err != nil {
return fmt.Errorf("failed to close issue: %w", err)
}
}
}
return nil
}
// buildIssueBody builds the body of the issue
func (provider *AlertProvider) buildIssueBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result) string {
var formattedConditionResults string
if len(result.ConditionResults) > 0 {
formattedConditionResults = "\n\n## Condition results\n"
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = ":white_check_mark:"
} else {
prefix = ":x:"
}
formattedConditionResults += fmt.Sprintf("- %s - `%s`\n", prefix, conditionResult.Condition)
}
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\n> " + alertDescription
}
message := fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
return message + description + formattedConditionResults
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration (we're returning the cfg here even if there's an error mostly for testing purposes)
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
+229
View File
@@ -0,0 +1,229 @@
package gitea
import (
"net/http"
"strings"
"testing"
"code.gitea.io/sdk/gitea"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
ExpectedError bool
}{
{
Name: "invalid",
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "", Token: ""}},
ExpectedError: true,
},
{
Name: "invalid-token",
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"}},
ExpectedError: true,
},
{
Name: "missing-repository-name",
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN", Token: "12345"}},
ExpectedError: true,
},
{
Name: "enterprise-client",
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://gitea.example.com/TwiN/test", Token: "12345"}},
ExpectedError: false,
},
{
Name: "invalid-url",
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "gitea.com/TwiN/test", Token: "12345"}},
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
err := scenario.Provider.Validate()
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered-error",
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedError: true,
},
{
Name: "resolved-error",
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
cfg, err := scenario.Provider.GetConfig("", &scenario.Alert)
if err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
t.Error("expected no error, got", err.Error())
}
cfg.giteaClient, _ = gitea.NewClient("https://gitea.com")
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err = scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
scenarios := []struct {
Name string
Endpoint endpoint.Endpoint
Provider AlertProvider
Alert alert.Alert
NoConditions bool
ExpectedBody string
}{
{
Name: "triggered",
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 3},
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\n> description-1\n\n## Condition results\n- :white_check_mark: - `[CONNECTED] == true`\n- :x: - `[STATUS] == 200`",
},
{
Name: "triggered-with-no-description",
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{},
Alert: alert.Alert{FailureThreshold: 10},
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 10 time(s) in a row\n\n## Condition results\n- :white_check_mark: - `[CONNECTED] == true`\n- :x: - `[STATUS] == 200`",
},
{
Name: "triggered-with-no-conditions",
NoConditions: true,
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 10},
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 10 time(s) in a row:\n> description-1",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
var conditionResults []*endpoint.ConditionResult
if !scenario.NoConditions {
conditionResults = []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: true},
{Condition: "[STATUS] == 200", Success: false},
}
}
body := scenario.Provider.buildIssueBody(
&scenario.Endpoint,
&scenario.Alert,
&endpoint.Result{ConditionResults: conditionResults},
)
if strings.TrimSpace(body) != strings.TrimSpace(scenario.ExpectedBody) {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-should-default",
Provider: AlertProvider{
DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"},
},
InputAlert: alert.Alert{},
ExpectedOutput: Config{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"},
},
{
Name: "provider-with-alert-override",
Provider: AlertProvider{
DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"},
},
InputAlert: alert.Alert{ProviderOverride: map[string]any{"repository-url": "https://gitea.com/TwiN/alert-test", "token": "54321", "assignees": []string{"TwiN"}}},
ExpectedOutput: Config{RepositoryURL: "https://gitea.com/TwiN/alert-test", Token: "54321", Assignees: []string{"TwiN"}},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
if err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
t.Fatalf("unexpected error: %s", err)
}
if got.RepositoryURL != scenario.ExpectedOutput.RepositoryURL {
t.Errorf("expected repository URL %s, got %s", scenario.ExpectedOutput.RepositoryURL, got.RepositoryURL)
}
if got.Token != scenario.ExpectedOutput.Token {
t.Errorf("expected token %s, got %s", scenario.ExpectedOutput.Token, got.Token)
}
if len(got.Assignees) != len(scenario.ExpectedOutput.Assignees) {
t.Errorf("expected %d assignees, got %d", len(scenario.ExpectedOutput.Assignees), len(got.Assignees))
}
for i, assignee := range got.Assignees {
if assignee != scenario.ExpectedOutput.Assignees[i] {
t.Errorf("expected assignee %s, got %s", scenario.ExpectedOutput.Assignees[i], assignee)
}
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil && !strings.Contains(err.Error(), "user does not exist") {
t.Errorf("unexpected error: %s", err)
}
})
}
}
+90 -32
View File
@@ -2,88 +2,124 @@ package github
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"net/url" "net/url"
"strings" "strings"
"time" "time"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/google/go-github/v48/github" "github.com/google/go-github/v48/github"
"golang.org/x/oauth2" "golang.org/x/oauth2"
"gopkg.in/yaml.v3"
) )
// AlertProvider is the configuration necessary for sending an alert using Discord var (
type AlertProvider struct { ErrRepositoryURLNotSet = errors.New("repository-url not set")
ErrInvalidRepositoryURL = errors.New("invalid repository-url")
ErrTokenNotSet = errors.New("token not set")
)
type Config struct {
RepositoryURL string `yaml:"repository-url"` // The URL of the GitHub repository to create issues in RepositoryURL string `yaml:"repository-url"` // The URL of the GitHub repository to create issues in
Token string `yaml:"token"` // Token requires at least RW on issues and RO on metadata Token string `yaml:"token"` // Token requires at least RW on issues and RO on metadata
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
username string username string
repositoryOwner string repositoryOwner string
repositoryName string repositoryName string
githubClient *github.Client githubClient *github.Client
} }
// IsValid returns whether the provider's configuration is valid func (cfg *Config) Validate() error {
func (provider *AlertProvider) IsValid() bool { if len(cfg.RepositoryURL) == 0 {
if len(provider.Token) == 0 || len(provider.RepositoryURL) == 0 { return ErrRepositoryURLNotSet
return false }
if len(cfg.Token) == 0 {
return ErrTokenNotSet
} }
// Validate format of the repository URL // Validate format of the repository URL
repositoryURL, err := url.Parse(provider.RepositoryURL) repositoryURL, err := url.Parse(cfg.RepositoryURL)
if err != nil { if err != nil {
return false return err
} }
baseURL := repositoryURL.Scheme + "://" + repositoryURL.Host baseURL := repositoryURL.Scheme + "://" + repositoryURL.Host
pathParts := strings.Split(repositoryURL.Path, "/") pathParts := strings.Split(repositoryURL.Path, "/")
if len(pathParts) != 3 { if len(pathParts) != 3 {
return false return ErrInvalidRepositoryURL
} }
provider.repositoryOwner = pathParts[1] if cfg.repositoryOwner == pathParts[1] && cfg.repositoryName == pathParts[2] && cfg.githubClient != nil {
provider.repositoryName = pathParts[2] // Already validated, let's skip the rest of the validation to avoid unnecessary API calls
return nil
}
cfg.repositoryOwner = pathParts[1]
cfg.repositoryName = pathParts[2]
// Create oauth2 HTTP client with GitHub token // Create oauth2 HTTP client with GitHub token
httpClientWithStaticTokenSource := oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(&oauth2.Token{ httpClientWithStaticTokenSource := oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(&oauth2.Token{
AccessToken: provider.Token, AccessToken: cfg.Token,
})) }))
// Create GitHub client // Create GitHub client
if baseURL == "https://github.com" { if baseURL == "https://github.com" {
provider.githubClient = github.NewClient(httpClientWithStaticTokenSource) cfg.githubClient = github.NewClient(httpClientWithStaticTokenSource)
} else { } else {
provider.githubClient, err = github.NewEnterpriseClient(baseURL, baseURL, httpClientWithStaticTokenSource) cfg.githubClient, err = github.NewEnterpriseClient(baseURL, baseURL, httpClientWithStaticTokenSource)
if err != nil { if err != nil {
return false return fmt.Errorf("failed to create enterprise GitHub client: %w", err)
} }
} }
// Retrieve the username once to validate that the token is valid // Retrieve the username once to validate that the token is valid
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
user, _, err := provider.githubClient.Users.Get(ctx, "") user, _, err := cfg.githubClient.Users.Get(ctx, "")
if err != nil { if err != nil {
return false return fmt.Errorf("failed to retrieve GitHub user: %w", err)
} }
provider.username = *user.Login cfg.username = *user.Login
return true return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.RepositoryURL) > 0 {
cfg.RepositoryURL = override.RepositoryURL
}
if len(override.Token) > 0 {
cfg.Token = override.Token
}
}
// AlertProvider is the configuration necessary for sending an alert using Discord
type AlertProvider struct {
DefaultConfig Config `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"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
return provider.DefaultConfig.Validate()
} }
// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false, // Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,
// or closes the relevant issue(s) if the resolved parameter passed is true. // or closes the relevant issue(s) if the resolved parameter passed is true.
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
title := "alert(gatus): " + endpoint.DisplayName() cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
title := "alert(gatus): " + ep.DisplayName()
if !resolved { if !resolved {
_, _, err := provider.githubClient.Issues.Create(context.Background(), provider.repositoryOwner, provider.repositoryName, &github.IssueRequest{ _, _, err := cfg.githubClient.Issues.Create(context.Background(), cfg.repositoryOwner, cfg.repositoryName, &github.IssueRequest{
Title: github.String(title), Title: github.String(title),
Body: github.String(provider.buildIssueBody(endpoint, alert, result)), Body: github.String(provider.buildIssueBody(ep, alert, result)),
}) })
if err != nil { if err != nil {
return fmt.Errorf("failed to create issue: %w", err) return fmt.Errorf("failed to create issue: %w", err)
} }
} else { } else {
issues, _, err := provider.githubClient.Issues.ListByRepo(context.Background(), provider.repositoryOwner, provider.repositoryName, &github.IssueListByRepoOptions{ issues, _, err := cfg.githubClient.Issues.ListByRepo(context.Background(), cfg.repositoryOwner, cfg.repositoryName, &github.IssueListByRepoOptions{
State: "open", State: "open",
Creator: provider.username, Creator: cfg.username,
ListOptions: github.ListOptions{PerPage: 100}, ListOptions: github.ListOptions{PerPage: 100},
}) })
if err != nil { if err != nil {
@@ -91,7 +127,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
} }
for _, issue := range issues { for _, issue := range issues {
if *issue.Title == title { if *issue.Title == title {
_, _, err = provider.githubClient.Issues.Edit(context.Background(), provider.repositoryOwner, provider.repositoryName, *issue.Number, &github.IssueRequest{ _, _, err = cfg.githubClient.Issues.Edit(context.Background(), cfg.repositoryOwner, cfg.repositoryName, *issue.Number, &github.IssueRequest{
State: github.String("closed"), State: github.String("closed"),
}) })
if err != nil { if err != nil {
@@ -104,7 +140,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
} }
// buildIssueBody builds the body of the issue // buildIssueBody builds the body of the issue
func (provider *AlertProvider) buildIssueBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result) string { func (provider *AlertProvider) buildIssueBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result) string {
var formattedConditionResults string var formattedConditionResults string
if len(result.ConditionResults) > 0 { if len(result.ConditionResults) > 0 {
formattedConditionResults = "\n\n## Condition results\n" formattedConditionResults = "\n\n## Condition results\n"
@@ -122,7 +158,7 @@ func (provider *AlertProvider) buildIssueBody(endpoint *core.Endpoint, alert *al
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\n> " + alertDescription description = ":\n> " + alertDescription
} }
message := fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold) message := fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
return message + description + formattedConditionResults return message + description + formattedConditionResults
} }
@@ -130,3 +166,25 @@ func (provider *AlertProvider) buildIssueBody(endpoint *core.Endpoint, alert *al
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert
} }
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration (we're returning the cfg here even if there's an error mostly for testing purposes)
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
+88 -36
View File
@@ -7,47 +7,51 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
"github.com/google/go-github/v48/github" "github.com/google/go-github/v48/github"
) )
func TestAlertDefaultProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
Expected bool ExpectedError bool
}{ }{
{ {
Name: "invalid", Name: "invalid",
Provider: AlertProvider{RepositoryURL: "", Token: ""}, Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "", Token: ""}},
Expected: false, ExpectedError: true,
}, },
{ {
Name: "invalid-token", Name: "invalid-token",
Provider: AlertProvider{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"}, Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"}},
Expected: false, ExpectedError: true,
}, },
{ {
Name: "missing-repository-name", Name: "missing-repository-name",
Provider: AlertProvider{RepositoryURL: "https://github.com/TwiN", Token: "12345"}, Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN", Token: "12345"}},
Expected: false, ExpectedError: true,
}, },
{ {
Name: "enterprise-client", Name: "enterprise-client",
Provider: AlertProvider{RepositoryURL: "https://github.example.com/TwiN/test", Token: "12345"}, Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.example.com/TwiN/test", Token: "12345"}},
Expected: false, ExpectedError: true,
}, },
{ {
Name: "invalid-url", Name: "invalid-url",
Provider: AlertProvider{RepositoryURL: "github.com/TwiN/test", Token: "12345"}, Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "github.com/TwiN/test", Token: "12345"}},
Expected: false, ExpectedError: true,
}, },
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
if scenario.Provider.IsValid() != scenario.Expected { err := scenario.Provider.Validate()
t.Errorf("expected %t, got %t", scenario.Expected, scenario.Provider.IsValid()) if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
t.Error("expected no error, got", err.Error())
} }
}) })
} }
@@ -67,14 +71,14 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"}, Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedError: true, ExpectedError: true,
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"}, Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedError: true, ExpectedError: true,
@@ -82,13 +86,17 @@ func TestAlertProvider_Send(t *testing.T) {
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
scenario.Provider.githubClient = github.NewClient(nil) cfg, err := scenario.Provider.GetConfig("", &scenario.Alert)
if err != nil && !strings.Contains(err.Error(), "failed to retrieve GitHub user") && !strings.Contains(err.Error(), "no such host") {
t.Error("expected no error, got", err.Error())
}
cfg.githubClient = github.NewClient(nil)
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: "endpoint-name", Group: "endpoint-group"}, &endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.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},
}, },
@@ -109,7 +117,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1" firstDescription := "description-1"
scenarios := []struct { scenarios := []struct {
Name string Name string
Endpoint core.Endpoint Endpoint endpoint.Endpoint
Provider AlertProvider Provider AlertProvider
Alert alert.Alert Alert alert.Alert
NoConditions bool NoConditions bool
@@ -117,14 +125,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"}, Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{}, Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 3},
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\n> description-1\n\n## Condition results\n- :white_check_mark: - `[CONNECTED] == true`\n- :x: - `[STATUS] == 200`", ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\n> description-1\n\n## Condition results\n- :white_check_mark: - `[CONNECTED] == true`\n- :x: - `[STATUS] == 200`",
}, },
{ {
Name: "triggered-with-no-description", Name: "triggered-with-no-description",
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"}, Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{}, Provider: AlertProvider{},
Alert: alert.Alert{FailureThreshold: 10}, Alert: alert.Alert{FailureThreshold: 10},
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 10 time(s) in a row\n\n## Condition results\n- :white_check_mark: - `[CONNECTED] == true`\n- :x: - `[STATUS] == 200`", ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 10 time(s) in a row\n\n## Condition results\n- :white_check_mark: - `[CONNECTED] == true`\n- :x: - `[STATUS] == 200`",
@@ -132,7 +140,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
{ {
Name: "triggered-with-no-conditions", Name: "triggered-with-no-conditions",
NoConditions: true, NoConditions: true,
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"}, Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{}, Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 10}, Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 10},
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 10 time(s) in a row:\n> description-1", ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 10 time(s) in a row:\n> description-1",
@@ -140,9 +148,9 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
var conditionResults []*core.ConditionResult var conditionResults []*endpoint.ConditionResult
if !scenario.NoConditions { if !scenario.NoConditions {
conditionResults = []*core.ConditionResult{ conditionResults = []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: true}, {Condition: "[CONNECTED] == true", Success: true},
{Condition: "[STATUS] == 200", Success: false}, {Condition: "[STATUS] == 200", Success: false},
} }
@@ -150,7 +158,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
body := scenario.Provider.buildIssueBody( body := scenario.Provider.buildIssueBody(
&scenario.Endpoint, &scenario.Endpoint,
&scenario.Alert, &scenario.Alert,
&core.Result{ConditionResults: conditionResults}, &endpoint.Result{ConditionResults: conditionResults},
) )
if strings.TrimSpace(body) != strings.TrimSpace(scenario.ExpectedBody) { if strings.TrimSpace(body) != strings.TrimSpace(scenario.ExpectedBody) {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body) t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
@@ -167,3 +175,47 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
t.Error("expected default alert to be nil") t.Error("expected default alert to be nil")
} }
} }
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-should-default",
Provider: AlertProvider{
DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"},
},
InputAlert: alert.Alert{},
ExpectedOutput: Config{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"},
},
{
Name: "provider-with-alert-override",
Provider: AlertProvider{
DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"},
},
InputAlert: alert.Alert{ProviderOverride: map[string]any{"repository-url": "https://github.com/TwiN/alert-test", "token": "54321"}},
ExpectedOutput: Config{RepositoryURL: "https://github.com/TwiN/alert-test", Token: "54321"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
if err != nil && !strings.Contains(err.Error(), "failed to retrieve GitHub user") && !strings.Contains(err.Error(), "no such host") {
t.Fatalf("unexpected error: %s", err)
}
if got.RepositoryURL != scenario.ExpectedOutput.RepositoryURL {
t.Errorf("expected repository URL %s, got %s", scenario.ExpectedOutput.RepositoryURL, got.RepositoryURL)
}
if got.Token != scenario.ExpectedOutput.Token {
t.Errorf("expected token %s, got %s", scenario.ExpectedOutput.Token, got.Token)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil && !strings.Contains(err.Error(), "failed to retrieve GitHub user") {
t.Errorf("unexpected error: %s", err)
}
})
}
}
+107 -53
View File
@@ -11,57 +11,99 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/google/uuid" "github.com/google/uuid"
"gopkg.in/yaml.v3"
) )
const (
DefaultSeverity = "critical"
DefaultMonitoringTool = "gatus"
)
var (
ErrInvalidWebhookURL = fmt.Errorf("invalid webhook-url")
ErrAuthorizationKeyNotSet = fmt.Errorf("authorization-key not set")
)
type Config struct {
WebhookURL string `yaml:"webhook-url"` // The webhook url provided by GitLab
AuthorizationKey string `yaml:"authorization-key"` // The authorization key provided by GitLab
Severity string `yaml:"severity,omitempty"` // Severity can be one of: critical, high, medium, low, info, unknown. Defaults to critical
MonitoringTool string `yaml:"monitoring-tool,omitempty"` // MonitoringTool overrides the name sent to gitlab. Defaults to gatus
EnvironmentName string `yaml:"environment-name,omitempty"` // EnvironmentName is the name of the associated GitLab environment. Required to display alerts on a dashboard.
Service string `yaml:"service,omitempty"` // Service affected. Defaults to the endpoint's display name
}
func (cfg *Config) Validate() error {
if len(cfg.WebhookURL) == 0 {
return ErrInvalidWebhookURL
} else if _, err := url.Parse(cfg.WebhookURL); err != nil {
return ErrInvalidWebhookURL
}
if len(cfg.AuthorizationKey) == 0 {
return ErrAuthorizationKeyNotSet
}
if len(cfg.Severity) == 0 {
cfg.Severity = DefaultSeverity
}
if len(cfg.MonitoringTool) == 0 {
cfg.MonitoringTool = DefaultMonitoringTool
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.WebhookURL) > 0 {
cfg.WebhookURL = override.WebhookURL
}
if len(override.AuthorizationKey) > 0 {
cfg.AuthorizationKey = override.AuthorizationKey
}
if len(override.Severity) > 0 {
cfg.Severity = override.Severity
}
if len(override.MonitoringTool) > 0 {
cfg.MonitoringTool = override.MonitoringTool
}
if len(override.EnvironmentName) > 0 {
cfg.EnvironmentName = override.EnvironmentName
}
if len(override.Service) > 0 {
cfg.Service = override.Service
}
}
// AlertProvider is the configuration necessary for sending an alert using GitLab // AlertProvider is the configuration necessary for sending an alert using GitLab
type AlertProvider struct { type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"` // The webhook url provided by GitLab DefaultConfig Config `yaml:",inline"`
AuthorizationKey string `yaml:"authorization-key"` // The authorization key provided by GitLab
// 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"`
// Severity can be one of: critical, high, medium, low, info, unknown. Defaults to critical
Severity string `yaml:"severity,omitempty"`
// MonitoringTool overrides the name sent to gitlab. Defaults to gatus
MonitoringTool string `yaml:"monitoring-tool,omitempty"`
// EnvironmentName is the name of the associated GitLab environment. Required to display alerts on a dashboard.
EnvironmentName string `yaml:"environment-name,omitempty"`
// Service affected. Defaults to endpoint display name
Service string `yaml:"service,omitempty"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
if len(provider.AuthorizationKey) == 0 || len(provider.WebhookURL) == 0 { return provider.DefaultConfig.Validate()
return false
}
// Validate format of the repository URL
_, err := url.Parse(provider.WebhookURL)
if err != nil {
return false
}
return true
} }
// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false, // Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,
// or closes the relevant issue(s) if the resolved parameter passed is true. // or closes the relevant issue(s) if the resolved parameter passed is true.
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
if len(alert.ResolveKey) == 0 { if len(alert.ResolveKey) == 0 {
alert.ResolveKey = uuid.NewString() alert.ResolveKey = uuid.NewString()
} }
buffer := bytes.NewBuffer(provider.buildAlertBody(endpoint, alert, result, resolved)) buffer := bytes.NewBuffer(provider.buildAlertBody(cfg, ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer) request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, 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")
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", provider.AuthorizationKey)) request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.AuthorizationKey))
response, err := client.GetHTTPClient(nil).Do(request) response, err := client.GetHTTPClient(nil).Do(request)
if err != nil { if err != nil {
return err return err
@@ -87,30 +129,20 @@ type AlertBody struct {
GitlabEnvironmentName string `json:"gitlab_environment_name,omitempty"` // The name of the associated GitLab environment. Required to display alerts on a dashboard. GitlabEnvironmentName string `json:"gitlab_environment_name,omitempty"` // The name of the associated GitLab environment. Required to display alerts on a dashboard.
} }
func (provider *AlertProvider) monitoringTool() string {
if len(provider.MonitoringTool) > 0 {
return provider.MonitoringTool
}
return "gatus"
}
func (provider *AlertProvider) service(endpoint *core.Endpoint) string {
if len(provider.Service) > 0 {
return provider.Service
}
return endpoint.DisplayName()
}
// buildAlertBody builds the body of the alert // buildAlertBody builds the body of the alert
func (provider *AlertProvider) buildAlertBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { func (provider *AlertProvider) buildAlertBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
service := cfg.Service
if len(service) == 0 {
service = ep.DisplayName()
}
body := AlertBody{ body := AlertBody{
Title: fmt.Sprintf("alert(%s): %s", provider.monitoringTool(), provider.service(endpoint)), Title: fmt.Sprintf("alert(%s): %s", cfg.MonitoringTool, service),
StartTime: result.Timestamp.Format(time.RFC3339), StartTime: result.Timestamp.Format(time.RFC3339),
Service: provider.service(endpoint), Service: service,
MonitoringTool: provider.monitoringTool(), MonitoringTool: cfg.MonitoringTool,
Hosts: endpoint.URL, Hosts: ep.URL,
GitlabEnvironmentName: provider.EnvironmentName, GitlabEnvironmentName: cfg.EnvironmentName,
Severity: provider.Severity, Severity: cfg.Severity,
Fingerprint: alert.ResolveKey, Fingerprint: alert.ResolveKey,
} }
if resolved { if resolved {
@@ -135,9 +167,9 @@ func (provider *AlertProvider) buildAlertBody(endpoint *core.Endpoint, alert *al
} }
var message string var message 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", ep.DisplayName(), alert.SuccessThreshold)
} else { } 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) message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
} }
body.Description = message + description + formattedConditionResults body.Description = message + description + formattedConditionResults
bodyAsJSON, _ := json.Marshal(body) bodyAsJSON, _ := json.Marshal(body)
@@ -148,3 +180,25 @@ func (provider *AlertProvider) buildAlertBody(endpoint *core.Endpoint, alert *al
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert
} }
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration (we're returning the cfg here even if there's an error mostly for testing purposes)
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
+98 -33
View File
@@ -7,41 +7,45 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertDefaultProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
Expected bool ExpectedError bool
}{ }{
{ {
Name: "invalid", Name: "invalid",
Provider: AlertProvider{WebhookURL: "", AuthorizationKey: ""}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "", AuthorizationKey: ""}},
Expected: false, ExpectedError: true,
}, },
{ {
Name: "missing-webhook-url", Name: "missing-webhook-url",
Provider: AlertProvider{WebhookURL: "", AuthorizationKey: "12345"}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "", AuthorizationKey: "12345"}},
Expected: false, ExpectedError: true,
}, },
{ {
Name: "missing-authorization-key", Name: "missing-authorization-key",
Provider: AlertProvider{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: ""}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://gitlab.com/whatever/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: ""}},
Expected: false, ExpectedError: true,
}, },
{ {
Name: "invalid-url", Name: "invalid-url",
Provider: AlertProvider{WebhookURL: " http://foo.com", AuthorizationKey: "12345"}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: " http://foo.com", AuthorizationKey: "12345"}},
Expected: false, ExpectedError: true,
}, },
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
if scenario.Provider.IsValid() != scenario.Expected { err := scenario.Provider.Validate()
t.Errorf("expected %t, got %t", scenario.Expected, scenario.Provider.IsValid()) if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
t.Error("expected no error, got", err.Error())
} }
}) })
} }
@@ -61,7 +65,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedError: false, ExpectedError: false,
@@ -71,7 +75,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedError: false, ExpectedError: false,
@@ -84,10 +88,10 @@ 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: "endpoint-name", Group: "endpoint-group"}, &endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.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},
}, },
@@ -108,33 +112,38 @@ func TestAlertProvider_buildAlertBody(t *testing.T) {
firstDescription := "description-1" firstDescription := "description-1"
scenarios := []struct { scenarios := []struct {
Name string Name string
Endpoint core.Endpoint Endpoint endpoint.Endpoint
Provider AlertProvider Provider AlertProvider
Alert alert.Alert Alert alert.Alert
ExpectedBody string ExpectedBody string
}{ }{
{ {
Name: "triggered", Name: "triggered",
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"}, Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"}},
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 3},
ExpectedBody: "{\"title\":\"alert(gatus): endpoint-name\",\"description\":\"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\\n\\n## Condition results\\n- :white_check_mark: - `[CONNECTED] == true`\\n- :x: - `[STATUS] == 200`\\n\",\"start_time\":\"0001-01-01T00:00:00Z\",\"service\":\"endpoint-name\",\"monitoring_tool\":\"gatus\",\"hosts\":\"https://example.org\"}", ExpectedBody: "{\"title\":\"alert(gatus): endpoint-name\",\"description\":\"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\\n\\n## Condition results\\n- :white_check_mark: - `[CONNECTED] == true`\\n- :x: - `[STATUS] == 200`\\n\",\"start_time\":\"0001-01-01T00:00:00Z\",\"service\":\"endpoint-name\",\"monitoring_tool\":\"gatus\",\"hosts\":\"https://example.org\",\"severity\":\"critical\"}",
}, },
{ {
Name: "no-description", Name: "no-description",
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"}, Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"}},
Alert: alert.Alert{FailureThreshold: 10}, Alert: alert.Alert{FailureThreshold: 10},
ExpectedBody: "{\"title\":\"alert(gatus): endpoint-name\",\"description\":\"An alert for *endpoint-name* has been triggered due to having failed 10 time(s) in a row\\n\\n## Condition results\\n- :white_check_mark: - `[CONNECTED] == true`\\n- :x: - `[STATUS] == 200`\\n\",\"start_time\":\"0001-01-01T00:00:00Z\",\"service\":\"endpoint-name\",\"monitoring_tool\":\"gatus\",\"hosts\":\"https://example.org\"}", ExpectedBody: "{\"title\":\"alert(gatus): endpoint-name\",\"description\":\"An alert for *endpoint-name* has been triggered due to having failed 10 time(s) in a row\\n\\n## Condition results\\n- :white_check_mark: - `[CONNECTED] == true`\\n- :x: - `[STATUS] == 200`\\n\",\"start_time\":\"0001-01-01T00:00:00Z\",\"service\":\"endpoint-name\",\"monitoring_tool\":\"gatus\",\"hosts\":\"https://example.org\",\"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) {
cfg, err := scenario.Provider.GetConfig("", &scenario.Alert)
if err != nil {
t.Error("expected no error, got", err.Error())
}
body := scenario.Provider.buildAlertBody( body := scenario.Provider.buildAlertBody(
cfg,
&scenario.Endpoint, &scenario.Endpoint,
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: true}, {Condition: "[CONNECTED] == true", Success: true},
{Condition: "[STATUS] == 200", Success: false}, {Condition: "[STATUS] == 200", Success: false},
}, },
@@ -156,3 +165,59 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
t.Error("expected default alert to be nil") t.Error("expected default alert to be nil")
} }
} }
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-should-default",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "https://github.com/TwiN/test", AuthorizationKey: "12345"},
},
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "https://github.com/TwiN/test", AuthorizationKey: "12345", Severity: DefaultSeverity, MonitoringTool: DefaultMonitoringTool},
},
{
Name: "provider-with-alert-override",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "https://github.com/TwiN/test", AuthorizationKey: "12345"},
},
InputAlert: alert.Alert{ProviderOverride: map[string]any{"repository-url": "https://github.com/TwiN/alert-test", "authorization-key": "54321", "severity": "info", "monitoring-tool": "not-gatus", "environment-name": "prod", "service": "example"}},
ExpectedOutput: Config{WebhookURL: "https://github.com/TwiN/test", AuthorizationKey: "54321", Severity: "info", MonitoringTool: "not-gatus", EnvironmentName: "prod", Service: "example"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
if err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
t.Fatalf("unexpected error: %s", err)
}
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
t.Errorf("expected repository URL %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
}
if got.AuthorizationKey != scenario.ExpectedOutput.AuthorizationKey {
t.Errorf("expected AuthorizationKey %s, got %s", scenario.ExpectedOutput.AuthorizationKey, got.AuthorizationKey)
}
if got.Severity != scenario.ExpectedOutput.Severity {
t.Errorf("expected Severity %s, got %s", scenario.ExpectedOutput.Severity, got.Severity)
}
if got.MonitoringTool != scenario.ExpectedOutput.MonitoringTool {
t.Errorf("expected MonitoringTool %s, got %s", scenario.ExpectedOutput.MonitoringTool, got.MonitoringTool)
}
if got.EnvironmentName != scenario.ExpectedOutput.EnvironmentName {
t.Errorf("expected EnvironmentName %s, got %s", scenario.ExpectedOutput.EnvironmentName, got.EnvironmentName)
}
if got.Service != scenario.ExpectedOutput.Service {
t.Errorf("expected Service %s, got %s", scenario.ExpectedOutput.Service, got.Service)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
}
})
}
}
+79 -34
View File
@@ -3,21 +3,46 @@ package googlechat
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
) )
var (
ErrWebhookURLNotSet = errors.New("webhook-url not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
WebhookURL string `yaml:"webhook-url"`
ClientConfig *client.Config `yaml:"client,omitempty"`
}
func (cfg *Config) Validate() error {
if len(cfg.WebhookURL) == 0 {
return ErrWebhookURLNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if override.ClientConfig != nil {
cfg.ClientConfig = override.ClientConfig
}
if len(override.WebhookURL) > 0 {
cfg.WebhookURL = override.WebhookURL
}
}
// AlertProvider is the configuration necessary for sending an alert using Google chat // AlertProvider is the configuration necessary for sending an alert using Google chat
type AlertProvider struct { type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"` DefaultConfig Config `yaml:",inline"`
// 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"`
@@ -28,36 +53,37 @@ type AlertProvider struct {
// Override is a case under which the default integration is overridden // Override is a case under which the default integration is overridden
type Override struct { type Override struct {
Group string `yaml:"group"` Group string `yaml:"group"`
WebhookURL string `yaml:"webhook-url"` Config `yaml:",inline"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
if provider.ClientConfig == nil {
provider.ClientConfig = client.GetDefaultConfig()
}
registeredGroups := make(map[string]bool) registeredGroups := make(map[string]bool)
if provider.Overrides != nil { if provider.Overrides != nil {
for _, override := range provider.Overrides { for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 { if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
return false return ErrDuplicateGroupOverride
} }
registeredGroups[override.Group] = true registeredGroups[override.Group] = true
} }
} }
return len(provider.WebhookURL) > 0 return provider.DefaultConfig.Validate()
} }
// 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(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) cfg, err := provider.GetConfig(ep.Group, alert)
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer) if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, 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(provider.ClientConfig).Do(request) response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
if err != nil { if err != nil {
return err return err
} }
@@ -112,7 +138,7 @@ type OpenLink struct {
} }
// 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) []byte { func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message, color string var message, color string
if resolved { if resolved {
color = "#36A64F" color = "#36A64F"
@@ -143,7 +169,7 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
Widgets: []Widgets{ Widgets: []Widgets{
{ {
KeyValue: &KeyValue{ KeyValue: &KeyValue{
TopLabel: endpoint.DisplayName(), TopLabel: ep.DisplayName(),
Content: message, Content: message,
ContentMultiline: "true", ContentMultiline: "true",
BottomLabel: description, BottomLabel: description,
@@ -166,7 +192,7 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
}, },
}) })
} }
if endpoint.Type() == core.EndpointTypeHTTP { if ep.Type() == endpoint.TypeHTTP {
// We only include a button targeting the URL if the endpoint is an HTTP endpoint // 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. // 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 // See https://github.com/TwiN/gatus/issues/362
@@ -175,7 +201,7 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
{ {
TextButton: TextButton{ TextButton: TextButton{
Text: "URL", Text: "URL",
OnClick: OnClick{OpenLink: OpenLink{URL: endpoint.URL}}, OnClick: OnClick{OpenLink: OpenLink{URL: ep.URL}},
}, },
}, },
}, },
@@ -185,19 +211,38 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
return bodyAsJSON return bodyAsJSON
} }
// 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
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert
} }
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
+80 -52
View File
@@ -7,54 +7,54 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertDefaultProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{WebhookURL: ""} invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{WebhookURL: "http://example.com"} validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
func TestAlertProvider_IsValidWithOverride(t *testing.T) { func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{ providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "http://example.com", Config: Config{WebhookURL: "http://example.com"},
Group: "", Group: "",
}, },
}, },
} }
if providerWithInvalidOverrideGroup.IsValid() { if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid") t.Error("provider Group shouldn't have been valid")
} }
providerWithInvalidOverrideTo := AlertProvider{ providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "", Config: Config{WebhookURL: ""},
Group: "group", Group: "group",
}, },
}, },
} }
if providerWithInvalidOverrideTo.IsValid() { if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid") t.Error("provider integration key shouldn't have been valid")
} }
providerWithValidOverride := AlertProvider{ providerWithValidOverride := AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "http://example.com", Config: Config{WebhookURL: "http://example.com"},
Group: "group", Group: "group",
}, },
}, },
} }
if !providerWithValidOverride.IsValid() { if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
@@ -73,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -83,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -93,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -103,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -116,10 +116,10 @@ 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: "endpoint-name", Group: "endpoint-group"}, &endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.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},
}, },
@@ -141,7 +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 Endpoint endpoint.Endpoint
Provider AlertProvider Provider AlertProvider
Alert alert.Alert Alert alert.Alert
Resolved bool Resolved bool
@@ -149,7 +149,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"}, Endpoint: endpoint.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,
@@ -157,7 +157,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"}, Endpoint: endpoint.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,
@@ -165,7 +165,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}, },
{ {
Name: "icmp-should-not-include-url", // See https://github.com/TwiN/gatus/issues/362 Name: "icmp-should-not-include-url", // See https://github.com/TwiN/gatus/issues/362
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "icmp://example.org"}, Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "icmp://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,
@@ -173,7 +173,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}, },
{ {
Name: "tcp-should-not-include-url", // See https://github.com/TwiN/gatus/issues/362 Name: "tcp-should-not-include-url", // See https://github.com/TwiN/gatus/issues/362
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "tcp://example.org"}, Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "tcp://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,
@@ -185,8 +185,8 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&scenario.Endpoint, &scenario.Endpoint,
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.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},
}, },
@@ -213,64 +213,92 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
} }
} }
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) { func TestAlertProvider_GetConfig(t *testing.T) {
tests := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
InputGroup string InputGroup string
ExpectedOutput string InputAlert alert.Alert
ExpectedOutput Config
}{ }{
{ {
Name: "provider-no-override-specify-no-group-should-default", Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-no-override-specify-group-should-default", Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-with-override-specify-no-group-should-default", Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
WebhookURL: "http://example01.com", Config: Config{WebhookURL: "http://example01.com"},
}, },
}, },
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-with-override-specify-group-should-override", Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
WebhookURL: "http://example01.com", Config: Config{WebhookURL: "http://group-example.com"},
}, },
}, },
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "http://example01.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://group-example.com"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
ExpectedOutput: Config{WebhookURL: "http://alert-example.com"},
}, },
} }
for _, tt := range tests { for _, scenario := range scenarios {
t.Run(tt.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput { got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput) if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
} }
}) })
} }
+83 -28
View File
@@ -3,47 +3,80 @@ package gotify
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
) )
const DefaultPriority = 5 const DefaultPriority = 5
var (
ErrServerURLNotSet = errors.New("server URL not set")
ErrTokenNotSet = errors.New("token not set")
)
type Config struct {
ServerURL string `yaml:"server-url"` // URL of the Gotify server
Token string `yaml:"token"` // Token to use when sending a message to the Gotify server
Priority int `yaml:"priority,omitempty"` // Priority of the message. Defaults to DefaultPriority.
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
}
func (cfg *Config) Validate() error {
if cfg.Priority == 0 {
cfg.Priority = DefaultPriority
}
if len(cfg.ServerURL) == 0 {
return ErrServerURLNotSet
}
if len(cfg.Token) == 0 {
return ErrTokenNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.ServerURL) > 0 {
cfg.ServerURL = override.ServerURL
}
if len(override.Token) > 0 {
cfg.Token = override.Token
}
if override.Priority != 0 {
cfg.Priority = override.Priority
}
if len(override.Title) > 0 {
cfg.Title = override.Title
}
}
// AlertProvider is the configuration necessary for sending an alert using Gotify // AlertProvider is the configuration necessary for sending an alert using Gotify
type AlertProvider struct { type AlertProvider struct {
// ServerURL is the URL of the Gotify server DefaultConfig Config `yaml:",inline"`
ServerURL string `yaml:"server-url"`
// Token is the token to use when sending a message to the Gotify server
Token string `yaml:"token"`
// Priority is the priority of the message
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 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"`
// Title is the title of the message that will be sent
Title string `yaml:"title,omitempty"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
if provider.Priority == 0 { return provider.DefaultConfig.Validate()
provider.Priority = DefaultPriority
}
return len(provider.ServerURL) > 0 && len(provider.Token) > 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(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) cfg, err := provider.GetConfig(ep.Group, alert)
request, err := http.NewRequest(http.MethodPost, provider.ServerURL+"/message?token="+provider.Token, buffer) if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, cfg.ServerURL+"/message?token="+cfg.Token, buffer)
if err != nil { if err != nil {
return err return err
} }
@@ -67,12 +100,12 @@ type Body struct {
} }
// 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) []byte { func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message string var message 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", ep.DisplayName(), alert.SuccessThreshold)
} else { } 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) message = fmt.Sprintf("An alert for `%s` has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
} }
var formattedConditionResults string var formattedConditionResults string
for _, conditionResult := range result.ConditionResults { for _, conditionResult := range result.ConditionResults {
@@ -88,14 +121,14 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
message += " with the following description: " + alert.GetDescription() message += " with the following description: " + alert.GetDescription()
} }
message += formattedConditionResults message += formattedConditionResults
title := "Gatus: " + endpoint.DisplayName() title := "Gatus: " + ep.DisplayName()
if provider.Title != "" { if cfg.Title != "" {
title = provider.Title title = cfg.Title
} }
bodyAsJSON, _ := json.Marshal(Body{ bodyAsJSON, _ := json.Marshal(Body{
Message: message, Message: message,
Title: title, Title: title,
Priority: provider.Priority, Priority: cfg.Priority,
}) })
return bodyAsJSON return bodyAsJSON
} }
@@ -104,3 +137,25 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert
} }
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
+76 -18
View File
@@ -6,10 +6,10 @@ import (
"testing" "testing"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct { scenarios := []struct {
name string name string
provider AlertProvider provider AlertProvider
@@ -17,29 +17,29 @@ func TestAlertProvider_IsValid(t *testing.T) {
}{ }{
{ {
name: "valid", name: "valid",
provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"}, provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "faketoken"}},
expected: true, expected: true,
}, },
{ {
name: "invalid-server-url", name: "invalid-server-url",
provider: AlertProvider{ServerURL: "", Token: "faketoken"}, provider: AlertProvider{DefaultConfig: Config{ServerURL: "", Token: "faketoken"}},
expected: false, expected: false,
}, },
{ {
name: "invalid-app-token", name: "invalid-app-token",
provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: ""}, provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: ""}},
expected: false, expected: false,
}, },
{ {
name: "no-priority-should-use-default-value", name: "no-priority-should-use-default-value",
provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"}, provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "faketoken"}},
expected: true, expected: true,
}, },
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) { t.Run(scenario.name, func(t *testing.T) {
if scenario.provider.IsValid() != scenario.expected { if err := scenario.provider.Validate(); (err == nil) != scenario.expected {
t.Errorf("expected %t, got %t", scenario.expected, scenario.provider.IsValid()) t.Errorf("expected: %t, got: %t", scenario.expected, err == nil)
} }
}) })
} }
@@ -49,7 +49,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
var ( var (
description = "custom-description" description = "custom-description"
//title = "custom-title" //title = "custom-title"
endpoint = "custom-endpoint" endpointName = "custom-endpoint"
) )
scenarios := []struct { scenarios := []struct {
Name string Name string
@@ -60,33 +60,34 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"}, Provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "faketoken"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been triggered due to having failed 3 time(s) in a row with the following description: %s\\n✕ - [CONNECTED] == true\\n✕ - [STATUS] == 200\",\"title\":\"Gatus: custom-endpoint\",\"priority\":0}", endpoint, description), ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been triggered due to having failed 3 time(s) in a row with the following description: %s\\n✕ - [CONNECTED] == true\\n✕ - [STATUS] == 200\",\"title\":\"Gatus: custom-endpoint\",\"priority\":0}", endpointName, description),
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"}, Provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "faketoken"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been resolved after passing successfully 5 time(s) in a row with the following description: %s\\n✓ - [CONNECTED] == true\\n✓ - [STATUS] == 200\",\"title\":\"Gatus: custom-endpoint\",\"priority\":0}", endpoint, description), ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been resolved after passing successfully 5 time(s) in a row with the following description: %s\\n✓ - [CONNECTED] == true\\n✓ - [STATUS] == 200\",\"title\":\"Gatus: custom-endpoint\",\"priority\":0}", endpointName, description),
}, },
{ {
Name: "custom-title", Name: "custom-title",
Provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken", Title: "custom-title"}, Provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "faketoken", Title: "custom-title"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been triggered due to having failed 3 time(s) in a row with the following description: %s\\n✕ - [CONNECTED] == true\\n✕ - [STATUS] == 200\",\"title\":\"custom-title\",\"priority\":0}", endpoint, description), ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been triggered due to having failed 3 time(s) in a row with the following description: %s\\n✕ - [CONNECTED] == true\\n✕ - [STATUS] == 200\",\"title\":\"custom-title\",\"priority\":0}", endpointName, 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}, &scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: endpointName},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.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},
}, },
@@ -103,3 +104,60 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}) })
} }
} }
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
provider := AlertProvider{DefaultAlert: &alert.Alert{}}
if provider.GetDefaultAlert() != provider.DefaultAlert {
t.Error("expected default alert to be returned")
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-should-default",
Provider: AlertProvider{
DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "12345"},
},
InputAlert: alert.Alert{},
ExpectedOutput: Config{ServerURL: "https://gotify.example.com", Token: "12345", Priority: DefaultPriority},
},
{
Name: "provider-with-alert-override",
Provider: AlertProvider{
DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "12345"},
},
InputAlert: alert.Alert{ProviderOverride: map[string]any{"server-url": "https://gotify.group-example.com", "token": "54321", "title": "alert-title", "priority": 3}},
ExpectedOutput: Config{ServerURL: "https://gotify.group-example.com", Token: "54321", Title: "alert-title", Priority: 3},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
if err != nil {
t.Error("expected no error, got:", err.Error())
}
if got.ServerURL != scenario.ExpectedOutput.ServerURL {
t.Errorf("expected server URL to be %s, got %s", scenario.ExpectedOutput.ServerURL, got.ServerURL)
}
if got.Token != scenario.ExpectedOutput.Token {
t.Errorf("expected token to be %s, got %s", scenario.ExpectedOutput.Token, got.Token)
}
if got.Title != scenario.ExpectedOutput.Title {
t.Errorf("expected title to be %s, got %s", scenario.ExpectedOutput.Title, got.Title)
}
if got.Priority != scenario.ExpectedOutput.Priority {
t.Errorf("expected priority to be %d, got %d", scenario.ExpectedOutput.Priority, got.Priority)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
}
})
}
}
+210
View File
@@ -0,0 +1,210 @@
package incidentio
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/logr"
"gopkg.in/yaml.v3"
)
const (
restAPIUrl = "https://api.incident.io/v2/alert_events/http/"
)
var (
ErrURLNotSet = errors.New("url not set")
ErrURLNotPrefixedWithRestAPIURL = fmt.Errorf("url must be prefixed with %s", restAPIUrl)
ErrDuplicateGroupOverride = errors.New("duplicate group override")
ErrAuthTokenNotSet = errors.New("auth-token not set")
)
type Config struct {
URL string `yaml:"url,omitempty"`
AuthToken string `yaml:"auth-token,omitempty"`
SourceURL string `yaml:"source-url,omitempty"`
Metadata map[string]interface{} `yaml:"metadata,omitempty"`
}
func (cfg *Config) Validate() error {
if len(cfg.URL) == 0 {
return ErrURLNotSet
}
if !strings.HasPrefix(cfg.URL, restAPIUrl) {
return ErrURLNotPrefixedWithRestAPIURL
}
if len(cfg.AuthToken) == 0 {
return ErrAuthTokenNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.URL) > 0 {
cfg.URL = override.URL
}
if len(override.AuthToken) > 0 {
cfg.AuthToken = override.AuthToken
}
if len(override.SourceURL) > 0 {
cfg.SourceURL = override.SourceURL
}
if len(override.Metadata) > 0 {
cfg.Metadata = override.Metadata
}
}
// AlertProvider is the configuration necessary for sending an alert using incident.io
type AlertProvider struct {
DefaultConfig Config `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"`
}
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
req, err := http.NewRequest(http.MethodPost, cfg.URL, buffer)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+cfg.AuthToken)
response, err := client.GetHTTPClient(nil).Do(req)
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))
}
incidentioResponse := Response{}
err = json.NewDecoder(response.Body).Decode(&incidentioResponse)
if err != nil {
// Silently fail. We don't want to create tons of alerts just because we failed to parse the body.
logr.Errorf("[incidentio.Send] Ran into error decoding pagerduty response: %s", err.Error())
}
alert.ResolveKey = incidentioResponse.DeduplicationKey
return err
}
type Body struct {
AlertSourceConfigID string `json:"alert_source_config_id"`
Status string `json:"status"`
Title string `json:"title"`
DeduplicationKey string `json:"deduplication_key,omitempty"`
Description string `json:"description,omitempty"`
SourceURL string `json:"source_url,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
type Response struct {
DeduplicationKey string `json:"deduplication_key"`
}
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message, formattedConditionResults, status string
if resolved {
message = "An alert has been resolved after passing successfully " + strconv.Itoa(alert.SuccessThreshold) + " time(s) in a row"
status = "resolved"
} else {
message = "An alert has been triggered due to having failed " + strconv.Itoa(alert.FailureThreshold) + " time(s) in a row"
status = "firing"
}
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "🟢"
} else {
prefix = "🔴"
}
// No need for \n since incident.io trims it anyways.
formattedConditionResults += fmt.Sprintf(" %s %s ", prefix, conditionResult.Condition)
}
if len(alert.GetDescription()) > 0 {
message += " with the following description: " + alert.GetDescription()
}
message += fmt.Sprintf(" and the following conditions: %s ", formattedConditionResults)
var body []byte
alertSourceID := strings.TrimPrefix(cfg.URL, restAPIUrl)
body, _ = json.Marshal(Body{
AlertSourceConfigID: alertSourceID,
Title: "Gatus: " + ep.DisplayName(),
Status: status,
DeduplicationKey: alert.ResolveKey,
Description: message,
SourceURL: cfg.SourceURL,
Metadata: cfg.Metadata,
})
fmt.Printf("%v", string(body))
return body
}
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
@@ -0,0 +1,390 @@
package incidentio
import (
"bytes"
"encoding/json"
"io"
"net/http"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct {
name string
provider AlertProvider
expected bool
}{
{
name: "valid",
provider: AlertProvider{
DefaultConfig: Config{
URL: "https://api.incident.io/v2/alert_events/http/some-id",
AuthToken: "some-token",
},
},
expected: true,
},
{
name: "invalid-url",
provider: AlertProvider{
DefaultConfig: Config{
URL: "id-without-rest-api-url-as-prefix",
AuthToken: "some-token",
},
},
expected: false,
},
{
name: "invalid-missing-auth-token",
provider: AlertProvider{
DefaultConfig: Config{
URL: "some-id",
},
},
expected: false,
},
{
name: "invalid-missing-alert-source-config-id",
provider: AlertProvider{
DefaultConfig: Config{
AuthToken: "some-token",
},
},
expected: false,
},
{
name: "valid-override",
provider: AlertProvider{
DefaultConfig: Config{
AuthToken: "some-token",
URL: "https://api.incident.io/v2/alert_events/http/some-id",
},
Overrides: []Override{{Group: "core", Config: Config{URL: "https://api.incident.io/v2/alert_events/http/another-id"}}},
},
expected: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.provider.Validate()
if scenario.expected && err != nil {
t.Error("expected no error, got", err.Error())
}
if !scenario.expected && err == nil {
t.Error("expected error, got none")
}
})
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
restAPIUrl := "https://api.incident.io/v2/alert_events/http/"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{
URL: restAPIUrl + "some-id",
AuthToken: "some-token",
}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
var b bytes.Buffer
response := Response{DeduplicationKey: "some-key"}
json.NewEncoder(&b).Encode(response)
reader := io.NopCloser(&b)
return &http.Response{StatusCode: http.StatusAccepted, Body: reader}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{DefaultConfig: Config{
URL: restAPIUrl + "some-id",
AuthToken: "some-token",
}},
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{DefaultConfig: Config{
URL: restAPIUrl + "some-id",
AuthToken: "some-token",
}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
var b bytes.Buffer
response := Response{DeduplicationKey: "some-key"}
json.NewEncoder(&b).Encode(response)
reader := io.NopCloser(&b)
return &http.Response{StatusCode: http.StatusAccepted, Body: reader}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{DefaultConfig: Config{}},
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(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.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"
restAPIUrl := "https://api.incident.io/v2/alert_events/http/"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: `{"alert_source_config_id":"some-id","status":"firing","title":"Gatus: endpoint-name","description":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1 and the following conditions: 🔴 [CONNECTED] == true 🔴 [STATUS] == 200 "}`,
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: `{"alert_source_config_id":"some-id","status":"resolved","title":"Gatus: endpoint-name","description":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2 and the following conditions: 🟢 [CONNECTED] == true 🟢 [STATUS] == 200 "}`,
},
{
Name: "resolved-with-metadata-source-url",
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token", Metadata: map[string]interface{}{"service": "some-service", "team": "very-core"}, SourceURL: "some-source-url"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: `{"alert_source_config_id":"some-id","status":"resolved","title":"Gatus: endpoint-name","description":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2 and the following conditions: 🟢 [CONNECTED] == true 🟢 [STATUS] == 200 ","source_url":"some-source-url","metadata":{"service":"some-service","team":"very-core"}}`,
},
{
Name: "group-override",
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}, Overrides: []Override{{Group: "g", Config: Config{URL: restAPIUrl + "different-id", AuthToken: "some-token"}}}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: `{"alert_source_config_id":"different-id","status":"firing","title":"Gatus: endpoint-name","description":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1 and the following conditions: 🔴 [CONNECTED] == true 🔴 [STATUS] == 200 "}`,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
cfg, err := scenario.Provider.GetConfig("g", &scenario.Alert)
if err != nil {
t.Error("expected no error, got", err.Error())
}
body := scenario.Provider.buildRequestBody(
cfg,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.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_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
Overrides: nil,
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
Overrides: []Override{
{
Group: "group",
Config: Config{URL: "https://api.incident.io/v2/alert_events/http/diff-id"},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
Overrides: []Override{
{
Group: "group",
Config: Config{URL: "https://api.incident.io/v2/alert_events/http/diff-id", AuthToken: "some-token"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "https://api.incident.io/v2/alert_events/http/diff-id", AuthToken: "some-token"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
Overrides: []Override{
{
Group: "group",
Config: Config{URL: "https://api.incident.io/v2/alert_events/http/diff-id", AuthToken: "some-token"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"url": "https://api.incident.io/v2/alert_events/http/another-id"}},
ExpectedOutput: Config{URL: "https://api.incident.io/v2/alert_events/http/another-id", AuthToken: "some-token"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.URL != scenario.ExpectedOutput.URL {
t.Errorf("expected alert source config to be %s, got %s", scenario.ExpectedOutput.URL, got.URL)
}
if got.AuthToken != scenario.ExpectedOutput.AuthToken {
t.Errorf("expected alert auth token to be %s, got %s", scenario.ExpectedOutput.AuthToken, got.AuthToken)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
}
})
}
}
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
Config: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
Group: "",
},
},
}
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
Config: Config{URL: "", AuthToken: "some-token"},
Group: "group",
},
},
}
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
DefaultConfig: Config{URL: "https://api.incident.io/v2/alert_events/http/nice-id", AuthToken: "some-token"},
Overrides: []Override{
{
Config: Config{URL: "https://api.incident.io/v2/alert_events/http/very-good-id", AuthToken: "some-token"},
Group: "group",
},
},
}
if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
@@ -3,20 +3,58 @@ package jetbrainsspace
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
) )
var (
ErrProjectNotSet = errors.New("project not set")
ErrChannelIDNotSet = errors.New("channel-id not set")
ErrTokenNotSet = errors.New("token not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
Project string `yaml:"project"` // Project name
ChannelID string `yaml:"channel-id"` // Chat Channel ID
Token string `yaml:"token"` // Bearer Token
}
func (cfg *Config) Validate() error {
if len(cfg.Project) == 0 {
return ErrProjectNotSet
}
if len(cfg.ChannelID) == 0 {
return ErrChannelIDNotSet
}
if len(cfg.Token) == 0 {
return ErrTokenNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.Project) > 0 {
cfg.Project = override.Project
}
if len(override.ChannelID) > 0 {
cfg.ChannelID = override.ChannelID
}
if len(override.Token) > 0 {
cfg.Token = override.Token
}
}
// AlertProvider is the configuration necessary for sending an alert using JetBrains Space // AlertProvider is the configuration necessary for sending an alert using JetBrains Space
type AlertProvider struct { type AlertProvider struct {
Project string `yaml:"project"` // JetBrains Space Project name DefaultConfig Config `yaml:",inline"`
ChannelID string `yaml:"channel-id"` // JetBrains Space Chat Channel ID
Token string `yaml:"token"` // JetBrains Space Bearer Token
// 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"`
@@ -27,34 +65,38 @@ type AlertProvider struct {
// Override is a case under which the default integration is overridden // Override is a case under which the default integration is overridden
type Override struct { type Override struct {
Group string `yaml:"group"` Group string `yaml:"group"`
ChannelID string `yaml:"channel-id"` Config `yaml:",inline"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool) registeredGroups := make(map[string]bool)
if provider.Overrides != nil { if provider.Overrides != nil {
for _, override := range provider.Overrides { for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.ChannelID) == 0 { if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return false return ErrDuplicateGroupOverride
} }
registeredGroups[override.Group] = true registeredGroups[override.Group] = true
} }
} }
return len(provider.Project) > 0 && len(provider.ChannelID) > 0 && len(provider.Token) > 0 return provider.DefaultConfig.Validate()
} }
// 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(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) cfg, err := provider.GetConfig(ep.Group, alert)
url := fmt.Sprintf("https://%s.jetbrains.space/api/http/chats/messages/send-message", provider.Project) if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
url := fmt.Sprintf("https://%s.jetbrains.space/api/http/chats/messages/send-message", cfg.Project)
request, err := http.NewRequest(http.MethodPost, url, buffer) request, err := http.NewRequest(http.MethodPost, url, 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")
request.Header.Set("Authorization", "Bearer "+provider.Token) request.Header.Set("Authorization", "Bearer "+cfg.Token)
response, err := client.GetHTTPClient(nil).Do(request) response, err := client.GetHTTPClient(nil).Do(request)
if err != nil { if err != nil {
return err return err
@@ -103,9 +145,9 @@ type Icon struct {
} }
// 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) []byte { func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
body := Body{ body := Body{
Channel: "id:" + provider.getChannelIDForGroup(endpoint.Group), Channel: "id:" + cfg.ChannelID,
Content: Content{ Content: Content{
ClassName: "ChatMessage.Block", ClassName: "ChatMessage.Block",
Sections: []Section{{ Sections: []Section{{
@@ -116,10 +158,10 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
} }
if resolved { if resolved {
body.Content.Style = "SUCCESS" body.Content.Style = "SUCCESS"
body.Content.Sections[0].Header = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold) body.Content.Sections[0].Header = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else { } else {
body.Content.Style = "WARNING" body.Content.Style = "WARNING"
body.Content.Sections[0].Header = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold) body.Content.Sections[0].Header = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
} }
for _, conditionResult := range result.ConditionResults { for _, conditionResult := range result.ConditionResults {
icon := "warning" icon := "warning"
@@ -144,19 +186,38 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
return bodyAsJSON return bodyAsJSON
} }
// getChannelIDForGroup returns the appropriate channel ID to for a given group override
func (provider *AlertProvider) getChannelIDForGroup(group string) string {
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
return override.ChannelID
}
}
}
return provider.ChannelID
}
// 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
} }
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
@@ -7,58 +7,60 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertDefaultProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{Project: ""} invalidProvider := AlertProvider{DefaultConfig: Config{Project: ""}}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{Project: "foo", ChannelID: "bar", Token: "baz"} validProvider := AlertProvider{DefaultConfig: Config{Project: "foo", ChannelID: "bar", Token: "baz"}}
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
func TestAlertProvider_IsValidWithOverride(t *testing.T) { func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{ providerWithInvalidOverrideGroup := AlertProvider{
Project: "foobar", DefaultConfig: Config{Project: "foobar"},
Overrides: []Override{ Overrides: []Override{
{ {
ChannelID: "http://example.com", Config: Config{ChannelID: "http://example.com"},
Group: "", Group: "",
}, },
}, },
} }
if providerWithInvalidOverrideGroup.IsValid() { if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid") t.Error("provider Group shouldn't have been valid")
} }
providerWithInvalidOverrideTo := AlertProvider{ providerWithInvalidOverrideTo := AlertProvider{
Project: "foobar", DefaultConfig: Config{Project: "foobar"},
Overrides: []Override{ Overrides: []Override{
{ {
ChannelID: "", Config: Config{ChannelID: ""},
Group: "group", Group: "group",
}, },
}, },
} }
if providerWithInvalidOverrideTo.IsValid() { if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid") t.Error("provider integration key shouldn't have been valid")
} }
providerWithValidOverride := AlertProvider{ providerWithValidOverride := AlertProvider{
Project: "foo", DefaultConfig: Config{
ChannelID: "bar", Project: "foo",
Token: "baz", ChannelID: "bar",
Token: "baz",
},
Overrides: []Override{ Overrides: []Override{
{ {
ChannelID: "foobar", Config: Config{ChannelID: "foobar"},
Group: "group", Group: "group",
}, },
}, },
} }
if !providerWithValidOverride.IsValid() { if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
@@ -77,7 +79,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -87,7 +89,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -97,7 +99,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -107,7 +109,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -120,10 +122,10 @@ 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: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.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},
}, },
@@ -146,51 +148,52 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
scenarios := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
Endpoint core.Endpoint Endpoint endpoint.Endpoint
Alert alert.Alert Alert alert.Alert
Resolved bool Resolved bool
ExpectedBody string ExpectedBody string
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
Endpoint: core.Endpoint{Name: "name"}, Endpoint: endpoint.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: `{"channel":"id:","content":{"className":"ChatMessage.Block","style":"WARNING","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *name* has been triggered due to having failed 3 time(s) in a row"}]}}`, ExpectedBody: `{"channel":"id:1","content":{"className":"ChatMessage.Block","style":"WARNING","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *name* has been triggered due to having failed 3 time(s) in a row"}]}}`,
}, },
{ {
Name: "triggered-with-group", Name: "triggered-with-group",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
Endpoint: core.Endpoint{Name: "name", Group: "group"}, Endpoint: endpoint.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: `{"channel":"id:","content":{"className":"ChatMessage.Block","style":"WARNING","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *group/name* has been triggered due to having failed 3 time(s) in a row"}]}}`, ExpectedBody: `{"channel":"id:1","content":{"className":"ChatMessage.Block","style":"WARNING","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *group/name* has been triggered due to having failed 3 time(s) in a row"}]}}`,
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
Endpoint: core.Endpoint{Name: "name"}, Endpoint: endpoint.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: `{"channel":"id:","content":{"className":"ChatMessage.Block","style":"SUCCESS","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *name* has been resolved after passing successfully 5 time(s) in a row"}]}}`, ExpectedBody: `{"channel":"id:1","content":{"className":"ChatMessage.Block","style":"SUCCESS","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *name* has been resolved after passing successfully 5 time(s) in a row"}]}}`,
}, },
{ {
Name: "resolved-with-group", Name: "resolved-with-group",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
Endpoint: core.Endpoint{Name: "name", Group: "group"}, Endpoint: endpoint.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: `{"channel":"id:","content":{"className":"ChatMessage.Block","style":"SUCCESS","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row"}]}}`, ExpectedBody: `{"channel":"id:1","content":{"className":"ChatMessage.Block","style":"SUCCESS","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row"}]}}`,
}, },
} }
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(
&scenario.Provider.DefaultConfig,
&scenario.Endpoint, &scenario.Endpoint,
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.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},
}, },
@@ -217,62 +220,98 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
} }
} }
func TestAlertProvider_getChannelIDForGroup(t *testing.T) { func TestAlertProvider_GetConfig(t *testing.T) {
tests := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
InputGroup string InputGroup string
ExpectedOutput string InputAlert alert.Alert
ExpectedOutput Config
}{ }{
{ {
Name: "provider-no-override-specify-no-group-should-default", Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
ChannelID: "bar", DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
Overrides: nil,
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "bar", InputAlert: alert.Alert{},
ExpectedOutput: Config{ChannelID: "default", Project: "project", Token: "token"},
}, },
{ {
Name: "provider-no-override-specify-group-should-default", Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
ChannelID: "bar", DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
Overrides: nil,
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "bar", InputAlert: alert.Alert{},
ExpectedOutput: Config{ChannelID: "default", Project: "project", Token: "token"},
}, },
{ {
Name: "provider-with-override-specify-no-group-should-default", Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
ChannelID: "bar", DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
ChannelID: "foobar", Config: Config{ChannelID: "group-channel"},
}, },
}, },
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "bar", InputAlert: alert.Alert{},
ExpectedOutput: Config{ChannelID: "default", Project: "project", Token: "token"},
}, },
{ {
Name: "provider-with-override-specify-group-should-override", Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{ Provider: AlertProvider{
ChannelID: "bar", DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
ChannelID: "foobar", Config: Config{ChannelID: "group-channel"},
}, },
}, },
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "foobar", InputAlert: alert.Alert{},
ExpectedOutput: Config{ChannelID: "group-channel", Project: "project", Token: "token"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
Overrides: []Override{
{
Group: "group",
Config: Config{ChannelID: "group-channel"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"channel-id": "alert-channel", "project": "alert-project", "token": "alert-token"}},
ExpectedOutput: Config{ChannelID: "alert-channel", Project: "alert-project", Token: "alert-token"},
}, },
} }
for _, tt := range tests { for _, scenario := range scenarios {
t.Run(tt.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
if got := tt.Provider.getChannelIDForGroup(tt.InputGroup); got != tt.ExpectedOutput { got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
t.Errorf("AlertProvider.getChannelIDForGroup() = %v, want %v", got, tt.ExpectedOutput) if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.ChannelID != scenario.ExpectedOutput.ChannelID {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.ChannelID, got.ChannelID)
}
if got.Project != scenario.ExpectedOutput.Project {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.Project, got.Project)
}
if got.Token != scenario.ExpectedOutput.Token {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.Token, got.Token)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
} }
}) })
} }
+104 -53
View File
@@ -3,6 +3,7 @@ package matrix
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"math/rand" "math/rand"
@@ -12,30 +13,19 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
) )
// AlertProvider is the configuration necessary for sending an alert using Matrix
type AlertProvider struct {
ProviderConfig `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"`
ProviderConfig `yaml:",inline"`
}
const defaultServerURL = "https://matrix-client.matrix.org" const defaultServerURL = "https://matrix-client.matrix.org"
type ProviderConfig struct { var (
ErrAccessTokenNotSet = errors.New("access-token not set")
ErrInternalRoomID = errors.New("internal-room-id not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
// ServerURL is the custom homeserver to use (optional) // ServerURL is the custom homeserver to use (optional)
ServerURL string `yaml:"server-url"` ServerURL string `yaml:"server-url"`
@@ -46,36 +36,78 @@ type ProviderConfig struct {
InternalRoomID string `yaml:"internal-room-id"` InternalRoomID string `yaml:"internal-room-id"`
} }
// IsValid returns whether the provider's configuration is valid func (cfg *Config) Validate() error {
func (provider *AlertProvider) IsValid() bool { if len(cfg.ServerURL) == 0 {
cfg.ServerURL = defaultServerURL
}
if len(cfg.AccessToken) == 0 {
return ErrAccessTokenNotSet
}
if len(cfg.InternalRoomID) == 0 {
return ErrInternalRoomID
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.ServerURL) > 0 {
cfg.ServerURL = override.ServerURL
}
if len(override.AccessToken) > 0 {
cfg.AccessToken = override.AccessToken
}
if len(override.InternalRoomID) > 0 {
cfg.InternalRoomID = override.InternalRoomID
}
}
// AlertProvider is the configuration necessary for sending an alert using Matrix
type AlertProvider struct {
DefaultConfig Config `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"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool) registeredGroups := make(map[string]bool)
if provider.Overrides != nil { if provider.Overrides != nil {
for _, override := range provider.Overrides { for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.AccessToken) == 0 || len(override.InternalRoomID) == 0 { if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.AccessToken) == 0 || len(override.InternalRoomID) == 0 {
return false return ErrDuplicateGroupOverride
} }
registeredGroups[override.Group] = true registeredGroups[override.Group] = true
} }
} }
return len(provider.AccessToken) > 0 && len(provider.InternalRoomID) > 0 return provider.DefaultConfig.Validate()
} }
// 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(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) cfg, err := provider.GetConfig(ep.Group, alert)
config := provider.getConfigForGroup(endpoint.Group) if err != nil {
if config.ServerURL == "" { return err
config.ServerURL = defaultServerURL
} }
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
// The Matrix endpoint requires a unique transaction ID for each event sent // The Matrix endpoint requires a unique transaction ID for each event sent
txnId := randStringBytes(24) txnId := randStringBytes(24)
request, err := http.NewRequest( request, err := http.NewRequest(
http.MethodPut, http.MethodPut,
fmt.Sprintf("%s/_matrix/client/v3/rooms/%s/send/m.room.message/%s?access_token=%s", fmt.Sprintf("%s/_matrix/client/v3/rooms/%s/send/m.room.message/%s?access_token=%s",
config.ServerURL, cfg.ServerURL,
url.PathEscape(config.InternalRoomID), url.PathEscape(cfg.InternalRoomID),
txnId, txnId,
url.QueryEscape(config.AccessToken), url.QueryEscape(cfg.AccessToken),
), ),
buffer, buffer,
) )
@@ -103,23 +135,23 @@ type Body struct {
} }
// 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) []byte { func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
body, _ := json.Marshal(Body{ body, _ := json.Marshal(Body{
MsgType: "m.text", MsgType: "m.text",
Format: "org.matrix.custom.html", Format: "org.matrix.custom.html",
Body: buildPlaintextMessageBody(endpoint, alert, result, resolved), Body: buildPlaintextMessageBody(ep, alert, result, resolved),
FormattedBody: buildHTMLMessageBody(endpoint, alert, result, resolved), FormattedBody: buildHTMLMessageBody(ep, alert, result, resolved),
}) })
return body return body
} }
// buildPlaintextMessageBody builds the message body in plaintext to include in request // 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 { func buildPlaintextMessageBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
var message string var message 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", ep.DisplayName(), alert.SuccessThreshold)
} else { } 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) message = fmt.Sprintf("An alert for `%s` has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
} }
var formattedConditionResults string var formattedConditionResults string
for _, conditionResult := range result.ConditionResults { for _, conditionResult := range result.ConditionResults {
@@ -139,12 +171,12 @@ func buildPlaintextMessageBody(endpoint *core.Endpoint, alert *alert.Alert, resu
} }
// buildHTMLMessageBody builds the message body in HTML to include in request // 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 { func buildHTMLMessageBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
var message string var message string
if resolved { 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) message = fmt.Sprintf("An alert for <code>%s</code> has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else { } 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) message = fmt.Sprintf("An alert for <code>%s</code> has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
} }
var formattedConditionResults string var formattedConditionResults string
if len(result.ConditionResults) > 0 { if len(result.ConditionResults) > 0 {
@@ -167,18 +199,6 @@ func buildHTMLMessageBody(endpoint *core.Endpoint, alert *alert.Alert, result *c
return fmt.Sprintf("<h3>%s</h3>%s%s", message, description, formattedConditionResults) return fmt.Sprintf("<h3>%s</h3>%s%s", message, description, formattedConditionResults)
} }
// getConfigForGroup returns the appropriate configuration for a given group
func (provider *AlertProvider) getConfigForGroup(group string) ProviderConfig {
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
return override.ProviderConfig
}
}
}
return provider.ProviderConfig
}
func randStringBytes(n int) string { func randStringBytes(n int) string {
// All the compatible characters to use in a transaction ID // All the compatible characters to use in a transaction ID
const availableCharacterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" const availableCharacterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
@@ -194,3 +214,34 @@ func randStringBytes(n int) string {
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert
} }
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
+105 -49
View File
@@ -7,79 +7,79 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{ invalidProvider := AlertProvider{
ProviderConfig: ProviderConfig{ DefaultConfig: Config{
AccessToken: "", AccessToken: "",
InternalRoomID: "", InternalRoomID: "",
}, },
} }
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{ validProvider := AlertProvider{
ProviderConfig: ProviderConfig{ DefaultConfig: Config{
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
}, },
} }
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
validProviderWithHomeserver := AlertProvider{ validProviderWithHomeserver := AlertProvider{
ProviderConfig: ProviderConfig{ DefaultConfig: Config{
ServerURL: "https://example.com", ServerURL: "https://example.com",
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
}, },
} }
if !validProviderWithHomeserver.IsValid() { if err := validProviderWithHomeserver.Validate(); err != nil {
t.Error("provider with homeserver should've been valid") t.Error("provider with homeserver should've been valid")
} }
} }
func TestAlertProvider_IsValidWithOverride(t *testing.T) { func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{ providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
Group: "", Group: "",
ProviderConfig: ProviderConfig{ Config: Config{
AccessToken: "", AccessToken: "",
InternalRoomID: "", InternalRoomID: "",
}, },
}, },
}, },
} }
if providerWithInvalidOverrideGroup.IsValid() { if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid") t.Error("provider Group shouldn't have been valid")
} }
providerWithInvalidOverrideTo := AlertProvider{ providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
ProviderConfig: ProviderConfig{ Config: Config{
AccessToken: "", AccessToken: "",
InternalRoomID: "", InternalRoomID: "",
}, },
}, },
}, },
} }
if providerWithInvalidOverrideTo.IsValid() { if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid") t.Error("provider integration key shouldn't have been valid")
} }
providerWithValidOverride := AlertProvider{ providerWithValidOverride := AlertProvider{
ProviderConfig: ProviderConfig{ DefaultConfig: Config{
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
}, },
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
ProviderConfig: ProviderConfig{ Config: Config{
ServerURL: "https://example.com", ServerURL: "https://example.com",
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
@@ -87,7 +87,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) {
}, },
}, },
} }
if !providerWithValidOverride.IsValid() { if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
@@ -105,18 +105,28 @@ func TestAlertProvider_Send(t *testing.T) {
ExpectedError bool ExpectedError bool
}{ }{
{ {
Name: "triggered", Name: "triggered-with-bad-config",
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,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody} return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}), }),
ExpectedError: true,
},
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{AccessToken: "1", InternalRoomID: "!a:example.com"}},
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, ExpectedError: false,
}, },
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{AccessToken: "1", InternalRoomID: "!a:example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -126,7 +136,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{AccessToken: "1", InternalRoomID: "!a:example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -136,7 +146,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{AccessToken: "1", InternalRoomID: "!a:example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -149,10 +159,10 @@ 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: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.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},
}, },
@@ -197,10 +207,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
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"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.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},
}, },
@@ -227,17 +237,18 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
} }
} }
func TestAlertProvider_getConfigForGroup(t *testing.T) { func TestAlertProvider_GetConfig(t *testing.T) {
tests := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
InputGroup string InputGroup string
ExpectedOutput ProviderConfig InputAlert alert.Alert
ExpectedOutput Config
}{ }{
{ {
Name: "provider-no-override-specify-no-group-should-default", Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
ProviderConfig: ProviderConfig{ DefaultConfig: Config{
ServerURL: "https://example.com", ServerURL: "https://example.com",
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
@@ -245,7 +256,8 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
Overrides: nil, Overrides: nil,
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: ProviderConfig{ InputAlert: alert.Alert{},
ExpectedOutput: Config{
ServerURL: "https://example.com", ServerURL: "https://example.com",
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
@@ -254,7 +266,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
{ {
Name: "provider-no-override-specify-group-should-default", Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
ProviderConfig: ProviderConfig{ DefaultConfig: Config{
ServerURL: "https://example.com", ServerURL: "https://example.com",
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
@@ -262,7 +274,8 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
Overrides: nil, Overrides: nil,
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: ProviderConfig{ InputAlert: alert.Alert{},
ExpectedOutput: Config{
ServerURL: "https://example.com", ServerURL: "https://example.com",
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
@@ -271,7 +284,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
{ {
Name: "provider-with-override-specify-no-group-should-default", Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
ProviderConfig: ProviderConfig{ DefaultConfig: Config{
ServerURL: "https://example.com", ServerURL: "https://example.com",
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
@@ -279,16 +292,17 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
ProviderConfig: ProviderConfig{ Config: Config{
ServerURL: "https://example01.com", ServerURL: "https://group-example.com",
AccessToken: "12", AccessToken: "12",
InternalRoomID: "!a:example01.com", InternalRoomID: "!a:group-example.com",
}, },
}, },
}, },
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: ProviderConfig{ InputAlert: alert.Alert{},
ExpectedOutput: Config{
ServerURL: "https://example.com", ServerURL: "https://example.com",
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
@@ -297,7 +311,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
{ {
Name: "provider-with-override-specify-group-should-override", Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{ Provider: AlertProvider{
ProviderConfig: ProviderConfig{ DefaultConfig: Config{
ServerURL: "https://example.com", ServerURL: "https://example.com",
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
@@ -305,8 +319,35 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
ProviderConfig: ProviderConfig{ Config: Config{
ServerURL: "https://example01.com", ServerURL: "https://group-example.com",
AccessToken: "12",
InternalRoomID: "!a:group-example.com",
},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{
ServerURL: "https://group-example.com",
AccessToken: "12",
InternalRoomID: "!a:group-example.com",
},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
Overrides: []Override{
{
Group: "group",
Config: Config{
ServerURL: "https://group-example.com",
AccessToken: "12", AccessToken: "12",
InternalRoomID: "!a:example01.com", InternalRoomID: "!a:example01.com",
}, },
@@ -314,17 +355,32 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
}, },
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: ProviderConfig{ InputAlert: alert.Alert{ProviderOverride: map[string]any{"server-url": "https://alert-example.com", "access-token": "123", "internal-room-id": "!a:alert-example.com"}},
ServerURL: "https://example01.com", ExpectedOutput: Config{
AccessToken: "12", ServerURL: "https://alert-example.com",
InternalRoomID: "!a:example01.com", AccessToken: "123",
InternalRoomID: "!a:alert-example.com",
}, },
}, },
} }
for _, tt := range tests { for _, scenario := range scenarios {
t.Run(tt.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
if got := tt.Provider.getConfigForGroup(tt.InputGroup); got != tt.ExpectedOutput { outputConfig, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
t.Errorf("AlertProvider.getConfigForGroup() = %v, want %v", got, tt.ExpectedOutput) if err != nil {
t.Errorf("expected no error, got %v", err)
}
if outputConfig.ServerURL != scenario.ExpectedOutput.ServerURL {
t.Errorf("expected ServerURL to be %s, got %s", scenario.ExpectedOutput.ServerURL, outputConfig.ServerURL)
}
if outputConfig.AccessToken != scenario.ExpectedOutput.AccessToken {
t.Errorf("expected AccessToken to be %s, got %s", scenario.ExpectedOutput.AccessToken, outputConfig.AccessToken)
}
if outputConfig.InternalRoomID != scenario.ExpectedOutput.InternalRoomID {
t.Errorf("expected InternalRoomID to be %s, got %s", scenario.ExpectedOutput.InternalRoomID, outputConfig.InternalRoomID)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
} }
}) })
} }
+84 -33
View File
@@ -3,21 +3,50 @@ package mattermost
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
) )
var (
ErrWebhookURLNotSet = errors.New("webhook URL not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
WebhookURL string `yaml:"webhook-url"`
Channel string `yaml:"channel,omitempty"`
ClientConfig *client.Config `yaml:"client,omitempty"`
}
func (cfg *Config) Validate() error {
if len(cfg.WebhookURL) == 0 {
return ErrWebhookURLNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if override.ClientConfig != nil {
cfg.ClientConfig = override.ClientConfig
}
if len(override.WebhookURL) > 0 {
cfg.WebhookURL = override.WebhookURL
}
if len(override.Channel) > 0 {
cfg.Channel = override.Channel
}
}
// AlertProvider is the configuration necessary for sending an alert using Mattermost // AlertProvider is the configuration necessary for sending an alert using Mattermost
type AlertProvider struct { type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"` DefaultConfig Config `yaml:",inline"`
// 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"`
@@ -28,36 +57,37 @@ type AlertProvider struct {
// Override is a case under which the default integration is overridden // Override is a case under which the default integration is overridden
type Override struct { type Override struct {
Group string `yaml:"group"` Group string `yaml:"group"`
WebhookURL string `yaml:"webhook-url"` Config `yaml:",inline"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
if provider.ClientConfig == nil {
provider.ClientConfig = client.GetDefaultConfig()
}
if provider.Overrides != nil { if provider.Overrides != nil {
registeredGroups := make(map[string]bool) registeredGroups := make(map[string]bool)
for _, override := range provider.Overrides { for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 { if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
return false return ErrDuplicateGroupOverride
} }
registeredGroups[override.Group] = true registeredGroups[override.Group] = true
} }
} }
return len(provider.WebhookURL) > 0 return provider.DefaultConfig.Validate()
} }
// 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(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved))) cfg, err := provider.GetConfig(ep.Group, alert)
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer) if err != nil {
return err
}
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(cfg, ep, alert, result, resolved)))
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, 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(provider.ClientConfig).Do(request) response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
if err != nil { if err != nil {
return err return err
} }
@@ -70,6 +100,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
} }
type Body struct { type Body struct {
Channel string `json:"channel,omitempty"` // Optional channel override
Text string `json:"text"` Text string `json:"text"`
Username string `json:"username"` Username string `json:"username"`
IconURL string `json:"icon_url"` IconURL string `json:"icon_url"`
@@ -92,13 +123,13 @@ type Field struct {
} }
// 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) []byte { func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.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", ep.DisplayName(), alert.SuccessThreshold)
color = "#36A64F" color = "#36A64F"
} else { } else {
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold) message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
color = "#DD0000" color = "#DD0000"
} }
var formattedConditionResults string var formattedConditionResults string
@@ -118,6 +149,7 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
description = ":\n> " + alertDescription description = ":\n> " + alertDescription
} }
body := Body{ body := Body{
Channel: cfg.Channel,
Text: "", Text: "",
Username: "gatus", Username: "gatus",
IconURL: "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png", IconURL: "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png",
@@ -142,19 +174,38 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
return bodyAsJSON return bodyAsJSON
} }
// 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
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert
} }
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
+77 -52
View File
@@ -7,58 +7,54 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{WebhookURL: ""} invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{WebhookURL: "http://example.com"} validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
func TestAlertProvider_IsValidWithOverride(t *testing.T) { func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{ providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "http://example.com", Config: Config{WebhookURL: "http://example.com"},
Group: "", Group: "",
}, },
}, },
} }
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
if providerWithInvalidOverrideGroup.IsValid() {
t.Error("provider Group shouldn't have been valid") t.Error("provider Group shouldn't have been valid")
} }
providerWithInvalidOverrideWebHookUrl := AlertProvider{ providerWithInvalidOverrideWebHookUrl := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
Config: Config{WebhookURL: ""},
WebhookURL: "", Group: "group",
Group: "group",
}, },
}, },
} }
if providerWithInvalidOverrideWebHookUrl.IsValid() { if err := providerWithInvalidOverrideWebHookUrl.Validate(); err == nil {
t.Error("provider WebHookURL shouldn't have been valid") t.Error("provider WebHookURL shouldn't have been valid")
} }
providerWithValidOverride := AlertProvider{ providerWithValidOverride := AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "http://example.com", Config: Config{WebhookURL: "http://example.com"},
Group: "group", Group: "group",
}, },
}, },
} }
if !providerWithValidOverride.IsValid() { if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
@@ -77,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -87,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -97,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -107,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -120,10 +116,10 @@ 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: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.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},
}, },
@@ -168,10 +164,11 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
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.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.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},
}, },
@@ -198,64 +195,92 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
} }
} }
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) { func TestAlertProvider_GetConfig(t *testing.T) {
tests := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
InputGroup string InputGroup string
ExpectedOutput string InputAlert alert.Alert
ExpectedOutput Config
}{ }{
{ {
Name: "provider-no-override-specify-no-group-should-default", Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-no-override-specify-group-should-default", Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-with-override-specify-no-group-should-default", Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
WebhookURL: "http://example01.com", Config: Config{WebhookURL: "http://example01.com"},
}, },
}, },
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-with-override-specify-group-should-override", Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
WebhookURL: "http://example01.com", Config: Config{WebhookURL: "http://group-example.com"},
}, },
}, },
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "http://example01.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://group-example.com"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
ExpectedOutput: Config{WebhookURL: "http://alert-example.com"},
}, },
} }
for _, tt := range tests { for _, scenario := range scenarios {
t.Run(tt.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput { got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput) if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
} }
}) })
} }
+77 -16
View File
@@ -3,44 +3,83 @@ package messagebird
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
) )
const ( const restAPIURL = "https://rest.messagebird.com/messages"
restAPIURL = "https://rest.messagebird.com/messages"
var (
ErrorAccessKeyNotSet = errors.New("access-key not set")
ErrorOriginatorNotSet = errors.New("originator not set")
ErrorRecipientsNotSet = errors.New("recipients not set")
) )
// AlertProvider is the configuration necessary for sending an alert using Messagebird type Config struct {
type AlertProvider struct {
AccessKey string `yaml:"access-key"` AccessKey string `yaml:"access-key"`
Originator string `yaml:"originator"` Originator string `yaml:"originator"`
Recipients string `yaml:"recipients"` Recipients string `yaml:"recipients"`
}
func (cfg *Config) Validate() error {
if len(cfg.AccessKey) == 0 {
return ErrorAccessKeyNotSet
}
if len(cfg.Originator) == 0 {
return ErrorOriginatorNotSet
}
if len(cfg.Recipients) == 0 {
return ErrorRecipientsNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.AccessKey) > 0 {
cfg.AccessKey = override.AccessKey
}
if len(override.Originator) > 0 {
cfg.Originator = override.Originator
}
if len(override.Recipients) > 0 {
cfg.Recipients = override.Recipients
}
}
// AlertProvider is the configuration necessary for sending an alert using Messagebird
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// 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"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
return len(provider.AccessKey) > 0 && len(provider.Originator) > 0 && len(provider.Recipients) > 0 return provider.DefaultConfig.Validate()
} }
// 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(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, 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
} }
request.Header.Set("Content-Type", "application/json") request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", fmt.Sprintf("AccessKey %s", provider.AccessKey)) request.Header.Set("Authorization", fmt.Sprintf("AccessKey %s", cfg.AccessKey))
response, err := client.GetHTTPClient(nil).Do(request) response, err := client.GetHTTPClient(nil).Do(request)
if err != nil { if err != nil {
return err return err
@@ -60,16 +99,16 @@ type Body struct {
} }
// 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) []byte { func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.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", ep.DisplayName(), alert.GetDescription())
} else { } else {
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.DisplayName(), alert.GetDescription()) message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
} }
body, _ := json.Marshal(Body{ body, _ := json.Marshal(Body{
Originator: provider.Originator, Originator: cfg.Originator,
Recipients: provider.Recipients, Recipients: cfg.Recipients,
Body: message, Body: message,
}) })
return body return body
@@ -79,3 +118,25 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert
} }
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
@@ -7,21 +7,23 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestMessagebirdAlertProvider_IsValid(t *testing.T) { func TestMessagebirdAlertProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{} invalidProvider := AlertProvider{}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{ validProvider := AlertProvider{
AccessKey: "1", DefaultConfig: Config{
Originator: "1", AccessKey: "1",
Recipients: "1", Originator: "1",
Recipients: "1",
},
} }
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
@@ -40,7 +42,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{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,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -50,7 +52,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{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,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -60,7 +62,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -70,7 +72,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -83,10 +85,10 @@ 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: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.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},
}, },
@@ -115,14 +117,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{AccessKey: "1", Originator: "2", Recipients: "3"}, Provider: AlertProvider{DefaultConfig: Config{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: "{\"originator\":\"2\",\"recipients\":\"3\",\"body\":\"TRIGGERED: endpoint-name - description-1\"}", 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{DefaultConfig: Config{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: "{\"originator\":\"5\",\"recipients\":\"6\",\"body\":\"RESOLVED: endpoint-name - description-2\"}", ExpectedBody: "{\"originator\":\"5\",\"recipients\":\"6\",\"body\":\"RESOLVED: endpoint-name - description-2\"}",
@@ -131,10 +133,11 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
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.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.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},
}, },
@@ -145,7 +148,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
t.Errorf("expected:\n%s\ngot:\n%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())
} }
}) })
@@ -160,3 +163,50 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
t.Error("expected default alert to be nil") t.Error("expected default alert to be nil")
} }
} }
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-should-default",
Provider: AlertProvider{
DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"},
},
InputAlert: alert.Alert{},
ExpectedOutput: Config{AccessKey: "1", Originator: "2", Recipients: "3"},
},
{
Name: "provider-with-alert-override",
Provider: AlertProvider{
DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"},
},
InputAlert: alert.Alert{ProviderOverride: map[string]any{"access-key": "4", "originator": "5", "recipients": "6"}},
ExpectedOutput: Config{AccessKey: "4", Originator: "5", Recipients: "6"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
if err != nil {
t.Error("expected no error, got:", err.Error())
}
if got.AccessKey != scenario.ExpectedOutput.AccessKey {
t.Errorf("expected access key to be %s, got %s", scenario.ExpectedOutput.AccessKey, got.AccessKey)
}
if got.Originator != scenario.ExpectedOutput.Originator {
t.Errorf("expected originator to be %s, got %s", scenario.ExpectedOutput.Originator, got.Originator)
}
if got.Recipients != scenario.ExpectedOutput.Recipients {
t.Errorf("expected recipients to be %s, got %s", scenario.ExpectedOutput.Recipients, got.Recipients)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
}
})
}
}
+154 -26
View File
@@ -3,6 +3,7 @@ package ntfy
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -11,50 +12,142 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
) )
const ( const (
DefaultURL = "https://ntfy.sh" DefaultURL = "https://ntfy.sh"
DefaultPriority = 3 DefaultPriority = 3
TokenPrefix = "tk_"
) )
var (
ErrInvalidToken = errors.New("invalid token")
ErrTopicNotSet = errors.New("topic not set")
ErrInvalidPriority = errors.New("priority must between 1 and 5 inclusively")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
Topic string `yaml:"topic"`
URL string `yaml:"url,omitempty"` // Defaults to DefaultURL
Priority int `yaml:"priority,omitempty"` // Defaults to DefaultPriority
Token string `yaml:"token,omitempty"` // Defaults to ""
Email string `yaml:"email,omitempty"` // Defaults to ""
Click string `yaml:"click,omitempty"` // Defaults to ""
DisableFirebase bool `yaml:"disable-firebase,omitempty"` // Defaults to false
DisableCache bool `yaml:"disable-cache,omitempty"` // Defaults to false
}
func (cfg *Config) Validate() error {
if len(cfg.URL) == 0 {
cfg.URL = DefaultURL
}
if cfg.Priority == 0 {
cfg.Priority = DefaultPriority
}
if len(cfg.Token) > 0 && !strings.HasPrefix(cfg.Token, TokenPrefix) {
return ErrInvalidToken
}
if len(cfg.Topic) == 0 {
return ErrTopicNotSet
}
if cfg.Priority < 1 || cfg.Priority > 5 {
return ErrInvalidPriority
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.Topic) > 0 {
cfg.Topic = override.Topic
}
if len(override.URL) > 0 {
cfg.URL = override.URL
}
if override.Priority > 0 {
cfg.Priority = override.Priority
}
if len(override.Token) > 0 {
cfg.Token = override.Token
}
if len(override.Email) > 0 {
cfg.Email = override.Email
}
if len(override.Click) > 0 {
cfg.Click = override.Click
}
if override.DisableFirebase {
cfg.DisableFirebase = true
}
if override.DisableCache {
cfg.DisableCache = true
}
}
// 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 {
Topic string `yaml:"topic"` DefaultConfig Config `yaml:",inline"`
URL string `yaml:"url,omitempty"` // Defaults to DefaultURL
Priority int `yaml:"priority,omitempty"` // Defaults to DefaultPriority
Token string `yaml:"token,omitempty"` // Defaults to ""
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,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"`
} }
// IsValid returns whether the provider's configuration is valid // Override is a case under which the default integration is overridden
func (provider *AlertProvider) IsValid() bool { type Override struct {
if len(provider.URL) == 0 { Group string `yaml:"group"`
provider.URL = DefaultURL Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if len(override.Group) == 0 {
return ErrDuplicateGroupOverride
}
if _, ok := registeredGroups[override.Group]; ok {
return ErrDuplicateGroupOverride
}
if len(override.Token) > 0 && !strings.HasPrefix(override.Token, TokenPrefix) {
return ErrDuplicateGroupOverride
}
if override.Priority < 0 || override.Priority >= 6 {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
} }
if provider.Priority == 0 {
provider.Priority = DefaultPriority return provider.DefaultConfig.Validate()
}
isTokenValid := true
if len(provider.Token) > 0 {
isTokenValid = strings.HasPrefix(provider.Token, "tk_")
}
return len(provider.URL) > 0 && len(provider.Topic) > 0 && provider.Priority > 0 && provider.Priority < 6 && isTokenValid
} }
// 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(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) cfg, err := provider.GetConfig(ep.Group, alert)
request, err := http.NewRequest(http.MethodPost, provider.URL, buffer) if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
url := cfg.URL
request, err := http.NewRequest(http.MethodPost, url, 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")
if len(provider.Token) > 0 { if token := cfg.Token; len(token) > 0 {
request.Header.Set("Authorization", "Bearer "+provider.Token) request.Header.Set("Authorization", "Bearer "+token)
}
if cfg.DisableFirebase {
request.Header.Set("Firebase", "no")
}
if cfg.DisableCache {
request.Header.Set("Cache", "no")
} }
response, err := client.GetHTTPClient(nil).Do(request) response, err := client.GetHTTPClient(nil).Do(request)
if err != nil { if err != nil {
@@ -74,10 +167,12 @@ type Body struct {
Message string `json:"message"` Message string `json:"message"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
Priority int `json:"priority"` Priority int `json:"priority"`
Email string `json:"email,omitempty"`
Click string `json:"click,omitempty"`
} }
// 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) []byte { func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message, formattedConditionResults, tag string var message, formattedConditionResults, tag string
if resolved { if resolved {
tag = "white_check_mark" tag = "white_check_mark"
@@ -100,11 +195,13 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
} }
message += formattedConditionResults message += formattedConditionResults
body, _ := json.Marshal(Body{ body, _ := json.Marshal(Body{
Topic: provider.Topic, Topic: cfg.Topic,
Title: "Gatus: " + endpoint.DisplayName(), Title: "Gatus: " + ep.DisplayName(),
Message: message, Message: message,
Tags: []string{tag}, Tags: []string{tag},
Priority: provider.Priority, Priority: cfg.Priority,
Email: cfg.Email,
Click: cfg.Click,
}) })
return body return body
} }
@@ -113,3 +210,34 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert
} }
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
+317 -17
View File
@@ -2,13 +2,16 @@ package ntfy
import ( import (
"encoding/json" "encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing" "testing"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
func TestAlertDefaultProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct { scenarios := []struct {
name string name string
provider AlertProvider provider AlertProvider
@@ -16,49 +19,78 @@ func TestAlertDefaultProvider_IsValid(t *testing.T) {
}{ }{
{ {
name: "valid", name: "valid",
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1}, provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1}},
expected: true, expected: true,
}, },
{ {
name: "no-url-should-use-default-value", name: "no-url-should-use-default-value",
provider: AlertProvider{Topic: "example", Priority: 1}, provider: AlertProvider{DefaultConfig: Config{Topic: "example", Priority: 1}},
expected: true, expected: true,
}, },
{ {
name: "valid-with-token", name: "valid-with-token",
provider: AlertProvider{Topic: "example", Priority: 1, Token: "tk_faketoken"}, provider: AlertProvider{DefaultConfig: Config{Topic: "example", Priority: 1, Token: "tk_faketoken"}},
expected: true, expected: true,
}, },
{ {
name: "invalid-token", name: "invalid-token",
provider: AlertProvider{Topic: "example", Priority: 1, Token: "xx_faketoken"}, provider: AlertProvider{DefaultConfig: Config{Topic: "example", Priority: 1, Token: "xx_faketoken"}},
expected: false, expected: false,
}, },
{ {
name: "invalid-topic", name: "invalid-topic",
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "", Priority: 1}, provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "", Priority: 1}},
expected: false, expected: false,
}, },
{ {
name: "invalid-priority-too-high", name: "invalid-priority-too-high",
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 6}, provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 6}},
expected: false, expected: false,
}, },
{ {
name: "invalid-priority-too-low", name: "invalid-priority-too-low",
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: -1}, provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: -1}},
expected: false, expected: false,
}, },
{ {
name: "no-priority-should-use-default-value", name: "no-priority-should-use-default-value",
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example"}, provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example"}},
expected: true,
},
{
name: "invalid-override-token",
provider: AlertProvider{DefaultConfig: Config{Topic: "example"}, Overrides: []Override{{Group: "g", Config: Config{Token: "xx_faketoken"}}}},
expected: false,
},
{
name: "invalid-override-priority",
provider: AlertProvider{DefaultConfig: Config{Topic: "example"}, Overrides: []Override{{Group: "g", Config: Config{Priority: 8}}}},
expected: false,
},
{
name: "no-override-group-name",
provider: AlertProvider{DefaultConfig: Config{Topic: "example"}, Overrides: []Override{{}}},
expected: false,
},
{
name: "duplicate-override-group-names",
provider: AlertProvider{DefaultConfig: Config{Topic: "example"}, Overrides: []Override{{Group: "g"}, {Group: "g"}}},
expected: false,
},
{
name: "valid-override",
provider: AlertProvider{DefaultConfig: Config{Topic: "example"}, Overrides: []Override{{Group: "g1", Config: Config{Priority: 4, Click: "https://example.com"}}, {Group: "g2", Config: Config{Topic: "Example", Token: "tk_faketoken"}}}},
expected: true, expected: true,
}, },
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) { t.Run(scenario.name, func(t *testing.T) {
if scenario.provider.IsValid() != scenario.expected { err := scenario.provider.Validate()
t.Errorf("expected %t, got %t", scenario.expected, scenario.provider.IsValid()) if scenario.expected && err != nil {
t.Error("expected no error, got", err.Error())
}
if !scenario.expected && err == nil {
t.Error("expected error, got none")
} }
}) })
} }
@@ -76,26 +108,59 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1}, Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1}`, ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1}`,
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 2}, Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 2}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\n🟢 [CONNECTED] == true\n🟢 [STATUS] == 200","tags":["white_check_mark"],"priority":2}`, ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\n🟢 [CONNECTED] == true\n🟢 [STATUS] == 200","tags":["white_check_mark"],"priority":2}`,
}, },
{
Name: "triggered-email",
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
},
{
Name: "resolved-email",
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 2, Email: "test@example.com", Click: "example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\n🟢 [CONNECTED] == true\n🟢 [STATUS] == 200","tags":["white_check_mark"],"priority":2,"email":"test@example.com","click":"example.com"}`,
},
{
Name: "group-override",
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 5, Email: "test@example.com", Click: "example.com"}, Overrides: []Override{{Group: "g", Config: Config{Topic: "group-topic", Priority: 4, Email: "override@test.com", Click: "test.com"}}}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: `{"topic":"group-topic","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":4,"email":"override@test.com","click":"test.com"}`,
},
{
Name: "alert-override",
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 5, Email: "test@example.com", Click: "example.com"}, Overrides: []Override{{Group: "g", Config: Config{Topic: "group-topic", Priority: 4, Email: "override@test.com", Click: "test.com"}}}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3, ProviderOverride: map[string]any{"topic": "alert-topic"}},
Resolved: false,
ExpectedBody: `{"topic":"alert-topic","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":4,"email":"override@test.com","click":"test.com"}`,
},
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
cfg, err := scenario.Provider.GetConfig("g", &scenario.Alert)
if err != nil {
t.Error("expected no error, got", err.Error())
}
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"}, cfg,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.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},
}, },
@@ -112,3 +177,238 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}) })
} }
} }
func TestAlertProvider_Send(t *testing.T) {
description := "description-1"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
Group string
ExpectedBody string
ExpectedHeaders map[string]string
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
Group: "",
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
ExpectedHeaders: map[string]string{
"Content-Type": "application/json",
},
},
{
Name: "token",
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", Token: "tk_mytoken"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
Group: "",
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
ExpectedHeaders: map[string]string{
"Content-Type": "application/json",
"Authorization": "Bearer tk_mytoken",
},
},
{
Name: "no firebase",
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", DisableFirebase: true}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
Group: "",
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
ExpectedHeaders: map[string]string{
"Content-Type": "application/json",
"Firebase": "no",
},
},
{
Name: "no cache",
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", DisableCache: true}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
Group: "",
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
ExpectedHeaders: map[string]string{
"Content-Type": "application/json",
"Cache": "no",
},
},
{
Name: "neither firebase & cache",
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", DisableFirebase: true, DisableCache: true}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
Group: "",
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
ExpectedHeaders: map[string]string{
"Content-Type": "application/json",
"Firebase": "no",
"Cache": "no",
},
},
{
Name: "overrides",
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", Token: "tk_mytoken"}, Overrides: []Override{Override{Group: "other-group", Config: Config{URL: "https://example.com", Token: "tk_othertoken"}}, Override{Group: "test-group", Config: Config{Token: "tk_test_token"}}}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
Group: "test-group",
ExpectedBody: `{"topic":"example","title":"Gatus: test-group/endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
ExpectedHeaders: map[string]string{
"Content-Type": "application/json",
"Authorization": "Bearer tk_test_token",
},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
// Start a local HTTP server
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
// Test request parameters
for header, value := range scenario.ExpectedHeaders {
if value != req.Header.Get(header) {
t.Errorf("expected: %s, got: %s", value, req.Header.Get(header))
}
}
body, _ := io.ReadAll(req.Body)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
// Send response to be tested
rw.Write([]byte(`OK`))
}))
// Close the server when test finishes
defer server.Close()
scenario.Provider.DefaultConfig.URL = server.URL
err := scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name", Group: scenario.Group},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if err != nil {
t.Error("Encountered an error on Send: ", err)
}
})
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
Overrides: nil,
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
Overrides: []Override{
{
Group: "group",
Config: Config{URL: "https://group-example.com", Topic: "group-topic", Priority: 2},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
Overrides: []Override{
{
Group: "group",
Config: Config{URL: "https://group-example.com", Topic: "group-topic", Priority: 2},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "https://group-example.com", Topic: "group-topic", Priority: 2},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
Overrides: []Override{
{
Group: "group",
Config: Config{URL: "https://group-example.com", Topic: "group-topic", Priority: 2},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"url": "http://alert-example.com", "topic": "alert-topic", "priority": 3}},
ExpectedOutput: Config{URL: "http://alert-example.com", Topic: "alert-topic", Priority: 3},
},
{
Name: "provider-with-partial-overrides",
Provider: AlertProvider{
DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
Overrides: []Override{
{
Group: "group",
Config: Config{Topic: "group-topic"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"priority": 3}},
ExpectedOutput: Config{URL: "https://ntfy.sh", Topic: "group-topic", Priority: 3},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.URL != scenario.ExpectedOutput.URL {
t.Errorf("expected url %s, got %s", scenario.ExpectedOutput.URL, got.URL)
}
if got.Topic != scenario.ExpectedOutput.Topic {
t.Errorf("expected topic %s, got %s", scenario.ExpectedOutput.Topic, got.Topic)
}
if got.Priority != scenario.ExpectedOutput.Priority {
t.Errorf("expected priority %d, got %d", scenario.ExpectedOutput.Priority, got.Priority)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
}
})
}
}
+116 -72
View File
@@ -3,6 +3,7 @@ package opsgenie
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -11,14 +12,19 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
) )
const ( const (
restAPI = "https://api.opsgenie.com/v2/alerts" restAPI = "https://api.opsgenie.com/v2/alerts"
) )
type AlertProvider struct { var (
ErrAPIKeyNotSet = errors.New("api-key not set")
)
type Config struct {
// APIKey to use for // APIKey to use for
APIKey string `yaml:"api-key"` APIKey string `yaml:"api-key"`
@@ -46,26 +52,74 @@ type AlertProvider struct {
// //
// default: [] // default: []
Tags []string `yaml:"tags"` Tags []string `yaml:"tags"`
}
func (cfg *Config) Validate() error {
if len(cfg.APIKey) == 0 {
return ErrAPIKeyNotSet
}
if len(cfg.Source) == 0 {
cfg.Source = "gatus"
}
if len(cfg.EntityPrefix) == 0 {
cfg.EntityPrefix = "gatus-"
}
if len(cfg.AliasPrefix) == 0 {
cfg.AliasPrefix = "gatus-healthcheck-"
}
if len(cfg.Priority) == 0 {
cfg.Priority = "P1"
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.APIKey) > 0 {
cfg.APIKey = override.APIKey
}
if len(override.Priority) > 0 {
cfg.Priority = override.Priority
}
if len(override.Source) > 0 {
cfg.Source = override.Source
}
if len(override.EntityPrefix) > 0 {
cfg.EntityPrefix = override.EntityPrefix
}
if len(override.AliasPrefix) > 0 {
cfg.AliasPrefix = override.AliasPrefix
}
if len(override.Tags) > 0 {
cfg.Tags = override.Tags
}
}
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// 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"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
return len(provider.APIKey) > 0 return provider.DefaultConfig.Validate()
} }
// Send an alert using the provider // Send an alert using the provider
// //
// Relevant: https://docs.opsgenie.com/docs/alert-api // Relevant: https://docs.opsgenie.com/docs/alert-api
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
err := provider.createAlert(endpoint, alert, result, resolved) cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
err = provider.sendAlertRequest(cfg, ep, alert, result, resolved)
if err != nil { if err != nil {
return err return err
} }
if resolved { if resolved {
err = provider.closeAlert(endpoint, alert) err = provider.closeAlert(cfg, ep, alert)
if err != nil { if err != nil {
return err return err
} }
@@ -75,24 +129,24 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
// The alert has been resolved and there's no error, so we can clear the alert's ResolveKey // The alert has been resolved and there's no error, so we can clear the alert's ResolveKey
alert.ResolveKey = "" alert.ResolveKey = ""
} else { } else {
alert.ResolveKey = provider.alias(buildKey(endpoint)) alert.ResolveKey = cfg.AliasPrefix + buildKey(ep)
} }
} }
return nil return nil
} }
func (provider *AlertProvider) createAlert(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) sendAlertRequest(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
payload := provider.buildCreateRequestBody(endpoint, alert, result, resolved) payload := provider.buildCreateRequestBody(cfg, ep, alert, result, resolved)
return provider.sendRequest(restAPI, http.MethodPost, payload) return provider.sendRequest(cfg, restAPI, http.MethodPost, payload)
} }
func (provider *AlertProvider) closeAlert(endpoint *core.Endpoint, alert *alert.Alert) error { func (provider *AlertProvider) closeAlert(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert) error {
payload := provider.buildCloseRequestBody(endpoint, alert) payload := provider.buildCloseRequestBody(ep, alert)
url := restAPI + "/" + provider.alias(buildKey(endpoint)) + "/close?identifierType=alias" url := restAPI + "/" + cfg.AliasPrefix + buildKey(ep) + "/close?identifierType=alias"
return provider.sendRequest(url, http.MethodPost, payload) return provider.sendRequest(cfg, url, http.MethodPost, payload)
} }
func (provider *AlertProvider) sendRequest(url, method string, payload interface{}) error { func (provider *AlertProvider) sendRequest(cfg *Config, url, method string, payload interface{}) error {
body, err := json.Marshal(payload) body, err := json.Marshal(payload)
if err != nil { if err != nil {
return fmt.Errorf("error build alert with payload %v: %w", payload, err) return fmt.Errorf("error build alert with payload %v: %w", payload, err)
@@ -102,7 +156,7 @@ func (provider *AlertProvider) sendRequest(url, method string, payload interface
return 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 "+cfg.APIKey)
response, err := client.GetHTTPClient(nil).Do(request) response, err := client.GetHTTPClient(nil).Do(request)
if err != nil { if err != nil {
return err return err
@@ -115,17 +169,17 @@ func (provider *AlertProvider) sendRequest(url, method string, payload interface
return nil return nil
} }
func (provider *AlertProvider) buildCreateRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) alertCreateRequest { func (provider *AlertProvider) buildCreateRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) alertCreateRequest {
var message, description string var message, description string
if resolved { if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription()) message = fmt.Sprintf("RESOLVED: %s - %s", ep.Name, alert.GetDescription())
description = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold) description = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else { } else {
message = fmt.Sprintf("%s - %s", endpoint.Name, alert.GetDescription()) message = fmt.Sprintf("%s - %s", ep.Name, alert.GetDescription())
description = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold) description = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
} }
if endpoint.Group != "" { if ep.Group != "" {
message = fmt.Sprintf("[%s] %s", endpoint.Group, message) message = fmt.Sprintf("[%s] %s", ep.Group, message)
} }
var formattedConditionResults string var formattedConditionResults string
for _, conditionResult := range result.ConditionResults { for _, conditionResult := range result.ConditionResults {
@@ -138,10 +192,10 @@ func (provider *AlertProvider) buildCreateRequestBody(endpoint *core.Endpoint, a
formattedConditionResults += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition) formattedConditionResults += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
} }
description = description + "\n" + formattedConditionResults description = description + "\n" + formattedConditionResults
key := buildKey(endpoint) key := buildKey(ep)
details := map[string]string{ details := map[string]string{
"endpoint:url": endpoint.URL, "endpoint:url": ep.URL,
"endpoint:group": endpoint.Group, "endpoint:group": ep.Group,
"result:hostname": result.Hostname, "result:hostname": result.Hostname,
"result:ip": result.IP, "result:ip": result.IP,
"result:dns_code": result.DNSRCode, "result:dns_code": result.DNSRCode,
@@ -158,65 +212,55 @@ func (provider *AlertProvider) buildCreateRequestBody(endpoint *core.Endpoint, a
return alertCreateRequest{ return alertCreateRequest{
Message: message, Message: message,
Description: description, Description: description,
Source: provider.source(), Source: cfg.Source,
Priority: provider.priority(), Priority: cfg.Priority,
Alias: provider.alias(key), Alias: cfg.AliasPrefix + key,
Entity: provider.entity(key), Entity: cfg.EntityPrefix + key,
Tags: provider.Tags, Tags: cfg.Tags,
Details: details, Details: details,
} }
} }
func (provider *AlertProvider) buildCloseRequestBody(endpoint *core.Endpoint, alert *alert.Alert) alertCloseRequest { func (provider *AlertProvider) buildCloseRequestBody(ep *endpoint.Endpoint, alert *alert.Alert) alertCloseRequest {
return alertCloseRequest{ return alertCloseRequest{
Source: buildKey(endpoint), Source: buildKey(ep),
Note: fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription()), Note: fmt.Sprintf("RESOLVED: %s - %s", ep.Name, alert.GetDescription()),
} }
} }
func (provider *AlertProvider) source() string {
source := provider.Source
if source == "" {
return "gatus"
}
return source
}
func (provider *AlertProvider) alias(key string) string {
alias := provider.AliasPrefix
if alias == "" {
alias = "gatus-healthcheck-"
}
return alias + key
}
func (provider *AlertProvider) entity(key string) string {
alias := provider.EntityPrefix
if alias == "" {
alias = "gatus-"
}
return alias + key
}
func (provider *AlertProvider) priority() string {
priority := provider.Priority
if priority == "" {
return "P1"
}
return priority
}
// GetDefaultAlert returns the provider's default alert configuration // 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
} }
func buildKey(endpoint *core.Endpoint) string { // GetConfig returns the configuration for the provider with the overrides applied
name := toKebabCase(endpoint.Name) func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
if endpoint.Group == "" { cfg := provider.DefaultConfig
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
func buildKey(ep *endpoint.Endpoint) string {
name := toKebabCase(ep.Name)
if ep.Group == "" {
return name return name
} }
return toKebabCase(endpoint.Group) + "-" + name return toKebabCase(ep.Group) + "-" + name
} }
func toKebabCase(val string) string { func toKebabCase(val string) string {
+86 -44
View File
@@ -7,17 +7,17 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{APIKey: ""} invalidProvider := AlertProvider{DefaultConfig: Config{APIKey: ""}}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{APIKey: "00000000-0000-0000-0000-000000000000"} validProvider := AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}}
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
@@ -35,7 +35,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 1, FailureThreshold: 1}, Alert: alert.Alert{Description: &description, SuccessThreshold: 1, FailureThreshold: 1},
Resolved: false, Resolved: false,
ExpectedError: false, ExpectedError: false,
@@ -45,7 +45,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedError: true, ExpectedError: true,
@@ -55,7 +55,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedError: false, ExpectedError: false,
@@ -65,7 +65,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedError: true, ExpectedError: true,
@@ -74,15 +74,14 @@ func TestAlertProvider_Send(t *testing.T) {
}), }),
}, },
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
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: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.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},
}, },
@@ -106,17 +105,17 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
Name string Name string
Provider *AlertProvider Provider *AlertProvider
Alert *alert.Alert Alert *alert.Alert
Endpoint *core.Endpoint Endpoint *endpoint.Endpoint
Result *core.Result Result *endpoint.Result
Resolved bool Resolved bool
want alertCreateRequest want alertCreateRequest
}{ }{
{ {
Name: "missing all params (unresolved)", Name: "missing all params (unresolved)",
Provider: &AlertProvider{}, Provider: &AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: &alert.Alert{}, Alert: &alert.Alert{},
Endpoint: &core.Endpoint{}, Endpoint: &endpoint.Endpoint{},
Result: &core.Result{}, Result: &endpoint.Result{},
Resolved: false, Resolved: false,
want: alertCreateRequest{ want: alertCreateRequest{
Message: " - ", Message: " - ",
@@ -131,10 +130,10 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
}, },
{ {
Name: "missing all params (resolved)", Name: "missing all params (resolved)",
Provider: &AlertProvider{}, Provider: &AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: &alert.Alert{}, Alert: &alert.Alert{},
Endpoint: &core.Endpoint{}, Endpoint: &endpoint.Endpoint{},
Result: &core.Result{}, Result: &endpoint.Result{},
Resolved: true, Resolved: true,
want: alertCreateRequest{ want: alertCreateRequest{
Message: "RESOLVED: - ", Message: "RESOLVED: - ",
@@ -149,16 +148,16 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
}, },
{ {
Name: "with default options (unresolved)", Name: "with default options (unresolved)",
Provider: &AlertProvider{}, Provider: &AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: &alert.Alert{ Alert: &alert.Alert{
Description: &description, Description: &description,
FailureThreshold: 3, FailureThreshold: 3,
}, },
Endpoint: &core.Endpoint{ Endpoint: &endpoint.Endpoint{
Name: "my super app", Name: "my super app",
}, },
Result: &core.Result{ Result: &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{ {
Condition: "[STATUS] == 200", Condition: "[STATUS] == 200",
Success: true, Success: true,
@@ -184,21 +183,23 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
{ {
Name: "with custom options (resolved)", Name: "with custom options (resolved)",
Provider: &AlertProvider{ Provider: &AlertProvider{
Priority: "P5", DefaultConfig: Config{
EntityPrefix: "oompa-", Priority: "P5",
AliasPrefix: "loompa-", EntityPrefix: "oompa-",
Source: "gatus-hc", AliasPrefix: "loompa-",
Tags: []string{"do-ba-dee-doo"}, Source: "gatus-hc",
Tags: []string{"do-ba-dee-doo"},
},
}, },
Alert: &alert.Alert{ Alert: &alert.Alert{
Description: &description, Description: &description,
SuccessThreshold: 4, SuccessThreshold: 4,
}, },
Endpoint: &core.Endpoint{ Endpoint: &endpoint.Endpoint{
Name: "my mega app", Name: "my mega app",
}, },
Result: &core.Result{ Result: &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{ {
Condition: "[STATUS] == 200", Condition: "[STATUS] == 200",
Success: true, Success: true,
@@ -220,23 +221,23 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
{ {
Name: "with default options and details (unresolved)", Name: "with default options and details (unresolved)",
Provider: &AlertProvider{ Provider: &AlertProvider{
Tags: []string{"foo"}, DefaultConfig: Config{Tags: []string{"foo"}, APIKey: "00000000-0000-0000-0000-000000000000"},
}, },
Alert: &alert.Alert{ Alert: &alert.Alert{
Description: &description, Description: &description,
FailureThreshold: 6, FailureThreshold: 6,
}, },
Endpoint: &core.Endpoint{ Endpoint: &endpoint.Endpoint{
Name: "my app", Name: "my app",
Group: "end game", Group: "end game",
URL: "https://my.go/app", URL: "https://my.go/app",
}, },
Result: &core.Result{ Result: &endpoint.Result{
HTTPStatus: 400, HTTPStatus: 400,
Hostname: "my.go", Hostname: "my.go",
Errors: []string{"error 01", "error 02"}, Errors: []string{"error 01", "error 02"},
Success: false, Success: false,
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{ {
Condition: "[STATUS] == 200", Condition: "[STATUS] == 200",
Success: false, Success: false,
@@ -265,8 +266,9 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
for _, scenario := range scenarios { for _, scenario := range scenarios {
actual := scenario actual := scenario
t.Run(actual.Name, func(t *testing.T) { t.Run(actual.Name, func(t *testing.T) {
if got := actual.Provider.buildCreateRequestBody(actual.Endpoint, actual.Alert, actual.Result, actual.Resolved); !reflect.DeepEqual(got, actual.want) { _ = scenario.Provider.Validate()
t.Errorf("buildCreateRequestBody() = %v, want %v", got, actual.want) if got := actual.Provider.buildCreateRequestBody(&scenario.Provider.DefaultConfig, actual.Endpoint, actual.Alert, actual.Result, actual.Resolved); !reflect.DeepEqual(got, actual.want) {
t.Errorf("got:\n%v\nwant:\n%v", got, actual.want)
} }
}) })
} }
@@ -279,14 +281,14 @@ func TestAlertProvider_buildCloseRequestBody(t *testing.T) {
Name string Name string
Provider *AlertProvider Provider *AlertProvider
Alert *alert.Alert Alert *alert.Alert
Endpoint *core.Endpoint Endpoint *endpoint.Endpoint
want alertCloseRequest want alertCloseRequest
}{ }{
{ {
Name: "Missing all values", Name: "Missing all values",
Provider: &AlertProvider{}, Provider: &AlertProvider{},
Alert: &alert.Alert{}, Alert: &alert.Alert{},
Endpoint: &core.Endpoint{}, Endpoint: &endpoint.Endpoint{},
want: alertCloseRequest{ want: alertCloseRequest{
Source: "", Source: "",
Note: "RESOLVED: - ", Note: "RESOLVED: - ",
@@ -298,7 +300,7 @@ func TestAlertProvider_buildCloseRequestBody(t *testing.T) {
Alert: &alert.Alert{ Alert: &alert.Alert{
Description: &description, Description: &description,
}, },
Endpoint: &core.Endpoint{ Endpoint: &endpoint.Endpoint{
Name: "endpoint name", Name: "endpoint name",
}, },
want: alertCloseRequest{ want: alertCloseRequest{
@@ -307,7 +309,6 @@ func TestAlertProvider_buildCloseRequestBody(t *testing.T) {
}, },
}, },
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
actual := scenario actual := scenario
t.Run(actual.Name, func(t *testing.T) { t.Run(actual.Name, func(t *testing.T) {
@@ -317,3 +318,44 @@ func TestAlertProvider_buildCloseRequestBody(t *testing.T) {
}) })
} }
} }
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-should-default",
Provider: AlertProvider{
DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"},
},
InputAlert: alert.Alert{},
ExpectedOutput: Config{APIKey: "00000000-0000-0000-0000-000000000000"},
},
{
Name: "provider-with-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"},
},
InputAlert: alert.Alert{ProviderOverride: map[string]any{"api-key": "00000000-0000-0000-0000-000000000001"}},
ExpectedOutput: Config{APIKey: "00000000-0000-0000-0000-000000000001"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.APIKey != scenario.ExpectedOutput.APIKey {
t.Errorf("expected APIKey to be %s, got %s", scenario.ExpectedOutput.APIKey, got.APIKey)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
}
})
}
}
+77 -31
View File
@@ -3,23 +3,47 @@ package pagerduty
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"log"
"net/http" "net/http"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/logr"
"gopkg.in/yaml.v3"
) )
const ( const (
restAPIURL = "https://events.pagerduty.com/v2/enqueue" restAPIURL = "https://events.pagerduty.com/v2/enqueue"
) )
var (
ErrIntegrationKeyNotSet = errors.New("integration-key must have exactly 32 characters")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
IntegrationKey string `yaml:"integration-key"`
}
func (cfg *Config) Validate() error {
if len(cfg.IntegrationKey) != 32 {
return ErrIntegrationKeyNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.IntegrationKey) > 0 {
cfg.IntegrationKey = override.IntegrationKey
}
}
// AlertProvider is the configuration necessary for sending an alert using PagerDuty // AlertProvider is the configuration necessary for sending an alert using PagerDuty
type AlertProvider struct { type AlertProvider struct {
IntegrationKey string `yaml:"integration-key"` DefaultConfig Config `yaml:",inline"`
// 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"`
@@ -30,30 +54,34 @@ type AlertProvider struct {
// Override is a case under which the default integration is overridden // Override is a case under which the default integration is overridden
type Override struct { type Override struct {
Group string `yaml:"group"` Group string `yaml:"group"`
IntegrationKey string `yaml:"integration-key"` Config `yaml:",inline"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool) registeredGroups := make(map[string]bool)
if provider.Overrides != nil { if provider.Overrides != nil {
for _, override := range provider.Overrides { for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.IntegrationKey) != 32 { if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return false return ErrDuplicateGroupOverride
} }
registeredGroups[override.Group] = true registeredGroups[override.Group] = true
} }
} }
// Either the default integration key has the right length, or there are overrides who are properly configured. // Either the default integration key has the right length, or there are overrides who are properly configured.
return len(provider.IntegrationKey) == 32 || len(provider.Overrides) != 0 return provider.DefaultConfig.Validate()
} }
// Send an alert using the provider // Send an alert using the provider
// //
// Relevant: https://developer.pagerduty.com/docs/events-api-v2/trigger-events/ // Relevant: https://developer.pagerduty.com/docs/events-api-v2/trigger-events/
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, 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
@@ -74,11 +102,10 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
alert.ResolveKey = "" alert.ResolveKey = ""
} else { } else {
// We need to retrieve the resolve key from the response // We need to retrieve the resolve key from the response
body, err := io.ReadAll(response.Body)
var payload pagerDutyResponsePayload var payload pagerDutyResponsePayload
if err = json.Unmarshal(body, &payload); err != nil { if err = json.NewDecoder(response.Body).Decode(&payload); err != nil {
// Silently fail. We don't want to create tons of alerts just because we failed to parse the body. // Silently fail. We don't want to create tons of alerts just because we failed to parse the body.
log.Printf("[pagerduty.Send] Ran into error unmarshaling pagerduty response: %s", err.Error()) logr.Errorf("[pagerduty.Send] Ran into error decoding pagerduty response: %s", err.Error())
} else { } else {
alert.ResolveKey = payload.DedupKey alert.ResolveKey = payload.DedupKey
} }
@@ -101,19 +128,19 @@ type Payload struct {
} }
// 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) []byte { func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.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", ep.DisplayName(), alert.GetDescription())
eventAction = "resolve" eventAction = "resolve"
resolveKey = alert.ResolveKey resolveKey = alert.ResolveKey
} else { } else {
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.DisplayName(), alert.GetDescription()) message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
eventAction = "trigger" eventAction = "trigger"
resolveKey = "" resolveKey = ""
} }
body, _ := json.Marshal(Body{ body, _ := json.Marshal(Body{
RoutingKey: provider.getIntegrationKeyForGroup(endpoint.Group), RoutingKey: cfg.IntegrationKey,
DedupKey: resolveKey, DedupKey: resolveKey,
EventAction: eventAction, EventAction: eventAction,
Payload: Payload{ Payload: Payload{
@@ -125,23 +152,42 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
return body return body
} }
// getIntegrationKeyForGroup returns the appropriate pagerduty integration key for a given group
func (provider *AlertProvider) getIntegrationKeyForGroup(group string) string {
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
return override.IntegrationKey
}
}
}
return provider.IntegrationKey
}
// GetDefaultAlert returns the provider's default alert configuration // 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
} }
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
type pagerDutyResponsePayload struct { type pagerDutyResponsePayload struct {
Status string `json:"status"` Status string `json:"status"`
Message string `json:"message"` Message string `json:"message"`
+117 -98
View File
@@ -7,54 +7,45 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{IntegrationKey: ""} invalidProvider := AlertProvider{DefaultConfig: Config{IntegrationKey: ""}}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{IntegrationKey: "00000000000000000000000000000000"} validProvider := AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}}
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
func TestAlertProvider_IsValidWithOverride(t *testing.T) { func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{ providerWithInvalidOverrideGroup := AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: []Override{ Overrides: []Override{
{ {
IntegrationKey: "00000000000000000000000000000000", Config: Config{IntegrationKey: "00000000000000000000000000000002"},
Group: "", Group: "",
}, },
}, },
} }
if providerWithInvalidOverrideGroup.IsValid() { if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid") t.Error("provider Group shouldn't have been valid")
} }
providerWithInvalidOverrideIntegrationKey := AlertProvider{
Overrides: []Override{
{
IntegrationKey: "",
Group: "group",
},
},
}
if providerWithInvalidOverrideIntegrationKey.IsValid() {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{ providerWithValidOverride := AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: []Override{ Overrides: []Override{
{ {
IntegrationKey: "00000000000000000000000000000000", Config: Config{IntegrationKey: "00000000000000000000000000000002"},
Group: "group", Group: "group",
}, },
}, },
} }
if !providerWithValidOverride.IsValid() { if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid, got error:", err.Error())
} }
} }
@@ -72,7 +63,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -82,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -92,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -102,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -115,10 +106,10 @@ 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: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.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},
}, },
@@ -146,14 +137,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{IntegrationKey: "00000000000000000000000000000000"}, Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
Alert: alert.Alert{Description: &description}, Alert: alert.Alert{Description: &description},
Resolved: false, Resolved: false,
ExpectedBody: "{\"routing_key\":\"00000000000000000000000000000000\",\"dedup_key\":\"\",\"event_action\":\"trigger\",\"payload\":{\"summary\":\"TRIGGERED: endpoint-name - test\",\"source\":\"Gatus\",\"severity\":\"critical\"}}", 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{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
Alert: alert.Alert{Description: &description, ResolveKey: "key"}, Alert: alert.Alert{Description: &description, ResolveKey: "key"},
Resolved: true, Resolved: true,
ExpectedBody: "{\"routing_key\":\"00000000000000000000000000000000\",\"dedup_key\":\"key\",\"event_action\":\"resolve\",\"payload\":{\"summary\":\"RESOLVED: endpoint-name - test\",\"source\":\"Gatus\",\"severity\":\"critical\"}}", ExpectedBody: "{\"routing_key\":\"00000000000000000000000000000000\",\"dedup_key\":\"key\",\"event_action\":\"resolve\",\"payload\":{\"summary\":\"RESOLVED: endpoint-name - test\",\"source\":\"Gatus\",\"severity\":\"critical\"}}",
@@ -161,7 +152,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
} }
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{Name: "endpoint-name"}, &scenario.Alert, &core.Result{}, scenario.Resolved) body := scenario.Provider.buildRequestBody(&scenario.Provider.DefaultConfig, &endpoint.Endpoint{Name: "endpoint-name"}, &scenario.Alert, &endpoint.Result{}, scenario.Resolved)
if string(body) != scenario.ExpectedBody { if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body) t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
} }
@@ -173,69 +164,6 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
} }
} }
func TestAlertProvider_getIntegrationKeyForGroup(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
ExpectedOutput string
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
IntegrationKey: "00000000000000000000000000000001",
Overrides: nil,
},
InputGroup: "",
ExpectedOutput: "00000000000000000000000000000001",
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
IntegrationKey: "00000000000000000000000000000001",
Overrides: nil,
},
InputGroup: "group",
ExpectedOutput: "00000000000000000000000000000001",
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
IntegrationKey: "00000000000000000000000000000001",
Overrides: []Override{
{
Group: "group",
IntegrationKey: "00000000000000000000000000000002",
},
},
},
InputGroup: "",
ExpectedOutput: "00000000000000000000000000000001",
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
IntegrationKey: "00000000000000000000000000000001",
Overrides: []Override{
{
Group: "group",
IntegrationKey: "00000000000000000000000000000002",
},
},
},
InputGroup: "group",
ExpectedOutput: "00000000000000000000000000000002",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
if output := scenario.Provider.getIntegrationKeyForGroup(scenario.InputGroup); output != scenario.ExpectedOutput {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput, output)
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) { func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil { if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil") t.Error("expected default alert to be not nil")
@@ -244,3 +172,94 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
t.Error("expected default alert to be nil") t.Error("expected default alert to be nil")
} }
} }
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: nil,
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000001"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000001"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: []Override{
{
Group: "group",
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000001"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: []Override{
{
Group: "group",
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000002"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: []Override{
{
Group: "group",
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"integration-key": "00000000000000000000000000000003"}},
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000003"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.IntegrationKey != scenario.ExpectedOutput.IntegrationKey {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.IntegrationKey, got.IntegrationKey)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
}
})
}
}
+49 -8
View File
@@ -6,9 +6,11 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/custom" "github.com/TwiN/gatus/v5/alerting/provider/custom"
"github.com/TwiN/gatus/v5/alerting/provider/discord" "github.com/TwiN/gatus/v5/alerting/provider/discord"
"github.com/TwiN/gatus/v5/alerting/provider/email" "github.com/TwiN/gatus/v5/alerting/provider/email"
"github.com/TwiN/gatus/v5/alerting/provider/gitea"
"github.com/TwiN/gatus/v5/alerting/provider/github" "github.com/TwiN/gatus/v5/alerting/provider/github"
"github.com/TwiN/gatus/v5/alerting/provider/gitlab" "github.com/TwiN/gatus/v5/alerting/provider/gitlab"
"github.com/TwiN/gatus/v5/alerting/provider/googlechat" "github.com/TwiN/gatus/v5/alerting/provider/googlechat"
"github.com/TwiN/gatus/v5/alerting/provider/incidentio"
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace" "github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
"github.com/TwiN/gatus/v5/alerting/provider/matrix" "github.com/TwiN/gatus/v5/alerting/provider/matrix"
"github.com/TwiN/gatus/v5/alerting/provider/mattermost" "github.com/TwiN/gatus/v5/alerting/provider/mattermost"
@@ -19,25 +21,35 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/pushover" "github.com/TwiN/gatus/v5/alerting/provider/pushover"
"github.com/TwiN/gatus/v5/alerting/provider/slack" "github.com/TwiN/gatus/v5/alerting/provider/slack"
"github.com/TwiN/gatus/v5/alerting/provider/teams" "github.com/TwiN/gatus/v5/alerting/provider/teams"
"github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows"
"github.com/TwiN/gatus/v5/alerting/provider/telegram" "github.com/TwiN/gatus/v5/alerting/provider/telegram"
"github.com/TwiN/gatus/v5/alerting/provider/twilio" "github.com/TwiN/gatus/v5/alerting/provider/twilio"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/alerting/provider/zulip"
"github.com/TwiN/gatus/v5/config/endpoint"
) )
// AlertProvider is the interface that each provider should implement // AlertProvider is the interface that each provider should implement
type AlertProvider interface { type AlertProvider interface {
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
IsValid() bool Validate() error
// Send an alert using the provider
Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
GetDefaultAlert() *alert.Alert GetDefaultAlert() *alert.Alert
// Send an alert using the provider // ValidateOverrides validates the alert's provider override and, if present, the group override
Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error ValidateOverrides(group string, alert *alert.Alert) error
} }
// ParseWithDefaultAlert parses an Endpoint alert by using the provider's default alert as a baseline type Config[T any] interface {
func ParseWithDefaultAlert(providerDefaultAlert, endpointAlert *alert.Alert) { Validate() error
Merge(override *T)
}
// MergeProviderDefaultAlertIntoEndpointAlert parses an Endpoint alert by using the provider's default alert as a baseline
func MergeProviderDefaultAlertIntoEndpointAlert(providerDefaultAlert, endpointAlert *alert.Alert) {
if providerDefaultAlert == nil || endpointAlert == nil { if providerDefaultAlert == nil || endpointAlert == nil {
return return
} }
@@ -59,11 +71,12 @@ func ParseWithDefaultAlert(providerDefaultAlert, endpointAlert *alert.Alert) {
} }
var ( var (
// Validate interface implementation on compile // Validate provider interface implementation on compile
_ AlertProvider = (*awsses.AlertProvider)(nil) _ AlertProvider = (*awsses.AlertProvider)(nil)
_ AlertProvider = (*custom.AlertProvider)(nil) _ AlertProvider = (*custom.AlertProvider)(nil)
_ AlertProvider = (*discord.AlertProvider)(nil) _ AlertProvider = (*discord.AlertProvider)(nil)
_ AlertProvider = (*email.AlertProvider)(nil) _ AlertProvider = (*email.AlertProvider)(nil)
_ AlertProvider = (*gitea.AlertProvider)(nil)
_ AlertProvider = (*github.AlertProvider)(nil) _ AlertProvider = (*github.AlertProvider)(nil)
_ AlertProvider = (*gitlab.AlertProvider)(nil) _ AlertProvider = (*gitlab.AlertProvider)(nil)
_ AlertProvider = (*googlechat.AlertProvider)(nil) _ AlertProvider = (*googlechat.AlertProvider)(nil)
@@ -77,6 +90,34 @@ var (
_ AlertProvider = (*pushover.AlertProvider)(nil) _ AlertProvider = (*pushover.AlertProvider)(nil)
_ AlertProvider = (*slack.AlertProvider)(nil) _ AlertProvider = (*slack.AlertProvider)(nil)
_ AlertProvider = (*teams.AlertProvider)(nil) _ AlertProvider = (*teams.AlertProvider)(nil)
_ AlertProvider = (*teamsworkflows.AlertProvider)(nil)
_ AlertProvider = (*telegram.AlertProvider)(nil) _ AlertProvider = (*telegram.AlertProvider)(nil)
_ AlertProvider = (*twilio.AlertProvider)(nil) _ AlertProvider = (*twilio.AlertProvider)(nil)
_ AlertProvider = (*zulip.AlertProvider)(nil)
_ AlertProvider = (*incidentio.AlertProvider)(nil)
// Validate config interface implementation on compile
_ Config[awsses.Config] = (*awsses.Config)(nil)
_ Config[custom.Config] = (*custom.Config)(nil)
_ Config[discord.Config] = (*discord.Config)(nil)
_ Config[email.Config] = (*email.Config)(nil)
_ Config[gitea.Config] = (*gitea.Config)(nil)
_ Config[github.Config] = (*github.Config)(nil)
_ Config[gitlab.Config] = (*gitlab.Config)(nil)
_ Config[googlechat.Config] = (*googlechat.Config)(nil)
_ Config[incidentio.Config] = (*incidentio.Config)(nil)
_ Config[jetbrainsspace.Config] = (*jetbrainsspace.Config)(nil)
_ Config[matrix.Config] = (*matrix.Config)(nil)
_ Config[mattermost.Config] = (*mattermost.Config)(nil)
_ Config[messagebird.Config] = (*messagebird.Config)(nil)
_ Config[ntfy.Config] = (*ntfy.Config)(nil)
_ Config[opsgenie.Config] = (*opsgenie.Config)(nil)
_ Config[pagerduty.Config] = (*pagerduty.Config)(nil)
_ Config[pushover.Config] = (*pushover.Config)(nil)
_ Config[slack.Config] = (*slack.Config)(nil)
_ Config[teams.Config] = (*teams.Config)(nil)
_ Config[teamsworkflows.Config] = (*teamsworkflows.Config)(nil)
_ Config[telegram.Config] = (*telegram.Config)(nil)
_ Config[twilio.Config] = (*twilio.Config)(nil)
_ Config[zulip.Config] = (*zulip.Config)(nil)
) )
+1 -1
View File
@@ -126,7 +126,7 @@ func TestParseWithDefaultAlert(t *testing.T) {
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
ParseWithDefaultAlert(scenario.DefaultAlert, scenario.EndpointAlert) MergeProviderDefaultAlertIntoEndpointAlert(scenario.DefaultAlert, scenario.EndpointAlert)
if scenario.ExpectedOutputAlert == nil { if scenario.ExpectedOutputAlert == nil {
if scenario.EndpointAlert != nil { if scenario.EndpointAlert != nil {
t.Fail() t.Fail()
+145 -29
View File
@@ -3,13 +3,15 @@ package pushover
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
) )
const ( const (
@@ -17,8 +19,14 @@ const (
defaultPriority = 0 defaultPriority = 0
) )
// AlertProvider is the configuration necessary for sending an alert using Pushover var (
type AlertProvider struct { ErrInvalidApplicationToken = errors.New("application-token must be 30 characters long")
ErrInvalidUserKey = errors.New("user-key must be 30 characters long")
ErrInvalidPriority = errors.New("priority and resolved-priority must be between -2 and 2")
ErrInvalidDevice = errors.New("device name must have 25 characters or less")
)
type Config struct {
// Key used to authenticate the application sending // Key used to authenticate the application sending
// See "Your Applications" on the dashboard, or add a new one: https://pushover.net/apps/build // See "Your Applications" on the dashboard, or add a new one: https://pushover.net/apps/build
ApplicationToken string `yaml:"application-token"` ApplicationToken string `yaml:"application-token"`
@@ -26,34 +34,102 @@ type AlertProvider struct {
// Key of the user or group the messages should be sent to // Key of the user or group the messages should be sent to
UserKey string `yaml:"user-key"` UserKey string `yaml:"user-key"`
// The title of your message, likely the application name // The title of your message
// default: the name of your application in Pushover // default: "Gatus: <endpoint>""
Title string `yaml:"title,omitempty"` Title string `yaml:"title,omitempty"`
// Priority of all messages, ranging from -2 (very low) to 2 (Emergency) // Priority of all messages, ranging from -2 (very low) to 2 (Emergency)
// default: 0 // default: 0
Priority int `yaml:"priority,omitempty"` Priority int `yaml:"priority,omitempty"`
// Priority of resolved messages, ranging from -2 (very low) to 2 (Emergency)
// default: 0
ResolvedPriority int `yaml:"resolved-priority,omitempty"`
// Sound of the messages (see: https://pushover.net/api#sounds) // Sound of the messages (see: https://pushover.net/api#sounds)
// default: "" (pushover) // default: "" (pushover)
Sound string `yaml:"sound,omitempty"` Sound string `yaml:"sound,omitempty"`
// TTL of your message (https://pushover.net/api#ttl)
// If priority is 2 then this parameter is ignored
// default: 0
TTL int `yaml:"ttl,omitempty"`
// Device to send the message to (see: https://pushover.net/api#devices)
// default: "" (all devices)
Device string `yaml:"device,omitempty"`
}
func (cfg *Config) Validate() error {
if cfg.Priority == 0 {
cfg.Priority = defaultPriority
}
if cfg.ResolvedPriority == 0 {
cfg.ResolvedPriority = defaultPriority
}
if len(cfg.ApplicationToken) != 30 {
return ErrInvalidApplicationToken
}
if len(cfg.UserKey) != 30 {
return ErrInvalidUserKey
}
if cfg.Priority < -2 || cfg.Priority > 2 || cfg.ResolvedPriority < -2 || cfg.ResolvedPriority > 2 {
return ErrInvalidPriority
}
if len(cfg.Device) > 25 {
return ErrInvalidDevice
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.ApplicationToken) > 0 {
cfg.ApplicationToken = override.ApplicationToken
}
if len(override.UserKey) > 0 {
cfg.UserKey = override.UserKey
}
if len(override.Title) > 0 {
cfg.Title = override.Title
}
if override.Priority != 0 {
cfg.Priority = override.Priority
}
if override.ResolvedPriority != 0 {
cfg.ResolvedPriority = override.ResolvedPriority
}
if len(override.Sound) > 0 {
cfg.Sound = override.Sound
}
if override.TTL > 0 {
cfg.TTL = override.TTL
}
if len(override.Device) > 0 {
cfg.Device = override.Device
}
}
// AlertProvider is the configuration necessary for sending an alert using Pushover
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// 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"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
if provider.Priority == 0 { return provider.DefaultConfig.Validate()
provider.Priority = defaultPriority
}
return len(provider.ApplicationToken) == 30 && len(provider.UserKey) == 30 && provider.Priority >= -2 && provider.Priority <= 2
} }
// Send an alert using the provider // Send an alert using the provider
// Reference doc for pushover: https://pushover.net/api // Reference doc for pushover: https://pushover.net/api
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, 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
@@ -77,36 +153,76 @@ type Body struct {
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
Message string `json:"message"` Message string `json:"message"`
Priority int `json:"priority"` Priority int `json:"priority"`
Html int `json:"html"`
Sound string `json:"sound,omitempty"` Sound string `json:"sound,omitempty"`
TTL int `json:"ttl,omitempty"`
Device string `json:"device,omitempty"`
} }
// 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) []byte { func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message string var message, formattedConditionResults string
priority := cfg.Priority
if resolved { if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription()) priority = cfg.ResolvedPriority
message = fmt.Sprintf("An alert for <b>%s</b> has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else { } else {
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.DisplayName(), alert.GetDescription()) message = fmt.Sprintf("An alert for <b>%s</b> has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
}
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "✅"
} else {
prefix = "❌"
}
formattedConditionResults += fmt.Sprintf("\n%s - %s", prefix, conditionResult.Condition)
}
if len(alert.GetDescription()) > 0 {
message += " with the following description: " + alert.GetDescription()
}
message += formattedConditionResults
title := "Gatus: " + ep.DisplayName()
if cfg.Title != "" {
title = cfg.Title
} }
body, _ := json.Marshal(Body{ body, _ := json.Marshal(Body{
Token: provider.ApplicationToken, Token: cfg.ApplicationToken,
User: provider.UserKey, User: cfg.UserKey,
Title: provider.Title, Title: title,
Message: message, Message: message,
Priority: provider.priority(), Priority: priority,
Sound: provider.Sound, Html: 1,
Sound: cfg.Sound,
TTL: cfg.TTL,
Device: cfg.Device,
}) })
return body return body
} }
func (provider *AlertProvider) priority() int {
if provider.Priority == 0 {
return defaultPriority
}
return provider.Priority
}
// 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
} }
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
+131 -46
View File
@@ -7,35 +7,43 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestPushoverAlertProvider_IsValid(t *testing.T) { func TestPushoverAlertProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{} t.Run("empty-invalid-provider", func(t *testing.T) {
if invalidProvider.IsValid() { invalidProvider := AlertProvider{}
t.Error("provider shouldn't have been valid") if err := invalidProvider.Validate(); err == nil {
} t.Error("provider shouldn't have been valid")
validProvider := AlertProvider{ }
ApplicationToken: "aTokenWithLengthOf30characters", })
UserKey: "aTokenWithLengthOf30characters", t.Run("valid-provider", func(t *testing.T) {
Title: "Gatus Notification", validProvider := AlertProvider{
Priority: 1, DefaultConfig: Config{
} ApplicationToken: "aTokenWithLengthOf30characters",
if !validProvider.IsValid() { UserKey: "aTokenWithLengthOf30characters",
t.Error("provider should've been valid") Title: "Gatus Notification",
} Priority: 1,
} ResolvedPriority: 1,
},
func TestPushoverAlertProvider_IsInvalid(t *testing.T) { }
invalidProvider := AlertProvider{ if err := validProvider.Validate(); err != nil {
ApplicationToken: "aTokenWithLengthOfMoreThan30characters", t.Error("provider should've been valid")
UserKey: "aTokenWithLengthOfMoreThan30characters", }
Priority: 5, })
} t.Run("invalid-provider", func(t *testing.T) {
if invalidProvider.IsValid() { invalidProvider := AlertProvider{
t.Error("provider should've been invalid") DefaultConfig: Config{
} ApplicationToken: "aTokenWithLengthOfMoreThan30characters",
UserKey: "aTokenWithLengthOfMoreThan30characters",
Priority: 5,
},
}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider should've been invalid")
}
})
} }
func TestAlertProvider_Send(t *testing.T) { func TestAlertProvider_Send(t *testing.T) {
@@ -52,7 +60,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -62,7 +70,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -72,7 +80,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -82,7 +90,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -95,10 +103,10 @@ 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: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.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},
}, },
@@ -119,41 +127,71 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1" firstDescription := "description-1"
secondDescription := "description-2" secondDescription := "description-2"
scenarios := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
Alert alert.Alert Alert alert.Alert
Resolved bool Resolved bool
ExpectedBody string ResolvedPriority bool
ExpectedBody string
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{ApplicationToken: "TokenWithLengthOf30Characters1", UserKey: "TokenWithLengthOf30Characters4"}, Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters1", UserKey: "TokenWithLengthOf30Characters4"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters1\",\"user\":\"TokenWithLengthOf30Characters4\",\"message\":\"TRIGGERED: endpoint-name - description-1\",\"priority\":0}", ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters1\",\"user\":\"TokenWithLengthOf30Characters4\",\"title\":\"Gatus: endpoint-name\",\"message\":\"An alert for \\u003cb\\u003eendpoint-name\\u003c/b\\u003e has been triggered due to having failed 3 time(s) in a row with the following description: description-1\\n❌ - [CONNECTED] == true\\n❌ - [STATUS] == 200\",\"priority\":0,\"html\":1}",
},
{
Name: "triggered-customtitle",
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters1", UserKey: "TokenWithLengthOf30Characters4", Title: "Gatus Notifications"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters1\",\"user\":\"TokenWithLengthOf30Characters4\",\"title\":\"Gatus Notifications\",\"message\":\"An alert for \\u003cb\\u003eendpoint-name\\u003c/b\\u003e has been triggered due to having failed 3 time(s) in a row with the following description: description-1\\n❌ - [CONNECTED] == true\\n❌ - [STATUS] == 200\",\"priority\":0,\"html\":1}",
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2}, Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Priority: 2, ResolvedPriority: 2}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"RESOLVED: endpoint-name - description-2\",\"priority\":2}", ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus: endpoint-name\",\"message\":\"An alert for \\u003cb\\u003eendpoint-name\\u003c/b\\u003e has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\\n✅ - [CONNECTED] == true\\n✅ - [STATUS] == 200\",\"priority\":2,\"html\":1}",
},
{
Name: "resolved-priority",
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2, ResolvedPriority: 0}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"An alert for \\u003cb\\u003eendpoint-name\\u003c/b\\u003e has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\\n✅ - [CONNECTED] == true\\n✅ - [STATUS] == 200\",\"priority\":0,\"html\":1}",
}, },
{ {
Name: "with-sound", Name: "with-sound",
Provider: AlertProvider{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2, Sound: "falling"}, Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2, ResolvedPriority: 2, Sound: "falling"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"RESOLVED: endpoint-name - description-2\",\"priority\":2,\"sound\":\"falling\"}", ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"An alert for \\u003cb\\u003eendpoint-name\\u003c/b\\u003e has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\\n✅ - [CONNECTED] == true\\n✅ - [STATUS] == 200\",\"priority\":2,\"html\":1,\"sound\":\"falling\"}",
}, },
{
Name: "with-ttl",
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2, ResolvedPriority: 2, TTL: 3600}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"An alert for \\u003cb\\u003eendpoint-name\\u003c/b\\u003e has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\\n✅ - [CONNECTED] == true\\n✅ - [STATUS] == 200\",\"priority\":2,\"html\":1,\"ttl\":3600}",
},
{
Name: "with-device",
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2, ResolvedPriority: 2, TTL: 3600, Device: "iphone15pro",}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"An alert for \\u003cb\\u003eendpoint-name\\u003c/b\\u003e has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\\n✅ - [CONNECTED] == true\\n✅ - [STATUS] == 200\",\"priority\":2,\"html\":1,\"ttl\":3600,\"device\":\"iphone15pro\"}",
},
} }
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.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.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},
}, },
@@ -179,3 +217,50 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
t.Error("expected default alert to be nil") t.Error("expected default alert to be nil")
} }
} }
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"},
},
{
Name: "provider-with-alert-override",
Provider: AlertProvider{
DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"application-token": "TokenWithLengthOf30Characters2", "user-key": "TokenWithLengthOf30Characters3"}},
ExpectedOutput: Config{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters3"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.ApplicationToken != scenario.ExpectedOutput.ApplicationToken {
t.Errorf("expected application token to be %s, got %s", scenario.ExpectedOutput.ApplicationToken, got.ApplicationToken)
}
if got.UserKey != scenario.ExpectedOutput.UserKey {
t.Errorf("expected user key to be %s, got %s", scenario.ExpectedOutput.UserKey, got.UserKey)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
}
})
}
}
+76 -27
View File
@@ -3,48 +3,78 @@ package slack
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
) )
var (
ErrWebhookURLNotSet = errors.New("webhook-url not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
WebhookURL string `yaml:"webhook-url"` // Slack webhook URL
}
func (cfg *Config) Validate() error {
if len(cfg.WebhookURL) == 0 {
return ErrWebhookURLNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.WebhookURL) > 0 {
cfg.WebhookURL = override.WebhookURL
}
}
// 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 DefaultConfig Config `yaml:",inline"`
// 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 is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"` Overrides []Override `yaml:"overrides,omitempty"`
} }
// Override is a case under which the default integration is overridden // Override is a case under which the default integration is overridden
type Override struct { type Override struct {
Group string `yaml:"group"` Group string `yaml:"group"`
WebhookURL string `yaml:"webhook-url"` Config `yaml:",inline"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool) registeredGroups := make(map[string]bool)
if provider.Overrides != nil { if provider.Overrides != nil {
for _, override := range provider.Overrides { for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 { if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return false return ErrDuplicateGroupOverride
} }
registeredGroups[override.Group] = true registeredGroups[override.Group] = true
} }
} }
return len(provider.WebhookURL) > 0 return provider.DefaultConfig.Validate()
} }
// 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(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) cfg, err := provider.GetConfig(ep.Group, alert)
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer) if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
if err != nil { if err != nil {
return err return err
} }
@@ -81,13 +111,13 @@ type Field struct {
} }
// 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) []byte { func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.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", ep.DisplayName(), alert.SuccessThreshold)
color = "#36A64F" color = "#36A64F"
} else { } else {
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold) message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
color = "#DD0000" color = "#DD0000"
} }
var formattedConditionResults string var formattedConditionResults string
@@ -126,19 +156,38 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
return bodyAsJSON return bodyAsJSON
} }
// 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
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert
} }
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
+82 -54
View File
@@ -7,54 +7,54 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertDefaultProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{WebhookURL: ""} invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{WebhookURL: "https://example.com"} validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "https://example.com"}}
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
func TestAlertProvider_IsValidWithOverride(t *testing.T) { func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{ providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "http://example.com", Config: Config{WebhookURL: "http://example.com"},
Group: "", Group: "",
}, },
}, },
} }
if providerWithInvalidOverrideGroup.IsValid() { if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid") t.Error("provider Group shouldn't have been valid")
} }
providerWithInvalidOverrideTo := AlertProvider{ providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "", Config: Config{WebhookURL: ""},
Group: "group", Group: "group",
}, },
}, },
} }
if providerWithInvalidOverrideTo.IsValid() { if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid") t.Error("provider integration key shouldn't have been valid")
} }
providerWithValidOverride := AlertProvider{ providerWithValidOverride := AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "http://example.com", Config: Config{WebhookURL: "http://example.com"},
Group: "group", Group: "group",
}, },
}, },
} }
if !providerWithValidOverride.IsValid() { if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
@@ -73,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -83,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -93,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -103,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -116,10 +116,10 @@ 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: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.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},
}, },
@@ -142,7 +142,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
scenarios := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
Endpoint core.Endpoint Endpoint endpoint.Endpoint
Alert alert.Alert Alert alert.Alert
NoConditions bool NoConditions bool
Resolved bool Resolved bool
@@ -151,7 +151,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{},
Endpoint: core.Endpoint{Name: "name"}, Endpoint: endpoint.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: "{\"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}]}]}", 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}]}]}",
@@ -159,7 +159,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
{ {
Name: "triggered-with-group", Name: "triggered-with-group",
Provider: AlertProvider{}, Provider: AlertProvider{},
Endpoint: core.Endpoint{Name: "name", Group: "group"}, Endpoint: endpoint.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: "{\"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}]}]}", 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}]}]}",
@@ -168,7 +168,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Name: "triggered-with-no-conditions", Name: "triggered-with-no-conditions",
NoConditions: true, NoConditions: true,
Provider: AlertProvider{}, Provider: AlertProvider{},
Endpoint: core.Endpoint{Name: "name"}, Endpoint: endpoint.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: "{\"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\"}]}", 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\"}]}",
@@ -176,7 +176,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{},
Endpoint: core.Endpoint{Name: "name"}, Endpoint: endpoint.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: "{\"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}]}]}", 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}]}]}",
@@ -184,7 +184,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
{ {
Name: "resolved-with-group", Name: "resolved-with-group",
Provider: AlertProvider{}, Provider: AlertProvider{},
Endpoint: core.Endpoint{Name: "name", Group: "group"}, Endpoint: endpoint.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: "{\"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}]}]}", 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}]}]}",
@@ -192,9 +192,9 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
var conditionResults []*core.ConditionResult var conditionResults []*endpoint.ConditionResult
if !scenario.NoConditions { if !scenario.NoConditions {
conditionResults = []*core.ConditionResult{ conditionResults = []*endpoint.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},
} }
@@ -202,7 +202,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&scenario.Endpoint, &scenario.Endpoint,
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: conditionResults, ConditionResults: conditionResults,
}, },
scenario.Resolved, scenario.Resolved,
@@ -227,64 +227,92 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
} }
} }
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) { func TestAlertProvider_GetConfig(t *testing.T) {
tests := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
InputGroup string InputGroup string
ExpectedOutput string InputAlert alert.Alert
ExpectedOutput Config
}{ }{
{ {
Name: "provider-no-override-specify-no-group-should-default", Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-no-override-specify-group-should-default", Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-with-override-specify-no-group-should-default", Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
WebhookURL: "http://example01.com", Config: Config{WebhookURL: "http://example01.com"},
}, },
}, },
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-with-override-specify-group-should-override", Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
WebhookURL: "http://example01.com", Config: Config{WebhookURL: "http://group-example.com"},
}, },
}, },
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "http://example01.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://group-example.com"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
ExpectedOutput: Config{WebhookURL: "http://alert-example.com"},
}, },
} }
for _, tt := range tests { for _, scenario := range scenarios {
t.Run(tt.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput { got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
t.Errorf("AlertProvider.getWebhookURLForGroup() = %v, want %v", got, tt.ExpectedOutput) if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
} }
}) })
} }
+89 -29
View File
@@ -3,18 +3,52 @@ package teams
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
) )
var (
ErrWebhookURLNotSet = errors.New("webhook-url not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
WebhookURL string `yaml:"webhook-url"`
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"`
}
func (cfg *Config) Validate() error {
if len(cfg.WebhookURL) == 0 {
return ErrWebhookURLNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if override.ClientConfig != nil {
cfg.ClientConfig = override.ClientConfig
}
if len(override.WebhookURL) > 0 {
cfg.WebhookURL = override.WebhookURL
}
if len(override.Title) > 0 {
cfg.Title = override.Title
}
}
// AlertProvider is the configuration necessary for sending an alert using Teams // AlertProvider is the configuration necessary for sending an alert using Teams
type AlertProvider struct { type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"` DefaultConfig Config `yaml:",inline"`
// 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"`
@@ -25,33 +59,37 @@ type AlertProvider struct {
// Override is a case under which the default integration is overridden // Override is a case under which the default integration is overridden
type Override struct { type Override struct {
Group string `yaml:"group"` Group string `yaml:"group"`
WebhookURL string `yaml:"webhook-url"` Config `yaml:",inline"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool) registeredGroups := make(map[string]bool)
if provider.Overrides != nil { if provider.Overrides != nil {
for _, override := range provider.Overrides { for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 { if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return false return ErrDuplicateGroupOverride
} }
registeredGroups[override.Group] = true registeredGroups[override.Group] = true
} }
} }
return len(provider.WebhookURL) > 0 return provider.DefaultConfig.Validate()
} }
// 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(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) cfg, err := provider.GetConfig(ep.Group, alert)
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer) if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, 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(cfg.ClientConfig).Do(request)
if err != nil { if err != nil {
return err return err
} }
@@ -78,13 +116,13 @@ type Section struct {
} }
// 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) []byte { func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.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", ep.DisplayName(), alert.SuccessThreshold)
color = "#36A64F" color = "#36A64F"
} else { } else {
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold) message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
color = "#DD0000" color = "#DD0000"
} }
var formattedConditionResults string var formattedConditionResults string
@@ -105,9 +143,12 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
Type: "MessageCard", Type: "MessageCard",
Context: "http://schema.org/extensions", Context: "http://schema.org/extensions",
ThemeColor: color, ThemeColor: color,
Title: "&#x1F6A8; Gatus", Title: cfg.Title,
Text: message + description, Text: message + description,
} }
if len(body.Title) == 0 {
body.Title = "&#x1F6A8; Gatus"
}
if len(formattedConditionResults) > 0 { if len(formattedConditionResults) > 0 {
body.Sections = append(body.Sections, Section{ body.Sections = append(body.Sections, Section{
ActivityTitle: "Condition results", ActivityTitle: "Condition results",
@@ -118,19 +159,38 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
return bodyAsJSON return bodyAsJSON
} }
// 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
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert
} }
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
+78 -49
View File
@@ -7,54 +7,54 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertDefaultProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{WebhookURL: ""} invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{WebhookURL: "http://example.com"} validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
func TestAlertProvider_IsValidWithOverride(t *testing.T) { func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{ providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "http://example.com", Config: Config{WebhookURL: "http://example.com"},
Group: "", Group: "",
}, },
}, },
} }
if providerWithInvalidOverrideGroup.IsValid() { if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid") t.Error("provider Group shouldn't have been valid")
} }
providerWithInvalidOverrideTo := AlertProvider{ providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "", Config: Config{WebhookURL: ""},
Group: "group", Group: "group",
}, },
}, },
} }
if providerWithInvalidOverrideTo.IsValid() { if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid") t.Error("provider integration key shouldn't have been valid")
} }
providerWithValidOverride := AlertProvider{ providerWithValidOverride := AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "http://example.com", Config: Config{WebhookURL: "http://example.com"},
Group: "group", Group: "group",
}, },
}, },
} }
if !providerWithValidOverride.IsValid() { if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
@@ -73,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -83,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -93,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -103,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -116,10 +116,10 @@ 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: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.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},
}, },
@@ -172,17 +172,18 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
var conditionResults []*core.ConditionResult var conditionResults []*endpoint.ConditionResult
if !scenario.NoConditions { if !scenario.NoConditions {
conditionResults = []*core.ConditionResult{ conditionResults = []*endpoint.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},
} }
} }
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"}, &scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ConditionResults: conditionResults}, &endpoint.Result{ConditionResults: conditionResults},
scenario.Resolved, scenario.Resolved,
) )
if string(body) != scenario.ExpectedBody { if string(body) != scenario.ExpectedBody {
@@ -205,64 +206,92 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
} }
} }
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) { func TestAlertProvider_GetConfig(t *testing.T) {
tests := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
InputGroup string InputGroup string
ExpectedOutput string InputAlert alert.Alert
ExpectedOutput Config
}{ }{
{ {
Name: "provider-no-override-specify-no-group-should-default", Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-no-override-specify-group-should-default", Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-with-override-specify-no-group-should-default", Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
WebhookURL: "http://example01.com", Config: Config{WebhookURL: "http://example01.com"},
}, },
}, },
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-with-override-specify-group-should-override", Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
WebhookURL: "http://example01.com", Config: Config{WebhookURL: "http://group-example.com"},
}, },
}, },
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "http://example01.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://group-example.com"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
ExpectedOutput: Config{WebhookURL: "http://alert-example.com"},
}, },
} }
for _, tt := range tests { for _, scenario := range scenarios {
t.Run(tt.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput { got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput) if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
} }
}) })
} }
@@ -0,0 +1,255 @@
package teamsworkflows
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrWebhookURLNotSet = errors.New("webhook-url not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
WebhookURL string `yaml:"webhook-url"`
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
}
func (cfg *Config) Validate() error {
if len(cfg.WebhookURL) == 0 {
return ErrWebhookURLNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.WebhookURL) > 0 {
cfg.WebhookURL = override.WebhookURL
}
if len(override.Title) > 0 {
cfg.Title = override.Title
}
}
// AlertProvider is the configuration necessary for sending an alert using Teams
type AlertProvider struct {
DefaultConfig Config `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"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, 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
}
// AdaptiveCardBody represents the structure of an Adaptive Card
type AdaptiveCardBody struct {
Type string `json:"type"`
Version string `json:"version"`
Body []CardBody `json:"body"`
MSTeams MSTeamsBody `json:"msteams"`
}
// CardBody represents the body of the Adaptive Card
type CardBody struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
Wrap bool `json:"wrap"`
Separator bool `json:"separator,omitempty"`
Size string `json:"size,omitempty"`
Weight string `json:"weight,omitempty"`
Items []CardBody `json:"items,omitempty"`
Facts []Fact `json:"facts,omitempty"`
FactSet *FactSetBody `json:"factSet,omitempty"`
Style string `json:"style,omitempty"`
}
// MSTeamsBody represents the msteams options
type MSTeamsBody struct {
Width string `json:"width"`
}
// FactSetBody represents the FactSet in the Adaptive Card
type FactSetBody struct {
Type string `json:"type"`
Facts []Fact `json:"facts"`
}
// Fact represents an individual fact in the FactSet
type Fact struct {
Title string `json:"title"`
Value string `json:"value"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message string
var themeColor string
if resolved {
message = fmt.Sprintf("An alert for **%s** has been resolved after passing successfully %d time(s) in a row.", ep.DisplayName(), alert.SuccessThreshold)
themeColor = "Good" // green
} else {
message = fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row.", ep.DisplayName(), alert.FailureThreshold)
themeColor = "Attention" // red
}
// Configure default title if it's not provided
title := "⛑️ Gatus"
if cfg.Title != "" {
title = cfg.Title
}
// Build the facts from the condition results
var facts []Fact
for _, conditionResult := range result.ConditionResults {
var key string
if conditionResult.Success {
key = "✅"
} else {
key = "❌"
}
facts = append(facts, Fact{
Title: key,
Value: conditionResult.Condition,
})
}
cardContent := AdaptiveCardBody{
Type: "AdaptiveCard",
Version: "1.4", // Version 1.5 and 1.6 doesn't seem to be supported by Teams as of 27/08/2024
Body: []CardBody{
{
Type: "Container",
Style: themeColor,
Items: []CardBody{
{
Type: "Container",
Style: "Default",
Items: []CardBody{
{
Type: "TextBlock",
Text: title,
Size: "Medium",
Weight: "Bolder",
},
{
Type: "TextBlock",
Text: message,
Wrap: true,
},
{
Type: "FactSet",
Facts: facts,
},
},
},
},
},
},
MSTeams: MSTeamsBody{
Width: "Full",
},
}
attachment := map[string]interface{}{
"contentType": "application/vnd.microsoft.card.adaptive",
"content": cardContent,
}
payload := map[string]interface{}{
"type": "message",
"attachments": []interface{}{attachment},
}
bodyAsJSON, _ := json.Marshal(payload)
return bodyAsJSON
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
@@ -0,0 +1,298 @@
package teamsworkflows
import (
"encoding/json"
"net/http"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid, got", err.Error())
}
}
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
Config: Config{WebhookURL: "http://example.com"},
Group: "",
},
},
}
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
Config: Config{WebhookURL: ""},
Group: "group",
},
},
}
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Config: Config{WebhookURL: "http://example.com"},
Group: "group",
},
},
}
if err := providerWithValidOverride.Validate(); err != nil {
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{DefaultConfig: Config{WebhookURL: "http://example.com"}},
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{DefaultConfig: Config{WebhookURL: "http://example.com"}},
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{DefaultConfig: Config{WebhookURL: "http://example.com"}},
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{DefaultConfig: Config{WebhookURL: "http://example.com"}},
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(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.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
NoConditions bool
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"❌\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"❌\",\"value\":\"[STATUS] == 200\"}]}],\"style\":\"Default\"}],\"style\":\"Attention\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"✅\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"✅\",\"value\":\"[STATUS] == 200\"}]}],\"style\":\"Default\"}],\"style\":\"Good\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
},
{
Name: "resolved-with-no-conditions",
NoConditions: true,
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false}],\"style\":\"Default\"}],\"style\":\"Good\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
var conditionResults []*endpoint.ConditionResult
if !scenario.NoConditions {
conditionResults = []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
}
}
body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{ConditionResults: conditionResults},
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_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil,
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://example01.com"},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://group-example.com"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
ExpectedOutput: Config{WebhookURL: "http://alert-example.com"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
}
})
}
}
+109 -24
View File
@@ -3,51 +3,105 @@ package telegram
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
) )
const defaultAPIURL = "https://api.telegram.org" const defaultApiUrl = "https://api.telegram.org"
var (
ErrTokenNotSet = errors.New("token not set")
ErrIDNotSet = errors.New("id not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
Token string `yaml:"token"`
ID string `yaml:"id"`
ApiUrl string `yaml:"api-url"`
ClientConfig *client.Config `yaml:"client,omitempty"`
}
func (cfg *Config) Validate() error {
if len(cfg.ApiUrl) == 0 {
cfg.ApiUrl = defaultApiUrl
}
if len(cfg.Token) == 0 {
return ErrTokenNotSet
}
if len(cfg.ID) == 0 {
return ErrIDNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if override.ClientConfig != nil {
cfg.ClientConfig = override.ClientConfig
}
if len(override.Token) > 0 {
cfg.Token = override.Token
}
if len(override.ID) > 0 {
cfg.ID = override.ID
}
if len(override.ApiUrl) > 0 {
cfg.ApiUrl = override.ApiUrl
}
}
// 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"` DefaultConfig Config `yaml:",inline"`
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"`
// Overrides is a list of overrides that may be prioritized over the default configuration
Overrides []*Override `yaml:"overrides,omitempty"`
} }
// IsValid returns whether the provider's configuration is valid // Override is a configuration that may be prioritized over the default configuration
func (provider *AlertProvider) IsValid() bool { type Override struct {
if provider.ClientConfig == nil { Group string `yaml:"group"`
provider.ClientConfig = client.GetDefaultConfig() Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
} }
return len(provider.Token) > 0 && len(provider.ID) > 0 return provider.DefaultConfig.Validate()
} }
// 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(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) cfg, err := provider.GetConfig(ep.Group, alert)
apiURL := provider.APIURL if err != nil {
if apiURL == "" { return err
apiURL = defaultAPIURL
} }
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/bot%s/sendMessage", apiURL, provider.Token), buffer) buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/bot%s/sendMessage", cfg.ApiUrl, cfg.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(provider.ClientConfig).Do(request) response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
if err != nil { if err != nil {
return err return err
} }
@@ -66,12 +120,12 @@ type Body struct {
} }
// 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) []byte { func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message string var message 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.SuccessThreshold) message = fmt.Sprintf("An alert for *%s* has been resolved:\n—\n _healthcheck passing successfully %d time(s) in a row_\n— ", ep.DisplayName(), alert.SuccessThreshold)
} 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— ", ep.DisplayName(), alert.FailureThreshold)
} }
var formattedConditionResults string var formattedConditionResults string
if len(result.ConditionResults) > 0 { if len(result.ConditionResults) > 0 {
@@ -93,7 +147,7 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
text = fmt.Sprintf("⛑ *Gatus* \n%s%s", message, formattedConditionResults) text = fmt.Sprintf("⛑ *Gatus* \n%s%s", message, formattedConditionResults)
} }
bodyAsJSON, _ := json.Marshal(Body{ bodyAsJSON, _ := json.Marshal(Body{
ChatID: provider.ID, ChatID: cfg.ID,
Text: text, Text: text,
ParseMode: "MARKDOWN", ParseMode: "MARKDOWN",
}) })
@@ -104,3 +158,34 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert
} }
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
+98 -25
View File
@@ -7,27 +7,39 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
t.Run("invalid-provider", func(t *testing.T) { t.Run("invalid-provider", func(t *testing.T) {
invalidProvider := AlertProvider{Token: "", ID: ""} invalidProvider := AlertProvider{DefaultConfig: Config{Token: "", ID: ""}}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
}) })
t.Run("valid-provider", func(t *testing.T) { t.Run("valid-provider", func(t *testing.T) {
validProvider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"} validProvider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}}
if validProvider.ClientConfig != nil { if err := validProvider.Validate(); err != 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") t.Error("provider should've been valid")
} }
if validProvider.ClientConfig == nil { })
t.Error("provider client config should have been set after IsValid() was executed") t.Run("invalid-provider-override-nonexist-group", func(t *testing.T) {
invalidProvider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Config: Config{Token: "token", ID: "id"}}}}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
})
t.Run("invalid-provider-override-duplicate-group", func(t *testing.T) {
invalidProvider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group1", Config: Config{Token: "token", ID: "id"}}, {Group: "group1", Config: Config{ID: "id2"}}}}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
})
t.Run("valid-provider-with-overrides", func(t *testing.T) {
validProvider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group", Config: Config{Token: "token", ID: "id"}}}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
} }
}) })
} }
@@ -46,7 +58,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ID: "123", Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -56,7 +68,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ID: "123", Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -66,7 +78,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ID: "123", Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -76,7 +88,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ID: "123", Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -89,10 +101,10 @@ 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: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.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},
}, },
@@ -122,14 +134,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{ID: "123"}, Provider: AlertProvider{DefaultConfig: Config{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{DefaultConfig: Config{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 5 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 5 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\"}",
@@ -137,7 +149,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
{ {
Name: "resolved-with-no-conditions", Name: "resolved-with-no-conditions",
NoConditions: true, NoConditions: true,
Provider: AlertProvider{ID: "123"}, Provider: AlertProvider{DefaultConfig: Config{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 5 time(s) in a row_\\n— \\n*Description* \\n_description-2_ \\n\",\"parse_mode\":\"MARKDOWN\"}", ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 5 time(s) in a row_\\n— \\n*Description* \\n_description-2_ \\n\",\"parse_mode\":\"MARKDOWN\"}",
@@ -145,17 +157,18 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
var conditionResults []*core.ConditionResult var conditionResults []*endpoint.ConditionResult
if !scenario.NoConditions { if !scenario.NoConditions {
conditionResults = []*core.ConditionResult{ conditionResults = []*endpoint.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},
} }
} }
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"}, &scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ConditionResults: conditionResults}, &endpoint.Result{ConditionResults: conditionResults},
scenario.Resolved, scenario.Resolved,
) )
if string(body) != scenario.ExpectedBody { if string(body) != scenario.ExpectedBody {
@@ -177,3 +190,63 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
t.Error("expected default alert to be nil") t.Error("expected default alert to be nil")
} }
} }
func TestAlertProvider_GetConfig(t *testing.T) {
t.Run("get-token-with-override", func(t *testing.T) {
provider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group", Config: Config{Token: "groupToken", ID: "overrideID"}}}}
cfg, err := provider.GetConfig("group", &alert.Alert{})
if err != nil {
t.Error("expected no error, got", err)
}
if cfg.Token != "groupToken" {
t.Error("token should have been 'groupToken'")
}
if cfg.ID != "overrideID" {
t.Error("id should have been 'overrideID'")
}
})
t.Run("get-default-token-with-overridden-id", func(t *testing.T) {
provider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group", Config: Config{ID: "overrideID"}}}}
cfg, err := provider.GetConfig("group", &alert.Alert{})
if err != nil {
t.Error("expected no error, got", err)
}
if cfg.Token != provider.DefaultConfig.Token {
t.Error("token should have been the default token")
}
if cfg.ID != "overrideID" {
t.Error("id should have been 'overrideID'")
}
})
t.Run("get-default-token-with-overridden-token", func(t *testing.T) {
provider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group", Config: Config{Token: "groupToken"}}}}
cfg, err := provider.GetConfig("group", &alert.Alert{})
if err != nil {
t.Error("expected no error, got", err)
}
if cfg.Token != "groupToken" {
t.Error("token should have been 'groupToken'")
}
if cfg.ID != provider.DefaultConfig.ID {
t.Error("id should have been the default id")
}
})
t.Run("get-default-token-with-overridden-token-and-alert-token-override", func(t *testing.T) {
provider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group", Config: Config{Token: "groupToken"}}}}
alert := &alert.Alert{ProviderOverride: map[string]any{"token": "alertToken"}}
cfg, err := provider.GetConfig("group", alert)
if err != nil {
t.Error("expected no error, got", err)
}
if cfg.Token != "alertToken" {
t.Error("token should have been 'alertToken'")
}
if cfg.ID != provider.DefaultConfig.ID {
t.Error("id should have been the default id")
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = provider.ValidateOverrides("group", alert); err != nil {
t.Errorf("unexpected error: %s", err)
}
})
}
+103 -15
View File
@@ -3,41 +3,99 @@ package twilio
import ( import (
"bytes" "bytes"
"encoding/base64" "encoding/base64"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"strings"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
) )
// AlertProvider is the configuration necessary for sending an alert using Twilio var (
type AlertProvider struct { ErrSIDNotSet = errors.New("sid not set")
ErrTokenNotSet = errors.New("token not set")
ErrFromNotSet = errors.New("from not set")
ErrToNotSet = errors.New("to not set")
)
type Config struct {
SID string `yaml:"sid"` SID string `yaml:"sid"`
Token string `yaml:"token"` Token string `yaml:"token"`
From string `yaml:"from"` From string `yaml:"from"`
To string `yaml:"to"` To string `yaml:"to"`
TextTwilioTriggered string `yaml:"text-twilio-triggered,omitempty"` // String used in the SMS body and subject (optional)
TextTwilioResolved string `yaml:"text-twilio-resolved,omitempty"` // String used in the SMS body and subject (optional)
}
func (cfg *Config) Validate() error {
if len(cfg.SID) == 0 {
return ErrSIDNotSet
}
if len(cfg.Token) == 0 {
return ErrTokenNotSet
}
if len(cfg.From) == 0 {
return ErrFromNotSet
}
if len(cfg.To) == 0 {
return ErrToNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.SID) > 0 {
cfg.SID = override.SID
}
if len(override.Token) > 0 {
cfg.Token = override.Token
}
if len(override.From) > 0 {
cfg.From = override.From
}
if len(override.To) > 0 {
cfg.To = override.To
}
if len(override.TextTwilioTriggered) > 0 {
cfg.TextTwilioTriggered = override.TextTwilioTriggered
}
if len(override.TextTwilioResolved) > 0 {
cfg.TextTwilioResolved = override.TextTwilioResolved
}
}
// AlertProvider is the configuration necessary for sending an alert using Twilio
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// 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"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
return len(provider.Token) > 0 && len(provider.SID) > 0 && len(provider.From) > 0 && len(provider.To) > 0 return provider.DefaultConfig.Validate()
} }
// 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(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved))) cfg, err := provider.GetConfig(ep.Group, alert)
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json", provider.SID), buffer) if err != nil {
return err
}
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(cfg, ep, alert, result, resolved)))
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json", cfg.SID), buffer)
if err != nil { if err != nil {
return err return err
} }
request.Header.Set("Content-Type", "application/x-www-form-urlencoded") request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(provider.SID+":"+provider.Token)))) request.Header.Set("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(cfg.SID+":"+cfg.Token))))
response, err := client.GetHTTPClient(nil).Do(request) response, err := client.GetHTTPClient(nil).Do(request)
if err != nil { if err != nil {
return err return err
@@ -51,16 +109,24 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
} }
// 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(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
var message string var message string
if resolved { if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription()) if len(cfg.TextTwilioResolved) > 0 {
message = strings.Replace(strings.Replace(cfg.TextTwilioResolved, "{endpoint}", ep.DisplayName(), 1), "{description}", alert.GetDescription(), 1)
} else {
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
}
} else { } else {
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.DisplayName(), alert.GetDescription()) if len(cfg.TextTwilioTriggered) > 0 {
message = strings.Replace(strings.Replace(cfg.TextTwilioTriggered, "{endpoint}", ep.DisplayName(), 1), "{description}", alert.GetDescription(), 1)
} else {
message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
}
} }
return url.Values{ return url.Values{
"To": {provider.To}, "To": {cfg.To},
"From": {provider.From}, "From": {cfg.From},
"Body": {message}, "Body": {message},
}.Encode() }.Encode()
} }
@@ -69,3 +135,25 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert
} }
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
+145 -12
View File
@@ -1,28 +1,110 @@
package twilio package twilio
import ( import (
"net/http"
"testing" "testing"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
) )
func TestTwilioAlertProvider_IsValid(t *testing.T) { func TestTwilioAlertProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{} invalidProvider := AlertProvider{}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{ validProvider := AlertProvider{
SID: "1", DefaultConfig: Config{
Token: "1", SID: "1",
From: "1", Token: "1",
To: "1", From: "1",
To: "1",
},
} }
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") 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{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"}},
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{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"}},
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{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"}},
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{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"}},
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(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.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) { func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1" firstDescription := "description-1"
secondDescription := "description-2" secondDescription := "description-2"
@@ -35,14 +117,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{SID: "1", Token: "2", From: "3", To: "4"}, Provider: AlertProvider{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedBody: "Body=TRIGGERED%3A+endpoint-name+-+description-1&From=3&To=4", ExpectedBody: "Body=TRIGGERED%3A+endpoint-name+-+description-1&From=3&To=4",
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{SID: "1", Token: "2", From: "3", To: "4"}, Provider: AlertProvider{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedBody: "Body=RESOLVED%3A+endpoint-name+-+description-2&From=3&To=4", ExpectedBody: "Body=RESOLVED%3A+endpoint-name+-+description-2&From=3&To=4",
@@ -51,10 +133,11 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
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.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.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},
}, },
@@ -76,3 +159,53 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
t.Error("expected default alert to be nil") t.Error("expected default alert to be nil")
} }
} }
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-should-default",
Provider: AlertProvider{
DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"},
},
InputAlert: alert.Alert{},
ExpectedOutput: Config{SID: "1", Token: "2", From: "3", To: "4"},
},
{
Name: "provider-with-alert-override",
Provider: AlertProvider{
DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"},
},
InputAlert: alert.Alert{ProviderOverride: map[string]any{"sid": "5", "token": "6", "from": "7", "to": "8"}},
ExpectedOutput: Config{SID: "5", Token: "6", From: "7", To: "8"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
if err != nil {
t.Error("expected no error, got:", err.Error())
}
if got.SID != scenario.ExpectedOutput.SID {
t.Errorf("expected SID to be %s, got %s", scenario.ExpectedOutput.SID, got.SID)
}
if got.Token != scenario.ExpectedOutput.Token {
t.Errorf("expected token to be %s, got %s", scenario.ExpectedOutput.Token, got.Token)
}
if got.From != scenario.ExpectedOutput.From {
t.Errorf("expected from to be %s, got %s", scenario.ExpectedOutput.From, got.From)
}
if got.To != scenario.ExpectedOutput.To {
t.Errorf("expected to to be %s, got %s", scenario.ExpectedOutput.To, got.To)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
}
})
}
}
+183
View File
@@ -0,0 +1,183 @@
package zulip
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrBotEmailNotSet = errors.New("bot-email not set")
ErrBotAPIKeyNotSet = errors.New("bot-api-key not set")
ErrDomainNotSet = errors.New("domain not set")
ErrChannelIDNotSet = errors.New("channel-id not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
BotEmail string `yaml:"bot-email"` // Email of the bot user
BotAPIKey string `yaml:"bot-api-key"` // API key of the bot user
Domain string `yaml:"domain"` // Domain of the Zulip server
ChannelID string `yaml:"channel-id"` // ID of the channel to send the message to
}
func (cfg *Config) Validate() error {
if len(cfg.BotEmail) == 0 {
return ErrBotEmailNotSet
}
if len(cfg.BotAPIKey) == 0 {
return ErrBotAPIKeyNotSet
}
if len(cfg.Domain) == 0 {
return ErrDomainNotSet
}
if len(cfg.ChannelID) == 0 {
return ErrChannelIDNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.BotEmail) > 0 {
cfg.BotEmail = override.BotEmail
}
if len(override.BotAPIKey) > 0 {
cfg.BotAPIKey = override.BotAPIKey
}
if len(override.Domain) > 0 {
cfg.Domain = override.Domain
}
if len(override.ChannelID) > 0 {
cfg.ChannelID = override.ChannelID
}
}
// AlertProvider is the configuration necessary for sending an alert using Zulip
type AlertProvider struct {
DefaultConfig Config `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"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
buffer := bytes.NewBufferString(provider.buildRequestBody(cfg, ep, alert, result, resolved))
zulipEndpoint := fmt.Sprintf("https://%s/api/v1/messages", cfg.Domain)
request, err := http.NewRequest(http.MethodPost, zulipEndpoint, buffer)
if err != nil {
return err
}
request.SetBasicAuth(cfg.BotEmail, cfg.BotAPIKey)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("User-Agent", "Gatus")
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 nil
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
var message string
if resolved {
message = fmt.Sprintf("An alert for **%s** has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else {
message = fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
}
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
message += "\n> " + alertDescription + "\n"
}
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = ":check:"
} else {
prefix = ":cross_mark:"
}
message += fmt.Sprintf("\n%s - `%s`", prefix, conditionResult.Condition)
}
return url.Values{
"type": {"channel"},
"to": {cfg.ChannelID},
"topic": {"Gatus"},
"content": {message},
}.Encode()
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
+482
View File
@@ -0,0 +1,482 @@
package zulip
import (
"errors"
"fmt"
"net/http"
"net/url"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct {
Name string
AlertProvider AlertProvider
ExpectedError error
}{
{
Name: "Empty provider",
AlertProvider: AlertProvider{},
ExpectedError: ErrBotEmailNotSet,
},
{
Name: "Empty channel id",
AlertProvider: AlertProvider{
DefaultConfig: Config{
BotEmail: "something",
BotAPIKey: "something",
Domain: "something",
},
},
ExpectedError: ErrChannelIDNotSet,
},
{
Name: "Empty domain",
AlertProvider: AlertProvider{
DefaultConfig: Config{
BotEmail: "something",
BotAPIKey: "something",
ChannelID: "something",
},
},
ExpectedError: ErrDomainNotSet,
},
{
Name: "Empty bot api key",
AlertProvider: AlertProvider{
DefaultConfig: Config{
BotEmail: "something",
Domain: "something",
ChannelID: "something",
},
},
ExpectedError: ErrBotAPIKeyNotSet,
},
{
Name: "Empty bot email",
AlertProvider: AlertProvider{
DefaultConfig: Config{
BotAPIKey: "something",
Domain: "something",
ChannelID: "something",
},
},
ExpectedError: ErrBotEmailNotSet,
},
{
Name: "Valid provider",
AlertProvider: AlertProvider{
DefaultConfig: Config{
BotEmail: "something",
BotAPIKey: "something",
Domain: "something",
ChannelID: "something",
},
},
ExpectedError: nil,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
if err := scenario.AlertProvider.Validate(); !errors.Is(err, scenario.ExpectedError) {
t.Errorf("ExpectedError error %v, got %v", scenario.ExpectedError, err)
}
})
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
basicConfig := Config{
BotEmail: "bot-email",
BotAPIKey: "bot-api-key",
Domain: "domain",
ChannelID: "channel-id",
}
alertDesc := "Description"
basicAlert := alert.Alert{
SuccessThreshold: 2,
FailureThreshold: 3,
Description: &alertDesc,
}
testCases := []struct {
name string
provider AlertProvider
alert alert.Alert
resolved bool
hasConditions bool
expectedBody url.Values
}{
{
name: "Resolved alert with no conditions",
provider: AlertProvider{
DefaultConfig: basicConfig,
},
alert: basicAlert,
resolved: true,
hasConditions: false,
expectedBody: url.Values{
"content": {`An alert for **endpoint-Name** has been resolved after passing successfully 2 time(s) in a row
> Description
`},
"to": {"channel-id"},
"topic": {"Gatus"},
"type": {"channel"},
},
},
{
name: "Resolved alert with conditions",
provider: AlertProvider{
DefaultConfig: basicConfig,
},
alert: basicAlert,
resolved: true,
hasConditions: true,
expectedBody: url.Values{
"content": {`An alert for **endpoint-Name** has been resolved after passing successfully 2 time(s) in a row
> Description
:check: - ` + "`[CONNECTED] == true`" + `
:check: - ` + "`[STATUS] == 200`" + `
:check: - ` + "`[BODY] != \"\"`"},
"to": {"channel-id"},
"topic": {"Gatus"},
"type": {"channel"},
},
},
{
name: "Failed alert with no conditions",
provider: AlertProvider{
DefaultConfig: basicConfig,
},
alert: basicAlert,
resolved: false,
hasConditions: false,
expectedBody: url.Values{
"content": {`An alert for **endpoint-Name** has been triggered due to having failed 3 time(s) in a row
> Description
`},
"to": {"channel-id"},
"topic": {"Gatus"},
"type": {"channel"},
},
},
{
name: "Failed alert with conditions",
provider: AlertProvider{
DefaultConfig: basicConfig,
},
alert: basicAlert,
resolved: false,
hasConditions: true,
expectedBody: url.Values{
"content": {`An alert for **endpoint-Name** has been triggered due to having failed 3 time(s) in a row
> Description
:cross_mark: - ` + "`[CONNECTED] == true`" + `
:cross_mark: - ` + "`[STATUS] == 200`" + `
:cross_mark: - ` + "`[BODY] != \"\"`"},
"to": {"channel-id"},
"topic": {"Gatus"},
"type": {"channel"},
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var conditionResults []*endpoint.ConditionResult
if tc.hasConditions {
conditionResults = []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: tc.resolved},
{Condition: "[STATUS] == 200", Success: tc.resolved},
{Condition: "[BODY] != \"\"", Success: tc.resolved},
}
}
body := tc.provider.buildRequestBody(
&tc.provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-Name"},
&tc.alert,
&endpoint.Result{
ConditionResults: conditionResults,
},
tc.resolved,
)
valuesResult, err := url.ParseQuery(body)
if err != nil {
t.Error(err)
}
if fmt.Sprintf("%v", valuesResult) != fmt.Sprintf("%v", tc.expectedBody) {
t.Errorf("Expected body:\n%v\ngot:\n%v", tc.expectedBody, valuesResult)
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("ExpectedError default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("ExpectedError default alert to be nil")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
validateRequest := func(req *http.Request) {
if req.URL.String() != "https://custom-domain/api/v1/messages" {
t.Errorf("ExpectedError url https://custom-domain.zulipchat.com/api/v1/messages, got %s", req.URL.String())
}
if req.Method != http.MethodPost {
t.Errorf("ExpectedError POST request, got %s", req.Method)
}
if req.Header.Get("Content-Type") != "application/x-www-form-urlencoded" {
t.Errorf("ExpectedError Content-Type header to be application/x-www-form-urlencoded, got %s", req.Header.Get("Content-Type"))
}
if req.Header.Get("User-Agent") != "Gatus" {
t.Errorf("ExpectedError User-Agent header to be Gatus, got %s", req.Header.Get("User-Agent"))
}
}
basicConfig := Config{
BotEmail: "bot-email",
BotAPIKey: "bot-api-key",
Domain: "custom-domain",
ChannelID: "channel-id",
}
basicAlert := alert.Alert{
SuccessThreshold: 2,
FailureThreshold: 3,
}
testCases := []struct {
name string
provider AlertProvider
alert alert.Alert
resolved bool
mockRoundTripper test.MockRoundTripper
expectedError bool
}{
{
name: "resolved",
provider: AlertProvider{
DefaultConfig: basicConfig,
},
alert: basicAlert,
resolved: true,
mockRoundTripper: test.MockRoundTripper(func(req *http.Request) *http.Response {
validateRequest(req)
return &http.Response{StatusCode: http.StatusOK}
}),
expectedError: false,
},
{
name: "resolved error",
provider: AlertProvider{
DefaultConfig: basicConfig,
},
alert: basicAlert,
resolved: true,
mockRoundTripper: test.MockRoundTripper(func(req *http.Request) *http.Response {
validateRequest(req)
return &http.Response{StatusCode: http.StatusInternalServerError}
}),
expectedError: true,
},
{
name: "triggered",
provider: AlertProvider{
DefaultConfig: basicConfig,
},
alert: basicAlert,
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(req *http.Request) *http.Response {
validateRequest(req)
return &http.Response{StatusCode: http.StatusOK}
}),
expectedError: false,
},
{
name: "triggered error",
provider: AlertProvider{
DefaultConfig: basicConfig,
},
alert: basicAlert,
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(req *http.Request) *http.Response {
validateRequest(req)
return &http.Response{StatusCode: http.StatusInternalServerError}
}),
expectedError: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: tc.mockRoundTripper})
err := tc.provider.Send(
&endpoint.Endpoint{Name: "endpoint-Name"},
&tc.alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: tc.resolved},
{Condition: "[STATUS] == 200", Success: tc.resolved},
},
},
tc.resolved,
)
if tc.expectedError && err == nil {
t.Error("ExpectedError error, got none")
}
if !tc.expectedError && err != nil {
t.Errorf("ExpectedError no error, got: %v", err)
}
})
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-overrides",
Provider: AlertProvider{
DefaultConfig: Config{
BotEmail: "default-bot-email",
BotAPIKey: "default-bot-api-key",
Domain: "default-domain",
ChannelID: "default-channel-id",
},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{
BotEmail: "default-bot-email",
BotAPIKey: "default-bot-api-key",
Domain: "default-domain",
ChannelID: "default-channel-id",
},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{
BotEmail: "default-bot-email",
BotAPIKey: "default-bot-api-key",
Domain: "default-domain",
ChannelID: "default-channel-id",
},
Overrides: []Override{
{
Group: "group",
Config: Config{ChannelID: "group-channel-id"},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{
BotEmail: "default-bot-email",
BotAPIKey: "default-bot-api-key",
Domain: "default-domain",
ChannelID: "default-channel-id",
},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{
BotEmail: "default-bot-email",
BotAPIKey: "default-bot-api-key",
Domain: "default-domain",
ChannelID: "default-channel-id",
},
Overrides: []Override{
{
Group: "group",
Config: Config{
BotEmail: "group-bot-email",
BotAPIKey: "group-bot-api-key",
Domain: "group-domain",
ChannelID: "group-channel-id",
},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{
BotEmail: "group-bot-email",
BotAPIKey: "group-bot-api-key",
Domain: "group-domain",
ChannelID: "group-channel-id",
},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{
BotEmail: "default-bot-email",
BotAPIKey: "default-bot-api-key",
Domain: "default-domain",
ChannelID: "default-channel-id",
},
Overrides: []Override{
{
Group: "group",
Config: Config{
BotEmail: "group-bot-email",
BotAPIKey: "group-bot-api-key",
Domain: "group-domain",
ChannelID: "group-channel-id",
},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{
"bot-email": "alert-bot-email",
"bot-api-key": "alert-bot-api-key",
"domain": "alert-domain",
"channel-id": "alert-channel-id",
}},
ExpectedOutput: Config{
BotEmail: "alert-bot-email",
BotAPIKey: "alert-bot-api-key",
Domain: "alert-domain",
ChannelID: "alert-channel-id",
},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.BotEmail != scenario.ExpectedOutput.BotEmail {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.BotEmail, got.BotEmail)
}
if got.BotAPIKey != scenario.ExpectedOutput.BotAPIKey {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.BotAPIKey, got.BotAPIKey)
}
if got.Domain != scenario.ExpectedOutput.Domain {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.Domain, got.Domain)
}
if got.ChannelID != scenario.ExpectedOutput.ChannelID {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.ChannelID, got.ChannelID)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
}
})
}
}
+13 -4
View File
@@ -2,14 +2,15 @@ package api
import ( import (
"io/fs" "io/fs"
"log"
"net/http" "net/http"
"os" "os"
"github.com/TwiN/gatus/v5/config" "github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/ui"
"github.com/TwiN/gatus/v5/config/web" "github.com/TwiN/gatus/v5/config/web"
static "github.com/TwiN/gatus/v5/web" static "github.com/TwiN/gatus/v5/web"
"github.com/TwiN/health" "github.com/TwiN/health"
"github.com/TwiN/logr"
fiber "github.com/gofiber/fiber/v2" fiber "github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/adaptor" "github.com/gofiber/fiber/v2/middleware/adaptor"
"github.com/gofiber/fiber/v2/middleware/compress" "github.com/gofiber/fiber/v2/middleware/compress"
@@ -28,9 +29,13 @@ type API struct {
func New(cfg *config.Config) *API { func New(cfg *config.Config) *API {
api := &API{} api := &API{}
if cfg.Web == nil { if cfg.Web == nil {
log.Println("[api.New] nil web config passed as parameter. This should only happen in tests. Using default web configuration") logr.Warnf("[api.New] nil web config passed as parameter. This should only happen in tests. Using default web configuration")
cfg.Web = web.GetDefaultConfig() cfg.Web = web.GetDefaultConfig()
} }
if cfg.UI == nil {
logr.Warnf("[api.New] nil ui config passed as parameter. This should only happen in tests. Using default ui configuration")
cfg.UI = ui.GetDefaultConfig()
}
api.router = api.createRouter(cfg) api.router = api.createRouter(cfg)
return api return api
} }
@@ -42,7 +47,7 @@ func (a *API) Router() *fiber.App {
func (a *API) createRouter(cfg *config.Config) *fiber.App { func (a *API) createRouter(cfg *config.Config) *fiber.App {
app := fiber.New(fiber.Config{ app := fiber.New(fiber.Config{
ErrorHandler: func(c *fiber.Ctx, err error) error { ErrorHandler: func(c *fiber.Ctx, err error) error {
log.Printf("[api.ErrorHandler] %s", err.Error()) logr.Errorf("[api.ErrorHandler] %s", err.Error())
return fiber.DefaultErrorHandler(c, err) return fiber.DefaultErrorHandler(c, err)
}, },
ReadBufferSize: cfg.Web.ReadBufferSize, ReadBufferSize: cfg.Web.ReadBufferSize,
@@ -73,7 +78,9 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App {
unprotectedAPIRouter.Get("/v1/config", ConfigHandler{securityConfig: cfg.Security}.GetConfig) unprotectedAPIRouter.Get("/v1/config", ConfigHandler{securityConfig: cfg.Security}.GetConfig)
unprotectedAPIRouter.Get("/v1/endpoints/:key/health/badge.svg", HealthBadge) unprotectedAPIRouter.Get("/v1/endpoints/:key/health/badge.svg", HealthBadge)
unprotectedAPIRouter.Get("/v1/endpoints/:key/health/badge.shields", HealthBadgeShields) unprotectedAPIRouter.Get("/v1/endpoints/:key/health/badge.shields", HealthBadgeShields)
unprotectedAPIRouter.Get("/v1/endpoints/:key/uptimes/:duration", UptimeRaw)
unprotectedAPIRouter.Get("/v1/endpoints/:key/uptimes/:duration/badge.svg", UptimeBadge) unprotectedAPIRouter.Get("/v1/endpoints/:key/uptimes/:duration/badge.svg", UptimeBadge)
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration", ResponseTimeRaw)
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/badge.svg", ResponseTimeBadge(cfg)) unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/badge.svg", ResponseTimeBadge(cfg))
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/chart.svg", ResponseTimeChart) unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/chart.svg", ResponseTimeChart)
// This endpoint requires authz with bearer token, so technically it is protected // This endpoint requires authz with bearer token, so technically it is protected
@@ -87,6 +94,8 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App {
statusCode, body := healthHandler.GetResponseStatusCodeAndBody() statusCode, body := healthHandler.GetResponseStatusCodeAndBody()
return c.Status(statusCode).Send(body) return c.Status(statusCode).Send(body)
}) })
// Custom CSS
app.Get("/css/custom.css", CustomCSSHandler{customCSS: cfg.UI.CustomCSS}.GetCustomCSS)
// Everything else falls back on static content // Everything else falls back on static content
app.Use(redirect.New(redirect.Config{ app.Use(redirect.New(redirect.Config{
Rules: map[string]string{ Rules: map[string]string{
@@ -117,6 +126,6 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App {
} }
} }
protectedAPIRouter.Get("/v1/endpoints/statuses", EndpointStatuses(cfg)) protectedAPIRouter.Get("/v1/endpoints/statuses", EndpointStatuses(cfg))
protectedAPIRouter.Get("/v1/endpoints/:key/statuses", EndpointStatus) protectedAPIRouter.Get("/v1/endpoints/:key/statuses", EndpointStatus(cfg))
return app return app
} }
+11
View File
@@ -25,6 +25,17 @@ func TestNew(t *testing.T) {
Path: "/health", Path: "/health",
ExpectedCode: fiber.StatusOK, ExpectedCode: fiber.StatusOK,
}, },
{
Name: "custom.css",
Path: "/css/custom.css",
ExpectedCode: fiber.StatusOK,
},
{
Name: "custom.css-gzipped",
Path: "/css/custom.css",
ExpectedCode: fiber.StatusOK,
Gzip: true,
},
{ {
Name: "metrics", Name: "metrics",
Path: "/metrics", Path: "/metrics",
+30 -9
View File
@@ -4,12 +4,13 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/url"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/TwiN/gatus/v5/config" "github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/core/ui" "github.com/TwiN/gatus/v5/config/endpoint/ui"
"github.com/TwiN/gatus/v5/storage/store" "github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/storage/store/common" "github.com/TwiN/gatus/v5/storage/store/common"
"github.com/TwiN/gatus/v5/storage/store/common/paging" "github.com/TwiN/gatus/v5/storage/store/common/paging"
@@ -37,11 +38,13 @@ var (
// 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 -> 30d, 7d, 24h, 1h
func UptimeBadge(c *fiber.Ctx) error { func UptimeBadge(c *fiber.Ctx) error {
duration := c.Params("duration") duration := c.Params("duration")
var from time.Time var from time.Time
switch duration { switch duration {
case "30d":
from = time.Now().Add(-30 * 24 * time.Hour)
case "7d": case "7d":
from = time.Now().Add(-7 * 24 * time.Hour) from = time.Now().Add(-7 * 24 * time.Hour)
case "24h": case "24h":
@@ -49,9 +52,12 @@ func UptimeBadge(c *fiber.Ctx) error {
case "1h": case "1h":
from = time.Now().Add(-2 * time.Hour) // Because uptime metrics are stored by hour, we have to cheat a little from = time.Now().Add(-2 * time.Hour) // Because uptime metrics are stored by hour, we have to cheat a little
default: default:
return c.Status(400).SendString("Durations supported: 7d, 24h, 1h") return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
}
key, err := url.QueryUnescape(c.Params("key"))
if err != nil {
return c.Status(400).SendString("invalid key encoding")
} }
key := c.Params("key")
uptime, err := store.Get().GetUptimeByKey(key, from, time.Now()) uptime, err := store.Get().GetUptimeByKey(key, from, time.Now())
if err != nil { if err != nil {
if errors.Is(err, common.ErrEndpointNotFound) { if errors.Is(err, common.ErrEndpointNotFound) {
@@ -69,12 +75,14 @@ func UptimeBadge(c *fiber.Ctx) error {
// 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 -> 30d, 7d, 24h, 1h
func ResponseTimeBadge(cfg *config.Config) fiber.Handler { func ResponseTimeBadge(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error {
duration := c.Params("duration") duration := c.Params("duration")
var from time.Time var from time.Time
switch duration { switch duration {
case "30d":
from = time.Now().Add(-30 * 24 * time.Hour)
case "7d": case "7d":
from = time.Now().Add(-7 * 24 * time.Hour) from = time.Now().Add(-7 * 24 * time.Hour)
case "24h": case "24h":
@@ -82,9 +90,12 @@ func ResponseTimeBadge(cfg *config.Config) fiber.Handler {
case "1h": case "1h":
from = time.Now().Add(-2 * time.Hour) // Because response time metrics are stored by hour, we have to cheat a little from = time.Now().Add(-2 * time.Hour) // Because response time metrics are stored by hour, we have to cheat a little
default: default:
return c.Status(400).SendString("Durations supported: 7d, 24h, 1h") return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
}
key, err := url.QueryUnescape(c.Params("key"))
if err != nil {
return c.Status(400).SendString("invalid key encoding")
} }
key := c.Params("key")
averageResponseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now()) averageResponseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now())
if err != nil { if err != nil {
if errors.Is(err, common.ErrEndpointNotFound) { if errors.Is(err, common.ErrEndpointNotFound) {
@@ -103,7 +114,10 @@ func ResponseTimeBadge(cfg *config.Config) fiber.Handler {
// HealthBadge handles the automatic generation of badge based on the group name and endpoint name passed. // HealthBadge handles the automatic generation of badge based on the group name and endpoint name passed.
func HealthBadge(c *fiber.Ctx) error { func HealthBadge(c *fiber.Ctx) error {
key := c.Params("key") key, err := url.QueryUnescape(c.Params("key"))
if err != nil {
return c.Status(400).SendString("invalid key encoding")
}
pagingConfig := paging.NewEndpointStatusParams() pagingConfig := paging.NewEndpointStatusParams()
status, err := store.Get().GetEndpointStatusByKey(key, pagingConfig.WithResults(1, 1)) status, err := store.Get().GetEndpointStatusByKey(key, pagingConfig.WithResults(1, 1))
if err != nil { if err != nil {
@@ -129,7 +143,10 @@ func HealthBadge(c *fiber.Ctx) error {
} }
func HealthBadgeShields(c *fiber.Ctx) error { func HealthBadgeShields(c *fiber.Ctx) error {
key := c.Params("key") key, err := url.QueryUnescape(c.Params("key"))
if err != nil {
return c.Status(400).SendString("invalid key encoding")
}
pagingConfig := paging.NewEndpointStatusParams() pagingConfig := paging.NewEndpointStatusParams()
status, err := store.Get().GetEndpointStatusByKey(key, pagingConfig.WithResults(1, 1)) status, err := store.Get().GetEndpointStatusByKey(key, pagingConfig.WithResults(1, 1))
if err != nil { if err != nil {
@@ -161,6 +178,8 @@ func HealthBadgeShields(c *fiber.Ctx) error {
func generateUptimeBadgeSVG(duration string, uptime float64) []byte { func generateUptimeBadgeSVG(duration string, uptime float64) []byte {
var labelWidth, valueWidth, valueWidthAdjustment int var labelWidth, valueWidth, valueWidthAdjustment int
switch duration { switch duration {
case "30d":
labelWidth = 70
case "7d": case "7d":
labelWidth = 65 labelWidth = 65
case "24h": case "24h":
@@ -227,6 +246,8 @@ func getBadgeColorFromUptime(uptime float64) string {
func generateResponseTimeBadgeSVG(duration string, averageResponseTime int, key string, cfg *config.Config) []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 "30d":
labelWidth = 110
case "7d": case "7d":
labelWidth = 105 labelWidth = 105
case "24h": case "24h":
+15 -15
View File
@@ -8,8 +8,8 @@ import (
"time" "time"
"github.com/TwiN/gatus/v5/config" "github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/core/ui" "github.com/TwiN/gatus/v5/config/endpoint/ui"
"github.com/TwiN/gatus/v5/storage/store" "github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/watchdog" "github.com/TwiN/gatus/v5/watchdog"
) )
@@ -19,7 +19,7 @@ func TestBadge(t *testing.T) {
defer cache.Clear() defer cache.Clear()
cfg := &config.Config{ cfg := &config.Config{
Metrics: true, Metrics: true,
Endpoints: []*core.Endpoint{ Endpoints: []*endpoint.Endpoint{
{ {
Name: "frontend", Name: "frontend",
Group: "core", Group: "core",
@@ -34,8 +34,8 @@ func TestBadge(t *testing.T) {
cfg.Endpoints[0].UIConfig = ui.GetDefaultConfig() cfg.Endpoints[0].UIConfig = ui.GetDefaultConfig()
cfg.Endpoints[1].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[0], &endpoint.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()}) watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &endpoint.Result{Success: false, Connected: false, Duration: time.Second, Timestamp: time.Now()})
api := New(cfg) api := New(cfg)
router := api.Router() router := api.Router()
type Scenario struct { type Scenario struct {
@@ -218,30 +218,30 @@ func TestGetBadgeColorFromResponseTime(t *testing.T) {
defer cache.Clear() defer cache.Clear()
var ( var (
firstCondition = core.Condition("[STATUS] == 200") firstCondition = endpoint.Condition("[STATUS] == 200")
secondCondition = core.Condition("[RESPONSE_TIME] < 500") secondCondition = endpoint.Condition("[RESPONSE_TIME] < 500")
thirdCondition = core.Condition("[CERTIFICATE_EXPIRATION] < 72h") thirdCondition = endpoint.Condition("[CERTIFICATE_EXPIRATION] < 72h")
) )
firstTestEndpoint := core.Endpoint{ firstTestEndpoint := endpoint.Endpoint{
Name: "a", Name: "a",
URL: "https://example.org/what/ever", URL: "https://example.org/what/ever",
Method: "GET", Method: "GET",
Body: "body", Body: "body",
Interval: 30 * time.Second, Interval: 30 * time.Second,
Conditions: []core.Condition{firstCondition, secondCondition, thirdCondition}, Conditions: []endpoint.Condition{firstCondition, secondCondition, thirdCondition},
Alerts: nil, Alerts: nil,
NumberOfFailuresInARow: 0, NumberOfFailuresInARow: 0,
NumberOfSuccessesInARow: 0, NumberOfSuccessesInARow: 0,
UIConfig: ui.GetDefaultConfig(), UIConfig: ui.GetDefaultConfig(),
} }
secondTestEndpoint := core.Endpoint{ secondTestEndpoint := endpoint.Endpoint{
Name: "b", Name: "b",
URL: "https://example.org/what/ever", URL: "https://example.org/what/ever",
Method: "GET", Method: "GET",
Body: "body", Body: "body",
Interval: 30 * time.Second, Interval: 30 * time.Second,
Conditions: []core.Condition{firstCondition, secondCondition, thirdCondition}, Conditions: []endpoint.Condition{firstCondition, secondCondition, thirdCondition},
Alerts: nil, Alerts: nil,
NumberOfFailuresInARow: 0, NumberOfFailuresInARow: 0,
NumberOfSuccessesInARow: 0, NumberOfSuccessesInARow: 0,
@@ -255,10 +255,10 @@ func TestGetBadgeColorFromResponseTime(t *testing.T) {
} }
cfg := &config.Config{ cfg := &config.Config{
Metrics: true, Metrics: true,
Endpoints: []*core.Endpoint{&firstTestEndpoint, &secondTestEndpoint}, Endpoints: []*endpoint.Endpoint{&firstTestEndpoint, &secondTestEndpoint},
} }
testSuccessfulResult := core.Result{ testSuccessfulResult := endpoint.Result{
Hostname: "example.org", Hostname: "example.org",
IP: "127.0.0.1", IP: "127.0.0.1",
HTTPStatus: 200, HTTPStatus: 200,
@@ -268,7 +268,7 @@ func TestGetBadgeColorFromResponseTime(t *testing.T) {
Timestamp: time.Now(), Timestamp: time.Now(),
Duration: 150 * time.Millisecond, Duration: 150 * time.Millisecond,
CertificateExpiration: 10 * time.Hour, CertificateExpiration: 10 * time.Hour,
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{ {
Condition: "[STATUS] == 200", Condition: "[STATUS] == 200",
Success: true, Success: true,
+15 -6
View File
@@ -2,14 +2,15 @@ package api
import ( import (
"errors" "errors"
"log"
"math" "math"
"net/http" "net/http"
"net/url"
"sort" "sort"
"time" "time"
"github.com/TwiN/gatus/v5/storage/store" "github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/storage/store/common" "github.com/TwiN/gatus/v5/storage/store/common"
"github.com/TwiN/logr"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"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"
@@ -32,16 +33,24 @@ var (
func ResponseTimeChart(c *fiber.Ctx) error { func ResponseTimeChart(c *fiber.Ctx) error {
duration := c.Params("duration") duration := c.Params("duration")
chartTimestampFormatter := chart.TimeValueFormatterWithFormat(timeFormat)
var from time.Time var from time.Time
switch duration { switch duration {
case "30d":
from = time.Now().Truncate(time.Hour).Add(-30 * 24 * time.Hour)
chartTimestampFormatter = chart.TimeDateValueFormatter
case "7d": case "7d":
from = time.Now().Truncate(time.Hour).Add(-24 * 7 * time.Hour) from = time.Now().Truncate(time.Hour).Add(-7 * 24 * time.Hour)
case "24h": case "24h":
from = time.Now().Truncate(time.Hour).Add(-24 * time.Hour) from = time.Now().Truncate(time.Hour).Add(-24 * time.Hour)
default: default:
return c.Status(400).SendString("Durations supported: 7d, 24h") return c.Status(400).SendString("Durations supported: 30d, 7d, 24h")
} }
hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(c.Params("key"), from, time.Now()) key, err := url.QueryUnescape(c.Params("key"))
if err != nil {
return c.Status(400).SendString("invalid key encoding")
}
hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(key, from, time.Now())
if err != nil { if err != nil {
if errors.Is(err, common.ErrEndpointNotFound) { if errors.Is(err, common.ErrEndpointNotFound) {
return c.Status(404).SendString(err.Error()) return c.Status(404).SendString(err.Error())
@@ -88,7 +97,7 @@ func ResponseTimeChart(c *fiber.Ctx) error {
Width: 1280, Width: 1280,
Height: 300, Height: 300,
XAxis: chart.XAxis{ XAxis: chart.XAxis{
ValueFormatter: chart.TimeValueFormatterWithFormat(timeFormat), ValueFormatter: chartTimestampFormatter,
GridMajorStyle: gridStyle, GridMajorStyle: gridStyle,
GridMinorStyle: gridStyle, GridMinorStyle: gridStyle,
Style: axisStyle, Style: axisStyle,
@@ -112,7 +121,7 @@ func ResponseTimeChart(c *fiber.Ctx) error {
c.Set("Expires", "0") c.Set("Expires", "0")
c.Status(http.StatusOK) c.Status(http.StatusOK)
if err := graph.Render(chart.SVG, c); err != nil { if err := graph.Render(chart.SVG, c); err != nil {
log.Println("[api.ResponseTimeChart] Failed to render response time chart:", err.Error()) logr.Errorf("[api.ResponseTimeChart] Failed to render response time chart: %s", err.Error())
return c.Status(500).SendString(err.Error()) return c.Status(500).SendString(err.Error())
} }
return nil return nil
+9 -4
View File
@@ -7,7 +7,7 @@ import (
"time" "time"
"github.com/TwiN/gatus/v5/config" "github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/storage/store" "github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/watchdog" "github.com/TwiN/gatus/v5/watchdog"
) )
@@ -17,7 +17,7 @@ func TestResponseTimeChart(t *testing.T) {
defer cache.Clear() defer cache.Clear()
cfg := &config.Config{ cfg := &config.Config{
Metrics: true, Metrics: true,
Endpoints: []*core.Endpoint{ Endpoints: []*endpoint.Endpoint{
{ {
Name: "frontend", Name: "frontend",
Group: "core", Group: "core",
@@ -28,8 +28,8 @@ 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], &endpoint.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], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
api := New(cfg) api := New(cfg)
router := api.Router() router := api.Router()
type Scenario struct { type Scenario struct {
@@ -49,6 +49,11 @@ func TestResponseTimeChart(t *testing.T) {
Path: "/api/v1/endpoints/core_frontend/response-times/7d/chart.svg", Path: "/api/v1/endpoints/core_frontend/response-times/7d/chart.svg",
ExpectedCode: http.StatusOK, ExpectedCode: http.StatusOK,
}, },
{
Name: "chart-response-time-30d",
Path: "/api/v1/endpoints/core_frontend/response-times/30d/chart.svg",
ExpectedCode: http.StatusOK,
},
{ {
Name: "chart-response-time-with-invalid-duration", Name: "chart-response-time-with-invalid-duration",
Path: "/api/v1/endpoints/core_backend/response-times/3d/chart.svg", Path: "/api/v1/endpoints/core_backend/response-times/3d/chart.svg",
+14
View File
@@ -0,0 +1,14 @@
package api
import (
"github.com/gofiber/fiber/v2"
)
type CustomCSSHandler struct {
customCSS string
}
func (handler CustomCSSHandler) GetCustomCSS(c *fiber.Ctx) error {
c.Set("Content-Type", "text/css")
return c.Status(200).SendString(handler.customCSS)
}
+45 -38
View File
@@ -4,16 +4,16 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "net/url"
"log"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config" "github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/remote" "github.com/TwiN/gatus/v5/config/remote"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/storage/store" "github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/storage/store/common" "github.com/TwiN/gatus/v5/storage/store/common"
"github.com/TwiN/gatus/v5/storage/store/common/paging" "github.com/TwiN/gatus/v5/storage/store/common/paging"
"github.com/TwiN/logr"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
@@ -21,25 +21,25 @@ import (
// Due to how intensive this operation can be on the storage, this function leverages a cache. // Due to how intensive this operation can be on the storage, this function leverages a cache.
func EndpointStatuses(cfg *config.Config) fiber.Handler { func EndpointStatuses(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error {
page, pageSize := extractPageAndPageSizeFromRequest(c) page, pageSize := extractPageAndPageSizeFromRequest(c, cfg.Storage.MaximumNumberOfResults)
value, exists := cache.Get(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize)) value, exists := cache.Get(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize))
var data []byte var data []byte
if !exists { if !exists {
endpointStatuses, err := store.Get().GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(page, pageSize)) endpointStatuses, err := store.Get().GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(page, pageSize))
if err != nil { if err != nil {
log.Printf("[api.EndpointStatuses] Failed to retrieve endpoint statuses: %s", err.Error()) logr.Errorf("[api.EndpointStatuses] Failed to retrieve endpoint statuses: %s", err.Error())
return c.Status(500).SendString(err.Error()) return c.Status(500).SendString(err.Error())
} }
// ALPHA: Retrieve endpoint statuses from remote instances // ALPHA: Retrieve endpoint statuses from remote instances
if endpointStatusesFromRemote, err := getEndpointStatusesFromRemoteInstances(cfg.Remote); err != nil { if endpointStatusesFromRemote, err := getEndpointStatusesFromRemoteInstances(cfg.Remote); err != nil {
log.Printf("[handler.EndpointStatuses] Silently failed to retrieve endpoint statuses from remote: %s", err.Error()) logr.Errorf("[handler.EndpointStatuses] Silently failed to retrieve endpoint statuses from remote: %s", err.Error())
} else if endpointStatusesFromRemote != nil { } else if endpointStatusesFromRemote != nil {
endpointStatuses = append(endpointStatuses, endpointStatusesFromRemote...) endpointStatuses = append(endpointStatuses, endpointStatusesFromRemote...)
} }
// Marshal endpoint statuses to JSON // Marshal endpoint statuses to JSON
data, err = json.Marshal(endpointStatuses) data, err = json.Marshal(endpointStatuses)
if err != nil { if err != nil {
log.Printf("[api.EndpointStatuses] Unable to marshal object to JSON: %s", err.Error()) logr.Errorf("[api.EndpointStatuses] Unable to marshal object to JSON: %s", err.Error())
return c.Status(500).SendString("unable to marshal object to JSON") return c.Status(500).SendString("unable to marshal object to JSON")
} }
cache.SetWithTTL(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize), data, cacheTTL) cache.SetWithTTL(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize), data, cacheTTL)
@@ -51,27 +51,23 @@ func EndpointStatuses(cfg *config.Config) fiber.Handler {
} }
} }
func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*core.EndpointStatus, error) { func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*endpoint.Status, error) {
if remoteConfig == nil || len(remoteConfig.Instances) == 0 { if remoteConfig == nil || len(remoteConfig.Instances) == 0 {
return nil, nil return nil, nil
} }
var endpointStatusesFromAllRemotes []*core.EndpointStatus var endpointStatusesFromAllRemotes []*endpoint.Status
httpClient := client.GetHTTPClient(remoteConfig.ClientConfig) httpClient := client.GetHTTPClient(remoteConfig.ClientConfig)
for _, instance := range remoteConfig.Instances { for _, instance := range remoteConfig.Instances {
response, err := httpClient.Get(instance.URL) response, err := httpClient.Get(instance.URL)
if err != nil { if err != nil {
return nil, err // Log the error but continue with other instances
} logr.Errorf("[api.getEndpointStatusesFromRemoteInstances] Failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error())
body, err := io.ReadAll(response.Body)
if err != nil {
_ = response.Body.Close()
log.Printf("[api.getEndpointStatusesFromRemoteInstances] Silently failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error())
continue continue
} }
var endpointStatuses []*core.EndpointStatus var endpointStatuses []*endpoint.Status
if err = json.Unmarshal(body, &endpointStatuses); err != nil { if err = json.NewDecoder(response.Body).Decode(&endpointStatuses); err != nil {
_ = response.Body.Close() _ = response.Body.Close()
log.Printf("[api.getEndpointStatusesFromRemoteInstances] Silently failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error()) logr.Errorf("[api.getEndpointStatusesFromRemoteInstances] Failed to decode endpoint statuses from %s: %s", instance.URL, err.Error())
continue continue
} }
_ = response.Body.Close() _ = response.Body.Close()
@@ -80,29 +76,40 @@ func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*cor
} }
endpointStatusesFromAllRemotes = append(endpointStatusesFromAllRemotes, endpointStatuses...) endpointStatusesFromAllRemotes = append(endpointStatusesFromAllRemotes, endpointStatuses...)
} }
// Only return nil, error if no remote instances were successfully processed
if len(endpointStatusesFromAllRemotes) == 0 && remoteConfig.Instances != nil {
return nil, fmt.Errorf("failed to retrieve endpoint statuses from all remote instances")
}
return endpointStatusesFromAllRemotes, nil return endpointStatusesFromAllRemotes, nil
} }
// EndpointStatus retrieves a single core.EndpointStatus by group and endpoint name // EndpointStatus retrieves a single endpoint.Status by group and endpoint name
func EndpointStatus(c *fiber.Ctx) error { func EndpointStatus(cfg *config.Config) fiber.Handler {
page, pageSize := extractPageAndPageSizeFromRequest(c) return func(c *fiber.Ctx) error {
endpointStatus, err := store.Get().GetEndpointStatusByKey(c.Params("key"), paging.NewEndpointStatusParams().WithResults(page, pageSize).WithEvents(1, common.MaximumNumberOfEvents)) page, pageSize := extractPageAndPageSizeFromRequest(c, cfg.Storage.MaximumNumberOfResults)
if err != nil { key, err := url.QueryUnescape(c.Params("key"))
if errors.Is(err, common.ErrEndpointNotFound) { if err != nil {
return c.Status(404).SendString(err.Error()) logr.Errorf("[api.EndpointStatus] Failed to decode key: %s", err.Error())
return c.Status(400).SendString("invalid key encoding")
} }
log.Printf("[api.EndpointStatus] Failed to retrieve endpoint status: %s", err.Error()) endpointStatus, err := store.Get().GetEndpointStatusByKey(key, paging.NewEndpointStatusParams().WithResults(page, pageSize).WithEvents(1, cfg.Storage.MaximumNumberOfEvents))
return c.Status(500).SendString(err.Error()) if err != nil {
if errors.Is(err, common.ErrEndpointNotFound) {
return c.Status(404).SendString(err.Error())
}
logr.Errorf("[api.EndpointStatus] Failed to retrieve endpoint status: %s", err.Error())
return c.Status(500).SendString(err.Error())
}
if endpointStatus == nil { // XXX: is this check necessary?
logr.Errorf("[api.EndpointStatus] Endpoint with key=%s not found", key)
return c.Status(404).SendString("not found")
}
output, err := json.Marshal(endpointStatus)
if err != nil {
logr.Errorf("[api.EndpointStatus] Unable to marshal object to JSON: %s", err.Error())
return c.Status(500).SendString("unable to marshal object to JSON")
}
c.Set("Content-Type", "application/json")
return c.Status(200).Send(output)
} }
if endpointStatus == nil { // XXX: is this check necessary?
log.Printf("[api.EndpointStatus] Endpoint with key=%s not found", c.Params("key"))
return c.Status(404).SendString("not found")
}
output, err := json.Marshal(endpointStatus)
if err != nil {
log.Printf("[api.EndpointStatus] Unable to marshal object to JSON: %s", err.Error())
return c.Status(500).SendString("unable to marshal object to JSON")
}
c.Set("Content-Type", "application/json")
return c.Status(200).Send(output)
} }
+22 -11
View File
@@ -8,7 +8,8 @@ import (
"time" "time"
"github.com/TwiN/gatus/v5/config" "github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/storage"
"github.com/TwiN/gatus/v5/storage/store" "github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/watchdog" "github.com/TwiN/gatus/v5/watchdog"
) )
@@ -16,19 +17,19 @@ import (
var ( var (
timestamp = time.Now() timestamp = time.Now()
testEndpoint = core.Endpoint{ testEndpoint = endpoint.Endpoint{
Name: "name", Name: "name",
Group: "group", Group: "group",
URL: "https://example.org/what/ever", URL: "https://example.org/what/ever",
Method: "GET", Method: "GET",
Body: "body", Body: "body",
Interval: 30 * time.Second, Interval: 30 * time.Second,
Conditions: []core.Condition{core.Condition("[STATUS] == 200"), core.Condition("[RESPONSE_TIME] < 500"), core.Condition("[CERTIFICATE_EXPIRATION] < 72h")}, Conditions: []endpoint.Condition{endpoint.Condition("[STATUS] == 200"), endpoint.Condition("[RESPONSE_TIME] < 500"), endpoint.Condition("[CERTIFICATE_EXPIRATION] < 72h")},
Alerts: nil, Alerts: nil,
NumberOfFailuresInARow: 0, NumberOfFailuresInARow: 0,
NumberOfSuccessesInARow: 0, NumberOfSuccessesInARow: 0,
} }
testSuccessfulResult = core.Result{ testSuccessfulResult = endpoint.Result{
Hostname: "example.org", Hostname: "example.org",
IP: "127.0.0.1", IP: "127.0.0.1",
HTTPStatus: 200, HTTPStatus: 200,
@@ -38,7 +39,7 @@ var (
Timestamp: timestamp, Timestamp: timestamp,
Duration: 150 * time.Millisecond, Duration: 150 * time.Millisecond,
CertificateExpiration: 10 * time.Hour, CertificateExpiration: 10 * time.Hour,
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{ {
Condition: "[STATUS] == 200", Condition: "[STATUS] == 200",
Success: true, Success: true,
@@ -53,7 +54,7 @@ var (
}, },
}, },
} }
testUnsuccessfulResult = core.Result{ testUnsuccessfulResult = endpoint.Result{
Hostname: "example.org", Hostname: "example.org",
IP: "127.0.0.1", IP: "127.0.0.1",
HTTPStatus: 200, HTTPStatus: 200,
@@ -63,7 +64,7 @@ var (
Timestamp: timestamp, Timestamp: timestamp,
Duration: 750 * time.Millisecond, Duration: 750 * time.Millisecond,
CertificateExpiration: 10 * time.Hour, CertificateExpiration: 10 * time.Hour,
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{ {
Condition: "[STATUS] == 200", Condition: "[STATUS] == 200",
Success: true, Success: true,
@@ -85,7 +86,7 @@ func TestEndpointStatus(t *testing.T) {
defer cache.Clear() defer cache.Clear()
cfg := &config.Config{ cfg := &config.Config{
Metrics: true, Metrics: true,
Endpoints: []*core.Endpoint{ Endpoints: []*endpoint.Endpoint{
{ {
Name: "frontend", Name: "frontend",
Group: "core", Group: "core",
@@ -95,9 +96,13 @@ func TestEndpointStatus(t *testing.T) {
Group: "core", Group: "core",
}, },
}, },
Storage: &storage.Config{
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents,
},
} }
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()}) watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &endpoint.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], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
api := New(cfg) api := New(cfg)
router := api.Router() router := api.Router()
type Scenario struct { type Scenario struct {
@@ -156,7 +161,13 @@ 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{}
api := New(&config.Config{Metrics: true}) api := New(&config.Config{
Metrics: true,
Storage: &storage.Config{
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents,
},
})
router := api.Router() router := api.Router()
type Scenario struct { type Scenario struct {
Name string Name string
+11 -8
View File
@@ -2,15 +2,15 @@ package api
import ( import (
"errors" "errors"
"log"
"strings" "strings"
"time" "time"
"github.com/TwiN/gatus/v5/config" "github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/storage/store" "github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/storage/store/common" "github.com/TwiN/gatus/v5/storage/store/common"
"github.com/TwiN/gatus/v5/watchdog" "github.com/TwiN/gatus/v5/watchdog"
"github.com/TwiN/logr"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
@@ -33,31 +33,34 @@ func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
key := c.Params("key") key := c.Params("key")
externalEndpoint := cfg.GetExternalEndpointByKey(key) externalEndpoint := cfg.GetExternalEndpointByKey(key)
if externalEndpoint == nil { if externalEndpoint == nil {
log.Printf("[api.CreateExternalEndpointResult] External endpoint with key=%s not found", key) logr.Errorf("[api.CreateExternalEndpointResult] External endpoint with key=%s not found", key)
return c.Status(404).SendString("not found") return c.Status(404).SendString("not found")
} }
if externalEndpoint.Token != token { if externalEndpoint.Token != token {
log.Printf("[api.CreateExternalEndpointResult] Invalid token for external endpoint with key=%s", key) logr.Errorf("[api.CreateExternalEndpointResult] Invalid token for external endpoint with key=%s", key)
return c.Status(401).SendString("invalid token") return c.Status(401).SendString("invalid token")
} }
// Persist the result in the storage // Persist the result in the storage
result := &core.Result{ result := &endpoint.Result{
Timestamp: time.Now(), Timestamp: time.Now(),
Success: c.QueryBool("success"), Success: c.QueryBool("success"),
Errors: []string{}, Errors: []string{},
} }
if !result.Success && c.Query("error") != "" {
result.Errors = append(result.Errors, c.Query("error"))
}
convertedEndpoint := externalEndpoint.ToEndpoint() convertedEndpoint := externalEndpoint.ToEndpoint()
if err := store.Get().Insert(convertedEndpoint, result); err != nil { if err := store.Get().Insert(convertedEndpoint, result); err != nil {
if errors.Is(err, common.ErrEndpointNotFound) { if errors.Is(err, common.ErrEndpointNotFound) {
return c.Status(404).SendString(err.Error()) return c.Status(404).SendString(err.Error())
} }
log.Printf("[api.CreateExternalEndpointResult] Failed to insert result in storage: %s", err.Error()) logr.Errorf("[api.CreateExternalEndpointResult] Failed to insert result in storage: %s", err.Error())
return c.Status(500).SendString(err.Error()) return c.Status(500).SendString(err.Error())
} }
log.Printf("[api.CreateExternalEndpointResult] Successfully inserted result for external endpoint with key=%s and success=%s", c.Params("key"), success) logr.Infof("[api.CreateExternalEndpointResult] Successfully inserted result for external endpoint with key=%s and success=%s", c.Params("key"), success)
// Check if an alert should be triggered or resolved // Check if an alert should be triggered or resolved
if !cfg.Maintenance.IsUnderMaintenance() { if !cfg.Maintenance.IsUnderMaintenance() {
watchdog.HandleAlerting(convertedEndpoint, result, cfg.Alerting, cfg.Debug) watchdog.HandleAlerting(convertedEndpoint, result, cfg.Alerting)
externalEndpoint.NumberOfSuccessesInARow = convertedEndpoint.NumberOfSuccessesInARow externalEndpoint.NumberOfSuccessesInARow = convertedEndpoint.NumberOfSuccessesInARow
externalEndpoint.NumberOfFailuresInARow = convertedEndpoint.NumberOfFailuresInARow externalEndpoint.NumberOfFailuresInARow = convertedEndpoint.NumberOfFailuresInARow
} }
+37 -7
View File
@@ -9,8 +9,8 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/alerting/provider/discord" "github.com/TwiN/gatus/v5/alerting/provider/discord"
"github.com/TwiN/gatus/v5/config" "github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/maintenance" "github.com/TwiN/gatus/v5/config/maintenance"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/storage/store" "github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/storage/store/common/paging" "github.com/TwiN/gatus/v5/storage/store/common/paging"
) )
@@ -22,7 +22,7 @@ func TestCreateExternalEndpointResult(t *testing.T) {
Alerting: &alerting.Config{ Alerting: &alerting.Config{
Discord: &discord.AlertProvider{}, Discord: &discord.AlertProvider{},
}, },
ExternalEndpoints: []*core.ExternalEndpoint{ ExternalEndpoints: []*endpoint.ExternalEndpoint{
{ {
Name: "n", Name: "n",
Group: "g", Group: "g",
@@ -64,12 +64,24 @@ func TestCreateExternalEndpointResult(t *testing.T) {
AuthorizationHeaderBearerToken: "Bearer token", AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 404, ExpectedCode: 404,
}, },
{
Name: "bad-success-value",
Path: "/api/v1/endpoints/g_n/external?success=invalid",
AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 400,
},
{ {
Name: "good-token-success-true", Name: "good-token-success-true",
Path: "/api/v1/endpoints/g_n/external?success=true", Path: "/api/v1/endpoints/g_n/external?success=true",
AuthorizationHeaderBearerToken: "Bearer token", AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 200, ExpectedCode: 200,
}, },
{
Name: "good-token-success-true-with-ignored-error-because-success-true",
Path: "/api/v1/endpoints/g_n/external?success=true&error=failed",
AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 200,
},
{ {
Name: "good-token-success-false", Name: "good-token-success-false",
Path: "/api/v1/endpoints/g_n/external?success=false", Path: "/api/v1/endpoints/g_n/external?success=false",
@@ -82,6 +94,12 @@ func TestCreateExternalEndpointResult(t *testing.T) {
AuthorizationHeaderBearerToken: "Bearer token", AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 200, ExpectedCode: 200,
}, },
{
Name: "good-token-success-false-with-error",
Path: "/api/v1/endpoints/g_n/external?success=false&error=failed",
AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 200,
},
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
@@ -108,21 +126,33 @@ func TestCreateExternalEndpointResult(t *testing.T) {
if endpointStatus.Key != "g_n" { if endpointStatus.Key != "g_n" {
t.Errorf("expected key to be g_n but got %s", endpointStatus.Key) t.Errorf("expected key to be g_n but got %s", endpointStatus.Key)
} }
if len(endpointStatus.Results) != 3 { if len(endpointStatus.Results) != 5 {
t.Errorf("expected 3 results but got %d", len(endpointStatus.Results)) t.Errorf("expected 3 results but got %d", len(endpointStatus.Results))
} }
if !endpointStatus.Results[0].Success { if !endpointStatus.Results[0].Success {
t.Errorf("expected first result to be successful") t.Errorf("expected first result to be successful")
} }
if endpointStatus.Results[1].Success { if !endpointStatus.Results[1].Success {
t.Errorf("expected second result to be unsuccessful") t.Errorf("expected second result to be successful")
}
if len(endpointStatus.Results[1].Errors) > 0 {
t.Errorf("expected second result to have no errors")
} }
if endpointStatus.Results[2].Success { if endpointStatus.Results[2].Success {
t.Errorf("expected third result to be unsuccessful") t.Errorf("expected third result to be unsuccessful")
} }
if endpointStatus.Results[3].Success {
t.Errorf("expected fourth result to be unsuccessful")
}
if endpointStatus.Results[4].Success {
t.Errorf("expected fifth result to be unsuccessful")
}
if len(endpointStatus.Results[4].Errors) == 0 || endpointStatus.Results[4].Errors[0] != "failed" {
t.Errorf("expected fifth result to have errors: failed")
}
externalEndpointFromConfig := cfg.GetExternalEndpointByKey("g_n") externalEndpointFromConfig := cfg.GetExternalEndpointByKey("g_n")
if externalEndpointFromConfig.NumberOfFailuresInARow != 2 { if externalEndpointFromConfig.NumberOfFailuresInARow != 3 {
t.Errorf("expected 2 failures in a row but got %d", externalEndpointFromConfig.NumberOfFailuresInARow) t.Errorf("expected 3 failures in a row but got %d", externalEndpointFromConfig.NumberOfFailuresInARow)
} }
if externalEndpointFromConfig.NumberOfSuccessesInARow != 0 { if externalEndpointFromConfig.NumberOfSuccessesInARow != 0 {
t.Errorf("expected 0 successes in a row but got %d", externalEndpointFromConfig.NumberOfSuccessesInARow) t.Errorf("expected 0 successes in a row but got %d", externalEndpointFromConfig.NumberOfSuccessesInARow)
+75
View File
@@ -0,0 +1,75 @@
package api
import (
"errors"
"fmt"
"time"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/storage/store/common"
"github.com/gofiber/fiber/v2"
)
func UptimeRaw(c *fiber.Ctx) error {
duration := c.Params("duration")
var from time.Time
switch duration {
case "30d":
from = time.Now().Add(-30 * 24 * time.Hour)
case "7d":
from = time.Now().Add(-7 * 24 * time.Hour)
case "24h":
from = time.Now().Add(-24 * time.Hour)
case "1h":
from = time.Now().Add(-2 * time.Hour) // Because uptime metrics are stored by hour, we have to cheat a little
default:
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
}
key := c.Params("key")
uptime, err := store.Get().GetUptimeByKey(key, from, time.Now())
if err != nil {
if errors.Is(err, common.ErrEndpointNotFound) {
return c.Status(404).SendString(err.Error())
} else if errors.Is(err, common.ErrInvalidTimeRange) {
return c.Status(400).SendString(err.Error())
}
return c.Status(500).SendString(err.Error())
}
c.Set("Content-Type", "text/plain")
c.Set("Cache-Control", "no-cache, no-store, must-revalidate")
c.Set("Expires", "0")
return c.Status(200).Send([]byte(fmt.Sprintf("%f", uptime)))
}
func ResponseTimeRaw(c *fiber.Ctx) error {
duration := c.Params("duration")
var from time.Time
switch duration {
case "30d":
from = time.Now().Add(-30 * 24 * time.Hour)
case "7d":
from = time.Now().Add(-7 * 24 * time.Hour)
case "24h":
from = time.Now().Add(-24 * time.Hour)
case "1h":
from = time.Now().Add(-2 * time.Hour) // Because uptime metrics are stored by hour, we have to cheat a little
default:
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
}
key := c.Params("key")
responseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now())
if err != nil {
if errors.Is(err, common.ErrEndpointNotFound) {
return c.Status(404).SendString(err.Error())
} else if errors.Is(err, common.ErrInvalidTimeRange) {
return c.Status(400).SendString(err.Error())
}
return c.Status(500).SendString(err.Error())
}
c.Set("Content-Type", "text/plain")
c.Set("Cache-Control", "no-cache, no-store, must-revalidate")
c.Set("Expires", "0")
return c.Status(200).Send([]byte(fmt.Sprintf("%d", responseTime)))
}
+123
View File
@@ -0,0 +1,123 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/endpoint/ui"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/watchdog"
)
func TestRawDataEndpoint(t *testing.T) {
defer store.Get().Clear()
defer cache.Clear()
cfg := &config.Config{
Metrics: true,
Endpoints: []*endpoint.Endpoint{
{
Name: "frontend",
Group: "core",
},
{
Name: "backend",
Group: "core",
},
},
}
cfg.Endpoints[0].UIConfig = ui.GetDefaultConfig()
cfg.Endpoints[1].UIConfig = ui.GetDefaultConfig()
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &endpoint.Result{Success: true, Connected: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &endpoint.Result{Success: false, Connected: false, Duration: time.Second, Timestamp: time.Now()})
api := New(cfg)
router := api.Router()
type Scenario struct {
Name string
Path string
ExpectedCode int
Gzip bool
}
scenarios := []Scenario{
{
Name: "raw-uptime-1h",
Path: "/api/v1/endpoints/core_frontend/uptimes/1h",
ExpectedCode: http.StatusOK,
},
{
Name: "raw-uptime-24h",
Path: "/api/v1/endpoints/core_backend/uptimes/24h",
ExpectedCode: http.StatusOK,
},
{
Name: "raw-uptime-7d",
Path: "/api/v1/endpoints/core_frontend/uptimes/7d",
ExpectedCode: http.StatusOK,
},
{
Name: "raw-uptime-30d",
Path: "/api/v1/endpoints/core_frontend/uptimes/30d",
ExpectedCode: http.StatusOK,
},
{
Name: "raw-uptime-with-invalid-duration",
Path: "/api/v1/endpoints/core_backend/uptimes/3d",
ExpectedCode: http.StatusBadRequest,
},
{
Name: "raw-uptime-for-invalid-key",
Path: "/api/v1/endpoints/invalid_key/uptimes/7d",
ExpectedCode: http.StatusNotFound,
},
{
Name: "raw-response-times-1h",
Path: "/api/v1/endpoints/core_frontend/response-times/1h",
ExpectedCode: http.StatusOK,
},
{
Name: "raw-response-times-24h",
Path: "/api/v1/endpoints/core_backend/response-times/24h",
ExpectedCode: http.StatusOK,
},
{
Name: "raw-response-times-7d",
Path: "/api/v1/endpoints/core_frontend/response-times/7d",
ExpectedCode: http.StatusOK,
},
{
Name: "raw-response-times-30d",
Path: "/api/v1/endpoints/core_frontend/response-times/30d",
ExpectedCode: http.StatusOK,
},
{
Name: "raw-response-times-with-invalid-duration",
Path: "/api/v1/endpoints/core_backend/response-times/3d",
ExpectedCode: http.StatusBadRequest,
},
{
Name: "raw-response-times-for-invalid-key",
Path: "/api/v1/endpoints/invalid_key/response-times/7d",
ExpectedCode: http.StatusNotFound,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
if scenario.Gzip {
request.Header.Set("Accept-Encoding", "gzip")
}
response, err := router.Test(request)
if err != nil {
return
}
if response.StatusCode != scenario.ExpectedCode {
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)
}
})
}
}
+16 -5
View File
@@ -3,26 +3,37 @@ package api
import ( import (
_ "embed" _ "embed"
"html/template" "html/template"
"log"
"github.com/TwiN/gatus/v5/config/ui" "github.com/TwiN/gatus/v5/config/ui"
static "github.com/TwiN/gatus/v5/web" static "github.com/TwiN/gatus/v5/web"
"github.com/TwiN/logr"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
func SinglePageApplication(ui *ui.Config) fiber.Handler { func SinglePageApplication(uiConfig *ui.Config) fiber.Handler {
return func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error {
vd := ui.ViewData{UI: uiConfig}
{
themeFromCookie := string(c.Request().Header.Cookie("theme"))
if len(themeFromCookie) > 0 {
if themeFromCookie == "dark" {
vd.Theme = "dark"
}
} else if uiConfig.IsDarkMode() { // Since there's no theme cookie, we'll rely on ui.DarkMode
vd.Theme = "dark"
}
}
t, err := template.ParseFS(static.FileSystem, static.IndexPath) t, err := template.ParseFS(static.FileSystem, static.IndexPath)
if err != nil { if err != nil {
// This should never happen, because ui.ValidateAndSetDefaults validates that the template works. // This should never happen, because ui.ValidateAndSetDefaults validates that the template works.
log.Println("[api.SinglePageApplication] Failed to parse template. This should never happen, because the template is validated on start. Error:", err.Error()) logr.Errorf("[api.SinglePageApplication] Failed to parse template. This should never happen, because the template is validated on start. Error: %s", err.Error())
return c.Status(500).SendString("Failed to parse template. This should never happen, because the template is validated on start.") return c.Status(500).SendString("Failed to parse template. This should never happen, because the template is validated on start.")
} }
c.Set("Content-Type", "text/html") c.Set("Content-Type", "text/html")
err = t.Execute(c, ui) err = t.Execute(c, vd)
if err != nil { if err != nil {
// This should never happen, because ui.ValidateAndSetDefaults validates that the template works. // This should never happen, because ui.ValidateAndSetDefaults validates that the template works.
log.Println("[api.SinglePageApplication] Failed to execute template. This should never happen, because the template is validated on start. Error:", err.Error()) logr.Errorf("[api.SinglePageApplication] Failed to execute template. This should never happen, because the template is validated on start. Error: %s", err.Error())
return c.Status(500).SendString("Failed to parse template. This should never happen, because the template is validated on start.") return c.Status(500).SendString("Failed to parse template. This should never happen, because the template is validated on start.")
} }
return c.SendStatus(200) return c.SendStatus(200)
+43 -15
View File
@@ -9,8 +9,8 @@ import (
"time" "time"
"github.com/TwiN/gatus/v5/config" "github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/ui" "github.com/TwiN/gatus/v5/config/ui"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/storage/store" "github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/watchdog" "github.com/TwiN/gatus/v5/watchdog"
) )
@@ -20,7 +20,7 @@ func TestSinglePageApplication(t *testing.T) {
defer cache.Clear() defer cache.Clear()
cfg := &config.Config{ cfg := &config.Config{
Metrics: true, Metrics: true,
Endpoints: []*core.Endpoint{ Endpoints: []*endpoint.Endpoint{
{ {
Name: "frontend", Name: "frontend",
Group: "core", Group: "core",
@@ -34,34 +34,55 @@ func TestSinglePageApplication(t *testing.T) {
Title: "example-title", Title: "example-title",
}, },
} }
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()}) watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &endpoint.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], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
api := New(cfg) api := New(cfg)
router := api.Router() router := api.Router()
type Scenario struct { type Scenario struct {
Name string Name string
Path string Path string
ExpectedCode int Gzip bool
Gzip bool CookieDarkMode bool
UIDarkMode bool
ExpectedCode int
ExpectedDarkTheme bool
} }
scenarios := []Scenario{ scenarios := []Scenario{
{ {
Name: "frontend-home", Name: "frontend-home",
Path: "/", Path: "/",
ExpectedCode: 200, CookieDarkMode: true,
UIDarkMode: false,
ExpectedDarkTheme: true,
ExpectedCode: 200,
}, },
{ {
Name: "frontend-endpoint", Name: "frontend-endpoint-light",
Path: "/endpoints/core_frontend", Path: "/endpoints/core_frontend",
ExpectedCode: 200, CookieDarkMode: false,
UIDarkMode: false,
ExpectedDarkTheme: false,
ExpectedCode: 200,
},
{
Name: "frontend-endpoint-dark",
Path: "/endpoints/core_frontend",
CookieDarkMode: false,
UIDarkMode: true,
ExpectedDarkTheme: true,
ExpectedCode: 200,
}, },
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
cfg.UI.DarkMode = &scenario.UIDarkMode
request := httptest.NewRequest("GET", scenario.Path, http.NoBody) request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
if scenario.Gzip { if scenario.Gzip {
request.Header.Set("Accept-Encoding", "gzip") request.Header.Set("Accept-Encoding", "gzip")
} }
if scenario.CookieDarkMode {
request.Header.Set("Cookie", "theme=dark")
}
response, err := router.Test(request) response, err := router.Test(request)
if err != nil { if err != nil {
return return
@@ -71,9 +92,16 @@ func TestSinglePageApplication(t *testing.T) {
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode) t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)
} }
body, _ := io.ReadAll(response.Body) body, _ := io.ReadAll(response.Body)
if !strings.Contains(string(body), cfg.UI.Title) { strBody := string(body)
if !strings.Contains(strBody, cfg.UI.Title) {
t.Errorf("%s %s should have contained the title", request.Method, request.URL) t.Errorf("%s %s should have contained the title", request.Method, request.URL)
} }
if scenario.ExpectedDarkTheme && !strings.Contains(strBody, "class=\"dark\"") {
t.Errorf("%s %s should have responded with dark mode headers", request.Method, request.URL)
}
if !scenario.ExpectedDarkTheme && strings.Contains(strBody, "class=\"dark\"") {
t.Errorf("%s %s should not have responded with dark mode headers", request.Method, request.URL)
}
}) })
} }
} }
+3 -7
View File
@@ -3,7 +3,6 @@ package api
import ( import (
"strconv" "strconv"
"github.com/TwiN/gatus/v5/storage/store/common"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
@@ -13,12 +12,9 @@ const (
// DefaultPageSize is the default page siZE to use if none is specified or an invalid value is provided // DefaultPageSize is the default page siZE to use if none is specified or an invalid value is provided
DefaultPageSize = 20 DefaultPageSize = 20
// MaximumPageSize is the maximum page size allowed
MaximumPageSize = common.MaximumNumberOfResults
) )
func extractPageAndPageSizeFromRequest(c *fiber.Ctx) (page, pageSize int) { func extractPageAndPageSizeFromRequest(c *fiber.Ctx, maximumNumberOfResults int) (page, pageSize int) {
var err error var err error
if pageParameter := c.Query("page"); len(pageParameter) == 0 { if pageParameter := c.Query("page"); len(pageParameter) == 0 {
page = DefaultPage page = DefaultPage
@@ -38,8 +34,8 @@ func extractPageAndPageSizeFromRequest(c *fiber.Ctx) (page, pageSize int) {
if err != nil { if err != nil {
pageSize = DefaultPageSize pageSize = DefaultPageSize
} }
if pageSize > MaximumPageSize { if pageSize > maximumNumberOfResults {
pageSize = MaximumPageSize pageSize = maximumNumberOfResults
} else if pageSize < 1 { } else if pageSize < 1 {
pageSize = DefaultPageSize pageSize = DefaultPageSize
} }
+38 -30
View File
@@ -4,54 +4,62 @@ import (
"fmt" "fmt"
"testing" "testing"
"github.com/TwiN/gatus/v5/storage"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
) )
func TestExtractPageAndPageSizeFromRequest(t *testing.T) { func TestExtractPageAndPageSizeFromRequest(t *testing.T) {
type Scenario struct { type Scenario struct {
Name string Name string
Page string Page string
PageSize string PageSize string
ExpectedPage int ExpectedPage int
ExpectedPageSize int ExpectedPageSize int
MaximumNumberOfResults int
} }
scenarios := []Scenario{ scenarios := []Scenario{
{ {
Page: "1", Page: "1",
PageSize: "20", PageSize: "20",
ExpectedPage: 1, ExpectedPage: 1,
ExpectedPageSize: 20, ExpectedPageSize: 20,
MaximumNumberOfResults: 20,
}, },
{ {
Page: "2", Page: "2",
PageSize: "10", PageSize: "10",
ExpectedPage: 2, ExpectedPage: 2,
ExpectedPageSize: 10, ExpectedPageSize: 10,
MaximumNumberOfResults: 40,
}, },
{ {
Page: "2", Page: "2",
PageSize: "10", PageSize: "10",
ExpectedPage: 2, ExpectedPage: 2,
ExpectedPageSize: 10, ExpectedPageSize: 10,
MaximumNumberOfResults: 200,
}, },
{ {
Page: "1", Page: "1",
PageSize: "999999", PageSize: "999999",
ExpectedPage: 1, ExpectedPage: 1,
ExpectedPageSize: MaximumPageSize, ExpectedPageSize: storage.DefaultMaximumNumberOfResults,
MaximumNumberOfResults: 100,
}, },
{ {
Page: "-1", Page: "-1",
PageSize: "-1", PageSize: "-1",
ExpectedPage: DefaultPage, ExpectedPage: DefaultPage,
ExpectedPageSize: DefaultPageSize, ExpectedPageSize: DefaultPageSize,
MaximumNumberOfResults: 20,
}, },
{ {
Page: "invalid", Page: "invalid",
PageSize: "invalid", PageSize: "invalid",
ExpectedPage: DefaultPage, ExpectedPage: DefaultPage,
ExpectedPageSize: DefaultPageSize, ExpectedPageSize: DefaultPageSize,
MaximumNumberOfResults: 100,
}, },
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
@@ -61,7 +69,7 @@ func TestExtractPageAndPageSizeFromRequest(t *testing.T) {
c := app.AcquireCtx(&fasthttp.RequestCtx{}) c := app.AcquireCtx(&fasthttp.RequestCtx{})
defer app.ReleaseCtx(c) defer app.ReleaseCtx(c)
c.Request().SetRequestURI(fmt.Sprintf("/api/v1/statuses?page=%s&pageSize=%s", scenario.Page, scenario.PageSize)) c.Request().SetRequestURI(fmt.Sprintf("/api/v1/statuses?page=%s&pageSize=%s", scenario.Page, scenario.PageSize))
actualPage, actualPageSize := extractPageAndPageSizeFromRequest(c) actualPage, actualPageSize := extractPageAndPageSizeFromRequest(c, scenario.MaximumNumberOfResults)
if actualPage != scenario.ExpectedPage { if actualPage != scenario.ExpectedPage {
t.Errorf("expected %d, got %d", scenario.ExpectedPage, actualPage) t.Errorf("expected %d, got %d", scenario.ExpectedPage, actualPage)
} }
+93 -15
View File
@@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"net" "net"
"net/http" "net/http"
"net/smtp" "net/smtp"
@@ -14,13 +15,19 @@ import (
"time" "time"
"github.com/TwiN/gocache/v2" "github.com/TwiN/gocache/v2"
"github.com/TwiN/logr"
"github.com/TwiN/whois" "github.com/TwiN/whois"
"github.com/ishidawataru/sctp" "github.com/ishidawataru/sctp"
"github.com/miekg/dns"
ping "github.com/prometheus-community/pro-bing" ping "github.com/prometheus-community/pro-bing"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
"golang.org/x/net/websocket" "golang.org/x/net/websocket"
) )
const (
dnsPort = 53
)
var ( var (
// injectedHTTPClient is used for testing purposes // injectedHTTPClient is used for testing purposes
injectedHTTPClient *http.Client injectedHTTPClient *http.Client
@@ -91,21 +98,22 @@ func CanCreateUDPConnection(address string, config *Config) bool {
// CanCreateSCTPConnection checks whether a connection can be established with a SCTP endpoint // CanCreateSCTPConnection checks whether a connection can be established with a SCTP endpoint
func CanCreateSCTPConnection(address string, config *Config) bool { func CanCreateSCTPConnection(address string, config *Config) bool {
ch := make(chan bool) ch := make(chan bool, 1)
go (func(res chan bool) { go (func(res chan bool) {
addr, err := sctp.ResolveSCTPAddr("sctp", address) addr, err := sctp.ResolveSCTPAddr("sctp", address)
if err != nil { if err != nil {
res <- false res <- false
return
} }
conn, err := sctp.DialSCTP("sctp", nil, addr) conn, err := sctp.DialSCTP("sctp", nil, addr)
if err != nil { if err != nil {
res <- false res <- false
return
} }
_ = conn.Close() _ = conn.Close()
res <- true res <- true
})(ch) })(ch)
select { select {
case result := <-ch: case result := <-ch:
return result return result
@@ -177,7 +185,6 @@ func CanCreateSSHConnection(address, username, password string, config *Config)
} else { } else {
port = "22" port = "22"
} }
cli, err := ssh.Dial("tcp", strings.Join([]string{address, port}, ":"), &ssh.ClientConfig{ cli, err := ssh.Dial("tcp", strings.Join([]string{address, port}, ":"), &ssh.ClientConfig{
HostKeyCallback: ssh.InsecureIgnoreHostKey(), HostKeyCallback: ssh.InsecureIgnoreHostKey(),
User: username, User: username,
@@ -189,46 +196,65 @@ func CanCreateSSHConnection(address, username, password string, config *Config)
if err != nil { if err != nil {
return false, nil, err return false, nil, err
} }
return true, cli, nil return true, cli, nil
} }
func CheckSSHBanner(address string, cfg *Config) (bool, int, error) {
var port string
if strings.Contains(address, ":") {
addressAndPort := strings.Split(address, ":")
if len(addressAndPort) != 2 {
return false, 1, errors.New("invalid address for ssh, format must be ssh://host:port")
}
address = addressAndPort[0]
port = addressAndPort[1]
} else {
port = "22"
}
dialer := net.Dialer{}
connStr := net.JoinHostPort(address, port)
conn, err := dialer.Dial("tcp", connStr)
if err != nil {
return false, 1, err
}
defer conn.Close()
conn.SetReadDeadline(time.Now().Add(time.Second))
buf := make([]byte, 256)
_, err = io.ReadAtLeast(conn, buf, 1)
if err != nil {
return false, 1, err
}
return true, 0, err
}
// ExecuteSSHCommand executes a command to an address using the SSH protocol. // ExecuteSSHCommand executes a command to an address using the SSH protocol.
func ExecuteSSHCommand(sshClient *ssh.Client, body string, config *Config) (bool, int, error) { func ExecuteSSHCommand(sshClient *ssh.Client, body string, config *Config) (bool, int, error) {
type Body struct { type Body struct {
Command string `json:"command"` Command string `json:"command"`
} }
defer sshClient.Close() defer sshClient.Close()
var b Body var b Body
if err := json.Unmarshal([]byte(body), &b); err != nil { if err := json.Unmarshal([]byte(body), &b); err != nil {
return false, 0, err return false, 0, err
} }
sess, err := sshClient.NewSession() sess, err := sshClient.NewSession()
if err != nil { if err != nil {
return false, 0, err return false, 0, err
} }
err = sess.Start(b.Command) err = sess.Start(b.Command)
if err != nil { if err != nil {
return false, 0, err return false, 0, err
} }
defer sess.Close() defer sess.Close()
err = sess.Wait() err = sess.Wait()
if err == nil { if err == nil {
return true, 0, nil return true, 0, nil
} }
var exitErr *ssh.ExitError
e, ok := err.(*ssh.ExitError) if ok := errors.As(err, &exitErr); !ok {
if !ok {
return false, 0, err return false, 0, err
} }
return true, exitErr.ExitStatus(), nil
return true, e.ExitStatus(), nil
} }
// Ping checks if an address can be pinged and returns the round-trip time if the address can be pinged // Ping checks if an address can be pinged and returns the round-trip time if the address can be pinged
@@ -291,6 +317,58 @@ func QueryWebSocket(address, body string, config *Config) (bool, []byte, error)
return true, msg[:n], nil return true, msg[:n], nil
} }
func QueryDNS(queryType, queryName, url string) (connected bool, dnsRcode string, body []byte, err error) {
if !strings.Contains(url, ":") {
url = fmt.Sprintf("%s:%d", url, dnsPort)
}
queryTypeAsUint16 := dns.StringToType[queryType]
c := new(dns.Client)
m := new(dns.Msg)
m.SetQuestion(queryName, queryTypeAsUint16)
r, _, err := c.Exchange(m, url)
if err != nil {
logr.Infof("[client.QueryDNS] Error exchanging DNS message: %v", err)
return false, "", nil, err
}
connected = true
dnsRcode = dns.RcodeToString[r.Rcode]
for _, rr := range r.Answer {
switch rr.Header().Rrtype {
case dns.TypeA:
if a, ok := rr.(*dns.A); ok {
body = []byte(a.A.String())
}
case dns.TypeAAAA:
if aaaa, ok := rr.(*dns.AAAA); ok {
body = []byte(aaaa.AAAA.String())
}
case dns.TypeCNAME:
if cname, ok := rr.(*dns.CNAME); ok {
body = []byte(cname.Target)
}
case dns.TypeMX:
if mx, ok := rr.(*dns.MX); ok {
body = []byte(mx.Mx)
}
case dns.TypeNS:
if ns, ok := rr.(*dns.NS); ok {
body = []byte(ns.Ns)
}
case dns.TypePTR:
if ptr, ok := rr.(*dns.PTR); ok {
body = []byte(ptr.Ptr)
}
case dns.TypeSRV:
if srv, ok := rr.(*dns.SRV); ok {
body = []byte(fmt.Sprintf("%s:%d", srv.Target, srv.Port))
}
default:
body = []byte("query type is not supported yet")
}
}
return connected, dnsRcode, body, nil
}
// InjectHTTPClient is used to inject a custom HTTP client for testing purposes // InjectHTTPClient is used to inject a custom HTTP client for testing purposes
func InjectHTTPClient(httpClient *http.Client) { func InjectHTTPClient(httpClient *http.Client) {
injectedHTTPClient = httpClient injectedHTTPClient = httpClient
+175
View File
@@ -5,9 +5,12 @@ import (
"crypto/tls" "crypto/tls"
"io" "io"
"net/http" "net/http"
"net/netip"
"testing" "testing"
"time" "time"
"github.com/TwiN/gatus/v5/config/endpoint/dns"
"github.com/TwiN/gatus/v5/pattern"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
@@ -198,6 +201,24 @@ func TestCanPerformTLS(t *testing.T) {
wantConnected: true, wantConnected: true,
wantErr: false, wantErr: false,
}, },
{
name: "bad cert with insecure true",
args: args{
address: "expired.badssl.com:443",
insecure: true,
},
wantConnected: true,
wantErr: false,
},
{
name: "bad cert with insecure false",
args: args{
address: "expired.badssl.com:443",
insecure: false,
},
wantConnected: false,
wantErr: true,
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
@@ -334,3 +355,157 @@ func TestTlsRenegotiation(t *testing.T) {
}) })
} }
} }
func TestQueryDNS(t *testing.T) {
scenarios := []struct {
name string
inputDNS dns.Config
inputURL string
expectedDNSCode string
expectedBody string
isErrExpected bool
}{
{
name: "test Config with type A",
inputDNS: dns.Config{
QueryType: "A",
QueryName: "example.com.",
},
inputURL: "8.8.8.8",
expectedDNSCode: "NOERROR",
expectedBody: "__IPV4__",
},
{
name: "test Config with type AAAA",
inputDNS: dns.Config{
QueryType: "AAAA",
QueryName: "example.com.",
},
inputURL: "8.8.8.8",
expectedDNSCode: "NOERROR",
expectedBody: "__IPV6__",
},
{
name: "test Config with type CNAME",
inputDNS: dns.Config{
QueryType: "CNAME",
QueryName: "en.wikipedia.org.",
},
inputURL: "8.8.8.8",
expectedDNSCode: "NOERROR",
expectedBody: "dyna.wikimedia.org.",
},
{
name: "test Config with type MX",
inputDNS: dns.Config{
QueryType: "MX",
QueryName: "example.com.",
},
inputURL: "8.8.8.8",
expectedDNSCode: "NOERROR",
expectedBody: ".",
},
{
name: "test Config with type NS",
inputDNS: dns.Config{
QueryType: "NS",
QueryName: "example.com.",
},
inputURL: "8.8.8.8",
expectedDNSCode: "NOERROR",
expectedBody: "*.iana-servers.net.",
},
{
name: "test Config with type PTR",
inputDNS: dns.Config{
QueryType: "PTR",
QueryName: "8.8.8.8.in-addr.arpa.",
},
inputURL: "8.8.8.8",
expectedDNSCode: "NOERROR",
expectedBody: "dns.google.",
},
{
name: "test Config with fake type and retrieve error",
inputDNS: dns.Config{
QueryType: "B",
QueryName: "example",
},
inputURL: "8.8.8.8",
isErrExpected: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
_, dnsRCode, body, err := QueryDNS(scenario.inputDNS.QueryType, scenario.inputDNS.QueryName, scenario.inputURL)
if scenario.isErrExpected && err == nil {
t.Errorf("there should be an error")
}
if dnsRCode != scenario.expectedDNSCode {
t.Errorf("expected DNSRCode to be %s, got %s", scenario.expectedDNSCode, dnsRCode)
}
if scenario.inputDNS.QueryType == "NS" {
// Because there are often multiple nameservers backing a single domain, we'll only look at the suffix
if !pattern.Match(scenario.expectedBody, string(body)) {
t.Errorf("got %s, expected result %s,", string(body), scenario.expectedBody)
}
} else {
if string(body) != scenario.expectedBody {
// little hack to validate arbitrary ipv4/ipv6
switch scenario.expectedBody {
case "__IPV4__":
if addr, err := netip.ParseAddr(string(body)); err != nil {
t.Errorf("got %s, expected result %s", string(body), scenario.expectedBody)
} else if !addr.Is4() {
t.Errorf("got %s, expected valid IPv4", string(body))
}
case "__IPV6__":
if addr, err := netip.ParseAddr(string(body)); err != nil {
t.Errorf("got %s, expected result %s", string(body), scenario.expectedBody)
} else if !addr.Is6() {
t.Errorf("got %s, expected valid IPv6", string(body))
}
default:
t.Errorf("got %s, expected result %s", string(body), scenario.expectedBody)
}
}
}
})
time.Sleep(10 * time.Millisecond)
}
}
func TestCheckSSHBanner(t *testing.T) {
cfg := &Config{Timeout: 3}
t.Run("no-auth-ssh", func(t *testing.T) {
connected, status, err := CheckSSHBanner("tty.sdf.org", cfg)
if err != nil {
t.Errorf("Expected: error != nil, got: %v ", err)
}
if connected == false {
t.Errorf("Expected: connected == true, got: %v", connected)
}
if status != 0 {
t.Errorf("Expected: 0, got: %v", status)
}
})
t.Run("invalid-address", func(t *testing.T) {
connected, status, err := CheckSSHBanner("idontplaytheodds.com", cfg)
if err == nil {
t.Errorf("Expected: error, got: %v ", err)
}
if connected != false {
t.Errorf("Expected: connected == false, got: %v", connected)
}
if status != 1 {
t.Errorf("Expected: 1, got: %v", status)
}
})
}

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