Compare commits

...

249 Commits

Author SHA1 Message Date
TwiN
fd17dcd204 fix(tls): Pass certificate and private key files to listener method (#531)
Fixes #530
2023-07-20 19:02:34 -04:00
TwiN
2f6b8f23f7 test(store): Make sure the write-through cache works 2023-07-19 23:07:42 -04:00
dependabot[bot]
0a37a61619 chore(deps): bump github.com/valyala/fasthttp from 1.47.0 to 1.48.0 (#524)
Bumps [github.com/valyala/fasthttp](https://github.com/valyala/fasthttp) from 1.47.0 to 1.48.0.
- [Release notes](https://github.com/valyala/fasthttp/releases)
- [Commits](https://github.com/valyala/fasthttp/compare/v1.47.0...v1.48.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-18 21:35:16 -04:00
macmoritz
70f9f8738c fix(ui): disable hover effects when not supported (#514)
* fix(mobile css): disable hover effects when not supported

* fix missing trailing comma in tailwind config

* make css hover fix static

* update static css

---------

Co-authored-by: TwiN <twin@linux.com>
2023-07-16 21:14:43 -04:00
dependabot[bot]
a725e7e770 chore(deps): bump github.com/TwiN/whois from 1.1.2 to 1.1.3
Bumps [github.com/TwiN/whois](https://github.com/TwiN/whois) from 1.1.2 to 1.1.3.
- [Release notes](https://github.com/TwiN/whois/releases)
- [Commits](https://github.com/TwiN/whois/compare/v1.1.2...v1.1.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-07-13 23:22:08 -04:00
dependabot[bot]
fc29c45e5f chore(deps): bump github.com/prometheus/client_golang
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.14.0 to 1.16.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.14.0...v1.16.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>
2023-07-12 21:35:28 -04:00
TwiN
6a8c308af7 fix(api): Define unprotected routes first
This is because fiber does not work the same way that mux did wrt groups (mux referred to them as subrouters).
I could not apply a middleware to a specific fiber Group due to the groups being prefix-based.
In other words, the only other way to apply middlewares to specific endpoints without changing the path is by applying the security middleware after the routes that did not need to be protected and before the routes that did need to be protected
2023-07-11 22:46:49 -04:00
TwiN
0e33556775 test(security): Make sure router behaves as intended when authn is required 2023-07-11 22:46:49 -04:00
TwiN
6bb65f4eec feat(api): Migrate from gorilla/mux to fiber
Fixes #468
2023-07-11 22:46:49 -04:00
TwiN
9142ff837c fix(ui): Only show loading icon on initial load and on page change 2023-07-08 17:36:12 -04:00
dependabot[bot]
11eb7fb02e chore(deps): bump github.com/TwiN/whois from 1.1.1 to 1.1.2 (#518)
Bumps [github.com/TwiN/whois](https://github.com/TwiN/whois) from 1.1.1 to 1.1.2.
- [Release notes](https://github.com/TwiN/whois/releases)
- [Commits](https://github.com/TwiN/whois/compare/v1.1.1...v1.1.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-04 22:32:53 -04:00
dependabot[bot]
271b836160 chore(deps): bump modernc.org/sqlite from 1.23.0 to 1.23.1 (#516)
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.23.0 to 1.23.1.
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.23.0...v1.23.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-03 22:38:58 -04:00
TwiN
c42f3ef787 chore(ui): Rebuild static assets 2023-07-02 23:41:24 -04:00
TwiN
c7a774b213 refactor: Use v-else instead of redundant statement 2023-07-02 23:38:07 -04:00
Karol Danko
36207490b2 feat(ui): Show number of failing checks per group (#493)
* feat: added number of failing checks

* updated design

---------

Co-authored-by: TwiN <twin@linux.com>
2023-07-02 23:35:05 -04:00
Oskar Carl
5eebe6d9cc feat(alerting): Add token authorization for ntfy (#512)
* feat(alerting): Add token authorization for ntfy

* feat(alerting): Fix suggestions for ntfy auth

---------

Co-authored-by: TwiN <twin@linux.com>
2023-07-02 23:10:16 -04:00
dependabot[bot]
ecda4a9987 chore(deps): bump golang.org/x/crypto from 0.9.0 to 0.10.0 (#513)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.9.0 to 0.10.0.
- [Commits](https://github.com/golang/crypto/compare/v0.9.0...v0.10.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>
2023-07-01 21:46:23 -04:00
dependabot[bot]
162dd9bc7c chore(deps): bump github.com/prometheus-community/pro-bing from 0.1.0 to 0.2.0 (#502)
chore(deps): bump github.com/prometheus-community/pro-bing

Bumps [github.com/prometheus-community/pro-bing](https://github.com/prometheus-community/pro-bing) from 0.1.0 to 0.2.0.
- [Release notes](https://github.com/prometheus-community/pro-bing/releases)
- [Changelog](https://github.com/prometheus-community/pro-bing/blob/main/.goreleaser.yaml)
- [Commits](https://github.com/prometheus-community/pro-bing/compare/v0.1.0...v0.2.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2023-06-29 22:41:39 -04:00
Ansil H
60b0f3fa29 docs: Specify that exposing Gatus through a subpath is not supported (#507)
* Updated README.md

Added Gatus UI custom path supportability to FQA

* Update README.md

* Update README.md

* Update README.md

---------

Co-authored-by: TwiN <twin@linux.com>
2023-06-20 23:21:55 -04:00
dependabot[bot]
0cf7621162 chore(deps): bump modernc.org/sqlite from 1.22.1 to 1.23.0 (#501)
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.22.1 to 1.23.0.
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.22.1...v1.23.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>
2023-06-11 22:45:53 -04:00
dependabot[bot]
8d27864aaa chore(deps): bump golang.org/x/image from 0.0.0-20210628002857-a66eb6448b8d to 0.5.0 (#500)
chore(deps): bump golang.org/x/image

Bumps [golang.org/x/image](https://github.com/golang/image) from 0.0.0-20210628002857-a66eb6448b8d to 0.5.0.
- [Commits](https://github.com/golang/image/commits/v0.5.0)

---
updated-dependencies:
- dependency-name: golang.org/x/image
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-06-07 19:00:23 -04:00
dependabot[bot]
3bf880a199 chore(deps): bump github.com/TwiN/whois from 1.1.0 to 1.1.1 (#499)
Bumps [github.com/TwiN/whois](https://github.com/TwiN/whois) from 1.1.0 to 1.1.1.
- [Release notes](https://github.com/TwiN/whois/releases)
- [Commits](https://github.com/TwiN/whois/compare/v1.1.0...v1.1.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-06-07 18:36:05 -04:00
TwiN
719f684982 ci: Update interval to daily 2023-06-07 18:31:41 -04:00
dependabot[bot]
a3df822df3 chore(deps): bump github.com/coreos/go-oidc/v3 from 3.5.0 to 3.6.0 (#498)
Bumps [github.com/coreos/go-oidc/v3](https://github.com/coreos/go-oidc) from 3.5.0 to 3.6.0.
- [Release notes](https://github.com/coreos/go-oidc/releases)
- [Commits](https://github.com/coreos/go-oidc/compare/v3.5.0...v3.6.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-06-07 18:25:43 -04:00
Sébastien
d62a6c5054 docs: Fix typo (#494)
Typo on "Monitoring an endpoint using TLS"
2023-06-03 10:12:10 -04:00
dependabot[bot]
fcf8b5d86f chore(deps): bump golang.org/x/oauth2 from 0.7.0 to 0.8.0 (#491)
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.7.0 to 0.8.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.7.0...v0.8.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>
2023-06-02 20:25:29 -04:00
TwiN
5bbd240dd9 docs(alerting): Update GitLab issue prefix 2023-05-30 21:58:57 -04:00
Hugues Lismonde
7e163c3fcf feat(alerting): Add GitLab alerting provider (#485)
* feat:  add gitlab provider

* docs: 📝 add documentation for gitlab provider

* docs: 📝 fix removed github screenshot

* refactor:  remove uniuri dependency

* refactor: 🎨 correctly capitalize GitLab

* Update alerting/alert/type.go

* fix: 💡 correct comments about webhook url and authorization key

* Update alerting/provider/gitlab/gitlab.go

* Update alerting/provider/gitlab/gitlab_test.go

---------

Co-authored-by: TwiN <twin@linux.com>
2023-05-30 21:57:15 -04:00
dependabot[bot]
1dd0ecf10b chore(deps): bump modernc.org/sqlite from 1.20.4 to 1.22.1 (#479)
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.20.4 to 1.22.1.
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.20.4...v1.22.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-05-29 22:46:53 -04:00
dependabot[bot]
3350e81443 chore(deps): bump codecov/codecov-action from 3.1.3 to 3.1.4 (#478)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 3.1.3 to 3.1.4.
- [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.3...v3.1.4)

---
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>
2023-05-18 22:05:11 -04:00
Sergey Khokhlov
c3e1835dd6 fix(alerting): Unable to set maintenance interval to all day (#475) 2023-05-17 18:45:18 -04:00
dependabot[bot]
2a45a151da chore(deps): bump golang.org/x/crypto from 0.8.0 to 0.9.0 (#471)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.8.0 to 0.9.0.
- [Commits](https://github.com/golang/crypto/compare/v0.8.0...v0.9.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>
2023-05-11 22:58:44 -04:00
Sergey Khokhlov
74cde8ae8d fix(alerting): use same email subject for trigger and resolution alert (#465) 2023-05-06 10:09:05 -04:00
dependabot[bot]
669877baf4 chore(deps): bump github.com/miekg/dns from 1.1.53 to 1.1.54 (#462)
Bumps [github.com/miekg/dns](https://github.com/miekg/dns) from 1.1.53 to 1.1.54.
- [Release notes](https://github.com/miekg/dns/releases)
- [Changelog](https://github.com/miekg/dns/blob/master/Makefile.release)
- [Commits](https://github.com/miekg/dns/compare/v1.1.53...v1.1.54)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-05-04 21:38:52 -04:00
TwiN
447e140479 feat(connectivity): Allow internet connection validation prior to endpoint execution (#461) 2023-05-02 22:41:22 -04:00
dependabot[bot]
6908199716 chore(deps): bump golang.org/x/crypto from 0.7.0 to 0.8.0 (#455)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.7.0 to 0.8.0.
- [Release notes](https://github.com/golang/crypto/releases)
- [Commits](https://github.com/golang/crypto/compare/v0.7.0...v0.8.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>
2023-04-30 23:35:14 -04:00
dependabot[bot]
b12f652553 chore(deps): bump codecov/codecov-action from 3.1.2 to 3.1.3 (#459)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 3.1.2 to 3.1.3.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v3.1.2...v3.1.3)

---
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>
2023-04-26 22:39:19 -04:00
TwiN
83edca6e80 refactor: Modify implementation of TLS (#457)
* refactor: Don't generate certificates programmatically

* build: Add testdata folder to .dockerignore
2023-04-22 15:22:09 -04:00
TwiN
636688b43e chore(deps): Upgrade github.com/TwiN/g8 to v2.0.0 2023-04-22 12:55:30 -04:00
TwiN
4fdb55d632 chore: Update Go to 1.20 2023-04-22 12:13:37 -04:00
Christian Krudewig
a05daeda2e feat(web): Support TLS encryption (#322)
* Basic setup to serve HTTPS

* Correctly handle the case of missing TLS configs

* Documenting TLS

* Refactor TLS configuration setup

* Add TLS Encryption section again to README

* Extending TOC in README

* Moving TLS settings to subsection of web settings

* Adding tests for config/web

* Add test for handling TLS

* Rename some variables as suggested

* Corrected error formatting

* Update test module import

* Polishing the readme file

* Error handling for TLSConfig()

---------

Co-authored-by: TwiN <twin@linux.com>
2023-04-22 12:12:56 -04:00
TwiN
0bd0c1fd15 docs(sponsors): Add @8ball030 to list of sponsors 2023-04-20 18:28:03 -04:00
dependabot[bot]
eb3ca71c72 chore(deps): bump golang.org/x/oauth2 from 0.6.0 to 0.7.0 (#454)
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.6.0 to 0.7.0.
- [Release notes](https://github.com/golang/oauth2/releases)
- [Commits](https://github.com/golang/oauth2/compare/v0.6.0...v0.7.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>
2023-04-17 20:01:08 -04:00
dependabot[bot]
37325cd78a chore(deps): bump codecov/codecov-action from 3.1.1 to 3.1.2 (#453)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 3.1.1 to 3.1.2.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v3.1.1...v3.1.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-04-15 12:01:44 -04:00
dependabot[bot]
f6e7e346b6 chore(deps): bump golang.org/x/oauth2 from 0.5.0 to 0.6.0 (#450)
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.5.0 to 0.6.0.
- [Release notes](https://github.com/golang/oauth2/releases)
- [Commits](https://github.com/golang/oauth2/compare/v0.5.0...v0.6.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>
2023-04-06 20:14:16 -04:00
TwiN
b5e742acde fix(ui): Hide endpoint and prev/next arrows while data is being fetched 2023-04-05 19:33:50 -04:00
dependabot[bot]
685351a025 chore(deps): bump github.com/miekg/dns from 1.1.50 to 1.1.53 (#449)
Bumps [github.com/miekg/dns](https://github.com/miekg/dns) from 1.1.50 to 1.1.53.
- [Release notes](https://github.com/miekg/dns/releases)
- [Changelog](https://github.com/miekg/dns/blob/master/Makefile.release)
- [Commits](https://github.com/miekg/dns/compare/v1.1.50...v1.1.53)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-29 20:38:19 -04:00
Flo J
ee8e0c4b40 fix(deps): Replace deprecated go-ping dependency to pro-bing (maintained fork) (#444)
update go-ping to pro-ping maintained fork

additionally add a hint in the README for previliged mode

Co-authored-by: floj <floj@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2023-03-26 17:26:40 -04:00
dependabot[bot]
fb94eea914 chore(deps): bump golang.org/x/crypto from 0.6.0 to 0.7.0 (#446)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.6.0 to 0.7.0.
- [Release notes](https://github.com/golang/crypto/releases)
- [Commits](https://github.com/golang/crypto/compare/v0.6.0...v0.7.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>
2023-03-26 16:39:05 -04:00
dependabot[bot]
a69ccfdb08 chore(deps): bump actions/setup-go from 3 to 4 (#447)
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 3 to 4.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v3...v4)

---
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>
2023-03-26 16:34:03 -04:00
TwiN
018f723e78 docs: Update list of sponsors 2023-03-16 21:12:15 -04:00
TwiN
038c8c8d8e fix: Print response body on failure if debug is set to true 2023-03-14 20:02:31 -04:00
TwiN
f8f61deb2c docs: Add link to managed solution
Apparently it's not obvious enough that there's a hosted solution.
2023-03-05 15:50:56 -05:00
dependabot[bot]
32a15decfd chore(deps): bump golang.org/x/crypto from 0.0.0-20210921155107-089bfa567519 to 0.6.0 (#443)
chore(deps): bump golang.org/x/crypto

Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.0.0-20210921155107-089bfa567519 to 0.6.0.
- [Release notes](https://github.com/golang/crypto/releases)
- [Commits](https://github.com/golang/crypto/commits/v0.6.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>
2023-03-03 20:21:49 -05:00
dependabot[bot]
0dba6e8674 chore(deps): bump modernc.org/sqlite from 1.20.3 to 1.20.4 (#441)
---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-27 22:14:49 -05:00
dependabot[bot]
0c92534432 chore(deps): bump golang.org/x/oauth2 from 0.4.0 to 0.5.0 (#431)
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.4.0 to 0.5.0.
- [Release notes](https://github.com/golang/oauth2/releases)
- [Commits](https://github.com/golang/oauth2/compare/v0.4.0...v0.5.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>
2023-02-16 22:21:55 -05:00
TwiN
6ab8899dc6 fix(condition): Partially support numbers with floating point (#434)
* docs: Don't include Pushover in alerting provider examples

* fix(condition): Partially support numbers with floating point

Fixes #433

Does not add support for decimal numbers, but it converts float64 to int64.
The reason why I'm not just using float64 instead of int64 is because float64 does not support all the numbers that int64 supports, which means this would be a breaking change. Instead, this change at least supports the non-decimal part of floating point numbers.

This is an improvement over the current implementation, as right now, numbers with decimals are just converted to 0 when compared using a non-equal operator
2023-02-15 19:30:29 -05:00
Andrii Vakarev
819abf4263 Helm chart moved from avakarev/gatus-chart to minicloudlabs/helm-charts (#428) 2023-02-13 20:04:46 -05:00
Marc Brugger
6950a080df docs(alerting): Correct newline in docu (#420) 2023-02-13 19:55:19 -05:00
TwiN
7d6923730e fix(config): Support $$ in config for literal $ (#427) 2023-02-11 22:43:13 -05:00
TwiN
542da61215 fix(ci): Use single quotes instead of double quotes 2023-02-09 21:25:06 -05:00
TwiN
45fe7beb6d fix(ci): Add missing asterisk to path-ignore elements with nested structure 2023-02-09 21:23:29 -05:00
TwiN
26611b7793 ci: Run benchmark on every new latest image 2023-02-09 21:15:36 -05:00
dependabot[bot]
a29cf158dd chore(deps): bump golang.org/x/oauth2 from 0.3.0 to 0.4.0 (#423)
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.3.0 to 0.4.0.
- [Release notes](https://github.com/golang/oauth2/releases)
- [Commits](https://github.com/golang/oauth2/compare/v0.3.0...v0.4.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>
2023-02-09 20:30:30 -05:00
TwiN
9d14e3011b docs: Add link to banner 2023-02-08 20:50:30 -05:00
dependabot[bot]
d13998d13d chore(deps): bump modernc.org/sqlite from 1.19.5 to 1.20.3 (#409)
---
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>
2023-02-05 22:39:11 -05:00
TwiN
f6a39f6df0 docs(alerting): Update pushover documentation 2023-02-05 21:58:41 -05:00
Marc Brugger
9e2006910d feat(alerting): Add Pushover sound support (#417) 2023-02-05 21:47:39 -05:00
tesibelda
6e4b88dc6e fix(test): flaky TestStore_InsertCleansUpOldUptimeEntriesProperly (#418) 2023-02-01 22:59:31 -05:00
dependabot[bot]
277e805dbb chore(deps): bump docker/build-push-action from 3 to 4 (#415)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 3 to 4.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v3...v4)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-01 01:16:25 -05:00
TwiN
941c10ca45 chore: Remove vendor folder (#414)
* chore: Remove vendor folder

* chore: Add vendor folder to .gitignore
2023-01-30 22:29:30 -05:00
Kevin Woblick
21f62f362f feat(alerting): Add Pushover provider (#405)
* Add a new Pushover provider (#129)
- Adds new provider named Pushover with corresponding tests
- Adds Pushover as a provider to the configuration and adjusts test accordingly
- Adds Pushover to alerting_test.go, provider.go and type.go
- Updates the readme with configuration details

* Correct import order

* Fix some missing pushover references

* Apply suggestions from code review

* Rename application-key to application-token for Pushover

---------

Co-authored-by: TwiN <twin@linux.com>
2023-01-29 17:32:16 -05:00
TwiN
d75180c341 docs: Clarify what the storage is used for
Resolves #411
2023-01-29 16:47:25 -05:00
dependabot[bot]
a82b883276 chore(deps): bump github.com/coreos/go-oidc/v3 from 3.4.0 to 3.5.0 (#404)
Bumps [github.com/coreos/go-oidc/v3](https://github.com/coreos/go-oidc) from 3.4.0 to 3.5.0.
- [Release notes](https://github.com/coreos/go-oidc/releases)
- [Commits](https://github.com/coreos/go-oidc/compare/v3.4.0...v3.5.0)

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

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-22 16:54:53 -05:00
TwiN
24e207c0c6 fix(metrics): Disable compression on prometheus/client_golang's handler
Fixes #406

Root cause seems to be that promhttp.Handler() has its own gzip compression https://github.com/prometheus/client_golang/issues/622
2023-01-19 23:06:38 -05:00
TwiN
90bb8f7b5f test: Don't run tests in parallel 2023-01-19 01:53:52 -05:00
TwiN
0db92f46da test: Add several tests for numerical conditions 2023-01-19 01:37:21 -05:00
dependabot[bot]
0ffa03f42d chore(deps): bump github.com/TwiN/deepmerge from 0.1.0 to 0.2.0 (#401)
Bumps [github.com/TwiN/deepmerge](https://github.com/TwiN/deepmerge) from 0.1.0 to 0.2.0.
- [Release notes](https://github.com/TwiN/deepmerge/releases)
- [Commits](https://github.com/TwiN/deepmerge/compare/v0.1.0...v0.2.0)

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

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-11 01:05:17 -05:00
TwiN
e61a42220c fix: Log GATUS_CONFIG_FILE deprecation message only if it's non-empty 2023-01-10 19:04:19 -05:00
TwiN
78dccc90e1 fix(#400): Use proper configuration path when iterating over options 2023-01-10 00:24:56 -05:00
TwiN
6bdd3c94fe chore: Add deprecation message for GATUS_CONFIG_FILE 2023-01-09 23:24:20 -05:00
TwiN
4225d22369 docs: Add example of docker-compose with multiple configuration files 2023-01-08 18:34:13 -05:00
TwiN
3059e3e028 feat: Support multiple configuration files (#396)
* Revert "Revert "feat: Support multiple configuration files" (#395)"

This reverts commit 87740e74a6.

* feat: Properly implement support for config directory
2023-01-08 17:53:37 -05:00
TwiN
87740e74a6 Revert "feat: Support multiple configuration files" (#395)
Revert "feat: Support multiple configuration files (#389)"

This reverts commit 8e14302765.
2023-01-07 03:45:43 -05:00
Henning Janßen
8e14302765 feat: Support multiple configuration files (#389)
* Allow configuration to be distributed

* catch iteration errors when collecting config files

* rm unused func

* Fix suffix check for config loading

* test configuration loading

* GATUS_CONFIG_PATH can be a file or a directory now

* Add deprecation note

* Fix cs

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

* cs fixes

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

* cs fixes

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

* cs fixes

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

* cs + rm useless line

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

* Update config/config.go

Co-authored-by: TwiN <twin@linux.com>
2023-01-06 23:46:19 -05:00
TwiN
844f417ea1 docs: Update sponsor list 2023-01-06 20:06:56 -05:00
TwiN
2f7f782f11 docs: Improve documentation and add ghcr Docker image link 2023-01-06 20:04:21 -05:00
dependabot[bot]
37bea336ca chore(deps): bump github.com/TwiN/health from 1.5.0 to 1.6.0 (#394)
Bumps [github.com/TwiN/health](https://github.com/TwiN/health) from 1.5.0 to 1.6.0.
- [Release notes](https://github.com/TwiN/health/releases)
- [Commits](https://github.com/TwiN/health/compare/v1.5.0...v1.6.0)

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

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-04 01:37:24 -05:00
TwiN
616a654b27 fix: Compress everything with Gzip 2022-12-30 21:37:52 -05:00
TwiN
a1c8422c2f ci: Make release also push latest tag 2022-12-23 10:00:17 -05:00
TwiN
947173bf71 fix: Prevent jsonpath from causing panic when body is expected to be array but isn't (#392)
* fix: Prevent jsonpath from causing panic when body is expected to be array but isn't

Fixes #391
2022-12-23 09:55:17 -05:00
TwiN
a81a83e2d4 docs: Update example 2022-12-22 17:38:27 -05:00
Salim B
4599fe4da7 Clarify description of len() function 2022-12-21 22:37:49 -05:00
TwiN
19e90cdf31 test(condition): Add has-key-of-map test case 2022-12-17 23:58:01 -05:00
TwiN
ecc0636a59 feat(alerting): Implement GitHub alerting provider 2022-12-15 23:38:44 -05:00
TwiN
27502acd10 chore: Improve oauth2 configuration error 2022-12-15 23:25:37 -05:00
TwiN
51255e33ea refactor(alerting): Use reflection to retrieve provider based on alert type 2022-12-15 21:37:34 -05:00
TwiN
be0962112e refactor: Remove unnecessary logs 2022-12-15 21:37:34 -05:00
TwiN
dfcea93080 fix(alerting): Use reflection to set invalid providers to nil instead of re-validating on every alert trigger/resolve 2022-12-15 21:37:34 -05:00
dependabot[bot]
a5f135c675 chore(deps): bump github.com/miekg/dns from 1.1.43 to 1.1.50 (#385)
Bumps [github.com/miekg/dns](https://github.com/miekg/dns) from 1.1.43 to 1.1.50.
- [Release notes](https://github.com/miekg/dns/releases)
- [Changelog](https://github.com/miekg/dns/blob/master/Makefile.release)
- [Commits](https://github.com/miekg/dns/compare/v1.1.43...v1.1.50)

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

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-12-14 23:51:19 -05:00
TwiN
9acace7d37 Revert "ci: Allow triggering releases manually"
This reverts commit 184c7f23ad.
2022-12-10 16:29:07 -05:00
TwiN
184c7f23ad ci: Allow triggering releases manually 2022-12-10 16:24:35 -05:00
TwiN
5ce890bbff ci: Don't run tests for workflow changes 2022-12-10 16:24:01 -05:00
TwiN
b0bec5ff94 fix(ci): Don't prevent publish-release-to-ghcr from running 2022-12-10 16:08:46 -05:00
dependabot[bot]
e503dd3861 chore(deps): bump github.com/coreos/go-oidc/v3 from 3.1.0 to 3.4.0 (#383)
Bumps [github.com/coreos/go-oidc/v3](https://github.com/coreos/go-oidc) from 3.1.0 to 3.4.0.
- [Release notes](https://github.com/coreos/go-oidc/releases)
- [Commits](https://github.com/coreos/go-oidc/compare/v3.1.0...v3.4.0)

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

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-12-08 23:20:27 -05:00
TwiN
f2d51f3e50 docs: Update Gatus diagram 2022-12-06 23:03:27 -05:00
TwiN
a1a2fba326 docs: Link Alerting section in valid alert types 2022-12-06 22:36:33 -05:00
TwiN
fdd51869a3 test: Improve condition validation test coverage 2022-12-06 21:57:24 -05:00
TwiN
f6a621da28 chore: Bump module version to v5 2022-12-06 01:41:18 -05:00
TwiN
2346a6ee4f fix!: Enforce mandatory space around condition operator (#382)
BREAKING CHANGE: The comparator in each condition must now be wrapped by a space (e.g. [STATUS] == 200) or the condition will not be valid.
2022-12-06 01:37:05 -05:00
TwiN
741109f25d chore!: Remove deprecated "services" in favor of "endpoints" (#381)
BREAKING CHANGE: `services` has been replaced by `endpoints` in v3.3.0, but backward compatibility has been retained until now. v5.0.0, however, will no longer support `services`, so make sure to update your configuration to use `endpoints` instead.

See #374 for more information
2022-12-06 01:27:31 -05:00
TwiN
d058d7a54b fix!: Default Alert.Enabled to true (#380)
BREAKING CHANGE: It used to default to false, which meant that users had to explicitly set endpoints[].alerts[].enabled to true
2022-12-05 23:15:19 -05:00
TwiN
7dccf5f08c docs: Clarify that alerts still have to be defined when using default-alerts 2022-12-05 22:40:25 -05:00
BondarSlavik
9e46e3972d feat: Add necessary files for PWA (#347)
* feat: PWA Implementation

* feat: PWA Implementation. fix comments

* feat: PWA Implementation. fix comments

Co-authored-by: Viacheslav Bondar <bondarslavik@gmail.com>
2022-12-04 15:11:26 -05:00
TwiN
9fc8374a4d docs: Fix typos 2022-12-03 21:26:46 -05:00
TwiN
1aeb045703 test: Fix failing tests 2022-12-01 20:26:14 -05:00
TwiN
cdec353744 chore(deps): Update sqlite dependencies 2022-12-01 20:19:56 -05:00
TwiN
080563bd4f fix(alerting): Improve ntfy alert 2022-12-01 20:19:11 -05:00
TwiN
bcb565ba37 ci: Remove workflow_dispatch 2022-11-23 18:15:01 -05:00
TwiN
2327854641 ci: Update workflows 2022-11-23 18:12:55 -05:00
TwiN
79eacc5e50 ci: Re-add other platforms 2022-11-22 22:02:38 -05:00
TwiN
048a1d4a88 ci: Only publish for linux/amd64 during tests 2022-11-22 21:53:28 -05:00
TwiN
c09ee0b82f ci: Create workflow to publish latest to GHCR 2022-11-22 21:08:40 -05:00
TwiN
7908eea2df ci: Update workflow dependency name 2022-11-22 20:56:49 -05:00
TwiN
f8140e0d96 fix(alerting): Resolve issue with blank GoogleChat messages (#364)
* debug: Print GoogleChat request body

* chore: Update TwiN/whois to v1.1.0

* fix: Add missing client changes

* test: Improve DNS tests

* chore: Remove accidental change

* docs: Add note for future change to default behavior

* fix(alerting): Don't include URL in Google Chat alert if endpoint type isn't HTTP

Fixes #362
2022-11-22 20:12:26 -05:00
TwiN
4f569b7a0e fix(jsonpath): Properly handle len of object in array, len of int and len of bool (#372) 2022-11-19 17:25:40 -05:00
dependabot[bot]
e9f46c58f8 chore(deps): bump github.com/prometheus/client_golang
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.13.0 to 1.14.0.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.13.0...v1.14.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-11-16 21:58:14 -05:00
TwiN
502e159dca test: Add case for making sure pat() works inside a JSON array 2022-11-16 18:27:28 -05:00
TwiN
cdbf5902c7 build: Remove -mod vendor flag from Dockerfile 2022-11-15 21:58:24 -05:00
TwiN
c7f80f1301 ci: Rename build.yml to test.yml 2022-11-15 21:51:40 -05:00
TwiN
eb4e22e76b chore: Replace 1.1.1.1 by 8.8.8.8 everywhere due to 1.1.1.1 being unreliable 2022-11-15 21:50:54 -05:00
TwiN
f37a0ef2d7 test: Replace DNS 1.1.1.1 by 8.8.8.8 2022-11-15 21:50:54 -05:00
TwiN
114b78c75c test: Replace DNS 1.1.1.1 by 8.8.8.8 2022-11-15 21:50:54 -05:00
TwiN
d24ff5bd07 refactor: Move whois to client package and implement caching 2022-11-15 21:50:54 -05:00
TwiN
c172e733be chore(deps): Update TwiN/gocache to v2.2.0 2022-11-15 21:50:54 -05:00
TwiN
f1ce83c211 chore(deps): Update TwiN/whois to v1.1.0 2022-11-15 00:25:02 -05:00
TwiN
64f4dac705 fix: Wrap error properly (%s -> %w) 2022-11-12 14:56:25 -05:00
dependabot[bot]
861c443842 chore(deps): bump loader-utils from 1.4.0 to 1.4.2 in /web/app (#365)
Bumps [loader-utils](https://github.com/webpack/loader-utils) from 1.4.0 to 1.4.2.
- [Release notes](https://github.com/webpack/loader-utils/releases)
- [Changelog](https://github.com/webpack/loader-utils/blob/v1.4.2/CHANGELOG.md)
- [Commits](https://github.com/webpack/loader-utils/compare/v1.4.0...v1.4.2)

---
updated-dependencies:
- dependency-name: loader-utils
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-11-12 14:51:39 -05:00
TwiN
b801cc5801 fix(alerting): Prevent empty keyValue from being marshalled 2022-11-10 22:12:20 -05:00
TwiN
f1711b5c0b fix(alerting): Omit empty KeyValue parameters when marshalling to JSON 2022-11-10 22:12:20 -05:00
TwiN
0ebd6c7a67 chore: Clean up old commented code 2022-11-10 22:12:20 -05:00
TwiN
967124eb43 fix(alerting): Resolve GoogleChat issue with bad payload when condition has " in it
Fixes #362
2022-11-10 22:12:20 -05:00
Ian Chen
fa47a199e5 feat: support SCTP & UDP as endpoint type (#352)
* feat: support SCTP & UDP as endpoint type

* update README

* modify endpoint type test for sctp & udp
2022-11-09 19:22:13 -05:00
TwiN
1f84f2afa0 fix: Make sure len([BODY]) works if the body is a JSON array
Fixes #359
2022-11-03 20:50:40 -04:00
dependabot[bot]
ed3683cb32 chore(deps): bump github.com/lib/pq from 1.10.3 to 1.10.7
Bumps [github.com/lib/pq](https://github.com/lib/pq) from 1.10.3 to 1.10.7.
- [Release notes](https://github.com/lib/pq/releases)
- [Commits](https://github.com/lib/pq/compare/v1.10.3...v1.10.7)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-11-02 19:40:52 -04:00
dependabot[bot]
6e92c0eb40 chore(deps): bump github.com/prometheus/client_golang
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.11.0 to 1.13.0.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.11.0...v1.13.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-11-01 23:48:39 -04:00
TwiN
cd927f630b ci: Remove unnecessary dependabot labels 2022-11-01 23:41:35 -04:00
TwiN
c6c9bc8fa5 ci: Update dependabot labels 2022-11-01 23:36:40 -04:00
dependabot[bot]
a3facc3887 chore(deps): bump docker/setup-qemu-action from 1 to 2
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 1 to 2.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v1...v2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-11-01 23:34:49 -04:00
dependabot[bot]
991d7e876d chore(deps): bump docker/setup-buildx-action from 1 to 2
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 1 to 2.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v1...v2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-11-01 23:34:40 -04:00
dependabot[bot]
3b7fb083ca chore(deps): bump codecov/codecov-action from 2.1.0 to 3.1.1
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 2.1.0 to 3.1.1.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v2.1.0...v3.1.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-11-01 23:34:14 -04:00
dependabot[bot]
ebdf5bde49 chore(deps): bump github.com/TwiN/health from 1.4.0 to 1.5.0
Bumps [github.com/TwiN/health](https://github.com/TwiN/health) from 1.4.0 to 1.5.0.
- [Release notes](https://github.com/TwiN/health/releases)
- [Commits](https://github.com/TwiN/health/compare/v1.4.0...v1.5.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-11-01 23:33:21 -04:00
TwiN
d4983733f5 ci: Add dependabot.yml 2022-11-01 23:30:35 -04:00
TwiN
fed021826a ui: Generate static assets 2022-11-01 00:45:04 -04:00
TwiN
8f9eca51c0 ui: Show "now" if the pretty time difference is less than 500ms 2022-11-01 00:44:41 -04:00
TwiN
e13730f119 docs: Fix table format 2022-11-01 00:43:42 -04:00
David Wheatley
22d74a5ea8 feat(ui): Allow configuring meta description (#342)
* feat: add description prop to HTML template

* feat: add desc property to backend config validation

* test: add desc field to ui config test

* chore: add default description text

* test: add test for description default

* docs: add description config option explanation

* Update README.md

* Update config/ui/ui_test.go

Co-authored-by: TwiN <twin@linux.com>
2022-11-01 00:33:19 -04:00
TwiN
fe4d9821f3 fix(alerting): Fix Discord alert payload missing required field 2022-10-20 20:23:10 -04:00
TwiN
d01a5d418b test: Improve error readability 2022-10-20 20:23:10 -04:00
TwiN
34f8cd1eca docs: Update screenshot of Mattermost alerts 2022-10-20 20:23:10 -04:00
TwiN
d101c17136 fix(alerting): Resolve Mattermost issue with bad payload when condition has " in it 2022-10-20 20:23:10 -04:00
TwiN
ade3d05983 fix(alerting): Use required Field.Title in Slack provider even if it's not enforced
Just to prevent future issues
2022-10-20 20:23:10 -04:00
TwiN
fbab0ef7ca fix(alerting): Resolve Discord issue with bad payload when condition has " in it 2022-10-20 20:23:10 -04:00
TwiN
9121ec1cc8 fix(alerting): Resolve Matrix issue with bad payload when condition has " in it 2022-10-20 20:23:10 -04:00
TwiN
6ddf1258e5 fix(alerting): Resolve PagerDuty issue with bad payload when alert description has " in it 2022-10-20 20:23:10 -04:00
TwiN
490610ccfd fix(alerting): Resolve Teams issue with bad payload when condition has " in it 2022-10-20 20:23:10 -04:00
TwiN
0eb6958085 fix(alerting): Resolve Telegram issue with bad payload when condition has " in it 2022-10-20 20:23:10 -04:00
TwiN
d20a41c7a7 fix(alerting): Make sure to close response body 2022-10-20 20:23:10 -04:00
TwiN
4c18e0d602 chore(alerting): Remove unnecessary cast 2022-10-20 20:23:10 -04:00
TwiN
da24b7e8ac fix(alerting): Resolve Slack issue with bad payload when condition has " in it 2022-10-20 20:23:10 -04:00
TwiN
c619066e25 docs: Swap conditions/dark screenshots 2022-10-19 18:00:53 -04:00
TwiN
3688dd6e6f chore: Clean up unused assets 2022-10-19 17:53:43 -04:00
TwiN
fc778300be ci: Update Go to 1.19 2022-10-19 17:52:53 -04:00
TwiN
df560ad872 ui: Replace and reposition old icons by SVG icons (#349) 2022-10-19 17:38:32 -04:00
TwiN
de9c366777 docs: Add Keeping your configuration small section 2022-10-19 16:51:14 -04:00
TwiN
6a5fec2c55 perf: Improve jsonpath speed (#348) 2022-10-19 15:52:20 -04:00
TwiN
01d2ed3f02 ui: Make it more obvious that the response time can be toggled between avg and min-max 2022-10-17 00:49:33 -04:00
TwiN
92b85ee1ab ui: Improve login page 2022-10-17 00:48:40 -04:00
TwiN
a789deb8c2 ui: Render div instead of a when link is blank (#346)
Fixes #343
2022-10-17 00:03:15 -04:00
David Wheatley
e5a94979dd fix: add Google Chat to list of alert types when determining valid providers (#341) 2022-10-15 17:56:38 -04:00
TwiN
3c0ea72a5c ci: Bump docker/build-push-action to v3 2022-10-10 22:11:19 -04:00
TwiN
d17e893131 docs: Uniformize number of newlines between each header 2022-10-10 22:05:48 -04:00
TwiN
7ea34ec8a8 docs: Add link for sponsoring below description 2022-10-10 21:49:55 -04:00
TwiN
f6b99f34db ci: Add missing concurrency.group parameter 2022-10-09 23:08:52 -04:00
TwiN
37495ac3f3 ci: Prevent publish-latest workflow from running concurrently 2022-10-09 23:02:05 -04:00
TwiN
557f696f88 fix(alerting): Encode messagebird request body using json.Marshal 2022-10-09 22:59:18 -04:00
TwiN
c86492dbfd fix(alerting): Encode ntfy request body using json.Marshal
Relevant: #336
2022-10-09 22:58:18 -04:00
TwiN
8a4db600c9 test: Add tests for endpoint display name 2022-10-09 21:34:36 -04:00
TwiN
02879e2645 ci: Bump docker/build-push-action to v3 and add "stable" tag 2022-10-09 21:33:55 -04:00
TwiN
00b56ecefd feat: Bundle assets in binary using go:embed (#340)
Fixes #47
2022-10-09 21:33:31 -04:00
TwiN
47dd18a0b5 test(alerting): Add coverage for ntfy's request body 2022-10-09 16:45:01 -04:00
TwiN
1a708ebca2 test(alerting): Fix tests following change to defaults 2022-10-09 16:45:01 -04:00
TwiN
5f8e62dad0 fix(alerting): Make priority and url optional for ntfy 2022-10-09 16:45:01 -04:00
TwiN
b74f7758dc docs(alerting): Document how to configure ntfy alerts 2022-10-09 16:45:01 -04:00
TwiN
899c19b2d7 fix: Swap tag for resolved and triggered 2022-10-09 16:45:01 -04:00
TwiN
35038a63c4 feat(alerting): Implement ntfy provider
Closes #308

Work remaining:
- Add the documentation on the README.md
- Test it with an actual Ntfy instance (I've only used https://ntfy.sh/docs/examples/#gatus as a reference; I haven't actually tested it yet)
2022-10-09 16:45:01 -04:00
TwiN
7b2af3c514 chore: Fix alerting provider order 2022-10-09 16:45:01 -04:00
TwiN
4ab7428599 chore: Format code 2022-10-09 16:45:01 -04:00
TwiN
be88af5d48 chore: Update Go to 1.19 + Update dependencies 2022-09-21 20:16:00 -04:00
TwiN
5bb3f6d0a9 refactor: Use %w instead of %s for formatting errors 2022-09-20 21:54:59 -04:00
TwiN
17c14a7243 docs(alerting): Provide better Matrix examples 2022-09-19 22:08:39 -04:00
TwiN
f44d4055e6 refactor(alerting): Clean up Matrix code 2022-09-19 22:08:18 -04:00
TwiN
38054f57e5 feat: Set minimum interval for endpoints with [DOMAIN_EXPIRATION] to 5m 2022-09-15 21:23:14 -04:00
TwiN
33ce0e99b5 chore: Fix typo in deprecation message 2022-09-15 17:41:24 -04:00
TwiN
b5e6466c1d docs(security): Link "Securing Gatus with OIDC using Auth0" article 2022-09-09 22:59:13 -04:00
TwiN
f89ecd5c64 fix(ui): Decrease size of error message 2022-09-09 22:58:45 -04:00
TwiN
e434178a5c test(alerting): Make sure ClientConfig is set after IsValid() call in Telegram provider 2022-09-07 19:02:30 -04:00
Lukas Schlötterer
7a3ee1b557 feat(alerting): add client config for telegram (#324) 2022-09-07 18:50:59 -04:00
TwiN
e51abaf5bd chore: Add check-domain-expiration to placeholder configuration file 2022-09-07 18:19:57 -04:00
TwiN
46d6d6c733 test(alerting): Improve coverage for custom alerting provider 2022-09-07 18:19:20 -04:00
TwiN
d9f86f1155 fix(storage): Default domain_expiration to 0 for SQL when the column doesn't already exist
This will prevent temporary issues with the parsing of old results that would otherwise
have a value of NULL for domain_expiration

Fixes an issue introduced by #325
2022-09-07 18:18:26 -04:00
TwiN
01484832fc feat: Add [DOMAIN_EXPIRATION] placeholder for monitoring domain expiration using WHOIS (#325)
* feat: Add [DOMAIN_EXPIRATION] placeholder for monitoring domain expiration using WHOIS

* test: Fix issue caused by possibility of millisecond elapsed during previous tests

* test: Fix test with different behavior based on architecture

* docs: Revert accidental change to starttls example

* docs: Fix mistake in comment for Condition.hasIPPlaceholder()
2022-09-06 21:22:02 -04:00
TwiN
4857b43771 test: Improve coverage for Endpoint.Type() 2022-09-01 21:12:29 -04:00
TwiN
52d7cb6f04 ux: Improve endpoint validation by checking type on start 2022-09-01 21:12:29 -04:00
TwiN
5c6bf84106 ux: Improve error message when endpoint is invalid 2022-09-01 21:12:29 -04:00
TwiN
c84ae1cd55 refactor: Remove unused file 2022-09-01 21:12:29 -04:00
TwiN
daf8e3a16f test(chart): Improve coverage for response time charts 2022-08-30 20:00:04 -04:00
TwiN
df719958cf chore(remote): Log message about feature being a candidate for removal 2022-08-23 21:38:50 -04:00
TwiN
2be81b8e1a docs(remote): Add "Remote instances (EXPERIMENTAL)" section 2022-08-22 18:26:36 -04:00
TwiN
4bed86dec9 ui(event): Add divider between each event 2022-08-18 20:24:52 -04:00
TwiN
072cf20cc6 chore(dependencies): Update tailwindcss to 3.1.8 2022-08-18 20:24:08 -04:00
TwiN
cca421e283 refactor(storage): Remove TODO comment about writeThroughCache 2022-08-18 19:29:39 -04:00
TwiN
a044f1d274 docs(storage): Add documentation for storage.caching 2022-08-18 19:29:39 -04:00
TwiN
9de6334f21 feat(storage): Add optional write-through cache to sql store 2022-08-18 19:29:39 -04:00
TwiN
f01b66f083 refactor(storage): Remove decommissioned path for memory store (#313) 2022-08-11 20:42:56 -04:00
TwiN
262d436533 ci: Remove paths-ignore since workflow_run doesn't support it 2022-08-11 20:33:25 -04:00
TwiN
b8ab17eee1 ci: Decrease timeout-minutes to 20 2022-08-11 20:31:36 -04:00
TwiN
7bbd7bcee3 ci: Stop publish-latest from being triggered by changes in .github folder 2022-08-11 20:29:33 -04:00
TwiN
4865d12147 chore(ci): Remove redundant input
Since we now get to pick from what branch to execute the workflow, it is no longer necessary to have a branch input.
2022-08-11 20:27:48 -04:00
TwiN
0713ca1c1a chore(ci): Remove useless "description" parameter 2022-08-11 20:25:10 -04:00
TwiN
dce202d0be feat(ci): Add publish-experimental workflow 2022-08-11 20:24:23 -04:00
TwiN
4673d147db chore(ci): Update docker/login-action to v2 2022-08-11 20:17:17 -04:00
TwiN
0943c45ae6 test(badge): Add test cases for custom response-time badge thresholds 2022-08-10 21:26:36 -04:00
TwiN
798c4248ff refactor(badge): Fix formatting 2022-08-10 21:09:22 -04:00
Jesibu
1bce4e727e feat(api): Configurable response time badge thresholds (#309)
* recreated all changes for setting thresholds on Uptime Badges

* Suggestion accepted: Update core/ui/ui.go

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

* Suggestion accepted: Update core/ui/ui.go

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

* implemented final suggestions by Twin

* Update controller/handler/badge.go

* Update README.md

* test: added the suggestons to set the UiConfig at another line

Co-authored-by: TwiN <twin@linux.com>
2022-08-10 21:05:34 -04:00
TwiN
1aa94a3365 feat(remote): Implement lazy distributed feature (#64)
THIS IS AN EXPERIMENTAL FEATURE/IMPLEMENTATION, AND IT MAY BE REMOVED IN THE FUTURE.

Note that for now, it will be an undocumented feature.
2022-07-28 20:29:29 -04:00
TwiN
319f460553 docs: Update GetHTTPClient comment 2022-07-28 20:29:29 -04:00
TwiN
7daf2b5cac legal: Revert change to copyright appendix
relevant: #203
2022-07-28 17:55:23 -04:00
TwiN
f0fc275f67 legal: Update copyright dates 2022-07-28 09:20:42 -04:00
Kalissaac
04a682eddc style(alerting): Alphabetically sort Matrix provider 2022-07-20 19:00:12 -04:00
Kalissaac
2fb807632c style(alerting): Add comments and rename character bytes constant 2022-07-20 19:00:12 -04:00
Kalissaac
4b339bca37 fix(alerting): Update Matrix send endpoint to v3 2022-07-20 19:00:12 -04:00
Kalissaac
09c3a6c72b fix(alerting): Reuse MatrixProviderConfig struct 2022-07-20 19:00:12 -04:00
Kalissaac
755c8bb43a fix(alerting): Alphabetically sort Matrix provider 2022-07-20 19:00:12 -04:00
Kalissaac
9d4a639f31 test(alerting): Add Matrix tests 2022-07-20 19:00:12 -04:00
Kalissaac
60e6b2b039 docs(alerting): Add Matrix alerts to README 2022-07-20 19:00:12 -04:00
Kalissaac
37f3f964ea feat(alerts): Add Matrix alert provider 2022-07-20 19:00:12 -04:00
TwiN
4a1a8ff380 ci: Increase timeout-minutes to 60 2022-07-18 20:45:42 -04:00
TwiN
6787fed062 docs: Update feedback/question contact 2022-07-14 18:13:46 -04:00
2196 changed files with 9867 additions and 2877551 deletions

View File

@@ -4,4 +4,5 @@ Dockerfile
.idea
.git
web/app
*.db
*.db
testdata

View File

@@ -7,10 +7,9 @@ endpoints:
- name: example
url: https://example.org
interval: 1m
alerts:
- type: mattermost
enabled: true
description: "health check failed 3 times in a row"
send-on-resolved: true
conditions:
- "[STATUS] == 200"
alerts:
- type: mattermost
description: "health check failed 3 times in a row"
send-on-resolved: true

View File

@@ -0,0 +1,21 @@
endpoints:
- name: check-if-api-is-healthy
group: backend
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 1000"
- name: check-if-website-is-pingable
url: "icmp://example.org"
interval: 1m
conditions:
- "[CONNECTED] == true"
- name: check-domain-expiration
url: "https://example.org"
interval: 6h
conditions:
- "[DOMAIN_EXPIRATION] > 720h"

View File

@@ -0,0 +1,8 @@
endpoints:
- name: make-sure-html-rendering-works
group: frontend
url: "https://example.org"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY] == pat(*<h1>Example Domain</h1>*)" # Check for header in HTML page

View File

@@ -0,0 +1,8 @@
metrics: true
debug: false
ui:
header: Example Company
link: https://example.org
buttons:
- name: "Home"
link: "https://example.org"

View File

@@ -0,0 +1,10 @@
version: "3.8"
services:
gatus:
image: twinproduction/gatus:latest
ports:
- "8080:8080"
environment:
- GATUS_CONFIG_PATH=/config
volumes:
- ./config:/config

View File

@@ -26,7 +26,7 @@ endpoints:
- "[STATUS] == 200"
- name: example-dns-query
url: "1.1.1.1" # Address of the DNS server to use
url: "8.8.8.8" # Address of the DNS server to use
interval: 5m
dns:
query-name: "example.com"

View File

@@ -26,7 +26,7 @@ endpoints:
- "[STATUS] == 200"
- name: example-dns-query
url: "1.1.1.1" # Address of the DNS server to use
url: "8.8.8.8" # Address of the DNS server to use
interval: 5m
dns:
query-name: "example.com"

View File

@@ -1 +1 @@
<mxfile host="app.diagrams.net" modified="2021-09-12T22:49:28.336Z" agent="5.0 (Windows)" etag="r9FJ6Bphqwq-LaTO-jp3" version="15.0.6" type="device"><diagram id="FBbfVOMCjf6Z2LK8Yagy" name="Page-1">7Vtdb5swFP01edwEOCHJY5t03aR1q9RWbR9d8MCb4TJj8rFfP9OYBOI2YVrTi5S8RPjaxOack2sf7PTIJFlcSprFVxAy0fOccNEj057nDceu/iwDy1WADEarQCR5uAq5m8AN/8NM0DHRgocsbzRUAELxrBkMIE1ZoBoxKiXMm81+gGj2mtGIWYGbgAo7es9DFZuo5zibis+MR3HV9dgzNQmtWptAHtMQ5rUQueiRiQRQq6tkMWGiBK8CZnXfp1dq1yOTLFVtbvj2/UGwZFq4GpXZfXYJ8Yh9MN8yo6IwT2wGq5YVBCzUiJgiSBVDBCkVF5vouYQiDVnZjaNLmzZfATIddHXwJ1NqaeilhQIdilUiTO2qz7KjV5/NhHIoZMB2PJAZv6IyYmrXgw/XFGjtMkiYkkt9o2SCKj5rDoQaFUXrdhuc9YWB+h9g9yzYteKk4mlkwb8Bt0RqHnPFbjL6jMFc/+ReAnKmv4otdkNpP7m5YTQ2ejW/WM8xg5039L+KxTXp+86BwCIYktRoyeWDuf+58FgWPg6q4nRRr5wuTekNpey3lPIIU8n9Ezk7yfEwyRngkrPh47Fe1x1yiIPJjm9NAnOqgjgE/Elg6DYnAUKwJ4HhkeaZUds842NKeWRJOVcgy7U1tpK3lzN9H1vJYwurI5W2O2yrbdQ0XaXCEz+vNhyj8uOe+NnDD6pDcE/5bV9+e2UKeyd+jtVft+eHoPJzrBa7PT+oJttFdtnd54fg5jf/xM8efnB/P2aYNUOUSZjxkEmLuD1+sWkue//vHl1n2LW34a7tta9Ynmuv/cRliI8Y2Xp15A5H7RDzDoaY7bhvBA1+dQ8rj2BjVX1xDatrLS05LdSyg3g56Hi9tIHqC93techn+jIqL28ZTfIqrvupVXUQVB8dVM9OcVQpJhPIVfcAc/tjbMCIBdgtEyySNOkeXN4YXV/H6qi81ruWuKcjjtVRtecHd8//WN+It+aHoL4R9+yd5XI7Dn8zzrJT+Ltxnu0+899CA9G9ibM/agnW4SZO23vyNGEJyA6ag/7AwYZrjJsnm+dvnPfLk8RpmSdRDy0Q2+vmTM54wPISiDKsRwEpetrcPo2D/xKK2LZ3yvMAZNi9PLBGBi0PVIen6vZszgWH7oFFqvkQDyzk7Ui0Q4uk3zJpoq79q1HWtByAhgSEeOF9PfZpr8EQPVMeq5clrb1SH1XPtle6+4Ku4+0ZvwM6tn0ShrDfUqBtj9YS3FWq7bnu8hdSbR7TrLwsEnEWaLevwSolyAONLn1i4hpy/ryaJdMnUAqSWoMzwaOyQsGWlKFQgqdssv53nfM2+vbJfn2Td5W3vUeYQa4iWXqBri3SBtWRrQMs0nRx85fE57raHzvJxV8=</diagram></mxfile>
<mxfile host="app.diagrams.net" modified="2022-12-07T04:00:31.242Z" agent="5.0 (Windows)" etag="4-CttOJPoGYGt_6RMEMf" version="20.5.3" type="device"><diagram id="oCf8YAkR0GE5Fy88uv5t" name="Page-1">7Vxbc6M2FP41frQHxDWPuW13Z9JpZtKddPdNAQWrBcQKObH76ytsyVxkHOxgC6bOS+BICPl856ZzJCbWbbL8jcJs/jsJUTwBRricWHcTAEzDBfxfQVkJiu9ZG0pEcShoJeEJ/4vko4K6wCHKax0ZITHDWZ0YkDRFAavRIKXkvd7tlcT1t2YwQgrhKYCxSn3GIZsLKjCMsuErwtFcvvoKiJYEyt6CkM9hSN4rJOt+Yt1SQtjmKlneorhgn2TM5rkvLa3bmVGUsi4PfMHmwwP9av/5bDqPP9Obbw/4j6kn5sZW8hejkDNA3BLK5iQiKYzvS+oNJYs0RMWoBr8r+zwQknGiyYl/I8ZWAk24YIST5iyJRSufMF39JZ5f3/wobmaOvL1bVhvvVuLulaRMDGr6/H4z92LCrSwRpJwsaID28EHKFqQRYnv6uVvguMwjkiA+P/4cRTFk+K0+DyhkL9r2K9HhFwKgA8AS477BeCHe9D1HVEGQC1pWXC6S+DpghHJOvSHKMJfqB/iC4keSY4ZJyru8EMZIUulwHeOoaGAFlFXMyILFOEW3Wz0ztgAUz6LlfghUlokHHKEdwkA4UvHfS22zBGle0TPXOBGPry4KUQp6B4XwdSoEsBSNmAA3ZoI1NRzdXwsiG6b5mmnXvINpZ8s152Q7v4qK/zgvWLnMSM6RBcbLSo7MZ7oZfNNvp7ys9ayOMRSaFXAoEN2hcgkOw404IT49+LIer0A5Izhla9Y5NxPnrhX3HXoo3KIYrPRFVYloV4NWpZ0aM9Mz7c1YnaEWwz0WP6ccywQ1AzCV93IE8vqacwlsisp2UsdLj/t54fFbhEcOlGcwlbTv3yoSVG3oKlh1M/M+xww9ZXCtxe88/KqL21pExLNmq3x0t9Om/bGhNsE5LbWpukPKB8boDRWqG0IGi4iPcv+m1aSXVvxHte38Jt3vaNJNY7ckHKbo15TCVaWDsGKtdsB26wJmXzXC2cP684vNDHo1Gb4icjwgYpTEMaJ5DwpbAb5vBbaNoSkwcMcaa9k9Lz6MjpoJHK3Rlvd5hwn2RVs4yWKUFPHRoCOuEv0+Iy7QsrQsQy4LeE4jVuonAvMbozZGOF0EJgW/IlFPfMFa5GRGZUstX7ctNR2Fk6i4hgypfun/Gf7IhcXH8Y+t08pKbRyfV9SGl6UVr0vG6FC8Pru++FwUo/qcjAfvOGfrECTVHV34a/FgUGRo96ZZjw42zI/zO7ZfDzZAL7HGtJ7tOV+yR76pAvszZME8JNGwY41m4mWbMdcWa1haLN4pLZfV0XJZWrPdpprtvo4Lo5MOXISb4fJWXPWFy7bCyvs0LFNUw+Vl0xzo56X8DRVeMoqjaFdGbOS+dD80ltWw1KYxMwzbND3X9gzHd+zDvSUfoPZn197gnst7WpYOkz/AIBc4HV0FaImwzuMqLPuC12F4WS26fSa8nAteB+KldxGpptt6qx0XGVB0eE3YPCw22ZvE7u7zXHtomVBwdTpoEpQQ2rkscXTcCPrBxm9i43XEBpxs5ajmXnrDJiM5i2iR7R4nOralHR01kO8NnfxXzBk8WmwM3dg4ow3oSkh6CRCsrrtYbK1VHGe0AZ02vLTubXBGuxdFG15at8I7oz24oA0vTyteo61qa8NLa63BGW1VWxteWhN+cjl9wasrXo7WBJJrXvA6EC+tCVp5kPWCV2e8gFa8Rluw0oaX1l10lnqy4pGSNxzuON96dM5771G543Pe+kvwdg/nIC878z8E3vHcOvD2yQBV96cEi5ztOMo2tFRpV104WabUVktzIc4DQsPB867rzr7T8U49EosSiOPBc65r1fF0nFPPRkWERDEK5pANnn1m17OAp+Of6v8TyCheDp93tnbeqRVvzjvuWBOSj0D2tFelpdmt8C9lr6vBcw7or0qqFePig0g0XLARsE97wd0BCvvyGAb/DJ91rnbWqSsexILZbDZ83l3p5p2ruRZz/InQnnMjrlhlDfurX57qnpqLe/nZPLl2t3Zsj6KE8dVv5+1RZ16571W2o4+5uS01NDF8ccrtCtS0c9rPR42m9X36Z/yokae5sDCY496dlVtrndVT/VipqXpV8jQfFvP2e0tjBky/oTzW51RSDnO2wzKumrx7QsGC4j6D4ubZMqMVqu6hCvDqoYrbdWl7RGKb35afPt1wvvyErHX/Hw==</diagram></mxfile>

BIN
.github/assets/gatus-diagram.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 48 KiB

13
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,13 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
labels: ["dependencies"]
schedule:
interval: "daily"
- package-ecosystem: "gomod"
directory: "/"
open-pull-requests-limit: 1
labels: ["dependencies"]
schedule:
interval: "daily"

View File

@@ -1,5 +1,9 @@
name: benchmark
on:
workflow_run:
workflows: [publish-latest]
branches: [master]
types: [completed]
workflow_dispatch:
inputs:
repository:
@@ -16,11 +20,11 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/setup-go@v3
- uses: actions/setup-go@v4
with:
go-version: 1.18
repository: "${{ github.event.inputs.repository }}"
ref: "${{ github.event.inputs.ref }}"
go-version: 1.19
repository: "${{ github.event.inputs.repository || 'TwiN/gatus' }}"
ref: "${{ github.event.inputs.ref || 'master' }}"
- uses: actions/checkout@v3
- name: Benchmark
run: go test -bench=. ./storage/store

View File

@@ -0,0 +1,26 @@
name: publish-experimental
on: [workflow_dispatch]
jobs:
publish-experimental:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Get image repository
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Login to Docker Registry
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
platforms: linux/amd64
pull: true
push: true
tags: ${{ env.IMAGE_REPOSITORY }}:experimental

View File

@@ -0,0 +1,38 @@
name: publish-latest-to-ghcr
on:
workflow_run:
workflows: [test]
branches: [master]
types: [completed]
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
jobs:
publish-latest-to-ghcr:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
permissions:
contents: read
packages: write
timeout-minutes: 60
steps:
- uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Get image repository
run: echo IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Login to Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
pull: true
push: true
tags: ${{ env.IMAGE_REPOSITORY }}:latest

View File

@@ -1,33 +1,34 @@
name: publish-latest
on:
workflow_run:
workflows: ["build"]
workflows: [test]
branches: [master]
types: [completed]
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
jobs:
publish-latest:
name: publish-latest
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
timeout-minutes: 45
timeout-minutes: 60
steps:
- uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Get image repository
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Login to Docker Registry
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push docker image
uses: docker/build-push-action@v2
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
pull: true
push: true
tags: |
${{ env.IMAGE_REPOSITORY }}:latest
tags: ${{ env.IMAGE_REPOSITORY }}:latest

View File

@@ -0,0 +1,37 @@
name: publish-release-to-ghcr
on:
release:
types: [published]
jobs:
publish-release-to-ghcr:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
timeout-minutes: 60
steps:
- uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Get image repository
run: echo IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Get the release
run: echo RELEASE=${GITHUB_REF/refs\/tags\//} >> $GITHUB_ENV
- name: Login to Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
pull: true
push: true
tags: |
${{ env.IMAGE_REPOSITORY }}:${{ env.RELEASE }}
${{ env.IMAGE_REPOSITORY }}:stable
${{ env.IMAGE_REPOSITORY }}:latest

View File

@@ -6,27 +6,29 @@ jobs:
publish-release:
name: publish-release
runs-on: ubuntu-latest
timeout-minutes: 45
timeout-minutes: 60
steps:
- uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Get image repository
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Get the release
run: echo RELEASE=${GITHUB_REF/refs\/tags\//} >> $GITHUB_ENV
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Login to Docker Registry
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push docker image
uses: docker/build-push-action@v2
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
pull: true
push: true
tags: |
${{ env.IMAGE_REPOSITORY }}:${{ env.RELEASE }}
${{ env.IMAGE_REPOSITORY }}:stable
${{ env.IMAGE_REPOSITORY }}:latest

View File

@@ -1,31 +1,33 @@
name: build
name: test
on:
pull_request:
paths-ignore:
- '*.md'
- '.examples/**'
push:
branches:
- master
paths-ignore:
- '*.md'
- '.github/**'
- '.examples/**'
jobs:
build:
name: build
test:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/setup-go@v3
- uses: actions/setup-go@v4
with:
go-version: 1.18
go-version: 1.19
- uses: actions/checkout@v3
- name: Build binary to make sure it works
run: go build -mod vendor
run: go build
- name: Test
# We're using "sudo" because one of the tests leverages ping, which requires super-user privileges.
# As for the 'env "PATH=$PATH" "GOROOT=$GOROOT"', we need it to use the same "go" executable that
# was configured by the "Set up Go" step (otherwise, it'd use sudo's "go" executable)
run: sudo env "PATH=$PATH" "GOROOT=$GOROOT" go test -mod vendor ./... -race -coverprofile=coverage.txt -covermode=atomic
run: sudo env "PATH=$PATH" "GOROOT=$GOROOT" go test ./... -race -coverprofile=coverage.txt -covermode=atomic
- name: Codecov
uses: codecov/codecov-action@v2.1.0
uses: codecov/codecov-action@v3.1.4
with:
files: ./coverage.txt

13
.gitignore vendored
View File

@@ -1,5 +1,18 @@
# IDE
*.iml
.idea
.vscode
# OS
.DS_Store
# JS
node_modules
# Go
/vendor
# Misc
*.db
*.db-shm
*.db-wal

View File

@@ -3,7 +3,7 @@ FROM golang:alpine as builder
RUN apk --update add ca-certificates
WORKDIR /app
COPY . ./
RUN CGO_ENABLED=0 GOOS=linux go build -mod vendor -a -installsuffix cgo -o gatus .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o gatus .
# Run Tests inside docker image if you don't have a configured go environment
#RUN apk update && apk add --virtual build-dependencies build-base gcc
@@ -13,7 +13,6 @@ RUN CGO_ENABLED=0 GOOS=linux go build -mod vendor -a -installsuffix cgo -o gatus
FROM scratch
COPY --from=builder /app/gatus .
COPY --from=builder /app/config.yaml ./config/config.yaml
COPY --from=builder /app/web/static ./web/static
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
ENV PORT=8080
EXPOSE ${PORT}

View File

@@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2021 TwiN
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.

View File

@@ -7,7 +7,7 @@ install:
go build -mod vendor -o $(BINARY) .
run:
GATUS_CONFIG_FILE=./config.yaml ./$(BINARY)
GATUS_CONFIG_PATH=./config.yaml ./$(BINARY)
clean:
rm $(BINARY)

767
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,8 @@ type Alert struct {
// Enabled defines whether the alert is enabled
//
// Use Alert.IsEnabled() to retrieve the value of this field.
//
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
// or not for provider.ParseWithDefaultAlert to work.
Enabled *bool `yaml:"enabled,omitempty"`
@@ -77,9 +79,10 @@ func (alert Alert) GetDescription() string {
}
// IsEnabled returns whether an alert is enabled or not
// Returns true if not set
func (alert Alert) IsEnabled() bool {
if alert.Enabled == nil {
return false
return true
}
return *alert.Enabled
}

View File

@@ -52,8 +52,8 @@ func TestAlert_ValidateAndSetDefaults(t *testing.T) {
}
func TestAlert_IsEnabled(t *testing.T) {
if (Alert{Enabled: nil}).IsEnabled() {
t.Error("alert.IsEnabled() should've returned false, because Enabled was set to nil")
if !(Alert{Enabled: nil}).IsEnabled() {
t.Error("alert.IsEnabled() should've returned true, because Enabled was set to nil")
}
if value := false; (Alert{Enabled: &value}).IsEnabled() {
t.Error("alert.IsEnabled() should've returned false, because Enabled was set to false")

View File

@@ -14,18 +14,36 @@ const (
// TypeEmail is the Type for the email alerting provider
TypeEmail Type = "email"
// TypeGitHub is the Type for the github alerting provider
TypeGitHub Type = "github"
// TypeGitLab is the Type for the gitlab alerting provider
TypeGitLab Type = "gitlab"
// TypeGoogleChat is the Type for the googlechat alerting provider
TypeGoogleChat Type = "googlechat"
// TypeMatrix is the Type for the matrix alerting provider
TypeMatrix Type = "matrix"
// TypeMattermost is the Type for the mattermost alerting provider
TypeMattermost Type = "mattermost"
// TypeMessagebird is the Type for the messagebird alerting provider
TypeMessagebird Type = "messagebird"
// TypeNtfy is the Type for the ntfy alerting provider
TypeNtfy Type = "ntfy"
// TypeOpsgenie is the Type for the opsgenie alerting provider
TypeOpsgenie Type = "opsgenie"
// TypePagerDuty is the Type for the pagerduty alerting provider
TypePagerDuty Type = "pagerduty"
// TypePushover is the Type for the pushover alerting provider
TypePushover Type = "pushover"
// TypeSlack is the Type for the slack alerting provider
TypeSlack Type = "slack"
@@ -37,7 +55,4 @@ const (
// TypeTwilio is the Type for the twilio alerting provider
TypeTwilio Type = "twilio"
// TypeOpsgenie is the Type for the opsgenie alerting provider
TypeOpsgenie Type = "opsgenie"
)

View File

@@ -1,20 +1,29 @@
package alerting
import (
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/alerting/provider"
"github.com/TwiN/gatus/v4/alerting/provider/custom"
"github.com/TwiN/gatus/v4/alerting/provider/discord"
"github.com/TwiN/gatus/v4/alerting/provider/email"
"github.com/TwiN/gatus/v4/alerting/provider/googlechat"
"github.com/TwiN/gatus/v4/alerting/provider/mattermost"
"github.com/TwiN/gatus/v4/alerting/provider/messagebird"
"github.com/TwiN/gatus/v4/alerting/provider/opsgenie"
"github.com/TwiN/gatus/v4/alerting/provider/pagerduty"
"github.com/TwiN/gatus/v4/alerting/provider/slack"
"github.com/TwiN/gatus/v4/alerting/provider/teams"
"github.com/TwiN/gatus/v4/alerting/provider/telegram"
"github.com/TwiN/gatus/v4/alerting/provider/twilio"
"log"
"reflect"
"strings"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/alerting/provider"
"github.com/TwiN/gatus/v5/alerting/provider/custom"
"github.com/TwiN/gatus/v5/alerting/provider/discord"
"github.com/TwiN/gatus/v5/alerting/provider/email"
"github.com/TwiN/gatus/v5/alerting/provider/github"
"github.com/TwiN/gatus/v5/alerting/provider/gitlab"
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
"github.com/TwiN/gatus/v5/alerting/provider/ntfy"
"github.com/TwiN/gatus/v5/alerting/provider/opsgenie"
"github.com/TwiN/gatus/v5/alerting/provider/pagerduty"
"github.com/TwiN/gatus/v5/alerting/provider/pushover"
"github.com/TwiN/gatus/v5/alerting/provider/slack"
"github.com/TwiN/gatus/v5/alerting/provider/teams"
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
)
// Config is the configuration for alerting providers
@@ -22,24 +31,42 @@ type Config struct {
// Custom is the configuration for the custom alerting provider
Custom *custom.AlertProvider `yaml:"custom,omitempty"`
// googlechat is the configuration for the Google chat alerting provider
GoogleChat *googlechat.AlertProvider `yaml:"googlechat,omitempty"`
// Discord is the configuration for the discord alerting provider
Discord *discord.AlertProvider `yaml:"discord,omitempty"`
// Email is the configuration for the email alerting provider
Email *email.AlertProvider `yaml:"email,omitempty"`
// GitHub is the configuration for the github alerting provider
GitHub *github.AlertProvider `yaml:"github,omitempty"`
// GitLab is the configuration for the gitlab alerting provider
GitLab *gitlab.AlertProvider `yaml:"gitlab,omitempty"`
// GoogleChat is the configuration for the googlechat alerting provider
GoogleChat *googlechat.AlertProvider `yaml:"googlechat,omitempty"`
// Matrix is the configuration for the matrix alerting provider
Matrix *matrix.AlertProvider `yaml:"matrix,omitempty"`
// Mattermost is the configuration for the mattermost alerting provider
Mattermost *mattermost.AlertProvider `yaml:"mattermost,omitempty"`
// Messagebird is the configuration for the messagebird alerting provider
Messagebird *messagebird.AlertProvider `yaml:"messagebird,omitempty"`
// Ntfy is the configuration for the ntfy alerting provider
Ntfy *ntfy.AlertProvider `yaml:"ntfy,omitempty"`
// Opsgenie is the configuration for the opsgenie alerting provider
Opsgenie *opsgenie.AlertProvider `yaml:"opsgenie,omitempty"`
// PagerDuty is the configuration for the pagerduty alerting provider
PagerDuty *pagerduty.AlertProvider `yaml:"pagerduty,omitempty"`
// Pushover is the configuration for the pushover alerting provider
Pushover *pushover.AlertProvider `yaml:"pushover,omitempty"`
// Slack is the configuration for the slack alerting provider
Slack *slack.AlertProvider `yaml:"slack,omitempty"`
@@ -51,86 +78,33 @@ type Config struct {
// Twilio is the configuration for the twilio alerting provider
Twilio *twilio.AlertProvider `yaml:"twilio,omitempty"`
// Opsgenie is the configuration for the opsgenie alerting provider
Opsgenie *opsgenie.AlertProvider `yaml:"opsgenie,omitempty"`
}
// GetAlertingProviderByAlertType returns an provider.AlertProvider by its corresponding alert.Type
func (config Config) GetAlertingProviderByAlertType(alertType alert.Type) provider.AlertProvider {
switch alertType {
case alert.TypeCustom:
if config.Custom == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
return nil
func (config *Config) GetAlertingProviderByAlertType(alertType alert.Type) provider.AlertProvider {
entityType := reflect.TypeOf(config).Elem()
for i := 0; i < entityType.NumField(); i++ {
field := entityType.Field(i)
if strings.ToLower(field.Name) == string(alertType) {
fieldValue := reflect.ValueOf(config).Elem().Field(i)
if fieldValue.IsNil() {
return nil
}
return fieldValue.Interface().(provider.AlertProvider)
}
return config.Custom
case alert.TypeDiscord:
if config.Discord == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
return nil
}
return config.Discord
case alert.TypeEmail:
if config.Email == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
return nil
}
return config.Email
case alert.TypeGoogleChat:
if config.GoogleChat == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
return nil
}
return config.GoogleChat
case alert.TypeMattermost:
if config.Mattermost == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
return nil
}
return config.Mattermost
case alert.TypeMessagebird:
if config.Messagebird == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
return nil
}
return config.Messagebird
case alert.TypeOpsgenie:
if config.Opsgenie == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
return nil
}
return config.Opsgenie
case alert.TypePagerDuty:
if config.PagerDuty == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
return nil
}
return config.PagerDuty
case alert.TypeSlack:
if config.Slack == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
return nil
}
return config.Slack
case alert.TypeTeams:
if config.Teams == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
return nil
}
return config.Teams
case alert.TypeTelegram:
if config.Telegram == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
return nil
}
return config.Telegram
case alert.TypeTwilio:
if config.Twilio == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
return nil
}
return config.Twilio
}
log.Printf("[alerting][GetAlertingProviderByAlertType] No alerting provider found for alert type %s", alertType)
return nil
}
// SetAlertingProviderToNil Sets an alerting provider to nil to avoid having to revalidate it every time an
// alert of its corresponding type is sent.
func (config *Config) SetAlertingProviderToNil(p provider.AlertProvider) {
entityType := reflect.TypeOf(config).Elem()
for i := 0; i < entityType.NumField(); i++ {
field := entityType.Field(i)
if field.Type == reflect.TypeOf(p) {
reflect.ValueOf(config).Elem().Field(i).Set(reflect.Zero(field.Type))
}
}
}

View File

@@ -7,9 +7,9 @@ import (
"net/http"
"strings"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
)
// AlertProvider is the configuration necessary for sending an alert using a custom HTTP request
@@ -84,6 +84,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
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))

View File

@@ -6,21 +6,31 @@ import (
"net/http"
"testing"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/test"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{URL: ""}
if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{URL: "https://example.com"}
if !validProvider.IsValid() {
t.Error("provider should've been valid")
}
t.Run("invalid-provider", func(t *testing.T) {
invalidProvider := AlertProvider{URL: ""}
if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid")
}
})
t.Run("valid-provider", func(t *testing.T) {
validProvider := AlertProvider{URL: "https://example.com"}
if validProvider.ClientConfig != nil {
t.Error("provider client config should have been nil prior to IsValid() being executed")
}
if !validProvider.IsValid() {
t.Error("provider should've been valid")
}
if validProvider.ClientConfig == nil {
t.Error("provider client config should have been set after IsValid() was executed")
}
})
}
func TestAlertProvider_Send(t *testing.T) {

View File

@@ -2,13 +2,14 @@ package discord
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
)
// AlertProvider is the configuration necessary for sending an alert using Discord
@@ -44,7 +45,7 @@ func (provider *AlertProvider) IsValid() bool {
// Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer)
if err != nil {
return err
@@ -54,6 +55,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
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))
@@ -61,8 +63,26 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
return err
}
type Body struct {
Content string `json:"content"`
Embeds []Embed `json:"embeds"`
}
type Embed struct {
Title string `json:"title"`
Description string `json:"description"`
Color int `json:"color"`
Fields []Field `json:"fields"`
}
type Field struct {
Name string `json:"name"`
Value string `json:"value"`
Inline bool `json:"inline"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
var message, results string
var colorCode int
if resolved {
@@ -79,29 +99,30 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
} else {
prefix = ":x:"
}
results += fmt.Sprintf("%s - `%s`\\n", prefix, conditionResult.Condition)
results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\\n> " + alertDescription
description = ":\n> " + alertDescription
}
return fmt.Sprintf(`{
"content": "",
"embeds": [
{
"title": ":helmet_with_white_cross: Gatus",
"description": "%s%s",
"color": %d,
"fields": [
{
"name": "Condition results",
"value": "%s",
"inline": false
}
]
}
]
}`, message, description, colorCode, results)
body, _ := json.Marshal(Body{
Content: "",
Embeds: []Embed{
{
Title: ":helmet_with_white_cross: Gatus",
Description: message + description,
Color: colorCode,
Fields: []Field{
{
Name: "Condition results",
Value: results,
Inline: false,
},
},
},
},
})
return body
}
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group

View File

@@ -5,10 +5,10 @@ import (
"net/http"
"testing"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/test"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_IsValid(t *testing.T) {
@@ -151,14 +151,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\n \"content\": \"\",\n \"embeds\": [\n {\n \"title\": \":helmet_with_white_cross: Gatus\",\n \"description\": \"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n> description-1\",\n \"color\": 15158332,\n \"fields\": [\n {\n \"name\": \"Condition results\",\n \"value\": \":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\n \"inline\": false\n }\n ]\n }\n ]\n}",
ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"description\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"color\":15158332,\"fields\":[{\"name\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n:x: - `[BODY] != \\\"\\\"`\\n\",\"inline\":false}]}]}",
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\n \"content\": \"\",\n \"embeds\": [\n {\n \"title\": \":helmet_with_white_cross: Gatus\",\n \"description\": \"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row:\\n> description-2\",\n \"color\": 3066993,\n \"fields\": [\n {\n \"name\": \"Condition results\",\n \"value\": \":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\n \"inline\": false\n }\n ]\n }\n ]\n}",
ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"description\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"color\":3066993,\"fields\":[{\"name\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n:white_check_mark: - `[BODY] != \\\"\\\"`\\n\",\"inline\":false}]}]}",
},
}
for _, scenario := range scenarios {
@@ -170,15 +170,16 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
{Condition: "[BODY] != \"\"", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if body != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
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([]byte(body), &out); err != nil {
if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})

View File

@@ -5,8 +5,8 @@ import (
"math"
"strings"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/core"
gomail "gopkg.in/mail.v2"
)
@@ -67,12 +67,11 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
// buildMessageSubjectAndBody builds the message subject and body
func (provider *AlertProvider) buildMessageSubjectAndBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) (string, string) {
var subject, message, results string
var message, results string
subject := fmt.Sprintf("[%s] Alert triggered", endpoint.DisplayName())
if resolved {
subject = fmt.Sprintf("[%s] Alert resolved", endpoint.DisplayName())
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
} else {
subject = fmt.Sprintf("[%s] Alert triggered", endpoint.DisplayName())
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
}
for _, conditionResult := range result.ConditionResults {

View File

@@ -3,8 +3,8 @@ package email
import (
"testing"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/core"
)
func TestAlertDefaultProvider_IsValid(t *testing.T) {
@@ -83,7 +83,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedSubject: "[endpoint-name] Alert resolved",
ExpectedSubject: "[endpoint-name] Alert triggered",
ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
},
}

View File

@@ -0,0 +1,129 @@
package github
import (
"context"
"fmt"
"net/url"
"strings"
"time"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/core"
"github.com/google/go-github/v48/github"
"golang.org/x/oauth2"
)
// AlertProvider is the configuration necessary for sending an alert using Discord
type AlertProvider struct {
RepositoryURL string `yaml:"repository-url"` // The URL of the GitHub repository to create issues in
Token string `yaml:"token"` // Token requires at least RW on issues and RO on metadata
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
username string
repositoryOwner string
repositoryName string
githubClient *github.Client
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
if len(provider.Token) == 0 || len(provider.RepositoryURL) == 0 {
return false
}
// Validate format of the repository URL
repositoryURL, err := url.Parse(provider.RepositoryURL)
if err != nil {
return false
}
baseURL := repositoryURL.Scheme + "://" + repositoryURL.Host
pathParts := strings.Split(repositoryURL.Path, "/")
if len(pathParts) != 3 {
return false
}
provider.repositoryOwner = pathParts[1]
provider.repositoryName = pathParts[2]
// Create oauth2 HTTP client with GitHub token
httpClientWithStaticTokenSource := oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(&oauth2.Token{
AccessToken: provider.Token,
}))
// Create GitHub client
if baseURL == "https://github.com" {
provider.githubClient = github.NewClient(httpClientWithStaticTokenSource)
} else {
provider.githubClient, err = github.NewEnterpriseClient(baseURL, baseURL, httpClientWithStaticTokenSource)
if err != nil {
return false
}
}
// Retrieve the username once to validate that the token is valid
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
user, _, err := provider.githubClient.Users.Get(ctx, "")
if err != nil {
return false
}
provider.username = *user.Login
return true
}
// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,
// or closes the relevant issue(s) if the resolved parameter passed is true.
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
title := "alert(gatus): " + endpoint.DisplayName()
if !resolved {
_, _, err := provider.githubClient.Issues.Create(context.Background(), provider.repositoryOwner, provider.repositoryName, &github.IssueRequest{
Title: github.String(title),
Body: github.String(provider.buildIssueBody(endpoint, alert, result)),
})
if err != nil {
return fmt.Errorf("failed to create issue: %w", err)
}
} else {
issues, _, err := provider.githubClient.Issues.ListByRepo(context.Background(), provider.repositoryOwner, provider.repositoryName, &github.IssueListByRepoOptions{
State: "open",
Creator: provider.username,
ListOptions: github.ListOptions{PerPage: 100},
})
if err != nil {
return fmt.Errorf("failed to list issues: %w", err)
}
for _, issue := range issues {
if *issue.Title == title {
_, _, err = provider.githubClient.Issues.Edit(context.Background(), provider.repositoryOwner, provider.repositoryName, *issue.Number, &github.IssueRequest{
State: github.String("closed"),
})
if err != nil {
return fmt.Errorf("failed to close issue: %w", err)
}
}
}
}
return nil
}
// buildIssueBody builds the body of the issue
func (provider *AlertProvider) buildIssueBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result) string {
var results string
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = ":white_check_mark:"
} else {
prefix = ":x:"
}
results += fmt.Sprintf("- %s - `%s`\n", prefix, conditionResult.Condition)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\n> " + alertDescription
}
message := fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
return message + description + "\n\n## Condition results\n" + results
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}

View File

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

View File

@@ -0,0 +1,147 @@
package gitlab
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/google/uuid"
)
// AlertProvider is the configuration necessary for sending an alert using GitLab
type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"` // The webhook url provided by GitLab
AuthorizationKey string `yaml:"authorization-key"` // The authorization key provided by GitLab
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Severity can be one of: critical, high, medium, low, info, unknown. Defaults to critical
Severity string `yaml:"severity,omitempty"`
// MonitoringTool overrides the name sent to gitlab. Defaults to gatus
MonitoringTool string `yaml:"monitoring-tool,omitempty"`
// EnvironmentName is the name of the associated GitLab environment. Required to display alerts on a dashboard.
EnvironmentName string `yaml:"environment-name,omitempty"`
// Service affected. Defaults to endpoint display name
Service string `yaml:"service,omitempty"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
if len(provider.AuthorizationKey) == 0 || len(provider.WebhookURL) == 0 {
return false
}
// Validate format of the repository URL
_, err := url.Parse(provider.WebhookURL)
if err != nil {
return false
}
return true
}
// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,
// or closes the relevant issue(s) if the resolved parameter passed is true.
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
if len(alert.ResolveKey) == 0 {
alert.ResolveKey = uuid.NewString()
}
buffer := bytes.NewBuffer(provider.buildAlertBody(endpoint, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", provider.AuthorizationKey))
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 AlertBody struct {
Title string `json:"title,omitempty"` // The title of the alert.
Description string `json:"description,omitempty"` // A high-level summary of the problem.
StartTime string `json:"start_time,omitempty"` // The time of the alert. If none is provided, a current time is used.
EndTime string `json:"end_time,omitempty"` // The resolution time of the alert. If provided, the alert is resolved.
Service string `json:"service,omitempty"` // The affected service.
MonitoringTool string `json:"monitoring_tool,omitempty"` // The name of the associated monitoring tool.
Hosts string `json:"hosts,omitempty"` // One or more hosts, as to where this incident occurred.
Severity string `json:"severity,omitempty"` // The severity of the alert. Case-insensitive. Can be one of: critical, high, medium, low, info, unknown. Defaults to critical if missing or value is not in this list.
Fingerprint string `json:"fingerprint,omitempty"` // The unique identifier of the alert. This can be used to group occurrences of the same alert.
GitlabEnvironmentName string `json:"gitlab_environment_name,omitempty"` // The name of the associated GitLab environment. Required to display alerts on a dashboard.
}
func (provider *AlertProvider) monitoringTool() string {
if len(provider.MonitoringTool) > 0 {
return provider.MonitoringTool
}
return "gatus"
}
func (provider *AlertProvider) service(endpoint *core.Endpoint) string {
if len(provider.Service) > 0 {
return provider.Service
}
return endpoint.DisplayName()
}
// buildAlertBody builds the body of the alert
func (provider *AlertProvider) buildAlertBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
body := AlertBody{
Title: fmt.Sprintf("alert(%s): %s", provider.monitoringTool(), provider.service(endpoint)),
StartTime: result.Timestamp.Format(time.RFC3339),
Service: provider.service(endpoint),
MonitoringTool: provider.monitoringTool(),
Hosts: endpoint.URL,
GitlabEnvironmentName: provider.EnvironmentName,
Severity: provider.Severity,
Fingerprint: alert.ResolveKey,
}
if resolved {
body.EndTime = result.Timestamp.Format(time.RFC3339)
}
var results string
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = ":white_check_mark:"
} else {
prefix = ":x:"
}
results += fmt.Sprintf("- %s - `%s`\n", prefix, conditionResult.Condition)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\n> " + alertDescription
}
var message string
if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
} 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)
}
body.Description = message + description + "\n\n## Condition results\n" + results
json, _ := json.Marshal(body)
return json
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}

View File

@@ -0,0 +1,158 @@
package gitlab
import (
"net/http"
"strings"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertDefaultProvider_IsValid(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
Expected bool
}{
{
Name: "invalid",
Provider: AlertProvider{WebhookURL: "", AuthorizationKey: ""},
Expected: false,
},
{
Name: "missing-webhook-url",
Provider: AlertProvider{WebhookURL: "", AuthorizationKey: "12345"},
Expected: false,
},
{
Name: "missing-authorization-key",
Provider: AlertProvider{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: ""},
Expected: false,
},
{
Name: "invalid-url",
Provider: AlertProvider{WebhookURL: " http://foo.com", AuthorizationKey: "12345"},
Expected: false,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
if scenario.Provider.IsValid() != scenario.Expected {
t.Errorf("expected %t, got %t", scenario.Expected, scenario.Provider.IsValid())
}
})
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered-error",
Provider: AlertProvider{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedError: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
},
{
Name: "resolved-error",
Provider: AlertProvider{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedError: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_buildAlertBody(t *testing.T) {
firstDescription := "description-1"
scenarios := []struct {
Name string
Endpoint core.Endpoint
Provider AlertProvider
Alert alert.Alert
ExpectedBody string
}{
{
Name: "triggered",
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 3},
ExpectedBody: "{\"title\":\"alert(gatus): endpoint-name\",\"description\":\"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\\n\\n## Condition results\\n- :white_check_mark: - `[CONNECTED] == true`\\n- :x: - `[STATUS] == 200`\\n\",\"start_time\":\"0001-01-01T00:00:00Z\",\"service\":\"endpoint-name\",\"monitoring_tool\":\"gatus\",\"hosts\":\"https://example.org\"}",
},
{
Name: "no-description",
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{},
Alert: alert.Alert{FailureThreshold: 10},
ExpectedBody: "{\"title\":\"alert(gatus): endpoint-name\",\"description\":\"An alert for *endpoint-name* has been triggered due to having failed 10 time(s) in a row\\n\\n## Condition results\\n- :white_check_mark: - `[CONNECTED] == true`\\n- :x: - `[STATUS] == 200`\\n\",\"start_time\":\"0001-01-01T00:00:00Z\",\"service\":\"endpoint-name\",\"monitoring_tool\":\"gatus\",\"hosts\":\"https://example.org\"}",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildAlertBody(
&scenario.Endpoint,
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: true},
{Condition: "[STATUS] == 200", Success: false},
},
},
false,
)
if strings.TrimSpace(string(body)) != strings.TrimSpace(scenario.ExpectedBody) {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}

View File

@@ -2,13 +2,14 @@ package googlechat
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
)
// AlertProvider is the configuration necessary for sending an alert using Google chat
@@ -36,7 +37,6 @@ func (provider *AlertProvider) IsValid() bool {
if provider.ClientConfig == nil {
provider.ClientConfig = client.GetDefaultConfig()
}
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
@@ -46,13 +46,12 @@ func (provider *AlertProvider) IsValid() bool {
registeredGroups[override.Group] = true
}
}
return len(provider.WebhookURL) > 0
}
// Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer)
if err != nil {
return err
@@ -62,6 +61,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
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))
@@ -69,8 +69,50 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
return err
}
type Body struct {
Cards []Cards `json:"cards"`
}
type Cards struct {
Sections []Sections `json:"sections"`
}
type Sections struct {
Widgets []Widgets `json:"widgets"`
}
type Widgets struct {
KeyValue *KeyValue `json:"keyValue,omitempty"`
Buttons []Buttons `json:"buttons,omitempty"`
}
type KeyValue struct {
TopLabel string `json:"topLabel,omitempty"`
Content string `json:"content,omitempty"`
ContentMultiline string `json:"contentMultiline,omitempty"`
BottomLabel string `json:"bottomLabel,omitempty"`
Icon string `json:"icon,omitempty"`
}
type Buttons struct {
TextButton TextButton `json:"textButton"`
}
type TextButton struct {
Text string `json:"text"`
OnClick OnClick `json:"onClick"`
}
type OnClick struct {
OpenLink OpenLink `json:"openLink"`
}
type OpenLink struct {
URL string `json:"url"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
var message, color string
if resolved {
color = "#36A64F"
@@ -93,49 +135,52 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":: " + alertDescription
}
return fmt.Sprintf(`{
"cards": [
{
"sections": [
{
"widgets": [
{
"keyValue": {
"topLabel": "%s [%s]",
"content": "%s",
"contentMultiline": "true",
"bottomLabel": "%s",
"icon": "BOOKMARK"
}
},
{
"keyValue": {
"topLabel": "Condition results",
"content": "%s",
"contentMultiline": "true",
"icon": "DESCRIPTION"
}
},
{
"buttons": [
{
"textButton": {
"text": "URL",
"onClick": {
"openLink": {
"url": "%s"
}
}
}
}
]
}
]
}
]
}
]
}`, endpoint.Name, endpoint.Group, message, description, results, endpoint.URL)
payload := Body{
Cards: []Cards{
{
Sections: []Sections{
{
Widgets: []Widgets{
{
KeyValue: &KeyValue{
TopLabel: endpoint.DisplayName(),
Content: message,
ContentMultiline: "true",
BottomLabel: description,
Icon: "BOOKMARK",
},
},
{
KeyValue: &KeyValue{
TopLabel: "Condition results",
Content: results,
ContentMultiline: "true",
Icon: "DESCRIPTION",
},
},
},
},
},
},
},
}
if endpoint.Type() == core.EndpointTypeHTTP {
// We only include a button targeting the URL if the endpoint is an HTTP endpoint
// If the URL isn't prefixed with https://, Google Chat will just display a blank message aynways.
// See https://github.com/TwiN/gatus/issues/362
payload.Cards[0].Sections[0].Widgets = append(payload.Cards[0].Sections[0].Widgets, Widgets{
Buttons: []Buttons{
{
TextButton: TextButton{
Text: "URL",
OnClick: OnClick{OpenLink: OpenLink{URL: endpoint.URL}},
},
},
},
})
}
body, _ := json.Marshal(payload)
return body
}
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group

View File

@@ -5,10 +5,10 @@ import (
"net/http"
"testing"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/test"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertDefaultProvider_IsValid(t *testing.T) {
@@ -141,6 +141,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
secondDescription := "description-2"
scenarios := []struct {
Name string
Endpoint core.Endpoint
Provider AlertProvider
Alert alert.Alert
Resolved bool
@@ -148,23 +149,41 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{
{
Name: "triggered",
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\n \"cards\": [\n {\n \"sections\": [\n {\n \"widgets\": [\n {\n \"keyValue\": {\n \"topLabel\": \"endpoint-name []\",\n \"content\": \"\u003cfont color='#DD0000'\u003eAn alert has been triggered due to having failed 3 time(s) in a row\u003c/font\u003e\",\n \"contentMultiline\": \"true\",\n \"bottomLabel\": \":: description-1\",\n \"icon\": \"BOOKMARK\"\n }\n },\n {\n \"keyValue\": {\n \"topLabel\": \"Condition results\",\n \"content\": \"❌ [CONNECTED] == true\u003cbr\u003e❌ [STATUS] == 200\u003cbr\u003e\",\n \"contentMultiline\": \"true\",\n \"icon\": \"DESCRIPTION\"\n }\n },\n {\n \"buttons\": [\n {\n \"textButton\": {\n \"text\": \"URL\",\n \"onClick\": {\n \"openLink\": {\n \"url\": \"\"\n }\n }\n }\n }\n ]\n }\n ]\n }\n ]\n }\n]\n}",
ExpectedBody: `{"cards":[{"sections":[{"widgets":[{"keyValue":{"topLabel":"endpoint-name","content":"\u003cfont color='#DD0000'\u003eAn alert has been triggered due to having failed 3 time(s) in a row\u003c/font\u003e","contentMultiline":"true","bottomLabel":":: description-1","icon":"BOOKMARK"}},{"keyValue":{"topLabel":"Condition results","content":"❌ [CONNECTED] == true\u003cbr\u003e❌ [STATUS] == 200\u003cbr\u003e","contentMultiline":"true","icon":"DESCRIPTION"}},{"buttons":[{"textButton":{"text":"URL","onClick":{"openLink":{"url":"https://example.org"}}}}]}]}]}]}`,
},
{
Name: "resolved",
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\n \"cards\": [\n {\n \"sections\": [\n {\n \"widgets\": [\n {\n \"keyValue\": {\n \"topLabel\": \"endpoint-name []\",\n \"content\": \"\u003cfont color='#36A64F'\u003eAn alert has been resolved after passing successfully 5 time(s) in a row\u003c/font\u003e\",\n \"contentMultiline\": \"true\",\n \"bottomLabel\": \":: description-2\",\n \"icon\": \"BOOKMARK\"\n }\n },\n {\n \"keyValue\": {\n \"topLabel\": \"Condition results\",\n \"content\": \"✅ [CONNECTED] == true\u003cbr\u003e✅ [STATUS] == 200\u003cbr\u003e\",\n \"contentMultiline\": \"true\",\n \"icon\": \"DESCRIPTION\"\n }\n },\n {\n \"buttons\": [\n {\n \"textButton\": {\n \"text\": \"URL\",\n \"onClick\": {\n \"openLink\": {\n \"url\": \"\"\n }\n }\n }\n }\n ]\n }\n ]\n }\n ]\n }\n]\n}",
ExpectedBody: `{"cards":[{"sections":[{"widgets":[{"keyValue":{"topLabel":"endpoint-name","content":"\u003cfont color='#36A64F'\u003eAn alert has been resolved after passing successfully 5 time(s) in a row\u003c/font\u003e","contentMultiline":"true","bottomLabel":":: description-2","icon":"BOOKMARK"}},{"keyValue":{"topLabel":"Condition results","content":"✅ [CONNECTED] == true\u003cbr\u003e✅ [STATUS] == 200\u003cbr\u003e","contentMultiline":"true","icon":"DESCRIPTION"}},{"buttons":[{"textButton":{"text":"URL","onClick":{"openLink":{"url":"https://example.org"}}}}]}]}]}]}`,
},
{
Name: "icmp-should-not-include-url", // See https://github.com/TwiN/gatus/issues/362
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "icmp://example.org"},
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: `{"cards":[{"sections":[{"widgets":[{"keyValue":{"topLabel":"endpoint-name","content":"\u003cfont color='#DD0000'\u003eAn alert has been triggered due to having failed 3 time(s) in a row\u003c/font\u003e","contentMultiline":"true","bottomLabel":":: description-1","icon":"BOOKMARK"}},{"keyValue":{"topLabel":"Condition results","content":"❌ [CONNECTED] == true\u003cbr\u003e❌ [STATUS] == 200\u003cbr\u003e","contentMultiline":"true","icon":"DESCRIPTION"}}]}]}]}`,
},
{
Name: "tcp-should-not-include-url", // See https://github.com/TwiN/gatus/issues/362
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "tcp://example.org"},
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: `{"cards":[{"sections":[{"widgets":[{"keyValue":{"topLabel":"endpoint-name","content":"\u003cfont color='#DD0000'\u003eAn alert has been triggered due to having failed 3 time(s) in a row\u003c/font\u003e","contentMultiline":"true","bottomLabel":":: description-1","icon":"BOOKMARK"}},{"keyValue":{"topLabel":"Condition results","content":"❌ [CONNECTED] == true\u003cbr\u003e❌ [STATUS] == 200\u003cbr\u003e","contentMultiline":"true","icon":"DESCRIPTION"}}]}]}]}`,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Endpoint,
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
@@ -174,13 +193,11 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
},
scenario.Resolved,
)
b, _ := json.Marshal(body)
e, _ := json.Marshal(scenario.ExpectedBody)
if body != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", e, b)
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([]byte(body), &out); err != nil {
if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})

View File

@@ -0,0 +1,190 @@
package matrix
import (
"bytes"
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"net/url"
"time"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
)
// AlertProvider is the configuration necessary for sending an alert using Matrix
type AlertProvider struct {
MatrixProviderConfig `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
MatrixProviderConfig `yaml:",inline"`
}
const defaultHomeserverURL = "https://matrix-client.matrix.org"
type MatrixProviderConfig struct {
// ServerURL is the custom homeserver to use (optional)
ServerURL string `yaml:"server-url"`
// AccessToken is the bot user's access token to send messages
AccessToken string `yaml:"access-token"`
// InternalRoomID is the room that the bot user has permissions to send messages to
InternalRoomID string `yaml:"internal-room-id"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.AccessToken) == 0 || len(override.InternalRoomID) == 0 {
return false
}
registeredGroups[override.Group] = true
}
}
return len(provider.AccessToken) > 0 && len(provider.InternalRoomID) > 0
}
// Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
config := provider.getConfigForGroup(endpoint.Group)
if config.ServerURL == "" {
config.ServerURL = defaultHomeserverURL
}
// The Matrix endpoint requires a unique transaction ID for each event sent
txnId := randStringBytes(24)
request, err := http.NewRequest(
http.MethodPut,
fmt.Sprintf("%s/_matrix/client/v3/rooms/%s/send/m.room.message/%s?access_token=%s",
config.ServerURL,
url.PathEscape(config.InternalRoomID),
txnId,
url.QueryEscape(config.AccessToken),
),
buffer,
)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
type Body struct {
MsgType string `json:"msgtype"`
Format string `json:"format"`
Body string `json:"body"`
FormattedBody string `json:"formatted_body"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
body, _ := json.Marshal(Body{
MsgType: "m.text",
Format: "org.matrix.custom.html",
Body: buildPlaintextMessageBody(endpoint, alert, result, resolved),
FormattedBody: buildHTMLMessageBody(endpoint, alert, result, resolved),
})
return body
}
// buildPlaintextMessageBody builds the message body in plaintext to include in request
func buildPlaintextMessageBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
var message, results string
if resolved {
message = fmt.Sprintf("An alert for `%s` has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
} else {
message = fmt.Sprintf("An alert for `%s` has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
}
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "✓"
} else {
prefix = "✕"
}
results += fmt.Sprintf("\n%s - %s", prefix, conditionResult.Condition)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = "\n" + alertDescription
}
return fmt.Sprintf("%s%s\n%s", message, description, results)
}
// buildHTMLMessageBody builds the message body in HTML to include in request
func buildHTMLMessageBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
var message, results string
if resolved {
message = fmt.Sprintf("An alert for <code>%s</code> has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
} else {
message = fmt.Sprintf("An alert for <code>%s</code> has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
}
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "✅"
} else {
prefix = "❌"
}
results += fmt.Sprintf("<li>%s - <code>%s</code></li>", prefix, conditionResult.Condition)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = fmt.Sprintf("\n<blockquote>%s</blockquote>", alertDescription)
}
return fmt.Sprintf("<h3>%s</h3>%s\n<h5>Condition results</h5><ul>%s</ul>", message, description, results)
}
// getConfigForGroup returns the appropriate configuration for a given group
func (provider *AlertProvider) getConfigForGroup(group string) MatrixProviderConfig {
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
return override.MatrixProviderConfig
}
}
}
return provider.MatrixProviderConfig
}
func randStringBytes(n int) string {
// All the compatible characters to use in a transaction ID
const availableCharacterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, n)
rand.Seed(time.Now().UnixNano())
for i := range b {
b[i] = availableCharacterBytes[rand.Intn(len(availableCharacterBytes))]
}
return string(b)
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}

View File

@@ -0,0 +1,331 @@
package matrix
import (
"encoding/json"
"net/http"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{
MatrixProviderConfig: MatrixProviderConfig{
AccessToken: "",
InternalRoomID: "",
},
}
if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{
MatrixProviderConfig: MatrixProviderConfig{
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
}
if !validProvider.IsValid() {
t.Error("provider should've been valid")
}
validProviderWithHomeserver := AlertProvider{
MatrixProviderConfig: MatrixProviderConfig{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
}
if !validProviderWithHomeserver.IsValid() {
t.Error("provider with homeserver should've been valid")
}
}
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
Group: "",
MatrixProviderConfig: MatrixProviderConfig{
AccessToken: "",
InternalRoomID: "",
},
},
},
}
if providerWithInvalidOverrideGroup.IsValid() {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
Group: "group",
MatrixProviderConfig: MatrixProviderConfig{
AccessToken: "",
InternalRoomID: "",
},
},
},
}
if providerWithInvalidOverrideTo.IsValid() {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
MatrixProviderConfig: MatrixProviderConfig{
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
Overrides: []Override{
{
Group: "group",
MatrixProviderConfig: MatrixProviderConfig{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
},
},
}
if !providerWithValidOverride.IsValid() {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"msgtype\":\"m.text\",\"format\":\"org.matrix.custom.html\",\"body\":\"An alert for `endpoint-name` has been triggered due to having failed 3 time(s) in a row\\ndescription-1\\n\\n✕ - [CONNECTED] == true\\n✕ - [STATUS] == 200\",\"formatted_body\":\"\\u003ch3\\u003eAn alert for \\u003ccode\\u003eendpoint-name\\u003c/code\\u003e has been triggered due to having failed 3 time(s) in a row\\u003c/h3\\u003e\\n\\u003cblockquote\\u003edescription-1\\u003c/blockquote\\u003e\\n\\u003ch5\\u003eCondition results\\u003c/h5\\u003e\\u003cul\\u003e\\u003cli\\u003e❌ - \\u003ccode\\u003e[CONNECTED] == true\\u003c/code\\u003e\\u003c/li\\u003e\\u003cli\\u003e❌ - \\u003ccode\\u003e[STATUS] == 200\\u003c/code\\u003e\\u003c/li\\u003e\\u003c/ul\\u003e\"}",
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"msgtype\":\"m.text\",\"format\":\"org.matrix.custom.html\",\"body\":\"An alert for `endpoint-name` has been resolved after passing successfully 5 time(s) in a row\\ndescription-2\\n\\n✓ - [CONNECTED] == true\\n✓ - [STATUS] == 200\",\"formatted_body\":\"\\u003ch3\\u003eAn alert for \\u003ccode\\u003eendpoint-name\\u003c/code\\u003e has been resolved after passing successfully 5 time(s) in a row\\u003c/h3\\u003e\\n\\u003cblockquote\\u003edescription-2\\u003c/blockquote\\u003e\\n\\u003ch5\\u003eCondition results\\u003c/h5\\u003e\\u003cul\\u003e\\u003cli\\u003e✅ - \\u003ccode\\u003e[CONNECTED] == true\\u003c/code\\u003e\\u003c/li\\u003e\\u003cli\\u003e✅ - \\u003ccode\\u003e[STATUS] == 200\\u003c/code\\u003e\\u003c/li\\u003e\\u003c/ul\\u003e\"}",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_getConfigForGroup(t *testing.T) {
tests := []struct {
Name string
Provider AlertProvider
InputGroup string
ExpectedOutput MatrixProviderConfig
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
MatrixProviderConfig: MatrixProviderConfig{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
Overrides: nil,
},
InputGroup: "",
ExpectedOutput: MatrixProviderConfig{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
MatrixProviderConfig: MatrixProviderConfig{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
Overrides: nil,
},
InputGroup: "group",
ExpectedOutput: MatrixProviderConfig{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
MatrixProviderConfig: MatrixProviderConfig{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
Overrides: []Override{
{
Group: "group",
MatrixProviderConfig: MatrixProviderConfig{
ServerURL: "https://example01.com",
AccessToken: "12",
InternalRoomID: "!a:example01.com",
},
},
},
},
InputGroup: "",
ExpectedOutput: MatrixProviderConfig{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
MatrixProviderConfig: MatrixProviderConfig{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
Overrides: []Override{
{
Group: "group",
MatrixProviderConfig: MatrixProviderConfig{
ServerURL: "https://example01.com",
AccessToken: "12",
InternalRoomID: "!a:example01.com",
},
},
},
},
InputGroup: "group",
ExpectedOutput: MatrixProviderConfig{
ServerURL: "https://example01.com",
AccessToken: "12",
InternalRoomID: "!a:example01.com",
},
},
}
for _, tt := range tests {
t.Run(tt.Name, func(t *testing.T) {
if got := tt.Provider.getConfigForGroup(tt.InputGroup); got != tt.ExpectedOutput {
t.Errorf("AlertProvider.getConfigForGroup() = %v, want %v", got, tt.ExpectedOutput)
}
})
}
}

View File

@@ -2,13 +2,14 @@ package mattermost
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
)
// AlertProvider is the configuration necessary for sending an alert using Mattermost
@@ -60,6 +61,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
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))
@@ -67,9 +69,31 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
return err
}
type Body struct {
Text string `json:"text"`
Username string `json:"username"`
IconURL string `json:"icon_url"`
Attachments []Attachment `json:"attachments"`
}
type Attachment struct {
Title string `json:"title"`
Fallback string `json:"fallback"`
Text string `json:"text"`
Short bool `json:"short"`
Color string `json:"color"`
Fields []Field `json:"fields"`
}
type Field struct {
Title string `json:"title"`
Value string `json:"value"`
Short bool `json:"short"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
var message, color string
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
var message, color, results string
if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
color = "#36A64F"
@@ -77,7 +101,6 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
color = "#DD0000"
}
var results string
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
@@ -85,38 +108,34 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
} else {
prefix = ":x:"
}
results += fmt.Sprintf("%s - `%s`\\n", prefix, conditionResult.Condition)
results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\\n> " + alertDescription
description = ":\n> " + alertDescription
}
return fmt.Sprintf(`{
"text": "",
"username": "gatus",
"icon_url": "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png",
"attachments": [
{
"title": ":rescue_worker_helmet: Gatus",
"fallback": "Gatus - %s",
"text": "%s%s",
"short": false,
"color": "%s",
"fields": [
{
"title": "URL",
"value": "%s",
"short": false
},
{
"title": "Condition results",
"value": "%s",
"short": false
}
]
}
]
}`, message, message, description, color, endpoint.URL, results)
body, _ := json.Marshal(Body{
Text: "",
Username: "gatus",
IconURL: "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png",
Attachments: []Attachment{
{
Title: ":helmet_with_white_cross: Gatus",
Fallback: "Gatus - " + message,
Text: message + description,
Short: false,
Color: color,
Fields: []Field{
{
Title: "Condition results",
Value: results,
Short: false,
},
},
},
},
})
return body
}
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group

View File

@@ -5,10 +5,10 @@ import (
"net/http"
"testing"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/test"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_IsValid(t *testing.T) {
@@ -155,14 +155,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\n \"text\": \"\",\n \"username\": \"gatus\",\n \"icon_url\": \"https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png\",\n \"attachments\": [\n {\n \"title\": \":rescue_worker_helmet: Gatus\",\n \"fallback\": \"Gatus - An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row\",\n \"text\": \"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row:\\n> description-1\",\n \"short\": false,\n \"color\": \"#DD0000\",\n \"fields\": [\n {\n \"title\": \"URL\",\n \"value\": \"\",\n \"short\": false\n },\n {\n \"title\": \"Condition results\",\n \"value\": \":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\n \"short\": false\n }\n ]\n }\n ]\n}",
ExpectedBody: "{\"text\":\"\",\"username\":\"gatus\",\"icon_url\":\"https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"fallback\":\"Gatus - An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row\",\"text\":\"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"short\":false,\"color\":\"#DD0000\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\n \"text\": \"\",\n \"username\": \"gatus\",\n \"icon_url\": \"https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png\",\n \"attachments\": [\n {\n \"title\": \":rescue_worker_helmet: Gatus\",\n \"fallback\": \"Gatus - An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row\",\n \"text\": \"An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row:\\n> description-2\",\n \"short\": false,\n \"color\": \"#36A64F\",\n \"fields\": [\n {\n \"title\": \"URL\",\n \"value\": \"\",\n \"short\": false\n },\n {\n \"title\": \"Condition results\",\n \"value\": \":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\n \"short\": false\n }\n ]\n }\n ]\n}",
ExpectedBody: "{\"text\":\"\",\"username\":\"gatus\",\"icon_url\":\"https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"fallback\":\"Gatus - An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row\",\"text\":\"An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
},
}
for _, scenario := range scenarios {
@@ -178,11 +178,11 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
},
scenario.Resolved,
)
if body != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
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([]byte(body), &out); err != nil {
if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})

View File

@@ -2,13 +2,14 @@ package messagebird
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
)
const (
@@ -33,7 +34,7 @@ func (provider *AlertProvider) IsValid() bool {
// Send an alert using the provider
// Reference doc for messagebird: https://developers.messagebird.com/api/sms-messaging/#send-outbound-sms
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
if err != nil {
return err
@@ -44,6 +45,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
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))
@@ -51,19 +53,26 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
return err
}
type Body struct {
Originator string `json:"originator"`
Recipients string `json:"recipients"`
Body string `json:"body"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
var message string
if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
} else {
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
}
return fmt.Sprintf(`{
"originator": "%s",
"recipients": "%s",
"body": "%s"
}`, provider.Originator, provider.Recipients, message)
body, _ := json.Marshal(Body{
Originator: provider.Originator,
Recipients: provider.Recipients,
Body: message,
})
return body
}
// GetDefaultAlert returns the provider's default alert configuration

View File

@@ -5,10 +5,10 @@ import (
"net/http"
"testing"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/test"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/test"
)
func TestMessagebirdAlertProvider_IsValid(t *testing.T) {
@@ -118,14 +118,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider: AlertProvider{AccessKey: "1", Originator: "2", Recipients: "3"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\n \"originator\": \"2\",\n \"recipients\": \"3\",\n \"body\": \"TRIGGERED: endpoint-name - description-1\"\n}",
ExpectedBody: "{\"originator\":\"2\",\"recipients\":\"3\",\"body\":\"TRIGGERED: endpoint-name - description-1\"}",
},
{
Name: "resolved",
Provider: AlertProvider{AccessKey: "4", Originator: "5", Recipients: "6"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\n \"originator\": \"5\",\n \"recipients\": \"6\",\n \"body\": \"RESOLVED: endpoint-name - description-2\"\n}",
ExpectedBody: "{\"originator\":\"5\",\"recipients\":\"6\",\"body\":\"RESOLVED: endpoint-name - description-2\"}",
},
}
for _, scenario := range scenarios {
@@ -141,8 +141,8 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
},
scenario.Resolved,
)
if body != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
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([]byte(body), &out); err != nil {

View File

@@ -0,0 +1,105 @@
package ntfy
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
)
const (
DefaultURL = "https://ntfy.sh"
DefaultPriority = 3
)
// AlertProvider is the configuration necessary for sending an alert using Slack
type AlertProvider struct {
Topic string `yaml:"topic"`
URL string `yaml:"url,omitempty"` // Defaults to DefaultURL
Priority int `yaml:"priority,omitempty"` // Defaults to DefaultPriority
Token string `yaml:"token,omitempty"` // Defaults to ""
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
if len(provider.URL) == 0 {
provider.URL = DefaultURL
}
if provider.Priority == 0 {
provider.Priority = DefaultPriority
}
isTokenValid := true
if len(provider.Token) > 0 {
isTokenValid = strings.HasPrefix(provider.Token, "tk_")
}
return len(provider.URL) > 0 && len(provider.Topic) > 0 && provider.Priority > 0 && provider.Priority < 6 && isTokenValid
}
// Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.URL, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
if len(provider.Token) > 0 {
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 {
Topic string `json:"topic"`
Title string `json:"title"`
Message string `json:"message"`
Tags []string `json:"tags"`
Priority int `json:"priority"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
var message, tag string
if resolved {
tag = "white_check_mark"
message = "An alert has been resolved after passing successfully " + strconv.Itoa(alert.SuccessThreshold) + " time(s) in a row"
} else {
tag = "x"
message = "An alert has been triggered due to having failed " + strconv.Itoa(alert.FailureThreshold) + " time(s) in a row"
}
if len(alert.GetDescription()) > 0 {
message += " with the following description: " + alert.GetDescription()
}
body, _ := json.Marshal(Body{
Topic: provider.Topic,
Title: "Gatus: " + endpoint.DisplayName(),
Message: message,
Tags: []string{tag},
Priority: provider.Priority,
})
return body
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}

View File

@@ -0,0 +1,114 @@
package ntfy
import (
"encoding/json"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/core"
)
func TestAlertDefaultProvider_IsValid(t *testing.T) {
scenarios := []struct {
name string
provider AlertProvider
expected bool
}{
{
name: "valid",
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
expected: true,
},
{
name: "no-url-should-use-default-value",
provider: AlertProvider{Topic: "example", Priority: 1},
expected: true,
},
{
name: "valid-with-token",
provider: AlertProvider{Topic: "example", Priority: 1, Token: "tk_faketoken"},
expected: true,
},
{
name: "invalid-token",
provider: AlertProvider{Topic: "example", Priority: 1, Token: "xx_faketoken"},
expected: false,
},
{
name: "invalid-topic",
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "", Priority: 1},
expected: false,
},
{
name: "invalid-priority-too-high",
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 6},
expected: false,
},
{
name: "invalid-priority-too-low",
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: -1},
expected: false,
},
{
name: "no-priority-should-use-default-value",
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example"},
expected: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
if scenario.provider.IsValid() != scenario.expected {
t.Errorf("expected %t, got %t", scenario.expected, scenario.provider.IsValid())
}
})
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1","tags":["x"],"priority":1}`,
},
{
Name: "resolved",
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 2},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2","tags":["white_check_mark"],"priority":2}`,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
}
}

View File

@@ -9,9 +9,9 @@ import (
"strconv"
"strings"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
)
const (
@@ -83,37 +83,36 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
func (provider *AlertProvider) createAlert(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
payload := provider.buildCreateRequestBody(endpoint, alert, result, resolved)
_, err := provider.sendRequest(restAPI, http.MethodPost, payload)
return err
return provider.sendRequest(restAPI, http.MethodPost, payload)
}
func (provider *AlertProvider) closeAlert(endpoint *core.Endpoint, alert *alert.Alert) error {
payload := provider.buildCloseRequestBody(endpoint, alert)
url := restAPI + "/" + provider.alias(buildKey(endpoint)) + "/close?identifierType=alias"
_, err := provider.sendRequest(url, http.MethodPost, payload)
return err
return provider.sendRequest(url, http.MethodPost, payload)
}
func (provider *AlertProvider) sendRequest(url, method string, payload interface{}) (*http.Response, error) {
func (provider *AlertProvider) sendRequest(url, method string, payload interface{}) error {
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("fail to build alert payload: %v", payload)
return fmt.Errorf("error build alert with payload %v: %w", payload, err)
}
request, err := http.NewRequest(method, url, bytes.NewBuffer(body))
if err != nil {
return nil, err
return err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "GenieKey "+provider.APIKey)
res, err := client.GetHTTPClient(nil).Do(request)
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return nil, err
return err
}
if res.StatusCode > 399 {
rBody, _ := io.ReadAll(res.Body)
return nil, fmt.Errorf("call to provider alert returned status code %d: %s", res.StatusCode, string(rBody))
defer response.Body.Close()
if response.StatusCode > 399 {
rBody, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(rBody))
}
return res, nil
return nil
}
func (provider *AlertProvider) buildCreateRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) alertCreateRequest {

View File

@@ -5,10 +5,10 @@ import (
"reflect"
"testing"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/test"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_IsValid(t *testing.T) {

View File

@@ -8,9 +8,9 @@ import (
"log"
"net/http"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
)
const (
@@ -53,7 +53,7 @@ func (provider *AlertProvider) IsValid() bool {
//
// Relevant: https://developer.pagerduty.com/docs/events-api-v2/trigger-events/
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
if err != nil {
return err
@@ -63,6 +63,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
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))
@@ -86,8 +87,21 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
return nil
}
type Body struct {
RoutingKey string `json:"routing_key"`
DedupKey string `json:"dedup_key"`
EventAction string `json:"event_action"`
Payload Payload `json:"payload"`
}
type Payload struct {
Summary string `json:"summary"`
Source string `json:"source"`
Severity string `json:"severity"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
var message, eventAction, resolveKey string
if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
@@ -98,16 +112,17 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
eventAction = "trigger"
resolveKey = ""
}
return fmt.Sprintf(`{
"routing_key": "%s",
"dedup_key": "%s",
"event_action": "%s",
"payload": {
"summary": "%s",
"source": "%s",
"severity": "critical"
}
}`, provider.getIntegrationKeyForGroup(endpoint.Group), resolveKey, eventAction, message, endpoint.Name)
body, _ := json.Marshal(Body{
RoutingKey: provider.getIntegrationKeyForGroup(endpoint.Group),
DedupKey: resolveKey,
EventAction: eventAction,
Payload: Payload{
Summary: message,
Source: "Gatus",
Severity: "critical",
},
})
return body
}
// getIntegrationKeyForGroup returns the appropriate pagerduty integration key for a given group

View File

@@ -5,10 +5,10 @@ import (
"net/http"
"testing"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/test"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_IsValid(t *testing.T) {
@@ -149,24 +149,24 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider: AlertProvider{IntegrationKey: "00000000000000000000000000000000"},
Alert: alert.Alert{Description: &description},
Resolved: false,
ExpectedBody: "{\n \"routing_key\": \"00000000000000000000000000000000\",\n \"dedup_key\": \"\",\n \"event_action\": \"trigger\",\n \"payload\": {\n \"summary\": \"TRIGGERED: - test\",\n \"source\": \"\",\n \"severity\": \"critical\"\n }\n}",
ExpectedBody: "{\"routing_key\":\"00000000000000000000000000000000\",\"dedup_key\":\"\",\"event_action\":\"trigger\",\"payload\":{\"summary\":\"TRIGGERED: endpoint-name - test\",\"source\":\"Gatus\",\"severity\":\"critical\"}}",
},
{
Name: "resolved",
Provider: AlertProvider{IntegrationKey: "00000000000000000000000000000000"},
Alert: alert.Alert{Description: &description, ResolveKey: "key"},
Resolved: true,
ExpectedBody: "{\n \"routing_key\": \"00000000000000000000000000000000\",\n \"dedup_key\": \"key\",\n \"event_action\": \"resolve\",\n \"payload\": {\n \"summary\": \"RESOLVED: - test\",\n \"source\": \"\",\n \"severity\": \"critical\"\n }\n}",
ExpectedBody: "{\"routing_key\":\"00000000000000000000000000000000\",\"dedup_key\":\"key\",\"event_action\":\"resolve\",\"payload\":{\"summary\":\"RESOLVED: endpoint-name - test\",\"source\":\"Gatus\",\"severity\":\"critical\"}}",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(&core.Endpoint{}, &scenario.Alert, &core.Result{}, scenario.Resolved)
if body != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
body := scenario.Provider.buildRequestBody(&core.Endpoint{Name: "endpoint-name"}, &scenario.Alert, &core.Result{}, 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([]byte(body), &out); err != nil {
if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})

View File

@@ -1,20 +1,25 @@
package provider
import (
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/alerting/provider/custom"
"github.com/TwiN/gatus/v4/alerting/provider/discord"
"github.com/TwiN/gatus/v4/alerting/provider/email"
"github.com/TwiN/gatus/v4/alerting/provider/googlechat"
"github.com/TwiN/gatus/v4/alerting/provider/mattermost"
"github.com/TwiN/gatus/v4/alerting/provider/messagebird"
"github.com/TwiN/gatus/v4/alerting/provider/opsgenie"
"github.com/TwiN/gatus/v4/alerting/provider/pagerduty"
"github.com/TwiN/gatus/v4/alerting/provider/slack"
"github.com/TwiN/gatus/v4/alerting/provider/teams"
"github.com/TwiN/gatus/v4/alerting/provider/telegram"
"github.com/TwiN/gatus/v4/alerting/provider/twilio"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/alerting/provider/custom"
"github.com/TwiN/gatus/v5/alerting/provider/discord"
"github.com/TwiN/gatus/v5/alerting/provider/email"
"github.com/TwiN/gatus/v5/alerting/provider/github"
"github.com/TwiN/gatus/v5/alerting/provider/gitlab"
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
"github.com/TwiN/gatus/v5/alerting/provider/ntfy"
"github.com/TwiN/gatus/v5/alerting/provider/opsgenie"
"github.com/TwiN/gatus/v5/alerting/provider/pagerduty"
"github.com/TwiN/gatus/v5/alerting/provider/pushover"
"github.com/TwiN/gatus/v5/alerting/provider/slack"
"github.com/TwiN/gatus/v5/alerting/provider/teams"
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
"github.com/TwiN/gatus/v5/core"
)
// AlertProvider is the interface that each providers should implement
@@ -56,11 +61,16 @@ var (
_ AlertProvider = (*custom.AlertProvider)(nil)
_ AlertProvider = (*discord.AlertProvider)(nil)
_ AlertProvider = (*email.AlertProvider)(nil)
_ AlertProvider = (*github.AlertProvider)(nil)
_ AlertProvider = (*gitlab.AlertProvider)(nil)
_ AlertProvider = (*googlechat.AlertProvider)(nil)
_ AlertProvider = (*matrix.AlertProvider)(nil)
_ AlertProvider = (*mattermost.AlertProvider)(nil)
_ AlertProvider = (*messagebird.AlertProvider)(nil)
_ AlertProvider = (*ntfy.AlertProvider)(nil)
_ AlertProvider = (*opsgenie.AlertProvider)(nil)
_ AlertProvider = (*pagerduty.AlertProvider)(nil)
_ AlertProvider = (*pushover.AlertProvider)(nil)
_ AlertProvider = (*slack.AlertProvider)(nil)
_ AlertProvider = (*teams.AlertProvider)(nil)
_ AlertProvider = (*telegram.AlertProvider)(nil)

View File

@@ -3,7 +3,7 @@ package provider
import (
"testing"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v5/alerting/alert"
)
func TestParseWithDefaultAlert(t *testing.T) {

View File

@@ -0,0 +1,112 @@
package pushover
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/core"
)
const (
restAPIURL = "https://api.pushover.net/1/messages.json"
defaultPriority = 0
)
// AlertProvider is the configuration necessary for sending an alert using Pushover
type AlertProvider struct {
// Key used to authenticate the application sending
// See "Your Applications" on the dashboard, or add a new one: https://pushover.net/apps/build
ApplicationToken string `yaml:"application-token"`
// Key of the user or group the messages should be sent to
UserKey string `yaml:"user-key"`
// The title of your message, likely the application name
// default: the name of your application in Pushover
Title string `yaml:"title,omitempty"`
// Priority of all messages, ranging from -2 (very low) to 2 (Emergency)
// default: 0
Priority int `yaml:"priority,omitempty"`
// Sound of the messages (see: https://pushover.net/api#sounds)
// default: "" (pushover)
Sound string `yaml:"sound,omitempty"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
if provider.Priority == 0 {
provider.Priority = defaultPriority
}
return len(provider.ApplicationToken) == 30 && len(provider.UserKey) == 30 && provider.Priority >= -2 && provider.Priority <= 2
}
// Send an alert using the provider
// Reference doc for pushover: https://pushover.net/api
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
type Body struct {
Token string `json:"token"`
User string `json:"user"`
Title string `json:"title,omitempty"`
Message string `json:"message"`
Priority int `json:"priority"`
Sound string `json:"sound,omitempty"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
var message string
if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
} else {
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
}
body, _ := json.Marshal(Body{
Token: provider.ApplicationToken,
User: provider.UserKey,
Title: provider.Title,
Message: message,
Priority: provider.priority(),
Sound: provider.Sound,
})
return body
}
func (provider *AlertProvider) priority() int {
if provider.Priority == 0 {
return defaultPriority
}
return provider.Priority
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}

View File

@@ -0,0 +1,181 @@
package pushover
import (
"encoding/json"
"net/http"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/test"
)
func TestPushoverAlertProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{}
if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{
ApplicationToken: "aTokenWithLengthOf30characters",
UserKey: "aTokenWithLengthOf30characters",
Title: "Gatus Notification",
Priority: 1,
}
if !validProvider.IsValid() {
t.Error("provider should've been valid")
}
}
func TestPushoverAlertProvider_IsInvalid(t *testing.T) {
invalidProvider := AlertProvider{
ApplicationToken: "aTokenWithLengthOfMoreThan30characters",
UserKey: "aTokenWithLengthOfMoreThan30characters",
Priority: 5,
}
if invalidProvider.IsValid() {
t.Error("provider should've been invalid")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{ApplicationToken: "TokenWithLengthOf30Characters1", UserKey: "TokenWithLengthOf30Characters4"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters1\",\"user\":\"TokenWithLengthOf30Characters4\",\"message\":\"TRIGGERED: endpoint-name - description-1\",\"priority\":0}",
},
{
Name: "resolved",
Provider: AlertProvider{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"RESOLVED: endpoint-name - description-2\",\"priority\":2}",
},
{
Name: "with-sound",
Provider: AlertProvider{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2, Sound: "falling"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"RESOLVED: endpoint-name - description-2\",\"priority\":2,\"sound\":\"falling\"}",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}

View File

@@ -2,13 +2,14 @@ package slack
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
)
// AlertProvider is the configuration necessary for sending an alert using Slack
@@ -42,7 +43,7 @@ func (provider *AlertProvider) IsValid() bool {
// Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer)
if err != nil {
return err
@@ -52,6 +53,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
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))
@@ -59,8 +61,27 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
return err
}
type Body struct {
Text string `json:"text"`
Attachments []Attachment `json:"attachments"`
}
type Attachment struct {
Title string `json:"title"`
Text string `json:"text"`
Short bool `json:"short"`
Color string `json:"color"`
Fields []Field `json:"fields"`
}
type Field struct {
Title string `json:"title"`
Value string `json:"value"`
Short bool `json:"short"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
var message, color, results string
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)
@@ -76,30 +97,31 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
} else {
prefix = ":x:"
}
results += fmt.Sprintf("%s - `%s`\\n", prefix, conditionResult.Condition)
results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\\n> " + alertDescription
description = ":\n> " + alertDescription
}
return fmt.Sprintf(`{
"text": "",
"attachments": [
{
"title": ":helmet_with_white_cross: Gatus",
"text": "%s%s",
"short": false,
"color": "%s",
"fields": [
{
"title": "Condition results",
"value": "%s",
"short": false
}
]
}
]
}`, message, description, color, results)
body, _ := json.Marshal(Body{
Text: "",
Attachments: []Attachment{
{
Title: ":helmet_with_white_cross: Gatus",
Text: message + description,
Short: false,
Color: color,
Fields: []Field{
{
Title: "Condition results",
Value: results,
Short: false,
},
},
},
},
})
return body
}
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group

View File

@@ -5,10 +5,10 @@ import (
"net/http"
"testing"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/test"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertDefaultProvider_IsValid(t *testing.T) {
@@ -20,8 +20,8 @@ func TestAlertDefaultProvider_IsValid(t *testing.T) {
if !validProvider.IsValid() {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
@@ -58,6 +58,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
@@ -152,7 +153,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Endpoint: core.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\n \"text\": \"\",\n \"attachments\": [\n {\n \"title\": \":helmet_with_white_cross: Gatus\",\n \"text\": \"An alert for *name* has been triggered due to having failed 3 time(s) in a row:\\n> description-1\",\n \"short\": false,\n \"color\": \"#DD0000\",\n \"fields\": [\n {\n \"title\": \"Condition results\",\n \"value\": \":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\n \"short\": false\n }\n ]\n }\n ]\n}",
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"short\":false,\"color\":\"#DD0000\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
},
{
Name: "triggered-with-group",
@@ -160,7 +161,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Endpoint: core.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\n \"text\": \"\",\n \"attachments\": [\n {\n \"title\": \":helmet_with_white_cross: Gatus\",\n \"text\": \"An alert for *group/name* has been triggered due to having failed 3 time(s) in a row:\\n> description-1\",\n \"short\": false,\n \"color\": \"#DD0000\",\n \"fields\": [\n {\n \"title\": \"Condition results\",\n \"value\": \":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\n \"short\": false\n }\n ]\n }\n ]\n}",
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *group/name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"short\":false,\"color\":\"#DD0000\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
},
{
Name: "resolved",
@@ -168,7 +169,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Endpoint: core.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\n \"text\": \"\",\n \"attachments\": [\n {\n \"title\": \":helmet_with_white_cross: Gatus\",\n \"text\": \"An alert for *name* has been resolved after passing successfully 5 time(s) in a row:\\n> description-2\",\n \"short\": false,\n \"color\": \"#36A64F\",\n \"fields\": [\n {\n \"title\": \"Condition results\",\n \"value\": \":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\n \"short\": false\n }\n ]\n }\n ]\n}",
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
},
{
Name: "resolved-with-group",
@@ -176,7 +177,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Endpoint: core.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\n \"text\": \"\",\n \"attachments\": [\n {\n \"title\": \":helmet_with_white_cross: Gatus\",\n \"text\": \"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row:\\n> description-2\",\n \"short\": false,\n \"color\": \"#36A64F\",\n \"fields\": [\n {\n \"title\": \"Condition results\",\n \"value\": \":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\n \"short\": false\n }\n ]\n }\n ]\n}",
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
},
}
for _, scenario := range scenarios {
@@ -192,11 +193,11 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
},
scenario.Resolved,
)
if body != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
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([]byte(body), &out); err != nil {
if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})

View File

@@ -2,13 +2,14 @@ package teams
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
)
// AlertProvider is the configuration necessary for sending an alert using Teams
@@ -44,7 +45,7 @@ func (provider *AlertProvider) IsValid() bool {
// Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer)
if err != nil {
return err
@@ -54,6 +55,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
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))
@@ -61,8 +63,22 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
return err
}
type Body struct {
Type string `json:"@type"`
Context string `json:"@context"`
ThemeColor string `json:"themeColor"`
Title string `json:"title"`
Text string `json:"text"`
Sections []Section `json:"sections"`
}
type Section struct {
ActivityTitle string `json:"activityTitle"`
Text string `json:"text"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
var message, color string
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)
@@ -83,25 +99,22 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\\n> " + alertDescription
description = ": " + alertDescription
}
return fmt.Sprintf(`{
"@type": "MessageCard",
"@context": "http://schema.org/extensions",
"themeColor": "%s",
"title": "&#x1F6A8; Gatus",
"text": "%s%s",
"sections": [
{
"activityTitle": "URL",
"text": "%s"
},
{
"activityTitle": "Condition results",
"text": "%s"
}
]
}`, color, message, description, endpoint.URL, results)
body, _ := json.Marshal(Body{
Type: "MessageCard",
Context: "http://schema.org/extensions",
ThemeColor: color,
Title: "&#x1F6A8; Gatus",
Text: message + description,
Sections: []Section{
{
ActivityTitle: "Condition results",
Text: results,
},
},
})
return body
}
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group

View File

@@ -5,10 +5,10 @@ import (
"net/http"
"testing"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/test"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertDefaultProvider_IsValid(t *testing.T) {
@@ -151,14 +151,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\n \"@type\": \"MessageCard\",\n \"@context\": \"http://schema.org/extensions\",\n \"themeColor\": \"#DD0000\",\n \"title\": \"&#x1F6A8; Gatus\",\n \"text\": \"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row:\\n> description-1\",\n \"sections\": [\n {\n \"activityTitle\": \"URL\",\n \"text\": \"\"\n },\n {\n \"activityTitle\": \"Condition results\",\n \"text\": \"&#x274C; - `[CONNECTED] == true`<br/>&#x274C; - `[STATUS] == 200`<br/>\"\n }\n ]\n}",
ExpectedBody: "{\"@type\":\"MessageCard\",\"@context\":\"http://schema.org/extensions\",\"themeColor\":\"#DD0000\",\"title\":\"\\u0026#x1F6A8; Gatus\",\"text\":\"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row: description-1\",\"sections\":[{\"activityTitle\":\"Condition results\",\"text\":\"\\u0026#x274C; - `[CONNECTED] == true`\\u003cbr/\\u003e\\u0026#x274C; - `[STATUS] == 200`\\u003cbr/\\u003e\"}]}",
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\n \"@type\": \"MessageCard\",\n \"@context\": \"http://schema.org/extensions\",\n \"themeColor\": \"#36A64F\",\n \"title\": \"&#x1F6A8; Gatus\",\n \"text\": \"An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row:\\n> description-2\",\n \"sections\": [\n {\n \"activityTitle\": \"URL\",\n \"text\": \"\"\n },\n {\n \"activityTitle\": \"Condition results\",\n \"text\": \"&#x2705; - `[CONNECTED] == true`<br/>&#x2705; - `[STATUS] == 200`<br/>\"\n }\n ]\n}",
ExpectedBody: "{\"@type\":\"MessageCard\",\"@context\":\"http://schema.org/extensions\",\"themeColor\":\"#36A64F\",\"title\":\"\\u0026#x1F6A8; Gatus\",\"text\":\"An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row: description-2\",\"sections\":[{\"activityTitle\":\"Condition results\",\"text\":\"\\u0026#x2705; - `[CONNECTED] == true`\\u003cbr/\\u003e\\u0026#x2705; - `[STATUS] == 200`\\u003cbr/\\u003e\"}]}",
},
}
for _, scenario := range scenarios {
@@ -174,11 +174,11 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
},
scenario.Resolved,
)
if body != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
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([]byte(body), &out); err != nil {
if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})

View File

@@ -2,13 +2,14 @@ package telegram
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
)
const defaultAPIURL = "https://api.telegram.org"
@@ -19,18 +20,24 @@ type AlertProvider struct {
ID string `yaml:"id"`
APIURL string `yaml:"api-url"`
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
if provider.ClientConfig == nil {
provider.ClientConfig = client.GetDefaultConfig()
}
return len(provider.Token) > 0 && len(provider.ID) > 0
}
// Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
apiURL := provider.APIURL
if apiURL == "" {
apiURL = defaultAPIURL
@@ -40,10 +47,11 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
if err != nil {
return err
}
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))
@@ -51,13 +59,19 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
return err
}
type Body struct {
ChatID string `json:"chat_id"`
Text string `json:"text"`
ParseMode string `json:"parse_mode"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
var message, results string
if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved:\\n—\\n _healthcheck passing successfully %d time(s) in a row_\\n— ", endpoint.DisplayName(), alert.FailureThreshold)
message = fmt.Sprintf("An alert for *%s* has been resolved:\n—\n _healthcheck passing successfully %d time(s) in a row_\n— ", endpoint.DisplayName(), alert.FailureThreshold)
} else {
message = fmt.Sprintf("An alert for *%s* has been triggered:\\n—\\n _healthcheck failed %d time(s) in a row_\\n— ", endpoint.DisplayName(), alert.FailureThreshold)
message = fmt.Sprintf("An alert for *%s* has been triggered:\n—\n _healthcheck failed %d time(s) in a row_\n— ", endpoint.DisplayName(), alert.FailureThreshold)
}
for _, conditionResult := range result.ConditionResults {
var prefix string
@@ -66,15 +80,20 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
} else {
prefix = "❌"
}
results += fmt.Sprintf("%s - `%s`\\n", prefix, conditionResult.Condition)
results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
}
var text string
if len(alert.GetDescription()) > 0 {
text = fmt.Sprintf("⛑ *Gatus* \\n%s \\n*Description* \\n_%s_ \\n\\n*Condition results*\\n%s", message, alert.GetDescription(), results)
text = fmt.Sprintf("⛑ *Gatus* \n%s \n*Description* \n_%s_ \n\n*Condition results*\n%s", message, alert.GetDescription(), results)
} else {
text = fmt.Sprintf("⛑ *Gatus* \\n%s \\n*Condition results*\\n%s", message, results)
text = fmt.Sprintf("⛑ *Gatus* \n%s \n*Condition results*\n%s", message, results)
}
return fmt.Sprintf(`{"chat_id": "%s", "text": "%s", "parse_mode": "MARKDOWN"}`, provider.ID, text)
body, _ := json.Marshal(Body{
ChatID: provider.ID,
Text: text,
ParseMode: "MARKDOWN",
})
return body
}
// GetDefaultAlert returns the provider's default alert configuration

View File

@@ -5,21 +5,31 @@ import (
"net/http"
"testing"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/test"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{Token: "", ID: ""}
if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}
if !validProvider.IsValid() {
t.Error("provider should've been valid")
}
t.Run("invalid-provider", func(t *testing.T) {
invalidProvider := AlertProvider{Token: "", ID: ""}
if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid")
}
})
t.Run("valid-provider", func(t *testing.T) {
validProvider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}
if validProvider.ClientConfig != nil {
t.Error("provider client config should have been nil prior to IsValid() being executed")
}
if !validProvider.IsValid() {
t.Error("provider should've been valid")
}
if validProvider.ClientConfig == nil {
t.Error("provider client config should have been set after IsValid() was executed")
}
})
}
func TestAlertProvider_Send(t *testing.T) {
@@ -114,14 +124,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider: AlertProvider{ID: "123"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"chat_id\": \"123\", \"text\": \"⛑ *Gatus* \\nAn alert for *endpoint-name* has been triggered:\\n—\\n _healthcheck failed 3 time(s) in a row_\\n— \\n*Description* \\n_description-1_ \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\", \"parse_mode\": \"MARKDOWN\"}",
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been triggered:\\n—\\n _healthcheck failed 3 time(s) in a row_\\n— \\n*Description* \\n_description-1_ \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
},
{
Name: "resolved",
Provider: AlertProvider{ID: "123"},
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 3 time(s) in a row_\\n— \\n*Description* \\n_description-2_ \\n\\n*Condition results*\\n✅ - `[CONNECTED] == true`\\n✅ - `[STATUS] == 200`\\n\", \"parse_mode\": \"MARKDOWN\"}",
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 3 time(s) in a row_\\n— \\n*Description* \\n_description-2_ \\n\\n*Condition results*\\n✅ - `[CONNECTED] == true`\\n✅ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
},
}
for _, scenario := range scenarios {
@@ -137,11 +147,11 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
},
scenario.Resolved,
)
if body != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
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([]byte(body), &out); err != nil {
if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})

View File

@@ -8,9 +8,9 @@ import (
"net/http"
"net/url"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
)
// AlertProvider is the configuration necessary for sending an alert using Twilio
@@ -42,6 +42,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
if err != nil {
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))

View File

@@ -3,8 +3,8 @@ package twilio
import (
"testing"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/core"
)
func TestTwilioAlertProvider_IsValid(t *testing.T) {

112
api/api.go Normal file
View File

@@ -0,0 +1,112 @@
package api
import (
"io/fs"
"log"
"net/http"
"os"
"github.com/TwiN/gatus/v5/config"
static "github.com/TwiN/gatus/v5/web"
"github.com/TwiN/health"
fiber "github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/adaptor"
"github.com/gofiber/fiber/v2/middleware/compress"
"github.com/gofiber/fiber/v2/middleware/cors"
fiberfs "github.com/gofiber/fiber/v2/middleware/filesystem"
"github.com/gofiber/fiber/v2/middleware/recover"
"github.com/gofiber/fiber/v2/middleware/redirect"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
type API struct {
router *fiber.App
}
func New(cfg *config.Config) *API {
api := &API{}
api.router = api.createRouter(cfg)
return api
}
func (a *API) Router() *fiber.App {
return a.router
}
func (a *API) createRouter(cfg *config.Config) *fiber.App {
app := fiber.New(fiber.Config{
ErrorHandler: func(c *fiber.Ctx, err error) error {
log.Printf("[api.ErrorHandler] %s", err.Error())
return fiber.DefaultErrorHandler(c, err)
},
})
if os.Getenv("ENVIRONMENT") == "dev" {
app.Use(cors.New(cors.Config{
AllowOrigins: "http://localhost:8081",
AllowCredentials: true,
}))
}
// Middlewares
app.Use(recover.New())
app.Use(compress.New())
// Define metrics handler, if necessary
if cfg.Metrics {
metricsHandler := promhttp.InstrumentMetricHandler(prometheus.DefaultRegisterer, promhttp.HandlerFor(prometheus.DefaultGatherer, promhttp.HandlerOpts{
DisableCompression: true,
}))
app.Get("/metrics", adaptor.HTTPHandler(metricsHandler))
}
// Define main router
apiRouter := app.Group("/api")
////////////////////////
// UNPROTECTED ROUTES //
////////////////////////
unprotectedAPIRouter := apiRouter.Group("/")
unprotectedAPIRouter.Get("/v1/config", ConfigHandler{securityConfig: cfg.Security}.GetConfig)
unprotectedAPIRouter.Get("/v1/endpoints/:key/health/badge.svg", HealthBadge)
unprotectedAPIRouter.Get("/v1/endpoints/:key/uptimes/:duration/badge.svg", UptimeBadge)
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/badge.svg", ResponseTimeBadge(cfg))
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/chart.svg", ResponseTimeChart)
// SPA
app.Get("/", SinglePageApplication(cfg.UI))
app.Get("/endpoints/:name", SinglePageApplication(cfg.UI))
// Health endpoint
healthHandler := health.Handler().WithJSON(true)
app.Get("/health", func(c *fiber.Ctx) error {
statusCode, body := healthHandler.GetResponseStatusCodeAndBody()
return c.Status(statusCode).Send(body)
})
// Everything else falls back on static content
app.Use(redirect.New(redirect.Config{
Rules: map[string]string{
"/index.html": "/",
},
StatusCode: 301,
}))
staticFileSystem, err := fs.Sub(static.FileSystem, static.RootPath)
if err != nil {
panic(err)
}
app.Use("/", fiberfs.New(fiberfs.Config{
Root: http.FS(staticFileSystem),
Index: "index.html",
Browse: true,
}))
//////////////////////
// PROTECTED ROUTES //
//////////////////////
// ORDER IS IMPORTANT: all routes applied AFTER the security middleware will require authn
protectedAPIRouter := apiRouter.Group("/")
if cfg.Security != nil {
if err := cfg.Security.RegisterHandlers(app); err != nil {
panic(err)
}
if err := cfg.Security.ApplySecurityMiddleware(protectedAPIRouter); err != nil {
panic(err)
}
}
protectedAPIRouter.Get("/v1/endpoints/statuses", EndpointStatuses(cfg))
protectedAPIRouter.Get("/v1/endpoints/:key/statuses", EndpointStatus)
return app
}

121
api/api_test.go Normal file
View File

@@ -0,0 +1,121 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/ui"
"github.com/TwiN/gatus/v5/security"
"github.com/gofiber/fiber/v2"
)
func TestNew(t *testing.T) {
type Scenario struct {
Name string
Path string
ExpectedCode int
Gzip bool
WithSecurity bool
}
scenarios := []Scenario{
{
Name: "health",
Path: "/health",
ExpectedCode: fiber.StatusOK,
},
{
Name: "metrics",
Path: "/metrics",
ExpectedCode: fiber.StatusOK,
},
{
Name: "favicon.ico",
Path: "/favicon.ico",
ExpectedCode: fiber.StatusOK,
},
{
Name: "app.js",
Path: "/js/app.js",
ExpectedCode: fiber.StatusOK,
},
{
Name: "app.js-gzipped",
Path: "/js/app.js",
ExpectedCode: fiber.StatusOK,
Gzip: true,
},
{
Name: "chunk-vendors.js",
Path: "/js/chunk-vendors.js",
ExpectedCode: fiber.StatusOK,
},
{
Name: "chunk-vendors.js-gzipped",
Path: "/js/chunk-vendors.js",
ExpectedCode: fiber.StatusOK,
Gzip: true,
},
{
Name: "index",
Path: "/",
ExpectedCode: fiber.StatusOK,
},
{
Name: "index-html-redirect",
Path: "/index.html",
ExpectedCode: fiber.StatusMovedPermanently,
},
{
Name: "index-should-return-200-even-if-not-authenticated",
Path: "/",
ExpectedCode: fiber.StatusOK,
WithSecurity: true,
},
{
Name: "endpoints-should-return-401-if-not-authenticated",
Path: "/api/v1/endpoints/statuses",
ExpectedCode: fiber.StatusUnauthorized,
WithSecurity: true,
},
{
Name: "config-should-return-200-even-if-not-authenticated",
Path: "/api/v1/config",
ExpectedCode: fiber.StatusOK,
WithSecurity: true,
},
{
Name: "config-should-always-return-200",
Path: "/api/v1/config",
ExpectedCode: fiber.StatusOK,
WithSecurity: false,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
cfg := &config.Config{Metrics: true, UI: &ui.Config{}}
if scenario.WithSecurity {
cfg.Security = &security.Config{
Basic: &security.BasicConfig{
Username: "john.doe",
PasswordBcryptHashBase64Encoded: "JDJhJDA4JDFoRnpPY1hnaFl1OC9ISlFsa21VS09wOGlPU1ZOTDlHZG1qeTFvb3dIckRBUnlHUmNIRWlT",
},
}
}
api := New(cfg)
router := api.Router()
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
if scenario.Gzip {
request.Header.Set("Accept-Encoding", "gzip")
}
response, err := router.Test(request)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != scenario.ExpectedCode {
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)
}
})
}
}

View File

@@ -1,16 +1,16 @@
package handler
package api
import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/TwiN/gatus/v4/storage/store"
"github.com/TwiN/gatus/v4/storage/store/common"
"github.com/TwiN/gatus/v4/storage/store/common/paging"
"github.com/gorilla/mux"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/storage/store/common"
"github.com/TwiN/gatus/v5/storage/store/common/paging"
"github.com/gofiber/fiber/v2"
)
const (
@@ -28,12 +28,15 @@ const (
HealthStatusUnknown = "?"
)
var (
badgeColors = []string{badgeColorHexAwesome, badgeColorHexGreat, badgeColorHexGood, badgeColorHexPassable, badgeColorHexBad}
)
// UptimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.
//
// Valid values for {duration}: 7d, 24h, 1h
func UptimeBadge(writer http.ResponseWriter, request *http.Request) {
variables := mux.Vars(request)
duration := variables["duration"]
// Valid values for :duration -> 7d, 24h, 1h
func UptimeBadge(c *fiber.Ctx) error {
duration := c.Params("duration")
var from time.Time
switch duration {
case "7d":
@@ -43,80 +46,70 @@ func UptimeBadge(writer http.ResponseWriter, request *http.Request) {
case "1h":
from = time.Now().Add(-2 * time.Hour) // Because uptime metrics are stored by hour, we have to cheat a little
default:
http.Error(writer, "Durations supported: 7d, 24h, 1h", http.StatusBadRequest)
return
return c.Status(400).SendString("Durations supported: 7d, 24h, 1h")
}
key := variables["key"]
key := c.Params("key")
uptime, err := store.Get().GetUptimeByKey(key, from, time.Now())
if err != nil {
if err == common.ErrEndpointNotFound {
http.Error(writer, err.Error(), http.StatusNotFound)
return c.Status(404).SendString(err.Error())
} else if err == common.ErrInvalidTimeRange {
http.Error(writer, err.Error(), http.StatusBadRequest)
} else {
http.Error(writer, err.Error(), http.StatusInternalServerError)
return c.Status(400).SendString(err.Error())
}
return
return c.Status(500).SendString(err.Error())
}
writer.Header().Set("Content-Type", "image/svg+xml")
writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
writer.Header().Set("Expires", "0")
writer.WriteHeader(http.StatusOK)
_, _ = writer.Write(generateUptimeBadgeSVG(duration, uptime))
c.Set("Content-Type", "image/svg+xml")
c.Set("Cache-Control", "no-cache, no-store, must-revalidate")
c.Set("Expires", "0")
return c.Status(200).Send(generateUptimeBadgeSVG(duration, uptime))
}
// ResponseTimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.
//
// Valid values for {duration}: 7d, 24h, 1h
func ResponseTimeBadge(writer http.ResponseWriter, request *http.Request) {
variables := mux.Vars(request)
duration := variables["duration"]
var from time.Time
switch duration {
case "7d":
from = time.Now().Add(-7 * 24 * time.Hour)
case "24h":
from = time.Now().Add(-24 * time.Hour)
case "1h":
from = time.Now().Add(-2 * time.Hour) // Because response time metrics are stored by hour, we have to cheat a little
default:
http.Error(writer, "Durations supported: 7d, 24h, 1h", http.StatusBadRequest)
return
}
key := variables["key"]
averageResponseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now())
if err != nil {
if err == common.ErrEndpointNotFound {
http.Error(writer, err.Error(), http.StatusNotFound)
} else if err == common.ErrInvalidTimeRange {
http.Error(writer, err.Error(), http.StatusBadRequest)
} else {
http.Error(writer, err.Error(), http.StatusInternalServerError)
// Valid values for :duration -> 7d, 24h, 1h
func ResponseTimeBadge(config *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
duration := c.Params("duration")
var from time.Time
switch duration {
case "7d":
from = time.Now().Add(-7 * 24 * time.Hour)
case "24h":
from = time.Now().Add(-24 * time.Hour)
case "1h":
from = time.Now().Add(-2 * time.Hour) // Because response time metrics are stored by hour, we have to cheat a little
default:
return c.Status(400).SendString("Durations supported: 7d, 24h, 1h")
}
return
key := c.Params("key")
averageResponseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now())
if err != nil {
if err == common.ErrEndpointNotFound {
return c.Status(404).SendString(err.Error())
} else if err == common.ErrInvalidTimeRange {
return c.Status(400).SendString(err.Error())
}
return c.Status(500).SendString(err.Error())
}
c.Set("Content-Type", "image/svg+xml")
c.Set("Cache-Control", "no-cache, no-store, must-revalidate")
c.Set("Expires", "0")
return c.Status(200).Send(generateResponseTimeBadgeSVG(duration, averageResponseTime, key, config))
}
writer.Header().Set("Content-Type", "image/svg+xml")
writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
writer.Header().Set("Expires", "0")
writer.WriteHeader(http.StatusOK)
_, _ = writer.Write(generateResponseTimeBadgeSVG(duration, averageResponseTime))
}
// HealthBadge handles the automatic generation of badge based on the group name and endpoint name passed.
func HealthBadge(writer http.ResponseWriter, request *http.Request) {
variables := mux.Vars(request)
key := variables["key"]
func HealthBadge(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 err == common.ErrEndpointNotFound {
http.Error(writer, err.Error(), http.StatusNotFound)
return c.Status(404).SendString(err.Error())
} else if err == common.ErrInvalidTimeRange {
http.Error(writer, err.Error(), http.StatusBadRequest)
} else {
http.Error(writer, err.Error(), http.StatusInternalServerError)
return c.Status(400).SendString(err.Error())
}
return
return c.Status(500).SendString(err.Error())
}
healthStatus := HealthStatusUnknown
if len(status.Results) > 0 {
@@ -126,11 +119,10 @@ func HealthBadge(writer http.ResponseWriter, request *http.Request) {
healthStatus = HealthStatusDown
}
}
writer.Header().Set("Content-Type", "image/svg+xml")
writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
writer.Header().Set("Expires", "0")
writer.WriteHeader(http.StatusOK)
_, _ = writer.Write(generateHealthBadgeSVG(healthStatus))
c.Set("Content-Type", "image/svg+xml")
c.Set("Cache-Control", "no-cache, no-store, must-revalidate")
c.Set("Expires", "0")
return c.Status(200).Send(generateHealthBadgeSVG(healthStatus))
}
func generateUptimeBadgeSVG(duration string, uptime float64) []byte {
@@ -199,7 +191,7 @@ func getBadgeColorFromUptime(uptime float64) string {
return badgeColorHexVeryBad
}
func generateResponseTimeBadgeSVG(duration string, averageResponseTime int) []byte {
func generateResponseTimeBadgeSVG(duration string, averageResponseTime int, key string, cfg *config.Config) []byte {
var labelWidth, valueWidth int
switch duration {
case "7d":
@@ -210,7 +202,7 @@ func generateResponseTimeBadgeSVG(duration string, averageResponseTime int) []by
labelWidth = 105
default:
}
color := getBadgeColorFromResponseTime(averageResponseTime)
color := getBadgeColorFromResponseTime(averageResponseTime, key, cfg)
sanitizedValue := strconv.Itoa(averageResponseTime) + "ms"
valueWidth = len(sanitizedValue) * 11
width := labelWidth + valueWidth
@@ -247,17 +239,13 @@ func generateResponseTimeBadgeSVG(duration string, averageResponseTime int) []by
return svg
}
func getBadgeColorFromResponseTime(responseTime int) string {
if responseTime <= 50 {
return badgeColorHexAwesome
} else if responseTime <= 200 {
return badgeColorHexGreat
} else if responseTime <= 300 {
return badgeColorHexGood
} else if responseTime <= 500 {
return badgeColorHexPassable
} else if responseTime <= 750 {
return badgeColorHexBad
func getBadgeColorFromResponseTime(responseTime int, key string, cfg *config.Config) string {
endpoint := cfg.GetEndpointByKey(key)
// the threshold config requires 5 values, so we can be sure it's set here
for i := 0; i < 5; i++ {
if responseTime <= endpoint.UIConfig.Badge.ResponseTime.Thresholds[i] {
return badgeColors[i]
}
}
return badgeColorHexVeryBad
}

View File

@@ -1,4 +1,4 @@
package handler
package api
import (
"net/http"
@@ -7,10 +7,11 @@ import (
"testing"
"time"
"github.com/TwiN/gatus/v4/config"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/storage/store"
"github.com/TwiN/gatus/v4/watchdog"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/core/ui"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/watchdog"
)
func TestBadge(t *testing.T) {
@@ -29,9 +30,14 @@ func TestBadge(t *testing.T) {
},
},
}
cfg.Endpoints[0].UIConfig = ui.GetDefaultConfig()
cfg.Endpoints[1].UIConfig = ui.GetDefaultConfig()
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Connected: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Connected: false, Duration: time.Second, Timestamp: time.Now()})
router := CreateRouter("../../web/static", cfg.Security, nil, cfg.Metrics)
api := New(cfg)
router := api.Router()
type Scenario struct {
Name string
Path string
@@ -109,17 +115,29 @@ func TestBadge(t *testing.T) {
Path: "/api/v1/endpoints/core_backend/response-times/24h/chart.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "chart-response-time-7d",
Path: "/api/v1/endpoints/core_frontend/response-times/7d/chart.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "chart-response-time-with-invalid-duration",
Path: "/api/v1/endpoints/core_backend/response-times/3d/chart.svg",
ExpectedCode: http.StatusBadRequest,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
request, _ := http.NewRequest("GET", scenario.Path, http.NoBody)
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
if scenario.Gzip {
request.Header.Set("Accept-Encoding", "gzip")
}
responseRecorder := httptest.NewRecorder()
router.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != scenario.ExpectedCode {
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
response, err := router.Test(request)
if err != nil {
return
}
if response.StatusCode != scenario.ExpectedCode {
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)
}
})
}
@@ -181,55 +199,154 @@ func TestGetBadgeColorFromUptime(t *testing.T) {
}
func TestGetBadgeColorFromResponseTime(t *testing.T) {
defer store.Get().Clear()
defer cache.Clear()
var (
firstCondition = core.Condition("[STATUS] == 200")
secondCondition = core.Condition("[RESPONSE_TIME] < 500")
thirdCondition = core.Condition("[CERTIFICATE_EXPIRATION] < 72h")
)
firstTestEndpoint := core.Endpoint{
Name: "a",
URL: "https://example.org/what/ever",
Method: "GET",
Body: "body",
Interval: 30 * time.Second,
Conditions: []core.Condition{firstCondition, secondCondition, thirdCondition},
Alerts: nil,
NumberOfFailuresInARow: 0,
NumberOfSuccessesInARow: 0,
UIConfig: ui.GetDefaultConfig(),
}
secondTestEndpoint := core.Endpoint{
Name: "b",
URL: "https://example.org/what/ever",
Method: "GET",
Body: "body",
Interval: 30 * time.Second,
Conditions: []core.Condition{firstCondition, secondCondition, thirdCondition},
Alerts: nil,
NumberOfFailuresInARow: 0,
NumberOfSuccessesInARow: 0,
UIConfig: &ui.Config{
Badge: &ui.Badge{
ResponseTime: &ui.ResponseTime{
Thresholds: []int{100, 500, 1000, 2000, 3000},
},
},
},
}
cfg := &config.Config{
Metrics: true,
Endpoints: []*core.Endpoint{&firstTestEndpoint, &secondTestEndpoint},
}
testSuccessfulResult := core.Result{
Hostname: "example.org",
IP: "127.0.0.1",
HTTPStatus: 200,
Errors: nil,
Connected: true,
Success: true,
Timestamp: time.Now(),
Duration: 150 * time.Millisecond,
CertificateExpiration: 10 * time.Hour,
ConditionResults: []*core.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: true,
},
{
Condition: "[RESPONSE_TIME] < 500",
Success: true,
},
{
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
Success: true,
},
},
}
store.Get().Insert(&firstTestEndpoint, &testSuccessfulResult)
store.Get().Insert(&secondTestEndpoint, &testSuccessfulResult)
scenarios := []struct {
Key string
ResponseTime int
ExpectedColor string
}{
{
Key: firstTestEndpoint.Key(),
ResponseTime: 10,
ExpectedColor: badgeColorHexAwesome,
},
{
Key: firstTestEndpoint.Key(),
ResponseTime: 50,
ExpectedColor: badgeColorHexAwesome,
},
{
Key: firstTestEndpoint.Key(),
ResponseTime: 75,
ExpectedColor: badgeColorHexGreat,
},
{
Key: firstTestEndpoint.Key(),
ResponseTime: 150,
ExpectedColor: badgeColorHexGreat,
},
{
Key: firstTestEndpoint.Key(),
ResponseTime: 201,
ExpectedColor: badgeColorHexGood,
},
{
Key: firstTestEndpoint.Key(),
ResponseTime: 300,
ExpectedColor: badgeColorHexGood,
},
{
Key: firstTestEndpoint.Key(),
ResponseTime: 301,
ExpectedColor: badgeColorHexPassable,
},
{
Key: firstTestEndpoint.Key(),
ResponseTime: 450,
ExpectedColor: badgeColorHexPassable,
},
{
Key: firstTestEndpoint.Key(),
ResponseTime: 700,
ExpectedColor: badgeColorHexBad,
},
{
Key: firstTestEndpoint.Key(),
ResponseTime: 1500,
ExpectedColor: badgeColorHexVeryBad,
},
{
Key: secondTestEndpoint.Key(),
ResponseTime: 50,
ExpectedColor: badgeColorHexAwesome,
},
{
Key: secondTestEndpoint.Key(),
ResponseTime: 1500,
ExpectedColor: badgeColorHexPassable,
},
{
Key: secondTestEndpoint.Key(),
ResponseTime: 2222,
ExpectedColor: badgeColorHexBad,
},
}
for _, scenario := range scenarios {
t.Run("response-time-"+strconv.Itoa(scenario.ResponseTime), func(t *testing.T) {
if getBadgeColorFromResponseTime(scenario.ResponseTime) != scenario.ExpectedColor {
t.Errorf("expected %s from %d, got %v", scenario.ExpectedColor, scenario.ResponseTime, getBadgeColorFromResponseTime(scenario.ResponseTime))
t.Run(scenario.Key+"-response-time-"+strconv.Itoa(scenario.ResponseTime), func(t *testing.T) {
if getBadgeColorFromResponseTime(scenario.ResponseTime, scenario.Key, cfg) != scenario.ExpectedColor {
t.Errorf("expected %s from %d, got %v", scenario.ExpectedColor, scenario.ResponseTime, getBadgeColorFromResponseTime(scenario.ResponseTime, scenario.Key, cfg))
}
})
}

15
api/cache.go Normal file
View File

@@ -0,0 +1,15 @@
package api
import (
"time"
"github.com/TwiN/gocache/v2"
)
const (
cacheTTL = 10 * time.Second
)
var (
cache = gocache.NewCache().WithMaxSize(100).WithEvictionPolicy(gocache.FirstInFirstOut)
)

View File

@@ -1,4 +1,4 @@
package handler
package api
import (
"log"
@@ -7,9 +7,9 @@ import (
"sort"
"time"
"github.com/TwiN/gatus/v4/storage/store"
"github.com/TwiN/gatus/v4/storage/store/common"
"github.com/gorilla/mux"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/storage/store/common"
"github.com/gofiber/fiber/v2"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
)
@@ -29,9 +29,8 @@ var (
}
)
func ResponseTimeChart(writer http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
duration := vars["duration"]
func ResponseTimeChart(c *fiber.Ctx) error {
duration := c.Params("duration")
var from time.Time
switch duration {
case "7d":
@@ -39,23 +38,19 @@ func ResponseTimeChart(writer http.ResponseWriter, r *http.Request) {
case "24h":
from = time.Now().Truncate(time.Hour).Add(-24 * time.Hour)
default:
http.Error(writer, "Durations supported: 7d, 24h", http.StatusBadRequest)
return
return c.Status(400).SendString("Durations supported: 7d, 24h")
}
hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(vars["key"], from, time.Now())
hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(c.Params("key"), from, time.Now())
if err != nil {
if err == common.ErrEndpointNotFound {
http.Error(writer, err.Error(), http.StatusNotFound)
return c.Status(404).SendString(err.Error())
} else if err == common.ErrInvalidTimeRange {
http.Error(writer, err.Error(), http.StatusBadRequest)
} else {
http.Error(writer, err.Error(), http.StatusInternalServerError)
return c.Status(400).SendString(err.Error())
}
return
return c.Status(500).SendString(err.Error())
}
if len(hourlyAverageResponseTime) == 0 {
http.Error(writer, "", http.StatusNoContent)
return
return c.Status(204).SendString("")
}
series := chart.TimeSeries{
Name: "Average response time per hour",
@@ -111,12 +106,13 @@ func ResponseTimeChart(writer http.ResponseWriter, r *http.Request) {
},
Series: []chart.Series{series},
}
writer.Header().Set("Content-Type", "image/svg+xml")
writer.Header().Set("Cache-Control", "no-cache, no-store")
writer.Header().Set("Expires", "0")
writer.WriteHeader(http.StatusOK)
if err := graph.Render(chart.SVG, writer); err != nil {
log.Println("[handler][ResponseTimeChart] Failed to render response time chart:", err.Error())
return
c.Set("Content-Type", "image/svg+xml")
c.Set("Cache-Control", "no-cache, no-store")
c.Set("Expires", "0")
c.Status(http.StatusOK)
if err := graph.Render(chart.SVG, c); err != nil {
log.Println("[api][ResponseTimeChart] Failed to render response time chart:", err.Error())
return c.Status(500).SendString(err.Error())
}
return nil
}

View File

@@ -1,4 +1,4 @@
package handler
package api
import (
"net/http"
@@ -6,10 +6,10 @@ import (
"testing"
"time"
"github.com/TwiN/gatus/v4/config"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/storage/store"
"github.com/TwiN/gatus/v4/watchdog"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/watchdog"
)
func TestResponseTimeChart(t *testing.T) {
@@ -30,7 +30,8 @@ func TestResponseTimeChart(t *testing.T) {
}
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
router := CreateRouter("../../web/static", cfg.Security, nil, cfg.Metrics)
api := New(cfg)
router := api.Router()
type Scenario struct {
Name string
Path string
@@ -61,14 +62,16 @@ func TestResponseTimeChart(t *testing.T) {
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
request, _ := http.NewRequest("GET", scenario.Path, http.NoBody)
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
if scenario.Gzip {
request.Header.Set("Accept-Encoding", "gzip")
}
responseRecorder := httptest.NewRecorder()
router.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != scenario.ExpectedCode {
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
response, err := router.Test(request)
if err != nil {
return
}
if response.StatusCode != scenario.ExpectedCode {
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)
}
})
}

25
api/config.go Normal file
View File

@@ -0,0 +1,25 @@
package api
import (
"fmt"
"github.com/TwiN/gatus/v5/security"
"github.com/gofiber/fiber/v2"
)
type ConfigHandler struct {
securityConfig *security.Config
}
func (handler ConfigHandler) GetConfig(c *fiber.Ctx) error {
hasOIDC := false
isAuthenticated := true // Default to true if no security config is set
if handler.securityConfig != nil {
hasOIDC = handler.securityConfig.OIDC != nil
isAuthenticated = handler.securityConfig.IsAuthenticated(c)
}
// Return the config
c.Set("Content-Type", "application/json")
return c.Status(200).
SendString(fmt.Sprintf(`{"oidc":%v,"authenticated":%v}`, hasOIDC, isAuthenticated))
}

46
api/config_test.go Normal file
View File

@@ -0,0 +1,46 @@
package api
import (
"io"
"net/http"
"testing"
"github.com/TwiN/gatus/v5/security"
"github.com/gofiber/fiber/v2"
)
func TestConfigHandler_ServeHTTP(t *testing.T) {
securityConfig := &security.Config{
OIDC: &security.OIDCConfig{
IssuerURL: "https://sso.gatus.io/",
RedirectURL: "http://localhost:80/authorization-code/callback",
Scopes: []string{"openid"},
AllowedSubjects: []string{"user1@example.com"},
},
}
handler := ConfigHandler{securityConfig: securityConfig}
// Create a fake router. We're doing this because I need the gate to be initialized.
app := fiber.New()
app.Get("/api/v1/config", handler.GetConfig)
err := securityConfig.ApplySecurityMiddleware(app)
if err != nil {
t.Error("expected err to be nil, but was", err)
}
// Test the config handler
request, _ := http.NewRequest("GET", "/api/v1/config", http.NoBody)
response, err := app.Test(request)
if err != nil {
t.Error("expected err to be nil, but was", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
t.Error("expected code to be 200, but was", response.StatusCode)
}
body, err := io.ReadAll(response.Body)
if err != nil {
t.Error("expected err to be nil, but was", err)
}
if string(body) != `{"oidc":true,"authenticated":false}` {
t.Error("expected body to be `{\"oidc\":true,\"authenticated\":false}`, but was", string(body))
}
}

107
api/endpoint_status.go Normal file
View File

@@ -0,0 +1,107 @@
package api
import (
"encoding/json"
"fmt"
"io"
"log"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/remote"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/storage/store/common"
"github.com/TwiN/gatus/v5/storage/store/common/paging"
"github.com/gofiber/fiber/v2"
)
// EndpointStatuses handles requests to retrieve all EndpointStatus
// Due to how intensive this operation can be on the storage, this function leverages a cache.
func EndpointStatuses(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
page, pageSize := extractPageAndPageSizeFromRequest(c)
value, exists := cache.Get(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize))
var data []byte
if !exists {
endpointStatuses, err := store.Get().GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(page, pageSize))
if err != nil {
log.Printf("[api][EndpointStatuses] Failed to retrieve endpoint statuses: %s", err.Error())
return c.Status(500).SendString(err.Error())
}
// ALPHA: Retrieve endpoint statuses from remote instances
if endpointStatusesFromRemote, err := getEndpointStatusesFromRemoteInstances(cfg.Remote); err != nil {
log.Printf("[handler][EndpointStatuses] Silently failed to retrieve endpoint statuses from remote: %s", err.Error())
} else if endpointStatusesFromRemote != nil {
endpointStatuses = append(endpointStatuses, endpointStatusesFromRemote...)
}
// Marshal endpoint statuses to JSON
data, err = json.Marshal(endpointStatuses)
if err != nil {
log.Printf("[api][EndpointStatuses] Unable to marshal object to JSON: %s", err.Error())
return c.Status(500).SendString("unable to marshal object to JSON")
}
cache.SetWithTTL(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize), data, cacheTTL)
} else {
data = value.([]byte)
}
c.Set("Content-Type", "application/json")
return c.Status(200).Send(data)
}
}
func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*core.EndpointStatus, error) {
if remoteConfig == nil || len(remoteConfig.Instances) == 0 {
return nil, nil
}
var endpointStatusesFromAllRemotes []*core.EndpointStatus
httpClient := client.GetHTTPClient(remoteConfig.ClientConfig)
for _, instance := range remoteConfig.Instances {
response, err := httpClient.Get(instance.URL)
if err != nil {
return nil, err
}
body, err := io.ReadAll(response.Body)
if err != nil {
_ = response.Body.Close()
log.Printf("[handler][getEndpointStatusesFromRemoteInstances] Silently failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error())
continue
}
var endpointStatuses []*core.EndpointStatus
if err = json.Unmarshal(body, &endpointStatuses); err != nil {
_ = response.Body.Close()
log.Printf("[handler][getEndpointStatusesFromRemoteInstances] Silently failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error())
continue
}
_ = response.Body.Close()
for _, endpointStatus := range endpointStatuses {
endpointStatus.Name = instance.EndpointPrefix + endpointStatus.Name
}
endpointStatusesFromAllRemotes = append(endpointStatusesFromAllRemotes, endpointStatuses...)
}
return endpointStatusesFromAllRemotes, nil
}
// EndpointStatus retrieves a single core.EndpointStatus by group and endpoint name
func EndpointStatus(c *fiber.Ctx) error {
page, pageSize := extractPageAndPageSizeFromRequest(c)
endpointStatus, err := store.Get().GetEndpointStatusByKey(c.Params("key"), paging.NewEndpointStatusParams().WithResults(page, pageSize).WithEvents(1, common.MaximumNumberOfEvents))
if err != nil {
if err == common.ErrEndpointNotFound {
return c.Status(404).SendString(err.Error())
}
log.Printf("[api][EndpointStatus] Failed to retrieve endpoint status: %s", err.Error())
return c.Status(500).SendString(err.Error())
}
if endpointStatus == nil { // XXX: is this check necessary?
log.Printf("[api][EndpointStatus] Endpoint with key=%s not found", c.Params("key"))
return c.Status(404).SendString("not found")
}
output, err := json.Marshal(endpointStatus)
if err != nil {
log.Printf("[api][EndpointStatus] Unable to marshal object to JSON: %s", err.Error())
return c.Status(500).SendString("unable to marshal object to JSON")
}
c.Set("Content-Type", "application/json")
return c.Status(200).Send(output)
}

View File

@@ -1,15 +1,16 @@
package handler
package api
import (
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/TwiN/gatus/v4/config"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/storage/store"
"github.com/TwiN/gatus/v4/watchdog"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/watchdog"
)
var (
@@ -97,8 +98,8 @@ func TestEndpointStatus(t *testing.T) {
}
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
router := CreateRouter("../../web/static", cfg.Security, nil, cfg.Metrics)
api := New(cfg)
router := api.Router()
type Scenario struct {
Name string
Path string
@@ -130,14 +131,16 @@ func TestEndpointStatus(t *testing.T) {
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
request, _ := http.NewRequest("GET", scenario.Path, http.NoBody)
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
if scenario.Gzip {
request.Header.Set("Accept-Encoding", "gzip")
}
responseRecorder := httptest.NewRecorder()
router.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != scenario.ExpectedCode {
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
response, err := router.Test(request)
if err != nil {
return
}
if response.StatusCode != scenario.ExpectedCode {
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)
}
})
}
@@ -153,8 +156,8 @@ func TestEndpointStatuses(t *testing.T) {
// Can't be bothered dealing with timezone issues on the worker that runs the automated tests
firstResult.Timestamp = time.Time{}
secondResult.Timestamp = time.Time{}
router := CreateRouter("../../web/static", nil, nil, false)
api := New(&config.Config{Metrics: true})
router := api.Router()
type Scenario struct {
Name string
Path string
@@ -196,15 +199,21 @@ func TestEndpointStatuses(t *testing.T) {
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
request, _ := http.NewRequest("GET", scenario.Path, http.NoBody)
responseRecorder := httptest.NewRecorder()
router.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != scenario.ExpectedCode {
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
response, err := router.Test(request)
if err != nil {
return
}
output := responseRecorder.Body.String()
if output != scenario.ExpectedBody {
t.Errorf("expected:\n %s\n\ngot:\n %s", scenario.ExpectedBody, output)
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)
}
body, err := io.ReadAll(response.Body)
if err != nil {
t.Error("expected err to be nil, but was", err)
}
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n %s\n\ngot:\n %s", scenario.ExpectedBody, string(body))
}
})
}

30
api/spa.go Normal file
View File

@@ -0,0 +1,30 @@
package api
import (
_ "embed"
"html/template"
"log"
"github.com/TwiN/gatus/v5/config/ui"
static "github.com/TwiN/gatus/v5/web"
"github.com/gofiber/fiber/v2"
)
func SinglePageApplication(ui *ui.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
t, err := template.ParseFS(static.FileSystem, static.IndexPath)
if err != nil {
// This should never happen, because ui.ValidateAndSetDefaults validates that the template works.
log.Println("[api][SinglePageApplication] Failed to parse template. This should never happen, because the template is validated on start. Error:", err.Error())
return c.Status(500).SendString("Failed to parse template. This should never happen, because the template is validated on start.")
}
c.Set("Content-Type", "text/html")
err = t.Execute(c, ui)
if err != nil {
// This should never happen, because ui.ValidateAndSetDefaults validates that the template works.
log.Println("[api][SinglePageApplication] Failed to execute template. This should never happen, because the template is validated on start. Error:", err.Error())
return c.Status(500).SendString("Failed to parse template. This should never happen, because the template is validated on start.")
}
return c.SendStatus(200)
}
}

View File

@@ -1,15 +1,18 @@
package handler
package api
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/TwiN/gatus/v4/config"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/storage/store"
"github.com/TwiN/gatus/v4/watchdog"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/ui"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/watchdog"
)
func TestSinglePageApplication(t *testing.T) {
@@ -27,10 +30,14 @@ func TestSinglePageApplication(t *testing.T) {
Group: "core",
},
},
UI: &ui.Config{
Title: "example-title",
},
}
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
router := CreateRouter("../../web/static", cfg.Security, nil, cfg.Metrics)
api := New(cfg)
router := api.Router()
type Scenario struct {
Name string
Path string
@@ -41,24 +48,31 @@ func TestSinglePageApplication(t *testing.T) {
{
Name: "frontend-home",
Path: "/",
ExpectedCode: http.StatusOK,
ExpectedCode: 200,
},
{
Name: "frontend-endpoint",
Path: "/endpoints/core_frontend",
ExpectedCode: http.StatusOK,
ExpectedCode: 200,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
request, _ := http.NewRequest("GET", scenario.Path, http.NoBody)
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
if scenario.Gzip {
request.Header.Set("Accept-Encoding", "gzip")
}
responseRecorder := httptest.NewRecorder()
router.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != scenario.ExpectedCode {
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
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)
}
body, _ := io.ReadAll(response.Body)
if !strings.Contains(string(body), cfg.UI.Title) {
t.Errorf("%s %s should have contained the title", request.Method, request.URL)
}
})
}

View File

@@ -1,10 +1,10 @@
package handler
package api
import (
"net/http"
"strconv"
"github.com/TwiN/gatus/v4/storage/store/common"
"github.com/TwiN/gatus/v5/storage/store/common"
"github.com/gofiber/fiber/v2"
)
const (
@@ -18,9 +18,9 @@ const (
MaximumPageSize = common.MaximumNumberOfResults
)
func extractPageAndPageSizeFromRequest(r *http.Request) (page int, pageSize int) {
func extractPageAndPageSizeFromRequest(c *fiber.Ctx) (page, pageSize int) {
var err error
if pageParameter := r.URL.Query().Get("page"); len(pageParameter) == 0 {
if pageParameter := c.Query("page"); len(pageParameter) == 0 {
page = DefaultPage
} else {
page, err = strconv.Atoi(pageParameter)
@@ -31,7 +31,7 @@ func extractPageAndPageSizeFromRequest(r *http.Request) (page int, pageSize int)
page = DefaultPage
}
}
if pageSizeParameter := r.URL.Query().Get("pageSize"); len(pageSizeParameter) == 0 {
if pageSizeParameter := c.Query("pageSize"); len(pageSizeParameter) == 0 {
pageSize = DefaultPageSize
} else {
pageSize, err = strconv.Atoi(pageSizeParameter)

View File

@@ -1,9 +1,11 @@
package handler
package api
import (
"fmt"
"net/http"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/valyala/fasthttp"
)
func TestExtractPageAndPageSizeFromRequest(t *testing.T) {
@@ -54,8 +56,12 @@ func TestExtractPageAndPageSizeFromRequest(t *testing.T) {
}
for _, scenario := range scenarios {
t.Run("page-"+scenario.Page+"-pageSize-"+scenario.PageSize, func(t *testing.T) {
request, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/statuses?page=%s&pageSize=%s", scenario.Page, scenario.PageSize), http.NoBody)
actualPage, actualPageSize := extractPageAndPageSizeFromRequest(request)
//request := httptest.NewRequest("GET", fmt.Sprintf("/api/v1/statuses?page=%s&pageSize=%s", scenario.Page, scenario.PageSize), http.NoBody)
app := fiber.New()
c := app.AcquireCtx(&fasthttp.RequestCtx{})
defer app.ReleaseCtx(c)
c.Request().SetRequestURI(fmt.Sprintf("/api/v1/statuses?page=%s&pageSize=%s", scenario.Page, scenario.PageSize))
actualPage, actualPageSize := extractPageAndPageSizeFromRequest(c)
if actualPage != scenario.ExpectedPage {
t.Errorf("expected %d, got %d", scenario.ExpectedPage, actualPage)
}

View File

@@ -4,6 +4,7 @@ import (
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"net"
"net/http"
"net/smtp"
@@ -11,13 +12,21 @@ import (
"strings"
"time"
"github.com/go-ping/ping"
"github.com/TwiN/gocache/v2"
"github.com/TwiN/whois"
"github.com/ishidawataru/sctp"
ping "github.com/prometheus-community/pro-bing"
)
// injectedHTTPClient is used for testing purposes
var injectedHTTPClient *http.Client
var (
// injectedHTTPClient is used for testing purposes
injectedHTTPClient *http.Client
// GetHTTPClient returns the shared HTTP client
whoisClient = whois.NewClient().WithReferralCache(true)
whoisExpirationDateCache = gocache.NewCache().WithMaxSize(10000).WithDefaultTTL(24 * time.Hour)
)
// GetHTTPClient returns the shared HTTP client, or the client from the configuration passed
func GetHTTPClient(config *Config) *http.Client {
if injectedHTTPClient != nil {
return injectedHTTPClient
@@ -28,6 +37,35 @@ func GetHTTPClient(config *Config) *http.Client {
return config.getHTTPClient()
}
// GetDomainExpiration retrieves the duration until the domain provided expires
func GetDomainExpiration(hostname string) (domainExpiration time.Duration, err error) {
var retrievedCachedValue bool
if v, exists := whoisExpirationDateCache.Get(hostname); exists {
domainExpiration = time.Until(v.(time.Time))
retrievedCachedValue = true
// If the domain OR the TTL is not going to expire in less than 24 hours
// we don't have to refresh the cache. Otherwise, we'll refresh it.
cacheEntryTTL, _ := whoisExpirationDateCache.TTL(hostname)
if cacheEntryTTL > 24*time.Hour && domainExpiration > 24*time.Hour {
// No need to refresh, so we'll just return the cached values
return domainExpiration, nil
}
}
if whoisResponse, err := whoisClient.QueryAndParse(hostname); err != nil {
if !retrievedCachedValue { // Add an error unless we already retrieved a cached value
return 0, fmt.Errorf("error querying and parsing hostname using whois client: %w", err)
}
} else {
domainExpiration = time.Until(whoisResponse.ExpirationDate)
if domainExpiration > 720*time.Hour {
whoisExpirationDateCache.SetWithTTL(hostname, whoisResponse.ExpirationDate, 240*time.Hour)
} else {
whoisExpirationDateCache.SetWithTTL(hostname, whoisResponse.ExpirationDate, 72*time.Hour)
}
}
return domainExpiration, nil
}
// CanCreateTCPConnection checks whether a connection can be established with a TCP endpoint
func CanCreateTCPConnection(address string, config *Config) bool {
conn, err := net.DialTimeout("tcp", address, config.Timeout)
@@ -38,6 +76,41 @@ func CanCreateTCPConnection(address string, config *Config) bool {
return true
}
// CanCreateUDPConnection checks whether a connection can be established with a UDP endpoint
func CanCreateUDPConnection(address string, config *Config) bool {
conn, err := net.DialTimeout("udp", address, config.Timeout)
if err != nil {
return false
}
_ = conn.Close()
return true
}
// CanCreateSCTPConnection checks whether a connection can be established with a SCTP endpoint
func CanCreateSCTPConnection(address string, config *Config) bool {
ch := make(chan bool)
go (func(res chan bool) {
addr, err := sctp.ResolveSCTPAddr("sctp", address)
if err != nil {
res <- false
}
conn, err := sctp.DialSCTP("sctp", nil, addr)
if err != nil {
res <- false
}
_ = conn.Close()
res <- true
})(ch)
select {
case result := <-ch:
return result
case <-time.After(config.Timeout):
return false
}
}
// CanPerformStartTLS checks whether a connection can be established to an address using the STARTTLS protocol
func CanPerformStartTLS(address string, config *Config) (connected bool, certificate *x509.Certificate, err error) {
hostAndPort := strings.Split(address, ":")
@@ -95,7 +168,7 @@ func Ping(address string, config *Config) (bool, time.Duration) {
// See https://github.com/TwiN/gatus/issues/132
//
// Note that for this to work on Linux, Gatus must run with sudo privileges.
// See https://github.com/go-ping/ping#linux
// See https://github.com/prometheus-community/pro-bing#linux
pinger.SetPrivileged(runtime.GOOS != "darwin")
err = pinger.Run()
if err != nil {

View File

@@ -7,7 +7,7 @@ import (
"testing"
"time"
"github.com/TwiN/gatus/v4/test"
"github.com/TwiN/gatus/v5/test"
)
func TestGetHTTPClient(t *testing.T) {
@@ -35,7 +35,36 @@ func TestGetHTTPClient(t *testing.T) {
}
}
func TestGetDomainExpiration(t *testing.T) {
t.Parallel()
if domainExpiration, err := GetDomainExpiration("example.com"); err != nil {
t.Fatalf("expected error to be nil, but got: `%s`", err)
} else if domainExpiration <= 0 {
t.Error("expected domain expiration to be higher than 0")
}
if domainExpiration, err := GetDomainExpiration("example.com"); err != nil {
t.Errorf("expected error to be nil, but got: `%s`", err)
} else if domainExpiration <= 0 {
t.Error("expected domain expiration to be higher than 0")
}
// Hack to pretend like the domain is expiring in 1 hour, which should trigger a refresh
whoisExpirationDateCache.SetWithTTL("example.com", time.Now().Add(time.Hour), 25*time.Hour)
if domainExpiration, err := GetDomainExpiration("example.com"); err != nil {
t.Errorf("expected error to be nil, but got: `%s`", err)
} else if domainExpiration <= 0 {
t.Error("expected domain expiration to be higher than 0")
}
// Make sure the refresh works when the ttl is <24 hours
whoisExpirationDateCache.SetWithTTL("example.com", time.Now().Add(35*time.Hour), 23*time.Hour)
if domainExpiration, err := GetDomainExpiration("example.com"); err != nil {
t.Errorf("expected error to be nil, but got: `%s`", err)
} else if domainExpiration <= 0 {
t.Error("expected domain expiration to be higher than 0")
}
}
func TestPing(t *testing.T) {
t.Parallel()
if success, rtt := Ping("127.0.0.1", &Config{Timeout: 500 * time.Millisecond}); !success {
t.Error("expected true")
if rtt == 0 {
@@ -94,6 +123,7 @@ func TestCanPerformStartTLS(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
connected, _, err := CanPerformStartTLS(tt.args.address, &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second})
if (err != nil) != tt.wantErr {
t.Errorf("CanPerformStartTLS() err=%v, wantErr=%v", err, tt.wantErr)
@@ -144,6 +174,7 @@ func TestCanPerformTLS(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
connected, _, err := CanPerformTLS(tt.args.address, &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second})
if (err != nil) != tt.wantErr {
t.Errorf("CanPerformTLS() err=%v, wantErr=%v", err, tt.wantErr)
@@ -160,6 +191,9 @@ func TestCanCreateTCPConnection(t *testing.T) {
if CanCreateTCPConnection("127.0.0.1", &Config{Timeout: 5 * time.Second}) {
t.Error("should've failed, because there's no port in the address")
}
if !CanCreateTCPConnection("1.1.1.1:53", &Config{Timeout: 5 * time.Second}) {
t.Error("should've succeeded, because that IP should always™ be up")
}
}
// This test checks if a HTTP client configured with `configureOAuth2()` automatically

View File

@@ -22,7 +22,7 @@ const (
var (
ErrInvalidDNSResolver = errors.New("invalid DNS resolver specified. Required format is {proto}://{ip}:{port}")
ErrInvalidDNSResolverPort = errors.New("invalid DNS resolver port")
ErrInvalidClientOAuth2Config = errors.New("invalid OAuth2 configuration, all fields are required")
ErrInvalidClientOAuth2Config = errors.New("invalid oauth2 configuration: must define all fields for client credentials flow (token-url, client-id, client-secret, scopes)")
defaultConfig = Config{
Insecure: false,
@@ -49,7 +49,7 @@ type Config struct {
Timeout time.Duration `yaml:"timeout"`
// DNSResolver override for the HTTP client
// Expected format is {protocol}://{host}:{port}, e.g. tcp://1.1.1.1:53
// Expected format is {protocol}://{host}:{port}, e.g. tcp://8.8.8.8:53
DNSResolver string `yaml:"dns-resolver,omitempty"`
// OAuth2Config is the OAuth2 configuration used for the client.

View File

@@ -31,7 +31,7 @@ endpoints:
- "[STATUS] == 200"
- name: example-dns-query
url: "1.1.1.1" # Address of the DNS server to use
url: "8.8.8.8" # Address of the DNS server to use
interval: 5m
dns:
query-name: "example.com"
@@ -45,3 +45,9 @@ endpoints:
interval: 1m
conditions:
- "[CONNECTED] == true"
- name: check-domain-expiration
url: "https://example.org/"
interval: 1h
conditions:
- "[DOMAIN_EXPIRATION] > 720h"

View File

@@ -2,25 +2,33 @@ package config
import (
"errors"
"fmt"
"io/fs"
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/TwiN/gatus/v4/alerting"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/alerting/provider"
"github.com/TwiN/gatus/v4/config/maintenance"
"github.com/TwiN/gatus/v4/config/ui"
"github.com/TwiN/gatus/v4/config/web"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/security"
"github.com/TwiN/gatus/v4/storage"
"gopkg.in/yaml.v2"
"github.com/TwiN/deepmerge"
"github.com/TwiN/gatus/v5/alerting"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/alerting/provider"
"github.com/TwiN/gatus/v5/config/connectivity"
"github.com/TwiN/gatus/v5/config/maintenance"
"github.com/TwiN/gatus/v5/config/remote"
"github.com/TwiN/gatus/v5/config/ui"
"github.com/TwiN/gatus/v5/config/web"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/security"
"github.com/TwiN/gatus/v5/storage"
"github.com/TwiN/gatus/v5/util"
"gopkg.in/yaml.v3"
)
const (
// DefaultConfigurationFilePath is the default path that will be used to search for the configuration file
// if a custom path isn't configured through the GATUS_CONFIG_FILE environment variable
// if a custom path isn't configured through the GATUS_CONFIG_PATH environment variable
DefaultConfigurationFilePath = "config/config.yaml"
// DefaultFallbackConfigurationFilePath is the default fallback path that will be used to search for the
@@ -29,14 +37,17 @@ const (
)
var (
// ErrNoEndpointInConfig is an error returned when a configuration file has no endpoints configured
ErrNoEndpointInConfig = errors.New("configuration file should contain at least 1 endpoint")
// ErrNoEndpointInConfig is an error returned when a configuration file or directory has no endpoints configured
ErrNoEndpointInConfig = errors.New("configuration should contain at least 1 endpoint")
// ErrConfigFileNotFound is an error returned when the configuration file could not be found
// ErrConfigFileNotFound is an error returned when a configuration file could not be found
ErrConfigFileNotFound = errors.New("configuration file not found")
// ErrInvalidSecurityConfig is an error returned when the security configuration is invalid
ErrInvalidSecurityConfig = errors.New("invalid security configuration")
// errEarlyReturn is returned to break out of a loop from a callback early
errEarlyReturn = errors.New("early escape")
)
// Config is the main configuration structure
@@ -65,14 +76,6 @@ type Config struct {
// Endpoints List of endpoints to monitor
Endpoints []*core.Endpoint `yaml:"endpoints,omitempty"`
// Services List of endpoints to monitor
//
// XXX: Remove this in v5.0.0
// XXX: This is not a typo -- not v4.0.0, but v5.0.0 -- I want to give enough time for people to migrate
//
// Deprecated in favor of Endpoints
Services []*core.Endpoint `yaml:"services,omitempty"`
// Storage is the configuration for how the data is stored
Storage *storage.Config `yaml:"storage,omitempty"`
@@ -85,83 +88,148 @@ type Config struct {
// Maintenance is the configuration for creating a maintenance window in which no alerts are sent
Maintenance *maintenance.Config `yaml:"maintenance,omitempty"`
filePath string // path to the file from which config was loaded from
// Remote is the configuration for remote Gatus instances
// WARNING: This is in ALPHA and may change or be completely removed in the future
Remote *remote.Config `yaml:"remote,omitempty"`
// Connectivity is the configuration for connectivity
Connectivity *connectivity.Config `yaml:"connectivity,omitempty"`
configPath string // path to the file or directory from which config was loaded
lastFileModTime time.Time // last modification time
}
// HasLoadedConfigurationFileBeenModified returns whether the file that the
// configuration has been loaded from has been modified since it was last read
func (config Config) HasLoadedConfigurationFileBeenModified() bool {
if fileInfo, err := os.Stat(config.filePath); err == nil {
if !fileInfo.ModTime().IsZero() {
return config.lastFileModTime.Unix() != fileInfo.ModTime().Unix()
func (config *Config) GetEndpointByKey(key string) *core.Endpoint {
// TODO: Should probably add a mutex here to prevent concurrent access
for i := 0; i < len(config.Endpoints); i++ {
ep := config.Endpoints[i]
if util.ConvertGroupAndEndpointNameToKey(ep.Group, ep.Name) == key {
return ep
}
}
return false
return nil
}
// HasLoadedConfigurationBeenModified returns whether one of the file that the
// configuration has been loaded from has been modified since it was last read
func (config Config) HasLoadedConfigurationBeenModified() bool {
lastMod := config.lastFileModTime.Unix()
fileInfo, err := os.Stat(config.configPath)
if err != nil {
return false
}
if fileInfo.IsDir() {
err = walkConfigDir(config.configPath, func(path string, d fs.DirEntry, err error) error {
if info, err := d.Info(); err == nil && lastMod < info.ModTime().Unix() {
return errEarlyReturn
}
return nil
})
return err == errEarlyReturn
}
return !fileInfo.ModTime().IsZero() && config.lastFileModTime.Unix() < fileInfo.ModTime().Unix()
}
// UpdateLastFileModTime refreshes Config.lastFileModTime
func (config *Config) UpdateLastFileModTime() {
if fileInfo, err := os.Stat(config.filePath); err == nil {
if !fileInfo.ModTime().IsZero() {
config.lastFileModTime = fileInfo.ModTime()
config.lastFileModTime = time.Now()
}
// LoadConfiguration loads the full configuration composed from the main configuration file
// and all composed configuration files
func LoadConfiguration(configPath string) (*Config, error) {
var configBytes []byte
var fileInfo os.FileInfo
var usedConfigPath string
// Figure out what config path we'll use (either configPath or the default config path)
for _, configurationPath := range []string{configPath, DefaultConfigurationFilePath, DefaultFallbackConfigurationFilePath} {
if len(configurationPath) == 0 {
continue
}
var err error
fileInfo, err = os.Stat(configurationPath)
if err != nil {
continue
}
usedConfigPath = configurationPath
break
}
if len(usedConfigPath) == 0 {
return nil, ErrConfigFileNotFound
}
var config *Config
if fileInfo.IsDir() {
err := walkConfigDir(configPath, func(path string, d fs.DirEntry, err error) error {
if err != nil {
log.Printf("[config][LoadConfiguration] Error walking path=%s: %s", path, err)
return err
}
log.Printf("[config][LoadConfiguration] Reading configuration from %s", path)
data, err := os.ReadFile(path)
if err != nil {
log.Printf("[config][LoadConfiguration] Error reading configuration from %s: %s", path, err)
return fmt.Errorf("error reading configuration from file %s: %w", path, err)
}
configBytes, err = deepmerge.YAML(configBytes, data)
return err
})
if err != nil {
return nil, fmt.Errorf("error reading configuration from directory %s: %w", usedConfigPath, err)
}
} else {
log.Println("[config][UpdateLastFileModTime] Ran into error updating lastFileModTime:", err.Error())
}
}
// Load loads a custom configuration file
// Note that the misconfiguration of some fields may lead to panics. This is on purpose.
func Load(configFile string) (*Config, error) {
log.Printf("[config][Load] Reading configuration from configFile=%s", configFile)
cfg, err := readConfigurationFile(configFile)
if err != nil {
if os.IsNotExist(err) {
return nil, ErrConfigFileNotFound
log.Printf("[config][LoadConfiguration] Reading configuration from configFile=%s", configPath)
if data, err := os.ReadFile(usedConfigPath); err != nil {
return nil, err
} else {
configBytes = data
}
}
if len(configBytes) == 0 {
return nil, ErrConfigFileNotFound
}
config, err := parseAndValidateConfigBytes(configBytes)
if err != nil {
return nil, err
}
cfg.filePath = configFile
cfg.UpdateLastFileModTime()
return cfg, nil
config.configPath = usedConfigPath
config.UpdateLastFileModTime()
return config, err
}
// LoadDefaultConfiguration loads the default configuration file
func LoadDefaultConfiguration() (*Config, error) {
cfg, err := Load(DefaultConfigurationFilePath)
if err != nil {
if err == ErrConfigFileNotFound {
return Load(DefaultFallbackConfigurationFilePath)
// walkConfigDir is a wrapper for filepath.WalkDir that strips directories and non-config files
func walkConfigDir(path string, fn fs.WalkDirFunc) error {
if len(path) == 0 {
// If the user didn't provide a directory, we'll just use the default config file, so we can return nil now.
return nil
}
return filepath.WalkDir(path, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
return nil, err
}
return cfg, nil
}
func readConfigurationFile(fileName string) (config *Config, err error) {
var bytes []byte
if bytes, err = os.ReadFile(fileName); err == nil {
// file exists, so we'll parse it and return it
return parseAndValidateConfigBytes(bytes)
}
return
if d == nil || d.IsDir() {
return nil
}
ext := filepath.Ext(path)
if ext != ".yml" && ext != ".yaml" {
return nil
}
return fn(path, d, err)
})
}
// parseAndValidateConfigBytes parses a Gatus configuration file into a Config struct and validates its parameters
func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
// Replace $$ with __GATUS_LITERAL_DOLLAR_SIGN__ to prevent os.ExpandEnv from treating "$$" as if it was an
// environment variable. This allows Gatus to support literal "$" in the configuration file.
yamlBytes = []byte(strings.ReplaceAll(string(yamlBytes), "$$", "__GATUS_LITERAL_DOLLAR_SIGN__"))
// Expand environment variables
yamlBytes = []byte(os.ExpandEnv(string(yamlBytes)))
// Replace __GATUS_LITERAL_DOLLAR_SIGN__ with "$" to restore the literal "$" in the configuration file
yamlBytes = []byte(strings.ReplaceAll(string(yamlBytes), "__GATUS_LITERAL_DOLLAR_SIGN__", "$"))
// Parse configuration file
if err = yaml.Unmarshal(yamlBytes, &config); err != nil {
return
}
if config != nil && len(config.Services) > 0 { // XXX: Remove this in v5.0.0
log.Println("WARNING: Your configuration is using 'services:', which is deprecated in favor of 'endpoints:'.")
log.Println("WARNING: See https://github.com/TwiN/gatus/issues/191 for more information")
config.Endpoints = append(config.Endpoints, config.Services...)
config.Services = nil
}
// Check if the configuration file at least has endpoints configured
if config == nil || config.Endpoints == nil || len(config.Endpoints) == 0 {
err = ErrNoEndpointInConfig
@@ -185,10 +253,32 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
if err := validateStorageConfig(config); err != nil {
return nil, err
}
if err := validateRemoteConfig(config); err != nil {
return nil, err
}
if err := validateConnectivityConfig(config); err != nil {
return nil, err
}
}
return
}
func validateConnectivityConfig(config *Config) error {
if config.Connectivity != nil {
return config.Connectivity.ValidateAndSetDefaults()
}
return nil
}
func validateRemoteConfig(config *Config) error {
if config.Remote != nil {
if err := config.Remote.ValidateAndSetDefaults(); err != nil {
return err
}
}
return nil
}
func validateStorageConfig(config *Config) error {
if config.Storage == nil {
config.Storage = &storage.Config{
@@ -239,7 +329,7 @@ func validateEndpointsConfig(config *Config) error {
log.Printf("[config][validateEndpointsConfig] Validating endpoint '%s'", endpoint.Name)
}
if err := endpoint.ValidateAndSetDefaults(); err != nil {
return err
return fmt.Errorf("invalid endpoint %s: %w", endpoint.DisplayName(), err)
}
}
log.Printf("[config][validateEndpointsConfig] Validated %d endpoints", len(config.Endpoints))
@@ -273,11 +363,17 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*core.E
alertTypes := []alert.Type{
alert.TypeCustom,
alert.TypeDiscord,
alert.TypeGitHub,
alert.TypeGitLab,
alert.TypeGoogleChat,
alert.TypeEmail,
alert.TypeMatrix,
alert.TypeMattermost,
alert.TypeMessagebird,
alert.TypeNtfy,
alert.TypeOpsgenie,
alert.TypePagerDuty,
alert.TypePushover,
alert.TypeSlack,
alert.TypeTeams,
alert.TypeTelegram,
@@ -305,6 +401,7 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*core.E
} else {
log.Printf("[config][validateAlertingConfig] Ignoring provider=%s because configuration is invalid", alertType)
invalidProviders = append(invalidProviders, alertType)
alertingConfig.SetAlertingProviderToNil(alertProvider)
}
} else {
invalidProviders = append(invalidProviders, alertType)

View File

@@ -1,48 +1,304 @@
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/TwiN/gatus/v4/alerting"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/alerting/provider/custom"
"github.com/TwiN/gatus/v4/alerting/provider/discord"
"github.com/TwiN/gatus/v4/alerting/provider/mattermost"
"github.com/TwiN/gatus/v4/alerting/provider/messagebird"
"github.com/TwiN/gatus/v4/alerting/provider/pagerduty"
"github.com/TwiN/gatus/v4/alerting/provider/slack"
"github.com/TwiN/gatus/v4/alerting/provider/teams"
"github.com/TwiN/gatus/v4/alerting/provider/telegram"
"github.com/TwiN/gatus/v4/alerting/provider/twilio"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/config/ui"
"github.com/TwiN/gatus/v4/config/web"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/storage"
"github.com/TwiN/gatus/v5/alerting"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/alerting/provider"
"github.com/TwiN/gatus/v5/alerting/provider/custom"
"github.com/TwiN/gatus/v5/alerting/provider/discord"
"github.com/TwiN/gatus/v5/alerting/provider/email"
"github.com/TwiN/gatus/v5/alerting/provider/github"
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
"github.com/TwiN/gatus/v5/alerting/provider/ntfy"
"github.com/TwiN/gatus/v5/alerting/provider/opsgenie"
"github.com/TwiN/gatus/v5/alerting/provider/pagerduty"
"github.com/TwiN/gatus/v5/alerting/provider/pushover"
"github.com/TwiN/gatus/v5/alerting/provider/slack"
"github.com/TwiN/gatus/v5/alerting/provider/teams"
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/web"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/storage"
"gopkg.in/yaml.v3"
)
func TestLoadFileThatDoesNotExist(t *testing.T) {
_, err := Load("file-that-does-not-exist.yaml")
if err == nil {
t.Error("Should've returned an error, because the file specified doesn't exist")
func TestLoadConfiguration(t *testing.T) {
dir := t.TempDir()
scenarios := []struct {
name string
configPath string // value to pass as the configPath parameter in LoadConfiguration
pathAndFiles map[string]string // files to create in dir
expectedConfig *Config
expectedError error
}{
{
name: "empty-config-file",
configPath: filepath.Join(dir, "config.yaml"),
pathAndFiles: map[string]string{
"config.yaml": "",
},
expectedError: ErrConfigFileNotFound,
},
{
name: "config-file-that-does-not-exist",
configPath: filepath.Join(dir, "config.yaml"),
expectedError: ErrConfigFileNotFound,
},
{
name: "config-file-with-endpoint-that-has-no-url",
configPath: filepath.Join(dir, "config.yaml"),
pathAndFiles: map[string]string{
"config.yaml": `
endpoints:
- name: website`,
},
expectedError: core.ErrEndpointWithNoURL,
},
{
name: "config-file-with-endpoint-that-has-no-conditions",
configPath: filepath.Join(dir, "config.yaml"),
pathAndFiles: map[string]string{
"config.yaml": `
endpoints:
- name: website
url: https://twin.sh/health`,
},
expectedError: core.ErrEndpointWithNoCondition,
},
{
name: "config-file",
configPath: filepath.Join(dir, "config.yaml"),
pathAndFiles: map[string]string{
"config.yaml": `
endpoints:
- name: website
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"`,
},
expectedConfig: &Config{
Endpoints: []*core.Endpoint{
{
Name: "website",
URL: "https://twin.sh/health",
Conditions: []core.Condition{"[STATUS] == 200"},
},
},
},
},
{
name: "empty-dir",
configPath: dir,
pathAndFiles: map[string]string{},
expectedError: ErrConfigFileNotFound,
},
{
name: "dir-with-empty-config-file",
configPath: dir,
pathAndFiles: map[string]string{
"config.yaml": "",
},
expectedError: ErrNoEndpointInConfig,
},
{
name: "dir-with-two-config-files",
configPath: dir,
pathAndFiles: map[string]string{
"config.yaml": `endpoints:
- name: one
url: https://example.com
conditions:
- "[CONNECTED] == true"
- "[STATUS] == 200"
- name: two
url: https://example.org
conditions:
- "len([BODY]) > 0"`,
"config.yml": `endpoints:
- name: three
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"`,
},
expectedConfig: &Config{
Endpoints: []*core.Endpoint{
{
Name: "one",
URL: "https://example.com",
Conditions: []core.Condition{"[CONNECTED] == true", "[STATUS] == 200"},
},
{
Name: "two",
URL: "https://example.org",
Conditions: []core.Condition{"len([BODY]) > 0"},
},
{
Name: "three",
URL: "https://twin.sh/health",
Conditions: []core.Condition{"[STATUS] == 200", "[BODY].status == UP"},
},
},
},
},
{
name: "dir-with-2-config-files-deep-merge-with-map-slice-and-primitive",
configPath: dir,
pathAndFiles: map[string]string{
"a.yaml": `
metrics: true
alerting:
slack:
webhook-url: https://hooks.slack.com/services/xxx/yyy/zzz
endpoints:
- name: example
url: https://example.org
interval: 5s
conditions:
- "[STATUS] == 200"`,
"b.yaml": `
debug: true
alerting:
discord:
webhook-url: https://discord.com/api/webhooks/xxx/yyy
endpoints:
- name: frontend
url: https://example.com
conditions:
- "[STATUS] == 200"`,
},
expectedConfig: &Config{
Debug: true,
Metrics: true,
Alerting: &alerting.Config{
Discord: &discord.AlertProvider{WebhookURL: "https://discord.com/api/webhooks/xxx/yyy"},
Slack: &slack.AlertProvider{WebhookURL: "https://hooks.slack.com/services/xxx/yyy/zzz"},
},
Endpoints: []*core.Endpoint{
{
Name: "example",
URL: "https://example.org",
Interval: 5 * time.Second,
Conditions: []core.Condition{"[STATUS] == 200"},
},
{
Name: "frontend",
URL: "https://example.com",
Conditions: []core.Condition{"[STATUS] == 200"},
},
},
},
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
for path, content := range scenario.pathAndFiles {
if err := os.WriteFile(filepath.Join(dir, path), []byte(content), 0644); err != nil {
t.Fatalf("[%s] failed to write file: %v", scenario.name, err)
}
}
defer func(pathAndFiles map[string]string) {
for path := range pathAndFiles {
_ = os.Remove(filepath.Join(dir, path))
}
}(scenario.pathAndFiles)
config, err := LoadConfiguration(scenario.configPath)
if !errors.Is(err, scenario.expectedError) {
t.Errorf("[%s] expected error %v, got %v", scenario.name, scenario.expectedError, err)
return
} else if err != nil && errors.Is(err, scenario.expectedError) {
return
}
// parse the expected output so that expectations are closer to reality (under the right circumstances, even I can be poetic)
expectedConfigAsYAML, _ := yaml.Marshal(scenario.expectedConfig)
expectedConfigAfterBeingParsedAndValidated, err := parseAndValidateConfigBytes(expectedConfigAsYAML)
if err != nil {
t.Fatalf("[%s] failed to parse expected config: %v", scenario.name, err)
}
// Marshal em' before comparing em' so that we don't have to deal with formatting and ordering
actualConfigAsYAML, err := yaml.Marshal(config)
if err != nil {
t.Fatalf("[%s] failed to marshal actual config: %v", scenario.name, err)
}
expectedConfigAfterBeingParsedAndValidatedAsYAML, _ := yaml.Marshal(expectedConfigAfterBeingParsedAndValidated)
if string(actualConfigAsYAML) != string(expectedConfigAfterBeingParsedAndValidatedAsYAML) {
t.Errorf("[%s] expected config %s, got %s", scenario.name, string(expectedConfigAfterBeingParsedAndValidatedAsYAML), string(actualConfigAsYAML))
}
})
}
}
func TestLoadDefaultConfigurationFile(t *testing.T) {
_, err := LoadDefaultConfiguration()
if err == nil {
t.Error("Should've returned an error, because there's no configuration files at the default path nor the default fallback path")
}
func TestConfig_HasLoadedConfigurationBeenModified(t *testing.T) {
t.Parallel()
dir := t.TempDir()
configFilePath := filepath.Join(dir, "config.yaml")
_ = os.WriteFile(configFilePath, []byte(`endpoints:
- name: website
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"
`), 0644)
t.Run("config-file-as-config-path", func(t *testing.T) {
config, err := LoadConfiguration(configFilePath)
if err != nil {
t.Fatalf("failed to load configuration: %v", err)
}
if config.HasLoadedConfigurationBeenModified() {
t.Errorf("expected config.HasLoadedConfigurationBeenModified() to return false because nothing has happened since it was created")
}
time.Sleep(time.Second) // Because the file mod time only has second precision, we have to wait for a second
// Update the config file
if err = os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(`endpoints:
- name: website
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"`), 0644); err != nil {
t.Fatalf("failed to overwrite config file: %v", err)
}
if !config.HasLoadedConfigurationBeenModified() {
t.Errorf("expected config.HasLoadedConfigurationBeenModified() to return true because a new file has been added in the directory")
}
})
t.Run("config-directory-as-config-path", func(t *testing.T) {
config, err := LoadConfiguration(dir)
if err != nil {
t.Fatalf("failed to load configuration: %v", err)
}
if config.HasLoadedConfigurationBeenModified() {
t.Errorf("expected config.HasLoadedConfigurationBeenModified() to return false because nothing has happened since it was created")
}
time.Sleep(time.Second) // Because the file mod time only has second precision, we have to wait for a second
// Update the config file
if err = os.WriteFile(filepath.Join(dir, "metrics.yaml"), []byte(`metrics: true`), 0644); err != nil {
t.Fatalf("failed to overwrite config file: %v", err)
}
if !config.HasLoadedConfigurationBeenModified() {
t.Errorf("expected config.HasLoadedConfigurationBeenModified() to return true because a new file has been added in the directory")
}
})
}
func TestParseAndValidateConfigBytes(t *testing.T) {
file := t.TempDir() + "/test.db"
ui.StaticFolder = "../web/static"
defer func() {
ui.StaticFolder = "./web/static"
}()
config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`
storage:
type: sqlite
@@ -429,6 +685,9 @@ alerting:
webhook-url: "http://example.org"
pagerduty:
integration-key: "00000000000000000000000000000000"
pushover:
application-token: "000000000000000000000000000000"
user-key: "000000000000000000000000000000"
mattermost:
webhook-url: "http://example.com"
client:
@@ -453,26 +712,22 @@ endpoints:
url: https://twin.sh/health
alerts:
- type: slack
enabled: true
- type: pagerduty
enabled: true
failure-threshold: 7
success-threshold: 5
description: "Healthcheck failed 7 times in a row"
- type: mattermost
enabled: true
- type: messagebird
enabled: false
- type: discord
enabled: true
failure-threshold: 10
- type: telegram
enabled: true
- type: twilio
enabled: true
failure-threshold: 12
success-threshold: 15
- type: teams
enabled: true
- type: pushover
conditions:
- "[STATUS] == 200"
`))
@@ -499,8 +754,8 @@ endpoints:
if config.Endpoints[0].Interval != 60*time.Second {
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
}
if len(config.Endpoints[0].Alerts) != 8 {
t.Fatal("There should've been 8 alerts configured")
if len(config.Endpoints[0].Alerts) != 9 {
t.Fatal("There should've been 9 alerts configured")
}
if config.Endpoints[0].Alerts[0].Type != alert.TypeSlack {
@@ -600,6 +855,13 @@ endpoints:
if config.Endpoints[0].Alerts[7].SuccessThreshold != 2 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[7].SuccessThreshold)
}
if config.Endpoints[0].Alerts[8].Type != alert.TypePushover {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypePushover, config.Endpoints[0].Alerts[8].Type)
}
if !config.Endpoints[0].Alerts[8].IsEnabled() {
t.Error("The alert should've been enabled")
}
}
func TestParseAndValidateConfigBytesWithAlertingAndDefaultAlert(t *testing.T) {
@@ -624,6 +886,14 @@ alerting:
description: default description
failure-threshold: 7
success-threshold: 5
pushover:
application-token: "000000000000000000000000000000"
user-key: "000000000000000000000000000000"
default-alert:
enabled: true
description: default description
failure-threshold: 5
success-threshold: 3
mattermost:
webhook-url: "http://example.com"
default-alert:
@@ -667,6 +937,7 @@ endpoints:
- type: telegram
- type: twilio
- type: teams
- type: pushover
conditions:
- "[STATUS] == 200"
`))
@@ -703,6 +974,19 @@ endpoints:
t.Errorf("PagerDuty integration key should've been %s, but was %s", "00000000000000000000000000000000", config.Alerting.PagerDuty.IntegrationKey)
}
if config.Alerting.Pushover == nil || !config.Alerting.Pushover.IsValid() {
t.Fatal("Pushover alerting config should've been valid")
}
if config.Alerting.Pushover.GetDefaultAlert() == nil {
t.Fatal("Pushover.GetDefaultAlert() shouldn't have returned nil")
}
if config.Alerting.Pushover.ApplicationToken != "000000000000000000000000000000" {
t.Errorf("Pushover application token should've been %s, but was %s", "000000000000000000000000000000", config.Alerting.Pushover.ApplicationToken)
}
if config.Alerting.Pushover.UserKey != "000000000000000000000000000000" {
t.Errorf("Pushover user key should've been %s, but was %s", "000000000000000000000000000000", config.Alerting.Pushover.UserKey)
}
if config.Alerting.Mattermost == nil || !config.Alerting.Mattermost.IsValid() {
t.Fatal("Mattermost alerting config should've been valid")
}
@@ -776,8 +1060,8 @@ endpoints:
if config.Endpoints[0].Interval != 60*time.Second {
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
}
if len(config.Endpoints[0].Alerts) != 8 {
t.Fatal("There should've been 8 alerts configured")
if len(config.Endpoints[0].Alerts) != 9 {
t.Fatal("There should've been 9 alerts configured")
}
if config.Endpoints[0].Alerts[0].Type != alert.TypeSlack {
@@ -881,6 +1165,19 @@ endpoints:
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[7].SuccessThreshold)
}
if config.Endpoints[0].Alerts[8].Type != alert.TypePushover {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypePushover, config.Endpoints[0].Alerts[8].Type)
}
if !config.Endpoints[0].Alerts[8].IsEnabled() {
t.Error("The alert should've been enabled")
}
if config.Endpoints[0].Alerts[8].FailureThreshold != 5 {
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[8].FailureThreshold)
}
if config.Endpoints[0].Alerts[8].SuccessThreshold != 3 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[8].SuccessThreshold)
}
}
func TestParseAndValidateConfigBytesWithAlertingAndDefaultAlertAndMultipleAlertsOfSameTypeWithOverriddenParameters(t *testing.T) {
@@ -984,11 +1281,34 @@ endpoints:
if config.Alerting == nil {
t.Fatal("config.Alerting shouldn't have been nil")
}
if config.Alerting.PagerDuty == nil {
t.Fatal("PagerDuty alerting config shouldn't have been nil")
if config.Alerting.PagerDuty != nil {
t.Fatal("PagerDuty alerting config should've been set to nil, because its IsValid() method returned false and therefore alerting.Config.SetAlertingProviderToNil() should've been called")
}
if config.Alerting.PagerDuty.IsValid() {
t.Fatal("PagerDuty alerting config should've been invalid")
}
func TestParseAndValidateConfigBytesWithInvalidPushoverAlertingConfig(t *testing.T) {
config, err := parseAndValidateConfigBytes([]byte(`
alerting:
pushover:
application-token: "INVALID_TOKEN"
endpoints:
- name: website
url: https://twin.sh/health
alerts:
- type: pushover
conditions:
- "[STATUS] == 200"
`))
if err != nil {
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
}
if config.Alerting == nil {
t.Fatal("config.Alerting shouldn't have been nil")
}
if config.Alerting.Pushover != nil {
t.Fatal("Pushover alerting config should've been set to nil, because its IsValid() method returned false and therefore alerting.Config.SetAlertingProviderToNil() should've been called")
}
}
@@ -1019,7 +1339,7 @@ endpoints:
t.Fatal("config.Alerting shouldn't have been nil")
}
if config.Alerting.Custom == nil {
t.Fatal("PagerDuty alerting config shouldn't have been nil")
t.Fatal("Custom alerting config shouldn't have been nil")
}
if !config.Alerting.Custom.IsValid() {
t.Fatal("Custom alerting config should've been valid")
@@ -1064,7 +1384,7 @@ endpoints:
t.Fatal("config.Alerting shouldn't have been nil")
}
if config.Alerting.Custom == nil {
t.Fatal("PagerDuty alerting config shouldn't have been nil")
t.Fatal("Custom alerting config shouldn't have been nil")
}
if !config.Alerting.Custom.IsValid() {
t.Fatal("Custom alerting config should've been valid")
@@ -1104,7 +1424,7 @@ endpoints:
t.Fatal("config.Alerting shouldn't have been nil")
}
if config.Alerting.Custom == nil {
t.Fatal("PagerDuty alerting config shouldn't have been nil")
t.Fatal("Custom alerting config shouldn't have been nil")
}
if !config.Alerting.Custom.IsValid() {
t.Fatal("Custom alerting config should've been valid")
@@ -1125,7 +1445,7 @@ endpoints:
conditions:
- "[STATUS] == 200"
`))
if err != core.ErrEndpointWithNoName {
if err == nil {
t.Error("should've returned an error")
}
}
@@ -1214,7 +1534,34 @@ endpoints:
}
}
func TestParseAndValidateConfigBytesWithNoEndpointsOrAutoDiscovery(t *testing.T) {
func TestParseAndValidateConfigBytesWithLiteralDollarSign(t *testing.T) {
os.Setenv("GATUS_TestParseAndValidateConfigBytesWithLiteralDollarSign", "whatever")
config, err := parseAndValidateConfigBytes([]byte(`
endpoints:
- name: website
url: https://twin.sh/health
conditions:
- "[BODY] == $$GATUS_TestParseAndValidateConfigBytesWithLiteralDollarSign"
- "[BODY] == $GATUS_TestParseAndValidateConfigBytesWithLiteralDollarSign"
`))
if err != nil {
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
}
if config.Endpoints[0].URL != "https://twin.sh/health" {
t.Errorf("URL should have been %s", "https://twin.sh/health")
}
if config.Endpoints[0].Conditions[0] != "[BODY] == $GATUS_TestParseAndValidateConfigBytesWithLiteralDollarSign" {
t.Errorf("Condition should have been %s", "[BODY] == $GATUS_TestParseAndValidateConfigBytesWithLiteralDollarSign")
}
if config.Endpoints[0].Conditions[1] != "[BODY] == whatever" {
t.Errorf("Condition should have been %s", "[BODY] == whatever")
}
}
func TestParseAndValidateConfigBytesWithNoEndpoints(t *testing.T) {
_, err := parseAndValidateConfigBytes([]byte(``))
if err != ErrNoEndpointInConfig {
t.Error("The error returned should have been of type ErrNoEndpointInConfig")
@@ -1225,87 +1572,47 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
alertingConfig := &alerting.Config{
Custom: &custom.AlertProvider{},
Discord: &discord.AlertProvider{},
Email: &email.AlertProvider{},
GitHub: &github.AlertProvider{},
GoogleChat: &googlechat.AlertProvider{},
Matrix: &matrix.AlertProvider{},
Mattermost: &mattermost.AlertProvider{},
Messagebird: &messagebird.AlertProvider{},
Ntfy: &ntfy.AlertProvider{},
Opsgenie: &opsgenie.AlertProvider{},
PagerDuty: &pagerduty.AlertProvider{},
Pushover: &pushover.AlertProvider{},
Slack: &slack.AlertProvider{},
Telegram: &telegram.AlertProvider{},
Twilio: &twilio.AlertProvider{},
Teams: &teams.AlertProvider{},
}
if alertingConfig.GetAlertingProviderByAlertType(alert.TypeCustom) != alertingConfig.Custom {
t.Error("expected Custom configuration")
scenarios := []struct {
alertType alert.Type
expected provider.AlertProvider
}{
{alertType: alert.TypeCustom, expected: alertingConfig.Custom},
{alertType: alert.TypeDiscord, expected: alertingConfig.Discord},
{alertType: alert.TypeEmail, expected: alertingConfig.Email},
{alertType: alert.TypeGitHub, expected: alertingConfig.GitHub},
{alertType: alert.TypeGoogleChat, expected: alertingConfig.GoogleChat},
{alertType: alert.TypeMatrix, expected: alertingConfig.Matrix},
{alertType: alert.TypeMattermost, expected: alertingConfig.Mattermost},
{alertType: alert.TypeMessagebird, expected: alertingConfig.Messagebird},
{alertType: alert.TypeNtfy, expected: alertingConfig.Ntfy},
{alertType: alert.TypeOpsgenie, expected: alertingConfig.Opsgenie},
{alertType: alert.TypePagerDuty, expected: alertingConfig.PagerDuty},
{alertType: alert.TypePushover, expected: alertingConfig.Pushover},
{alertType: alert.TypeSlack, expected: alertingConfig.Slack},
{alertType: alert.TypeTelegram, expected: alertingConfig.Telegram},
{alertType: alert.TypeTwilio, expected: alertingConfig.Twilio},
{alertType: alert.TypeTeams, expected: alertingConfig.Teams},
}
if alertingConfig.GetAlertingProviderByAlertType(alert.TypeDiscord) != alertingConfig.Discord {
t.Error("expected Discord configuration")
}
if alertingConfig.GetAlertingProviderByAlertType(alert.TypeMattermost) != alertingConfig.Mattermost {
t.Error("expected Mattermost configuration")
}
if alertingConfig.GetAlertingProviderByAlertType(alert.TypeMessagebird) != alertingConfig.Messagebird {
t.Error("expected Messagebird configuration")
}
if alertingConfig.GetAlertingProviderByAlertType(alert.TypePagerDuty) != alertingConfig.PagerDuty {
t.Error("expected PagerDuty configuration")
}
if alertingConfig.GetAlertingProviderByAlertType(alert.TypeSlack) != alertingConfig.Slack {
t.Error("expected Slack configuration")
}
if alertingConfig.GetAlertingProviderByAlertType(alert.TypeTelegram) != alertingConfig.Telegram {
t.Error("expected Telegram configuration")
}
if alertingConfig.GetAlertingProviderByAlertType(alert.TypeTwilio) != alertingConfig.Twilio {
t.Error("expected Twilio configuration")
}
if alertingConfig.GetAlertingProviderByAlertType(alert.TypeTeams) != alertingConfig.Teams {
t.Error("expected Teams configuration")
}
}
// XXX: Remove this in v5.0.0
func TestParseAndValidateConfigBytes_backwardCompatibleWithServices(t *testing.T) {
config, err := parseAndValidateConfigBytes([]byte(`
services:
- name: website
url: https://twin.sh/actuator/health
conditions:
- "[STATUS] == 200"
`))
if err != nil {
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
}
if config.Endpoints[0].URL != "https://twin.sh/actuator/health" {
t.Errorf("URL should have been %s", "https://twin.sh/actuator/health")
}
if config.Endpoints[0].Interval != 60*time.Second {
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
}
}
// XXX: Remove this in v5.0.0
func TestParseAndValidateConfigBytes_backwardCompatibleMergeServicesInEndpoints(t *testing.T) {
config, err := parseAndValidateConfigBytes([]byte(`
services:
- name: website1
url: https://twin.sh/actuator/health
conditions:
- "[STATUS] == 200"
endpoints:
- name: website2
url: https://twin.sh/actuator/health
conditions:
- "[STATUS] == 200"
`))
if err != nil {
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
}
if len(config.Endpoints) != 2 {
t.Error("services should've been merged in endpoints")
for _, scenario := range scenarios {
t.Run(string(scenario.alertType), func(t *testing.T) {
if alertingConfig.GetAlertingProviderByAlertType(scenario.alertType) != scenario.expected {
t.Errorf("expected %s configuration", scenario.alertType)
}
})
}
}

View File

@@ -0,0 +1,53 @@
package connectivity
import (
"errors"
"strings"
"time"
"github.com/TwiN/gatus/v5/client"
)
var (
ErrInvalidInterval = errors.New("connectivity.checker.interval must be 5s or higher")
ErrInvalidDNSTarget = errors.New("connectivity.checker.target must be suffixed with :53")
)
// Config is the configuration for the connectivity checker.
type Config struct {
Checker *Checker `yaml:"checker,omitempty"`
}
func (c *Config) ValidateAndSetDefaults() error {
if c.Checker != nil {
if c.Checker.Interval == 0 {
c.Checker.Interval = 60 * time.Second
} else if c.Checker.Interval < 5*time.Second {
return ErrInvalidInterval
}
if !strings.HasSuffix(c.Checker.Target, ":53") {
return ErrInvalidDNSTarget
}
}
return nil
}
// Checker is the configuration for making sure Gatus has access to the internet.
type Checker struct {
Target string `yaml:"target"` // e.g. 1.1.1.1:53
Interval time.Duration `yaml:"interval,omitempty"`
isConnected bool
lastCheck time.Time
}
func (c Checker) Check() bool {
return client.CanCreateTCPConnection(c.Target, &client.Config{Timeout: 5 * time.Second})
}
func (c *Checker) IsConnected() bool {
if now := time.Now(); now.After(c.lastCheck.Add(c.Interval)) {
c.lastCheck, c.isConnected = now, c.Check()
}
return c.isConnected
}

View File

@@ -0,0 +1,62 @@
package connectivity
import (
"fmt"
"testing"
"time"
)
func TestConfig(t *testing.T) {
scenarios := []struct {
name string
cfg *Config
expectedErr error
expectedInterval time.Duration
}{
{
name: "good-config",
cfg: &Config{Checker: &Checker{Target: "1.1.1.1:53", Interval: 10 * time.Second}},
expectedInterval: 10 * time.Second,
},
{
name: "good-config-with-default-interval",
cfg: &Config{Checker: &Checker{Target: "8.8.8.8:53", Interval: 0}},
expectedInterval: 60 * time.Second,
},
{
name: "config-with-interval-too-low",
cfg: &Config{Checker: &Checker{Target: "1.1.1.1:53", Interval: 4 * time.Second}},
expectedErr: ErrInvalidInterval,
},
{
name: "config-with-invalid-target-due-to-missing-port",
cfg: &Config{Checker: &Checker{Target: "1.1.1.1", Interval: 15 * time.Second}},
expectedErr: ErrInvalidDNSTarget,
},
{
name: "config-with-invalid-target-due-to-invalid-dns-port",
cfg: &Config{Checker: &Checker{Target: "1.1.1.1:52", Interval: 15 * time.Second}},
expectedErr: ErrInvalidDNSTarget,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.cfg.ValidateAndSetDefaults()
if fmt.Sprintf("%s", err) != fmt.Sprintf("%s", scenario.expectedErr) {
t.Errorf("expected error %v, got %v", scenario.expectedErr, err)
}
if err == nil && scenario.expectedErr == nil {
if scenario.cfg.Checker.Interval != scenario.expectedInterval {
t.Errorf("expected interval %v, got %v", scenario.expectedInterval, scenario.cfg.Checker.Interval)
}
}
})
}
}
func TestChecker_IsConnected(t *testing.T) {
checker := &Checker{Target: "1.1.1.1:53", Interval: 10 * time.Second}
if !checker.IsConnected() {
t.Error("expected checker.IsConnected() to be true")
}
}

View File

@@ -0,0 +1 @@
TODO: move files from core to here.

View File

@@ -82,7 +82,7 @@ func (c *Config) ValidateAndSetDefaults() error {
if err != nil {
return err
}
if c.Duration <= 0 || c.Duration >= 24*time.Hour {
if c.Duration <= 0 || c.Duration > 24*time.Hour {
return errInvalidMaintenanceDuration
}
return nil

View File

@@ -211,6 +211,14 @@ func TestConfig_IsUnderMaintenance(t *testing.T) {
},
expected: true,
},
{
name: "under-maintenance-starting-22h-ago-for-24h",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", normalizeHour(now.Hour()-22)),
Duration: 24 * time.Hour,
},
expected: true,
},
{
name: "under-maintenance-starting-4h-ago-for-3h",
cfg: &Config{
@@ -236,6 +244,15 @@ func TestConfig_IsUnderMaintenance(t *testing.T) {
},
expected: false,
},
{
name: "not-under-maintenance-today-with-24h-duration",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", now.Hour()),
Duration: 24 * time.Hour,
Every: []string{now.Add(48 * time.Hour).Weekday().String()},
},
expected: false,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {

39
config/remote/remote.go Normal file
View File

@@ -0,0 +1,39 @@
package remote
import (
"log"
"github.com/TwiN/gatus/v5/client"
)
// NOTICE: This is an experimental alpha feature and may be updated/removed in future versions.
// For more information, see https://github.com/TwiN/gatus/issues/64
type Config struct {
// Instances is a list of remote instances to retrieve endpoint statuses from.
Instances []Instance `yaml:"instances,omitempty"`
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"`
}
type Instance struct {
EndpointPrefix string `yaml:"endpoint-prefix"`
URL string `yaml:"url"`
}
func (c *Config) ValidateAndSetDefaults() error {
if c.ClientConfig == nil {
c.ClientConfig = client.GetDefaultConfig()
} else {
if err := c.ClientConfig.ValidateAndSetDefaults(); err != nil {
return err
}
}
if len(c.Instances) > 0 {
log.Println("WARNING: Your configuration is using 'remote', which is in alpha and may be updated/removed in future versions.")
log.Println("WARNING: See https://github.com/TwiN/gatus/issues/64 for more information")
log.Println("WARNING: This feature is a candidate for removal in future versions. Please comment on the issue above if you need this feature.")
}
return nil
}

View File

@@ -4,30 +4,30 @@ import (
"bytes"
"errors"
"html/template"
static "github.com/TwiN/gatus/v5/web"
)
const (
defaultTitle = "Health Dashboard | Gatus"
defaultHeader = "Health Status"
defaultLogo = ""
defaultLink = ""
defaultTitle = "Health Dashboard | Gatus"
defaultDescription = "Gatus is an advanced automated status page that lets you monitor your applications and configure alerts to notify you if there's an issue"
defaultHeader = "Health Status"
defaultLogo = ""
defaultLink = ""
)
var (
// StaticFolder is the path to the location of the static folder from the root path of the project
// The only reason this is exposed is to allow running tests from a different path than the root path of the project
StaticFolder = "./web/static"
ErrButtonValidationFailed = errors.New("invalid button configuration: missing required name or link")
)
// Config is the configuration for the UI of Gatus
type Config struct {
Title string `yaml:"title,omitempty"` // Title of the page
Header string `yaml:"header,omitempty"` // Header is the text at the top of the page
Logo string `yaml:"logo,omitempty"` // Logo to display on the page
Link string `yaml:"link,omitempty"` // Link to open when clicking on the logo
Buttons []Button `yaml:"buttons,omitempty"` // Buttons to display below the header
Title string `yaml:"title,omitempty"` // Title of the page
Description string `yaml:"description,omitempty"` // Meta description of the page
Header string `yaml:"header,omitempty"` // Header is the text at the top of the page
Logo string `yaml:"logo,omitempty"` // Logo to display on the page
Link string `yaml:"link,omitempty"` // Link to open when clicking on the logo
Buttons []Button `yaml:"buttons,omitempty"` // Buttons to display below the header
}
// Button is the configuration for a button on the UI
@@ -47,10 +47,11 @@ func (btn *Button) Validate() error {
// GetDefaultConfig returns a Config struct with the default values
func GetDefaultConfig() *Config {
return &Config{
Title: defaultTitle,
Header: defaultHeader,
Logo: defaultLogo,
Link: defaultLink,
Title: defaultTitle,
Description: defaultDescription,
Header: defaultHeader,
Logo: defaultLogo,
Link: defaultLink,
}
}
@@ -59,6 +60,9 @@ func (cfg *Config) ValidateAndSetDefaults() error {
if len(cfg.Title) == 0 {
cfg.Title = defaultTitle
}
if len(cfg.Description) == 0 {
cfg.Description = defaultDescription
}
if len(cfg.Header) == 0 {
cfg.Header = defaultHeader
}
@@ -71,7 +75,7 @@ func (cfg *Config) ValidateAndSetDefaults() error {
}
}
// Validate that the template works
t, err := template.ParseFiles(StaticFolder + "/index.html")
t, err := template.ParseFS(static.FileSystem, static.IndexPath)
if err != nil {
return err
}

View File

@@ -6,15 +6,12 @@ import (
)
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
StaticFolder = "../../web/static"
defer func() {
StaticFolder = "./web/static"
}()
cfg := &Config{
Title: "",
Header: "",
Logo: "",
Link: "",
Title: "",
Description: "",
Header: "",
Logo: "",
Link: "",
}
if err := cfg.ValidateAndSetDefaults(); err != nil {
t.Error("expected no error, got", err.Error())
@@ -22,6 +19,9 @@ func TestConfig_ValidateAndSetDefaults(t *testing.T) {
if cfg.Title != defaultTitle {
t.Errorf("expected title to be %s, got %s", defaultTitle, cfg.Title)
}
if cfg.Description != defaultDescription {
t.Errorf("expected description to be %s, got %s", defaultDescription, cfg.Description)
}
if cfg.Header != defaultHeader {
t.Errorf("expected header to be %s, got %s", defaultHeader, cfg.Header)
}

View File

@@ -1,6 +1,8 @@
package web
import (
"crypto/tls"
"errors"
"fmt"
"math"
)
@@ -21,6 +23,17 @@ type Config struct {
// Port to listen on (default to 8080 specified by DefaultPort)
Port int `yaml:"port"`
// TLS configuration (optional)
TLS *TLSConfig `yaml:"tls,omitempty"`
}
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"`
}
// GetDefaultConfig returns a Config struct with the default values
@@ -40,10 +53,31 @@ func (web *Config) ValidateAndSetDefaults() error {
} else if web.Port < 0 || web.Port > math.MaxUint16 {
return fmt.Errorf("invalid port: value should be between %d and %d", 0, math.MaxUint16)
}
// Try to load the TLS certificates
if web.TLS != nil {
if err := web.TLS.isValid(); err != nil {
return fmt.Errorf("invalid tls config: %w", err)
}
}
return nil
}
func (web *Config) HasTLS() bool {
return web.TLS != nil && len(web.TLS.CertificateFile) > 0 && len(web.TLS.PrivateKeyFile) > 0
}
// SocketAddress returns the combination of the Address and the Port
func (web *Config) SocketAddress() string {
return fmt.Sprintf("%s:%d", web.Address, web.Port)
}
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 errors.New("certificate-file and private-key-file must be specified")
}

View File

@@ -12,6 +12,9 @@ func TestGetDefaultConfig(t *testing.T) {
if defaultConfig.Address != DefaultAddress {
t.Error("expected default config to have the default address")
}
if defaultConfig.TLS != nil {
t.Error("expected default config to have TLS disabled")
}
}
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
@@ -34,6 +37,27 @@ func TestConfig_ValidateAndSetDefaults(t *testing.T) {
cfg: &Config{Port: 100000000},
expectedErr: true,
},
{
name: "with-good-tls-config",
cfg: &Config{Port: 443, TLS: &TLSConfig{CertificateFile: "../../testdata/cert.pem", PrivateKeyFile: "../../testdata/cert.key"}},
expectedAddress: "0.0.0.0",
expectedPort: 443,
expectedErr: false,
},
{
name: "with-bad-tls-config",
cfg: &Config{Port: 443, TLS: &TLSConfig{CertificateFile: "../../testdata/badcert.pem", PrivateKeyFile: "../../testdata/cert.key"}},
expectedAddress: "0.0.0.0",
expectedPort: 443,
expectedErr: true,
},
{
name: "with-partial-tls-config",
cfg: &Config{Port: 443, TLS: &TLSConfig{CertificateFile: "", PrivateKeyFile: "../../testdata/cert.key"}},
expectedAddress: "0.0.0.0",
expectedPort: 443,
expectedErr: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
@@ -63,3 +87,66 @@ func TestConfig_SocketAddress(t *testing.T) {
t.Errorf("expected %s, got %s", "0.0.0.0:8081", web.SocketAddress())
}
}
func TestConfig_isValid(t *testing.T) {
scenarios := []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 _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.cfg.ValidateAndSetDefaults()
if (err != nil) != scenario.expectedErr {
t.Errorf("expected the existence of an error to be %v, got %v", scenario.expectedErr, err)
return
}
if !scenario.expectedErr {
if scenario.cfg.TLS.isValid() != nil {
t.Error("cfg.TLS.isValid() returned an error even though no error was expected")
}
}
})
}
}

View File

@@ -1,49 +1,49 @@
package controller
import (
"context"
"fmt"
"log"
"net/http"
"os"
"time"
"github.com/TwiN/gatus/v4/config/ui"
"github.com/TwiN/gatus/v4/config/web"
"github.com/TwiN/gatus/v4/controller/handler"
"github.com/TwiN/gatus/v4/security"
"github.com/TwiN/gatus/v5/api"
"github.com/TwiN/gatus/v5/config"
"github.com/gofiber/fiber/v2"
)
var (
// server is the http.Server created by Handle.
// The only reason it exists is for testing purposes.
server *http.Server
app *fiber.App
)
// Handle creates the router and starts the server
func Handle(securityConfig *security.Config, webConfig *web.Config, uiConfig *ui.Config, enableMetrics bool) {
var router http.Handler = handler.CreateRouter(ui.StaticFolder, securityConfig, uiConfig, enableMetrics)
if os.Getenv("ENVIRONMENT") == "dev" {
router = handler.DevelopmentCORS(router)
}
server = &http.Server{
Addr: fmt.Sprintf("%s:%d", webConfig.Address, webConfig.Port),
Handler: router,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 15 * time.Second,
}
log.Println("[controller][Handle] Listening on " + webConfig.SocketAddress())
func Handle(cfg *config.Config) {
api := api.New(cfg)
app = api.Router()
server := app.Server()
server.ReadTimeout = 15 * time.Second
server.WriteTimeout = 15 * time.Second
server.IdleTimeout = 15 * time.Second
if os.Getenv("ROUTER_TEST") == "true" {
return
}
log.Println("[controller][Handle]", server.ListenAndServe())
log.Println("[controller][Handle] Listening on " + cfg.Web.SocketAddress())
if cfg.Web.HasTLS() {
err := app.ListenTLS(cfg.Web.SocketAddress(), cfg.Web.TLS.CertificateFile, cfg.Web.TLS.PrivateKeyFile)
if err != nil {
log.Fatal("[controller][Handle]", err)
}
} else {
err := app.Listen(cfg.Web.SocketAddress())
if err != nil {
log.Fatal("[controller][Handle]", err)
}
}
log.Println("[controller][Handle] Server has shut down successfully")
}
// Shutdown stops the server
func Shutdown() {
if server != nil {
_ = server.Shutdown(context.TODO())
server = nil
if app != nil {
_ = app.Shutdown()
app = nil
}
}

View File

@@ -7,9 +7,10 @@ import (
"os"
"testing"
"github.com/TwiN/gatus/v4/config"
"github.com/TwiN/gatus/v4/config/web"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/web"
"github.com/TwiN/gatus/v5/core"
"github.com/gofiber/fiber/v2"
)
func TestHandle(t *testing.T) {
@@ -32,24 +33,70 @@ func TestHandle(t *testing.T) {
_ = os.Setenv("ROUTER_TEST", "true")
_ = os.Setenv("ENVIRONMENT", "dev")
defer os.Clearenv()
Handle(cfg.Security, cfg.Web, cfg.UI, cfg.Metrics)
Handle(cfg)
defer Shutdown()
request, _ := http.NewRequest("GET", "/health", http.NoBody)
responseRecorder := httptest.NewRecorder()
server.Handler.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != http.StatusOK {
request := httptest.NewRequest("GET", "/health", http.NoBody)
response, err := app.Test(request)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != 200 {
t.Error("expected GET /health to return status code 200")
}
if server == nil {
if app == nil {
t.Fatal("server should've been set (but because we set ROUTER_TEST, it shouldn't have been started)")
}
}
func TestHandleTLS(t *testing.T) {
scenarios := []struct {
name string
tls *web.TLSConfig
expectedStatusCode int
}{
{
name: "good-tls-config",
tls: &web.TLSConfig{CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "../testdata/cert.key"},
expectedStatusCode: 200,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
cfg := &config.Config{
Web: &web.Config{Address: "0.0.0.0", Port: rand.Intn(65534), TLS: scenario.tls},
Endpoints: []*core.Endpoint{
{Name: "frontend", Group: "core"},
{Name: "backend", Group: "core"},
},
}
if err := cfg.Web.ValidateAndSetDefaults(); err != nil {
t.Error("expected no error from web (TLS) validation, got", err)
}
_ = os.Setenv("ROUTER_TEST", "true")
_ = os.Setenv("ENVIRONMENT", "dev")
defer os.Clearenv()
Handle(cfg)
defer Shutdown()
request := httptest.NewRequest("GET", "/health", http.NoBody)
response, err := app.Test(request)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != scenario.expectedStatusCode {
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.expectedStatusCode, response.StatusCode)
}
if app == nil {
t.Fatal("server should've been set (but because we set ROUTER_TEST, it shouldn't have been started)")
}
})
}
}
func TestShutdown(t *testing.T) {
// Pretend that we called controller.Handle(), which initializes the server variable
server = &http.Server{}
app = fiber.New()
Shutdown()
if server != nil {
if app != nil {
t.Error("server should've been shut down")
}
}

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