Compare commits

..

88 Commits

Author SHA1 Message Date
TwiN
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
TwiN
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]
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]
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]
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
Xetera
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]
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
TwiN
168dafe5bb ci(labeler): Set name to labeler 2025-03-06 22:18:12 -05:00
TwiN
ca2c6899ed ci(labeler): Don't bother checking the body 2025-03-06 21:48:42 -05:00
TwiN
da3607106c ci(labeler): Fix permission issue and continue on failure 2025-03-06 21:44:41 -05:00
dependabot[bot]
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
Konstantin Nosov
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
TwiN
467e811fd5 ci(labeler): Support area/storage label 2025-03-06 21:10:03 -05:00
TwiN
11292b5f1e ci: Bump publish-latest's timeout-minutes 2025-03-04 19:50:02 -05:00
TwiN
4cd35ef51d fix(dev): Add run-binary target in Makefile 2025-03-03 21:40:46 -05:00
TwiN
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
TwiN
62af9e842f ci: Add labeler workflow (#1010) 2025-02-21 23:39:48 -05:00
TwiN
2049601be5 ci: Add publish-custom workflow 2025-02-18 20:59:40 -05:00
Lisovskii Stan
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
TwiN
46b24b849f feat(dns): Support SRV query type (#1005) 2025-02-17 10:22:44 -05:00
Alex Maras
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]
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
TwiN
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øy
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
TwiN
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
Rani
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
Oleh Rakovych
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 Laborie
9d8928dee0 fix(config): Ignore config files with double dot in it (#696) (#984) 2025-02-02 20:37:55 -05:00
dependabot[bot]
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 Wu
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 Maras
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
Rani
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 Buhl
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]
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
TwiN
69dbe4fa23 test: Fix flaky DNS tests that check for valid IP (#968) 2025-01-18 20:26:25 -05:00
Zuhair Zaki
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
Sascha Voth
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
TwiN
e1675cc747 docs: Fix test badge 2025-01-13 23:04:45 -05:00
dependabot[bot]
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]
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
tigattack
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
TwiN
d69844dc13 docs: Use stable tag in quick start instructions 2024-12-29 10:07:03 -05:00
TwiN
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
JamesHillyard
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
Daniel Santos
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]
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]
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
Mehmet Osmanoglu
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]
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
TwiN
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 Nikonowicz
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 Ashimine
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]
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]
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]
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]
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
TwiN
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
Bryan J.
be9ae6f55d docs: Fix typo (#931)
chore: typo fix

Co-authored-by: TwiN <twin@linux.com>
2024-12-14 13:28:57 -05:00
Philip Whiteside
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]
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]
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]
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]
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]
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
Stephen
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
Martin Stone
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
Tim
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
TwiN
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
Gary Hodgson
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]
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
TwiN
c758af8791 docs: Fix order of alerting config params 2024-11-10 17:48:30 -05:00
dependabot[bot]
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
Devin Buhl
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
118 changed files with 6783 additions and 2842 deletions

View File

@@ -1,5 +1,4 @@
metrics: true
debug: false
ui:
header: Example Company
link: https://example.org

2
.github/codecov.yml vendored
View File

@@ -1,7 +1,7 @@
ignore:
- "watchdog/watchdog.go"
- "storage/store/sql/specific_postgres.go" # Can't test for postgres
comment: false
coverage:
status:
patch: off

View File

@@ -22,7 +22,7 @@ jobs:
steps:
- uses: actions/setup-go@v5
with:
go-version: 1.22.2
go-version: 1.23.3
repository: "${{ github.event.inputs.repository || 'TwiN/gatus' }}"
ref: "${{ github.event.inputs.ref || 'master' }}"
- uses: actions/checkout@v4

