Compare commits

...
47 Commits
Author SHA1 Message Date
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
ea7bf2c194 chore(deps): bump codecov/codecov-action from 4.3.0 to 4.4.0 (#765)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4.3.0 to 4.4.0.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v4.3.0...v4.4.0)

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

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

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

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

Fixes #679

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

* refactor: Move core package to config/endpoint

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

* refactor: Rename EndpointStatus to Status

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

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

* refactor: Rename parameter r to result

* refactor: Revert accidental change to endpoint.TypeDNS

* refactor: Rename parameter r to result

* refactor: Merge util package into endpoint package

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

* Update README.md

* Small adjustment

---------

Co-authored-by: TwiN <twin@linux.com>
2024-05-04 19:24:25 -04:00
TwiNandGitHub dcfdfd423e docs: Remove unnecessary badge 2024-04-27 14:39:42 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
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
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
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
TwiNandGitHub 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
TwiNandGitHub dc9cddfd77 ci: Add codecov token 2024-04-09 18:53:15 -04:00
Chris SmithGitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Bo-Yi WuTwiNSalim Bdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
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
TwiNandGitHub 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]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
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
TwiNandGitHub 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
TwiNandGitHub ceb2c7884f docs: Add discord badge 2024-03-29 21:44:38 -04:00
michael-barabooandGitHub 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]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
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]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
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 BandGitHub 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]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
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]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
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
TwiNandGitHub 1fcc6c0cc0 chore: Update Go to 1.21 2024-02-18 14:37:29 -05:00
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]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
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
TwiNandGitHub 08742e4af3 refactor(alerting): Use pointer for receiver in AlertProvider.GetDefaultAlert method (#676) 2024-02-07 20:09:45 -05:00
TwiNandGitHub 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
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
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]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
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 WuandGitHub 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]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
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
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]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
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]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
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
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
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
138 changed files with 4728 additions and 1899 deletions
@@ -32,7 +32,7 @@ endpoints:
query-name: "example.com" query-name: "example.com"
query-type: "A" query-type: "A"
conditions: conditions:
- "[BODY] == 93.184.216.34" - "[BODY] == 93.184.215.14"
- "[DNS_RCODE] == NOERROR" - "[DNS_RCODE] == NOERROR"
- name: icmp-ping - name: icmp-ping
@@ -32,7 +32,7 @@ endpoints:
query-name: "example.com" query-name: "example.com"
query-type: "A" query-type: "A"
conditions: conditions:
- "[BODY] == 93.184.216.34" - "[BODY] == 93.184.215.14"
- "[DNS_RCODE] == NOERROR" - "[DNS_RCODE] == NOERROR"
- name: icmp-ping - name: icmp-ping
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 5 timeout-minutes: 5
steps: steps:
- uses: actions/setup-go@v4 - uses: actions/setup-go@v5
with: with:
go-version: 1.19 go-version: 1.19
repository: "${{ github.event.inputs.repository || 'TwiN/gatus' }}" repository: "${{ github.event.inputs.repository || 'TwiN/gatus' }}"
+4 -3
View File
@@ -16,9 +16,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/setup-go@v4 - uses: actions/setup-go@v5
with: with:
go-version: 1.19 go-version: 1.21
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Build binary to make sure it works - name: Build binary to make sure it works
run: go build run: go build
@@ -28,6 +28,7 @@ jobs:
# was configured by the "Set up Go" step (otherwise, it'd use sudo's "go" executable) # was configured by the "Set up Go" step (otherwise, it'd use sudo's "go" executable)
run: sudo env "PATH=$PATH" "GOROOT=$GOROOT" go test ./... -race -coverprofile=coverage.txt -covermode=atomic run: sudo env "PATH=$PATH" "GOROOT=$GOROOT" go test ./... -race -coverprofile=coverage.txt -covermode=atomic
- name: Codecov - name: Codecov
uses: codecov/codecov-action@v3.1.4 uses: codecov/codecov-action@v4.4.0
with: with:
files: ./coverage.txt files: ./coverage.txt
token: ${{ secrets.CODECOV_TOKEN }}
+1
View File
@@ -3,6 +3,7 @@ FROM golang:alpine as builder
RUN apk --update add ca-certificates RUN apk --update add ca-certificates
WORKDIR /app WORKDIR /app
COPY . ./ COPY . ./
RUN go mod tidy
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o gatus . RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o gatus .
# Run Tests inside docker image if you don't have a configured go environment # Run Tests inside docker image if you don't have a configured go environment
+5 -4
View File
@@ -1,17 +1,18 @@
BINARY=gatus BINARY=gatus
# Because there's a folder called "test", we need to make the target "test" phony .PHONY: install
.PHONY: test
install: install:
go build -mod vendor -o $(BINARY) . go build -v -o $(BINARY) .
.PHONY: run
run: run:
GATUS_CONFIG_PATH=./config.yaml ./$(BINARY) GATUS_CONFIG_PATH=./config.yaml ./$(BINARY)
.PHONY: clean
clean: clean:
rm $(BINARY) rm $(BINARY)
.PHONY: test
test: test:
go test ./... -cover go test ./... -cover
+380 -140
View File
@@ -7,6 +7,7 @@
[![Docker pulls](https://img.shields.io/docker/pulls/twinproduction/gatus.svg)](https://cloud.docker.com/repository/docker/twinproduction/gatus) [![Docker pulls](https://img.shields.io/docker/pulls/twinproduction/gatus.svg)](https://cloud.docker.com/repository/docker/twinproduction/gatus)
[![Follow TwiN](https://img.shields.io/github/followers/TwiN?label=Follow&style=social)](https://github.com/TwiN) [![Follow TwiN](https://img.shields.io/github/followers/TwiN?label=Follow&style=social)](https://github.com/TwiN)
Gatus is a developer-oriented health dashboard that gives you the ability to monitor your services using HTTP, ICMP, TCP, and even DNS Gatus is a developer-oriented health dashboard that gives you the ability to monitor your services using HTTP, ICMP, TCP, and even DNS
queries as well as evaluate the result of said queries by using a list of conditions on values like the status code, queries as well as evaluate the result of said queries by using a list of conditions on values like the status code,
the response time, the certificate expiration, the body and many others. The icing on top is that each of these health the response time, the certificate expiration, the body and many others. The icing on top is that each of these health
@@ -38,23 +39,26 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
## Table of Contents ## Table of Contents
- [Table of Contents](#table-of-contents)
- [Why Gatus?](#why-gatus) - [Why Gatus?](#why-gatus)
- [Features](#features) - [Features](#features)
- [Usage](#usage) - [Usage](#usage)
- [Configuration](#configuration) - [Configuration](#configuration)
- [Endpoints](#endpoints)
- [External Endpoints](#external-endpoints)
- [Conditions](#conditions) - [Conditions](#conditions)
- [Placeholders](#placeholders) - [Placeholders](#placeholders)
- [Functions](#functions) - [Functions](#functions)
- [Storage](#storage) - [Storage](#storage)
- [Client configuration](#client-configuration) - [Client configuration](#client-configuration)
- [Alerting](#alerting) - [Alerting](#alerting)
- [Configuring AWS SES alerts](#configuring-aws-ses-alerts)
- [Configuring Discord alerts](#configuring-discord-alerts) - [Configuring Discord alerts](#configuring-discord-alerts)
- [Configuring Email alerts](#configuring-email-alerts) - [Configuring Email alerts](#configuring-email-alerts)
- [Configuring GitHub alerts](#configuring-github-alerts) - [Configuring GitHub alerts](#configuring-github-alerts)
- [Configuring GitLab alerts](#configuring-gitlab-alerts) - [Configuring GitLab alerts](#configuring-gitlab-alerts)
- [Configuring Google Chat alerts](#configuring-google-chat-alerts) - [Configuring Google Chat alerts](#configuring-google-chat-alerts)
- [Configuring Gotify alerts](#configuring-gotify-alerts) - [Configuring Gotify alerts](#configuring-gotify-alerts)
- [Configuring JetBrains Space alerts](#configuring-jetbrains-space-alerts)
- [Configuring Matrix alerts](#configuring-matrix-alerts) - [Configuring Matrix alerts](#configuring-matrix-alerts)
- [Configuring Mattermost alerts](#configuring-mattermost-alerts) - [Configuring Mattermost alerts](#configuring-mattermost-alerts)
- [Configuring Messagebird alerts](#configuring-messagebird-alerts) - [Configuring Messagebird alerts](#configuring-messagebird-alerts)
@@ -66,6 +70,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Configuring Teams alerts](#configuring-teams-alerts) - [Configuring Teams alerts](#configuring-teams-alerts)
- [Configuring Telegram alerts](#configuring-telegram-alerts) - [Configuring Telegram alerts](#configuring-telegram-alerts)
- [Configuring Twilio alerts](#configuring-twilio-alerts) - [Configuring Twilio alerts](#configuring-twilio-alerts)
- [Configuring AWS SES alerts](#configuring-aws-ses-alerts)
- [Configuring custom alerts](#configuring-custom-alerts) - [Configuring custom alerts](#configuring-custom-alerts)
- [Setting a default alert](#setting-a-default-alert) - [Setting a default alert](#setting-a-default-alert)
- [Maintenance](#maintenance) - [Maintenance](#maintenance)
@@ -101,15 +106,18 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Endpoint groups](#endpoint-groups) - [Endpoint groups](#endpoint-groups)
- [Exposing Gatus on a custom path](#exposing-gatus-on-a-custom-path) - [Exposing Gatus on a custom path](#exposing-gatus-on-a-custom-path)
- [Exposing Gatus on a custom port](#exposing-gatus-on-a-custom-port) - [Exposing Gatus on a custom port](#exposing-gatus-on-a-custom-port)
- [Configuring a startup delay](#configuring-a-startup-delay)
- [Keeping your configuration small](#keeping-your-configuration-small) - [Keeping your configuration small](#keeping-your-configuration-small)
- [Proxy client configuration](#proxy-client-configuration)
- [Badges](#badges) - [Badges](#badges)
- [Uptime](#uptime) - [Uptime](#uptime)
- [Health](#health) - [Health](#health)
- [Health (Shields.io)](#health-shieldsio)
- [Response time](#response-time) - [Response time](#response-time)
- [How to change the color thresholds of the response time badge](#how-to-change-the-color-thresholds-of-the-response-time-badge)
- [API](#api) - [API](#api)
- [Installing as binary](#installing-as-binary) - [Installing as binary](#installing-as-binary)
- [High level design overview](#high-level-design-overview) - [High level design overview](#high-level-design-overview)
- [Sponsors](#sponsors)
## Why Gatus? ## Why Gatus?
@@ -132,6 +140,7 @@ fixing the issue before they even know about it.
## Features ## Features
The main features of Gatus are: The main features of Gatus are:
- **Highly flexible health check conditions**: While checking the response status may be enough for some use cases, Gatus goes much further and allows you to add conditions on the response time, the response body and even the IP address. - **Highly flexible health check conditions**: While checking the response status may be enough for some use cases, Gatus goes much further and allows you to add conditions on the response time, the response body and even the IP address.
- **Ability to use Gatus for user acceptance tests**: Thanks to the point above, you can leverage this application to create automated user acceptance tests. - **Ability to use Gatus for user acceptance tests**: Thanks to the point above, you can leverage this application to create automated user acceptance tests.
- **Very easy to configure**: Not only is the configuration designed to be as readable as possible, it's also extremely easy to add a new service or a new endpoint to monitor. - **Very easy to configure**: Not only is the configuration designed to be as readable as possible, it's also extremely easy to add a new service or a new endpoint to monitor.
@@ -194,66 +203,116 @@ subdirectories are merged like so:
- To clarify, this also means that you could not define `alerting.slack.webhook-url` in two files with different values. All files are merged into one before they are processed. This is by design. - To clarify, this also means that you could not define `alerting.slack.webhook-url` in two files with different values. All files are merged into one before they are processed. This is by design.
> 💡 You can also use environment variables in the configuration file (e.g. `$DOMAIN`, `${DOMAIN}`) > 💡 You can also use environment variables in the configuration file (e.g. `$DOMAIN`, `${DOMAIN}`)
> >
> See [examples/docker-compose-postgres-storage/config/config.yaml](.examples/docker-compose-postgres-storage/config/config.yaml) for an example. > See [examples/docker-compose-postgres-storage/config/config.yaml](.examples/docker-compose-postgres-storage/config/config.yaml) for an example.
If you want to test it locally, see [Docker](#docker). If you want to test it locally, see [Docker](#docker).
## Configuration ## Configuration
| Parameter | Description | Default |
|:-----------------------------|:-------------------------------------------------------------------------------------------------------------------------------------|:---------------------------|
| `debug` | Whether to enable debug logs. | `false` |
| `metrics` | Whether to expose metrics at `/metrics`. | `false` |
| `storage` | [Storage configuration](#storage). | `{}` |
| `alerting` | [Alerting configuration](#alerting). | `{}` |
| `endpoints` | [Endpoints configuration](#endpoints). | Required `[]` |
| `external-endpoints` | [External Endpoints configuration](#external-endpoints). | `[]` |
| `security` | [Security configuration](#security). | `{}` |
| `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock). | `false` |
| `skip-invalid-config-update` | Whether to ignore invalid configuration update. <br />See [Reloading configuration on the fly](#reloading-configuration-on-the-fly). | `false` |
| `web` | Web configuration. | `{}` |
| `web.address` | Address to listen on. | `0.0.0.0` |
| `web.port` | Port to listen on. | `8080` |
| `web.read-buffer-size` | Buffer size for reading requests from a connection. Also limit for the maximum header size. | `8192` |
| `web.tls.certificate-file` | Optional public certificate file for TLS in PEM format. | `` |
| `web.tls.private-key-file` | Optional private key file for TLS in PEM format. | `` |
| `ui` | UI configuration. | `{}` |
| `ui.title` | [Title of the document](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/title). | `Health Dashboard ǀ Gatus` |
| `ui.description` | Meta description for the page. | `Gatus is an advanced...`. |
| `ui.header` | Header at the top of the dashboard. | `Health Status` |
| `ui.logo` | URL to the logo to display. | `""` |
| `ui.link` | Link to open when the logo is clicked. | `""` |
| `ui.buttons` | List of buttons to display below the header. | `[]` |
| `ui.buttons[].name` | Text to display on the button. | Required `""` |
| `ui.buttons[].link` | Link to open when the button is clicked. | Required `""` |
| `maintenance` | [Maintenance configuration](#maintenance). | `{}` |
### Endpoints
Endpoints are URLs, applications, or services that you want to monitor. Each endpoint has a list of conditions that are
evaluated on an interval that you define. If any condition fails, the endpoint is considered as unhealthy.
You can then configure alerts to be triggered when an endpoint is unhealthy once a certain threshold is reached.
| Parameter | Description | Default | | Parameter | Description | Default |
|:------------------------------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------|:---------------------------| |:------------------------------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------|:---------------------------|
| `debug` | Whether to enable debug logs. | `false` | | `endpoints` | List of endpoints to monitor. | Required `[]` |
| `metrics` | Whether to expose metrics at /metrics. | `false` | | `endpoints[].enabled` | Whether to monitor the endpoint. | `true` |
| `storage` | [Storage configuration](#storage) | `{}` | | `endpoints[].name` | Name of the endpoint. Can be anything. | Required `""` |
| `endpoints` | List of endpoints to monitor. | Required `[]` | | `endpoints[].group` | Group name. Used to group multiple endpoints together on the dashboard. <br />See [Endpoint groups](#endpoint-groups). | `""` |
| `endpoints[].enabled` | Whether to monitor the endpoint. | `true` | | `endpoints[].url` | URL to send the request to. | Required `""` |
| `endpoints[].name` | Name of the endpoint. Can be anything. | Required `""` | | `endpoints[].method` | Request method. | `GET` |
| `endpoints[].group` | Group name. Used to group multiple endpoints together on the dashboard. <br />See [Endpoint groups](#endpoint-groups). | `""` | | `endpoints[].conditions` | Conditions used to determine the health of the endpoint. <br />See [Conditions](#conditions). | `[]` |
| `endpoints[].url` | URL to send the request to. | Required `""` | | `endpoints[].interval` | Duration to wait between every status check. | `60s` |
| `endpoints[].method` | Request method. | `GET` | | `endpoints[].graphql` | Whether to wrap the body in a query param (`{"query":"$body"}`). | `false` |
| `endpoints[].conditions` | Conditions used to determine the health of the endpoint. <br />See [Conditions](#conditions). | `[]` | | `endpoints[].body` | Request body. | `""` |
| `endpoints[].interval` | Duration to wait between every status check. | `60s` | | `endpoints[].headers` | Request headers. | `{}` |
| `endpoints[].graphql` | Whether to wrap the body in a query param (`{"query":"$body"}`). | `false` | | `endpoints[].dns` | Configuration for an endpoint of type DNS. <br />See [Monitoring an endpoint using DNS queries](#monitoring-an-endpoint-using-dns-queries). | `""` |
| `endpoints[].body` | Request body. | `""` | | `endpoints[].dns.query-type` | Query type (e.g. MX). | `""` |
| `endpoints[].headers` | Request headers. | `{}` | | `endpoints[].dns.query-name` | Query name (e.g. example.com). | `""` |
| `endpoints[].dns` | Configuration for an endpoint of type DNS. <br />See [Monitoring an endpoint using DNS queries](#monitoring-an-endpoint-using-dns-queries). | `""` | | `endpoints[].ssh` | Configuration for an endpoint of type SSH. <br />See [Monitoring an endpoint using SSH](#monitoring-an-endpoint-using-ssh). | `""` |
| `endpoints[].dns.query-type` | Query type (e.g. MX) | `""` | | `endpoints[].ssh.username` | SSH username (e.g. example). | Required `""` |
| `endpoints[].dns.query-name` | Query name (e.g. example.com) | `""` | | `endpoints[].ssh.password` | SSH password (e.g. password). | Required `""` |
| `endpoints[].ssh` | Configuration for an endpoint of type SSH. <br />See [Monitoring an endpoint using SSH](#monitoring-an-endpoint-using-ssh). | `""` | | `endpoints[].alerts` | List of all alerts for a given endpoint. <br />See [Alerting](#alerting). | `[]` |
| `endpoints[].ssh.username` | SSH username (e.g. example) | Required `""` | | `endpoints[].client` | [Client configuration](#client-configuration). | `{}` |
| `endpoints[].ssh.password` | SSH password (e.g. password) | Required `""` | | `endpoints[].ui` | UI configuration at the endpoint level. | `{}` |
| `endpoints[].alerts[].type` | Type of alert. <br />See [Alerting](#alerting) for all valid types. | Required `""` | | `endpoints[].ui.hide-conditions` | Whether to hide conditions from the results. Note that this only hides conditions from results evaluated from the moment this was enabled. | `false` |
| `endpoints[].alerts[].enabled` | Whether to enable the alert. | `true` | | `endpoints[].ui.hide-hostname` | Whether to hide the hostname in the result. | `false` |
| `endpoints[].alerts[].failure-threshold` | Number of failures in a row needed before triggering the alert. | `3` | | `endpoints[].ui.hide-url` | Whether to ensure the URL is not displayed in the results. Useful if the URL contains a token. | `false` |
| `endpoints[].alerts[].success-threshold` | Number of successes in a row before an ongoing incident is marked as resolved. | `2` | | `endpoints[].ui.dont-resolve-failed-conditions` | Whether to resolve failed conditions for the UI. | `false` |
| `endpoints[].alerts[].send-on-resolved` | Whether to send a notification once a triggered alert is marked as resolved. | `false` | | `endpoints[].ui.badge.reponse-time` | List of response time thresholds. Each time a threshold is reached, the badge has a different color. | `[50, 200, 300, 500, 750]` |
| `endpoints[].alerts[].description` | Description of the alert. Will be included in the alert sent. | `""` |
| `endpoints[].client` | [Client configuration](#client-configuration). | `{}` |
| `endpoints[].ui` | UI configuration at the endpoint level. | `{}` | ### External Endpoints
| `endpoints[].ui.hide-hostname` | Whether to hide the hostname in the result. | `false` | Unlike regular endpoints, external endpoints are not monitored by Gatus, but they are instead pushed programmatically.
| `endpoints[].ui.hide-url` | Whether to ensure the URL is not displayed in the results. Useful if the URL contains a token. | `false` | This allows you to monitor anything you want, even when what you want to check lives in an environment that would not normally be accessible by Gatus.
| `endpoints[].ui.dont-resolve-failed-conditions` | Whether to resolve failed conditions for the UI. | `false` |
| `endpoints[].ui.badge.reponse-time` | List of response time thresholds. Each time a threshold is reached, the badge has a different color. | `[50, 200, 300, 500, 750]` | For instance:
| `alerting` | [Alerting configuration](#alerting). | `{}` | - You can create your own agent that lives in a private network and pushes the status of your services to a publicly-exposed Gatus instance
| `security` | [Security configuration](#security). | `{}` | - You can monitor services that are not supported by Gatus
| `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock). | `false` | - You can implement your own monitoring system while using Gatus as the dashboard
| `skip-invalid-config-update` | Whether to ignore invalid configuration update. <br />See [Reloading configuration on the fly](#reloading-configuration-on-the-fly). | `false` |
| `web` | Web configuration. | `{}` | | Parameter | Description | Default |
| `web.address` | Address to listen on. | `0.0.0.0` | |:-------------------------------|:-----------------------------------------------------------------------------------------------------------------------|:--------------|
| `web.port` | Port to listen on. | `8080` | | `external-endpoints` | List of endpoints to monitor. | `[]` |
| `web.tls.certificate-file` | Optional public certificate file for TLS in PEM format. | `` | | `external-endpoints[].enabled` | Whether to monitor the endpoint. | `true` |
| `web.tls.private-key-file` | Optional private key file for TLS in PEM format. | `` | | `external-endpoints[].name` | Name of the endpoint. Can be anything. | Required `""` |
| `ui` | UI configuration. | `{}` | | `external-endpoints[].group` | Group name. Used to group multiple endpoints together on the dashboard. <br />See [Endpoint groups](#endpoint-groups). | `""` |
| `ui.title` | [Title of the document](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/title). | `Health Dashboard ǀ Gatus` | | `external-endpoints[].token` | Bearer token required to push status to. | Required `""` |
| `ui.description` | Meta description for the page. | `Gatus is an advanced...`. | | `external-endpoints[].alerts` | List of all alerts for a given endpoint. <br />See [Alerting](#alerting). | `[]` |
| `ui.header` | Header at the top of the dashboard. | `Health Status` |
| `ui.logo` | URL to the logo to display. | `""` | Example:
| `ui.link` | Link to open when the logo is clicked. | `""` | ```yaml
| `ui.buttons` | List of buttons to display below the header. | `[]` | external-endpoints:
| `ui.buttons[].name` | Text to display on the button. | Required `""` | - name: ext-ep-test
| `ui.buttons[].link` | Link to open when the button is clicked. | Required `""` | group: core
| `maintenance` | [Maintenance configuration](#maintenance). | `{}` | token: "potato"
alerts:
- type: discord
description: "healthcheck failed"
send-on-resolved: true
```
To push the status of an external endpoint, the request would have to look like this:
```
POST /api/v1/endpoints/{key}/external?success={success}
```
Where:
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`.
- Using the example configuration above, the key would be `core_ext-ep-test`.
- `{success}` is a boolean (`true` or `false`) value indicating whether the health check was successful or not.
You must also pass the token as a `Bearer` token in the `Authorization` header.
### Conditions ### Conditions
@@ -346,31 +405,42 @@ See [examples/docker-compose-postgres-storage](.examples/docker-compose-postgres
In order to support a wide range of environments, each monitored endpoint has a unique configuration for In order to support a wide range of environments, each monitored endpoint has a unique configuration for
the client used to send the request. the client used to send the request.
| Parameter | Description | Default | | Parameter | Description | Default |
|:------------------------------|:---------------------------------------------------------------------------|:----------------| |:---------------------------------------|:----------------------------------------------------------------------------|:----------------|
| `client.insecure` | Whether to skip verifying the server's certificate chain and host name. | `false` | | `client.insecure` | Whether to skip verifying the server's certificate chain and host name. | `false` |
| `client.ignore-redirect` | Whether to ignore redirects (true) or follow them (false, default). | `false` | | `client.ignore-redirect` | Whether to ignore redirects (true) or follow them (false, default). | `false` |
| `client.timeout` | Duration before timing out. | `10s` | | `client.timeout` | Duration before timing out. | `10s` |
| `client.dns-resolver` | Override the DNS resolver using the format `{proto}://{host}:{port}`. | `""` | | `client.dns-resolver` | Override the DNS resolver using the format `{proto}://{host}:{port}`. | `""` |
| `client.oauth2` | OAuth2 client configuration. | `{}` | | `client.oauth2` | OAuth2 client configuration. | `{}` |
| `client.oauth2.token-url` | The token endpoint URL | required `""` | | `client.oauth2.token-url` | The token endpoint URL | required `""` |
| `client.oauth2.client-id` | The client id which should be used for the `Client credentials flow` | required `""` | | `client.oauth2.client-id` | The client id which should be used for the `Client credentials flow` | required `""` |
| `client.oauth2.client-secret` | The client secret which should be used for the `Client credentials flow` | required `""` | | `client.oauth2.client-secret` | The client secret which should be used for the `Client credentials flow` | required `""` |
| `client.oauth2.scopes[]` | A list of `scopes` which should be used for the `Client credentials flow`. | required `[""]` | | `client.oauth2.scopes[]` | A list of `scopes` which should be used for the `Client credentials flow`. | required `[""]` |
| `client.proxy-url` | The URL of the proxy to use for the client | `""` |
| `client.identity-aware-proxy` | Google Identity-Aware-Proxy client configuration. | `{}` |
| `client.identity-aware-proxy.audience` | The Identity-Aware-Proxy audience. (client-id of the IAP oauth2 credential) | required `""` |
| `client.tls.certificate-file` | Path to a client certificate (in PEM format) for mTLS configurations. | `""` |
| `client.tls.private-key-file` | Path to a client private key (in PEM format) for mTLS configurations. | `""` |
| `client.tls.renegotiation` | Type of renegotiation support to provide. (`never`, `freely`, `once`). | `"never"` |
| `client.network` | The network to use for ICMP endpoint client (`ip`, `ip4` or `ip6`). | `"ip"` |
> 📝 Some of these parameters are ignored based on the type of endpoint. For instance, there's no certificate involved > 📝 Some of these parameters are ignored based on the type of endpoint. For instance, there's no certificate involved
in ICMP requests (ping), therefore, setting `client.insecure` to `true` for an endpoint of that type will not do anything. > in ICMP requests (ping), therefore, setting `client.insecure` to `true` for an endpoint of that type will not do anything.
This default configuration is as follows: This default configuration is as follows:
```yaml ```yaml
client: client:
insecure: false insecure: false
ignore-redirect: false ignore-redirect: false
timeout: 10s timeout: 10s
``` ```
Note that this configuration is only available under `endpoints[]`, `alerting.mattermost` and `alerting.custom`. Note that this configuration is only available under `endpoints[]`, `alerting.mattermost` and `alerting.custom`.
Here's an example with the client configuration under `endpoints[]`: Here's an example with the client configuration under `endpoints[]`:
```yaml ```yaml
endpoints: endpoints:
- name: website - name: website
@@ -384,6 +454,7 @@ endpoints:
``` ```
This example shows how you can specify a custom DNS resolver: This example shows how you can specify a custom DNS resolver:
```yaml ```yaml
endpoints: endpoints:
- name: with-custom-dns-resolver - name: with-custom-dns-resolver
@@ -395,6 +466,7 @@ endpoints:
``` ```
This example shows how you can use the `client.oauth2` configuration to query a backend API with `Bearer token`: This example shows how you can use the `client.oauth2` configuration to query a backend API with `Bearer token`:
```yaml ```yaml
endpoints: endpoints:
- name: with-custom-oauth2 - name: with-custom-oauth2
@@ -409,46 +481,103 @@ endpoints:
- "[STATUS] == 200" - "[STATUS] == 200"
``` ```
This example shows how you can use the `client.identity-aware-proxy` configuration to query a backend API with `Bearer token` using Google Identity-Aware-Proxy:
```yaml
endpoints:
- name: with-custom-iap
url: "https://my.iap.protected.app/health"
client:
identity-aware-proxy:
audience: "XXXXXXXX-XXXXXXXXXXXX.apps.googleusercontent.com"
conditions:
- "[STATUS] == 200"
```
> 📝 Note that Gatus will use the [gcloud default credentials](https://cloud.google.com/docs/authentication/application-default-credentials) within its environment to generate the token.
This example shows you how you cna use the `client.tls` configuration to perform an mTLS query to a backend API:
```yaml
endpoints:
- name: website
url: "https://your.mtls.protected.app/health"
client:
tls:
certificate-file: /path/to/user_cert.pem
private-key-file: /path/to/user_key.pem
renegotiation: once
conditions:
- "[STATUS] == 200"
```
> 📝 Note that if running in a container, you must volume mount the certificate and key into the container.
### Alerting ### Alerting
Gatus supports multiple alerting providers, such as Slack and PagerDuty, and supports different alerts for each Gatus supports multiple alerting providers, such as Slack and PagerDuty, and supports different alerts for each
individual endpoints with configurable descriptions and thresholds. individual endpoints with configurable descriptions and thresholds.
> 📝 If an alerting provider is not properly configured, all alerts configured with the provider's type will be Alerts are configured at the endpoint level like so:
ignored.
| Parameter | Description | Default | | Parameter | Description | Default |
|:-----------------------|:-----------------------------------------------------------------------------------------------------------------------------|:--------| |:-----------------------------|:-------------------------------------------------------------------------------|:--------------|
| `alerting.custom` | Configuration for custom actions on failure or alerts. <br />See [Configuring Custom alerts](#configuring-custom-alerts). | `{}` | | `alerts` | List of all alerts for a given endpoint. | `[]` |
| `alerting.discord` | Configuration for alerts of type `discord`. <br />See [Configuring Discord alerts](#configuring-discord-alerts). | `{}` | | `alerts[].type` | Type of alert. <br />See table below for all valid types. | Required `""` |
| `alerting.email` | Configuration for alerts of type `email`. <br />See [Configuring Email alerts](#configuring-email-alerts). | `{}` | | `alerts[].enabled` | Whether to enable the alert. | `true` |
| `alerting.github` | Configuration for alerts of type `github`. <br />See [Configuring GitHub alerts](#configuring-github-alerts). | `{}` | | `alerts[].failure-threshold` | Number of failures in a row needed before triggering the alert. | `3` |
| `alerting.gitlab` | Configuration for alerts of type `gitlab`. <br />See [Configuring GitLab alerts](#configuring-gitlab-alerts). | `{}` | | `alerts[].success-threshold` | Number of successes in a row before an ongoing incident is marked as resolved. | `2` |
| `alerting.googlechat` | Configuration for alerts of type `googlechat`. <br />See [Configuring Google Chat alerts](#configuring-google-chat-alerts). | `{}` | | `alerts[].send-on-resolved` | Whether to send a notification once a triggered alert is marked as resolved. | `false` |
| `alerting.gotify` | Configuration for alerts of type `gotify`. <br />See [Configuring Gotify alerts](#configuring-gotify-alerts). | `{}` | | `alerts[].description` | Description of the alert. Will be included in the alert sent. | `""` |
| `alerting.matrix` | Configuration for alerts of type `matrix`. <br />See [Configuring Matrix alerts](#configuring-matrix-alerts). | `{}` |
| `alerting.mattermost` | Configuration for alerts of type `mattermost`. <br />See [Configuring Mattermost alerts](#configuring-mattermost-alerts). | `{}` | Here's an example of what an alert configuration might look like at the endpoint level:
| `alerting.messagebird` | Configuration for alerts of type `messagebird`. <br />See [Configuring Messagebird alerts](#configuring-messagebird-alerts). | `{}` | ```yaml
| `alerting.ntfy` | Configuration for alerts of type `ntfy`. <br />See [Configuring Ntfy alerts](#configuring-ntfy-alerts). | `{}` | endpoints:
| `alerting.opsgenie` | Configuration for alerts of type `opsgenie`. <br />See [Configuring Opsgenie alerts](#configuring-opsgenie-alerts). | `{}` | - name: example
| `alerting.pagerduty` | Configuration for alerts of type `pagerduty`. <br />See [Configuring PagerDuty alerts](#configuring-pagerduty-alerts). | `{}` | url: "https://example.org"
| `alerting.pushover` | Configuration for alerts of type `pushover`. <br />See [Configuring Pushover alerts](#configuring-pushover-alerts). | `{}` | conditions:
| `alerting.slack` | Configuration for alerts of type `slack`. <br />See [Configuring Slack alerts](#configuring-slack-alerts). | `{}` | - "[STATUS] == 200"
| `alerting.teams` | Configuration for alerts of type `teams`. <br />See [Configuring Teams alerts](#configuring-teams-alerts). | `{}` | alerts:
| `alerting.telegram` | Configuration for alerts of type `telegram`. <br />See [Configuring Telegram alerts](#configuring-telegram-alerts). | `{}` | - type: slack
| `alerting.twilio` | Settings for alerts of type `twilio`. <br />See [Configuring Twilio alerts](#configuring-twilio-alerts). | `{}` | description: "healthcheck failed"
send-on-resolved: true
```
> 📝 If an alerting provider is not properly configured, all alerts configured with the provider's type will be
> ignored.
| Parameter | Description | Default |
|:--------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------|:--------|
| `alerting.custom` | Configuration for custom actions on failure or alerts. <br />See [Configuring Custom alerts](#configuring-custom-alerts). | `{}` |
| `alerting.discord` | Configuration for alerts of type `discord`. <br />See [Configuring Discord alerts](#configuring-discord-alerts). | `{}` |
| `alerting.email` | Configuration for alerts of type `email`. <br />See [Configuring Email alerts](#configuring-email-alerts). | `{}` |
| `alerting.github` | Configuration for alerts of type `github`. <br />See [Configuring GitHub alerts](#configuring-github-alerts). | `{}` |
| `alerting.gitlab` | Configuration for alerts of type `gitlab`. <br />See [Configuring GitLab alerts](#configuring-gitlab-alerts). | `{}` |
| `alerting.googlechat` | Configuration for alerts of type `googlechat`. <br />See [Configuring Google Chat alerts](#configuring-google-chat-alerts). | `{}` |
| `alerting.gotify` | Configuration for alerts of type `gotify`. <br />See [Configuring Gotify alerts](#configuring-gotify-alerts). | `{}` |
| `alerting.jetbrainsspace` | Configuration for alerts of type `jetbrainsspace`. <br />See [Configuring JetBrains Space alerts](#configuring-jetbrains-space-alerts). | `{}` |
| `alerting.matrix` | Configuration for alerts of type `matrix`. <br />See [Configuring Matrix alerts](#configuring-matrix-alerts). | `{}` |
| `alerting.mattermost` | Configuration for alerts of type `mattermost`. <br />See [Configuring Mattermost alerts](#configuring-mattermost-alerts). | `{}` |
| `alerting.messagebird` | Configuration for alerts of type `messagebird`. <br />See [Configuring Messagebird alerts](#configuring-messagebird-alerts). | `{}` |
| `alerting.ntfy` | Configuration for alerts of type `ntfy`. <br />See [Configuring Ntfy alerts](#configuring-ntfy-alerts). | `{}` |
| `alerting.opsgenie` | Configuration for alerts of type `opsgenie`. <br />See [Configuring Opsgenie alerts](#configuring-opsgenie-alerts). | `{}` |
| `alerting.pagerduty` | Configuration for alerts of type `pagerduty`. <br />See [Configuring PagerDuty alerts](#configuring-pagerduty-alerts). | `{}` |
| `alerting.pushover` | Configuration for alerts of type `pushover`. <br />See [Configuring Pushover alerts](#configuring-pushover-alerts). | `{}` |
| `alerting.slack` | Configuration for alerts of type `slack`. <br />See [Configuring Slack alerts](#configuring-slack-alerts). | `{}` |
| `alerting.teams` | Configuration for alerts of type `teams`. <br />See [Configuring Teams alerts](#configuring-teams-alerts). | `{}` |
| `alerting.telegram` | Configuration for alerts of type `telegram`. <br />See [Configuring Telegram alerts](#configuring-telegram-alerts). | `{}` |
| `alerting.twilio` | Settings for alerts of type `twilio`. <br />See [Configuring Twilio alerts](#configuring-twilio-alerts). | `{}` |
#### Configuring Discord alerts #### Configuring Discord alerts
| Parameter | Description | Default | | Parameter | Description | Default |
|:-------------------------------------------|:-------------------------------------------------------------------------------------------|:--------------| |:-------------------------------------------|:-------------------------------------------------------------------------------------------|:------------------------------------|
| `alerting.discord` | Configuration for alerts of type `discord` | `{}` | | `alerting.discord` | Configuration for alerts of type `discord` | `{}` |
| `alerting.discord.webhook-url` | Discord Webhook URL | Required `""` | | `alerting.discord.webhook-url` | Discord Webhook URL | Required `""` |
| `alerting.discord.title` | Title of the notification | `":helmet_with_white_cross: Gatus"` | | `alerting.discord.title` | Title of the notification | `":helmet_with_white_cross: Gatus"` |
| `alerting.discord.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A | | `alerting.discord.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.discord.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | | `alerting.discord.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.discord.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | | `alerting.discord.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.discord.overrides[].webhook-url` | Discord Webhook URL | `""` | | `alerting.discord.overrides[].webhook-url` | Discord Webhook URL | `""` |
```yaml ```yaml
alerting: alerting:
@@ -568,17 +697,18 @@ endpoints:
![GitHub alert](.github/assets/github-alerts.png) ![GitHub alert](.github/assets/github-alerts.png)
#### Configuring GitLab alerts #### Configuring GitLab alerts
| Parameter | Description | Default | | Parameter | Description | Default |
|:------------------------------------|:----------------------------------------------------------------------------------------------------------------|:--------------| |:------------------------------------|:--------------------------------------------------------------------------------------------------------------------|:--------------|
| `alerting.gitlab` | Configuration for alerts of type `gitlab` | `{}` | | `alerting.gitlab` | Configuration for alerts of type `gitlab` | `{}` |
| `alerting.gitlab.webhook-url` | GitLab alert webhook URL (e.g. `https://gitlab.com/hlidotbe/example/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json`) | Required `""` | | `alerting.gitlab.webhook-url` | GitLab alert webhook URL (e.g. `https://gitlab.com/yourusername/example/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json`) | Required `""` |
| `alerting.gitlab.authorization-key` | Personal access token to use for authentication. <br />Must have at least RW on issues and RO on metadata. | Required `""` | | `alerting.gitlab.authorization-key` | GitLab alert authorization key. | Required `""` |
| `alerting.gitlab.severity` | Override default severity (critical), can be one of `critical, high, medium, low, info, unknown` | `""` | | `alerting.gitlab.severity` | Override default severity (critical), can be one of `critical, high, medium, low, info, unknown` | `""` |
| `alerting.gitlab.monitoring-tool` | Override the monitoring tool name (gatus) | `"gatus"` | | `alerting.gitlab.monitoring-tool` | Override the monitoring tool name (gatus) | `"gatus"` |
| `alerting.gitlab.environment-name` | Set gitlab environment's name. Required to display alerts on a dashboard. | `""` | | `alerting.gitlab.environment-name` | Set gitlab environment's name. Required to display alerts on a dashboard. | `""` |
| `alerting.gitlab.service` | Override endpoint displayname | `""` | | `alerting.gitlab.service` | Override endpoint display name | `""` |
| `alerting.gitlab.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert). | N/A | | `alerting.gitlab.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert). | N/A |
The GitLab alerting provider creates an alert prefixed with `alert(gatus):` and suffixed with the endpoint's display The GitLab alerting provider creates an alert prefixed with `alert(gatus):` and suffixed with the endpoint's display
name for each alert. If `send-on-resolved` is set to `true` on the endpoint alert, the alert will be automatically name for each alert. If `send-on-resolved` is set to `true` on the endpoint alert, the alert will be automatically
@@ -642,14 +772,14 @@ endpoints:
#### Configuring Gotify alerts #### Configuring Gotify alerts
| Parameter | Description | Default | | Parameter | Description | Default |
|:----------------------------------------------|:--------------------------------------------------------------------------------------------|:-----------------------| |:----------------------------------------------|:--------------------------------------------------------------------------------------------|:----------------------|
| `alerting.gotify` | Configuration for alerts of type `gotify` | `{}` | | `alerting.gotify` | Configuration for alerts of type `gotify` | `{}` |
| `alerting.gotify.server-url` | Gotify server URL | Required `""` | | `alerting.gotify.server-url` | Gotify server URL | Required `""` |
| `alerting.gotify.token` | Token that is used for authentication. | Required `""` | | `alerting.gotify.token` | Token that is used for authentication. | Required `""` |
| `alerting.gotify.priority` | Priority of the alert according to Gotify standarts. | `5` | | `alerting.gotify.priority` | Priority of the alert according to Gotify standards. | `5` |
| `alerting.gotify.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert). | N/A | | `alerting.gotify.title` | Title of the notification | `"Gatus: <endpoint>"` |
| `alerting.gotify.title` | Title of the notification | `"Gatus: <endpoint>"` | | `alerting.gotify.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert). | N/A |
```yaml ```yaml
alerting: alerting:
@@ -676,6 +806,41 @@ Here's an example of what the notifications look like:
![Gotify notifications](.github/assets/gotify-alerts.png) ![Gotify notifications](.github/assets/gotify-alerts.png)
#### Configuring JetBrains Space alerts
| Parameter | Description | Default |
|:---------------------------------------------------|:--------------------------------------------------------------------------------------------|:-----------------------|
| `alerting.jetbrainsspace` | Configuration for alerts of type `jetbrainsspace` | `{}` |
| `alerting.jetbrainsspace.project` | JetBrains Space project name | Required `""` |
| `alerting.jetbrainsspace.channel-id` | JetBrains Space Chat Channel ID | Required `""` |
| `alerting.jetbrainsspace.token` | Token that is used for authentication. | Required `""` |
| `alerting.jetbrainsspace.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.jetbrainsspace.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.jetbrainsspace.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
```yaml
alerting:
jetbrainsspace:
project: myproject
channel-id: ABCDE12345
token: "**************"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
alerts:
- type: jetbrainsspace
description: "healthcheck failed"
send-on-resolved: true
```
Here's an example of what the notifications look like:
![JetBrains Space notifications](.github/assets/jetbrains-space-alerts.png)
#### Configuring Matrix alerts #### Configuring Matrix alerts
| Parameter | Description | Default | | Parameter | Description | Default |
|:-----------------------------------------|:-------------------------------------------------------------------------------------------|:-----------------------------------| |:-----------------------------------------|:-------------------------------------------------------------------------------------------|:-----------------------------------|
@@ -928,6 +1093,7 @@ endpoints:
description: "healthcheck failed" description: "healthcheck failed"
``` ```
#### Configuring Slack alerts #### Configuring Slack alerts
| Parameter | Description | Default | | Parameter | Description | Default |
|:------------------------------------------|:-------------------------------------------------------------------------------------------|:--------------| |:------------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
@@ -967,14 +1133,15 @@ Here's an example of what the notifications look like:
#### Configuring Teams alerts #### Configuring Teams alerts
| Parameter | Description | Default | | Parameter | Description | Default |
|:-----------------------------------------|:-------------------------------------------------------------------------------------------|:--------------| |:-----------------------------------------|:-------------------------------------------------------------------------------------------|:--------------------|
| `alerting.teams` | Configuration for alerts of type `teams` | `{}` | | `alerting.teams` | Configuration for alerts of type `teams` | `{}` |
| `alerting.teams.webhook-url` | Teams Webhook URL | Required `""` | | `alerting.teams.webhook-url` | Teams Webhook URL | Required `""` |
| `alerting.teams.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A | | `alerting.teams.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.teams.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | | `alerting.teams.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.teams.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | | `alerting.teams.title` | Title of the notification | `"&#x1F6A8; Gatus"` |
| `alerting.teams.overrides[].webhook-url` | Teams Webhook URL | `""` | | `alerting.teams.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.teams.overrides[].webhook-url` | Teams Webhook URL | `""` |
```yaml ```yaml
alerting: alerting:
@@ -1338,7 +1505,7 @@ security:
``` ```
> ⚠ Make sure to carefully select to cost of the bcrypt hash. The higher the cost, the longer it takes to compute the hash, > ⚠ Make sure to carefully select to cost of the bcrypt hash. The higher the cost, the longer it takes to compute the hash,
and basic auth verifies the password against the hash on every request. As of 2023-01-06, I suggest a cost of 9. > and basic auth verifies the password against the hash on every request. As of 2023-01-06, I suggest a cost of 9.
#### OIDC #### OIDC
@@ -1366,6 +1533,7 @@ security:
Confused? Read [Securing Gatus with OIDC using Auth0](https://twin.sh/articles/56/securing-gatus-with-oidc-using-auth0). Confused? Read [Securing Gatus with OIDC using Auth0](https://twin.sh/articles/56/securing-gatus-with-oidc-using-auth0).
### TLS Encryption ### TLS Encryption
Gatus supports basic encryption with TLS. To enable this, certificate files in PEM format have to be provided. Gatus supports basic encryption with TLS. To enable this, certificate files in PEM format have to be provided.
@@ -1378,6 +1546,7 @@ web:
private-key-file: "private.key" private-key-file: "private.key"
``` ```
### Metrics ### Metrics
To enable metrics, you must set `metrics` to `true`. Doing so will expose Prometheus-friendly metrics at the `/metrics` To enable metrics, you must set `metrics` to `true`. Doing so will expose Prometheus-friendly metrics at the `/metrics`
endpoint on the same port your application is configured to run on (`web.port`). endpoint on the same port your application is configured to run on (`web.port`).
@@ -1402,7 +1571,7 @@ See [examples/docker-compose-grafana-prometheus](.examples/docker-compose-grafan
| `connectivity.checker.interval` | Interval at which to validate connectivity | `1m` | | `connectivity.checker.interval` | Interval at which to validate connectivity | `1m` |
While Gatus is used to monitor other services, it is possible for Gatus itself to lose connectivity to the internet. While Gatus is used to monitor other services, it is possible for Gatus itself to lose connectivity to the internet.
In order to prevent Gatus from reporting endpoints as unhealthy when Gatus itself is unhealthy, you may configure In order to prevent Gatus from reporting endpoints as unhealthy when Gatus itself is unhealthy, you may configure
Gatus to periodically check for internet connectivity. Gatus to periodically check for internet connectivity.
All endpoint executions are skipped while the connectivity checker deems connectivity to be down. All endpoint executions are skipped while the connectivity checker deems connectivity to be down.
@@ -1420,7 +1589,7 @@ This feature allows you to retrieve endpoint statuses from a remote Gatus instan
There are two main use cases for this: There are two main use cases for this:
- You have multiple Gatus instances running on different machines, and you wish to visually expose the statuses through a single dashboard - You have multiple Gatus instances running on different machines, and you wish to visually expose the statuses through a single dashboard
- You have one or more Gatus instances that are not publicly accessible (e.g. behind a firewall), and you wish to retrieve - You have one or more Gatus instances that are not publicly accessible (e.g. behind a firewall), and you wish to retrieve
This is an experimental feature. It may be removed or updated in a breaking manner at any time. Furthermore, This is an experimental feature. It may be removed or updated in a breaking manner at any time. Furthermore,
there are known issues with this feature. If you'd like to provide some feedback, please write a comment in [#64](https://github.com/TwiN/gatus/issues/64). there are known issues with this feature. If you'd like to provide some feedback, please write a comment in [#64](https://github.com/TwiN/gatus/issues/64).
@@ -1480,7 +1649,7 @@ helm repo add minicloudlabs https://minicloudlabs.github.io/helm-charts
``` ```
To get more details, please check [chart's configuration](https://github.com/minicloudlabs/helm-charts/tree/main/charts/gatus#configuration) To get more details, please check [chart's configuration](https://github.com/minicloudlabs/helm-charts/tree/main/charts/gatus#configuration)
and [helmfile example](https://github.com/minicloudlabs/helm-charts/tree/main/charts/gatus#helmfileyaml-example) and [helm file example](https://github.com/minicloudlabs/helm-charts/tree/main/charts/gatus#helmfileyaml-example)
### Terraform ### Terraform
@@ -1489,7 +1658,7 @@ Gatus can be deployed on Terraform by using the following module: [terraform-kub
## Running the tests ## Running the tests
```console ```console
go test ./... -mod vendor go test -v ./...
``` ```
@@ -1586,8 +1755,9 @@ Placeholders `[STATUS]` and `[BODY]` as well as the fields `endpoints[].body`, `
This works for applications such as databases (Postgres, MySQL, etc.) and caches (Redis, Memcached, etc.). This works for applications such as databases (Postgres, MySQL, etc.) and caches (Redis, Memcached, etc.).
> 📝 `[CONNECTED] == true` does not guarantee that the endpoint itself is healthy - it only guarantees that there's > 📝 `[CONNECTED] == true` does not guarantee that the endpoint itself is healthy - it only guarantees that there's
something at the given address listening to the given port, and that a connection to that address was successfully > something at the given address listening to the given port, and that a connection to that address was successfully
established. > established.
### Monitoring a UDP endpoint ### Monitoring a UDP endpoint
By prefixing `endpoints[].url` with `udp:\\`, you can monitor UDP endpoints at a very basic level: By prefixing `endpoints[].url` with `udp:\\`, you can monitor UDP endpoints at a very basic level:
@@ -1605,6 +1775,7 @@ Placeholders `[STATUS]` and `[BODY]` as well as the fields `endpoints[].body`, `
This works for UDP based application. This works for UDP based application.
### Monitoring a SCTP endpoint ### Monitoring a SCTP endpoint
By prefixing `endpoints[].url` with `sctp:\\`, you can monitor Stream Control Transmission Protocol (SCTP) endpoints at a very basic level: By prefixing `endpoints[].url` with `sctp:\\`, you can monitor Stream Control Transmission Protocol (SCTP) endpoints at a very basic level:
@@ -1621,6 +1792,7 @@ Placeholders `[STATUS]` and `[BODY]` as well as the fields `endpoints[].body`, `
This works for SCTP based application. This works for SCTP based application.
### Monitoring a WebSocket endpoint ### Monitoring a WebSocket endpoint
By prefixing `endpoints[].url` with `ws://` or `wss://`, you can monitor WebSocket endpoints at a very basic level: By prefixing `endpoints[].url` with `ws://` or `wss://`, you can monitor WebSocket endpoints at a very basic level:
@@ -1637,6 +1809,7 @@ endpoints:
The `[BODY]` placeholder contains the output of the query, and `[CONNECTED]` The `[BODY]` placeholder contains the output of the query, and `[CONNECTED]`
shows whether the connection was successfully established. shows whether the connection was successfully established.
### Monitoring an endpoint using ICMP ### Monitoring an endpoint using ICMP
By prefixing `endpoints[].url` with `icmp:\\`, you can monitor endpoints at a very basic level using ICMP, or more By prefixing `endpoints[].url` with `icmp:\\`, you can monitor endpoints at a very basic level using ICMP, or more
commonly known as "ping" or "echo": commonly known as "ping" or "echo":
@@ -1655,6 +1828,7 @@ You can specify a domain prefixed by `icmp://`, or an IP address prefixed by `ic
If you run Gatus on Linux, please read the Linux section on https://github.com/prometheus-community/pro-bing#linux If you run Gatus on Linux, please read the Linux section on https://github.com/prometheus-community/pro-bing#linux
if you encounter any problems. if you encounter any problems.
### Monitoring an endpoint using DNS queries ### Monitoring an endpoint using DNS queries
Defining a `dns` configuration in an endpoint will automatically mark said endpoint as an endpoint of type DNS: Defining a `dns` configuration in an endpoint will automatically mark said endpoint as an endpoint of type DNS:
```yaml ```yaml
@@ -1665,7 +1839,7 @@ endpoints:
query-name: "example.com" query-name: "example.com"
query-type: "A" query-type: "A"
conditions: conditions:
- "[BODY] == 93.184.216.34" - "[BODY] == 93.184.215.14"
- "[DNS_RCODE] == NOERROR" - "[DNS_RCODE] == NOERROR"
``` ```
@@ -1674,6 +1848,7 @@ There are two placeholders that can be used in the conditions for endpoints of t
- The placeholder `[DNS_RCODE]` resolves to the name associated to the response code returned by the query, such as - The placeholder `[DNS_RCODE]` resolves to the name associated to the response code returned by the query, such as
`NOERROR`, `FORMERR`, `SERVFAIL`, `NXDOMAIN`, etc. `NOERROR`, `FORMERR`, `SERVFAIL`, `NXDOMAIN`, etc.
### Monitoring an endpoint using SSH ### Monitoring an endpoint using SSH
You can monitor endpoints using SSH by prefixing `endpoints[].url` with `ssh:\\`: You can monitor endpoints using SSH by prefixing `endpoints[].url` with `ssh:\\`:
```yaml ```yaml
@@ -1683,7 +1858,7 @@ endpoints:
ssh: ssh:
username: "username" username: "username"
password: "password" password: "password"
body: | body: |
{ {
"command": "uptime" "command": "uptime"
} }
@@ -1697,6 +1872,7 @@ The following placeholders are supported for endpoints of type SSH:
- `[CONNECTED]` resolves to `true` if the SSH connection was successful, `false` otherwise - `[CONNECTED]` resolves to `true` if the SSH connection was successful, `false` otherwise
- `[STATUS]` resolves the exit code of the command executed on the remote server (e.g. `0` for success) - `[STATUS]` resolves the exit code of the command executed on the remote server (e.g. `0` for success)
### Monitoring an endpoint using STARTTLS ### Monitoring an endpoint using STARTTLS
If you have an email server that you want to ensure there are no problems with, monitoring it through STARTTLS If you have an email server that you want to ensure there are no problems with, monitoring it through STARTTLS
will serve as a good initial indicator: will serve as a good initial indicator:
@@ -1712,6 +1888,7 @@ endpoints:
- "[CERTIFICATE_EXPIRATION] > 48h" - "[CERTIFICATE_EXPIRATION] > 48h"
``` ```
### Monitoring an endpoint using TLS ### Monitoring an endpoint using TLS
Monitoring endpoints using SSL/TLS encryption, such as LDAP over TLS, can help detect certificate expiration: Monitoring endpoints using SSL/TLS encryption, such as LDAP over TLS, can help detect certificate expiration:
```yaml ```yaml
@@ -1741,9 +1918,9 @@ endpoints:
``` ```
> ⚠ The usage of the `[DOMAIN_EXPIRATION]` placeholder requires Gatus to send a request to the official IANA WHOIS service [through a library](https://github.com/TwiN/whois) > ⚠ The usage of the `[DOMAIN_EXPIRATION]` placeholder requires Gatus to send a request to the official IANA WHOIS service [through a library](https://github.com/TwiN/whois)
and in some cases, a secondary request to a TLD-specific WHOIS server (e.g. `whois.nic.sh`). > and in some cases, a secondary request to a TLD-specific WHOIS server (e.g. `whois.nic.sh`).
To prevent the WHOIS service from throttling your IP address if you send too many requests, Gatus will prevent you from > To prevent the WHOIS service from throttling your IP address if you send too many requests, Gatus will prevent you from
using the `[DOMAIN_EXPIRATION]` placeholder on an endpoint with an interval of less than `5m`. > using the `[DOMAIN_EXPIRATION]` placeholder on an endpoint with an interval of less than `5m`.
### disable-monitoring-lock ### disable-monitoring-lock
@@ -1850,6 +2027,10 @@ web:
``` ```
### Configuring a startup delay
If, for any reason, you need Gatus to wait for a given amount of time before monitoring the endpoints on application start, you can use the `GATUS_DELAY_START_SECONDS` environment variable to make Gatus sleep on startup.
### Keeping your configuration small ### Keeping your configuration small
While not specific to Gatus, you can leverage YAML anchors to create a default configuration. While not specific to Gatus, you can leverage YAML anchors to create a default configuration.
If you have a large configuration file, this should help you keep things clean. If you have a large configuration file, this should help you keep things clean.
@@ -1873,7 +2054,7 @@ endpoints:
url: "https://example.org" url: "https://example.org"
- name: anchor-example-2 - name: anchor-example-2
<<: *defaults <<: *defaults
group: example # This will override the group defined in &defaults group: example # This will override the group defined in &defaults
url: "https://example.com" url: "https://example.com"
@@ -1887,6 +2068,45 @@ endpoints:
</details> </details>
### Proxy client configuration
You can configure a proxy for the client to use by setting the `proxy-url` parameter in the client configuration.
```yaml
endpoints:
- name: website
url: "https://twin.sh/health"
client:
proxy-url: http://proxy.example.com:8080
conditions:
- "[STATUS] == 200"
```
### Proxy client configuration
You can configure a proxy for the client to use by setting the `proxy-url` parameter in the client configuration.
```yaml
endpoints:
- name: website
url: "https://twin.sh/health"
client:
proxy-url: http://proxy.example.com:8080
conditions:
- "[STATUS] == 200"
```
### How to fix 431 Request Header Fields Too Large error
Depending on where your environment is deployed and what kind of middleware or reverse proxy sits in front of Gatus,
you may run into this issue. This could be because the request headers are too large, e.g. big cookies.
By default, `web.read-buffer-size` is set to `8192`, but increasing this value like so will increase the read buffer size:
```yaml
web:
read-buffer-size: 32768
```
### Badges ### Badges
#### Uptime #### Uptime
![Uptime 1h](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/1h/badge.svg) ![Uptime 1h](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/1h/badge.svg)
@@ -1938,6 +2158,25 @@ https://example.com/api/v1/endpoints/core_frontend/health/badge.svg
``` ```
#### Health (Shields.io)
![Health](https://img.shields.io/endpoint?url=https%3A%2F%2Fstatus.twin.sh%2Fapi%2Fv1%2Fendpoints%2Fcore_blog-external%2Fhealth%2Fbadge.shields)
The path to generate a badge is the following:
```
/api/v1/endpoints/{key}/health/badge.shields
```
Where:
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`.
For instance, if you want the current status of the endpoint `frontend` in the group `core`,
the URL would look like this:
```
https://example.com/api/v1/endpoints/core_frontend/health/badge.shields
```
See more information about the Shields.io badge endpoint [here](https://shields.io/badges/endpoint-badge).
#### Response time #### Response time
![Response time 1h](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/1h/badge.svg) ![Response time 1h](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/1h/badge.svg)
![Response time 24h](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/24h/badge.svg) ![Response time 24h](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/24h/badge.svg)
@@ -1952,10 +2191,10 @@ Where:
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`. - `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`.
##### How to change the color thresholds of the response time badge ##### How to change the color thresholds of the response time badge
To change the response time badges' threshold, a corresponding configuration can be added to an endpoint. To change the response time badges' threshold, a corresponding configuration can be added to an endpoint.
The values in the array correspond to the levels [Awesome, Great, Good, Passable, Bad] The values in the array correspond to the levels [Awesome, Great, Good, Passable, Bad]
All five values must be given in milliseconds (ms). All five values must be given in milliseconds (ms).
``` ```
endpoints: endpoints:
@@ -1999,5 +2238,6 @@ You can download Gatus as a binary using the following command:
go install github.com/TwiN/gatus/v5@latest go install github.com/TwiN/gatus/v5@latest
``` ```
### High level design overview ### High level design overview
![Gatus diagram](.github/assets/gatus-diagram.jpg) ![Gatus diagram](.github/assets/gatus-diagram.jpg)
+24 -7
View File
@@ -1,7 +1,10 @@
package alert package alert
import ( import (
"crypto/sha256"
"encoding/hex"
"errors" "errors"
"strconv"
"strings" "strings"
) )
@@ -10,7 +13,7 @@ var (
ErrAlertWithInvalidDescription = errors.New("alert description must not have \" or \\") ErrAlertWithInvalidDescription = errors.New("alert description must not have \" or \\")
) )
// Alert is a core.Endpoint's alert configuration // Alert is a endpoint.Endpoint's alert configuration
type Alert struct { type Alert struct {
// Type of alert (required) // Type of alert (required)
Type Type `yaml:"type"` Type Type `yaml:"type"`
@@ -26,6 +29,9 @@ type Alert struct {
// FailureThreshold is the number of failures in a row needed before triggering the alert // FailureThreshold is the number of failures in a row needed before triggering the alert
FailureThreshold int `yaml:"failure-threshold"` FailureThreshold int `yaml:"failure-threshold"`
// SuccessThreshold defines how many successful executions must happen in a row before an ongoing incident is marked as resolved
SuccessThreshold int `yaml:"success-threshold"`
// Description of the alert. Will be included in the alert sent. // Description of the alert. Will be included in the alert sent.
// //
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value // This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
@@ -38,9 +44,6 @@ type Alert struct {
// or not for provider.ParseWithDefaultAlert to work. Use Alert.IsSendingOnResolved() for a non-pointer // or not for provider.ParseWithDefaultAlert to work. Use Alert.IsSendingOnResolved() for a non-pointer
SendOnResolved *bool `yaml:"send-on-resolved"` SendOnResolved *bool `yaml:"send-on-resolved"`
// 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 // ResolveKey is an optional field that is used by some providers (i.e. PagerDuty's dedup_key) to resolve
// ongoing/triggered incidents // ongoing/triggered incidents
ResolveKey string `yaml:"-"` ResolveKey string `yaml:"-"`
@@ -71,7 +74,7 @@ func (alert *Alert) ValidateAndSetDefaults() error {
} }
// GetDescription retrieves the description of the alert // GetDescription retrieves the description of the alert
func (alert Alert) GetDescription() string { func (alert *Alert) GetDescription() string {
if alert.Description == nil { if alert.Description == nil {
return "" return ""
} }
@@ -80,7 +83,7 @@ func (alert Alert) GetDescription() string {
// IsEnabled returns whether an alert is enabled or not // IsEnabled returns whether an alert is enabled or not
// Returns true if not set // Returns true if not set
func (alert Alert) IsEnabled() bool { func (alert *Alert) IsEnabled() bool {
if alert.Enabled == nil { if alert.Enabled == nil {
return true return true
} }
@@ -88,9 +91,23 @@ func (alert Alert) IsEnabled() bool {
} }
// IsSendingOnResolved returns whether an alert is sending on resolve or not // 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 { if alert.SendOnResolved == nil {
return false return false
} }
return *alert.SendOnResolved return *alert.SendOnResolved
} }
// Checksum returns a checksum of the alert
// Used to determine which persisted triggered alert should be deleted on application start
func (alert *Alert) Checksum() string {
hash := sha256.New()
hash.Write([]byte(string(alert.Type) + "_" +
strconv.FormatBool(alert.IsEnabled()) + "_" +
strconv.FormatBool(alert.IsSendingOnResolved()) + "_" +
strconv.Itoa(alert.SuccessThreshold) + "_" +
strconv.Itoa(alert.FailureThreshold) + "_" +
alert.GetDescription()),
)
return hex.EncodeToString(hash.Sum(nil))
}
+116 -9
View File
@@ -1,6 +1,7 @@
package alert package alert
import ( import (
"errors"
"testing" "testing"
) )
@@ -38,7 +39,7 @@ func TestAlert_ValidateAndSetDefaults(t *testing.T) {
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) { t.Run(scenario.name, func(t *testing.T) {
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) t.Errorf("expected error %v, got %v", scenario.expectedError, err)
} }
if scenario.alert.SuccessThreshold != scenario.expectedSuccessThreshold { if scenario.alert.SuccessThreshold != scenario.expectedSuccessThreshold {
@@ -52,34 +53,140 @@ func TestAlert_ValidateAndSetDefaults(t *testing.T) {
} }
func TestAlert_IsEnabled(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") 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") 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") t.Error("alert.IsEnabled() should've returned true, because Enabled was set to true")
} }
} }
func TestAlert_GetDescription(t *testing.T) { 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") 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'") t.Error("alert.GetDescription() should've returned false, because Description was set to 'description'")
} }
} }
func TestAlert_IsSendingOnResolved(t *testing.T) { 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") 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") 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") 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)
}
})
}
}
+3
View File
@@ -29,6 +29,9 @@ const (
// TypeGotify is the Type for the gotify alerting provider // TypeGotify is the Type for the gotify alerting provider
TypeGotify Type = "gotify" 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 is the Type for the matrix alerting provider
TypeMatrix Type = "matrix" TypeMatrix Type = "matrix"
+5 -1
View File
@@ -15,6 +15,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/gitlab" "github.com/TwiN/gatus/v5/alerting/provider/gitlab"
"github.com/TwiN/gatus/v5/alerting/provider/googlechat" "github.com/TwiN/gatus/v5/alerting/provider/googlechat"
"github.com/TwiN/gatus/v5/alerting/provider/gotify" "github.com/TwiN/gatus/v5/alerting/provider/gotify"
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
"github.com/TwiN/gatus/v5/alerting/provider/matrix" "github.com/TwiN/gatus/v5/alerting/provider/matrix"
"github.com/TwiN/gatus/v5/alerting/provider/mattermost" "github.com/TwiN/gatus/v5/alerting/provider/mattermost"
"github.com/TwiN/gatus/v5/alerting/provider/messagebird" "github.com/TwiN/gatus/v5/alerting/provider/messagebird"
@@ -54,6 +55,9 @@ type Config struct {
// Gotify is the configuration for the gotify alerting provider // Gotify is the configuration for the gotify alerting provider
Gotify *gotify.AlertProvider `yaml:"gotify,omitempty"` Gotify *gotify.AlertProvider `yaml:"gotify,omitempty"`
// 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 is the configuration for the matrix alerting provider
Matrix *matrix.AlertProvider `yaml:"matrix,omitempty"` Matrix *matrix.AlertProvider `yaml:"matrix,omitempty"`
@@ -102,7 +106,7 @@ func (config *Config) GetAlertingProviderByAlertType(alertType alert.Type) provi
return fieldValue.Interface().(provider.AlertProvider) return fieldValue.Interface().(provider.AlertProvider)
} }
} }
log.Printf("[alerting][GetAlertingProviderByAlertType] No alerting provider found for alert type %s", alertType) log.Printf("[alerting.GetAlertingProviderByAlertType] No alerting provider found for alert type %s", alertType)
return nil return nil
} }
+27 -25
View File
@@ -2,14 +2,15 @@ package awsses
import ( import (
"fmt" "fmt"
"strings"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/awserr" "github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/aws/credentials" "github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/ses" "github.com/aws/aws-sdk-go/service/ses"
"strings"
) )
const ( const (
@@ -49,7 +50,6 @@ func (provider *AlertProvider) IsValid() bool {
registeredGroups[override.Group] = true registeredGroups[override.Group] = true
} }
} }
// if both AccessKeyID and SecretAccessKey are specified, we'll use these to authenticate, // 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. // otherwise if neither are specified, then we'll fall back on IAM authentication.
return len(provider.From) > 0 && len(provider.To) > 0 && return len(provider.From) > 0 && len(provider.To) > 0 &&
@@ -57,14 +57,14 @@ func (provider *AlertProvider) IsValid() bool {
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
sess, err := provider.CreateSesSession() sess, err := provider.createSession()
if err != nil { if err != nil {
return err return err
} }
svc := ses.New(sess) svc := ses.New(sess)
subject, body := provider.buildMessageSubjectAndBody(endpoint, alert, result, resolved) subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)
emails := strings.Split(provider.getToForGroup(endpoint.Group), ",") emails := strings.Split(provider.getToForGroup(ep.Group), ",")
input := &ses.SendEmailInput{ input := &ses.SendEmailInput{
Destination: &ses.Destination{ Destination: &ses.Destination{
@@ -110,29 +110,33 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
} }
// buildMessageSubjectAndBody builds the message subject and body // buildMessageSubjectAndBody builds the message subject and body
func (provider *AlertProvider) buildMessageSubjectAndBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) (string, string) { func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) (string, string) {
var subject, message, results string var subject, message string
if resolved { if resolved {
subject = fmt.Sprintf("[%s] Alert resolved", endpoint.DisplayName()) subject = fmt.Sprintf("[%s] Alert resolved", ep.DisplayName())
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold) message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else { } else {
subject = fmt.Sprintf("[%s] Alert triggered", endpoint.DisplayName()) subject = fmt.Sprintf("[%s] Alert triggered", ep.DisplayName())
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold) message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
} }
for _, conditionResult := range result.ConditionResults { var formattedConditionResults string
var prefix string if len(result.ConditionResults) > 0 {
if conditionResult.Success { formattedConditionResults = "\n\nCondition results:\n"
prefix = "✅" for _, conditionResult := range result.ConditionResults {
} else { var prefix string
prefix = "❌" 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 var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = "\n\nAlert description: " + alertDescription 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 // getToForGroup returns the appropriate email integration to for a given group
@@ -148,18 +152,16 @@ func (provider *AlertProvider) getToForGroup(group string) string {
} }
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert
} }
func (provider AlertProvider) CreateSesSession() (*session.Session, error) { func (provider *AlertProvider) createSession() (*session.Session, error) {
config := &aws.Config{ config := &aws.Config{
Region: aws.String(provider.Region), Region: aws.String(provider.Region),
} }
if len(provider.AccessKeyID) > 0 && len(provider.SecretAccessKey) > 0 { if len(provider.AccessKeyID) > 0 && len(provider.SecretAccessKey) > 0 {
config.Credentials = credentials.NewStaticCredentials(provider.AccessKeyID, provider.SecretAccessKey, "") config.Credentials = credentials.NewStaticCredentials(provider.AccessKeyID, provider.SecretAccessKey, "")
} }
return session.NewSession(config) return session.NewSession(config)
} }
+6 -6
View File
@@ -4,7 +4,7 @@ import (
"testing" "testing"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
func TestAlertDefaultProvider_IsValid(t *testing.T) { func TestAlertDefaultProvider_IsValid(t *testing.T) {
@@ -95,10 +95,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
subject, body := scenario.Provider.buildMessageSubjectAndBody( subject, body := scenario.Provider.buildMessageSubjectAndBody(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },
@@ -116,10 +116,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
} }
func TestAlertProvider_GetDefaultAlert(t *testing.T) { func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil { if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil") t.Error("expected default alert to be not nil")
} }
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil { if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil") t.Error("expected default alert to be nil")
} }
} }
+11 -11
View File
@@ -9,7 +9,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
// AlertProvider is the configuration necessary for sending an alert using a custom HTTP request // AlertProvider is the configuration necessary for sending an alert using a custom HTTP request
@@ -50,16 +50,16 @@ func (provider *AlertProvider) GetAlertStatePlaceholderValue(resolved bool) stri
return status 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, resolved bool) *http.Request {
body, url, method := provider.Body, provider.URL, provider.Method body, url, method := provider.Body, provider.URL, provider.Method
body = strings.ReplaceAll(body, "[ALERT_DESCRIPTION]", alert.GetDescription()) body = strings.ReplaceAll(body, "[ALERT_DESCRIPTION]", alert.GetDescription())
url = strings.ReplaceAll(url, "[ALERT_DESCRIPTION]", alert.GetDescription()) url = strings.ReplaceAll(url, "[ALERT_DESCRIPTION]", alert.GetDescription())
body = strings.ReplaceAll(body, "[ENDPOINT_NAME]", endpoint.Name) body = strings.ReplaceAll(body, "[ENDPOINT_NAME]", ep.Name)
url = strings.ReplaceAll(url, "[ENDPOINT_NAME]", endpoint.Name) url = strings.ReplaceAll(url, "[ENDPOINT_NAME]", ep.Name)
body = strings.ReplaceAll(body, "[ENDPOINT_GROUP]", endpoint.Group) body = strings.ReplaceAll(body, "[ENDPOINT_GROUP]", ep.Group)
url = strings.ReplaceAll(url, "[ENDPOINT_GROUP]", endpoint.Group) url = strings.ReplaceAll(url, "[ENDPOINT_GROUP]", ep.Group)
body = strings.ReplaceAll(body, "[ENDPOINT_URL]", endpoint.URL) body = strings.ReplaceAll(body, "[ENDPOINT_URL]", ep.URL)
url = strings.ReplaceAll(url, "[ENDPOINT_URL]", endpoint.URL) url = strings.ReplaceAll(url, "[ENDPOINT_URL]", ep.URL)
if resolved { if resolved {
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true)) body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true)) url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
@@ -78,8 +78,8 @@ func (provider *AlertProvider) buildHTTPRequest(endpoint *core.Endpoint, alert *
return request return request
} }
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 {
request := provider.buildHTTPRequest(endpoint, alert, resolved) request := provider.buildHTTPRequest(ep, alert, resolved)
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request) response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
if err != nil { if err != nil {
return err return err
@@ -93,6 +93,6 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
} }
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert
} }
+8 -8
View File
@@ -8,7 +8,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
@@ -90,10 +90,10 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send( err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },
@@ -138,7 +138,7 @@ func TestAlertProvider_buildHTTPRequest(t *testing.T) {
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(fmt.Sprintf("resolved-%v-with-default-placeholders", scenario.Resolved), func(t *testing.T) { t.Run(fmt.Sprintf("resolved-%v-with-default-placeholders", scenario.Resolved), func(t *testing.T) {
request := customAlertProvider.buildHTTPRequest( request := 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}, &alert.Alert{Description: &alertDescription},
scenario.Resolved, scenario.Resolved,
) )
@@ -188,7 +188,7 @@ func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(fmt.Sprintf("resolved-%v-with-custom-placeholders", scenario.Resolved), func(t *testing.T) { t.Run(fmt.Sprintf("resolved-%v-with-custom-placeholders", scenario.Resolved), func(t *testing.T) {
request := customAlertProvider.buildHTTPRequest( request := customAlertProvider.buildHTTPRequest(
&core.Endpoint{Name: "endpoint-name", Group: "endpoint-group"}, &endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&alert.Alert{Description: &alertDescription}, &alert.Alert{Description: &alertDescription},
scenario.Resolved, scenario.Resolved,
) )
@@ -217,10 +217,10 @@ func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) {
} }
func TestAlertProvider_GetDefaultAlert(t *testing.T) { func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil { if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil") t.Error("expected default alert to be not nil")
} }
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil { if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil") t.Error("expected default alert to be nil")
} }
} }
+23 -21
View File
@@ -9,7 +9,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
// AlertProvider is the configuration necessary for sending an alert using Discord // AlertProvider is the configuration necessary for sending an alert using Discord
@@ -47,9 +47,9 @@ func (provider *AlertProvider) IsValid() bool {
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer) request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer)
if err != nil { if err != nil {
return err return err
} }
@@ -75,7 +75,7 @@ type Embed struct {
Title string `json:"title"` Title string `json:"title"`
Description string `json:"description"` Description string `json:"description"`
Color int `json:"color"` Color int `json:"color"`
Fields []Field `json:"fields"` Fields []Field `json:"fields,omitempty"`
} }
type Field struct { type Field struct {
@@ -85,16 +85,17 @@ type Field struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message, results string var message string
var colorCode int var colorCode int
if resolved { if resolved {
message = fmt.Sprintf("An alert for **%s** has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold) message = fmt.Sprintf("An alert for **%s** has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
colorCode = 3066993 colorCode = 3066993
} else { } else {
message = fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold) message = fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
colorCode = 15158332 colorCode = 15158332
} }
var formattedConditionResults string
for _, conditionResult := range result.ConditionResults { for _, conditionResult := range result.ConditionResults {
var prefix string var prefix string
if conditionResult.Success { if conditionResult.Success {
@@ -102,7 +103,7 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
} else { } else {
prefix = ":x:" prefix = ":x:"
} }
results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition) formattedConditionResults += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
} }
var description string var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
@@ -112,24 +113,25 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
if provider.Title != "" { if provider.Title != "" {
title = provider.Title title = provider.Title
} }
body, _ := json.Marshal(Body{ body := Body{
Content: "", Content: "",
Embeds: []Embed{ Embeds: []Embed{
{ {
Title: title, Title: title,
Description: message + description, Description: message + description,
Color: colorCode, 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 // getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
@@ -145,6 +147,6 @@ func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
} }
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert
} }
+26 -13
View File
@@ -7,7 +7,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
@@ -127,10 +127,10 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send( err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },
@@ -155,6 +155,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Name string Name string
Provider AlertProvider Provider AlertProvider
Alert alert.Alert Alert alert.Alert
NoConditions bool
Resolved bool Resolved bool
ExpectedBody string ExpectedBody string
}{ }{
@@ -179,18 +180,30 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Resolved: false, Resolved: false,
ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\"provider-title\",\"description\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"color\":15158332,\"fields\":[{\"name\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n:x: - `[BODY] != \\\"\\\"`\\n\",\"inline\":false}]}]}", ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\"provider-title\",\"description\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"color\":15158332,\"fields\":[{\"name\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n:x: - `[BODY] != \\\"\\\"`\\n\",\"inline\":false}]}]}",
}, },
{
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 { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { 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( body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: conditionResults,
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
{Condition: "[BODY] != \"\"", Success: scenario.Resolved},
},
}, },
scenario.Resolved, scenario.Resolved,
) )
@@ -206,10 +219,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
} }
func TestAlertProvider_GetDefaultAlert(t *testing.T) { func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil { if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil") t.Error("expected default alert to be not nil")
} }
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil { if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil") t.Error("expected default alert to be nil")
} }
} }
+23 -19
View File
@@ -8,7 +8,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
gomail "gopkg.in/mail.v2" gomail "gopkg.in/mail.v2"
) )
@@ -53,17 +53,17 @@ func (provider *AlertProvider) IsValid() bool {
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
var username string var username string
if len(provider.Username) > 0 { if len(provider.Username) > 0 {
username = provider.Username username = provider.Username
} else { } else {
username = provider.From username = provider.From
} }
subject, body := provider.buildMessageSubjectAndBody(endpoint, alert, result, resolved) subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)
m := gomail.NewMessage() m := gomail.NewMessage()
m.SetHeader("From", provider.From) m.SetHeader("From", provider.From)
m.SetHeader("To", strings.Split(provider.getToForGroup(endpoint.Group), ",")...) m.SetHeader("To", strings.Split(provider.getToForGroup(ep.Group), ",")...)
m.SetHeader("Subject", subject) m.SetHeader("Subject", subject)
m.SetBody("text/plain", body) m.SetBody("text/plain", body)
var d *gomail.Dialer var d *gomail.Dialer
@@ -87,29 +87,33 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
} }
// buildMessageSubjectAndBody builds the message subject and body // buildMessageSubjectAndBody builds the message subject and body
func (provider *AlertProvider) buildMessageSubjectAndBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) (string, string) { func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) (string, string) {
var subject, message, results string var subject, message string
if resolved { if resolved {
subject = fmt.Sprintf("[%s] Alert resolved", endpoint.DisplayName()) subject = fmt.Sprintf("[%s] Alert resolved", ep.DisplayName())
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold) message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else { } else {
subject = fmt.Sprintf("[%s] Alert triggered", endpoint.DisplayName()) subject = fmt.Sprintf("[%s] Alert triggered", ep.DisplayName())
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold) message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
} }
for _, conditionResult := range result.ConditionResults { var formattedConditionResults string
var prefix string if len(result.ConditionResults) > 0 {
if conditionResult.Success { formattedConditionResults = "\n\nCondition results:\n"
prefix = "✅" for _, conditionResult := range result.ConditionResults {
} else { var prefix string
prefix = "❌" 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 var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = "\n\nAlert description: " + alertDescription 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 // getToForGroup returns the appropriate email integration to for a given group
@@ -125,6 +129,6 @@ func (provider *AlertProvider) getToForGroup(group string) string {
} }
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert
} }
+6 -6
View File
@@ -4,7 +4,7 @@ import (
"testing" "testing"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
func TestAlertDefaultProvider_IsValid(t *testing.T) { func TestAlertDefaultProvider_IsValid(t *testing.T) {
@@ -97,10 +97,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
subject, body := scenario.Provider.buildMessageSubjectAndBody( subject, body := scenario.Provider.buildMessageSubjectAndBody(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },
@@ -118,10 +118,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
} }
func TestAlertProvider_GetDefaultAlert(t *testing.T) { func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil { if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil") t.Error("expected default alert to be not nil")
} }
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil { if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil") t.Error("expected default alert to be nil")
} }
} }
+19 -16
View File
@@ -8,7 +8,7 @@ import (
"time" "time"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/google/go-github/v48/github" "github.com/google/go-github/v48/github"
"golang.org/x/oauth2" "golang.org/x/oauth2"
) )
@@ -70,12 +70,12 @@ func (provider *AlertProvider) IsValid() bool {
// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false, // Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,
// or closes the relevant issue(s) if the resolved parameter passed is true. // or closes the relevant issue(s) if the resolved parameter passed is true.
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
title := "alert(gatus): " + endpoint.DisplayName() title := "alert(gatus): " + ep.DisplayName()
if !resolved { if !resolved {
_, _, err := provider.githubClient.Issues.Create(context.Background(), provider.repositoryOwner, provider.repositoryName, &github.IssueRequest{ _, _, err := provider.githubClient.Issues.Create(context.Background(), provider.repositoryOwner, provider.repositoryName, &github.IssueRequest{
Title: github.String(title), Title: github.String(title),
Body: github.String(provider.buildIssueBody(endpoint, alert, result)), Body: github.String(provider.buildIssueBody(ep, alert, result)),
}) })
if err != nil { if err != nil {
return fmt.Errorf("failed to create issue: %w", err) return fmt.Errorf("failed to create issue: %w", err)
@@ -104,26 +104,29 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
} }
// buildIssueBody builds the body of the issue // buildIssueBody builds the body of the issue
func (provider *AlertProvider) buildIssueBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result) string { func (provider *AlertProvider) buildIssueBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result) string {
var results string var formattedConditionResults string
for _, conditionResult := range result.ConditionResults { if len(result.ConditionResults) > 0 {
var prefix string formattedConditionResults = "\n\n## Condition results\n"
if conditionResult.Success { for _, conditionResult := range result.ConditionResults {
prefix = ":white_check_mark:" var prefix string
} else { if conditionResult.Success {
prefix = ":x:" 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 var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\n> " + alertDescription description = ":\n> " + alertDescription
} }
message := fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold) message := fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
return message + description + "\n\n## Condition results\n" + results return message + description + formattedConditionResults
} }
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert
} }
+27 -16
View File
@@ -7,7 +7,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
"github.com/google/go-github/v48/github" "github.com/google/go-github/v48/github"
) )
@@ -85,10 +85,10 @@ func TestAlertProvider_Send(t *testing.T) {
scenario.Provider.githubClient = github.NewClient(nil) scenario.Provider.githubClient = github.NewClient(nil)
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send( err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name", Group: "endpoint-group"}, &endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },
@@ -109,37 +109,48 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1" firstDescription := "description-1"
scenarios := []struct { scenarios := []struct {
Name string Name string
Endpoint core.Endpoint Endpoint endpoint.Endpoint
Provider AlertProvider Provider AlertProvider
Alert alert.Alert Alert alert.Alert
NoConditions bool
ExpectedBody string ExpectedBody string
}{ }{
{ {
Name: "triggered", Name: "triggered",
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"}, Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{}, Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 3},
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\n> description-1\n\n## Condition results\n- :white_check_mark: - `[CONNECTED] == true`\n- :x: - `[STATUS] == 200`", ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\n> description-1\n\n## Condition results\n- :white_check_mark: - `[CONNECTED] == true`\n- :x: - `[STATUS] == 200`",
}, },
{ {
Name: "no-description", Name: "triggered-with-no-description",
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"}, Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{}, Provider: AlertProvider{},
Alert: alert.Alert{FailureThreshold: 10}, Alert: alert.Alert{FailureThreshold: 10},
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 10 time(s) in a row\n\n## Condition results\n- :white_check_mark: - `[CONNECTED] == true`\n- :x: - `[STATUS] == 200`", ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 10 time(s) in a row\n\n## Condition results\n- :white_check_mark: - `[CONNECTED] == true`\n- :x: - `[STATUS] == 200`",
}, },
{
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 { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { 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( body := scenario.Provider.buildIssueBody(
&scenario.Endpoint, &scenario.Endpoint,
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{ConditionResults: conditionResults},
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: true},
{Condition: "[STATUS] == 200", Success: false},
},
},
) )
if strings.TrimSpace(body) != strings.TrimSpace(scenario.ExpectedBody) { if strings.TrimSpace(body) != strings.TrimSpace(scenario.ExpectedBody) {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body) t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
@@ -149,10 +160,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
} }
func TestAlertProvider_GetDefaultAlert(t *testing.T) { func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil { if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil") t.Error("expected default alert to be not nil")
} }
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil { if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil") t.Error("expected default alert to be nil")
} }
} }
+29 -26
View File
@@ -11,7 +11,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -25,10 +25,13 @@ type AlertProvider struct {
// Severity can be one of: critical, high, medium, low, info, unknown. Defaults to critical // Severity can be one of: critical, high, medium, low, info, unknown. Defaults to critical
Severity string `yaml:"severity,omitempty"` Severity string `yaml:"severity,omitempty"`
// MonitoringTool overrides the name sent to gitlab. Defaults to gatus // MonitoringTool overrides the name sent to gitlab. Defaults to gatus
MonitoringTool string `yaml:"monitoring-tool,omitempty"` MonitoringTool string `yaml:"monitoring-tool,omitempty"`
// EnvironmentName is the name of the associated GitLab environment. Required to display alerts on a dashboard. // EnvironmentName is the name of the associated GitLab environment. Required to display alerts on a dashboard.
EnvironmentName string `yaml:"environment-name,omitempty"` EnvironmentName string `yaml:"environment-name,omitempty"`
// Service affected. Defaults to endpoint display name // Service affected. Defaults to endpoint display name
Service string `yaml:"service,omitempty"` 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, // Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,
// or closes the relevant issue(s) if the resolved parameter passed is true. // or closes the relevant issue(s) if the resolved parameter passed is true.
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
if len(alert.ResolveKey) == 0 { if len(alert.ResolveKey) == 0 {
alert.ResolveKey = uuid.NewString() alert.ResolveKey = uuid.NewString()
} }
buffer := bytes.NewBuffer(provider.buildAlertBody(ep, alert, result, resolved))
buffer := bytes.NewBuffer(provider.buildAlertBody(endpoint, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer) request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer)
if err != nil { if err != nil {
return err return err
@@ -92,21 +94,21 @@ func (provider *AlertProvider) monitoringTool() string {
return "gatus" return "gatus"
} }
func (provider *AlertProvider) service(endpoint *core.Endpoint) string { func (provider *AlertProvider) service(ep *endpoint.Endpoint) string {
if len(provider.Service) > 0 { if len(provider.Service) > 0 {
return provider.Service return provider.Service
} }
return endpoint.DisplayName() return ep.DisplayName()
} }
// buildAlertBody builds the body of the alert // buildAlertBody builds the body of the alert
func (provider *AlertProvider) buildAlertBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { func (provider *AlertProvider) buildAlertBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
body := AlertBody{ 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), StartTime: result.Timestamp.Format(time.RFC3339),
Service: provider.service(endpoint), Service: provider.service(ep),
MonitoringTool: provider.monitoringTool(), MonitoringTool: provider.monitoringTool(),
Hosts: endpoint.URL, Hosts: ep.URL,
GitlabEnvironmentName: provider.EnvironmentName, GitlabEnvironmentName: provider.EnvironmentName,
Severity: provider.Severity, Severity: provider.Severity,
Fingerprint: alert.ResolveKey, Fingerprint: alert.ResolveKey,
@@ -114,16 +116,18 @@ func (provider *AlertProvider) buildAlertBody(endpoint *core.Endpoint, alert *al
if resolved { if resolved {
body.EndTime = result.Timestamp.Format(time.RFC3339) body.EndTime = result.Timestamp.Format(time.RFC3339)
} }
var formattedConditionResults string
var results string if len(result.ConditionResults) > 0 {
for _, conditionResult := range result.ConditionResults { formattedConditionResults = "\n\n## Condition results\n"
var prefix string for _, conditionResult := range result.ConditionResults {
if conditionResult.Success { var prefix string
prefix = ":white_check_mark:" if conditionResult.Success {
} else { prefix = ":white_check_mark:"
prefix = ":x:" } else {
prefix = ":x:"
}
formattedConditionResults += fmt.Sprintf("- %s - `%s`\n", prefix, conditionResult.Condition)
} }
results += fmt.Sprintf("- %s - `%s`\n", prefix, conditionResult.Condition)
} }
var description string var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
@@ -131,17 +135,16 @@ func (provider *AlertProvider) buildAlertBody(endpoint *core.Endpoint, alert *al
} }
var message string var message string
if resolved { if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold) message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else { } else {
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold) message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
} }
body.Description = message + description + "\n\n## Condition results\n" + results body.Description = message + description + formattedConditionResults
bodyAsJSON, _ := json.Marshal(body)
json, _ := json.Marshal(body) return bodyAsJSON
return json
} }
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert
} }
+11 -11
View File
@@ -7,7 +7,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
@@ -84,10 +84,10 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send( err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name", Group: "endpoint-group"}, &endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },
@@ -108,21 +108,21 @@ func TestAlertProvider_buildAlertBody(t *testing.T) {
firstDescription := "description-1" firstDescription := "description-1"
scenarios := []struct { scenarios := []struct {
Name string Name string
Endpoint core.Endpoint Endpoint endpoint.Endpoint
Provider AlertProvider Provider AlertProvider
Alert alert.Alert Alert alert.Alert
ExpectedBody string ExpectedBody string
}{ }{
{ {
Name: "triggered", Name: "triggered",
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"}, Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{}, Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 3},
ExpectedBody: "{\"title\":\"alert(gatus): endpoint-name\",\"description\":\"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\\n\\n## Condition results\\n- :white_check_mark: - `[CONNECTED] == true`\\n- :x: - `[STATUS] == 200`\\n\",\"start_time\":\"0001-01-01T00:00:00Z\",\"service\":\"endpoint-name\",\"monitoring_tool\":\"gatus\",\"hosts\":\"https://example.org\"}", ExpectedBody: "{\"title\":\"alert(gatus): endpoint-name\",\"description\":\"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\\n\\n## Condition results\\n- :white_check_mark: - `[CONNECTED] == true`\\n- :x: - `[STATUS] == 200`\\n\",\"start_time\":\"0001-01-01T00:00:00Z\",\"service\":\"endpoint-name\",\"monitoring_tool\":\"gatus\",\"hosts\":\"https://example.org\"}",
}, },
{ {
Name: "no-description", Name: "no-description",
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"}, Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{}, Provider: AlertProvider{},
Alert: alert.Alert{FailureThreshold: 10}, Alert: alert.Alert{FailureThreshold: 10},
ExpectedBody: "{\"title\":\"alert(gatus): endpoint-name\",\"description\":\"An alert for *endpoint-name* has been triggered due to having failed 10 time(s) in a row\\n\\n## Condition results\\n- :white_check_mark: - `[CONNECTED] == true`\\n- :x: - `[STATUS] == 200`\\n\",\"start_time\":\"0001-01-01T00:00:00Z\",\"service\":\"endpoint-name\",\"monitoring_tool\":\"gatus\",\"hosts\":\"https://example.org\"}", ExpectedBody: "{\"title\":\"alert(gatus): endpoint-name\",\"description\":\"An alert for *endpoint-name* has been triggered due to having failed 10 time(s) in a row\\n\\n## Condition results\\n- :white_check_mark: - `[CONNECTED] == true`\\n- :x: - `[STATUS] == 200`\\n\",\"start_time\":\"0001-01-01T00:00:00Z\",\"service\":\"endpoint-name\",\"monitoring_tool\":\"gatus\",\"hosts\":\"https://example.org\"}",
@@ -133,8 +133,8 @@ func TestAlertProvider_buildAlertBody(t *testing.T) {
body := scenario.Provider.buildAlertBody( body := scenario.Provider.buildAlertBody(
&scenario.Endpoint, &scenario.Endpoint,
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: true}, {Condition: "[CONNECTED] == true", Success: true},
{Condition: "[STATUS] == 200", Success: false}, {Condition: "[STATUS] == 200", Success: false},
}, },
@@ -149,10 +149,10 @@ func TestAlertProvider_buildAlertBody(t *testing.T) {
} }
func TestAlertProvider_GetDefaultAlert(t *testing.T) { func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil { if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil") t.Error("expected default alert to be not nil")
} }
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil { if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil") t.Error("expected default alert to be nil")
} }
} }
+23 -21
View File
@@ -9,7 +9,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
// AlertProvider is the configuration necessary for sending an alert using Google chat // 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 // Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer) request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer)
if err != nil { if err != nil {
return err return err
} }
@@ -112,7 +112,7 @@ type OpenLink struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message, color string var message, color string
if resolved { if resolved {
color = "#36A64F" color = "#36A64F"
@@ -121,7 +121,7 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
color = "#DD0000" 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) 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 { for _, conditionResult := range result.ConditionResults {
var prefix string var prefix string
if conditionResult.Success { if conditionResult.Success {
@@ -129,7 +129,7 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
} else { } else {
prefix = "❌" prefix = "❌"
} }
results += fmt.Sprintf("%s %s<br>", prefix, conditionResult.Condition) formattedConditionResults += fmt.Sprintf("%s %s<br>", prefix, conditionResult.Condition)
} }
var description string var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
@@ -143,28 +143,30 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
Widgets: []Widgets{ Widgets: []Widgets{
{ {
KeyValue: &KeyValue{ KeyValue: &KeyValue{
TopLabel: endpoint.DisplayName(), TopLabel: ep.DisplayName(),
Content: message, Content: message,
ContentMultiline: "true", ContentMultiline: "true",
BottomLabel: description, BottomLabel: description,
Icon: "BOOKMARK", 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 // We only include a button targeting the URL if the endpoint is an HTTP endpoint
// If the URL isn't prefixed with https://, Google Chat will just display a blank message aynways. // If the URL isn't prefixed with https://, Google Chat will just display a blank message aynways.
// See https://github.com/TwiN/gatus/issues/362 // See https://github.com/TwiN/gatus/issues/362
@@ -173,14 +175,14 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
{ {
TextButton: TextButton{ TextButton: TextButton{
Text: "URL", Text: "URL",
OnClick: OnClick{OpenLink: OpenLink{URL: endpoint.URL}}, OnClick: OnClick{OpenLink: OpenLink{URL: ep.URL}},
}, },
}, },
}, },
}) })
} }
body, _ := json.Marshal(payload) bodyAsJSON, _ := json.Marshal(payload)
return body return bodyAsJSON
} }
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group // 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 // GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert
} }
+13 -13
View File
@@ -7,7 +7,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
@@ -116,10 +116,10 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send( err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name", Group: "endpoint-group"}, &endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },
@@ -141,7 +141,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
secondDescription := "description-2" secondDescription := "description-2"
scenarios := []struct { scenarios := []struct {
Name string Name string
Endpoint core.Endpoint Endpoint endpoint.Endpoint
Provider AlertProvider Provider AlertProvider
Alert alert.Alert Alert alert.Alert
Resolved bool Resolved bool
@@ -149,7 +149,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"}, Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{}, Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
@@ -157,7 +157,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"}, Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{}, Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
@@ -165,7 +165,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}, },
{ {
Name: "icmp-should-not-include-url", // See https://github.com/TwiN/gatus/issues/362 Name: "icmp-should-not-include-url", // See https://github.com/TwiN/gatus/issues/362
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "icmp://example.org"}, Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "icmp://example.org"},
Provider: AlertProvider{}, Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
@@ -173,7 +173,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}, },
{ {
Name: "tcp-should-not-include-url", // See https://github.com/TwiN/gatus/issues/362 Name: "tcp-should-not-include-url", // See https://github.com/TwiN/gatus/issues/362
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "tcp://example.org"}, Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "tcp://example.org"},
Provider: AlertProvider{}, Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
@@ -185,8 +185,8 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&scenario.Endpoint, &scenario.Endpoint,
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },
@@ -205,10 +205,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
} }
func TestAlertProvider_GetDefaultAlert(t *testing.T) { func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil { if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil") t.Error("expected default alert to be not nil")
} }
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil { if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil") t.Error("expected default alert to be nil")
} }
} }
+14 -13
View File
@@ -9,7 +9,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
const DefaultPriority = 5 const DefaultPriority = 5
@@ -41,8 +41,8 @@ func (provider *AlertProvider) IsValid() bool {
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.ServerURL+"/message?token="+provider.Token, buffer) request, err := http.NewRequest(http.MethodPost, provider.ServerURL+"/message?token="+provider.Token, buffer)
if err != nil { if err != nil {
return err return err
@@ -67,13 +67,14 @@ type Body struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message, results string var message string
if resolved { if resolved {
message = fmt.Sprintf("An alert for `%s` has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold) message = fmt.Sprintf("An alert for `%s` has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else { } else {
message = fmt.Sprintf("An alert for `%s` has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold) message = fmt.Sprintf("An alert for `%s` has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
} }
var formattedConditionResults string
for _, conditionResult := range result.ConditionResults { for _, conditionResult := range result.ConditionResults {
var prefix string var prefix string
if conditionResult.Success { if conditionResult.Success {
@@ -81,25 +82,25 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
} else { } else {
prefix = "✕" prefix = "✕"
} }
results += fmt.Sprintf("\n%s - %s", prefix, conditionResult.Condition) formattedConditionResults += fmt.Sprintf("\n%s - %s", prefix, conditionResult.Condition)
} }
if len(alert.GetDescription()) > 0 { if len(alert.GetDescription()) > 0 {
message += " with the following description: " + alert.GetDescription() message += " with the following description: " + alert.GetDescription()
} }
message += results message += formattedConditionResults
title := "Gatus: " + endpoint.DisplayName() title := "Gatus: " + ep.DisplayName()
if provider.Title != "" { if provider.Title != "" {
title = provider.Title title = provider.Title
} }
body, _ := json.Marshal(Body{ bodyAsJSON, _ := json.Marshal(Body{
Message: message, Message: message,
Title: title, Title: title,
Priority: provider.Priority, Priority: provider.Priority,
}) })
return body return bodyAsJSON
} }
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert
} }
+8 -8
View File
@@ -6,7 +6,7 @@ import (
"testing" "testing"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertProvider_IsValid(t *testing.T) {
@@ -49,7 +49,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
var ( var (
description = "custom-description" description = "custom-description"
//title = "custom-title" //title = "custom-title"
endpoint = "custom-endpoint" endpointName = "custom-endpoint"
) )
scenarios := []struct { scenarios := []struct {
Name string Name string
@@ -63,30 +63,30 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"}, Provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been triggered due to having failed 3 time(s) in a row with the following description: %s\\n✕ - [CONNECTED] == true\\n✕ - [STATUS] == 200\",\"title\":\"Gatus: custom-endpoint\",\"priority\":0}", endpoint, description), ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been triggered due to having failed 3 time(s) in a row with the following description: %s\\n✕ - [CONNECTED] == true\\n✕ - [STATUS] == 200\",\"title\":\"Gatus: custom-endpoint\",\"priority\":0}", endpointName, description),
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"}, Provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been resolved after passing successfully 5 time(s) in a row with the following description: %s\\n✓ - [CONNECTED] == true\\n✓ - [STATUS] == 200\",\"title\":\"Gatus: custom-endpoint\",\"priority\":0}", endpoint, description), ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been resolved after passing successfully 5 time(s) in a row with the following description: %s\\n✓ - [CONNECTED] == true\\n✓ - [STATUS] == 200\",\"title\":\"Gatus: custom-endpoint\",\"priority\":0}", endpointName, description),
}, },
{ {
Name: "custom-title", Name: "custom-title",
Provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken", Title: "custom-title"}, Provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken", Title: "custom-title"},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been triggered due to having failed 3 time(s) in a row with the following description: %s\\n✕ - [CONNECTED] == true\\n✕ - [STATUS] == 200\",\"title\":\"custom-title\",\"priority\":0}", endpoint, description), ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been triggered due to having failed 3 time(s) in a row with the following description: %s\\n✕ - [CONNECTED] == true\\n✕ - [STATUS] == 200\",\"title\":\"custom-title\",\"priority\":0}", endpointName, description),
}, },
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: endpoint}, &endpoint.Endpoint{Name: endpointName},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },
@@ -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
}
@@ -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)
}
})
}
}
+40 -34
View File
@@ -12,12 +12,12 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
// AlertProvider is the configuration necessary for sending an alert using Matrix // AlertProvider is the configuration necessary for sending an alert using Matrix
type AlertProvider struct { 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 is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
@@ -30,12 +30,12 @@ type AlertProvider struct {
type Override struct { type Override struct {
Group string `yaml:"group"` 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 is the custom homeserver to use (optional)
ServerURL string `yaml:"server-url"` ServerURL string `yaml:"server-url"`
@@ -61,11 +61,11 @@ func (provider *AlertProvider) IsValid() bool {
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
config := provider.getConfigForGroup(endpoint.Group) config := provider.getConfigForGroup(ep.Group)
if config.ServerURL == "" { if config.ServerURL == "" {
config.ServerURL = defaultHomeserverURL config.ServerURL = defaultServerURL
} }
// The Matrix endpoint requires a unique transaction ID for each event sent // The Matrix endpoint requires a unique transaction ID for each event sent
txnId := randStringBytes(24) txnId := randStringBytes(24)
@@ -103,24 +103,25 @@ type Body struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
body, _ := json.Marshal(Body{ body, _ := json.Marshal(Body{
MsgType: "m.text", MsgType: "m.text",
Format: "org.matrix.custom.html", Format: "org.matrix.custom.html",
Body: buildPlaintextMessageBody(endpoint, alert, result, resolved), Body: buildPlaintextMessageBody(ep, alert, result, resolved),
FormattedBody: buildHTMLMessageBody(endpoint, alert, result, resolved), FormattedBody: buildHTMLMessageBody(ep, alert, result, resolved),
}) })
return body return body
} }
// buildPlaintextMessageBody builds the message body in plaintext to include in request // buildPlaintextMessageBody builds the message body in plaintext to include in request
func buildPlaintextMessageBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string { func buildPlaintextMessageBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
var message, results string var message string
if resolved { if resolved {
message = fmt.Sprintf("An alert for `%s` has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold) message = fmt.Sprintf("An alert for `%s` has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else { } else {
message = fmt.Sprintf("An alert for `%s` has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold) message = fmt.Sprintf("An alert for `%s` has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
} }
var formattedConditionResults string
for _, conditionResult := range result.ConditionResults { for _, conditionResult := range result.ConditionResults {
var prefix string var prefix string
if conditionResult.Success { if conditionResult.Success {
@@ -128,49 +129,54 @@ func buildPlaintextMessageBody(endpoint *core.Endpoint, alert *alert.Alert, resu
} else { } else {
prefix = "✕" prefix = "✕"
} }
results += fmt.Sprintf("\n%s - %s", prefix, conditionResult.Condition) formattedConditionResults += fmt.Sprintf("\n%s - %s", prefix, conditionResult.Condition)
} }
var description string var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = "\n" + alertDescription description = "\n" + alertDescription
} }
return fmt.Sprintf("%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 // buildHTMLMessageBody builds the message body in HTML to include in request
func buildHTMLMessageBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string { func buildHTMLMessageBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
var message, results string var message string
if resolved { if resolved {
message = fmt.Sprintf("An alert for <code>%s</code> has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold) message = fmt.Sprintf("An alert for <code>%s</code> has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else { } else {
message = fmt.Sprintf("An alert for <code>%s</code> has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold) message = fmt.Sprintf("An alert for <code>%s</code> has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
} }
for _, conditionResult := range result.ConditionResults { var formattedConditionResults string
var prefix string if len(result.ConditionResults) > 0 {
if conditionResult.Success { formattedConditionResults = "\n<h5>Condition results</h5><ul>"
prefix = "✅" for _, conditionResult := range result.ConditionResults {
} else { var prefix string
prefix = "❌" 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 var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = fmt.Sprintf("\n<blockquote>%s</blockquote>", alertDescription) 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 // 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 { if provider.Overrides != nil {
for _, override := range provider.Overrides { for _, override := range provider.Overrides {
if group == override.Group { if group == override.Group {
return override.MatrixProviderConfig return override.ProviderConfig
} }
} }
} }
return provider.MatrixProviderConfig return provider.ProviderConfig
} }
func randStringBytes(n int) string { func randStringBytes(n int) string {
@@ -185,6 +191,6 @@ func randStringBytes(n int) string {
} }
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert
} }
+27 -27
View File
@@ -7,13 +7,13 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{ invalidProvider := AlertProvider{
MatrixProviderConfig: MatrixProviderConfig{ ProviderConfig: ProviderConfig{
AccessToken: "", AccessToken: "",
InternalRoomID: "", InternalRoomID: "",
}, },
@@ -22,7 +22,7 @@ func TestAlertProvider_IsValid(t *testing.T) {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{ validProvider := AlertProvider{
MatrixProviderConfig: MatrixProviderConfig{ ProviderConfig: ProviderConfig{
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
}, },
@@ -31,7 +31,7 @@ func TestAlertProvider_IsValid(t *testing.T) {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
validProviderWithHomeserver := AlertProvider{ validProviderWithHomeserver := AlertProvider{
MatrixProviderConfig: MatrixProviderConfig{ ProviderConfig: ProviderConfig{
ServerURL: "https://example.com", ServerURL: "https://example.com",
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
@@ -47,7 +47,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) {
Overrides: []Override{ Overrides: []Override{
{ {
Group: "", Group: "",
MatrixProviderConfig: MatrixProviderConfig{ ProviderConfig: ProviderConfig{
AccessToken: "", AccessToken: "",
InternalRoomID: "", InternalRoomID: "",
}, },
@@ -61,7 +61,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) {
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
MatrixProviderConfig: MatrixProviderConfig{ ProviderConfig: ProviderConfig{
AccessToken: "", AccessToken: "",
InternalRoomID: "", InternalRoomID: "",
}, },
@@ -72,14 +72,14 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) {
t.Error("provider integration key shouldn't have been valid") t.Error("provider integration key shouldn't have been valid")
} }
providerWithValidOverride := AlertProvider{ providerWithValidOverride := AlertProvider{
MatrixProviderConfig: MatrixProviderConfig{ ProviderConfig: ProviderConfig{
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
}, },
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
MatrixProviderConfig: MatrixProviderConfig{ ProviderConfig: ProviderConfig{
ServerURL: "https://example.com", ServerURL: "https://example.com",
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
@@ -149,10 +149,10 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send( err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },
@@ -197,10 +197,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },
@@ -219,10 +219,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
} }
func TestAlertProvider_GetDefaultAlert(t *testing.T) { func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil { if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil") t.Error("expected default alert to be not nil")
} }
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil { if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil") t.Error("expected default alert to be nil")
} }
} }
@@ -232,12 +232,12 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
Name string Name string
Provider AlertProvider Provider AlertProvider
InputGroup string InputGroup string
ExpectedOutput MatrixProviderConfig ExpectedOutput ProviderConfig
}{ }{
{ {
Name: "provider-no-override-specify-no-group-should-default", Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
MatrixProviderConfig: MatrixProviderConfig{ ProviderConfig: ProviderConfig{
ServerURL: "https://example.com", ServerURL: "https://example.com",
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
@@ -245,7 +245,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
Overrides: nil, Overrides: nil,
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: MatrixProviderConfig{ ExpectedOutput: ProviderConfig{
ServerURL: "https://example.com", ServerURL: "https://example.com",
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
@@ -254,7 +254,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
{ {
Name: "provider-no-override-specify-group-should-default", Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
MatrixProviderConfig: MatrixProviderConfig{ ProviderConfig: ProviderConfig{
ServerURL: "https://example.com", ServerURL: "https://example.com",
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
@@ -262,7 +262,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
Overrides: nil, Overrides: nil,
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: MatrixProviderConfig{ ExpectedOutput: ProviderConfig{
ServerURL: "https://example.com", ServerURL: "https://example.com",
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
@@ -271,7 +271,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
{ {
Name: "provider-with-override-specify-no-group-should-default", Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
MatrixProviderConfig: MatrixProviderConfig{ ProviderConfig: ProviderConfig{
ServerURL: "https://example.com", ServerURL: "https://example.com",
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
@@ -279,7 +279,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
MatrixProviderConfig: MatrixProviderConfig{ ProviderConfig: ProviderConfig{
ServerURL: "https://example01.com", ServerURL: "https://example01.com",
AccessToken: "12", AccessToken: "12",
InternalRoomID: "!a:example01.com", InternalRoomID: "!a:example01.com",
@@ -288,7 +288,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
}, },
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: MatrixProviderConfig{ ExpectedOutput: ProviderConfig{
ServerURL: "https://example.com", ServerURL: "https://example.com",
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
@@ -297,7 +297,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
{ {
Name: "provider-with-override-specify-group-should-override", Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{ Provider: AlertProvider{
MatrixProviderConfig: MatrixProviderConfig{ ProviderConfig: ProviderConfig{
ServerURL: "https://example.com", ServerURL: "https://example.com",
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
@@ -305,7 +305,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
MatrixProviderConfig: MatrixProviderConfig{ ProviderConfig: ProviderConfig{
ServerURL: "https://example01.com", ServerURL: "https://example01.com",
AccessToken: "12", AccessToken: "12",
InternalRoomID: "!a:example01.com", InternalRoomID: "!a:example01.com",
@@ -314,7 +314,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
}, },
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: MatrixProviderConfig{ ExpectedOutput: ProviderConfig{
ServerURL: "https://example01.com", ServerURL: "https://example01.com",
AccessToken: "12", AccessToken: "12",
InternalRoomID: "!a:example01.com", InternalRoomID: "!a:example01.com",
+30 -26
View File
@@ -9,7 +9,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
// AlertProvider is the configuration necessary for sending an alert using Mattermost // AlertProvider is the configuration necessary for sending an alert using Mattermost
@@ -50,9 +50,9 @@ func (provider *AlertProvider) IsValid() bool {
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved))) buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(ep, alert, result, resolved)))
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer) request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer)
if err != nil { if err != nil {
return err return err
} }
@@ -92,29 +92,32 @@ type Field struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message, color, results string var message, color string
if resolved { if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold) message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
color = "#36A64F" color = "#36A64F"
} else { } else {
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold) message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
color = "#DD0000" color = "#DD0000"
} }
for _, conditionResult := range result.ConditionResults { var formattedConditionResults string
var prefix string if len(result.ConditionResults) > 0 {
if conditionResult.Success { for _, conditionResult := range result.ConditionResults {
prefix = ":white_check_mark:" var prefix string
} else { if conditionResult.Success {
prefix = ":x:" 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 var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\n> " + alertDescription description = ":\n> " + alertDescription
} }
body, _ := json.Marshal(Body{ body := Body{
Text: "", Text: "",
Username: "gatus", Username: "gatus",
IconURL: "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png", IconURL: "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png",
@@ -125,17 +128,18 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
Text: message + description, Text: message + description,
Short: false, Short: false,
Color: color, 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 // getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
@@ -151,6 +155,6 @@ func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
} }
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert
} }
+10 -10
View File
@@ -7,7 +7,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
@@ -46,7 +46,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) {
}, },
} }
if providerWithInvalidOverrideWebHookUrl.IsValid() { if providerWithInvalidOverrideWebHookUrl.IsValid() {
t.Error("provider WebHookURL shoudn't have been valid") t.Error("provider WebHookURL shouldn't have been valid")
} }
providerWithValidOverride := AlertProvider{ providerWithValidOverride := AlertProvider{
@@ -120,10 +120,10 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send( err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },
@@ -168,10 +168,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },
@@ -190,10 +190,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
} }
func TestAlertProvider_GetDefaultAlert(t *testing.T) { func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil { if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil") t.Error("expected default alert to be not nil")
} }
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil { if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil") t.Error("expected default alert to be nil")
} }
} }
+7 -7
View File
@@ -9,7 +9,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
const ( const (
@@ -33,8 +33,8 @@ func (provider *AlertProvider) IsValid() bool {
// Send an alert using the provider // Send an alert using the provider
// Reference doc for messagebird: https://developers.messagebird.com/api/sms-messaging/#send-outbound-sms // Reference doc for messagebird: https://developers.messagebird.com/api/sms-messaging/#send-outbound-sms
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer) request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
if err != nil { if err != nil {
return err return err
@@ -60,12 +60,12 @@ type Body struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message string var message string
if resolved { if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription()) message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
} else { } else {
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.DisplayName(), alert.GetDescription()) message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
} }
body, _ := json.Marshal(Body{ body, _ := json.Marshal(Body{
Originator: provider.Originator, Originator: provider.Originator,
@@ -76,6 +76,6 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
} }
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert
} }
@@ -7,7 +7,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
@@ -83,10 +83,10 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send( err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },
@@ -131,10 +131,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },
@@ -153,10 +153,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
} }
func TestAlertProvider_GetDefaultAlert(t *testing.T) { func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil { if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil") t.Error("expected default alert to be not nil")
} }
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil { if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil") t.Error("expected default alert to be nil")
} }
} }
+9 -9
View File
@@ -11,7 +11,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
const ( const (
@@ -46,8 +46,8 @@ func (provider *AlertProvider) IsValid() bool {
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.URL, buffer) request, err := http.NewRequest(http.MethodPost, provider.URL, buffer)
if err != nil { if err != nil {
return err return err
@@ -77,8 +77,8 @@ type Body struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message, results, tag string var message, formattedConditionResults, tag string
if resolved { if resolved {
tag = "white_check_mark" tag = "white_check_mark"
message = "An alert has been resolved after passing successfully " + strconv.Itoa(alert.SuccessThreshold) + " time(s) in a row" message = "An alert has been resolved after passing successfully " + strconv.Itoa(alert.SuccessThreshold) + " time(s) in a row"
@@ -93,15 +93,15 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
} else { } else {
prefix = "🔴" prefix = "🔴"
} }
results += fmt.Sprintf("\n%s %s", prefix, conditionResult.Condition) formattedConditionResults += fmt.Sprintf("\n%s %s", prefix, conditionResult.Condition)
} }
if len(alert.GetDescription()) > 0 { if len(alert.GetDescription()) > 0 {
message += " with the following description: " + alert.GetDescription() message += " with the following description: " + alert.GetDescription()
} }
message += results message += formattedConditionResults
body, _ := json.Marshal(Body{ body, _ := json.Marshal(Body{
Topic: provider.Topic, Topic: provider.Topic,
Title: "Gatus: " + endpoint.DisplayName(), Title: "Gatus: " + ep.DisplayName(),
Message: message, Message: message,
Tags: []string{tag}, Tags: []string{tag},
Priority: provider.Priority, Priority: provider.Priority,
@@ -110,6 +110,6 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
} }
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert
} }
+4 -4
View File
@@ -5,7 +5,7 @@ import (
"testing" "testing"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
func TestAlertDefaultProvider_IsValid(t *testing.T) { func TestAlertDefaultProvider_IsValid(t *testing.T) {
@@ -92,10 +92,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },
+32 -31
View File
@@ -11,7 +11,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
const ( const (
@@ -59,13 +59,13 @@ func (provider *AlertProvider) IsValid() bool {
// Send an alert using the provider // Send an alert using the provider
// //
// Relevant: https://docs.opsgenie.com/docs/alert-api // Relevant: https://docs.opsgenie.com/docs/alert-api
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
err := provider.createAlert(endpoint, alert, result, resolved) err := provider.createAlert(ep, alert, result, resolved)
if err != nil { if err != nil {
return err return err
} }
if resolved { if resolved {
err = provider.closeAlert(endpoint, alert) err = provider.closeAlert(ep, alert)
if err != nil { if err != nil {
return err 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 // The alert has been resolved and there's no error, so we can clear the alert's ResolveKey
alert.ResolveKey = "" alert.ResolveKey = ""
} else { } else {
alert.ResolveKey = provider.alias(buildKey(endpoint)) alert.ResolveKey = provider.alias(buildKey(ep))
} }
} }
return nil return nil
} }
func (provider *AlertProvider) createAlert(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) createAlert(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
payload := provider.buildCreateRequestBody(endpoint, alert, result, resolved) payload := provider.buildCreateRequestBody(ep, alert, result, resolved)
return provider.sendRequest(restAPI, http.MethodPost, payload) return provider.sendRequest(restAPI, http.MethodPost, payload)
} }
func (provider *AlertProvider) closeAlert(endpoint *core.Endpoint, alert *alert.Alert) error { func (provider *AlertProvider) closeAlert(ep *endpoint.Endpoint, alert *alert.Alert) error {
payload := provider.buildCloseRequestBody(endpoint, alert) payload := provider.buildCloseRequestBody(ep, alert)
url := restAPI + "/" + provider.alias(buildKey(endpoint)) + "/close?identifierType=alias" url := restAPI + "/" + provider.alias(buildKey(ep)) + "/close?identifierType=alias"
return provider.sendRequest(url, http.MethodPost, payload) return provider.sendRequest(url, http.MethodPost, payload)
} }
@@ -115,18 +115,19 @@ func (provider *AlertProvider) sendRequest(url, method string, payload interface
return nil return nil
} }
func (provider *AlertProvider) buildCreateRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) alertCreateRequest { func (provider *AlertProvider) buildCreateRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) alertCreateRequest {
var message, description, results string var message, description string
if resolved { if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription()) message = fmt.Sprintf("RESOLVED: %s - %s", ep.Name, alert.GetDescription())
description = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold) description = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else { } else {
message = fmt.Sprintf("%s - %s", endpoint.Name, alert.GetDescription()) message = fmt.Sprintf("%s - %s", ep.Name, alert.GetDescription())
description = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold) description = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
} }
if endpoint.Group != "" { if ep.Group != "" {
message = fmt.Sprintf("[%s] %s", endpoint.Group, message) message = fmt.Sprintf("[%s] %s", ep.Group, message)
} }
var formattedConditionResults string
for _, conditionResult := range result.ConditionResults { for _, conditionResult := range result.ConditionResults {
var prefix string var prefix string
if conditionResult.Success { if conditionResult.Success {
@@ -134,13 +135,13 @@ func (provider *AlertProvider) buildCreateRequestBody(endpoint *core.Endpoint, a
} else { } else {
prefix = "▢" prefix = "▢"
} }
results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition) formattedConditionResults += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
} }
description = description + "\n" + results description = description + "\n" + formattedConditionResults
key := buildKey(endpoint) key := buildKey(ep)
details := map[string]string{ details := map[string]string{
"endpoint:url": endpoint.URL, "endpoint:url": ep.URL,
"endpoint:group": endpoint.Group, "endpoint:group": ep.Group,
"result:hostname": result.Hostname, "result:hostname": result.Hostname,
"result:ip": result.IP, "result:ip": result.IP,
"result:dns_code": result.DNSRCode, "result:dns_code": result.DNSRCode,
@@ -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{ return alertCloseRequest{
Source: buildKey(endpoint), Source: buildKey(ep),
Note: fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription()), Note: fmt.Sprintf("RESOLVED: %s - %s", ep.Name, alert.GetDescription()),
} }
} }
@@ -206,16 +207,16 @@ func (provider *AlertProvider) priority() string {
} }
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert
} }
func buildKey(endpoint *core.Endpoint) string { func buildKey(ep *endpoint.Endpoint) string {
name := toKebabCase(endpoint.Name) name := toKebabCase(ep.Name)
if endpoint.Group == "" { if ep.Group == "" {
return name return name
} }
return toKebabCase(endpoint.Group) + "-" + name return toKebabCase(ep.Group) + "-" + name
} }
func toKebabCase(val string) string { func toKebabCase(val string) string {
+22 -22
View File
@@ -7,7 +7,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
@@ -79,10 +79,10 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send( err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },
@@ -106,8 +106,8 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
Name string Name string
Provider *AlertProvider Provider *AlertProvider
Alert *alert.Alert Alert *alert.Alert
Endpoint *core.Endpoint Endpoint *endpoint.Endpoint
Result *core.Result Result *endpoint.Result
Resolved bool Resolved bool
want alertCreateRequest want alertCreateRequest
}{ }{
@@ -115,8 +115,8 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
Name: "missing all params (unresolved)", Name: "missing all params (unresolved)",
Provider: &AlertProvider{}, Provider: &AlertProvider{},
Alert: &alert.Alert{}, Alert: &alert.Alert{},
Endpoint: &core.Endpoint{}, Endpoint: &endpoint.Endpoint{},
Result: &core.Result{}, Result: &endpoint.Result{},
Resolved: false, Resolved: false,
want: alertCreateRequest{ want: alertCreateRequest{
Message: " - ", Message: " - ",
@@ -133,8 +133,8 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
Name: "missing all params (resolved)", Name: "missing all params (resolved)",
Provider: &AlertProvider{}, Provider: &AlertProvider{},
Alert: &alert.Alert{}, Alert: &alert.Alert{},
Endpoint: &core.Endpoint{}, Endpoint: &endpoint.Endpoint{},
Result: &core.Result{}, Result: &endpoint.Result{},
Resolved: true, Resolved: true,
want: alertCreateRequest{ want: alertCreateRequest{
Message: "RESOLVED: - ", Message: "RESOLVED: - ",
@@ -154,11 +154,11 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
Description: &description, Description: &description,
FailureThreshold: 3, FailureThreshold: 3,
}, },
Endpoint: &core.Endpoint{ Endpoint: &endpoint.Endpoint{
Name: "my super app", Name: "my super app",
}, },
Result: &core.Result{ Result: &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{ {
Condition: "[STATUS] == 200", Condition: "[STATUS] == 200",
Success: true, Success: true,
@@ -194,11 +194,11 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
Description: &description, Description: &description,
SuccessThreshold: 4, SuccessThreshold: 4,
}, },
Endpoint: &core.Endpoint{ Endpoint: &endpoint.Endpoint{
Name: "my mega app", Name: "my mega app",
}, },
Result: &core.Result{ Result: &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{ {
Condition: "[STATUS] == 200", Condition: "[STATUS] == 200",
Success: true, Success: true,
@@ -226,17 +226,17 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
Description: &description, Description: &description,
FailureThreshold: 6, FailureThreshold: 6,
}, },
Endpoint: &core.Endpoint{ Endpoint: &endpoint.Endpoint{
Name: "my app", Name: "my app",
Group: "end game", Group: "end game",
URL: "https://my.go/app", URL: "https://my.go/app",
}, },
Result: &core.Result{ Result: &endpoint.Result{
HTTPStatus: 400, HTTPStatus: 400,
Hostname: "my.go", Hostname: "my.go",
Errors: []string{"error 01", "error 02"}, Errors: []string{"error 01", "error 02"},
Success: false, Success: false,
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{ {
Condition: "[STATUS] == 200", Condition: "[STATUS] == 200",
Success: false, Success: false,
@@ -279,14 +279,14 @@ func TestAlertProvider_buildCloseRequestBody(t *testing.T) {
Name string Name string
Provider *AlertProvider Provider *AlertProvider
Alert *alert.Alert Alert *alert.Alert
Endpoint *core.Endpoint Endpoint *endpoint.Endpoint
want alertCloseRequest want alertCloseRequest
}{ }{
{ {
Name: "Missing all values", Name: "Missing all values",
Provider: &AlertProvider{}, Provider: &AlertProvider{},
Alert: &alert.Alert{}, Alert: &alert.Alert{},
Endpoint: &core.Endpoint{}, Endpoint: &endpoint.Endpoint{},
want: alertCloseRequest{ want: alertCloseRequest{
Source: "", Source: "",
Note: "RESOLVED: - ", Note: "RESOLVED: - ",
@@ -298,7 +298,7 @@ func TestAlertProvider_buildCloseRequestBody(t *testing.T) {
Alert: &alert.Alert{ Alert: &alert.Alert{
Description: &description, Description: &description,
}, },
Endpoint: &core.Endpoint{ Endpoint: &endpoint.Endpoint{
Name: "endpoint name", Name: "endpoint name",
}, },
want: alertCloseRequest{ want: alertCloseRequest{
+9 -9
View File
@@ -10,7 +10,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
const ( const (
@@ -52,8 +52,8 @@ func (provider *AlertProvider) IsValid() bool {
// Send an alert using the provider // Send an alert using the provider
// //
// Relevant: https://developer.pagerduty.com/docs/events-api-v2/trigger-events/ // Relevant: https://developer.pagerduty.com/docs/events-api-v2/trigger-events/
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer) request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
if err != nil { if err != nil {
return err return err
@@ -78,7 +78,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
var payload pagerDutyResponsePayload var payload pagerDutyResponsePayload
if err = json.Unmarshal(body, &payload); err != nil { 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. // 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 { } else {
alert.ResolveKey = payload.DedupKey alert.ResolveKey = payload.DedupKey
} }
@@ -101,19 +101,19 @@ type Payload struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message, eventAction, resolveKey string var message, eventAction, resolveKey string
if resolved { if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription()) message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
eventAction = "resolve" eventAction = "resolve"
resolveKey = alert.ResolveKey resolveKey = alert.ResolveKey
} else { } else {
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.DisplayName(), alert.GetDescription()) message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
eventAction = "trigger" eventAction = "trigger"
resolveKey = "" resolveKey = ""
} }
body, _ := json.Marshal(Body{ body, _ := json.Marshal(Body{
RoutingKey: provider.getIntegrationKeyForGroup(endpoint.Group), RoutingKey: provider.getIntegrationKeyForGroup(ep.Group),
DedupKey: resolveKey, DedupKey: resolveKey,
EventAction: eventAction, EventAction: eventAction,
Payload: Payload{ Payload: Payload{
@@ -138,7 +138,7 @@ func (provider *AlertProvider) getIntegrationKeyForGroup(group string) string {
} }
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert
} }
@@ -7,7 +7,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
@@ -115,10 +115,10 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send( err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },
@@ -161,7 +161,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(&core.Endpoint{Name: "endpoint-name"}, &scenario.Alert, &core.Result{}, scenario.Resolved) body := scenario.Provider.buildRequestBody(&endpoint.Endpoint{Name: "endpoint-name"}, &scenario.Alert, &endpoint.Result{}, scenario.Resolved)
if string(body) != scenario.ExpectedBody { if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body) t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
} }
@@ -237,10 +237,10 @@ func TestAlertProvider_getIntegrationKeyForGroup(t *testing.T) {
} }
func TestAlertProvider_GetDefaultAlert(t *testing.T) { func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil { if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil") t.Error("expected default alert to be not nil")
} }
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil { if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil") t.Error("expected default alert to be nil")
} }
} }
+5 -3
View File
@@ -9,6 +9,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/github" "github.com/TwiN/gatus/v5/alerting/provider/github"
"github.com/TwiN/gatus/v5/alerting/provider/gitlab" "github.com/TwiN/gatus/v5/alerting/provider/gitlab"
"github.com/TwiN/gatus/v5/alerting/provider/googlechat" "github.com/TwiN/gatus/v5/alerting/provider/googlechat"
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
"github.com/TwiN/gatus/v5/alerting/provider/matrix" "github.com/TwiN/gatus/v5/alerting/provider/matrix"
"github.com/TwiN/gatus/v5/alerting/provider/mattermost" "github.com/TwiN/gatus/v5/alerting/provider/mattermost"
"github.com/TwiN/gatus/v5/alerting/provider/messagebird" "github.com/TwiN/gatus/v5/alerting/provider/messagebird"
@@ -20,10 +21,10 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/teams" "github.com/TwiN/gatus/v5/alerting/provider/teams"
"github.com/TwiN/gatus/v5/alerting/provider/telegram" "github.com/TwiN/gatus/v5/alerting/provider/telegram"
"github.com/TwiN/gatus/v5/alerting/provider/twilio" "github.com/TwiN/gatus/v5/alerting/provider/twilio"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
// AlertProvider is the interface that each providers should implement // AlertProvider is the interface that each provider should implement
type AlertProvider interface { type AlertProvider interface {
// IsValid returns whether the provider's configuration is valid // IsValid returns whether the provider's configuration is valid
IsValid() bool IsValid() bool
@@ -32,7 +33,7 @@ type AlertProvider interface {
GetDefaultAlert() *alert.Alert GetDefaultAlert() *alert.Alert
// Send an alert using the provider // 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 // ParseWithDefaultAlert parses an Endpoint alert by using the provider's default alert as a baseline
@@ -66,6 +67,7 @@ var (
_ AlertProvider = (*github.AlertProvider)(nil) _ AlertProvider = (*github.AlertProvider)(nil)
_ AlertProvider = (*gitlab.AlertProvider)(nil) _ AlertProvider = (*gitlab.AlertProvider)(nil)
_ AlertProvider = (*googlechat.AlertProvider)(nil) _ AlertProvider = (*googlechat.AlertProvider)(nil)
_ AlertProvider = (*jetbrainsspace.AlertProvider)(nil)
_ AlertProvider = (*matrix.AlertProvider)(nil) _ AlertProvider = (*matrix.AlertProvider)(nil)
_ AlertProvider = (*mattermost.AlertProvider)(nil) _ AlertProvider = (*mattermost.AlertProvider)(nil)
_ AlertProvider = (*messagebird.AlertProvider)(nil) _ AlertProvider = (*messagebird.AlertProvider)(nil)
+7 -7
View File
@@ -9,7 +9,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
const ( const (
@@ -52,8 +52,8 @@ func (provider *AlertProvider) IsValid() bool {
// Send an alert using the provider // Send an alert using the provider
// Reference doc for pushover: https://pushover.net/api // Reference doc for pushover: https://pushover.net/api
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer) request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
if err != nil { if err != nil {
return err return err
@@ -81,12 +81,12 @@ type Body struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message string var message string
if resolved { if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription()) message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
} else { } else {
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.DisplayName(), alert.GetDescription()) message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
} }
body, _ := json.Marshal(Body{ body, _ := json.Marshal(Body{
Token: provider.ApplicationToken, Token: provider.ApplicationToken,
@@ -107,6 +107,6 @@ func (provider *AlertProvider) priority() int {
} }
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert
} }
+9 -9
View File
@@ -7,7 +7,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
@@ -95,10 +95,10 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send( err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },
@@ -150,10 +150,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },
@@ -172,10 +172,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
} }
func TestAlertProvider_GetDefaultAlert(t *testing.T) { func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil { if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil") t.Error("expected default alert to be not nil")
} }
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil { if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil") t.Error("expected default alert to be nil")
} }
} }
+23 -21
View File
@@ -9,7 +9,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
// AlertProvider is the configuration necessary for sending an alert using Slack // 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 // Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer) request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer)
if err != nil { if err != nil {
return err return err
} }
@@ -71,7 +71,7 @@ type Attachment struct {
Text string `json:"text"` Text string `json:"text"`
Short bool `json:"short"` Short bool `json:"short"`
Color string `json:"color"` Color string `json:"color"`
Fields []Field `json:"fields"` Fields []Field `json:"fields,omitempty"`
} }
type Field struct { type Field struct {
@@ -81,15 +81,16 @@ type Field struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message, color, results string var message, color string
if resolved { if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold) message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
color = "#36A64F" color = "#36A64F"
} else { } else {
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold) message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
color = "#DD0000" color = "#DD0000"
} }
var formattedConditionResults string
for _, conditionResult := range result.ConditionResults { for _, conditionResult := range result.ConditionResults {
var prefix string var prefix string
if conditionResult.Success { if conditionResult.Success {
@@ -97,13 +98,13 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
} else { } else {
prefix = ":x:" prefix = ":x:"
} }
results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition) formattedConditionResults += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
} }
var description string var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\n> " + alertDescription description = ":\n> " + alertDescription
} }
body, _ := json.Marshal(Body{ body := Body{
Text: "", Text: "",
Attachments: []Attachment{ Attachments: []Attachment{
{ {
@@ -111,17 +112,18 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
Text: message + description, Text: message + description,
Short: false, Short: false,
Color: color, 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 // 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 // GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert
} }
+30 -16
View File
@@ -7,7 +7,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
@@ -116,10 +116,10 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send( err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },
@@ -142,15 +142,16 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
scenarios := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
Endpoint core.Endpoint Endpoint endpoint.Endpoint
Alert alert.Alert Alert alert.Alert
NoConditions bool
Resolved bool Resolved bool
ExpectedBody string ExpectedBody string
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{},
Endpoint: core.Endpoint{Name: "name"}, Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"short\":false,\"color\":\"#DD0000\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\"short\":false}]}]}", ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"short\":false,\"color\":\"#DD0000\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
@@ -158,15 +159,24 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
{ {
Name: "triggered-with-group", Name: "triggered-with-group",
Provider: AlertProvider{}, Provider: AlertProvider{},
Endpoint: core.Endpoint{Name: "name", Group: "group"}, Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *group/name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"short\":false,\"color\":\"#DD0000\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\"short\":false}]}]}", ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *group/name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"short\":false,\"color\":\"#DD0000\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
}, },
{
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", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{},
Endpoint: core.Endpoint{Name: "name"}, Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}", ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
@@ -174,7 +184,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
{ {
Name: "resolved-with-group", Name: "resolved-with-group",
Provider: AlertProvider{}, Provider: AlertProvider{},
Endpoint: core.Endpoint{Name: "name", Group: "group"}, Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}", ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
@@ -182,14 +192,18 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
var conditionResults []*endpoint.ConditionResult
if !scenario.NoConditions {
conditionResults = []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
}
}
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&scenario.Endpoint, &scenario.Endpoint,
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: conditionResults,
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
}, },
scenario.Resolved, scenario.Resolved,
) )
@@ -205,10 +219,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
} }
func TestAlertProvider_GetDefaultAlert(t *testing.T) { func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil { if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil") t.Error("expected default alert to be not nil")
} }
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil { if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil") t.Error("expected default alert to be nil")
} }
} }
+28 -21
View File
@@ -9,7 +9,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
// AlertProvider is the configuration necessary for sending an alert using Teams // AlertProvider is the configuration necessary for sending an alert using Teams
@@ -21,6 +21,9 @@ type AlertProvider struct {
// Overrides is a list of Override that may be prioritized over the default configuration // Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"` Overrides []Override `yaml:"overrides,omitempty"`
// Title is the title of the message that will be sent
Title string `yaml:"title,omitempty"`
} }
// Override is a case under which the default integration is overridden // 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 // Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer) request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer)
if err != nil { if err != nil {
return err return err
} }
@@ -69,7 +72,7 @@ type Body struct {
ThemeColor string `json:"themeColor"` ThemeColor string `json:"themeColor"`
Title string `json:"title"` Title string `json:"title"`
Text string `json:"text"` Text string `json:"text"`
Sections []Section `json:"sections"` Sections []Section `json:"sections,omitempty"`
} }
type Section struct { type Section struct {
@@ -78,16 +81,16 @@ type Section struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message, color string var message, color string
if resolved { if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold) message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
color = "#36A64F" color = "#36A64F"
} else { } else {
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold) message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
color = "#DD0000" color = "#DD0000"
} }
var results string var formattedConditionResults string
for _, conditionResult := range result.ConditionResults { for _, conditionResult := range result.ConditionResults {
var prefix string var prefix string
if conditionResult.Success { if conditionResult.Success {
@@ -95,26 +98,30 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
} else { } else {
prefix = "&#x274C;" prefix = "&#x274C;"
} }
results += fmt.Sprintf("%s - `%s`<br/>", prefix, conditionResult.Condition) formattedConditionResults += fmt.Sprintf("%s - `%s`<br/>", prefix, conditionResult.Condition)
} }
var description string var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ": " + alertDescription description = ": " + alertDescription
} }
body, _ := json.Marshal(Body{ body := Body{
Type: "MessageCard", Type: "MessageCard",
Context: "http://schema.org/extensions", Context: "http://schema.org/extensions",
ThemeColor: color, ThemeColor: color,
Title: "&#x1F6A8; Gatus", Title: provider.Title,
Text: message + description, Text: message + description,
Sections: []Section{ }
{ if len(body.Title) == 0 {
ActivityTitle: "Condition results", body.Title = "&#x1F6A8; Gatus"
Text: results, }
}, if len(formattedConditionResults) > 0 {
}, body.Sections = append(body.Sections, Section{
}) ActivityTitle: "Condition results",
return body Text: formattedConditionResults,
})
}
bodyAsJSON, _ := json.Marshal(body)
return bodyAsJSON
} }
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group // getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
@@ -130,6 +137,6 @@ func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
} }
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert
} }
+24 -13
View File
@@ -7,7 +7,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
@@ -116,10 +116,10 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send( err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },
@@ -143,6 +143,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Name string Name string
Provider AlertProvider Provider AlertProvider
Alert alert.Alert Alert alert.Alert
NoConditions bool
Resolved bool Resolved bool
ExpectedBody string ExpectedBody string
}{ }{
@@ -160,18 +161,28 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Resolved: true, 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\"}]}", 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 { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { 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( body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{ConditionResults: conditionResults},
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved, scenario.Resolved,
) )
if string(body) != scenario.ExpectedBody { if string(body) != scenario.ExpectedBody {
@@ -186,10 +197,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
} }
func TestAlertProvider_GetDefaultAlert(t *testing.T) { func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil { if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil") t.Error("expected default alert to be not nil")
} }
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil { if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil") t.Error("expected default alert to be nil")
} }
} }
+23 -19
View File
@@ -9,7 +9,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
const defaultAPIURL = "https://api.telegram.org" const defaultAPIURL = "https://api.telegram.org"
@@ -36,8 +36,8 @@ func (provider *AlertProvider) IsValid() bool {
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
apiURL := provider.APIURL apiURL := provider.APIURL
if apiURL == "" { if apiURL == "" {
apiURL = defaultAPIURL apiURL = defaultAPIURL
@@ -66,37 +66,41 @@ type Body struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message, results string var message string
if resolved { if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved:\n—\n _healthcheck passing successfully %d time(s) in a row_\n— ", endpoint.DisplayName(), alert.SuccessThreshold) message = fmt.Sprintf("An alert for *%s* has been resolved:\n—\n _healthcheck passing successfully %d time(s) in a row_\n— ", ep.DisplayName(), alert.SuccessThreshold)
} else { } else {
message = fmt.Sprintf("An alert for *%s* has been triggered:\n—\n _healthcheck failed %d time(s) in a row_\n— ", endpoint.DisplayName(), alert.FailureThreshold) message = fmt.Sprintf("An alert for *%s* has been triggered:\n—\n _healthcheck failed %d time(s) in a row_\n— ", ep.DisplayName(), alert.FailureThreshold)
} }
for _, conditionResult := range result.ConditionResults { var formattedConditionResults string
var prefix string if len(result.ConditionResults) > 0 {
if conditionResult.Success { formattedConditionResults = "\n*Condition results*\n"
prefix = "✅" for _, conditionResult := range result.ConditionResults {
} else { var prefix string
prefix = "❌" 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 var text string
if len(alert.GetDescription()) > 0 { if len(alert.GetDescription()) > 0 {
text = fmt.Sprintf("⛑ *Gatus* \n%s \n*Description* \n_%s_ \n\n*Condition results*\n%s", message, alert.GetDescription(), results) text = fmt.Sprintf("⛑ *Gatus* \n%s \n*Description* \n_%s_ \n%s", message, alert.GetDescription(), formattedConditionResults)
} else { } 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{ bodyAsJSON, _ := json.Marshal(Body{
ChatID: provider.ID, ChatID: provider.ID,
Text: text, Text: text,
ParseMode: "MARKDOWN", ParseMode: "MARKDOWN",
}) })
return body return bodyAsJSON
} }
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert
} }
+24 -13
View File
@@ -7,7 +7,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
@@ -89,10 +89,10 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send( err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },
@@ -116,6 +116,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Name string Name string
Provider AlertProvider Provider AlertProvider
Alert alert.Alert Alert alert.Alert
NoConditions bool
Resolved bool Resolved bool
ExpectedBody string ExpectedBody string
}{ }{
@@ -133,18 +134,28 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Resolved: true, Resolved: true,
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 5 time(s) in a row_\\n— \\n*Description* \\n_description-2_ \\n\\n*Condition results*\\n✅ - `[CONNECTED] == true`\\n✅ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}", ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 5 time(s) in a row_\\n— \\n*Description* \\n_description-2_ \\n\\n*Condition results*\\n✅ - `[CONNECTED] == true`\\n✅ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
}, },
{
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 { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { 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( body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{ConditionResults: conditionResults},
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved, scenario.Resolved,
) )
if string(body) != scenario.ExpectedBody { if string(body) != scenario.ExpectedBody {
@@ -159,10 +170,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
} }
func TestAlertProvider_GetDefaultAlert(t *testing.T) { func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil { if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil") t.Error("expected default alert to be not nil")
} }
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil { if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil") t.Error("expected default alert to be nil")
} }
} }
+7 -7
View File
@@ -10,7 +10,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
// AlertProvider is the configuration necessary for sending an alert using Twilio // 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 // Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved))) 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) 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 { if err != nil {
return err return err
@@ -51,12 +51,12 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string { func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
var message string var message string
if resolved { if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription()) message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
} else { } else {
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.DisplayName(), alert.GetDescription()) message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
} }
return url.Values{ return url.Values{
"To": {provider.To}, "To": {provider.To},
@@ -66,6 +66,6 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
} }
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert
} }
+6 -6
View File
@@ -4,7 +4,7 @@ import (
"testing" "testing"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
func TestTwilioAlertProvider_IsValid(t *testing.T) { func TestTwilioAlertProvider_IsValid(t *testing.T) {
@@ -51,10 +51,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },
@@ -69,10 +69,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
} }
func TestAlertProvider_GetDefaultAlert(t *testing.T) { func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil { if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil") t.Error("expected default alert to be not nil")
} }
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil { if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil") t.Error("expected default alert to be nil")
} }
} }
+10
View File
@@ -7,6 +7,7 @@ import (
"os" "os"
"github.com/TwiN/gatus/v5/config" "github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/web"
static "github.com/TwiN/gatus/v5/web" static "github.com/TwiN/gatus/v5/web"
"github.com/TwiN/health" "github.com/TwiN/health"
fiber "github.com/gofiber/fiber/v2" fiber "github.com/gofiber/fiber/v2"
@@ -26,6 +27,10 @@ type API struct {
func New(cfg *config.Config) *API { func New(cfg *config.Config) *API {
api := &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) api.router = api.createRouter(cfg)
return api return api
} }
@@ -40,6 +45,8 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App {
log.Printf("[api.ErrorHandler] %s", err.Error()) log.Printf("[api.ErrorHandler] %s", err.Error())
return fiber.DefaultErrorHandler(c, err) return fiber.DefaultErrorHandler(c, err)
}, },
ReadBufferSize: cfg.Web.ReadBufferSize,
Network: fiber.NetworkTCP,
}) })
if os.Getenv("ENVIRONMENT") == "dev" { if os.Getenv("ENVIRONMENT") == "dev" {
app.Use(cors.New(cors.Config{ app.Use(cors.New(cors.Config{
@@ -65,9 +72,12 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App {
unprotectedAPIRouter := apiRouter.Group("/") unprotectedAPIRouter := apiRouter.Group("/")
unprotectedAPIRouter.Get("/v1/config", ConfigHandler{securityConfig: cfg.Security}.GetConfig) unprotectedAPIRouter.Get("/v1/config", ConfigHandler{securityConfig: cfg.Security}.GetConfig)
unprotectedAPIRouter.Get("/v1/endpoints/:key/health/badge.svg", HealthBadge) unprotectedAPIRouter.Get("/v1/endpoints/:key/health/badge.svg", HealthBadge)
unprotectedAPIRouter.Get("/v1/endpoints/:key/health/badge.shields", HealthBadgeShields)
unprotectedAPIRouter.Get("/v1/endpoints/:key/uptimes/:duration/badge.svg", UptimeBadge) 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/badge.svg", ResponseTimeBadge(cfg))
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/chart.svg", ResponseTimeChart) unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/chart.svg", ResponseTimeChart)
// This endpoint requires authz with bearer token, so technically it is protected
unprotectedAPIRouter.Post("/v1/endpoints/:key/external", CreateExternalEndpointResult(cfg))
// SPA // SPA
app.Get("/", SinglePageApplication(cfg.UI)) app.Get("/", SinglePageApplication(cfg.UI))
app.Get("/endpoints/:name", SinglePageApplication(cfg.UI)) app.Get("/endpoints/:name", SinglePageApplication(cfg.UI))
+66 -10
View File
@@ -1,12 +1,15 @@
package api package api
import ( import (
"encoding/json"
"errors"
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/TwiN/gatus/v5/config" "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"
"github.com/TwiN/gatus/v5/storage/store/common" "github.com/TwiN/gatus/v5/storage/store/common"
"github.com/TwiN/gatus/v5/storage/store/common/paging" "github.com/TwiN/gatus/v5/storage/store/common/paging"
@@ -51,9 +54,9 @@ func UptimeBadge(c *fiber.Ctx) error {
key := c.Params("key") key := c.Params("key")
uptime, err := store.Get().GetUptimeByKey(key, from, time.Now()) uptime, err := store.Get().GetUptimeByKey(key, from, time.Now())
if err != nil { if err != nil {
if err == common.ErrEndpointNotFound { if errors.Is(err, common.ErrEndpointNotFound) {
return c.Status(404).SendString(err.Error()) 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(400).SendString(err.Error())
} }
return c.Status(500).SendString(err.Error()) return c.Status(500).SendString(err.Error())
@@ -67,7 +70,7 @@ func UptimeBadge(c *fiber.Ctx) error {
// ResponseTimeBadge handles the automatic generation of badge based on the group name and endpoint name passed. // ResponseTimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.
// //
// Valid values for :duration -> 7d, 24h, 1h // Valid values for :duration -> 7d, 24h, 1h
func ResponseTimeBadge(config *config.Config) fiber.Handler { func ResponseTimeBadge(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error {
duration := c.Params("duration") duration := c.Params("duration")
var from time.Time var from time.Time
@@ -84,9 +87,9 @@ func ResponseTimeBadge(config *config.Config) fiber.Handler {
key := c.Params("key") key := c.Params("key")
averageResponseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now()) averageResponseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now())
if err != nil { if err != nil {
if err == common.ErrEndpointNotFound { if errors.Is(err, common.ErrEndpointNotFound) {
return c.Status(404).SendString(err.Error()) 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(400).SendString(err.Error())
} }
return c.Status(500).SendString(err.Error()) return c.Status(500).SendString(err.Error())
@@ -94,7 +97,7 @@ func ResponseTimeBadge(config *config.Config) fiber.Handler {
c.Set("Content-Type", "image/svg+xml") c.Set("Content-Type", "image/svg+xml")
c.Set("Cache-Control", "no-cache, no-store, must-revalidate") c.Set("Cache-Control", "no-cache, no-store, must-revalidate")
c.Set("Expires", "0") 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 +107,9 @@ func HealthBadge(c *fiber.Ctx) error {
pagingConfig := paging.NewEndpointStatusParams() pagingConfig := paging.NewEndpointStatusParams()
status, err := store.Get().GetEndpointStatusByKey(key, pagingConfig.WithResults(1, 1)) status, err := store.Get().GetEndpointStatusByKey(key, pagingConfig.WithResults(1, 1))
if err != nil { if err != nil {
if err == common.ErrEndpointNotFound { if errors.Is(err, common.ErrEndpointNotFound) {
return c.Status(404).SendString(err.Error()) 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(400).SendString(err.Error())
} }
return c.Status(500).SendString(err.Error()) return c.Status(500).SendString(err.Error())
@@ -125,6 +128,36 @@ func HealthBadge(c *fiber.Ctx) error {
return c.Status(200).Send(generateHealthBadgeSVG(healthStatus)) 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 { func generateUptimeBadgeSVG(duration string, uptime float64) []byte {
var labelWidth, valueWidth, valueWidthAdjustment int var labelWidth, valueWidth, valueWidthAdjustment int
switch duration { switch duration {
@@ -240,10 +273,13 @@ func generateResponseTimeBadgeSVG(duration string, averageResponseTime int, key
} }
func getBadgeColorFromResponseTime(responseTime int, key string, cfg *config.Config) string { 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 // the threshold config requires 5 values, so we can be sure it's set here
for i := 0; i < 5; i++ { for i := 0; i < 5; i++ {
if responseTime <= endpoint.UIConfig.Badge.ResponseTime.Thresholds[i] { if responseTime <= thresholds[i] {
return badgeColors[i] return badgeColors[i]
} }
} }
@@ -299,6 +335,17 @@ func generateHealthBadgeSVG(healthStatus string) []byte {
return svg 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 { func getBadgeColorFromHealth(healthStatus string) string {
if healthStatus == HealthStatusUp { if healthStatus == HealthStatusUp {
return badgeColorHexAwesome return badgeColorHexAwesome
@@ -307,3 +354,12 @@ func getBadgeColorFromHealth(healthStatus string) string {
} }
return badgeColorHexPassable return badgeColorHexPassable
} }
func getBadgeShieldsColorFromHealth(healthStatus string) string {
if healthStatus == HealthStatusUp {
return "brightgreen"
} else if healthStatus == HealthStatusDown {
return "red"
}
return "yellow"
}
+30 -15
View File
@@ -8,8 +8,8 @@ import (
"time" "time"
"github.com/TwiN/gatus/v5/config" "github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/core/ui" "github.com/TwiN/gatus/v5/config/endpoint/ui"
"github.com/TwiN/gatus/v5/storage/store" "github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/watchdog" "github.com/TwiN/gatus/v5/watchdog"
) )
@@ -19,7 +19,7 @@ func TestBadge(t *testing.T) {
defer cache.Clear() defer cache.Clear()
cfg := &config.Config{ cfg := &config.Config{
Metrics: true, Metrics: true,
Endpoints: []*core.Endpoint{ Endpoints: []*endpoint.Endpoint{
{ {
Name: "frontend", Name: "frontend",
Group: "core", Group: "core",
@@ -34,8 +34,8 @@ func TestBadge(t *testing.T) {
cfg.Endpoints[0].UIConfig = ui.GetDefaultConfig() cfg.Endpoints[0].UIConfig = ui.GetDefaultConfig()
cfg.Endpoints[1].UIConfig = ui.GetDefaultConfig() cfg.Endpoints[1].UIConfig = ui.GetDefaultConfig()
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Connected: true, Duration: time.Millisecond, Timestamp: time.Now()}) watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &endpoint.Result{Success: true, Connected: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Connected: false, Duration: time.Second, Timestamp: time.Now()}) watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &endpoint.Result{Success: false, Connected: false, Duration: time.Second, Timestamp: time.Now()})
api := New(cfg) api := New(cfg)
router := api.Router() router := api.Router()
type Scenario struct { type Scenario struct {
@@ -110,6 +110,21 @@ func TestBadge(t *testing.T) {
Path: "/api/v1/endpoints/invalid_key/health/badge.svg", Path: "/api/v1/endpoints/invalid_key/health/badge.svg",
ExpectedCode: http.StatusNotFound, 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", Name: "chart-response-time-24h",
Path: "/api/v1/endpoints/core_backend/response-times/24h/chart.svg", Path: "/api/v1/endpoints/core_backend/response-times/24h/chart.svg",
@@ -203,30 +218,30 @@ func TestGetBadgeColorFromResponseTime(t *testing.T) {
defer cache.Clear() defer cache.Clear()
var ( var (
firstCondition = core.Condition("[STATUS] == 200") firstCondition = endpoint.Condition("[STATUS] == 200")
secondCondition = core.Condition("[RESPONSE_TIME] < 500") secondCondition = endpoint.Condition("[RESPONSE_TIME] < 500")
thirdCondition = core.Condition("[CERTIFICATE_EXPIRATION] < 72h") thirdCondition = endpoint.Condition("[CERTIFICATE_EXPIRATION] < 72h")
) )
firstTestEndpoint := core.Endpoint{ firstTestEndpoint := endpoint.Endpoint{
Name: "a", Name: "a",
URL: "https://example.org/what/ever", URL: "https://example.org/what/ever",
Method: "GET", Method: "GET",
Body: "body", Body: "body",
Interval: 30 * time.Second, Interval: 30 * time.Second,
Conditions: []core.Condition{firstCondition, secondCondition, thirdCondition}, Conditions: []endpoint.Condition{firstCondition, secondCondition, thirdCondition},
Alerts: nil, Alerts: nil,
NumberOfFailuresInARow: 0, NumberOfFailuresInARow: 0,
NumberOfSuccessesInARow: 0, NumberOfSuccessesInARow: 0,
UIConfig: ui.GetDefaultConfig(), UIConfig: ui.GetDefaultConfig(),
} }
secondTestEndpoint := core.Endpoint{ secondTestEndpoint := endpoint.Endpoint{
Name: "b", Name: "b",
URL: "https://example.org/what/ever", URL: "https://example.org/what/ever",
Method: "GET", Method: "GET",
Body: "body", Body: "body",
Interval: 30 * time.Second, Interval: 30 * time.Second,
Conditions: []core.Condition{firstCondition, secondCondition, thirdCondition}, Conditions: []endpoint.Condition{firstCondition, secondCondition, thirdCondition},
Alerts: nil, Alerts: nil,
NumberOfFailuresInARow: 0, NumberOfFailuresInARow: 0,
NumberOfSuccessesInARow: 0, NumberOfSuccessesInARow: 0,
@@ -240,10 +255,10 @@ func TestGetBadgeColorFromResponseTime(t *testing.T) {
} }
cfg := &config.Config{ cfg := &config.Config{
Metrics: true, Metrics: true,
Endpoints: []*core.Endpoint{&firstTestEndpoint, &secondTestEndpoint}, Endpoints: []*endpoint.Endpoint{&firstTestEndpoint, &secondTestEndpoint},
} }
testSuccessfulResult := core.Result{ testSuccessfulResult := endpoint.Result{
Hostname: "example.org", Hostname: "example.org",
IP: "127.0.0.1", IP: "127.0.0.1",
HTTPStatus: 200, HTTPStatus: 200,
@@ -253,7 +268,7 @@ func TestGetBadgeColorFromResponseTime(t *testing.T) {
Timestamp: time.Now(), Timestamp: time.Now(),
Duration: 150 * time.Millisecond, Duration: 150 * time.Millisecond,
CertificateExpiration: 10 * time.Hour, CertificateExpiration: 10 * time.Hour,
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{ {
Condition: "[STATUS] == 200", Condition: "[STATUS] == 200",
Success: true, Success: true,
+4 -3
View File
@@ -1,6 +1,7 @@
package api package api
import ( import (
"errors"
"log" "log"
"math" "math"
"net/http" "net/http"
@@ -42,9 +43,9 @@ func ResponseTimeChart(c *fiber.Ctx) error {
} }
hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(c.Params("key"), from, time.Now()) hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(c.Params("key"), from, time.Now())
if err != nil { if err != nil {
if err == common.ErrEndpointNotFound { if errors.Is(err, common.ErrEndpointNotFound) {
return c.Status(404).SendString(err.Error()) 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(400).SendString(err.Error())
} }
return c.Status(500).SendString(err.Error()) return c.Status(500).SendString(err.Error())
@@ -111,7 +112,7 @@ func ResponseTimeChart(c *fiber.Ctx) error {
c.Set("Expires", "0") c.Set("Expires", "0")
c.Status(http.StatusOK) c.Status(http.StatusOK)
if err := graph.Render(chart.SVG, c); err != nil { if err := graph.Render(chart.SVG, c); err != nil {
log.Println("[api][ResponseTimeChart] Failed to render response time chart:", err.Error()) log.Println("[api.ResponseTimeChart] Failed to render response time chart:", err.Error())
return c.Status(500).SendString(err.Error()) return c.Status(500).SendString(err.Error())
} }
return nil return nil
+4 -4
View File
@@ -7,7 +7,7 @@ import (
"time" "time"
"github.com/TwiN/gatus/v5/config" "github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/storage/store" "github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/watchdog" "github.com/TwiN/gatus/v5/watchdog"
) )
@@ -17,7 +17,7 @@ func TestResponseTimeChart(t *testing.T) {
defer cache.Clear() defer cache.Clear()
cfg := &config.Config{ cfg := &config.Config{
Metrics: true, Metrics: true,
Endpoints: []*core.Endpoint{ Endpoints: []*endpoint.Endpoint{
{ {
Name: "frontend", Name: "frontend",
Group: "core", Group: "core",
@@ -28,8 +28,8 @@ func TestResponseTimeChart(t *testing.T) {
}, },
}, },
} }
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()}) watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()}) watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
api := New(cfg) api := New(cfg)
router := api.Router() router := api.Router()
type Scenario struct { type Scenario struct {
+15 -14
View File
@@ -2,14 +2,15 @@ package api
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"log" "log"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config" "github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/remote" "github.com/TwiN/gatus/v5/config/remote"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/storage/store" "github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/storage/store/common" "github.com/TwiN/gatus/v5/storage/store/common"
"github.com/TwiN/gatus/v5/storage/store/common/paging" "github.com/TwiN/gatus/v5/storage/store/common/paging"
@@ -26,19 +27,19 @@ func EndpointStatuses(cfg *config.Config) fiber.Handler {
if !exists { if !exists {
endpointStatuses, err := store.Get().GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(page, pageSize)) endpointStatuses, err := store.Get().GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(page, pageSize))
if err != nil { if err != nil {
log.Printf("[api][EndpointStatuses] Failed to retrieve endpoint statuses: %s", err.Error()) log.Printf("[api.EndpointStatuses] Failed to retrieve endpoint statuses: %s", err.Error())
return c.Status(500).SendString(err.Error()) return c.Status(500).SendString(err.Error())
} }
// ALPHA: Retrieve endpoint statuses from remote instances // ALPHA: Retrieve endpoint statuses from remote instances
if endpointStatusesFromRemote, err := getEndpointStatusesFromRemoteInstances(cfg.Remote); err != nil { if endpointStatusesFromRemote, err := getEndpointStatusesFromRemoteInstances(cfg.Remote); err != nil {
log.Printf("[handler][EndpointStatuses] Silently failed to retrieve endpoint statuses from remote: %s", err.Error()) log.Printf("[handler.EndpointStatuses] Silently failed to retrieve endpoint statuses from remote: %s", err.Error())
} else if endpointStatusesFromRemote != nil { } else if endpointStatusesFromRemote != nil {
endpointStatuses = append(endpointStatuses, endpointStatusesFromRemote...) endpointStatuses = append(endpointStatuses, endpointStatusesFromRemote...)
} }
// Marshal endpoint statuses to JSON // Marshal endpoint statuses to JSON
data, err = json.Marshal(endpointStatuses) data, err = json.Marshal(endpointStatuses)
if err != nil { if err != nil {
log.Printf("[api][EndpointStatuses] Unable to marshal object to JSON: %s", err.Error()) log.Printf("[api.EndpointStatuses] Unable to marshal object to JSON: %s", err.Error())
return c.Status(500).SendString("unable to marshal object to JSON") return c.Status(500).SendString("unable to marshal object to JSON")
} }
cache.SetWithTTL(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize), data, cacheTTL) cache.SetWithTTL(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize), data, cacheTTL)
@@ -50,11 +51,11 @@ func EndpointStatuses(cfg *config.Config) fiber.Handler {
} }
} }
func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*core.EndpointStatus, error) { func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*endpoint.Status, error) {
if remoteConfig == nil || len(remoteConfig.Instances) == 0 { if remoteConfig == nil || len(remoteConfig.Instances) == 0 {
return nil, nil return nil, nil
} }
var endpointStatusesFromAllRemotes []*core.EndpointStatus var endpointStatusesFromAllRemotes []*endpoint.Status
httpClient := client.GetHTTPClient(remoteConfig.ClientConfig) httpClient := client.GetHTTPClient(remoteConfig.ClientConfig)
for _, instance := range remoteConfig.Instances { for _, instance := range remoteConfig.Instances {
response, err := httpClient.Get(instance.URL) response, err := httpClient.Get(instance.URL)
@@ -64,13 +65,13 @@ func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*cor
body, err := io.ReadAll(response.Body) body, err := io.ReadAll(response.Body)
if err != nil { if err != nil {
_ = response.Body.Close() _ = 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 continue
} }
var endpointStatuses []*core.EndpointStatus var endpointStatuses []*endpoint.Status
if err = json.Unmarshal(body, &endpointStatuses); err != nil { if err = json.Unmarshal(body, &endpointStatuses); err != nil {
_ = response.Body.Close() _ = 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 continue
} }
_ = response.Body.Close() _ = response.Body.Close()
@@ -82,24 +83,24 @@ func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*cor
return endpointStatusesFromAllRemotes, nil return endpointStatusesFromAllRemotes, nil
} }
// EndpointStatus retrieves a single core.EndpointStatus by group and endpoint name // EndpointStatus retrieves a single endpoint.Status by group and endpoint name
func EndpointStatus(c *fiber.Ctx) error { func EndpointStatus(c *fiber.Ctx) error {
page, pageSize := extractPageAndPageSizeFromRequest(c) page, pageSize := extractPageAndPageSizeFromRequest(c)
endpointStatus, err := store.Get().GetEndpointStatusByKey(c.Params("key"), paging.NewEndpointStatusParams().WithResults(page, pageSize).WithEvents(1, common.MaximumNumberOfEvents)) endpointStatus, err := store.Get().GetEndpointStatusByKey(c.Params("key"), paging.NewEndpointStatusParams().WithResults(page, pageSize).WithEvents(1, common.MaximumNumberOfEvents))
if err != nil { if err != nil {
if err == common.ErrEndpointNotFound { if errors.Is(err, common.ErrEndpointNotFound) {
return c.Status(404).SendString(err.Error()) return c.Status(404).SendString(err.Error())
} }
log.Printf("[api][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()) return c.Status(500).SendString(err.Error())
} }
if endpointStatus == nil { // XXX: is this check necessary? 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") return c.Status(404).SendString("not found")
} }
output, err := json.Marshal(endpointStatus) output, err := json.Marshal(endpointStatus)
if err != nil { 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") return c.Status(500).SendString("unable to marshal object to JSON")
} }
c.Set("Content-Type", "application/json") c.Set("Content-Type", "application/json")
+10 -10
View File
@@ -8,7 +8,7 @@ import (
"time" "time"
"github.com/TwiN/gatus/v5/config" "github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/storage/store" "github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/watchdog" "github.com/TwiN/gatus/v5/watchdog"
) )
@@ -16,19 +16,19 @@ import (
var ( var (
timestamp = time.Now() timestamp = time.Now()
testEndpoint = core.Endpoint{ testEndpoint = endpoint.Endpoint{
Name: "name", Name: "name",
Group: "group", Group: "group",
URL: "https://example.org/what/ever", URL: "https://example.org/what/ever",
Method: "GET", Method: "GET",
Body: "body", Body: "body",
Interval: 30 * time.Second, Interval: 30 * time.Second,
Conditions: []core.Condition{core.Condition("[STATUS] == 200"), core.Condition("[RESPONSE_TIME] < 500"), core.Condition("[CERTIFICATE_EXPIRATION] < 72h")}, Conditions: []endpoint.Condition{endpoint.Condition("[STATUS] == 200"), endpoint.Condition("[RESPONSE_TIME] < 500"), endpoint.Condition("[CERTIFICATE_EXPIRATION] < 72h")},
Alerts: nil, Alerts: nil,
NumberOfFailuresInARow: 0, NumberOfFailuresInARow: 0,
NumberOfSuccessesInARow: 0, NumberOfSuccessesInARow: 0,
} }
testSuccessfulResult = core.Result{ testSuccessfulResult = endpoint.Result{
Hostname: "example.org", Hostname: "example.org",
IP: "127.0.0.1", IP: "127.0.0.1",
HTTPStatus: 200, HTTPStatus: 200,
@@ -38,7 +38,7 @@ var (
Timestamp: timestamp, Timestamp: timestamp,
Duration: 150 * time.Millisecond, Duration: 150 * time.Millisecond,
CertificateExpiration: 10 * time.Hour, CertificateExpiration: 10 * time.Hour,
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{ {
Condition: "[STATUS] == 200", Condition: "[STATUS] == 200",
Success: true, Success: true,
@@ -53,7 +53,7 @@ var (
}, },
}, },
} }
testUnsuccessfulResult = core.Result{ testUnsuccessfulResult = endpoint.Result{
Hostname: "example.org", Hostname: "example.org",
IP: "127.0.0.1", IP: "127.0.0.1",
HTTPStatus: 200, HTTPStatus: 200,
@@ -63,7 +63,7 @@ var (
Timestamp: timestamp, Timestamp: timestamp,
Duration: 750 * time.Millisecond, Duration: 750 * time.Millisecond,
CertificateExpiration: 10 * time.Hour, CertificateExpiration: 10 * time.Hour,
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{ {
Condition: "[STATUS] == 200", Condition: "[STATUS] == 200",
Success: true, Success: true,
@@ -85,7 +85,7 @@ func TestEndpointStatus(t *testing.T) {
defer cache.Clear() defer cache.Clear()
cfg := &config.Config{ cfg := &config.Config{
Metrics: true, Metrics: true,
Endpoints: []*core.Endpoint{ Endpoints: []*endpoint.Endpoint{
{ {
Name: "frontend", Name: "frontend",
Group: "core", Group: "core",
@@ -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[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()}) watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
api := New(cfg) api := New(cfg)
router := api.Router() router := api.Router()
type Scenario struct { type Scenario struct {
+67
View File
@@ -0,0 +1,67 @@
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{},
}
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("")
}
}
+137
View File
@@ -0,0 +1,137 @@
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-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,
},
}
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) != 3 {
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 unsuccessful")
}
if endpointStatus.Results[2].Success {
t.Errorf("expected third result to be unsuccessful")
}
externalEndpointFromConfig := cfg.GetExternalEndpointByKey("g_n")
if externalEndpointFromConfig.NumberOfFailuresInARow != 2 {
t.Errorf("expected 2 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)
}
})
}
+2 -2
View File
@@ -15,14 +15,14 @@ func SinglePageApplication(ui *ui.Config) fiber.Handler {
t, err := template.ParseFS(static.FileSystem, static.IndexPath) t, err := template.ParseFS(static.FileSystem, static.IndexPath)
if err != nil { if err != nil {
// This should never happen, because ui.ValidateAndSetDefaults validates that the template works. // This should never happen, because ui.ValidateAndSetDefaults validates that the template works.
log.Println("[api][SinglePageApplication] Failed to parse template. This should never happen, because the template is validated on start. Error:", err.Error()) 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.") return c.Status(500).SendString("Failed to parse template. This should never happen, because the template is validated on start.")
} }
c.Set("Content-Type", "text/html") c.Set("Content-Type", "text/html")
err = t.Execute(c, ui) err = t.Execute(c, ui)
if err != nil { if err != nil {
// This should never happen, because ui.ValidateAndSetDefaults validates that the template works. // This should never happen, because ui.ValidateAndSetDefaults validates that the template works.
log.Println("[api][SinglePageApplication] Failed to execute template. This should never happen, because the template is validated on start. Error:", err.Error()) 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.Status(500).SendString("Failed to parse template. This should never happen, because the template is validated on start.")
} }
return c.SendStatus(200) return c.SendStatus(200)
+4 -4
View File
@@ -9,8 +9,8 @@ import (
"time" "time"
"github.com/TwiN/gatus/v5/config" "github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/ui" "github.com/TwiN/gatus/v5/config/ui"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/storage/store" "github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/watchdog" "github.com/TwiN/gatus/v5/watchdog"
) )
@@ -20,7 +20,7 @@ func TestSinglePageApplication(t *testing.T) {
defer cache.Clear() defer cache.Clear()
cfg := &config.Config{ cfg := &config.Config{
Metrics: true, Metrics: true,
Endpoints: []*core.Endpoint{ Endpoints: []*endpoint.Endpoint{
{ {
Name: "frontend", Name: "frontend",
Group: "core", Group: "core",
@@ -34,8 +34,8 @@ func TestSinglePageApplication(t *testing.T) {
Title: "example-title", Title: "example-title",
}, },
} }
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()}) watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()}) watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
api := New(cfg) api := New(cfg)
router := api.Router() router := api.Router()
type Scenario struct { type Scenario struct {
+51 -5
View File
@@ -16,11 +16,16 @@ import (
"github.com/TwiN/gocache/v2" "github.com/TwiN/gocache/v2"
"github.com/TwiN/whois" "github.com/TwiN/whois"
"github.com/ishidawataru/sctp" "github.com/ishidawataru/sctp"
"github.com/miekg/dns"
ping "github.com/prometheus-community/pro-bing" ping "github.com/prometheus-community/pro-bing"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
"golang.org/x/net/websocket" "golang.org/x/net/websocket"
) )
const (
dnsPort = 53
)
var ( var (
// injectedHTTPClient is used for testing purposes // injectedHTTPClient is used for testing purposes
injectedHTTPClient *http.Client injectedHTTPClient *http.Client
@@ -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 // 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) { func Ping(address string, config *Config) (bool, time.Duration) {
pinger, err := ping.NewPinger(address) pinger := ping.New(address)
if err != nil {
return false, 0
}
pinger.Count = 1 pinger.Count = 1
pinger.Timeout = config.Timeout pinger.Timeout = config.Timeout
// Set the pinger's privileged mode to true for every GOOS except darwin // 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. // Note that for this to work on Linux, Gatus must run with sudo privileges.
// See https://github.com/prometheus-community/pro-bing#linux // See https://github.com/prometheus-community/pro-bing#linux
pinger.SetPrivileged(runtime.GOOS != "darwin") pinger.SetPrivileged(runtime.GOOS != "darwin")
err = pinger.Run() pinger.SetNetwork(config.Network)
err := pinger.Run()
if err != nil { if err != nil {
return false, 0 return false, 0
} }
@@ -293,6 +296,49 @@ func QueryWebSocket(address, body string, config *Config) (bool, []byte, error)
return true, msg[:n], nil return true, msg[:n], nil
} }
func QueryDNS(queryType, queryName, url string) (connected bool, dnsRcode string, body []byte, err error) {
if !strings.Contains(url, ":") {
url = fmt.Sprintf("%s:%d", url, dnsPort)
}
queryTypeAsUint16 := dns.StringToType[queryType]
c := new(dns.Client)
m := new(dns.Msg)
m.SetQuestion(queryName, queryTypeAsUint16)
r, _, err := c.Exchange(m, url)
if err != nil {
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 // InjectHTTPClient is used to inject a custom HTTP client for testing purposes
func InjectHTTPClient(httpClient *http.Client) { func InjectHTTPClient(httpClient *http.Client) {
injectedHTTPClient = httpClient injectedHTTPClient = httpClient
+167 -1
View File
@@ -2,11 +2,14 @@ package client
import ( import (
"bytes" "bytes"
"crypto/tls"
"io" "io"
"net/http" "net/http"
"testing" "testing"
"time" "time"
"github.com/TwiN/gatus/v5/config/endpoint/dns"
"github.com/TwiN/gatus/v5/pattern"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
@@ -83,6 +86,32 @@ func TestPing(t *testing.T) {
t.Error("Round-trip time returned on failure should've been 0") 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) { func TestCanPerformStartTLS(t *testing.T) {
@@ -250,7 +279,7 @@ func TestHttpClientProvidesOAuth2BearerToken(t *testing.T) {
// to us as `X-Org-Authorization` header, we check here if the value matches // to us as `X-Org-Authorization` header, we check here if the value matches
// our expected token `secret-token` // our expected token `secret-token`
if response.Header.Get("X-Org-Authorization") != "Bearer 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"))
} }
} }
@@ -264,3 +293,140 @@ func TestQueryWebSocket(t *testing.T) {
t.Error("expected an error due to the target not being websocket-friendly") 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)
}
}
+151 -6
View File
@@ -7,12 +7,14 @@ import (
"log" "log"
"net" "net"
"net/http" "net/http"
"net/url"
"regexp" "regexp"
"strconv" "strconv"
"time" "time"
"golang.org/x/oauth2" "golang.org/x/oauth2"
"golang.org/x/oauth2/clientcredentials" "golang.org/x/oauth2/clientcredentials"
"google.golang.org/api/idtoken"
) )
const ( const (
@@ -23,11 +25,14 @@ var (
ErrInvalidDNSResolver = errors.New("invalid DNS resolver specified. Required format is {proto}://{ip}:{port}") ErrInvalidDNSResolver = errors.New("invalid DNS resolver specified. Required format is {proto}://{ip}:{port}")
ErrInvalidDNSResolverPort = errors.New("invalid DNS resolver 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)") 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{ defaultConfig = Config{
Insecure: false, Insecure: false,
IgnoreRedirect: false, IgnoreRedirect: false,
Timeout: defaultTimeout, Timeout: defaultTimeout,
Network: "ip",
} }
) )
@@ -39,6 +44,9 @@ func GetDefaultConfig() *Config {
// Config is the configuration for clients // Config is the configuration for clients
type Config struct { 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 determines whether to skip verifying the server's certificate chain and host name
Insecure bool `yaml:"insecure,omitempty"` Insecure bool `yaml:"insecure,omitempty"`
@@ -58,7 +66,16 @@ type Config struct {
// See configureOAuth2 for more details. // See configureOAuth2 for more details.
OAuth2Config *OAuth2Config `yaml:"oauth2,omitempty"` 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 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. // DNSResolverConfig is the parsed configuration from the DNSResolver config string.
@@ -76,6 +93,22 @@ type OAuth2Config struct {
Scopes []string `yaml:"scopes"` // e.g. ["openid"] 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 // ValidateAndSetDefaults validates the client configuration and sets the default values if necessary
func (c *Config) ValidateAndSetDefaults() error { func (c *Config) ValidateAndSetDefaults() error {
if c.Timeout < time.Millisecond { if c.Timeout < time.Millisecond {
@@ -90,6 +123,14 @@ func (c *Config) ValidateAndSetDefaults() error {
if c.HasOAuth2Config() && !c.OAuth2Config.isValid() { if c.HasOAuth2Config() && !c.OAuth2Config.isValid() {
return ErrInvalidClientOAuth2Config return ErrInvalidClientOAuth2Config
} }
if c.HasIAPConfig() && !c.IAPConfig.isValid() {
return ErrInvalidClientIAPConfig
}
if c.HasTlsConfig() {
if err := c.TLS.isValid(); err != nil {
return err
}
}
return nil return nil
} }
@@ -130,13 +171,46 @@ func (c *Config) HasOAuth2Config() bool {
return c.OAuth2Config != nil 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 // isValid() returns true if the OAuth2 configuration is valid
func (c *OAuth2Config) isValid() bool { func (c *OAuth2Config) isValid() bool {
return len(c.TokenURL) > 0 && len(c.ClientID) > 0 && len(c.ClientSecret) > 0 && len(c.Scopes) > 0 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. // GetHTTPClient return an HTTP client matching the Config's parameters.
func (c *Config) getHTTPClient() *http.Client { 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 { if c.httpClient == nil {
c.httpClient = &http.Client{ c.httpClient = &http.Client{
Timeout: c.Timeout, Timeout: c.Timeout,
@@ -144,9 +218,7 @@ func (c *Config) getHTTPClient() *http.Client {
MaxIdleConns: 100, MaxIdleConns: 100,
MaxIdleConnsPerHost: 20, MaxIdleConnsPerHost: 20,
Proxy: http.ProxyFromEnvironment, Proxy: http.ProxyFromEnvironment,
TLSClientConfig: &tls.Config{ TLSClientConfig: tlsConfig,
InsecureSkipVerify: c.Insecure,
},
}, },
CheckRedirect: func(req *http.Request, via []*http.Request) error { CheckRedirect: func(req *http.Request, via []*http.Request) error {
if c.IgnoreRedirect { if c.IgnoreRedirect {
@@ -157,12 +229,20 @@ func (c *Config) getHTTPClient() *http.Client {
return nil 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() { if c.HasCustomDNSResolver() {
dnsResolver, err := c.parseDNSResolver() dnsResolver, err := c.parseDNSResolver()
if err != nil { if err != nil {
// We're ignoring the error, because it should have been validated on startup ValidateAndSetDefaults. // 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 ;) // 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 { } else {
dialer := &net.Dialer{ dialer := &net.Dialer{
Resolver: &net.Resolver{ 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) c.httpClient = configureOAuth2(c.httpClient, *c.OAuth2Config)
} else if c.HasIAPConfig() {
c.httpClient = configureIAP(c.httpClient, *c.IAPConfig)
} }
} }
return c.httpClient 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. // configureOAuth2 returns an HTTP client that will obtain and refresh tokens as necessary.
// The returned Client and its Transport should not be modified. // The returned Client and its Transport should not be modified.
func configureOAuth2(httpClient *http.Client, c OAuth2Config) *http.Client { 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, TokenURL: c.TokenURL,
} }
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, httpClient) 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
} }
+90
View File
@@ -2,6 +2,7 @@ package client
import ( import (
"net/http" "net/http"
"net/url"
"testing" "testing"
"time" "time"
) )
@@ -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")
}
}
})
}
}
+1 -1
View File
@@ -37,7 +37,7 @@ endpoints:
query-name: "example.com" query-name: "example.com"
query-type: "A" query-type: "A"
conditions: conditions:
- "[BODY] == 93.184.216.34" - "[BODY] == 93.184.215.14"
- "[DNS_RCODE] == NOERROR" - "[DNS_RCODE] == NOERROR"
- name: icmp-ping - name: icmp-ping
+76 -32
View File
@@ -15,14 +15,13 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/alerting/provider" "github.com/TwiN/gatus/v5/alerting/provider"
"github.com/TwiN/gatus/v5/config/connectivity" "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/maintenance"
"github.com/TwiN/gatus/v5/config/remote" "github.com/TwiN/gatus/v5/config/remote"
"github.com/TwiN/gatus/v5/config/ui" "github.com/TwiN/gatus/v5/config/ui"
"github.com/TwiN/gatus/v5/config/web" "github.com/TwiN/gatus/v5/config/web"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/security" "github.com/TwiN/gatus/v5/security"
"github.com/TwiN/gatus/v5/storage" "github.com/TwiN/gatus/v5/storage"
"github.com/TwiN/gatus/v5/util"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
@@ -67,14 +66,17 @@ type Config struct {
// Disabling this may lead to inaccurate response times // Disabling this may lead to inaccurate response times
DisableMonitoringLock bool `yaml:"disable-monitoring-lock,omitempty"` 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"` Security *security.Config `yaml:"security,omitempty"`
// Alerting Configuration for alerting // Alerting is the configuration for alerting providers
Alerting *alerting.Config `yaml:"alerting,omitempty"` Alerting *alerting.Config `yaml:"alerting,omitempty"`
// Endpoints List of endpoints to monitor // Endpoints is the list of endpoints to monitor
Endpoints []*core.Endpoint `yaml:"endpoints,omitempty"` 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 is the configuration for how the data is stored
Storage *storage.Config `yaml:"storage,omitempty"` Storage *storage.Config `yaml:"storage,omitempty"`
@@ -99,20 +101,29 @@ type Config struct {
lastFileModTime time.Time // last modification time lastFileModTime time.Time // last modification time
} }
func (config *Config) GetEndpointByKey(key string) *core.Endpoint { func (config *Config) GetEndpointByKey(key string) *endpoint.Endpoint {
// TODO: Should probably add a mutex here to prevent concurrent access
for i := 0; i < len(config.Endpoints); i++ { for i := 0; i < len(config.Endpoints); i++ {
ep := config.Endpoints[i] ep := config.Endpoints[i]
if util.ConvertGroupAndEndpointNameToKey(ep.Group, ep.Name) == key { if ep.Key() == key {
return ep return ep
} }
} }
return nil 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 // HasLoadedConfigurationBeenModified returns whether one of the file that the
// configuration has been loaded from has been modified since it was last read // configuration has been loaded from has been modified since it was last read
func (config Config) HasLoadedConfigurationBeenModified() bool { func (config *Config) HasLoadedConfigurationBeenModified() bool {
lastMod := config.lastFileModTime.Unix() lastMod := config.lastFileModTime.Unix()
fileInfo, err := os.Stat(config.configPath) fileInfo, err := os.Stat(config.configPath)
if err != nil { if err != nil {
@@ -125,7 +136,7 @@ func (config Config) HasLoadedConfigurationBeenModified() bool {
} }
return nil return nil
}) })
return err == errEarlyReturn return errors.Is(err, errEarlyReturn)
} }
return !fileInfo.ModTime().IsZero() && config.lastFileModTime.Unix() < fileInfo.ModTime().Unix() return !fileInfo.ModTime().IsZero() && config.lastFileModTime.Unix() < fileInfo.ModTime().Unix()
} }
@@ -135,7 +146,7 @@ func (config *Config) UpdateLastFileModTime() {
config.lastFileModTime = time.Now() 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 // and all composed configuration files
func LoadConfiguration(configPath string) (*Config, error) { func LoadConfiguration(configPath string) (*Config, error) {
var configBytes []byte var configBytes []byte
@@ -161,13 +172,13 @@ func LoadConfiguration(configPath string) (*Config, error) {
if fileInfo.IsDir() { if fileInfo.IsDir() {
err := walkConfigDir(configPath, func(path string, d fs.DirEntry, err error) error { err := walkConfigDir(configPath, func(path string, d fs.DirEntry, err error) error {
if err != nil { 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 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) data, err := os.ReadFile(path)
if err != nil { 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) return fmt.Errorf("error reading configuration from file %s: %w", path, err)
} }
configBytes, err = deepmerge.YAML(configBytes, data) 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) return nil, fmt.Errorf("error reading configuration from directory %s: %w", usedConfigPath, err)
} }
} else { } else {
log.Printf("[config][LoadConfiguration] Reading configuration from configFile=%s", configPath) log.Printf("[config.LoadConfiguration] Reading configuration from configFile=%s", configPath)
if data, err := os.ReadFile(usedConfigPath); err != nil { if data, err := os.ReadFile(usedConfigPath); err != nil {
return nil, err return nil, err
} else { } else {
@@ -234,7 +245,7 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
if config == nil || config.Endpoints == nil || len(config.Endpoints) == 0 { if config == nil || config.Endpoints == nil || len(config.Endpoints) == 0 {
err = ErrNoEndpointInConfig err = ErrNoEndpointInConfig
} else { } else {
validateAlertingConfig(config.Alerting, config.Endpoints, config.Debug) validateAlertingConfig(config.Alerting, config.Endpoints, config.ExternalEndpoints, config.Debug)
if err := validateSecurityConfig(config); err != nil { if err := validateSecurityConfig(config); err != nil {
return nil, err return nil, err
} }
@@ -324,15 +335,37 @@ func validateWebConfig(config *Config) error {
} }
func validateEndpointsConfig(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 { 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 { if endpointKey := ep.Key(); duplicateValidationMap[endpointKey] {
return fmt.Errorf("invalid endpoint %s: %w", endpoint.DisplayName(), err) 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 return nil
} }
@@ -340,7 +373,7 @@ func validateSecurityConfig(config *Config) error {
if config.Security != nil { if config.Security != nil {
if config.Security.IsValid() { if config.Security.IsValid() {
if config.Debug { if config.Debug {
log.Printf("[config][validateSecurityConfig] Basic security configuration has been validated") log.Printf("[config.validateSecurityConfig] Basic security configuration has been validated")
} }
} else { } else {
// If there was an attempt to configure security, then it must mean that some confidential or private // If there was an attempt to configure security, then it must mean that some confidential or private
@@ -353,22 +386,23 @@ func validateSecurityConfig(config *Config) error {
// validateAlertingConfig validates the alerting configuration // validateAlertingConfig validates the alerting configuration
// Note that the alerting configuration has to be validated before the endpoint configuration, because the default alert // 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. // 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 { if alertingConfig == nil {
log.Printf("[config][validateAlertingConfig] Alerting is not configured") log.Printf("[config.validateAlertingConfig] Alerting is not configured")
return return
} }
alertTypes := []alert.Type{ alertTypes := []alert.Type{
alert.TypeAWSSES, alert.TypeAWSSES,
alert.TypeCustom, alert.TypeCustom,
alert.TypeDiscord, alert.TypeDiscord,
alert.TypeEmail,
alert.TypeGitHub, alert.TypeGitHub,
alert.TypeGitLab, alert.TypeGitLab,
alert.TypeGoogleChat, alert.TypeGoogleChat,
alert.TypeGotify, alert.TypeGotify,
alert.TypeEmail, alert.TypeJetBrainsSpace,
alert.TypeMatrix, alert.TypeMatrix,
alert.TypeMattermost, alert.TypeMattermost,
alert.TypeMessagebird, alert.TypeMessagebird,
@@ -388,11 +422,21 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*core.E
if alertProvider.IsValid() { if alertProvider.IsValid() {
// Parse alerts with the provider's default alert // Parse alerts with the provider's default alert
if alertProvider.GetDefaultAlert() != nil { if alertProvider.GetDefaultAlert() != nil {
for _, endpoint := range endpoints { for _, ep := range endpoints {
for alertIndex, endpointAlert := range endpoint.Alerts { for alertIndex, endpointAlert := range ep.Alerts {
if alertType == endpointAlert.Type { if alertType == endpointAlert.Type {
if debug { 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) provider.ParseWithDefaultAlert(alertProvider.GetDefaultAlert(), endpointAlert)
} }
@@ -401,7 +445,7 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*core.E
} }
validProviders = append(validProviders, alertType) validProviders = append(validProviders, alertType)
} else { } 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) invalidProviders = append(invalidProviders, alertType)
alertingConfig.SetAlertingProviderToNil(alertProvider) alertingConfig.SetAlertingProviderToNil(alertProvider)
} }
@@ -409,5 +453,5 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*core.E
invalidProviders = append(invalidProviders, alertType) invalidProviders = append(invalidProviders, alertType)
} }
} }
log.Printf("[config][validateAlertingConfig] configuredProviders=%s; ignoredProviders=%s", validProviders, invalidProviders) log.Printf("[config.validateAlertingConfig] configuredProviders=%s; ignoredProviders=%s", validProviders, invalidProviders)
} }
+362 -57
View File
@@ -16,6 +16,8 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/email" "github.com/TwiN/gatus/v5/alerting/provider/email"
"github.com/TwiN/gatus/v5/alerting/provider/github" "github.com/TwiN/gatus/v5/alerting/provider/github"
"github.com/TwiN/gatus/v5/alerting/provider/googlechat" "github.com/TwiN/gatus/v5/alerting/provider/googlechat"
"github.com/TwiN/gatus/v5/alerting/provider/gotify"
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
"github.com/TwiN/gatus/v5/alerting/provider/matrix" "github.com/TwiN/gatus/v5/alerting/provider/matrix"
"github.com/TwiN/gatus/v5/alerting/provider/mattermost" "github.com/TwiN/gatus/v5/alerting/provider/mattermost"
"github.com/TwiN/gatus/v5/alerting/provider/messagebird" "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/telegram"
"github.com/TwiN/gatus/v5/alerting/provider/twilio" "github.com/TwiN/gatus/v5/alerting/provider/twilio"
"github.com/TwiN/gatus/v5/client" "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/config/web"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/storage" "github.com/TwiN/gatus/v5/storage"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
func TestLoadConfiguration(t *testing.T) { func TestLoadConfiguration(t *testing.T) {
yes := true
dir := t.TempDir() dir := t.TempDir()
scenarios := []struct { scenarios := []struct {
name string name string
@@ -64,7 +67,7 @@ func TestLoadConfiguration(t *testing.T) {
endpoints: endpoints:
- name: website`, - name: website`,
}, },
expectedError: core.ErrEndpointWithNoURL, expectedError: endpoint.ErrEndpointWithNoURL,
}, },
{ {
name: "config-file-with-endpoint-that-has-no-conditions", name: "config-file-with-endpoint-that-has-no-conditions",
@@ -75,7 +78,7 @@ endpoints:
- name: website - name: website
url: https://twin.sh/health`, url: https://twin.sh/health`,
}, },
expectedError: core.ErrEndpointWithNoCondition, expectedError: endpoint.ErrEndpointWithNoCondition,
}, },
{ {
name: "config-file", name: "config-file",
@@ -89,11 +92,11 @@ endpoints:
- "[STATUS] == 200"`, - "[STATUS] == 200"`,
}, },
expectedConfig: &Config{ expectedConfig: &Config{
Endpoints: []*core.Endpoint{ Endpoints: []*endpoint.Endpoint{
{ {
Name: "website", Name: "website",
URL: "https://twin.sh/health", URL: "https://twin.sh/health",
Conditions: []core.Condition{"[STATUS] == 200"}, Conditions: []endpoint.Condition{"[STATUS] == 200"},
}, },
}, },
}, },
@@ -135,21 +138,21 @@ endpoints:
- "[BODY].status == UP"`, - "[BODY].status == UP"`,
}, },
expectedConfig: &Config{ expectedConfig: &Config{
Endpoints: []*core.Endpoint{ Endpoints: []*endpoint.Endpoint{
{ {
Name: "one", Name: "one",
URL: "https://example.com", URL: "https://example.com",
Conditions: []core.Condition{"[CONNECTED] == true", "[STATUS] == 200"}, Conditions: []endpoint.Condition{"[CONNECTED] == true", "[STATUS] == 200"},
}, },
{ {
Name: "two", Name: "two",
URL: "https://example.org", URL: "https://example.org",
Conditions: []core.Condition{"len([BODY]) > 0"}, Conditions: []endpoint.Condition{"len([BODY]) > 0"},
}, },
{ {
Name: "three", Name: "three",
URL: "https://twin.sh/health", 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: alerting:
slack: slack:
webhook-url: https://hooks.slack.com/services/xxx/yyy/zzz webhook-url: https://hooks.slack.com/services/xxx/yyy/zzz
default-alert:
enabled: true
endpoints: endpoints:
- name: example - name: example
@@ -178,6 +183,12 @@ alerting:
discord: discord:
webhook-url: https://discord.com/api/webhooks/xxx/yyy webhook-url: https://discord.com/api/webhooks/xxx/yyy
external-endpoints:
- name: ext-ep-test
token: "potato"
alerts:
- type: slack
endpoints: endpoints:
- name: frontend - name: frontend
url: https://example.com url: https://example.com
@@ -189,19 +200,32 @@ endpoints:
Metrics: true, Metrics: true,
Alerting: &alerting.Config{ Alerting: &alerting.Config{
Discord: &discord.AlertProvider{WebhookURL: "https://discord.com/api/webhooks/xxx/yyy"}, 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", Name: "example",
URL: "https://example.org", URL: "https://example.org",
Interval: 5 * time.Second, Interval: 5 * time.Second,
Conditions: []core.Condition{"[STATUS] == 200"}, Conditions: []endpoint.Condition{"[STATUS] == 200"},
}, },
{ {
Name: "frontend", Name: "frontend",
URL: "https://example.com", URL: "https://example.com",
Conditions: []core.Condition{"[STATUS] == 200"}, Conditions: []endpoint.Condition{"[STATUS] == 200"},
}, },
}, },
}, },
@@ -303,11 +327,13 @@ func TestParseAndValidateConfigBytes(t *testing.T) {
storage: storage:
type: sqlite type: sqlite
path: %s path: %s
maintenance: maintenance:
enabled: true enabled: true
start: 00:00 start: 00:00
duration: 4h duration: 4h
every: [Monday, Thursday] every: [Monday, Thursday]
ui: ui:
title: T title: T
header: H header: H
@@ -317,6 +343,12 @@ ui:
link: "https://example.org" link: "https://example.org"
- name: "Status page" - name: "Status page"
link: "https://status.example.org" link: "https://status.example.org"
external-endpoints:
- name: ext-ep-test
group: core
token: "potato"
endpoints: endpoints:
- name: website - name: website
url: https://twin.sh/health 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 { if mc := config.Maintenance; mc == nil || mc.Start != "00:00" || !mc.IsEnabled() || mc.Duration != 4*time.Hour || len(mc.Every) != 2 {
t.Error("Expected Config.Maintenance to be configured properly") t.Error("Expected Config.Maintenance to be configured properly")
} }
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 { if len(config.Endpoints) != 3 {
t.Error("Should have returned two endpoints") t.Error("Should have returned two endpoints")
} }
if config.Endpoints[0].URL != "https://twin.sh/health" { if config.Endpoints[0].URL != "https://twin.sh/health" {
t.Errorf("URL should have been %s", "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 { if len(config.Endpoints[0].Conditions) != 1 {
t.Errorf("There should have been %d conditions", 1) t.Errorf("There should have been %d conditions", 1)
} }
if config.Endpoints[1].URL != "https://api.github.com/healthz" { if config.Endpoints[1].URL != "https://api.github.com/healthz" {
t.Errorf("URL should have been %s", "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 { if len(config.Endpoints[1].Conditions) != 2 {
t.Errorf("There should have been %d conditions", 2) t.Errorf("There should have been %d conditions", 2)
} }
if config.Endpoints[2].URL != "https://example.com/" { if config.Endpoints[2].URL != "https://example.com/" {
t.Errorf("URL should have been %s", "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 { if config.Endpoints[0].Interval != 60*time.Second {
t.Errorf("Interval should have been %s, because it is the default value", 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 { 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", core.GatusUserAgent, userAgent) 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" to: "+1-234-567-8901"
teams: teams:
webhook-url: "http://example.com" webhook-url: "http://example.com"
jetbrainsspace:
project: "foo"
channel-id: "bar"
token: "baz"
endpoints: endpoints:
- name: website - name: website
@@ -728,6 +774,7 @@ endpoints:
success-threshold: 15 success-threshold: 15
- type: teams - type: teams
- type: pushover - type: pushover
- type: jetbrainsspace
conditions: conditions:
- "[STATUS] == 200" - "[STATUS] == 200"
`)) `))
@@ -754,8 +801,8 @@ endpoints:
if config.Endpoints[0].Interval != 60*time.Second { if config.Endpoints[0].Interval != 60*time.Second {
t.Errorf("Interval should have been %s, because it is the default value", 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 { if len(config.Endpoints[0].Alerts) != 10 {
t.Fatal("There should've been 9 alerts configured") t.Fatal("There should've been 10 alerts configured")
} }
if config.Endpoints[0].Alerts[0].Type != alert.TypeSlack { if config.Endpoints[0].Alerts[0].Type != alert.TypeSlack {
@@ -862,6 +909,12 @@ endpoints:
if !config.Endpoints[0].Alerts[8].IsEnabled() { if !config.Endpoints[0].Alerts[8].IsEnabled() {
t.Error("The alert should've been enabled") 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) { func TestParseAndValidateConfigBytesWithAlertingAndDefaultAlert(t *testing.T) {
@@ -878,7 +931,7 @@ alerting:
default-alert: default-alert:
enabled: true enabled: true
failure-threshold: 10 failure-threshold: 10
success-threshold: 1 success-threshold: 15
pagerduty: pagerduty:
integration-key: "00000000000000000000000000000000" integration-key: "00000000000000000000000000000000"
default-alert: default-alert:
@@ -923,23 +976,57 @@ alerting:
webhook-url: "http://example.com" webhook-url: "http://example.com"
default-alert: default-alert:
enabled: true 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: endpoints:
- name: website - name: website
url: https://twin.sh/health url: https://twin.sh/health
alerts: alerts:
- type: slack - type: slack
- type: pagerduty - type: pagerduty
- type: mattermost - type: mattermost
- type: messagebird - type: messagebird
- type: discord - type: discord
success-threshold: 2 # test endpoint alert override success-threshold: 8 # test endpoint alert override
- type: telegram - type: telegram
- type: twilio - type: twilio
- type: teams - type: teams
- type: pushover - type: pushover
conditions: - type: jetbrainsspace
- "[STATUS] == 200" - type: email
- type: gotify
conditions:
- "[STATUS] == 200"
`)) `))
if err != nil { if err != nil {
t.Error("expected no error, got", err.Error()) t.Error("expected no error, got", err.Error())
@@ -1016,6 +1103,12 @@ endpoints:
if config.Alerting.Discord.GetDefaultAlert() == nil { if config.Alerting.Discord.GetDefaultAlert() == nil {
t.Fatal("Discord.GetDefaultAlert() shouldn't have returned 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" { 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) 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 { if config.Alerting.Teams.GetDefaultAlert() == nil {
t.Fatal("Teams.GetDefaultAlert() shouldn't have returned 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 // Endpoints
if len(config.Endpoints) != 1 { if len(config.Endpoints) != 1 {
@@ -1060,8 +1230,8 @@ endpoints:
if config.Endpoints[0].Interval != 60*time.Second { if config.Endpoints[0].Interval != 60*time.Second {
t.Errorf("Interval should have been %s, because it is the default value", 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 { if len(config.Endpoints[0].Alerts) != 12 {
t.Fatal("There should've been 9 alerts configured") 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 { if config.Endpoints[0].Alerts[0].Type != alert.TypeSlack {
@@ -1122,8 +1292,8 @@ endpoints:
if config.Endpoints[0].Alerts[4].FailureThreshold != 10 { 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) 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 { if config.Endpoints[0].Alerts[4].SuccessThreshold != 8 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[4].SuccessThreshold) 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 { 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) 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) { 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) { func TestParseAndValidateConfigBytesWithInvalidStorageConfig(t *testing.T) {
_, err := parseAndValidateConfigBytes([]byte(` _, err := parseAndValidateConfigBytes([]byte(`
storage: storage:
@@ -1563,29 +1864,31 @@ endpoints:
func TestParseAndValidateConfigBytesWithNoEndpoints(t *testing.T) { func TestParseAndValidateConfigBytesWithNoEndpoints(t *testing.T) {
_, err := parseAndValidateConfigBytes([]byte(``)) _, err := parseAndValidateConfigBytes([]byte(``))
if err != ErrNoEndpointInConfig { if !errors.Is(err, ErrNoEndpointInConfig) {
t.Error("The error returned should have been of type ErrNoEndpointInConfig") t.Error("The error returned should have been of type ErrNoEndpointInConfig")
} }
} }
func TestGetAlertingProviderByAlertType(t *testing.T) { func TestGetAlertingProviderByAlertType(t *testing.T) {
alertingConfig := &alerting.Config{ alertingConfig := &alerting.Config{
Custom: &custom.AlertProvider{}, Custom: &custom.AlertProvider{},
Discord: &discord.AlertProvider{}, Discord: &discord.AlertProvider{},
Email: &email.AlertProvider{}, Email: &email.AlertProvider{},
GitHub: &github.AlertProvider{}, GitHub: &github.AlertProvider{},
GoogleChat: &googlechat.AlertProvider{}, GoogleChat: &googlechat.AlertProvider{},
Matrix: &matrix.AlertProvider{}, Gotify: &gotify.AlertProvider{},
Mattermost: &mattermost.AlertProvider{}, JetBrainsSpace: &jetbrainsspace.AlertProvider{},
Messagebird: &messagebird.AlertProvider{}, Matrix: &matrix.AlertProvider{},
Ntfy: &ntfy.AlertProvider{}, Mattermost: &mattermost.AlertProvider{},
Opsgenie: &opsgenie.AlertProvider{}, Messagebird: &messagebird.AlertProvider{},
PagerDuty: &pagerduty.AlertProvider{}, Ntfy: &ntfy.AlertProvider{},
Pushover: &pushover.AlertProvider{}, Opsgenie: &opsgenie.AlertProvider{},
Slack: &slack.AlertProvider{}, PagerDuty: &pagerduty.AlertProvider{},
Telegram: &telegram.AlertProvider{}, Pushover: &pushover.AlertProvider{},
Twilio: &twilio.AlertProvider{}, Slack: &slack.AlertProvider{},
Teams: &teams.AlertProvider{}, Telegram: &telegram.AlertProvider{},
Twilio: &twilio.AlertProvider{},
Teams: &teams.AlertProvider{},
} }
scenarios := []struct { scenarios := []struct {
alertType alert.Type alertType alert.Type
@@ -1596,6 +1899,8 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
{alertType: alert.TypeEmail, expected: alertingConfig.Email}, {alertType: alert.TypeEmail, expected: alertingConfig.Email},
{alertType: alert.TypeGitHub, expected: alertingConfig.GitHub}, {alertType: alert.TypeGitHub, expected: alertingConfig.GitHub},
{alertType: alert.TypeGoogleChat, expected: alertingConfig.GoogleChat}, {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.TypeMatrix, expected: alertingConfig.Matrix},
{alertType: alert.TypeMattermost, expected: alertingConfig.Mattermost}, {alertType: alert.TypeMattermost, expected: alertingConfig.Mattermost},
{alertType: alert.TypeMessagebird, expected: alertingConfig.Messagebird}, {alertType: alert.TypeMessagebird, expected: alertingConfig.Messagebird},
+1 -1
View File
@@ -41,7 +41,7 @@ type Checker struct {
lastCheck time.Time lastCheck time.Time
} }
func (c Checker) Check() bool { func (c *Checker) Check() bool {
return client.CanCreateTCPConnection(c.Target, &client.Config{Timeout: 5 * time.Second}) return client.CanCreateTCPConnection(c.Target, &client.Config{Timeout: 5 * time.Second})
} }
+32
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
}
+51
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)
}
})
}
}
@@ -1,4 +1,4 @@
package core package endpoint
import ( import (
"errors" "errors"
@@ -150,7 +150,7 @@ func (c Condition) evaluate(result *Result, dontResolveFailedConditions bool) bo
return false return false
} }
if !success { 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}) result.ConditionResults = append(result.ConditionResults, &ConditionResult{Condition: conditionToDisplay, Success: success})
return success return success
@@ -1,6 +1,8 @@
package core package endpoint
import "testing" import (
"testing"
)
func BenchmarkCondition_evaluateWithBodyStringAny(b *testing.B) { func BenchmarkCondition_evaluateWithBodyStringAny(b *testing.B) {
condition := Condition("[BODY].name == any(john.doe, jane.doe)") condition := Condition("[BODY].name == any(john.doe, jane.doe)")
@@ -1,4 +1,4 @@
package core package endpoint
// ConditionResult result of a Condition // ConditionResult result of a Condition
type ConditionResult struct { type ConditionResult struct {
@@ -1,4 +1,4 @@
package core package endpoint
import ( import (
"errors" "errors"
@@ -259,6 +259,27 @@ func TestCondition_evaluate(t *testing.T) {
ExpectedSuccess: true, ExpectedSuccess: true,
ExpectedOutput: "[BODY][0].id == 1", ExpectedOutput: "[BODY][0].id == 1",
}, },
{
Name: "body-jsonpath-when-body-has-null-parameter",
Condition: Condition("[BODY].data == OK"),
Result: &Result{Body: []byte(`{"data": null}"`)},
ExpectedSuccess: false,
ExpectedOutput: "[BODY].data (INVALID) == OK",
},
{
Name: "body-jsonpath-when-body-has-array-with-null",
Condition: Condition("[BODY].items[0] == OK"),
Result: &Result{Body: []byte(`{"items": [null, null]}"`)},
ExpectedSuccess: false,
ExpectedOutput: "[BODY].items[0] (INVALID) == OK",
},
{
Name: "body-jsonpath-when-body-is-null",
Condition: Condition("[BODY].data == OK"),
Result: &Result{Body: []byte(`null`)},
ExpectedSuccess: false,
ExpectedOutput: "[BODY].data (INVALID) == OK",
},
{ {
Name: "body-jsonpath-when-body-is-array-but-actual-body-is-not", Name: "body-jsonpath-when-body-is-array-but-actual-body-is-not",
Condition: Condition("[BODY][0].name == test"), Condition: Condition("[BODY][0].name == test"),
+38
View File
@@ -0,0 +1,38 @@
package dns
import (
"errors"
"strings"
"github.com/miekg/dns"
)
var (
// ErrDNSWithNoQueryName is the error with which gatus will panic if a dns is configured without query name
ErrDNSWithNoQueryName = errors.New("you must specify a query name in the DNS configuration")
// ErrDNSWithInvalidQueryType is the error with which gatus will panic if a dns is configured with invalid query type
ErrDNSWithInvalidQueryType = errors.New("invalid query type in the DNS configuration")
)
// Config for an Endpoint of type DNS
type Config struct {
// QueryType is the type for the DNS records like A, AAAA, CNAME...
QueryType string `yaml:"query-type"`
// QueryName is the query for DNS
QueryName string `yaml:"query-name"`
}
func (d *Config) ValidateAndSetDefault() error {
if len(d.QueryName) == 0 {
return ErrDNSWithNoQueryName
}
if !strings.HasSuffix(d.QueryName, ".") {
d.QueryName += "."
}
if _, ok := dns.StringToType[d.QueryType]; !ok {
return ErrDNSWithInvalidQueryType
}
return nil
}
+27
View File
@@ -0,0 +1,27 @@
package dns
import (
"testing"
)
func TestConfig_ValidateAndSetDefault(t *testing.T) {
dns := &Config{
QueryType: "A",
QueryName: "",
}
err := dns.ValidateAndSetDefault()
if err == nil {
t.Error("Should've returned an error because endpoint's dns didn't have a query name, which is a mandatory field for dns")
}
}
func TestConfig_ValidateAndSetDefaultsWithInvalidDNSQueryType(t *testing.T) {
dns := &Config{
QueryType: "B",
QueryName: "example.com",
}
err := dns.ValidateAndSetDefault()
if err == nil {
t.Error("Should've returned an error because endpoint's dns query type is invalid, it needs to be a valid query name like A, AAAA, CNAME...")
}
}
+154 -183
View File
@@ -1,4 +1,4 @@
package core package endpoint
import ( import (
"bytes" "bytes"
@@ -15,12 +15,13 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core/ui" "github.com/TwiN/gatus/v5/config/endpoint/dns"
"github.com/TwiN/gatus/v5/util" sshconfig "github.com/TwiN/gatus/v5/config/endpoint/ssh"
"github.com/TwiN/gatus/v5/config/endpoint/ui"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
type EndpointType string type Type string
const ( const (
// HostHeader is the name of the header used to specify the host // HostHeader is the name of the header used to specify the host
@@ -35,17 +36,17 @@ const (
// GatusUserAgent is the default user agent that Gatus uses to send requests. // GatusUserAgent is the default user agent that Gatus uses to send requests.
GatusUserAgent = "Gatus/1.0" GatusUserAgent = "Gatus/1.0"
EndpointTypeDNS EndpointType = "DNS" TypeDNS Type = "DNS"
EndpointTypeTCP EndpointType = "TCP" TypeTCP Type = "TCP"
EndpointTypeSCTP EndpointType = "SCTP" TypeSCTP Type = "SCTP"
EndpointTypeUDP EndpointType = "UDP" TypeUDP Type = "UDP"
EndpointTypeICMP EndpointType = "ICMP" TypeICMP Type = "ICMP"
EndpointTypeSTARTTLS EndpointType = "STARTTLS" TypeSTARTTLS Type = "STARTTLS"
EndpointTypeTLS EndpointType = "TLS" TypeTLS Type = "TLS"
EndpointTypeHTTP EndpointType = "HTTP" TypeHTTP Type = "HTTP"
EndpointTypeWS EndpointType = "WEBSOCKET" TypeWS Type = "WEBSOCKET"
EndpointTypeSSH EndpointType = "SSH" TypeSSH Type = "SSH"
EndpointTypeUNKNOWN EndpointType = "UNKNOWN" TypeUNKNOWN Type = "UNKNOWN"
) )
var ( var (
@@ -55,12 +56,6 @@ var (
// ErrEndpointWithNoURL is the error with which Gatus will panic if an endpoint is configured with no url // ErrEndpointWithNoURL is the error with which Gatus will panic if an endpoint is configured with no url
ErrEndpointWithNoURL = errors.New("you must specify an url for each endpoint") ErrEndpointWithNoURL = errors.New("you must specify an url for each endpoint")
// 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 \\")
// ErrUnknownEndpointType is the error with which Gatus will panic if an endpoint has an unknown type // ErrUnknownEndpointType is the error with which Gatus will panic if an endpoint has an unknown type
ErrUnknownEndpointType = errors.New("unknown endpoint type") ErrUnknownEndpointType = errors.New("unknown endpoint type")
@@ -72,13 +67,9 @@ var (
// This is because the free whois service we are using should not be abused, especially considering the fact that // This is because the free whois service we are using should not be abused, especially considering the fact that
// the data takes a while to be updated. // the data takes a while to be updated.
ErrInvalidEndpointIntervalForDomainExpirationPlaceholder = errors.New("the minimum interval for an endpoint with a condition using the " + DomainExpirationPlaceholder + " placeholder is 300s (5m)") ErrInvalidEndpointIntervalForDomainExpirationPlaceholder = errors.New("the minimum interval for an endpoint with a condition using the " + DomainExpirationPlaceholder + " placeholder is 300s (5m)")
// ErrEndpointWithoutSSHUsername is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a user.
ErrEndpointWithoutSSHUsername = errors.New("you must specify a username for each endpoint with SSH")
// ErrEndpointWithoutSSHPassword is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a password.
ErrEndpointWithoutSSHPassword = errors.New("you must specify a password for each endpoint with SSH")
) )
// Endpoint is the configuration of a monitored // Endpoint is the configuration of a service to be monitored
type Endpoint struct { type Endpoint struct {
// Enabled defines whether to enable the monitoring of the endpoint // Enabled defines whether to enable the monitoring of the endpoint
Enabled *bool `yaml:"enabled,omitempty"` Enabled *bool `yaml:"enabled,omitempty"`
@@ -92,9 +83,6 @@ type Endpoint struct {
// URL to send the request to // URL to send the request to
URL string `yaml:"url"` URL string `yaml:"url"`
// DNS is the configuration of DNS monitoring
DNS *DNS `yaml:"dns,omitempty"`
// Method of the request made to the url of the endpoint // Method of the request made to the url of the endpoint
Method string `yaml:"method,omitempty"` Method string `yaml:"method,omitempty"`
@@ -116,6 +104,12 @@ type Endpoint struct {
// Alerts is the alerting configuration for the endpoint in case of failure // Alerts is the alerting configuration for the endpoint in case of failure
Alerts []*alert.Alert `yaml:"alerts,omitempty"` Alerts []*alert.Alert `yaml:"alerts,omitempty"`
// DNSConfig is the configuration for DNS monitoring
DNSConfig *dns.Config `yaml:"dns,omitempty"`
// SSH is the configuration for SSH monitoring
SSHConfig *sshconfig.Config `yaml:"ssh,omitempty"`
// ClientConfig is the configuration of the client used to communicate with the endpoint's target // ClientConfig is the configuration of the client used to communicate with the endpoint's target
ClientConfig *client.Config `yaml:"client,omitempty"` ClientConfig *client.Config `yaml:"client,omitempty"`
@@ -127,163 +121,142 @@ type Endpoint struct {
// NumberOfSuccessesInARow is the number of successful evaluations in a row // NumberOfSuccessesInARow is the number of successful evaluations in a row
NumberOfSuccessesInARow int `yaml:"-"` NumberOfSuccessesInARow int `yaml:"-"`
// SSH is the configuration of SSH monitoring.
SSH *SSH `yaml:"ssh,omitempty"`
}
type SSH struct {
// Username is the username to use when connecting to the SSH server.
Username string `yaml:"username,omitempty"`
// Password is the password to use when connecting to the SSH server.
Password string `yaml:"password,omitempty"`
}
// ValidateAndSetDefaults validates the endpoint
func (s *SSH) ValidateAndSetDefaults() error {
if s.Username == "" {
return ErrEndpointWithoutSSHUsername
}
if s.Password == "" {
return ErrEndpointWithoutSSHPassword
}
return nil
} }
// IsEnabled returns whether the endpoint is enabled or not // IsEnabled returns whether the endpoint is enabled or not
func (endpoint Endpoint) IsEnabled() bool { func (e *Endpoint) IsEnabled() bool {
if endpoint.Enabled == nil { if e.Enabled == nil {
return true return true
} }
return *endpoint.Enabled return *e.Enabled
} }
// Type returns the endpoint type // Type returns the endpoint type
func (endpoint Endpoint) Type() EndpointType { func (e *Endpoint) Type() Type {
switch { switch {
case endpoint.DNS != nil: case e.DNSConfig != nil:
return EndpointTypeDNS return TypeDNS
case strings.HasPrefix(endpoint.URL, "tcp://"): case strings.HasPrefix(e.URL, "tcp://"):
return EndpointTypeTCP return TypeTCP
case strings.HasPrefix(endpoint.URL, "sctp://"): case strings.HasPrefix(e.URL, "sctp://"):
return EndpointTypeSCTP return TypeSCTP
case strings.HasPrefix(endpoint.URL, "udp://"): case strings.HasPrefix(e.URL, "udp://"):
return EndpointTypeUDP return TypeUDP
case strings.HasPrefix(endpoint.URL, "icmp://"): case strings.HasPrefix(e.URL, "icmp://"):
return EndpointTypeICMP return TypeICMP
case strings.HasPrefix(endpoint.URL, "starttls://"): case strings.HasPrefix(e.URL, "starttls://"):
return EndpointTypeSTARTTLS return TypeSTARTTLS
case strings.HasPrefix(endpoint.URL, "tls://"): case strings.HasPrefix(e.URL, "tls://"):
return EndpointTypeTLS return TypeTLS
case strings.HasPrefix(endpoint.URL, "http://") || strings.HasPrefix(endpoint.URL, "https://"): case strings.HasPrefix(e.URL, "http://") || strings.HasPrefix(e.URL, "https://"):
return EndpointTypeHTTP return TypeHTTP
case strings.HasPrefix(endpoint.URL, "ws://") || strings.HasPrefix(endpoint.URL, "wss://"): case strings.HasPrefix(e.URL, "ws://") || strings.HasPrefix(e.URL, "wss://"):
return EndpointTypeWS return TypeWS
case strings.HasPrefix(endpoint.URL, "ssh://"): case strings.HasPrefix(e.URL, "ssh://"):
return EndpointTypeSSH return TypeSSH
default: default:
return EndpointTypeUNKNOWN return TypeUNKNOWN
} }
} }
// ValidateAndSetDefaults validates the endpoint's configuration and sets the default value of args that have one // ValidateAndSetDefaults validates the endpoint's configuration and sets the default value of args that have one
func (endpoint *Endpoint) ValidateAndSetDefaults() error { func (e *Endpoint) ValidateAndSetDefaults() error {
// Set default values if err := validateEndpointNameGroupAndAlerts(e.Name, e.Group, e.Alerts); err != nil {
if endpoint.ClientConfig == nil { return err
endpoint.ClientConfig = client.GetDefaultConfig() }
if len(e.URL) == 0 {
return ErrEndpointWithNoURL
}
if e.ClientConfig == nil {
e.ClientConfig = client.GetDefaultConfig()
} else { } else {
if err := endpoint.ClientConfig.ValidateAndSetDefaults(); err != nil { if err := e.ClientConfig.ValidateAndSetDefaults(); err != nil {
return err return err
} }
} }
if endpoint.UIConfig == nil { if e.UIConfig == nil {
endpoint.UIConfig = ui.GetDefaultConfig() e.UIConfig = ui.GetDefaultConfig()
} else { } else {
if err := endpoint.UIConfig.ValidateAndSetDefaults(); err != nil { if err := e.UIConfig.ValidateAndSetDefaults(); err != nil {
return err return err
} }
} }
if endpoint.Interval == 0 { if e.Interval == 0 {
endpoint.Interval = 1 * time.Minute e.Interval = 1 * time.Minute
} }
if len(endpoint.Method) == 0 { if len(e.Method) == 0 {
endpoint.Method = http.MethodGet e.Method = http.MethodGet
} }
if len(endpoint.Headers) == 0 { if len(e.Headers) == 0 {
endpoint.Headers = make(map[string]string) e.Headers = make(map[string]string)
} }
// Automatically add user agent header if there isn't one specified in the endpoint configuration // Automatically add user agent header if there isn't one specified in the endpoint configuration
if _, userAgentHeaderExists := endpoint.Headers[UserAgentHeader]; !userAgentHeaderExists { if _, userAgentHeaderExists := e.Headers[UserAgentHeader]; !userAgentHeaderExists {
endpoint.Headers[UserAgentHeader] = GatusUserAgent e.Headers[UserAgentHeader] = GatusUserAgent
} }
// Automatically add "Content-Type: application/json" header if there's no Content-Type set // Automatically add "Content-Type: application/json" header if there's no Content-Type set
// and endpoint.GraphQL is set to true // and endpoint.GraphQL is set to true
if _, contentTypeHeaderExists := endpoint.Headers[ContentTypeHeader]; !contentTypeHeaderExists && endpoint.GraphQL { if _, contentTypeHeaderExists := e.Headers[ContentTypeHeader]; !contentTypeHeaderExists && e.GraphQL {
endpoint.Headers[ContentTypeHeader] = "application/json" e.Headers[ContentTypeHeader] = "application/json"
} }
for _, endpointAlert := range endpoint.Alerts { if len(e.Conditions) == 0 {
if err := endpointAlert.ValidateAndSetDefaults(); err != nil {
return err
}
}
if len(endpoint.Name) == 0 {
return ErrEndpointWithNoName
}
if strings.ContainsAny(endpoint.Name, "\"\\") || strings.ContainsAny(endpoint.Group, "\"\\") {
return ErrEndpointWithInvalidNameOrGroup
}
if len(endpoint.URL) == 0 {
return ErrEndpointWithNoURL
}
if len(endpoint.Conditions) == 0 {
return ErrEndpointWithNoCondition return ErrEndpointWithNoCondition
} }
for _, c := range endpoint.Conditions { for _, c := range e.Conditions {
if endpoint.Interval < 5*time.Minute && c.hasDomainExpirationPlaceholder() { if e.Interval < 5*time.Minute && c.hasDomainExpirationPlaceholder() {
return ErrInvalidEndpointIntervalForDomainExpirationPlaceholder return ErrInvalidEndpointIntervalForDomainExpirationPlaceholder
} }
if err := c.Validate(); err != nil { if err := c.Validate(); err != nil {
return fmt.Errorf("%v: %w", ErrInvalidConditionFormat, err) return fmt.Errorf("%v: %w", ErrInvalidConditionFormat, err)
} }
} }
if endpoint.DNS != nil { if e.DNSConfig != nil {
return endpoint.DNS.validateAndSetDefault() return e.DNSConfig.ValidateAndSetDefault()
} }
if endpoint.Type() == EndpointTypeUNKNOWN { if e.SSHConfig != nil {
return e.SSHConfig.Validate()
}
if e.Type() == TypeUNKNOWN {
return ErrUnknownEndpointType return ErrUnknownEndpointType
} }
// Make sure that the request can be created // Make sure that the request can be created
_, err := http.NewRequest(endpoint.Method, endpoint.URL, bytes.NewBuffer([]byte(endpoint.Body))) _, err := http.NewRequest(e.Method, e.URL, bytes.NewBuffer([]byte(e.Body)))
if err != nil { if err != nil {
return err return err
} }
if endpoint.SSH != nil {
return endpoint.SSH.ValidateAndSetDefaults()
}
return nil return nil
} }
// DisplayName returns an identifier made up of the Name and, if not empty, the Group. // DisplayName returns an identifier made up of the Name and, if not empty, the Group.
func (endpoint Endpoint) DisplayName() string { func (e *Endpoint) DisplayName() string {
if len(endpoint.Group) > 0 { if len(e.Group) > 0 {
return endpoint.Group + "/" + endpoint.Name return e.Group + "/" + e.Name
} }
return endpoint.Name return e.Name
} }
// Key returns the unique key for the Endpoint // Key returns the unique key for the Endpoint
func (endpoint Endpoint) Key() string { func (e *Endpoint) Key() string {
return util.ConvertGroupAndEndpointNameToKey(endpoint.Group, endpoint.Name) return ConvertGroupAndEndpointNameToKey(e.Group, e.Name)
}
// Close HTTP connections between watchdog and endpoints to avoid dangling socket file descriptors
// on configuration reload.
// More context on https://github.com/TwiN/gatus/issues/536
func (e *Endpoint) Close() {
if e.Type() == TypeHTTP {
client.GetHTTPClient(e.ClientConfig).CloseIdleConnections()
}
} }
// EvaluateHealth sends a request to the endpoint's URL and evaluates the conditions of the endpoint. // EvaluateHealth sends a request to the endpoint's URL and evaluates the conditions of the endpoint.
func (endpoint *Endpoint) EvaluateHealth() *Result { func (e *Endpoint) EvaluateHealth() *Result {
result := &Result{Success: true, Errors: []string{}} result := &Result{Success: true, Errors: []string{}}
// Parse or extract hostname from URL // Parse or extract hostname from URL
if endpoint.DNS != nil { if e.DNSConfig != nil {
result.Hostname = strings.TrimSuffix(endpoint.URL, ":53") result.Hostname = strings.TrimSuffix(e.URL, ":53")
} else { } else {
urlObject, err := url.Parse(endpoint.URL) urlObject, err := url.Parse(e.URL)
if err != nil { if err != nil {
result.AddError(err.Error()) result.AddError(err.Error())
} else { } else {
@@ -291,11 +264,11 @@ func (endpoint *Endpoint) EvaluateHealth() *Result {
} }
} }
// Retrieve IP if necessary // Retrieve IP if necessary
if endpoint.needsToRetrieveIP() { if e.needsToRetrieveIP() {
endpoint.getIP(result) e.getIP(result)
} }
// Retrieve domain expiration if necessary // Retrieve domain expiration if necessary
if endpoint.needsToRetrieveDomainExpiration() && len(result.Hostname) > 0 { if e.needsToRetrieveDomainExpiration() && len(result.Hostname) > 0 {
var err error var err error
if result.DomainExpiration, err = client.GetDomainExpiration(result.Hostname); err != nil { if result.DomainExpiration, err = client.GetDomainExpiration(result.Hostname); err != nil {
result.AddError(err.Error()) result.AddError(err.Error())
@@ -303,34 +276,37 @@ func (endpoint *Endpoint) EvaluateHealth() *Result {
} }
// Call the endpoint (if there's no errors) // Call the endpoint (if there's no errors)
if len(result.Errors) == 0 { if len(result.Errors) == 0 {
endpoint.call(result) e.call(result)
} else { } else {
result.Success = false result.Success = false
} }
// Evaluate the conditions // Evaluate the conditions
for _, condition := range endpoint.Conditions { for _, condition := range e.Conditions {
success := condition.evaluate(result, endpoint.UIConfig.DontResolveFailedConditions) success := condition.evaluate(result, e.UIConfig.DontResolveFailedConditions)
if !success { if !success {
result.Success = false result.Success = false
} }
} }
result.Timestamp = time.Now() result.Timestamp = time.Now()
// Clean up parameters that we don't need to keep in the results // Clean up parameters that we don't need to keep in the results
if endpoint.UIConfig.HideURL { if e.UIConfig.HideURL {
for errIdx, errorString := range result.Errors { for errIdx, errorString := range result.Errors {
result.Errors[errIdx] = strings.ReplaceAll(errorString, endpoint.URL, "<redacted>") result.Errors[errIdx] = strings.ReplaceAll(errorString, e.URL, "<redacted>")
} }
} }
if endpoint.UIConfig.HideHostname { if e.UIConfig.HideHostname {
for errIdx, errorString := range result.Errors { for errIdx, errorString := range result.Errors {
result.Errors[errIdx] = strings.ReplaceAll(errorString, result.Hostname, "<redacted>") result.Errors[errIdx] = strings.ReplaceAll(errorString, result.Hostname, "<redacted>")
} }
result.Hostname = "" result.Hostname = ""
} }
if e.UIConfig.HideConditions {
result.ConditionResults = nil
}
return result return result
} }
func (endpoint *Endpoint) getIP(result *Result) { func (e *Endpoint) getIP(result *Result) {
if ips, err := net.LookupIP(result.Hostname); err != nil { if ips, err := net.LookupIP(result.Hostname); err != nil {
result.AddError(err.Error()) result.AddError(err.Error())
return return
@@ -339,24 +315,28 @@ func (endpoint *Endpoint) getIP(result *Result) {
} }
} }
func (endpoint *Endpoint) call(result *Result) { func (e *Endpoint) call(result *Result) {
var request *http.Request var request *http.Request
var response *http.Response var response *http.Response
var err error var err error
var certificate *x509.Certificate var certificate *x509.Certificate
endpointType := endpoint.Type() endpointType := e.Type()
if endpointType == EndpointTypeHTTP { if endpointType == TypeHTTP {
request = endpoint.buildHTTPRequest() request = e.buildHTTPRequest()
} }
startTime := time.Now() startTime := time.Now()
if endpointType == EndpointTypeDNS { if endpointType == TypeDNS {
endpoint.DNS.query(endpoint.URL, result) result.Connected, result.DNSRCode, result.Body, err = client.QueryDNS(e.DNSConfig.QueryType, e.DNSConfig.QueryName, e.URL)
if err != nil {
result.AddError(err.Error())
return
}
result.Duration = time.Since(startTime) result.Duration = time.Since(startTime)
} else if endpointType == EndpointTypeSTARTTLS || endpointType == EndpointTypeTLS { } else if endpointType == TypeSTARTTLS || endpointType == TypeTLS {
if endpointType == EndpointTypeSTARTTLS { if endpointType == TypeSTARTTLS {
result.Connected, certificate, err = client.CanPerformStartTLS(strings.TrimPrefix(endpoint.URL, "starttls://"), endpoint.ClientConfig) result.Connected, certificate, err = client.CanPerformStartTLS(strings.TrimPrefix(e.URL, "starttls://"), e.ClientConfig)
} else { } else {
result.Connected, certificate, err = client.CanPerformTLS(strings.TrimPrefix(endpoint.URL, "tls://"), endpoint.ClientConfig) result.Connected, certificate, err = client.CanPerformTLS(strings.TrimPrefix(e.URL, "tls://"), e.ClientConfig)
} }
if err != nil { if err != nil {
result.AddError(err.Error()) result.AddError(err.Error())
@@ -364,39 +344,39 @@ func (endpoint *Endpoint) call(result *Result) {
} }
result.Duration = time.Since(startTime) result.Duration = time.Since(startTime)
result.CertificateExpiration = time.Until(certificate.NotAfter) result.CertificateExpiration = time.Until(certificate.NotAfter)
} else if endpointType == EndpointTypeTCP { } else if endpointType == TypeTCP {
result.Connected = client.CanCreateTCPConnection(strings.TrimPrefix(endpoint.URL, "tcp://"), endpoint.ClientConfig) result.Connected = client.CanCreateTCPConnection(strings.TrimPrefix(e.URL, "tcp://"), e.ClientConfig)
result.Duration = time.Since(startTime) result.Duration = time.Since(startTime)
} else if endpointType == EndpointTypeUDP { } else if endpointType == TypeUDP {
result.Connected = client.CanCreateUDPConnection(strings.TrimPrefix(endpoint.URL, "udp://"), endpoint.ClientConfig) result.Connected = client.CanCreateUDPConnection(strings.TrimPrefix(e.URL, "udp://"), e.ClientConfig)
result.Duration = time.Since(startTime) result.Duration = time.Since(startTime)
} else if endpointType == EndpointTypeSCTP { } else if endpointType == TypeSCTP {
result.Connected = client.CanCreateSCTPConnection(strings.TrimPrefix(endpoint.URL, "sctp://"), endpoint.ClientConfig) result.Connected = client.CanCreateSCTPConnection(strings.TrimPrefix(e.URL, "sctp://"), e.ClientConfig)
result.Duration = time.Since(startTime) result.Duration = time.Since(startTime)
} else if endpointType == EndpointTypeICMP { } else if endpointType == TypeICMP {
result.Connected, result.Duration = client.Ping(strings.TrimPrefix(endpoint.URL, "icmp://"), endpoint.ClientConfig) result.Connected, result.Duration = client.Ping(strings.TrimPrefix(e.URL, "icmp://"), e.ClientConfig)
} else if endpointType == EndpointTypeWS { } else if endpointType == TypeWS {
result.Connected, result.Body, err = client.QueryWebSocket(endpoint.URL, endpoint.Body, endpoint.ClientConfig) result.Connected, result.Body, err = client.QueryWebSocket(e.URL, e.Body, e.ClientConfig)
if err != nil { if err != nil {
result.AddError(err.Error()) result.AddError(err.Error())
return return
} }
result.Duration = time.Since(startTime) result.Duration = time.Since(startTime)
} else if endpointType == EndpointTypeSSH { } else if endpointType == TypeSSH {
var cli *ssh.Client var cli *ssh.Client
result.Connected, cli, err = client.CanCreateSSHConnection(strings.TrimPrefix(endpoint.URL, "ssh://"), endpoint.SSH.Username, endpoint.SSH.Password, endpoint.ClientConfig) result.Connected, cli, err = client.CanCreateSSHConnection(strings.TrimPrefix(e.URL, "ssh://"), e.SSHConfig.Username, e.SSHConfig.Password, e.ClientConfig)
if err != nil { if err != nil {
result.AddError(err.Error()) result.AddError(err.Error())
return return
} }
result.Success, result.HTTPStatus, err = client.ExecuteSSHCommand(cli, endpoint.Body, endpoint.ClientConfig) result.Success, result.HTTPStatus, err = client.ExecuteSSHCommand(cli, e.Body, e.ClientConfig)
if err != nil { if err != nil {
result.AddError(err.Error()) result.AddError(err.Error())
return return
} }
result.Duration = time.Since(startTime) result.Duration = time.Since(startTime)
} else { } else {
response, err = client.GetHTTPClient(endpoint.ClientConfig).Do(request) response, err = client.GetHTTPClient(e.ClientConfig).Do(request)
result.Duration = time.Since(startTime) result.Duration = time.Since(startTime)
if err != nil { if err != nil {
result.AddError(err.Error()) result.AddError(err.Error())
@@ -410,7 +390,7 @@ func (endpoint *Endpoint) call(result *Result) {
result.HTTPStatus = response.StatusCode result.HTTPStatus = response.StatusCode
result.Connected = response.StatusCode > 0 result.Connected = response.StatusCode > 0
// Only read the Body if there's a condition that uses the BodyPlaceholder // Only read the Body if there's a condition that uses the BodyPlaceholder
if endpoint.needsToReadBody() { if e.needsToReadBody() {
result.Body, err = io.ReadAll(response.Body) result.Body, err = io.ReadAll(response.Body)
if err != nil { if err != nil {
result.AddError("error reading response body:" + err.Error()) result.AddError("error reading response body:" + err.Error())
@@ -419,28 +399,19 @@ func (endpoint *Endpoint) call(result *Result) {
} }
} }
// Close HTTP connections between watchdog and endpoints to avoid dangling socket file descriptors func (e *Endpoint) buildHTTPRequest() *http.Request {
// on configuration reload.
// More context on https://github.com/TwiN/gatus/issues/536
func (endpoint *Endpoint) Close() {
if endpoint.Type() == EndpointTypeHTTP {
client.GetHTTPClient(endpoint.ClientConfig).CloseIdleConnections()
}
}
func (endpoint *Endpoint) buildHTTPRequest() *http.Request {
var bodyBuffer *bytes.Buffer var bodyBuffer *bytes.Buffer
if endpoint.GraphQL { if e.GraphQL {
graphQlBody := map[string]string{ graphQlBody := map[string]string{
"query": endpoint.Body, "query": e.Body,
} }
body, _ := json.Marshal(graphQlBody) body, _ := json.Marshal(graphQlBody)
bodyBuffer = bytes.NewBuffer(body) bodyBuffer = bytes.NewBuffer(body)
} else { } else {
bodyBuffer = bytes.NewBuffer([]byte(endpoint.Body)) bodyBuffer = bytes.NewBuffer([]byte(e.Body))
} }
request, _ := http.NewRequest(endpoint.Method, endpoint.URL, bodyBuffer) request, _ := http.NewRequest(e.Method, e.URL, bodyBuffer)
for k, v := range endpoint.Headers { for k, v := range e.Headers {
request.Header.Set(k, v) request.Header.Set(k, v)
if k == HostHeader { if k == HostHeader {
request.Host = v request.Host = v
@@ -450,8 +421,8 @@ func (endpoint *Endpoint) buildHTTPRequest() *http.Request {
} }
// needsToReadBody checks if there's any condition that requires the response Body to be read // needsToReadBody checks if there's any condition that requires the response Body to be read
func (endpoint *Endpoint) needsToReadBody() bool { func (e *Endpoint) needsToReadBody() bool {
for _, condition := range endpoint.Conditions { for _, condition := range e.Conditions {
if condition.hasBodyPlaceholder() { if condition.hasBodyPlaceholder() {
return true return true
} }
@@ -460,8 +431,8 @@ func (endpoint *Endpoint) needsToReadBody() bool {
} }
// needsToRetrieveDomainExpiration checks if there's any condition that requires a whois query to be performed // needsToRetrieveDomainExpiration checks if there's any condition that requires a whois query to be performed
func (endpoint *Endpoint) needsToRetrieveDomainExpiration() bool { func (e *Endpoint) needsToRetrieveDomainExpiration() bool {
for _, condition := range endpoint.Conditions { for _, condition := range e.Conditions {
if condition.hasDomainExpirationPlaceholder() { if condition.hasDomainExpirationPlaceholder() {
return true return true
} }
@@ -470,8 +441,8 @@ func (endpoint *Endpoint) needsToRetrieveDomainExpiration() bool {
} }
// needsToRetrieveIP checks if there's any condition that requires an IP lookup // needsToRetrieveIP checks if there's any condition that requires an IP lookup
func (endpoint *Endpoint) needsToRetrieveIP() bool { func (e *Endpoint) needsToRetrieveIP() bool {
for _, condition := range endpoint.Conditions { for _, condition := range e.Conditions {
if condition.hasIPPlaceholder() { if condition.hasIPPlaceholder() {
return true return true
} }
@@ -1,9 +1,10 @@
package core package endpoint
import ( import (
"bytes" "bytes"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"errors"
"io" "io"
"net/http" "net/http"
"strings" "strings"
@@ -12,7 +13,9 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core/ui" "github.com/TwiN/gatus/v5/config/endpoint/dns"
"github.com/TwiN/gatus/v5/config/endpoint/ssh"
"github.com/TwiN/gatus/v5/config/endpoint/ui"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
@@ -91,6 +94,25 @@ func TestEndpoint(t *testing.T) {
return &http.Response{StatusCode: http.StatusBadGateway, Body: http.NoBody} return &http.Response{StatusCode: http.StatusBadGateway, Body: http.NoBody}
}), }),
}, },
{
Name: "failed-status-condition-with-hidden-conditions",
Endpoint: Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []Condition{"[STATUS] == 200"},
UIConfig: &ui.Config{HideConditions: true},
},
ExpectedResult: &Result{
Success: false,
Connected: true,
Hostname: "twin.sh",
ConditionResults: []*ConditionResult{}, // Because UIConfig.HideConditions is true, the condition results should not be shown.
DomainExpiration: 0, // Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.
},
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusBadGateway, Body: http.NoBody}
}),
},
{ {
Name: "condition-with-failed-certificate-expiration", Name: "condition-with-failed-certificate-expiration",
Endpoint: Endpoint{ Endpoint: Endpoint{
@@ -123,6 +145,7 @@ func TestEndpoint(t *testing.T) {
Name: "website-health", Name: "website-health",
URL: "https://twin.sh/health", URL: "https://twin.sh/health",
Conditions: []Condition{"[DOMAIN_EXPIRATION] > 100h"}, Conditions: []Condition{"[DOMAIN_EXPIRATION] > 100h"},
Interval: 5 * time.Minute,
}, },
ExpectedResult: &Result{ ExpectedResult: &Result{
Success: true, Success: true,
@@ -195,7 +218,10 @@ func TestEndpoint(t *testing.T) {
} else { } else {
client.InjectHTTPClient(nil) client.InjectHTTPClient(nil)
} }
scenario.Endpoint.ValidateAndSetDefaults() err := scenario.Endpoint.ValidateAndSetDefaults()
if err != nil {
t.Error("did not expect an error, got", err)
}
result := scenario.Endpoint.EvaluateHealth() result := scenario.Endpoint.EvaluateHealth()
if result.Success != scenario.ExpectedResult.Success { if result.Success != scenario.ExpectedResult.Success {
t.Errorf("Expected success to be %v, got %v", scenario.ExpectedResult.Success, result.Success) t.Errorf("Expected success to be %v, got %v", scenario.ExpectedResult.Success, result.Success)
@@ -241,13 +267,13 @@ func TestEndpoint(t *testing.T) {
} }
func TestEndpoint_IsEnabled(t *testing.T) { func TestEndpoint_IsEnabled(t *testing.T) {
if !(Endpoint{Enabled: nil}).IsEnabled() { if !(&Endpoint{Enabled: nil}).IsEnabled() {
t.Error("endpoint.IsEnabled() should've returned true, because Enabled was set to nil") t.Error("endpoint.IsEnabled() should've returned true, because Enabled was set to nil")
} }
if value := false; (Endpoint{Enabled: &value}).IsEnabled() { if value := false; (&Endpoint{Enabled: &value}).IsEnabled() {
t.Error("endpoint.IsEnabled() should've returned false, because Enabled was set to false") t.Error("endpoint.IsEnabled() should've returned false, because Enabled was set to false")
} }
if value := true; !(Endpoint{Enabled: &value}).IsEnabled() { if value := true; !(&Endpoint{Enabled: &value}).IsEnabled() {
t.Error("Endpoint.IsEnabled() should've returned true, because Enabled was set to true") t.Error("Endpoint.IsEnabled() should've returned true, because Enabled was set to true")
} }
} }
@@ -255,105 +281,105 @@ func TestEndpoint_IsEnabled(t *testing.T) {
func TestEndpoint_Type(t *testing.T) { func TestEndpoint_Type(t *testing.T) {
type args struct { type args struct {
URL string URL string
DNS *DNS DNS *dns.Config
SSH *SSH SSH *ssh.Config
} }
tests := []struct { tests := []struct {
args args args args
want EndpointType want Type
}{ }{
{ {
args: args{ args: args{
URL: "8.8.8.8", URL: "8.8.8.8",
DNS: &DNS{ DNS: &dns.Config{
QueryType: "A", QueryType: "A",
QueryName: "example.com", QueryName: "example.com",
}, },
}, },
want: EndpointTypeDNS, want: TypeDNS,
}, },
{ {
args: args{ args: args{
URL: "tcp://127.0.0.1:6379", URL: "tcp://127.0.0.1:6379",
}, },
want: EndpointTypeTCP, want: TypeTCP,
}, },
{ {
args: args{ args: args{
URL: "icmp://example.com", URL: "icmp://example.com",
}, },
want: EndpointTypeICMP, want: TypeICMP,
}, },
{ {
args: args{ args: args{
URL: "sctp://example.com", URL: "sctp://example.com",
}, },
want: EndpointTypeSCTP, want: TypeSCTP,
}, },
{ {
args: args{ args: args{
URL: "udp://example.com", URL: "udp://example.com",
}, },
want: EndpointTypeUDP, want: TypeUDP,
}, },
{ {
args: args{ args: args{
URL: "starttls://smtp.gmail.com:587", URL: "starttls://smtp.gmail.com:587",
}, },
want: EndpointTypeSTARTTLS, want: TypeSTARTTLS,
}, },
{ {
args: args{ args: args{
URL: "tls://example.com:443", URL: "tls://example.com:443",
}, },
want: EndpointTypeTLS, want: TypeTLS,
}, },
{ {
args: args{ args: args{
URL: "https://twin.sh/health", URL: "https://twin.sh/health",
}, },
want: EndpointTypeHTTP, want: TypeHTTP,
}, },
{ {
args: args{ args: args{
URL: "wss://example.com/", URL: "wss://example.com/",
}, },
want: EndpointTypeWS, want: TypeWS,
}, },
{ {
args: args{ args: args{
URL: "ws://example.com/", URL: "ws://example.com/",
}, },
want: EndpointTypeWS, want: TypeWS,
}, },
{ {
args: args{ args: args{
URL: "ssh://example.com:22", URL: "ssh://example.com:22",
SSH: &SSH{ SSH: &ssh.Config{
Username: "root", Username: "root",
Password: "password", Password: "password",
}, },
}, },
want: EndpointTypeSSH, want: TypeSSH,
}, },
{ {
args: args{ args: args{
URL: "invalid://example.org", URL: "invalid://example.org",
}, },
want: EndpointTypeUNKNOWN, want: TypeUNKNOWN,
}, },
{ {
args: args{ args: args{
URL: "no-scheme", URL: "no-scheme",
}, },
want: EndpointTypeUNKNOWN, want: TypeUNKNOWN,
}, },
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(string(tt.want), func(t *testing.T) { t.Run(string(tt.want), func(t *testing.T) {
endpoint := Endpoint{ endpoint := Endpoint{
URL: tt.args.URL, URL: tt.args.URL,
DNS: tt.args.DNS, DNSConfig: tt.args.DNS,
} }
if got := endpoint.Type(); got != tt.want { if got := endpoint.Type(); got != tt.want {
t.Errorf("Endpoint.Type() = %v, want %v", got, tt.want) t.Errorf("Endpoint.Type() = %v, want %v", got, tt.want)
@@ -430,7 +456,10 @@ func TestEndpoint_ValidateAndSetDefaultsWithClientConfig(t *testing.T) {
Timeout: 0, Timeout: 0,
}, },
} }
endpoint.ValidateAndSetDefaults() err := endpoint.ValidateAndSetDefaults()
if err != nil {
t.Fatal("did not expect an error, got", err)
}
if endpoint.ClientConfig == nil { if endpoint.ClientConfig == nil {
t.Error("client configuration should've been set to the default configuration") t.Error("client configuration should've been set to the default configuration")
} else { } else {
@@ -450,7 +479,7 @@ func TestEndpoint_ValidateAndSetDefaultsWithDNS(t *testing.T) {
endpoint := &Endpoint{ endpoint := &Endpoint{
Name: "dns-test", Name: "dns-test",
URL: "https://example.com", URL: "https://example.com",
DNS: &DNS{ DNSConfig: &dns.Config{
QueryType: "A", QueryType: "A",
QueryName: "example.com", QueryName: "example.com",
}, },
@@ -460,13 +489,13 @@ func TestEndpoint_ValidateAndSetDefaultsWithDNS(t *testing.T) {
if err != nil { if err != nil {
t.Error("did not expect an error, got", err) t.Error("did not expect an error, got", err)
} }
if endpoint.DNS.QueryName != "example.com." { if endpoint.DNSConfig.QueryName != "example.com." {
t.Error("Endpoint.dns.query-name should be formatted with . suffix") t.Error("Endpoint.dns.query-name should be formatted with . suffix")
} }
} }
func TestEndpoint_ValidateAndSetDefaultsWithSSH(t *testing.T) { func TestEndpoint_ValidateAndSetDefaultsWithSSH(t *testing.T) {
tests := []struct { scenarios := []struct {
name string name string
username string username string
password string password string
@@ -476,13 +505,13 @@ func TestEndpoint_ValidateAndSetDefaultsWithSSH(t *testing.T) {
name: "fail when has no user", name: "fail when has no user",
username: "", username: "",
password: "password", password: "password",
expectedErr: ErrEndpointWithoutSSHUsername, expectedErr: ssh.ErrEndpointWithoutSSHUsername,
}, },
{ {
name: "fail when has no password", name: "fail when has no password",
username: "username", username: "username",
password: "", password: "",
expectedErr: ErrEndpointWithoutSSHPassword, expectedErr: ssh.ErrEndpointWithoutSSHPassword,
}, },
{ {
name: "success when all fields are set", name: "success when all fields are set",
@@ -492,20 +521,20 @@ func TestEndpoint_ValidateAndSetDefaultsWithSSH(t *testing.T) {
}, },
} }
for _, test := range tests { for _, scenario := range scenarios {
t.Run(test.name, func(t *testing.T) { t.Run(scenario.name, func(t *testing.T) {
endpoint := &Endpoint{ endpoint := &Endpoint{
Name: "ssh-test", Name: "ssh-test",
URL: "https://example.com", URL: "https://example.com",
SSH: &SSH{ SSHConfig: &ssh.Config{
Username: test.username, Username: scenario.username,
Password: test.password, Password: scenario.password,
}, },
Conditions: []Condition{Condition("[STATUS] == 0")}, Conditions: []Condition{Condition("[STATUS] == 0")},
} }
err := endpoint.ValidateAndSetDefaults() err := endpoint.ValidateAndSetDefaults()
if err != test.expectedErr { if !errors.Is(err, scenario.expectedErr) {
t.Errorf("expected error %v, got %v", test.expectedErr, err) t.Errorf("expected error %v, got %v", scenario.expectedErr, err)
} }
}) })
} }
@@ -575,7 +604,10 @@ func TestEndpoint_buildHTTPRequest(t *testing.T) {
URL: "https://twin.sh/health", URL: "https://twin.sh/health",
Conditions: []Condition{condition}, Conditions: []Condition{condition},
} }
endpoint.ValidateAndSetDefaults() err := endpoint.ValidateAndSetDefaults()
if err != nil {
t.Fatal("did not expect an error, got", err)
}
request := endpoint.buildHTTPRequest() request := endpoint.buildHTTPRequest()
if request.Method != "GET" { if request.Method != "GET" {
t.Error("request.Method should've been GET, but was", request.Method) t.Error("request.Method should've been GET, but was", request.Method)
@@ -598,7 +630,10 @@ func TestEndpoint_buildHTTPRequestWithCustomUserAgent(t *testing.T) {
"User-Agent": "Test/2.0", "User-Agent": "Test/2.0",
}, },
} }
endpoint.ValidateAndSetDefaults() err := endpoint.ValidateAndSetDefaults()
if err != nil {
t.Fatal("did not expect an error, got", err)
}
request := endpoint.buildHTTPRequest() request := endpoint.buildHTTPRequest()
if request.Method != "GET" { if request.Method != "GET" {
t.Error("request.Method should've been GET, but was", request.Method) t.Error("request.Method should've been GET, but was", request.Method)
@@ -622,7 +657,10 @@ func TestEndpoint_buildHTTPRequestWithHostHeader(t *testing.T) {
"Host": "example.com", "Host": "example.com",
}, },
} }
endpoint.ValidateAndSetDefaults() err := endpoint.ValidateAndSetDefaults()
if err != nil {
t.Fatal("did not expect an error, got", err)
}
request := endpoint.buildHTTPRequest() request := endpoint.buildHTTPRequest()
if request.Method != "POST" { if request.Method != "POST" {
t.Error("request.Method should've been POST, but was", request.Method) t.Error("request.Method should've been POST, but was", request.Method)
@@ -649,7 +687,10 @@ func TestEndpoint_buildHTTPRequestWithGraphQLEnabled(t *testing.T) {
} }
}`, }`,
} }
endpoint.ValidateAndSetDefaults() err := endpoint.ValidateAndSetDefaults()
if err != nil {
t.Fatal("did not expect an error, got", err)
}
request := endpoint.buildHTTPRequest() request := endpoint.buildHTTPRequest()
if request.Method != "POST" { if request.Method != "POST" {
t.Error("request.Method should've been POST, but was", request.Method) t.Error("request.Method should've been POST, but was", request.Method)
@@ -671,7 +712,10 @@ func TestIntegrationEvaluateHealth(t *testing.T) {
URL: "https://twin.sh/health", URL: "https://twin.sh/health",
Conditions: []Condition{condition, bodyCondition}, Conditions: []Condition{condition, bodyCondition},
} }
endpoint.ValidateAndSetDefaults() err := endpoint.ValidateAndSetDefaults()
if err != nil {
t.Fatal("did not expect an error, got", err)
}
result := endpoint.EvaluateHealth() result := endpoint.EvaluateHealth()
if !result.ConditionResults[0].Success { if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition) t.Errorf("Condition '%s' should have been a success", condition)
@@ -699,7 +743,10 @@ func TestIntegrationEvaluateHealthWithErrorAndHideURL(t *testing.T) {
HideURL: true, HideURL: true,
}, },
} }
endpoint.ValidateAndSetDefaults() err := endpoint.ValidateAndSetDefaults()
if err != nil {
t.Fatal("did not expect an error, got", err)
}
result := endpoint.EvaluateHealth() result := endpoint.EvaluateHealth()
if result.Success { if result.Success {
t.Error("Because one of the conditions was invalid, result.Success should have been false") t.Error("Because one of the conditions was invalid, result.Success should have been false")
@@ -714,17 +761,20 @@ func TestIntegrationEvaluateHealthWithErrorAndHideURL(t *testing.T) {
func TestIntegrationEvaluateHealthForDNS(t *testing.T) { func TestIntegrationEvaluateHealthForDNS(t *testing.T) {
conditionSuccess := Condition("[DNS_RCODE] == NOERROR") conditionSuccess := Condition("[DNS_RCODE] == NOERROR")
conditionBody := Condition("[BODY] == 93.184.216.34") conditionBody := Condition("[BODY] == 93.184.215.14")
endpoint := Endpoint{ endpoint := Endpoint{
Name: "example", Name: "example",
URL: "8.8.8.8", URL: "8.8.8.8",
DNS: &DNS{ DNSConfig: &dns.Config{
QueryType: "A", QueryType: "A",
QueryName: "example.com.", QueryName: "example.com.",
}, },
Conditions: []Condition{conditionSuccess, conditionBody}, Conditions: []Condition{conditionSuccess, conditionBody},
} }
endpoint.ValidateAndSetDefaults() err := endpoint.ValidateAndSetDefaults()
if err != nil {
t.Fatal("did not expect an error, got", err)
}
result := endpoint.EvaluateHealth() result := endpoint.EvaluateHealth()
if !result.ConditionResults[0].Success { if !result.ConditionResults[0].Success {
t.Errorf("Conditions '%s' and '%s' should have been a success", conditionSuccess, conditionBody) t.Errorf("Conditions '%s' and '%s' should have been a success", conditionSuccess, conditionBody)
@@ -738,7 +788,7 @@ func TestIntegrationEvaluateHealthForDNS(t *testing.T) {
} }
func TestIntegrationEvaluateHealthForSSH(t *testing.T) { func TestIntegrationEvaluateHealthForSSH(t *testing.T) {
tests := []struct { scenarios := []struct {
name string name string
endpoint Endpoint endpoint Endpoint
conditions []Condition conditions []Condition
@@ -749,9 +799,9 @@ func TestIntegrationEvaluateHealthForSSH(t *testing.T) {
endpoint: Endpoint{ endpoint: Endpoint{
Name: "ssh-success", Name: "ssh-success",
URL: "ssh://localhost", URL: "ssh://localhost",
SSH: &SSH{ SSHConfig: &ssh.Config{
Username: "test", Username: "scenario",
Password: "test", Password: "scenario",
}, },
Body: "{ \"command\": \"uptime\" }", Body: "{ \"command\": \"uptime\" }",
}, },
@@ -763,9 +813,9 @@ func TestIntegrationEvaluateHealthForSSH(t *testing.T) {
endpoint: Endpoint{ endpoint: Endpoint{
Name: "ssh-failure", Name: "ssh-failure",
URL: "ssh://localhost", URL: "ssh://localhost",
SSH: &SSH{ SSHConfig: &ssh.Config{
Username: "test", Username: "scenario",
Password: "test", Password: "scenario",
}, },
Body: "{ \"command\": \"uptime\" }", Body: "{ \"command\": \"uptime\" }",
}, },
@@ -774,13 +824,13 @@ func TestIntegrationEvaluateHealthForSSH(t *testing.T) {
}, },
} }
for _, test := range tests { for _, scenario := range scenarios {
t.Run(test.name, func(t *testing.T) { t.Run(scenario.name, func(t *testing.T) {
test.endpoint.ValidateAndSetDefaults() scenario.endpoint.ValidateAndSetDefaults()
test.endpoint.Conditions = test.conditions scenario.endpoint.Conditions = scenario.conditions
result := test.endpoint.EvaluateHealth() result := scenario.endpoint.EvaluateHealth()
if result.Success != test.success { if result.Success != scenario.success {
t.Errorf("Expected success to be %v, but was %v", test.success, result.Success) t.Errorf("Expected success to be %v, but was %v", scenario.success, result.Success)
} }
}) })
} }
@@ -792,7 +842,10 @@ func TestIntegrationEvaluateHealthForICMP(t *testing.T) {
URL: "icmp://127.0.0.1", URL: "icmp://127.0.0.1",
Conditions: []Condition{"[CONNECTED] == true"}, Conditions: []Condition{"[CONNECTED] == true"},
} }
endpoint.ValidateAndSetDefaults() err := endpoint.ValidateAndSetDefaults()
if err != nil {
t.Fatal("did not expect an error, got", err)
}
result := endpoint.EvaluateHealth() result := endpoint.EvaluateHealth()
if !result.ConditionResults[0].Success { if !result.ConditionResults[0].Success {
t.Errorf("Conditions '%s' should have been a success", endpoint.Conditions[0]) t.Errorf("Conditions '%s' should have been a success", endpoint.Conditions[0])
+4 -2
View File
@@ -1,6 +1,8 @@
package core package endpoint
import "time" import (
"time"
)
// Event is something that happens at a specific time // Event is something that happens at a specific time
type Event struct { type Event struct {
@@ -1,6 +1,8 @@
package core package endpoint
import "testing" import (
"testing"
)
func TestNewEventFromResult(t *testing.T) { func TestNewEventFromResult(t *testing.T) {
if event := NewEventFromResult(&Result{Success: true}); event.Type != EventHealthy { if event := NewEventFromResult(&Result{Success: true}); event.Type != EventHealthy {
+83
View File
@@ -0,0 +1,83 @@
package endpoint
import (
"errors"
"github.com/TwiN/gatus/v5/alerting/alert"
)
var (
// ErrExternalEndpointWithNoToken is the error with which Gatus will panic if an external endpoint is configured without a token.
ErrExternalEndpointWithNoToken = errors.New("you must specify a token for each external endpoint")
)
// ExternalEndpoint is an endpoint whose result is pushed from outside Gatus, which means that
// said endpoints are not monitored by Gatus itself; Gatus only displays their results and takes
// care of alerting
type ExternalEndpoint struct {
// Enabled defines whether to enable the monitoring of the endpoint
Enabled *bool `yaml:"enabled,omitempty"`
// Name of the endpoint. Can be anything.
Name string `yaml:"name"`
// Group the endpoint is a part of. Used for grouping multiple endpoints together on the front end.
Group string `yaml:"group,omitempty"`
// Token is the bearer token that must be provided through the Authorization header to push results to the endpoint
Token string `yaml:"token,omitempty"`
// Alerts is the alerting configuration for the endpoint in case of failure
Alerts []*alert.Alert `yaml:"alerts,omitempty"`
// NumberOfFailuresInARow is the number of unsuccessful evaluations in a row
NumberOfFailuresInARow int `yaml:"-"`
// NumberOfSuccessesInARow is the number of successful evaluations in a row
NumberOfSuccessesInARow int `yaml:"-"`
}
// ValidateAndSetDefaults validates the ExternalEndpoint and sets the default values
func (externalEndpoint *ExternalEndpoint) ValidateAndSetDefaults() error {
if err := validateEndpointNameGroupAndAlerts(externalEndpoint.Name, externalEndpoint.Group, externalEndpoint.Alerts); err != nil {
return err
}
if len(externalEndpoint.Token) == 0 {
return ErrExternalEndpointWithNoToken
}
return nil
}
// IsEnabled returns whether the endpoint is enabled or not
func (externalEndpoint *ExternalEndpoint) IsEnabled() bool {
if externalEndpoint.Enabled == nil {
return true
}
return *externalEndpoint.Enabled
}
// DisplayName returns an identifier made up of the Name and, if not empty, the Group.
func (externalEndpoint *ExternalEndpoint) DisplayName() string {
if len(externalEndpoint.Group) > 0 {
return externalEndpoint.Group + "/" + externalEndpoint.Name
}
return externalEndpoint.Name
}
// Key returns the unique key for the Endpoint
func (externalEndpoint *ExternalEndpoint) Key() string {
return ConvertGroupAndEndpointNameToKey(externalEndpoint.Group, externalEndpoint.Name)
}
// ToEndpoint converts the ExternalEndpoint to an Endpoint
func (externalEndpoint *ExternalEndpoint) ToEndpoint() *Endpoint {
endpoint := &Endpoint{
Enabled: externalEndpoint.Enabled,
Name: externalEndpoint.Name,
Group: externalEndpoint.Group,
Alerts: externalEndpoint.Alerts,
NumberOfFailuresInARow: externalEndpoint.NumberOfFailuresInARow,
NumberOfSuccessesInARow: externalEndpoint.NumberOfSuccessesInARow,
}
return endpoint
}
+25
View File
@@ -0,0 +1,25 @@
package endpoint
import (
"testing"
)
func TestExternalEndpoint_ToEndpoint(t *testing.T) {
externalEndpoint := &ExternalEndpoint{
Name: "name",
Group: "group",
}
convertedEndpoint := externalEndpoint.ToEndpoint()
if externalEndpoint.Name != convertedEndpoint.Name {
t.Errorf("expected %s, got %s", externalEndpoint.Name, convertedEndpoint.Name)
}
if externalEndpoint.Group != convertedEndpoint.Group {
t.Errorf("expected %s, got %s", externalEndpoint.Group, convertedEndpoint.Group)
}
if externalEndpoint.Key() != convertedEndpoint.Key() {
t.Errorf("expected %s, got %s", externalEndpoint.Key(), convertedEndpoint.Key())
}
if externalEndpoint.DisplayName() != convertedEndpoint.DisplayName() {
t.Errorf("expected %s, got %s", externalEndpoint.DisplayName(), convertedEndpoint.DisplayName())
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
package util package endpoint
import "strings" import "strings"
@@ -1,4 +1,4 @@
package util package endpoint
import ( import (
"testing" "testing"
@@ -1,4 +1,4 @@
package util package endpoint
import "testing" import "testing"
+4 -4
View File
@@ -1,4 +1,4 @@
package core package endpoint
import ( import (
"time" "time"
@@ -7,7 +7,7 @@ import (
// Result of the evaluation of a Endpoint // Result of the evaluation of a Endpoint
type Result struct { type Result struct {
// HTTPStatus is the HTTP response status code // HTTPStatus is the HTTP response status code
HTTPStatus int `json:"status"` HTTPStatus int `json:"status,omitempty"`
// DNSRCode is the response code of a DNS query in a human-readable format // DNSRCode is the response code of a DNS query in a human-readable format
// //
@@ -29,8 +29,8 @@ type Result struct {
// Errors encountered during the evaluation of the Endpoint's health // Errors encountered during the evaluation of the Endpoint's health
Errors []string `json:"errors,omitempty"` Errors []string `json:"errors,omitempty"`
// ConditionResults results of the Endpoint's conditions // ConditionResults are the results of each of the Endpoint's Condition
ConditionResults []*ConditionResult `json:"conditionResults"` ConditionResults []*ConditionResult `json:"conditionResults,omitempty"`
// Success whether the result signifies a success or not // Success whether the result signifies a success or not
Success bool `json:"success"` Success bool `json:"success"`
@@ -1,4 +1,4 @@
package core package endpoint
import ( import (
"testing" "testing"
+29
View File
@@ -0,0 +1,29 @@
package ssh
import (
"errors"
)
var (
// ErrEndpointWithoutSSHUsername is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a user.
ErrEndpointWithoutSSHUsername = errors.New("you must specify a username for each SSH endpoint")
// ErrEndpointWithoutSSHPassword is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a password.
ErrEndpointWithoutSSHPassword = errors.New("you must specify a password for each SSH endpoint")
)
type Config struct {
Username string `yaml:"username,omitempty"`
Password string `yaml:"password,omitempty"`
}
// Validate the SSH configuration
func (cfg *Config) Validate() error {
if len(cfg.Username) == 0 {
return ErrEndpointWithoutSSHUsername
}
if len(cfg.Password) == 0 {
return ErrEndpointWithoutSSHPassword
}
return nil
}
+25
View File
@@ -0,0 +1,25 @@
package ssh
import (
"errors"
"testing"
)
func TestSSH_validate(t *testing.T) {
cfg := &Config{}
if err := cfg.Validate(); err == nil {
t.Error("expected an error")
} else if !errors.Is(err, ErrEndpointWithoutSSHUsername) {
t.Errorf("expected error to be '%v', got '%v'", ErrEndpointWithoutSSHUsername, err)
}
cfg.Username = "username"
if err := cfg.Validate(); err == nil {
t.Error("expected an error")
} else if !errors.Is(err, ErrEndpointWithoutSSHPassword) {
t.Errorf("expected error to be '%v', got '%v'", ErrEndpointWithoutSSHPassword, err)
}
cfg.Password = "password"
if err := cfg.Validate(); err != nil {
t.Errorf("expected no error, got '%v'", err)
}
}
@@ -1,16 +1,14 @@
package core package endpoint
import "github.com/TwiN/gatus/v5/util" // Status contains the evaluation Results of an Endpoint
type Status struct {
// EndpointStatus contains the evaluation Results of an Endpoint
type EndpointStatus struct {
// Name of the endpoint // Name of the endpoint
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
// Group the endpoint is a part of. Used for grouping multiple endpoints together on the front end. // Group the endpoint is a part of. Used for grouping multiple endpoints together on the front end.
Group string `json:"group,omitempty"` Group string `json:"group,omitempty"`
// Key is the key representing the EndpointStatus // Key of the Endpoint
Key string `json:"key"` Key string `json:"key"`
// Results is the list of endpoint evaluation results // Results is the list of endpoint evaluation results
@@ -27,12 +25,12 @@ type EndpointStatus struct {
Uptime *Uptime `json:"-"` Uptime *Uptime `json:"-"`
} }
// NewEndpointStatus creates a new EndpointStatus // NewStatus creates a new Status
func NewEndpointStatus(group, name string) *EndpointStatus { func NewStatus(group, name string) *Status {
return &EndpointStatus{ return &Status{
Name: name, Name: name,
Group: group, Group: group,
Key: util.ConvertGroupAndEndpointNameToKey(group, name), Key: ConvertGroupAndEndpointNameToKey(group, name),
Results: make([]*Result, 0), Results: make([]*Result, 0),
Events: make([]*Event, 0), Events: make([]*Event, 0),
Uptime: NewUptime(), Uptime: NewUptime(),
+19
View File
@@ -0,0 +1,19 @@
package endpoint
import (
"testing"
)
func TestNewEndpointStatus(t *testing.T) {
ep := &Endpoint{Name: "name", Group: "group"}
status := NewStatus(ep.Group, ep.Name)
if status.Name != ep.Name {
t.Errorf("expected %s, got %s", ep.Name, status.Name)
}
if status.Group != ep.Group {
t.Errorf("expected %s, got %s", ep.Group, status.Group)
}
if status.Key != "group_name" {
t.Errorf("expected %s, got %s", "group_name", status.Key)
}
}
+6 -1
View File
@@ -2,8 +2,11 @@ package ui
import "errors" import "errors"
// Config is the UI configuration for core.Endpoint // Config is the UI configuration for endpoint.Endpoint
type Config struct { type Config struct {
// HideConditions whether to hide the condition results on the UI
HideConditions bool `yaml:"hide-conditions"`
// HideHostname whether to hide the hostname in the Result // HideHostname whether to hide the hostname in the Result
HideHostname bool `yaml:"hide-hostname"` HideHostname bool `yaml:"hide-hostname"`
@@ -29,6 +32,7 @@ var (
ErrInvalidBadgeResponseTimeConfig = errors.New("invalid response time badge configuration: expected parameter 'response-time' to have 5 ascending numerical values") ErrInvalidBadgeResponseTimeConfig = errors.New("invalid response time badge configuration: expected parameter 'response-time' to have 5 ascending numerical values")
) )
// ValidateAndSetDefaults validates the UI configuration and sets the default values
func (config *Config) ValidateAndSetDefaults() error { func (config *Config) ValidateAndSetDefaults() error {
if config.Badge != nil { if config.Badge != nil {
if len(config.Badge.ResponseTime.Thresholds) != 5 { if len(config.Badge.ResponseTime.Thresholds) != 5 {
@@ -51,6 +55,7 @@ func GetDefaultConfig() *Config {
HideHostname: false, HideHostname: false,
HideURL: false, HideURL: false,
DontResolveFailedConditions: false, DontResolveFailedConditions: false,
HideConditions: false,
Badge: &Badge{ Badge: &Badge{
ResponseTime: &ResponseTime{ ResponseTime: &ResponseTime{
Thresholds: []int{50, 200, 300, 500, 750}, Thresholds: []int{50, 200, 300, 500, 750},
+53
View File
@@ -0,0 +1,53 @@
package ui
import (
"errors"
"testing"
)
func TestValidateAndSetDefaults(t *testing.T) {
tests := []struct {
name string
config *Config
wantErr error
}{
{
name: "with-valid-config",
config: &Config{
Badge: &Badge{
ResponseTime: &ResponseTime{Thresholds: []int{50, 200, 300, 500, 750}},
},
},
wantErr: nil,
},
{
name: "with-invalid-threshold-length",
config: &Config{
Badge: &Badge{
ResponseTime: &ResponseTime{Thresholds: []int{50, 200, 300, 500}},
},
},
wantErr: ErrInvalidBadgeResponseTimeConfig,
},
{
name: "with-invalid-thresholds-order",
config: &Config{
Badge: &Badge{ResponseTime: &ResponseTime{Thresholds: []int{50, 200, 500, 300, 750}}},
},
wantErr: ErrInvalidBadgeResponseTimeConfig,
},
{
name: "with-no-badge-configured", // should give default badge cfg
config: &Config{},
wantErr: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := tt.config.ValidateAndSetDefaults(); !errors.Is(err, tt.wantErr) {
t.Errorf("Expected error %v, got %v", tt.wantErr, err)
}
})
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
package core package endpoint
// Uptime is the struct that contains the relevant data for calculating the uptime as well as the uptime itself // Uptime is the struct that contains the relevant data for calculating the uptime as well as the uptime itself
// and some other statistics // and some other statistics
-1
View File
@@ -1 +0,0 @@
TODO: move files from core to here.
+27 -3
View File
@@ -13,10 +13,16 @@ const (
// DefaultPort is the default port the application will listen on // DefaultPort is the default port the application will listen on
DefaultPort = 8080 DefaultPort = 8080
// DefaultReadBufferSize is the default value for ReadBufferSize
DefaultReadBufferSize = 8192
// MinimumReadBufferSize is the minimum value for ReadBufferSize, and also the default value set
// for fiber.Config.ReadBufferSize
MinimumReadBufferSize = 4096
) )
// Config is the structure which supports the configuration of the endpoint // Config is the structure which supports the configuration of the server listening to requests
// which provides access to the web frontend
type Config struct { type Config struct {
// Address to listen on (defaults to 0.0.0.0 specified by DefaultAddress) // Address to listen on (defaults to 0.0.0.0 specified by DefaultAddress)
Address string `yaml:"address"` Address string `yaml:"address"`
@@ -24,6 +30,14 @@ type Config struct {
// Port to listen on (default to 8080 specified by DefaultPort) // Port to listen on (default to 8080 specified by DefaultPort)
Port int `yaml:"port"` Port int `yaml:"port"`
// ReadBufferSize sets fiber.Config.ReadBufferSize, which is the buffer size for reading requests coming from a
// single connection and also acts as a limit for the maximum header size.
//
// If you're getting occasional "Request Header Fields Too Large", you may want to try increasing this value.
//
// Defaults to DefaultReadBufferSize
ReadBufferSize int `yaml:"read-buffer-size,omitempty"`
// TLS configuration (optional) // TLS configuration (optional)
TLS *TLSConfig `yaml:"tls,omitempty"` TLS *TLSConfig `yaml:"tls,omitempty"`
} }
@@ -38,7 +52,11 @@ type TLSConfig struct {
// GetDefaultConfig returns a Config struct with the default values // GetDefaultConfig returns a Config struct with the default values
func GetDefaultConfig() *Config { func GetDefaultConfig() *Config {
return &Config{Address: DefaultAddress, Port: DefaultPort} return &Config{
Address: DefaultAddress,
Port: DefaultPort,
ReadBufferSize: DefaultReadBufferSize,
}
} }
// ValidateAndSetDefaults validates the web configuration and sets the default values if necessary. // ValidateAndSetDefaults validates the web configuration and sets the default values if necessary.
@@ -53,6 +71,12 @@ func (web *Config) ValidateAndSetDefaults() error {
} else if web.Port < 0 || web.Port > math.MaxUint16 { } else if web.Port < 0 || web.Port > math.MaxUint16 {
return fmt.Errorf("invalid port: value should be between %d and %d", 0, math.MaxUint16) return fmt.Errorf("invalid port: value should be between %d and %d", 0, math.MaxUint16)
} }
// Validate ReadBufferSize
if web.ReadBufferSize == 0 {
web.ReadBufferSize = DefaultReadBufferSize // Not set? Use the default value.
} else if web.ReadBufferSize < MinimumReadBufferSize {
web.ReadBufferSize = MinimumReadBufferSize // Below the minimum? Use the minimum value.
}
// Try to load the TLS certificates // Try to load the TLS certificates
if web.TLS != nil { if web.TLS != nil {
if err := web.TLS.isValid(); err != nil { if err := web.TLS.isValid(); err != nil {

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