Compare commits

...

21 Commits

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

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

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

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

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

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

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

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

* Document Teams Workflow Alert

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

* Rename 'teamsworkflow' to 'teams-workflows'

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

* Fix README Table Format

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

* Fix Test to Expect Correct Emoji

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix typo

---------

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

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

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

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

* feat: add ENDPOINT_ERRORS example

* feat: add tests

* Refactor: code review feedback

* delete unsed errors

* Update README.md

* Apply suggestions from code review

---------

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

* Added omitempty to channel in Body for Mattermost

* Update README.md

* Update alerting/provider/mattermost/mattermost.go

* Expose channel configuration parameter through alerting.mattermost.channel

---------

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

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

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

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

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

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

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

* feat(alerting): Implement Zulip alert provider

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

* docs: Add Zulip alerts to README.md

* fix(alerting): Include alert description in message

* fix(alerting): validate Zuilip interface on compile

* chore(alerting): fix import order

* fix(alerting): rename ChannelId to ChannelID

* Update alerting/provider/zulip/zulip_test.go

---------

Co-authored-by: TwiN <twin@linux.com>
2024-09-03 23:21:08 -04:00
TwiN
54221eff9b fix(maintenance): Import time/tzdata to support timezones (#849)
Fix #848
2024-08-28 18:41:21 -04:00
23 changed files with 1531 additions and 88 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

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

View File

@@ -28,7 +28,7 @@ jobs:
# was configured by the "Set up Go" step (otherwise, it'd use sudo's "go" executable)
run: sudo env "PATH=$PATH" "GOROOT=$GOROOT" go test ./... -race -coverprofile=coverage.txt -covermode=atomic
- name: Codecov
uses: codecov/codecov-action@v4.5.0
uses: codecov/codecov-action@v4.6.0
with:
files: ./coverage.txt
token: ${{ secrets.CODECOV_TOKEN }}

144
README.md
View File

@@ -67,11 +67,13 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Configuring PagerDuty alerts](#configuring-pagerduty-alerts)
- [Configuring Pushover alerts](#configuring-pushover-alerts)
- [Configuring Slack alerts](#configuring-slack-alerts)
- [Configuring Teams alerts](#configuring-teams-alerts)
- [Configuring Teams alerts *(Deprecated)*](#configuring-teams-alerts-deprecated)
- [Configuring Teams Workflow alerts](#configuring-teams-workflow-alerts)
- [Configuring Telegram alerts](#configuring-telegram-alerts)
- [Configuring Twilio alerts](#configuring-twilio-alerts)
- [Configuring AWS SES alerts](#configuring-aws-ses-alerts)
- [Configuring custom alerts](#configuring-custom-alerts)
- [Configuring Zulip alerts](#configuring-zulip-alerts)
- [Setting a default alert](#setting-a-default-alert)
- [Maintenance](#maintenance)
- [Security](#security)
@@ -306,12 +308,13 @@ external-endpoints:
To push the status of an external endpoint, the request would have to look like this:
```
POST /api/v1/endpoints/{key}/external?success={success}
POST /api/v1/endpoints/{key}/external?success={success}&error={error}
```
Where:
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`.
- Using the example configuration above, the key would be `core_ext-ep-test`.
- `{success}` is a boolean (`true` or `false`) value indicating whether the health check was successful or not.
- `{error}`: a string describing the reason for a failed health check. If {success} is false, this should contain the error message; if the check is successful, it can be omitted or left empty.
You must also pass the token as a `Bearer` token in the `Authorization` header.
@@ -497,7 +500,7 @@ endpoints:
> 📝 Note that Gatus will use the [gcloud default credentials](https://cloud.google.com/docs/authentication/application-default-credentials) within its environment to generate the token.
This example shows you how you cna use the `client.tls` configuration to perform an mTLS query to a backend API:
This example shows you how you can use the `client.tls` configuration to perform an mTLS query to a backend API:
```yaml
endpoints:
@@ -564,7 +567,8 @@ endpoints:
| `alerting.pagerduty` | Configuration for alerts of type `pagerduty`. <br />See [Configuring PagerDuty alerts](#configuring-pagerduty-alerts). | `{}` |
| `alerting.pushover` | Configuration for alerts of type `pushover`. <br />See [Configuring Pushover alerts](#configuring-pushover-alerts). | `{}` |
| `alerting.slack` | Configuration for alerts of type `slack`. <br />See [Configuring Slack alerts](#configuring-slack-alerts). | `{}` |
| `alerting.teams` | Configuration for alerts of type `teams`. <br />See [Configuring Teams alerts](#configuring-teams-alerts). | `{}` |
| `alerting.teams` | Configuration for alerts of type `teams`. *(Deprecated)* <br />See [Configuring Teams alerts](#configuring-teams-alerts-deprecated). | `{}` |
| `alerting.teams-workflows` | Configuration for alerts of type `teams-workflows`. <br />See [Configuring Teams Workflow alerts](#configuring-teams-workflow-alerts). | `{}` |
| `alerting.telegram` | Configuration for alerts of type `telegram`. <br />See [Configuring Telegram alerts](#configuring-telegram-alerts). | `{}` |
| `alerting.twilio` | Settings for alerts of type `twilio`. <br />See [Configuring Twilio alerts](#configuring-twilio-alerts). | `{}` |
@@ -914,6 +918,7 @@ endpoints:
|:----------------------------------------------|:--------------------------------------------------------------------------------------------|:--------------|
| `alerting.mattermost` | Configuration for alerts of type `mattermost` | `{}` |
| `alerting.mattermost.webhook-url` | Mattermost Webhook URL | Required `""` |
| `alerting.mattermost.channel` | Mattermost channel name override (optional) | `""` |
| `alerting.mattermost.client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` |
| `alerting.mattermost.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert). | N/A |
| `alerting.mattermost.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
@@ -980,14 +985,18 @@ endpoints:
#### Configuring Ntfy alerts
| Parameter | Description | Default |
|:------------------------------|:-------------------------------------------------------------------------------------------|:------------------|
| `alerting.ntfy` | Configuration for alerts of type `ntfy` | `{}` |
| `alerting.ntfy.topic` | Topic at which the alert will be sent | Required `""` |
| `alerting.ntfy.url` | The URL of the target server | `https://ntfy.sh` |
| `alerting.ntfy.token` | [Access token](https://docs.ntfy.sh/publish/#access-tokens) for restricted topics | `""` |
| `alerting.ntfy.priority` | The priority of the alert | `3` |
| `alerting.ntfy.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| Parameter | Description | Default |
|:---------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------|:------------------|
| `alerting.ntfy` | Configuration for alerts of type `ntfy` | `{}` |
| `alerting.ntfy.topic` | Topic at which the alert will be sent | Required `""` |
| `alerting.ntfy.url` | The URL of the target server | `https://ntfy.sh` |
| `alerting.ntfy.token` | [Access token](https://docs.ntfy.sh/publish/#access-tokens) for restricted topics | `""` |
| `alerting.ntfy.email` | E-mail address for additional e-mail notifications | `""` |
| `alerting.ntfy.click` | Website opened when notification is clicked | `""` |
| `alerting.ntfy.priority` | The priority of the alert | `3` |
| `alerting.ntfy.disable-firebase` | Whether message push delivery via firebase should be disabled. [ntfy.sh defaults to enabled](https://docs.ntfy.sh/publish/#disable-firebase) | `false` |
| `alerting.ntfy.disable-cache` | Whether server side message caching should be disabled. [ntfy.sh defaults to enabled](https://docs.ntfy.sh/publish/#message-caching) | `false` |
| `alerting.ntfy.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
[ntfy](https://github.com/binwiederhier/ntfy) is an amazing project that allows you to subscribe to desktop
and mobile notifications, making it an awesome addition to Gatus.
@@ -1169,7 +1178,12 @@ Here's an example of what the notifications look like:
![Slack notifications](.github/assets/slack-alerts.png)
#### Configuring Teams alerts
#### Configuring Teams alerts *(Deprecated)*
> [!CAUTION]
> **Deprecated:** Office 365 Connectors within Microsoft Teams are being retired ([Source: Microsoft DevBlog](https://devblogs.microsoft.com/microsoft365dev/retirement-of-office-365-connectors-within-microsoft-teams/)).
> Existing connectors will continue to work until December 2025. The new [Teams Workflow Alerts](#configuring-teams-workflow-alerts) should be used with Microsoft Workflows instead of this legacy configuration.
| Parameter | Description | Default |
|:-----------------------------------------|:-------------------------------------------------------------------------------------------|:--------------------|
| `alerting.teams` | Configuration for alerts of type `teams` | `{}` |
@@ -1179,11 +1193,14 @@ Here's an example of what the notifications look like:
| `alerting.teams.title` | Title of the notification | `"&#x1F6A8; Gatus"` |
| `alerting.teams.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.teams.overrides[].webhook-url` | Teams Webhook URL | `""` |
| `alerting.teams.client.insecure` | Whether to skip TLS verification | `false` |
```yaml
alerting:
teams:
webhook-url: "https://********.webhook.office.com/webhookb2/************"
client:
insecure: false
# You can also add group-specific to keys, which will
# override the to key above for the specified groups
overrides:
@@ -1220,6 +1237,61 @@ Here's an example of what the notifications look like:
![Teams notifications](.github/assets/teams-alerts.png)
#### Configuring Teams Workflow alerts
> [!NOTE]
> This alert is compatible with Workflows for Microsoft Teams. To setup the workflow and get the webhook URL, follow the [Microsoft Documentation](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498).
| Parameter | Description | Default |
|:---------------------------------------------------|:-------------------------------------------------------------------------------------------|:-------------------|
| `alerting.teams-workflows` | Configuration for alerts of type `teams` | `{}` |
| `alerting.teams-workflows.webhook-url` | Teams Webhook URL | Required `""` |
| `alerting.teams-workflows.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.teams-workflows.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.teams-workflows.title` | Title of the notification | `"&#x26D1; Gatus"` |
| `alerting.teams-workflows.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.teams-workflows.overrides[].webhook-url` | Teams WorkFlow Webhook URL | `""` |
```yaml
alerting:
teams-workflows:
webhook-url: "https://********.webhook.office.com/webhookb2/************"
# You can also add group-specific to keys, which will
# override the to key above for the specified groups
overrides:
- group: "core"
webhook-url: "https://********.webhook.office.com/webhookb3/************"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 30s
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
alerts:
- type: teams-workflows
description: "healthcheck failed"
send-on-resolved: true
- name: back-end
group: core
url: "https://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[CERTIFICATE_EXPIRATION] > 48h"
alerts:
- type: teams-workflows
description: "healthcheck failed"
send-on-resolved: true
```
Here's an example of what the notifications look like:
![Teams Workflow notifications](.github/assets/teams-workflows-alerts.png)
#### Configuring Telegram alerts
| Parameter | Description | Default |
@@ -1355,6 +1427,7 @@ Furthermore, you may use the following placeholders in the body (`alerting.custo
- `[ENDPOINT_NAME]` (resolved from `endpoints[].name`)
- `[ENDPOINT_GROUP]` (resolved from `endpoints[].group`)
- `[ENDPOINT_URL]` (resolved from `endpoints[].url`)
- `[RESULT_ERRORS]` (resolved from the health evaluation of a given health check)
If you have an alert using the `custom` provider with `send-on-resolved` set to `true`, you can use the
`[ALERT_TRIGGERED_OR_RESOLVED]` placeholder to differentiate the notifications.
@@ -1369,7 +1442,7 @@ alerting:
method: "POST"
body: |
{
"text": "[ALERT_TRIGGERED_OR_RESOLVED]: [ENDPOINT_GROUP] - [ENDPOINT_NAME] - [ALERT_DESCRIPTION]"
"text": "[ALERT_TRIGGERED_OR_RESOLVED]: [ENDPOINT_GROUP] - [ENDPOINT_NAME] - [ALERT_DESCRIPTION] - [RESULT_ERRORS]"
}
endpoints:
- name: website
@@ -1490,6 +1563,42 @@ endpoints:
- type: pagerduty
```
#### Configuring Zulip alerts
| Parameter | Description | Default |
|:-----------------------------------------|:------------------------------------------------------------------------------------|:------------------------------------|
| `alerting.zulip` | Configuration for alerts of type `discord` | `{}` |
| `alerting.zulip.bot-email` | Bot Email | Required `""` |
| `alerting.zulip.bot-api-key` | Bot API key | Required `""` |
| `alerting.zulip.domain` | Full organization domain (e.g.: yourZulipDomain.zulipchat.com) | Required `""` |
| `alerting.zulip.channel-id` | The channel ID where Gatus will send the alerts | Required `""` |
| `alerting.zulip.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.zulip.overrides[].bot-email` | . | `""` |
| `alerting.zulip.overrides[].bot-api-key` | . | `""` |
| `alerting.zulip.overrides[].domain` | . | `""` |
| `alerting.zulip.overrides[].channel-id` | . | `""` |
```yaml
alerting:
zulip:
bot-email: gatus-bot@some.zulip.org
bot-api-key: "********************************"
domain: some.zulip.org
channel-id: 123456
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
alerts:
- type: zulip
description: "healthcheck failed"
send-on-resolved: true
```
### Maintenance
If you have maintenance windows, you may not want to be annoyed by alerts.
@@ -1687,11 +1796,12 @@ Please refer to Helm's [documentation](https://helm.sh/docs/) to get started.
Once Helm is set up properly, add the repository as follows:
```console
helm repo add minicloudlabs https://minicloudlabs.github.io/helm-charts
helm repo add twin https://twin.github.io/helm-charts
helm repo update
helm install gatus twin/gatus
```
To get more details, please check [chart's configuration](https://github.com/minicloudlabs/helm-charts/tree/main/charts/gatus#configuration)
and [helm file example](https://github.com/minicloudlabs/helm-charts/tree/main/charts/gatus#helmfileyaml-example)
To get more details, please check [chart's configuration](https://github.com/TwiN/helm-charts/blob/master/charts/gatus/README.md).
### Terraform

View File

@@ -62,9 +62,15 @@ const (
// TypeTeams is the Type for the teams alerting provider
TypeTeams Type = "teams"
// TypeTeamsWorkflows is the Type for the teams-workflows alerting provider
TypeTeamsWorkflows Type = "teams-workflows"
// TypeTelegram is the Type for the telegram alerting provider
TypeTelegram Type = "telegram"
// TypeTwilio is the Type for the twilio alerting provider
TypeTwilio Type = "twilio"
// TypeZulip is the Type for the Zulip alerting provider
TypeZulip Type = "zulip"
)

View File

@@ -26,8 +26,10 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/pushover"
"github.com/TwiN/gatus/v5/alerting/provider/slack"
"github.com/TwiN/gatus/v5/alerting/provider/teams"
"github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows"
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
"github.com/TwiN/gatus/v5/alerting/provider/zulip"
)
// Config is the configuration for alerting providers
@@ -89,11 +91,17 @@ type Config struct {
// Teams is the configuration for the teams alerting provider
Teams *teams.AlertProvider `yaml:"teams,omitempty"`
// TeamsWorkflows is the configuration for the teams alerting provider using the new Workflow App Webhook Connector
TeamsWorkflows *teamsworkflows.AlertProvider `yaml:"teams-workflows,omitempty"`
// Telegram is the configuration for the telegram alerting provider
Telegram *telegram.AlertProvider `yaml:"telegram,omitempty"`
// Twilio is the configuration for the twilio alerting provider
Twilio *twilio.AlertProvider `yaml:"twilio,omitempty"`
// Zulip is the configuration for the zulip alerting provider
Zulip *zulip.AlertProvider `yaml:"zulip,omitempty"`
}
// GetAlertingProviderByAlertType returns an provider.AlertProvider by its corresponding alert.Type

View File

@@ -50,7 +50,7 @@ func (provider *AlertProvider) GetAlertStatePlaceholderValue(resolved bool) stri
return status
}
func (provider *AlertProvider) buildHTTPRequest(ep *endpoint.Endpoint, alert *alert.Alert, resolved bool) *http.Request {
func (provider *AlertProvider) buildHTTPRequest(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) *http.Request {
body, url, method := provider.Body, provider.URL, provider.Method
body = strings.ReplaceAll(body, "[ALERT_DESCRIPTION]", alert.GetDescription())
url = strings.ReplaceAll(url, "[ALERT_DESCRIPTION]", alert.GetDescription())
@@ -60,6 +60,8 @@ func (provider *AlertProvider) buildHTTPRequest(ep *endpoint.Endpoint, alert *al
url = strings.ReplaceAll(url, "[ENDPOINT_GROUP]", ep.Group)
body = strings.ReplaceAll(body, "[ENDPOINT_URL]", ep.URL)
url = strings.ReplaceAll(url, "[ENDPOINT_URL]", ep.URL)
body = strings.ReplaceAll(body, "[RESULT_ERRORS]", strings.Join(result.Errors, ","))
url = strings.ReplaceAll(url, "[RESULT_ERRORS]", strings.Join(result.Errors, ","))
if resolved {
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
@@ -79,7 +81,7 @@ func (provider *AlertProvider) buildHTTPRequest(ep *endpoint.Endpoint, alert *al
}
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
request := provider.buildHTTPRequest(ep, alert, resolved)
request := provider.buildHTTPRequest(ep, alert, result, resolved)
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
if err != nil {
return err

View File

@@ -140,6 +140,53 @@ func TestAlertProvider_buildHTTPRequest(t *testing.T) {
request := customAlertProvider.buildHTTPRequest(
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group", URL: "https://example.com"},
&alert.Alert{Description: &alertDescription},
&endpoint.Result{Errors: []string{}},
scenario.Resolved,
)
if request.URL.String() != scenario.ExpectedURL {
t.Error("expected URL to be", scenario.ExpectedURL, "got", request.URL.String())
}
body, _ := io.ReadAll(request.Body)
if string(body) != scenario.ExpectedBody {
t.Error("expected body to be", scenario.ExpectedBody, "got", string(body))
}
})
}
}
func TestAlertProviderWithResultErrors_buildHTTPRequest(t *testing.T) {
customAlertWithErrorsProvider := &AlertProvider{
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]&url=[ENDPOINT_URL]&error=[RESULT_ERRORS]",
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ENDPOINT_URL],[ALERT_TRIGGERED_OR_RESOLVED],[RESULT_ERRORS]",
}
alertDescription := "alert-description"
scenarios := []struct {
AlertProvider *AlertProvider
Resolved bool
ExpectedURL string
ExpectedBody string
Errors []string
}{
{
AlertProvider: customAlertWithErrorsProvider,
Resolved: true,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=RESOLVED&description=alert-description&url=https://example.com&error=",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,RESOLVED,",
},
{
AlertProvider: customAlertWithErrorsProvider,
Resolved: false,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=TRIGGERED&description=alert-description&url=https://example.com&error=error1,error2",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,TRIGGERED,error1,error2",
Errors: []string{"error1", "error2"},
},
}
for _, scenario := range scenarios {
t.Run(fmt.Sprintf("resolved-%v-with-default-placeholders-and-result-errors", scenario.Resolved), func(t *testing.T) {
request := customAlertWithErrorsProvider.buildHTTPRequest(
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group", URL: "https://example.com"},
&alert.Alert{Description: &alertDescription},
&endpoint.Result{Errors: scenario.Errors},
scenario.Resolved,
)
if request.URL.String() != scenario.ExpectedURL {
@@ -190,6 +237,7 @@ func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
request := customAlertProvider.buildHTTPRequest(
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&alert.Alert{Description: &alertDescription},
&endpoint.Result{},
scenario.Resolved,
)
if request.URL.String() != scenario.ExpectedURL {

View File

@@ -16,6 +16,9 @@ import (
type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"`
// Channel is the optional setting to override the default webhook's channel
Channel string `yaml:"channel,omitempty"`
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"`
@@ -70,6 +73,7 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
}
type Body struct {
Channel string `json:"channel,omitempty"` // Optional channel override
Text string `json:"text"`
Username string `json:"username"`
IconURL string `json:"icon_url"`
@@ -118,6 +122,7 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
description = ":\n> " + alertDescription
}
body := Body{
Channel: provider.Channel,
Text: "",
Username: "gatus",
IconURL: "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png",

View File

@@ -21,10 +21,14 @@ const (
// AlertProvider is the configuration necessary for sending an alert using Slack
type AlertProvider struct {
Topic string `yaml:"topic"`
URL string `yaml:"url,omitempty"` // Defaults to DefaultURL
Priority int `yaml:"priority,omitempty"` // Defaults to DefaultPriority
Token string `yaml:"token,omitempty"` // Defaults to ""
Topic string `yaml:"topic"`
URL string `yaml:"url,omitempty"` // Defaults to DefaultURL
Priority int `yaml:"priority,omitempty"` // Defaults to DefaultPriority
Token string `yaml:"token,omitempty"` // Defaults to ""
Email string `yaml:"email,omitempty"` // Defaults to ""
Click string `yaml:"click,omitempty"` // Defaults to ""
DisableFirebase bool `yaml:"disable-firebase,omitempty"` // Defaults to false
DisableCache bool `yaml:"disable-cache,omitempty"` // Defaults to false
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
@@ -56,6 +60,12 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
if len(provider.Token) > 0 {
request.Header.Set("Authorization", "Bearer "+provider.Token)
}
if provider.DisableFirebase {
request.Header.Set("Firebase", "no")
}
if provider.DisableCache {
request.Header.Set("Cache", "no")
}
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
@@ -74,6 +84,8 @@ type Body struct {
Message string `json:"message"`
Tags []string `json:"tags"`
Priority int `json:"priority"`
Email string `json:"email,omitempty"`
Click string `json:"click,omitempty"`
}
// buildRequestBody builds the request body for the provider
@@ -105,6 +117,8 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
Message: message,
Tags: []string{tag},
Priority: provider.Priority,
Email: provider.Email,
Click: provider.Click,
})
return body
}

View File

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

View File

@@ -20,8 +20,10 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/pushover"
"github.com/TwiN/gatus/v5/alerting/provider/slack"
"github.com/TwiN/gatus/v5/alerting/provider/teams"
"github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows"
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
"github.com/TwiN/gatus/v5/alerting/provider/zulip"
"github.com/TwiN/gatus/v5/config/endpoint"
)
@@ -79,6 +81,8 @@ var (
_ AlertProvider = (*pushover.AlertProvider)(nil)
_ AlertProvider = (*slack.AlertProvider)(nil)
_ AlertProvider = (*teams.AlertProvider)(nil)
_ AlertProvider = (*teamsworkflows.AlertProvider)(nil)
_ AlertProvider = (*telegram.AlertProvider)(nil)
_ AlertProvider = (*twilio.AlertProvider)(nil)
_ AlertProvider = (*zulip.AlertProvider)(nil)
)

View File

@@ -19,6 +19,9 @@ type AlertProvider struct {
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
@@ -54,7 +57,7 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
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
}

View File

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

View File

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

View File

@@ -0,0 +1,132 @@
package zulip
import (
"bytes"
"fmt"
"io"
"net/http"
"net/url"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
)
type Config struct {
// BotEmail is the email of the bot user
BotEmail string `yaml:"bot-email"`
// BotAPIKey is the API key of the bot user
BotAPIKey string `yaml:"bot-api-key"`
// Domain is the domain of the Zulip server
Domain string `yaml:"domain"`
// ChannelID is the ID of the channel to send the message to
ChannelID string `yaml:"channel-id"`
}
// AlertProvider is the configuration necessary for sending an alert using Zulip
type AlertProvider struct {
Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Config
Group string `yaml:"group"`
}
func (provider *AlertProvider) validateConfig(conf *Config) bool {
return len(conf.BotEmail) > 0 && len(conf.BotAPIKey) > 0 && len(conf.Domain) > 0 && len(conf.ChannelID) > 0
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
isAlreadyRegistered := registeredGroups[override.Group]
if isAlreadyRegistered || override.Group == "" || !provider.validateConfig(&override.Config) {
return false
}
registeredGroups[override.Group] = true
}
}
return provider.validateConfig(&provider.Config)
}
// getChannelIdForGroup returns the channel ID for the provided group
func (provider *AlertProvider) getChannelIdForGroup(group string) string {
for _, override := range provider.Overrides {
if override.Group == group {
return override.ChannelID
}
}
return provider.ChannelID
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
var message string
if resolved {
message = fmt.Sprintf("An alert for **%s** has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else {
message = fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
}
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
message += "\n> " + alertDescription + "\n"
}
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = ":check:"
} else {
prefix = ":cross_mark:"
}
message += fmt.Sprintf("\n%s - `%s`", prefix, conditionResult.Condition)
}
postData := map[string]string{
"type": "channel",
"to": provider.getChannelIdForGroup(ep.Group),
"topic": "Gatus",
"content": message,
}
bodyParams := url.Values{}
for field, value := range postData {
bodyParams.Add(field, value)
}
return bodyParams.Encode()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBufferString(provider.buildRequestBody(ep, alert, result, resolved))
zulipEndpoint := fmt.Sprintf("https://%s/api/v1/messages", provider.Domain)
request, err := http.NewRequest(http.MethodPost, zulipEndpoint, buffer)
if err != nil {
return err
}
request.SetBasicAuth(provider.BotEmail, provider.BotAPIKey)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("User-Agent", "Gatus")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return nil
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}

View File

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

View File

@@ -46,6 +46,9 @@ func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
Success: c.QueryBool("success"),
Errors: []string{},
}
if !result.Success && c.Query("error") != "" {
result.Errors = append(result.Errors, c.Query("error"))
}
convertedEndpoint := externalEndpoint.ToEndpoint()
if err := store.Get().Insert(convertedEndpoint, result); err != nil {
if errors.Is(err, common.ErrEndpointNotFound) {

View File

@@ -76,6 +76,12 @@ func TestCreateExternalEndpointResult(t *testing.T) {
AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 200,
},
{
Name: "good-token-success-true-with-ignored-error-because-success-true",
Path: "/api/v1/endpoints/g_n/external?success=true&error=failed",
AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 200,
},
{
Name: "good-token-success-false",
Path: "/api/v1/endpoints/g_n/external?success=false",
@@ -88,6 +94,12 @@ func TestCreateExternalEndpointResult(t *testing.T) {
AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 200,
},
{
Name: "good-token-success-false-with-error",
Path: "/api/v1/endpoints/g_n/external?success=false&error=failed",
AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 200,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
@@ -114,21 +126,33 @@ func TestCreateExternalEndpointResult(t *testing.T) {
if endpointStatus.Key != "g_n" {
t.Errorf("expected key to be g_n but got %s", endpointStatus.Key)
}
if len(endpointStatus.Results) != 3 {
if len(endpointStatus.Results) != 5 {
t.Errorf("expected 3 results but got %d", len(endpointStatus.Results))
}
if !endpointStatus.Results[0].Success {
t.Errorf("expected first result to be successful")
}
if endpointStatus.Results[1].Success {
t.Errorf("expected second result to be unsuccessful")
if !endpointStatus.Results[1].Success {
t.Errorf("expected second result to be successful")
}
if len(endpointStatus.Results[1].Errors) > 0 {
t.Errorf("expected second result to have no errors")
}
if endpointStatus.Results[2].Success {
t.Errorf("expected third result to be unsuccessful")
}
if endpointStatus.Results[3].Success {
t.Errorf("expected fourth result to be unsuccessful")
}
if endpointStatus.Results[4].Success {
t.Errorf("expected fifth result to be unsuccessful")
}
if len(endpointStatus.Results[4].Errors) == 0 || endpointStatus.Results[4].Errors[0] != "failed" {
t.Errorf("expected fifth result to have errors: failed")
}
externalEndpointFromConfig := cfg.GetExternalEndpointByKey("g_n")
if externalEndpointFromConfig.NumberOfFailuresInARow != 2 {
t.Errorf("expected 2 failures in a row but got %d", externalEndpointFromConfig.NumberOfFailuresInARow)
if externalEndpointFromConfig.NumberOfFailuresInARow != 3 {
t.Errorf("expected 3 failures in a row but got %d", externalEndpointFromConfig.NumberOfFailuresInARow)
}
if externalEndpointFromConfig.NumberOfSuccessesInARow != 0 {
t.Errorf("expected 0 successes in a row but got %d", externalEndpointFromConfig.NumberOfSuccessesInARow)

View File

@@ -413,8 +413,10 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
alert.TypePushover,
alert.TypeSlack,
alert.TypeTeams,
alert.TypeTeamsWorkflows,
alert.TypeTelegram,
alert.TypeTwilio,
alert.TypeZulip,
}
var validProviders, invalidProviders []alert.Type
for _, alertType := range alertTypes {

View File

@@ -6,6 +6,7 @@ import (
"strconv"
"strings"
"time"
_ "time/tzdata" // Required for IANA timezone support
)
var (
@@ -52,7 +53,7 @@ func GetDefaultConfig() *Config {
}
// IsEnabled returns whether maintenance is enabled or not
func (c Config) IsEnabled() bool {
func (c *Config) IsEnabled() bool {
if c.Enabled == nil {
return true
}
@@ -101,7 +102,7 @@ func (c *Config) ValidateAndSetDefaults() error {
}
// IsUnderMaintenance checks whether the endpoints that Gatus monitors are within the configured maintenance window
func (c Config) IsUnderMaintenance() bool {
func (c *Config) IsUnderMaintenance() bool {
if !c.IsEnabled() {
return false
}
@@ -127,7 +128,7 @@ func (c Config) IsUnderMaintenance() bool {
return now.After(startOfMaintenancePeriod) && now.Before(endOfMaintenancePeriod)
}
func (c Config) hasDay(day string) bool {
func (c *Config) hasDay(day string) bool {
for _, d := range c.Every {
if d == day {
return true

36
go.mod
View File

@@ -10,24 +10,24 @@ require (
github.com/TwiN/health v1.6.0
github.com/TwiN/whois v1.1.9
github.com/aws/aws-sdk-go v1.54.10
github.com/coreos/go-oidc/v3 v3.10.0
github.com/gofiber/fiber/v2 v2.52.4
github.com/coreos/go-oidc/v3 v3.11.0
github.com/gofiber/fiber/v2 v2.52.5
github.com/google/go-github/v48 v48.2.0
github.com/google/uuid v1.6.0
github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2
github.com/lib/pq v1.10.9
github.com/miekg/dns v1.1.61
github.com/miekg/dns v1.1.62
github.com/prometheus-community/pro-bing v0.4.0
github.com/prometheus/client_golang v1.19.1
github.com/valyala/fasthttp v1.54.0
github.com/wcharczuk/go-chart/v2 v2.1.1
golang.org/x/crypto v0.24.0
golang.org/x/net v0.26.0
github.com/prometheus/client_golang v1.20.4
github.com/valyala/fasthttp v1.56.0
github.com/wcharczuk/go-chart/v2 v2.1.2
golang.org/x/crypto v0.27.0
golang.org/x/net v0.29.0
golang.org/x/oauth2 v0.21.0
google.golang.org/api v0.183.0
gopkg.in/mail.v2 v2.3.1
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.31.1
modernc.org/sqlite v1.33.1
)
require (
@@ -36,9 +36,7 @@ require (
cloud.google.com/go/compute/metadata v0.3.0 // indirect
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/blend/go-sdk v1.20220411.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
@@ -55,14 +53,16 @@ require (
github.com/hashicorp/go-version v1.6.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/klauspost/compress v1.17.8 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.54.0 // indirect
github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
@@ -74,15 +74,15 @@ require (
go.opentelemetry.io/otel v1.27.0 // indirect
go.opentelemetry.io/otel/metric v1.27.0 // indirect
go.opentelemetry.io/otel/trace v1.27.0 // indirect
golang.org/x/image v0.17.0 // indirect
golang.org/x/image v0.18.0 // indirect
golang.org/x/mod v0.18.0 // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.22.0 // indirect
golang.org/x/text v0.16.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/text v0.18.0 // indirect
golang.org/x/tools v0.22.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 // indirect
google.golang.org/grpc v1.64.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b // indirect
modernc.org/libc v1.55.3 // indirect

101
go.sum
View File

@@ -24,15 +24,13 @@ github.com/aws/aws-sdk-go v1.54.10 h1:dvkMlAttUsyacKj2L4poIQBLzOSWL2JG2ty+yWrqet
github.com/aws/aws-sdk-go v1.54.10/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/blend/go-sdk v1.20220411.3 h1:GFV4/FQX5UzXLPwWV03gP811pj7B8J2sbuq+GJQofXc=
github.com/blend/go-sdk v1.20220411.3/go.mod h1:7lnH8fTi6U4i1fArEXRyOIY2E1X4MALg09qsQqY1+ak=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/coreos/go-oidc/v3 v3.10.0 h1:tDnXHnLyiTVyT/2zLDGj09pFPkhND8Gl8lnTRhoEaJU=
github.com/coreos/go-oidc/v3 v3.10.0/go.mod h1:5j11xcw0D3+SGxn6Z/WFADsgcWVMyNAlSQupk0KK3ac=
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -56,8 +54,8 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/gofiber/fiber/v2 v2.52.4 h1:P+T+4iK7VaqUsq2PALYEfBBo6bJZ4q3FP8cZ84EggTM=
github.com/gofiber/fiber/v2 v2.52.4/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ=
github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo=
github.com/gofiber/fiber/v2 v2.52.5/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
@@ -110,12 +108,14 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
@@ -125,21 +125,23 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/miekg/dns v1.1.61 h1:nLxbwF3XxhwVSm8g9Dghm9MHPaUZuqhPiGL+675ZmEs=
github.com/miekg/dns v1.1.61/go.mod h1:mnAarhS3nWaW+NVP2wTkYVIZyHNJ098SJZUki3eykwQ=
github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ=
github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4=
github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4=
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
github.com/prometheus/client_golang v1.20.4 h1:Tgh3Yr67PaOv/uTqloMsCEdeuFTatm5zIq5+qNN23vI=
github.com/prometheus/client_golang v1.20.4/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.54.0 h1:ZlZy0BgJhTwVZUn7dLOkwCZHUkrAqd3WYtcFCWnM1D8=
github.com/prometheus/common v0.54.0/go.mod h1:/TQgMJP5CuVYveyT7n/0Ix8yLNNXy9yRSkhnLTHPDIQ=
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -159,12 +161,12 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.54.0 h1:cCL+ZZR3z3HPLMVfEYVUMtJqVaui0+gu7Lx63unHwS0=
github.com/valyala/fasthttp v1.54.0/go.mod h1:6dt4/8olwq9QARP/TDuPmWyWcl4byhpvTJ4AAtcz+QM=
github.com/valyala/fasthttp v1.56.0 h1:bEZdJev/6LCBlpdORfrLu/WOZXXxvrUQSiyniuaoW8U=
github.com/valyala/fasthttp v1.56.0/go.mod h1:sReBt3XZVnudxuLOx4J/fMrJVorWRiWY2koQKgABiVI=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/wcharczuk/go-chart/v2 v2.1.1 h1:2u7na789qiD5WzccZsFz4MJWOJP72G+2kUuJoSNqWnE=
github.com/wcharczuk/go-chart/v2 v2.1.1/go.mod h1:CyCAUt2oqvfhCl6Q5ZvAZwItgpQKZOkCJGb+VGv6l14=
github.com/wcharczuk/go-chart/v2 v2.1.2 h1:Y17/oYNuXwZg6TFag06qe8sBajwwsuvPiJJXcUcLL6E=
github.com/wcharczuk/go-chart/v2 v2.1.2/go.mod h1:Zi4hbaqlWpYajnXB2K22IUYVXRXaLfSGNNR7P4ukyyQ=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
@@ -182,17 +184,22 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/image v0.11.0/go.mod h1:bglhjqbqVuEb9e9+eNR45Jfu7D+T4Qan+NhQk8Ck2P8=
golang.org/x/image v0.17.0 h1:nTRVVdajgB8zCMZVsViyzhnMKPwYeroEERRC64JuLco=
golang.org/x/image v0.17.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -205,8 +212,12 @@ golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
@@ -215,8 +226,11 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -228,20 +242,33 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM=
golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -252,6 +279,8 @@ golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBn
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -281,8 +310,8 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -318,8 +347,8 @@ modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.31.1 h1:XVU0VyzxrYHlBhIs1DiEgSl0ZtdnPtbLVy8hSkzxGrs=
modernc.org/sqlite v1.31.1/go.mod h1:UqoylwmTb9F+IqXERT8bW9zzOWN8qwAIcLdzeBZs4hA=
modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM=
modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=