42
.github/workflows/labeler.yml vendored Normal file
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:"* || $TITLE == *"feat("* ]]; then
gh issue edit "$NUMBER" --add-label "feature"
elif [[ $TITLE == *"fix:"* || $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

39
.github/workflows/publish-custom.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
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 IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Login to Docker Registry
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.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 }}

View File

@@ -17,10 +17,18 @@ jobs:
with:
username: ${{ secrets.DOCKER_USERNAME }}
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
uses: docker/build-push-action@v6
with:
platforms: linux/amd64
pull: true
push: true
tags: ${{ env.IMAGE_REPOSITORY }}:experimental
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -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@v6
with:
platforms: linux/amd64,linux/arm/v7,linux/arm64
pull: true
push: true
tags: ${{ env.IMAGE_REPOSITORY }}:latest

View File

@@ -11,7 +11,7 @@ jobs:
publish-latest:
runs-on: ubuntu-latest
if: ${{ (github.event.workflow_run.conclusion == 'success') && (github.event.workflow_run.head_repository.full_name == github.repository) }}
timeout-minutes: 60
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
@@ -19,16 +19,34 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- 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
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- 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.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
push: true
tags: ${{ env.IMAGE_REPOSITORY }}:latest
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -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@v6
with:
platforms: linux/amd64,linux/arm/v7,linux/arm64
pull: true
push: true
tags: |
${{ env.IMAGE_REPOSITORY }}:${{ env.RELEASE }}
${{ env.IMAGE_REPOSITORY }}:stable
${{ env.IMAGE_REPOSITORY }}:latest

View File

@@ -14,7 +14,9 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- 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
run: echo RELEASE=${GITHUB_REF/refs\/tags\//} >> $GITHUB_ENV
- name: Login to Docker Registry
@@ -22,13 +24,28 @@ jobs:
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- 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.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
push: true
tags: |
${{ env.IMAGE_REPOSITORY }}:${{ env.RELEASE }}
${{ env.IMAGE_REPOSITORY }}:stable
${{ env.IMAGE_REPOSITORY }}:latest
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

18
.github/workflows/test-ui.yml vendored Normal file
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

View File

@@ -18,7 +18,7 @@ jobs:
steps:
- uses: actions/setup-go@v5
with:
go-version: 1.22.2
go-version: 1.23.3
- uses: actions/checkout@v4
- name: Build binary to make sure it works
run: go build
@@ -28,7 +28,7 @@ jobs:
# 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
- name: Codecov
uses: codecov/codecov-action@v4.6.0
uses: codecov/codecov-action@v5.4.0
with:
files: ./coverage.txt
token: ${{ secrets.CODECOV_TOKEN }}

3
.gitignore vendored
View File

@@ -17,4 +17,5 @@ node_modules
*.db-shm
*.db-wal
gatus
config/config.yml
config/config.yml
config.yaml

View File

@@ -1,5 +1,5 @@
# Build the go application into a binary
FROM golang:alpine as builder
FROM golang:alpine AS builder
RUN apk --update add ca-certificates
WORKDIR /app
COPY . ./
@@ -15,6 +15,8 @@ FROM scratch
COPY --from=builder /app/gatus .
COPY --from=builder /app/config.yaml ./config/config.yaml
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}
ENTRYPOINT ["/gatus"]

View File

@@ -6,7 +6,11 @@ install:
.PHONY: 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
clean:
@@ -34,6 +38,9 @@ docker-build-and-run: docker-build docker-run
# Front end #
#############
frontend-install-dependencies:
npm --prefix web/app install
frontend-build:
npm --prefix web/app run build

568
README.md
View File

@@ -1,6 +1,6 @@
[![Gatus](.github/assets/logo-with-dark-text.png)](https://gatus.io)
![test](https://github.com/TwiN/gatus/workflows/test/badge.svg?branch=master)
![test](https://github.com/TwiN/gatus/actions/workflows/test.yml/badge.svg)
[![Go Report Card](https://goreportcard.com/badge/github.com/TwiN/gatus?)](https://goreportcard.com/report/github.com/TwiN/gatus)
[![codecov](https://codecov.io/gh/TwiN/gatus/branch/master/graph/badge.svg)](https://codecov.io/gh/TwiN/gatus)
[![Go version](https://img.shields.io/github/go-mod/go-version/TwiN/gatus.svg)](https://github.com/TwiN/gatus)
@@ -21,11 +21,11 @@ _Looking for a managed solution? Check out [Gatus.io](https://gatus.io)._
<summary><b>Quick start</b></summary>
```console
docker run -p 8080:8080 --name gatus twinproduction/gatus
docker run -p 8080:8080 --name gatus twinproduction/gatus:stable
```
You can also use GitHub Container Registry if you prefer:
```console
docker run -p 8080:8080 --name gatus ghcr.io/twin/gatus
docker run -p 8080:8080 --name gatus ghcr.io/twin/gatus:stable
```
For more details, see [Usage](#usage)
</details>
@@ -51,6 +51,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Storage](#storage)
- [Client configuration](#client-configuration)
- [Alerting](#alerting)
- [Configuring AWS SES alerts](#configuring-aws-ses-alerts)
- [Configuring Discord alerts](#configuring-discord-alerts)
- [Configuring Email alerts](#configuring-email-alerts)
- [Configuring Gitea alerts](#configuring-gitea-alerts)
@@ -58,6 +59,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Configuring GitLab alerts](#configuring-gitlab-alerts)
- [Configuring Google Chat alerts](#configuring-google-chat-alerts)
- [Configuring Gotify alerts](#configuring-gotify-alerts)
- [Configuring Incident.io alerts](#configuring-incidentio-alerts)
- [Configuring JetBrains Space alerts](#configuring-jetbrains-space-alerts)
- [Configuring Matrix alerts](#configuring-matrix-alerts)
- [Configuring Mattermost alerts](#configuring-mattermost-alerts)
@@ -71,9 +73,8 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Configuring Teams Workflow alerts](#configuring-teams-workflow-alerts)
- [Configuring Telegram alerts](#configuring-telegram-alerts)
- [Configuring Twilio alerts](#configuring-twilio-alerts)
- [Configuring AWS SES alerts](#configuring-aws-ses-alerts)
- [Configuring custom alerts](#configuring-custom-alerts)
- [Configuring Zulip alerts](#configuring-zulip-alerts)
- [Configuring custom alerts](#configuring-custom-alerts)
- [Setting a default alert](#setting-a-default-alert)
- [Maintenance](#maintenance)
- [Security](#security)
@@ -119,6 +120,8 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Response time](#response-time)
- [How to change the color thresholds of the response time badge](#how-to-change-the-color-thresholds-of-the-response-time-badge)
- [API](#api)
- [Raw Data](#raw-data)
- [Uptime](#uptime-1)
- [Installing as binary](#installing-as-binary)
- [High level design overview](#high-level-design-overview)
@@ -202,7 +205,7 @@ If `GATUS_CONFIG_PATH` points to a directory, all `*.yaml` and `*.yml` files ins
subdirectories are merged like so:
- All maps/objects are deep merged (i.e. you could define `alerting.slack` in one file and `alerting.pagerduty` in another file)
- All slices/arrays are appended (i.e. you can define `endpoints` in multiple files and each endpoint will be added to the final list of endpoints)
- Parameters with a primitive value (e.g. `debug`, `metrics`, `alerting.slack.webhook-url`, etc.) may only be defined once to forcefully avoid any ambiguity
- Parameters with a primitive value (e.g. `metrics`, `alerting.slack.webhook-url`, etc.) may only be defined once to forcefully avoid any ambiguity
- To clarify, this also means that you could not define `alerting.slack.webhook-url` in two files with different values. All files are merged into one before they are processed. This is by design.
> 💡 You can also use environment variables in the configuration file (e.g. `$DOMAIN`, `${DOMAIN}`)
@@ -215,7 +218,6 @@ If you want to test it locally, see [Docker](#docker).
## Configuration
| Parameter | Description | Default |
|:-----------------------------|:-------------------------------------------------------------------------------------------------------------------------------------|:---------------------------|
| `debug` | Whether to enable debug logs. | `false` |
| `metrics` | Whether to expose metrics at `/metrics`. | `false` |
| `storage` | [Storage configuration](#storage). | `{}` |
| `alerting` | [Alerting configuration](#alerting). | `{}` |
@@ -239,8 +241,13 @@ If you want to test it locally, see [Docker](#docker).
| `ui.buttons` | List of buttons to display below the header. | `[]` |
| `ui.buttons[].name` | Text to display on the button. | Required `""` |
| `ui.buttons[].link` | Link to open when the button is clicked. | Required `""` |
| `ui.custom-css` | Custom CSS | `""` |
| `ui.dark-mode` | Whether to enable dark mode by default. Note that this is superseded by the user's operating system theme preferences. | `true` |
| `maintenance` | [Maintenance configuration](#maintenance). | `{}` |
If you want more verbose logging, you may set the `GATUS_LOG_LEVEL` environment variable to `DEBUG`.
Conversely, if you want less verbose logging, you can set the aforementioned environment variable to `WARN`, `ERROR` or `FATAL`.
The default value for `GATUS_LOG_LEVEL` is `INFO`.
### Endpoints
Endpoints are URLs, applications, or services that you want to monitor. Each endpoint has a list of conditions that are
@@ -267,13 +274,15 @@ You can then configure alerts to be triggered when an endpoint is unhealthy once
| `endpoints[].ssh.username` | SSH username (e.g. example). | Required `""` |
| `endpoints[].ssh.password` | SSH password (e.g. password). | Required `""` |
| `endpoints[].alerts` | List of all alerts for a given endpoint. <br />See [Alerting](#alerting). | `[]` |
| `endpoints[].maintenance-windows` | List of all maintenance windows for a given endpoint. <br />See [Maintenance](#maintenance). | `[]` |
| `endpoints[].client` | [Client configuration](#client-configuration). | `{}` |
| `endpoints[].ui` | UI configuration at the endpoint level. | `{}` |
| `endpoints[].ui.hide-conditions` | Whether to hide conditions from the results. Note that this only hides conditions from results evaluated from the moment this was enabled. | `false` |
| `endpoints[].ui.hide-hostname` | Whether to hide the hostname in the result. | `false` |
| `endpoints[].ui.hide-url` | Whether to ensure the URL is not displayed in the results. Useful if the URL contains a token. | `false` |
| `endpoints[].ui.hide-hostname` | Whether to hide the hostname from the results. | `false` |
| `endpoints[].ui.hide-port` | Whether to hide the port from the results. | `false` |
| `endpoints[].ui.hide-url` | Whether to hide the URL from the results. Useful if the URL contains a token. | `false` |
| `endpoints[].ui.dont-resolve-failed-conditions` | Whether to resolve failed conditions for the UI. | `false` |
| `endpoints[].ui.badge.reponse-time` | List of response time thresholds. Each time a threshold is reached, the badge has a different color. | `[50, 200, 300, 500, 750]` |
| `endpoints[].ui.badge.response-time` | List of response time thresholds. Each time a threshold is reached, the badge has a different color. | `[50, 200, 300, 500, 750]` |
### External Endpoints
@@ -532,6 +541,7 @@ Alerts are configured at the endpoint level like so:
| `alerts[].success-threshold` | Number of successes in a row before an ongoing incident is marked as resolved. | `2` |
| `alerts[].send-on-resolved` | Whether to send a notification once a triggered alert is marked as resolved. | `false` |
| `alerts[].description` | Description of the alert. Will be included in the alert sent. | `""` |
| `alerts[].provider-override` | Alerting provider configuration override for the given alert type | `{}` |
Here's an example of what an alert configuration might look like at the endpoint level:
```yaml
@@ -546,43 +556,96 @@ endpoints:
send-on-resolved: true
```
You can also override global provider configuration by using `alerts[].provider-override`, like so:
```yaml
endpoints:
- name: example
url: "https://example.org"
conditions:
- "[STATUS] == 200"
alerts:
- type: slack
provider-override:
webhook-url: "https://hooks.slack.com/services/**********/**********/**********"
```
> 📝 If an alerting provider is not properly configured, all alerts configured with the provider's type will be
> ignored.
| Parameter | Description | Default |
|:--------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------|:--------|
| `alerting.custom` | Configuration for custom actions on failure or alerts. <br />See [Configuring Custom alerts](#configuring-custom-alerts). | `{}` |
| `alerting.discord` | Configuration for alerts of type `discord`. <br />See [Configuring Discord alerts](#configuring-discord-alerts). | `{}` |
| `alerting.email` | Configuration for alerts of type `email`. <br />See [Configuring Email alerts](#configuring-email-alerts). | `{}` |
| `alerting.github` | Configuration for alerts of type `github`. <br />See [Configuring GitHub alerts](#configuring-github-alerts). | `{}` |
| `alerting.gitlab` | Configuration for alerts of type `gitlab`. <br />See [Configuring GitLab alerts](#configuring-gitlab-alerts). | `{}` |
| `alerting.googlechat` | Configuration for alerts of type `googlechat`. <br />See [Configuring Google Chat alerts](#configuring-google-chat-alerts). | `{}` |
| `alerting.gotify` | Configuration for alerts of type `gotify`. <br />See [Configuring Gotify alerts](#configuring-gotify-alerts). | `{}` |
| `alerting.jetbrainsspace` | Configuration for alerts of type `jetbrainsspace`. <br />See [Configuring JetBrains Space alerts](#configuring-jetbrains-space-alerts). | `{}` |
| `alerting.matrix` | Configuration for alerts of type `matrix`. <br />See [Configuring Matrix alerts](#configuring-matrix-alerts). | `{}` |
| `alerting.mattermost` | Configuration for alerts of type `mattermost`. <br />See [Configuring Mattermost alerts](#configuring-mattermost-alerts). | `{}` |
| `alerting.messagebird` | Configuration for alerts of type `messagebird`. <br />See [Configuring Messagebird alerts](#configuring-messagebird-alerts). | `{}` |
| `alerting.ntfy` | Configuration for alerts of type `ntfy`. <br />See [Configuring Ntfy alerts](#configuring-ntfy-alerts). | `{}` |
| `alerting.opsgenie` | Configuration for alerts of type `opsgenie`. <br />See [Configuring Opsgenie alerts](#configuring-opsgenie-alerts). | `{}` |
| `alerting.pagerduty` | Configuration for alerts of type `pagerduty`. <br />See [Configuring PagerDuty alerts](#configuring-pagerduty-alerts). | `{}` |
| `alerting.pushover` | Configuration for alerts of type `pushover`. <br />See [Configuring Pushover alerts](#configuring-pushover-alerts). | `{}` |
| `alerting.slack` | Configuration for alerts of type `slack`. <br />See [Configuring Slack alerts](#configuring-slack-alerts). | `{}` |
| `alerting.teams` | Configuration for alerts of type `teams`. *(Deprecated)* <br />See [Configuring Teams alerts](#configuring-teams-alerts-deprecated). | `{}` |
| `alerting.teams-workflows` | Configuration for alerts of type `teams-workflows`. <br />See [Configuring Teams Workflow alerts](#configuring-teams-workflow-alerts). | `{}` |
| `alerting.telegram` | Configuration for alerts of type `telegram`. <br />See [Configuring Telegram alerts](#configuring-telegram-alerts). | `{}` |
| `alerting.twilio` | Settings for alerts of type `twilio`. <br />See [Configuring Twilio alerts](#configuring-twilio-alerts). | `{}` |
| Parameter | Description | Default |
|:---------------------------|:----------------------------------------------------------------------------------------------------------------------------------------|:--------|
| `alerting.awsses` | Configuration for alerts of type `awsses`. <br />See [Configuring AWS SES alerts](#configuring-aws-ses-alerts). | `{}` |
| `alerting.custom` | Configuration for custom actions on failure or alerts. <br />See [Configuring Custom alerts](#configuring-custom-alerts). | `{}` |
| `alerting.discord` | Configuration for alerts of type `discord`. <br />See [Configuring Discord alerts](#configuring-discord-alerts). | `{}` |
| `alerting.email` | Configuration for alerts of type `email`. <br />See [Configuring Email alerts](#configuring-email-alerts). | `{}` |
| `alerting.gitea` | Configuration for alerts of type `gitea`. <br />See [Configuring Gitea alerts](#configuring-gitea-alerts). | `{}` |
| `alerting.github` | Configuration for alerts of type `github`. <br />See [Configuring GitHub alerts](#configuring-github-alerts). | `{}` |
| `alerting.gitlab` | Configuration for alerts of type `gitlab`. <br />See [Configuring GitLab alerts](#configuring-gitlab-alerts). | `{}` |
| `alerting.googlechat` | Configuration for alerts of type `googlechat`. <br />See [Configuring Google Chat alerts](#configuring-google-chat-alerts). | `{}` |
| `alerting.gotify` | Configuration for alerts of type `gotify`. <br />See [Configuring Gotify alerts](#configuring-gotify-alerts). | `{}` |
| `alerting.incident-io` | Configuration for alerts of type `incident-io`. <br />See [Configuring Incident.io alerts](#configuring-incidentio-alerts). | `{}` |
| `alerting.jetbrainsspace` | Configuration for alerts of type `jetbrainsspace`. <br />See [Configuring JetBrains Space alerts](#configuring-jetbrains-space-alerts). | `{}` |
| `alerting.matrix` | Configuration for alerts of type `matrix`. <br />See [Configuring Matrix alerts](#configuring-matrix-alerts). | `{}` |
| `alerting.mattermost` | Configuration for alerts of type `mattermost`. <br />See [Configuring Mattermost alerts](#configuring-mattermost-alerts). | `{}` |
| `alerting.messagebird` | Configuration for alerts of type `messagebird`. <br />See [Configuring Messagebird alerts](#configuring-messagebird-alerts). | `{}` |
| `alerting.ntfy` | Configuration for alerts of type `ntfy`. <br />See [Configuring Ntfy alerts](#configuring-ntfy-alerts). | `{}` |
| `alerting.opsgenie` | Configuration for alerts of type `opsgenie`. <br />See [Configuring Opsgenie alerts](#configuring-opsgenie-alerts). | `{}` |
| `alerting.pagerduty` | Configuration for alerts of type `pagerduty`. <br />See [Configuring PagerDuty alerts](#configuring-pagerduty-alerts). | `{}` |
| `alerting.pushover` | Configuration for alerts of type `pushover`. <br />See [Configuring Pushover alerts](#configuring-pushover-alerts). | `{}` |
| `alerting.slack` | Configuration for alerts of type `slack`. <br />See [Configuring Slack alerts](#configuring-slack-alerts). | `{}` |
| `alerting.teams` | Configuration for alerts of type `teams`. *(Deprecated)* <br />See [Configuring Teams alerts](#configuring-teams-alerts-deprecated). | `{}` |
| `alerting.teams-workflows` | Configuration for alerts of type `teams-workflows`. <br />See [Configuring Teams Workflow alerts](#configuring-teams-workflow-alerts). | `{}` |
| `alerting.telegram` | Configuration for alerts of type `telegram`. <br />See [Configuring Telegram alerts](#configuring-telegram-alerts). | `{}` |
| `alerting.twilio` | Settings for alerts of type `twilio`. <br />See [Configuring Twilio alerts](#configuring-twilio-alerts). | `{}` |
| `alerting.zulip` | Configuration for alerts of type `zulip`. <br />See [Configuring Zulip alerts](#configuring-zulip-alerts). | `{}` |
#### Configuring AWS SES alerts
| Parameter | Description | Default |
|:-------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.aws-ses` | Settings for alerts of type `aws-ses` | `{}` |
| `alerting.aws-ses.access-key-id` | AWS Access Key ID | Optional `""` |
| `alerting.aws-ses.secret-access-key` | AWS Secret Access Key | Optional `""` |
| `alerting.aws-ses.region` | AWS Region | Required `""` |
| `alerting.aws-ses.from` | The Email address to send the emails from (should be registered in SES) | Required `""` |
| `alerting.aws-ses.to` | Comma separated list of email address to notify | Required `""` |
| `alerting.aws-ses.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
```yaml
alerting:
aws-ses:
access-key-id: "..."
secret-access-key: "..."
region: "us-east-1"
from: "status@example.com"
to: "user@example.com"
endpoints:
- name: website
interval: 30s
url: "https://twin.sh/health"
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
alerts:
- type: aws-ses
failure-threshold: 5
send-on-resolved: true
description: "healthcheck failed"
```
#### Configuring Discord alerts
| Parameter | Description | Default |
|:-------------------------------------------|:-------------------------------------------------------------------------------------------|:------------------------------------|
| `alerting.discord` | Configuration for alerts of type `discord` | `{}` |
| `alerting.discord.webhook-url` | Discord Webhook URL | Required `""` |
| `alerting.discord.title` | Title of the notification | `":helmet_with_white_cross: Gatus"` |
| `alerting.discord.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.discord.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.discord.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.discord.overrides[].webhook-url` | Discord Webhook URL | `""` |
| Parameter | Description | Default |
|:-------------------------------------|:-------------------------------------------------------------------------------------------|:------------------------------------|
| `alerting.discord` | Configuration for alerts of type `discord` | `{}` |
| `alerting.discord.webhook-url` | Discord Webhook URL | Required `""` |
| `alerting.discord.title` | Title of the notification | `":helmet_with_white_cross: Gatus"` |
| `alerting.discord.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.discord.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.discord.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.discord.overrides[].*` | See `alerting.discord.*` parameters | `{}` |
```yaml
alerting:
@@ -618,7 +681,7 @@ endpoints:
| `alerting.email.client.insecure` | Whether to skip TLS verification | `false` |
| `alerting.email.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.email.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.email.overrides[].to` | Email(s) to send the alerts to | `""` |
| `alerting.email.overrides[].*` | See `alerting.email.*` parameters | `{}` |
```yaml
alerting:
@@ -667,12 +730,12 @@ endpoints:
#### Configuring Gitea alerts
| Parameter | Description | Default |
|:---------------------------------|:-----------------------------------------------------------------------------------------------------------|:--------------|
| `alerting.gitea` | Configuration for alerts of type `gitea` | `{}` |
| `alerting.gitea.repository-url` | Gitea repository URL (e.g. `https://gitea.com/TwiN/example`) | Required `""` |
| Parameter | Description | Default |
|:--------------------------------|:-----------------------------------------------------------------------------------------------------------|:--------------|
| `alerting.gitea` | Configuration for alerts of type `gitea` | `{}` |
| `alerting.gitea.repository-url` | Gitea repository URL (e.g. `https://gitea.com/TwiN/example`) | Required `""` |
| `alerting.gitea.token` | Personal access token to use for authentication. <br />Must have at least RW on issues and RO on metadata. | Required `""` |
| `alerting.github.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert). | N/A |
| `alerting.github.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert). | N/A |
The Gitea alerting provider creates an issue prefixed with `alert(gatus):` and suffixed with the endpoint's display
name for each alert. If `send-on-resolved` is set to `true` on the endpoint alert, the issue will be automatically
@@ -782,15 +845,15 @@ endpoints:
#### Configuring Google Chat alerts
| Parameter | Description | Default |
|:----------------------------------------------|:--------------------------------------------------------------------------------------------|:--------------|
| `alerting.googlechat` | Configuration for alerts of type `googlechat` | `{}` |
| `alerting.googlechat.webhook-url` | Google Chat Webhook URL | Required `""` |
| `alerting.googlechat.client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` |
| `alerting.googlechat.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert). | N/A |
| `alerting.googlechat.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.googlechat.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.googlechat.overrides[].webhook-url` | Google Chat Webhook URL | `""` |
| Parameter | Description | Default |
|:----------------------------------------|:--------------------------------------------------------------------------------------------|:--------------|
| `alerting.googlechat` | Configuration for alerts of type `googlechat` | `{}` |
| `alerting.googlechat.webhook-url` | Google Chat Webhook URL | Required `""` |
| `alerting.googlechat.client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` |
| `alerting.googlechat.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert). | N/A |
| `alerting.googlechat.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.googlechat.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.googlechat.overrides[].*` | See `alerting.googlechat.*` parameters | `{}` |
```yaml
alerting:
@@ -847,16 +910,53 @@ Here's an example of what the notifications look like:
![Gotify notifications](.github/assets/gotify-alerts.png)
#### Configuring Incident.io alerts
| Parameter | Description | Default |
|:-----------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.incident-io` | Configuration for alerts of type `incident-io` | `{}` |
| `alerting.incident-io.url` | url to trigger an alert event. | Required `""` |
| `alerting.incident-io.auth-token` | Token that is used for authentication. | Required `""` |
| `alerting.incident-io.source-url` | Source URL | `""` |
| `alerting.incident-io.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.incident-io.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.incident-io.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.incident-io.overrides[].*` | See `alerting.incident-io.*` parameters | `{}` |
```yaml
alerting:
incident-io:
url: "*****************"
auth-token: "********************************************"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 30s
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
alerts:
- type: incident-io
description: "healthcheck failed"
send-on-resolved: true
```
In order to get the required alert source config id and authentication token, you must configure an HTTP alert source.
> **_NOTE:_** the source config id is of the form `https://api.incident.io/v2/alert_events/http/$ID` and the token is expected to be passed as a bearer token like so: `Authorization: Bearer $TOKEN`
#### Configuring JetBrains Space alerts
| Parameter | Description | Default |
|:---------------------------------------------------|:--------------------------------------------------------------------------------------------|:-----------------------|
| `alerting.jetbrainsspace` | Configuration for alerts of type `jetbrainsspace` | `{}` |
| `alerting.jetbrainsspace.project` | JetBrains Space project name | Required `""` |
| `alerting.jetbrainsspace.channel-id` | JetBrains Space Chat Channel ID | Required `""` |
| `alerting.jetbrainsspace.token` | Token that is used for authentication. | Required `""` |
| `alerting.jetbrainsspace.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.jetbrainsspace.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.jetbrainsspace.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| Parameter | Description | Default |
|:--------------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.jetbrainsspace` | Configuration for alerts of type `jetbrainsspace` | `{}` |
| `alerting.jetbrainsspace.project` | JetBrains Space project name | Required `""` |
| `alerting.jetbrainsspace.channel-id` | JetBrains Space Chat Channel ID | Required `""` |
| `alerting.jetbrainsspace.token` | Token that is used for authentication. | Required `""` |
| `alerting.jetbrainsspace.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.jetbrainsspace.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.jetbrainsspace.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.jetbrainsspace.overrides[].*` | See `alerting.jetbrainsspace.*` parameters | `{}` |
```yaml
alerting:
@@ -923,7 +1023,7 @@ endpoints:
| `alerting.mattermost.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert). | N/A |
| `alerting.mattermost.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.mattermost.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.mattermist.overrides[].webhook-url` | Mattermost Webhook URL | `""` |
| `alerting.mattermost.overrides[].*` | See `alerting.mattermost.*` parameters | `{}` |
```yaml
alerting:
@@ -985,18 +1085,21 @@ endpoints:
#### Configuring Ntfy alerts
| Parameter | Description | Default |
|:---------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------|:------------------|
| `alerting.ntfy` | Configuration for alerts of type `ntfy` | `{}` |
| `alerting.ntfy.topic` | Topic at which the alert will be sent | Required `""` |
| `alerting.ntfy.url` | The URL of the target server | `https://ntfy.sh` |
| `alerting.ntfy.token` | [Access token](https://docs.ntfy.sh/publish/#access-tokens) for restricted topics | `""` |
| `alerting.ntfy.email` | E-mail address for additional e-mail notifications | `""` |
| `alerting.ntfy.click` | Website opened when notification is clicked | `""` |
| `alerting.ntfy.priority` | The priority of the alert | `3` |
| `alerting.ntfy.disable-firebase` | Whether message push delivery via firebase should be disabled. [ntfy.sh defaults to enabled](https://docs.ntfy.sh/publish/#disable-firebase) | `false` |
| `alerting.ntfy.disable-cache` | Whether server side message caching should be disabled. [ntfy.sh defaults to enabled](https://docs.ntfy.sh/publish/#message-caching) | `false` |
| `alerting.ntfy.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| Parameter | Description | Default |
|:-------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------|:------------------|
| `alerting.ntfy` | Configuration for alerts of type `ntfy` | `{}` |
| `alerting.ntfy.topic` | Topic at which the alert will be sent | Required `""` |
| `alerting.ntfy.url` | The URL of the target server | `https://ntfy.sh` |
| `alerting.ntfy.token` | [Access token](https://docs.ntfy.sh/publish/#access-tokens) for restricted topics | `""` |
| `alerting.ntfy.email` | E-mail address for additional e-mail notifications | `""` |
| `alerting.ntfy.click` | Website opened when notification is clicked | `""` |
| `alerting.ntfy.priority` | The priority of the alert | `3` |
| `alerting.ntfy.disable-firebase` | Whether message push delivery via firebase should be disabled. [ntfy.sh defaults to enabled](https://docs.ntfy.sh/publish/#disable-firebase) | `false` |
| `alerting.ntfy.disable-cache` | Whether server side message caching should be disabled. [ntfy.sh defaults to enabled](https://docs.ntfy.sh/publish/#message-caching) | `false` |
| `alerting.ntfy.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.ntfy.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.ntfy.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.ntfy.overrides[].*` | See `alerting.ntfy.*` parameters | `{}` |
[ntfy](https://github.com/binwiederhier/ntfy) is an amazing project that allows you to subscribe to desktop
and mobile notifications, making it an awesome addition to Gatus.
@@ -1011,6 +1114,13 @@ alerting:
default-alert:
failure-threshold: 3
send-on-resolved: true
# You can also add group-specific to keys, which will
# override the to key above for the specified groups
overrides:
- group: "other"
topic: "gatus-other-test-topic"
priority: 4
click: "https://example.com"
endpoints:
- name: website
@@ -1022,6 +1132,16 @@ endpoints:
- "[RESPONSE_TIME] < 300"
alerts:
- type: ntfy
- name: other example
group: other
interval: 30m
url: "https://example.com"
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
alerts:
- type: ntfy
description: example
```
@@ -1047,14 +1167,14 @@ alerting:
#### Configuring PagerDuty alerts
| Parameter | Description | Default |
|:-------------------------------------------------|:-------------------------------------------------------------------------------------------|:--------|
| `alerting.pagerduty` | Configuration for alerts of type `pagerduty` | `{}` |
| `alerting.pagerduty.integration-key` | PagerDuty Events API v2 integration key | `""` |
| `alerting.pagerduty.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.pagerduty.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.pagerduty.overrides[].integration-key` | PagerDuty Events API v2 integration key | `""` |
| `alerting.pagerduty.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| Parameter | Description | Default |
|:---------------------------------------|:-------------------------------------------------------------------------------------------|:--------|
| `alerting.pagerduty` | Configuration for alerts of type `pagerduty` | `{}` |
| `alerting.pagerduty.integration-key` | PagerDuty Events API v2 integration key | `""` |
| `alerting.pagerduty.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.pagerduty.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.pagerduty.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.pagerduty.overrides[].*` | See `alerting.pagerduty.*` parameters | `{}` |
It is highly recommended to set `endpoints[].alerts[].send-on-resolved` to `true` for alerts
of type `pagerduty`, because unlike other alerts, the operation resulting from setting said
@@ -1107,15 +1227,16 @@ endpoints:
#### Configuring Pushover alerts
| Parameter | Description | Default |
|:---------------------------------------|:------------------------------------------------------------------------------------------------|:-----------------------------|
| `alerting.pushover` | Configuration for alerts of type `pushover` | `{}` |
| `alerting.pushover.application-token` | Pushover application token | `""` |
| `alerting.pushover.user-key` | User or group key | `""` |
| `alerting.pushover.title` | Fixed title for all messages sent via Pushover | Name of your App in Pushover |
| `alerting.pushover.priority` | Priority of all messages, ranging from -2 (very low) to 2 (emergency) | `0` |
| `alerting.pushover.sound` | Sound of all messages<br />See [sounds](https://pushover.net/api#sounds) for all valid choices. | `""` |
| `alerting.pushover.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| Parameter | Description | Default |
|:--------------------------------------|:------------------------------------------------------------------------------------------------|:-----------------------------|
| `alerting.pushover` | Configuration for alerts of type `pushover` | `{}` |
| `alerting.pushover.application-token` | Pushover application token | `""` |
| `alerting.pushover.user-key` | User or group key | `""` |
| `alerting.pushover.title` | Fixed title for all messages sent via Pushover | `"Gatus: <endpoint>"` |
| `alerting.pushover.priority` | Priority of all messages, ranging from -2 (very low) to 2 (emergency) | `0` |
| `alerting.pushover.resolved-priority` | Override the priority of messages on resolved, ranging from -2 (very low) to 2 (emergency) | `0` |
| `alerting.pushover.sound` | Sound of all messages<br />See [sounds](https://pushover.net/api#sounds) for all valid choices. | `""` |
| `alerting.pushover.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
```yaml
alerting:
@@ -1141,14 +1262,14 @@ endpoints:
#### Configuring Slack alerts
| Parameter | Description | Default |
|:------------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.slack` | Configuration for alerts of type `slack` | `{}` |
| `alerting.slack.webhook-url` | Slack Webhook URL | Required `""` |
| `alerting.slack.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.slack.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.slack.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.slack.overrides[].webhook-url` | Slack Webhook URL | `""` |
| Parameter | Description | Default |
|:-----------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.slack` | Configuration for alerts of type `slack` | `{}` |
| `alerting.slack.webhook-url` | Slack Webhook URL | Required `""` |
| `alerting.slack.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.slack.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.slack.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.slack.overrides[].*` | See `alerting.slack.*` parameters | `{}` |
```yaml
alerting:
@@ -1184,16 +1305,16 @@ Here's an example of what the notifications look like:
> **Deprecated:** Office 365 Connectors within Microsoft Teams are being retired ([Source: Microsoft DevBlog](https://devblogs.microsoft.com/microsoft365dev/retirement-of-office-365-connectors-within-microsoft-teams/)).
> Existing connectors will continue to work until December 2025. The new [Teams Workflow Alerts](#configuring-teams-workflow-alerts) should be used with Microsoft Workflows instead of this legacy configuration.
| Parameter | Description | Default |
|:-----------------------------------------|:-------------------------------------------------------------------------------------------|:--------------------|
| `alerting.teams` | Configuration for alerts of type `teams` | `{}` |
| `alerting.teams.webhook-url` | Teams Webhook URL | Required `""` |
| `alerting.teams.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.teams.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.teams.title` | Title of the notification | `"&#x1F6A8; Gatus"` |
| `alerting.teams.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.teams.overrides[].webhook-url` | Teams Webhook URL | `""` |
| `alerting.teams.client.insecure` | Whether to skip TLS verification | `false` |
| Parameter | Description | Default |
|:-----------------------------------|:-------------------------------------------------------------------------------------------|:--------------------|
| `alerting.teams` | Configuration for alerts of type `teams` | `{}` |
| `alerting.teams.webhook-url` | Teams Webhook URL | Required `""` |
| `alerting.teams.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.teams.title` | Title of the notification | `"&#x1F6A8; Gatus"` |
| `alerting.teams.client.insecure` | Whether to skip TLS verification | `false` |
| `alerting.teams.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.teams.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.teams.overrides[].*` | See `alerting.teams.*` parameters | `{}` |
```yaml
alerting:
@@ -1242,15 +1363,15 @@ Here's an example of what the notifications look like:
> [!NOTE]
> This alert is compatible with Workflows for Microsoft Teams. To setup the workflow and get the webhook URL, follow the [Microsoft Documentation](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498).
| Parameter | Description | Default |
|:---------------------------------------------------|:-------------------------------------------------------------------------------------------|:-------------------|
| `alerting.teams-workflows` | Configuration for alerts of type `teams` | `{}` |
| `alerting.teams-workflows.webhook-url` | Teams Webhook URL | Required `""` |
| `alerting.teams-workflows.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.teams-workflows.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.teams-workflows.title` | Title of the notification | `"&#x26D1; Gatus"` |
| `alerting.teams-workflows.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.teams-workflows.overrides[].webhook-url` | Teams WorkFlow Webhook URL | `""` |
| Parameter | Description | Default |
|:---------------------------------------------|:-------------------------------------------------------------------------------------------|:-------------------|
| `alerting.teams-workflows` | Configuration for alerts of type `teams` | `{}` |
| `alerting.teams-workflows.webhook-url` | Teams Webhook URL | Required `""` |
| `alerting.teams-workflows.title` | Title of the notification | `"&#x26D1; Gatus"` |
| `alerting.teams-workflows.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.teams-workflows.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.teams-workflows.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.teams-workflows.overrides[].*` | See `alerting.teams-workflows.*` parameters | `{}` |
```yaml
alerting:
@@ -1304,8 +1425,7 @@ Here's an example of what the notifications look like:
| `alerting.telegram.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.telegram.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.telegram.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.telegram.overrides[].token` | Telegram Bot Token for override default value | `""` |
| `alerting.telegram.overrides[].id` | Telegram User ID for override default value | `""` |
| `alerting.telegram.overrides[].*` | See `alerting.telegram.*` parameters | `{}` |
```yaml
alerting:
@@ -1364,45 +1484,44 @@ endpoints:
```
#### Configuring AWS SES alerts
| Parameter | Description | Default |
|:-------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.aws-ses` | Settings for alerts of type `aws-ses` | `{}` |
| `alerting.aws-ses.access-key-id` | AWS Access Key ID | Optional `""` |
| `alerting.aws-ses.secret-access-key` | AWS Secret Access Key | Optional `""` |
| `alerting.aws-ses.region` | AWS Region | Required `""` |
| `alerting.aws-ses.from` | The Email address to send the emails from (should be registered in SES) | Required `""` |
| `alerting.aws-ses.to` | Comma separated list of email address to notify | Required `""` |
| `alerting.aws-ses.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
If the `access-key-id` and `secret-access-key` are not defined Gatus will fall back to IAM authentication.
Make sure you have the ability to use `ses:SendEmail`.
#### Configuring Zulip alerts
| Parameter | Description | Default |
|:-----------------------------------|:------------------------------------------------------------------------------------|:--------------|
| `alerting.zulip` | Configuration for alerts of type `discord` | `{}` |
| `alerting.zulip.bot-email` | Bot Email | Required `""` |
| `alerting.zulip.bot-api-key` | Bot API key | Required `""` |
| `alerting.zulip.domain` | Full organization domain (e.g.: yourZulipDomain.zulipchat.com) | Required `""` |
| `alerting.zulip.channel-id` | The channel ID where Gatus will send the alerts | Required `""` |
| `alerting.zulip.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.zulip.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.zulip.overrides[].*` | See `alerting.zulip.*` parameters | `{}` |
```yaml
alerting:
aws-ses:
access-key-id: "..."
secret-access-key: "..."
region: "us-east-1"
from: "status@example.com"
to: "user@example.com"
zulip:
bot-email: gatus-bot@some.zulip.org
bot-api-key: "********************************"
domain: some.zulip.org
channel-id: 123456
endpoints:
- name: website
interval: 30s
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
alerts:
- type: aws-ses
failure-threshold: 5
send-on-resolved: true
- type: zulip
description: "healthcheck failed"
send-on-resolved: true
```
If the `access-key-id` and `secret-access-key` are not defined Gatus will fall back to IAM authentication.
Make sure you have the ability to use `ses:SendEmail`.
#### Configuring custom alerts
| Parameter | Description | Default |
@@ -1563,54 +1682,18 @@ endpoints:
- type: pagerduty
```
#### Configuring Zulip alerts
| Parameter | Description | Default |
|:-----------------------------------------|:------------------------------------------------------------------------------------|:------------------------------------|
| `alerting.zulip` | Configuration for alerts of type `discord` | `{}` |
| `alerting.zulip.bot-email` | Bot Email | Required `""` |
| `alerting.zulip.bot-api-key` | Bot API key | Required `""` |
| `alerting.zulip.domain` | Full organization domain (e.g.: yourZulipDomain.zulipchat.com) | Required `""` |
| `alerting.zulip.channel-id` | The channel ID where Gatus will send the alerts | Required `""` |
| `alerting.zulip.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.zulip.overrides[].bot-email` | . | `""` |
| `alerting.zulip.overrides[].bot-api-key` | . | `""` |
| `alerting.zulip.overrides[].domain` | . | `""` |
| `alerting.zulip.overrides[].channel-id` | . | `""` |
```yaml
alerting:
zulip:
bot-email: gatus-bot@some.zulip.org
bot-api-key: "********************************"
domain: some.zulip.org
channel-id: 123456
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
alerts:
- type: zulip
description: "healthcheck failed"
send-on-resolved: true
```
### Maintenance
If you have maintenance windows, you may not want to be annoyed by alerts.
To do that, you'll have to use the maintenance configuration:
| Parameter | Description | Default |
|:-----------------------|:---------------------------------------------------------------------------------------------------------------------------------------|:--------------|
| `maintenance.enabled` | Whether the maintenance period is enabled | `true` |
| `maintenance.start` | Time at which the maintenance window starts in `hh:mm` format (e.g. `23:00`) | Required `""` |
| `maintenance.duration` | Duration of the maintenance window (e.g. `1h`, `30m`) | Required `""` |
| `maintenance.timezone` | Timezone of the maintenance window format (e.g. `Europe/Amsterdam`).<br />See [List of tz database time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for more info | `UTC` |
| `maintenance.every` | Days on which the maintenance period applies (e.g. `[Monday, Thursday]`).<br />If left empty, the maintenance window applies every day | `[]` |
| Parameter | Description | Default |
|:-----------------------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:--------------|
| `maintenance.enabled` | Whether the maintenance period is enabled | `true` |
| `maintenance.start` | Time at which the maintenance window starts in `hh:mm` format (e.g. `23:00`) | Required `""` |
| `maintenance.duration` | Duration of the maintenance window (e.g. `1h`, `30m`) | Required `""` |
| `maintenance.timezone` | Timezone of the maintenance window format (e.g. `Europe/Amsterdam`).<br />See [List of tz database time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for more info | `UTC` |
| `maintenance.every` | Days on which the maintenance period applies (e.g. `[Monday, Thursday]`).<br />If left empty, the maintenance window applies every day | `[]` |
Here's an example:
```yaml
@@ -1630,6 +1713,19 @@ maintenance:
- Monday
- Thursday
```
You can also specify maintenance windows on a per-endpoint basis:
```yaml
endpoints:
- name: endpoint-1
url: "https://example.org"
maintenance-windows:
- start: "07:30"
duration: 40m
timezone: "Europe/Berlin"
- start: "14:30"
duration: 1h
timezone: "Europe/Berlin"
```
### Security
@@ -1655,7 +1751,7 @@ security:
password-bcrypt-base64: "JDJhJDEwJHRiMnRFakxWazZLdXBzRERQazB1TE8vckRLY05Yb1hSdnoxWU0yQ1FaYXZRSW1McmladDYu"
```
> ⚠ Make sure to carefully select to cost of the bcrypt hash. The higher the cost, the longer it takes to compute the hash,
> ⚠ Make sure to carefully select the cost of the bcrypt hash. The higher the cost, the longer it takes to compute the hash,
> and basic auth verifies the password against the hash on every request. As of 2023-01-06, I suggest a cost of 9.
@@ -1704,11 +1800,12 @@ endpoint on the same port your application is configured to run on (`web.port`).
| Metric name | Type | Description | Labels | Relevant endpoint types |
|:---------------------------------------------|:--------|:---------------------------------------------------------------------------|:--------------------------------|:------------------------|
| gatus_results_total | counter | Number of results per endpoint | key, group, name, type, success | All |
| gatus_results_total | counter | Number of results per endpoint per success state | key, group, name, type, success | All |
| gatus_results_code_total | counter | Total number of results by code | key, group, name, type, code | DNS, HTTP |
| gatus_results_connected_total | counter | Total number of results in which a connection was successfully established | key, group, name, type | All |
| gatus_results_duration_seconds | gauge | Duration of the request in seconds | key, group, name, type | All |
| gatus_results_certificate_expiration_seconds | gauge | Number of seconds until the certificate expires | key, group, name, type | HTTP, STARTTLS |
| gatus_results_endpoint_success | gauge | Displays whether or not the endpoint was a success (0 failure, 1 success) | key, group, name, type | All |
See [examples/docker-compose-grafana-prometheus](.examples/docker-compose-grafana-prometheus) for further documentation as well as an example.
@@ -1890,8 +1987,7 @@ To modify the timeout, see [Client configuration](#client-configuration).
### Monitoring a TCP endpoint
By prefixing `endpoints[].url` with `tcp:\\`, you can monitor TCP endpoints at a very basic level:
By prefixing `endpoints[].url` with `tcp://`, you can monitor TCP endpoints at a very basic level:
```yaml
endpoints:
- name: redis
@@ -1912,8 +2008,7 @@ This works for applications such as databases (Postgres, MySQL, etc.) and caches
### Monitoring a UDP endpoint
By prefixing `endpoints[].url` with `udp:\\`, you can monitor UDP endpoints at a very basic level:
By prefixing `endpoints[].url` with `udp://`, you can monitor UDP endpoints at a very basic level:
```yaml
endpoints:
- name: example
@@ -1929,8 +2024,7 @@ This works for UDP based application.
### Monitoring a SCTP endpoint
By prefixing `endpoints[].url` with `sctp:\\`, you can monitor Stream Control Transmission Protocol (SCTP) endpoints at a very basic level:
By prefixing `endpoints[].url` with `sctp://`, you can monitor Stream Control Transmission Protocol (SCTP) endpoints at a very basic level:
```yaml
endpoints:
- name: example
@@ -1947,7 +2041,6 @@ This works for SCTP based application.
### Monitoring a WebSocket endpoint
By prefixing `endpoints[].url` with `ws://` or `wss://`, you can monitor WebSocket endpoints at a very basic level:
```yaml
endpoints:
- name: example
@@ -1963,9 +2056,8 @@ shows whether the connection was successfully established.
### Monitoring an endpoint using ICMP
By prefixing `endpoints[].url` with `icmp:\\`, you can monitor endpoints at a very basic level using ICMP, or more
By prefixing `endpoints[].url` with `icmp://`, you can monitor endpoints at a very basic level using ICMP, or more
commonly known as "ping" or "echo":
```yaml
endpoints:
- name: ping-example
@@ -2002,7 +2094,7 @@ There are two placeholders that can be used in the conditions for endpoints of t
### Monitoring an endpoint using SSH
You can monitor endpoints using SSH by prefixing `endpoints[].url` with `ssh:\\`:
You can monitor endpoints using SSH by prefixing `endpoints[].url` with `ssh://`:
```yaml
endpoints:
- name: ssh-example
@@ -2020,6 +2112,23 @@ endpoints:
- "[STATUS] == 0"
```
you can also use no authentication to monitor the endpoint by not specifying the username
and password fields.
```yaml
endpoints:
- name: ssh-example
url: "ssh://example.com:22" # port is optional. Default is 22.
ssh:
username: ""
password: ""
interval: 1m
conditions:
- "[CONNECTED] == true"
- "[STATUS] == 0"
```
The following placeholders are supported for endpoints of type SSH:
- `[CONNECTED]` resolves to `true` if the SSH connection was successful, `false` otherwise
- `[STATUS]` resolves the exit code of the command executed on the remote server (e.g. `0` for success)
@@ -2076,7 +2185,7 @@ endpoints:
### disable-monitoring-lock
Setting `disable-monitoring-lock` to `true` means that multiple endpoints could be monitored at the same time.
Setting `disable-monitoring-lock` to `true` means that multiple endpoints could be monitored at the same time (i.e. parallel execution).
While this behavior wouldn't generally be harmful, conditions using the `[RESPONSE_TIME]` placeholder could be impacted
by the evaluation of multiple endpoints at the same time, therefore, the default value for this parameter is `false`.
@@ -2221,7 +2330,6 @@ endpoints:
### Proxy client configuration
You can configure a proxy for the client to use by setting the `proxy-url` parameter in the client configuration.
```yaml
@@ -2234,19 +2342,6 @@ endpoints:
- "[STATUS] == 200"
```
### Proxy client configuration
You can configure a proxy for the client to use by setting the `proxy-url` parameter in the client configuration.
```yaml
endpoints:
- name: website
url: "https://twin.sh/health"
client:
proxy-url: http://proxy.example.com:8080
conditions:
- "[STATUS] == 200"
```
### How to fix 431 Request Header Fields Too Large error
Depending on where your environment is deployed and what kind of middleware or reverse proxy sits in front of Gatus,
@@ -2264,6 +2359,7 @@ web:
![Uptime 1h](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/1h/badge.svg)
![Uptime 24h](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/24h/badge.svg)
![Uptime 7d](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/7d/badge.svg)
![Uptime 30d](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/30d/badge.svg)
Gatus can automatically generate an SVG badge for one of your monitored endpoints.
This allows you to put badges in your individual applications' README or even create your own status page if you
@@ -2274,7 +2370,7 @@ The path to generate a badge is the following:
/api/v1/endpoints/{key}/uptimes/{duration}/badge.svg
```
Where:
- `{duration}` is `30d` (alpha), `7d`, `24h` or `1h`
- `{duration}` is `30d`, `7d`, `24h` or `1h`
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`.
For instance, if you want the uptime during the last 24 hours from the endpoint `frontend` in the group `core`,
@@ -2333,22 +2429,35 @@ See more information about the Shields.io badge endpoint [here](https://shields.
![Response time 1h](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/1h/badge.svg)
![Response time 24h](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/24h/badge.svg)
![Response time 7d](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/7d/badge.svg)
![Response time 30d](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/30d/badge.svg)
The endpoint to generate a badge is the following:
```
/api/v1/endpoints/{key}/response-times/{duration}/badge.svg
```
Where:
- `{duration}` is `30d` (alpha), `7d`, `24h` or `1h`
- `{duration}` is `30d`, `7d`, `24h` or `1h`
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`.
#### Response time (chart)
![Response time 24h](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/24h/chart.svg)
![Response time 7d](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/7d/chart.svg)
![Response time 30d](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/30d/chart.svg)
The endpoint to generate a response time chart is the following:
```
/api/v1/endpoints/{key}/response-times/{duration}/chart.svg
```
Where:
- `{duration}` is `30d`, `7d`, or `24h`
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`.
##### How to change the color thresholds of the response time badge
To change the response time badges' threshold, a corresponding configuration can be added to an endpoint.
The values in the array correspond to the levels [Awesome, Great, Good, Passable, Bad]
All five values must be given in milliseconds (ms).
```
```yaml
endpoints:
- name: nas
group: internal
@@ -2383,6 +2492,23 @@ Gzip compression will be used if the `Accept-Encoding` HTTP header contains `gzi
The API will return a JSON payload with the `Content-Type` response header set to `application/json`.
No such header is required to query the API.
#### Raw Data
Gatus exposes the raw data for one of your monitored endpoints.
This allows you to track and aggregate data in your own applications for monitored endpoints. For instance if you want to track uptime for a period longer than 7 days.
##### Uptime
The path to get raw uptime data for an endpoint is:
```
/api/v1/endpoints/{key}/uptimes/{duration}
```
Where:
- `{duration}` is `30d`, `7d`, `24h` or `1h`
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`.
For instance, if you want the raw uptime data for the last 24 hours from the endpoint `frontend` in the group `core`, the URL would look like this:
```
https://example.com/api/v1/endpoints/core_frontend/uptimes/24h
```
### Installing as binary
You can download Gatus as a binary using the following command:

View File

@@ -6,6 +6,9 @@ import (
"errors"
"strconv"
"strings"
"github.com/TwiN/logr"
"gopkg.in/yaml.v3"
)
var (
@@ -13,7 +16,7 @@ var (
ErrAlertWithInvalidDescription = errors.New("alert description must not have \" or \\")
)
// Alert is a endpoint.Endpoint's alert configuration
// Alert is endpoint.Endpoint's alert configuration
type Alert struct {
// Type of alert (required)
Type Type `yaml:"type"`
@@ -36,13 +39,17 @@ type Alert struct {
//
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
// 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
//
// 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
SendOnResolved *bool `yaml:"send-on-resolved"`
SendOnResolved *bool `yaml:"send-on-resolved,omitempty"`
// ProviderOverride is an optional field that can be used to override the provider's configuration
// 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
// ongoing/triggered incidents
@@ -111,3 +118,11 @@ func (alert *Alert) Checksum() string {
)
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
}

View File

@@ -32,6 +32,9 @@ const (
// TypeGotify is the Type for the gotify alerting provider
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 Type = "jetbrainsspace"

View File

@@ -1,7 +1,6 @@
package alerting
import (
"log"
"reflect"
"strings"
@@ -16,6 +15,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/gitlab"
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
"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/matrix"
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
@@ -30,6 +30,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
"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
@@ -61,6 +62,9 @@ type Config struct {
// Gotify is the configuration for the gotify alerting provider
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 *jetbrainsspace.AlertProvider `yaml:"jetbrainsspace,omitempty"`
@@ -118,7 +122,7 @@ func (config *Config) GetAlertingProviderByAlertType(alertType alert.Type) provi
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
}

View File

@@ -1,30 +1,73 @@
package awsses
import (
"errors"
"fmt"
"strings"
"github.com/TwiN/gatus/v5/alerting/alert"
"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/awserr"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/ses"
"gopkg.in/yaml.v3"
)
const (
CharSet = "UTF-8"
)
// AlertProvider is the configuration necessary for sending an alert using AWS Simple Email Service
type AlertProvider struct {
var (
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"`
SecretAccessKey string `yaml:"secret-access-key"`
Region string `yaml:"region"`
From string `yaml:"from"`
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 *alert.Alert `yaml:"default-alert,omitempty"`
@@ -35,36 +78,37 @@ type AlertProvider struct {
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
To string `yaml:"to"`
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
// 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 == "" || len(override.To) == 0 {
return false
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
// 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 len(provider.From) > 0 && len(provider.To) > 0 &&
((len(provider.AccessKeyID) == 0 && len(provider.SecretAccessKey) == 0) || (len(provider.AccessKeyID) > 0 && len(provider.SecretAccessKey) > 0))
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 {
sess, err := provider.createSession()
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
svc := ses.New(sess)
awsSession, err := provider.createSession(cfg)
if err != nil {
return err
}
svc := ses.New(awsSession)
subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)
emails := strings.Split(provider.getToForGroup(ep.Group), ",")
emails := strings.Split(cfg.To, ",")
input := &ses.SendEmailInput{
Destination: &ses.Destination{
@@ -82,26 +126,24 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
Data: aws.String(subject),
},
},
Source: aws.String(provider.From),
Source: aws.String(cfg.From),
}
_, err = svc.SendEmail(input)
if err != nil {
if _, err = svc.SendEmail(input); err != nil {
if aerr, ok := err.(awserr.Error); ok {
switch aerr.Code() {
case ses.ErrCodeMessageRejected:
fmt.Println(ses.ErrCodeMessageRejected, aerr.Error())
logr.Error(ses.ErrCodeMessageRejected + ": " + aerr.Error())
case ses.ErrCodeMailFromDomainNotVerifiedException:
fmt.Println(ses.ErrCodeMailFromDomainNotVerifiedException, aerr.Error())
logr.Error(ses.ErrCodeMailFromDomainNotVerifiedException + ": " + aerr.Error())
case ses.ErrCodeConfigurationSetDoesNotExistException:
fmt.Println(ses.ErrCodeConfigurationSetDoesNotExistException, aerr.Error())
logr.Error(ses.ErrCodeConfigurationSetDoesNotExistException + ": " + aerr.Error())
default:
fmt.Println(aerr.Error())
logr.Error(aerr.Error())
}
} else {
// Print the error, cast err to awserr.Error to get the Code and
// Message from an error.
fmt.Println(err.Error())
logr.Error(err.Error())
}
return err
@@ -109,6 +151,16 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
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
func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) (string, string) {
var subject, message string
@@ -139,29 +191,38 @@ func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint,
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
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
func (provider *AlertProvider) createSession() (*session.Session, error) {
config := &aws.Config{
Region: aws.String(provider.Region),
// 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
}
}
}
if len(provider.AccessKeyID) > 0 && len(provider.SecretAccessKey) > 0 {
config.Credentials = credentials.NewStaticCredentials(provider.AccessKeyID, provider.SecretAccessKey, "")
// 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)
}
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
}

View File

@@ -7,59 +7,61 @@ import (
"github.com/TwiN/gatus/v5/config/endpoint"
)
func TestAlertDefaultProvider_IsValid(t *testing.T) {
func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{}
if invalidProvider.IsValid() {
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
invalidProviderWithOneKey := AlertProvider{From: "from@example.com", To: "to@example.com", AccessKeyID: "1"}
if invalidProviderWithOneKey.IsValid() {
invalidProviderWithOneKey := AlertProvider{DefaultConfig: Config{From: "from@example.com", To: "to@example.com", AccessKeyID: "1"}}
if err := invalidProviderWithOneKey.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{From: "from@example.com", To: "to@example.com"}
if !validProvider.IsValid() {
validProvider := AlertProvider{DefaultConfig: Config{From: "from@example.com", To: "to@example.com"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
validProviderWithKeys := AlertProvider{From: "from@example.com", To: "to@example.com", AccessKeyID: "1", SecretAccessKey: "1"}
if !validProviderWithKeys.IsValid() {
validProviderWithKeys := AlertProvider{DefaultConfig: Config{From: "from@example.com", To: "to@example.com", AccessKeyID: "1", SecretAccessKey: "1"}}
if err := validProviderWithKeys.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
To: "to@example.com",
Group: "",
Config: Config{To: "to@example.com"},
Group: "",
},
},
}
if providerWithInvalidOverrideGroup.IsValid() {
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
To: "",
Group: "group",
Config: Config{To: ""},
Group: "group",
},
},
}
if providerWithInvalidOverrideTo.IsValid() {
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
From: "from@example.com",
To: "to@example.com",
DefaultConfig: Config{
From: "from@example.com",
To: "to@example.com",
},
Overrides: []Override{
{
To: "to@example.com",
Group: "group",
Config: Config{To: "to@example.com"},
Group: "group",
},
},
}
if !providerWithValidOverride.IsValid() {
if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
@@ -124,64 +126,124 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
}
}
func TestAlertProvider_getToForGroup(t *testing.T) {
tests := []struct {
func TestAlertProvider_getConfigWithOverrides(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
ExpectedOutput string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
To: "to@example.com",
DefaultConfig: Config{
From: "from@example.com",
To: "to@example.com",
},
Overrides: nil,
},
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",
Provider: AlertProvider{
To: "to@example.com",
DefaultConfig: Config{
From: "from@example.com",
To: "to@example.com",
},
Overrides: nil,
},
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",
Provider: AlertProvider{
To: "to@example.com",
DefaultConfig: Config{
From: "from@example.com",
To: "to@example.com",
},
Overrides: []Override{
{
Group: "group",
To: "to01@example.com",
Group: "group",
Config: Config{To: "groupto@example.com"},
},
},
},
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",
Provider: AlertProvider{
To: "to@example.com",
DefaultConfig: Config{
From: "from@example.com",
To: "to@example.com",
},
Overrides: []Override{
{
Group: "group",
To: "to01@example.com",
Group: "group",
Config: Config{To: "groupto@example.com", SecretAccessKey: "wow", AccessKeyID: "noway"},
},
},
},
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 {
t.Run(tt.Name, func(t *testing.T) {
if got := tt.Provider.getToForGroup(tt.InputGroup); got != tt.ExpectedOutput {
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
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.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)
}
})
}

View File

@@ -2,6 +2,7 @@ package custom
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
@@ -10,11 +11,14 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"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
// Technically, all alert providers should be reachable using the custom alert provider
type AlertProvider struct {
var (
ErrURLNotSet = errors.New("url not set")
)
type Config struct {
URL string `yaml:"url"`
Method string `yaml:"method,omitempty"`
Body string `yaml:"body,omitempty"`
@@ -23,66 +27,66 @@ type AlertProvider struct {
// 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.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 *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
func (provider *AlertProvider) IsValid() bool {
if provider.ClientConfig == nil {
provider.ClientConfig = client.GetDefaultConfig()
}
return len(provider.URL) > 0 && provider.ClientConfig != nil
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// GetAlertStatePlaceholderValue returns the Placeholder value for ALERT_TRIGGERED_OR_RESOLVED if configured
func (provider *AlertProvider) GetAlertStatePlaceholderValue(resolved bool) string {
status := "TRIGGERED"
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(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) *http.Request {
body, url, method := provider.Body, provider.URL, provider.Method
body = strings.ReplaceAll(body, "[ALERT_DESCRIPTION]", alert.GetDescription())
url = strings.ReplaceAll(url, "[ALERT_DESCRIPTION]", alert.GetDescription())
body = strings.ReplaceAll(body, "[ENDPOINT_NAME]", 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)
body = strings.ReplaceAll(body, "[RESULT_ERRORS]", strings.Join(result.Errors, ","))
url = strings.ReplaceAll(url, "[RESULT_ERRORS]", strings.Join(result.Errors, ","))
if resolved {
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
} else {
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(false))
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(false))
}
if len(method) == 0 {
method = http.MethodGet
}
bodyBuffer := bytes.NewBuffer([]byte(body))
request, _ := http.NewRequest(method, url, bodyBuffer)
for k, v := range provider.Headers {
request.Header.Set(k, v)
}
return request
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
return provider.DefaultConfig.Validate()
}
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
request := provider.buildHTTPRequest(ep, alert, result, resolved)
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
request := provider.buildHTTPRequest(cfg, ep, alert, result, resolved)
response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
if err != nil {
return err
}
@@ -94,7 +98,82 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
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)
body = strings.ReplaceAll(body, "[RESULT_ERRORS]", strings.Join(result.Errors, ","))
url = strings.ReplaceAll(url, "[RESULT_ERRORS]", strings.Join(result.Errors, ","))
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
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
}

View File

@@ -12,24 +12,18 @@ import (
"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) {
invalidProvider := AlertProvider{URL: ""}
if invalidProvider.IsValid() {
invalidProvider := AlertProvider{DefaultConfig: Config{URL: ""}}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
})
t.Run("valid-provider", func(t *testing.T) {
validProvider := AlertProvider{URL: "https://example.com"}
if validProvider.ClientConfig != nil {
t.Error("provider client config should have been nil prior to IsValid() being executed")
}
if !validProvider.IsValid() {
validProvider := AlertProvider{DefaultConfig: Config{URL: "https://example.com"}}
if err := validProvider.Validate(); err != nil {
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",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{URL: "https://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -57,7 +51,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "triggered-error",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{URL: "https://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -67,7 +61,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{URL: "https://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -77,7 +71,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved-error",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{URL: "https://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -111,9 +105,11 @@ func TestAlertProvider_Send(t *testing.T) {
}
func TestAlertProvider_buildHTTPRequest(t *testing.T) {
customAlertProvider := &AlertProvider{
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]",
alertProvider := &AlertProvider{
DefaultConfig: Config{
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"
scenarios := []struct {
@@ -123,13 +119,13 @@ func TestAlertProvider_buildHTTPRequest(t *testing.T) {
ExpectedBody string
}{
{
AlertProvider: customAlertProvider,
AlertProvider: alertProvider,
Resolved: true,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=RESOLVED&description=alert-description&url=https://example.com",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,RESOLVED",
},
{
AlertProvider: customAlertProvider,
AlertProvider: alertProvider,
Resolved: false,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=TRIGGERED&description=alert-description&url=https://example.com",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,TRIGGERED",
@@ -137,7 +133,8 @@ func TestAlertProvider_buildHTTPRequest(t *testing.T) {
}
for _, scenario := range scenarios {
t.Run(fmt.Sprintf("resolved-%v-with-default-placeholders", scenario.Resolved), func(t *testing.T) {
request := customAlertProvider.buildHTTPRequest(
request := alertProvider.buildHTTPRequest(
&alertProvider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group", URL: "https://example.com"},
&alert.Alert{Description: &alertDescription},
&endpoint.Result{Errors: []string{}},
@@ -155,9 +152,11 @@ func TestAlertProvider_buildHTTPRequest(t *testing.T) {
}
func TestAlertProviderWithResultErrors_buildHTTPRequest(t *testing.T) {
customAlertWithErrorsProvider := &AlertProvider{
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]",
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 {
@@ -168,13 +167,13 @@ func TestAlertProviderWithResultErrors_buildHTTPRequest(t *testing.T) {
Errors []string
}{
{
AlertProvider: customAlertWithErrorsProvider,
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: customAlertWithErrorsProvider,
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",
@@ -183,7 +182,8 @@ func TestAlertProviderWithResultErrors_buildHTTPRequest(t *testing.T) {
}
for _, scenario := range scenarios {
t.Run(fmt.Sprintf("resolved-%v-with-default-placeholders-and-result-errors", scenario.Resolved), func(t *testing.T) {
request := customAlertWithErrorsProvider.buildHTTPRequest(
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},
@@ -201,14 +201,16 @@ func TestAlertProviderWithResultErrors_buildHTTPRequest(t *testing.T) {
}
func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
customAlertProvider := &AlertProvider{
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
Headers: nil,
Placeholders: map[string]map[string]string{
"ALERT_TRIGGERED_OR_RESOLVED": {
"RESOLVED": "fixed",
"TRIGGERED": "boom",
alertProvider := &AlertProvider{
DefaultConfig: Config{
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
Headers: nil,
Placeholders: map[string]map[string]string{
"ALERT_TRIGGERED_OR_RESOLVED": {
"RESOLVED": "fixed",
"TRIGGERED": "boom",
},
},
},
}
@@ -220,13 +222,13 @@ func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
ExpectedBody string
}{
{
AlertProvider: customAlertProvider,
AlertProvider: alertProvider,
Resolved: true,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=fixed&description=alert-description",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,fixed",
},
{
AlertProvider: customAlertProvider,
AlertProvider: alertProvider,
Resolved: false,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=boom&description=alert-description",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,boom",
@@ -234,7 +236,8 @@ func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
}
for _, scenario := range scenarios {
t.Run(fmt.Sprintf("resolved-%v-with-custom-placeholders", scenario.Resolved), func(t *testing.T) {
request := customAlertProvider.buildHTTPRequest(
request := alertProvider.buildHTTPRequest(
&alertProvider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&alert.Alert{Description: &alertDescription},
&endpoint.Result{},
@@ -252,15 +255,17 @@ func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
}
func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) {
customAlertProvider := &AlertProvider{
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
alertProvider := &AlertProvider{
DefaultConfig: Config{
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
},
}
if customAlertProvider.GetAlertStatePlaceholderValue(true) != "RESOLVED" {
t.Error("expected RESOLVED, got", customAlertProvider.GetAlertStatePlaceholderValue(true))
if alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, true) != "RESOLVED" {
t.Error("expected RESOLVED, got", alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, true))
}
if customAlertProvider.GetAlertStatePlaceholderValue(false) != "TRIGGERED" {
t.Error("expected TRIGGERED, got", customAlertProvider.GetAlertStatePlaceholderValue(false))
if alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, false) != "TRIGGERED" {
t.Error("expected TRIGGERED, got", alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, false))
}
}
@@ -272,3 +277,119 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
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)
}
})
}
}

View File

@@ -3,6 +3,7 @@ package discord
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -10,46 +11,73 @@ import (
"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 Discord
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 *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"`
// 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 {
Group string `yaml:"group"`
WebhookURL string `yaml:"webhook-url"`
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
// 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 == "" || len(override.WebhookURL) == 0 {
return false
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return len(provider.WebhookURL) > 0
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 {
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer)
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
}
@@ -85,7 +113,7 @@ type Field struct {
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.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 colorCode int
if resolved {
@@ -110,8 +138,8 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
description = ":\n> " + alertDescription
}
title := ":helmet_with_white_cross: Gatus"
if provider.Title != "" {
title = provider.Title
if cfg.Title != "" {
title = cfg.Title
}
body := Body{
Content: "",
@@ -134,19 +162,38 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
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
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
}

View File

@@ -11,50 +11,52 @@ import (
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{WebhookURL: ""}
if invalidProvider.IsValid() {
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{WebhookURL: "http://example.com"}
if !validProvider.IsValid() {
validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
WebhookURL: "http://example.com",
Group: "",
Config: Config{WebhookURL: "http://example.com"},
Group: "",
},
},
}
if providerWithInvalidOverrideGroup.IsValid() {
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
WebhookURL: "",
Group: "group",
Config: Config{WebhookURL: ""},
Group: "group",
},
},
}
if providerWithInvalidOverrideTo.IsValid() {
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
WebhookURL: "http://example.com",
DefaultConfig: Config{
WebhookURL: "http://example.com",
},
Overrides: []Override{
{
WebhookURL: "http://example.com",
Group: "group",
Config: Config{WebhookURL: "http://example.com"},
Group: "group",
},
},
}
if !providerWithValidOverride.IsValid() {
if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
@@ -74,7 +76,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{
{
Name: "triggered",
Provider: AlertProvider{},
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 {
@@ -84,7 +86,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "triggered-error",
Provider: AlertProvider{},
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 {
@@ -94,7 +96,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved",
Provider: AlertProvider{},
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 {
@@ -104,7 +106,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved-error",
Provider: AlertProvider{},
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 {
@@ -114,7 +116,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
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},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -122,6 +124,16 @@ func TestAlertProvider_Send(t *testing.T) {
}),
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 {
t.Run(scenario.Name, func(t *testing.T) {
@@ -175,7 +187,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
},
{
Name: "triggered-with-modified-title",
Provider: AlertProvider{Title: title},
Provider: AlertProvider{DefaultConfig: Config{Title: title}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
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}]}]}",
@@ -183,7 +195,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
{
Name: "triggered-with-no-conditions",
NoConditions: true,
Provider: AlertProvider{Title: title},
Provider: AlertProvider{DefaultConfig: Config{Title: title}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
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}]}",
@@ -200,6 +212,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}
}
body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
@@ -227,64 +240,92 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
}
}
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
tests := []struct {
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
ExpectedOutput string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: nil,
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil,
},
InputGroup: "",
ExpectedOutput: "http://example.com",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: nil,
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil,
},
InputGroup: "group",
ExpectedOutput: "http://example.com",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
WebhookURL: "http://example01.com",
Group: "group",
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
InputGroup: "",
ExpectedOutput: "http://example.com",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
WebhookURL: "http://example.com",
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
WebhookURL: "http://example01.com",
Group: "group",
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
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 {
t.Run(tt.Name, func(t *testing.T) {
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
t.Errorf("AlertProvider.getWebhookURLForGroup() = %v, want %v", got, tt.ExpectedOutput)
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)
}
})
}

View File

@@ -2,6 +2,7 @@ package email
import (
"crypto/tls"
"errors"
"fmt"
"math"
"strings"
@@ -10,10 +11,17 @@ import (
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
gomail "gopkg.in/mail.v2"
"gopkg.in/yaml.v3"
)
// AlertProvider is the configuration necessary for sending an alert using SMTP
type AlertProvider struct {
var (
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"`
Username string `yaml:"username"`
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 *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 *alert.Alert `yaml:"default-alert,omitempty"`
@@ -33,54 +83,57 @@ type AlertProvider struct {
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
To string `yaml:"to"`
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
// 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 == "" || len(override.To) == 0 {
return false
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return len(provider.From) > 0 && len(provider.Host) > 0 && len(provider.To) > 0 && provider.Port > 0 && provider.Port < math.MaxUint16
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
}
var username string
if len(provider.Username) > 0 {
username = provider.Username
if len(cfg.Username) > 0 {
username = cfg.Username
} else {
username = provider.From
username = cfg.From
}
subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)
m := gomail.NewMessage()
m.SetHeader("From", provider.From)
m.SetHeader("To", strings.Split(provider.getToForGroup(ep.Group), ",")...)
m.SetHeader("From", cfg.From)
m.SetHeader("To", strings.Split(cfg.To, ",")...)
m.SetHeader("Subject", subject)
m.SetBody("text/plain", body)
var d *gomail.Dialer
if len(provider.Password) == 0 {
if len(cfg.Password) == 0 {
// Get the domain in the From address
localName := "localhost"
fromParts := strings.Split(provider.From, `@`)
fromParts := strings.Split(cfg.From, `@`)
if len(fromParts) == 2 {
localName = fromParts[1]
}
// 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 {
// 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}
}
return d.DialAndSend(m)
@@ -116,19 +169,38 @@ func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint,
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
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
}

View File

@@ -7,61 +7,63 @@ import (
"github.com/TwiN/gatus/v5/config/endpoint"
)
func TestAlertDefaultProvider_IsValid(t *testing.T) {
func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{}
if invalidProvider.IsValid() {
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{From: "from@example.com", Password: "password", Host: "smtp.gmail.com", Port: 587, To: "to@example.com"}
if !validProvider.IsValid() {
validProvider := AlertProvider{DefaultConfig: Config{From: "from@example.com", Password: "password", Host: "smtp.gmail.com", Port: 587, To: "to@example.com"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_IsValidWithNoCredentials(t *testing.T) {
validProvider := AlertProvider{From: "from@example.com", Host: "smtp-relay.gmail.com", Port: 587, To: "to@example.com"}
if !validProvider.IsValid() {
func TestAlertProvider_ValidateWithNoCredentials(t *testing.T) {
validProvider := AlertProvider{DefaultConfig: Config{From: "from@example.com", Host: "smtp-relay.gmail.com", Port: 587, To: "to@example.com"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
To: "to@example.com",
Group: "",
Config: Config{To: "to@example.com"},
Group: "",
},
},
}
if providerWithInvalidOverrideGroup.IsValid() {
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
To: "",
Group: "group",
Config: Config{To: ""},
Group: "group",
},
},
}
if providerWithInvalidOverrideTo.IsValid() {
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
From: "from@example.com",
Password: "password",
Host: "smtp.gmail.com",
Port: 587,
To: "to@example.com",
DefaultConfig: Config{
From: "from@example.com",
Password: "password",
Host: "smtp.gmail.com",
Port: 587,
To: "to@example.com",
},
Overrides: []Override{
{
To: "to@example.com",
Group: "group",
Config: Config{To: "to@example.com"},
Group: "group",
},
},
}
if !providerWithValidOverride.IsValid() {
if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
@@ -126,64 +128,104 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
}
}
func TestAlertProvider_getToForGroup(t *testing.T) {
tests := []struct {
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
ExpectedOutput string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
To: "to@example.com",
Overrides: nil,
DefaultConfig: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
Overrides: nil,
},
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",
Provider: AlertProvider{
To: "to@example.com",
Overrides: nil,
DefaultConfig: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
Overrides: nil,
},
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",
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{
{
Group: "group",
To: "to01@example.com",
Group: "group",
Config: Config{To: "to01@example.com"},
},
},
},
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",
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{
{
Group: "group",
To: "to01@example.com",
Group: "group",
Config: Config{To: "group-to@example.com"},
},
},
},
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 {
t.Run(tt.Name, func(t *testing.T) {
if got := tt.Provider.getToForGroup(tt.InputGroup); got != tt.ExpectedOutput {
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
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.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)
}
})
}

View File

@@ -2,6 +2,7 @@ package gitea
import (
"crypto/tls"
"errors"
"fmt"
"net/http"
"net/url"
@@ -11,55 +12,56 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
// AlertProvider is the configuration necessary for sending an alert using Discord
type AlertProvider 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
var (
ErrRepositoryURLNotSet = errors.New("repository-url not set")
ErrInvalidRepositoryURL = errors.New("invalid repository-url")
ErrTokenNotSet = errors.New("token not set")
)
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"`
// Assignees is a list of users to assign the issue to
Assignees []string `yaml:"assignees,omitempty"`
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"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
if provider.ClientConfig == nil {
provider.ClientConfig = client.GetDefaultConfig()
func (cfg *Config) Validate() error {
if len(cfg.RepositoryURL) == 0 {
return ErrRepositoryURLNotSet
}
if len(provider.Token) == 0 || len(provider.RepositoryURL) == 0 {
return false
if len(cfg.Token) == 0 {
return ErrTokenNotSet
}
// Validate format of the repository URL
repositoryURL, err := url.Parse(provider.RepositoryURL)
repositoryURL, err := url.Parse(cfg.RepositoryURL)
if err != nil {
return false
return err
}
baseURL := repositoryURL.Scheme + "://" + repositoryURL.Host
pathParts := strings.Split(repositoryURL.Path, "/")
if len(pathParts) != 3 {
return false
return ErrInvalidRepositoryURL
}
provider.repositoryOwner = pathParts[1]
provider.repositoryName = pathParts[2]
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(provider.Token),
gitea.SetToken(cfg.Token),
}
if provider.ClientConfig != nil && provider.ClientConfig.Insecure {
if cfg.ClientConfig != nil && cfg.ClientConfig.Insecure {
// add new http client for skip verify
httpClient := &http.Client{
Transport: &http.Transport{
@@ -68,34 +70,62 @@ func (provider *AlertProvider) IsValid() bool {
}
opts = append(opts, gitea.SetHTTPClient(httpClient))
}
provider.giteaClient, err = gitea.NewClient(baseURL, opts...)
cfg.giteaClient, err = gitea.NewClient(baseURL, opts...)
if err != nil {
return false
return err
}
user, _, err := provider.giteaClient.GetMyUserInfo()
user, _, err := cfg.giteaClient.GetMyUserInfo()
if err != nil {
return false
return err
}
cfg.username = user.UserName
return nil
}
provider.username = user.UserName
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
}
}
return true
// 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 := provider.giteaClient.CreateIssue(
provider.repositoryOwner,
provider.repositoryName,
_, _, err = cfg.giteaClient.CreateIssue(
cfg.repositoryOwner,
cfg.repositoryName,
gitea.CreateIssueOption{
Title: title,
Body: provider.buildIssueBody(ep, alert, result),
Assignees: provider.Assignees,
Assignees: cfg.Assignees,
},
)
if err != nil {
@@ -103,13 +133,12 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
}
return nil
}
issues, _, err := provider.giteaClient.ListRepoIssues(
provider.repositoryOwner,
provider.repositoryName,
issues, _, err := cfg.giteaClient.ListRepoIssues(
cfg.repositoryOwner,
cfg.repositoryName,
gitea.ListIssueOption{
State: gitea.StateOpen,
CreatedBy: provider.username,
CreatedBy: cfg.username,
ListOptions: gitea.ListOptions{
Page: 100,
},
@@ -118,13 +147,12 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
if err != nil {
return fmt.Errorf("failed to list issues: %w", err)
}
for _, issue := range issues {
if issue.Title == title {
stateClosed := gitea.StateClosed
_, _, err = provider.giteaClient.EditIssue(
provider.repositoryOwner,
provider.repositoryName,
_, _, err = cfg.giteaClient.EditIssue(
cfg.repositoryOwner,
cfg.repositoryName,
issue.ID,
gitea.EditIssueOption{
State: &stateClosed,
@@ -165,3 +193,25 @@ func (provider *AlertProvider) buildIssueBody(ep *endpoint.Endpoint, alert *aler
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
}

View File

@@ -12,42 +12,46 @@ import (
"github.com/TwiN/gatus/v5/test"
)
func TestAlertDefaultProvider_IsValid(t *testing.T) {
func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
Expected bool
Name string
Provider AlertProvider
ExpectedError bool
}{
{
Name: "invalid",
Provider: AlertProvider{RepositoryURL: "", Token: ""},
Expected: false,
Name: "invalid",
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "", Token: ""}},
ExpectedError: true,
},
{
Name: "invalid-token",
Provider: AlertProvider{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"},
Expected: false,
Name: "invalid-token",
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"}},
ExpectedError: true,
},
{
Name: "missing-repository-name",
Provider: AlertProvider{RepositoryURL: "https://gitea.com/TwiN", Token: "12345"},
Expected: false,
Name: "missing-repository-name",
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN", Token: "12345"}},
ExpectedError: true,
},
{
Name: "enterprise-client",
Provider: AlertProvider{RepositoryURL: "https://gitea.example.com/TwiN/test", Token: "12345"},
Expected: false,
Name: "enterprise-client",
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://gitea.example.com/TwiN/test", Token: "12345"}},
ExpectedError: false,
},
{
Name: "invalid-url",
Provider: AlertProvider{RepositoryURL: "gitea.com/TwiN/test", Token: "12345"},
Expected: 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) {
if scenario.Provider.IsValid() != scenario.Expected {
t.Errorf("expected %t, got %t", scenario.Expected, scenario.Provider.IsValid())
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())
}
})
}
@@ -67,14 +71,14 @@ func TestAlertProvider_Send(t *testing.T) {
}{
{
Name: "triggered-error",
Provider: AlertProvider{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"},
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{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"},
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,
@@ -82,9 +86,13 @@ func TestAlertProvider_Send(t *testing.T) {
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
scenario.Provider.giteaClient, _ = gitea.NewClient("https://gitea.com")
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(
err = scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&scenario.Alert,
&endpoint.Result{
@@ -167,3 +175,55 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
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)
}
})
}
}

View File

@@ -2,6 +2,7 @@ package github
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
@@ -11,69 +12,104 @@ import (
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/google/go-github/v48/github"
"golang.org/x/oauth2"
"gopkg.in/yaml.v3"
)
// AlertProvider is the configuration necessary for sending an alert using Discord
type AlertProvider struct {
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 GitHub repository to create issues in
Token string `yaml:"token"` // Token requires at least RW on issues and RO on metadata
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
username string
repositoryOwner string
repositoryName string
githubClient *github.Client
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
if len(provider.Token) == 0 || len(provider.RepositoryURL) == 0 {
return false
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(provider.RepositoryURL)
repositoryURL, err := url.Parse(cfg.RepositoryURL)
if err != nil {
return false
return err
}
baseURL := repositoryURL.Scheme + "://" + repositoryURL.Host
pathParts := strings.Split(repositoryURL.Path, "/")
if len(pathParts) != 3 {
return false
return ErrInvalidRepositoryURL
}
provider.repositoryOwner = pathParts[1]
provider.repositoryName = pathParts[2]
if cfg.repositoryOwner == pathParts[1] && cfg.repositoryName == pathParts[2] && cfg.githubClient != 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]
// Create oauth2 HTTP client with GitHub token
httpClientWithStaticTokenSource := oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(&oauth2.Token{
AccessToken: provider.Token,
AccessToken: cfg.Token,
}))
// Create GitHub client
if baseURL == "https://github.com" {
provider.githubClient = github.NewClient(httpClientWithStaticTokenSource)
cfg.githubClient = github.NewClient(httpClientWithStaticTokenSource)
} else {
provider.githubClient, err = github.NewEnterpriseClient(baseURL, baseURL, httpClientWithStaticTokenSource)
cfg.githubClient, err = github.NewEnterpriseClient(baseURL, baseURL, httpClientWithStaticTokenSource)
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
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
user, _, err := provider.githubClient.Users.Get(ctx, "")
user, _, err := cfg.githubClient.Users.Get(ctx, "")
if err != nil {
return false
return fmt.Errorf("failed to retrieve GitHub user: %w", err)
}
provider.username = *user.Login
return true
cfg.username = *user.Login
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,
// 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 := 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),
Body: github.String(provider.buildIssueBody(ep, alert, result)),
})
@@ -81,9 +117,9 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
return fmt.Errorf("failed to create issue: %w", err)
}
} 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",
Creator: provider.username,
Creator: cfg.username,
ListOptions: github.ListOptions{PerPage: 100},
})
if err != nil {
@@ -91,7 +127,7 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
}
for _, issue := range issues {
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"),
})
if err != nil {
@@ -130,3 +166,25 @@ func (provider *AlertProvider) buildIssueBody(ep *endpoint.Endpoint, alert *aler
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
}

View File

@@ -12,42 +12,46 @@ import (
"github.com/google/go-github/v48/github"
)
func TestAlertDefaultProvider_IsValid(t *testing.T) {
func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
Expected bool
Name string
Provider AlertProvider
ExpectedError bool
}{
{
Name: "invalid",
Provider: AlertProvider{RepositoryURL: "", Token: ""},
Expected: false,
Name: "invalid",
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "", Token: ""}},
ExpectedError: true,
},
{
Name: "invalid-token",
Provider: AlertProvider{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"},
Expected: false,
Name: "invalid-token",
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"}},
ExpectedError: true,
},
{
Name: "missing-repository-name",
Provider: AlertProvider{RepositoryURL: "https://github.com/TwiN", Token: "12345"},
Expected: false,
Name: "missing-repository-name",
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN", Token: "12345"}},
ExpectedError: true,
},
{
Name: "enterprise-client",
Provider: AlertProvider{RepositoryURL: "https://github.example.com/TwiN/test", Token: "12345"},
Expected: false,
Name: "enterprise-client",
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.example.com/TwiN/test", Token: "12345"}},
ExpectedError: true,
},
{
Name: "invalid-url",
Provider: AlertProvider{RepositoryURL: "github.com/TwiN/test", Token: "12345"},
Expected: false,
Name: "invalid-url",
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "github.com/TwiN/test", Token: "12345"}},
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
if scenario.Provider.IsValid() != scenario.Expected {
t.Errorf("expected %t, got %t", scenario.Expected, scenario.Provider.IsValid())
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())
}
})
}
@@ -67,14 +71,14 @@ func TestAlertProvider_Send(t *testing.T) {
}{
{
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},
Resolved: false,
ExpectedError: true,
},
{
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},
Resolved: true,
ExpectedError: true,
@@ -82,9 +86,13 @@ func TestAlertProvider_Send(t *testing.T) {
}
for _, scenario := range scenarios {
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})
err := scenario.Provider.Send(
err = scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&scenario.Alert,
&endpoint.Result{
@@ -167,3 +175,47 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
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)
}
})
}
}

View File

@@ -13,55 +13,97 @@ import (
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"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
type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"` // The webhook url provided by GitLab
AuthorizationKey string `yaml:"authorization-key"` // The authorization key provided by GitLab
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"`
// 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
func (provider *AlertProvider) IsValid() bool {
if len(provider.AuthorizationKey) == 0 || len(provider.WebhookURL) == 0 {
return false
}
// Validate format of the repository URL
_, err := url.Parse(provider.WebhookURL)
if err != nil {
return false
}
return true
// 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
}
if len(alert.ResolveKey) == 0 {
alert.ResolveKey = uuid.NewString()
}
buffer := bytes.NewBuffer(provider.buildAlertBody(ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer)
buffer := bytes.NewBuffer(provider.buildAlertBody(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")
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)
if err != nil {
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.
}
func (provider *AlertProvider) monitoringTool() string {
if len(provider.MonitoringTool) > 0 {
return provider.MonitoringTool
}
return "gatus"
}
func (provider *AlertProvider) service(ep *endpoint.Endpoint) string {
if len(provider.Service) > 0 {
return provider.Service
}
return ep.DisplayName()
}
// buildAlertBody builds the body of the alert
func (provider *AlertProvider) buildAlertBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.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{
Title: fmt.Sprintf("alert(%s): %s", provider.monitoringTool(), provider.service(ep)),
Title: fmt.Sprintf("alert(%s): %s", cfg.MonitoringTool, service),
StartTime: result.Timestamp.Format(time.RFC3339),
Service: provider.service(ep),
MonitoringTool: provider.monitoringTool(),
Service: service,
MonitoringTool: cfg.MonitoringTool,
Hosts: ep.URL,
GitlabEnvironmentName: provider.EnvironmentName,
Severity: provider.Severity,
GitlabEnvironmentName: cfg.EnvironmentName,
Severity: cfg.Severity,
Fingerprint: alert.ResolveKey,
}
if resolved {
@@ -148,3 +180,25 @@ func (provider *AlertProvider) buildAlertBody(ep *endpoint.Endpoint, alert *aler
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
}

View File

@@ -11,37 +11,41 @@ import (
"github.com/TwiN/gatus/v5/test"
)
func TestAlertDefaultProvider_IsValid(t *testing.T) {
func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
Expected bool
Name string
Provider AlertProvider
ExpectedError bool
}{
{
Name: "invalid",
Provider: AlertProvider{WebhookURL: "", AuthorizationKey: ""},
Expected: false,
Name: "invalid",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "", AuthorizationKey: ""}},
ExpectedError: true,
},
{
Name: "missing-webhook-url",
Provider: AlertProvider{WebhookURL: "", AuthorizationKey: "12345"},
Expected: false,
Name: "missing-webhook-url",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "", AuthorizationKey: "12345"}},
ExpectedError: true,
},
{
Name: "missing-authorization-key",
Provider: AlertProvider{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: ""},
Expected: false,
Name: "missing-authorization-key",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://gitlab.com/whatever/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: ""}},
ExpectedError: true,
},
{
Name: "invalid-url",
Provider: AlertProvider{WebhookURL: " http://foo.com", AuthorizationKey: "12345"},
Expected: false,
Name: "invalid-url",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: " http://foo.com", AuthorizationKey: "12345"}},
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
if scenario.Provider.IsValid() != scenario.Expected {
t.Errorf("expected %t, got %t", scenario.Expected, scenario.Provider.IsValid())
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())
}
})
}
@@ -61,7 +65,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{
{
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},
Resolved: false,
ExpectedError: false,
@@ -71,7 +75,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
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},
Resolved: true,
ExpectedError: false,
@@ -116,21 +120,26 @@ func TestAlertProvider_buildAlertBody(t *testing.T) {
{
Name: "triggered",
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},
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",
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},
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 {
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(
cfg,
&scenario.Endpoint,
&scenario.Alert,
&endpoint.Result{
@@ -156,3 +165,59 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
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)
}
})
}
}

View File

@@ -3,6 +3,7 @@ package googlechat
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -10,14 +11,38 @@ import (
"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"`
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
type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"`
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"`
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"`
@@ -28,36 +53,37 @@ type AlertProvider struct {
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
WebhookURL string `yaml:"webhook-url"`
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
if provider.ClientConfig == nil {
provider.ClientConfig = client.GetDefaultConfig()
}
// 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 == "" || len(override.WebhookURL) == 0 {
return false
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return len(provider.WebhookURL) > 0
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(ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer)
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(provider.ClientConfig).Do(request)
response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
if err != nil {
return err
}
@@ -185,19 +211,38 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
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
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
}

View File

@@ -11,50 +11,50 @@ import (
"github.com/TwiN/gatus/v5/test"
)
func TestAlertDefaultProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{WebhookURL: ""}
if invalidProvider.IsValid() {
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{WebhookURL: "http://example.com"}
if !validProvider.IsValid() {
validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
WebhookURL: "http://example.com",
Group: "",
Config: Config{WebhookURL: "http://example.com"},
Group: "",
},
},
}
if providerWithInvalidOverrideGroup.IsValid() {
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
WebhookURL: "",
Group: "group",
Config: Config{WebhookURL: ""},
Group: "group",
},
},
}
if providerWithInvalidOverrideTo.IsValid() {
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
WebhookURL: "http://example.com",
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
WebhookURL: "http://example.com",
Group: "group",
Config: Config{WebhookURL: "http://example.com"},
Group: "group",
},
},
}
if !providerWithValidOverride.IsValid() {
if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
@@ -73,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{
{
Name: "triggered",
Provider: AlertProvider{},
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 {
@@ -83,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "triggered-error",
Provider: AlertProvider{},
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 {
@@ -93,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved",
Provider: AlertProvider{},
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 {
@@ -103,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved-error",
Provider: AlertProvider{},
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 {
@@ -213,64 +213,92 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
}
}
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
tests := []struct {
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
ExpectedOutput string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: nil,
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil,
},
InputGroup: "",
ExpectedOutput: "http://example.com",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: nil,
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil,
},
InputGroup: "group",
ExpectedOutput: "http://example.com",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
WebhookURL: "http://example01.com",
Group: "group",
Config: Config{WebhookURL: "http://example01.com"},
},
},
},
InputGroup: "",
ExpectedOutput: "http://example.com",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
WebhookURL: "http://example.com",
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
WebhookURL: "http://example01.com",
Group: "group",
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
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 {
t.Run(tt.Name, func(t *testing.T) {
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
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)
}
})
}

View File

@@ -3,6 +3,7 @@ package gotify
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -10,40 +11,72 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
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
type AlertProvider struct {
// ServerURL is the URL of the Gotify server
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
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"`
// 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
func (provider *AlertProvider) IsValid() bool {
if provider.Priority == 0 {
provider.Priority = DefaultPriority
}
return len(provider.ServerURL) > 0 && len(provider.Token) > 0
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
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 {
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.ServerURL+"/message?token="+provider.Token, buffer)
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.ServerURL+"/message?token="+cfg.Token, buffer)
if err != nil {
return err
}
@@ -67,7 +100,7 @@ type Body struct {
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.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
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)
@@ -89,13 +122,13 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
}
message += formattedConditionResults
title := "Gatus: " + ep.DisplayName()
if provider.Title != "" {
title = provider.Title
if cfg.Title != "" {
title = cfg.Title
}
bodyAsJSON, _ := json.Marshal(Body{
Message: message,
Title: title,
Priority: provider.Priority,
Priority: cfg.Priority,
})
return bodyAsJSON
}
@@ -104,3 +137,25 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
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
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
}

View File

@@ -9,7 +9,7 @@ import (
"github.com/TwiN/gatus/v5/config/endpoint"
)
func TestAlertProvider_IsValid(t *testing.T) {
func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct {
name string
provider AlertProvider
@@ -17,29 +17,29 @@ func TestAlertProvider_IsValid(t *testing.T) {
}{
{
name: "valid",
provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"},
provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "faketoken"}},
expected: true,
},
{
name: "invalid-server-url",
provider: AlertProvider{ServerURL: "", Token: "faketoken"},
provider: AlertProvider{DefaultConfig: Config{ServerURL: "", Token: "faketoken"}},
expected: false,
},
{
name: "invalid-app-token",
provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: ""},
provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: ""}},
expected: false,
},
{
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,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
if scenario.provider.IsValid() != scenario.expected {
t.Errorf("expected %t, got %t", scenario.expected, scenario.provider.IsValid())
if err := scenario.provider.Validate(); (err == nil) != scenario.expected {
t.Errorf("expected: %t, got: %t", scenario.expected, err == nil)
}
})
}
@@ -60,21 +60,21 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{
{
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},
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}", endpointName, description),
},
{
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},
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}", endpointName, description),
},
{
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},
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}", endpointName, description),
@@ -83,6 +83,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: endpointName},
&scenario.Alert,
&endpoint.Result{
@@ -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)
}
})
}
}

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
}

View File

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

View File

@@ -3,6 +3,7 @@ package jetbrainsspace
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -10,13 +11,50 @@ import (
"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 (
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
type AlertProvider struct {
Project string `yaml:"project"` // JetBrains Space Project name
ChannelID string `yaml:"channel-id"` // JetBrains Space Chat Channel ID
Token string `yaml:"token"` // JetBrains Space Bearer Token
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"`
@@ -27,34 +65,38 @@ type AlertProvider struct {
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
ChannelID string `yaml:"channel-id"`
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
// 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 == "" || len(override.ChannelID) == 0 {
return false
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
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
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
url := fmt.Sprintf("https://%s.jetbrains.space/api/http/chats/messages/send-message", provider.Project)
cfg, err := provider.GetConfig(ep.Group, alert)
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)
if err != nil {
return err
}
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)
if err != nil {
return err
@@ -103,9 +145,9 @@ type Icon struct {
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
body := Body{
Channel: "id:" + provider.getChannelIDForGroup(ep.Group),
Channel: "id:" + cfg.ChannelID,
Content: Content{
ClassName: "ChatMessage.Block",
Sections: []Section{{
@@ -144,19 +186,38 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
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
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
}

View File

@@ -11,54 +11,56 @@ import (
"github.com/TwiN/gatus/v5/test"
)
func TestAlertDefaultProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{Project: ""}
if invalidProvider.IsValid() {
func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{DefaultConfig: Config{Project: ""}}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{Project: "foo", ChannelID: "bar", Token: "baz"}
if !validProvider.IsValid() {
validProvider := AlertProvider{DefaultConfig: Config{Project: "foo", ChannelID: "bar", Token: "baz"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Project: "foobar",
DefaultConfig: Config{Project: "foobar"},
Overrides: []Override{
{
ChannelID: "http://example.com",
Group: "",
Config: Config{ChannelID: "http://example.com"},
Group: "",
},
},
}
if providerWithInvalidOverrideGroup.IsValid() {
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Project: "foobar",
DefaultConfig: Config{Project: "foobar"},
Overrides: []Override{
{
ChannelID: "",
Group: "group",
Config: Config{ChannelID: ""},
Group: "group",
},
},
}
if providerWithInvalidOverrideTo.IsValid() {
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
Project: "foo",
ChannelID: "bar",
Token: "baz",
DefaultConfig: Config{
Project: "foo",
ChannelID: "bar",
Token: "baz",
},
Overrides: []Override{
{
ChannelID: "foobar",
Group: "group",
Config: Config{ChannelID: "foobar"},
Group: "group",
},
},
}
if !providerWithValidOverride.IsValid() {
if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
@@ -77,7 +79,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{
{
Name: "triggered",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -87,7 +89,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "triggered-error",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -97,7 +99,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -107,7 +109,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved-error",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -153,40 +155,41 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{
{
Name: "triggered",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
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",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
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",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
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",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
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 {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&scenario.Endpoint,
&scenario.Alert,
&endpoint.Result{
@@ -217,62 +220,98 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
}
}
func TestAlertProvider_getChannelIDForGroup(t *testing.T) {
tests := []struct {
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
ExpectedOutput string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
ChannelID: "bar",
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
Overrides: nil,
},
InputGroup: "",
ExpectedOutput: "bar",
InputAlert: alert.Alert{},
ExpectedOutput: Config{ChannelID: "default", Project: "project", Token: "token"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
ChannelID: "bar",
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
Overrides: nil,
},
InputGroup: "group",
ExpectedOutput: "bar",
InputAlert: alert.Alert{},
ExpectedOutput: Config{ChannelID: "default", Project: "project", Token: "token"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
ChannelID: "bar",
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
Overrides: []Override{
{
Group: "group",
ChannelID: "foobar",
Group: "group",
Config: Config{ChannelID: "group-channel"},
},
},
},
InputGroup: "",
ExpectedOutput: "bar",
InputAlert: alert.Alert{},
ExpectedOutput: Config{ChannelID: "default", Project: "project", Token: "token"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
ChannelID: "bar",
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
Overrides: []Override{
{
Group: "group",
ChannelID: "foobar",
Group: "group",
Config: Config{ChannelID: "group-channel"},
},
},
},
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 {
t.Run(tt.Name, func(t *testing.T) {
if got := tt.Provider.getChannelIDForGroup(tt.InputGroup); got != tt.ExpectedOutput {
t.Errorf("AlertProvider.getChannelIDForGroup() = %v, want %v", got, tt.ExpectedOutput)
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.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)
}
})
}

View File

@@ -3,6 +3,7 @@ package matrix
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"math/rand"
@@ -13,29 +14,18 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"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"
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 string `yaml:"server-url"`
@@ -46,36 +36,78 @@ type ProviderConfig struct {
InternalRoomID string `yaml:"internal-room-id"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
func (cfg *Config) Validate() error {
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)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.AccessToken) == 0 || len(override.InternalRoomID) == 0 {
return false
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return len(provider.AccessToken) > 0 && len(provider.InternalRoomID) > 0
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 {
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
config := provider.getConfigForGroup(ep.Group)
if config.ServerURL == "" {
config.ServerURL = defaultServerURL
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
// The Matrix endpoint requires a unique transaction ID for each event sent
txnId := randStringBytes(24)
request, err := http.NewRequest(
http.MethodPut,
fmt.Sprintf("%s/_matrix/client/v3/rooms/%s/send/m.room.message/%s?access_token=%s",
config.ServerURL,
url.PathEscape(config.InternalRoomID),
cfg.ServerURL,
url.PathEscape(cfg.InternalRoomID),
txnId,
url.QueryEscape(config.AccessToken),
url.QueryEscape(cfg.AccessToken),
),
buffer,
)
@@ -167,18 +199,6 @@ func buildHTMLMessageBody(ep *endpoint.Endpoint, alert *alert.Alert, result *end
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 {
// All the compatible characters to use in a transaction ID
const availableCharacterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
@@ -194,3 +214,34 @@ func randStringBytes(n int) string {
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
}

View File

@@ -11,75 +11,75 @@ import (
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_IsValid(t *testing.T) {
func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{
ProviderConfig: ProviderConfig{
DefaultConfig: Config{
AccessToken: "",
InternalRoomID: "",
},
}
if invalidProvider.IsValid() {
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{
ProviderConfig: ProviderConfig{
DefaultConfig: Config{
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
}
if !validProvider.IsValid() {
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
validProviderWithHomeserver := AlertProvider{
ProviderConfig: ProviderConfig{
DefaultConfig: Config{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
}
if !validProviderWithHomeserver.IsValid() {
if err := validProviderWithHomeserver.Validate(); err != nil {
t.Error("provider with homeserver should've been valid")
}
}
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
Group: "",
ProviderConfig: ProviderConfig{
Config: Config{
AccessToken: "",
InternalRoomID: "",
},
},
},
}
if providerWithInvalidOverrideGroup.IsValid() {
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
Group: "group",
ProviderConfig: ProviderConfig{
Config: Config{
AccessToken: "",
InternalRoomID: "",
},
},
},
}
if providerWithInvalidOverrideTo.IsValid() {
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
ProviderConfig: ProviderConfig{
DefaultConfig: Config{
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
Overrides: []Override{
{
Group: "group",
ProviderConfig: ProviderConfig{
Config: Config{
ServerURL: "https://example.com",
AccessToken: "1",
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")
}
}
@@ -105,18 +105,28 @@ func TestAlertProvider_Send(t *testing.T) {
ExpectedError bool
}{
{
Name: "triggered",
Name: "triggered-with-bad-config",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: 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,
},
{
Name: "triggered-error",
Provider: AlertProvider{},
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 {
@@ -126,7 +136,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{AccessToken: "1", InternalRoomID: "!a:example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -136,7 +146,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved-error",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{AccessToken: "1", InternalRoomID: "!a:example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -227,17 +237,18 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
}
}
func TestAlertProvider_getConfigForGroup(t *testing.T) {
tests := []struct {
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
ExpectedOutput ProviderConfig
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
ProviderConfig: ProviderConfig{
DefaultConfig: Config{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
@@ -245,7 +256,8 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
Overrides: nil,
},
InputGroup: "",
ExpectedOutput: ProviderConfig{
InputAlert: alert.Alert{},
ExpectedOutput: Config{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
@@ -254,7 +266,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
ProviderConfig: ProviderConfig{
DefaultConfig: Config{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
@@ -262,7 +274,8 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
Overrides: nil,
},
InputGroup: "group",
ExpectedOutput: ProviderConfig{
InputAlert: alert.Alert{},
ExpectedOutput: Config{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
@@ -271,7 +284,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
ProviderConfig: ProviderConfig{
DefaultConfig: Config{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
@@ -279,16 +292,17 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
Overrides: []Override{
{
Group: "group",
ProviderConfig: ProviderConfig{
ServerURL: "https://example01.com",
Config: Config{
ServerURL: "https://group-example.com",
AccessToken: "12",
InternalRoomID: "!a:example01.com",
InternalRoomID: "!a:group-example.com",
},
},
},
},
InputGroup: "",
ExpectedOutput: ProviderConfig{
InputAlert: alert.Alert{},
ExpectedOutput: Config{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
@@ -297,7 +311,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
ProviderConfig: ProviderConfig{
DefaultConfig: Config{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
@@ -305,8 +319,35 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
Overrides: []Override{
{
Group: "group",
ProviderConfig: ProviderConfig{
ServerURL: "https://example01.com",
Config: Config{
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",
InternalRoomID: "!a:example01.com",
},
@@ -314,17 +355,32 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
},
},
InputGroup: "group",
ExpectedOutput: ProviderConfig{
ServerURL: "https://example01.com",
AccessToken: "12",
InternalRoomID: "!a:example01.com",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"server-url": "https://alert-example.com", "access-token": "123", "internal-room-id": "!a:alert-example.com"}},
ExpectedOutput: Config{
ServerURL: "https://alert-example.com",
AccessToken: "123",
InternalRoomID: "!a:alert-example.com",
},
},
}
for _, tt := range tests {
t.Run(tt.Name, func(t *testing.T) {
if got := tt.Provider.getConfigForGroup(tt.InputGroup); got != tt.ExpectedOutput {
t.Errorf("AlertProvider.getConfigForGroup() = %v, want %v", got, tt.ExpectedOutput)
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
outputConfig, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
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)
}
})
}

View File

@@ -3,6 +3,7 @@ package mattermost
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -10,17 +11,42 @@ import (
"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"`
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
type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"`
// Channel is the optional setting to override the default webhook's channel
Channel string `yaml:"channel,omitempty"`
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"`
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"`
@@ -31,36 +57,37 @@ type AlertProvider struct {
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
WebhookURL string `yaml:"webhook-url"`
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
if provider.ClientConfig == nil {
provider.ClientConfig = client.GetDefaultConfig()
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
if provider.Overrides != nil {
registeredGroups := make(map[string]bool)
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
return false
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return len(provider.WebhookURL) > 0
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 {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(ep, alert, result, resolved)))
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer)
cfg, err := provider.GetConfig(ep.Group, alert)
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 {
return err
}
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 {
return err
}
@@ -96,7 +123,7 @@ type Field struct {
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.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
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)
@@ -122,7 +149,7 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
description = ":\n> " + alertDescription
}
body := Body{
Channel: provider.Channel,
Channel: cfg.Channel,
Text: "",
Username: "gatus",
IconURL: "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png",
@@ -147,19 +174,38 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
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
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
}

View File

@@ -11,54 +11,50 @@ import (
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{WebhookURL: ""}
if invalidProvider.IsValid() {
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{WebhookURL: "http://example.com"}
if !validProvider.IsValid() {
validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
WebhookURL: "http://example.com",
Group: "",
Config: Config{WebhookURL: "http://example.com"},
Group: "",
},
},
}
if providerWithInvalidOverrideGroup.IsValid() {
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideWebHookUrl := AlertProvider{
Overrides: []Override{
{
WebhookURL: "",
Group: "group",
Config: Config{WebhookURL: ""},
Group: "group",
},
},
}
if providerWithInvalidOverrideWebHookUrl.IsValid() {
if err := providerWithInvalidOverrideWebHookUrl.Validate(); err == nil {
t.Error("provider WebHookURL shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
WebhookURL: "http://example.com",
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
WebhookURL: "http://example.com",
Group: "group",
Config: Config{WebhookURL: "http://example.com"},
Group: "group",
},
},
}
if !providerWithValidOverride.IsValid() {
if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
@@ -77,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{
{
Name: "triggered",
Provider: AlertProvider{},
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 {
@@ -87,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "triggered-error",
Provider: AlertProvider{},
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 {
@@ -97,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved",
Provider: AlertProvider{},
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 {
@@ -107,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved-error",
Provider: AlertProvider{},
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 {
@@ -168,6 +164,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
@@ -198,64 +195,92 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
}
}
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
tests := []struct {
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
ExpectedOutput string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: nil,
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil,
},
InputGroup: "",
ExpectedOutput: "http://example.com",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: nil,
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil,
},
InputGroup: "group",
ExpectedOutput: "http://example.com",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
WebhookURL: "http://example01.com",
Group: "group",
Config: Config{WebhookURL: "http://example01.com"},
},
},
},
InputGroup: "",
ExpectedOutput: "http://example.com",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
WebhookURL: "http://example.com",
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
WebhookURL: "http://example01.com",
Group: "group",
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
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 {
t.Run(tt.Name, func(t *testing.T) {
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
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)
}
})
}

View File

@@ -3,6 +3,7 @@ package messagebird
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -10,37 +11,75 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
const (
restAPIURL = "https://rest.messagebird.com/messages"
const 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 AlertProvider struct {
type Config struct {
AccessKey string `yaml:"access-key"`
Originator string `yaml:"originator"`
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 *alert.Alert `yaml:"default-alert,omitempty"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
return len(provider.AccessKey) > 0 && len(provider.Originator) > 0 && len(provider.Recipients) > 0
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
// Reference doc for messagebird: https://developers.messagebird.com/api/sms-messaging/#send-outbound-sms
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, 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)
if err != nil {
return err
}
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)
if err != nil {
return err
@@ -60,7 +99,7 @@ type Body struct {
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.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
if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
@@ -68,8 +107,8 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
}
body, _ := json.Marshal(Body{
Originator: provider.Originator,
Recipients: provider.Recipients,
Originator: cfg.Originator,
Recipients: cfg.Recipients,
Body: message,
})
return body
@@ -79,3 +118,25 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
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
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
}

View File

@@ -13,15 +13,17 @@ import (
func TestMessagebirdAlertProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{}
if invalidProvider.IsValid() {
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{
AccessKey: "1",
Originator: "1",
Recipients: "1",
DefaultConfig: Config{
AccessKey: "1",
Originator: "1",
Recipients: "1",
},
}
if !validProvider.IsValid() {
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
@@ -40,7 +42,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{
{
Name: "triggered",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -50,7 +52,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "triggered-error",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -60,7 +62,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -70,7 +72,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved-error",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -115,14 +117,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{
{
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},
Resolved: false,
ExpectedBody: "{\"originator\":\"2\",\"recipients\":\"3\",\"body\":\"TRIGGERED: endpoint-name - description-1\"}",
},
{
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},
Resolved: true,
ExpectedBody: "{\"originator\":\"5\",\"recipients\":\"6\",\"body\":\"RESOLVED: endpoint-name - description-2\"}",
@@ -131,6 +133,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
@@ -145,7 +148,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal([]byte(body), &out); err != nil {
if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
@@ -160,3 +163,50 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
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)
}
})
}
}

View File

@@ -3,6 +3,7 @@ package ntfy
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -12,15 +13,23 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
const (
DefaultURL = "https://ntfy.sh"
DefaultPriority = 3
TokenPrefix = "tk_"
)
// AlertProvider is the configuration necessary for sending an alert using Slack
type AlertProvider struct {
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
@@ -29,41 +38,115 @@ type AlertProvider struct {
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
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"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
if len(provider.URL) == 0 {
provider.URL = DefaultURL
// 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 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
}
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
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 {
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.URL, buffer)
cfg, err := provider.GetConfig(ep.Group, alert)
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 {
return err
}
request.Header.Set("Content-Type", "application/json")
if len(provider.Token) > 0 {
request.Header.Set("Authorization", "Bearer "+provider.Token)
if token := cfg.Token; len(token) > 0 {
request.Header.Set("Authorization", "Bearer "+token)
}
if provider.DisableFirebase {
if cfg.DisableFirebase {
request.Header.Set("Firebase", "no")
}
if provider.DisableCache {
if cfg.DisableCache {
request.Header.Set("Cache", "no")
}
response, err := client.GetHTTPClient(nil).Do(request)
@@ -89,7 +172,7 @@ type Body struct {
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.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
if resolved {
tag = "white_check_mark"
@@ -112,13 +195,13 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
}
message += formattedConditionResults
body, _ := json.Marshal(Body{
Topic: provider.Topic,
Topic: cfg.Topic,
Title: "Gatus: " + ep.DisplayName(),
Message: message,
Tags: []string{tag},
Priority: provider.Priority,
Email: provider.Email,
Click: provider.Click,
Priority: cfg.Priority,
Email: cfg.Email,
Click: cfg.Click,
})
return body
}
@@ -127,3 +210,34 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
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
}

View File

@@ -11,7 +11,7 @@ import (
"github.com/TwiN/gatus/v5/config/endpoint"
)
func TestAlertDefaultProvider_IsValid(t *testing.T) {
func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct {
name string
provider AlertProvider
@@ -19,49 +19,78 @@ func TestAlertDefaultProvider_IsValid(t *testing.T) {
}{
{
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,
},
{
name: "no-url-should-use-default-value",
provider: AlertProvider{Topic: "example", Priority: 1},
provider: AlertProvider{DefaultConfig: Config{Topic: "example", Priority: 1}},
expected: true,
},
{
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,
},
{
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,
},
{
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,
},
{
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,
},
{
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,
},
{
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,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
if scenario.provider.IsValid() != scenario.expected {
t.Errorf("expected %t, got %t", scenario.expected, scenario.provider.IsValid())
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")
}
})
}
@@ -79,36 +108,55 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{
{
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},
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}`,
},
{
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},
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}`,
},
{
Name: "triggered-email",
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com"},
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{URL: "https://ntfy.sh", Topic: "example", Priority: 2, Email: "test@example.com", Click: "example.com"},
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 {
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{
@@ -137,24 +185,39 @@ func TestAlertProvider_Send(t *testing.T) {
Provider AlertProvider
Alert alert.Alert
Resolved bool
Group string
ExpectedBody string
ExpectedHeaders map[string]string
}{
{
Name: "triggered",
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com"},
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: "no firebase",
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", DisableFirebase: true},
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",
@@ -163,9 +226,10 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "no cache",
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", DisableCache: true},
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",
@@ -174,9 +238,10 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "neither firebase & cache",
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", DisableFirebase: true, DisableCache: true},
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",
@@ -184,6 +249,18 @@ func TestAlertProvider_Send(t *testing.T) {
"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) {
@@ -205,9 +282,9 @@ func TestAlertProvider_Send(t *testing.T) {
// Close the server when test finishes
defer server.Close()
scenario.Provider.URL = server.URL
scenario.Provider.DefaultConfig.URL = server.URL
err := scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&endpoint.Endpoint{Name: "endpoint-name", Group: scenario.Group},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
@@ -220,8 +297,118 @@ func TestAlertProvider_Send(t *testing.T) {
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)
}
})
}
}

View File

@@ -3,6 +3,7 @@ package opsgenie
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -12,13 +13,18 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
const (
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 string `yaml:"api-key"`
@@ -46,26 +52,74 @@ type AlertProvider struct {
//
// default: []
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 *alert.Alert `yaml:"default-alert,omitempty"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
return len(provider.APIKey) > 0
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
//
// Relevant: https://docs.opsgenie.com/docs/alert-api
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
err := provider.createAlert(ep, 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 {
return err
}
if resolved {
err = provider.closeAlert(ep, alert)
err = provider.closeAlert(cfg, ep, alert)
if err != nil {
return err
}
@@ -75,24 +129,24 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
// The alert has been resolved and there's no error, so we can clear the alert's ResolveKey
alert.ResolveKey = ""
} else {
alert.ResolveKey = provider.alias(buildKey(ep))
alert.ResolveKey = cfg.AliasPrefix + buildKey(ep)
}
}
return nil
}
func (provider *AlertProvider) createAlert(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
payload := provider.buildCreateRequestBody(ep, alert, result, resolved)
return provider.sendRequest(restAPI, http.MethodPost, payload)
func (provider *AlertProvider) sendAlertRequest(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
payload := provider.buildCreateRequestBody(cfg, ep, alert, result, resolved)
return provider.sendRequest(cfg, restAPI, http.MethodPost, payload)
}
func (provider *AlertProvider) closeAlert(ep *endpoint.Endpoint, alert *alert.Alert) error {
func (provider *AlertProvider) closeAlert(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert) error {
payload := provider.buildCloseRequestBody(ep, alert)
url := restAPI + "/" + provider.alias(buildKey(ep)) + "/close?identifierType=alias"
return provider.sendRequest(url, http.MethodPost, payload)
url := restAPI + "/" + cfg.AliasPrefix + buildKey(ep) + "/close?identifierType=alias"
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)
if err != nil {
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
}
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)
if err != nil {
return err
@@ -115,7 +169,7 @@ func (provider *AlertProvider) sendRequest(url, method string, payload interface
return nil
}
func (provider *AlertProvider) buildCreateRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.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
if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", ep.Name, alert.GetDescription())
@@ -158,11 +212,11 @@ func (provider *AlertProvider) buildCreateRequestBody(ep *endpoint.Endpoint, ale
return alertCreateRequest{
Message: message,
Description: description,
Source: provider.source(),
Priority: provider.priority(),
Alias: provider.alias(key),
Entity: provider.entity(key),
Tags: provider.Tags,
Source: cfg.Source,
Priority: cfg.Priority,
Alias: cfg.AliasPrefix + key,
Entity: cfg.EntityPrefix + key,
Tags: cfg.Tags,
Details: details,
}
}
@@ -174,43 +228,33 @@ func (provider *AlertProvider) buildCloseRequestBody(ep *endpoint.Endpoint, aler
}
}
func (provider *AlertProvider) source() string {
source := provider.Source
if source == "" {
return "gatus"
}
return source
}
func (provider *AlertProvider) alias(key string) string {
alias := provider.AliasPrefix
if alias == "" {
alias = "gatus-healthcheck-"
}
return alias + key
}
func (provider *AlertProvider) entity(key string) string {
alias := provider.EntityPrefix
if alias == "" {
alias = "gatus-"
}
return alias + key
}
func (provider *AlertProvider) priority() string {
priority := provider.Priority
if priority == "" {
return "P1"
}
return priority
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// 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
}
func buildKey(ep *endpoint.Endpoint) string {
name := toKebabCase(ep.Name)
if ep.Group == "" {

View File

@@ -11,13 +11,13 @@ import (
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{APIKey: ""}
if invalidProvider.IsValid() {
func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{DefaultConfig: Config{APIKey: ""}}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{APIKey: "00000000-0000-0000-0000-000000000000"}
if !validProvider.IsValid() {
validProvider := AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
@@ -35,7 +35,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{
{
Name: "triggered",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 1, FailureThreshold: 1},
Resolved: false,
ExpectedError: false,
@@ -45,7 +45,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "triggered-error",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedError: true,
@@ -55,7 +55,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedError: false,
@@ -65,7 +65,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved-error",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedError: true,
@@ -74,7 +74,6 @@ func TestAlertProvider_Send(t *testing.T) {
}),
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
@@ -113,7 +112,7 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
}{
{
Name: "missing all params (unresolved)",
Provider: &AlertProvider{},
Provider: &AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: &alert.Alert{},
Endpoint: &endpoint.Endpoint{},
Result: &endpoint.Result{},
@@ -131,7 +130,7 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
},
{
Name: "missing all params (resolved)",
Provider: &AlertProvider{},
Provider: &AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: &alert.Alert{},
Endpoint: &endpoint.Endpoint{},
Result: &endpoint.Result{},
@@ -149,7 +148,7 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
},
{
Name: "with default options (unresolved)",
Provider: &AlertProvider{},
Provider: &AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: &alert.Alert{
Description: &description,
FailureThreshold: 3,
@@ -184,11 +183,13 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
{
Name: "with custom options (resolved)",
Provider: &AlertProvider{
Priority: "P5",
EntityPrefix: "oompa-",
AliasPrefix: "loompa-",
Source: "gatus-hc",
Tags: []string{"do-ba-dee-doo"},
DefaultConfig: Config{
Priority: "P5",
EntityPrefix: "oompa-",
AliasPrefix: "loompa-",
Source: "gatus-hc",
Tags: []string{"do-ba-dee-doo"},
},
},
Alert: &alert.Alert{
Description: &description,
@@ -220,7 +221,7 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
{
Name: "with default options and details (unresolved)",
Provider: &AlertProvider{
Tags: []string{"foo"},
DefaultConfig: Config{Tags: []string{"foo"}, APIKey: "00000000-0000-0000-0000-000000000000"},
},
Alert: &alert.Alert{
Description: &description,
@@ -265,8 +266,9 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
for _, scenario := range scenarios {
actual := scenario
t.Run(actual.Name, func(t *testing.T) {
if got := actual.Provider.buildCreateRequestBody(actual.Endpoint, actual.Alert, actual.Result, actual.Resolved); !reflect.DeepEqual(got, actual.want) {
t.Errorf("buildCreateRequestBody() = %v, want %v", got, actual.want)
_ = scenario.Provider.Validate()
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)
}
})
}
@@ -307,7 +309,6 @@ func TestAlertProvider_buildCloseRequestBody(t *testing.T) {
},
},
}
for _, scenario := range scenarios {
actual := scenario
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)
}
})
}
}

View File

@@ -3,23 +3,47 @@ package pagerduty
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"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://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
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 *alert.Alert `yaml:"default-alert,omitempty"`
@@ -30,30 +54,34 @@ type AlertProvider struct {
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
IntegrationKey string `yaml:"integration-key"`
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
// 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 == "" || len(override.IntegrationKey) != 32 {
return false
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
// 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
//
// Relevant: https://developer.pagerduty.com/docs/events-api-v2/trigger-events/
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, 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)
if err != nil {
return err
@@ -74,11 +102,10 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
alert.ResolveKey = ""
} else {
// We need to retrieve the resolve key from the response
body, err := io.ReadAll(response.Body)
var payload pagerDutyResponsePayload
if err = json.Unmarshal(body, &payload); err != nil {
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.
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 {
alert.ResolveKey = payload.DedupKey
}
@@ -101,7 +128,7 @@ type Payload struct {
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.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
if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
@@ -113,7 +140,7 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
resolveKey = ""
}
body, _ := json.Marshal(Body{
RoutingKey: provider.getIntegrationKeyForGroup(ep.Group),
RoutingKey: cfg.IntegrationKey,
DedupKey: resolveKey,
EventAction: eventAction,
Payload: Payload{
@@ -125,23 +152,42 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
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
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
}
type pagerDutyResponsePayload struct {
Status string `json:"status"`
Message string `json:"message"`

View File

@@ -11,50 +11,41 @@ import (
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{IntegrationKey: ""}
if invalidProvider.IsValid() {
func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{DefaultConfig: Config{IntegrationKey: ""}}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{IntegrationKey: "00000000000000000000000000000000"}
if !validProvider.IsValid() {
validProvider := AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: []Override{
{
IntegrationKey: "00000000000000000000000000000000",
Group: "",
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
Group: "",
},
},
}
if providerWithInvalidOverrideGroup.IsValid() {
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
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{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: []Override{
{
IntegrationKey: "00000000000000000000000000000000",
Group: "group",
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
Group: "group",
},
},
}
if !providerWithValidOverride.IsValid() {
t.Error("provider should've been valid")
if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid, got error:", err.Error())
}
}
@@ -72,7 +63,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{
{
Name: "triggered",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -82,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "triggered-error",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -92,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -102,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved-error",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -146,14 +137,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{
{
Name: "triggered",
Provider: AlertProvider{IntegrationKey: "00000000000000000000000000000000"},
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
Alert: alert.Alert{Description: &description},
Resolved: false,
ExpectedBody: "{\"routing_key\":\"00000000000000000000000000000000\",\"dedup_key\":\"\",\"event_action\":\"trigger\",\"payload\":{\"summary\":\"TRIGGERED: endpoint-name - test\",\"source\":\"Gatus\",\"severity\":\"critical\"}}",
},
{
Name: "resolved",
Provider: AlertProvider{IntegrationKey: "00000000000000000000000000000000"},
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
Alert: alert.Alert{Description: &description, ResolveKey: "key"},
Resolved: true,
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 {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(&endpoint.Endpoint{Name: "endpoint-name"}, &scenario.Alert, &endpoint.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 {
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) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == 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")
}
}
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)
}
})
}
}

View File

@@ -10,6 +10,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/github"
"github.com/TwiN/gatus/v5/alerting/provider/gitlab"
"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/matrix"
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
@@ -29,18 +30,26 @@ import (
// AlertProvider is the interface that each provider should implement
type AlertProvider interface {
// IsValid returns whether the provider's configuration is valid
IsValid() bool
// Validate the provider's configuration
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() *alert.Alert
// Send an alert using the provider
Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error
// ValidateOverrides validates the alert's provider override and, if present, the group override
ValidateOverrides(group string, alert *alert.Alert) error
}
// ParseWithDefaultAlert parses an Endpoint alert by using the provider's default alert as a baseline
func ParseWithDefaultAlert(providerDefaultAlert, endpointAlert *alert.Alert) {
type Config[T any] interface {
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 {
return
}
@@ -62,14 +71,14 @@ func ParseWithDefaultAlert(providerDefaultAlert, endpointAlert *alert.Alert) {
}
var (
// Validate interface implementation on compile
// Validate provider interface implementation on compile
_ AlertProvider = (*awsses.AlertProvider)(nil)
_ AlertProvider = (*custom.AlertProvider)(nil)
_ AlertProvider = (*discord.AlertProvider)(nil)
_ AlertProvider = (*email.AlertProvider)(nil)
_ AlertProvider = (*gitea.AlertProvider)(nil)
_ AlertProvider = (*github.AlertProvider)(nil)
_ AlertProvider = (*gitlab.AlertProvider)(nil)
_ AlertProvider = (*gitea.AlertProvider)(nil)
_ AlertProvider = (*googlechat.AlertProvider)(nil)
_ AlertProvider = (*jetbrainsspace.AlertProvider)(nil)
_ AlertProvider = (*matrix.AlertProvider)(nil)
@@ -85,4 +94,30 @@ var (
_ AlertProvider = (*telegram.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)
)

View File

@@ -126,7 +126,7 @@ func TestParseWithDefaultAlert(t *testing.T) {
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
ParseWithDefaultAlert(scenario.DefaultAlert, scenario.EndpointAlert)
MergeProviderDefaultAlertIntoEndpointAlert(scenario.DefaultAlert, scenario.EndpointAlert)
if scenario.ExpectedOutputAlert == nil {
if scenario.EndpointAlert != nil {
t.Fail()

View File

@@ -3,6 +3,7 @@ package pushover
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -10,6 +11,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
const (
@@ -17,8 +19,13 @@ const (
defaultPriority = 0
)
// AlertProvider is the configuration necessary for sending an alert using Pushover
type AlertProvider struct {
var (
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")
)
type Config struct {
// Key used to authenticate the application sending
// See "Your Applications" on the dashboard, or add a new one: https://pushover.net/apps/build
ApplicationToken string `yaml:"application-token"`
@@ -26,34 +33,84 @@ type AlertProvider struct {
// Key of the user or group the messages should be sent to
UserKey string `yaml:"user-key"`
// The title of your message, likely the application name
// default: the name of your application in Pushover
// The title of your message
// default: "Gatus: <endpoint>""
Title string `yaml:"title,omitempty"`
// Priority of all messages, ranging from -2 (very low) to 2 (Emergency)
// default: 0
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)
// default: "" (pushover)
Sound string `yaml:"sound,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
}
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
}
}
// 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 *alert.Alert `yaml:"default-alert,omitempty"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
if provider.Priority == 0 {
provider.Priority = defaultPriority
}
return len(provider.ApplicationToken) == 30 && len(provider.UserKey) == 30 && provider.Priority >= -2 && provider.Priority <= 2
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
// Reference doc for pushover: https://pushover.net/api
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, 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)
if err != nil {
return err
@@ -77,36 +134,72 @@ type Body struct {
Title string `json:"title,omitempty"`
Message string `json:"message"`
Priority int `json:"priority"`
Html int `json:"html"`
Sound string `json:"sound,omitempty"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message string
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message, formattedConditionResults string
priority := cfg.Priority
if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", ep.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 {
message = fmt.Sprintf("TRIGGERED: %s - %s", ep.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{
Token: provider.ApplicationToken,
User: provider.UserKey,
Title: provider.Title,
Token: cfg.ApplicationToken,
User: cfg.UserKey,
Title: title,
Message: message,
Priority: provider.priority(),
Sound: provider.Sound,
Priority: priority,
Html: 1,
Sound: cfg.Sound,
})
return body
}
func (provider *AlertProvider) priority() int {
if provider.Priority == 0 {
return defaultPriority
}
return provider.Priority
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// 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
}

View File

@@ -12,30 +12,38 @@ import (
)
func TestPushoverAlertProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{}
if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{
ApplicationToken: "aTokenWithLengthOf30characters",
UserKey: "aTokenWithLengthOf30characters",
Title: "Gatus Notification",
Priority: 1,
}
if !validProvider.IsValid() {
t.Error("provider should've been valid")
}
}
func TestPushoverAlertProvider_IsInvalid(t *testing.T) {
invalidProvider := AlertProvider{
ApplicationToken: "aTokenWithLengthOfMoreThan30characters",
UserKey: "aTokenWithLengthOfMoreThan30characters",
Priority: 5,
}
if invalidProvider.IsValid() {
t.Error("provider should've been invalid")
}
t.Run("empty-invalid-provider", func(t *testing.T) {
invalidProvider := AlertProvider{}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
})
t.Run("valid-provider", func(t *testing.T) {
validProvider := AlertProvider{
DefaultConfig: Config{
ApplicationToken: "aTokenWithLengthOf30characters",
UserKey: "aTokenWithLengthOf30characters",
Title: "Gatus Notification",
Priority: 1,
ResolvedPriority: 1,
},
}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
})
t.Run("invalid-provider", func(t *testing.T) {
invalidProvider := AlertProvider{
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) {
@@ -52,7 +60,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{
{
Name: "triggered",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -62,7 +70,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "triggered-error",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -72,7 +80,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -82,7 +90,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved-error",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -119,37 +127,53 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ResolvedPriority bool
ExpectedBody string
}{
{
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},
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",
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},
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",
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},
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\"}",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
@@ -179,3 +203,50 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
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)
}
})
}
}

View File

@@ -3,6 +3,7 @@ package slack
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -10,41 +11,70 @@ import (
"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"` // 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
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 *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
WebhookURL string `yaml:"webhook-url"`
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
// 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 == "" || len(override.WebhookURL) == 0 {
return false
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return len(provider.WebhookURL) > 0
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(ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer)
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
if err != nil {
return err
}
@@ -126,19 +156,38 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
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
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
}

View File

@@ -11,50 +11,50 @@ import (
"github.com/TwiN/gatus/v5/test"
)
func TestAlertDefaultProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{WebhookURL: ""}
if invalidProvider.IsValid() {
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{WebhookURL: "https://example.com"}
if !validProvider.IsValid() {
validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "https://example.com"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
WebhookURL: "http://example.com",
Group: "",
Config: Config{WebhookURL: "http://example.com"},
Group: "",
},
},
}
if providerWithInvalidOverrideGroup.IsValid() {
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
WebhookURL: "",
Group: "group",
Config: Config{WebhookURL: ""},
Group: "group",
},
},
}
if providerWithInvalidOverrideTo.IsValid() {
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
WebhookURL: "http://example.com",
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
WebhookURL: "http://example.com",
Group: "group",
Config: Config{WebhookURL: "http://example.com"},
Group: "group",
},
},
}
if !providerWithValidOverride.IsValid() {
if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
@@ -73,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{
{
Name: "triggered",
Provider: AlertProvider{},
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 {
@@ -83,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "triggered-error",
Provider: AlertProvider{},
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 {
@@ -93,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved",
Provider: AlertProvider{},
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 {
@@ -103,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved-error",
Provider: AlertProvider{},
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 {
@@ -227,64 +227,92 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
}
}
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
tests := []struct {
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
ExpectedOutput string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: nil,
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil,
},
InputGroup: "",
ExpectedOutput: "http://example.com",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: nil,
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil,
},
InputGroup: "group",
ExpectedOutput: "http://example.com",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
WebhookURL: "http://example01.com",
Group: "group",
Config: Config{WebhookURL: "http://example01.com"},
},
},
},
InputGroup: "",
ExpectedOutput: "http://example.com",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
WebhookURL: "http://example.com",
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
WebhookURL: "http://example01.com",
Group: "group",
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
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 {
t.Run(tt.Name, func(t *testing.T) {
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
t.Errorf("AlertProvider.getWebhookURLForGroup() = %v, want %v", got, tt.ExpectedOutput)
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)
}
})
}

View File

@@ -3,6 +3,7 @@ package teams
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -10,54 +11,85 @@ import (
"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
// 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
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 *alert.Alert `yaml:"default-alert,omitempty"`
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
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 {
Group string `yaml:"group"`
WebhookURL string `yaml:"webhook-url"`
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
// 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 == "" || len(override.WebhookURL) == 0 {
return false
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return len(provider.WebhookURL) > 0
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 {
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer)
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(provider.ClientConfig).Do(request)
response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
if err != nil {
return err
}
@@ -84,7 +116,7 @@ type Section struct {
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.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
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)
@@ -111,7 +143,7 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
Type: "MessageCard",
Context: "http://schema.org/extensions",
ThemeColor: color,
Title: provider.Title,
Title: cfg.Title,
Text: message + description,
}
if len(body.Title) == 0 {
@@ -127,19 +159,38 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
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
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
}

View File

@@ -11,50 +11,50 @@ import (
"github.com/TwiN/gatus/v5/test"
)
func TestAlertDefaultProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{WebhookURL: ""}
if invalidProvider.IsValid() {
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{WebhookURL: "http://example.com"}
if !validProvider.IsValid() {
validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
WebhookURL: "http://example.com",
Group: "",
Config: Config{WebhookURL: "http://example.com"},
Group: "",
},
},
}
if providerWithInvalidOverrideGroup.IsValid() {
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
WebhookURL: "",
Group: "group",
Config: Config{WebhookURL: ""},
Group: "group",
},
},
}
if providerWithInvalidOverrideTo.IsValid() {
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
WebhookURL: "http://example.com",
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
WebhookURL: "http://example.com",
Group: "group",
Config: Config{WebhookURL: "http://example.com"},
Group: "group",
},
},
}
if !providerWithValidOverride.IsValid() {
if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
@@ -73,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{
{
Name: "triggered",
Provider: AlertProvider{},
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 {
@@ -83,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "triggered-error",
Provider: AlertProvider{},
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 {
@@ -93,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved",
Provider: AlertProvider{},
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 {
@@ -103,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved-error",
Provider: AlertProvider{},
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 {
@@ -180,6 +180,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}
}
body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{ConditionResults: conditionResults},
@@ -205,64 +206,92 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
}
}
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
tests := []struct {
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
ExpectedOutput string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: nil,
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil,
},
InputGroup: "",
ExpectedOutput: "http://example.com",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: nil,
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil,
},
InputGroup: "group",
ExpectedOutput: "http://example.com",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
WebhookURL: "http://example01.com",
Group: "group",
Config: Config{WebhookURL: "http://example01.com"},
},
},
},
InputGroup: "",
ExpectedOutput: "http://example.com",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
WebhookURL: "http://example.com",
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
WebhookURL: "http://example01.com",
Group: "group",
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
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 {
t.Run(tt.Name, func(t *testing.T) {
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
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)
}
})
}

View File

@@ -3,6 +3,7 @@ package teamsworkflows
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -10,46 +11,74 @@ import (
"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 {
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 *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"`
// 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 {
Group string `yaml:"group"`
WebhookURL string `yaml:"webhook-url"`
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
// 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 == "" || len(override.WebhookURL) == 0 {
return false
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return len(provider.WebhookURL) > 0
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 {
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer)
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
}
@@ -68,9 +97,10 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
// AdaptiveCardBody represents the structure of an Adaptive Card
type AdaptiveCardBody struct {
Type string `json:"type"`
Version string `json:"version"`
Body []CardBody `json:"body"`
Type string `json:"type"`
Version string `json:"version"`
Body []CardBody `json:"body"`
MSTeams MSTeamsBody `json:"msteams"`
}
// CardBody represents the body of the Adaptive Card
@@ -84,6 +114,12 @@ type CardBody struct {
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
@@ -99,18 +135,21 @@ type Fact struct {
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.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 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 := "&#x26D1; Gatus"
if provider.Title != "" {
title = provider.Title
title := "⛑️ Gatus"
if cfg.Title != "" {
title = cfg.Title
}
// Build the facts from the condition results
@@ -118,9 +157,9 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
for _, conditionResult := range result.ConditionResults {
var key string
if conditionResult.Success {
key = "&#x2705;"
key = ""
} else {
key = "&#x274C;"
key = ""
}
facts = append(facts, Fact{
Title: key,
@@ -133,21 +172,36 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
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: "TextBlock",
Text: title,
Size: "Medium",
Weight: "Bolder",
},
{
Type: "TextBlock",
Text: message,
Wrap: true,
},
{
Type: "FactSet",
Facts: facts,
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{}{
@@ -164,19 +218,38 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
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
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
}

View File

@@ -11,50 +11,50 @@ import (
"github.com/TwiN/gatus/v5/test"
)
func TestAlertDefaultProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{WebhookURL: ""}
if invalidProvider.IsValid() {
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{WebhookURL: "http://example.com"}
if !validProvider.IsValid() {
t.Error("provider should've 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_IsValidWithOverride(t *testing.T) {
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
WebhookURL: "http://example.com",
Group: "",
Config: Config{WebhookURL: "http://example.com"},
Group: "",
},
},
}
if providerWithInvalidOverrideGroup.IsValid() {
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
WebhookURL: "",
Group: "group",
Config: Config{WebhookURL: ""},
Group: "group",
},
},
}
if providerWithInvalidOverrideTo.IsValid() {
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
WebhookURL: "http://example.com",
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
WebhookURL: "http://example.com",
Group: "group",
Config: Config{WebhookURL: "http://example.com"},
Group: "group",
},
},
}
if !providerWithValidOverride.IsValid() {
if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
@@ -73,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{
{
Name: "triggered",
Provider: AlertProvider{},
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 {
@@ -83,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "triggered-error",
Provider: AlertProvider{},
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 {
@@ -93,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved",
Provider: AlertProvider{},
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 {
@@ -103,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved-error",
Provider: AlertProvider{},
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 {
@@ -152,14 +152,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"TextBlock\",\"text\":\"\\u0026#x26D1; 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\":\"\\u0026#x274C;\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"\\u0026#x274C;\",\"value\":\"[STATUS] == 200\"}]}]},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
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\":\"TextBlock\",\"text\":\"\\u0026#x26D1; 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\":\"\\u0026#x2705;\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"\\u0026#x2705;\",\"value\":\"[STATUS] == 200\"}]}]},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
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",
@@ -167,7 +167,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"TextBlock\",\"text\":\"\\u0026#x26D1; 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}]},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
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 {
@@ -180,6 +180,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}
}
body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{ConditionResults: conditionResults},
@@ -205,64 +206,92 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
}
}
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
tests := []struct {
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
ExpectedOutput string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: nil,
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil,
},
InputGroup: "",
ExpectedOutput: "http://example.com",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: nil,
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil,
},
InputGroup: "group",
ExpectedOutput: "http://example.com",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
WebhookURL: "http://example01.com",
Group: "group",
Config: Config{WebhookURL: "http://example01.com"},
},
},
},
InputGroup: "",
ExpectedOutput: "http://example.com",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
WebhookURL: "http://example.com",
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
WebhookURL: "http://example01.com",
Group: "group",
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
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 {
t.Run(tt.Name, func(t *testing.T) {
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
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)
}
})
}

View File

@@ -3,6 +3,7 @@ package telegram
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -10,66 +11,97 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"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
type AlertProvider struct {
Token string `yaml:"token"`
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"`
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 Overrid that may be prioritized over the default configuration
// Overrides is a list of overrides that may be prioritized over the default configuration
Overrides []*Override `yaml:"overrides,omitempty"`
}
// Override is a configuration that may be prioritized over the default configuration
type Override struct {
group string `yaml:"group"`
token string `yaml:"token"`
id string `yaml:"id"`
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
if provider.ClientConfig == nil {
provider.ClientConfig = client.GetDefaultConfig()
}
registerGroups := make(map[string]bool)
for _, override := range provider.Overrides {
if len(override.group) == 0 {
return false
// 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
}
if _, ok := registerGroups[override.group]; ok {
return false
}
registerGroups[override.group] = true
}
return len(provider.Token) > 0 && len(provider.ID) > 0
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 {
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
apiURL := provider.APIURL
if apiURL == "" {
apiURL = defaultAPIURL
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/bot%s/sendMessage", apiURL, provider.getTokenForGroup(ep.Group)), 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 {
return err
}
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 {
return err
}
@@ -81,15 +113,6 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
return err
}
func (provider *AlertProvider) getTokenForGroup(group string) string {
for _, override := range provider.Overrides {
if override.group == group && len(override.token) > 0 {
return override.token
}
}
return provider.Token
}
type Body struct {
ChatID string `json:"chat_id"`
Text string `json:"text"`
@@ -97,7 +120,7 @@ type Body struct {
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.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
if resolved {
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)
@@ -124,23 +147,45 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
text = fmt.Sprintf("⛑ *Gatus* \n%s%s", message, formattedConditionResults)
}
bodyAsJSON, _ := json.Marshal(Body{
ChatID: provider.getIDForGroup(ep.Group),
ChatID: cfg.ID,
Text: text,
ParseMode: "MARKDOWN",
})
return bodyAsJSON
}
func (provider *AlertProvider) getIDForGroup(group string) string {
for _, override := range provider.Overrides {
if override.group == group && len(override.id) > 0 {
return override.id
}
}
return provider.ID
}
// 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
}

View File

@@ -11,87 +11,36 @@ import (
"github.com/TwiN/gatus/v5/test"
)
func TestAlertDefaultProvider_IsValid(t *testing.T) {
func TestAlertProvider_Validate(t *testing.T) {
t.Run("invalid-provider", func(t *testing.T) {
invalidProvider := AlertProvider{Token: "", ID: ""}
if invalidProvider.IsValid() {
invalidProvider := AlertProvider{DefaultConfig: Config{Token: "", ID: ""}}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
})
t.Run("valid-provider", func(t *testing.T) {
validProvider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}
if validProvider.ClientConfig != nil {
t.Error("provider client config should have been nil prior to IsValid() being executed")
}
if !validProvider.IsValid() {
validProvider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
if validProvider.ClientConfig == nil {
t.Error("provider client config should have been set after IsValid() was executed")
}
})
}
func TestAlertProvider_IsValidWithOverrides(t *testing.T) {
t.Run("invalid-provider-override-nonexist-group", func(t *testing.T) {
invalidProvider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678", Overrides: []*Override{{token: "token", id: "id"}}}
if invalidProvider.IsValid() {
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{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678", Overrides: []*Override{{group: "group1", token: "token", id: "id"}, {group: "group1", id: "id2"}}}
if invalidProvider.IsValid() {
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", func(t *testing.T) {
validProvider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678", Overrides: []*Override{{group: "group", token: "token", id: "id"}}}
if validProvider.ClientConfig != nil {
t.Error("provider client config should have been nil prior to IsValid() being executed")
}
if !validProvider.IsValid() {
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")
}
if validProvider.ClientConfig == nil {
t.Error("provider client config should have been set after IsValid() was executed")
}
})
}
func TestAlertProvider_getTokenAndIDForGroup(t *testing.T) {
t.Run("get-token-with-override", func(t *testing.T) {
provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678", Overrides: []*Override{{group: "group", token: "overrideToken", id: "overrideID"}}}
token := provider.getTokenForGroup("group")
if token != "overrideToken" {
t.Error("token should have been 'overrideToken'")
}
id := provider.getIDForGroup("group")
if id != "overrideID" {
t.Error("id should have been 'overrideID'")
}
})
t.Run("get-default-token-with-overridden-id", func(t *testing.T) {
provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678", Overrides: []*Override{{group: "group", id: "overrideID"}}}
token := provider.getTokenForGroup("group")
if token != provider.Token {
t.Error("token should have been the default token")
}
id := provider.getIDForGroup("group")
if id != "overrideID" {
t.Error("id should have been 'overrideID'")
}
})
t.Run("get-default-token-with-overridden-token", func(t *testing.T) {
provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678", Overrides: []*Override{{group: "group", token: "overrideToken"}}}
token := provider.getTokenForGroup("group")
if token != "overrideToken" {
t.Error("token should have been 'overrideToken'")
}
id := provider.getIDForGroup("group")
if id != provider.ID {
t.Error("id should have been the default id")
}
})
}
@@ -109,7 +58,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{
{
Name: "triggered",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{ID: "123", Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -119,7 +68,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
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},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -129,7 +78,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{ID: "123", Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -139,7 +88,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
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},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -185,14 +134,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{
{
Name: "triggered",
Provider: AlertProvider{ID: "123"},
Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been triggered:\\n—\\n _healthcheck failed 3 time(s) in a row_\\n— \\n*Description* \\n_description-1_ \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
},
{
Name: "resolved",
Provider: AlertProvider{ID: "123"},
Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 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\"}",
@@ -200,7 +149,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
{
Name: "resolved-with-no-conditions",
NoConditions: true,
Provider: AlertProvider{ID: "123"},
Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 5 time(s) in a row_\\n— \\n*Description* \\n_description-2_ \\n\",\"parse_mode\":\"MARKDOWN\"}",
@@ -216,6 +165,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}
}
body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{ConditionResults: conditionResults},
@@ -240,3 +190,63 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
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)
}
})
}

View File

@@ -3,6 +3,7 @@ package twilio
import (
"bytes"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
@@ -11,33 +12,80 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
// AlertProvider is the configuration necessary for sending an alert using Twilio
type AlertProvider struct {
var (
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"`
Token string `yaml:"token"`
From string `yaml:"from"`
To string `yaml:"to"`
}
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
}
}
// 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 *alert.Alert `yaml:"default-alert,omitempty"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
return len(provider.Token) > 0 && len(provider.SID) > 0 && len(provider.From) > 0 && len(provider.To) > 0
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
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 {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(ep, alert, result, resolved)))
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json", provider.SID), buffer)
cfg, err := provider.GetConfig(ep.Group, alert)
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 {
return err
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(provider.SID+":"+provider.Token))))
request.Header.Set("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(cfg.SID+":"+cfg.Token))))
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
@@ -51,7 +99,7 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.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
if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
@@ -59,8 +107,8 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
}
return url.Values{
"To": {provider.To},
"From": {provider.From},
"To": {cfg.To},
"From": {cfg.From},
"Body": {message},
}.Encode()
}
@@ -69,3 +117,25 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
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
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
}

View File

@@ -1,28 +1,110 @@
package twilio
import (
"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 TestTwilioAlertProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{}
if invalidProvider.IsValid() {
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{
SID: "1",
Token: "1",
From: "1",
To: "1",
DefaultConfig: Config{
SID: "1",
Token: "1",
From: "1",
To: "1",
},
}
if !validProvider.IsValid() {
if err := validProvider.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{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) {
firstDescription := "description-1"
secondDescription := "description-2"
@@ -35,14 +117,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{
{
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},
Resolved: false,
ExpectedBody: "Body=TRIGGERED%3A+endpoint-name+-+description-1&From=3&To=4",
},
{
Name: "resolved",
Provider: AlertProvider{SID: "1", Token: "2", From: "3", To: "4"},
Provider: AlertProvider{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "Body=RESOLVED%3A+endpoint-name+-+description-2&From=3&To=4",
@@ -51,6 +133,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
@@ -76,3 +159,53 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
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)
}
})
}
}

View File

@@ -2,6 +2,7 @@ package zulip
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
@@ -10,108 +11,99 @@ import (
"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 is the email of the bot user
BotEmail string `yaml:"bot-email"`
// BotAPIKey is the API key of the bot user
BotAPIKey string `yaml:"bot-api-key"`
// Domain is the domain of the Zulip server
Domain string `yaml:"domain"`
// ChannelID is the ID of the channel to send the message to
ChannelID string `yaml:"channel-id"`
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 {
Config `yaml:",inline"`
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 {
Config
Group string `yaml:"group"`
Group string `yaml:"group"`
Config `yaml:",inline"`
}
func (provider *AlertProvider) validateConfig(conf *Config) bool {
return len(conf.BotEmail) > 0 && len(conf.BotAPIKey) > 0 && len(conf.Domain) > 0 && len(conf.ChannelID) > 0
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
isAlreadyRegistered := registeredGroups[override.Group]
if isAlreadyRegistered || override.Group == "" || !provider.validateConfig(&override.Config) {
return false
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.validateConfig(&provider.Config)
}
// getChannelIdForGroup returns the channel ID for the provided group
func (provider *AlertProvider) getChannelIdForGroup(group string) string {
for _, override := range provider.Overrides {
if override.Group == group {
return override.ChannelID
}
}
return provider.ChannelID
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(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)
}
postData := map[string]string{
"type": "channel",
"to": provider.getChannelIdForGroup(ep.Group),
"topic": "Gatus",
"content": message,
}
bodyParams := url.Values{}
for field, value := range postData {
bodyParams.Add(field, value)
}
return bodyParams.Encode()
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 {
buffer := bytes.NewBufferString(provider.buildRequestBody(ep, alert, result, resolved))
zulipEndpoint := fmt.Sprintf("https://%s/api/v1/messages", provider.Domain)
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(provider.BotEmail, provider.BotAPIKey)
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)
@@ -126,7 +118,66 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
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
}

View File

@@ -1,6 +1,7 @@
package zulip
import (
"errors"
"fmt"
"net/http"
"net/url"
@@ -12,237 +13,84 @@ import (
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_IsValid(t *testing.T) {
testCase := []struct {
name string
alertProvider AlertProvider
expected bool
func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct {
Name string
AlertProvider AlertProvider
ExpectedError error
}{
{
name: "Empty provider",
alertProvider: AlertProvider{},
expected: false,
Name: "Empty provider",
AlertProvider: AlertProvider{},
ExpectedError: ErrBotEmailNotSet,
},
{
name: "Empty channel id",
alertProvider: AlertProvider{
Config: Config{
Name: "Empty channel id",
AlertProvider: AlertProvider{
DefaultConfig: Config{
BotEmail: "something",
BotAPIKey: "something",
Domain: "something",
},
},
expected: false,
ExpectedError: ErrChannelIDNotSet,
},
{
name: "Empty domain",
alertProvider: AlertProvider{
Config: Config{
Name: "Empty domain",
AlertProvider: AlertProvider{
DefaultConfig: Config{
BotEmail: "something",
BotAPIKey: "something",
ChannelID: "something",
},
},
expected: false,
ExpectedError: ErrDomainNotSet,
},
{
name: "Empty bot api key",
alertProvider: AlertProvider{
Config: Config{
Name: "Empty bot api key",
AlertProvider: AlertProvider{
DefaultConfig: Config{
BotEmail: "something",
Domain: "something",
ChannelID: "something",
},
},
expected: false,
ExpectedError: ErrBotAPIKeyNotSet,
},
{
name: "Empty bot email",
alertProvider: AlertProvider{
Config: Config{
Name: "Empty bot email",
AlertProvider: AlertProvider{
DefaultConfig: Config{
BotAPIKey: "something",
Domain: "something",
ChannelID: "something",
},
},
expected: false,
ExpectedError: ErrBotEmailNotSet,
},
{
name: "Valid provider",
alertProvider: AlertProvider{
Config: Config{
Name: "Valid provider",
AlertProvider: AlertProvider{
DefaultConfig: Config{
BotEmail: "something",
BotAPIKey: "something",
Domain: "something",
ChannelID: "something",
},
},
expected: true,
ExpectedError: nil,
},
}
for _, tc := range testCase {
t.Run(tc.name, func(t *testing.T) {
if tc.alertProvider.IsValid() != tc.expected {
t.Errorf("IsValid assertion failed (expected %v, got %v)", tc.expected, !tc.expected)
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_IsValidWithOverride(t *testing.T) {
validConfig := Config{
BotEmail: "something",
BotAPIKey: "something",
Domain: "something",
ChannelID: "something",
}
testCase := []struct {
name string
alertProvider AlertProvider
expected bool
}{
{
name: "Empty group",
alertProvider: AlertProvider{
Config: validConfig,
Overrides: []Override{
{
Config: validConfig,
Group: "",
},
},
},
expected: false,
},
{
name: "Empty override config",
alertProvider: AlertProvider{
Config: validConfig,
Overrides: []Override{
{
Group: "something",
},
},
},
expected: false,
},
{
name: "Empty channel id",
alertProvider: AlertProvider{
Config: validConfig,
Overrides: []Override{
{
Group: "something",
Config: Config{
BotEmail: "something",
BotAPIKey: "something",
Domain: "something",
},
},
},
},
expected: false,
},
{
name: "Empty domain",
alertProvider: AlertProvider{
Config: validConfig,
Overrides: []Override{
{
Group: "something",
Config: Config{
BotEmail: "something",
BotAPIKey: "something",
ChannelID: "something",
},
},
},
},
expected: false,
},
{
name: "Empty bot api key",
alertProvider: AlertProvider{
Config: validConfig,
Overrides: []Override{
{
Group: "something",
Config: Config{
BotEmail: "something",
Domain: "something",
ChannelID: "something",
},
},
},
},
expected: false,
},
{
name: "Empty bot email",
alertProvider: AlertProvider{
Config: validConfig,
Overrides: []Override{
{
Group: "something",
Config: Config{
BotAPIKey: "something",
Domain: "something",
ChannelID: "something",
},
},
},
},
expected: false,
},
{
name: "Valid provider",
alertProvider: AlertProvider{
Config: validConfig,
Overrides: []Override{
{
Group: "something",
Config: validConfig,
},
},
},
expected: true,
},
}
for _, tc := range testCase {
t.Run(tc.name, func(t *testing.T) {
if tc.alertProvider.IsValid() != tc.expected {
t.Errorf("IsValid assertion failed (expected %v, got %v)", tc.expected, !tc.expected)
}
})
}
}
func TestAlertProvider_GetChannelIdForGroup(t *testing.T) {
provider := AlertProvider{
Config: Config{
ChannelID: "default",
},
Overrides: []Override{
{
Group: "group1",
Config: Config{ChannelID: "group1"},
},
{
Group: "group2",
Config: Config{ChannelID: "group2"},
},
},
}
if provider.getChannelIdForGroup("") != "default" {
t.Error("Expected default channel ID")
}
if provider.getChannelIdForGroup("group2") != "group2" {
t.Error("Expected group2 channel ID")
}
}
func TestAlertProvider_BuildRequestBody(t *testing.T) {
func TestAlertProvider_buildRequestBody(t *testing.T) {
basicConfig := Config{
BotEmail: "bot-email",
BotAPIKey: "bot-api-key",
@@ -266,13 +114,13 @@ func TestAlertProvider_BuildRequestBody(t *testing.T) {
{
name: "Resolved alert with no conditions",
provider: AlertProvider{
Config: basicConfig,
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
"content": {`An alert for **endpoint-Name** has been resolved after passing successfully 2 time(s) in a row
> Description
`},
"to": {"channel-id"},
@@ -283,13 +131,13 @@ func TestAlertProvider_BuildRequestBody(t *testing.T) {
{
name: "Resolved alert with conditions",
provider: AlertProvider{
Config: basicConfig,
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
"content": {`An alert for **endpoint-Name** has been resolved after passing successfully 2 time(s) in a row
> Description
:check: - ` + "`[CONNECTED] == true`" + `
@@ -303,13 +151,13 @@ func TestAlertProvider_BuildRequestBody(t *testing.T) {
{
name: "Failed alert with no conditions",
provider: AlertProvider{
Config: basicConfig,
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
"content": {`An alert for **endpoint-Name** has been triggered due to having failed 3 time(s) in a row
> Description
`},
"to": {"channel-id"},
@@ -320,13 +168,13 @@ func TestAlertProvider_BuildRequestBody(t *testing.T) {
{
name: "Failed alert with conditions",
provider: AlertProvider{
Config: basicConfig,
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
"content": {`An alert for **endpoint-Name** has been triggered due to having failed 3 time(s) in a row
> Description
:cross_mark: - ` + "`[CONNECTED] == true`" + `
@@ -349,7 +197,8 @@ func TestAlertProvider_BuildRequestBody(t *testing.T) {
}
}
body := tc.provider.buildRequestBody(
&endpoint.Endpoint{Name: "endpoint-name"},
&tc.provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-Name"},
&tc.alert,
&endpoint.Result{
ConditionResults: conditionResults,
@@ -369,10 +218,10 @@ func TestAlertProvider_BuildRequestBody(t *testing.T) {
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
t.Error("ExpectedError default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
t.Error("ExpectedError default alert to be nil")
}
}
@@ -380,16 +229,16 @@ 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("expected url https://custom-domain.zulipchat.com/api/v1/messages, got %s", req.URL.String())
t.Errorf("ExpectedError url https://custom-domain.zulipchat.com/api/v1/messages, got %s", req.URL.String())
}
if req.Method != http.MethodPost {
t.Errorf("expected POST request, got %s", req.Method)
t.Errorf("ExpectedError POST request, got %s", req.Method)
}
if req.Header.Get("Content-Type") != "application/x-www-form-urlencoded" {
t.Errorf("expected Content-Type header to be application/x-www-form-urlencoded, got %s", req.Header.Get("Content-Type"))
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("expected User-Agent header to be Gatus, got %s", req.Header.Get("User-Agent"))
t.Errorf("ExpectedError User-Agent header to be Gatus, got %s", req.Header.Get("User-Agent"))
}
}
basicConfig := Config{
@@ -413,7 +262,7 @@ func TestAlertProvider_Send(t *testing.T) {
{
name: "resolved",
provider: AlertProvider{
Config: basicConfig,
DefaultConfig: basicConfig,
},
alert: basicAlert,
resolved: true,
@@ -426,7 +275,7 @@ func TestAlertProvider_Send(t *testing.T) {
{
name: "resolved error",
provider: AlertProvider{
Config: basicConfig,
DefaultConfig: basicConfig,
},
alert: basicAlert,
resolved: true,
@@ -439,7 +288,7 @@ func TestAlertProvider_Send(t *testing.T) {
{
name: "triggered",
provider: AlertProvider{
Config: basicConfig,
DefaultConfig: basicConfig,
},
alert: basicAlert,
resolved: false,
@@ -452,7 +301,7 @@ func TestAlertProvider_Send(t *testing.T) {
{
name: "triggered error",
provider: AlertProvider{
Config: basicConfig,
DefaultConfig: basicConfig,
},
alert: basicAlert,
resolved: false,
@@ -467,7 +316,7 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: tc.mockRoundTripper})
err := tc.provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&endpoint.Endpoint{Name: "endpoint-Name"},
&tc.alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
@@ -478,10 +327,155 @@ func TestAlertProvider_Send(t *testing.T) {
tc.resolved,
)
if tc.expectedError && err == nil {
t.Error("expected error, got none")
t.Error("ExpectedError error, got none")
}
if !tc.expectedError && err != nil {
t.Errorf("expected no error, got: %v", err)
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)
}
})
}

View File

@@ -2,14 +2,15 @@ package api
import (
"io/fs"
"log"
"net/http"
"os"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/ui"
"github.com/TwiN/gatus/v5/config/web"
static "github.com/TwiN/gatus/v5/web"
"github.com/TwiN/health"
"github.com/TwiN/logr"
fiber "github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/adaptor"
"github.com/gofiber/fiber/v2/middleware/compress"
@@ -28,9 +29,13 @@ type API struct {
func New(cfg *config.Config) *API {
api := &API{}
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()
}
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)
return api
}
@@ -42,7 +47,7 @@ func (a *API) Router() *fiber.App {
func (a *API) createRouter(cfg *config.Config) *fiber.App {
app := fiber.New(fiber.Config{
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)
},
ReadBufferSize: cfg.Web.ReadBufferSize,
@@ -73,6 +78,7 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App {
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.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/response-times/:duration/badge.svg", ResponseTimeBadge(cfg))
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/chart.svg", ResponseTimeChart)
@@ -87,6 +93,8 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App {
statusCode, body := healthHandler.GetResponseStatusCodeAndBody()
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
app.Use(redirect.New(redirect.Config{
Rules: map[string]string{

View File

@@ -25,6 +25,17 @@ func TestNew(t *testing.T) {
Path: "/health",
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",
Path: "/metrics",

View File

@@ -2,7 +2,6 @@ package api
import (
"errors"
"log"
"math"
"net/http"
"sort"
@@ -10,6 +9,7 @@ import (
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/storage/store/common"
"github.com/TwiN/logr"
"github.com/gofiber/fiber/v2"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
@@ -116,7 +116,7 @@ func ResponseTimeChart(c *fiber.Ctx) error {
c.Set("Expires", "0")
c.Status(http.StatusOK)
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 nil

14
api/custom_css.go Normal file
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)
}

View File

@@ -4,7 +4,6 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config"
@@ -13,6 +12,7 @@ import (
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/storage/store/common"
"github.com/TwiN/gatus/v5/storage/store/common/paging"
"github.com/TwiN/logr"
"github.com/gofiber/fiber/v2"
)
@@ -26,19 +26,19 @@ func EndpointStatuses(cfg *config.Config) fiber.Handler {
if !exists {
endpointStatuses, err := store.Get().GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(page, pageSize))
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())
}
// ALPHA: Retrieve endpoint statuses from remote instances
if endpointStatusesFromRemote, err := getEndpointStatusesFromRemoteInstances(cfg.Remote); err != nil {
log.Printf("[handler.EndpointStatuses] Silently failed to retrieve endpoint statuses from remote: %s", err.Error())
logr.Errorf("[handler.EndpointStatuses] Silently failed to retrieve endpoint statuses from remote: %s", err.Error())
} else if endpointStatusesFromRemote != nil {
endpointStatuses = append(endpointStatuses, endpointStatusesFromRemote...)
}
// Marshal endpoint statuses to JSON
data, err = json.Marshal(endpointStatuses)
if err != nil {
log.Printf("[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")
}
cache.SetWithTTL(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize), data, cacheTTL)
@@ -59,12 +59,14 @@ func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*end
for _, instance := range remoteConfig.Instances {
response, err := httpClient.Get(instance.URL)
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())
continue
}
var endpointStatuses []*endpoint.Status
if err = json.NewDecoder(response.Body).Decode(&endpointStatuses); err != nil {
_ = 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
}
_ = response.Body.Close()
@@ -73,6 +75,10 @@ func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*end
}
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
}
@@ -84,16 +90,16 @@ func EndpointStatus(c *fiber.Ctx) error {
if errors.Is(err, common.ErrEndpointNotFound) {
return c.Status(404).SendString(err.Error())
}
log.Printf("[api.EndpointStatus] Failed to retrieve endpoint status: %s", 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?
log.Printf("[api.EndpointStatus] Endpoint with key=%s not found", c.Params("key"))
logr.Errorf("[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())
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")

View File

@@ -2,7 +2,6 @@ package api
import (
"errors"
"log"
"strings"
"time"
@@ -11,6 +10,7 @@ import (
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/storage/store/common"
"github.com/TwiN/gatus/v5/watchdog"
"github.com/TwiN/logr"
"github.com/gofiber/fiber/v2"
)
@@ -33,11 +33,11 @@ func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
key := c.Params("key")
externalEndpoint := cfg.GetExternalEndpointByKey(key)
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")
}
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")
}
// Persist the result in the storage
@@ -54,13 +54,13 @@ func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
if errors.Is(err, common.ErrEndpointNotFound) {
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())
}
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
if !cfg.Maintenance.IsUnderMaintenance() {
watchdog.HandleAlerting(convertedEndpoint, result, cfg.Alerting, cfg.Debug)
watchdog.HandleAlerting(convertedEndpoint, result, cfg.Alerting)
externalEndpoint.NumberOfSuccessesInARow = convertedEndpoint.NumberOfSuccessesInARow
externalEndpoint.NumberOfFailuresInARow = convertedEndpoint.NumberOfFailuresInARow
}

43
api/raw.go Normal file
View File

@@ -0,0 +1,43 @@
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)))
}

93
api/raw_test.go Normal file
View File

@@ -0,0 +1,93 @@
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,
},
}
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)
}
})
}
}

View File

@@ -3,26 +3,37 @@ package api
import (
_ "embed"
"html/template"
"log"
"github.com/TwiN/gatus/v5/config/ui"
static "github.com/TwiN/gatus/v5/web"
"github.com/TwiN/logr"
"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 {
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)
if err != nil {
// 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.")
}
c.Set("Content-Type", "text/html")
err = t.Execute(c, ui)
err = t.Execute(c, vd)
if err != nil {
// 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.SendStatus(200)

View File

@@ -39,29 +39,50 @@ func TestSinglePageApplication(t *testing.T) {
api := New(cfg)
router := api.Router()
type Scenario struct {
Name string
Path string
ExpectedCode int
Gzip bool
Name string
Path string
Gzip bool
CookieDarkMode bool
UIDarkMode bool
ExpectedCode int
ExpectedDarkTheme bool
}
scenarios := []Scenario{
{
Name: "frontend-home",
Path: "/",
ExpectedCode: 200,
Name: "frontend-home",
Path: "/",
CookieDarkMode: true,
UIDarkMode: false,
ExpectedDarkTheme: true,
ExpectedCode: 200,
},
{
Name: "frontend-endpoint",
Path: "/endpoints/core_frontend",
ExpectedCode: 200,
Name: "frontend-endpoint-light",
Path: "/endpoints/core_frontend",
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 {
t.Run(scenario.Name, func(t *testing.T) {
cfg.UI.DarkMode = &scenario.UIDarkMode
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
if scenario.Gzip {
request.Header.Set("Accept-Encoding", "gzip")
}
if scenario.CookieDarkMode {
request.Header.Set("Cookie", "theme=dark")
}
response, err := router.Test(request)
if err != nil {
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)
}
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)
}
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)
}
})
}
}

View File

@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/smtp"
@@ -14,6 +15,7 @@ import (
"time"
"github.com/TwiN/gocache/v2"
"github.com/TwiN/logr"
"github.com/TwiN/whois"
"github.com/ishidawataru/sctp"
"github.com/miekg/dns"
@@ -96,21 +98,22 @@ func CanCreateUDPConnection(address string, config *Config) bool {
// CanCreateSCTPConnection checks whether a connection can be established with a SCTP endpoint
func CanCreateSCTPConnection(address string, config *Config) bool {
ch := make(chan bool)
ch := make(chan bool, 1)
go (func(res chan bool) {
addr, err := sctp.ResolveSCTPAddr("sctp", address)
if err != nil {
res <- false
return
}
conn, err := sctp.DialSCTP("sctp", nil, addr)
if err != nil {
res <- false
return
}
_ = conn.Close()
res <- true
})(ch)
select {
case result := <-ch:
return result
@@ -182,7 +185,6 @@ func CanCreateSSHConnection(address, username, password string, config *Config)
} else {
port = "22"
}
cli, err := ssh.Dial("tcp", strings.Join([]string{address, port}, ":"), &ssh.ClientConfig{
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
User: username,
@@ -194,46 +196,65 @@ func CanCreateSSHConnection(address, username, password string, config *Config)
if err != nil {
return false, nil, err
}
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.
func ExecuteSSHCommand(sshClient *ssh.Client, body string, config *Config) (bool, int, error) {
type Body struct {
Command string `json:"command"`
}
defer sshClient.Close()
var b Body
if err := json.Unmarshal([]byte(body), &b); err != nil {
return false, 0, err
}
sess, err := sshClient.NewSession()
if err != nil {
return false, 0, err
}
err = sess.Start(b.Command)
if err != nil {
return false, 0, err
}
defer sess.Close()
err = sess.Wait()
if err == nil {
return true, 0, nil
}
e, ok := err.(*ssh.ExitError)
if !ok {
var exitErr *ssh.ExitError
if ok := errors.As(err, &exitErr); !ok {
return false, 0, err
}
return true, e.ExitStatus(), nil
return true, exitErr.ExitStatus(), nil
}
// Ping checks if an address can be pinged and returns the round-trip time if the address can be pinged
@@ -306,6 +327,7 @@ func QueryDNS(queryType, queryName, url string) (connected bool, dnsRcode string
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
@@ -332,6 +354,14 @@ func QueryDNS(queryType, queryName, url string) (connected bool, dnsRcode string
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")
}

View File

@@ -5,6 +5,7 @@ import (
"crypto/tls"
"io"
"net/http"
"net/netip"
"testing"
"time"
@@ -200,6 +201,24 @@ func TestCanPerformTLS(t *testing.T) {
wantConnected: true,
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 {
t.Run(tt.name, func(t *testing.T) {
@@ -338,7 +357,7 @@ func TestTlsRenegotiation(t *testing.T) {
}
func TestQueryDNS(t *testing.T) {
tests := []struct {
scenarios := []struct {
name string
inputDNS dns.Config
inputURL string
@@ -354,7 +373,7 @@ func TestQueryDNS(t *testing.T) {
},
inputURL: "8.8.8.8",
expectedDNSCode: "NOERROR",
expectedBody: "93.184.215.14",
expectedBody: "__IPV4__",
},
{
name: "test Config with type AAAA",
@@ -364,7 +383,7 @@ func TestQueryDNS(t *testing.T) {
},
inputURL: "8.8.8.8",
expectedDNSCode: "NOERROR",
expectedBody: "2606:2800:21f:cb07:6820:80da:af6b:8b2c",
expectedBody: "__IPV6__",
},
{
name: "test Config with type CNAME",
@@ -396,6 +415,16 @@ func TestQueryDNS(t *testing.T) {
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{
@@ -406,27 +435,77 @@ func TestQueryDNS(t *testing.T) {
isErrExpected: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, dnsRCode, body, err := QueryDNS(test.inputDNS.QueryType, test.inputDNS.QueryName, test.inputURL)
if test.isErrExpected && err == nil {
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 != test.expectedDNSCode {
t.Errorf("expected DNSRCode to be %s, got %s", test.expectedDNSCode, dnsRCode)
if dnsRCode != scenario.expectedDNSCode {
t.Errorf("expected DNSRCode to be %s, got %s", scenario.expectedDNSCode, dnsRCode)
}
if test.inputDNS.QueryType == "NS" {
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(test.expectedBody, string(body)) {
t.Errorf("got %s, expected result %s,", string(body), test.expectedBody)
if !pattern.Match(scenario.expectedBody, string(body)) {
t.Errorf("got %s, expected result %s,", string(body), scenario.expectedBody)
}
} else {
if string(body) != test.expectedBody {
t.Errorf("got %s, expected result %s,", string(body), test.expectedBody)
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(5 * time.Millisecond)
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)
}
})
}

View File

@@ -4,7 +4,6 @@ import (
"context"
"crypto/tls"
"errors"
"log"
"net"
"net/http"
"net/url"
@@ -12,6 +11,7 @@ import (
"strconv"
"time"
"github.com/TwiN/logr"
"golang.org/x/oauth2"
"golang.org/x/oauth2/clientcredentials"
"google.golang.org/api/idtoken"
@@ -126,7 +126,7 @@ func (c *Config) ValidateAndSetDefaults() error {
if c.HasIAPConfig() && !c.IAPConfig.isValid() {
return ErrInvalidClientIAPConfig
}
if c.HasTlsConfig() {
if c.HasTLSConfig() {
if err := c.TLS.isValid(); err != nil {
return err
}
@@ -176,8 +176,8 @@ func (c *Config) HasIAPConfig() bool {
return c.IAPConfig != nil
}
// HasTlsConfig returns true if the client has client certificate parameters
func (c *Config) HasTlsConfig() bool {
// HasTLSConfig returns true if the client has client certificate parameters
func (c *Config) HasTLSConfig() bool {
return c.TLS != nil && len(c.TLS.CertificateFile) > 0 && len(c.TLS.PrivateKeyFile) > 0
}
@@ -203,12 +203,12 @@ func (t *TLSConfig) isValid() error {
return ErrInvalidClientTLSConfig
}
// GetHTTPClient return an HTTP client matching the Config's parameters.
// getHTTPClient return an HTTP client matching the Config's parameters.
func (c *Config) getHTTPClient() *http.Client {
tlsConfig := &tls.Config{
InsecureSkipVerify: c.Insecure,
}
if c.HasTlsConfig() && c.TLS.isValid() == nil {
if c.HasTLSConfig() && c.TLS.isValid() == nil {
tlsConfig = configureTLS(tlsConfig, *c.TLS)
}
if c.httpClient == nil {
@@ -232,7 +232,7 @@ func (c *Config) getHTTPClient() *http.Client {
if c.ProxyURL != "" {
proxyURL, err := url.Parse(c.ProxyURL)
if err != nil {
log.Println("[client.getHTTPClient] THIS SHOULD NOT HAPPEN. Silently ignoring custom proxy due to error:", err.Error())
logr.Errorf("[client.getHTTPClient] THIS SHOULD NOT HAPPEN. Silently ignoring custom proxy due to error: %s", err.Error())
} else {
c.httpClient.Transport.(*http.Transport).Proxy = http.ProxyURL(proxyURL)
}
@@ -242,7 +242,7 @@ func (c *Config) getHTTPClient() *http.Client {
if err != nil {
// We're ignoring the error, because it should have been validated on startup ValidateAndSetDefaults.
// It shouldn't happen, but if it does, we'll log it... Better safe than sorry ;)
log.Println("[client.getHTTPClient] THIS SHOULD NOT HAPPEN. Silently ignoring invalid DNS resolver due to error:", err.Error())
logr.Errorf("[client.getHTTPClient] THIS SHOULD NOT HAPPEN. Silently ignoring invalid DNS resolver due to error: %s", err.Error())
} else {
dialer := &net.Dialer{
Resolver: &net.Resolver{
@@ -259,7 +259,7 @@ func (c *Config) getHTTPClient() *http.Client {
}
}
if c.HasOAuth2Config() && c.HasIAPConfig() {
log.Println("[client.getHTTPClient] Error: Both Identity-Aware-Proxy and Oauth2 configuration are present.")
logr.Errorf("[client.getHTTPClient] Error: Both Identity-Aware-Proxy and Oauth2 configuration are present.")
} else if c.HasOAuth2Config() {
c.httpClient = configureOAuth2(c.httpClient, *c.OAuth2Config)
} else if c.HasIAPConfig() {
@@ -269,23 +269,22 @@ func (c *Config) getHTTPClient() *http.Client {
return c.httpClient
}
// validateIAPToken returns a boolean that will define if the google identity-aware-proxy token can be fetch
// validateIAPToken returns a boolean that will define if the Google identity-aware-proxy token can be fetched
// and if is it valid.
func validateIAPToken(ctx context.Context, c IAPConfig) bool {
ts, err := idtoken.NewTokenSource(ctx, c.Audience)
if err != nil {
log.Println("[client.ValidateIAPToken] Claiming Identity token failed. error:", err.Error())
logr.Errorf("[client.ValidateIAPToken] Claiming Identity token failed: %s", err.Error())
return false
}
tok, err := ts.Token()
if err != nil {
log.Println("[client.ValidateIAPToken] Get Identity-Aware-Proxy token failed. error:", err.Error())
logr.Errorf("[client.ValidateIAPToken] Get Identity-Aware-Proxy token failed: %s", err.Error())
return false
}
payload, err := idtoken.Validate(ctx, tok.AccessToken, c.Audience)
_ = payload
_, err = idtoken.Validate(ctx, tok.AccessToken, c.Audience)
if err != nil {
log.Println("[client.ValidateIAPToken] Token Validation failed. error:", err.Error())
logr.Errorf("[client.ValidateIAPToken] Token Validation failed: %s", err.Error())
return false
}
return true
@@ -298,7 +297,7 @@ func configureIAP(httpClient *http.Client, c IAPConfig) *http.Client {
if validateIAPToken(ctx, c) {
ts, err := idtoken.NewTokenSource(ctx, c.Audience)
if err != nil {
log.Println("[client.ConfigureIAP] Claiming Token Source failed. error:", err.Error())
logr.Errorf("[client.configureIAP] Claiming Token Source failed: %s", err.Error())
return httpClient
}
client := oauth2.NewClient(ctx, ts)
@@ -327,17 +326,17 @@ func configureOAuth2(httpClient *http.Client, c OAuth2Config) *http.Client {
func configureTLS(tlsConfig *tls.Config, c TLSConfig) *tls.Config {
clientTLSCert, err := tls.LoadX509KeyPair(c.CertificateFile, c.PrivateKeyFile)
if err != nil {
logr.Errorf("[client.configureTLS] Failed to load certificate: %s", err.Error())
return nil
}
tlsConfig.Certificates = []tls.Certificate{clientTLSCert}
tlsConfig.Renegotiation = tls.RenegotiateNever
renegotionSupport := map[string]tls.RenegotiationSupport{
renegotiationSupport := map[string]tls.RenegotiationSupport{
"once": tls.RenegotiateOnceAsClient,
"freely": tls.RenegotiateFreelyAsClient,
"never": tls.RenegotiateNever,
}
if val, ok := renegotionSupport[c.RenegotiationSupport]; ok {
if val, ok := renegotiationSupport[c.RenegotiationSupport]; ok {
tlsConfig.Renegotiation = val
}
return tlsConfig

View File

@@ -4,7 +4,6 @@ import (
"errors"
"fmt"
"io/fs"
"log"
"os"
"path/filepath"
"strings"
@@ -22,6 +21,8 @@ import (
"github.com/TwiN/gatus/v5/config/web"
"github.com/TwiN/gatus/v5/security"
"github.com/TwiN/gatus/v5/storage"
"github.com/TwiN/logr"
"github.com/gofiber/fiber/v2/log"
"gopkg.in/yaml.v3"
)
@@ -52,6 +53,7 @@ var (
// Config is the main configuration structure
type Config struct {
// Debug Whether to enable debug logs
// Deprecated: Use the GATUS_LOG_LEVEL environment variable instead
Debug bool `yaml:"debug,omitempty"`
// Metrics Whether to expose metrics at /metrics
@@ -172,13 +174,15 @@ func LoadConfiguration(configPath string) (*Config, error) {
if fileInfo.IsDir() {
err := walkConfigDir(configPath, func(path string, d fs.DirEntry, err error) error {
if err != nil {
log.Printf("[config.LoadConfiguration] Error walking path=%s: %s", path, err)
return err
return fmt.Errorf("error walking path %s: %w", path, err)
}
log.Printf("[config.LoadConfiguration] Reading configuration from %s", path)
if strings.Contains(path, "..") {
logr.Warnf("[config.LoadConfiguration] Ignoring configuration from %s", path)
return nil
}
logr.Infof("[config.LoadConfiguration] Reading configuration from %s", path)
data, err := os.ReadFile(path)
if err != nil {
log.Printf("[config.LoadConfiguration] Error reading configuration from %s: %s", path, err)
return fmt.Errorf("error reading configuration from file %s: %w", path, err)
}
configBytes, err = deepmerge.YAML(configBytes, data)
@@ -188,9 +192,9 @@ func LoadConfiguration(configPath string) (*Config, error) {
return nil, fmt.Errorf("error reading configuration from directory %s: %w", usedConfigPath, err)
}
} else {
log.Printf("[config.LoadConfiguration] Reading configuration from configFile=%s", usedConfigPath)
logr.Infof("[config.LoadConfiguration] Reading configuration from configFile=%s", usedConfigPath)
if data, err := os.ReadFile(usedConfigPath); err != nil {
return nil, err
return nil, fmt.Errorf("error reading configuration from directory %s: %w", usedConfigPath, err)
} else {
configBytes = data
}
@@ -200,11 +204,11 @@ func LoadConfiguration(configPath string) (*Config, error) {
}
config, err := parseAndValidateConfigBytes(configBytes)
if err != nil {
return nil, err
return nil, fmt.Errorf("error parsing config: %w", err)
}
config.configPath = usedConfigPath
config.UpdateLastFileModTime()
return config, err
return config, nil
}
// walkConfigDir is a wrapper for filepath.WalkDir that strips directories and non-config files
@@ -245,7 +249,13 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
if config == nil || config.Endpoints == nil || len(config.Endpoints) == 0 {
err = ErrNoEndpointInConfig
} else {
validateAlertingConfig(config.Alerting, config.Endpoints, config.ExternalEndpoints, config.Debug)
// XXX: Remove this in v6.0.0
if config.Debug {
logr.Warn("WARNING: The 'debug' configuration has been deprecated and will be removed in v6.0.0")
logr.Warn("WARNING: Please use the GATUS_LOG_LEVEL environment variable instead")
}
// XXX: End of v6.0.0 removals
validateAlertingConfig(config.Alerting, config.Endpoints, config.ExternalEndpoints)
if err := validateSecurityConfig(config); err != nil {
return nil, err
}
@@ -338,9 +348,7 @@ func validateEndpointsConfig(config *Config) error {
duplicateValidationMap := make(map[string]bool)
// Validate endpoints
for _, ep := range config.Endpoints {
if config.Debug {
log.Printf("[config.validateEndpointsConfig] Validating endpoint '%s'", ep.Name)
}
logr.Debugf("[config.validateEndpointsConfig] Validating endpoint with key %s", ep.Key())
if endpointKey := ep.Key(); duplicateValidationMap[endpointKey] {
return fmt.Errorf("invalid endpoint %s: name and group combination must be unique", ep.Key())
} else {
@@ -350,12 +358,10 @@ func validateEndpointsConfig(config *Config) error {
return fmt.Errorf("invalid endpoint %s: %w", ep.Key(), err)
}
}
log.Printf("[config.validateEndpointsConfig] Validated %d endpoints", len(config.Endpoints))
logr.Infof("[config.validateEndpointsConfig] Validated %d endpoints", len(config.Endpoints))
// Validate external endpoints
for _, ee := range config.ExternalEndpoints {
if config.Debug {
log.Printf("[config.validateEndpointsConfig] Validating external endpoint '%s'", ee.Name)
}
logr.Debugf("[config.validateEndpointsConfig] Validating external endpoint '%s'", ee.Name)
if endpointKey := ee.Key(); duplicateValidationMap[endpointKey] {
return fmt.Errorf("invalid external endpoint %s: name and group combination must be unique", ee.Key())
} else {
@@ -365,16 +371,14 @@ func validateEndpointsConfig(config *Config) error {
return fmt.Errorf("invalid external endpoint %s: %w", ee.Key(), err)
}
}
log.Printf("[config.validateEndpointsConfig] Validated %d external endpoints", len(config.ExternalEndpoints))
logr.Infof("[config.validateEndpointsConfig] Validated %d external endpoints", len(config.ExternalEndpoints))
return nil
}
func validateSecurityConfig(config *Config) error {
if config.Security != nil {
if config.Security.IsValid() {
if config.Debug {
log.Printf("[config.validateSecurityConfig] Basic security configuration has been validated")
}
logr.Debug("[config.validateSecurityConfig] Basic security configuration has been validated")
} else {
// If there was an attempt to configure security, then it must mean that some confidential or private
// data are exposed. As a result, we'll force a panic because it's better to be safe than sorry.
@@ -388,9 +392,9 @@ func validateSecurityConfig(config *Config) error {
// Note that the alerting configuration has to be validated before the endpoint configuration, because the default alert
// returned by provider.AlertProvider.GetDefaultAlert() must be parsed before endpoint.Endpoint.ValidateAndSetDefaults()
// sets the default alert values when none are set.
func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoint.Endpoint, externalEndpoints []*endpoint.ExternalEndpoint, debug bool) {
func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoint.Endpoint, externalEndpoints []*endpoint.ExternalEndpoint) {
if alertingConfig == nil {
log.Printf("[config.validateAlertingConfig] Alerting is not configured")
logr.Info("[config.validateAlertingConfig] Alerting is not configured")
return
}
alertTypes := []alert.Type{
@@ -417,38 +421,47 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
alert.TypeTelegram,
alert.TypeTwilio,
alert.TypeZulip,
alert.TypeIncidentIO,
}
var validProviders, invalidProviders []alert.Type
for _, alertType := range alertTypes {
alertProvider := alertingConfig.GetAlertingProviderByAlertType(alertType)
if alertProvider != nil {
if alertProvider.IsValid() {
if err := alertProvider.Validate(); err == nil {
// Parse alerts with the provider's default alert
if alertProvider.GetDefaultAlert() != nil {
for _, ep := range endpoints {
for alertIndex, endpointAlert := range ep.Alerts {
if alertType == endpointAlert.Type {
if debug {
log.Printf("[config.validateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ep.Key())
logr.Debugf("[config.validateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ep.Key())
provider.MergeProviderDefaultAlertIntoEndpointAlert(alertProvider.GetDefaultAlert(), endpointAlert)
// Validate the endpoint alert's overrides, if applicable
if len(endpointAlert.ProviderOverride) > 0 {
if err = alertProvider.ValidateOverrides(ep.Group, endpointAlert); err != nil {
log.Warnf("[config.validateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ep.Key(), alertType, err.Error())
}
}
provider.ParseWithDefaultAlert(alertProvider.GetDefaultAlert(), endpointAlert)
}
}
}
for _, ee := range externalEndpoints {
for alertIndex, endpointAlert := range ee.Alerts {
if alertType == endpointAlert.Type {
if debug {
log.Printf("[config.validateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ee.Key())
logr.Debugf("[config.validateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ee.Key())
provider.MergeProviderDefaultAlertIntoEndpointAlert(alertProvider.GetDefaultAlert(), endpointAlert)
// Validate the endpoint alert's overrides, if applicable
if len(endpointAlert.ProviderOverride) > 0 {
if err = alertProvider.ValidateOverrides(ee.Group, endpointAlert); err != nil {
log.Warnf("[config.validateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ee.Key(), alertType, err.Error())
}
}
provider.ParseWithDefaultAlert(alertProvider.GetDefaultAlert(), endpointAlert)
}
}
}
}
validProviders = append(validProviders, alertType)
} else {
log.Printf("[config.validateAlertingConfig] Ignoring provider=%s because configuration is invalid", alertType)
logr.Warnf("[config.validateAlertingConfig] Ignoring provider=%s due to error=%s", alertType, err.Error())
invalidProviders = append(invalidProviders, alertType)
alertingConfig.SetAlertingProviderToNil(alertProvider)
}
@@ -456,5 +469,5 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
invalidProviders = append(invalidProviders, alertType)
}
}
log.Printf("[config.validateAlertingConfig] configuredProviders=%s; ignoredProviders=%s", validProviders, invalidProviders)
logr.Infof("[config.validateAlertingConfig] configuredProviders=%s; ignoredProviders=%s", validProviders, invalidProviders)
}

View File

@@ -11,10 +11,13 @@ import (
"github.com/TwiN/gatus/v5/alerting"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/alerting/provider"
"github.com/TwiN/gatus/v5/alerting/provider/awsses"
"github.com/TwiN/gatus/v5/alerting/provider/custom"
"github.com/TwiN/gatus/v5/alerting/provider/discord"
"github.com/TwiN/gatus/v5/alerting/provider/email"
"github.com/TwiN/gatus/v5/alerting/provider/gitea"
"github.com/TwiN/gatus/v5/alerting/provider/github"
"github.com/TwiN/gatus/v5/alerting/provider/gitlab"
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
"github.com/TwiN/gatus/v5/alerting/provider/gotify"
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
@@ -27,8 +30,10 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/pushover"
"github.com/TwiN/gatus/v5/alerting/provider/slack"
"github.com/TwiN/gatus/v5/alerting/provider/teams"
"github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows"
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
"github.com/TwiN/gatus/v5/alerting/provider/zulip"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/web"
@@ -177,7 +182,6 @@ endpoints:
conditions:
- "[STATUS] == 200"`,
"b.yaml": `
debug: true
alerting:
discord:
@@ -196,11 +200,10 @@ endpoints:
- "[STATUS] == 200"`,
},
expectedConfig: &Config{
Debug: true,
Metrics: true,
Alerting: &alerting.Config{
Discord: &discord.AlertProvider{WebhookURL: "https://discord.com/api/webhooks/xxx/yyy"},
Slack: &slack.AlertProvider{WebhookURL: "https://hooks.slack.com/services/xxx/yyy/zzz", DefaultAlert: &alert.Alert{Enabled: &yes}},
Discord: &discord.AlertProvider{DefaultConfig: discord.Config{WebhookURL: "https://discord.com/api/webhooks/xxx/yyy"}},
Slack: &slack.AlertProvider{DefaultConfig: slack.Config{WebhookURL: "https://hooks.slack.com/services/xxx/yyy/zzz"}, DefaultAlert: &alert.Alert{Enabled: &yes}},
},
ExternalEndpoints: []*endpoint.ExternalEndpoint{
{
@@ -482,7 +485,7 @@ endpoints:
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
t.Fatal("DefaultConfig shouldn't have been nil")
}
if config.Metrics {
t.Error("Metrics should've been false by default")
@@ -719,7 +722,6 @@ badconfig:
func TestParseAndValidateConfigBytesWithAlerting(t *testing.T) {
config, err := parseAndValidateConfigBytes([]byte(`
debug: true
alerting:
slack:
webhook-url: "http://example.com"
@@ -788,7 +790,7 @@ endpoints:
if config.Alerting == nil {
t.Fatal("config.Alerting shouldn't have been nil")
}
if config.Alerting.Slack == nil || !config.Alerting.Slack.IsValid() {
if config.Alerting.Slack == nil || config.Alerting.Slack.Validate() != nil {
t.Fatal("Slack alerting config should've been valid")
}
// Endpoints
@@ -919,8 +921,6 @@ endpoints:
func TestParseAndValidateConfigBytesWithAlertingAndDefaultAlert(t *testing.T) {
config, err := parseAndValidateConfigBytes([]byte(`
debug: true
alerting:
slack:
webhook-url: "http://example.com"
@@ -1041,63 +1041,64 @@ endpoints:
if config.Alerting == nil {
t.Fatal("config.Alerting shouldn't have been nil")
}
if config.Alerting.Slack == nil || !config.Alerting.Slack.IsValid() {
if config.Alerting.Slack == nil || config.Alerting.Slack.Validate() != nil {
t.Fatal("Slack alerting config should've been valid")
}
if config.Alerting.Slack.GetDefaultAlert() == nil {
t.Fatal("Slack.GetDefaultAlert() shouldn't have returned nil")
}
if config.Alerting.Slack.WebhookURL != "http://example.com" {
t.Errorf("Slack webhook should've been %s, but was %s", "http://example.com", config.Alerting.Slack.WebhookURL)
if config.Alerting.Slack.DefaultConfig.WebhookURL != "http://example.com" {
t.Errorf("Slack webhook should've been %s, but was %s", "http://example.com", config.Alerting.Slack.DefaultConfig.WebhookURL)
}
if config.Alerting.PagerDuty == nil || !config.Alerting.PagerDuty.IsValid() {
if config.Alerting.PagerDuty == nil || config.Alerting.PagerDuty.Validate() != nil {
t.Fatal("PagerDuty alerting config should've been valid")
}
if config.Alerting.PagerDuty.GetDefaultAlert() == nil {
t.Fatal("PagerDuty.GetDefaultAlert() shouldn't have returned nil")
}
if config.Alerting.PagerDuty.IntegrationKey != "00000000000000000000000000000000" {
t.Errorf("PagerDuty integration key should've been %s, but was %s", "00000000000000000000000000000000", config.Alerting.PagerDuty.IntegrationKey)
if config.Alerting.PagerDuty.DefaultConfig.IntegrationKey != "00000000000000000000000000000000" {
t.Errorf("PagerDuty integration key should've been %s, but was %s", "00000000000000000000000000000000", config.Alerting.PagerDuty.DefaultConfig.IntegrationKey)
}
if config.Alerting.Pushover == nil || !config.Alerting.Pushover.IsValid() {
if config.Alerting.Pushover == nil || config.Alerting.Pushover.Validate() != nil {
t.Fatal("Pushover alerting config should've been valid")
}
if config.Alerting.Pushover.GetDefaultAlert() == nil {
t.Fatal("Pushover.GetDefaultAlert() shouldn't have returned nil")
}
if config.Alerting.Pushover.ApplicationToken != "000000000000000000000000000000" {
t.Errorf("Pushover application token should've been %s, but was %s", "000000000000000000000000000000", config.Alerting.Pushover.ApplicationToken)
if config.Alerting.Pushover.DefaultConfig.ApplicationToken != "000000000000000000000000000000" {
t.Errorf("Pushover application token should've been %s, but was %s", "000000000000000000000000000000", config.Alerting.Pushover.DefaultConfig.ApplicationToken)
}
if config.Alerting.Pushover.UserKey != "000000000000000000000000000000" {
t.Errorf("Pushover user key should've been %s, but was %s", "000000000000000000000000000000", config.Alerting.Pushover.UserKey)
if config.Alerting.Pushover.DefaultConfig.UserKey != "000000000000000000000000000000" {
t.Errorf("Pushover user key should've been %s, but was %s", "000000000000000000000000000000", config.Alerting.Pushover.DefaultConfig.UserKey)
}
if config.Alerting.Mattermost == nil || !config.Alerting.Mattermost.IsValid() {
if config.Alerting.Mattermost == nil || config.Alerting.Mattermost.Validate() != nil {
t.Fatal("Mattermost alerting config should've been valid")
}
if config.Alerting.Mattermost.GetDefaultAlert() == nil {
t.Fatal("Mattermost.GetDefaultAlert() shouldn't have returned nil")
}
if config.Alerting.Messagebird == nil || !config.Alerting.Messagebird.IsValid() {
if config.Alerting.Messagebird == nil || config.Alerting.Messagebird.Validate() != nil {
t.Fatal("Messagebird alerting config should've been valid")
}
if config.Alerting.Messagebird.GetDefaultAlert() == nil {
t.Fatal("Messagebird.GetDefaultAlert() shouldn't have returned nil")
}
if config.Alerting.Messagebird.AccessKey != "1" {
t.Errorf("Messagebird access key should've been %s, but was %s", "1", config.Alerting.Messagebird.AccessKey)
if config.Alerting.Messagebird.DefaultConfig.AccessKey != "1" {
t.Errorf("Messagebird access key should've been %s, but was %s", "1", config.Alerting.Messagebird.DefaultConfig.AccessKey)
}
if config.Alerting.Messagebird.Originator != "31619191918" {
t.Errorf("Messagebird originator field should've been %s, but was %s", "31619191918", config.Alerting.Messagebird.Originator)
if config.Alerting.Messagebird.DefaultConfig.Originator != "31619191918" {
t.Errorf("Messagebird originator field should've been %s, but was %s", "31619191918", config.Alerting.Messagebird.DefaultConfig.Originator)
}
if config.Alerting.Messagebird.Recipients != "31619191919" {
t.Errorf("Messagebird to recipients should've been %s, but was %s", "31619191919", config.Alerting.Messagebird.Recipients)
if config.Alerting.Messagebird.DefaultConfig.Recipients != "31619191919" {
t.Errorf("Messagebird to recipients should've been %s, but was %s", "31619191919", config.Alerting.Messagebird.DefaultConfig.Recipients)
}
if config.Alerting.Discord == nil || !config.Alerting.Discord.IsValid() {
if config.Alerting.Discord == nil || config.Alerting.Discord.Validate() != nil {
t.Fatal("Discord alerting config should've been valid")
}
if config.Alerting.Discord.GetDefaultAlert() == nil {
@@ -1109,98 +1110,98 @@ endpoints:
if config.Alerting.Discord.GetDefaultAlert().SuccessThreshold != 15 {
t.Errorf("Discord default alert success threshold should've been %d, but was %d", 15, config.Alerting.Discord.GetDefaultAlert().SuccessThreshold)
}
if config.Alerting.Discord.WebhookURL != "http://example.org" {
t.Errorf("Discord webhook should've been %s, but was %s", "http://example.org", config.Alerting.Discord.WebhookURL)
if config.Alerting.Discord.DefaultConfig.WebhookURL != "http://example.org" {
t.Errorf("Discord webhook should've been %s, but was %s", "http://example.org", config.Alerting.Discord.DefaultConfig.WebhookURL)
}
if config.Alerting.GetAlertingProviderByAlertType(alert.TypeDiscord) != config.Alerting.Discord {
t.Error("expected discord configuration")
}
if config.Alerting.Telegram == nil || !config.Alerting.Telegram.IsValid() {
if config.Alerting.Telegram == nil || config.Alerting.Telegram.Validate() != nil {
t.Fatal("Telegram alerting config should've been valid")
}
if config.Alerting.Telegram.GetDefaultAlert() == nil {
t.Fatal("Telegram.GetDefaultAlert() shouldn't have returned nil")
}
if config.Alerting.Telegram.Token != "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" {
t.Errorf("Telegram token should've been %s, but was %s", "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", config.Alerting.Telegram.Token)
if config.Alerting.Telegram.DefaultConfig.Token != "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" {
t.Errorf("Telegram token should've been %s, but was %s", "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", config.Alerting.Telegram.DefaultConfig.Token)
}
if config.Alerting.Telegram.ID != "0123456789" {
t.Errorf("Telegram ID should've been %s, but was %s", "012345689", config.Alerting.Telegram.ID)
if config.Alerting.Telegram.DefaultConfig.ID != "0123456789" {
t.Errorf("Telegram ID should've been %s, but was %s", "012345689", config.Alerting.Telegram.DefaultConfig.ID)
}
if config.Alerting.Twilio == nil || !config.Alerting.Twilio.IsValid() {
if config.Alerting.Twilio == nil || config.Alerting.Twilio.Validate() != nil {
t.Fatal("Twilio alerting config should've been valid")
}
if config.Alerting.Twilio.GetDefaultAlert() == nil {
t.Fatal("Twilio.GetDefaultAlert() shouldn't have returned nil")
}
if config.Alerting.Teams == nil || !config.Alerting.Teams.IsValid() {
if config.Alerting.Teams == nil || config.Alerting.Teams.Validate() != nil {
t.Fatal("Teams alerting config should've been valid")
}
if config.Alerting.Teams.GetDefaultAlert() == nil {
t.Fatal("Teams.GetDefaultAlert() shouldn't have returned nil")
}
if config.Alerting.JetBrainsSpace == nil || !config.Alerting.JetBrainsSpace.IsValid() {
if config.Alerting.JetBrainsSpace == nil || config.Alerting.JetBrainsSpace.Validate() != nil {
t.Fatal("JetBrainsSpace alerting config should've been valid")
}
if config.Alerting.JetBrainsSpace.GetDefaultAlert() == nil {
t.Fatal("JetBrainsSpace.GetDefaultAlert() shouldn't have returned nil")
}
if config.Alerting.JetBrainsSpace.Project != "foo" {
t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "foo", config.Alerting.JetBrainsSpace.Project)
if config.Alerting.JetBrainsSpace.DefaultConfig.Project != "foo" {
t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "foo", config.Alerting.JetBrainsSpace.DefaultConfig.Project)
}
if config.Alerting.JetBrainsSpace.ChannelID != "bar" {
t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "bar", config.Alerting.JetBrainsSpace.ChannelID)
if config.Alerting.JetBrainsSpace.DefaultConfig.ChannelID != "bar" {
t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "bar", config.Alerting.JetBrainsSpace.DefaultConfig.ChannelID)
}
if config.Alerting.JetBrainsSpace.Token != "baz" {
t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "baz", config.Alerting.JetBrainsSpace.Token)
if config.Alerting.JetBrainsSpace.DefaultConfig.Token != "baz" {
t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "baz", config.Alerting.JetBrainsSpace.DefaultConfig.Token)
}
if config.Alerting.Email == nil || !config.Alerting.Email.IsValid() {
if config.Alerting.Email == nil || config.Alerting.Email.Validate() != nil {
t.Fatal("Email alerting config should've been valid")
}
if config.Alerting.Email.GetDefaultAlert() == nil {
t.Fatal("Email.GetDefaultAlert() shouldn't have returned nil")
}
if config.Alerting.Email.From != "from@example.com" {
t.Errorf("Email from should've been %s, but was %s", "from@example.com", config.Alerting.Email.From)
if config.Alerting.Email.DefaultConfig.From != "from@example.com" {
t.Errorf("Email from should've been %s, but was %s", "from@example.com", config.Alerting.Email.DefaultConfig.From)
}
if config.Alerting.Email.Username != "from@example.com" {
t.Errorf("Email username should've been %s, but was %s", "from@example.com", config.Alerting.Email.Username)
if config.Alerting.Email.DefaultConfig.Username != "from@example.com" {
t.Errorf("Email username should've been %s, but was %s", "from@example.com", config.Alerting.Email.DefaultConfig.Username)
}
if config.Alerting.Email.Password != "hunter2" {
t.Errorf("Email password should've been %s, but was %s", "hunter2", config.Alerting.Email.Password)
if config.Alerting.Email.DefaultConfig.Password != "hunter2" {
t.Errorf("Email password should've been %s, but was %s", "hunter2", config.Alerting.Email.DefaultConfig.Password)
}
if config.Alerting.Email.Host != "mail.example.com" {
t.Errorf("Email host should've been %s, but was %s", "mail.example.com", config.Alerting.Email.Host)
if config.Alerting.Email.DefaultConfig.Host != "mail.example.com" {
t.Errorf("Email host should've been %s, but was %s", "mail.example.com", config.Alerting.Email.DefaultConfig.Host)
}
if config.Alerting.Email.Port != 587 {
t.Errorf("Email port should've been %d, but was %d", 587, config.Alerting.Email.Port)
if config.Alerting.Email.DefaultConfig.Port != 587 {
t.Errorf("Email port should've been %d, but was %d", 587, config.Alerting.Email.DefaultConfig.Port)
}
if config.Alerting.Email.To != "recipient1@example.com,recipient2@example.com" {
t.Errorf("Email to should've been %s, but was %s", "recipient1@example.com,recipient2@example.com", config.Alerting.Email.To)
if config.Alerting.Email.DefaultConfig.To != "recipient1@example.com,recipient2@example.com" {
t.Errorf("Email to should've been %s, but was %s", "recipient1@example.com,recipient2@example.com", config.Alerting.Email.DefaultConfig.To)
}
if config.Alerting.Email.ClientConfig == nil {
if config.Alerting.Email.DefaultConfig.ClientConfig == nil {
t.Fatal("Email client config should've been set")
}
if config.Alerting.Email.ClientConfig.Insecure {
if config.Alerting.Email.DefaultConfig.ClientConfig.Insecure {
t.Error("Email client config should've been secure")
}
if config.Alerting.Gotify == nil || !config.Alerting.Gotify.IsValid() {
if config.Alerting.Gotify == nil || config.Alerting.Gotify.Validate() != nil {
t.Fatal("Gotify alerting config should've been valid")
}
if config.Alerting.Gotify.GetDefaultAlert() == nil {
t.Fatal("Gotify.GetDefaultAlert() shouldn't have returned nil")
}
if config.Alerting.Gotify.ServerURL != "https://gotify.example" {
t.Errorf("Gotify server URL should've been %s, but was %s", "https://gotify.example", config.Alerting.Gotify.ServerURL)
if config.Alerting.Gotify.DefaultConfig.ServerURL != "https://gotify.example" {
t.Errorf("Gotify server URL should've been %s, but was %s", "https://gotify.example", config.Alerting.Gotify.DefaultConfig.ServerURL)
}
if config.Alerting.Gotify.Token != "**************" {
t.Errorf("Gotify token should've been %s, but was %s", "**************", config.Alerting.Gotify.Token)
if config.Alerting.Gotify.DefaultConfig.Token != "**************" {
t.Errorf("Gotify token should've been %s, but was %s", "**************", config.Alerting.Gotify.DefaultConfig.Token)
}
// External endpoints
@@ -1409,6 +1410,8 @@ endpoints:
- type: slack
enabled: false
failure-threshold: 30
provider-override:
webhook-url: https://example.com
conditions:
- "[STATUS] == 200"
`))
@@ -1422,7 +1425,7 @@ endpoints:
if config.Alerting == nil {
t.Fatal("config.Alerting shouldn't have been nil")
}
if config.Alerting.Slack == nil || !config.Alerting.Slack.IsValid() {
if config.Alerting.Slack == nil || config.Alerting.Slack.Validate() != nil {
t.Fatal("Slack alerting config should've been valid")
}
// Endpoints
@@ -1493,6 +1496,7 @@ endpoints:
t.Fatal("PagerDuty alerting config should've been set to nil, because its IsValid() method returned false and therefore alerting.Config.SetAlertingProviderToNil() should've been called")
}
}
func TestParseAndValidateConfigBytesWithInvalidPushoverAlertingConfig(t *testing.T) {
config, err := parseAndValidateConfigBytes([]byte(`
alerting:
@@ -1549,17 +1553,18 @@ endpoints:
if config.Alerting.Custom == nil {
t.Fatal("Custom alerting config shouldn't have been nil")
}
if !config.Alerting.Custom.IsValid() {
if err = config.Alerting.Custom.Validate(); err != nil {
t.Fatal("Custom alerting config should've been valid")
}
if config.Alerting.Custom.GetAlertStatePlaceholderValue(true) != "RESOLVED" {
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for RESOLVED should've been 'RESOLVED', got", config.Alerting.Custom.GetAlertStatePlaceholderValue(true))
cfg, _ := config.Alerting.Custom.GetConfig("", &alert.Alert{ProviderOverride: map[string]any{"client": map[string]any{"insecure": true}}})
if config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, true) != "RESOLVED" {
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for RESOLVED should've been 'RESOLVED', got", config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, true))
}
if config.Alerting.Custom.GetAlertStatePlaceholderValue(false) != "TRIGGERED" {
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for TRIGGERED should've been 'TRIGGERED', got", config.Alerting.Custom.GetAlertStatePlaceholderValue(false))
if config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, false) != "TRIGGERED" {
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for TRIGGERED should've been 'TRIGGERED', got", config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, false))
}
if config.Alerting.Custom.ClientConfig.Insecure {
t.Errorf("ClientConfig.Insecure should have been %v, got %v", false, config.Alerting.Custom.ClientConfig.Insecure)
if !cfg.ClientConfig.Insecure {
t.Errorf("ClientConfig.Insecure should have been %v, got %v", true, cfg.ClientConfig.Insecure)
}
}
@@ -1586,7 +1591,7 @@ endpoints:
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
t.Fatal("DefaultConfig shouldn't have been nil")
}
if config.Alerting == nil {
t.Fatal("config.Alerting shouldn't have been nil")
@@ -1594,13 +1599,14 @@ endpoints:
if config.Alerting.Custom == nil {
t.Fatal("Custom alerting config shouldn't have been nil")
}
if !config.Alerting.Custom.IsValid() {
if err = config.Alerting.Custom.Validate(); err != nil {
t.Fatal("Custom alerting config should've been valid")
}
if config.Alerting.Custom.GetAlertStatePlaceholderValue(true) != "operational" {
cfg, _ := config.Alerting.Custom.GetConfig("", &alert.Alert{})
if config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, true) != "operational" {
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for RESOLVED should've been 'operational'")
}
if config.Alerting.Custom.GetAlertStatePlaceholderValue(false) != "partial_outage" {
if config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, false) != "partial_outage" {
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for TRIGGERED should've been 'partial_outage'")
}
}
@@ -1626,7 +1632,7 @@ endpoints:
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
t.Fatal("DefaultConfig shouldn't have been nil")
}
if config.Alerting == nil {
t.Fatal("config.Alerting shouldn't have been nil")
@@ -1634,13 +1640,14 @@ endpoints:
if config.Alerting.Custom == nil {
t.Fatal("Custom alerting config shouldn't have been nil")
}
if !config.Alerting.Custom.IsValid() {
if err := config.Alerting.Custom.Validate(); err != nil {
t.Fatal("Custom alerting config should've been valid")
}
if config.Alerting.Custom.GetAlertStatePlaceholderValue(true) != "RESOLVED" {
cfg, _ := config.Alerting.Custom.GetConfig("", &alert.Alert{})
if config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, true) != "RESOLVED" {
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for RESOLVED should've been 'RESOLVED'")
}
if config.Alerting.Custom.GetAlertStatePlaceholderValue(false) != "partial_outage" {
if config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, false) != "partial_outage" {
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for TRIGGERED should've been 'partial_outage'")
}
}
@@ -1801,7 +1808,7 @@ endpoints:
func TestParseAndValidateConfigBytesWithValidSecurityConfig(t *testing.T) {
const expectedUsername = "admin"
const expectedPasswordHash = "JDJhJDEwJHRiMnRFakxWazZLdXBzRERQazB1TE8vckRLY05Yb1hSdnoxWU0yQ1FaYXZRSW1McmladDYu"
config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`debug: true
config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`
security:
basic:
username: "%s"
@@ -1816,7 +1823,7 @@ endpoints:
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
t.Fatal("DefaultConfig shouldn't have been nil")
}
if config.Security == nil {
t.Fatal("config.Security shouldn't have been nil")
@@ -1849,7 +1856,7 @@ endpoints:
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
t.Fatal("DefaultConfig shouldn't have been nil")
}
if config.Endpoints[0].URL != "https://twin.sh/health" {
t.Errorf("URL should have been %s", "https://twin.sh/health")
@@ -1871,33 +1878,41 @@ func TestParseAndValidateConfigBytesWithNoEndpoints(t *testing.T) {
func TestGetAlertingProviderByAlertType(t *testing.T) {
alertingConfig := &alerting.Config{
Custom: &custom.AlertProvider{},
Discord: &discord.AlertProvider{},
Email: &email.AlertProvider{},
GitHub: &github.AlertProvider{},
GoogleChat: &googlechat.AlertProvider{},
Gotify: &gotify.AlertProvider{},
JetBrainsSpace: &jetbrainsspace.AlertProvider{},
Matrix: &matrix.AlertProvider{},
Mattermost: &mattermost.AlertProvider{},
Messagebird: &messagebird.AlertProvider{},
Ntfy: &ntfy.AlertProvider{},
Opsgenie: &opsgenie.AlertProvider{},
PagerDuty: &pagerduty.AlertProvider{},
Pushover: &pushover.AlertProvider{},
Slack: &slack.AlertProvider{},
Telegram: &telegram.AlertProvider{},
Twilio: &twilio.AlertProvider{},
Teams: &teams.AlertProvider{},
AWSSimpleEmailService: &awsses.AlertProvider{},
Custom: &custom.AlertProvider{},
Discord: &discord.AlertProvider{},
Email: &email.AlertProvider{},
Gitea: &gitea.AlertProvider{},
GitHub: &github.AlertProvider{},
GitLab: &gitlab.AlertProvider{},
GoogleChat: &googlechat.AlertProvider{},
Gotify: &gotify.AlertProvider{},
JetBrainsSpace: &jetbrainsspace.AlertProvider{},
Matrix: &matrix.AlertProvider{},
Mattermost: &mattermost.AlertProvider{},
Messagebird: &messagebird.AlertProvider{},
Ntfy: &ntfy.AlertProvider{},
Opsgenie: &opsgenie.AlertProvider{},
PagerDuty: &pagerduty.AlertProvider{},
Pushover: &pushover.AlertProvider{},
Slack: &slack.AlertProvider{},
Telegram: &telegram.AlertProvider{},
Teams: &teams.AlertProvider{},
TeamsWorkflows: &teamsworkflows.AlertProvider{},
Twilio: &twilio.AlertProvider{},
Zulip: &zulip.AlertProvider{},
}
scenarios := []struct {
alertType alert.Type
expected provider.AlertProvider
}{
{alertType: alert.TypeAWSSES, expected: alertingConfig.AWSSimpleEmailService},
{alertType: alert.TypeCustom, expected: alertingConfig.Custom},
{alertType: alert.TypeDiscord, expected: alertingConfig.Discord},
{alertType: alert.TypeEmail, expected: alertingConfig.Email},
{alertType: alert.TypeGitea, expected: alertingConfig.Gitea},
{alertType: alert.TypeGitHub, expected: alertingConfig.GitHub},
{alertType: alert.TypeGitLab, expected: alertingConfig.GitLab},
{alertType: alert.TypeGoogleChat, expected: alertingConfig.GoogleChat},
{alertType: alert.TypeGotify, expected: alertingConfig.Gotify},
{alertType: alert.TypeJetBrainsSpace, expected: alertingConfig.JetBrainsSpace},
@@ -1910,8 +1925,10 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
{alertType: alert.TypePushover, expected: alertingConfig.Pushover},
{alertType: alert.TypeSlack, expected: alertingConfig.Slack},
{alertType: alert.TypeTelegram, expected: alertingConfig.Telegram},
{alertType: alert.TypeTwilio, expected: alertingConfig.Twilio},
{alertType: alert.TypeTeams, expected: alertingConfig.Teams},
{alertType: alert.TypeTeamsWorkflows, expected: alertingConfig.TeamsWorkflows},
{alertType: alert.TypeTwilio, expected: alertingConfig.Twilio},
{alertType: alert.TypeZulip, expected: alertingConfig.Zulip},
}
for _, scenario := range scenarios {
t.Run(string(scenario.alertType), func(t *testing.T) {

View File

@@ -150,7 +150,7 @@ func (c Condition) evaluate(result *Result, dontResolveFailedConditions bool) bo
return false
}
if !success {
//log.Printf("[Condition.evaluate] Condition '%s' did not succeed because '%s' is false", condition, condition)
//logr.Debugf("[Condition.evaluate] Condition '%s' did not succeed because '%s' is false", condition, condition)
}
result.ConditionResults = append(result.ConditionResults, &ConditionResult{Condition: conditionToDisplay, Success: success})
return success

View File

@@ -18,6 +18,7 @@ import (
"github.com/TwiN/gatus/v5/config/endpoint/dns"
sshconfig "github.com/TwiN/gatus/v5/config/endpoint/ssh"
"github.com/TwiN/gatus/v5/config/endpoint/ui"
"github.com/TwiN/gatus/v5/config/maintenance"
"golang.org/x/crypto/ssh"
)
@@ -104,6 +105,9 @@ type Endpoint struct {
// Alerts is the alerting configuration for the endpoint in case of failure
Alerts []*alert.Alert `yaml:"alerts,omitempty"`
// MaintenanceWindow is the configuration for per-endpoint maintenance windows
MaintenanceWindows []*maintenance.Config `yaml:"maintenance-windows,omitempty"`
// DNSConfig is the configuration for DNS monitoring
DNSConfig *dns.Config `yaml:"dns,omitempty"`
@@ -219,6 +223,11 @@ func (e *Endpoint) ValidateAndSetDefaults() error {
if e.Type() == TypeUNKNOWN {
return ErrUnknownEndpointType
}
for _, maintenanceWindow := range e.MaintenanceWindows {
if err := maintenanceWindow.ValidateAndSetDefaults(); err != nil {
return err
}
}
// Make sure that the request can be created
_, err := http.NewRequest(e.Method, e.URL, bytes.NewBuffer([]byte(e.Body)))
if err != nil {
@@ -261,6 +270,7 @@ func (e *Endpoint) EvaluateHealth() *Result {
result.AddError(err.Error())
} else {
result.Hostname = urlObject.Hostname()
result.port = urlObject.Port()
}
}
// Retrieve IP if necessary
@@ -298,7 +308,13 @@ func (e *Endpoint) EvaluateHealth() *Result {
for errIdx, errorString := range result.Errors {
result.Errors[errIdx] = strings.ReplaceAll(errorString, result.Hostname, "<redacted>")
}
result.Hostname = ""
result.Hostname = "" // remove it from the result so it doesn't get exposed
}
if e.UIConfig.HidePort && len(result.port) > 0 {
for errIdx, errorString := range result.Errors {
result.Errors[errIdx] = strings.ReplaceAll(errorString, result.port, "<redacted>")
}
result.port = ""
}
if e.UIConfig.HideConditions {
result.ConditionResults = nil
@@ -363,6 +379,18 @@ func (e *Endpoint) call(result *Result) {
}
result.Duration = time.Since(startTime)
} else if endpointType == TypeSSH {
// If there's no username/password specified, attempt to validate just the SSH banner
if len(e.SSHConfig.Username) == 0 && len(e.SSHConfig.Password) == 0 {
result.Connected, result.HTTPStatus, err =
client.CheckSSHBanner(strings.TrimPrefix(e.URL, "ssh://"), e.ClientConfig)
if err != nil {
result.AddError(err.Error())
return
}
result.Success = result.Connected
result.Duration = time.Since(startTime)
return
}
var cli *ssh.Client
result.Connected, cli, err = client.CanCreateSSHConnection(strings.TrimPrefix(e.URL, "ssh://"), e.SSHConfig.Username, e.SSHConfig.Password, e.ClientConfig)
if err != nil {

View File

@@ -16,6 +16,7 @@ import (
"github.com/TwiN/gatus/v5/config/endpoint/dns"
"github.com/TwiN/gatus/v5/config/endpoint/ssh"
"github.com/TwiN/gatus/v5/config/endpoint/ui"
"github.com/TwiN/gatus/v5/config/maintenance"
"github.com/TwiN/gatus/v5/test"
)
@@ -164,9 +165,9 @@ func TestEndpoint(t *testing.T) {
Name: "endpoint-that-will-time-out-and-hidden-hostname",
Endpoint: Endpoint{
Name: "endpoint-that-will-time-out",
URL: "https://twin.sh/health",
URL: "https://twin.sh:9999/health",
Conditions: []Condition{"[CONNECTED] == true"},
UIConfig: &ui.Config{HideHostname: true},
UIConfig: &ui.Config{HideHostname: true, HidePort: true},
ClientConfig: &client.Config{Timeout: time.Millisecond},
},
ExpectedResult: &Result{
@@ -179,7 +180,7 @@ func TestEndpoint(t *testing.T) {
// Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.
DomainExpiration: 0,
// Because Endpoint.UIConfig.HideHostname is true, the hostname should be replaced by <redacted>.
Errors: []string{`Get "https://<redacted>/health": context deadline exceeded (Client.Timeout exceeded while awaiting headers)`},
Errors: []string{`Get "https://<redacted>:<redacted>/health": context deadline exceeded (Client.Timeout exceeded while awaiting headers)`},
},
MockRoundTripper: nil,
},
@@ -390,10 +391,11 @@ func TestEndpoint_Type(t *testing.T) {
func TestEndpoint_ValidateAndSetDefaults(t *testing.T) {
endpoint := Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []Condition{Condition("[STATUS] == 200")},
Alerts: []*alert.Alert{{Type: alert.TypePagerDuty}},
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []Condition{Condition("[STATUS] == 200")},
Alerts: []*alert.Alert{{Type: alert.TypePagerDuty}},
MaintenanceWindows: []*maintenance.Config{{Start: "03:50", Duration: 4 * time.Hour}},
}
if err := endpoint.ValidateAndSetDefaults(); err != nil {
t.Errorf("Expected no error, got %v", err)
@@ -432,6 +434,15 @@ func TestEndpoint_ValidateAndSetDefaults(t *testing.T) {
if endpoint.Alerts[0].FailureThreshold != 3 {
t.Error("Endpoint alert should've defaulted to a failure threshold of 3")
}
if len(endpoint.MaintenanceWindows) != 1 {
t.Error("Endpoint should've had 1 maintenance window")
}
if !endpoint.MaintenanceWindows[0].IsEnabled() {
t.Error("Endpoint maintenance should've defaulted to true")
}
if endpoint.MaintenanceWindows[0].Timezone != "UTC" {
t.Error("Endpoint maintenance should've defaulted to UTC")
}
}
func TestEndpoint_ValidateAndSetDefaultsWithInvalidCondition(t *testing.T) {
@@ -761,7 +772,7 @@ func TestIntegrationEvaluateHealthWithErrorAndHideURL(t *testing.T) {
func TestIntegrationEvaluateHealthForDNS(t *testing.T) {
conditionSuccess := Condition("[DNS_RCODE] == NOERROR")
conditionBody := Condition("[BODY] == 93.184.215.14")
conditionBody := Condition("[BODY] == pat(*.*.*.*)")
endpoint := Endpoint{
Name: "example",
URL: "8.8.8.8",

View File

@@ -14,5 +14,6 @@ func sanitize(s string) string {
s = strings.ReplaceAll(s, ".", "-")
s = strings.ReplaceAll(s, ",", "-")
s = strings.ReplaceAll(s, " ", "-")
s = strings.ReplaceAll(s, "#", "-")
return s
}

View File

@@ -49,6 +49,11 @@ type Result struct {
// Note that this field is not persisted in the storage.
// It is used for health evaluation as well as debugging purposes.
Body []byte `json:"-"`
///////////////////////////////////////////////////////////////////////
// Below is used only for the UI and is not persisted in the storage //
///////////////////////////////////////////////////////////////////////
port string `yaml:"-"` // used for endpoints[].ui.hide-port
}
// AddError adds an error to the result's list of errors.

View File

@@ -19,6 +19,10 @@ type Config struct {
// Validate the SSH configuration
func (cfg *Config) Validate() error {
// If there's no username and password, this endpoint can still check the SSH banner, so the endpoint is still valid
if len(cfg.Username) == 0 && len(cfg.Password) == 0 {
return nil
}
if len(cfg.Username) == 0 {
return ErrEndpointWithoutSSHUsername
}

View File

@@ -7,10 +7,8 @@ import (
func TestSSH_validate(t *testing.T) {
cfg := &Config{}
if err := cfg.Validate(); err == nil {
t.Error("expected an error")
} else if !errors.Is(err, ErrEndpointWithoutSSHUsername) {
t.Errorf("expected error to be '%v', got '%v'", ErrEndpointWithoutSSHUsername, err)
if err := cfg.Validate(); err != nil {
t.Error("didn't expect an error")
}
cfg.Username = "username"
if err := cfg.Validate(); err == nil {

View File

@@ -13,6 +13,9 @@ type Config struct {
// HideURL whether to ensure the URL is not displayed in the results. Useful if the URL contains a token.
HideURL bool `yaml:"hide-url"`
// HidePort whether to hide the port in the Result
HidePort bool `yaml:"hide-port"`
// DontResolveFailedConditions whether to resolve failed conditions in the Result for display in the UI
DontResolveFailedConditions bool `yaml:"dont-resolve-failed-conditions"`
@@ -54,6 +57,7 @@ func GetDefaultConfig() *Config {
return &Config{
HideHostname: false,
HideURL: false,
HidePort: false,
DontResolveFailedConditions: false,
HideConditions: false,
Badge: &Badge{

View File

@@ -110,12 +110,13 @@ func (c *Config) IsUnderMaintenance() bool {
if c.TimezoneLocation != nil {
now = now.In(c.TimezoneLocation)
}
var dayWhereMaintenancePeriodWouldStart time.Time
if now.Hour() >= int(c.durationToStartFromMidnight.Hours()) {
dayWhereMaintenancePeriodWouldStart = now.Truncate(24 * time.Hour)
} else {
dayWhereMaintenancePeriodWouldStart = now.Add(-c.Duration).Truncate(24 * time.Hour)
adjustedDate := now.Day()
if now.Hour() < int(c.durationToStartFromMidnight.Hours()) {
// if time in maintenance window is later than now, treat it as yesterday
adjustedDate--
}
// Set to midnight prior to adding duration
dayWhereMaintenancePeriodWouldStart := time.Date(now.Year(), now.Month(), adjustedDate, 0, 0, 0, 0, now.Location())
hasMaintenanceEveryDay := len(c.Every) == 0
hasMaintenancePeriodScheduledToStartOnThatWeekday := c.hasDay(dayWhereMaintenancePeriodWouldStart.Weekday().String())
if !hasMaintenanceEveryDay && !hasMaintenancePeriodScheduledToStartOnThatWeekday {

View File

@@ -264,6 +264,15 @@ func TestConfig_IsUnderMaintenance(t *testing.T) {
},
expected: true,
},
{
name: "under-maintenance-perth-timezone-starting-now-for-2h",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", inTimezone(now, "Australia/Perth", t).Hour()),
Duration: 2 * time.Hour,
Timezone: "Australia/Perth",
},
expected: true,
},
{
name: "under-maintenance-utc-timezone-starting-now-for-2h",
cfg: &Config{
@@ -340,3 +349,12 @@ func normalizeHour(hour int) int {
}
return hour
}
func inTimezone(passedTime time.Time, timezone string, t *testing.T) time.Time {
timezoneLocation, err := time.LoadLocation(timezone)
if err != nil {
t.Fatalf("timezone %s did not load", timezone)
}
return passedTime.In(timezoneLocation)
}

View File

@@ -1,9 +1,8 @@
package remote
import (
"log"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/logr"
)
// NOTICE: This is an experimental alpha feature and may be updated/removed in future versions.
@@ -31,9 +30,8 @@ func (c *Config) ValidateAndSetDefaults() error {
}
}
if len(c.Instances) > 0 {
log.Println("WARNING: Your configuration is using 'remote', which is in alpha and may be updated/removed in future versions.")
log.Println("WARNING: See https://github.com/TwiN/gatus/issues/64 for more information")
log.Println("WARNING: This feature is a candidate for removal in future versions. Please comment on the issue above if you need this feature.")
logr.Warn("WARNING: Your configuration is using 'remote', which is in alpha and may be updated/removed in future versions.")
logr.Warn("WARNING: See https://github.com/TwiN/gatus/issues/64 for more information")
}
return nil
}

View File

@@ -14,9 +14,12 @@ const (
defaultHeader = "Health Status"
defaultLogo = ""
defaultLink = ""
defaultCustomCSS = ""
)
var (
defaultDarkMode = true
ErrButtonValidationFailed = errors.New("invalid button configuration: missing required name or link")
)
@@ -28,6 +31,15 @@ type Config struct {
Logo string `yaml:"logo,omitempty"` // Logo to display on the page
Link string `yaml:"link,omitempty"` // Link to open when clicking on the logo
Buttons []Button `yaml:"buttons,omitempty"` // Buttons to display below the header
CustomCSS string `yaml:"custom-css,omitempty"` // Custom CSS to include in the page
DarkMode *bool `yaml:"dark-mode,omitempty"` // DarkMode is a flag to enable dark mode by default
}
func (cfg *Config) IsDarkMode() bool {
if cfg.DarkMode != nil {
return *cfg.DarkMode
}
return defaultDarkMode
}
// Button is the configuration for a button on the UI
@@ -52,6 +64,8 @@ func GetDefaultConfig() *Config {
Header: defaultHeader,
Logo: defaultLogo,
Link: defaultLink,
CustomCSS: defaultCustomCSS,
DarkMode: &defaultDarkMode,
}
}
@@ -66,8 +80,17 @@ func (cfg *Config) ValidateAndSetDefaults() error {
if len(cfg.Header) == 0 {
cfg.Header = defaultHeader
}
if len(cfg.Header) == 0 {
cfg.Header = defaultLink
if len(cfg.Logo) == 0 {
cfg.Logo = defaultLogo
}
if len(cfg.Link) == 0 {
cfg.Link = defaultLink
}
if len(cfg.CustomCSS) == 0 {
cfg.CustomCSS = defaultCustomCSS
}
if cfg.DarkMode == nil {
cfg.DarkMode = &defaultDarkMode
}
for _, btn := range cfg.Buttons {
if err := btn.Validate(); err != nil {
@@ -80,9 +103,10 @@ func (cfg *Config) ValidateAndSetDefaults() error {
return err
}
var buffer bytes.Buffer
err = t.Execute(&buffer, cfg)
if err != nil {
return err
}
return nil
return t.Execute(&buffer, ViewData{UI: cfg, Theme: "dark"})
}
type ViewData struct {
UI *Config
Theme string
}

View File

@@ -1,12 +1,12 @@
package controller
import (
"log"
"os"
"time"
"github.com/TwiN/gatus/v5/api"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/logr"
"github.com/gofiber/fiber/v2"
)
@@ -25,19 +25,19 @@ func Handle(cfg *config.Config) {
if os.Getenv("ROUTER_TEST") == "true" {
return
}
log.Println("[controller.Handle] Listening on " + cfg.Web.SocketAddress())
logr.Info("[controller.Handle] Listening on " + cfg.Web.SocketAddress())
if cfg.Web.HasTLS() {
err := app.ListenTLS(cfg.Web.SocketAddress(), cfg.Web.TLS.CertificateFile, cfg.Web.TLS.PrivateKeyFile)
if err != nil {
log.Fatal("[controller.Handle]", err)
logr.Fatalf("[controller.Handle] %s", err.Error())
}
} else {
err := app.Listen(cfg.Web.SocketAddress())
if err != nil {
log.Fatal("[controller.Handle]", err)
logr.Fatalf("[controller.Handle] %s", err.Error())
}
}
log.Println("[controller.Handle] Server has shut down successfully")
logr.Info("[controller.Handle] Server has shut down successfully")
}
// Shutdown stops the server

69
go.mod
View File

@@ -1,40 +1,41 @@
module github.com/TwiN/gatus/v5
go 1.22.2
go 1.23.3
require (
code.gitea.io/sdk/gitea v0.19.0
github.com/TwiN/deepmerge v0.2.1
github.com/TwiN/deepmerge v0.2.2
github.com/TwiN/g8/v2 v2.0.0
github.com/TwiN/gocache/v2 v2.2.2
github.com/TwiN/health v1.6.0
github.com/TwiN/whois v1.1.9
github.com/aws/aws-sdk-go v1.54.10
github.com/coreos/go-oidc/v3 v3.11.0
github.com/gofiber/fiber/v2 v2.52.5
github.com/TwiN/logr v0.3.1
github.com/TwiN/whois v1.1.10
github.com/aws/aws-sdk-go v1.55.6
github.com/coreos/go-oidc/v3 v3.12.0
github.com/gofiber/fiber/v2 v2.52.6
github.com/google/go-github/v48 v48.2.0
github.com/google/uuid v1.6.0
github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2
github.com/lib/pq v1.10.9
github.com/miekg/dns v1.1.62
github.com/prometheus-community/pro-bing v0.4.0
github.com/prometheus/client_golang v1.20.4
github.com/valyala/fasthttp v1.56.0
github.com/prometheus-community/pro-bing v0.5.0
github.com/prometheus/client_golang v1.20.5
github.com/valyala/fasthttp v1.58.0
github.com/wcharczuk/go-chart/v2 v2.1.2
golang.org/x/crypto v0.27.0
golang.org/x/net v0.29.0
golang.org/x/oauth2 v0.21.0
google.golang.org/api v0.183.0
golang.org/x/crypto v0.36.0
golang.org/x/net v0.37.0
golang.org/x/oauth2 v0.25.0
google.golang.org/api v0.214.0
gopkg.in/mail.v2 v2.3.1
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.33.1
modernc.org/sqlite v1.34.4
)
require (
cloud.google.com/go/auth v0.5.1 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect
cloud.google.com/go/compute/metadata v0.3.0 // indirect
github.com/andybalholm/brotli v1.1.0 // indirect
cloud.google.com/go/auth v0.13.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
cloud.google.com/go/compute/metadata v0.6.0 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect
@@ -45,20 +46,19 @@ require (
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/s2a-go v0.1.7 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
github.com/google/s2a-go v0.1.8 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
github.com/googleapis/gax-go/v2 v2.14.0 // indirect
github.com/hashicorp/go-version v1.6.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
@@ -69,20 +69,19 @@ require (
github.com/rogpeppe/go-internal v1.11.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0 // indirect
go.opentelemetry.io/otel v1.27.0 // indirect
go.opentelemetry.io/otel/metric v1.27.0 // indirect
go.opentelemetry.io/otel/trace v1.27.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
go.opentelemetry.io/otel v1.29.0 // indirect
go.opentelemetry.io/otel/metric v1.29.0 // indirect
go.opentelemetry.io/otel/trace v1.29.0 // indirect
golang.org/x/image v0.18.0 // indirect
golang.org/x/mod v0.18.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/text v0.18.0 // indirect
golang.org/x/sync v0.12.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.23.0 // indirect
golang.org/x/tools v0.22.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 // indirect
google.golang.org/grpc v1.64.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect
google.golang.org/grpc v1.67.1 // indirect
google.golang.org/protobuf v1.35.2 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b // indirect
modernc.org/libc v1.55.3 // indirect

224
go.sum
View File

@@ -1,36 +1,33 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go/auth v0.5.1 h1:0QNO7VThG54LUzKiQxv8C6x1YX7lUrzlAa1nVLF8CIw=
cloud.google.com/go/auth v0.5.1/go.mod h1:vbZT8GjzDf3AVqCcQmqeeM32U9HBFc32vVVAbwDsa6s=
cloud.google.com/go/auth/oauth2adapt v0.2.2 h1:+TTV8aXpjeChS9M+aTtN/TjdQnzJvmzKFt//oWu7HX4=
cloud.google.com/go/auth/oauth2adapt v0.2.2/go.mod h1:wcYjgpZI9+Yu7LyYBg4pqSiaRkfEK3GQcpb7C/uyF1Q=
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
cloud.google.com/go/auth v0.13.0 h1:8Fu8TZy167JkW8Tj3q7dIkr2v4cndv41ouecJx0PAHs=
cloud.google.com/go/auth v0.13.0/go.mod h1:COOjD9gwfKNKz+IIduatIhYJQIc0mG3H102r/EMxX6Q=
cloud.google.com/go/auth/oauth2adapt v0.2.6 h1:V6a6XDu2lTwPZWOawrAa9HUK+DB2zfJyTuciBG5hFkU=
cloud.google.com/go/auth/oauth2adapt v0.2.6/go.mod h1:AlmsELtlEBnaNTL7jCj8VQFLy6mbZv0s4Q7NGBeQ5E8=
cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
code.gitea.io/sdk/gitea v0.19.0 h1:8I6s1s4RHgzxiPHhOQdgim1RWIRcr0LVMbHBjBFXq4Y=
code.gitea.io/sdk/gitea v0.19.0/go.mod h1:IG9xZJoltDNeDSW0qiF2Vqx5orMWa7OhVWrjvrd5NpI=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/TwiN/deepmerge v0.2.1 h1:GowJr9O4THTVW4awX63x1BVg1hgr4q+35XKKCYbwsSs=
github.com/TwiN/deepmerge v0.2.1/go.mod h1:LVBmCEBQvibYSF8Gyl/NqhHXH7yIiT7Ozqf9dHxGPW0=
github.com/TwiN/deepmerge v0.2.2 h1:FUG9QMIYg/j2aQyPPhA3XTFJwXSNHI/swaR4Lbyxwg4=
github.com/TwiN/deepmerge v0.2.2/go.mod h1:4OHvjV3pPNJCJZBHswYAwk6rxiD8h8YZ+9cPo7nu4oI=
github.com/TwiN/g8/v2 v2.0.0 h1:+hwIbRLMhDd2iwHzkZUPp2FkX7yTx8ddYOnS91HkDqQ=
github.com/TwiN/g8/v2 v2.0.0/go.mod h1:4sVAF27q8T8ISggRa/Fb0drw7wpB22B6eWd+/+SGMqE=
github.com/TwiN/gocache/v2 v2.2.2 h1:4HToPfDV8FSbaYO5kkbhLpEllUYse5rAf+hVU/mSsuI=
github.com/TwiN/gocache/v2 v2.2.2/go.mod h1:WfIuwd7GR82/7EfQqEtmLFC3a2vqaKbs4Pe6neB7Gyc=
github.com/TwiN/health v1.6.0 h1:L2ks575JhRgQqWWOfKjw9B0ec172hx7GdToqkYUycQM=
github.com/TwiN/health v1.6.0/go.mod h1:Z6TszwQPMvtSiVx1QMidVRgvVr4KZGfiwqcD7/Z+3iw=
github.com/TwiN/whois v1.1.9 h1:m20+m1CXnrstie+tW2ZmAJkfcT9zgwpVRUFsKeMw+ng=
github.com/TwiN/whois v1.1.9/go.mod h1:TjipCMpJRAJYKmtz/rXQBU6UGxMh6bk8SHazu7OMnQE=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/aws/aws-sdk-go v1.54.10 h1:dvkMlAttUsyacKj2L4poIQBLzOSWL2JG2ty+yWrqets=
github.com/aws/aws-sdk-go v1.54.10/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/TwiN/logr v0.3.1 h1:CfTKA83jUmsAoxqrr3p4JxEkqXOBnEE9/f35L5MODy4=
github.com/TwiN/logr v0.3.1/go.mod h1:BZgZFYq6fQdU3KtR8qYato3zUEw53yQDaIuujHb55Jw=
github.com/TwiN/whois v1.1.10 h1:OdnxMRPlegKr+ypwMKq5VpJ8QoD6F2e5gY+MKTs9VyA=
github.com/TwiN/whois v1.1.10/go.mod h1:TjipCMpJRAJYKmtz/rXQBU6UGxMh6bk8SHazu7OMnQE=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/aws/aws-sdk-go v1.55.6 h1:cSg4pvZ3m8dgYcgqB97MrcdjUmZ1BeMYKUxMMB89IPk=
github.com/aws/aws-sdk-go v1.55.6/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
github.com/coreos/go-oidc/v3 v3.12.0 h1:sJk+8G2qq94rDI6ehZ71Bol3oUHy63qNYmkiSjrc/Jo=
github.com/coreos/go-oidc/v3 v3.12.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -39,10 +36,6 @@ github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454Wv
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
@@ -54,33 +47,11 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo=
github.com/gofiber/fiber/v2 v2.52.5/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ=
github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI=
github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-github/v48 v48.2.0 h1:68puzySE6WqUY9KWmpOsDEQfDZsso98rT6pZcz9HqcE=
@@ -89,15 +60,14 @@ github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
github.com/googleapis/gax-go/v2 v2.12.4 h1:9gWcmF85Wvq4ryPFvGFaOgPIs1AQX0d0bcbGw4Z96qg=
github.com/googleapis/gax-go/v2 v2.12.4/go.mod h1:KYEYLorsnIGDi/rPC8b5TdlB9kbKoFubselGIoBMCwI=
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
github.com/googleapis/gax-go/v2 v2.14.0 h1:f+jMrjBPl+DL9nI4IQzLUxMq7XrAqFYB7hBPqMNIe8o=
github.com/googleapis/gax-go/v2 v2.14.0/go.mod h1:lhBCnjdLrWRaPvLWhmc8IS24m9mr07qSYnHncrgo+zk=
github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
@@ -108,8 +78,8 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -123,8 +93,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ=
github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
@@ -133,11 +103,10 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4=
github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4=
github.com/prometheus/client_golang v1.20.4 h1:Tgh3Yr67PaOv/uTqloMsCEdeuFTatm5zIq5+qNN23vI=
github.com/prometheus/client_golang v1.20.4/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus-community/pro-bing v0.5.0 h1:Fq+4BUXKIvsPtXUY8K+04ud9dkAuFozqGmRAyNUpffY=
github.com/prometheus-community/pro-bing v0.5.0/go.mod h1:1joR9oXdMEAcAJJvhs+8vNDvTg5thfAZcRFhcUozG2g=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
@@ -152,34 +121,29 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.56.0 h1:bEZdJev/6LCBlpdORfrLu/WOZXXxvrUQSiyniuaoW8U=
github.com/valyala/fasthttp v1.56.0/go.mod h1:sReBt3XZVnudxuLOx4J/fMrJVorWRiWY2koQKgABiVI=
github.com/valyala/fasthttp v1.58.0 h1:GGB2dWxSbEprU9j0iMJHgdKYJVDyjrOwF9RE59PbRuE=
github.com/valyala/fasthttp v1.58.0/go.mod h1:SYXvHHaFp7QZHGKSHmoMipInhrI5StHrhDTYVEjK/Kw=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/wcharczuk/go-chart/v2 v2.1.2 h1:Y17/oYNuXwZg6TFag06qe8sBajwwsuvPiJJXcUcLL6E=
github.com/wcharczuk/go-chart/v2 v2.1.2/go.mod h1:Zi4hbaqlWpYajnXB2K22IUYVXRXaLfSGNNR7P4ukyyQ=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0 h1:9l89oX4ba9kHbBol3Xin3leYJ+252h0zszDtBwyKe2A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0/go.mod h1:XLZfZboOJWHNKUv7eH0inh0E9VV6eWDFB/9yJyTLPp0=
go.opentelemetry.io/otel v1.27.0 h1:9BZoF3yMK/O1AafMiQTVu0YDj5Ea4hPhxCs7sGva+cg=
go.opentelemetry.io/otel v1.27.0/go.mod h1:DMpAK8fzYRzs+bi3rS5REupisuqTheUlSZJ1WnZaPAQ=
go.opentelemetry.io/otel/metric v1.27.0 h1:hvj3vdEKyeCi4YaYfNjv2NUje8FqKqUY8IlF0FxV/ik=
go.opentelemetry.io/otel/metric v1.27.0/go.mod h1:mVFgmRlhljgBiuk/MP/oKylr4hs85GZAylncepAX/ak=
go.opentelemetry.io/otel/trace v1.27.0 h1:IqYb813p7cmbHk0a5y6pD5JPakbVfftRXABGt5/Rscw=
go.opentelemetry.io/otel/trace v1.27.0/go.mod h1:6RiD1hkAprV4/q+yd2ln1HG9GoPx39SuvvstaLBl+l4=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
@@ -187,14 +151,10 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@@ -202,13 +162,8 @@ golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
@@ -216,25 +171,20 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -246,8 +196,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -256,8 +206,8 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM=
golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8=
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -267,15 +217,11 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
@@ -285,33 +231,14 @@ golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.183.0 h1:PNMeRDwo1pJdgNcFQ9GstuLe/noWKIc89pRWRLMvLwE=
google.golang.org/api v0.183.0/go.mod h1:q43adC5/pHoSZTx5h2mSmdF7NcyfW9JuDyIOJAgS9ZQ=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 h1:1GBuWVLM/KMVUv1t1En5Gs+gFZCNd360GGb4sSxtrhU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY=
google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
google.golang.org/api v0.214.0 h1:h2Gkq07OYi6kusGOaT/9rnNljuXmqPnaig7WGPmKbwA=
google.golang.org/api v0.214.0/go.mod h1:bYPpLG8AyeMWwDU6NXoB00xC0DFkikVvd5MfwoxjLqE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 h1:8ZmaLZE4XWrtU3MyClkYqqtl6Oegr3235h7jxsDyqCY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU=
google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E=
google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA=
google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io=
google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -322,11 +249,8 @@ gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
@@ -347,8 +271,8 @@ modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM=
modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
modernc.org/sqlite v1.34.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8=
modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

60
main.go
View File

@@ -1,7 +1,6 @@
package main
import (
"log"
"os"
"os/signal"
"strconv"
@@ -12,13 +11,21 @@ import (
"github.com/TwiN/gatus/v5/controller"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/watchdog"
"github.com/TwiN/logr"
)
const (
GatusConfigPathEnvVar = "GATUS_CONFIG_PATH"
GatusConfigFileEnvVar = "GATUS_CONFIG_FILE" // Deprecated in favor of GatusConfigPathEnvVar
GatusLogLevelEnvVar = "GATUS_LOG_LEVEL"
)
func main() {
if delayInSeconds, _ := strconv.Atoi(os.Getenv("GATUS_DELAY_START_SECONDS")); delayInSeconds > 0 {
log.Printf("Delaying start by %d seconds", delayInSeconds)
logr.Infof("Delaying start by %d seconds", delayInSeconds)
time.Sleep(time.Duration(delayInSeconds) * time.Second)
}
configureLogging()
cfg, err := loadConfiguration()
if err != nil {
panic(err)
@@ -31,13 +38,13 @@ func main() {
signal.Notify(signalChannel, os.Interrupt, syscall.SIGTERM)
go func() {
<-signalChannel
log.Println("Received termination signal, attempting to gracefully shut down")
logr.Info("Received termination signal, attempting to gracefully shut down")
stop(cfg)
save()
done <- true
}()
<-done
log.Println("Shutting down")
logr.Info("Shutting down")
}
func start(cfg *config.Config) {
@@ -53,16 +60,31 @@ func stop(cfg *config.Config) {
func save() {
if err := store.Get().Save(); err != nil {
log.Println("Failed to save storage provider:", err.Error())
logr.Errorf("Failed to save storage provider: %s", err.Error())
}
}
func configureLogging() {
logLevelAsString := os.Getenv(GatusLogLevelEnvVar)
if logLevel, err := logr.LevelFromString(logLevelAsString); err != nil {
logr.SetThreshold(logr.LevelInfo)
if len(logLevelAsString) == 0 {
logr.Infof("[main.configureLogging] Defaulting log level to %s", logr.LevelInfo)
} else {
logr.Warnf("[main.configureLogging] Invalid log level '%s', defaulting to %s", logLevelAsString, logr.LevelInfo)
}
} else {
logr.SetThreshold(logLevel)
logr.Infof("[main.configureLogging] Log Level is set to %s", logr.GetThreshold())
}
}
func loadConfiguration() (*config.Config, error) {
configPath := os.Getenv("GATUS_CONFIG_PATH")
configPath := os.Getenv(GatusConfigPathEnvVar)
// Backwards compatibility
if len(configPath) == 0 {
if configPath = os.Getenv("GATUS_CONFIG_FILE"); len(configPath) > 0 {
log.Println("WARNING: GATUS_CONFIG_FILE is deprecated. Please use GATUS_CONFIG_PATH instead.")
if configPath = os.Getenv(GatusConfigFileEnvVar); len(configPath) > 0 {
logr.Warnf("WARNING: %s is deprecated. Please use %s instead.", GatusConfigFileEnvVar, GatusConfigPathEnvVar)
}
}
return config.LoadConfiguration(configPath)
@@ -88,7 +110,7 @@ func initializeStorage(cfg *config.Config) {
}
numberOfEndpointStatusesDeleted := store.Get().DeleteAllEndpointStatusesNotInKeys(keys)
if numberOfEndpointStatusesDeleted > 0 {
log.Printf("[main.initializeStorage] Deleted %d endpoint statuses because their matching endpoints no longer existed", numberOfEndpointStatusesDeleted)
logr.Infof("[main.initializeStorage] Deleted %d endpoint statuses because their matching endpoints no longer existed", numberOfEndpointStatusesDeleted)
}
// Clean up the triggered alerts from the storage provider and load valid triggered endpoint alerts
numberOfPersistedTriggeredAlertsLoaded := 0
@@ -100,13 +122,13 @@ func initializeStorage(cfg *config.Config) {
}
}
numberOfTriggeredAlertsDeleted := store.Get().DeleteAllTriggeredAlertsNotInChecksumsByEndpoint(ep, checksums)
if cfg.Debug && numberOfTriggeredAlertsDeleted > 0 {
log.Printf("[main.initializeStorage] Deleted %d triggered alerts for endpoint with key=%s because their configurations have been changed or deleted", numberOfTriggeredAlertsDeleted, ep.Key())
if numberOfTriggeredAlertsDeleted > 0 {
logr.Debugf("[main.initializeStorage] Deleted %d triggered alerts for endpoint with key=%s because their configurations have been changed or deleted", numberOfTriggeredAlertsDeleted, ep.Key())
}
for _, alert := range ep.Alerts {
exists, resolveKey, numberOfSuccessesInARow, err := store.Get().GetTriggeredEndpointAlert(ep, alert)
if err != nil {
log.Printf("[main.initializeStorage] Failed to get triggered alert for endpoint with key=%s: %s", ep.Key(), err.Error())
logr.Errorf("[main.initializeStorage] Failed to get triggered alert for endpoint with key=%s: %s", ep.Key(), err.Error())
continue
}
if exists {
@@ -125,13 +147,13 @@ func initializeStorage(cfg *config.Config) {
}
convertedEndpoint := ee.ToEndpoint()
numberOfTriggeredAlertsDeleted := store.Get().DeleteAllTriggeredAlertsNotInChecksumsByEndpoint(convertedEndpoint, checksums)
if cfg.Debug && numberOfTriggeredAlertsDeleted > 0 {
log.Printf("[main.initializeStorage] Deleted %d triggered alerts for endpoint with key=%s because their configurations have been changed or deleted", numberOfTriggeredAlertsDeleted, ee.Key())
if numberOfTriggeredAlertsDeleted > 0 {
logr.Debugf("[main.initializeStorage] Deleted %d triggered alerts for endpoint with key=%s because their configurations have been changed or deleted", numberOfTriggeredAlertsDeleted, ee.Key())
}
for _, alert := range ee.Alerts {
exists, resolveKey, numberOfSuccessesInARow, err := store.Get().GetTriggeredEndpointAlert(convertedEndpoint, alert)
if err != nil {
log.Printf("[main.initializeStorage] Failed to get triggered alert for endpoint with key=%s: %s", ee.Key(), err.Error())
logr.Errorf("[main.initializeStorage] Failed to get triggered alert for endpoint with key=%s: %s", ee.Key(), err.Error())
continue
}
if exists {
@@ -142,7 +164,7 @@ func initializeStorage(cfg *config.Config) {
}
}
if numberOfPersistedTriggeredAlertsLoaded > 0 {
log.Printf("[main.initializeStorage] Loaded %d persisted triggered alerts", numberOfPersistedTriggeredAlertsLoaded)
logr.Infof("[main.initializeStorage] Loaded %d persisted triggered alerts", numberOfPersistedTriggeredAlertsLoaded)
}
}
@@ -150,15 +172,15 @@ func listenToConfigurationFileChanges(cfg *config.Config) {
for {
time.Sleep(30 * time.Second)
if cfg.HasLoadedConfigurationBeenModified() {
log.Println("[main.listenToConfigurationFileChanges] Configuration file has been modified")
logr.Info("[main.listenToConfigurationFileChanges] Configuration file has been modified")
stop(cfg)
time.Sleep(time.Second) // Wait a bit to make sure everything is done.
save()
updatedConfig, err := loadConfiguration()
if err != nil {
if cfg.SkipInvalidConfigUpdate {
log.Println("[main.listenToConfigurationFileChanges] Failed to load new configuration:", err.Error())
log.Println("[main.listenToConfigurationFileChanges] The configuration file was updated, but it is not valid. The old configuration will continue being used.")
logr.Errorf("[main.listenToConfigurationFileChanges] Failed to load new configuration: %s", err.Error())
logr.Error("[main.listenToConfigurationFileChanges] The configuration file was updated, but it is not valid. The old configuration will continue being used.")
// Update the last file modification time to avoid trying to process the same invalid configuration again
cfg.UpdateLastFileModTime()
continue

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