Compare commits

...

112 Commits

Author SHA1 Message Date
dependabot[bot]
c6ff6ec583 chore(deps): bump github.com/gofiber/fiber/v2 from 2.52.4 to 2.52.5 (#888)
Bumps [github.com/gofiber/fiber/v2](https://github.com/gofiber/fiber) from 2.52.4 to 2.52.5.
- [Release notes](https://github.com/gofiber/fiber/releases)
- [Commits](https://github.com/gofiber/fiber/compare/v2.52.4...v2.52.5)

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

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

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

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

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

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

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

* Document Teams Workflow Alert

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

* Rename 'teamsworkflow' to 'teams-workflows'

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

* Fix README Table Format

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

* Fix Test to Expect Correct Emoji

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.19.1 to 1.20.4.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.19.1...v1.20.4)

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

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

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

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

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

* fix typo

---------

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

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

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

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

* feat: add ENDPOINT_ERRORS example

* feat: add tests

* Refactor: code review feedback

* delete unsed errors

* Update README.md

* Apply suggestions from code review

---------

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

* Added omitempty to channel in Body for Mattermost

* Update README.md

* Update alerting/provider/mattermost/mattermost.go

* Expose channel configuration parameter through alerting.mattermost.channel

---------

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

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

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

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

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

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

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

* feat(alerting): Implement Zulip alert provider

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

* docs: Add Zulip alerts to README.md

* fix(alerting): Include alert description in message

* fix(alerting): validate Zuilip interface on compile

* chore(alerting): fix import order

* fix(alerting): rename ChannelId to ChannelID

* Update alerting/provider/zulip/zulip_test.go

---------

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

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

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

* chore: integrate Gitea alerting provider configuration

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

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

* feat: add Assignees support to AlertProvider functionality

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

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

* feat: implement Gitea alerting configuration and documentation

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

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

* Update README.md

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

* Update README.md

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

* Update README.md

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

* feat: refactor AlertProvider for improved client configuration

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

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

* Update README.md

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

* chore: update configuration for Gitea integration

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

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

* fix: ensure ClientConfig is validated and defaults set

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

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

---------

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

Fix #714

* Fix typo

* Fix test

* Fix typo

* Improve implementation

* Add check in existing test

* Add extra test to ensure functionality works

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

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

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

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

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

* Update alerting/provider/telegram/telegram_test.go

* Update alerting/provider/telegram/telegram_test.go

* Update alerting/provider/telegram/telegram_test.go

---------

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

* Update config/maintenance/maintenance.go

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

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

* fix: Include the original error in the message

---------

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-22 22:39:52 -04:00
dependabot[bot]
65b77e9525 chore(deps): bump codecov/codecov-action from 4.4.1 to 4.5.0 (#800)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4.4.1 to 4.5.0.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v4.4.1...v4.5.0)

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-21 20:17:55 -04:00
dependabot[bot]
ea7bf2c194 chore(deps): bump codecov/codecov-action from 4.3.0 to 4.4.0 (#765)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4.3.0 to 4.4.0.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v4.3.0...v4.4.0)

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

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

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

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

Fixes #679

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

* refactor: Move core package to config/endpoint

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

* refactor: Rename EndpointStatus to Status

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

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

* refactor: Rename parameter r to result

* refactor: Revert accidental change to endpoint.TypeDNS

* refactor: Rename parameter r to result

* refactor: Merge util package into endpoint package

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

* Update README.md

* Small adjustment

---------

Co-authored-by: TwiN <twin@linux.com>
2024-05-04 19:24:25 -04:00
TwiN
dcfdfd423e docs: Remove unnecessary badge 2024-04-27 14:39:42 -04:00
dependabot[bot]
28339684bf chore(deps): bump github.com/google/uuid from 1.5.0 to 1.6.0 (#703)
Bumps [github.com/google/uuid](https://github.com/google/uuid) from 1.5.0 to 1.6.0.
- [Release notes](https://github.com/google/uuid/releases)
- [Changelog](https://github.com/google/uuid/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/uuid/compare/v1.5.0...v1.6.0)

---
updated-dependencies:
- dependency-name: github.com/google/uuid
  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-04-18 21:34:26 -04:00
Robert Schelling
519500508a fix: add crossorigin attribute to manifest.json link in index.html (#736)
* fix: add crossorigin attribute

* Update index.html

---------

Co-authored-by: Robert Schelling <robert.schelling@neckar.it>
Co-authored-by: TwiN <twin@linux.com>
2024-04-18 21:19:08 -04:00
TwiN
76a84031c2 refactor: Update connectivity.Checker struct to use pointer on receiver 2024-04-18 21:08:43 -04:00
TwiN
b88d0b1c34 chore: Update example.com's IPv4 and IPv6 2024-04-18 21:08:43 -04:00
TwiN
e0ab35e86a feat(ui): Implement endpoints[].ui.hide-conditions 2024-04-18 21:08:43 -04:00
TwiN
7efe7429dd docs: Fix formatting 2024-04-11 21:37:15 -04:00
HongKuang
4393a49900 chore: fix some typos (#725)
Signed-off-by: hongkuang <liurenhong@outlook.com>
Co-authored-by: TwiN <twin@linux.com>
2024-04-11 21:18:30 -04:00
TwiN
241956b28c fix(alerting): Support alerts with no conditions for external endpoints (#729) 2024-04-10 20:46:17 -04:00
TwiN
a4bc3c4dfe docs: Add missing information on passing a token for external endpoints 2024-04-10 19:03:29 -04:00
TwiN
7cf2b427c9 docs: Add missing description for ui pkg ValidateAndSetDefaults 2024-04-09 18:56:33 -04:00
TwiN
dc9cddfd77 ci: Add codecov token 2024-04-09 18:53:15 -04:00
Chris Smith
f93cebe715 feat(client): add mTLS config (#665)
* feat: add mtls config to client

* feat: add mtls config to client

* Rework client tls configuration

* Rebase (#3)

* chore(deps): bump codecov/codecov-action from 3.1.6 to 4.0.1 (#671)

Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 3.1.6 to 4.0.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/v3.1.6...v4.0.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat(client): enhance HTTP client configuration with proxy support (#668)

* feat: enhance HTTP client configuration with proxy support

- Add `ProxyURL` field to `Config` struct with YAML tag
- Implement proxy URL parsing and setting in `getHTTPClient` method
- Add test case for `getHTTPClient` method with custom proxy URL setting
- Include `net/url` package in both `config.go` and `config_test.go` files

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

* docs: enhance README with Proxy and OAuth2 Docs

- Remove empty lines from README.md
- Add documentation for proxy configuration in client examples
- Include YAML examples for client using a proxy, custom DNS resolver, OAuth2, and identity-aware proxy configurations in README.md

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

* docs: add proxy client

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

* Update client/config.go

* Update README.md

* Update client/config_test.go

---------

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
Signed-off-by: appleboy <appleboy.tw@gmail.com>
Co-authored-by: TwiN <twin@linux.com>

* chore: Update Go to 1.21

* chore(deps): bump github.com/prometheus/client_golang from 1.17.0 to 1.18.0 (#658)

chore(deps): bump github.com/prometheus/client_golang

Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.17.0 to 1.18.0.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.17.0...v1.18.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump github.com/gofiber/fiber/v2 from 2.49.2 to 2.52.1 (#682)

Bumps [github.com/gofiber/fiber/v2](https://github.com/gofiber/fiber) from 2.49.2 to 2.52.1.
- [Release notes](https://github.com/gofiber/fiber/releases)
- [Commits](https://github.com/gofiber/fiber/compare/v2.49.2...v2.52.1)

---
updated-dependencies:
- dependency-name: github.com/gofiber/fiber/v2
  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>

* docs(alerting): Fix wrong gitlab terminology (alert key vs. PAT) (#694)

Fix wrong term (alert key vs. PAT)

* chore(deps): bump github.com/TwiN/deepmerge from 0.2.0 to 0.2.1 (#684)

* chore(deps): bump github.com/TwiN/deepmerge from 0.2.0 to 0.2.1

Bumps [github.com/TwiN/deepmerge](https://github.com/TwiN/deepmerge) from 0.2.0 to 0.2.1.
- [Release notes](https://github.com/TwiN/deepmerge/releases)
- [Commits](https://github.com/TwiN/deepmerge/compare/v0.2.0...v0.2.1)

---
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>

* build: Add `go mod tidy` in Dockerfile

* ci: Update Go to 1.20

* Update go.mod

* Update test.yml

---------

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>

* chore(deps): bump golang.org/x/oauth2 from 0.13.0 to 0.18.0 (#701)

Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.13.0 to 0.18.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.13.0...v0.18.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>

* feat: add mtls config to client

feat: add mtls config to client

Rework client tls configuration

---------

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
Signed-off-by: appleboy <appleboy.tw@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Bo-Yi Wu <appleboy.tw@gmail.com>
Co-authored-by: TwiN <twin@linux.com>
Co-authored-by: Salim B <git@salim.space>

* Rebase (#4)

* chore(deps): bump codecov/codecov-action from 3.1.6 to 4.0.1 (#671)

Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 3.1.6 to 4.0.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/v3.1.6...v4.0.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat(client): enhance HTTP client configuration with proxy support (#668)

* feat: enhance HTTP client configuration with proxy support

- Add `ProxyURL` field to `Config` struct with YAML tag
- Implement proxy URL parsing and setting in `getHTTPClient` method
- Add test case for `getHTTPClient` method with custom proxy URL setting
- Include `net/url` package in both `config.go` and `config_test.go` files

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

* docs: enhance README with Proxy and OAuth2 Docs

- Remove empty lines from README.md
- Add documentation for proxy configuration in client examples
- Include YAML examples for client using a proxy, custom DNS resolver, OAuth2, and identity-aware proxy configurations in README.md

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

* docs: add proxy client

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

* Update client/config.go

* Update README.md

* Update client/config_test.go

---------

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
Signed-off-by: appleboy <appleboy.tw@gmail.com>
Co-authored-by: TwiN <twin@linux.com>

* chore: Update Go to 1.21

* chore(deps): bump github.com/prometheus/client_golang from 1.17.0 to 1.18.0 (#658)

chore(deps): bump github.com/prometheus/client_golang

Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.17.0 to 1.18.0.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.17.0...v1.18.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump github.com/gofiber/fiber/v2 from 2.49.2 to 2.52.1 (#682)

Bumps [github.com/gofiber/fiber/v2](https://github.com/gofiber/fiber) from 2.49.2 to 2.52.1.
- [Release notes](https://github.com/gofiber/fiber/releases)
- [Commits](https://github.com/gofiber/fiber/compare/v2.49.2...v2.52.1)

---
updated-dependencies:
- dependency-name: github.com/gofiber/fiber/v2
  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>

* docs(alerting): Fix wrong gitlab terminology (alert key vs. PAT) (#694)

Fix wrong term (alert key vs. PAT)

* chore(deps): bump github.com/TwiN/deepmerge from 0.2.0 to 0.2.1 (#684)

* chore(deps): bump github.com/TwiN/deepmerge from 0.2.0 to 0.2.1

Bumps [github.com/TwiN/deepmerge](https://github.com/TwiN/deepmerge) from 0.2.0 to 0.2.1.
- [Release notes](https://github.com/TwiN/deepmerge/releases)
- [Commits](https://github.com/TwiN/deepmerge/compare/v0.2.0...v0.2.1)

---
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>

* build: Add `go mod tidy` in Dockerfile

* ci: Update Go to 1.20

* Update go.mod

* Update test.yml

---------

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>

* chore(deps): bump golang.org/x/oauth2 from 0.13.0 to 0.18.0 (#701)

Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.13.0 to 0.18.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.13.0...v0.18.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>

* feat: add mtls config to client

* feat: add mtls config to client

* Rework client tls configuration

---------

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
Signed-off-by: appleboy <appleboy.tw@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Bo-Yi Wu <appleboy.tw@gmail.com>
Co-authored-by: TwiN <twin@linux.com>
Co-authored-by: Salim B <git@salim.space>

* Rebase (#6)

* feat(tls): add mtls config to client (#189)

---------

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
Signed-off-by: appleboy <appleboy.tw@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Bo-Yi Wu <appleboy.tw@gmail.com>
Co-authored-by: TwiN <twin@linux.com>
Co-authored-by: Salim B <git@salim.space>
2024-04-09 18:41:37 -04:00
TwiN
f54c45e20e feat: Implement push-based external endpoints (#724)
* refactor: Move SSH outside of endpoint.go
* refactor: Use pointers for Alert receivers
* feat: Implement push-based external endpoints
* Fix failing tests
* Validate external endpoints on start
* Add tests for external endpoints
* refactor some error equality checks
* Improve docs and refactor some code
* Fix UI-related issues with external endpoints
2024-04-08 21:00:40 -04:00
dependabot[bot]
cacfbc0185 chore(deps): bump codecov/codecov-action from 4.0.1 to 4.1.1 (#715)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4.0.1 to 4.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/v4.0.1...v4.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>
Co-authored-by: TwiN <twin@linux.com>
2024-04-01 21:50:59 -04:00
TwiN
922638e071 refactor: Clean up code and change log format (#719) 2024-04-01 21:47:14 -04:00
TwiN
979d467e36 fix(jsonpath): Prevent panic when body is "null"
Resolves #718
2024-04-01 20:03:02 -04:00
TwiN
ceb2c7884f docs: Add discord badge 2024-03-29 21:44:38 -04:00
michael-baraboo
ae750aa367 feat(alerting): add alerting support for jetbrains space (#713)
* add alerting support for jetbrains space

* readme fixes

* add jetbrainsspace to provider interface compilation check

* add jetbrainsspace to a couple more tests
2024-03-28 18:36:22 -04:00
dependabot[bot]
5aa83ee274 chore(deps): bump golang.org/x/oauth2 from 0.13.0 to 0.18.0 (#701)
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.13.0 to 0.18.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.13.0...v0.18.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-03-10 18:59:00 -04:00
dependabot[bot]
1e431c797a chore(deps): bump github.com/TwiN/deepmerge from 0.2.0 to 0.2.1 (#684)
* chore(deps): bump github.com/TwiN/deepmerge from 0.2.0 to 0.2.1

Bumps [github.com/TwiN/deepmerge](https://github.com/TwiN/deepmerge) from 0.2.0 to 0.2.1.
- [Release notes](https://github.com/TwiN/deepmerge/releases)
- [Commits](https://github.com/TwiN/deepmerge/compare/v0.2.0...v0.2.1)

---
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>

* build: Add `go mod tidy` in Dockerfile

* ci: Update Go to 1.20

* Update go.mod

* Update test.yml

---------

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-03-06 20:37:42 -05:00
Salim B
143a093e20 docs(alerting): Fix wrong gitlab terminology (alert key vs. PAT) (#694)
Fix wrong term (alert key vs. PAT)
2024-03-06 19:40:19 -05:00
dependabot[bot]
1eba430797 chore(deps): bump github.com/gofiber/fiber/v2 from 2.49.2 to 2.52.1 (#682)
Bumps [github.com/gofiber/fiber/v2](https://github.com/gofiber/fiber) from 2.49.2 to 2.52.1.
- [Release notes](https://github.com/gofiber/fiber/releases)
- [Commits](https://github.com/gofiber/fiber/compare/v2.49.2...v2.52.1)

---
updated-dependencies:
- dependency-name: github.com/gofiber/fiber/v2
  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-02-24 15:12:58 -05:00
dependabot[bot]
6299b630ce chore(deps): bump github.com/prometheus/client_golang from 1.17.0 to 1.18.0 (#658)
chore(deps): bump github.com/prometheus/client_golang

Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.17.0 to 1.18.0.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.17.0...v1.18.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-20 22:26:25 -05:00
TwiN
1fcc6c0cc0 chore: Update Go to 1.21 2024-02-18 14:37:29 -05:00
Bo-Yi Wu
408a46f2af feat(client): enhance HTTP client configuration with proxy support (#668)
* feat: enhance HTTP client configuration with proxy support

- Add `ProxyURL` field to `Config` struct with YAML tag
- Implement proxy URL parsing and setting in `getHTTPClient` method
- Add test case for `getHTTPClient` method with custom proxy URL setting
- Include `net/url` package in both `config.go` and `config_test.go` files

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

* docs: enhance README with Proxy and OAuth2 Docs

- Remove empty lines from README.md
- Add documentation for proxy configuration in client examples
- Include YAML examples for client using a proxy, custom DNS resolver, OAuth2, and identity-aware proxy configurations in README.md

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

* docs: add proxy client

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

* Update client/config.go

* Update README.md

* Update client/config_test.go

---------

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
Signed-off-by: appleboy <appleboy.tw@gmail.com>
Co-authored-by: TwiN <twin@linux.com>
2024-02-14 21:43:57 -05:00
dependabot[bot]
3269e96f49 chore(deps): bump codecov/codecov-action from 3.1.6 to 4.0.1 (#671)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 3.1.6 to 4.0.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/v3.1.6...v4.0.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-12 23:26:58 -05:00
TwiN
08742e4af3 refactor(alerting): Use pointer for receiver in AlertProvider.GetDefaultAlert method (#676) 2024-02-07 20:09:45 -05:00
TwiN
2a623a59d3 fix(web): Allow configuration of read-buffer-size (#675)
This fixes the `431 Request Header Fields Too Large` error

By default, the read-buffer-size is 8192, up from fiber's default of 4096.

Fixes #674

Fixes #636

Supersedes #637

Supersedes #663
2024-02-07 18:54:30 -05:00
Nicolas Thumann
3d1b4e566d feat(client): Add network config for ICMP endpoint client (#661)
* feat(client): Add network to config

* feat(client): Use network client config for pinger

* feat(client): Add client network configuration and demo to README

* feat(client): Add tests for pinger using network config

* feat(client): Drop integration tests

* feat(client): Add comment to tests

* feat(client): Add tests

* Update README.md

---------

Co-authored-by: TwiN <twin@linux.com>
2024-02-06 21:15:51 -05:00
Steven Kreitzer
6cbc59b0e8 feat: shields.io endpoint badge (#652)
* feat: shields.io endpoint badge

Signed-off-by: Steven Kreitzer <skre@skre.me>

* chore: update readme to include new shields.io badge

Signed-off-by: Steven Kreitzer <skre@skre.me>

---------

Signed-off-by: Steven Kreitzer <skre@skre.me>
Co-authored-by: TwiN <twin@linux.com>
2024-02-01 00:06:08 -05:00
dependabot[bot]
1a7aeb5b35 chore(deps): bump codecov/codecov-action from 3.1.4 to 3.1.6 (#669)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 3.1.4 to 3.1.6.
- [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/v3.1.4...v3.1.6)

---
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-01-31 23:53:51 -05:00
Bo-Yi Wu
228cd4d1fb build: refine Makefile targets and build command (#666)
* build: refine Makefile targets and build command

- Remove `test` from `.PHONY` and add `install`, `run`, and `clean` targets to `.PHONY`
- Replace build command to include verbose flag and remove `-mod vendor` option

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

* docs: refine README structure and update tests

- Add a "Table of Contents" section to the README
- Reorganize sections within the README, moving "Configuring AWS SES alerts" and "How to change the color thresholds of the response time badge"
- Remove "Sponsors" section from the README
- Update the test command in the README from `go test ./... -mod vendor` to `go test -v ./...`

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

---------

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2024-01-29 01:12:14 -05:00
dependabot[bot]
bdad56e205 chore(deps): bump modernc.org/sqlite from 1.26.0 to 1.28.0 (#654)
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.26.0 to 1.28.0.
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.26.0...v1.28.0)

---
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-01-17 22:25:54 -05:00
Nicolas Thumann
911902353f fix: Allow binding IPv6 addresses for web (#650)
Co-authored-by: TwiN <twin@linux.com>
2024-01-14 15:03:12 -05:00
dependabot[bot]
f76be6df92 chore(deps): bump golang.org/x/crypto from 0.14.0 to 0.18.0 (#649)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.14.0 to 0.18.0.
- [Commits](https://github.com/golang/crypto/compare/v0.14.0...v0.18.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-13 23:06:52 -05:00
dependabot[bot]
dd6c4142fb chore(deps): bump actions/setup-go from 4 to 5 (#634)
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 4 to 5.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/setup-go
  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-01-01 23:26:52 -05:00
I-HSIN Cheng
1e82d2f07d feat: add ability to delay startup (#631)
* Feat: add ability to delay startup

* Enable ability to set delay seconds before start up gatus

* Update README.md

* Delete .examples/delay-startup/Makefile

---------

Co-authored-by: TwiN <twin@linux.com>
2023-12-24 20:48:43 -05:00
Kloox
3c246f0c69 feat(client): Add Google's Identity-Aware-Proxy in client config (#600)
* client: config: add Google Identity-Aware-Proxy support

* Add correct sum and mod

* Update README.md

* Update README.md

* client: config: add Google Identity-Aware-Proxy support

* Add correct sum and mod

* Update README.md

* Update README.md

* Change IAP acronym to Identity-Aware-Proxy

* Change IAP acronym to Identity-Aware-Proxy in README.md

* Fix conflict in go.mod

* merge readme from twin/master

* Fix typo error in readme.md

* Update client/config.go

---------

Co-authored-by: TwiN <twin@linux.com>
2023-11-28 22:50:07 -05:00
dependabot[bot]
76111ee133 chore(deps): bump github.com/aws/aws-sdk-go from 1.47.8 to 1.47.9 (#623)
Bumps [github.com/aws/aws-sdk-go](https://github.com/aws/aws-sdk-go) from 1.47.8 to 1.47.9.
- [Release notes](https://github.com/aws/aws-sdk-go/releases)
- [Commits](https://github.com/aws/aws-sdk-go/compare/v1.47.8...v1.47.9)

---
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>
2023-11-13 20:12:39 -05:00
dependabot[bot]
d3a82244a1 chore(deps): bump github.com/aws/aws-sdk-go from 1.45.16 to 1.47.8 (#621)
Bumps [github.com/aws/aws-sdk-go](https://github.com/aws/aws-sdk-go) from 1.45.16 to 1.47.8.
- [Release notes](https://github.com/aws/aws-sdk-go/releases)
- [Commits](https://github.com/aws/aws-sdk-go/compare/v1.45.16...v1.47.8)

---
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>
2023-11-12 17:14:17 -05:00
Bugra Kocabay
807599c05e docs: add instruction to install as binary (#615)
* feat(docs): add install binary command to docs

* feat(docs): change go install command format

* Update README.md

---------

Co-authored-by: Bugra Kocabay <kocabay.bugra@gmail.com>
Co-authored-by: TwiN <twin@linux.com>
2023-11-09 12:21:17 -05:00
Calvin Henderson
de7256e671 feat(alerting): make authentication optional for email provider (#608)
* feat(alerting): Made authentication optional for the email alert provider

* docs: Added  parameter to email alert provider docs

* feat(alerting): Updated email alert to set the LocalName attribute based on the From key

* Updated email provider to disable authentication when no credentials are provided

* Removed `disable-authentication` flag from email provider documentation

* Apply suggestions from code review

---------

Co-authored-by: TwiN <twin@linux.com>
2023-11-04 19:28:06 -04:00
Bugra Kocabay
c6515c4b1c feat(alerting): Add gotify provider (#605)
* feat(alerting): add gotify provider feature

* feat(alerting): update alert message

* feat(test): add tests for gotify provider

* feat(docs): add documentation for gotify provider

* feat(alerting): rename apptoken to token

* feat(docs): update docs for apptoken renaming to token

---------

Co-authored-by: Bugra Kocabay <kocabay.bugra@gmail.com>
Co-authored-by: TwiN <twin@linux.com>
2023-11-03 18:21:52 -04:00
dependabot[bot]
522b958d0f chore(deps): bump github.com/coreos/go-oidc/v3 from 3.6.0 to 3.7.0 (#604)
Bumps [github.com/coreos/go-oidc/v3](https://github.com/coreos/go-oidc) from 3.6.0 to 3.7.0.
- [Release notes](https://github.com/coreos/go-oidc/releases)
- [Commits](https://github.com/coreos/go-oidc/compare/v3.6.0...v3.7.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>
2023-11-02 23:06:06 -04:00
dependabot[bot]
16366e169e chore(deps): bump github.com/valyala/fasthttp from 1.49.0 to 1.50.0 (#594)
Bumps [github.com/valyala/fasthttp](https://github.com/valyala/fasthttp) from 1.49.0 to 1.50.0.
- [Release notes](https://github.com/valyala/fasthttp/releases)
- [Commits](https://github.com/valyala/fasthttp/compare/v1.49.0...v1.50.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>
2023-10-31 20:51:20 -04:00
Bugra Kocabay
ea3ae52f1e Feat/modify discord title (#602)
* feat(alerting): allow discord alert's title to be configurable

* feat(alerting): modify documentation for discord title feature

* feat(test): add tests for discord title modify feature

---------

Co-authored-by: Bugra Kocabay <kocabay.bugra@gmail.com>
Co-authored-by: TwiN <twin@linux.com>
2023-10-31 20:01:54 -04:00
TwiN
5a16151bba ui: Fix issue back button appearing over title when logo is too small 2023-10-31 19:44:54 -04:00
Kevin Richter
802ad7ff8f feat(alerting): Add AWS SES Alerting Provider (#579)
* Add SES Provider

* Formatting

* Rename ses to aws-ses

* Typo

* Parse tag instead of type name

* Use aws.slice to convert string array & rename awsses -> aws-ses

* Rename type

* Update README.md

* Update alerting/config.go

* Rename package aws-ses to awsses

* Update README.md

* PR comments

---------

Co-authored-by: TwiN <twin@linux.com>
2023-10-25 23:52:43 -04:00
dependabot[bot]
619b69f480 chore(deps): bump github.com/wcharczuk/go-chart/v2 from 2.1.0 to 2.1.1 (#591)
Bumps [github.com/wcharczuk/go-chart/v2](https://github.com/wcharczuk/go-chart) from 2.1.0 to 2.1.1.
- [Release notes](https://github.com/wcharczuk/go-chart/releases)
- [Commits](https://github.com/wcharczuk/go-chart/compare/v2.1.0...v2.1.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2023-10-17 19:46:01 -04:00
TwiN
87e029f555 ci: Increase timeout-minutes for test workflow to 10 minutes 2023-10-16 21:45:00 -04:00
TwiN
71c4d3ade1 ui: Use localStorage instead of sessionStorage for refresh interval + collapsed groups
Fixes #509

Supersedes #510
2023-10-16 21:35:43 -04:00
TwiN
315f9b7792 ui(settings): Fix refresh interval padding 2023-10-16 21:33:09 -04:00
dependabot[bot]
bde30b2efb chore(deps): bump github.com/prometheus/client_golang from 1.16.0 to 1.17.0 (#586)
chore(deps): bump github.com/prometheus/client_golang

Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.16.0 to 1.17.0.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.16.0...v1.17.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-11 18:38:05 -04:00
dependabot[bot]
88cb92745b chore(deps): bump modernc.org/sqlite from 1.24.0 to 1.26.0 (#585)
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.24.0 to 1.26.0.
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.24.0...v1.26.0)

---
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>
Co-authored-by: TwiN <twin@linux.com>
2023-10-10 21:29:13 -04:00
Heitor
744d63abac fix: Support hexadecimal integers in conditions (#563)
* Fix parsing hexadecimal integers in conditions

Remove the assumption that JSON contains only decimal integers and
instead identify the base from the prefix of the data.

`strconv.ParseInt` can identify the base of the integer based on its
"prefix":
- 0x -> hexadecimal
- 0o -> octal
- 0b -> binary
- decimal otherwise.

* core: add more tests for condiction.evaluate

* fix isEqual parsing integers as strings

* tests: extend conditions

* Test if we can compare equality of hex in JSON

* Test if we can have hex/oct/bin in conditions

---------

Co-authored-by: TwiN <twin@linux.com>
2023-10-04 20:25:18 -04:00
TwiN
c1cdf50851 chore(deps): Bump github.com/TwiN/whois to v1.1.7
Fixes 2bd268670f
2023-10-04 20:20:27 -04:00
TwiN
2bd268670f chore(deps): Bump github.com/TwiN/whois to v1.1.7
relevant: #548
2023-10-04 20:17:37 -04:00
Max Partenfelder
e88bfa8518 fix(alerting): Add support for client.insecure in email alerting provider (#583)
* feat: adding client.insecure flag to email configuration

* chore(review): applying suggested changes

---------

Co-authored-by: TwiN <twin@linux.com>
2023-10-02 20:41:12 -04:00
dependabot[bot]
0fa3c5d114 chore(deps): bump github.com/gofiber/fiber/v2 from 2.46.0 to 2.49.2 (#584)
Bumps [github.com/gofiber/fiber/v2](https://github.com/gofiber/fiber) from 2.46.0 to 2.49.2.
- [Release notes](https://github.com/gofiber/fiber/releases)
- [Commits](https://github.com/gofiber/fiber/compare/v2.46.0...v2.49.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-02 20:23:16 -04:00
TwiN
0903d28b56 docs: Clean up list of sponsors 2023-09-30 12:06:43 -04:00
dependabot[bot]
b50f3f3646 chore(deps): bump docker/build-push-action from 4 to 5 (#568)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 4 to 5.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v4...v5)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2023-09-30 08:20:50 -04:00
dependabot[bot]
1b0dfdd09d chore(deps): bump docker/setup-buildx-action from 2 to 3 (#567)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 2 to 3.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v2...v3)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2023-09-30 08:14:39 -04:00
dependabot[bot]
6d3468d81a chore(deps): bump actions/checkout from 3 to 4 (#560)
Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/checkout
  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>
Co-authored-by: TwiN <twin@linux.com>
2023-09-30 08:06:35 -04:00
dependabot[bot]
200d007eca chore(deps): bump github.com/miekg/dns from 1.1.55 to 1.1.56 (#578)
Bumps [github.com/miekg/dns](https://github.com/miekg/dns) from 1.1.55 to 1.1.56.
- [Changelog](https://github.com/miekg/dns/blob/master/Makefile.release)
- [Commits](https://github.com/miekg/dns/compare/v1.1.55...v1.1.56)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2023-09-30 08:03:22 -04:00
wei
24c3a84db9 fix(alerting): add condition results to ntfy (#582) 2023-09-30 07:51:03 -04:00
TwiN
0402bdb774 refactor: Clean up code and improve test coverage 2023-09-28 18:35:18 -04:00
TwiN
c7af44bcb0 fix: Get rid of unnecessary new endpoint 2023-09-28 18:15:14 -04:00
166 changed files with 8282 additions and 2102 deletions

View File

@@ -0,0 +1,29 @@
-----BEGIN CERTIFICATE-----
MIIFBjCCAu6gAwIBAgIUHJXHAqywj2v25AgX7pDSZ+LX4iAwDQYJKoZIhvcNAQEL
BQAwEjEQMA4GA1UEAwwHZXhhbXBsZTAeFw0yNDA0MjUwMTQ1MDFaFw0yOTA0MjQw
MTQ1MDFaMBExDzANBgNVBAMMBmNsaWVudDCCAiIwDQYJKoZIhvcNAQEBBQADggIP
ADCCAgoCggIBANTmRlS5BNG82mOdrhtRPIBD5U40nEW4CVFm85ZJ4Bge4Ty86juf
aoCnI6AEfwpVnJhXPzjUsMBxJFMbiCB+QTJRpxTphtK7orpbwRHjaDZNaLr1MrUO
ieADGiHw93zVDikD8FP5vG+2XWWA56hY84Ac0TR9GqPjsW0nobMgBNgsRtbYUD0B
T5QOItK180xQRn4jbys5jRnr161S+Sbg6mglz1LBFBCLmZnhZFZ8FAn87gumbnWN
etSnu9kX6iOXBIaB+3nuHOL4xmAan8tAyen6mPfkXrE5ogovjqFFMTUJOKQoJVp3
zzm/0XYANxoItFGtdjGMTl5IgI220/6kfpn6PYN7y1kYn5EI+UbobD/CuAhd94p6
aQwOXU53/l+eNH/XnTsL/32QQ6qdq8sYqevlslk1M39kKNewWYCeRzYlCVscQk14
O3fkyXrtRkz30xrzfjvJQ/VzMi+e5UlemsCuCXTVZ5YyBnuWyY+mI6lZICltZSSX
VinKzpz+t4Jl7glhKiGHaNAkBX2oLddyf280zw4Cx7nDMPs4uOHONYpm90IxEOJe
zgJ9YxPK9aaKv2AoYLbvhYyKrVT+TFqoEsbQk4vK0t0Gc1j5z4dET31CSOuxVnnU
LYwtbILFc0uZrbuOAbEbXtjPpw2OGqWagD0QpkE8TjN0Hd0ibyXyUuz5AgMBAAGj
VTBTMBEGA1UdEQQKMAiCBmNsaWVudDAdBgNVHQ4EFgQUleILTHG5lT2RhSe9H4fV
xUh0bNUwHwYDVR0jBBgwFoAUbh9Tg4oxxnHJTSaa0WLBTesYwxEwDQYJKoZIhvcN
AQELBQADggIBABq8zjRrDaljl867MXAlmbV7eJkSnaWRFct+N//jCVNnKMYaxyQm
+UG12xYP0U9Zr9vhsqwyTZTQFx/ZFiiz2zfXPtUAppV3AjE67IlKRbec3qmUhj0H
Rv20eNNWXTl1XTX5WDV5887TF+HLZm/4W2ZSBbS3V89cFhBLosy7HnBGrP0hACne
ZbdQWnnLHJMDKXkZey1H1ZLQQCQdAKGS147firj29M8uzSRHgrR6pvsNQnRT0zDL
TlTJoxyGTMaoj+1IZvRsAYMZCRb8Yct/v2i/ukIykFWUJZ+1Z3UZhGrX+gdhLfZM
jAP4VQ+vFgwD6NEXAA2DatoRqxbN1ZGJQkvnobWJdZDiYu4hBCs8ugKUTE+0iXWt
hSyrAVUspFCIeDN4xsXT5b0j2Ps4bpSAiGx+aDDTPUnd881I6JGCiIavgvdFMLCW
yOXJOZvXcNQwsndkob5fZAEqetjrARsHhQuygEq/LnPc6lWsO8O6UzYArEiKWTMx
N/5hx12Pb7aaQd1f4P3gmmHMb/YiCQK1Qy5d4v68POeqyrLvAHbvCwEMhBAbnLvw
gne3psql8s5wxhnzwYltcBUmmAw1t33CwzRBGEKifRdLGtA9pbua4G/tomcDDjVS
ChsHGebJvNxOnsQqoGgozqM2x8ScxmJzIflGxrKmEA8ybHpU0d02Xp3b
-----END CERTIFICATE-----

View File

@@ -0,0 +1,51 @@
-----BEGIN RSA PRIVATE KEY-----
MIIJKQIBAAKCAgEA1OZGVLkE0bzaY52uG1E8gEPlTjScRbgJUWbzlkngGB7hPLzq
O59qgKcjoAR/ClWcmFc/ONSwwHEkUxuIIH5BMlGnFOmG0ruiulvBEeNoNk1ouvUy
tQ6J4AMaIfD3fNUOKQPwU/m8b7ZdZYDnqFjzgBzRNH0ao+OxbSehsyAE2CxG1thQ
PQFPlA4i0rXzTFBGfiNvKzmNGevXrVL5JuDqaCXPUsEUEIuZmeFkVnwUCfzuC6Zu
dY161Ke72RfqI5cEhoH7ee4c4vjGYBqfy0DJ6fqY9+ResTmiCi+OoUUxNQk4pCgl
WnfPOb/RdgA3Ggi0Ua12MYxOXkiAjbbT/qR+mfo9g3vLWRifkQj5RuhsP8K4CF33
inppDA5dTnf+X540f9edOwv/fZBDqp2ryxip6+WyWTUzf2Qo17BZgJ5HNiUJWxxC
TXg7d+TJeu1GTPfTGvN+O8lD9XMyL57lSV6awK4JdNVnljIGe5bJj6YjqVkgKW1l
JJdWKcrOnP63gmXuCWEqIYdo0CQFfagt13J/bzTPDgLHucMw+zi44c41imb3QjEQ
4l7OAn1jE8r1poq/YChgtu+FjIqtVP5MWqgSxtCTi8rS3QZzWPnPh0RPfUJI67FW
edQtjC1sgsVzS5mtu44BsRte2M+nDY4apZqAPRCmQTxOM3Qd3SJvJfJS7PkCAwEA
AQKCAgAPwAALUStib3aMkLlfpfve1VGyc8FChcySrBYbKS3zOt2Y27T3DOJuesRE
7fA5Yyn+5H1129jo87XR5s3ZnDLV4SUw2THd3H8RCwFWgcdPinHUBZhnEpial5V9
q1DzzY3gSj1OSRcVVfLE3pYaEIflvhFasQ1L0JLAq4I9OSzX5+FPEEOnWmB5Ey6k
/fbuJLDXsLwPAOadDfiFBwgNm0KxdRKdtvugBGPW9s4Fzo9rnxLmjmfKOdmQv96Y
FI/Vat0Cgmfd661RZpbDvKnTpIsLdzw3zTpAIYOzqImvCT+3AmP2qPhSdV3sPMeR
047qqyLZOVxEFXLQFiGvL4uxYUPy8k0ZI9xkgOfZ/uASozMWsHkaD04+UDi1+kw5
nfasZLvOWBW/WE/E1Rfz8IiYTeZbgTnY4CraiLrIRc0LGgD1Df4gNr25+P+LKLyK
/WW89dl6/397HOFnA7CHi7DaA8+9uZAjOWhoCNDdqAVa3QpDD/3/iRiih26bjJfH
2+sarxU8GovDZFxWd59BUP3jkukCFH+CliQy72JtLXiuPNPAWeGV9UXxtIu40sRX
Sax/TQytYi2J9NJFZFMTwVueIfzsWc8dyM+IPAYJQxN94xYKQU4+Rb/wqqHgUfjT
1ZQJb8Cmg56IDY/0EPJWQ0qgnE7TZbY2BOEYbpOzdccwUbcEjQKCAQEA8kVyw4Hw
nqcDWXjzMhOOoRoF8CNwXBvE2KBzpuAioivGcSkjkm8vLGfQYAbDOVMPFt3xlZS0
0lQm894176Kk8BiMqtyPRWWOsv4vYMBTqbehKn09Kbh6lM7d7jO7sh5iWf4jt3Bw
Sk4XhZ9oQ/kpnEKiHPymHQY3pVYEyFCGJ8mdS6g/TWiYmjMjkQDVFA4xkiyJ0S5J
NGYxI+YXtHVTVNSePKvY0h51EqTxsexAphGjXnQ3xoe6e3tVGBkeEkcZlESFD/91
0iqdc5VtKQOwy6Tj4Awk7oK5/u3tfpyIyo31LQIqreTqMO534838lpyp3CbRdvCF
QdCNpKFX1gZgmwKCAQEA4Pa9VKO3Aw95fpp0T81xNi+Js/NhdsvQyv9NI9xOKKQU
hiWxmYmyyna3zliDGlqtlw113JFTNQYl1k1yi4JQPu2gnj8te9nB0yv0RVxvbTOq
u8K1j9Xmj8XVpcKftusQsZ2xu52ONj3ZOOf22wE4Y6mdQcps+rN6XTHRBn7a5b0v
ZCvWf4CIttdIh51pZUIbZKHTU51uU7AhTCY/wEUtiHwYTT9Wiy9Lmay5Lh2s2PCz
yPE5Y970nOzlSCUl3bVgY1t0xbQtaO5AJ/iuw/vNw+YAiAIPNDUcbcK5njb//+0E
uTEtDA6SHeYfsNXGDzxipueKXFHfJLCTXnnT5/1v+wKCAQEA0pF78uNAQJSGe8B9
F3waDnmwyYvzv4q/J00l19edIniLrJUF/uM2DBFa8etOyMchKU3UCJ9MHjbX+EOd
e19QngGoWWUD/VwMkBQPF7dxv+QDZwudGmLl3+qAx+Uc8O4pq3AQmQJYBq0jEpd/
Jv0rpk3f2vPYaQebW8+MrpIWWASK+1QLWPtdD0D9W61uhVTkzth5HF9vbuSXN01o
Mwd6WxPFSJRQCihAtui3zV26vtw7sv+t7pbPhT2nsx85nMdBOzXmtQXi4Lz7RpeM
XgaAJi91g6jqfIcQo7smHVJuLib9/pWQhL2estLBTzUcocced2Mh0Y+xMofSZFF7
J2E5mwKCAQAO9npbUdRPYM0c7ZsE385C42COVobKBv5pMhfoZbPRIjC3R3SLmMwK
iWDqWZrGuvdGz79iH0xgf3suyNHwk4dQ2C9RtzQIQ9CPgiHqJx7GLaSSfn3jBkAi
me7+6nYDDZl7pth2eSFHXE/BaDRUFr2wa0ypXpRnDF78Kd8URoW6uB2Z1QycSGlP
d/w8AO1Mrdvykozix9rZuCJO1VByMme350EaijbwZQHrQ8DBX3nqp//dQqYljWPJ
uDv703S0TWcO1LtslvJaQ1aDEhhVsr7Z48dvRGvMdifg6Q29hzz5wcMJqkqrvaBc
Wr0K3v0gcEzDey0JvOxRnWj/5KyChqnXAoIBAQDq6Dsks6BjVP4Y1HaA/NWcZxUU
EZfNCTA19jIHSUiPbWzWHNdndrUq33HkPorNmFaEIrTqd/viqahr2nXpYiY/7E+V
cpn9eSxot5J8DB4VI92UG9kixxY4K7QTMKvV43Rt6BLosW/cHxW5XTNhB4JDK+TO
NlHH48fUp2qJh7/qwSikDG130RVHKwK/5Fv3NQyXTw1/n9bhnaC4eSvV39CNSeb5
rWNEZcnc9zHT2z1UespzVTxVy4hscrkssXxcCq4bOF4bnDFjfblE43o/KrVr2/Ub
jzpXQrAwXNq7pAkIpin0v40lCeTMosSgQLFqMWmtmlCpBVkyEAc9ZYXc3Vs0
-----END RSA PRIVATE KEY-----

View File

@@ -0,0 +1,29 @@
-----BEGIN CERTIFICATE-----
MIIE9DCCAtygAwIBAgIUCXgA3IbeA2mn8DQ0E5IxaKBLtf8wDQYJKoZIhvcNAQEL
BQAwEjEQMA4GA1UEAwwHZXhhbXBsZTAeFw0yNDA0MjUwMTE5MzRaFw0zNDA0MjMw
MTE5MzRaMBIxEDAOBgNVBAMMB2V4YW1wbGUwggIiMA0GCSqGSIb3DQEBAQUAA4IC
DwAwggIKAoICAQDLE4aTrVJrAVYksFJt5fIVhEJT5T0cLqvtDRf9hXA5Gowremsl
VJPBm4qbdImzJZCfCcbVjFEBw8h9xID1JUqRWjJ8BfTnpa4qc1e+xRtnvC+OsUeT
CCgZvK3TZ5vFsaEbRoNGuiaNq9WSTfjLwTxkK6C3Xogm9uDx73PdRob1TNK5A9mE
Ws3ZyV91+g1phKdlNMRaK+wUrjUjEMLgr0t5A5t6WKefsGrFUDaT3sye3ZxDYuEa
ljt+F8hLVyvkDBAhh6B4S5dQILjp7L3VgOsG7Hx9py1TwCbpWXZEuee/1/2OD8tA
ALsxkvRE1w4AZzLPYRL/dOMllLjROQ4VugU8GVpNU7saK5SeWBw3XHyJ9m8vne3R
cPWaZTfkwfj8NjCgi9BzBPW8/uw7XZMmQFyTj494OKM3T5JQ5jZ5XD97ONm9h+C/
oOmkcWHz6IwEUu7XV5IESxiFlrq8ByAYF98XPhn2wMMrm2OvHMOwrfw2+5U8je5C
z70p9kpiGK8qCyjbOl9im975jwFCbl7LSj3Y+0+vRlTG/JA4jNZhXsMJcAxeJpvr
pmm/IzN+uXNQzmKzBHVDw+mTUMPziRsUq4q6WrcuQFZa6kQFGNYWI/eWV8o4AAvp
HtrOGdSyU19w0QqPW0wHmhsV2XFcn6H/E1Qg6sxWpl45YWJFhNaITxm1EQIDAQAB
o0IwQDAOBgNVHQ8BAf8EBAMCAgQwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU
bh9Tg4oxxnHJTSaa0WLBTesYwxEwDQYJKoZIhvcNAQELBQADggIBAKvOh81Gag0r
0ipYS9aK6rp58b6jPpF6shr3xFiJQVovgSvxNS3aWolh+ZupTCC3H2Q1ZUgatak0
VyEJVO4a7Tz+1XlA6KErhnORC6HB/fgr5KEGraO3Q1uWonPal5QU8xHFStbRaXfx
hl/k4LLhIdJqcJE+XX/AL8ekZ3NPDtf9+k4V+RBuarLGuKgOtBB8+1qjSpClmW2B
DaWPlrLPOr2Sd29WOeWHifwVc6kBGpwM3g5VGdDsNX4Ba5eIG3lX2kUzJ8wNGEf0
bZxcVbTBY+D4JaV4WXoeFmajjK3EdizRpJRZw3fM0ZIeqVYysByNu/TovYLJnBPs
5AybnO4RzYONKJtZ1GtQgJyG+80/VffDJeBmHKEiYvE6mvOFEBAcU4VLU6sfwfT1
y1dZq5G9Km72Fg5kCuYDXTT+PB5VAV3Z6k819tG3TyI4hPlEphpoidRbZ+QS9tK5
RgHah9EJoM7tDAN/mUVHJHQhhLJDBn+iCBYgSJVLwoE+F39NO9oFPD/ZxhJkbk9b
LkFnpjrVbwD1CNnawX3I2Eytg1IbbzyviQIbpSAEpotk9pCLMAxTR3a08wrVMwst
2XVSrgK0uUKsZhCIc+q21k98aeNIINor15humizngyBWYOk8SqV84ZNcD6VlM3Qv
ShSKoAkdKxcGG1+MKPt5b7zqvTo8BBPM
-----END CERTIFICATE-----

View File

@@ -0,0 +1,30 @@
-----BEGIN CERTIFICATE-----
MIIFDjCCAvagAwIBAgITc5Ejz7RzBJ2/PcUMsVhj41RtQDANBgkqhkiG9w0BAQsF
ADASMRAwDgYDVQQDDAdleGFtcGxlMB4XDTI0MDQyNTAxNDQ1N1oXDTI5MDQyNDAx
NDQ1N1owEDEOMAwGA1UEAwwFbmdpbngwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAw
ggIKAoICAQCgbLBnVrBdRkBF2XmJgDTiRqWFPQledzCrkHF4eiUvtEytJhkpoRv2
+SiRPsjCo3XjwcgQIgSy1sHUV8Sazn7V5ux/XBRovhdhUivzI8JSRYj6qwqdUnOy
dG1ZEy/VRLsIVfoFB0jKJrZCXMT256xkYTlsgPePDsduO7IPPrTN0/I/qBvINFet
zgWCl2qlZgF4c/MHljo2TR1KlBv0RJUZbfXPwemUazyMrh/MfQHaHE5pfrmMWFGA
6yLYHEhG+fy5d3F/1+4J24D2j7deIFmmuJMPSlAPt1UjDm7M/bmoTxDG+1MRXSnN
647EzzS0TFZspHe2+yBbw6j0MMiWMzNZX2iXGVcswXwrphe7ro6OITynM76gDTuM
ISYXKYHayqW0rHFRlKxMcnmrpf5tBuK7XKyoQv/LbFKI1e+j1bNVe7OZtC88EWRc
SD8WDLqo/3rsxJkRXRW/49hO1nynHrknXJEpZeRnTyglS+VCzXYD0XzwzPKN7CyN
CHpYpOcWrAMF+EJnE4WRVyJAAt4C1pGhiwn0yCvLEGXXedI/rR5zmUBKitSe7oMT
J82H/VaGtwH0lOD9Jjsv9cb+s1c3tChPDKvgGGDaFnlehKg9TM7p+xc9mnEsitfv
ovSGzYHk29nQu/S4QrPfWuCNwM2vP9OQ+VJyzDzSyH8iuPPmkfmK5wIDAQABo18w
XTAbBgNVHREEFDASggVuZ2lueIIJbG9jYWxob3N0MB0GA1UdDgQWBBT89oboWPBC
oNsSbaNquzrjTza6xDAfBgNVHSMEGDAWgBRuH1ODijHGcclNJprRYsFN6xjDETAN
BgkqhkiG9w0BAQsFAAOCAgEAeg8QwBTne1IGZMDvIGgs95lifzuTXGVQWEid7VVp
MmXGRYsweb0MwTUq3gSUc+3OPibR0i5HCJRR04H4U+cIjR6em1foIV/bW6nTaSls
xQAj92eMmzOo/KtOYqMnk//+Da5NvY0myWa/8FgJ7rK1tOZYiTZqFOlIsaiQMHgp
/PEkZBP5V57h0PY7T7tEj4SCw3DJ6qzzIdpD8T3+9kXd9dcrrjbivBkkJ23agcG5
wBcI862ELNJOD7p7+OFsv7IRsoXXYrydaDg8OJQovh4RccRqVEQu3hZdi7cPb8xJ
G7Gxn8SfSVcPg/UObiggydMl8E8QwqWAzJHvl1KUECd5QG6eq984JTR7zQB2iGb6
1qq+/d9uciuB2YY2h/0rl3Fjy6J6k3fpQK577TlJjZc0F4WH8fW5bcsyGTszxQLI
jQ6FuSOr55lZ9O3R3+95tAdJTrWsxX7j7xMIAXSYrfNt5HM91XNhqISF4SIZOBB6
enVrrJ/oCFqVSbYf6RVQz3XmPEEMh+k9KdwvIvwoS9NivLD3QH0RjhTyzHbf+LlR
rWM46XhmBwajlpnIuuMp6jZcXnbhTO1SheoRVMdijcnW+zrmx5oyn3peCfPqOVLz
95YfJUIFCt+0p/87/0Mm76uVemK6kFKZJQPnfbAdsKF7igPZfUQx6wZZP1qK9ZEU
eOk=
-----END CERTIFICATE-----

View File

@@ -0,0 +1,51 @@
-----BEGIN RSA PRIVATE KEY-----
MIIJKQIBAAKCAgEAoGywZ1awXUZARdl5iYA04kalhT0JXncwq5BxeHolL7RMrSYZ
KaEb9vkokT7IwqN148HIECIEstbB1FfEms5+1ebsf1wUaL4XYVIr8yPCUkWI+qsK
nVJzsnRtWRMv1US7CFX6BQdIyia2QlzE9uesZGE5bID3jw7HbjuyDz60zdPyP6gb
yDRXrc4FgpdqpWYBeHPzB5Y6Nk0dSpQb9ESVGW31z8HplGs8jK4fzH0B2hxOaX65
jFhRgOsi2BxIRvn8uXdxf9fuCduA9o+3XiBZpriTD0pQD7dVIw5uzP25qE8QxvtT
EV0pzeuOxM80tExWbKR3tvsgW8Oo9DDIljMzWV9olxlXLMF8K6YXu66OjiE8pzO+
oA07jCEmFymB2sqltKxxUZSsTHJ5q6X+bQbiu1ysqEL/y2xSiNXvo9WzVXuzmbQv
PBFkXEg/Fgy6qP967MSZEV0Vv+PYTtZ8px65J1yRKWXkZ08oJUvlQs12A9F88Mzy
jewsjQh6WKTnFqwDBfhCZxOFkVciQALeAtaRoYsJ9MgryxBl13nSP60ec5lASorU
nu6DEyfNh/1WhrcB9JTg/SY7L/XG/rNXN7QoTwyr4Bhg2hZ5XoSoPUzO6fsXPZpx
LIrX76L0hs2B5NvZ0Lv0uEKz31rgjcDNrz/TkPlScsw80sh/Irjz5pH5iucCAwEA
AQKCAgADiEEeFV+OvjQ+FXrCl0sSzGFqnJxvMwqkTGrjLzVQZpTlnxggvYZjGrtU
71/2QSkgWazxBf66fVYJOeF/Uxqh1RLR/xIH+F+FagzDrr7hltxcQJXcPuuDO2MI
+g4skPXZSiNWJwHoSY/ryCUiFpnKIAXmqLRKtxWXDMNv6H6MpaUI18e80cI4dnfS
l0jm2Wcg4tSwDxO7DFmfwcEX0MbDp5Mo/ukIto+/vTnAA+Sdi9ACLKMjPvKUdxju
TzkcLvbskn+yQ+ve1bFyPFnaPbYboKbESGuY3P2H5xJzewayeQMyjmgW0slP2mbr
WHCdo6ynebuVENR2kMlQjx5riDcSMMX5TLGPgNL7ZBf2b52mUgFyQb27eO2WXeyH
YLtInlKA44bdi76sDK+s8zYywZnxsUy7xrKhHE5rqz964EfoLRcY/fCm7XnMo6uK
VviBtdPebsMqkZOUKSaYSRpUgXILTud5FD+m68FeVjUvQFQqHYEa3gx+rAIjKBIn
082NzfDZSHVsvG+iB5q+37R8C0/YUzSb3TXys5pA82YsjIFeQiVE4hrV1yeNIZf6
2iaPD/r5H3vt0rFEDINZafC+6bTTRQoq8TOCZFh/Lu+ynXKOPrVUF8/y3sd8+T2v
kRDOL37reUotjE1lbO4RhLgHbeWHlT/PPnF7RDKCe6/erg2MqQKCAQEAy3f8B6I8
7CP4CZmMDWwHWsjMS/HGZgvPPbmWhaeZZmFyYi7I8MruJPhlhlw6YoUIV9Vvp8zE
eLtDvZ5WXuL38aRElWzNyrhrU1/vH4pkaFk+OgRcaleGUof+go0lE8BIYnWoWovo
/F7lQMQmHY4SuwF4oj6dpus7jMm41PQqDTsjofdLgwVAGy30LIkVt8qYha77sL8N
0ohXomDGik0nVa+i2mOJ0UuooGYF8WhujzVcELcerYvvg9kFDqJaEXdfTx4DRwiz
6f5gSbZHME7moqEkcJRtwj8TXSJYRHTI8ngS0xzyV0u2RL3FOxTcgikJIkmU6W3L
IcbP6XVlrCdoswKCAQEAydfBcsYcS2mMqCOdKkGVj6zBriT78/5dtPYeId9WkrnX
1vz6ErjHQ8vZkduvCm3KkijQvva+DFV0sv24qTyA2BIoDUJdk7cY962nR4Q9FHTX
Dkn1kgeKg4TtNdgo2KsIUn7bCibKASCExo6rO3PWiQyF+jTJVDD3rXx7+7N7WJaz
zTVt6BNOWoIjTufdXfRWt3wi0H6sSkqvRWoIAaguXkKXH7oBx0gKs+oAVovFvg7A
LLEtTszsv2LmbpGWaiT3Ny215mA0ZGI9T4utK7oUgd+DlV0+vj5tFfsye4COpCyG
V/ZQ7CBbxHDDak3R3fYy5pOwmh6814wHMyKKfdGm/QKCAQEAiW4Pk3BnyfA5lvJZ
gK9ZAF7kbt9tbHvJjR2Pp9Meb+KeCecj3lCTLfGBUZF19hl5GyqU8jgC9LE3/hm2
qPyREGwtzufg0G5kP7pqn1kwnLK6ryFG8qUPmys0IyYGxyJ3QdnKzu31fpDyNB7I
x+mwiRNjUeMNRTNZ06xk5aHNzYYGeV25aVPgivstE++79ZooDxOz+Rvy0CM7XfgT
4lJeoSeyzeOxsOZzjXObzAUHuD8IYlntpLcCHoI1Qj8yqt2ASMYy3IXqT8B7dQ5j
YyPH8Ez7efcnc656+8s453QiTnP/8wx4O7Jt+FxdnZxnnJrvCnO82zZHoBbTVBLx
i6hKtQKCAQA0j3SWmLRBhwjTuAJzQITb1xbQbF0X2oM4XmbWVzxKFQ75swLD4U4y
f2D2tIhOZOy9RtelAsfWmmI7QgrWNyUuHvxDB6cqkiF0Tcoju3HUY+CknenOzxvo
x7KltNZeJZuTL+mGKTetN3Sb6Ab7Al05bwNsdlZ/EAlPKf13O/PAy+2iYGlwZ6ad
twnOwF5K2xfBzBecx3/CENS3dLcFB3CbpyeHYX6ZEE+JLkRMRTWHGnw8px6vSHnW
FMEAxfSvS1T9D3Awv5ilE1f34N2FZ31znGq9eHygOc1aTgGFW6LJabbKLSBBfOOo
sdyRUBZ4gGYc2RTB7YMrdhFh5Xq+7NtZAoIBAQCOJ3CLecp/rS+lGy7oyx4f6QDd
zH/30Y/uvXLPUj+Ljg9bMTG9chjaKfyApXv6rcQI0d6wrqAunNl1b3opBQjsGCSt
bpBV/rGg3sl752og6KU1PCZ2KkVYPjugNhqPGonNh8tlw+1xFyBdt0c68g/auIHq
WaT5tWVfP01Ri43RjyCgNtJ2TJUzbA40BteDHPWKeM1lZ6e92fJTp5IjQ/Okc41u
Elr7p22fx/N04JTX9G6oGdxM7Gh2Uf4i4PnNOi+C3xqLrtUEi/OLof2UHlatypt9
pix0bXJtZE7WfFfesQIxGffVBhgN3UgqhAf2wquHgm1O17JXrmkR6JSYNpKc
-----END RSA PRIVATE KEY-----

View File

@@ -0,0 +1,13 @@
endpoints:
- name: example
url: https://nginx
interval: 30s
conditions:
- "[STATUS] == 200"
client:
# mtls
insecure: true
tls:
certificate-file: /certs/client.crt
private-key-file: /certs/client.key
renegotiation: once

View File

@@ -0,0 +1,27 @@
version: "3.9"
services:
nginx:
image: nginx:stable
volumes:
- ./certs/server:/etc/nginx/certs
- ./nginx:/etc/nginx/conf.d
ports:
- "8443:443"
networks:
- mtls
gatus:
image: twinproduction/gatus:latest
restart: always
ports:
- "8080:8080"
volumes:
- ./config:/config
- ./certs/client:/certs
environment:
- GATUS_CONFIG_PATH=/config
networks:
- mtls
networks:
mtls:

View File

@@ -0,0 +1,16 @@
server {
listen 443 ssl;
ssl_certificate /etc/nginx/certs/server.crt;
ssl_certificate_key /etc/nginx/certs/server.key;
ssl_client_certificate /etc/nginx/certs/ca.crt;
ssl_verify_client on;
location / {
if ($ssl_client_verify != SUCCESS) {
return 403;
}
root /usr/share/nginx/html;
index index.html index.htm;
}
}

View File

@@ -32,7 +32,7 @@ endpoints:
query-name: "example.com"
query-type: "A"
conditions:
- "[BODY] == 93.184.216.34"
- "[BODY] == 93.184.215.14"
- "[DNS_RCODE] == NOERROR"
- name: icmp-ping

View File

@@ -32,7 +32,7 @@ endpoints:
query-name: "example.com"
query-type: "A"
conditions:
- "[BODY] == 93.184.216.34"
- "[BODY] == 93.184.215.14"
- "[DNS_RCODE] == NOERROR"
- name: icmp-ping

BIN
.github/assets/gitea-alerts.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 KiB

BIN
.github/assets/gotify-alerts.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -7,7 +7,7 @@ updates:
interval: "daily"
- package-ecosystem: "gomod"
directory: "/"
open-pull-requests-limit: 1
open-pull-requests-limit: 3
labels: ["dependencies"]
schedule:
interval: "daily"

View File

@@ -20,11 +20,11 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/setup-go@v4
- uses: actions/setup-go@v5
with:
go-version: 1.19
go-version: 1.22.2
repository: "${{ github.event.inputs.repository || 'TwiN/gatus' }}"
ref: "${{ github.event.inputs.ref || 'master' }}"
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Benchmark
run: go test -bench=. ./storage/store

View File

@@ -5,11 +5,11 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
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
@@ -18,7 +18,7 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
uses: docker/build-push-action@v6
with:
platforms: linux/amd64
pull: true

View File

@@ -16,11 +16,11 @@ jobs:
packages: write
timeout-minutes: 60
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
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
@@ -30,9 +30,9 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
uses: docker/build-push-action@v6
with:
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
platforms: linux/amd64,linux/arm/v7,linux/arm64
pull: true
push: true
tags: ${{ env.IMAGE_REPOSITORY }}:latest

View File

@@ -13,11 +13,11 @@ jobs:
if: ${{ (github.event.workflow_run.conclusion == 'success') && (github.event.workflow_run.head_repository.full_name == github.repository) }}
timeout-minutes: 60
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
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
@@ -26,9 +26,9 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
uses: docker/build-push-action@v6
with:
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
platforms: linux/amd64,linux/arm/v7,linux/arm64
pull: true
push: true
tags: ${{ env.IMAGE_REPOSITORY }}:latest

View File

@@ -10,11 +10,11 @@ jobs:
packages: write
timeout-minutes: 60
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
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
@@ -26,9 +26,9 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
uses: docker/build-push-action@v6
with:
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
platforms: linux/amd64,linux/arm/v7,linux/arm64
pull: true
push: true
tags: |

View File

@@ -8,11 +8,11 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
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: Get the release
@@ -23,9 +23,9 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
uses: docker/build-push-action@v6
with:
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
platforms: linux/amd64,linux/arm/v7,linux/arm64
pull: true
push: true
tags: |

View File

@@ -14,12 +14,12 @@ on:
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 5
timeout-minutes: 10
steps:
- uses: actions/setup-go@v4
- uses: actions/setup-go@v5
with:
go-version: 1.19
- uses: actions/checkout@v3
go-version: 1.22.2
- uses: actions/checkout@v4
- name: Build binary to make sure it works
run: go build
- name: Test
@@ -28,6 +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@v3.1.4
uses: codecov/codecov-action@v4.6.0
with:
files: ./coverage.txt
token: ${{ secrets.CODECOV_TOKEN }}

View File

@@ -3,6 +3,7 @@ FROM golang:alpine as builder
RUN apk --update add ca-certificates
WORKDIR /app
COPY . ./
RUN go mod tidy
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o gatus .
# Run Tests inside docker image if you don't have a configured go environment

View File

@@ -1,17 +1,18 @@
BINARY=gatus
# Because there's a folder called "test", we need to make the target "test" phony
.PHONY: test
.PHONY: install
install:
go build -mod vendor -o $(BINARY) .
go build -v -o $(BINARY) .
.PHONY: run
run:
GATUS_CONFIG_PATH=./config.yaml ./$(BINARY)
.PHONY: clean
clean:
rm $(BINARY)
.PHONY: test
test:
go test ./... -cover

825
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,10 @@
package alert
import (
"crypto/sha256"
"encoding/hex"
"errors"
"strconv"
"strings"
)
@@ -10,7 +13,7 @@ var (
ErrAlertWithInvalidDescription = errors.New("alert description must not have \" or \\")
)
// Alert is a core.Endpoint's alert configuration
// Alert is a endpoint.Endpoint's alert configuration
type Alert struct {
// Type of alert (required)
Type Type `yaml:"type"`
@@ -26,6 +29,9 @@ type Alert struct {
// FailureThreshold is the number of failures in a row needed before triggering the alert
FailureThreshold int `yaml:"failure-threshold"`
// SuccessThreshold defines how many successful executions must happen in a row before an ongoing incident is marked as resolved
SuccessThreshold int `yaml:"success-threshold"`
// Description of the alert. Will be included in the alert sent.
//
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
@@ -38,9 +44,6 @@ type Alert struct {
// or not for provider.ParseWithDefaultAlert to work. Use Alert.IsSendingOnResolved() for a non-pointer
SendOnResolved *bool `yaml:"send-on-resolved"`
// SuccessThreshold defines how many successful executions must happen in a row before an ongoing incident is marked as resolved
SuccessThreshold int `yaml:"success-threshold"`
// ResolveKey is an optional field that is used by some providers (i.e. PagerDuty's dedup_key) to resolve
// ongoing/triggered incidents
ResolveKey string `yaml:"-"`
@@ -71,7 +74,7 @@ func (alert *Alert) ValidateAndSetDefaults() error {
}
// GetDescription retrieves the description of the alert
func (alert Alert) GetDescription() string {
func (alert *Alert) GetDescription() string {
if alert.Description == nil {
return ""
}
@@ -80,7 +83,7 @@ func (alert Alert) GetDescription() string {
// IsEnabled returns whether an alert is enabled or not
// Returns true if not set
func (alert Alert) IsEnabled() bool {
func (alert *Alert) IsEnabled() bool {
if alert.Enabled == nil {
return true
}
@@ -88,9 +91,23 @@ func (alert Alert) IsEnabled() bool {
}
// IsSendingOnResolved returns whether an alert is sending on resolve or not
func (alert Alert) IsSendingOnResolved() bool {
func (alert *Alert) IsSendingOnResolved() bool {
if alert.SendOnResolved == nil {
return false
}
return *alert.SendOnResolved
}
// Checksum returns a checksum of the alert
// Used to determine which persisted triggered alert should be deleted on application start
func (alert *Alert) Checksum() string {
hash := sha256.New()
hash.Write([]byte(string(alert.Type) + "_" +
strconv.FormatBool(alert.IsEnabled()) + "_" +
strconv.FormatBool(alert.IsSendingOnResolved()) + "_" +
strconv.Itoa(alert.SuccessThreshold) + "_" +
strconv.Itoa(alert.FailureThreshold) + "_" +
alert.GetDescription()),
)
return hex.EncodeToString(hash.Sum(nil))
}

View File

@@ -1,6 +1,7 @@
package alert
import (
"errors"
"testing"
)
@@ -38,7 +39,7 @@ func TestAlert_ValidateAndSetDefaults(t *testing.T) {
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
if err := scenario.alert.ValidateAndSetDefaults(); err != scenario.expectedError {
if err := scenario.alert.ValidateAndSetDefaults(); !errors.Is(err, scenario.expectedError) {
t.Errorf("expected error %v, got %v", scenario.expectedError, err)
}
if scenario.alert.SuccessThreshold != scenario.expectedSuccessThreshold {
@@ -52,34 +53,140 @@ func TestAlert_ValidateAndSetDefaults(t *testing.T) {
}
func TestAlert_IsEnabled(t *testing.T) {
if !(Alert{Enabled: nil}).IsEnabled() {
if !(&Alert{Enabled: nil}).IsEnabled() {
t.Error("alert.IsEnabled() should've returned true, because Enabled was set to nil")
}
if value := false; (Alert{Enabled: &value}).IsEnabled() {
if value := false; (&Alert{Enabled: &value}).IsEnabled() {
t.Error("alert.IsEnabled() should've returned false, because Enabled was set to false")
}
if value := true; !(Alert{Enabled: &value}).IsEnabled() {
if value := true; !(&Alert{Enabled: &value}).IsEnabled() {
t.Error("alert.IsEnabled() should've returned true, because Enabled was set to true")
}
}
func TestAlert_GetDescription(t *testing.T) {
if (Alert{Description: nil}).GetDescription() != "" {
if (&Alert{Description: nil}).GetDescription() != "" {
t.Error("alert.GetDescription() should've returned an empty string, because Description was set to nil")
}
if value := "description"; (Alert{Description: &value}).GetDescription() != value {
if value := "description"; (&Alert{Description: &value}).GetDescription() != value {
t.Error("alert.GetDescription() should've returned false, because Description was set to 'description'")
}
}
func TestAlert_IsSendingOnResolved(t *testing.T) {
if (Alert{SendOnResolved: nil}).IsSendingOnResolved() {
if (&Alert{SendOnResolved: nil}).IsSendingOnResolved() {
t.Error("alert.IsSendingOnResolved() should've returned false, because SendOnResolved was set to nil")
}
if value := false; (Alert{SendOnResolved: &value}).IsSendingOnResolved() {
if value := false; (&Alert{SendOnResolved: &value}).IsSendingOnResolved() {
t.Error("alert.IsSendingOnResolved() should've returned false, because SendOnResolved was set to false")
}
if value := true; !(Alert{SendOnResolved: &value}).IsSendingOnResolved() {
if value := true; !(&Alert{SendOnResolved: &value}).IsSendingOnResolved() {
t.Error("alert.IsSendingOnResolved() should've returned true, because SendOnResolved was set to true")
}
}
func TestAlert_Checksum(t *testing.T) {
description1, description2 := "a", "b"
yes, no := true, false
scenarios := []struct {
name string
alert Alert
expected string
}{
{
name: "barebone",
alert: Alert{
Type: TypeDiscord,
},
expected: "fed0580e44ed5701dbba73afa1f14b2c53ca5a7b8067a860441c212916057fe3",
},
{
name: "with-description-1",
alert: Alert{
Type: TypeDiscord,
Description: &description1,
},
expected: "005f407ebe506e74a4aeb46f74c28b376debead7011e1b085da3840f72ba9707",
},
{
name: "with-description-2",
alert: Alert{
Type: TypeDiscord,
Description: &description2,
},
expected: "3c2c4a9570cdc614006993c21f79a860a7f5afea10cf70d1a79d3c49342ef2c8",
},
{
name: "with-description-2-and-enabled-false",
alert: Alert{
Type: TypeDiscord,
Enabled: &no,
Description: &description2,
},
expected: "837945c2b4cd5e961db3e63e10c348d4f1c3446ba68cf5a48e35a1ae22cf0c22",
},
{
name: "with-description-2-and-enabled-true",
alert: Alert{
Type: TypeDiscord,
Enabled: &yes, // it defaults to true if not set, but just to make sure
Description: &description2,
},
expected: "3c2c4a9570cdc614006993c21f79a860a7f5afea10cf70d1a79d3c49342ef2c8",
},
{
name: "with-description-2-and-enabled-true-and-send-on-resolved-true",
alert: Alert{
Type: TypeDiscord,
Enabled: &yes,
SendOnResolved: &yes,
Description: &description2,
},
expected: "bf1436995a880eb4a352c74c5dfee1f1b5ff6b9fc55aef9bf411b3631adfd80c",
},
{
name: "with-description-2-and-failure-threshold-7",
alert: Alert{
Type: TypeSlack,
FailureThreshold: 7,
Description: &description2,
},
expected: "8bd479e18bda393d4c924f5a0d962e825002168dedaa88b445e435db7bacffd3",
},
{
name: "with-description-2-and-failure-threshold-9",
alert: Alert{
Type: TypeSlack,
FailureThreshold: 9,
Description: &description2,
},
expected: "5abdfce5236e344996d264d526e769c07cb0d3d329a999769a1ff84b157ca6f1",
},
{
name: "with-description-2-and-success-threshold-5",
alert: Alert{
Type: TypeSlack,
SuccessThreshold: 7,
Description: &description2,
},
expected: "c0000e73626b80e212cfc24830de7094568f648e37f3e16f9e68c7f8ef75c34c",
},
{
name: "with-description-2-and-success-threshold-1",
alert: Alert{
Type: TypeSlack,
SuccessThreshold: 1,
Description: &description2,
},
expected: "5c28963b3a76104cfa4a0d79c89dd29ec596c8cfa4b1af210ec83d6d41587b5f",
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
scenario.alert.ValidateAndSetDefaults()
if checksum := scenario.alert.Checksum(); checksum != scenario.expected {
t.Errorf("expected checksum %v, got %v", scenario.expected, checksum)
}
})
}
}

View File

@@ -5,6 +5,9 @@ package alert
type Type string
const (
// TypeAWSSES is the Type for the awsses alerting provider
TypeAWSSES Type = "aws-ses"
// TypeCustom is the Type for the custom alerting provider
TypeCustom Type = "custom"
@@ -20,9 +23,18 @@ const (
// TypeGitLab is the Type for the gitlab alerting provider
TypeGitLab Type = "gitlab"
// TypeGitea is the Type for the gitea alerting provider
TypeGitea Type = "gitea"
// TypeGoogleChat is the Type for the googlechat alerting provider
TypeGoogleChat Type = "googlechat"
// TypeGotify is the Type for the gotify alerting provider
TypeGotify Type = "gotify"
// TypeJetBrainsSpace is the Type for the jetbrains alerting provider
TypeJetBrainsSpace Type = "jetbrainsspace"
// TypeMatrix is the Type for the matrix alerting provider
TypeMatrix Type = "matrix"
@@ -50,9 +62,15 @@ const (
// TypeTeams is the Type for the teams alerting provider
TypeTeams Type = "teams"
// TypeTeamsWorkflows is the Type for the teams-workflows alerting provider
TypeTeamsWorkflows Type = "teams-workflows"
// TypeTelegram is the Type for the telegram alerting provider
TypeTelegram Type = "telegram"
// TypeTwilio is the Type for the twilio alerting provider
TypeTwilio Type = "twilio"
// TypeZulip is the Type for the Zulip alerting provider
TypeZulip Type = "zulip"
)

View File

@@ -7,12 +7,16 @@ import (
"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"
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
@@ -22,12 +26,17 @@ 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"
)
// Config is the configuration for alerting providers
type Config struct {
// AWSSimpleEmailService is the configuration for the aws-ses alerting provider
AWSSimpleEmailService *awsses.AlertProvider `yaml:"aws-ses,omitempty"`
// Custom is the configuration for the custom alerting provider
Custom *custom.AlertProvider `yaml:"custom,omitempty"`
@@ -43,9 +52,18 @@ type Config struct {
// GitLab is the configuration for the gitlab alerting provider
GitLab *gitlab.AlertProvider `yaml:"gitlab,omitempty"`
// Gitea is the configuration for the gitea alerting provider
Gitea *gitea.AlertProvider `yaml:"gitea,omitempty"`
// GoogleChat is the configuration for the googlechat alerting provider
GoogleChat *googlechat.AlertProvider `yaml:"googlechat,omitempty"`
// Gotify is the configuration for the gotify alerting provider
Gotify *gotify.AlertProvider `yaml:"gotify,omitempty"`
// JetBrainsSpace is the configuration for the jetbrains space alerting provider
JetBrainsSpace *jetbrainsspace.AlertProvider `yaml:"jetbrainsspace,omitempty"`
// Matrix is the configuration for the matrix alerting provider
Matrix *matrix.AlertProvider `yaml:"matrix,omitempty"`
@@ -73,11 +91,17 @@ type Config struct {
// Teams is the configuration for the teams alerting provider
Teams *teams.AlertProvider `yaml:"teams,omitempty"`
// TeamsWorkflows is the configuration for the teams alerting provider using the new Workflow App Webhook Connector
TeamsWorkflows *teamsworkflows.AlertProvider `yaml:"teams-workflows,omitempty"`
// Telegram is the configuration for the telegram alerting provider
Telegram *telegram.AlertProvider `yaml:"telegram,omitempty"`
// Twilio is the configuration for the twilio alerting provider
Twilio *twilio.AlertProvider `yaml:"twilio,omitempty"`
// Zulip is the configuration for the zulip alerting provider
Zulip *zulip.AlertProvider `yaml:"zulip,omitempty"`
}
// GetAlertingProviderByAlertType returns an provider.AlertProvider by its corresponding alert.Type
@@ -85,7 +109,8 @@ func (config *Config) GetAlertingProviderByAlertType(alertType alert.Type) provi
entityType := reflect.TypeOf(config).Elem()
for i := 0; i < entityType.NumField(); i++ {
field := entityType.Field(i)
if strings.ToLower(field.Name) == string(alertType) {
tag := strings.Split(field.Tag.Get("yaml"), ",")[0]
if tag == string(alertType) {
fieldValue := reflect.ValueOf(config).Elem().Field(i)
if fieldValue.IsNil() {
return nil
@@ -93,7 +118,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)
log.Printf("[alerting.GetAlertingProviderByAlertType] No alerting provider found for alert type %s", alertType)
return nil
}

View File

@@ -0,0 +1,167 @@
package awsses
import (
"fmt"
"strings"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/config/endpoint"
"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"
)
const (
CharSet = "UTF-8"
)
// AlertProvider is the configuration necessary for sending an alert using AWS Simple Email Service
type AlertProvider 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"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
To string `yaml:"to"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.To) == 0 {
return false
}
registeredGroups[override.Group] = true
}
}
// 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))
}
// 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()
if err != nil {
return err
}
svc := ses.New(sess)
subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)
emails := strings.Split(provider.getToForGroup(ep.Group), ",")
input := &ses.SendEmailInput{
Destination: &ses.Destination{
ToAddresses: aws.StringSlice(emails),
},
Message: &ses.Message{
Body: &ses.Body{
Text: &ses.Content{
Charset: aws.String(CharSet),
Data: aws.String(body),
},
},
Subject: &ses.Content{
Charset: aws.String(CharSet),
Data: aws.String(subject),
},
},
Source: aws.String(provider.From),
}
_, err = svc.SendEmail(input)
if err != nil {
if aerr, ok := err.(awserr.Error); ok {
switch aerr.Code() {
case ses.ErrCodeMessageRejected:
fmt.Println(ses.ErrCodeMessageRejected, aerr.Error())
case ses.ErrCodeMailFromDomainNotVerifiedException:
fmt.Println(ses.ErrCodeMailFromDomainNotVerifiedException, aerr.Error())
case ses.ErrCodeConfigurationSetDoesNotExistException:
fmt.Println(ses.ErrCodeConfigurationSetDoesNotExistException, aerr.Error())
default:
fmt.Println(aerr.Error())
}
} else {
// Print the error, cast err to awserr.Error to get the Code and
// Message from an error.
fmt.Println(err.Error())
}
return err
}
return nil
}
// 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
if resolved {
subject = fmt.Sprintf("[%s] Alert resolved", ep.DisplayName())
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else {
subject = fmt.Sprintf("[%s] Alert triggered", ep.DisplayName())
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
}
var formattedConditionResults string
if len(result.ConditionResults) > 0 {
formattedConditionResults = "\n\nCondition results:\n"
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "✅"
} else {
prefix = "❌"
}
formattedConditionResults += fmt.Sprintf("%s %s\n", prefix, conditionResult.Condition)
}
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = "\n\nAlert description: " + alertDescription
}
return subject, message + description + 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),
}
if len(provider.AccessKeyID) > 0 && len(provider.SecretAccessKey) > 0 {
config.Credentials = credentials.NewStaticCredentials(provider.AccessKeyID, provider.SecretAccessKey, "")
}
return session.NewSession(config)
}

View File

@@ -0,0 +1,188 @@
package awsses
import (
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/config/endpoint"
)
func TestAlertDefaultProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{}
if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid")
}
invalidProviderWithOneKey := AlertProvider{From: "from@example.com", To: "to@example.com", AccessKeyID: "1"}
if invalidProviderWithOneKey.IsValid() {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{From: "from@example.com", To: "to@example.com"}
if !validProvider.IsValid() {
t.Error("provider should've been valid")
}
validProviderWithKeys := AlertProvider{From: "from@example.com", To: "to@example.com", AccessKeyID: "1", SecretAccessKey: "1"}
if !validProviderWithKeys.IsValid() {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
To: "to@example.com",
Group: "",
},
},
}
if providerWithInvalidOverrideGroup.IsValid() {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
To: "",
Group: "group",
},
},
}
if providerWithInvalidOverrideTo.IsValid() {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
From: "from@example.com",
To: "to@example.com",
Overrides: []Override{
{
To: "to@example.com",
Group: "group",
},
},
}
if !providerWithValidOverride.IsValid() {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedSubject string
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedSubject: "[endpoint-name] Alert triggered",
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedSubject: "[endpoint-name] Alert resolved",
ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
subject, body := scenario.Provider.buildMessageSubjectAndBody(
&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 subject != scenario.ExpectedSubject {
t.Errorf("expected subject to be %s, got %s", scenario.ExpectedSubject, subject)
}
if body != scenario.ExpectedBody {
t.Errorf("expected body to be %s, got %s", scenario.ExpectedBody, body)
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_getToForGroup(t *testing.T) {
tests := []struct {
Name string
Provider AlertProvider
InputGroup string
ExpectedOutput string
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
To: "to@example.com",
Overrides: nil,
},
InputGroup: "",
ExpectedOutput: "to@example.com",
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
To: "to@example.com",
Overrides: nil,
},
InputGroup: "group",
ExpectedOutput: "to@example.com",
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
To: "to@example.com",
Overrides: []Override{
{
Group: "group",
To: "to01@example.com",
},
},
},
InputGroup: "",
ExpectedOutput: "to@example.com",
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
To: "to@example.com",
Overrides: []Override{
{
Group: "group",
To: "to01@example.com",
},
},
},
InputGroup: "group",
ExpectedOutput: "to01@example.com",
},
}
for _, tt := range tests {
t.Run(tt.Name, func(t *testing.T) {
if got := tt.Provider.getToForGroup(tt.InputGroup); got != tt.ExpectedOutput {
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
}
})
}
}

View File

@@ -9,7 +9,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/config/endpoint"
)
// AlertProvider is the configuration necessary for sending an alert using a custom HTTP request
@@ -50,16 +50,18 @@ func (provider *AlertProvider) GetAlertStatePlaceholderValue(resolved bool) stri
return status
}
func (provider *AlertProvider) buildHTTPRequest(endpoint *core.Endpoint, alert *alert.Alert, resolved bool) *http.Request {
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]", endpoint.Name)
url = strings.ReplaceAll(url, "[ENDPOINT_NAME]", endpoint.Name)
body = strings.ReplaceAll(body, "[ENDPOINT_GROUP]", endpoint.Group)
url = strings.ReplaceAll(url, "[ENDPOINT_GROUP]", endpoint.Group)
body = strings.ReplaceAll(body, "[ENDPOINT_URL]", endpoint.URL)
url = strings.ReplaceAll(url, "[ENDPOINT_URL]", endpoint.URL)
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))
@@ -78,8 +80,8 @@ func (provider *AlertProvider) buildHTTPRequest(endpoint *core.Endpoint, alert *
return request
}
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
request := provider.buildHTTPRequest(endpoint, alert, resolved)
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)
if err != nil {
return err
@@ -93,6 +95,6 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}

View File

@@ -8,7 +8,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
@@ -90,10 +90,10 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"},
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
@@ -138,8 +138,55 @@ 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(
&core.Endpoint{Name: "endpoint-name", Group: "endpoint-group", URL: "https://example.com"},
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group", URL: "https://example.com"},
&alert.Alert{Description: &alertDescription},
&endpoint.Result{Errors: []string{}},
scenario.Resolved,
)
if request.URL.String() != scenario.ExpectedURL {
t.Error("expected URL to be", scenario.ExpectedURL, "got", request.URL.String())
}
body, _ := io.ReadAll(request.Body)
if string(body) != scenario.ExpectedBody {
t.Error("expected body to be", scenario.ExpectedBody, "got", string(body))
}
})
}
}
func TestAlertProviderWithResultErrors_buildHTTPRequest(t *testing.T) {
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]",
}
alertDescription := "alert-description"
scenarios := []struct {
AlertProvider *AlertProvider
Resolved bool
ExpectedURL string
ExpectedBody string
Errors []string
}{
{
AlertProvider: customAlertWithErrorsProvider,
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,
Resolved: false,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=TRIGGERED&description=alert-description&url=https://example.com&error=error1,error2",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,TRIGGERED,error1,error2",
Errors: []string{"error1", "error2"},
},
}
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(
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group", URL: "https://example.com"},
&alert.Alert{Description: &alertDescription},
&endpoint.Result{Errors: scenario.Errors},
scenario.Resolved,
)
if request.URL.String() != scenario.ExpectedURL {
@@ -188,8 +235,9 @@ 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(
&core.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&alert.Alert{Description: &alertDescription},
&endpoint.Result{},
scenario.Resolved,
)
if request.URL.String() != scenario.ExpectedURL {
@@ -217,10 +265,10 @@ func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) {
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}

View File

@@ -9,7 +9,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/config/endpoint"
)
// AlertProvider is the configuration necessary for sending an alert using Discord
@@ -21,6 +21,9 @@ type AlertProvider struct {
// 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
@@ -44,9 +47,9 @@ func (provider *AlertProvider) IsValid() bool {
}
// Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer)
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)
if err != nil {
return err
}
@@ -72,7 +75,7 @@ type Embed struct {
Title string `json:"title"`
Description string `json:"description"`
Color int `json:"color"`
Fields []Field `json:"fields"`
Fields []Field `json:"fields,omitempty"`
}
type Field struct {
@@ -82,16 +85,17 @@ type Field struct {
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
var message, results string
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message string
var colorCode int
if resolved {
message = fmt.Sprintf("An alert for **%s** has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
message = fmt.Sprintf("An alert for **%s** has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
colorCode = 3066993
} else {
message = fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
message = fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
colorCode = 15158332
}
var formattedConditionResults string
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
@@ -99,30 +103,35 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
} else {
prefix = ":x:"
}
results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
formattedConditionResults += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\n> " + alertDescription
}
body, _ := json.Marshal(Body{
title := ":helmet_with_white_cross: Gatus"
if provider.Title != "" {
title = provider.Title
}
body := Body{
Content: "",
Embeds: []Embed{
{
Title: ":helmet_with_white_cross: Gatus",
Title: title,
Description: message + description,
Color: colorCode,
Fields: []Field{
{
Name: "Condition results",
Value: results,
Inline: false,
},
},
},
},
})
return body
}
if len(formattedConditionResults) > 0 {
body.Embeds[0].Fields = append(body.Embeds[0].Fields, Field{
Name: "Condition results",
Value: formattedConditionResults,
Inline: false,
})
}
bodyAsJSON, _ := json.Marshal(body)
return bodyAsJSON
}
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
@@ -138,6 +147,6 @@ func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}

View File

@@ -7,7 +7,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
@@ -63,6 +63,7 @@ func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
title := "provider-title"
scenarios := []struct {
Name string
Provider AlertProvider
@@ -111,15 +112,25 @@ func TestAlertProvider_Send(t *testing.T) {
}),
ExpectedError: true,
},
{
Name: "triggered-with-modified-title",
Provider: AlertProvider{Title: title},
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,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"},
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
@@ -139,10 +150,12 @@ func TestAlertProvider_Send(t *testing.T) {
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
title := "provider-title"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
NoConditions bool
Resolved bool
ExpectedBody string
}{
@@ -160,18 +173,37 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Resolved: true,
ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"description\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"color\":3066993,\"fields\":[{\"name\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n:white_check_mark: - `[BODY] != \\\"\\\"`\\n\",\"inline\":false}]}]}",
},
{
Name: "triggered-with-modified-title",
Provider: AlertProvider{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}]}]}",
},
{
Name: "triggered-with-no-conditions",
NoConditions: true,
Provider: AlertProvider{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}]}",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
var conditionResults []*endpoint.ConditionResult
if !scenario.NoConditions {
conditionResults = []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
{Condition: "[BODY] != \"\"", Success: scenario.Resolved},
}
}
body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"},
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
{Condition: "[BODY] != \"\"", Success: scenario.Resolved},
},
&endpoint.Result{
ConditionResults: conditionResults,
},
scenario.Resolved,
)
@@ -187,10 +219,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}

View File

@@ -1,12 +1,14 @@
package email
import (
"crypto/tls"
"fmt"
"math"
"strings"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
gomail "gopkg.in/mail.v2"
)
@@ -19,6 +21,9 @@ type AlertProvider struct {
Port int `yaml:"port"`
To string `yaml:"to"`
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
@@ -44,51 +49,71 @@ func (provider *AlertProvider) IsValid() bool {
}
}
return len(provider.From) > 0 && len(provider.Password) > 0 && len(provider.Host) > 0 && len(provider.To) > 0 && provider.Port > 0 && provider.Port < math.MaxUint16
return len(provider.From) > 0 && len(provider.Host) > 0 && len(provider.To) > 0 && provider.Port > 0 && provider.Port < math.MaxUint16
}
// Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
var username string
if len(provider.Username) > 0 {
username = provider.Username
} else {
username = provider.From
}
subject, body := provider.buildMessageSubjectAndBody(endpoint, alert, result, resolved)
subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)
m := gomail.NewMessage()
m.SetHeader("From", provider.From)
m.SetHeader("To", strings.Split(provider.getToForGroup(endpoint.Group), ",")...)
m.SetHeader("To", strings.Split(provider.getToForGroup(ep.Group), ",")...)
m.SetHeader("Subject", subject)
m.SetBody("text/plain", body)
d := gomail.NewDialer(provider.Host, provider.Port, username, provider.Password)
var d *gomail.Dialer
if len(provider.Password) == 0 {
// Get the domain in the From address
localName := "localhost"
fromParts := strings.Split(provider.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}
} else {
// Create an authenticated dialer
d = gomail.NewDialer(provider.Host, provider.Port, username, provider.Password)
}
if provider.ClientConfig != nil && provider.ClientConfig.Insecure {
d.TLSConfig = &tls.Config{InsecureSkipVerify: true}
}
return d.DialAndSend(m)
}
// buildMessageSubjectAndBody builds the message subject and body
func (provider *AlertProvider) buildMessageSubjectAndBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) (string, string) {
var subject, message, results string
func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) (string, string) {
var subject, message string
if resolved {
subject = fmt.Sprintf("[%s] Alert resolved", endpoint.DisplayName())
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
subject = fmt.Sprintf("[%s] Alert resolved", ep.DisplayName())
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else {
subject = fmt.Sprintf("[%s] Alert triggered", endpoint.DisplayName())
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
subject = fmt.Sprintf("[%s] Alert triggered", ep.DisplayName())
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
}
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "✅"
} else {
prefix = "❌"
var formattedConditionResults string
if len(result.ConditionResults) > 0 {
formattedConditionResults = "\n\nCondition results:\n"
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "✅"
} else {
prefix = "❌"
}
formattedConditionResults += fmt.Sprintf("%s %s\n", prefix, conditionResult.Condition)
}
results += fmt.Sprintf("%s %s\n", prefix, conditionResult.Condition)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = "\n\nAlert description: " + alertDescription
}
return subject, message + description + "\n\nCondition results:\n" + results
return subject, message + description + formattedConditionResults
}
// getToForGroup returns the appropriate email integration to for a given group
@@ -104,6 +129,6 @@ func (provider *AlertProvider) getToForGroup(group string) string {
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}

View File

@@ -4,7 +4,7 @@ import (
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/config/endpoint"
)
func TestAlertDefaultProvider_IsValid(t *testing.T) {
@@ -18,6 +18,13 @@ func TestAlertDefaultProvider_IsValid(t *testing.T) {
}
}
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() {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
@@ -90,10 +97,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
subject, body := scenario.Provider.buildMessageSubjectAndBody(
&core.Endpoint{Name: "endpoint-name"},
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
@@ -111,10 +118,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}

View File

@@ -0,0 +1,167 @@
package gitea
import (
"crypto/tls"
"fmt"
"net/http"
"net/url"
"strings"
"code.gitea.io/sdk/gitea"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
)
// 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
// 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"`
username string
repositoryOwner string
repositoryName string
giteaClient *gitea.Client
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
if provider.ClientConfig == nil {
provider.ClientConfig = client.GetDefaultConfig()
}
if len(provider.Token) == 0 || len(provider.RepositoryURL) == 0 {
return false
}
// Validate format of the repository URL
repositoryURL, err := url.Parse(provider.RepositoryURL)
if err != nil {
return false
}
baseURL := repositoryURL.Scheme + "://" + repositoryURL.Host
pathParts := strings.Split(repositoryURL.Path, "/")
if len(pathParts) != 3 {
return false
}
provider.repositoryOwner = pathParts[1]
provider.repositoryName = pathParts[2]
opts := []gitea.ClientOption{
gitea.SetToken(provider.Token),
}
if provider.ClientConfig != nil && provider.ClientConfig.Insecure {
// add new http client for skip verify
httpClient := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
opts = append(opts, gitea.SetHTTPClient(httpClient))
}
provider.giteaClient, err = gitea.NewClient(baseURL, opts...)
if err != nil {
return false
}
user, _, err := provider.giteaClient.GetMyUserInfo()
if err != nil {
return false
}
provider.username = user.UserName
return true
}
// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,
// or closes the relevant issue(s) if the resolved parameter passed is true.
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
title := "alert(gatus): " + ep.DisplayName()
if !resolved {
_, _, err := provider.giteaClient.CreateIssue(
provider.repositoryOwner,
provider.repositoryName,
gitea.CreateIssueOption{
Title: title,
Body: provider.buildIssueBody(ep, alert, result),
Assignees: provider.Assignees,
},
)
if err != nil {
return fmt.Errorf("failed to create issue: %w", err)
}
return nil
}
issues, _, err := provider.giteaClient.ListRepoIssues(
provider.repositoryOwner,
provider.repositoryName,
gitea.ListIssueOption{
State: gitea.StateOpen,
CreatedBy: provider.username,
ListOptions: gitea.ListOptions{
Page: 100,
},
},
)
if err != nil {
return fmt.Errorf("failed to list issues: %w", err)
}
for _, issue := range issues {
if issue.Title == title {
stateClosed := gitea.StateClosed
_, _, err = provider.giteaClient.EditIssue(
provider.repositoryOwner,
provider.repositoryName,
issue.ID,
gitea.EditIssueOption{
State: &stateClosed,
},
)
if err != nil {
return fmt.Errorf("failed to close issue: %w", err)
}
}
}
return nil
}
// buildIssueBody builds the body of the issue
func (provider *AlertProvider) buildIssueBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result) string {
var formattedConditionResults string
if len(result.ConditionResults) > 0 {
formattedConditionResults = "\n\n## Condition results\n"
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = ":white_check_mark:"
} else {
prefix = ":x:"
}
formattedConditionResults += fmt.Sprintf("- %s - `%s`\n", prefix, conditionResult.Condition)
}
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\n> " + alertDescription
}
message := fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
return message + description + formattedConditionResults
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}

View File

@@ -0,0 +1,169 @@
package gitea
import (
"net/http"
"strings"
"testing"
"code.gitea.io/sdk/gitea"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertDefaultProvider_IsValid(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
Expected bool
}{
{
Name: "invalid",
Provider: AlertProvider{RepositoryURL: "", Token: ""},
Expected: false,
},
{
Name: "invalid-token",
Provider: AlertProvider{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"},
Expected: false,
},
{
Name: "missing-repository-name",
Provider: AlertProvider{RepositoryURL: "https://gitea.com/TwiN", Token: "12345"},
Expected: false,
},
{
Name: "enterprise-client",
Provider: AlertProvider{RepositoryURL: "https://gitea.example.com/TwiN/test", Token: "12345"},
Expected: false,
},
{
Name: "invalid-url",
Provider: AlertProvider{RepositoryURL: "gitea.com/TwiN/test", Token: "12345"},
Expected: false,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
if scenario.Provider.IsValid() != scenario.Expected {
t.Errorf("expected %t, got %t", scenario.Expected, scenario.Provider.IsValid())
}
})
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered-error",
Provider: AlertProvider{RepositoryURL: "https://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"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
scenario.Provider.giteaClient, _ = gitea.NewClient("https://gitea.com")
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
scenarios := []struct {
Name string
Endpoint endpoint.Endpoint
Provider AlertProvider
Alert alert.Alert
NoConditions bool
ExpectedBody string
}{
{
Name: "triggered",
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 3},
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\n> description-1\n\n## Condition results\n- :white_check_mark: - `[CONNECTED] == true`\n- :x: - `[STATUS] == 200`",
},
{
Name: "triggered-with-no-description",
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{},
Alert: alert.Alert{FailureThreshold: 10},
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 10 time(s) in a row\n\n## Condition results\n- :white_check_mark: - `[CONNECTED] == true`\n- :x: - `[STATUS] == 200`",
},
{
Name: "triggered-with-no-conditions",
NoConditions: true,
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 10},
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 10 time(s) in a row:\n> description-1",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
var conditionResults []*endpoint.ConditionResult
if !scenario.NoConditions {
conditionResults = []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: true},
{Condition: "[STATUS] == 200", Success: false},
}
}
body := scenario.Provider.buildIssueBody(
&scenario.Endpoint,
&scenario.Alert,
&endpoint.Result{ConditionResults: conditionResults},
)
if strings.TrimSpace(body) != strings.TrimSpace(scenario.ExpectedBody) {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}

View File

@@ -8,7 +8,7 @@ import (
"time"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/google/go-github/v48/github"
"golang.org/x/oauth2"
)
@@ -70,12 +70,12 @@ func (provider *AlertProvider) IsValid() bool {
// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,
// or closes the relevant issue(s) if the resolved parameter passed is true.
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
title := "alert(gatus): " + endpoint.DisplayName()
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
title := "alert(gatus): " + ep.DisplayName()
if !resolved {
_, _, err := provider.githubClient.Issues.Create(context.Background(), provider.repositoryOwner, provider.repositoryName, &github.IssueRequest{
Title: github.String(title),
Body: github.String(provider.buildIssueBody(endpoint, alert, result)),
Body: github.String(provider.buildIssueBody(ep, alert, result)),
})
if err != nil {
return fmt.Errorf("failed to create issue: %w", err)
@@ -104,26 +104,29 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
}
// buildIssueBody builds the body of the issue
func (provider *AlertProvider) buildIssueBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result) string {
var results string
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = ":white_check_mark:"
} else {
prefix = ":x:"
func (provider *AlertProvider) buildIssueBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result) string {
var formattedConditionResults string
if len(result.ConditionResults) > 0 {
formattedConditionResults = "\n\n## Condition results\n"
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = ":white_check_mark:"
} else {
prefix = ":x:"
}
formattedConditionResults += fmt.Sprintf("- %s - `%s`\n", prefix, conditionResult.Condition)
}
results += fmt.Sprintf("- %s - `%s`\n", prefix, conditionResult.Condition)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\n> " + alertDescription
}
message := fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
return message + description + "\n\n## Condition results\n" + results
message := fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
return message + description + formattedConditionResults
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}

View File

@@ -7,7 +7,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
"github.com/google/go-github/v48/github"
)
@@ -85,10 +85,10 @@ func TestAlertProvider_Send(t *testing.T) {
scenario.Provider.githubClient = github.NewClient(nil)
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
@@ -109,37 +109,48 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
scenarios := []struct {
Name string
Endpoint core.Endpoint
Endpoint endpoint.Endpoint
Provider AlertProvider
Alert alert.Alert
NoConditions bool
ExpectedBody string
}{
{
Name: "triggered",
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 3},
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\n> description-1\n\n## Condition results\n- :white_check_mark: - `[CONNECTED] == true`\n- :x: - `[STATUS] == 200`",
},
{
Name: "no-description",
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Name: "triggered-with-no-description",
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{},
Alert: alert.Alert{FailureThreshold: 10},
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 10 time(s) in a row\n\n## Condition results\n- :white_check_mark: - `[CONNECTED] == true`\n- :x: - `[STATUS] == 200`",
},
{
Name: "triggered-with-no-conditions",
NoConditions: true,
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 10},
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 10 time(s) in a row:\n> description-1",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
var conditionResults []*endpoint.ConditionResult
if !scenario.NoConditions {
conditionResults = []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: true},
{Condition: "[STATUS] == 200", Success: false},
}
}
body := scenario.Provider.buildIssueBody(
&scenario.Endpoint,
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: true},
{Condition: "[STATUS] == 200", Success: false},
},
},
&endpoint.Result{ConditionResults: conditionResults},
)
if strings.TrimSpace(body) != strings.TrimSpace(scenario.ExpectedBody) {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
@@ -149,10 +160,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}

View File

@@ -11,7 +11,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/google/uuid"
)
@@ -25,10 +25,13 @@ type AlertProvider struct {
// 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"`
}
@@ -48,12 +51,11 @@ func (provider *AlertProvider) IsValid() bool {
// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,
// or closes the relevant issue(s) if the resolved parameter passed is true.
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
if len(alert.ResolveKey) == 0 {
alert.ResolveKey = uuid.NewString()
}
buffer := bytes.NewBuffer(provider.buildAlertBody(endpoint, alert, result, resolved))
buffer := bytes.NewBuffer(provider.buildAlertBody(ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer)
if err != nil {
return err
@@ -92,21 +94,21 @@ func (provider *AlertProvider) monitoringTool() string {
return "gatus"
}
func (provider *AlertProvider) service(endpoint *core.Endpoint) string {
func (provider *AlertProvider) service(ep *endpoint.Endpoint) string {
if len(provider.Service) > 0 {
return provider.Service
}
return endpoint.DisplayName()
return ep.DisplayName()
}
// buildAlertBody builds the body of the alert
func (provider *AlertProvider) buildAlertBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
func (provider *AlertProvider) buildAlertBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
body := AlertBody{
Title: fmt.Sprintf("alert(%s): %s", provider.monitoringTool(), provider.service(endpoint)),
Title: fmt.Sprintf("alert(%s): %s", provider.monitoringTool(), provider.service(ep)),
StartTime: result.Timestamp.Format(time.RFC3339),
Service: provider.service(endpoint),
Service: provider.service(ep),
MonitoringTool: provider.monitoringTool(),
Hosts: endpoint.URL,
Hosts: ep.URL,
GitlabEnvironmentName: provider.EnvironmentName,
Severity: provider.Severity,
Fingerprint: alert.ResolveKey,
@@ -114,16 +116,18 @@ func (provider *AlertProvider) buildAlertBody(endpoint *core.Endpoint, alert *al
if resolved {
body.EndTime = result.Timestamp.Format(time.RFC3339)
}
var results string
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = ":white_check_mark:"
} else {
prefix = ":x:"
var formattedConditionResults string
if len(result.ConditionResults) > 0 {
formattedConditionResults = "\n\n## Condition results\n"
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = ":white_check_mark:"
} else {
prefix = ":x:"
}
formattedConditionResults += fmt.Sprintf("- %s - `%s`\n", prefix, conditionResult.Condition)
}
results += fmt.Sprintf("- %s - `%s`\n", prefix, conditionResult.Condition)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
@@ -131,17 +135,16 @@ func (provider *AlertProvider) buildAlertBody(endpoint *core.Endpoint, alert *al
}
var message string
if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
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", endpoint.DisplayName(), alert.FailureThreshold)
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
}
body.Description = message + description + "\n\n## Condition results\n" + results
json, _ := json.Marshal(body)
return json
body.Description = message + description + formattedConditionResults
bodyAsJSON, _ := json.Marshal(body)
return bodyAsJSON
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}

View File

@@ -7,7 +7,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
@@ -84,10 +84,10 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
@@ -108,21 +108,21 @@ func TestAlertProvider_buildAlertBody(t *testing.T) {
firstDescription := "description-1"
scenarios := []struct {
Name string
Endpoint core.Endpoint
Endpoint endpoint.Endpoint
Provider AlertProvider
Alert alert.Alert
ExpectedBody string
}{
{
Name: "triggered",
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{},
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\"}",
},
{
Name: "no-description",
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{},
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\"}",
@@ -133,8 +133,8 @@ func TestAlertProvider_buildAlertBody(t *testing.T) {
body := scenario.Provider.buildAlertBody(
&scenario.Endpoint,
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: true},
{Condition: "[STATUS] == 200", Success: false},
},
@@ -149,10 +149,10 @@ func TestAlertProvider_buildAlertBody(t *testing.T) {
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}

View File

@@ -9,7 +9,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/config/endpoint"
)
// AlertProvider is the configuration necessary for sending an alert using Google chat
@@ -50,9 +50,9 @@ func (provider *AlertProvider) IsValid() bool {
}
// Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer)
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)
if err != nil {
return err
}
@@ -112,7 +112,7 @@ type OpenLink struct {
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message, color string
if resolved {
color = "#36A64F"
@@ -121,7 +121,7 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
color = "#DD0000"
message = fmt.Sprintf("<font color='%s'>An alert has been triggered due to having failed %d time(s) in a row</font>", color, alert.FailureThreshold)
}
var results string
var formattedConditionResults string
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
@@ -129,7 +129,7 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
} else {
prefix = "❌"
}
results += fmt.Sprintf("%s %s<br>", prefix, conditionResult.Condition)
formattedConditionResults += fmt.Sprintf("%s %s<br>", prefix, conditionResult.Condition)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
@@ -143,28 +143,30 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
Widgets: []Widgets{
{
KeyValue: &KeyValue{
TopLabel: endpoint.DisplayName(),
TopLabel: ep.DisplayName(),
Content: message,
ContentMultiline: "true",
BottomLabel: description,
Icon: "BOOKMARK",
},
},
{
KeyValue: &KeyValue{
TopLabel: "Condition results",
Content: results,
ContentMultiline: "true",
Icon: "DESCRIPTION",
},
},
},
},
},
},
},
}
if endpoint.Type() == core.EndpointTypeHTTP {
if len(formattedConditionResults) > 0 {
payload.Cards[0].Sections[0].Widgets = append(payload.Cards[0].Sections[0].Widgets, Widgets{
KeyValue: &KeyValue{
TopLabel: "Condition results",
Content: formattedConditionResults,
ContentMultiline: "true",
Icon: "DESCRIPTION",
},
})
}
if ep.Type() == endpoint.TypeHTTP {
// We only include a button targeting the URL if the endpoint is an HTTP endpoint
// If the URL isn't prefixed with https://, Google Chat will just display a blank message aynways.
// See https://github.com/TwiN/gatus/issues/362
@@ -173,14 +175,14 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
{
TextButton: TextButton{
Text: "URL",
OnClick: OnClick{OpenLink: OpenLink{URL: endpoint.URL}},
OnClick: OnClick{OpenLink: OpenLink{URL: ep.URL}},
},
},
},
})
}
body, _ := json.Marshal(payload)
return body
bodyAsJSON, _ := json.Marshal(payload)
return bodyAsJSON
}
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
@@ -196,6 +198,6 @@ func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}

View File

@@ -7,7 +7,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
@@ -116,10 +116,10 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
@@ -141,7 +141,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
secondDescription := "description-2"
scenarios := []struct {
Name string
Endpoint core.Endpoint
Endpoint endpoint.Endpoint
Provider AlertProvider
Alert alert.Alert
Resolved bool
@@ -149,7 +149,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{
{
Name: "triggered",
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
@@ -157,7 +157,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
},
{
Name: "resolved",
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
@@ -165,7 +165,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
},
{
Name: "icmp-should-not-include-url", // See https://github.com/TwiN/gatus/issues/362
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "icmp://example.org"},
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "icmp://example.org"},
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
@@ -173,7 +173,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
},
{
Name: "tcp-should-not-include-url", // See https://github.com/TwiN/gatus/issues/362
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "tcp://example.org"},
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "tcp://example.org"},
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
@@ -185,8 +185,8 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&scenario.Endpoint,
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
@@ -205,10 +205,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}

View File

@@ -0,0 +1,106 @@
package gotify
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
)
const DefaultPriority = 5
// 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
// 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
}
// 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)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("failed to send alert to Gotify: %s", string(body))
}
return nil
}
type Body struct {
Message string `json:"message"`
Title string `json:"title"`
Priority int `json:"priority"`
}
// 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
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)
}
var formattedConditionResults string
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 provider.Title != "" {
title = provider.Title
}
bodyAsJSON, _ := json.Marshal(Body{
Message: message,
Title: title,
Priority: provider.Priority,
})
return bodyAsJSON
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}

View File

@@ -0,0 +1,105 @@
package gotify
import (
"encoding/json"
"fmt"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/config/endpoint"
)
func TestAlertProvider_IsValid(t *testing.T) {
scenarios := []struct {
name string
provider AlertProvider
expected bool
}{
{
name: "valid",
provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"},
expected: true,
},
{
name: "invalid-server-url",
provider: AlertProvider{ServerURL: "", Token: "faketoken"},
expected: false,
},
{
name: "invalid-app-token",
provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: ""},
expected: false,
},
{
name: "no-priority-should-use-default-value",
provider: AlertProvider{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())
}
})
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
var (
description = "custom-description"
//title = "custom-title"
endpointName = "custom-endpoint"
)
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{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"},
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"},
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),
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&endpoint.Endpoint{Name: endpointName},
&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())
}
})
}
}

View File

@@ -0,0 +1,162 @@
package jetbrainsspace
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
)
// 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
// 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"`
ChannelID string `yaml:"channel-id"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.ChannelID) == 0 {
return false
}
registeredGroups[override.Group] = true
}
}
return len(provider.Project) > 0 && len(provider.ChannelID) > 0 && len(provider.Token) > 0
}
// 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)
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)
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
type Body struct {
Channel string `json:"channel"`
Content Content `json:"content"`
}
type Content struct {
ClassName string `json:"className"`
Style string `json:"style"`
Sections []Section `json:"sections,omitempty"`
}
type Section struct {
ClassName string `json:"className"`
Elements []Element `json:"elements"`
Header string `json:"header"`
}
type Element struct {
ClassName string `json:"className"`
Accessory Accessory `json:"accessory"`
Style string `json:"style"`
Size string `json:"size"`
Content string `json:"content"`
}
type Accessory struct {
ClassName string `json:"className"`
Icon Icon `json:"icon"`
Style string `json:"style"`
}
type Icon struct {
Icon string `json:"icon"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
body := Body{
Channel: "id:" + provider.getChannelIDForGroup(ep.Group),
Content: Content{
ClassName: "ChatMessage.Block",
Sections: []Section{{
ClassName: "MessageSection",
Elements: []Element{},
}},
},
}
if resolved {
body.Content.Style = "SUCCESS"
body.Content.Sections[0].Header = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else {
body.Content.Style = "WARNING"
body.Content.Sections[0].Header = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
}
for _, conditionResult := range result.ConditionResults {
icon := "warning"
style := "WARNING"
if conditionResult.Success {
icon = "success"
style = "SUCCESS"
}
body.Content.Sections[0].Elements = append(body.Content.Sections[0].Elements, Element{
ClassName: "MessageText",
Accessory: Accessory{
ClassName: "MessageIcon",
Icon: Icon{Icon: icon},
Style: style,
},
Style: style,
Size: "REGULAR",
Content: conditionResult.Condition,
})
}
bodyAsJSON, _ := json.Marshal(body)
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
}

View File

@@ -0,0 +1,279 @@
package jetbrainsspace
import (
"encoding/json"
"net/http"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertDefaultProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{Project: ""}
if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{Project: "foo", ChannelID: "bar", Token: "baz"}
if !validProvider.IsValid() {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Project: "foobar",
Overrides: []Override{
{
ChannelID: "http://example.com",
Group: "",
},
},
}
if providerWithInvalidOverrideGroup.IsValid() {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Project: "foobar",
Overrides: []Override{
{
ChannelID: "",
Group: "group",
},
},
}
if providerWithInvalidOverrideTo.IsValid() {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
Project: "foo",
ChannelID: "bar",
Token: "baz",
Overrides: []Override{
{
ChannelID: "foobar",
Group: "group",
},
},
}
if !providerWithValidOverride.IsValid() {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Endpoint endpoint.Endpoint
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{},
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"}]}}`,
},
{
Name: "triggered-with-group",
Provider: AlertProvider{},
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"}]}}`,
},
{
Name: "resolved",
Provider: AlertProvider{},
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"}]}}`,
},
{
Name: "resolved-with-group",
Provider: AlertProvider{},
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"}]}}`,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&scenario.Endpoint,
&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_getChannelIDForGroup(t *testing.T) {
tests := []struct {
Name string
Provider AlertProvider
InputGroup string
ExpectedOutput string
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
ChannelID: "bar",
},
InputGroup: "",
ExpectedOutput: "bar",
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
ChannelID: "bar",
},
InputGroup: "group",
ExpectedOutput: "bar",
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
ChannelID: "bar",
Overrides: []Override{
{
Group: "group",
ChannelID: "foobar",
},
},
},
InputGroup: "",
ExpectedOutput: "bar",
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
ChannelID: "bar",
Overrides: []Override{
{
Group: "group",
ChannelID: "foobar",
},
},
},
InputGroup: "group",
ExpectedOutput: "foobar",
},
}
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)
}
})
}
}

View File

@@ -12,12 +12,12 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/config/endpoint"
)
// AlertProvider is the configuration necessary for sending an alert using Matrix
type AlertProvider struct {
MatrixProviderConfig `yaml:",inline"`
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"`
@@ -30,12 +30,12 @@ type AlertProvider struct {
type Override struct {
Group string `yaml:"group"`
MatrixProviderConfig `yaml:",inline"`
ProviderConfig `yaml:",inline"`
}
const defaultHomeserverURL = "https://matrix-client.matrix.org"
const defaultServerURL = "https://matrix-client.matrix.org"
type MatrixProviderConfig struct {
type ProviderConfig struct {
// ServerURL is the custom homeserver to use (optional)
ServerURL string `yaml:"server-url"`
@@ -61,11 +61,11 @@ func (provider *AlertProvider) IsValid() bool {
}
// Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
config := provider.getConfigForGroup(endpoint.Group)
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 = defaultHomeserverURL
config.ServerURL = defaultServerURL
}
// The Matrix endpoint requires a unique transaction ID for each event sent
txnId := randStringBytes(24)
@@ -103,24 +103,25 @@ type Body struct {
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
body, _ := json.Marshal(Body{
MsgType: "m.text",
Format: "org.matrix.custom.html",
Body: buildPlaintextMessageBody(endpoint, alert, result, resolved),
FormattedBody: buildHTMLMessageBody(endpoint, alert, result, resolved),
Body: buildPlaintextMessageBody(ep, alert, result, resolved),
FormattedBody: buildHTMLMessageBody(ep, alert, result, resolved),
})
return body
}
// buildPlaintextMessageBody builds the message body in plaintext to include in request
func buildPlaintextMessageBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
var message, results string
func buildPlaintextMessageBody(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", endpoint.DisplayName(), alert.SuccessThreshold)
message = fmt.Sprintf("An alert for `%s` has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else {
message = fmt.Sprintf("An alert for `%s` has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
message = fmt.Sprintf("An alert for `%s` has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
}
var formattedConditionResults string
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
@@ -128,49 +129,54 @@ func buildPlaintextMessageBody(endpoint *core.Endpoint, alert *alert.Alert, resu
} else {
prefix = "✕"
}
results += fmt.Sprintf("\n%s - %s", prefix, conditionResult.Condition)
formattedConditionResults += fmt.Sprintf("\n%s - %s", prefix, conditionResult.Condition)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = "\n" + alertDescription
}
return fmt.Sprintf("%s%s\n%s", message, description, results)
return fmt.Sprintf("%s%s\n%s", message, description, formattedConditionResults)
}
// buildHTMLMessageBody builds the message body in HTML to include in request
func buildHTMLMessageBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
var message, results string
func buildHTMLMessageBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
var message string
if resolved {
message = fmt.Sprintf("An alert for <code>%s</code> has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
message = fmt.Sprintf("An alert for <code>%s</code> has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else {
message = fmt.Sprintf("An alert for <code>%s</code> has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
message = fmt.Sprintf("An alert for <code>%s</code> has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
}
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "✅"
} else {
prefix = "❌"
var formattedConditionResults string
if len(result.ConditionResults) > 0 {
formattedConditionResults = "\n<h5>Condition results</h5><ul>"
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "✅"
} else {
prefix = "❌"
}
formattedConditionResults += fmt.Sprintf("<li>%s - <code>%s</code></li>", prefix, conditionResult.Condition)
}
results += fmt.Sprintf("<li>%s - <code>%s</code></li>", prefix, conditionResult.Condition)
formattedConditionResults += "</ul>"
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = fmt.Sprintf("\n<blockquote>%s</blockquote>", alertDescription)
}
return fmt.Sprintf("<h3>%s</h3>%s\n<h5>Condition results</h5><ul>%s</ul>", message, description, results)
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) MatrixProviderConfig {
func (provider *AlertProvider) getConfigForGroup(group string) ProviderConfig {
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
return override.MatrixProviderConfig
return override.ProviderConfig
}
}
}
return provider.MatrixProviderConfig
return provider.ProviderConfig
}
func randStringBytes(n int) string {
@@ -185,6 +191,6 @@ func randStringBytes(n int) string {
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}

View File

@@ -7,13 +7,13 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{
MatrixProviderConfig: MatrixProviderConfig{
ProviderConfig: ProviderConfig{
AccessToken: "",
InternalRoomID: "",
},
@@ -22,7 +22,7 @@ func TestAlertProvider_IsValid(t *testing.T) {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{
MatrixProviderConfig: MatrixProviderConfig{
ProviderConfig: ProviderConfig{
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
@@ -31,7 +31,7 @@ func TestAlertProvider_IsValid(t *testing.T) {
t.Error("provider should've been valid")
}
validProviderWithHomeserver := AlertProvider{
MatrixProviderConfig: MatrixProviderConfig{
ProviderConfig: ProviderConfig{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
@@ -47,7 +47,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) {
Overrides: []Override{
{
Group: "",
MatrixProviderConfig: MatrixProviderConfig{
ProviderConfig: ProviderConfig{
AccessToken: "",
InternalRoomID: "",
},
@@ -61,7 +61,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) {
Overrides: []Override{
{
Group: "group",
MatrixProviderConfig: MatrixProviderConfig{
ProviderConfig: ProviderConfig{
AccessToken: "",
InternalRoomID: "",
},
@@ -72,14 +72,14 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
MatrixProviderConfig: MatrixProviderConfig{
ProviderConfig: ProviderConfig{
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
Overrides: []Override{
{
Group: "group",
MatrixProviderConfig: MatrixProviderConfig{
ProviderConfig: ProviderConfig{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
@@ -149,10 +149,10 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"},
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
@@ -197,10 +197,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"},
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
@@ -219,10 +219,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
@@ -232,12 +232,12 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
Name string
Provider AlertProvider
InputGroup string
ExpectedOutput MatrixProviderConfig
ExpectedOutput ProviderConfig
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
MatrixProviderConfig: MatrixProviderConfig{
ProviderConfig: ProviderConfig{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
@@ -245,7 +245,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
Overrides: nil,
},
InputGroup: "",
ExpectedOutput: MatrixProviderConfig{
ExpectedOutput: ProviderConfig{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
@@ -254,7 +254,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
MatrixProviderConfig: MatrixProviderConfig{
ProviderConfig: ProviderConfig{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
@@ -262,7 +262,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
Overrides: nil,
},
InputGroup: "group",
ExpectedOutput: MatrixProviderConfig{
ExpectedOutput: ProviderConfig{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
@@ -271,7 +271,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
MatrixProviderConfig: MatrixProviderConfig{
ProviderConfig: ProviderConfig{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
@@ -279,7 +279,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
Overrides: []Override{
{
Group: "group",
MatrixProviderConfig: MatrixProviderConfig{
ProviderConfig: ProviderConfig{
ServerURL: "https://example01.com",
AccessToken: "12",
InternalRoomID: "!a:example01.com",
@@ -288,7 +288,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
},
},
InputGroup: "",
ExpectedOutput: MatrixProviderConfig{
ExpectedOutput: ProviderConfig{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
@@ -297,7 +297,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
MatrixProviderConfig: MatrixProviderConfig{
ProviderConfig: ProviderConfig{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
@@ -305,7 +305,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
Overrides: []Override{
{
Group: "group",
MatrixProviderConfig: MatrixProviderConfig{
ProviderConfig: ProviderConfig{
ServerURL: "https://example01.com",
AccessToken: "12",
InternalRoomID: "!a:example01.com",
@@ -314,7 +314,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
},
},
InputGroup: "group",
ExpectedOutput: MatrixProviderConfig{
ExpectedOutput: ProviderConfig{
ServerURL: "https://example01.com",
AccessToken: "12",
InternalRoomID: "!a:example01.com",

View File

@@ -9,13 +9,16 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/config/endpoint"
)
// 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"`
@@ -50,9 +53,9 @@ func (provider *AlertProvider) IsValid() bool {
}
// Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer)
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)
if err != nil {
return err
}
@@ -70,6 +73,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
}
type Body struct {
Channel string `json:"channel,omitempty"` // Optional channel override
Text string `json:"text"`
Username string `json:"username"`
IconURL string `json:"icon_url"`
@@ -92,29 +96,33 @@ type Field struct {
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
var message, color, results string
func (provider *AlertProvider) buildRequestBody(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", endpoint.DisplayName(), alert.SuccessThreshold)
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
color = "#36A64F"
} else {
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
color = "#DD0000"
}
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = ":white_check_mark:"
} else {
prefix = ":x:"
var formattedConditionResults string
if len(result.ConditionResults) > 0 {
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = ":white_check_mark:"
} else {
prefix = ":x:"
}
formattedConditionResults += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
}
results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\n> " + alertDescription
}
body, _ := json.Marshal(Body{
body := Body{
Channel: provider.Channel,
Text: "",
Username: "gatus",
IconURL: "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png",
@@ -125,17 +133,18 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
Text: message + description,
Short: false,
Color: color,
Fields: []Field{
{
Title: "Condition results",
Value: results,
Short: false,
},
},
},
},
})
return body
}
if len(formattedConditionResults) > 0 {
body.Attachments[0].Fields = append(body.Attachments[0].Fields, Field{
Title: "Condition results",
Value: formattedConditionResults,
Short: false,
})
}
bodyAsJSON, _ := json.Marshal(body)
return bodyAsJSON
}
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
@@ -151,6 +160,6 @@ func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}

View File

@@ -7,7 +7,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
@@ -46,7 +46,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) {
},
}
if providerWithInvalidOverrideWebHookUrl.IsValid() {
t.Error("provider WebHookURL shoudn't have been valid")
t.Error("provider WebHookURL shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
@@ -120,10 +120,10 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"},
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
@@ -168,10 +168,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"},
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
@@ -190,10 +190,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}

View File

@@ -9,7 +9,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/config/endpoint"
)
const (
@@ -33,8 +33,8 @@ func (provider *AlertProvider) IsValid() bool {
// Send an alert using the provider
// Reference doc for messagebird: https://developers.messagebird.com/api/sms-messaging/#send-outbound-sms
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
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, restAPIURL, buffer)
if err != nil {
return err
@@ -60,12 +60,12 @@ type Body struct {
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message string
if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
} else {
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
}
body, _ := json.Marshal(Body{
Originator: provider.Originator,
@@ -76,6 +76,6 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}

View File

@@ -7,7 +7,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
@@ -83,10 +83,10 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"},
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
@@ -131,10 +131,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"},
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
@@ -153,10 +153,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}

View File

@@ -11,7 +11,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/config/endpoint"
)
const (
@@ -21,10 +21,14 @@ const (
// AlertProvider is the configuration necessary for sending an alert using Slack
type AlertProvider struct {
Topic string `yaml:"topic"`
URL string `yaml:"url,omitempty"` // Defaults to DefaultURL
Priority int `yaml:"priority,omitempty"` // Defaults to DefaultPriority
Token string `yaml:"token,omitempty"` // Defaults to ""
Topic string `yaml:"topic"`
URL string `yaml:"url,omitempty"` // Defaults to DefaultURL
Priority int `yaml:"priority,omitempty"` // Defaults to DefaultPriority
Token string `yaml:"token,omitempty"` // Defaults to ""
Email string `yaml:"email,omitempty"` // Defaults to ""
Click string `yaml:"click,omitempty"` // Defaults to ""
DisableFirebase bool `yaml:"disable-firebase,omitempty"` // Defaults to false
DisableCache bool `yaml:"disable-cache,omitempty"` // Defaults to false
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
@@ -46,8 +50,8 @@ func (provider *AlertProvider) IsValid() bool {
}
// Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
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)
if err != nil {
return err
@@ -56,6 +60,12 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
if len(provider.Token) > 0 {
request.Header.Set("Authorization", "Bearer "+provider.Token)
}
if provider.DisableFirebase {
request.Header.Set("Firebase", "no")
}
if provider.DisableCache {
request.Header.Set("Cache", "no")
}
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
@@ -74,32 +84,46 @@ type Body struct {
Message string `json:"message"`
Tags []string `json:"tags"`
Priority int `json:"priority"`
Email string `json:"email,omitempty"`
Click string `json:"click,omitempty"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
var message, tag string
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message, formattedConditionResults, tag string
if resolved {
tag = "white_check_mark"
message = "An alert has been resolved after passing successfully " + strconv.Itoa(alert.SuccessThreshold) + " time(s) in a row"
} else {
tag = "x"
tag = "rotating_light"
message = "An alert has been triggered due to having failed " + strconv.Itoa(alert.FailureThreshold) + " time(s) in a row"
}
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
body, _ := json.Marshal(Body{
Topic: provider.Topic,
Title: "Gatus: " + endpoint.DisplayName(),
Title: "Gatus: " + ep.DisplayName(),
Message: message,
Tags: []string{tag},
Priority: provider.Priority,
Email: provider.Email,
Click: provider.Click,
})
return body
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}

View File

@@ -2,10 +2,13 @@ package ntfy
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/config/endpoint"
)
func TestAlertDefaultProvider_IsValid(t *testing.T) {
@@ -79,23 +82,37 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1","tags":["x"],"priority":1}`,
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},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2","tags":["white_check_mark"],"priority":2}`,
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"},
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"},
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"}`,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"},
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
@@ -112,3 +129,99 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
})
}
}
func TestAlertProvider_Send(t *testing.T) {
description := "description-1"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
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"},
Alert: alert.Alert{Description: &description, 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"}`,
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},
Alert: alert.Alert{Description: &description, 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"}`,
ExpectedHeaders: map[string]string{
"Content-Type": "application/json",
"Firebase": "no",
},
},
{
Name: "no cache",
Provider: AlertProvider{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,
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
ExpectedHeaders: map[string]string{
"Content-Type": "application/json",
"Cache": "no",
},
},
{
Name: "neither firebase & cache",
Provider: AlertProvider{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,
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
ExpectedHeaders: map[string]string{
"Content-Type": "application/json",
"Firebase": "no",
"Cache": "no",
},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
// Start a local HTTP server
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
// Test request parameters
for header, value := range scenario.ExpectedHeaders {
if value != req.Header.Get(header) {
t.Errorf("expected: %s, got: %s", value, req.Header.Get(header))
}
}
body, _ := io.ReadAll(req.Body)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
// Send response to be tested
rw.Write([]byte(`OK`))
}))
// Close the server when test finishes
defer server.Close()
scenario.Provider.URL = server.URL
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 err != nil {
t.Error("Encountered an error on Send: ", err)
}
})
}
}

View File

@@ -11,7 +11,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/config/endpoint"
)
const (
@@ -59,13 +59,13 @@ func (provider *AlertProvider) IsValid() bool {
// Send an alert using the provider
//
// Relevant: https://docs.opsgenie.com/docs/alert-api
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
err := provider.createAlert(endpoint, alert, result, resolved)
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
err := provider.createAlert(ep, alert, result, resolved)
if err != nil {
return err
}
if resolved {
err = provider.closeAlert(endpoint, alert)
err = provider.closeAlert(ep, alert)
if err != nil {
return err
}
@@ -75,20 +75,20 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
// The alert has been resolved and there's no error, so we can clear the alert's ResolveKey
alert.ResolveKey = ""
} else {
alert.ResolveKey = provider.alias(buildKey(endpoint))
alert.ResolveKey = provider.alias(buildKey(ep))
}
}
return nil
}
func (provider *AlertProvider) createAlert(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
payload := provider.buildCreateRequestBody(endpoint, alert, result, resolved)
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) closeAlert(endpoint *core.Endpoint, alert *alert.Alert) error {
payload := provider.buildCloseRequestBody(endpoint, alert)
url := restAPI + "/" + provider.alias(buildKey(endpoint)) + "/close?identifierType=alias"
func (provider *AlertProvider) closeAlert(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)
}
@@ -115,18 +115,19 @@ func (provider *AlertProvider) sendRequest(url, method string, payload interface
return nil
}
func (provider *AlertProvider) buildCreateRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) alertCreateRequest {
var message, description, results string
func (provider *AlertProvider) buildCreateRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) alertCreateRequest {
var message, description string
if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription())
description = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
message = fmt.Sprintf("RESOLVED: %s - %s", ep.Name, alert.GetDescription())
description = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else {
message = fmt.Sprintf("%s - %s", endpoint.Name, alert.GetDescription())
description = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
message = fmt.Sprintf("%s - %s", ep.Name, alert.GetDescription())
description = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
}
if endpoint.Group != "" {
message = fmt.Sprintf("[%s] %s", endpoint.Group, message)
if ep.Group != "" {
message = fmt.Sprintf("[%s] %s", ep.Group, message)
}
var formattedConditionResults string
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
@@ -134,13 +135,13 @@ func (provider *AlertProvider) buildCreateRequestBody(endpoint *core.Endpoint, a
} else {
prefix = "▢"
}
results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
formattedConditionResults += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
}
description = description + "\n" + results
key := buildKey(endpoint)
description = description + "\n" + formattedConditionResults
key := buildKey(ep)
details := map[string]string{
"endpoint:url": endpoint.URL,
"endpoint:group": endpoint.Group,
"endpoint:url": ep.URL,
"endpoint:group": ep.Group,
"result:hostname": result.Hostname,
"result:ip": result.IP,
"result:dns_code": result.DNSRCode,
@@ -166,10 +167,10 @@ func (provider *AlertProvider) buildCreateRequestBody(endpoint *core.Endpoint, a
}
}
func (provider *AlertProvider) buildCloseRequestBody(endpoint *core.Endpoint, alert *alert.Alert) alertCloseRequest {
func (provider *AlertProvider) buildCloseRequestBody(ep *endpoint.Endpoint, alert *alert.Alert) alertCloseRequest {
return alertCloseRequest{
Source: buildKey(endpoint),
Note: fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription()),
Source: buildKey(ep),
Note: fmt.Sprintf("RESOLVED: %s - %s", ep.Name, alert.GetDescription()),
}
}
@@ -206,16 +207,16 @@ func (provider *AlertProvider) priority() string {
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
func buildKey(endpoint *core.Endpoint) string {
name := toKebabCase(endpoint.Name)
if endpoint.Group == "" {
func buildKey(ep *endpoint.Endpoint) string {
name := toKebabCase(ep.Name)
if ep.Group == "" {
return name
}
return toKebabCase(endpoint.Group) + "-" + name
return toKebabCase(ep.Group) + "-" + name
}
func toKebabCase(val string) string {

View File

@@ -7,7 +7,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
@@ -79,10 +79,10 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"},
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
@@ -106,8 +106,8 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
Name string
Provider *AlertProvider
Alert *alert.Alert
Endpoint *core.Endpoint
Result *core.Result
Endpoint *endpoint.Endpoint
Result *endpoint.Result
Resolved bool
want alertCreateRequest
}{
@@ -115,8 +115,8 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
Name: "missing all params (unresolved)",
Provider: &AlertProvider{},
Alert: &alert.Alert{},
Endpoint: &core.Endpoint{},
Result: &core.Result{},
Endpoint: &endpoint.Endpoint{},
Result: &endpoint.Result{},
Resolved: false,
want: alertCreateRequest{
Message: " - ",
@@ -133,8 +133,8 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
Name: "missing all params (resolved)",
Provider: &AlertProvider{},
Alert: &alert.Alert{},
Endpoint: &core.Endpoint{},
Result: &core.Result{},
Endpoint: &endpoint.Endpoint{},
Result: &endpoint.Result{},
Resolved: true,
want: alertCreateRequest{
Message: "RESOLVED: - ",
@@ -154,11 +154,11 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
Description: &description,
FailureThreshold: 3,
},
Endpoint: &core.Endpoint{
Endpoint: &endpoint.Endpoint{
Name: "my super app",
},
Result: &core.Result{
ConditionResults: []*core.ConditionResult{
Result: &endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: true,
@@ -194,11 +194,11 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
Description: &description,
SuccessThreshold: 4,
},
Endpoint: &core.Endpoint{
Endpoint: &endpoint.Endpoint{
Name: "my mega app",
},
Result: &core.Result{
ConditionResults: []*core.ConditionResult{
Result: &endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: true,
@@ -226,17 +226,17 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
Description: &description,
FailureThreshold: 6,
},
Endpoint: &core.Endpoint{
Endpoint: &endpoint.Endpoint{
Name: "my app",
Group: "end game",
URL: "https://my.go/app",
},
Result: &core.Result{
Result: &endpoint.Result{
HTTPStatus: 400,
Hostname: "my.go",
Errors: []string{"error 01", "error 02"},
Success: false,
ConditionResults: []*core.ConditionResult{
ConditionResults: []*endpoint.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: false,
@@ -279,14 +279,14 @@ func TestAlertProvider_buildCloseRequestBody(t *testing.T) {
Name string
Provider *AlertProvider
Alert *alert.Alert
Endpoint *core.Endpoint
Endpoint *endpoint.Endpoint
want alertCloseRequest
}{
{
Name: "Missing all values",
Provider: &AlertProvider{},
Alert: &alert.Alert{},
Endpoint: &core.Endpoint{},
Endpoint: &endpoint.Endpoint{},
want: alertCloseRequest{
Source: "",
Note: "RESOLVED: - ",
@@ -298,7 +298,7 @@ func TestAlertProvider_buildCloseRequestBody(t *testing.T) {
Alert: &alert.Alert{
Description: &description,
},
Endpoint: &core.Endpoint{
Endpoint: &endpoint.Endpoint{
Name: "endpoint name",
},
want: alertCloseRequest{

View File

@@ -10,7 +10,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/config/endpoint"
)
const (
@@ -52,8 +52,8 @@ func (provider *AlertProvider) IsValid() bool {
// Send an alert using the provider
//
// Relevant: https://developer.pagerduty.com/docs/events-api-v2/trigger-events/
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
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, restAPIURL, buffer)
if err != nil {
return err
@@ -78,7 +78,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
var payload pagerDutyResponsePayload
if err = json.Unmarshal(body, &payload); err != nil {
// Silently fail. We don't want to create tons of alerts just because we failed to parse the body.
log.Printf("[pagerduty][Send] Ran into error unmarshaling pagerduty response: %s", err.Error())
log.Printf("[pagerduty.Send] Ran into error unmarshaling pagerduty response: %s", err.Error())
} else {
alert.ResolveKey = payload.DedupKey
}
@@ -101,19 +101,19 @@ type Payload struct {
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message, eventAction, resolveKey string
if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
eventAction = "resolve"
resolveKey = alert.ResolveKey
} else {
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
eventAction = "trigger"
resolveKey = ""
}
body, _ := json.Marshal(Body{
RoutingKey: provider.getIntegrationKeyForGroup(endpoint.Group),
RoutingKey: provider.getIntegrationKeyForGroup(ep.Group),
DedupKey: resolveKey,
EventAction: eventAction,
Payload: Payload{
@@ -138,7 +138,7 @@ func (provider *AlertProvider) getIntegrationKeyForGroup(group string) string {
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}

View File

@@ -7,7 +7,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
@@ -115,10 +115,10 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"},
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
@@ -161,7 +161,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(&core.Endpoint{Name: "endpoint-name"}, &scenario.Alert, &core.Result{}, scenario.Resolved)
body := scenario.Provider.buildRequestBody(&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)
}
@@ -237,10 +237,10 @@ func TestAlertProvider_getIntegrationKeyForGroup(t *testing.T) {
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}

View File

@@ -2,12 +2,15 @@ package provider
import (
"github.com/TwiN/gatus/v5/alerting/alert"
"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/jetbrainsspace"
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
@@ -17,12 +20,14 @@ 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/core"
"github.com/TwiN/gatus/v5/alerting/provider/zulip"
"github.com/TwiN/gatus/v5/config/endpoint"
)
// AlertProvider is the interface that each providers should implement
// AlertProvider is the interface that each provider should implement
type AlertProvider interface {
// IsValid returns whether the provider's configuration is valid
IsValid() bool
@@ -31,7 +36,7 @@ type AlertProvider interface {
GetDefaultAlert() *alert.Alert
// Send an alert using the provider
Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error
Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error
}
// ParseWithDefaultAlert parses an Endpoint alert by using the provider's default alert as a baseline
@@ -58,12 +63,15 @@ func ParseWithDefaultAlert(providerDefaultAlert, endpointAlert *alert.Alert) {
var (
// Validate interface implementation on compile
_ AlertProvider = (*awsses.AlertProvider)(nil)
_ AlertProvider = (*custom.AlertProvider)(nil)
_ AlertProvider = (*discord.AlertProvider)(nil)
_ AlertProvider = (*email.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)
_ AlertProvider = (*mattermost.AlertProvider)(nil)
_ AlertProvider = (*messagebird.AlertProvider)(nil)
@@ -73,6 +81,8 @@ var (
_ AlertProvider = (*pushover.AlertProvider)(nil)
_ AlertProvider = (*slack.AlertProvider)(nil)
_ AlertProvider = (*teams.AlertProvider)(nil)
_ AlertProvider = (*teamsworkflows.AlertProvider)(nil)
_ AlertProvider = (*telegram.AlertProvider)(nil)
_ AlertProvider = (*twilio.AlertProvider)(nil)
_ AlertProvider = (*zulip.AlertProvider)(nil)
)

View File

@@ -9,7 +9,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/config/endpoint"
)
const (
@@ -52,8 +52,8 @@ func (provider *AlertProvider) IsValid() bool {
// Send an alert using the provider
// Reference doc for pushover: https://pushover.net/api
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
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, restAPIURL, buffer)
if err != nil {
return err
@@ -81,12 +81,12 @@ type Body struct {
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message string
if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
} else {
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
}
body, _ := json.Marshal(Body{
Token: provider.ApplicationToken,
@@ -107,6 +107,6 @@ func (provider *AlertProvider) priority() int {
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}

View File

@@ -7,7 +7,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
@@ -95,10 +95,10 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"},
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
@@ -150,10 +150,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"},
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
@@ -172,10 +172,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}

View File

@@ -9,7 +9,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/config/endpoint"
)
// AlertProvider is the configuration necessary for sending an alert using Slack
@@ -42,9 +42,9 @@ func (provider *AlertProvider) IsValid() bool {
}
// Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer)
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)
if err != nil {
return err
}
@@ -71,7 +71,7 @@ type Attachment struct {
Text string `json:"text"`
Short bool `json:"short"`
Color string `json:"color"`
Fields []Field `json:"fields"`
Fields []Field `json:"fields,omitempty"`
}
type Field struct {
@@ -81,15 +81,16 @@ type Field struct {
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
var message, color, results string
func (provider *AlertProvider) buildRequestBody(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", endpoint.DisplayName(), alert.SuccessThreshold)
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
color = "#36A64F"
} else {
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
color = "#DD0000"
}
var formattedConditionResults string
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
@@ -97,13 +98,13 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
} else {
prefix = ":x:"
}
results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
formattedConditionResults += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\n> " + alertDescription
}
body, _ := json.Marshal(Body{
body := Body{
Text: "",
Attachments: []Attachment{
{
@@ -111,17 +112,18 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
Text: message + description,
Short: false,
Color: color,
Fields: []Field{
{
Title: "Condition results",
Value: results,
Short: false,
},
},
},
},
})
return body
}
if len(formattedConditionResults) > 0 {
body.Attachments[0].Fields = append(body.Attachments[0].Fields, Field{
Title: "Condition results",
Value: formattedConditionResults,
Short: false,
})
}
bodyAsJSON, _ := json.Marshal(body)
return bodyAsJSON
}
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
@@ -137,6 +139,6 @@ func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}

View File

@@ -7,7 +7,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
@@ -116,10 +116,10 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"},
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
@@ -142,15 +142,16 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
Endpoint core.Endpoint
Endpoint endpoint.Endpoint
Alert alert.Alert
NoConditions bool
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{},
Endpoint: core.Endpoint{Name: "name"},
Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"short\":false,\"color\":\"#DD0000\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
@@ -158,15 +159,24 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
{
Name: "triggered-with-group",
Provider: AlertProvider{},
Endpoint: core.Endpoint{Name: "name", Group: "group"},
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *group/name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"short\":false,\"color\":\"#DD0000\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
},
{
Name: "triggered-with-no-conditions",
NoConditions: true,
Provider: AlertProvider{},
Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"short\":false,\"color\":\"#DD0000\"}]}",
},
{
Name: "resolved",
Provider: AlertProvider{},
Endpoint: core.Endpoint{Name: "name"},
Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
@@ -174,7 +184,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
{
Name: "resolved-with-group",
Provider: AlertProvider{},
Endpoint: core.Endpoint{Name: "name", Group: "group"},
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
@@ -182,14 +192,18 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
var conditionResults []*endpoint.ConditionResult
if !scenario.NoConditions {
conditionResults = []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
}
}
body := scenario.Provider.buildRequestBody(
&scenario.Endpoint,
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
&endpoint.Result{
ConditionResults: conditionResults,
},
scenario.Resolved,
)
@@ -205,10 +219,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}

View File

@@ -9,7 +9,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/config/endpoint"
)
// AlertProvider is the configuration necessary for sending an alert using Teams
@@ -19,8 +19,14 @@ type AlertProvider struct {
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// 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
@@ -44,14 +50,14 @@ func (provider *AlertProvider) IsValid() bool {
}
// Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer)
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)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
if err != nil {
return err
}
@@ -69,7 +75,7 @@ type Body struct {
ThemeColor string `json:"themeColor"`
Title string `json:"title"`
Text string `json:"text"`
Sections []Section `json:"sections"`
Sections []Section `json:"sections,omitempty"`
}
type Section struct {
@@ -78,16 +84,16 @@ type Section struct {
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message, color string
if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
color = "#36A64F"
} else {
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
color = "#DD0000"
}
var results string
var formattedConditionResults string
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
@@ -95,26 +101,30 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
} else {
prefix = "&#x274C;"
}
results += fmt.Sprintf("%s - `%s`<br/>", prefix, conditionResult.Condition)
formattedConditionResults += fmt.Sprintf("%s - `%s`<br/>", prefix, conditionResult.Condition)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ": " + alertDescription
}
body, _ := json.Marshal(Body{
body := Body{
Type: "MessageCard",
Context: "http://schema.org/extensions",
ThemeColor: color,
Title: "&#x1F6A8; Gatus",
Title: provider.Title,
Text: message + description,
Sections: []Section{
{
ActivityTitle: "Condition results",
Text: results,
},
},
})
return body
}
if len(body.Title) == 0 {
body.Title = "&#x1F6A8; Gatus"
}
if len(formattedConditionResults) > 0 {
body.Sections = append(body.Sections, Section{
ActivityTitle: "Condition results",
Text: formattedConditionResults,
})
}
bodyAsJSON, _ := json.Marshal(body)
return bodyAsJSON
}
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
@@ -130,6 +140,6 @@ func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}

View File

@@ -7,7 +7,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
@@ -116,10 +116,10 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"},
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
@@ -143,6 +143,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Name string
Provider AlertProvider
Alert alert.Alert
NoConditions bool
Resolved bool
ExpectedBody string
}{
@@ -160,18 +161,28 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Resolved: true,
ExpectedBody: "{\"@type\":\"MessageCard\",\"@context\":\"http://schema.org/extensions\",\"themeColor\":\"#36A64F\",\"title\":\"\\u0026#x1F6A8; Gatus\",\"text\":\"An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row: description-2\",\"sections\":[{\"activityTitle\":\"Condition results\",\"text\":\"\\u0026#x2705; - `[CONNECTED] == true`\\u003cbr/\\u003e\\u0026#x2705; - `[STATUS] == 200`\\u003cbr/\\u003e\"}]}",
},
{
Name: "resolved-with-no-conditions",
NoConditions: true,
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"@type\":\"MessageCard\",\"@context\":\"http://schema.org/extensions\",\"themeColor\":\"#36A64F\",\"title\":\"\\u0026#x1F6A8; Gatus\",\"text\":\"An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row: description-2\"}",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
var conditionResults []*endpoint.ConditionResult
if !scenario.NoConditions {
conditionResults = []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
}
}
body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"},
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
&endpoint.Result{ConditionResults: conditionResults},
scenario.Resolved,
)
if string(body) != scenario.ExpectedBody {
@@ -186,10 +197,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}

View File

@@ -0,0 +1,182 @@
package teamsworkflows
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
)
// AlertProvider is the configuration necessary for sending an alert using Teams
type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
// 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"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
return false
}
registeredGroups[override.Group] = true
}
}
return len(provider.WebhookURL) > 0
}
// Send an alert using the provider
func (provider *AlertProvider) Send(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)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
// AdaptiveCardBody represents the structure of an Adaptive Card
type AdaptiveCardBody struct {
Type string `json:"type"`
Version string `json:"version"`
Body []CardBody `json:"body"`
}
// CardBody represents the body of the Adaptive Card
type CardBody struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
Wrap bool `json:"wrap"`
Separator bool `json:"separator,omitempty"`
Size string `json:"size,omitempty"`
Weight string `json:"weight,omitempty"`
Items []CardBody `json:"items,omitempty"`
Facts []Fact `json:"facts,omitempty"`
FactSet *FactSetBody `json:"factSet,omitempty"`
}
// FactSetBody represents the FactSet in the Adaptive Card
type FactSetBody struct {
Type string `json:"type"`
Facts []Fact `json:"facts"`
}
// Fact represents an individual fact in the FactSet
type Fact struct {
Title string `json:"title"`
Value string `json:"value"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(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)
} 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)
}
// Configure default title if it's not provided
title := "&#x26D1; Gatus"
if provider.Title != "" {
title = provider.Title
}
// Build the facts from the condition results
var facts []Fact
for _, conditionResult := range result.ConditionResults {
var key string
if conditionResult.Success {
key = "&#x2705;"
} else {
key = "&#x274C;"
}
facts = append(facts, Fact{
Title: key,
Value: conditionResult.Condition,
})
}
cardContent := AdaptiveCardBody{
Type: "AdaptiveCard",
Version: "1.4", // Version 1.5 and 1.6 doesn't seem to be supported by Teams as of 27/08/2024
Body: []CardBody{
{
Type: "TextBlock",
Text: title,
Size: "Medium",
Weight: "Bolder",
},
{
Type: "TextBlock",
Text: message,
Wrap: true,
},
{
Type: "FactSet",
Facts: facts,
},
},
}
attachment := map[string]interface{}{
"contentType": "application/vnd.microsoft.card.adaptive",
"content": cardContent,
}
payload := map[string]interface{}{
"type": "message",
"attachments": []interface{}{attachment},
}
bodyAsJSON, _ := json.Marshal(payload)
return bodyAsJSON
}
// 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
}

View File

@@ -0,0 +1,269 @@
package teamsworkflows
import (
"encoding/json"
"net/http"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertDefaultProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{WebhookURL: ""}
if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{WebhookURL: "http://example.com"}
if !validProvider.IsValid() {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
WebhookURL: "http://example.com",
Group: "",
},
},
}
if providerWithInvalidOverrideGroup.IsValid() {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
WebhookURL: "",
Group: "group",
},
},
}
if providerWithInvalidOverrideTo.IsValid() {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
WebhookURL: "http://example.com",
Overrides: []Override{
{
WebhookURL: "http://example.com",
Group: "group",
},
},
}
if !providerWithValidOverride.IsValid() {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
NoConditions bool
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"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\"}",
},
{
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\"}",
},
{
Name: "resolved-with-no-conditions",
NoConditions: true,
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"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\"}",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
var conditionResults []*endpoint.ConditionResult
if !scenario.NoConditions {
conditionResults = []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
}
}
body := scenario.Provider.buildRequestBody(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{ConditionResults: conditionResults},
scenario.Resolved,
)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
tests := []struct {
Name string
Provider AlertProvider
InputGroup string
ExpectedOutput string
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: nil,
},
InputGroup: "",
ExpectedOutput: "http://example.com",
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: nil,
},
InputGroup: "group",
ExpectedOutput: "http://example.com",
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: []Override{
{
Group: "group",
WebhookURL: "http://example01.com",
},
},
},
InputGroup: "",
ExpectedOutput: "http://example.com",
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: []Override{
{
Group: "group",
WebhookURL: "http://example01.com",
},
},
},
InputGroup: "group",
ExpectedOutput: "http://example01.com",
},
}
for _, tt := range tests {
t.Run(tt.Name, func(t *testing.T) {
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
}
})
}
}

View File

@@ -9,7 +9,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/config/endpoint"
)
const defaultAPIURL = "https://api.telegram.org"
@@ -25,6 +25,16 @@ type AlertProvider struct {
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Overrid 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"`
}
// IsValid returns whether the provider's configuration is valid
@@ -32,17 +42,29 @@ 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
}
if _, ok := registerGroups[override.group]; ok {
return false
}
registerGroups[override.group] = true
}
return len(provider.Token) > 0 && len(provider.ID) > 0
}
// Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
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
}
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/bot%s/sendMessage", apiURL, provider.Token), buffer)
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/bot%s/sendMessage", apiURL, provider.getTokenForGroup(ep.Group)), buffer)
if err != nil {
return err
}
@@ -59,6 +81,15 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
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"`
@@ -66,37 +97,50 @@ type Body struct {
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
var message, results string
func (provider *AlertProvider) buildRequestBody(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— ", endpoint.DisplayName(), alert.SuccessThreshold)
message = fmt.Sprintf("An alert for *%s* has been resolved:\n—\n _healthcheck passing successfully %d time(s) in a row_\n— ", ep.DisplayName(), alert.SuccessThreshold)
} else {
message = fmt.Sprintf("An alert for *%s* has been triggered:\n—\n _healthcheck failed %d time(s) in a row_\n— ", endpoint.DisplayName(), alert.FailureThreshold)
message = fmt.Sprintf("An alert for *%s* has been triggered:\n—\n _healthcheck failed %d time(s) in a row_\n— ", ep.DisplayName(), alert.FailureThreshold)
}
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "✅"
} else {
prefix = "❌"
var formattedConditionResults string
if len(result.ConditionResults) > 0 {
formattedConditionResults = "\n*Condition results*\n"
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "✅"
} else {
prefix = "❌"
}
formattedConditionResults += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
}
results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
}
var text string
if len(alert.GetDescription()) > 0 {
text = fmt.Sprintf("⛑ *Gatus* \n%s \n*Description* \n_%s_ \n\n*Condition results*\n%s", message, alert.GetDescription(), results)
text = fmt.Sprintf("⛑ *Gatus* \n%s \n*Description* \n_%s_ \n%s", message, alert.GetDescription(), formattedConditionResults)
} else {
text = fmt.Sprintf("⛑ *Gatus* \n%s \n*Condition results*\n%s", message, results)
text = fmt.Sprintf("⛑ *Gatus* \n%s%s", message, formattedConditionResults)
}
body, _ := json.Marshal(Body{
ChatID: provider.ID,
bodyAsJSON, _ := json.Marshal(Body{
ChatID: provider.getIDForGroup(ep.Group),
Text: text,
ParseMode: "MARKDOWN",
})
return body
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 {
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}

View File

@@ -7,11 +7,11 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_IsValid(t *testing.T) {
func TestAlertDefaultProvider_IsValid(t *testing.T) {
t.Run("invalid-provider", func(t *testing.T) {
invalidProvider := AlertProvider{Token: "", ID: ""}
if invalidProvider.IsValid() {
@@ -32,6 +32,69 @@ func TestAlertProvider_IsValid(t *testing.T) {
})
}
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() {
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() {
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.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")
}
})
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
@@ -89,10 +152,10 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"},
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
@@ -116,6 +179,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Name string
Provider AlertProvider
Alert alert.Alert
NoConditions bool
Resolved bool
ExpectedBody string
}{
@@ -133,18 +197,28 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
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\"}",
},
{
Name: "resolved-with-no-conditions",
NoConditions: true,
Provider: AlertProvider{ID: "123"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 5 time(s) in a row_\\n— \\n*Description* \\n_description-2_ \\n\",\"parse_mode\":\"MARKDOWN\"}",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
var conditionResults []*endpoint.ConditionResult
if !scenario.NoConditions {
conditionResults = []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
}
}
body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"},
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
&endpoint.Result{ConditionResults: conditionResults},
scenario.Resolved,
)
if string(body) != scenario.ExpectedBody {
@@ -159,10 +233,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}

View File

@@ -10,7 +10,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/config/endpoint"
)
// AlertProvider is the configuration necessary for sending an alert using Twilio
@@ -30,8 +30,8 @@ func (provider *AlertProvider) IsValid() bool {
}
// Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
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)
if err != nil {
return err
@@ -51,12 +51,12 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
var message string
if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
} else {
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
}
return url.Values{
"To": {provider.To},
@@ -66,6 +66,6 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}

View File

@@ -4,7 +4,7 @@ import (
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/config/endpoint"
)
func TestTwilioAlertProvider_IsValid(t *testing.T) {
@@ -51,10 +51,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"},
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
@@ -69,10 +69,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}

View File

@@ -0,0 +1,132 @@
package zulip
import (
"bytes"
"fmt"
"io"
"net/http"
"net/url"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
)
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"`
}
// AlertProvider is the configuration necessary for sending an alert using Zulip
type AlertProvider struct {
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"`
}
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 {
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
}
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()
}
// 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)
request, err := http.NewRequest(http.MethodPost, zulipEndpoint, buffer)
if err != nil {
return err
}
request.SetBasicAuth(provider.BotEmail, provider.BotAPIKey)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("User-Agent", "Gatus")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return nil
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}

View File

@@ -0,0 +1,488 @@
package zulip
import (
"fmt"
"net/http"
"net/url"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_IsValid(t *testing.T) {
testCase := []struct {
name string
alertProvider AlertProvider
expected bool
}{
{
name: "Empty provider",
alertProvider: AlertProvider{},
expected: false,
},
{
name: "Empty channel id",
alertProvider: AlertProvider{
Config: Config{
BotEmail: "something",
BotAPIKey: "something",
Domain: "something",
},
},
expected: false,
},
{
name: "Empty domain",
alertProvider: AlertProvider{
Config: Config{
BotEmail: "something",
BotAPIKey: "something",
ChannelID: "something",
},
},
expected: false,
},
{
name: "Empty bot api key",
alertProvider: AlertProvider{
Config: Config{
BotEmail: "something",
Domain: "something",
ChannelID: "something",
},
},
expected: false,
},
{
name: "Empty bot email",
alertProvider: AlertProvider{
Config: Config{
BotAPIKey: "something",
Domain: "something",
ChannelID: "something",
},
},
expected: false,
},
{
name: "Valid provider",
alertProvider: AlertProvider{
Config: Config{
BotEmail: "something",
BotAPIKey: "something",
Domain: "something",
ChannelID: "something",
},
},
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_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) {
basicConfig := Config{
BotEmail: "bot-email",
BotAPIKey: "bot-api-key",
Domain: "domain",
ChannelID: "channel-id",
}
alertDesc := "Description"
basicAlert := alert.Alert{
SuccessThreshold: 2,
FailureThreshold: 3,
Description: &alertDesc,
}
testCases := []struct {
name string
provider AlertProvider
alert alert.Alert
resolved bool
hasConditions bool
expectedBody url.Values
}{
{
name: "Resolved alert with no conditions",
provider: AlertProvider{
Config: basicConfig,
},
alert: basicAlert,
resolved: true,
hasConditions: false,
expectedBody: url.Values{
"content": {`An alert for **endpoint-name** has been resolved after passing successfully 2 time(s) in a row
> Description
`},
"to": {"channel-id"},
"topic": {"Gatus"},
"type": {"channel"},
},
},
{
name: "Resolved alert with conditions",
provider: AlertProvider{
Config: basicConfig,
},
alert: basicAlert,
resolved: true,
hasConditions: true,
expectedBody: url.Values{
"content": {`An alert for **endpoint-name** has been resolved after passing successfully 2 time(s) in a row
> Description
:check: - ` + "`[CONNECTED] == true`" + `
:check: - ` + "`[STATUS] == 200`" + `
:check: - ` + "`[BODY] != \"\"`"},
"to": {"channel-id"},
"topic": {"Gatus"},
"type": {"channel"},
},
},
{
name: "Failed alert with no conditions",
provider: AlertProvider{
Config: basicConfig,
},
alert: basicAlert,
resolved: false,
hasConditions: false,
expectedBody: url.Values{
"content": {`An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row
> Description
`},
"to": {"channel-id"},
"topic": {"Gatus"},
"type": {"channel"},
},
},
{
name: "Failed alert with conditions",
provider: AlertProvider{
Config: basicConfig,
},
alert: basicAlert,
resolved: false,
hasConditions: true,
expectedBody: url.Values{
"content": {`An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row
> Description
:cross_mark: - ` + "`[CONNECTED] == true`" + `
:cross_mark: - ` + "`[STATUS] == 200`" + `
:cross_mark: - ` + "`[BODY] != \"\"`"},
"to": {"channel-id"},
"topic": {"Gatus"},
"type": {"channel"},
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var conditionResults []*endpoint.ConditionResult
if tc.hasConditions {
conditionResults = []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: tc.resolved},
{Condition: "[STATUS] == 200", Success: tc.resolved},
{Condition: "[BODY] != \"\"", Success: tc.resolved},
}
}
body := tc.provider.buildRequestBody(
&endpoint.Endpoint{Name: "endpoint-name"},
&tc.alert,
&endpoint.Result{
ConditionResults: conditionResults,
},
tc.resolved,
)
valuesResult, err := url.ParseQuery(body)
if err != nil {
t.Error(err)
}
if fmt.Sprintf("%v", valuesResult) != fmt.Sprintf("%v", tc.expectedBody) {
t.Errorf("Expected body:\n%v\ngot:\n%v", tc.expectedBody, valuesResult)
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
validateRequest := func(req *http.Request) {
if req.URL.String() != "https://custom-domain/api/v1/messages" {
t.Errorf("expected 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)
}
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"))
}
if req.Header.Get("User-Agent") != "Gatus" {
t.Errorf("expected User-Agent header to be Gatus, got %s", req.Header.Get("User-Agent"))
}
}
basicConfig := Config{
BotEmail: "bot-email",
BotAPIKey: "bot-api-key",
Domain: "custom-domain",
ChannelID: "channel-id",
}
basicAlert := alert.Alert{
SuccessThreshold: 2,
FailureThreshold: 3,
}
testCases := []struct {
name string
provider AlertProvider
alert alert.Alert
resolved bool
mockRoundTripper test.MockRoundTripper
expectedError bool
}{
{
name: "resolved",
provider: AlertProvider{
Config: basicConfig,
},
alert: basicAlert,
resolved: true,
mockRoundTripper: test.MockRoundTripper(func(req *http.Request) *http.Response {
validateRequest(req)
return &http.Response{StatusCode: http.StatusOK}
}),
expectedError: false,
},
{
name: "resolved error",
provider: AlertProvider{
Config: basicConfig,
},
alert: basicAlert,
resolved: true,
mockRoundTripper: test.MockRoundTripper(func(req *http.Request) *http.Response {
validateRequest(req)
return &http.Response{StatusCode: http.StatusInternalServerError}
}),
expectedError: true,
},
{
name: "triggered",
provider: AlertProvider{
Config: basicConfig,
},
alert: basicAlert,
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(req *http.Request) *http.Response {
validateRequest(req)
return &http.Response{StatusCode: http.StatusOK}
}),
expectedError: false,
},
{
name: "triggered error",
provider: AlertProvider{
Config: basicConfig,
},
alert: basicAlert,
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(req *http.Request) *http.Response {
validateRequest(req)
return &http.Response{StatusCode: http.StatusInternalServerError}
}),
expectedError: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: tc.mockRoundTripper})
err := tc.provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&tc.alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: tc.resolved},
{Condition: "[STATUS] == 200", Success: tc.resolved},
},
},
tc.resolved,
)
if tc.expectedError && err == nil {
t.Error("expected error, got none")
}
if !tc.expectedError && err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
}
}

View File

@@ -7,6 +7,7 @@ import (
"os"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/web"
static "github.com/TwiN/gatus/v5/web"
"github.com/TwiN/health"
fiber "github.com/gofiber/fiber/v2"
@@ -26,6 +27,10 @@ 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")
cfg.Web = web.GetDefaultConfig()
}
api.router = api.createRouter(cfg)
return api
}
@@ -40,6 +45,8 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App {
log.Printf("[api.ErrorHandler] %s", err.Error())
return fiber.DefaultErrorHandler(c, err)
},
ReadBufferSize: cfg.Web.ReadBufferSize,
Network: fiber.NetworkTCP,
})
if os.Getenv("ENVIRONMENT") == "dev" {
app.Use(cors.New(cors.Config{
@@ -65,9 +72,12 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App {
unprotectedAPIRouter := apiRouter.Group("/")
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/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)
// This endpoint requires authz with bearer token, so technically it is protected
unprotectedAPIRouter.Post("/v1/endpoints/:key/external", CreateExternalEndpointResult(cfg))
// SPA
app.Get("/", SinglePageApplication(cfg.UI))
app.Get("/endpoints/:name", SinglePageApplication(cfg.UI))

View File

@@ -1,12 +1,15 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/endpoint/ui"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/storage/store/common"
"github.com/TwiN/gatus/v5/storage/store/common/paging"
@@ -34,11 +37,13 @@ var (
// UptimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.
//
// Valid values for :duration -> 7d, 24h, 1h
// Valid values for :duration -> 30d, 7d, 24h, 1h
func UptimeBadge(c *fiber.Ctx) error {
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":
@@ -46,14 +51,14 @@ func UptimeBadge(c *fiber.Ctx) error {
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: 7d, 24h, 1h")
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 err == common.ErrEndpointNotFound {
if errors.Is(err, common.ErrEndpointNotFound) {
return c.Status(404).SendString(err.Error())
} else if err == common.ErrInvalidTimeRange {
} else if errors.Is(err, common.ErrInvalidTimeRange) {
return c.Status(400).SendString(err.Error())
}
return c.Status(500).SendString(err.Error())
@@ -66,12 +71,14 @@ func UptimeBadge(c *fiber.Ctx) error {
// ResponseTimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.
//
// Valid values for :duration -> 7d, 24h, 1h
func ResponseTimeBadge(config *config.Config) fiber.Handler {
// Valid values for :duration -> 30d, 7d, 24h, 1h
func ResponseTimeBadge(cfg *config.Config) fiber.Handler {
return func(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":
@@ -79,14 +86,14 @@ func ResponseTimeBadge(config *config.Config) fiber.Handler {
case "1h":
from = time.Now().Add(-2 * time.Hour) // Because response time metrics are stored by hour, we have to cheat a little
default:
return c.Status(400).SendString("Durations supported: 7d, 24h, 1h")
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
}
key := c.Params("key")
averageResponseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now())
if err != nil {
if err == common.ErrEndpointNotFound {
if errors.Is(err, common.ErrEndpointNotFound) {
return c.Status(404).SendString(err.Error())
} else if err == common.ErrInvalidTimeRange {
} else if errors.Is(err, common.ErrInvalidTimeRange) {
return c.Status(400).SendString(err.Error())
}
return c.Status(500).SendString(err.Error())
@@ -94,7 +101,7 @@ func ResponseTimeBadge(config *config.Config) fiber.Handler {
c.Set("Content-Type", "image/svg+xml")
c.Set("Cache-Control", "no-cache, no-store, must-revalidate")
c.Set("Expires", "0")
return c.Status(200).Send(generateResponseTimeBadgeSVG(duration, averageResponseTime, key, config))
return c.Status(200).Send(generateResponseTimeBadgeSVG(duration, averageResponseTime, key, cfg))
}
}
@@ -104,9 +111,9 @@ func HealthBadge(c *fiber.Ctx) error {
pagingConfig := paging.NewEndpointStatusParams()
status, err := store.Get().GetEndpointStatusByKey(key, pagingConfig.WithResults(1, 1))
if err != nil {
if err == common.ErrEndpointNotFound {
if errors.Is(err, common.ErrEndpointNotFound) {
return c.Status(404).SendString(err.Error())
} else if err == common.ErrInvalidTimeRange {
} else if errors.Is(err, common.ErrInvalidTimeRange) {
return c.Status(400).SendString(err.Error())
}
return c.Status(500).SendString(err.Error())
@@ -125,9 +132,41 @@ func HealthBadge(c *fiber.Ctx) error {
return c.Status(200).Send(generateHealthBadgeSVG(healthStatus))
}
func HealthBadgeShields(c *fiber.Ctx) error {
key := c.Params("key")
pagingConfig := paging.NewEndpointStatusParams()
status, err := store.Get().GetEndpointStatusByKey(key, pagingConfig.WithResults(1, 1))
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())
}
healthStatus := HealthStatusUnknown
if len(status.Results) > 0 {
if status.Results[0].Success {
healthStatus = HealthStatusUp
} else {
healthStatus = HealthStatusDown
}
}
c.Set("Content-Type", "application/json")
c.Set("Cache-Control", "no-cache, no-store, must-revalidate")
c.Set("Expires", "0")
jsonData, err := generateHealthBadgeShields(healthStatus)
if err != nil {
return c.Status(500).SendString(err.Error())
}
return c.Status(200).Send(jsonData)
}
func generateUptimeBadgeSVG(duration string, uptime float64) []byte {
var labelWidth, valueWidth, valueWidthAdjustment int
switch duration {
case "30d":
labelWidth = 70
case "7d":
labelWidth = 65
case "24h":
@@ -194,6 +233,8 @@ func getBadgeColorFromUptime(uptime float64) string {
func generateResponseTimeBadgeSVG(duration string, averageResponseTime int, key string, cfg *config.Config) []byte {
var labelWidth, valueWidth int
switch duration {
case "30d":
labelWidth = 110
case "7d":
labelWidth = 105
case "24h":
@@ -240,10 +281,13 @@ func generateResponseTimeBadgeSVG(duration string, averageResponseTime int, key
}
func getBadgeColorFromResponseTime(responseTime int, key string, cfg *config.Config) string {
endpoint := cfg.GetEndpointByKey(key)
thresholds := ui.GetDefaultConfig().Badge.ResponseTime.Thresholds
if endpoint := cfg.GetEndpointByKey(key); endpoint != nil {
thresholds = endpoint.UIConfig.Badge.ResponseTime.Thresholds
}
// the threshold config requires 5 values, so we can be sure it's set here
for i := 0; i < 5; i++ {
if responseTime <= endpoint.UIConfig.Badge.ResponseTime.Thresholds[i] {
if responseTime <= thresholds[i] {
return badgeColors[i]
}
}
@@ -299,6 +343,17 @@ func generateHealthBadgeSVG(healthStatus string) []byte {
return svg
}
func generateHealthBadgeShields(healthStatus string) ([]byte, error) {
color := getBadgeShieldsColorFromHealth(healthStatus)
data := map[string]interface{}{
"schemaVersion": 1,
"label": "gatus",
"message": healthStatus,
"color": color,
}
return json.Marshal(data)
}
func getBadgeColorFromHealth(healthStatus string) string {
if healthStatus == HealthStatusUp {
return badgeColorHexAwesome
@@ -307,3 +362,12 @@ func getBadgeColorFromHealth(healthStatus string) string {
}
return badgeColorHexPassable
}
func getBadgeShieldsColorFromHealth(healthStatus string) string {
if healthStatus == HealthStatusUp {
return "brightgreen"
} else if healthStatus == HealthStatusDown {
return "red"
}
return "yellow"
}

View File

@@ -8,8 +8,8 @@ import (
"time"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/core/ui"
"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"
)
@@ -19,7 +19,7 @@ func TestBadge(t *testing.T) {
defer cache.Clear()
cfg := &config.Config{
Metrics: true,
Endpoints: []*core.Endpoint{
Endpoints: []*endpoint.Endpoint{
{
Name: "frontend",
Group: "core",
@@ -34,8 +34,8 @@ func TestBadge(t *testing.T) {
cfg.Endpoints[0].UIConfig = ui.GetDefaultConfig()
cfg.Endpoints[1].UIConfig = ui.GetDefaultConfig()
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Connected: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Connected: false, Duration: time.Second, Timestamp: time.Now()})
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 {
@@ -110,6 +110,21 @@ func TestBadge(t *testing.T) {
Path: "/api/v1/endpoints/invalid_key/health/badge.svg",
ExpectedCode: http.StatusNotFound,
},
{
Name: "badge-shields-health-up",
Path: "/api/v1/endpoints/core_frontend/health/badge.shields",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-shields-health-down",
Path: "/api/v1/endpoints/core_backend/health/badge.shields",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-shields-health-for-invalid-key",
Path: "/api/v1/endpoints/invalid_key/health/badge.shields",
ExpectedCode: http.StatusNotFound,
},
{
Name: "chart-response-time-24h",
Path: "/api/v1/endpoints/core_backend/response-times/24h/chart.svg",
@@ -203,30 +218,30 @@ func TestGetBadgeColorFromResponseTime(t *testing.T) {
defer cache.Clear()
var (
firstCondition = core.Condition("[STATUS] == 200")
secondCondition = core.Condition("[RESPONSE_TIME] < 500")
thirdCondition = core.Condition("[CERTIFICATE_EXPIRATION] < 72h")
firstCondition = endpoint.Condition("[STATUS] == 200")
secondCondition = endpoint.Condition("[RESPONSE_TIME] < 500")
thirdCondition = endpoint.Condition("[CERTIFICATE_EXPIRATION] < 72h")
)
firstTestEndpoint := core.Endpoint{
firstTestEndpoint := endpoint.Endpoint{
Name: "a",
URL: "https://example.org/what/ever",
Method: "GET",
Body: "body",
Interval: 30 * time.Second,
Conditions: []core.Condition{firstCondition, secondCondition, thirdCondition},
Conditions: []endpoint.Condition{firstCondition, secondCondition, thirdCondition},
Alerts: nil,
NumberOfFailuresInARow: 0,
NumberOfSuccessesInARow: 0,
UIConfig: ui.GetDefaultConfig(),
}
secondTestEndpoint := core.Endpoint{
secondTestEndpoint := endpoint.Endpoint{
Name: "b",
URL: "https://example.org/what/ever",
Method: "GET",
Body: "body",
Interval: 30 * time.Second,
Conditions: []core.Condition{firstCondition, secondCondition, thirdCondition},
Conditions: []endpoint.Condition{firstCondition, secondCondition, thirdCondition},
Alerts: nil,
NumberOfFailuresInARow: 0,
NumberOfSuccessesInARow: 0,
@@ -240,10 +255,10 @@ func TestGetBadgeColorFromResponseTime(t *testing.T) {
}
cfg := &config.Config{
Metrics: true,
Endpoints: []*core.Endpoint{&firstTestEndpoint, &secondTestEndpoint},
Endpoints: []*endpoint.Endpoint{&firstTestEndpoint, &secondTestEndpoint},
}
testSuccessfulResult := core.Result{
testSuccessfulResult := endpoint.Result{
Hostname: "example.org",
IP: "127.0.0.1",
HTTPStatus: 200,
@@ -253,7 +268,7 @@ func TestGetBadgeColorFromResponseTime(t *testing.T) {
Timestamp: time.Now(),
Duration: 150 * time.Millisecond,
CertificateExpiration: 10 * time.Hour,
ConditionResults: []*core.ConditionResult{
ConditionResults: []*endpoint.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: true,

View File

@@ -1,6 +1,7 @@
package api
import (
"errors"
"log"
"math"
"net/http"
@@ -31,20 +32,24 @@ var (
func ResponseTimeChart(c *fiber.Ctx) error {
duration := c.Params("duration")
chartTimestampFormatter := chart.TimeValueFormatterWithFormat(timeFormat)
var from time.Time
switch duration {
case "30d":
from = time.Now().Truncate(time.Hour).Add(-30 * 24 * time.Hour)
chartTimestampFormatter = chart.TimeDateValueFormatter
case "7d":
from = time.Now().Truncate(time.Hour).Add(-24 * 7 * time.Hour)
from = time.Now().Truncate(time.Hour).Add(-7 * 24 * time.Hour)
case "24h":
from = time.Now().Truncate(time.Hour).Add(-24 * time.Hour)
default:
return c.Status(400).SendString("Durations supported: 7d, 24h")
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h")
}
hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(c.Params("key"), from, time.Now())
if err != nil {
if err == common.ErrEndpointNotFound {
if errors.Is(err, common.ErrEndpointNotFound) {
return c.Status(404).SendString(err.Error())
} else if err == common.ErrInvalidTimeRange {
} else if errors.Is(err, common.ErrInvalidTimeRange) {
return c.Status(400).SendString(err.Error())
}
return c.Status(500).SendString(err.Error())
@@ -87,7 +92,7 @@ func ResponseTimeChart(c *fiber.Ctx) error {
Width: 1280,
Height: 300,
XAxis: chart.XAxis{
ValueFormatter: chart.TimeValueFormatterWithFormat(timeFormat),
ValueFormatter: chartTimestampFormatter,
GridMajorStyle: gridStyle,
GridMinorStyle: gridStyle,
Style: axisStyle,
@@ -111,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())
log.Println("[api.ResponseTimeChart] Failed to render response time chart:", err.Error())
return c.Status(500).SendString(err.Error())
}
return nil

View File

@@ -7,7 +7,7 @@ import (
"time"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/watchdog"
)
@@ -17,7 +17,7 @@ func TestResponseTimeChart(t *testing.T) {
defer cache.Clear()
cfg := &config.Config{
Metrics: true,
Endpoints: []*core.Endpoint{
Endpoints: []*endpoint.Endpoint{
{
Name: "frontend",
Group: "core",
@@ -28,8 +28,8 @@ func TestResponseTimeChart(t *testing.T) {
},
},
}
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
api := New(cfg)
router := api.Router()
type Scenario struct {
@@ -49,6 +49,11 @@ func TestResponseTimeChart(t *testing.T) {
Path: "/api/v1/endpoints/core_frontend/response-times/7d/chart.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "chart-response-time-30d",
Path: "/api/v1/endpoints/core_frontend/response-times/30d/chart.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "chart-response-time-with-invalid-duration",
Path: "/api/v1/endpoints/core_backend/response-times/3d/chart.svg",

View File

@@ -2,14 +2,14 @@ package api
import (
"encoding/json"
"errors"
"fmt"
"io"
"log"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/remote"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/storage/store/common"
"github.com/TwiN/gatus/v5/storage/store/common/paging"
@@ -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())
log.Printf("[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())
log.Printf("[handler.EndpointStatuses] Silently failed to retrieve endpoint statuses from remote: %s", err.Error())
} else if endpointStatusesFromRemote != nil {
endpointStatuses = append(endpointStatuses, endpointStatusesFromRemote...)
}
// Marshal endpoint statuses to JSON
data, err = json.Marshal(endpointStatuses)
if err != nil {
log.Printf("[api][EndpointStatuses] Unable to marshal object to JSON: %s", err.Error())
log.Printf("[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)
@@ -50,27 +50,21 @@ func EndpointStatuses(cfg *config.Config) fiber.Handler {
}
}
func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*core.EndpointStatus, error) {
func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*endpoint.Status, error) {
if remoteConfig == nil || len(remoteConfig.Instances) == 0 {
return nil, nil
}
var endpointStatusesFromAllRemotes []*core.EndpointStatus
var endpointStatusesFromAllRemotes []*endpoint.Status
httpClient := client.GetHTTPClient(remoteConfig.ClientConfig)
for _, instance := range remoteConfig.Instances {
response, err := httpClient.Get(instance.URL)
if err != nil {
return nil, err
}
body, err := io.ReadAll(response.Body)
if err != nil {
var endpointStatuses []*endpoint.Status
if err = json.NewDecoder(response.Body).Decode(&endpointStatuses); err != nil {
_ = response.Body.Close()
log.Printf("[handler][getEndpointStatusesFromRemoteInstances] Silently failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error())
continue
}
var endpointStatuses []*core.EndpointStatus
if err = json.Unmarshal(body, &endpointStatuses); err != nil {
_ = response.Body.Close()
log.Printf("[handler][getEndpointStatusesFromRemoteInstances] Silently failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error())
log.Printf("[api.getEndpointStatusesFromRemoteInstances] Silently failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error())
continue
}
_ = response.Body.Close()
@@ -82,24 +76,24 @@ func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*cor
return endpointStatusesFromAllRemotes, nil
}
// EndpointStatus retrieves a single core.EndpointStatus by group and endpoint name
// EndpointStatus retrieves a single endpoint.Status by group and endpoint name
func EndpointStatus(c *fiber.Ctx) error {
page, pageSize := extractPageAndPageSizeFromRequest(c)
endpointStatus, err := store.Get().GetEndpointStatusByKey(c.Params("key"), paging.NewEndpointStatusParams().WithResults(page, pageSize).WithEvents(1, common.MaximumNumberOfEvents))
if err != nil {
if err == common.ErrEndpointNotFound {
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())
log.Printf("[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"))
log.Printf("[api.EndpointStatus] Endpoint with key=%s not found", c.Params("key"))
return c.Status(404).SendString("not found")
}
output, err := json.Marshal(endpointStatus)
if err != nil {
log.Printf("[api][EndpointStatus] Unable to marshal object to JSON: %s", err.Error())
log.Printf("[api.EndpointStatus] Unable to marshal object to JSON: %s", err.Error())
return c.Status(500).SendString("unable to marshal object to JSON")
}
c.Set("Content-Type", "application/json")

View File

@@ -8,7 +8,7 @@ import (
"time"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/watchdog"
)
@@ -16,19 +16,19 @@ import (
var (
timestamp = time.Now()
testEndpoint = core.Endpoint{
testEndpoint = endpoint.Endpoint{
Name: "name",
Group: "group",
URL: "https://example.org/what/ever",
Method: "GET",
Body: "body",
Interval: 30 * time.Second,
Conditions: []core.Condition{core.Condition("[STATUS] == 200"), core.Condition("[RESPONSE_TIME] < 500"), core.Condition("[CERTIFICATE_EXPIRATION] < 72h")},
Conditions: []endpoint.Condition{endpoint.Condition("[STATUS] == 200"), endpoint.Condition("[RESPONSE_TIME] < 500"), endpoint.Condition("[CERTIFICATE_EXPIRATION] < 72h")},
Alerts: nil,
NumberOfFailuresInARow: 0,
NumberOfSuccessesInARow: 0,
}
testSuccessfulResult = core.Result{
testSuccessfulResult = endpoint.Result{
Hostname: "example.org",
IP: "127.0.0.1",
HTTPStatus: 200,
@@ -38,7 +38,7 @@ var (
Timestamp: timestamp,
Duration: 150 * time.Millisecond,
CertificateExpiration: 10 * time.Hour,
ConditionResults: []*core.ConditionResult{
ConditionResults: []*endpoint.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: true,
@@ -53,7 +53,7 @@ var (
},
},
}
testUnsuccessfulResult = core.Result{
testUnsuccessfulResult = endpoint.Result{
Hostname: "example.org",
IP: "127.0.0.1",
HTTPStatus: 200,
@@ -63,7 +63,7 @@ var (
Timestamp: timestamp,
Duration: 750 * time.Millisecond,
CertificateExpiration: 10 * time.Hour,
ConditionResults: []*core.ConditionResult{
ConditionResults: []*endpoint.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: true,
@@ -85,7 +85,7 @@ func TestEndpointStatus(t *testing.T) {
defer cache.Clear()
cfg := &config.Config{
Metrics: true,
Endpoints: []*core.Endpoint{
Endpoints: []*endpoint.Endpoint{
{
Name: "frontend",
Group: "core",
@@ -96,8 +96,8 @@ func TestEndpointStatus(t *testing.T) {
},
},
}
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
api := New(cfg)
router := api.Router()
type Scenario struct {

70
api/external_endpoint.go Normal file
View File

@@ -0,0 +1,70 @@
package api
import (
"errors"
"log"
"strings"
"time"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/storage/store/common"
"github.com/TwiN/gatus/v5/watchdog"
"github.com/gofiber/fiber/v2"
)
func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
// Check if the success query parameter is present
success, exists := c.Queries()["success"]
if !exists || (success != "true" && success != "false") {
return c.Status(400).SendString("missing or invalid success query parameter")
}
// Check if the authorization bearer token header is correct
authorizationHeader := string(c.Request().Header.Peek("Authorization"))
if !strings.HasPrefix(authorizationHeader, "Bearer ") {
return c.Status(401).SendString("invalid Authorization header")
}
token := strings.TrimSpace(strings.TrimPrefix(authorizationHeader, "Bearer "))
if len(token) == 0 {
return c.Status(401).SendString("bearer token must not be empty")
}
key := c.Params("key")
externalEndpoint := cfg.GetExternalEndpointByKey(key)
if externalEndpoint == nil {
log.Printf("[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)
return c.Status(401).SendString("invalid token")
}
// Persist the result in the storage
result := &endpoint.Result{
Timestamp: time.Now(),
Success: c.QueryBool("success"),
Errors: []string{},
}
if !result.Success && c.Query("error") != "" {
result.Errors = append(result.Errors, c.Query("error"))
}
convertedEndpoint := externalEndpoint.ToEndpoint()
if err := store.Get().Insert(convertedEndpoint, result); err != nil {
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())
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)
// Check if an alert should be triggered or resolved
if !cfg.Maintenance.IsUnderMaintenance() {
watchdog.HandleAlerting(convertedEndpoint, result, cfg.Alerting, cfg.Debug)
externalEndpoint.NumberOfSuccessesInARow = convertedEndpoint.NumberOfSuccessesInARow
externalEndpoint.NumberOfFailuresInARow = convertedEndpoint.NumberOfFailuresInARow
}
// Return the result
return c.Status(200).SendString("")
}
}

View File

@@ -0,0 +1,161 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/TwiN/gatus/v5/alerting"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/alerting/provider/discord"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/maintenance"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/storage/store/common/paging"
)
func TestCreateExternalEndpointResult(t *testing.T) {
defer store.Get().Clear()
defer cache.Clear()
cfg := &config.Config{
Alerting: &alerting.Config{
Discord: &discord.AlertProvider{},
},
ExternalEndpoints: []*endpoint.ExternalEndpoint{
{
Name: "n",
Group: "g",
Token: "token",
Alerts: []*alert.Alert{
{
Type: alert.TypeDiscord,
FailureThreshold: 2,
SuccessThreshold: 2,
},
},
},
},
Maintenance: &maintenance.Config{},
}
api := New(cfg)
router := api.Router()
scenarios := []struct {
Name string
Path string
AuthorizationHeaderBearerToken string
ExpectedCode int
}{
{
Name: "no-token",
Path: "/api/v1/endpoints/g_n/external?success=true",
AuthorizationHeaderBearerToken: "",
ExpectedCode: 401,
},
{
Name: "bad-token",
Path: "/api/v1/endpoints/g_n/external?success=true",
AuthorizationHeaderBearerToken: "Bearer bad-token",
ExpectedCode: 401,
},
{
Name: "bad-key",
Path: "/api/v1/endpoints/bad_key/external?success=true",
AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 404,
},
{
Name: "bad-success-value",
Path: "/api/v1/endpoints/g_n/external?success=invalid",
AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 400,
},
{
Name: "good-token-success-true",
Path: "/api/v1/endpoints/g_n/external?success=true",
AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 200,
},
{
Name: "good-token-success-true-with-ignored-error-because-success-true",
Path: "/api/v1/endpoints/g_n/external?success=true&error=failed",
AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 200,
},
{
Name: "good-token-success-false",
Path: "/api/v1/endpoints/g_n/external?success=false",
AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 200,
},
{
Name: "good-token-success-false-again",
Path: "/api/v1/endpoints/g_n/external?success=false",
AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 200,
},
{
Name: "good-token-success-false-with-error",
Path: "/api/v1/endpoints/g_n/external?success=false&error=failed",
AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 200,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
request := httptest.NewRequest("POST", scenario.Path, http.NoBody)
if len(scenario.AuthorizationHeaderBearerToken) > 0 {
request.Header.Set("Authorization", scenario.AuthorizationHeaderBearerToken)
}
response, err := router.Test(request)
if err != nil {
return
}
defer response.Body.Close()
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)
}
})
}
t.Run("verify-end-results", func(t *testing.T) {
endpointStatus, err := store.Get().GetEndpointStatus("g", "n", paging.NewEndpointStatusParams().WithResults(1, 10))
if err != nil {
t.Errorf("failed to get endpoint status: %s", err.Error())
return
}
if endpointStatus.Key != "g_n" {
t.Errorf("expected key to be g_n but got %s", endpointStatus.Key)
}
if len(endpointStatus.Results) != 5 {
t.Errorf("expected 3 results but got %d", len(endpointStatus.Results))
}
if !endpointStatus.Results[0].Success {
t.Errorf("expected first result to be successful")
}
if !endpointStatus.Results[1].Success {
t.Errorf("expected second result to be successful")
}
if len(endpointStatus.Results[1].Errors) > 0 {
t.Errorf("expected second result to have no errors")
}
if endpointStatus.Results[2].Success {
t.Errorf("expected third result to be unsuccessful")
}
if endpointStatus.Results[3].Success {
t.Errorf("expected fourth result to be unsuccessful")
}
if endpointStatus.Results[4].Success {
t.Errorf("expected fifth result to be unsuccessful")
}
if len(endpointStatus.Results[4].Errors) == 0 || endpointStatus.Results[4].Errors[0] != "failed" {
t.Errorf("expected fifth result to have errors: failed")
}
externalEndpointFromConfig := cfg.GetExternalEndpointByKey("g_n")
if externalEndpointFromConfig.NumberOfFailuresInARow != 3 {
t.Errorf("expected 3 failures in a row but got %d", externalEndpointFromConfig.NumberOfFailuresInARow)
}
if externalEndpointFromConfig.NumberOfSuccessesInARow != 0 {
t.Errorf("expected 0 successes in a row but got %d", externalEndpointFromConfig.NumberOfSuccessesInARow)
}
})
}

View File

@@ -15,14 +15,14 @@ func SinglePageApplication(ui *ui.Config) fiber.Handler {
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())
log.Println("[api.SinglePageApplication] Failed to parse template. This should never happen, because the template is validated on start. Error:", 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)
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())
log.Println("[api.SinglePageApplication] Failed to execute template. This should never happen, because the template is validated on start. Error:", 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

@@ -9,8 +9,8 @@ import (
"time"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/ui"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/watchdog"
)
@@ -20,7 +20,7 @@ func TestSinglePageApplication(t *testing.T) {
defer cache.Clear()
cfg := &config.Config{
Metrics: true,
Endpoints: []*core.Endpoint{
Endpoints: []*endpoint.Endpoint{
{
Name: "frontend",
Group: "core",
@@ -34,8 +34,8 @@ func TestSinglePageApplication(t *testing.T) {
Title: "example-title",
},
}
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
api := New(cfg)
router := api.Router()
type Scenario struct {

View File

@@ -6,7 +6,6 @@ import (
"encoding/json"
"errors"
"fmt"
"golang.org/x/net/websocket"
"net"
"net/http"
"net/smtp"
@@ -17,8 +16,14 @@ import (
"github.com/TwiN/gocache/v2"
"github.com/TwiN/whois"
"github.com/ishidawataru/sctp"
"github.com/miekg/dns"
ping "github.com/prometheus-community/pro-bing"
"golang.org/x/crypto/ssh"
"golang.org/x/net/websocket"
)
const (
dnsPort = 53
)
var (
@@ -235,10 +240,7 @@ func ExecuteSSHCommand(sshClient *ssh.Client, body string, config *Config) (bool
//
// Note that this function takes at least 100ms, even if the address is 127.0.0.1
func Ping(address string, config *Config) (bool, time.Duration) {
pinger, err := ping.NewPinger(address)
if err != nil {
return false, 0
}
pinger := ping.New(address)
pinger.Count = 1
pinger.Timeout = config.Timeout
// Set the pinger's privileged mode to true for every GOOS except darwin
@@ -247,7 +249,8 @@ func Ping(address string, config *Config) (bool, time.Duration) {
// Note that for this to work on Linux, Gatus must run with sudo privileges.
// See https://github.com/prometheus-community/pro-bing#linux
pinger.SetPrivileged(runtime.GOOS != "darwin")
err = pinger.Run()
pinger.SetNetwork(config.Network)
err := pinger.Run()
if err != nil {
return false, 0
}
@@ -261,24 +264,25 @@ func Ping(address string, config *Config) (bool, time.Duration) {
return true, 0
}
// Open a websocket connection, write `body` and return a message from the server
func QueryWebSocket(address string, config *Config, body string) (bool, []byte, error) {
// QueryWebSocket opens a websocket connection, write `body` and return a message from the server
func QueryWebSocket(address, body string, config *Config) (bool, []byte, error) {
const (
Origin = "http://localhost/"
MaximumMessageSize = 1024 // in bytes
)
wsConfig, err := websocket.NewConfig(address, Origin)
if err != nil {
return false, nil, fmt.Errorf("error configuring websocket connection: %w", err)
}
if config != nil {
wsConfig.Dialer = &net.Dialer{Timeout: config.Timeout}
}
// Dial URL
ws, err := websocket.DialConfig(wsConfig)
if err != nil {
return false, nil, fmt.Errorf("error dialing websocket: %w", err)
}
defer ws.Close()
connected := true
// Write message
if _, err := ws.Write([]byte(body)); err != nil {
return false, nil, fmt.Errorf("error writing websocket body: %w", err)
@@ -289,7 +293,50 @@ func QueryWebSocket(address string, config *Config, body string) (bool, []byte,
if n, err = ws.Read(msg); err != nil {
return false, nil, fmt.Errorf("error reading websocket message: %w", err)
}
return connected, msg[:n], nil
return true, msg[:n], nil
}
func QueryDNS(queryType, queryName, url string) (connected bool, dnsRcode string, body []byte, err error) {
if !strings.Contains(url, ":") {
url = fmt.Sprintf("%s:%d", url, dnsPort)
}
queryTypeAsUint16 := dns.StringToType[queryType]
c := new(dns.Client)
m := new(dns.Msg)
m.SetQuestion(queryName, queryTypeAsUint16)
r, _, err := c.Exchange(m, url)
if err != nil {
return false, "", nil, err
}
connected = true
dnsRcode = dns.RcodeToString[r.Rcode]
for _, rr := range r.Answer {
switch rr.Header().Rrtype {
case dns.TypeA:
if a, ok := rr.(*dns.A); ok {
body = []byte(a.A.String())
}
case dns.TypeAAAA:
if aaaa, ok := rr.(*dns.AAAA); ok {
body = []byte(aaaa.AAAA.String())
}
case dns.TypeCNAME:
if cname, ok := rr.(*dns.CNAME); ok {
body = []byte(cname.Target)
}
case dns.TypeMX:
if mx, ok := rr.(*dns.MX); ok {
body = []byte(mx.Mx)
}
case dns.TypeNS:
if ns, ok := rr.(*dns.NS); ok {
body = []byte(ns.Ns)
}
default:
body = []byte("query type is not supported yet")
}
}
return connected, dnsRcode, body, nil
}
// InjectHTTPClient is used to inject a custom HTTP client for testing purposes

View File

@@ -2,11 +2,14 @@ package client
import (
"bytes"
"crypto/tls"
"io"
"net/http"
"testing"
"time"
"github.com/TwiN/gatus/v5/config/endpoint/dns"
"github.com/TwiN/gatus/v5/pattern"
"github.com/TwiN/gatus/v5/test"
)
@@ -83,6 +86,32 @@ func TestPing(t *testing.T) {
t.Error("Round-trip time returned on failure should've been 0")
}
}
// Can't perform integration tests (e.g. pinging public targets by single-stacked hostname) here,
// because ICMP is blocked in the network of GitHub-hosted runners.
if success, rtt := Ping("127.0.0.1", &Config{Timeout: 500 * time.Millisecond, Network: "ip"}); !success {
t.Error("expected true")
if rtt == 0 {
t.Error("Round-trip time returned on failure should've been 0")
}
}
if success, rtt := Ping("::1", &Config{Timeout: 500 * time.Millisecond, Network: "ip"}); !success {
t.Error("expected true")
if rtt == 0 {
t.Error("Round-trip time returned on failure should've been 0")
}
}
if success, rtt := Ping("::1", &Config{Timeout: 500 * time.Millisecond, Network: "ip4"}); success {
t.Error("expected false, because the IP isn't an IPv4 address")
if rtt != 0 {
t.Error("Round-trip time returned on failure should've been 0")
}
}
if success, rtt := Ping("127.0.0.1", &Config{Timeout: 500 * time.Millisecond, Network: "ip6"}); success {
t.Error("expected false, because the IP isn't an IPv6 address")
if rtt != 0 {
t.Error("Round-trip time returned on failure should've been 0")
}
}
}
func TestCanPerformStartTLS(t *testing.T) {
@@ -250,6 +279,154 @@ func TestHttpClientProvidesOAuth2BearerToken(t *testing.T) {
// to us as `X-Org-Authorization` header, we check here if the value matches
// our expected token `secret-token`
if response.Header.Get("X-Org-Authorization") != "Bearer secret-token" {
t.Error("exptected `secret-token` as Bearer token in the mocked response header `X-Org-Authorization`, but got", response.Header.Get("X-Org-Authorization"))
t.Error("expected `secret-token` as Bearer token in the mocked response header `X-Org-Authorization`, but got", response.Header.Get("X-Org-Authorization"))
}
}
func TestQueryWebSocket(t *testing.T) {
_, _, err := QueryWebSocket("", "body", &Config{Timeout: 2 * time.Second})
if err == nil {
t.Error("expected an error due to the address being invalid")
}
_, _, err = QueryWebSocket("ws://example.org", "body", &Config{Timeout: 2 * time.Second})
if err == nil {
t.Error("expected an error due to the target not being websocket-friendly")
}
}
func TestTlsRenegotiation(t *testing.T) {
tests := []struct {
name string
cfg TLSConfig
expectedConfig tls.RenegotiationSupport
}{
{
name: "default",
cfg: TLSConfig{CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "../testdata/cert.key"},
expectedConfig: tls.RenegotiateNever,
},
{
name: "never",
cfg: TLSConfig{RenegotiationSupport: "never", CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "../testdata/cert.key"},
expectedConfig: tls.RenegotiateNever,
},
{
name: "once",
cfg: TLSConfig{RenegotiationSupport: "once", CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "../testdata/cert.key"},
expectedConfig: tls.RenegotiateOnceAsClient,
},
{
name: "freely",
cfg: TLSConfig{RenegotiationSupport: "freely", CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "../testdata/cert.key"},
expectedConfig: tls.RenegotiateFreelyAsClient,
},
{
name: "not-valid-and-broken",
cfg: TLSConfig{RenegotiationSupport: "invalid", CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "../testdata/cert.key"},
expectedConfig: tls.RenegotiateNever,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
tls := &tls.Config{}
tlsConfig := configureTLS(tls, test.cfg)
if tlsConfig.Renegotiation != test.expectedConfig {
t.Errorf("expected tls renegotiation to be %v, but got %v", test.expectedConfig, tls.Renegotiation)
}
})
}
}
func TestQueryDNS(t *testing.T) {
tests := []struct {
name string
inputDNS dns.Config
inputURL string
expectedDNSCode string
expectedBody string
isErrExpected bool
}{
{
name: "test Config with type A",
inputDNS: dns.Config{
QueryType: "A",
QueryName: "example.com.",
},
inputURL: "8.8.8.8",
expectedDNSCode: "NOERROR",
expectedBody: "93.184.215.14",
},
{
name: "test Config with type AAAA",
inputDNS: dns.Config{
QueryType: "AAAA",
QueryName: "example.com.",
},
inputURL: "8.8.8.8",
expectedDNSCode: "NOERROR",
expectedBody: "2606:2800:21f:cb07:6820:80da:af6b:8b2c",
},
{
name: "test Config with type CNAME",
inputDNS: dns.Config{
QueryType: "CNAME",
QueryName: "en.wikipedia.org.",
},
inputURL: "8.8.8.8",
expectedDNSCode: "NOERROR",
expectedBody: "dyna.wikimedia.org.",
},
{
name: "test Config with type MX",
inputDNS: dns.Config{
QueryType: "MX",
QueryName: "example.com.",
},
inputURL: "8.8.8.8",
expectedDNSCode: "NOERROR",
expectedBody: ".",
},
{
name: "test Config with type NS",
inputDNS: dns.Config{
QueryType: "NS",
QueryName: "example.com.",
},
inputURL: "8.8.8.8",
expectedDNSCode: "NOERROR",
expectedBody: "*.iana-servers.net.",
},
{
name: "test Config with fake type and retrieve error",
inputDNS: dns.Config{
QueryType: "B",
QueryName: "example",
},
inputURL: "8.8.8.8",
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 {
t.Errorf("there should be an error")
}
if dnsRCode != test.expectedDNSCode {
t.Errorf("expected DNSRCode to be %s, got %s", test.expectedDNSCode, dnsRCode)
}
if test.inputDNS.QueryType == "NS" {
// Because there are often multiple nameservers backing a single domain, we'll only look at the suffix
if !pattern.Match(test.expectedBody, string(body)) {
t.Errorf("got %s, expected result %s,", string(body), test.expectedBody)
}
} else {
if string(body) != test.expectedBody {
t.Errorf("got %s, expected result %s,", string(body), test.expectedBody)
}
}
})
time.Sleep(5 * time.Millisecond)
}
}

View File

@@ -7,27 +7,32 @@ import (
"log"
"net"
"net/http"
"net/url"
"regexp"
"strconv"
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/clientcredentials"
"google.golang.org/api/idtoken"
)
const (
defaultHTTPTimeout = 10 * time.Second
defaultTimeout = 10 * time.Second
)
var (
ErrInvalidDNSResolver = errors.New("invalid DNS resolver specified. Required format is {proto}://{ip}:{port}")
ErrInvalidDNSResolverPort = errors.New("invalid DNS resolver port")
ErrInvalidClientOAuth2Config = errors.New("invalid oauth2 configuration: must define all fields for client credentials flow (token-url, client-id, client-secret, scopes)")
ErrInvalidClientIAPConfig = errors.New("invalid Identity-Aware-Proxy configuration: must define all fields for Google Identity-Aware-Proxy programmatic authentication (audience)")
ErrInvalidClientTLSConfig = errors.New("invalid TLS configuration: certificate-file and private-key-file must be specified")
defaultConfig = Config{
Insecure: false,
IgnoreRedirect: false,
Timeout: defaultHTTPTimeout,
Timeout: defaultTimeout,
Network: "ip",
}
)
@@ -39,6 +44,9 @@ func GetDefaultConfig() *Config {
// Config is the configuration for clients
type Config struct {
// ProxyURL is the URL of the proxy to use for the client
ProxyURL string `yaml:"proxy-url,omitempty"`
// Insecure determines whether to skip verifying the server's certificate chain and host name
Insecure bool `yaml:"insecure,omitempty"`
@@ -58,7 +66,16 @@ type Config struct {
// See configureOAuth2 for more details.
OAuth2Config *OAuth2Config `yaml:"oauth2,omitempty"`
// IAPConfig is the Google Cloud Identity-Aware-Proxy configuration used for the client. (e.g. audience)
IAPConfig *IAPConfig `yaml:"identity-aware-proxy,omitempty"`
httpClient *http.Client
// Network (ip, ip4 or ip6) for the ICMP client
Network string `yaml:"network"`
// TLS configuration (optional)
TLS *TLSConfig `yaml:"tls,omitempty"`
}
// DNSResolverConfig is the parsed configuration from the DNSResolver config string.
@@ -76,6 +93,22 @@ type OAuth2Config struct {
Scopes []string `yaml:"scopes"` // e.g. ["openid"]
}
// IAPConfig is the configuration for the Google Cloud Identity-Aware-Proxy
type IAPConfig struct {
Audience string `yaml:"audience"` // e.g. "toto.apps.googleusercontent.com"
}
// TLSConfig is the configuration for mTLS configurations
type TLSConfig struct {
// CertificateFile is the public certificate for TLS in PEM format.
CertificateFile string `yaml:"certificate-file,omitempty"`
// PrivateKeyFile is the private key file for TLS in PEM format.
PrivateKeyFile string `yaml:"private-key-file,omitempty"`
RenegotiationSupport string `yaml:"renegotiation,omitempty"`
}
// ValidateAndSetDefaults validates the client configuration and sets the default values if necessary
func (c *Config) ValidateAndSetDefaults() error {
if c.Timeout < time.Millisecond {
@@ -90,6 +123,14 @@ func (c *Config) ValidateAndSetDefaults() error {
if c.HasOAuth2Config() && !c.OAuth2Config.isValid() {
return ErrInvalidClientOAuth2Config
}
if c.HasIAPConfig() && !c.IAPConfig.isValid() {
return ErrInvalidClientIAPConfig
}
if c.HasTlsConfig() {
if err := c.TLS.isValid(); err != nil {
return err
}
}
return nil
}
@@ -130,13 +171,46 @@ func (c *Config) HasOAuth2Config() bool {
return c.OAuth2Config != nil
}
// HasIAPConfig returns true if the client has IAP configuration parameters
func (c *Config) HasIAPConfig() bool {
return c.IAPConfig != nil
}
// 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
}
// isValid() returns true if the IAP configuration is valid
func (c *IAPConfig) isValid() bool {
return len(c.Audience) > 0
}
// isValid() returns true if the OAuth2 configuration is valid
func (c *OAuth2Config) isValid() bool {
return len(c.TokenURL) > 0 && len(c.ClientID) > 0 && len(c.ClientSecret) > 0 && len(c.Scopes) > 0
}
// isValid() returns nil if the client tls certificates are valid, otherwise returns an error
func (t *TLSConfig) isValid() error {
if len(t.CertificateFile) > 0 && len(t.PrivateKeyFile) > 0 {
_, err := tls.LoadX509KeyPair(t.CertificateFile, t.PrivateKeyFile)
if err != nil {
return err
}
return nil
}
return ErrInvalidClientTLSConfig
}
// 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 {
tlsConfig = configureTLS(tlsConfig, *c.TLS)
}
if c.httpClient == nil {
c.httpClient = &http.Client{
Timeout: c.Timeout,
@@ -144,9 +218,7 @@ func (c *Config) getHTTPClient() *http.Client {
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20,
Proxy: http.ProxyFromEnvironment,
TLSClientConfig: &tls.Config{
InsecureSkipVerify: c.Insecure,
},
TLSClientConfig: tlsConfig,
},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if c.IgnoreRedirect {
@@ -157,12 +229,20 @@ func (c *Config) getHTTPClient() *http.Client {
return nil
},
}
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())
} else {
c.httpClient.Transport.(*http.Transport).Proxy = http.ProxyURL(proxyURL)
}
}
if c.HasCustomDNSResolver() {
dnsResolver, err := c.parseDNSResolver()
if err != nil {
// We're ignoring the error, because it should have been validated on startup ValidateAndSetDefaults.
// It shouldn't happen, but if it does, we'll log it... Better safe than sorry ;)
log.Println("[client][getHTTPClient] THIS SHOULD NOT HAPPEN. Silently ignoring invalid DNS resolver due to error:", err.Error())
log.Println("[client.getHTTPClient] THIS SHOULD NOT HAPPEN. Silently ignoring invalid DNS resolver due to error:", err.Error())
} else {
dialer := &net.Dialer{
Resolver: &net.Resolver{
@@ -178,13 +258,56 @@ func (c *Config) getHTTPClient() *http.Client {
}
}
}
if c.HasOAuth2Config() {
if c.HasOAuth2Config() && c.HasIAPConfig() {
log.Println("[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() {
c.httpClient = configureIAP(c.httpClient, *c.IAPConfig)
}
}
return c.httpClient
}
// validateIAPToken returns a boolean that will define if the google identity-aware-proxy token can be fetch
// 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())
return false
}
tok, err := ts.Token()
if err != nil {
log.Println("[client.ValidateIAPToken] Get Identity-Aware-Proxy token failed. error:", err.Error())
return false
}
payload, err := idtoken.Validate(ctx, tok.AccessToken, c.Audience)
_ = payload
if err != nil {
log.Println("[client.ValidateIAPToken] Token Validation failed. error:", err.Error())
return false
}
return true
}
// configureIAP returns an HTTP client that will obtain and refresh Identity-Aware-Proxy tokens as necessary.
// The returned Client and its Transport should not be modified.
func configureIAP(httpClient *http.Client, c IAPConfig) *http.Client {
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, httpClient)
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())
return httpClient
}
client := oauth2.NewClient(ctx, ts)
client.Timeout = httpClient.Timeout
return client
}
return httpClient
}
// configureOAuth2 returns an HTTP client that will obtain and refresh tokens as necessary.
// The returned Client and its Transport should not be modified.
func configureOAuth2(httpClient *http.Client, c OAuth2Config) *http.Client {
@@ -195,5 +318,27 @@ func configureOAuth2(httpClient *http.Client, c OAuth2Config) *http.Client {
TokenURL: c.TokenURL,
}
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, httpClient)
return oauth2cfg.Client(ctx)
client := oauth2cfg.Client(ctx)
client.Timeout = httpClient.Timeout
return client
}
// configureTLS returns a TLS Config that will enable mTLS
func configureTLS(tlsConfig *tls.Config, c TLSConfig) *tls.Config {
clientTLSCert, err := tls.LoadX509KeyPair(c.CertificateFile, c.PrivateKeyFile)
if err != nil {
return nil
}
tlsConfig.Certificates = []tls.Certificate{clientTLSCert}
tlsConfig.Renegotiation = tls.RenegotiateNever
renegotionSupport := map[string]tls.RenegotiationSupport{
"once": tls.RenegotiateOnceAsClient,
"freely": tls.RenegotiateFreelyAsClient,
"never": tls.RenegotiateNever,
}
if val, ok := renegotionSupport[c.RenegotiationSupport]; ok {
tlsConfig.Renegotiation = val
}
return tlsConfig
}

View File

@@ -2,6 +2,7 @@ package client
import (
"net/http"
"net/url"
"testing"
"time"
)
@@ -13,7 +14,7 @@ func TestConfig_getHTTPClient(t *testing.T) {
if !(insecureClient.Transport).(*http.Transport).TLSClientConfig.InsecureSkipVerify {
t.Error("expected Config.Insecure set to true to cause the HTTP client to skip certificate verification")
}
if insecureClient.Timeout != defaultHTTPTimeout {
if insecureClient.Timeout != defaultTimeout {
t.Error("expected Config.Timeout to default the HTTP client to a timeout of 10s")
}
request, _ := http.NewRequest("GET", "", nil)
@@ -79,3 +80,92 @@ func TestConfig_ValidateAndSetDefaults_withCustomDNSResolver(t *testing.T) {
})
}
}
func TestConfig_getHTTPClient_withCustomProxyURL(t *testing.T) {
proxyURL := "http://proxy.example.com:8080"
cfg := &Config{
ProxyURL: proxyURL,
}
cfg.ValidateAndSetDefaults()
client := cfg.getHTTPClient()
transport := client.Transport.(*http.Transport)
if transport.Proxy == nil {
t.Errorf("expected Config.ProxyURL to set the HTTP client's proxy to %s", proxyURL)
}
req := &http.Request{
URL: &url.URL{
Scheme: "http",
Host: "www.example.com",
},
}
expectProxyURL, err := transport.Proxy(req)
if err != nil {
t.Errorf("can't proxy the request %s", proxyURL)
}
if proxyURL != expectProxyURL.String() {
t.Errorf("expected Config.ProxyURL to set the HTTP client's proxy to %s", proxyURL)
}
}
func TestConfig_TlsIsValid(t *testing.T) {
tests := []struct {
name string
cfg *Config
expectedErr bool
}{
{
name: "good-tls-config",
cfg: &Config{TLS: &TLSConfig{CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "../testdata/cert.key"}},
expectedErr: false,
},
{
name: "missing-certificate-file",
cfg: &Config{TLS: &TLSConfig{CertificateFile: "doesnotexist", PrivateKeyFile: "../testdata/cert.key"}},
expectedErr: true,
},
{
name: "bad-certificate-file",
cfg: &Config{TLS: &TLSConfig{CertificateFile: "../testdata/badcert.pem", PrivateKeyFile: "../testdata/cert.key"}},
expectedErr: true,
},
{
name: "no-certificate-file",
cfg: &Config{TLS: &TLSConfig{CertificateFile: "", PrivateKeyFile: "../testdata/cert.key"}},
expectedErr: true,
},
{
name: "missing-private-key-file",
cfg: &Config{TLS: &TLSConfig{CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "doesnotexist"}},
expectedErr: true,
},
{
name: "no-private-key-file",
cfg: &Config{TLS: &TLSConfig{CertificateFile: "../testdata/cert.pem", PrivateKeyFile: ""}},
expectedErr: true,
},
{
name: "bad-private-key-file",
cfg: &Config{TLS: &TLSConfig{CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "../testdata/badcert.key"}},
expectedErr: true,
},
{
name: "bad-certificate-and-private-key-file",
cfg: &Config{TLS: &TLSConfig{CertificateFile: "../testdata/badcert.pem", PrivateKeyFile: "../testdata/badcert.key"}},
expectedErr: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := test.cfg.TLS.isValid()
if (err != nil) != test.expectedErr {
t.Errorf("expected the existence of an error to be %v, got %v", test.expectedErr, err)
return
}
if !test.expectedErr {
if test.cfg.TLS.isValid() != nil {
t.Error("cfg.TLS.isValid() returned an error even though no error was expected")
}
}
})
}
}

View File

@@ -1,17 +1,4 @@
endpoints:
- name: ssh
group: core
url: "ssh://example.org"
ssh:
username: "example"
password: "example"
body: |
{
"command": "uptime"
}
interval: 1m
conditions:
- "[STATUS] == 0"
- name: front-end
group: core
url: "https://twin.sh/health"
@@ -50,7 +37,7 @@ endpoints:
query-name: "example.com"
query-type: "A"
conditions:
- "[BODY] == 93.184.216.34"
- "[BODY] == 93.184.215.14"
- "[DNS_RCODE] == NOERROR"
- name: icmp-ping

View File

@@ -15,14 +15,13 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/alerting/provider"
"github.com/TwiN/gatus/v5/config/connectivity"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/maintenance"
"github.com/TwiN/gatus/v5/config/remote"
"github.com/TwiN/gatus/v5/config/ui"
"github.com/TwiN/gatus/v5/config/web"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/security"
"github.com/TwiN/gatus/v5/storage"
"github.com/TwiN/gatus/v5/util"
"gopkg.in/yaml.v3"
)
@@ -67,14 +66,17 @@ type Config struct {
// Disabling this may lead to inaccurate response times
DisableMonitoringLock bool `yaml:"disable-monitoring-lock,omitempty"`
// Security Configuration for securing access to Gatus
// Security is the configuration for securing access to Gatus
Security *security.Config `yaml:"security,omitempty"`
// Alerting Configuration for alerting
// Alerting is the configuration for alerting providers
Alerting *alerting.Config `yaml:"alerting,omitempty"`
// Endpoints List of endpoints to monitor
Endpoints []*core.Endpoint `yaml:"endpoints,omitempty"`
// Endpoints is the list of endpoints to monitor
Endpoints []*endpoint.Endpoint `yaml:"endpoints,omitempty"`
// ExternalEndpoints is the list of all external endpoints
ExternalEndpoints []*endpoint.ExternalEndpoint `yaml:"external-endpoints,omitempty"`
// Storage is the configuration for how the data is stored
Storage *storage.Config `yaml:"storage,omitempty"`
@@ -99,20 +101,29 @@ type Config struct {
lastFileModTime time.Time // last modification time
}
func (config *Config) GetEndpointByKey(key string) *core.Endpoint {
// TODO: Should probably add a mutex here to prevent concurrent access
func (config *Config) GetEndpointByKey(key string) *endpoint.Endpoint {
for i := 0; i < len(config.Endpoints); i++ {
ep := config.Endpoints[i]
if util.ConvertGroupAndEndpointNameToKey(ep.Group, ep.Name) == key {
if ep.Key() == key {
return ep
}
}
return nil
}
func (config *Config) GetExternalEndpointByKey(key string) *endpoint.ExternalEndpoint {
for i := 0; i < len(config.ExternalEndpoints); i++ {
ee := config.ExternalEndpoints[i]
if ee.Key() == key {
return ee
}
}
return nil
}
// HasLoadedConfigurationBeenModified returns whether one of the file that the
// configuration has been loaded from has been modified since it was last read
func (config Config) HasLoadedConfigurationBeenModified() bool {
func (config *Config) HasLoadedConfigurationBeenModified() bool {
lastMod := config.lastFileModTime.Unix()
fileInfo, err := os.Stat(config.configPath)
if err != nil {
@@ -125,7 +136,7 @@ func (config Config) HasLoadedConfigurationBeenModified() bool {
}
return nil
})
return err == errEarlyReturn
return errors.Is(err, errEarlyReturn)
}
return !fileInfo.ModTime().IsZero() && config.lastFileModTime.Unix() < fileInfo.ModTime().Unix()
}
@@ -135,7 +146,7 @@ func (config *Config) UpdateLastFileModTime() {
config.lastFileModTime = time.Now()
}
// LoadConfiguration loads the full configuration composed from the main configuration file
// LoadConfiguration loads the full configuration composed of the main configuration file
// and all composed configuration files
func LoadConfiguration(configPath string) (*Config, error) {
var configBytes []byte
@@ -161,13 +172,13 @@ 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)
log.Printf("[config.LoadConfiguration] Error walking path=%s: %s", path, err)
return err
}
log.Printf("[config][LoadConfiguration] Reading configuration from %s", path)
log.Printf("[config.LoadConfiguration] Reading configuration from %s", path)
data, err := os.ReadFile(path)
if err != nil {
log.Printf("[config][LoadConfiguration] Error reading configuration from %s: %s", path, err)
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)
@@ -177,7 +188,7 @@ 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", configPath)
log.Printf("[config.LoadConfiguration] Reading configuration from configFile=%s", usedConfigPath)
if data, err := os.ReadFile(usedConfigPath); err != nil {
return nil, err
} else {
@@ -234,7 +245,7 @@ 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.Debug)
validateAlertingConfig(config.Alerting, config.Endpoints, config.ExternalEndpoints, config.Debug)
if err := validateSecurityConfig(config); err != nil {
return nil, err
}
@@ -324,15 +335,37 @@ func validateWebConfig(config *Config) error {
}
func validateEndpointsConfig(config *Config) error {
for _, endpoint := range config.Endpoints {
duplicateValidationMap := make(map[string]bool)
// Validate endpoints
for _, ep := range config.Endpoints {
if config.Debug {
log.Printf("[config][validateEndpointsConfig] Validating endpoint '%s'", endpoint.Name)
log.Printf("[config.validateEndpointsConfig] Validating endpoint '%s'", ep.Name)
}
if err := endpoint.ValidateAndSetDefaults(); err != nil {
return fmt.Errorf("invalid endpoint %s: %w", endpoint.DisplayName(), err)
if endpointKey := ep.Key(); duplicateValidationMap[endpointKey] {
return fmt.Errorf("invalid endpoint %s: name and group combination must be unique", ep.Key())
} else {
duplicateValidationMap[endpointKey] = true
}
if err := ep.ValidateAndSetDefaults(); err != nil {
return fmt.Errorf("invalid endpoint %s: %w", ep.Key(), err)
}
}
log.Printf("[config][validateEndpointsConfig] Validated %d endpoints", len(config.Endpoints))
log.Printf("[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)
}
if endpointKey := ee.Key(); duplicateValidationMap[endpointKey] {
return fmt.Errorf("invalid external endpoint %s: name and group combination must be unique", ee.Key())
} else {
duplicateValidationMap[endpointKey] = true
}
if err := ee.ValidateAndSetDefaults(); err != nil {
return fmt.Errorf("invalid external endpoint %s: %w", ee.Key(), err)
}
}
log.Printf("[config.validateEndpointsConfig] Validated %d external endpoints", len(config.ExternalEndpoints))
return nil
}
@@ -340,7 +373,7 @@ 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")
log.Printf("[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
@@ -353,20 +386,24 @@ func validateSecurityConfig(config *Config) error {
// validateAlertingConfig validates the alerting configuration
// 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 core.Endpoint.ValidateAndSetDefaults()
// 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 []*core.Endpoint, debug bool) {
func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoint.Endpoint, externalEndpoints []*endpoint.ExternalEndpoint, debug bool) {
if alertingConfig == nil {
log.Printf("[config][validateAlertingConfig] Alerting is not configured")
log.Printf("[config.validateAlertingConfig] Alerting is not configured")
return
}
alertTypes := []alert.Type{
alert.TypeAWSSES,
alert.TypeCustom,
alert.TypeDiscord,
alert.TypeEmail,
alert.TypeGitHub,
alert.TypeGitLab,
alert.TypeGitea,
alert.TypeGoogleChat,
alert.TypeEmail,
alert.TypeGotify,
alert.TypeJetBrainsSpace,
alert.TypeMatrix,
alert.TypeMattermost,
alert.TypeMessagebird,
@@ -376,8 +413,10 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*core.E
alert.TypePushover,
alert.TypeSlack,
alert.TypeTeams,
alert.TypeTeamsWorkflows,
alert.TypeTelegram,
alert.TypeTwilio,
alert.TypeZulip,
}
var validProviders, invalidProviders []alert.Type
for _, alertType := range alertTypes {
@@ -386,11 +425,21 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*core.E
if alertProvider.IsValid() {
// Parse alerts with the provider's default alert
if alertProvider.GetDefaultAlert() != nil {
for _, endpoint := range endpoints {
for alertIndex, endpointAlert := range endpoint.Alerts {
for _, ep := range endpoints {
for alertIndex, endpointAlert := range ep.Alerts {
if alertType == endpointAlert.Type {
if debug {
log.Printf("[config][validateAlertingConfig] Parsing alert %d with provider's default alert for provider=%s in endpoint=%s", alertIndex, alertType, endpoint.Name)
log.Printf("[config.validateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ep.Key())
}
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())
}
provider.ParseWithDefaultAlert(alertProvider.GetDefaultAlert(), endpointAlert)
}
@@ -399,7 +448,7 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*core.E
}
validProviders = append(validProviders, alertType)
} else {
log.Printf("[config][validateAlertingConfig] Ignoring provider=%s because configuration is invalid", alertType)
log.Printf("[config.validateAlertingConfig] Ignoring provider=%s because configuration is invalid", alertType)
invalidProviders = append(invalidProviders, alertType)
alertingConfig.SetAlertingProviderToNil(alertProvider)
}
@@ -407,5 +456,5 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*core.E
invalidProviders = append(invalidProviders, alertType)
}
}
log.Printf("[config][validateAlertingConfig] configuredProviders=%s; ignoredProviders=%s", validProviders, invalidProviders)
log.Printf("[config.validateAlertingConfig] configuredProviders=%s; ignoredProviders=%s", validProviders, invalidProviders)
}

View File

@@ -16,6 +16,8 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/email"
"github.com/TwiN/gatus/v5/alerting/provider/github"
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
"github.com/TwiN/gatus/v5/alerting/provider/gotify"
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
@@ -28,13 +30,14 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/web"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/storage"
"gopkg.in/yaml.v3"
)
func TestLoadConfiguration(t *testing.T) {
yes := true
dir := t.TempDir()
scenarios := []struct {
name string
@@ -64,7 +67,7 @@ func TestLoadConfiguration(t *testing.T) {
endpoints:
- name: website`,
},
expectedError: core.ErrEndpointWithNoURL,
expectedError: endpoint.ErrEndpointWithNoURL,
},
{
name: "config-file-with-endpoint-that-has-no-conditions",
@@ -75,7 +78,7 @@ endpoints:
- name: website
url: https://twin.sh/health`,
},
expectedError: core.ErrEndpointWithNoCondition,
expectedError: endpoint.ErrEndpointWithNoCondition,
},
{
name: "config-file",
@@ -89,11 +92,11 @@ endpoints:
- "[STATUS] == 200"`,
},
expectedConfig: &Config{
Endpoints: []*core.Endpoint{
Endpoints: []*endpoint.Endpoint{
{
Name: "website",
URL: "https://twin.sh/health",
Conditions: []core.Condition{"[STATUS] == 200"},
Conditions: []endpoint.Condition{"[STATUS] == 200"},
},
},
},
@@ -135,21 +138,21 @@ endpoints:
- "[BODY].status == UP"`,
},
expectedConfig: &Config{
Endpoints: []*core.Endpoint{
Endpoints: []*endpoint.Endpoint{
{
Name: "one",
URL: "https://example.com",
Conditions: []core.Condition{"[CONNECTED] == true", "[STATUS] == 200"},
Conditions: []endpoint.Condition{"[CONNECTED] == true", "[STATUS] == 200"},
},
{
Name: "two",
URL: "https://example.org",
Conditions: []core.Condition{"len([BODY]) > 0"},
Conditions: []endpoint.Condition{"len([BODY]) > 0"},
},
{
Name: "three",
URL: "https://twin.sh/health",
Conditions: []core.Condition{"[STATUS] == 200", "[BODY].status == UP"},
Conditions: []endpoint.Condition{"[STATUS] == 200", "[BODY].status == UP"},
},
},
},
@@ -164,6 +167,8 @@ metrics: true
alerting:
slack:
webhook-url: https://hooks.slack.com/services/xxx/yyy/zzz
default-alert:
enabled: true
endpoints:
- name: example
@@ -178,6 +183,12 @@ alerting:
discord:
webhook-url: https://discord.com/api/webhooks/xxx/yyy
external-endpoints:
- name: ext-ep-test
token: "potato"
alerts:
- type: slack
endpoints:
- name: frontend
url: https://example.com
@@ -189,19 +200,32 @@ endpoints:
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"},
Slack: &slack.AlertProvider{WebhookURL: "https://hooks.slack.com/services/xxx/yyy/zzz", DefaultAlert: &alert.Alert{Enabled: &yes}},
},
Endpoints: []*core.Endpoint{
ExternalEndpoints: []*endpoint.ExternalEndpoint{
{
Name: "ext-ep-test",
Token: "potato",
Alerts: []*alert.Alert{
{
Type: alert.TypeSlack,
FailureThreshold: 3,
SuccessThreshold: 2,
},
},
},
},
Endpoints: []*endpoint.Endpoint{
{
Name: "example",
URL: "https://example.org",
Interval: 5 * time.Second,
Conditions: []core.Condition{"[STATUS] == 200"},
Conditions: []endpoint.Condition{"[STATUS] == 200"},
},
{
Name: "frontend",
URL: "https://example.com",
Conditions: []core.Condition{"[STATUS] == 200"},
Conditions: []endpoint.Condition{"[STATUS] == 200"},
},
},
},
@@ -303,11 +327,13 @@ func TestParseAndValidateConfigBytes(t *testing.T) {
storage:
type: sqlite
path: %s
maintenance:
enabled: true
start: 00:00
duration: 4h
every: [Monday, Thursday]
ui:
title: T
header: H
@@ -317,6 +343,12 @@ ui:
link: "https://example.org"
- name: "Status page"
link: "https://status.example.org"
external-endpoints:
- name: ext-ep-test
group: core
token: "potato"
endpoints:
- name: website
url: https://twin.sh/health
@@ -357,10 +389,22 @@ endpoints:
if mc := config.Maintenance; mc == nil || mc.Start != "00:00" || !mc.IsEnabled() || mc.Duration != 4*time.Hour || len(mc.Every) != 2 {
t.Error("Expected Config.Maintenance to be configured properly")
}
if len(config.ExternalEndpoints) != 1 {
t.Error("Should have returned one external endpoint")
}
if config.ExternalEndpoints[0].Name != "ext-ep-test" {
t.Errorf("Name should have been %s", "ext-ep-test")
}
if config.ExternalEndpoints[0].Group != "core" {
t.Errorf("Group should have been %s", "core")
}
if config.ExternalEndpoints[0].Token != "potato" {
t.Errorf("Token should have been %s", "potato")
}
if len(config.Endpoints) != 3 {
t.Error("Should have returned two endpoints")
}
if config.Endpoints[0].URL != "https://twin.sh/health" {
t.Errorf("URL should have been %s", "https://twin.sh/health")
}
@@ -382,7 +426,6 @@ endpoints:
if len(config.Endpoints[0].Conditions) != 1 {
t.Errorf("There should have been %d conditions", 1)
}
if config.Endpoints[1].URL != "https://api.github.com/healthz" {
t.Errorf("URL should have been %s", "https://api.github.com/healthz")
}
@@ -404,7 +447,6 @@ endpoints:
if len(config.Endpoints[1].Conditions) != 2 {
t.Errorf("There should have been %d conditions", 2)
}
if config.Endpoints[2].URL != "https://example.com/" {
t.Errorf("URL should have been %s", "https://example.com/")
}
@@ -654,8 +696,8 @@ endpoints:
if config.Endpoints[0].Interval != 60*time.Second {
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
}
if userAgent := config.Endpoints[0].Headers["User-Agent"]; userAgent != core.GatusUserAgent {
t.Errorf("User-Agent should've been %s because it's the default value, got %s", core.GatusUserAgent, userAgent)
if userAgent := config.Endpoints[0].Headers["User-Agent"]; userAgent != endpoint.GatusUserAgent {
t.Errorf("User-Agent should've been %s because it's the default value, got %s", endpoint.GatusUserAgent, userAgent)
}
}
@@ -706,6 +748,10 @@ alerting:
to: "+1-234-567-8901"
teams:
webhook-url: "http://example.com"
jetbrainsspace:
project: "foo"
channel-id: "bar"
token: "baz"
endpoints:
- name: website
@@ -728,6 +774,7 @@ endpoints:
success-threshold: 15
- type: teams
- type: pushover
- type: jetbrainsspace
conditions:
- "[STATUS] == 200"
`))
@@ -754,8 +801,8 @@ endpoints:
if config.Endpoints[0].Interval != 60*time.Second {
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
}
if len(config.Endpoints[0].Alerts) != 9 {
t.Fatal("There should've been 9 alerts configured")
if len(config.Endpoints[0].Alerts) != 10 {
t.Fatal("There should've been 10 alerts configured")
}
if config.Endpoints[0].Alerts[0].Type != alert.TypeSlack {
@@ -862,6 +909,12 @@ endpoints:
if !config.Endpoints[0].Alerts[8].IsEnabled() {
t.Error("The alert should've been enabled")
}
if config.Endpoints[0].Alerts[9].Type != alert.TypeJetBrainsSpace {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeJetBrainsSpace, config.Endpoints[0].Alerts[9].Type)
}
if !config.Endpoints[0].Alerts[9].IsEnabled() {
t.Error("The alert should've been enabled")
}
}
func TestParseAndValidateConfigBytesWithAlertingAndDefaultAlert(t *testing.T) {
@@ -878,7 +931,7 @@ alerting:
default-alert:
enabled: true
failure-threshold: 10
success-threshold: 1
success-threshold: 15
pagerduty:
integration-key: "00000000000000000000000000000000"
default-alert:
@@ -923,23 +976,57 @@ alerting:
webhook-url: "http://example.com"
default-alert:
enabled: true
jetbrainsspace:
project: "foo"
channel-id: "bar"
token: "baz"
default-alert:
enabled: true
failure-threshold: 5
success-threshold: 3
email:
from: "from@example.com"
username: "from@example.com"
password: "hunter2"
host: "mail.example.com"
port: 587
to: "recipient1@example.com,recipient2@example.com"
client:
insecure: false
default-alert:
enabled: true
gotify:
server-url: "https://gotify.example"
token: "**************"
default-alert:
enabled: true
external-endpoints:
- name: ext-ep-test
group: core
token: potato
alerts:
- type: discord
endpoints:
- name: website
url: https://twin.sh/health
alerts:
- type: slack
- type: pagerduty
- type: mattermost
- type: messagebird
- type: discord
success-threshold: 2 # test endpoint alert override
- type: telegram
- type: twilio
- type: teams
- type: pushover
conditions:
- "[STATUS] == 200"
- name: website
url: https://twin.sh/health
alerts:
- type: slack
- type: pagerduty
- type: mattermost
- type: messagebird
- type: discord
success-threshold: 8 # test endpoint alert override
- type: telegram
- type: twilio
- type: teams
- type: pushover
- type: jetbrainsspace
- type: email
- type: gotify
conditions:
- "[STATUS] == 200"
`))
if err != nil {
t.Error("expected no error, got", err.Error())
@@ -1016,6 +1103,12 @@ endpoints:
if config.Alerting.Discord.GetDefaultAlert() == nil {
t.Fatal("Discord.GetDefaultAlert() shouldn't have returned nil")
}
if config.Alerting.Discord.GetDefaultAlert().FailureThreshold != 10 {
t.Errorf("Discord default alert failure threshold should've been %d, but was %d", 10, config.Alerting.Discord.GetDefaultAlert().FailureThreshold)
}
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)
}
@@ -1049,6 +1142,83 @@ endpoints:
if config.Alerting.Teams.GetDefaultAlert() == nil {
t.Fatal("Teams.GetDefaultAlert() shouldn't have returned nil")
}
if config.Alerting.JetBrainsSpace == nil || !config.Alerting.JetBrainsSpace.IsValid() {
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.ChannelID != "bar" {
t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "bar", config.Alerting.JetBrainsSpace.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.Email == nil || !config.Alerting.Email.IsValid() {
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.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.Password != "hunter2" {
t.Errorf("Email password should've been %s, but was %s", "hunter2", config.Alerting.Email.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.Port != 587 {
t.Errorf("Email port should've been %d, but was %d", 587, config.Alerting.Email.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.ClientConfig == nil {
t.Fatal("Email client config should've been set")
}
if config.Alerting.Email.ClientConfig.Insecure {
t.Error("Email client config should've been secure")
}
if config.Alerting.Gotify == nil || !config.Alerting.Gotify.IsValid() {
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.Token != "**************" {
t.Errorf("Gotify token should've been %s, but was %s", "**************", config.Alerting.Gotify.Token)
}
// External endpoints
if len(config.ExternalEndpoints) != 1 {
t.Error("There should've been 1 external endpoint")
}
if config.ExternalEndpoints[0].Alerts[0].Type != alert.TypeDiscord {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeDiscord, config.ExternalEndpoints[0].Alerts[0].Type)
}
if !config.ExternalEndpoints[0].Alerts[0].IsEnabled() {
t.Error("The alert should've been enabled")
}
if config.ExternalEndpoints[0].Alerts[0].FailureThreshold != 10 {
t.Errorf("The failure threshold of the alert should've been %d, but it was %d", 10, config.ExternalEndpoints[0].Alerts[0].FailureThreshold)
}
if config.ExternalEndpoints[0].Alerts[0].SuccessThreshold != 15 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 15, config.ExternalEndpoints[0].Alerts[0].SuccessThreshold)
}
// Endpoints
if len(config.Endpoints) != 1 {
@@ -1060,8 +1230,8 @@ endpoints:
if config.Endpoints[0].Interval != 60*time.Second {
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
}
if len(config.Endpoints[0].Alerts) != 9 {
t.Fatal("There should've been 9 alerts configured")
if len(config.Endpoints[0].Alerts) != 12 {
t.Fatalf("There should've been 12 alerts configured, got %d", len(config.Endpoints[0].Alerts))
}
if config.Endpoints[0].Alerts[0].Type != alert.TypeSlack {
@@ -1122,8 +1292,8 @@ endpoints:
if config.Endpoints[0].Alerts[4].FailureThreshold != 10 {
t.Errorf("The failure threshold of the alert should've been %d, but it was %d", 10, config.Endpoints[0].Alerts[4].FailureThreshold)
}
if config.Endpoints[0].Alerts[4].SuccessThreshold != 2 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[4].SuccessThreshold)
if config.Endpoints[0].Alerts[4].SuccessThreshold != 8 {
t.Errorf("The default success threshold of the alert should've been %d because it was explicitly overriden, but it was %d", 8, config.Endpoints[0].Alerts[4].SuccessThreshold)
}
if config.Endpoints[0].Alerts[5].Type != alert.TypeTelegram {
@@ -1178,6 +1348,44 @@ endpoints:
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[8].SuccessThreshold)
}
if config.Endpoints[0].Alerts[9].Type != alert.TypeJetBrainsSpace {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeJetBrainsSpace, config.Endpoints[0].Alerts[9].Type)
}
if !config.Endpoints[0].Alerts[9].IsEnabled() {
t.Error("The alert should've been enabled")
}
if config.Endpoints[0].Alerts[9].FailureThreshold != 5 {
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 5, config.Endpoints[0].Alerts[9].FailureThreshold)
}
if config.Endpoints[0].Alerts[9].SuccessThreshold != 3 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[9].SuccessThreshold)
}
if config.Endpoints[0].Alerts[10].Type != alert.TypeEmail {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeEmail, config.Endpoints[0].Alerts[10].Type)
}
if !config.Endpoints[0].Alerts[10].IsEnabled() {
t.Error("The alert should've been enabled")
}
if config.Endpoints[0].Alerts[10].FailureThreshold != 3 {
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[10].FailureThreshold)
}
if config.Endpoints[0].Alerts[10].SuccessThreshold != 2 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[10].SuccessThreshold)
}
if config.Endpoints[0].Alerts[11].Type != alert.TypeGotify {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeGotify, config.Endpoints[0].Alerts[11].Type)
}
if !config.Endpoints[0].Alerts[11].IsEnabled() {
t.Error("The alert should've been enabled")
}
if config.Endpoints[0].Alerts[11].FailureThreshold != 3 {
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[11].FailureThreshold)
}
if config.Endpoints[0].Alerts[11].SuccessThreshold != 2 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[11].SuccessThreshold)
}
}
func TestParseAndValidateConfigBytesWithAlertingAndDefaultAlertAndMultipleAlertsOfSameTypeWithOverriddenParameters(t *testing.T) {
@@ -1450,6 +1658,99 @@ endpoints:
}
}
func TestParseAndValidateConfigBytesWithDuplicateEndpointName(t *testing.T) {
scenarios := []struct {
name string
shouldError bool
config string
}{
{
name: "same-name-no-group",
shouldError: true,
config: `
endpoints:
- name: ep1
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"
- name: ep1
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"`,
},
{
name: "same-name-different-group",
shouldError: false,
config: `
endpoints:
- name: ep1
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"
- name: ep1
group: g1
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"`,
},
{
name: "same-name-same-group",
shouldError: true,
config: `
endpoints:
- name: ep1
group: g1
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"
- name: ep1
group: g1
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"`,
},
{
name: "same-name-different-endpoint-type",
shouldError: true,
config: `
external-endpoints:
- name: ep1
token: "12345678"
endpoints:
- name: ep1
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"`,
},
{
name: "same-name-different-group-different-endpoint-type",
shouldError: false,
config: `
external-endpoints:
- name: ep1
group: gr1
token: "12345678"
endpoints:
- name: ep1
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"`,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
_, err := parseAndValidateConfigBytes([]byte(scenario.config))
if scenario.shouldError && err == nil {
t.Error("should've returned an error")
} else if !scenario.shouldError && err != nil {
t.Error("shouldn't have returned an error")
}
})
}
}
func TestParseAndValidateConfigBytesWithInvalidStorageConfig(t *testing.T) {
_, err := parseAndValidateConfigBytes([]byte(`
storage:
@@ -1563,29 +1864,31 @@ endpoints:
func TestParseAndValidateConfigBytesWithNoEndpoints(t *testing.T) {
_, err := parseAndValidateConfigBytes([]byte(``))
if err != ErrNoEndpointInConfig {
if !errors.Is(err, ErrNoEndpointInConfig) {
t.Error("The error returned should have been of type ErrNoEndpointInConfig")
}
}
func TestGetAlertingProviderByAlertType(t *testing.T) {
alertingConfig := &alerting.Config{
Custom: &custom.AlertProvider{},
Discord: &discord.AlertProvider{},
Email: &email.AlertProvider{},
GitHub: &github.AlertProvider{},
GoogleChat: &googlechat.AlertProvider{},
Matrix: &matrix.AlertProvider{},
Mattermost: &mattermost.AlertProvider{},
Messagebird: &messagebird.AlertProvider{},
Ntfy: &ntfy.AlertProvider{},
Opsgenie: &opsgenie.AlertProvider{},
PagerDuty: &pagerduty.AlertProvider{},
Pushover: &pushover.AlertProvider{},
Slack: &slack.AlertProvider{},
Telegram: &telegram.AlertProvider{},
Twilio: &twilio.AlertProvider{},
Teams: &teams.AlertProvider{},
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{},
}
scenarios := []struct {
alertType alert.Type
@@ -1596,6 +1899,8 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
{alertType: alert.TypeEmail, expected: alertingConfig.Email},
{alertType: alert.TypeGitHub, expected: alertingConfig.GitHub},
{alertType: alert.TypeGoogleChat, expected: alertingConfig.GoogleChat},
{alertType: alert.TypeGotify, expected: alertingConfig.Gotify},
{alertType: alert.TypeJetBrainsSpace, expected: alertingConfig.JetBrainsSpace},
{alertType: alert.TypeMatrix, expected: alertingConfig.Matrix},
{alertType: alert.TypeMattermost, expected: alertingConfig.Mattermost},
{alertType: alert.TypeMessagebird, expected: alertingConfig.Messagebird},

View File

@@ -41,7 +41,7 @@ type Checker struct {
lastCheck time.Time
}
func (c Checker) Check() bool {
func (c *Checker) Check() bool {
return client.CanCreateTCPConnection(c.Target, &client.Config{Timeout: 5 * time.Second})
}

32
config/endpoint/common.go Normal file
View File

@@ -0,0 +1,32 @@
package endpoint
import (
"errors"
"strings"
"github.com/TwiN/gatus/v5/alerting/alert"
)
var (
// ErrEndpointWithNoName is the error with which Gatus will panic if an endpoint is configured with no name
ErrEndpointWithNoName = errors.New("you must specify a name for each endpoint")
// ErrEndpointWithInvalidNameOrGroup is the error with which Gatus will panic if an endpoint has an invalid character where it shouldn't
ErrEndpointWithInvalidNameOrGroup = errors.New("endpoint name and group must not have \" or \\")
)
// validateEndpointNameGroupAndAlerts validates the name, group and alerts of an endpoint
func validateEndpointNameGroupAndAlerts(name, group string, alerts []*alert.Alert) error {
if len(name) == 0 {
return ErrEndpointWithNoName
}
if strings.ContainsAny(name, "\"\\") || strings.ContainsAny(group, "\"\\") {
return ErrEndpointWithInvalidNameOrGroup
}
for _, endpointAlert := range alerts {
if err := endpointAlert.ValidateAndSetDefaults(); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,51 @@
package endpoint
import (
"errors"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
)
func TestValidateEndpointNameGroupAndAlerts(t *testing.T) {
scenarios := []struct {
name string
group string
alerts []*alert.Alert
expectedErr error
}{
{
name: "n",
group: "g",
alerts: []*alert.Alert{{Type: "slack"}},
},
{
name: "n",
alerts: []*alert.Alert{{Type: "slack"}},
},
{
group: "g",
alerts: []*alert.Alert{{Type: "slack"}},
expectedErr: ErrEndpointWithNoName,
},
{
name: "\"",
alerts: []*alert.Alert{{Type: "slack"}},
expectedErr: ErrEndpointWithInvalidNameOrGroup,
},
{
name: "n",
group: "\\",
alerts: []*alert.Alert{{Type: "slack"}},
expectedErr: ErrEndpointWithInvalidNameOrGroup,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := validateEndpointNameGroupAndAlerts(scenario.name, scenario.group, scenario.alerts)
if !errors.Is(err, scenario.expectedErr) {
t.Errorf("expected error to be %v but got %v", scenario.expectedErr, err)
}
})
}
}

View File

@@ -1,4 +1,4 @@
package core
package endpoint
import (
"errors"
@@ -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)
//log.Printf("[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
@@ -224,6 +224,14 @@ func isEqual(first, second string) bool {
return false
}
}
// test if inputs are integers
firstInt, err1 := strconv.ParseInt(first, 0, 64)
secondInt, err2 := strconv.ParseInt(second, 0, 64)
if err1 == nil && err2 == nil {
return firstInt == secondInt
}
return first == second
}
@@ -304,7 +312,7 @@ func sanitizeAndResolveNumerical(list []string, result *Result) (parameters []st
if duration, err := time.ParseDuration(element); duration != 0 && err == nil {
// If the string is a duration, convert it to milliseconds
resolvedNumericalParameters = append(resolvedNumericalParameters, duration.Milliseconds())
} else if number, err := strconv.ParseInt(element, 10, 64); err != nil {
} else if number, err := strconv.ParseInt(element, 0, 64); err != nil {
// It's not an int, so we'll check if it's a float
if f, err := strconv.ParseFloat(element, 64); err == nil {
// It's a float, but we'll convert it to an int. We're losing precision here, but it's better than

View File

@@ -1,6 +1,8 @@
package core
package endpoint
import "testing"
import (
"testing"
)
func BenchmarkCondition_evaluateWithBodyStringAny(b *testing.B) {
condition := Condition("[BODY].name == any(john.doe, jane.doe)")

View File

@@ -1,4 +1,4 @@
package core
package endpoint
// ConditionResult result of a Condition
type ConditionResult struct {

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