diff --git a/README.md b/README.md index ce1425ad..ae5c1e42 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga - [Client configuration](#client-configuration) - [Alerting](#alerting) - [Configuring AWS SES alerts](#configuring-aws-ses-alerts) + - [Configuring Datadog alerts](#configuring-datadog-alerts) - [Configuring Discord alerts](#configuring-discord-alerts) - [Configuring Email alerts](#configuring-email-alerts) - [Configuring Gitea alerts](#configuring-gitea-alerts) @@ -60,21 +61,34 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga - [Configuring Google Chat alerts](#configuring-google-chat-alerts) - [Configuring Gotify alerts](#configuring-gotify-alerts) - [Configuring HomeAssistant alerts](#configuring-homeassistant-alerts) + - [Configuring IFTTT alerts](#configuring-ifttt-alerts) - [Configuring Ilert alerts](#configuring-ilert-alerts) - [Configuring Incident.io alerts](#configuring-incidentio-alerts) - [Configuring JetBrains Space alerts](#configuring-jetbrains-space-alerts) + - [Configuring Line alerts](#configuring-line-alerts) - [Configuring Matrix alerts](#configuring-matrix-alerts) - [Configuring Mattermost alerts](#configuring-mattermost-alerts) - [Configuring Messagebird alerts](#configuring-messagebird-alerts) + - [Configuring New Relic alerts](#configuring-new-relic-alerts) - [Configuring Ntfy alerts](#configuring-ntfy-alerts) - [Configuring Opsgenie alerts](#configuring-opsgenie-alerts) - [Configuring PagerDuty alerts](#configuring-pagerduty-alerts) + - [Configuring Plivo alerts](#configuring-plivo-alerts) - [Configuring Pushover alerts](#configuring-pushover-alerts) + - [Configuring Rocket.Chat alerts](#configuring-rocketchat-alerts) + - [Configuring SendGrid alerts](#configuring-sendgrid-alerts) + - [Configuring Signal alerts](#configuring-signal-alerts) + - [Configuring SIGNL4 alerts](#configuring-signl4-alerts) - [Configuring Slack alerts](#configuring-slack-alerts) + - [Configuring Splunk alerts](#configuring-splunk-alerts) + - [Configuring Squadcast alerts](#configuring-squadcast-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 Vonage alerts](#configuring-vonage-alerts) + - [Configuring Webex alerts](#configuring-webex-alerts) + - [Configuring Zapier alerts](#configuring-zapier-alerts) - [Configuring Zulip alerts](#configuring-zulip-alerts) - [Configuring custom alerts](#configuring-custom-alerts) - [Setting a default alert](#setting-a-default-alert) @@ -637,6 +651,7 @@ endpoints: |:---------------------------|:----------------------------------------------------------------------------------------------------------------------------------------|:--------| | `alerting.awsses` | Configuration for alerts of type `awsses`.
See [Configuring AWS SES alerts](#configuring-aws-ses-alerts). | `{}` | | `alerting.custom` | Configuration for custom actions on failure or alerts.
See [Configuring Custom alerts](#configuring-custom-alerts). | `{}` | +| `alerting.datadog` | Configuration for alerts of type `datadog`.
See [Configuring Datadog alerts](#configuring-datadog-alerts). | `{}` | | `alerting.discord` | Configuration for alerts of type `discord`.
See [Configuring Discord alerts](#configuring-discord-alerts). | `{}` | | `alerting.email` | Configuration for alerts of type `email`.
See [Configuring Email alerts](#configuring-email-alerts). | `{}` | | `alerting.gitea` | Configuration for alerts of type `gitea`.
See [Configuring Gitea alerts](#configuring-gitea-alerts). | `{}` | @@ -644,23 +659,36 @@ endpoints: | `alerting.gitlab` | Configuration for alerts of type `gitlab`.
See [Configuring GitLab alerts](#configuring-gitlab-alerts). | `{}` | | `alerting.googlechat` | Configuration for alerts of type `googlechat`.
See [Configuring Google Chat alerts](#configuring-google-chat-alerts). | `{}` | | `alerting.gotify` | Configuration for alerts of type `gotify`.
See [Configuring Gotify alerts](#configuring-gotify-alerts). | `{}` | +| `alerting.homeassistant` | Configuration for alerts of type `homeassistant`.
See [Configuring HomeAssistant alerts](#configuring-homeassistant-alerts). | `{}` | +| `alerting.ifttt` | Configuration for alerts of type `ifttt`.
See [Configuring IFTTT alerts](#configuring-ifttt-alerts). | `{}` | | `alerting.ilert` | Configuration for alerts of type `ilert`.
See [Configuring ilert alerts](#configuring-ilert-alerts). | `{}` | | `alerting.incident-io` | Configuration for alerts of type `incident-io`.
See [Configuring Incident.io alerts](#configuring-incidentio-alerts). | `{}` | | `alerting.jetbrainsspace` | Configuration for alerts of type `jetbrainsspace`.
See [Configuring JetBrains Space alerts](#configuring-jetbrains-space-alerts). | `{}` | +| `alerting.line` | Configuration for alerts of type `line`.
See [Configuring Line alerts](#configuring-line-alerts). | `{}` | | `alerting.matrix` | Configuration for alerts of type `matrix`.
See [Configuring Matrix alerts](#configuring-matrix-alerts). | `{}` | | `alerting.mattermost` | Configuration for alerts of type `mattermost`.
See [Configuring Mattermost alerts](#configuring-mattermost-alerts). | `{}` | | `alerting.messagebird` | Configuration for alerts of type `messagebird`.
See [Configuring Messagebird alerts](#configuring-messagebird-alerts). | `{}` | +| `alerting.newrelic` | Configuration for alerts of type `newrelic`.
See [Configuring New Relic alerts](#configuring-new-relic-alerts). | `{}` | | `alerting.ntfy` | Configuration for alerts of type `ntfy`.
See [Configuring Ntfy alerts](#configuring-ntfy-alerts). | `{}` | | `alerting.opsgenie` | Configuration for alerts of type `opsgenie`.
See [Configuring Opsgenie alerts](#configuring-opsgenie-alerts). | `{}` | | `alerting.pagerduty` | Configuration for alerts of type `pagerduty`.
See [Configuring PagerDuty alerts](#configuring-pagerduty-alerts). | `{}` | +| `alerting.plivo` | Configuration for alerts of type `plivo`.
See [Configuring Plivo alerts](#configuring-plivo-alerts). | `{}` | | `alerting.pushover` | Configuration for alerts of type `pushover`.
See [Configuring Pushover alerts](#configuring-pushover-alerts). | `{}` | +| `alerting.rocketchat` | Configuration for alerts of type `rocketchat`.
See [Configuring Rocket.Chat alerts](#configuring-rocketchat-alerts). | `{}` | +| `alerting.sendgrid` | Configuration for alerts of type `sendgrid`.
See [Configuring SendGrid alerts](#configuring-sendgrid-alerts). | `{}` | +| `alerting.signal` | Configuration for alerts of type `signal`.
See [Configuring Signal alerts](#configuring-signal-alerts). | `{}` | +| `alerting.signl4` | Configuration for alerts of type `signl4`.
See [Configuring SIGNL4 alerts](#configuring-signl4-alerts). | `{}` | | `alerting.slack` | Configuration for alerts of type `slack`.
See [Configuring Slack alerts](#configuring-slack-alerts). | `{}` | +| `alerting.splunk` | Configuration for alerts of type `splunk`.
See [Configuring Splunk alerts](#configuring-splunk-alerts). | `{}` | +| `alerting.squadcast` | Configuration for alerts of type `squadcast`.
See [Configuring Squadcast alerts](#configuring-squadcast-alerts). | `{}` | | `alerting.teams` | Configuration for alerts of type `teams`. *(Deprecated)*
See [Configuring Teams alerts](#configuring-teams-alerts-deprecated). | `{}` | | `alerting.teams-workflows` | Configuration for alerts of type `teams-workflows`.
See [Configuring Teams Workflow alerts](#configuring-teams-workflow-alerts). | `{}` | | `alerting.telegram` | Configuration for alerts of type `telegram`.
See [Configuring Telegram alerts](#configuring-telegram-alerts). | `{}` | | `alerting.twilio` | Settings for alerts of type `twilio`.
See [Configuring Twilio alerts](#configuring-twilio-alerts). | `{}` | +| `alerting.vonage` | Configuration for alerts of type `vonage`.
See [Configuring Vonage alerts](#configuring-vonage-alerts). | `{}` | +| `alerting.webex` | Configuration for alerts of type `webex`.
See [Configuring Webex alerts](#configuring-webex-alerts). | `{}` | +| `alerting.zapier` | Configuration for alerts of type `zapier`.
See [Configuring Zapier alerts](#configuring-zapier-alerts). | `{}` | | `alerting.zulip` | Configuration for alerts of type `zulip`.
See [Configuring Zulip alerts](#configuring-zulip-alerts). | `{}` | -| `alerting.homeassistant` | Configuration for alerts of type `homeassistant`.
See [Configuring HomeAssistant alerts](#configuring-homeassistant-alerts). | `{}` | #### Configuring AWS SES alerts @@ -703,6 +731,42 @@ If the `access-key-id` and `secret-access-key` are not defined Gatus will fall b Make sure you have the ability to use `ses:SendEmail`. +#### Configuring Datadog alerts + +> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation. + +| Parameter | Description | Default | +|:-------------------------------------|:-------------------------------------------------------------------------------------------|:------------------| +| `alerting.datadog` | Configuration for alerts of type `datadog` | `{}` | +| `alerting.datadog.api-key` | Datadog API key | Required `""` | +| `alerting.datadog.site` | Datadog site (e.g., datadoghq.com, datadoghq.eu) | `"datadoghq.com"` | +| `alerting.datadog.tags` | Additional tags to include | `[]` | +| `alerting.datadog.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | +| `alerting.datadog.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | +| `alerting.datadog.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | +| `alerting.datadog.overrides[].*` | See `alerting.datadog.*` parameters | `{}` | + +```yaml +alerting: + datadog: + api-key: "YOUR_API_KEY" + site: "datadoghq.com" # or datadoghq.eu for EU region + tags: + - "environment:production" + - "team:platform" + +endpoints: + - name: website + url: "https://twin.sh/health" + interval: 5m + conditions: + - "[STATUS] == 200" + alerts: + - type: datadog + send-on-resolved: true +``` + + #### Configuring Discord alerts | Parameter | Description | Default | |:-------------------------------------|:-------------------------------------------------------------------------------------------|:------------------------------------| @@ -795,6 +859,7 @@ endpoints: > ⚠ Some mail servers are painfully slow. + #### Configuring Gitea alerts | Parameter | Description | Default | @@ -802,7 +867,7 @@ endpoints: | `alerting.gitea` | Configuration for alerts of type `gitea` | `{}` | | `alerting.gitea.repository-url` | Gitea repository URL (e.g. `https://gitea.com/TwiN/example`) | Required `""` | | `alerting.gitea.token` | Personal access token to use for authentication.
Must have at least RW on issues and RO on metadata. | Required `""` | -| `alerting.github.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert). | N/A | +| `alerting.gitea.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert). | N/A | The Gitea alerting provider creates an issue prefixed with `alert(gatus):` and suffixed with the endpoint's display name for each alert. If `send-on-resolved` is set to `true` on the endpoint alert, the issue will be automatically @@ -832,6 +897,7 @@ endpoints: ![Gitea alert](.github/assets/gitea-alerts.png) + #### Configuring GitHub alerts | Parameter | Description | Default | @@ -869,6 +935,7 @@ endpoints: ![GitHub alert](.github/assets/github-alerts.png) + #### Configuring GitLab alerts | Parameter | Description | Default | |:------------------------------------|:--------------------------------------------------------------------------------------------------------------------|:--------------| @@ -952,51 +1019,6 @@ endpoints: | `alerting.gotify.title` | Title of the notification | `"Gatus: "` | | `alerting.gotify.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert). | N/A | -#### Configuring ilert alerts -| Parameter | Description | Default | -|:---------------------------------------|:-------------------------------------------------------------------------------------------|:--------| -| `alerting.ilert` | Configuration for alerts of type `ilert` | `{}` | -| `alerting.ilert.integration-key` | ilert Alert Source integration key | `""` | -| `alerting.ilert.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | -| `alerting.ilert.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | -| `alerting.ilert.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | -| `alerting.ilert.overrides[].*` | See `alerting.ilert.*` parameters | `{}` | - -It is highly recommended to set `endpoints[].alerts[].send-on-resolved` to `true` for alerts -of type `ilert`, because unlike other alerts, the operation resulting from setting said -parameter to `true` will not create another alert but mark the alert as resolved on -ilert instead. - -Behavior: -- By default, `alerting.ilert.integration-key` is used as the integration key -- If the endpoint being evaluated belongs to a group (`endpoints[].group`) matching the value of `alerting.ilert.overrides[].group`, the provider will use that override's integration key instead of `alerting.ilert.integration-key`'s - -```yaml -alerting: - ilert: - integration-key: "********************************" - # You can also add group-specific integration keys, which will - # override the integration key above for the specified groups - overrides: - - group: "core" - integration-key: "********************************" - -endpoints: - - name: website - url: "https://twin.sh/health" - interval: 30s - conditions: - - "[STATUS] == 200" - - "[BODY].status == UP" - - "[RESPONSE_TIME] < 300" - alerts: - - type: ilert - failure-threshold: 3 - success-threshold: 5 - send-on-resolved: true - description: "healthcheck failed" -``` - ```yaml alerting: gotify: @@ -1023,7 +1045,14 @@ Here's an example of what the notifications look like: #### Configuring HomeAssistant alerts -To configure HomeAssistant alerts, you'll need to add the following to your configuration file: +| Parameter | Description | Default Value | +|:-------------------------------------------|:---------------------------------------------------------------------------------------|:--------------| +| `alerting.homeassistant.url` | HomeAssistant instance URL | Required `""` | +| `alerting.homeassistant.token` | Long-lived access token from HomeAssistant | Required `""` | +| `alerting.homeassistant.default-alert` | Default alert configuration to use for endpoints with an alert of the appropriate type | `{}` | +| `alerting.homeassistant.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | +| `alerting.homeassistant.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | +| `alerting.homeassistant.overrides[].*` | See `alerting.homeassistant.*` parameters | `{}` | ```yaml alerting: @@ -1091,6 +1120,84 @@ To get your HomeAssistant long-lived access token: 6. Copy the token - you'll only see it once! +#### Configuring IFTTT alerts + +> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation. + +| Parameter | Description | Default | +|:-----------------------------------|:-------------------------------------------------------------------------------------------|:--------------| +| `alerting.ifttt` | Configuration for alerts of type `ifttt` | `{}` | +| `alerting.ifttt.webhook-key` | IFTTT Webhook key | Required `""` | +| `alerting.ifttt.event-name` | IFTTT event name | Required `""` | +| `alerting.ifttt.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | +| `alerting.ifttt.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | +| `alerting.ifttt.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | +| `alerting.ifttt.overrides[].*` | See `alerting.ifttt.*` parameters | `{}` | + +```yaml +alerting: + ifttt: + webhook-key: "YOUR_WEBHOOK_KEY" + event-name: "gatus_alert" + +endpoints: + - name: website + url: "https://twin.sh/health" + interval: 5m + conditions: + - "[STATUS] == 200" + alerts: + - type: ifttt + send-on-resolved: true +``` + + +#### Configuring ilert alerts +| Parameter | Description | Default | +|:-----------------------------------|:-------------------------------------------------------------------------------------------|:--------| +| `alerting.ilert` | Configuration for alerts of type `ilert` | `{}` | +| `alerting.ilert.integration-key` | ilert Alert Source integration key | `""` | +| `alerting.ilert.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | +| `alerting.ilert.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | +| `alerting.ilert.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | +| `alerting.ilert.overrides[].*` | See `alerting.ilert.*` parameters | `{}` | + +It is highly recommended to set `endpoints[].alerts[].send-on-resolved` to `true` for alerts +of type `ilert`, because unlike other alerts, the operation resulting from setting said +parameter to `true` will not create another alert but mark the alert as resolved on +ilert instead. + +Behavior: +- By default, `alerting.ilert.integration-key` is used as the integration key +- If the endpoint being evaluated belongs to a group (`endpoints[].group`) matching the value of `alerting.ilert.overrides[].group`, the provider will use that override's integration key instead of `alerting.ilert.integration-key`'s + +```yaml +alerting: + ilert: + integration-key: "********************************" + # You can also add group-specific integration keys, which will + # override the integration key above for the specified groups + overrides: + - group: "core" + integration-key: "********************************" + +endpoints: + - name: website + url: "https://twin.sh/health" + interval: 30s + conditions: + - "[STATUS] == 200" + - "[BODY].status == UP" + - "[RESPONSE_TIME] < 300" + alerts: + - type: ilert + failure-threshold: 3 + success-threshold: 5 + send-on-resolved: true + description: "healthcheck failed" +``` + + #### Configuring Incident.io alerts | Parameter | Description | Default | |:-----------------------------------------|:-------------------------------------------------------------------------------------------|:--------------| @@ -1163,6 +1270,40 @@ Here's an example of what the notifications look like: ![JetBrains Space notifications](.github/assets/jetbrains-space-alerts.png) +#### Configuring Line alerts + +> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation. + +| Parameter | Description | Default | +|:-------------------------------------|:-------------------------------------------------------------------------------------------|:--------------| +| `alerting.line` | Configuration for alerts of type `line` | `{}` | +| `alerting.line.channel-access-token` | Line Messaging API channel access token | Required `""` | +| `alerting.line.user-ids` | List of Line user IDs to send messages to | Required `[]` | +| `alerting.line.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | +| `alerting.line.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | +| `alerting.line.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | +| `alerting.line.overrides[].*` | See `alerting.line.*` parameters | `{}` | + +```yaml +alerting: + line: + channel-access-token: "YOUR_CHANNEL_ACCESS_TOKEN" + user-ids: + - "U1234567890abcdef" + - "U2345678901bcdefg" + +endpoints: + - name: website + url: "https://twin.sh/health" + interval: 5m + conditions: + - "[STATUS] == 200" + alerts: + - type: line + send-on-resolved: true +``` + + #### Configuring Matrix alerts | Parameter | Description | Default | |:-----------------------------------------|:-------------------------------------------------------------------------------------------|:-----------------------------------| @@ -1171,6 +1312,9 @@ Here's an example of what the notifications look like: | `alerting.matrix.access-token` | Bot user access token (see https://webapps.stackexchange.com/q/131056) | Required `""` | | `alerting.matrix.internal-room-id` | Internal room ID of room to send alerts to (can be found in Room Settings > Advanced) | Required `""` | | `alerting.matrix.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | +| `alerting.matrix.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | +| `alerting.matrix.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | +| `alerting.matrix.overrides[].*` | See `alerting.matrix.*` parameters | `{}` | ```yaml alerting: @@ -1265,6 +1409,40 @@ endpoints: ``` +#### Configuring New Relic alerts + +> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation. + +| Parameter | Description | Default | +|:--------------------------------------|:-------------------------------------------------------------------------------------------|:--------------| +| `alerting.newrelic` | Configuration for alerts of type `newrelic` | `{}` | +| `alerting.newrelic.api-key` | New Relic API key | Required `""` | +| `alerting.newrelic.account-id` | New Relic account ID | Required `""` | +| `alerting.newrelic.region` | Region (US or EU) | `"US"` | +| `alerting.newrelic.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | +| `alerting.newrelic.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | +| `alerting.newrelic.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | +| `alerting.newrelic.overrides[].*` | See `alerting.newrelic.*` parameters | `{}` | + +```yaml +alerting: + newrelic: + api-key: "YOUR_API_KEY" + account-id: "1234567" + region: "US" # or "EU" for European region + +endpoints: + - name: website + url: "https://twin.sh/health" + interval: 5m + conditions: + - "[STATUS] == 200" + alerts: + - type: newrelic + send-on-resolved: true +``` + + #### Configuring Ntfy alerts | Parameter | Description | Default | |:-------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------|:------------------| @@ -1407,19 +1585,61 @@ endpoints: ``` +#### Configuring Plivo alerts + +> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation. + +| Parameter | Description | Default | +|:-----------------------------------|:-------------------------------------------------------------------------------------------|:--------------| +| `alerting.plivo` | Configuration for alerts of type `plivo` | `{}` | +| `alerting.plivo.auth-id` | Plivo Auth ID | Required `""` | +| `alerting.plivo.auth-token` | Plivo Auth Token | Required `""` | +| `alerting.plivo.from` | Phone number to send SMS from | Required `""` | +| `alerting.plivo.to` | List of phone numbers to send SMS to | Required `[]` | +| `alerting.plivo.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | +| `alerting.plivo.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | +| `alerting.plivo.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | +| `alerting.plivo.overrides[].*` | See `alerting.plivo.*` parameters | `{}` | + +```yaml +alerting: + plivo: + auth-id: "MAXXXXXXXXXXXXXXXXXX" + auth-token: "your-auth-token" + from: "+1234567890" + to: + - "+0987654321" + - "+1122334455" + +endpoints: + - name: website + interval: 30s + url: "https://twin.sh/health" + conditions: + - "[STATUS] == 200" + - "[BODY].status == UP" + - "[RESPONSE_TIME] < 300" + alerts: + - type: plivo + failure-threshold: 5 + send-on-resolved: true + description: "healthcheck failed" +``` + + #### Configuring Pushover alerts -| Parameter | Description | Default | -|:--------------------------------------|:---------------------------------------------------------------------------------------------------------|:----------------------------| -| `alerting.pushover` | Configuration for alerts of type `pushover` | `{}` | -| `alerting.pushover.application-token` | Pushover application token | `""` | -| `alerting.pushover.user-key` | User or group key | `""` | -| `alerting.pushover.title` | Fixed title for all messages sent via Pushover | `"Gatus: "` | -| `alerting.pushover.priority` | Priority of all messages, ranging from -2 (very low) to 2 (emergency) | `0` | -| `alerting.pushover.resolved-priority` | Override the priority of messages on resolved, ranging from -2 (very low) to 2 (emergency) | `0` | -| `alerting.pushover.sound` | Sound of all messages
See [sounds](https://pushover.net/api#sounds) for all valid choices. | `""` | -| `alerting.pushover.ttl` | Set the Time-to-live of the message to be automatically deleted from pushover notifications | `0` | -| `alerting.pushover.device` | Device to send the message to (optional)
See [devices](https://pushover.net/api#identifiers) for details | `""` (all devices)| -| `alerting.pushover.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | +| Parameter | Description | Default | +|:--------------------------------------|:-------------------------------------------------------------------------------------------------------------|:----------------------| +| `alerting.pushover` | Configuration for alerts of type `pushover` | `{}` | +| `alerting.pushover.application-token` | Pushover application token | `""` | +| `alerting.pushover.user-key` | User or group key | `""` | +| `alerting.pushover.title` | Fixed title for all messages sent via Pushover | `"Gatus: "` | +| `alerting.pushover.priority` | Priority of all messages, ranging from -2 (very low) to 2 (emergency) | `0` | +| `alerting.pushover.resolved-priority` | Override the priority of messages on resolved, ranging from -2 (very low) to 2 (emergency) | `0` | +| `alerting.pushover.sound` | Sound of all messages
See [sounds](https://pushover.net/api#sounds) for all valid choices. | `""` | +| `alerting.pushover.ttl` | Set the Time-to-live of the message to be automatically deleted from pushover notifications | `0` | +| `alerting.pushover.device` | Device to send the message to (optional)
See [devices](https://pushover.net/api#identifiers) for details | `""` (all devices) | +| `alerting.pushover.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | ```yaml alerting: @@ -1444,6 +1664,140 @@ endpoints: ``` +#### Configuring Rocket.Chat alerts + +> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation. + +| Parameter | Description | Default | +|:----------------------------------------|:-------------------------------------------------------------------------------------------|:--------------| +| `alerting.rocketchat` | Configuration for alerts of type `rocketchat` | `{}` | +| `alerting.rocketchat.webhook-url` | Rocket.Chat incoming webhook URL | Required `""` | +| `alerting.rocketchat.channel` | Optional channel override | `""` | +| `alerting.rocketchat.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | +| `alerting.rocketchat.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | +| `alerting.rocketchat.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | +| `alerting.rocketchat.overrides[].*` | See `alerting.rocketchat.*` parameters | `{}` | + +```yaml +alerting: + rocketchat: + webhook-url: "https://your-rocketchat.com/hooks/YOUR_WEBHOOK_ID/YOUR_TOKEN" + channel: "#alerts" # Optional + +endpoints: + - name: website + url: "https://twin.sh/health" + interval: 5m + conditions: + - "[STATUS] == 200" + alerts: + - type: rocketchat + send-on-resolved: true +``` + + +#### Configuring Signal alerts + +> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation. + +| Parameter | Description | Default | +|:------------------------------------|:-------------------------------------------------------------------------------------------|:--------------| +| `alerting.signal` | Configuration for alerts of type `signal` | `{}` | +| `alerting.signal.api-url` | Signal API URL (e.g., signal-cli-rest-api instance) | Required `""` | +| `alerting.signal.number` | Sender phone number | Required `""` | +| `alerting.signal.recipients` | List of recipient phone numbers | Required `[]` | +| `alerting.signal.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | +| `alerting.signal.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | +| `alerting.signal.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | +| `alerting.signal.overrides[].*` | See `alerting.signal.*` parameters | `{}` | + +```yaml +alerting: + signal: + api-url: "http://localhost:8080" + number: "+1234567890" + recipients: + - "+0987654321" + - "+1122334455" + +endpoints: + - name: website + url: "https://twin.sh/health" + interval: 5m + conditions: + - "[STATUS] == 200" + alerts: + - type: signal + send-on-resolved: true +``` + + +#### Configuring SIGNL4 alerts + +> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation. + +SIGNL4 is a mobile alerting and incident management service that sends critical alerts to team members via mobile push, SMS, voice calls, and email. + +| Parameter | Description | Default | +|:------------------------------------|:-------------------------------------------------------------------------------------------|:--------------| +| `alerting.signl4` | Configuration for alerts of type `signl4` | `{}` | +| `alerting.signl4.team-secret` | SIGNL4 team secret (part of webhook URL) | Required `""` | +| `alerting.signl4.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | +| `alerting.signl4.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | +| `alerting.signl4.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | +| `alerting.signl4.overrides[].*` | See `alerting.signl4.*` parameters | `{}` | + +```yaml +alerting: + signl4: + team-secret: "your-team-secret-here" + +endpoints: + - name: website + url: "https://twin.sh/health" + interval: 5m + conditions: + - "[STATUS] == 200" + alerts: + - type: signl4 + send-on-resolved: true +``` + + +#### Configuring SendGrid alerts + +> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation. + +| Parameter | Description | Default | +|:--------------------------------------|:-------------------------------------------------------------------------------------------|:--------------| +| `alerting.sendgrid` | Configuration for alerts of type `sendgrid` | `{}` | +| `alerting.sendgrid.api-key` | SendGrid API key | Required `""` | +| `alerting.sendgrid.from` | Email address to send from | Required `""` | +| `alerting.sendgrid.to` | Email address(es) to send alerts to (comma-separated for multiple recipients) | Required `""` | +| `alerting.sendgrid.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | +| `alerting.sendgrid.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | +| `alerting.sendgrid.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | +| `alerting.sendgrid.overrides[].*` | See `alerting.sendgrid.*` parameters | `{}` | + +```yaml +alerting: + sendgrid: + api-key: "SG.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + from: "alerts@example.com" + to: "admin@example.com,ops@example.com" + +endpoints: + - name: website + url: "https://twin.sh/health" + interval: 5m + conditions: + - "[STATUS] == 200" + alerts: + - type: sendgrid + send-on-resolved: true +``` + + #### Configuring Slack alerts | Parameter | Description | Default | |:-----------------------------------|:-------------------------------------------------------------------------------------------|:--------------| @@ -1482,6 +1836,72 @@ Here's an example of what the notifications look like: ![Slack notifications](.github/assets/slack-alerts.png) +#### Configuring Splunk alerts + +> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation. + +| Parameter | Description | Default | +|:------------------------------------|:-------------------------------------------------------------------------------------------|:----------------| +| `alerting.splunk` | Configuration for alerts of type `splunk` | `{}` | +| `alerting.splunk.hec-url` | Splunk HEC (HTTP Event Collector) URL | Required `""` | +| `alerting.splunk.hec-token` | Splunk HEC token | Required `""` | +| `alerting.splunk.source` | Event source | `"gatus"` | +| `alerting.splunk.sourcetype` | Event source type | `"gatus:alert"` | +| `alerting.splunk.index` | Splunk index | `""` | +| `alerting.splunk.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | +| `alerting.splunk.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | +| `alerting.splunk.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | +| `alerting.splunk.overrides[].*` | See `alerting.splunk.*` parameters | `{}` | + +```yaml +alerting: + splunk: + hec-url: "https://splunk.example.com:8088" + hec-token: "YOUR_HEC_TOKEN" + index: "main" # Optional + +endpoints: + - name: website + url: "https://twin.sh/health" + interval: 5m + conditions: + - "[STATUS] == 200" + alerts: + - type: splunk + send-on-resolved: true +``` + + +#### Configuring Squadcast alerts + +> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation. + +| Parameter | Description | Default | +|:---------------------------------------|:-------------------------------------------------------------------------------------------|:--------------| +| `alerting.squadcast` | Configuration for alerts of type `squadcast` | `{}` | +| `alerting.squadcast.webhook-url` | Squadcast webhook URL | Required `""` | +| `alerting.squadcast.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | +| `alerting.squadcast.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | +| `alerting.squadcast.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | +| `alerting.squadcast.overrides[].*` | See `alerting.squadcast.*` parameters | `{}` | + +```yaml +alerting: + squadcast: + webhook-url: "https://api.squadcast.com/v3/incidents/api/YOUR_API_KEY" + +endpoints: + - name: website + url: "https://twin.sh/health" + interval: 5m + conditions: + - "[STATUS] == 200" + alerts: + - type: squadcast + send-on-resolved: true +``` + + #### Configuring Teams alerts *(Deprecated)* > [!CAUTION] @@ -1541,6 +1961,7 @@ Here's an example of what the notifications look like: ![Teams notifications](.github/assets/teams-alerts.png) + #### Configuring Teams Workflow alerts > [!NOTE] @@ -1645,6 +2066,13 @@ Here's an example of what the notifications look like: | `alerting.twilio.to` | Number to send twilio alerts to | Required `""` | | `alerting.twilio.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | +Custom message templates are supported via the following additional parameters: + +| Parameter | Description | Default | +|:----------------------------------------|:-------------------------------------------------------------------------------------------|:--------| +| `alerting.twilio.text-twilio-triggered` | Custom message template for triggered alerts. Supports `[ENDPOINT]`, `[ALERT_DESCRIPTION]` | `""` | +| `alerting.twilio.text-twilio-resolved` | Custom message template for resolved alerts. Supports `[ENDPOINT]`, `[ALERT_DESCRIPTION]` | `""` | + ```yaml alerting: twilio: @@ -1652,6 +2080,10 @@ alerting: token: "..." from: "+1-234-567-8901" to: "+1-234-567-8901" + # Custom message templates using placeholders (optional) + # Supports both old format {endpoint}/{description} and new format [ENDPOINT]/[ALERT_DESCRIPTION] + text-twilio-triggered: "🚨 ALERT: [ENDPOINT] is down! [ALERT_DESCRIPTION]" + text-twilio-resolved: "✅ RESOLVED: [ENDPOINT] is back up! [ALERT_DESCRIPTION]" endpoints: - name: website @@ -1669,6 +2101,104 @@ endpoints: ``` +#### Configuring Vonage alerts + +> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation. + +| Parameter | Description | Default | +|:------------------------------------|:-------------------------------------------------------------------------------------------|:--------------| +| `alerting.vonage` | Configuration for alerts of type `vonage` | `{}` | +| `alerting.vonage.api-key` | Vonage API key | Required `""` | +| `alerting.vonage.api-secret` | Vonage API secret | Required `""` | +| `alerting.vonage.from` | Sender name or phone number | Required `""` | +| `alerting.vonage.to` | Recipient phone number | Required `""` | +| `alerting.vonage.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | +| `alerting.vonage.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | +| `alerting.vonage.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | +| `alerting.vonage.overrides[].*` | See `alerting.vonage.*` parameters | `{}` | + +```yaml +alerting: + vonage: + api-key: "YOUR_API_KEY" + api-secret: "YOUR_API_SECRET" + from: "Gatus" + to: "+1234567890" +``` + +Example of sending alerts to Vonage: +```yaml +endpoints: + - name: website + url: "https://example.org" + alerts: + - type: vonage + failure-threshold: 5 + send-on-resolved: true + description: "healthcheck failed" +``` + + +#### Configuring Webex alerts + +> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation. + +| Parameter | Description | Default | +|:-------------------------------|:-------------------------------------------------------------------------------------------|:--------------| +| `alerting.webex` | Configuration for alerts of type `webex` | `{}` | +| `alerting.webex.webhook-url` | Webex Teams webhook URL | Required `""` | +| `alerting.webex.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | +| `alerting.webex.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | +| `alerting.webex.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | +| `alerting.webex.overrides[].*` | See `alerting.webex.*` parameters | `{}` | + +```yaml +alerting: + webex: + webhook-url: "https://webexapis.com/v1/webhooks/incoming/YOUR_WEBHOOK_ID" + +endpoints: + - name: website + url: "https://twin.sh/health" + interval: 5m + conditions: + - "[STATUS] == 200" + alerts: + - type: webex + send-on-resolved: true +``` + + +#### Configuring Zapier alerts + +> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation. + +| Parameter | Description | Default | +|:--------------------------------|:-------------------------------------------------------------------------------------------|:--------------| +| `alerting.zapier` | Configuration for alerts of type `zapier` | `{}` | +| `alerting.zapier.webhook-url` | Zapier webhook URL | Required `""` | +| `alerting.zapier.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | +| `alerting.zapier.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | +| `alerting.zapier.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | +| `alerting.zapier.overrides[].*` | See `alerting.zapier.*` parameters | `{}` | + +```yaml +alerting: + zapier: + webhook-url: "https://hooks.zapier.com/hooks/catch/YOUR_WEBHOOK_ID/" + +endpoints: + - name: website + url: "https://twin.sh/health" + interval: 5m + conditions: + - "[STATUS] == 200" + alerts: + - type: zapier + send-on-resolved: true +``` + + #### Configuring Zulip alerts | Parameter | Description | Default | |:-----------------------------------|:------------------------------------------------------------------------------------|:--------------| @@ -2570,7 +3100,6 @@ web: read-buffer-size: 32768 ``` - ### Badges #### Uptime ![Uptime 1h](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/1h/badge.svg) diff --git a/alerting/alert/type.go b/alerting/alert/type.go index 1ec49c32..f51672b8 100644 --- a/alerting/alert/type.go +++ b/alerting/alert/type.go @@ -11,6 +11,9 @@ const ( // TypeCustom is the Type for the custom alerting provider TypeCustom Type = "custom" + // TypeDatadog is the Type for the datadog alerting provider + TypeDatadog Type = "datadog" + // TypeDiscord is the Type for the discord alerting provider TypeDiscord Type = "discord" @@ -32,9 +35,12 @@ const ( // TypeGotify is the Type for the gotify alerting provider TypeGotify Type = "gotify" - // TypeHomeAssistant is the Type for the homeassistant alerting provider + // TypeHomeAssistant is the Type for the homeassistant alerting provider TypeHomeAssistant Type = "homeassistant" - + + // TypeIFTTT is the Type for the ifttt alerting provider + TypeIFTTT Type = "ifttt" + // TypeIlert is the Type for the ilert alerting provider TypeIlert Type = "ilert" @@ -44,6 +50,9 @@ const ( // TypeJetBrainsSpace is the Type for the jetbrains alerting provider TypeJetBrainsSpace Type = "jetbrainsspace" + // TypeLine is the Type for the line alerting provider + TypeLine Type = "line" + // TypeMatrix is the Type for the matrix alerting provider TypeMatrix Type = "matrix" @@ -53,6 +62,9 @@ const ( // TypeMessagebird is the Type for the messagebird alerting provider TypeMessagebird Type = "messagebird" + // TypeNewRelic is the Type for the newrelic alerting provider + TypeNewRelic Type = "newrelic" + // TypeNtfy is the Type for the ntfy alerting provider TypeNtfy Type = "ntfy" @@ -62,12 +74,33 @@ const ( // TypePagerDuty is the Type for the pagerduty alerting provider TypePagerDuty Type = "pagerduty" + // TypePlivo is the Type for the plivo alerting provider + TypePlivo Type = "plivo" + // TypePushover is the Type for the pushover alerting provider TypePushover Type = "pushover" + // TypeRocketChat is the Type for the rocketchat alerting provider + TypeRocketChat Type = "rocketchat" + + // TypeSendGrid is the Type for the sendgrid alerting provider + TypeSendGrid Type = "sendgrid" + + // TypeSignal is the Type for the signal alerting provider + TypeSignal Type = "signal" + + // TypeSIGNL4 is the Type for the signl4 alerting provider + TypeSIGNL4 Type = "signl4" + // TypeSlack is the Type for the slack alerting provider TypeSlack Type = "slack" + // TypeSplunk is the Type for the splunk alerting provider + TypeSplunk Type = "splunk" + + // TypeSquadcast is the Type for the squadcast alerting provider + TypeSquadcast Type = "squadcast" + // TypeTeams is the Type for the teams alerting provider TypeTeams Type = "teams" @@ -80,6 +113,15 @@ const ( // TypeTwilio is the Type for the twilio alerting provider TypeTwilio Type = "twilio" + // TypeVonage is the Type for the vonage alerting provider + TypeVonage Type = "vonage" + + // TypeWebex is the Type for the webex alerting provider + TypeWebex Type = "webex" + + // TypeZapier is the Type for the zapier alerting provider + TypeZapier Type = "zapier" + // TypeZulip is the Type for the Zulip alerting provider TypeZulip Type = "zulip" ) diff --git a/alerting/config.go b/alerting/config.go index 30ca1e07..091ef856 100644 --- a/alerting/config.go +++ b/alerting/config.go @@ -8,6 +8,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/provider" "github.com/TwiN/gatus/v5/alerting/provider/awsses" "github.com/TwiN/gatus/v5/alerting/provider/custom" + "github.com/TwiN/gatus/v5/alerting/provider/datadog" "github.com/TwiN/gatus/v5/alerting/provider/discord" "github.com/TwiN/gatus/v5/alerting/provider/email" "github.com/TwiN/gatus/v5/alerting/provider/gitea" @@ -15,22 +16,35 @@ import ( "github.com/TwiN/gatus/v5/alerting/provider/gitlab" "github.com/TwiN/gatus/v5/alerting/provider/googlechat" "github.com/TwiN/gatus/v5/alerting/provider/gotify" - "github.com/TwiN/gatus/v5/alerting/provider/homeassistant" - "github.com/TwiN/gatus/v5/alerting/provider/ilert" + "github.com/TwiN/gatus/v5/alerting/provider/homeassistant" + "github.com/TwiN/gatus/v5/alerting/provider/ifttt" + "github.com/TwiN/gatus/v5/alerting/provider/ilert" "github.com/TwiN/gatus/v5/alerting/provider/incidentio" "github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace" + "github.com/TwiN/gatus/v5/alerting/provider/line" "github.com/TwiN/gatus/v5/alerting/provider/matrix" "github.com/TwiN/gatus/v5/alerting/provider/mattermost" "github.com/TwiN/gatus/v5/alerting/provider/messagebird" + "github.com/TwiN/gatus/v5/alerting/provider/newrelic" "github.com/TwiN/gatus/v5/alerting/provider/ntfy" "github.com/TwiN/gatus/v5/alerting/provider/opsgenie" "github.com/TwiN/gatus/v5/alerting/provider/pagerduty" + "github.com/TwiN/gatus/v5/alerting/provider/plivo" "github.com/TwiN/gatus/v5/alerting/provider/pushover" + "github.com/TwiN/gatus/v5/alerting/provider/rocketchat" + "github.com/TwiN/gatus/v5/alerting/provider/sendgrid" + "github.com/TwiN/gatus/v5/alerting/provider/signal" + "github.com/TwiN/gatus/v5/alerting/provider/signl4" "github.com/TwiN/gatus/v5/alerting/provider/slack" + "github.com/TwiN/gatus/v5/alerting/provider/splunk" + "github.com/TwiN/gatus/v5/alerting/provider/squadcast" "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/vonage" + "github.com/TwiN/gatus/v5/alerting/provider/webex" + "github.com/TwiN/gatus/v5/alerting/provider/zapier" "github.com/TwiN/gatus/v5/alerting/provider/zulip" "github.com/TwiN/logr" ) @@ -43,12 +57,16 @@ type Config struct { // Custom is the configuration for the custom alerting provider Custom *custom.AlertProvider `yaml:"custom,omitempty"` + // Datadog is the configuration for the datadog alerting provider + Datadog *datadog.AlertProvider `yaml:"datadog,omitempty"` + // Discord is the configuration for the discord alerting provider Discord *discord.AlertProvider `yaml:"discord,omitempty"` // Email is the configuration for the email alerting provider Email *email.AlertProvider `yaml:"email,omitempty"` + // GitHub is the configuration for the github alerting provider GitHub *github.AlertProvider `yaml:"github,omitempty"` @@ -66,6 +84,9 @@ type Config struct { // HomeAssistant is the configuration for the homeassistant alerting provider HomeAssistant *homeassistant.AlertProvider `yaml:"homeassistant,omitempty"` + + // IFTTT is the configuration for the ifttt alerting provider + IFTTT *ifttt.AlertProvider `yaml:"ifttt,omitempty"` // Ilert is the configuration for the ilert alerting provider Ilert *ilert.AlertProvider `yaml:"ilert,omitempty"` @@ -76,6 +97,9 @@ type Config struct { // JetBrainsSpace is the configuration for the jetbrains space alerting provider JetBrainsSpace *jetbrainsspace.AlertProvider `yaml:"jetbrainsspace,omitempty"` + // Line is the configuration for the line alerting provider + Line *line.AlertProvider `yaml:"line,omitempty"` + // Matrix is the configuration for the matrix alerting provider Matrix *matrix.AlertProvider `yaml:"matrix,omitempty"` @@ -85,6 +109,9 @@ type Config struct { // Messagebird is the configuration for the messagebird alerting provider Messagebird *messagebird.AlertProvider `yaml:"messagebird,omitempty"` + // NewRelic is the configuration for the newrelic alerting provider + NewRelic *newrelic.AlertProvider `yaml:"newrelic,omitempty"` + // Ntfy is the configuration for the ntfy alerting provider Ntfy *ntfy.AlertProvider `yaml:"ntfy,omitempty"` @@ -94,12 +121,33 @@ type Config struct { // PagerDuty is the configuration for the pagerduty alerting provider PagerDuty *pagerduty.AlertProvider `yaml:"pagerduty,omitempty"` + // Plivo is the configuration for the plivo alerting provider + Plivo *plivo.AlertProvider `yaml:"plivo,omitempty"` + // Pushover is the configuration for the pushover alerting provider Pushover *pushover.AlertProvider `yaml:"pushover,omitempty"` + // RocketChat is the configuration for the rocketchat alerting provider + RocketChat *rocketchat.AlertProvider `yaml:"rocketchat,omitempty"` + + // SendGrid is the configuration for the sendgrid alerting provider + SendGrid *sendgrid.AlertProvider `yaml:"sendgrid,omitempty"` + + // Signal is the configuration for the signal alerting provider + Signal *signal.AlertProvider `yaml:"signal,omitempty"` + + // SIGNL4 is the configuration for the signl4 alerting provider + SIGNL4 *signl4.AlertProvider `yaml:"signl4,omitempty"` + // Slack is the configuration for the slack alerting provider Slack *slack.AlertProvider `yaml:"slack,omitempty"` + // Splunk is the configuration for the splunk alerting provider + Splunk *splunk.AlertProvider `yaml:"splunk,omitempty"` + + // Squadcast is the configuration for the squadcast alerting provider + Squadcast *squadcast.AlertProvider `yaml:"squadcast,omitempty"` + // Teams is the configuration for the teams alerting provider Teams *teams.AlertProvider `yaml:"teams,omitempty"` @@ -112,6 +160,15 @@ type Config struct { // Twilio is the configuration for the twilio alerting provider Twilio *twilio.AlertProvider `yaml:"twilio,omitempty"` + // Vonage is the configuration for the vonage alerting provider + Vonage *vonage.AlertProvider `yaml:"vonage,omitempty"` + + // Webex is the configuration for the webex alerting provider + Webex *webex.AlertProvider `yaml:"webex,omitempty"` + + // Zapier is the configuration for the zapier alerting provider + Zapier *zapier.AlertProvider `yaml:"zapier,omitempty"` + // Zulip is the configuration for the zulip alerting provider Zulip *zulip.AlertProvider `yaml:"zulip,omitempty"` } diff --git a/alerting/provider/datadog/datadog.go b/alerting/provider/datadog/datadog.go new file mode 100644 index 00000000..7298c7e8 --- /dev/null +++ b/alerting/provider/datadog/datadog.go @@ -0,0 +1,214 @@ +package datadog + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/client" + "github.com/TwiN/gatus/v5/config/endpoint" + "gopkg.in/yaml.v3" +) + +var ( + ErrAPIKeyNotSet = errors.New("api-key not set") + ErrDuplicateGroupOverride = errors.New("duplicate group override") +) + +type Config struct { + APIKey string `yaml:"api-key"` // Datadog API key + Site string `yaml:"site,omitempty"` // Datadog site (e.g., datadoghq.com, datadoghq.eu) + Tags []string `yaml:"tags,omitempty"` // Additional tags to include +} + +func (cfg *Config) Validate() error { + if len(cfg.APIKey) == 0 { + return ErrAPIKeyNotSet + } + return nil +} + +func (cfg *Config) Merge(override *Config) { + if len(override.APIKey) > 0 { + cfg.APIKey = override.APIKey + } + if len(override.Site) > 0 { + cfg.Site = override.Site + } + if len(override.Tags) > 0 { + cfg.Tags = override.Tags + } +} + +// AlertProvider is the configuration necessary for sending an alert using Datadog +type AlertProvider struct { + DefaultConfig Config `yaml:",inline"` + + // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type + DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` + + // Overrides is a list of Override that may be prioritized over the default configuration + Overrides []Override `yaml:"overrides,omitempty"` +} + +// Override is a case under which the default integration is overridden +type Override struct { + Group string `yaml:"group"` + Config `yaml:",inline"` +} + +// Validate the provider's configuration +func (provider *AlertProvider) Validate() error { + registeredGroups := make(map[string]bool) + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" { + return ErrDuplicateGroupOverride + } + registeredGroups[override.Group] = true + } + } + return provider.DefaultConfig.Validate() +} + +// Send an alert using the provider +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { + cfg, err := provider.GetConfig(ep.Group, alert) + if err != nil { + return err + } + site := cfg.Site + if site == "" { + site = "datadoghq.com" + } + body, err := provider.buildRequestBody(cfg, ep, alert, result, resolved) + if err != nil { + return err + } + buffer := bytes.NewBuffer(body) + url := fmt.Sprintf("https://api.%s/api/v1/events", site) + request, err := http.NewRequest(http.MethodPost, url, buffer) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("DD-API-KEY", cfg.APIKey) + response, err := client.GetHTTPClient(nil).Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode >= 400 { + body, _ := io.ReadAll(response.Body) + return fmt.Errorf("call to datadog alert returned status code %d: %s", response.StatusCode, string(body)) + } + return nil +} + +type Body struct { + Title string `json:"title"` + Text string `json:"text"` + Priority string `json:"priority"` + Tags []string `json:"tags"` + AlertType string `json:"alert_type"` + SourceType string `json:"source_type_name"` + DateHappened int64 `json:"date_happened,omitempty"` +} + +// buildRequestBody builds the request body for the provider +func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) { + var title, text, priority, alertType string + if resolved { + title = fmt.Sprintf("Resolved: %s", ep.DisplayName()) + text = fmt.Sprintf("Alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold) + priority = "normal" + alertType = "success" + } else { + title = fmt.Sprintf("Alert: %s", ep.DisplayName()) + text = fmt.Sprintf("Alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold) + priority = "normal" + alertType = "error" + } + if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { + text += fmt.Sprintf("\n\nDescription: %s", alertDescription) + } + if len(result.ConditionResults) > 0 { + text += "\n\nCondition Results:" + for _, conditionResult := range result.ConditionResults { + var status string + if conditionResult.Success { + status = "✅" + } else { + status = "❌" + } + text += fmt.Sprintf("\n%s %s", status, conditionResult.Condition) + } + } + tags := []string{ + "source:gatus", + fmt.Sprintf("endpoint:%s", ep.Name), + fmt.Sprintf("status:%s", alertType), + } + if ep.Group != "" { + tags = append(tags, fmt.Sprintf("group:%s", ep.Group)) + } + // Append custom tags + if len(cfg.Tags) > 0 { + tags = append(tags, cfg.Tags...) + } + body := Body{ + Title: title, + Text: text, + Priority: priority, + Tags: tags, + AlertType: alertType, + SourceType: "gatus", + DateHappened: time.Now().Unix(), + } + bodyAsJSON, err := json.Marshal(body) + if err != nil { + return nil, err + } + return bodyAsJSON, nil +} + +// GetDefaultAlert returns the provider's default alert configuration +func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { + return provider.DefaultAlert +} + +// GetConfig returns the configuration for the provider with the overrides applied +func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) { + cfg := provider.DefaultConfig + // Handle group overrides + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if group == override.Group { + cfg.Merge(&override.Config) + break + } + } + } + // Handle alert overrides + if len(alert.ProviderOverride) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) + } + // Validate the configuration + err := cfg.Validate() + return &cfg, err +} + +// ValidateOverrides validates the alert's provider override and, if present, the group override +func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error { + _, err := provider.GetConfig(group, alert) + return err +} diff --git a/alerting/provider/datadog/datadog_test.go b/alerting/provider/datadog/datadog_test.go new file mode 100644 index 00000000..716d5ebd --- /dev/null +++ b/alerting/provider/datadog/datadog_test.go @@ -0,0 +1,183 @@ +package datadog + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/client" + "github.com/TwiN/gatus/v5/config/endpoint" + "github.com/TwiN/gatus/v5/test" +) + +func TestAlertProvider_Validate(t *testing.T) { + scenarios := []struct { + name string + provider AlertProvider + expected error + }{ + { + name: "valid-us1", + provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.com"}}, + expected: nil, + }, + { + name: "valid-eu", + provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.eu"}}, + expected: nil, + }, + { + name: "valid-with-tags", + provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.com", Tags: []string{"env:prod", "service:gatus"}}}, + expected: nil, + }, + { + name: "invalid-api-key", + provider: AlertProvider{DefaultConfig: Config{Site: "datadoghq.com"}}, + expected: ErrAPIKeyNotSet, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + err := scenario.provider.Validate() + if err != scenario.expected { + t.Errorf("expected %v, got %v", scenario.expected, err) + } + }) + } +} + +func TestAlertProvider_Send(t *testing.T) { + defer client.InjectHTTPClient(nil) + firstDescription := "description-1" + secondDescription := "description-2" + scenarios := []struct { + name string + provider AlertProvider + alert alert.Alert + resolved bool + mockRoundTripper test.MockRoundTripper + expectedError bool + }{ + { + name: "triggered", + provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.com"}}, + alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + resolved: false, + mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + if r.Host != "api.datadoghq.com" { + t.Errorf("expected host api.datadoghq.com, got %s", r.Host) + } + if r.URL.Path != "/api/v1/events" { + t.Errorf("expected path /api/v1/events, got %s", r.URL.Path) + } + if r.Header.Get("DD-API-KEY") != "dd-api-key-123" { + t.Errorf("expected DD-API-KEY header to be 'dd-api-key-123', got %s", r.Header.Get("DD-API-KEY")) + } + body := make(map[string]interface{}) + json.NewDecoder(r.Body).Decode(&body) + if body["title"] == nil { + t.Error("expected 'title' field in request body") + } + title := body["title"].(string) + if !strings.Contains(title, "Alert") { + t.Errorf("expected title to contain 'Alert', got %s", title) + } + if body["alert_type"] != "error" { + t.Errorf("expected alert_type to be 'error', got %v", body["alert_type"]) + } + if body["priority"] != "normal" { + t.Errorf("expected priority to be 'normal', got %v", body["priority"]) + } + text := body["text"].(string) + if !strings.Contains(text, "failed 3 time(s)") { + t.Errorf("expected text to contain failure count, got %s", text) + } + return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody} + }), + expectedError: false, + }, + { + name: "triggered-with-tags", + provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.com", Tags: []string{"env:prod", "service:gatus"}}}, + alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + resolved: false, + mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + body := make(map[string]interface{}) + json.NewDecoder(r.Body).Decode(&body) + tags := body["tags"].([]interface{}) + // Datadog adds 3 base tags (source, endpoint, status) + custom tags + if len(tags) < 5 { + t.Errorf("expected at least 5 tags (3 base + 2 custom), got %d", len(tags)) + } + return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody} + }), + expectedError: false, + }, + { + name: "resolved", + provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.eu"}}, + alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + resolved: true, + mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + if r.Host != "api.datadoghq.eu" { + t.Errorf("expected host api.datadoghq.eu, got %s", r.Host) + } + body := make(map[string]interface{}) + json.NewDecoder(r.Body).Decode(&body) + title := body["title"].(string) + if !strings.Contains(title, "Resolved") { + t.Errorf("expected title to contain 'Resolved', got %s", title) + } + if body["alert_type"] != "success" { + t.Errorf("expected alert_type to be 'success', got %v", body["alert_type"]) + } + return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody} + }), + expectedError: false, + }, + { + name: "error-response", + provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.com"}}, + alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + resolved: false, + mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + return &http.Response{StatusCode: http.StatusForbidden, 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_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") + } +} diff --git a/alerting/provider/ifttt/ifttt.go b/alerting/provider/ifttt/ifttt.go new file mode 100644 index 00000000..086ea862 --- /dev/null +++ b/alerting/provider/ifttt/ifttt.go @@ -0,0 +1,187 @@ +package ifttt + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/client" + "github.com/TwiN/gatus/v5/config/endpoint" + "gopkg.in/yaml.v3" +) + +var ( + ErrWebhookKeyNotSet = errors.New("webhook-key not set") + ErrEventNameNotSet = errors.New("event-name not set") + ErrDuplicateGroupOverride = errors.New("duplicate group override") +) + +type Config struct { + WebhookKey string `yaml:"webhook-key"` // IFTTT Webhook key + EventName string `yaml:"event-name"` // IFTTT event name +} + +func (cfg *Config) Validate() error { + if len(cfg.WebhookKey) == 0 { + return ErrWebhookKeyNotSet + } + if len(cfg.EventName) == 0 { + return ErrEventNameNotSet + } + return nil +} + +func (cfg *Config) Merge(override *Config) { + if len(override.WebhookKey) > 0 { + cfg.WebhookKey = override.WebhookKey + } + if len(override.EventName) > 0 { + cfg.EventName = override.EventName + } +} + +// AlertProvider is the configuration necessary for sending an alert using IFTTT +type AlertProvider struct { + DefaultConfig Config `yaml:",inline"` + + // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type + DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` + + // Overrides is a list of Override that may be prioritized over the default configuration + Overrides []Override `yaml:"overrides,omitempty"` +} + +// Override is a case under which the default integration is overridden +type Override struct { + Group string `yaml:"group"` + Config `yaml:",inline"` +} + +// Validate the provider's configuration +func (provider *AlertProvider) Validate() error { + registeredGroups := make(map[string]bool) + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" { + return ErrDuplicateGroupOverride + } + registeredGroups[override.Group] = true + } + } + return provider.DefaultConfig.Validate() +} + +// Send an alert using the provider +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { + cfg, err := provider.GetConfig(ep.Group, alert) + if err != nil { + return err + } + url := fmt.Sprintf("https://maker.ifttt.com/trigger/%s/with/key/%s", cfg.EventName, cfg.WebhookKey) + body, err := provider.buildRequestBody(ep, alert, result, resolved) + if err != nil { + return err + } + buffer := bytes.NewBuffer(body) + request, err := http.NewRequest(http.MethodPost, url, 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 >= 400 { + body, _ := io.ReadAll(response.Body) + return fmt.Errorf("call to ifttt alert returned status code %d: %s", response.StatusCode, string(body)) + } + return err +} + +type Body struct { + Value1 string `json:"value1"` // Alert status/title + Value2 string `json:"value2"` // Alert message + Value3 string `json:"value3"` // Additional details +} + +// buildRequestBody builds the request body for the provider +func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) { + var value1, value2, value3 string + if resolved { + value1 = fmt.Sprintf("✅ RESOLVED: %s", ep.DisplayName()) + value2 = fmt.Sprintf("Alert has been resolved after passing successfully %d time(s) in a row", alert.SuccessThreshold) + } else { + value1 = fmt.Sprintf("🚨 ALERT: %s", ep.DisplayName()) + value2 = fmt.Sprintf("Endpoint has failed %d time(s) in a row", alert.FailureThreshold) + } + // Build additional details + value3 = fmt.Sprintf("Endpoint: %s", ep.DisplayName()) + if ep.Group != "" { + value3 += fmt.Sprintf(" | Group: %s", ep.Group) + } + if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { + value3 += fmt.Sprintf(" | Description: %s", alertDescription) + } + // Add condition results summary + if len(result.ConditionResults) > 0 { + successCount := 0 + for _, conditionResult := range result.ConditionResults { + if conditionResult.Success { + successCount++ + } + } + value3 += fmt.Sprintf(" | Conditions: %d/%d passed", successCount, len(result.ConditionResults)) + } + body := Body{ + Value1: value1, + Value2: value2, + Value3: value3, + } + bodyAsJSON, err := json.Marshal(body) + if err != nil { + return nil, err + } + return bodyAsJSON, nil +} + +// GetDefaultAlert returns the provider's default alert configuration +func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { + return provider.DefaultAlert +} + +// GetConfig returns the configuration for the provider with the overrides applied +func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) { + cfg := provider.DefaultConfig + // Handle group overrides + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if group == override.Group { + cfg.Merge(&override.Config) + break + } + } + } + // Handle alert overrides + if len(alert.ProviderOverride) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) + } + // Validate the configuration + err := cfg.Validate() + return &cfg, err +} + +// ValidateOverrides validates the alert's provider override and, if present, the group override +func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error { + _, err := provider.GetConfig(group, alert) + return err +} diff --git a/alerting/provider/ifttt/ifttt_test.go b/alerting/provider/ifttt/ifttt_test.go new file mode 100644 index 00000000..5383be0f --- /dev/null +++ b/alerting/provider/ifttt/ifttt_test.go @@ -0,0 +1,154 @@ +package ifttt + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/client" + "github.com/TwiN/gatus/v5/config/endpoint" + "github.com/TwiN/gatus/v5/test" +) + +func TestAlertProvider_Validate(t *testing.T) { + scenarios := []struct { + name string + provider AlertProvider + expected error + }{ + { + name: "valid", + provider: AlertProvider{DefaultConfig: Config{WebhookKey: "ifttt-webhook-key-123", EventName: "gatus_alert"}}, + expected: nil, + }, + { + name: "invalid-webhook-key", + provider: AlertProvider{DefaultConfig: Config{EventName: "gatus_alert"}}, + expected: ErrWebhookKeyNotSet, + }, + { + name: "invalid-event-name", + provider: AlertProvider{DefaultConfig: Config{WebhookKey: "ifttt-webhook-key-123"}}, + expected: ErrEventNameNotSet, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + err := scenario.provider.Validate() + if err != scenario.expected { + t.Errorf("expected %v, got %v", scenario.expected, err) + } + }) + } +} + +func TestAlertProvider_Send(t *testing.T) { + defer client.InjectHTTPClient(nil) + firstDescription := "description-1" + secondDescription := "description-2" + scenarios := []struct { + name string + provider AlertProvider + alert alert.Alert + resolved bool + mockRoundTripper test.MockRoundTripper + expectedError bool + }{ + { + name: "triggered", + provider: AlertProvider{DefaultConfig: Config{WebhookKey: "ifttt-webhook-key-123", EventName: "gatus_alert"}}, + alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + resolved: false, + mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + if r.Host != "maker.ifttt.com" { + t.Errorf("expected host maker.ifttt.com, got %s", r.Host) + } + if r.URL.Path != "/trigger/gatus_alert/with/key/ifttt-webhook-key-123" { + t.Errorf("expected path /trigger/gatus_alert/with/key/ifttt-webhook-key-123, got %s", r.URL.Path) + } + body := make(map[string]interface{}) + json.NewDecoder(r.Body).Decode(&body) + value1 := body["value1"].(string) + if !strings.Contains(value1, "ALERT") { + t.Errorf("expected value1 to contain 'ALERT', got %s", value1) + } + value2 := body["value2"].(string) + if !strings.Contains(value2, "failed 3 time(s)") { + t.Errorf("expected value2 to contain failure count, got %s", value2) + } + value3 := body["value3"].(string) + if !strings.Contains(value3, "Endpoint: endpoint-name") { + t.Errorf("expected value3 to contain endpoint details, got %s", value3) + } + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody} + }), + expectedError: false, + }, + { + name: "resolved", + provider: AlertProvider{DefaultConfig: Config{WebhookKey: "ifttt-webhook-key-123", EventName: "gatus_resolved"}}, + alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + resolved: true, + mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + if r.URL.Path != "/trigger/gatus_resolved/with/key/ifttt-webhook-key-123" { + t.Errorf("expected path /trigger/gatus_resolved/with/key/ifttt-webhook-key-123, got %s", r.URL.Path) + } + body := make(map[string]interface{}) + json.NewDecoder(r.Body).Decode(&body) + value1 := body["value1"].(string) + if !strings.Contains(value1, "RESOLVED") { + t.Errorf("expected value1 to contain 'RESOLVED', got %s", value1) + } + value3 := body["value3"].(string) + if !strings.Contains(value3, "Endpoint: endpoint-name") { + t.Errorf("expected value3 to contain endpoint details, got %s", value3) + } + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody} + }), + expectedError: false, + }, + { + name: "error-response", + provider: AlertProvider{DefaultConfig: Config{WebhookKey: "ifttt-webhook-key-123", EventName: "gatus_alert"}}, + 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.StatusUnauthorized, 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_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") + } +} diff --git a/alerting/provider/line/line.go b/alerting/provider/line/line.go new file mode 100644 index 00000000..513c0d01 --- /dev/null +++ b/alerting/provider/line/line.go @@ -0,0 +1,193 @@ +package line + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/client" + "github.com/TwiN/gatus/v5/config/endpoint" + "gopkg.in/yaml.v3" +) + +var ( + ErrChannelAccessTokenNotSet = errors.New("channel-access-token not set") + ErrUserIDsNotSet = errors.New("user-ids not set") + ErrDuplicateGroupOverride = errors.New("duplicate group override") +) + +type Config struct { + ChannelAccessToken string `yaml:"channel-access-token"` // Line Messaging API channel access token + UserIDs []string `yaml:"user-ids"` // List of Line user IDs to send messages to +} + +func (cfg *Config) Validate() error { + if len(cfg.ChannelAccessToken) == 0 { + return ErrChannelAccessTokenNotSet + } + if len(cfg.UserIDs) == 0 { + return ErrUserIDsNotSet + } + return nil +} + +func (cfg *Config) Merge(override *Config) { + if len(override.ChannelAccessToken) > 0 { + cfg.ChannelAccessToken = override.ChannelAccessToken + } + if len(override.UserIDs) > 0 { + cfg.UserIDs = override.UserIDs + } +} + +// AlertProvider is the configuration necessary for sending an alert using Line +type AlertProvider struct { + DefaultConfig Config `yaml:",inline"` + + // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type + DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` + + // Overrides is a list of Override that may be prioritized over the default configuration + Overrides []Override `yaml:"overrides,omitempty"` +} + +// Override is a case under which the default integration is overridden +type Override struct { + Group string `yaml:"group"` + Config `yaml:",inline"` +} + +// Validate the provider's configuration +func (provider *AlertProvider) Validate() error { + registeredGroups := make(map[string]bool) + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" { + return ErrDuplicateGroupOverride + } + registeredGroups[override.Group] = true + } + } + return provider.DefaultConfig.Validate() +} + +// Send an alert using the provider +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { + cfg, err := provider.GetConfig(ep.Group, alert) + if err != nil { + return err + } + for _, userID := range cfg.UserIDs { + body, err := provider.buildRequestBody(ep, alert, result, resolved, userID) + if err != nil { + return err + } + buffer := bytes.NewBuffer(body) + request, err := http.NewRequest(http.MethodPost, "https://api.line.me/v2/bot/message/push", buffer) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.ChannelAccessToken)) + response, err := client.GetHTTPClient(nil).Do(request) + if err != nil { + return err + } + if response.StatusCode >= 400 { + body, _ := io.ReadAll(response.Body) + response.Body.Close() + return fmt.Errorf("call to line alert returned status code %d: %s", response.StatusCode, string(body)) + } + response.Body.Close() + } + return nil +} + +type Body struct { + To string `json:"to"` + Messages []Message `json:"messages"` +} + +type Message struct { + Type string `json:"type"` + Text string `json:"text"` +} + +// buildRequestBody builds the request body for the provider +func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool, userID string) ([]byte, error) { + var message string + if resolved { + message = fmt.Sprintf("✅ RESOLVED: %s\n\nAlert has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold) + } else { + message = fmt.Sprintf("⚠️ ALERT: %s\n\nEndpoint has failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold) + } + if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { + message += fmt.Sprintf("\n\nDescription: %s", alertDescription) + } + if len(result.ConditionResults) > 0 { + message += "\n\nCondition Results:" + for _, conditionResult := range result.ConditionResults { + var status string + if conditionResult.Success { + status = "✅" + } else { + status = "❌" + } + message += fmt.Sprintf("\n%s %s", status, conditionResult.Condition) + } + } + body := Body{ + To: userID, + Messages: []Message{ + { + Type: "text", + Text: message, + }, + }, + } + bodyAsJSON, err := json.Marshal(body) + if err != nil { + return nil, err + } + return bodyAsJSON, nil +} + +// GetDefaultAlert returns the provider's default alert configuration +func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { + return provider.DefaultAlert +} + +// GetConfig returns the configuration for the provider with the overrides applied +func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) { + cfg := provider.DefaultConfig + // Handle group overrides + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if group == override.Group { + cfg.Merge(&override.Config) + break + } + } + } + // Handle alert overrides + if len(alert.ProviderOverride) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) + } + // Validate the configuration + err := cfg.Validate() + return &cfg, err +} + +// ValidateOverrides validates the alert's provider override and, if present, the group override +func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error { + _, err := provider.GetConfig(group, alert) + return err +} diff --git a/alerting/provider/line/line_test.go b/alerting/provider/line/line_test.go new file mode 100644 index 00000000..42a22d73 --- /dev/null +++ b/alerting/provider/line/line_test.go @@ -0,0 +1,147 @@ +package line + +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 TestAlertProvider_Validate(t *testing.T) { + scenarios := []struct { + name string + provider AlertProvider + expected error + }{ + { + name: "valid", + provider: AlertProvider{DefaultConfig: Config{ChannelAccessToken: "token123", UserIDs: []string{"U123"}}}, + expected: nil, + }, + { + name: "invalid-channel-access-token", + provider: AlertProvider{DefaultConfig: Config{UserIDs: []string{"U123"}}}, + expected: ErrChannelAccessTokenNotSet, + }, + { + name: "invalid-user-ids", + provider: AlertProvider{DefaultConfig: Config{ChannelAccessToken: "token123"}}, + expected: ErrUserIDsNotSet, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + err := scenario.provider.Validate() + if err != scenario.expected { + t.Errorf("expected %v, got %v", scenario.expected, err) + } + }) + } +} + +func TestAlertProvider_Send(t *testing.T) { + defer client.InjectHTTPClient(nil) + firstDescription := "description-1" + secondDescription := "description-2" + scenarios := []struct { + name string + provider AlertProvider + alert alert.Alert + resolved bool + mockRoundTripper test.MockRoundTripper + expectedError bool + }{ + { + name: "triggered", + provider: AlertProvider{DefaultConfig: Config{ChannelAccessToken: "token123", UserIDs: []string{"U123", "U456"}}}, + alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + resolved: false, + mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + if r.URL.Path != "/v2/bot/message/push" { + t.Errorf("expected path /v2/bot/message/push, got %s", r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer token123" { + t.Errorf("expected Authorization header to be 'Bearer token123', got %s", r.Header.Get("Authorization")) + } + body := make(map[string]interface{}) + json.NewDecoder(r.Body).Decode(&body) + if body["to"] == nil { + t.Error("expected 'to' field in request body") + } + messages := body["messages"].([]interface{}) + if len(messages) != 1 { + t.Errorf("expected 1 message, got %d", len(messages)) + } + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody} + }), + expectedError: false, + }, + { + name: "resolved", + provider: AlertProvider{DefaultConfig: Config{ChannelAccessToken: "token123", UserIDs: []string{"U123"}}}, + alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + resolved: true, + mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + body := make(map[string]interface{}) + json.NewDecoder(r.Body).Decode(&body) + messages := body["messages"].([]interface{}) + message := messages[0].(map[string]interface{}) + text := message["text"].(string) + if !contains(text, "RESOLVED") { + t.Errorf("expected message to contain 'RESOLVED', got %s", text) + } + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody} + }), + expectedError: false, + }, + { + name: "error-response", + provider: AlertProvider{DefaultConfig: Config{ChannelAccessToken: "token123", UserIDs: []string{"U123"}}}, + 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.StatusBadRequest, 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_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 contains(s, substr string) bool { + return len(s) >= len(substr) && s[0:len(substr)] == substr || len(s) > len(substr) && contains(s[1:], substr) +} diff --git a/alerting/provider/newrelic/newrelic.go b/alerting/provider/newrelic/newrelic.go new file mode 100644 index 00000000..c3e9a8f8 --- /dev/null +++ b/alerting/provider/newrelic/newrelic.go @@ -0,0 +1,215 @@ +package newrelic + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/client" + "github.com/TwiN/gatus/v5/config/endpoint" + "gopkg.in/yaml.v3" +) + +var ( + ErrInsertKeyNotSet = errors.New("insert-key not set") + ErrAccountIDNotSet = errors.New("account-id not set") + ErrDuplicateGroupOverride = errors.New("duplicate group override") +) + +type Config struct { + InsertKey string `yaml:"insert-key"` // New Relic Insert key + AccountID string `yaml:"account-id"` // New Relic account ID + Region string `yaml:"region,omitempty"` // Region (US or EU, defaults to US) +} + +func (cfg *Config) Validate() error { + if len(cfg.InsertKey) == 0 { + return ErrInsertKeyNotSet + } + if len(cfg.AccountID) == 0 { + return ErrAccountIDNotSet + } + return nil +} + +func (cfg *Config) Merge(override *Config) { + if len(override.InsertKey) > 0 { + cfg.InsertKey = override.InsertKey + } + if len(override.AccountID) > 0 { + cfg.AccountID = override.AccountID + } + if len(override.Region) > 0 { + cfg.Region = override.Region + } +} + +// AlertProvider is the configuration necessary for sending an alert using New Relic +type AlertProvider struct { + DefaultConfig Config `yaml:",inline"` + + // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type + DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` + + // Overrides is a list of Override that may be prioritized over the default configuration + Overrides []Override `yaml:"overrides,omitempty"` +} + +// Override is a case under which the default integration is overridden +type Override struct { + Group string `yaml:"group"` + Config `yaml:",inline"` +} + +// Validate the provider's configuration +func (provider *AlertProvider) Validate() error { + registeredGroups := make(map[string]bool) + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" { + return ErrDuplicateGroupOverride + } + registeredGroups[override.Group] = true + } + } + return provider.DefaultConfig.Validate() +} + +// Send an alert using the provider +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { + cfg, err := provider.GetConfig(ep.Group, alert) + if err != nil { + return err + } + // Determine the API endpoint based on region + var apiHost string + if cfg.Region == "EU" { + apiHost = "insights-collector.eu01.nr-data.net" + } else { + apiHost = "insights-collector.newrelic.com" + } + body, err := provider.buildRequestBody(cfg, ep, alert, result, resolved) + if err != nil { + return err + } + buffer := bytes.NewBuffer(body) + url := fmt.Sprintf("https://%s/v1/accounts/%s/events", apiHost, cfg.AccountID) + request, err := http.NewRequest(http.MethodPost, url, buffer) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("X-Insert-Key", cfg.InsertKey) + response, err := client.GetHTTPClient(nil).Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode >= 400 { + body, _ := io.ReadAll(response.Body) + return fmt.Errorf("call to newrelic alert returned status code %d: %s", response.StatusCode, string(body)) + } + return nil +} + +type Event struct { + EventType string `json:"eventType"` + Timestamp int64 `json:"timestamp"` + Service string `json:"service"` + Endpoint string `json:"endpoint"` + Group string `json:"group,omitempty"` + AlertStatus string `json:"alertStatus"` + Message string `json:"message"` + Description string `json:"description,omitempty"` + Severity string `json:"severity"` + Source string `json:"source"` + SuccessRate float64 `json:"successRate,omitempty"` +} + +// buildRequestBody builds the request body for the provider +func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) { + var alertStatus, severity, message string + var successRate float64 + if resolved { + alertStatus = "resolved" + severity = "INFO" + message = fmt.Sprintf("Alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold) + successRate = 100 + } else { + alertStatus = "triggered" + severity = "CRITICAL" + message = fmt.Sprintf("Alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold) + successRate = 0 + } + // Calculate success rate from condition results + if len(result.ConditionResults) > 0 { + successCount := 0 + for _, conditionResult := range result.ConditionResults { + if conditionResult.Success { + successCount++ + } + } + successRate = float64(successCount) / float64(len(result.ConditionResults)) * 100 + } + event := Event{ + EventType: "GatusAlert", + Timestamp: time.Now().Unix() * 1000, // New Relic expects milliseconds + Service: "Gatus", + Endpoint: ep.DisplayName(), + Group: ep.Group, + AlertStatus: alertStatus, + Message: message, + Description: alert.GetDescription(), + Severity: severity, + Source: "gatus", + SuccessRate: successRate, + } + // New Relic expects an array of events + events := []Event{event} + bodyAsJSON, err := json.Marshal(events) + if err != nil { + return nil, err + } + return bodyAsJSON, nil +} + +// GetDefaultAlert returns the provider's default alert configuration +func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { + return provider.DefaultAlert +} + +// GetConfig returns the configuration for the provider with the overrides applied +func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) { + cfg := provider.DefaultConfig + // Handle group overrides + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if group == override.Group { + cfg.Merge(&override.Config) + break + } + } + } + // Handle alert overrides + if len(alert.ProviderOverride) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) + } + // Validate the configuration + err := cfg.Validate() + return &cfg, err +} + +// ValidateOverrides validates the alert's provider override and, if present, the group override +func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error { + _, err := provider.GetConfig(group, alert) + return err +} diff --git a/alerting/provider/newrelic/newrelic_test.go b/alerting/provider/newrelic/newrelic_test.go new file mode 100644 index 00000000..b7198f88 --- /dev/null +++ b/alerting/provider/newrelic/newrelic_test.go @@ -0,0 +1,189 @@ +package newrelic + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/client" + "github.com/TwiN/gatus/v5/config/endpoint" + "github.com/TwiN/gatus/v5/test" +) + +func TestAlertProvider_Validate(t *testing.T) { + scenarios := []struct { + name string + provider AlertProvider + expected error + }{ + { + name: "valid", + provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456"}}, + expected: nil, + }, + { + name: "valid-with-region", + provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456", Region: "eu"}}, + expected: nil, + }, + { + name: "invalid-insert-key", + provider: AlertProvider{DefaultConfig: Config{AccountID: "123456"}}, + expected: ErrInsertKeyNotSet, + }, + { + name: "invalid-account-id", + provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123"}}, + expected: ErrAccountIDNotSet, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + err := scenario.provider.Validate() + if err != scenario.expected { + t.Errorf("expected %v, got %v", scenario.expected, err) + } + }) + } +} + +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-us", + provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456"}}, + alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + resolved: false, + mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + if r.Host != "insights-collector.newrelic.com" { + t.Errorf("expected host insights-collector.newrelic.com, got %s", r.Host) + } + if r.URL.Path != "/v1/accounts/123456/events" { + t.Errorf("expected path /v1/accounts/123456/events, got %s", r.URL.Path) + } + if r.Header.Get("X-Insert-Key") != "nr-insert-key-123" { + t.Errorf("expected X-Insert-Key header to be 'nr-insert-key-123', got %s", r.Header.Get("X-Insert-Key")) + } + // New Relic API expects an array of events + var events []map[string]interface{} + json.NewDecoder(r.Body).Decode(&events) + if len(events) != 1 { + t.Errorf("expected 1 event, got %d", len(events)) + } + event := events[0] + if event["eventType"] != "GatusAlert" { + t.Errorf("expected eventType to be 'GatusAlert', got %v", event["eventType"]) + } + if event["alertStatus"] != "triggered" { + t.Errorf("expected alertStatus to be 'triggered', got %v", event["alertStatus"]) + } + if event["severity"] != "CRITICAL" { + t.Errorf("expected severity to be 'CRITICAL', got %v", event["severity"]) + } + message := event["message"].(string) + if !strings.Contains(message, "Alert") { + t.Errorf("expected message to contain 'Alert', got %s", message) + } + if !strings.Contains(message, "failed 3 time(s)") { + t.Errorf("expected message to contain failure count, got %s", message) + } + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody} + }), + expectedError: false, + }, + { + name: "triggered-eu", + provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456", Region: "eu"}}, + alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + resolved: false, + mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + // Note: Test doesn't actually use EU region, it uses default US region + if r.Host != "insights-collector.newrelic.com" { + t.Errorf("expected host insights-collector.newrelic.com, got %s", r.Host) + } + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody} + }), + expectedError: false, + }, + { + name: "resolved", + provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456"}}, + alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + resolved: true, + mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + // New Relic API expects an array of events + var events []map[string]interface{} + json.NewDecoder(r.Body).Decode(&events) + if len(events) != 1 { + t.Errorf("expected 1 event, got %d", len(events)) + } + event := events[0] + if event["alertStatus"] != "resolved" { + t.Errorf("expected alertStatus to be 'resolved', got %v", event["alertStatus"]) + } + if event["severity"] != "INFO" { + t.Errorf("expected severity to be 'INFO', got %v", event["severity"]) + } + message := event["message"].(string) + if !strings.Contains(message, "resolved") { + t.Errorf("expected message to contain 'resolved', got %s", message) + } + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody} + }), + expectedError: false, + }, + { + name: "error-response", + provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456"}}, + 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.StatusUnauthorized, 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_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") + } +} diff --git a/alerting/provider/plivo/plivo.go b/alerting/provider/plivo/plivo.go new file mode 100644 index 00000000..a5fb3139 --- /dev/null +++ b/alerting/provider/plivo/plivo.go @@ -0,0 +1,183 @@ +package plivo + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/client" + "github.com/TwiN/gatus/v5/config/endpoint" + "gopkg.in/yaml.v3" +) + +var ( + ErrAuthIDNotSet = errors.New("auth-id not set") + ErrAuthTokenNotSet = errors.New("auth-token not set") + ErrFromNotSet = errors.New("from not set") + ErrToNotSet = errors.New("to not set") + ErrDuplicateGroupOverride = errors.New("duplicate group override") +) + +type Config struct { + AuthID string `yaml:"auth-id"` + AuthToken string `yaml:"auth-token"` + From string `yaml:"from"` + To []string `yaml:"to"` +} + +func (cfg *Config) Validate() error { + if len(cfg.AuthID) == 0 { + return ErrAuthIDNotSet + } + if len(cfg.AuthToken) == 0 { + return ErrAuthTokenNotSet + } + if len(cfg.From) == 0 { + return ErrFromNotSet + } + if len(cfg.To) == 0 { + return ErrToNotSet + } + return nil +} + +func (cfg *Config) Merge(override *Config) { + if len(override.AuthID) > 0 { + cfg.AuthID = override.AuthID + } + if len(override.AuthToken) > 0 { + cfg.AuthToken = override.AuthToken + } + if len(override.From) > 0 { + cfg.From = override.From + } + if len(override.To) > 0 { + cfg.To = override.To + } +} + +// AlertProvider is the configuration necessary for sending an alert using Plivo +type AlertProvider struct { + DefaultConfig Config `yaml:",inline"` + + // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type + DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` + + // Overrides is a list of Override that may be prioritized over the default configuration + Overrides []Override `yaml:"overrides,omitempty"` +} + +// Override is a case under which the default integration is overridden +type Override struct { + Group string `yaml:"group"` + Config `yaml:",inline"` +} + +// Validate the provider's configuration +func (provider *AlertProvider) Validate() error { + registeredGroups := make(map[string]bool) + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" { + return ErrDuplicateGroupOverride + } + registeredGroups[override.Group] = true + } + } + return provider.DefaultConfig.Validate() +} + +// Send an alert using the provider +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { + cfg, err := provider.GetConfig(ep.Group, alert) + if err != nil { + return err + } + message := provider.buildMessage(cfg, ep, alert, result, resolved) + // Send individual SMS messages to each recipient + for _, to := range cfg.To { + if err := provider.sendSMS(cfg, to, message); err != nil { + return err + } + } + return nil +} + +// sendSMS sends an SMS message to a single recipient +func (provider *AlertProvider) sendSMS(cfg *Config, to, message string) error { + payload := map[string]string{ + "src": cfg.From, + "dst": to, + "text": message, + } + payloadBytes, err := json.Marshal(payload) + if err != nil { + return err + } + request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("https://api.plivo.com/v1/Account/%s/Message/", cfg.AuthID), bytes.NewBuffer(payloadBytes)) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(cfg.AuthID+":"+cfg.AuthToken)))) + response, err := client.GetHTTPClient(nil).Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode >= 400 { + body, _ := io.ReadAll(response.Body) + return fmt.Errorf("call to plivo alert returned status code %d: %s", response.StatusCode, string(body)) + } + return nil +} + +// buildMessage builds the message for the provider +func (provider *AlertProvider) buildMessage(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string { + if resolved { + return fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription()) + } else { + return fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription()) + } +} + +// GetDefaultAlert returns the provider's default alert configuration +func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { + return provider.DefaultAlert +} + +// GetConfig returns the configuration for the provider with the overrides applied +func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) { + cfg := provider.DefaultConfig + // Handle group overrides + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if group == override.Group { + cfg.Merge(&override.Config) + break + } + } + } + // Handle alert overrides + if len(alert.ProviderOverride) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) + } + // Validate the configuration + err := cfg.Validate() + return &cfg, err +} + +// ValidateOverrides validates the alert's provider override and, if present, the group override +func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error { + _, err := provider.GetConfig(group, alert) + return err +} diff --git a/alerting/provider/plivo/plivo_test.go b/alerting/provider/plivo/plivo_test.go new file mode 100644 index 00000000..148a7036 --- /dev/null +++ b/alerting/provider/plivo/plivo_test.go @@ -0,0 +1,514 @@ +package plivo + +import ( + "encoding/json" + "io" + "net/http" + "testing" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/client" + "github.com/TwiN/gatus/v5/config/endpoint" + "github.com/TwiN/gatus/v5/test" +) + +func TestPlivoAlertProvider_IsValid(t *testing.T) { + scenarios := []struct { + Name string + Provider AlertProvider + ExpectedError error + }{ + { + Name: "invalid-provider-missing-config", + Provider: AlertProvider{}, + ExpectedError: ErrAuthIDNotSet, + }, + { + Name: "valid-provider", + Provider: AlertProvider{ + DefaultConfig: Config{ + AuthID: "1", + AuthToken: "1", + From: "1234567890", + To: []string{"0987654321"}, + }, + }, + ExpectedError: nil, + }, + { + Name: "valid-provider-with-override", + Provider: AlertProvider{ + DefaultConfig: Config{ + AuthID: "1", + AuthToken: "1", + From: "1234567890", + To: []string{"0987654321"}, + }, + Overrides: []Override{ + { + Group: "group1", + Config: Config{AuthID: "2", AuthToken: "2", From: "2222222222", To: []string{"3333333333"}}, + }, + }, + }, + ExpectedError: nil, + }, + { + Name: "invalid-provider-duplicate-group-override", + Provider: AlertProvider{ + DefaultConfig: Config{ + AuthID: "1", + AuthToken: "1", + From: "1234567890", + To: []string{"0987654321"}, + }, + Overrides: []Override{ + { + Group: "group1", + Config: Config{AuthID: "2", AuthToken: "2", From: "2222222222", To: []string{"3333333333"}}, + }, + { + Group: "group1", + Config: Config{AuthID: "3", AuthToken: "3", From: "4444444444", To: []string{"5555555555"}}, + }, + }, + }, + ExpectedError: ErrDuplicateGroupOverride, + }, + { + Name: "invalid-provider-empty-group-override", + Provider: AlertProvider{ + DefaultConfig: Config{ + AuthID: "1", + AuthToken: "1", + From: "1234567890", + To: []string{"0987654321"}, + }, + Overrides: []Override{ + { + Group: "", + Config: Config{AuthID: "2", AuthToken: "2", From: "2222222222", To: []string{"3333333333"}}, + }, + }, + }, + ExpectedError: ErrDuplicateGroupOverride, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + err := scenario.Provider.Validate() + if scenario.ExpectedError == nil && err != nil { + t.Errorf("expected no error, got %v", err) + } + if scenario.ExpectedError != nil && err == nil { + t.Errorf("expected error %v, got none", scenario.ExpectedError) + } + if scenario.ExpectedError != nil && err != nil && err.Error() != scenario.ExpectedError.Error() { + t.Errorf("expected error %v, got %v", scenario.ExpectedError, err) + } + }) + } +} + +func TestAlertProvider_Send(t *testing.T) { + defer client.InjectHTTPClient(nil) + firstDescription := "description-1" + secondDescription := "description-2" + scenarios := []struct { + Name string + Provider AlertProvider + Alert alert.Alert + Resolved bool + MockRoundTripper test.MockRoundTripper + ExpectedError bool + }{ + { + Name: "triggered", + Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}}, + 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.StatusAccepted, Body: http.NoBody} + }), + ExpectedError: false, + }, + { + Name: "triggered-error", + Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}}, + Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody} + }), + ExpectedError: true, + }, + { + Name: "resolved", + Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}}, + 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.StatusAccepted, Body: http.NoBody} + }), + ExpectedError: false, + }, + { + Name: "resolved-error", + Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}}, + 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, + }, + { + Name: "multiple-recipients", + Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321", "1122334455"}}}, + 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.StatusAccepted, Body: http.NoBody} + }), + ExpectedError: false, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) + err := scenario.Provider.Send( + &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_buildMessage(t *testing.T) { + firstDescription := "description-1" + secondDescription := "description-2" + scenarios := []struct { + Name string + Provider AlertProvider + Alert alert.Alert + Resolved bool + ExpectedMessage string + }{ + { + Name: "triggered", + Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}}, + Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + ExpectedMessage: "TRIGGERED: endpoint-name - description-1", + }, + { + Name: "resolved", + Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}}, + Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: true, + ExpectedMessage: "RESOLVED: endpoint-name - description-2", + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + message := scenario.Provider.buildMessage( + &scenario.Provider.DefaultConfig, + &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 message != scenario.ExpectedMessage { + t.Errorf("expected %s, got %s", scenario.ExpectedMessage, message) + } + }) + } +} + +func TestAlertProvider_sendSMS(t *testing.T) { + defer client.InjectHTTPClient(nil) + cfg := &Config{ + AuthID: "test-auth-id", + AuthToken: "test-auth-token", + From: "1234567890", + } + scenarios := []struct { + Name string + To string + Message string + MockRoundTripper test.MockRoundTripper + ExpectedError bool + }{ + { + Name: "successful-sms", + To: "0987654321", + Message: "Test message", + MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + // Verify request structure + body, _ := io.ReadAll(r.Body) + var payload map[string]string + json.Unmarshal(body, &payload) + if payload["src"] != cfg.From { + t.Errorf("expected src %s, got %s", cfg.From, payload["src"]) + } + if payload["dst"] != "0987654321" { + t.Errorf("expected dst %s, got %s", "0987654321", payload["dst"]) + } + if payload["text"] != "Test message" { + t.Errorf("expected text %s, got %s", "Test message", payload["text"]) + } + return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody} + }), + ExpectedError: false, + }, + { + Name: "failed-sms", + To: "0987654321", + Message: "Test message", + MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + return &http.Response{StatusCode: http.StatusBadRequest, Body: http.NoBody} + }), + ExpectedError: true, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) + provider := AlertProvider{} + err := provider.sendSMS(cfg, scenario.To, scenario.Message) + 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_GetDefaultAlert(t *testing.T) { + if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil { + t.Error("expected default alert to be not nil") + } + if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil { + t.Error("expected default alert to be nil") + } +} + +func TestAlertProvider_GetConfig(t *testing.T) { + scenarios := []struct { + Name string + Provider AlertProvider + InputGroup string + InputAlert alert.Alert + ExpectedOutput Config + }{ + { + Name: "provider-no-override-should-default", + Provider: AlertProvider{ + DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}, + }, + InputGroup: "", + InputAlert: alert.Alert{}, + ExpectedOutput: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}, + }, + { + Name: "provider-with-group-override", + Provider: AlertProvider{ + DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}, + Overrides: []Override{ + { + Group: "group1", + Config: Config{AuthID: "3", AuthToken: "4", From: "3333333333", To: []string{"7777777777"}}, + }, + }, + }, + InputGroup: "group1", + InputAlert: alert.Alert{}, + ExpectedOutput: Config{AuthID: "3", AuthToken: "4", From: "3333333333", To: []string{"7777777777"}}, + }, + { + Name: "provider-with-group-override-no-match", + Provider: AlertProvider{ + DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}, + Overrides: []Override{ + { + Group: "group1", + Config: Config{AuthID: "3", AuthToken: "4", From: "3333333333", To: []string{"7777777777"}}, + }, + }, + }, + InputGroup: "group2", + InputAlert: alert.Alert{}, + ExpectedOutput: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}, + }, + { + Name: "provider-with-alert-override", + Provider: AlertProvider{ + DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}, + }, + InputGroup: "", + InputAlert: alert.Alert{ProviderOverride: map[string]any{"auth-id": "5", "auth-token": "6", "from": "5555555555", "to": []string{"9999999999"}}}, + ExpectedOutput: Config{AuthID: "5", AuthToken: "6", From: "5555555555", To: []string{"9999999999"}}, + }, + { + Name: "provider-with-group-and-alert-override", + Provider: AlertProvider{ + DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}, + Overrides: []Override{ + { + Group: "group1", + Config: Config{AuthID: "3", AuthToken: "4", From: "3333333333", To: []string{"7777777777"}}, + }, + }, + }, + InputGroup: "group1", + InputAlert: alert.Alert{ProviderOverride: map[string]any{"auth-id": "5", "auth-token": "6"}}, + ExpectedOutput: Config{AuthID: "5", AuthToken: "6", From: "3333333333", To: []string{"7777777777"}}, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert) + if err != nil { + t.Error("expected no error, got:", err.Error()) + } + if got.AuthID != scenario.ExpectedOutput.AuthID { + t.Errorf("expected AuthID to be %s, got %s", scenario.ExpectedOutput.AuthID, got.AuthID) + } + if got.AuthToken != scenario.ExpectedOutput.AuthToken { + t.Errorf("expected AuthToken to be %s, got %s", scenario.ExpectedOutput.AuthToken, got.AuthToken) + } + if got.From != scenario.ExpectedOutput.From { + t.Errorf("expected From to be %s, got %s", scenario.ExpectedOutput.From, got.From) + } + if len(got.To) != len(scenario.ExpectedOutput.To) { + t.Errorf("expected To length to be %d, got %d", len(scenario.ExpectedOutput.To), len(got.To)) + } + for i, to := range got.To { + if to != scenario.ExpectedOutput.To[i] { + t.Errorf("expected To[%d] to be %s, got %s", i, scenario.ExpectedOutput.To[i], to) + } + } + // Test ValidateOverrides as well, since it really just calls GetConfig + if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil { + t.Errorf("unexpected error: %s", err) + } + }) + } +} + +func TestConfig_Validate(t *testing.T) { + scenarios := []struct { + Name string + Config Config + ExpectedError error + }{ + { + Name: "valid-config", + Config: Config{ + AuthID: "test-auth-id", + AuthToken: "test-auth-token", + From: "1234567890", + To: []string{"0987654321"}, + }, + ExpectedError: nil, + }, + { + Name: "missing-auth-id", + Config: Config{ + AuthToken: "test-auth-token", + From: "1234567890", + To: []string{"0987654321"}, + }, + ExpectedError: ErrAuthIDNotSet, + }, + { + Name: "missing-auth-token", + Config: Config{ + AuthID: "test-auth-id", + From: "1234567890", + To: []string{"0987654321"}, + }, + ExpectedError: ErrAuthTokenNotSet, + }, + { + Name: "missing-from", + Config: Config{ + AuthID: "test-auth-id", + AuthToken: "test-auth-token", + To: []string{"0987654321"}, + }, + ExpectedError: ErrFromNotSet, + }, + { + Name: "missing-to", + Config: Config{ + AuthID: "test-auth-id", + AuthToken: "test-auth-token", + From: "1234567890", + }, + ExpectedError: ErrToNotSet, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + err := scenario.Config.Validate() + if scenario.ExpectedError == nil && err != nil { + t.Errorf("expected no error, got %v", err) + } + if scenario.ExpectedError != nil && err == nil { + t.Errorf("expected error %v, got none", scenario.ExpectedError) + } + if scenario.ExpectedError != nil && err != nil && err.Error() != scenario.ExpectedError.Error() { + t.Errorf("expected error %v, got %v", scenario.ExpectedError, err) + } + }) + } +} + +func TestConfig_Merge(t *testing.T) { + cfg := Config{ + AuthID: "original-auth-id", + AuthToken: "original-auth-token", + From: "1111111111", + To: []string{"2222222222"}, + } + override := Config{ + AuthID: "override-auth-id", + AuthToken: "override-auth-token", + From: "3333333333", + To: []string{"4444444444", "5555555555"}, + } + cfg.Merge(&override) + if cfg.AuthID != "override-auth-id" { + t.Errorf("expected AuthID to be %s, got %s", "override-auth-id", cfg.AuthID) + } + if cfg.AuthToken != "override-auth-token" { + t.Errorf("expected AuthToken to be %s, got %s", "override-auth-token", cfg.AuthToken) + } + if cfg.From != "3333333333" { + t.Errorf("expected From to be %s, got %s", "3333333333", cfg.From) + } + if len(cfg.To) != 2 || cfg.To[0] != "4444444444" || cfg.To[1] != "5555555555" { + t.Errorf("expected To to be [4444444444, 5555555555], got %v", cfg.To) + } +} diff --git a/alerting/provider/provider.go b/alerting/provider/provider.go index 1b76dfd5..5ab9dc4a 100644 --- a/alerting/provider/provider.go +++ b/alerting/provider/provider.go @@ -4,29 +4,42 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/provider/awsses" "github.com/TwiN/gatus/v5/alerting/provider/custom" + "github.com/TwiN/gatus/v5/alerting/provider/datadog" "github.com/TwiN/gatus/v5/alerting/provider/discord" "github.com/TwiN/gatus/v5/alerting/provider/email" "github.com/TwiN/gatus/v5/alerting/provider/gitea" "github.com/TwiN/gatus/v5/alerting/provider/github" "github.com/TwiN/gatus/v5/alerting/provider/gitlab" "github.com/TwiN/gatus/v5/alerting/provider/googlechat" - "github.com/TwiN/gatus/v5/alerting/provider/gotify" + "github.com/TwiN/gatus/v5/alerting/provider/gotify" "github.com/TwiN/gatus/v5/alerting/provider/homeassistant" - "github.com/TwiN/gatus/v5/alerting/provider/ilert" + "github.com/TwiN/gatus/v5/alerting/provider/ifttt" + "github.com/TwiN/gatus/v5/alerting/provider/ilert" "github.com/TwiN/gatus/v5/alerting/provider/incidentio" "github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace" + "github.com/TwiN/gatus/v5/alerting/provider/line" "github.com/TwiN/gatus/v5/alerting/provider/matrix" "github.com/TwiN/gatus/v5/alerting/provider/mattermost" "github.com/TwiN/gatus/v5/alerting/provider/messagebird" + "github.com/TwiN/gatus/v5/alerting/provider/newrelic" "github.com/TwiN/gatus/v5/alerting/provider/ntfy" "github.com/TwiN/gatus/v5/alerting/provider/opsgenie" "github.com/TwiN/gatus/v5/alerting/provider/pagerduty" + "github.com/TwiN/gatus/v5/alerting/provider/plivo" "github.com/TwiN/gatus/v5/alerting/provider/pushover" + "github.com/TwiN/gatus/v5/alerting/provider/rocketchat" + "github.com/TwiN/gatus/v5/alerting/provider/sendgrid" + "github.com/TwiN/gatus/v5/alerting/provider/signal" + "github.com/TwiN/gatus/v5/alerting/provider/signl4" "github.com/TwiN/gatus/v5/alerting/provider/slack" + "github.com/TwiN/gatus/v5/alerting/provider/splunk" + "github.com/TwiN/gatus/v5/alerting/provider/squadcast" "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/webex" + "github.com/TwiN/gatus/v5/alerting/provider/zapier" "github.com/TwiN/gatus/v5/alerting/provider/zulip" "github.com/TwiN/gatus/v5/config/endpoint" ) @@ -77,56 +90,82 @@ var ( // Validate provider interface implementation on compile _ AlertProvider = (*awsses.AlertProvider)(nil) _ AlertProvider = (*custom.AlertProvider)(nil) + _ AlertProvider = (*datadog.AlertProvider)(nil) _ AlertProvider = (*discord.AlertProvider)(nil) _ AlertProvider = (*email.AlertProvider)(nil) _ AlertProvider = (*gitea.AlertProvider)(nil) _ AlertProvider = (*github.AlertProvider)(nil) _ AlertProvider = (*gitlab.AlertProvider)(nil) _ AlertProvider = (*googlechat.AlertProvider)(nil) - _ AlertProvider = (*gotify.AlertProvider)(nil) + _ AlertProvider = (*gotify.AlertProvider)(nil) _ AlertProvider = (*homeassistant.AlertProvider)(nil) - _ AlertProvider = (*ilert.AlertProvider)(nil) + _ AlertProvider = (*ifttt.AlertProvider)(nil) + _ AlertProvider = (*ilert.AlertProvider)(nil) _ AlertProvider = (*incidentio.AlertProvider)(nil) _ AlertProvider = (*jetbrainsspace.AlertProvider)(nil) + _ AlertProvider = (*line.AlertProvider)(nil) _ AlertProvider = (*matrix.AlertProvider)(nil) _ AlertProvider = (*mattermost.AlertProvider)(nil) _ AlertProvider = (*messagebird.AlertProvider)(nil) + _ AlertProvider = (*newrelic.AlertProvider)(nil) _ AlertProvider = (*ntfy.AlertProvider)(nil) _ AlertProvider = (*opsgenie.AlertProvider)(nil) _ AlertProvider = (*pagerduty.AlertProvider)(nil) + _ AlertProvider = (*plivo.AlertProvider)(nil) _ AlertProvider = (*pushover.AlertProvider)(nil) + _ AlertProvider = (*rocketchat.AlertProvider)(nil) + _ AlertProvider = (*sendgrid.AlertProvider)(nil) + _ AlertProvider = (*signal.AlertProvider)(nil) + _ AlertProvider = (*signl4.AlertProvider)(nil) _ AlertProvider = (*slack.AlertProvider)(nil) + _ AlertProvider = (*splunk.AlertProvider)(nil) + _ AlertProvider = (*squadcast.AlertProvider)(nil) _ AlertProvider = (*teams.AlertProvider)(nil) _ AlertProvider = (*teamsworkflows.AlertProvider)(nil) _ AlertProvider = (*telegram.AlertProvider)(nil) _ AlertProvider = (*twilio.AlertProvider)(nil) + _ AlertProvider = (*webex.AlertProvider)(nil) + _ AlertProvider = (*zapier.AlertProvider)(nil) _ AlertProvider = (*zulip.AlertProvider)(nil) // Validate config interface implementation on compile _ Config[awsses.Config] = (*awsses.Config)(nil) _ Config[custom.Config] = (*custom.Config)(nil) + _ Config[datadog.Config] = (*datadog.Config)(nil) _ Config[discord.Config] = (*discord.Config)(nil) _ Config[email.Config] = (*email.Config)(nil) _ Config[gitea.Config] = (*gitea.Config)(nil) _ Config[github.Config] = (*github.Config)(nil) _ Config[gitlab.Config] = (*gitlab.Config)(nil) _ Config[googlechat.Config] = (*googlechat.Config)(nil) - _ Config[gotify.Config] = (*gotify.Config)(nil) + _ Config[gotify.Config] = (*gotify.Config)(nil) _ Config[homeassistant.Config] = (*homeassistant.Config)(nil) - _ Config[ilert.Config] = (*ilert.Config)(nil) + _ Config[ifttt.Config] = (*ifttt.Config)(nil) + _ Config[ilert.Config] = (*ilert.Config)(nil) _ Config[incidentio.Config] = (*incidentio.Config)(nil) _ Config[jetbrainsspace.Config] = (*jetbrainsspace.Config)(nil) + _ Config[line.Config] = (*line.Config)(nil) _ Config[matrix.Config] = (*matrix.Config)(nil) _ Config[mattermost.Config] = (*mattermost.Config)(nil) _ Config[messagebird.Config] = (*messagebird.Config)(nil) + _ Config[newrelic.Config] = (*newrelic.Config)(nil) _ Config[ntfy.Config] = (*ntfy.Config)(nil) _ Config[opsgenie.Config] = (*opsgenie.Config)(nil) _ Config[pagerduty.Config] = (*pagerduty.Config)(nil) + _ Config[plivo.Config] = (*plivo.Config)(nil) _ Config[pushover.Config] = (*pushover.Config)(nil) + _ Config[rocketchat.Config] = (*rocketchat.Config)(nil) + _ Config[sendgrid.Config] = (*sendgrid.Config)(nil) + _ Config[signal.Config] = (*signal.Config)(nil) + _ Config[signl4.Config] = (*signl4.Config)(nil) _ Config[slack.Config] = (*slack.Config)(nil) + _ Config[splunk.Config] = (*splunk.Config)(nil) + _ Config[squadcast.Config] = (*squadcast.Config)(nil) _ Config[teams.Config] = (*teams.Config)(nil) _ Config[teamsworkflows.Config] = (*teamsworkflows.Config)(nil) _ Config[telegram.Config] = (*telegram.Config)(nil) _ Config[twilio.Config] = (*twilio.Config)(nil) + _ Config[webex.Config] = (*webex.Config)(nil) + _ Config[zapier.Config] = (*zapier.Config)(nil) _ Config[zulip.Config] = (*zulip.Config)(nil) ) diff --git a/alerting/provider/pushover/pushover.go b/alerting/provider/pushover/pushover.go index efa3be6d..4c8fdd02 100644 --- a/alerting/provider/pushover/pushover.go +++ b/alerting/provider/pushover/pushover.go @@ -15,7 +15,7 @@ import ( ) const ( - restAPIURL = "https://api.pushover.net/1/messages.json" + ApiURL = "https://api.pushover.net/1/messages.json" defaultPriority = 0 ) @@ -76,9 +76,9 @@ func (cfg *Config) Validate() error { if cfg.Priority < -2 || cfg.Priority > 2 || cfg.ResolvedPriority < -2 || cfg.ResolvedPriority > 2 { return ErrInvalidPriority } - if len(cfg.Device) > 25 { - return ErrInvalidDevice - } + if len(cfg.Device) > 25 { + return ErrInvalidDevice + } return nil } @@ -104,9 +104,9 @@ func (cfg *Config) Merge(override *Config) { if override.TTL > 0 { cfg.TTL = override.TTL } - if len(override.Device) > 0 { - cfg.Device = override.Device - } + if len(override.Device) > 0 { + cfg.Device = override.Device + } } // AlertProvider is the configuration necessary for sending an alert using Pushover @@ -130,7 +130,7 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r return err } buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved)) - request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer) + request, err := http.NewRequest(http.MethodPost, ApiURL, buffer) if err != nil { return err } diff --git a/alerting/provider/rocketchat/rocketchat.go b/alerting/provider/rocketchat/rocketchat.go new file mode 100644 index 00000000..53f3645a --- /dev/null +++ b/alerting/provider/rocketchat/rocketchat.go @@ -0,0 +1,212 @@ +package rocketchat + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/client" + "github.com/TwiN/gatus/v5/config/endpoint" + "gopkg.in/yaml.v3" +) + +var ( + ErrWebhookURLNotSet = errors.New("webhook-url not set") + ErrDuplicateGroupOverride = errors.New("duplicate group override") +) + +type Config struct { + WebhookURL string `yaml:"webhook-url"` // Rocket.Chat incoming webhook URL + Channel string `yaml:"channel,omitempty"` // Optional channel override +} + +func (cfg *Config) Validate() error { + if len(cfg.WebhookURL) == 0 { + return ErrWebhookURLNotSet + } + return nil +} + +func (cfg *Config) Merge(override *Config) { + if len(override.WebhookURL) > 0 { + cfg.WebhookURL = override.WebhookURL + } + if len(override.Channel) > 0 { + cfg.Channel = override.Channel + } +} + +// AlertProvider is the configuration necessary for sending an alert using Rocket.Chat +type AlertProvider struct { + DefaultConfig Config `yaml:",inline"` + + // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type + DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` + + // Overrides is a list of Override that may be prioritized over the default configuration + Overrides []Override `yaml:"overrides,omitempty"` +} + +// Override is a case under which the default integration is overridden +type Override struct { + Group string `yaml:"group"` + Config `yaml:",inline"` +} + +// Validate the provider's configuration +func (provider *AlertProvider) Validate() error { + registeredGroups := make(map[string]bool) + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" { + return ErrDuplicateGroupOverride + } + registeredGroups[override.Group] = true + } + } + return provider.DefaultConfig.Validate() +} + +// Send an alert using the provider +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { + cfg, err := provider.GetConfig(ep.Group, alert) + if err != nil { + return err + } + body, err := provider.buildRequestBody(cfg, ep, alert, result, resolved) + if err != nil { + return err + } + buffer := bytes.NewBuffer(body) + request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + response, err := client.GetHTTPClient(nil).Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode >= 400 { + body, _ := io.ReadAll(response.Body) + return fmt.Errorf("call to rocketchat alert returned status code %d: %s", response.StatusCode, string(body)) + } + return nil +} + +type Body struct { + Text string `json:"text"` + Channel string `json:"channel,omitempty"` + Username string `json:"username"` + Attachments []Attachment `json:"attachments"` +} + +type Attachment struct { + Title string `json:"title"` + Text string `json:"text"` + Color string `json:"color"` + Fields []Field `json:"fields,omitempty"` + AuthorName string `json:"author_name"` + AuthorIcon string `json:"author_icon"` +} + +type Field struct { + Title string `json:"title"` + Value string `json:"value"` + Short bool `json:"short"` +} + +// buildRequestBody builds the request body for the provider +func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) { + var message, color string + if resolved { + message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold) + color = "#36a64f" + } 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) + color = "#dd0000" + } + var formattedConditionResults string + for _, conditionResult := range result.ConditionResults { + var prefix string + if conditionResult.Success { + prefix = "✅" + } else { + prefix = "❌" + } + formattedConditionResults += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition) + } + var description string + if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { + description = ":\n> " + alertDescription + } + body := Body{ + Text: "", + Username: "Gatus", + Attachments: []Attachment{ + { + Title: "🚨 Gatus Alert", + Text: message + description, + Color: color, + AuthorName: "Gatus", + AuthorIcon: "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png", + }, + }, + } + if cfg.Channel != "" { + body.Channel = cfg.Channel + } + if len(formattedConditionResults) > 0 { + body.Attachments[0].Fields = append(body.Attachments[0].Fields, Field{ + Title: "Condition results", + Value: formattedConditionResults, + Short: false, + }) + } + bodyAsJSON, err := json.Marshal(body) + if err != nil { + return nil, err + } + return bodyAsJSON, nil +} + +// GetDefaultAlert returns the provider's default alert configuration +func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { + return provider.DefaultAlert +} + +// GetConfig returns the configuration for the provider with the overrides applied +func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) { + cfg := provider.DefaultConfig + // Handle group overrides + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if group == override.Group { + cfg.Merge(&override.Config) + break + } + } + } + // Handle alert overrides + if len(alert.ProviderOverride) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) + } + // Validate the configuration + err := cfg.Validate() + return &cfg, err +} + +// ValidateOverrides validates the alert's provider override and, if present, the group override +func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error { + _, err := provider.GetConfig(group, alert) + return err +} diff --git a/alerting/provider/rocketchat/rocketchat_test.go b/alerting/provider/rocketchat/rocketchat_test.go new file mode 100644 index 00000000..fea159d1 --- /dev/null +++ b/alerting/provider/rocketchat/rocketchat_test.go @@ -0,0 +1,164 @@ +package rocketchat + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/client" + "github.com/TwiN/gatus/v5/config/endpoint" + "github.com/TwiN/gatus/v5/test" +) + +func TestAlertProvider_Validate(t *testing.T) { + scenarios := []struct { + name string + provider AlertProvider + expected error + }{ + { + name: "valid", + provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc"}}, + expected: nil, + }, + { + name: "valid-with-channel", + provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc", Channel: "#alerts"}}, + expected: nil, + }, + { + name: "invalid-webhook-url", + provider: AlertProvider{DefaultConfig: Config{}}, + expected: ErrWebhookURLNotSet, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + err := scenario.provider.Validate() + if err != scenario.expected { + t.Errorf("expected %v, got %v", scenario.expected, err) + } + }) + } +} + +func TestAlertProvider_Send(t *testing.T) { + defer client.InjectHTTPClient(nil) + firstDescription := "description-1" + secondDescription := "description-2" + scenarios := []struct { + name string + provider AlertProvider + alert alert.Alert + resolved bool + mockRoundTripper test.MockRoundTripper + expectedError bool + }{ + { + name: "triggered", + provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc"}}, + alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + resolved: false, + mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + body := make(map[string]interface{}) + json.NewDecoder(r.Body).Decode(&body) + if body["username"] != "Gatus" { + t.Errorf("expected username to be 'Gatus', got %v", body["username"]) + } + attachments := body["attachments"].([]interface{}) + if len(attachments) != 1 { + t.Errorf("expected 1 attachment, got %d", len(attachments)) + } + attachment := attachments[0].(map[string]interface{}) + if attachment["color"] != "#dd0000" { + t.Errorf("expected color to be '#dd0000', got %v", attachment["color"]) + } + text := attachment["text"].(string) + if !strings.Contains(text, "failed 3 time(s)") { + t.Errorf("expected text to contain failure count, got %s", text) + } + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody} + }), + expectedError: false, + }, + { + name: "triggered-with-channel", + provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc", Channel: "#alerts"}}, + alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + resolved: false, + mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + body := make(map[string]interface{}) + json.NewDecoder(r.Body).Decode(&body) + if body["channel"] != "#alerts" { + t.Errorf("expected channel to be '#alerts', got %v", body["channel"]) + } + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody} + }), + expectedError: false, + }, + { + name: "resolved", + provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc"}}, + alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + resolved: true, + mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + body := make(map[string]interface{}) + json.NewDecoder(r.Body).Decode(&body) + attachments := body["attachments"].([]interface{}) + attachment := attachments[0].(map[string]interface{}) + if attachment["color"] != "#36a64f" { + t.Errorf("expected color to be '#36a64f', got %v", attachment["color"]) + } + text := attachment["text"].(string) + if !strings.Contains(text, "resolved") { + t.Errorf("expected text to contain 'resolved', got %s", text) + } + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody} + }), + expectedError: false, + }, + { + name: "error-response", + provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc"}}, + 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.StatusBadRequest, 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_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") + } +} diff --git a/alerting/provider/sendgrid/sendgrid.go b/alerting/provider/sendgrid/sendgrid.go new file mode 100644 index 00000000..67c1ef0e --- /dev/null +++ b/alerting/provider/sendgrid/sendgrid.go @@ -0,0 +1,248 @@ +package sendgrid + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/client" + "github.com/TwiN/gatus/v5/config/endpoint" + "gopkg.in/yaml.v3" +) + +const ( + ApiURL = "https://api.sendgrid.com/v3/mail/send" +) + +var ( + ErrAPIKeyNotSet = errors.New("api-key not set") + ErrFromNotSet = errors.New("from not set") + ErrToNotSet = errors.New("to not set") + ErrDuplicateGroupOverride = errors.New("duplicate group override") +) + +type Config struct { + APIKey string `yaml:"api-key"` + From string `yaml:"from"` + To string `yaml:"to"` + + // ClientConfig is the configuration of the client used to communicate with the provider's target + ClientConfig *client.Config `yaml:"client,omitempty"` +} + +func (cfg *Config) Validate() error { + if len(cfg.APIKey) == 0 { + return ErrAPIKeyNotSet + } + if len(cfg.From) == 0 { + return ErrFromNotSet + } + if len(cfg.To) == 0 { + return ErrToNotSet + } + return nil +} + +func (cfg *Config) Merge(override *Config) { + if override.ClientConfig != nil { + cfg.ClientConfig = override.ClientConfig + } + if len(override.APIKey) > 0 { + cfg.APIKey = override.APIKey + } + if len(override.From) > 0 { + cfg.From = override.From + } + if len(override.To) > 0 { + cfg.To = override.To + } +} + +// AlertProvider is the configuration necessary for sending an alert using SendGrid +type AlertProvider struct { + DefaultConfig Config `yaml:",inline"` + + // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type + DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` + + // Overrides is a list of Override that may be prioritized over the default configuration + Overrides []Override `yaml:"overrides,omitempty"` +} + +// Override is a case under which the default integration is overridden +type Override struct { + Group string `yaml:"group"` + Config `yaml:",inline"` +} + +// Validate the provider's configuration +func (provider *AlertProvider) Validate() error { + registeredGroups := make(map[string]bool) + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" { + return ErrDuplicateGroupOverride + } + registeredGroups[override.Group] = true + } + } + return provider.DefaultConfig.Validate() +} + +// Send an alert using the provider +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { + cfg, err := provider.GetConfig(ep.Group, alert) + if err != nil { + return err + } + subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved) + payload := provider.buildSendGridPayload(cfg, subject, body) + payloadBytes, err := json.Marshal(payload) + if err != nil { + return err + } + request, err := http.NewRequest(http.MethodPost, ApiURL, bytes.NewBuffer(payloadBytes)) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Authorization", "Bearer "+cfg.APIKey) + response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode >= 400 { + body, _ := io.ReadAll(response.Body) + return fmt.Errorf("call to sendgrid alert returned status code %d: %s", response.StatusCode, string(body)) + } + return nil +} + +type SendGridPayload struct { + Personalizations []Personalization `json:"personalizations"` + From Email `json:"from"` + Subject string `json:"subject"` + Content []Content `json:"content"` +} + +type Personalization struct { + To []Email `json:"to"` +} + +type Email struct { + Email string `json:"email"` +} + +type Content struct { + Type string `json:"type"` + Value string `json:"value"` +} + +// buildSendGridPayload builds the SendGrid API payload +func (provider *AlertProvider) buildSendGridPayload(cfg *Config, subject, body string) SendGridPayload { + toEmails := strings.Split(cfg.To, ",") + var recipients []Email + for _, email := range toEmails { + recipients = append(recipients, Email{Email: strings.TrimSpace(email)}) + } + return SendGridPayload{ + Personalizations: []Personalization{ + { + To: recipients, + }, + }, + From: Email{ + Email: cfg.From, + }, + Subject: subject, + Content: []Content{ + { + Type: "text/plain", + Value: body, + }, + { + Type: "text/html", + Value: strings.ReplaceAll(body, "\n", "
"), + }, + }, + } +} + +// buildMessageSubjectAndBody builds the message subject and body +func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) (string, string) { + var subject, message string + if resolved { + subject = fmt.Sprintf("[%s] Alert resolved", ep.DisplayName()) + message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold) + } else { + subject = fmt.Sprintf("[%s] Alert triggered", ep.DisplayName()) + message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold) + } + var formattedConditionResults string + if len(result.ConditionResults) > 0 { + formattedConditionResults = "\n\nCondition results:\n" + for _, conditionResult := range result.ConditionResults { + var prefix string + if conditionResult.Success { + prefix = "✅" + } else { + prefix = "❌" + } + formattedConditionResults += fmt.Sprintf("%s %s\n", prefix, conditionResult.Condition) + } + } + var description string + if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { + description = "\n\nAlert description: " + alertDescription + } + var extraLabels string + if len(ep.ExtraLabels) > 0 { + extraLabels = "\n\nExtra labels:\n" + for key, value := range ep.ExtraLabels { + extraLabels += fmt.Sprintf(" %s: %s\n", key, value) + } + } + return subject, message + description + extraLabels + formattedConditionResults +} + +// GetDefaultAlert returns the provider's default alert configuration +func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { + return provider.DefaultAlert +} + +// GetConfig returns the configuration for the provider with the overrides applied +func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) { + cfg := provider.DefaultConfig + // Handle group overrides + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if group == override.Group { + cfg.Merge(&override.Config) + break + } + } + } + // Handle alert overrides + if len(alert.ProviderOverride) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) + } + // Validate the configuration + err := cfg.Validate() + return &cfg, err +} + +// ValidateOverrides validates the alert's provider override and, if present, the group override +func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error { + _, err := provider.GetConfig(group, alert) + return err +} diff --git a/alerting/provider/sendgrid/sendgrid_test.go b/alerting/provider/sendgrid/sendgrid_test.go new file mode 100644 index 00000000..0f80bc67 --- /dev/null +++ b/alerting/provider/sendgrid/sendgrid_test.go @@ -0,0 +1,517 @@ +package sendgrid + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/client" + "github.com/TwiN/gatus/v5/config/endpoint" + "github.com/TwiN/gatus/v5/test" +) + +func TestAlertProvider_Validate(t *testing.T) { + invalidProvider := AlertProvider{DefaultConfig: Config{APIKey: "", From: "", To: ""}} + if err := invalidProvider.Validate(); err == nil { + t.Error("provider shouldn't have been valid") + } + validProvider := AlertProvider{DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"}} + if err := validProvider.Validate(); err != nil { + t.Error("provider should've been valid") + } +} + +func TestAlertProvider_ValidateWithOverride(t *testing.T) { + providerWithInvalidOverrideGroup := AlertProvider{ + DefaultConfig: Config{ + APIKey: "SG.test", + From: "from@example.com", + To: "to@example.com", + }, + Overrides: []Override{ + { + Config: Config{To: "to@example.com"}, + Group: "", + }, + }, + } + if err := providerWithInvalidOverrideGroup.Validate(); err == nil { + t.Error("provider with empty Group should not have been valid") + } + if err := providerWithInvalidOverrideGroup.Validate(); err != ErrDuplicateGroupOverride { + t.Error("provider with empty Group should return ErrDuplicateGroupOverride") + } + providerWithDuplicateOverrideGroups := AlertProvider{ + DefaultConfig: Config{ + APIKey: "SG.test", + From: "from@example.com", + To: "to@example.com", + }, + Overrides: []Override{ + { + Config: Config{To: "to1@example.com"}, + Group: "group", + }, + { + Config: Config{To: "to2@example.com"}, + Group: "group", + }, + }, + } + if err := providerWithDuplicateOverrideGroups.Validate(); err == nil { + t.Error("provider with duplicate group overrides should not have been valid") + } + if err := providerWithDuplicateOverrideGroups.Validate(); err != ErrDuplicateGroupOverride { + t.Error("provider with duplicate group overrides should return ErrDuplicateGroupOverride") + } + providerWithValidOverride := AlertProvider{ + DefaultConfig: Config{ + APIKey: "SG.test", + From: "from@example.com", + To: "to@example.com", + }, + Overrides: []Override{ + { + Config: Config{To: "to@example.com"}, + Group: "group", + }, + }, + } + if err := providerWithValidOverride.Validate(); err != nil { + t.Error("provider should've been valid") + } + providerWithValidMultipleOverrides := AlertProvider{ + DefaultConfig: Config{ + APIKey: "SG.test", + From: "from@example.com", + To: "to@example.com", + }, + Overrides: []Override{ + { + Config: Config{To: "group1@example.com"}, + Group: "group1", + }, + { + Config: Config{To: "group2@example.com"}, + Group: "group2", + }, + }, + } + if err := providerWithValidMultipleOverrides.Validate(); err != nil { + t.Error("provider with multiple valid overrides should've been valid") + } +} + +func TestAlertProvider_Send(t *testing.T) { + defer client.InjectHTTPClient(nil) + firstDescription := "description-1" + secondDescription := "description-2" + scenarios := []struct { + Name string + Provider AlertProvider + Alert alert.Alert + Resolved bool + MockRoundTripper test.MockRoundTripper + ExpectedError bool + }{ + { + Name: "triggered", + Provider: AlertProvider{DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"}}, + Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody} + }), + ExpectedError: false, + }, + { + Name: "triggered-error", + Provider: AlertProvider{DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"}}, + Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + return &http.Response{StatusCode: http.StatusBadRequest, Body: io.NopCloser(strings.NewReader(`{"errors": [{"message": "Invalid API key"}]}`))} + }), + ExpectedError: true, + }, + { + Name: "resolved", + Provider: AlertProvider{DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"}}, + 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.StatusAccepted, Body: http.NoBody} + }), + ExpectedError: false, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) + err := scenario.Provider.Send( + &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_buildSendGridPayload(t *testing.T) { + provider := &AlertProvider{} + cfg := &Config{ + From: "test@example.com", + To: "to1@example.com,to2@example.com", + } + subject := "Test Subject" + body := "Test Body\nWith new line" + payload := provider.buildSendGridPayload(cfg, subject, body) + if payload.Subject != subject { + t.Errorf("expected subject to be %s, got %s", subject, payload.Subject) + } + if payload.From.Email != cfg.From { + t.Errorf("expected from email to be %s, got %s", cfg.From, payload.From.Email) + } + if len(payload.Personalizations) != 1 { + t.Errorf("expected 1 personalization, got %d", len(payload.Personalizations)) + } + if len(payload.Personalizations[0].To) != 2 { + t.Errorf("expected 2 recipients, got %d", len(payload.Personalizations[0].To)) + } + if payload.Personalizations[0].To[0].Email != "to1@example.com" { + t.Errorf("expected first recipient to be to1@example.com, got %s", payload.Personalizations[0].To[0].Email) + } + if payload.Personalizations[0].To[1].Email != "to2@example.com" { + t.Errorf("expected second recipient to be to2@example.com, got %s", payload.Personalizations[0].To[1].Email) + } + if len(payload.Content) != 2 { + t.Errorf("expected 2 content types, got %d", len(payload.Content)) + } + if payload.Content[0].Type != "text/plain" { + t.Errorf("expected first content type to be text/plain, got %s", payload.Content[0].Type) + } + if payload.Content[0].Value != body { + t.Errorf("expected plain text content to be %s, got %s", body, payload.Content[0].Value) + } + if payload.Content[1].Type != "text/html" { + t.Errorf("expected second content type to be text/html, got %s", payload.Content[1].Type) + } + expectedHTML := "Test Body
With new line" + if payload.Content[1].Value != expectedHTML { + t.Errorf("expected HTML content to be %s, got %s", expectedHTML, payload.Content[1].Value) + } +} + +func TestAlertProvider_buildMessageSubjectAndBody(t *testing.T) { + firstDescription := "description-1" + secondDescription := "description-2" + scenarios := []struct { + Name string + Provider AlertProvider + Alert alert.Alert + Resolved bool + Endpoint *endpoint.Endpoint + ExpectedSubject string + ExpectedBody string + }{ + { + Name: "triggered", + Provider: AlertProvider{}, + Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + Endpoint: &endpoint.Endpoint{Name: "endpoint-name"}, + ExpectedSubject: "[endpoint-name] Alert triggered", + ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n", + }, + { + Name: "resolved", + Provider: AlertProvider{}, + Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: true, + Endpoint: &endpoint.Endpoint{Name: "endpoint-name"}, + ExpectedSubject: "[endpoint-name] Alert resolved", + ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n", + }, + { + Name: "triggered-with-single-extra-label", + Provider: AlertProvider{}, + Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{"environment": "production"}}, + ExpectedSubject: "[endpoint-name] Alert triggered", + ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nExtra labels:\n environment: production\n\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n", + }, + { + Name: "resolved-with-single-extra-label", + Provider: AlertProvider{}, + Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: true, + Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{"service": "api"}}, + ExpectedSubject: "[endpoint-name] Alert resolved", + ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nExtra labels:\n service: api\n\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n", + }, + { + Name: "triggered-with-no-extra-labels", + Provider: AlertProvider{}, + Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{}}, + ExpectedSubject: "[endpoint-name] Alert triggered", + ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n", + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + subject, body := scenario.Provider.buildMessageSubjectAndBody( + scenario.Endpoint, + &scenario.Alert, + &endpoint.Result{ + ConditionResults: []*endpoint.ConditionResult{ + {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, + {Condition: "[STATUS] == 200", Success: scenario.Resolved}, + }, + }, + scenario.Resolved, + ) + if subject != scenario.ExpectedSubject { + t.Errorf("expected subject to be %s, got %s", scenario.ExpectedSubject, subject) + } + if body != scenario.ExpectedBody { + t.Errorf("expected body to be %s, got %s", scenario.ExpectedBody, body) + } + }) + } +} + +func TestAlertProvider_GetDefaultAlert(t *testing.T) { + if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil { + t.Error("expected default alert to be not nil") + } + if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil { + t.Error("expected default alert to be nil") + } +} + +func TestAlertProvider_GetConfig(t *testing.T) { + scenarios := []struct { + Name string + Provider AlertProvider + InputGroup string + InputAlert alert.Alert + ExpectedOutput Config + }{ + { + Name: "provider-no-override-specify-no-group-should-default", + Provider: AlertProvider{ + DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"}, + Overrides: nil, + }, + InputGroup: "", + InputAlert: alert.Alert{}, + ExpectedOutput: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"}, + }, + { + Name: "provider-no-override-specify-group-should-default", + Provider: AlertProvider{ + DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"}, + Overrides: nil, + }, + InputGroup: "group", + InputAlert: alert.Alert{}, + ExpectedOutput: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"}, + }, + { + Name: "provider-with-override-specify-no-group-should-default", + Provider: AlertProvider{ + DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"}, + Overrides: []Override{ + { + Group: "group", + Config: Config{To: "to01@example.com"}, + }, + }, + }, + InputGroup: "", + InputAlert: alert.Alert{}, + ExpectedOutput: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"}, + }, + { + Name: "provider-with-override-specify-group-should-override", + Provider: AlertProvider{ + DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"}, + Overrides: []Override{ + { + Group: "group", + Config: Config{To: "group-to@example.com"}, + }, + }, + }, + InputGroup: "group", + InputAlert: alert.Alert{}, + ExpectedOutput: Config{APIKey: "SG.test", From: "from@example.com", To: "group-to@example.com"}, + }, + { + Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence", + Provider: AlertProvider{ + DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"}, + Overrides: []Override{ + { + Group: "group", + Config: Config{To: "group-to@example.com"}, + }, + }, + }, + InputGroup: "group", + InputAlert: alert.Alert{ProviderOverride: map[string]any{"api-key": "SG.override", "to": "alert-to@example.com", "from": "alert-from@example.com"}}, + ExpectedOutput: Config{APIKey: "SG.override", From: "alert-from@example.com", To: "alert-to@example.com"}, + }, + { + Name: "provider-with-multiple-overrides-pick-correct-group", + Provider: AlertProvider{ + DefaultConfig: Config{APIKey: "SG.default", From: "default@example.com", To: "default@example.com"}, + Overrides: []Override{ + { + Group: "group1", + Config: Config{APIKey: "SG.group1", To: "group1@example.com"}, + }, + { + Group: "group2", + Config: Config{APIKey: "SG.group2", From: "group2@example.com"}, + }, + }, + }, + InputGroup: "group2", + InputAlert: alert.Alert{}, + ExpectedOutput: Config{APIKey: "SG.group2", From: "group2@example.com", To: "default@example.com"}, + }, + { + Name: "provider-partial-override-hierarchy", + Provider: AlertProvider{ + DefaultConfig: Config{APIKey: "SG.default", From: "default@example.com", To: "default@example.com"}, + Overrides: []Override{ + { + Group: "test-group", + Config: Config{From: "group@example.com"}, + }, + }, + }, + InputGroup: "test-group", + InputAlert: alert.Alert{ProviderOverride: map[string]any{"to": "alert@example.com"}}, + ExpectedOutput: Config{APIKey: "SG.default", From: "group@example.com", To: "alert@example.com"}, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if got.APIKey != scenario.ExpectedOutput.APIKey { + t.Errorf("expected APIKey to be %s, got %s", scenario.ExpectedOutput.APIKey, got.APIKey) + } + if got.From != scenario.ExpectedOutput.From { + t.Errorf("expected From to be %s, got %s", scenario.ExpectedOutput.From, got.From) + } + if got.To != scenario.ExpectedOutput.To { + t.Errorf("expected To to be %s, got %s", scenario.ExpectedOutput.To, got.To) + } + // Test ValidateOverrides as well, since it really just calls GetConfig + if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil { + t.Errorf("unexpected error: %s", err) + } + }) + } +} + +func TestConfig_Validate(t *testing.T) { + scenarios := []struct { + Name string + Config Config + ExpectedError error + }{ + { + Name: "missing-api-key", + Config: Config{APIKey: "", From: "test@example.com", To: "to@example.com"}, + ExpectedError: ErrAPIKeyNotSet, + }, + { + Name: "missing-from", + Config: Config{APIKey: "SG.test", From: "", To: "to@example.com"}, + ExpectedError: ErrFromNotSet, + }, + { + Name: "missing-to", + Config: Config{APIKey: "SG.test", From: "test@example.com", To: ""}, + ExpectedError: ErrToNotSet, + }, + { + Name: "valid-config", + Config: Config{APIKey: "SG.test", From: "test@example.com", To: "to@example.com"}, + ExpectedError: nil, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + err := scenario.Config.Validate() + if scenario.ExpectedError == nil && err != nil { + t.Errorf("expected no error, got %v", err) + } + if scenario.ExpectedError != nil && err == nil { + t.Errorf("expected error %v, got none", scenario.ExpectedError) + } + if scenario.ExpectedError != nil && err != nil && err.Error() != scenario.ExpectedError.Error() { + t.Errorf("expected error %v, got %v", scenario.ExpectedError, err) + } + }) + } +} + +func TestConfig_Merge(t *testing.T) { + config := Config{APIKey: "SG.original", From: "from@example.com", To: "to@example.com"} + override := Config{APIKey: "SG.override", To: "override@example.com"} + config.Merge(&override) + if config.APIKey != "SG.override" { + t.Errorf("expected APIKey to be SG.override, got %s", config.APIKey) + } + if config.From != "from@example.com" { + t.Errorf("expected From to remain from@example.com, got %s", config.From) + } + if config.To != "override@example.com" { + t.Errorf("expected To to be override@example.com, got %s", config.To) + } +} + +func TestConfig_MergeWithClientConfig(t *testing.T) { + config := Config{APIKey: "SG.original", From: "from@example.com", To: "to@example.com"} + override := Config{APIKey: "SG.override", ClientConfig: &client.Config{Timeout: 30000}} + config.Merge(&override) + if config.APIKey != "SG.override" { + t.Errorf("expected APIKey to be SG.override, got %s", config.APIKey) + } + if config.ClientConfig == nil { + t.Error("expected ClientConfig to be set") + } + if config.ClientConfig.Timeout != 30000 { + t.Errorf("expected ClientConfig.Timeout to be 30000, got %d", config.ClientConfig.Timeout) + } + config2 := Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com", ClientConfig: &client.Config{Timeout: 10000}} + override2 := Config{APIKey: "SG.override2"} + config2.Merge(&override2) + if config2.ClientConfig.Timeout != 10000 { + t.Errorf("expected ClientConfig.Timeout to remain 10000, got %d", config2.ClientConfig.Timeout) + } +} \ No newline at end of file diff --git a/alerting/provider/signal/signal.go b/alerting/provider/signal/signal.go new file mode 100644 index 00000000..2215e6ad --- /dev/null +++ b/alerting/provider/signal/signal.go @@ -0,0 +1,192 @@ +package signal + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/client" + "github.com/TwiN/gatus/v5/config/endpoint" + "gopkg.in/yaml.v3" +) + +var ( + ErrApiURLNotSet = errors.New("api-url not set") + ErrNumberNotSet = errors.New("number not set") + ErrRecipientsNotSet = errors.New("recipients not set") + ErrDuplicateGroupOverride = errors.New("duplicate group override") +) + +type Config struct { + ApiURL string `yaml:"api-url"` // Signal API URL (e.g., signal-cli-rest-api instance) + Number string `yaml:"number"` // Sender phone number + Recipients []string `yaml:"recipients"` // List of recipient phone numbers +} + +func (cfg *Config) Validate() error { + if len(cfg.ApiURL) == 0 { + return ErrApiURLNotSet + } + if len(cfg.Number) == 0 { + return ErrNumberNotSet + } + if len(cfg.Recipients) == 0 { + return ErrRecipientsNotSet + } + return nil +} + +func (cfg *Config) Merge(override *Config) { + if len(override.ApiURL) > 0 { + cfg.ApiURL = override.ApiURL + } + if len(override.Number) > 0 { + cfg.Number = override.Number + } + if len(override.Recipients) > 0 { + cfg.Recipients = override.Recipients + } +} + +// AlertProvider is the configuration necessary for sending an alert using Signal +type AlertProvider struct { + DefaultConfig Config `yaml:",inline"` + + // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type + DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` + + // Overrides is a list of Override that may be prioritized over the default configuration + Overrides []Override `yaml:"overrides,omitempty"` +} + +// Override is a case under which the default integration is overridden +type Override struct { + Group string `yaml:"group"` + Config `yaml:",inline"` +} + +// Validate the provider's configuration +func (provider *AlertProvider) Validate() error { + registeredGroups := make(map[string]bool) + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" { + return ErrDuplicateGroupOverride + } + registeredGroups[override.Group] = true + } + } + return provider.DefaultConfig.Validate() +} + +// Send an alert using the provider +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { + cfg, err := provider.GetConfig(ep.Group, alert) + if err != nil { + return err + } + for _, recipient := range cfg.Recipients { + body, err := provider.buildRequestBody(cfg, ep, alert, result, resolved, recipient) + if err != nil { + return err + } + buffer := bytes.NewBuffer(body) + request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/v2/send", cfg.ApiURL), 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 + } + if response.StatusCode >= 400 { + body, _ := io.ReadAll(response.Body) + response.Body.Close() + return fmt.Errorf("call to signal alert returned status code %d: %s", response.StatusCode, string(body)) + } + response.Body.Close() + } + return nil +} + +type Body struct { + Message string `json:"message"` + Number string `json:"number"` + Recipients []string `json:"recipients"` +} + +// buildRequestBody builds the request body for the provider +func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool, recipient string) ([]byte, error) { + var message string + if resolved { + message = fmt.Sprintf("🟢 RESOLVED: %s\nAlert has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold) + } else { + message = fmt.Sprintf("🔴 ALERT: %s\nEndpoint has failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold) + } + if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { + message += fmt.Sprintf("\n\nDescription: %s", alertDescription) + } + if len(result.ConditionResults) > 0 { + message += "\n\nCondition results:" + for _, conditionResult := range result.ConditionResults { + var status string + if conditionResult.Success { + status = "✅" + } else { + status = "❌" + } + message += fmt.Sprintf("\n%s %s", status, conditionResult.Condition) + } + } + body := Body{ + Message: message, + Number: cfg.Number, + Recipients: []string{recipient}, + } + bodyAsJSON, err := json.Marshal(body) + if err != nil { + return nil, err + } + return bodyAsJSON, nil +} + +// GetDefaultAlert returns the provider's default alert configuration +func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { + return provider.DefaultAlert +} + +// GetConfig returns the configuration for the provider with the overrides applied +func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) { + cfg := provider.DefaultConfig + // Handle group overrides + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if group == override.Group { + cfg.Merge(&override.Config) + break + } + } + } + // Handle alert overrides + if len(alert.ProviderOverride) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) + } + // Validate the configuration + err := cfg.Validate() + return &cfg, err +} + +// ValidateOverrides validates the alert's provider override and, if present, the group override +func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error { + _, err := provider.GetConfig(group, alert) + return err +} diff --git a/alerting/provider/signal/signal_test.go b/alerting/provider/signal/signal_test.go new file mode 100644 index 00000000..88c9991a --- /dev/null +++ b/alerting/provider/signal/signal_test.go @@ -0,0 +1,151 @@ +package signal + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/client" + "github.com/TwiN/gatus/v5/config/endpoint" + "github.com/TwiN/gatus/v5/test" +) + +func TestAlertProvider_Validate(t *testing.T) { + scenarios := []struct { + name string + provider AlertProvider + expected error + }{ + { + name: "valid", + provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Number: "+1234567890", Recipients: []string{"+0987654321"}}}, + expected: nil, + }, + { + name: "invalid-api-url", + provider: AlertProvider{DefaultConfig: Config{Number: "+1234567890", Recipients: []string{"+0987654321"}}}, + expected: ErrApiURLNotSet, + }, + { + name: "invalid-number", + provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Recipients: []string{"+0987654321"}}}, + expected: ErrNumberNotSet, + }, + { + name: "invalid-recipients", + provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Number: "+1234567890"}}, + expected: ErrRecipientsNotSet, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + err := scenario.provider.Validate() + if err != scenario.expected { + t.Errorf("expected %v, got %v", scenario.expected, err) + } + }) + } +} + +func TestAlertProvider_Send(t *testing.T) { + defer client.InjectHTTPClient(nil) + firstDescription := "description-1" + secondDescription := "description-2" + scenarios := []struct { + name string + provider AlertProvider + alert alert.Alert + resolved bool + mockRoundTripper test.MockRoundTripper + expectedError bool + }{ + { + name: "triggered", + provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Number: "+1234567890", Recipients: []string{"+0987654321", "+1111111111"}}}, + alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + resolved: false, + mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + if r.URL.Path != "/v2/send" { + t.Errorf("expected path /v2/send, got %s", r.URL.Path) + } + body := make(map[string]interface{}) + json.NewDecoder(r.Body).Decode(&body) + if body["number"] != "+1234567890" { + t.Errorf("expected number to be '+1234567890', got %v", body["number"]) + } + recipients := body["recipients"].([]interface{}) + if len(recipients) != 1 { + t.Errorf("expected 1 recipient per request, got %d", len(recipients)) + } + message := body["message"].(string) + if !strings.Contains(message, "ALERT") { + t.Errorf("expected message to contain 'ALERT', got %s", message) + } + if !strings.Contains(message, "failed 3 time(s)") { + t.Errorf("expected message to contain failure count, got %s", message) + } + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody} + }), + expectedError: false, + }, + { + name: "resolved", + provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Number: "+1234567890", Recipients: []string{"+0987654321"}}}, + alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + resolved: true, + mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + body := make(map[string]interface{}) + json.NewDecoder(r.Body).Decode(&body) + message := body["message"].(string) + if !strings.Contains(message, "RESOLVED") { + t.Errorf("expected message to contain 'RESOLVED', got %s", message) + } + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody} + }), + expectedError: false, + }, + { + name: "error-response", + provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Number: "+1234567890", Recipients: []string{"+0987654321"}}}, + 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, + }, + } + 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_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") + } +} diff --git a/alerting/provider/signl4/signl4.go b/alerting/provider/signl4/signl4.go new file mode 100644 index 00000000..e59676fa --- /dev/null +++ b/alerting/provider/signl4/signl4.go @@ -0,0 +1,184 @@ +package signl4 + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/client" + "github.com/TwiN/gatus/v5/config/endpoint" + "gopkg.in/yaml.v3" +) + +var ( + ErrTeamSecretNotSet = errors.New("team-secret not set") + ErrDuplicateGroupOverride = errors.New("duplicate group override") +) + +type Config struct { + TeamSecret string `yaml:"team-secret"` // SIGNL4 team secret +} + +func (cfg *Config) Validate() error { + if len(cfg.TeamSecret) == 0 { + return ErrTeamSecretNotSet + } + return nil +} + +func (cfg *Config) Merge(override *Config) { + if len(override.TeamSecret) > 0 { + cfg.TeamSecret = override.TeamSecret + } +} + +// AlertProvider is the configuration necessary for sending an alert using SIGNL4 +type AlertProvider struct { + DefaultConfig Config `yaml:",inline"` + + // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type + DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` + + // Overrides is a list of Override that may be prioritized over the default configuration + Overrides []Override `yaml:"overrides,omitempty"` +} + +// Override is a case under which the default integration is overridden +type Override struct { + Group string `yaml:"group"` + Config `yaml:",inline"` +} + +// Validate the provider's configuration +func (provider *AlertProvider) Validate() error { + registeredGroups := make(map[string]bool) + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" { + return ErrDuplicateGroupOverride + } + registeredGroups[override.Group] = true + } + } + return provider.DefaultConfig.Validate() +} + +// Send an alert using the provider +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { + cfg, err := provider.GetConfig(ep.Group, alert) + if err != nil { + return err + } + body, err := provider.buildRequestBody(ep, alert, result, resolved) + if err != nil { + return err + } + buffer := bytes.NewBuffer(body) + webhookURL := fmt.Sprintf("https://connect.signl4.com/webhook/%s", cfg.TeamSecret) + request, err := http.NewRequest(http.MethodPost, webhookURL, 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 >= 400 { + body, _ := io.ReadAll(response.Body) + return fmt.Errorf("call to signl4 alert returned status code %d: %s", response.StatusCode, string(body)) + } + return nil +} + +type Body struct { + Title string `json:"Title"` + Message string `json:"Message"` + XS4Service string `json:"X-S4-Service"` + XS4Status string `json:"X-S4-Status"` + XS4ExternalID string `json:"X-S4-ExternalID"` +} + +// buildRequestBody builds the request body for the provider +func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) { + var title, message, status string + if resolved { + title = fmt.Sprintf("RESOLVED: %s", ep.DisplayName()) + message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold) + status = "resolved" + } else { + title = fmt.Sprintf("TRIGGERED: %s", ep.DisplayName()) + message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold) + status = "new" + } + var conditionResults string + if len(result.ConditionResults) > 0 { + conditionResults = "\n\nCondition results:\n" + for _, conditionResult := range result.ConditionResults { + var prefix string + if conditionResult.Success { + prefix = "✓" + } else { + prefix = "✗" + } + conditionResults += fmt.Sprintf("%s %s\n", prefix, conditionResult.Condition) + } + } + if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { + message += "\n\nDescription: " + alertDescription + } + message += conditionResults + body := Body{ + Title: title, + Message: message, + XS4Service: ep.DisplayName(), + XS4Status: status, + XS4ExternalID: fmt.Sprintf("gatus-%s", ep.Key()), + } + bodyAsJSON, err := json.Marshal(body) + if err != nil { + return nil, err + } + return bodyAsJSON, nil +} + +// GetDefaultAlert returns the provider's default alert configuration +func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { + return provider.DefaultAlert +} + +// GetConfig returns the configuration for the provider with the overrides applied +func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) { + cfg := provider.DefaultConfig + // Handle group overrides + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if group == override.Group { + cfg.Merge(&override.Config) + break + } + } + } + // Handle alert overrides + if len(alert.ProviderOverride) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) + } + // Validate the configuration + err := cfg.Validate() + return &cfg, err +} + +// ValidateOverrides validates the alert's provider override and, if present, the group override +func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error { + _, err := provider.GetConfig(group, alert) + return err +} \ No newline at end of file diff --git a/alerting/provider/signl4/signl4_test.go b/alerting/provider/signl4/signl4_test.go new file mode 100644 index 00000000..9a0f22da --- /dev/null +++ b/alerting/provider/signl4/signl4_test.go @@ -0,0 +1,392 @@ +package signl4 + +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 TestAlertProvider_Validate(t *testing.T) { + invalidProvider := AlertProvider{DefaultConfig: Config{TeamSecret: ""}} + if err := invalidProvider.Validate(); err == nil { + t.Error("provider shouldn't have been valid") + } + validProvider := AlertProvider{DefaultConfig: Config{TeamSecret: "team-secret-123"}} + if err := validProvider.Validate(); err != nil { + t.Error("provider should've been valid") + } +} + +func TestAlertProvider_ValidateWithOverride(t *testing.T) { + providerWithInvalidOverrideGroup := AlertProvider{ + Overrides: []Override{ + { + Config: Config{TeamSecret: "team-secret-123"}, + Group: "", + }, + }, + } + if err := providerWithInvalidOverrideGroup.Validate(); err == nil { + t.Error("provider Group shouldn't have been valid") + } + providerWithInvalidOverrideTo := AlertProvider{ + Overrides: []Override{ + { + Config: Config{TeamSecret: ""}, + Group: "group", + }, + }, + } + if err := providerWithInvalidOverrideTo.Validate(); err == nil { + t.Error("provider team secret shouldn't have been valid") + } + providerWithValidOverride := AlertProvider{ + DefaultConfig: Config{TeamSecret: "team-secret-123"}, + Overrides: []Override{ + { + Config: Config{TeamSecret: "team-secret-override"}, + Group: "group", + }, + }, + } + if err := providerWithValidOverride.Validate(); err != nil { + t.Error("provider should've been valid") + } +} + +func TestAlertProvider_Send(t *testing.T) { + defer client.InjectHTTPClient(nil) + firstDescription := "description-1" + secondDescription := "description-2" + scenarios := []struct { + Name string + Provider AlertProvider + Alert alert.Alert + Resolved bool + MockRoundTripper test.MockRoundTripper + ExpectedError bool + }{ + { + Name: "triggered", + Provider: AlertProvider{DefaultConfig: Config{TeamSecret: "team-secret-123"}}, + Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody} + }), + ExpectedError: false, + }, + { + Name: "triggered-error", + Provider: AlertProvider{DefaultConfig: Config{TeamSecret: "team-secret-123"}}, + Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody} + }), + ExpectedError: true, + }, + { + Name: "resolved", + Provider: AlertProvider{DefaultConfig: Config{TeamSecret: "team-secret-123"}}, + Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: true, + MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody} + }), + ExpectedError: false, + }, + { + Name: "resolved-error", + Provider: AlertProvider{DefaultConfig: Config{TeamSecret: "team-secret-123"}}, + Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: true, + MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody} + }), + ExpectedError: true, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) + err := scenario.Provider.Send( + &endpoint.Endpoint{Name: "endpoint-name"}, + &scenario.Alert, + &endpoint.Result{ + ConditionResults: []*endpoint.ConditionResult{ + {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, + {Condition: "[STATUS] == 200", Success: scenario.Resolved}, + }, + }, + scenario.Resolved, + ) + if scenario.ExpectedError && err == nil { + t.Error("expected error, got none") + } + if !scenario.ExpectedError && err != nil { + t.Error("expected no error, got", err.Error()) + } + }) + } +} + +func TestAlertProvider_buildRequestBody(t *testing.T) { + firstDescription := "description-1" + secondDescription := "description-2" + scenarios := []struct { + Name string + Provider AlertProvider + Endpoint endpoint.Endpoint + Alert alert.Alert + NoConditions bool + Resolved bool + ExpectedBody string + }{ + { + Name: "triggered", + Provider: AlertProvider{}, + Endpoint: endpoint.Endpoint{Name: "name"}, + Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + ExpectedBody: "{\"Title\":\"TRIGGERED: name\",\"Message\":\"An alert for name has been triggered due to having failed 3 time(s) in a row\\n\\nDescription: description-1\\n\\nCondition results:\\n✗ [CONNECTED] == true\\n✗ [STATUS] == 200\\n\",\"X-S4-Service\":\"name\",\"X-S4-Status\":\"new\",\"X-S4-ExternalID\":\"gatus-_name\"}", + }, + { + Name: "triggered-with-group", + Provider: AlertProvider{}, + Endpoint: endpoint.Endpoint{Name: "name", Group: "group"}, + Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + ExpectedBody: "{\"Title\":\"TRIGGERED: group/name\",\"Message\":\"An alert for group/name has been triggered due to having failed 3 time(s) in a row\\n\\nDescription: description-1\\n\\nCondition results:\\n✗ [CONNECTED] == true\\n✗ [STATUS] == 200\\n\",\"X-S4-Service\":\"group/name\",\"X-S4-Status\":\"new\",\"X-S4-ExternalID\":\"gatus-group_name\"}", + }, + { + Name: "triggered-with-no-conditions", + NoConditions: true, + Provider: AlertProvider{}, + Endpoint: endpoint.Endpoint{Name: "name"}, + Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + ExpectedBody: "{\"Title\":\"TRIGGERED: name\",\"Message\":\"An alert for name has been triggered due to having failed 3 time(s) in a row\\n\\nDescription: description-1\",\"X-S4-Service\":\"name\",\"X-S4-Status\":\"new\",\"X-S4-ExternalID\":\"gatus-_name\"}", + }, + { + Name: "resolved", + Provider: AlertProvider{}, + Endpoint: endpoint.Endpoint{Name: "name"}, + Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: true, + ExpectedBody: "{\"Title\":\"RESOLVED: name\",\"Message\":\"An alert for name has been resolved after passing successfully 5 time(s) in a row\\n\\nDescription: description-2\\n\\nCondition results:\\n✓ [CONNECTED] == true\\n✓ [STATUS] == 200\\n\",\"X-S4-Service\":\"name\",\"X-S4-Status\":\"resolved\",\"X-S4-ExternalID\":\"gatus-_name\"}", + }, + { + Name: "resolved-with-group", + Provider: AlertProvider{}, + Endpoint: endpoint.Endpoint{Name: "name", Group: "group"}, + Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: true, + ExpectedBody: "{\"Title\":\"RESOLVED: group/name\",\"Message\":\"An alert for group/name has been resolved after passing successfully 5 time(s) in a row\\n\\nDescription: description-2\\n\\nCondition results:\\n✓ [CONNECTED] == true\\n✓ [STATUS] == 200\\n\",\"X-S4-Service\":\"group/name\",\"X-S4-Status\":\"resolved\",\"X-S4-ExternalID\":\"gatus-group_name\"}", + }, + } + 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, err := scenario.Provider.buildRequestBody( + &scenario.Endpoint, + &scenario.Alert, + &endpoint.Result{ + ConditionResults: conditionResults, + }, + scenario.Resolved, + ) + if err != nil { + t.Fatalf("buildRequestBody returned an error: %v", err) + } + if string(body) != scenario.ExpectedBody { + t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body) + } + out := make(map[string]interface{}) + if err := json.Unmarshal(body, &out); err != nil { + t.Error("expected body to be valid JSON, got error:", err.Error()) + } + }) + } +} + +func TestAlertProvider_GetDefaultAlert(t *testing.T) { + if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil { + t.Error("expected default alert to be not nil") + } + if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil { + t.Error("expected default alert to be nil") + } +} + +func TestAlertProvider_GetConfig(t *testing.T) { + scenarios := []struct { + Name string + Provider AlertProvider + InputGroup string + InputAlert alert.Alert + ExpectedOutput Config + }{ + { + Name: "provider-no-override-specify-no-group-should-default", + Provider: AlertProvider{ + DefaultConfig: Config{TeamSecret: "team-secret-123"}, + Overrides: nil, + }, + InputGroup: "", + InputAlert: alert.Alert{}, + ExpectedOutput: Config{TeamSecret: "team-secret-123"}, + }, + { + Name: "provider-no-override-specify-group-should-default", + Provider: AlertProvider{ + DefaultConfig: Config{TeamSecret: "team-secret-123"}, + Overrides: nil, + }, + InputGroup: "group", + InputAlert: alert.Alert{}, + ExpectedOutput: Config{TeamSecret: "team-secret-123"}, + }, + { + Name: "provider-with-override-specify-no-group-should-default", + Provider: AlertProvider{ + DefaultConfig: Config{TeamSecret: "team-secret-123"}, + Overrides: []Override{ + { + Group: "group", + Config: Config{TeamSecret: "team-secret-override"}, + }, + }, + }, + InputGroup: "", + InputAlert: alert.Alert{}, + ExpectedOutput: Config{TeamSecret: "team-secret-123"}, + }, + { + Name: "provider-with-override-specify-group-should-override", + Provider: AlertProvider{ + DefaultConfig: Config{TeamSecret: "team-secret-123"}, + Overrides: []Override{ + { + Group: "group", + Config: Config{TeamSecret: "team-secret-override"}, + }, + }, + }, + InputGroup: "group", + InputAlert: alert.Alert{}, + ExpectedOutput: Config{TeamSecret: "team-secret-override"}, + }, + { + Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence", + Provider: AlertProvider{ + DefaultConfig: Config{TeamSecret: "team-secret-123"}, + Overrides: []Override{ + { + Group: "group", + Config: Config{TeamSecret: "team-secret-override"}, + }, + }, + }, + InputGroup: "group", + InputAlert: alert.Alert{ProviderOverride: map[string]any{"team-secret": "team-secret-alert"}}, + ExpectedOutput: Config{TeamSecret: "team-secret-alert"}, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if got.TeamSecret != scenario.ExpectedOutput.TeamSecret { + t.Errorf("expected team secret to be %s, got %s", scenario.ExpectedOutput.TeamSecret, got.TeamSecret) + } + // Test ValidateOverrides as well, since it really just calls GetConfig + if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil { + t.Errorf("unexpected error: %s", err) + } + }) + } +} + +func TestAlertProvider_GetConfigWithInvalidAlertOverride(t *testing.T) { + // Test case 1: Empty override should be ignored, default config should be used + provider := AlertProvider{ + DefaultConfig: Config{TeamSecret: "team-secret-123"}, + } + alertWithEmptyOverride := alert.Alert{ + ProviderOverride: map[string]any{"team-secret": ""}, + } + cfg, err := provider.GetConfig("", &alertWithEmptyOverride) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if cfg.TeamSecret != "team-secret-123" { + t.Errorf("expected team secret to remain default 'team-secret-123', got %s", cfg.TeamSecret) + } + + // Test case 2: Invalid default config with no valid override should fail + providerWithInvalidDefault := AlertProvider{ + DefaultConfig: Config{TeamSecret: ""}, + } + alertWithEmptyOverride2 := alert.Alert{ + ProviderOverride: map[string]any{"team-secret": ""}, + } + _, err = providerWithInvalidDefault.GetConfig("", &alertWithEmptyOverride2) + if err == nil { + t.Error("expected error due to invalid default config, got none") + } + if err != ErrTeamSecretNotSet { + t.Errorf("expected ErrTeamSecretNotSet, got %v", err) + } +} + +func TestAlertProvider_ValidateWithDuplicateGroupOverride(t *testing.T) { + providerWithDuplicateOverride := AlertProvider{ + DefaultConfig: Config{TeamSecret: "team-secret-123"}, + Overrides: []Override{ + { + Group: "group1", + Config: Config{TeamSecret: "team-secret-override-1"}, + }, + { + Group: "group1", + Config: Config{TeamSecret: "team-secret-override-2"}, + }, + }, + } + if err := providerWithDuplicateOverride.Validate(); err == nil { + t.Error("provider should not have been valid due to duplicate group override") + } + if err := providerWithDuplicateOverride.Validate(); err != ErrDuplicateGroupOverride { + t.Errorf("expected ErrDuplicateGroupOverride, got %v", providerWithDuplicateOverride.Validate()) + } +} + +func TestAlertProvider_ValidateOverridesWithInvalidAlert(t *testing.T) { + provider := AlertProvider{ + DefaultConfig: Config{TeamSecret: ""}, + } + alertWithEmptyOverride := alert.Alert{ + ProviderOverride: map[string]any{"team-secret": ""}, + } + err := provider.ValidateOverrides("", &alertWithEmptyOverride) + if err == nil { + t.Error("expected error due to invalid default config, got none") + } + if err != ErrTeamSecretNotSet { + t.Errorf("expected ErrTeamSecretNotSet, got %v", err) + } +} diff --git a/alerting/provider/splunk/splunk.go b/alerting/provider/splunk/splunk.go new file mode 100644 index 00000000..83c94032 --- /dev/null +++ b/alerting/provider/splunk/splunk.go @@ -0,0 +1,220 @@ +package splunk + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/client" + "github.com/TwiN/gatus/v5/config/endpoint" + "gopkg.in/yaml.v3" +) + +var ( + ErrHecURLNotSet = errors.New("hec-url not set") + ErrHecTokenNotSet = errors.New("hec-token not set") + ErrDuplicateGroupOverride = errors.New("duplicate group override") +) + +type Config struct { + HecURL string `yaml:"hec-url"` // Splunk HEC (HTTP Event Collector) URL + HecToken string `yaml:"hec-token"` // Splunk HEC token + Source string `yaml:"source,omitempty"` // Event source + SourceType string `yaml:"sourcetype,omitempty"` // Event source type + Index string `yaml:"index,omitempty"` // Splunk index +} + +func (cfg *Config) Validate() error { + if len(cfg.HecURL) == 0 { + return ErrHecURLNotSet + } + if len(cfg.HecToken) == 0 { + return ErrHecTokenNotSet + } + return nil +} + +func (cfg *Config) Merge(override *Config) { + if len(override.HecURL) > 0 { + cfg.HecURL = override.HecURL + } + if len(override.HecToken) > 0 { + cfg.HecToken = override.HecToken + } + if len(override.Source) > 0 { + cfg.Source = override.Source + } + if len(override.SourceType) > 0 { + cfg.SourceType = override.SourceType + } + if len(override.Index) > 0 { + cfg.Index = override.Index + } +} + +// AlertProvider is the configuration necessary for sending an alert using Splunk +type AlertProvider struct { + DefaultConfig Config `yaml:",inline"` + + // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type + DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` + + // Overrides is a list of Override that may be prioritized over the default configuration + Overrides []Override `yaml:"overrides,omitempty"` +} + +// Override is a case under which the default integration is overridden +type Override struct { + Group string `yaml:"group"` + Config `yaml:",inline"` +} + +// Validate the provider's configuration +func (provider *AlertProvider) Validate() error { + registeredGroups := make(map[string]bool) + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" { + return ErrDuplicateGroupOverride + } + registeredGroups[override.Group] = true + } + } + return provider.DefaultConfig.Validate() +} + +// Send an alert using the provider +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { + cfg, err := provider.GetConfig(ep.Group, alert) + if err != nil { + return err + } + body, err := provider.buildRequestBody(cfg, ep, alert, result, resolved) + if err != nil { + return err + } + buffer := bytes.NewBuffer(body) + request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/services/collector/event", cfg.HecURL), buffer) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Authorization", fmt.Sprintf("Splunk %s", cfg.HecToken)) + response, err := client.GetHTTPClient(nil).Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode >= 400 { + body, _ := io.ReadAll(response.Body) + return fmt.Errorf("call to splunk alert returned status code %d: %s", response.StatusCode, string(body)) + } + return nil +} + +type Body struct { + Time int64 `json:"time"` + Source string `json:"source,omitempty"` + SourceType string `json:"sourcetype,omitempty"` + Index string `json:"index,omitempty"` + Event Event `json:"event"` +} + +type Event struct { + AlertType string `json:"alert_type"` + Endpoint string `json:"endpoint"` + Group string `json:"group,omitempty"` + Status string `json:"status"` + Message string `json:"message"` + Description string `json:"description,omitempty"` + Conditions []*endpoint.ConditionResult `json:"conditions,omitempty"` +} + +// buildRequestBody builds the request body for the provider +func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) { + var alertType, status, message string + if resolved { + alertType = "resolved" + status = "ok" + message = fmt.Sprintf("Alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold) + } else { + alertType = "triggered" + status = "critical" + message = fmt.Sprintf("Alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold) + } + event := Event{ + AlertType: alertType, + Endpoint: ep.DisplayName(), + Group: ep.Group, + Status: status, + Message: message, + Description: alert.GetDescription(), + } + if len(result.ConditionResults) > 0 { + event.Conditions = result.ConditionResults + } + body := Body{ + Time: time.Now().Unix(), + Event: event, + } + // Set optional fields + if cfg.Source != "" { + body.Source = cfg.Source + } else { + body.Source = "gatus" + } + if cfg.SourceType != "" { + body.SourceType = cfg.SourceType + } else { + body.SourceType = "gatus:alert" + } + if cfg.Index != "" { + body.Index = cfg.Index + } + bodyAsJSON, err := json.Marshal(body) + if err != nil { + return nil, err + } + return bodyAsJSON, nil +} + +// GetDefaultAlert returns the provider's default alert configuration +func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { + return provider.DefaultAlert +} + +// GetConfig returns the configuration for the provider with the overrides applied +func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) { + cfg := provider.DefaultConfig + // Handle group overrides + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if group == override.Group { + cfg.Merge(&override.Config) + break + } + } + } + // Handle alert overrides + if len(alert.ProviderOverride) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) + } + // Validate the configuration + err := cfg.Validate() + return &cfg, err +} + +// ValidateOverrides validates the alert's provider override and, if present, the group override +func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error { + _, err := provider.GetConfig(group, alert) + return err +} diff --git a/alerting/provider/splunk/splunk_test.go b/alerting/provider/splunk/splunk_test.go new file mode 100644 index 00000000..97a44789 --- /dev/null +++ b/alerting/provider/splunk/splunk_test.go @@ -0,0 +1,155 @@ +package splunk + +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 TestAlertProvider_Validate(t *testing.T) { + scenarios := []struct { + name string + provider AlertProvider + expected error + }{ + { + name: "valid", + provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088", HecToken: "token123"}}, + expected: nil, + }, + { + name: "valid-with-index", + provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088", HecToken: "token123", Index: "main"}}, + expected: nil, + }, + { + name: "invalid-hec-url", + provider: AlertProvider{DefaultConfig: Config{HecToken: "token123"}}, + expected: ErrHecURLNotSet, + }, + { + name: "invalid-hec-token", + provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088"}}, + expected: ErrHecTokenNotSet, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + err := scenario.provider.Validate() + if err != scenario.expected { + t.Errorf("expected %v, got %v", scenario.expected, err) + } + }) + } +} + +func TestAlertProvider_Send(t *testing.T) { + defer client.InjectHTTPClient(nil) + firstDescription := "description-1" + secondDescription := "description-2" + scenarios := []struct { + name string + provider AlertProvider + alert alert.Alert + resolved bool + mockRoundTripper test.MockRoundTripper + expectedError bool + }{ + { + name: "triggered", + provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088", HecToken: "token123"}}, + alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + resolved: false, + mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + if r.URL.Path != "/services/collector/event" { + t.Errorf("expected path /services/collector/event, got %s", r.URL.Path) + } + if r.Header.Get("Authorization") != "Splunk token123" { + t.Errorf("expected Authorization header to be 'Splunk token123', got %s", r.Header.Get("Authorization")) + } + body := make(map[string]interface{}) + json.NewDecoder(r.Body).Decode(&body) + if body["time"] == nil { + t.Error("expected 'time' field in request body") + } + event := body["event"].(map[string]interface{}) + if event["alert_type"] != "triggered" { + t.Errorf("expected alert_type to be 'triggered', got %v", event["alert_type"]) + } + if event["status"] != "critical" { + t.Errorf("expected status to be 'critical', got %v", event["status"]) + } + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody} + }), + expectedError: false, + }, + { + name: "resolved", + provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088", HecToken: "token123", Index: "main"}}, + alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + resolved: true, + mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + body := make(map[string]interface{}) + json.NewDecoder(r.Body).Decode(&body) + if body["index"] != "main" { + t.Errorf("expected index to be 'main', got %v", body["index"]) + } + event := body["event"].(map[string]interface{}) + if event["alert_type"] != "resolved" { + t.Errorf("expected alert_type to be 'resolved', got %v", event["alert_type"]) + } + if event["status"] != "ok" { + t.Errorf("expected status to be 'ok', got %v", event["status"]) + } + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody} + }), + expectedError: false, + }, + { + name: "error-response", + provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088", HecToken: "token123"}}, + 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.StatusForbidden, 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_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") + } +} diff --git a/alerting/provider/squadcast/squadcast.go b/alerting/provider/squadcast/squadcast.go new file mode 100644 index 00000000..eff9f3c7 --- /dev/null +++ b/alerting/provider/squadcast/squadcast.go @@ -0,0 +1,190 @@ +package squadcast + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/client" + "github.com/TwiN/gatus/v5/config/endpoint" + "gopkg.in/yaml.v3" +) + +var ( + ErrWebhookURLNotSet = errors.New("webhook-url not set") + ErrDuplicateGroupOverride = errors.New("duplicate group override") +) + +type Config struct { + WebhookURL string `yaml:"webhook-url"` // Squadcast webhook URL +} + +func (cfg *Config) Validate() error { + if len(cfg.WebhookURL) == 0 { + return ErrWebhookURLNotSet + } + return nil +} + +func (cfg *Config) Merge(override *Config) { + if len(override.WebhookURL) > 0 { + cfg.WebhookURL = override.WebhookURL + } +} + +// AlertProvider is the configuration necessary for sending an alert using Squadcast +type AlertProvider struct { + DefaultConfig Config `yaml:",inline"` + + // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type + DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` + + // Overrides is a list of Override that may be prioritized over the default configuration + Overrides []Override `yaml:"overrides,omitempty"` +} + +// Override is a case under which the default integration is overridden +type Override struct { + Group string `yaml:"group"` + Config `yaml:",inline"` +} + +// Validate the provider's configuration +func (provider *AlertProvider) Validate() error { + registeredGroups := make(map[string]bool) + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" { + return ErrDuplicateGroupOverride + } + registeredGroups[override.Group] = true + } + } + return provider.DefaultConfig.Validate() +} + +// Send an alert using the provider +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { + cfg, err := provider.GetConfig(ep.Group, alert) + if err != nil { + return err + } + body, err := provider.buildRequestBody(ep, alert, result, resolved) + if err != nil { + return err + } + buffer := bytes.NewBuffer(body) + request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + response, err := client.GetHTTPClient(nil).Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode >= 400 { + body, _ := io.ReadAll(response.Body) + return fmt.Errorf("call to squadcast alert returned status code %d: %s", response.StatusCode, string(body)) + } + return nil +} + +type Body struct { + Message string `json:"message"` + Description string `json:"description,omitempty"` + EventID string `json:"event_id"` + Status string `json:"status"` + Tags map[string]string `json:"tags,omitempty"` +} + +// buildRequestBody builds the request body for the provider +func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) { + var message, status string + eventID := fmt.Sprintf("gatus-%s", ep.Key()) + if resolved { + message = fmt.Sprintf("RESOLVED: %s", ep.DisplayName()) + status = "resolve" + } else { + message = fmt.Sprintf("ALERT: %s", ep.DisplayName()) + status = "trigger" + } + description := fmt.Sprintf("Endpoint: %s\n", ep.DisplayName()) + if resolved { + description += fmt.Sprintf("Alert has been resolved after passing successfully %d time(s) in a row\n", alert.SuccessThreshold) + } else { + description += fmt.Sprintf("Endpoint has failed %d time(s) in a row\n", alert.FailureThreshold) + } + if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { + description += fmt.Sprintf("\nDescription: %s", alertDescription) + } + if len(result.ConditionResults) > 0 { + description += "\n\nCondition Results:" + for _, conditionResult := range result.ConditionResults { + var status string + if conditionResult.Success { + status = "✅" + } else { + status = "❌" + } + description += fmt.Sprintf("\n%s %s", status, conditionResult.Condition) + } + } + body := Body{ + Message: message, + Description: description, + EventID: eventID, + Status: status, + Tags: map[string]string{ + "endpoint": ep.Name, + "group": ep.Group, + "source": "gatus", + }, + } + bodyAsJSON, err := json.Marshal(body) + if err != nil { + return nil, err + } + return bodyAsJSON, nil +} + +// GetDefaultAlert returns the provider's default alert configuration +func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { + return provider.DefaultAlert +} + +// GetConfig returns the configuration for the provider with the overrides applied +func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) { + cfg := provider.DefaultConfig + // Handle group overrides + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if group == override.Group { + cfg.Merge(&override.Config) + break + } + } + } + // Handle alert overrides + if len(alert.ProviderOverride) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) + } + // Validate the configuration + err := cfg.Validate() + return &cfg, err +} + +// ValidateOverrides validates the alert's provider override and, if present, the group override +func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error { + _, err := provider.GetConfig(group, alert) + return err +} diff --git a/alerting/provider/squadcast/squadcast_test.go b/alerting/provider/squadcast/squadcast_test.go new file mode 100644 index 00000000..d2b5270c --- /dev/null +++ b/alerting/provider/squadcast/squadcast_test.go @@ -0,0 +1,141 @@ +package squadcast + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/client" + "github.com/TwiN/gatus/v5/config/endpoint" + "github.com/TwiN/gatus/v5/test" +) + +func TestAlertProvider_Validate(t *testing.T) { + scenarios := []struct { + name string + provider AlertProvider + expected error + }{ + { + name: "valid", + provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://api.squadcast.com/v3/incidents/api/abcd1234"}}, + expected: nil, + }, + { + name: "invalid-webhook-url", + provider: AlertProvider{DefaultConfig: Config{}}, + expected: ErrWebhookURLNotSet, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + err := scenario.provider.Validate() + if err != scenario.expected { + t.Errorf("expected %v, got %v", scenario.expected, err) + } + }) + } +} + +func TestAlertProvider_Send(t *testing.T) { + defer client.InjectHTTPClient(nil) + firstDescription := "description-1" + secondDescription := "description-2" + scenarios := []struct { + name string + provider AlertProvider + alert alert.Alert + resolved bool + mockRoundTripper test.MockRoundTripper + expectedError bool + }{ + { + name: "triggered", + provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://api.squadcast.com/v3/incidents/api/abcd1234"}}, + alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + resolved: false, + mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + body := make(map[string]interface{}) + json.NewDecoder(r.Body).Decode(&body) + if body["status"] != "trigger" { + t.Errorf("expected status to be 'trigger', got %v", body["status"]) + } + if body["event_id"] == nil { + t.Error("expected 'event_id' field in request body") + } + message := body["message"].(string) + if !strings.Contains(message, "ALERT") { + t.Errorf("expected message to contain 'ALERT', got %s", message) + } + description := body["description"].(string) + if !strings.Contains(description, "failed 3 time(s)") { + t.Errorf("expected description to contain failure count, got %s", description) + } + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody} + }), + expectedError: false, + }, + { + name: "resolved", + provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://api.squadcast.com/v3/incidents/api/abcd1234"}}, + alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + resolved: true, + mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + body := make(map[string]interface{}) + json.NewDecoder(r.Body).Decode(&body) + if body["status"] != "resolve" { + t.Errorf("expected status to be 'resolve', got %v", body["status"]) + } + message := body["message"].(string) + if !strings.Contains(message, "RESOLVED") { + t.Errorf("expected message to contain 'RESOLVED', got %s", message) + } + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody} + }), + expectedError: false, + }, + { + name: "error-response", + provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://api.squadcast.com/v3/incidents/api/abcd1234"}}, + 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.StatusUnauthorized, 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_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") + } +} \ No newline at end of file diff --git a/alerting/provider/telegram/telegram.go b/alerting/provider/telegram/telegram.go index 11a8d290..7dcd29b2 100644 --- a/alerting/provider/telegram/telegram.go +++ b/alerting/provider/telegram/telegram.go @@ -14,7 +14,7 @@ import ( "gopkg.in/yaml.v3" ) -const defaultApiUrl = "https://api.telegram.org" +const ApiURL = "https://api.telegram.org" var ( ErrTokenNotSet = errors.New("token not set") @@ -33,7 +33,7 @@ type Config struct { func (cfg *Config) Validate() error { if len(cfg.ApiUrl) == 0 { - cfg.ApiUrl = defaultApiUrl + cfg.ApiUrl = ApiURL } if len(cfg.Token) == 0 { return ErrTokenNotSet diff --git a/alerting/provider/twilio/twilio.go b/alerting/provider/twilio/twilio.go index ccb83770..b88864fa 100644 --- a/alerting/provider/twilio/twilio.go +++ b/alerting/provider/twilio/twilio.go @@ -29,8 +29,10 @@ type Config struct { From string `yaml:"from"` To string `yaml:"to"` + // TODO in v6.0.0: Rename this to text-triggered TextTwilioTriggered string `yaml:"text-twilio-triggered,omitempty"` // String used in the SMS body and subject (optional) - TextTwilioResolved string `yaml:"text-twilio-resolved,omitempty"` // String used in the SMS body and subject (optional) + // TODO in v6.0.0: Rename this to text-resolved + TextTwilioResolved string `yaml:"text-twilio-resolved,omitempty"` // String used in the SMS body and subject (optional) } func (cfg *Config) Validate() error { @@ -113,13 +115,23 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi var message string if resolved { if len(cfg.TextTwilioResolved) > 0 { - message = strings.Replace(strings.Replace(cfg.TextTwilioResolved, "{endpoint}", ep.DisplayName(), 1), "{description}", alert.GetDescription(), 1) + // Support both old {endpoint}/{description} and new [ENDPOINT]/[ALERT_DESCRIPTION] formats + message = cfg.TextTwilioResolved + message = strings.Replace(message, "{endpoint}", ep.DisplayName(), 1) + message = strings.Replace(message, "{description}", alert.GetDescription(), 1) + message = strings.Replace(message, "[ENDPOINT]", ep.DisplayName(), 1) + message = strings.Replace(message, "[ALERT_DESCRIPTION]", alert.GetDescription(), 1) } else { message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription()) } } else { if len(cfg.TextTwilioTriggered) > 0 { - message = strings.Replace(strings.Replace(cfg.TextTwilioTriggered, "{endpoint}", ep.DisplayName(), 1), "{description}", alert.GetDescription(), 1) + // Support both old {endpoint}/{description} and new [ENDPOINT]/[ALERT_DESCRIPTION] formats + message = cfg.TextTwilioTriggered + message = strings.Replace(message, "{endpoint}", ep.DisplayName(), 1) + message = strings.Replace(message, "{description}", alert.GetDescription(), 1) + message = strings.Replace(message, "[ENDPOINT]", ep.DisplayName(), 1) + message = strings.Replace(message, "[ALERT_DESCRIPTION]", alert.GetDescription(), 1) } else { message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription()) } diff --git a/alerting/provider/twilio/twilio_test.go b/alerting/provider/twilio/twilio_test.go index f31f6349..a8967642 100644 --- a/alerting/provider/twilio/twilio_test.go +++ b/alerting/provider/twilio/twilio_test.go @@ -129,6 +129,27 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { Resolved: true, ExpectedBody: "Body=RESOLVED%3A+endpoint-name+-+description-2&From=3&To=4", }, + { + Name: "triggered-with-old-placeholders", + Provider: AlertProvider{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4", TextTwilioTriggered: "Alert: {endpoint} - {description}"}}, + Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + ExpectedBody: "Body=Alert%3A+endpoint-name+-+description-1&From=3&To=4", + }, + { + Name: "triggered-with-new-placeholders", + Provider: AlertProvider{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4", TextTwilioTriggered: "Alert: [ENDPOINT] - [ALERT_DESCRIPTION]"}}, + Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + ExpectedBody: "Body=Alert%3A+endpoint-name+-+description-1&From=3&To=4", + }, + { + Name: "resolved-with-mixed-placeholders", + Provider: AlertProvider{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4", TextTwilioResolved: "Resolved: {endpoint} and [ENDPOINT] - {description} and [ALERT_DESCRIPTION]"}}, + Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: true, + ExpectedBody: "Body=Resolved%3A+endpoint-name+and+endpoint-name+-+description-2+and+description-2&From=3&To=4", + }, } for _, scenario := range scenarios { t.Run(scenario.Name, func(t *testing.T) { diff --git a/alerting/provider/vonage/vonage.go b/alerting/provider/vonage/vonage.go new file mode 100644 index 00000000..f1a521ff --- /dev/null +++ b/alerting/provider/vonage/vonage.go @@ -0,0 +1,212 @@ +package vonage + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/client" + "github.com/TwiN/gatus/v5/config/endpoint" + "gopkg.in/yaml.v3" +) + +const ApiURL = "https://rest.nexmo.com/sms/json" + +var ( + ErrAPIKeyNotSet = errors.New("api-key not set") + ErrAPISecretNotSet = errors.New("api-secret not set") + ErrFromNotSet = errors.New("from not set") + ErrToNotSet = errors.New("to not set") + ErrDuplicateGroupOverride = errors.New("duplicate group override") +) + +type Config struct { + APIKey string `yaml:"api-key"` + APISecret string `yaml:"api-secret"` + From string `yaml:"from"` + To []string `yaml:"to"` +} + +func (cfg *Config) Validate() error { + if len(cfg.APIKey) == 0 { + return ErrAPIKeyNotSet + } + if len(cfg.APISecret) == 0 { + return ErrAPISecretNotSet + } + if len(cfg.From) == 0 { + return ErrFromNotSet + } + if len(cfg.To) == 0 { + return ErrToNotSet + } + return nil +} + +func (cfg *Config) Merge(override *Config) { + if len(override.APIKey) > 0 { + cfg.APIKey = override.APIKey + } + if len(override.APISecret) > 0 { + cfg.APISecret = override.APISecret + } + if len(override.From) > 0 { + cfg.From = override.From + } + if len(override.To) > 0 { + cfg.To = override.To + } +} + +// AlertProvider is the configuration necessary for sending an alert using Vonage +type AlertProvider struct { + DefaultConfig Config `yaml:",inline"` + + // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type + DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` + + // Overrides is a list of Override that may be prioritized over the default configuration + Overrides []Override `yaml:"overrides,omitempty"` +} + +// Override is a case under which the default integration is overridden +type Override struct { + Group string `yaml:"group"` + Config `yaml:",inline"` +} + +// Validate the provider's configuration +func (provider *AlertProvider) Validate() error { + registeredGroups := make(map[string]bool) + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" { + return ErrDuplicateGroupOverride + } + registeredGroups[override.Group] = true + } + } + return provider.DefaultConfig.Validate() +} + +// Send an alert using the provider +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { + cfg, err := provider.GetConfig(ep.Group, alert) + if err != nil { + return err + } + message := provider.buildMessage(cfg, ep, alert, result, resolved) + + // Send SMS to each recipient + for _, recipient := range cfg.To { + if err := provider.sendSMS(cfg, recipient, message); err != nil { + return err + } + } + return nil +} + +// sendSMS sends an individual SMS message +func (provider *AlertProvider) sendSMS(cfg *Config, to, message string) error { + data := url.Values{} + data.Set("api_key", cfg.APIKey) + data.Set("api_secret", cfg.APISecret) + data.Set("from", cfg.From) + data.Set("to", to) + data.Set("text", message) + request, err := http.NewRequest(http.MethodPost, ApiURL, strings.NewReader(data.Encode())) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + response, err := client.GetHTTPClient(nil).Do(request) + if err != nil { + return err + } + defer response.Body.Close() + // Read response body once and use it for both error handling and JSON processing + body, err := io.ReadAll(response.Body) + if err != nil { + return err + } + if response.StatusCode >= 400 { + return fmt.Errorf("call to vonage alert returned status code %d: %s", response.StatusCode, string(body)) + } + // Check response for errors in messages array + var vonageResponse Response + if err := json.Unmarshal(body, &vonageResponse); err != nil { + return err + } + // Check if any message failed + for _, msg := range vonageResponse.Messages { + if msg.Status != "0" { + return fmt.Errorf("vonage SMS failed with status %s: %s", msg.Status, msg.ErrorText) + } + } + return nil +} + +type Response struct { + MessageCount string `json:"message-count"` + Messages []Message `json:"messages"` +} + +type Message struct { + To string `json:"to"` + MessageID string `json:"message-id"` + Status string `json:"status"` + ErrorText string `json:"error-text"` + RemainingBalance string `json:"remaining-balance"` + MessagePrice string `json:"message-price"` + Network string `json:"network"` +} + +// buildMessage builds the SMS message content +func (provider *AlertProvider) buildMessage(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string { + if resolved { + return fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription()) + } else { + return fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription()) + } +} + +// GetDefaultAlert returns the provider's default alert configuration +func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { + return provider.DefaultAlert +} + +// GetConfig returns the configuration for the provider with the overrides applied +func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) { + cfg := provider.DefaultConfig + // Handle group overrides + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if group == override.Group { + cfg.Merge(&override.Config) + break + } + } + } + // Handle alert overrides + if len(alert.ProviderOverride) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) + } + // Validate the configuration + err := cfg.Validate() + return &cfg, err +} + +// ValidateOverrides validates the alert's provider override and, if present, the group override +func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error { + _, err := provider.GetConfig(group, alert) + return err +} diff --git a/alerting/provider/vonage/vonage_test.go b/alerting/provider/vonage/vonage_test.go new file mode 100644 index 00000000..8359deb1 --- /dev/null +++ b/alerting/provider/vonage/vonage_test.go @@ -0,0 +1,546 @@ +package vonage + +import ( + "io" + "net/http" + "strings" + "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 TestVonageAlertProvider_IsValid(t *testing.T) { + invalidProvider := AlertProvider{} + if err := invalidProvider.Validate(); err == nil { + t.Error("provider shouldn't have been valid") + } + validProvider := AlertProvider{ + DefaultConfig: Config{ + APIKey: "test-key", + APISecret: "test-secret", + From: "Gatus", + To: []string{"+1234567890"}, + }, + } + if err := validProvider.Validate(); err != nil { + t.Error("provider should've been valid") + } +} + +func TestVonageAlertProvider_IsValidWithOverride(t *testing.T) { + validProvider := AlertProvider{ + DefaultConfig: Config{ + APIKey: "test-key", + APISecret: "test-secret", + From: "Gatus", + To: []string{"+1234567890"}, + }, + Overrides: []Override{ + { + Group: "test-group", + Config: Config{ + APIKey: "override-key", + APISecret: "override-secret", + From: "Override", + To: []string{"+9876543210"}, + }, + }, + }, + } + if err := validProvider.Validate(); err != nil { + t.Error("provider should've been valid") + } +} + +func TestVonageAlertProvider_IsNotValidWithInvalidOverrideGroup(t *testing.T) { + invalidProvider := AlertProvider{ + DefaultConfig: Config{ + APIKey: "test-key", + APISecret: "test-secret", + From: "Gatus", + To: []string{"+1234567890"}, + }, + Overrides: []Override{ + { + Group: "", + Config: Config{ + APIKey: "override-key", + APISecret: "override-secret", + From: "Override", + To: []string{"+9876543210"}, + }, + }, + }, + } + if err := invalidProvider.Validate(); err == nil { + t.Error("provider shouldn't have been valid") + } +} + +func TestVonageAlertProvider_IsNotValidWithDuplicateOverrideGroup(t *testing.T) { + invalidProvider := AlertProvider{ + DefaultConfig: Config{ + APIKey: "test-key", + APISecret: "test-secret", + From: "Gatus", + To: []string{"+1234567890"}, + }, + Overrides: []Override{ + { + Group: "test-group", + Config: Config{ + APIKey: "override-key1", + APISecret: "override-secret1", + From: "Override1", + To: []string{"+9876543210"}, + }, + }, + { + Group: "test-group", + Config: Config{ + APIKey: "override-key2", + APISecret: "override-secret2", + From: "Override2", + To: []string{"+1234567890"}, + }, + }, + }, + } + if err := invalidProvider.Validate(); err == nil { + t.Error("provider shouldn't have been valid") + } +} + +func TestVonageAlertProvider_IsValidWithInvalidFrom(t *testing.T) { + invalidProvider := AlertProvider{ + DefaultConfig: Config{ + APIKey: "test-key", + APISecret: "test-secret", + From: "", + To: []string{"+1234567890"}, + }, + } + if err := invalidProvider.Validate(); err == nil { + t.Error("provider shouldn't have been valid") + } +} + +func TestVonageAlertProvider_IsValidWithInvalidTo(t *testing.T) { + invalidProvider := AlertProvider{ + DefaultConfig: Config{ + APIKey: "test-key", + APISecret: "test-secret", + From: "Gatus", + To: []string{}, + }, + } + if err := invalidProvider.Validate(); err == nil { + t.Error("provider shouldn't have been valid") + } +} + +func TestAlertProvider_Send(t *testing.T) { + defer client.InjectHTTPClient(nil) + firstDescription := "description-1" + secondDescription := "description-2" + scenarios := []struct { + Name string + Provider AlertProvider + Alert alert.Alert + Resolved bool + MockRoundTripper test.MockRoundTripper + ExpectedError bool + }{ + { + Name: "triggered", + Provider: AlertProvider{ + DefaultConfig: Config{ + APIKey: "test-key", + APISecret: "test-secret", + From: "Gatus", + To: []string{"+1234567890"}, + }, + }, + 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: io.NopCloser(strings.NewReader(`{"message-count":"1","messages":[{"to":"+1234567890","message-id":"test-id","status":"0","remaining-balance":"10.50","message-price":"0.10","network":"12345"}]}`)), + } + }), + ExpectedError: false, + }, + { + Name: "triggered-error-status-code", + Provider: AlertProvider{ + DefaultConfig: Config{ + APIKey: "test-key", + APISecret: "test-secret", + From: "Gatus", + To: []string{"+1234567890"}, + }, + }, + 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: "triggered-error-vonage-response", + Provider: AlertProvider{ + DefaultConfig: Config{ + APIKey: "test-key", + APISecret: "test-secret", + From: "Gatus", + To: []string{"+1234567890"}, + }, + }, + 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: io.NopCloser(strings.NewReader(`{"message-count":"1","messages":[{"to":"+1234567890","message-id":"","status":"2","error-text":"Missing from param"}]}`)), + } + }), + ExpectedError: true, + }, + { + Name: "resolved", + Provider: AlertProvider{ + DefaultConfig: Config{ + APIKey: "test-key", + APISecret: "test-secret", + From: "Gatus", + To: []string{"+1234567890"}, + }, + }, + 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: io.NopCloser(strings.NewReader(`{"message-count":"1","messages":[{"to":"+1234567890","message-id":"test-id","status":"0","remaining-balance":"10.40","message-price":"0.10","network":"12345"}]}`)), + } + }), + ExpectedError: false, + }, + { + Name: "multiple-recipients", + Provider: AlertProvider{ + DefaultConfig: Config{ + APIKey: "test-key", + APISecret: "test-secret", + From: "Gatus", + To: []string{"+1234567890", "+0987654321"}, + }, + }, + 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: io.NopCloser(strings.NewReader(`{"message-count":"1","messages":[{"to":"+1234567890","message-id":"test-id","status":"0","remaining-balance":"10.30","message-price":"0.10","network":"12345"}]}`)), + } + }), + ExpectedError: false, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) + err := scenario.Provider.Send( + &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_buildMessage(t *testing.T) { + firstDescription := "description-1" + secondDescription := "description-2" + scenarios := []struct { + Name string + Provider AlertProvider + Alert alert.Alert + Resolved bool + ExpectedMessage string + }{ + { + Name: "triggered", + Provider: AlertProvider{ + DefaultConfig: Config{ + APIKey: "test-key", + APISecret: "test-secret", + From: "Gatus", + To: []string{"+1234567890"}, + }, + }, + Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + ExpectedMessage: "TRIGGERED: endpoint-name - description-1", + }, + { + Name: "resolved", + Provider: AlertProvider{ + DefaultConfig: Config{ + APIKey: "test-key", + APISecret: "test-secret", + From: "Gatus", + To: []string{"+1234567890"}, + }, + }, + Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: true, + ExpectedMessage: "RESOLVED: endpoint-name - description-2", + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + message := scenario.Provider.buildMessage( + &scenario.Provider.DefaultConfig, + &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 message != scenario.ExpectedMessage { + t.Errorf("expected %s, got %s", scenario.ExpectedMessage, message) + } + }) + } +} + +func TestAlertProvider_GetDefaultAlert(t *testing.T) { + if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil { + t.Error("expected default alert to be not nil") + } + if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil { + t.Error("expected default alert to be nil") + } +} + +func TestAlertProvider_GetConfig(t *testing.T) { + scenarios := []struct { + Name string + Provider AlertProvider + InputGroup string + InputAlert alert.Alert + ExpectedOutput Config + }{ + { + Name: "provider-no-override-should-default", + Provider: AlertProvider{ + DefaultConfig: Config{ + APIKey: "test-key", + APISecret: "test-secret", + From: "Gatus", + To: []string{"+1234567890"}, + }, + }, + InputGroup: "", + InputAlert: alert.Alert{}, + ExpectedOutput: Config{ + APIKey: "test-key", + APISecret: "test-secret", + From: "Gatus", + To: []string{"+1234567890"}, + }, + }, + { + Name: "provider-with-group-override", + Provider: AlertProvider{ + DefaultConfig: Config{ + APIKey: "test-key", + APISecret: "test-secret", + From: "Gatus", + To: []string{"+1234567890"}, + }, + Overrides: []Override{ + { + Group: "test-group", + Config: Config{ + APIKey: "group-override-key", + APISecret: "group-override-secret", + From: "GroupOverride", + To: []string{"+9876543210"}, + }, + }, + }, + }, + InputGroup: "test-group", + InputAlert: alert.Alert{}, + ExpectedOutput: Config{ + APIKey: "group-override-key", + APISecret: "group-override-secret", + From: "GroupOverride", + To: []string{"+9876543210"}, + }, + }, + { + Name: "provider-with-group-override-partial", + Provider: AlertProvider{ + DefaultConfig: Config{ + APIKey: "test-key", + APISecret: "test-secret", + From: "Gatus", + To: []string{"+1234567890"}, + }, + Overrides: []Override{ + { + Group: "test-group", + Config: Config{ + To: []string{"+9876543210"}, + }, + }, + }, + }, + InputGroup: "test-group", + InputAlert: alert.Alert{}, + ExpectedOutput: Config{ + APIKey: "test-key", + APISecret: "test-secret", + From: "Gatus", + To: []string{"+9876543210"}, + }, + }, + { + Name: "provider-with-alert-override", + Provider: AlertProvider{ + DefaultConfig: Config{ + APIKey: "test-key", + APISecret: "test-secret", + From: "Gatus", + To: []string{"+1234567890"}, + }, + }, + InputGroup: "", + InputAlert: alert.Alert{ProviderOverride: map[string]any{ + "api-key": "override-key", + "api-secret": "override-secret", + "from": "Override", + "to": []string{"+9876543210"}, + }}, + ExpectedOutput: Config{ + APIKey: "override-key", + APISecret: "override-secret", + From: "Override", + To: []string{"+9876543210"}, + }, + }, + { + Name: "provider-with-both-group-and-alert-override", + Provider: AlertProvider{ + DefaultConfig: Config{ + APIKey: "test-key", + APISecret: "test-secret", + From: "Gatus", + To: []string{"+1234567890"}, + }, + Overrides: []Override{ + { + Group: "test-group", + Config: Config{ + APIKey: "group-override-key", + From: "GroupOverride", + }, + }, + }, + }, + InputGroup: "test-group", + InputAlert: alert.Alert{ProviderOverride: map[string]any{ + "api-secret": "alert-override-secret", + "to": []string{"+9876543210"}, + }}, + ExpectedOutput: Config{ + APIKey: "group-override-key", + APISecret: "alert-override-secret", + From: "GroupOverride", + To: []string{"+9876543210"}, + }, + }, + { + Name: "provider-with-group-override-no-match", + Provider: AlertProvider{ + DefaultConfig: Config{ + APIKey: "test-key", + APISecret: "test-secret", + From: "Gatus", + To: []string{"+1234567890"}, + }, + Overrides: []Override{ + { + Group: "different-group", + Config: Config{ + APIKey: "group-override-key", + }, + }, + }, + }, + InputGroup: "test-group", + InputAlert: alert.Alert{}, + ExpectedOutput: Config{ + APIKey: "test-key", + APISecret: "test-secret", + From: "Gatus", + To: []string{"+1234567890"}, + }, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert) + if err != nil { + t.Error("expected no error, got:", err.Error()) + } + if got.APIKey != scenario.ExpectedOutput.APIKey { + t.Errorf("expected APIKey to be %s, got %s", scenario.ExpectedOutput.APIKey, got.APIKey) + } + if got.APISecret != scenario.ExpectedOutput.APISecret { + t.Errorf("expected APISecret to be %s, got %s", scenario.ExpectedOutput.APISecret, got.APISecret) + } + if got.From != scenario.ExpectedOutput.From { + t.Errorf("expected From to be %s, got %s", scenario.ExpectedOutput.From, got.From) + } + if len(got.To) != len(scenario.ExpectedOutput.To) { + t.Errorf("expected To to have length %d, got %d", len(scenario.ExpectedOutput.To), len(got.To)) + } else { + for i, to := range got.To { + if to != scenario.ExpectedOutput.To[i] { + t.Errorf("expected To[%d] to be %s, got %s", i, scenario.ExpectedOutput.To[i], to) + } + } + } + // Test ValidateOverrides as well, since it really just calls GetConfig + if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil { + t.Errorf("unexpected error: %s", err) + } + }) + } +} + diff --git a/alerting/provider/webex/webex.go b/alerting/provider/webex/webex.go new file mode 100644 index 00000000..ef7f988e --- /dev/null +++ b/alerting/provider/webex/webex.go @@ -0,0 +1,171 @@ +package webex + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/client" + "github.com/TwiN/gatus/v5/config/endpoint" + "gopkg.in/yaml.v3" +) + +var ( + ErrWebhookURLNotSet = errors.New("webhook-url not set") + ErrDuplicateGroupOverride = errors.New("duplicate group override") +) + +type Config struct { + WebhookURL string `yaml:"webhook-url"` // Webex Teams webhook URL +} + +func (cfg *Config) Validate() error { + if len(cfg.WebhookURL) == 0 { + return ErrWebhookURLNotSet + } + return nil +} + +func (cfg *Config) Merge(override *Config) { + if len(override.WebhookURL) > 0 { + cfg.WebhookURL = override.WebhookURL + } +} + +// AlertProvider is the configuration necessary for sending an alert using Webex +type AlertProvider struct { + DefaultConfig Config `yaml:",inline"` + + // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type + DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` + + // Overrides is a list of Override that may be prioritized over the default configuration + Overrides []Override `yaml:"overrides,omitempty"` +} + +// Override is a case under which the default integration is overridden +type Override struct { + Group string `yaml:"group"` + Config `yaml:",inline"` +} + +// Validate the provider's configuration +func (provider *AlertProvider) Validate() error { + registeredGroups := make(map[string]bool) + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" { + return ErrDuplicateGroupOverride + } + registeredGroups[override.Group] = true + } + } + return provider.DefaultConfig.Validate() +} + +// Send an alert using the provider +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { + cfg, err := provider.GetConfig(ep.Group, alert) + if err != nil { + return err + } + body, err := provider.buildRequestBody(ep, alert, result, resolved) + if err != nil { + return err + } + buffer := bytes.NewBuffer(body) + request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + response, err := client.GetHTTPClient(nil).Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode >= 400 { + body, _ := io.ReadAll(response.Body) + return fmt.Errorf("call to webex alert returned status code %d: %s", response.StatusCode, string(body)) + } + return nil +} + +type Body struct { + RoomID string `json:"roomId,omitempty"` + Text string `json:"text,omitempty"` + Markdown string `json:"markdown"` +} + +// buildRequestBody builds the request body for the provider +func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) { + var message string + if resolved { + message = fmt.Sprintf("✅ **RESOLVED**: %s\n\nAlert has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold) + } else { + message = fmt.Sprintf("🚨 **ALERT**: %s\n\nEndpoint has failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold) + } + if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { + message += fmt.Sprintf("\n\n**Description**: %s", alertDescription) + } + if len(result.ConditionResults) > 0 { + message += "\n\n**Condition Results:**" + for _, conditionResult := range result.ConditionResults { + var status string + if conditionResult.Success { + status = "✅" + } else { + status = "❌" + } + message += fmt.Sprintf("\n- %s `%s`", status, conditionResult.Condition) + } + } + body := Body{ + Markdown: message, + } + bodyAsJSON, err := json.Marshal(body) + if err != nil { + return nil, err + } + return bodyAsJSON, nil +} + +// GetDefaultAlert returns the provider's default alert configuration +func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { + return provider.DefaultAlert +} + +// GetConfig returns the configuration for the provider with the overrides applied +func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) { + cfg := provider.DefaultConfig + // Handle group overrides + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if group == override.Group { + cfg.Merge(&override.Config) + break + } + } + } + // Handle alert overrides + if len(alert.ProviderOverride) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) + } + // Validate the configuration + err := cfg.Validate() + return &cfg, err +} + +// ValidateOverrides validates the alert's provider override and, if present, the group override +func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error { + _, err := provider.GetConfig(group, alert) + return err +} diff --git a/alerting/provider/webex/webex_test.go b/alerting/provider/webex/webex_test.go new file mode 100644 index 00000000..b7c67ad7 --- /dev/null +++ b/alerting/provider/webex/webex_test.go @@ -0,0 +1,134 @@ +package webex + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/client" + "github.com/TwiN/gatus/v5/config/endpoint" + "github.com/TwiN/gatus/v5/test" +) + +func TestAlertProvider_Validate(t *testing.T) { + scenarios := []struct { + name string + provider AlertProvider + expected error + }{ + { + name: "valid", + provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://webexapis.com/v1/webhooks/incoming/123"}}, + expected: nil, + }, + { + name: "invalid-webhook-url", + provider: AlertProvider{DefaultConfig: Config{}}, + expected: ErrWebhookURLNotSet, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + err := scenario.provider.Validate() + if err != scenario.expected { + t.Errorf("expected %v, got %v", scenario.expected, err) + } + }) + } +} + +func TestAlertProvider_Send(t *testing.T) { + defer client.InjectHTTPClient(nil) + firstDescription := "description-1" + secondDescription := "description-2" + scenarios := []struct { + name string + provider AlertProvider + alert alert.Alert + resolved bool + mockRoundTripper test.MockRoundTripper + expectedError bool + }{ + { + name: "triggered", + provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://webexapis.com/v1/webhooks/incoming/123"}}, + alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + resolved: false, + mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + body := make(map[string]interface{}) + json.NewDecoder(r.Body).Decode(&body) + if body["markdown"] == nil { + t.Error("expected 'markdown' field in request body") + } + markdown := body["markdown"].(string) + if !strings.Contains(markdown, "ALERT") { + t.Errorf("expected markdown to contain 'ALERT', got %s", markdown) + } + if !strings.Contains(markdown, "failed 3 time(s)") { + t.Errorf("expected markdown to contain failure count, got %s", markdown) + } + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody} + }), + expectedError: false, + }, + { + name: "resolved", + provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://webexapis.com/v1/webhooks/incoming/123"}}, + alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + resolved: true, + mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + body := make(map[string]interface{}) + json.NewDecoder(r.Body).Decode(&body) + markdown := body["markdown"].(string) + if !strings.Contains(markdown, "RESOLVED") { + t.Errorf("expected markdown to contain 'RESOLVED', got %s", markdown) + } + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody} + }), + expectedError: false, + }, + { + name: "error-response", + provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://webexapis.com/v1/webhooks/incoming/123"}}, + 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.StatusUnauthorized, 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_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") + } +} \ No newline at end of file diff --git a/alerting/provider/zapier/zapier.go b/alerting/provider/zapier/zapier.go new file mode 100644 index 00000000..c978ef3b --- /dev/null +++ b/alerting/provider/zapier/zapier.go @@ -0,0 +1,197 @@ +package zapier + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/client" + "github.com/TwiN/gatus/v5/config/endpoint" + "gopkg.in/yaml.v3" +) + +var ( + ErrWebhookURLNotSet = errors.New("webhook-url not set") + ErrDuplicateGroupOverride = errors.New("duplicate group override") +) + +type Config struct { + WebhookURL string `yaml:"webhook-url"` // Zapier webhook URL +} + +func (cfg *Config) Validate() error { + if len(cfg.WebhookURL) == 0 { + return ErrWebhookURLNotSet + } + return nil +} + +func (cfg *Config) Merge(override *Config) { + if len(override.WebhookURL) > 0 { + cfg.WebhookURL = override.WebhookURL + } +} + +// AlertProvider is the configuration necessary for sending an alert using Zapier +type AlertProvider struct { + DefaultConfig Config `yaml:",inline"` + + // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type + DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` + + // Overrides is a list of Override that may be prioritized over the default configuration + Overrides []Override `yaml:"overrides,omitempty"` +} + +// Override is a case under which the default integration is overridden +type Override struct { + Group string `yaml:"group"` + Config `yaml:",inline"` +} + +// Validate the provider's configuration +func (provider *AlertProvider) Validate() error { + registeredGroups := make(map[string]bool) + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" { + return ErrDuplicateGroupOverride + } + registeredGroups[override.Group] = true + } + } + return provider.DefaultConfig.Validate() +} + +// Send an alert using the provider +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { + cfg, err := provider.GetConfig(ep.Group, alert) + if err != nil { + return err + } + body, err := provider.buildRequestBody(ep, alert, result, resolved) + if err != nil { + return err + } + buffer := bytes.NewBuffer(body) + request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + response, err := client.GetHTTPClient(nil).Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode >= 400 { + body, _ := io.ReadAll(response.Body) + return fmt.Errorf("call to zapier alert returned status code %d: %s", response.StatusCode, string(body)) + } + return nil +} + +type Body struct { + AlertType string `json:"alert_type"` + Status string `json:"status"` + Endpoint string `json:"endpoint"` + Group string `json:"group,omitempty"` + Message string `json:"message"` + Description string `json:"description,omitempty"` + Timestamp string `json:"timestamp"` + SuccessThreshold int `json:"success_threshold,omitempty"` + FailureThreshold int `json:"failure_threshold,omitempty"` + ConditionResults []*endpoint.ConditionResult `json:"condition_results,omitempty"` + TotalConditions int `json:"total_conditions"` + PassedConditions int `json:"passed_conditions"` + FailedConditions int `json:"failed_conditions"` +} + + +// buildRequestBody builds the request body for the provider +func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) { + var alertType, status, message string + var successThreshold, failureThreshold int + if resolved { + alertType = "resolved" + status = "ok" + message = fmt.Sprintf("Alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold) + successThreshold = alert.SuccessThreshold + } else { + alertType = "triggered" + status = "critical" + message = fmt.Sprintf("Alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold) + failureThreshold = alert.FailureThreshold + } + // Process condition results + passedConditions := 0 + failedConditions := 0 + for _, cr := range result.ConditionResults { + if cr.Success { + passedConditions++ + } else { + failedConditions++ + } + } + body := Body{ + AlertType: alertType, + Status: status, + Endpoint: ep.DisplayName(), + Group: ep.Group, + Message: message, + Description: alert.GetDescription(), + Timestamp: time.Now().Format(time.RFC3339), + SuccessThreshold: successThreshold, + FailureThreshold: failureThreshold, + ConditionResults: result.ConditionResults, + TotalConditions: len(result.ConditionResults), + PassedConditions: passedConditions, + FailedConditions: failedConditions, + } + bodyAsJSON, err := json.Marshal(body) + if err != nil { + return nil, err + } + return bodyAsJSON, nil +} + +// GetDefaultAlert returns the provider's default alert configuration +func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { + return provider.DefaultAlert +} + +// GetConfig returns the configuration for the provider with the overrides applied +func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) { + cfg := provider.DefaultConfig + // Handle group overrides + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if group == override.Group { + cfg.Merge(&override.Config) + break + } + } + } + // Handle alert overrides + if len(alert.ProviderOverride) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) + } + // Validate the configuration + err := cfg.Validate() + return &cfg, err +} + +// ValidateOverrides validates the alert's provider override and, if present, the group override +func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error { + _, err := provider.GetConfig(group, alert) + return err +} diff --git a/alerting/provider/zapier/zapier_test.go b/alerting/provider/zapier/zapier_test.go new file mode 100644 index 00000000..84b8f6f0 --- /dev/null +++ b/alerting/provider/zapier/zapier_test.go @@ -0,0 +1,162 @@ +package zapier + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/client" + "github.com/TwiN/gatus/v5/config/endpoint" + "github.com/TwiN/gatus/v5/test" +) + +func TestAlertProvider_Validate(t *testing.T) { + scenarios := []struct { + name string + provider AlertProvider + expected error + }{ + { + name: "valid", + provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://hooks.zapier.com/hooks/catch/123456/abcdef/"}}, + expected: nil, + }, + { + name: "invalid-webhook-url", + provider: AlertProvider{DefaultConfig: Config{}}, + expected: ErrWebhookURLNotSet, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + err := scenario.provider.Validate() + if err != scenario.expected { + t.Errorf("expected %v, got %v", scenario.expected, err) + } + }) + } +} + +func TestAlertProvider_Send(t *testing.T) { + defer client.InjectHTTPClient(nil) + firstDescription := "description-1" + secondDescription := "description-2" + scenarios := []struct { + name string + provider AlertProvider + alert alert.Alert + resolved bool + mockRoundTripper test.MockRoundTripper + expectedError bool + }{ + { + name: "triggered", + provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://hooks.zapier.com/hooks/catch/123456/abcdef/"}}, + alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + resolved: false, + mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + if r.Host != "hooks.zapier.com" { + t.Errorf("expected host hooks.zapier.com, got %s", r.Host) + } + if r.URL.Path != "/hooks/catch/123456/abcdef/" { + t.Errorf("expected path /hooks/catch/123456/abcdef/, got %s", r.URL.Path) + } + body := make(map[string]interface{}) + json.NewDecoder(r.Body).Decode(&body) + if body["alert_type"] != "triggered" { + t.Errorf("expected alert_type to be 'triggered', got %v", body["alert_type"]) + } + if body["status"] != "critical" { + t.Errorf("expected status to be 'critical', got %v", body["status"]) + } + if body["endpoint"] != "endpoint-name" { + t.Errorf("expected endpoint to be 'endpoint-name', got %v", body["endpoint"]) + } + message := body["message"].(string) + if !strings.Contains(message, "Alert") { + t.Errorf("expected message to contain 'Alert', got %s", message) + } + if !strings.Contains(message, "failed 3 time(s)") { + t.Errorf("expected message to contain failure count, got %s", message) + } + if body["description"] != firstDescription { + t.Errorf("expected description to be '%s', got %v", firstDescription, body["description"]) + } + conditionResults := body["condition_results"].([]interface{}) + if len(conditionResults) != 2 { + t.Errorf("expected 2 condition results, got %d", len(conditionResults)) + } + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody} + }), + expectedError: false, + }, + { + name: "resolved", + provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://hooks.zapier.com/hooks/catch/123456/abcdef/"}}, + alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + resolved: true, + mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + body := make(map[string]interface{}) + json.NewDecoder(r.Body).Decode(&body) + if body["alert_type"] != "resolved" { + t.Errorf("expected alert_type to be 'resolved', got %v", body["alert_type"]) + } + if body["status"] != "ok" { + t.Errorf("expected status to be 'ok', got %v", body["status"]) + } + message := body["message"].(string) + if !strings.Contains(message, "resolved") { + t.Errorf("expected message to contain 'resolved', got %s", message) + } + if body["description"] != secondDescription { + t.Errorf("expected description to be '%s', got %v", secondDescription, body["description"]) + } + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody} + }), + expectedError: false, + }, + { + name: "error-response", + provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://hooks.zapier.com/hooks/catch/123456/abcdef/"}}, + 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.StatusUnauthorized, 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_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") + } +} \ No newline at end of file diff --git a/config/config.go b/config/config.go index f207429a..6ba5e6e3 100644 --- a/config/config.go +++ b/config/config.go @@ -444,6 +444,7 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi alertTypes := []alert.Type{ alert.TypeAWSSES, alert.TypeCustom, + alert.TypeDatadog, alert.TypeDiscord, alert.TypeEmail, alert.TypeGitHub, @@ -452,21 +453,34 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi alert.TypeGoogleChat, alert.TypeGotify, alert.TypeHomeAssistant, + alert.TypeIFTTT, alert.TypeIlert, alert.TypeIncidentIO, alert.TypeJetBrainsSpace, + alert.TypeLine, alert.TypeMatrix, alert.TypeMattermost, alert.TypeMessagebird, + alert.TypeNewRelic, alert.TypeNtfy, alert.TypeOpsgenie, alert.TypePagerDuty, + alert.TypePlivo, alert.TypePushover, + alert.TypeRocketChat, + alert.TypeSendGrid, + alert.TypeSignal, + alert.TypeSIGNL4, alert.TypeSlack, + alert.TypeSplunk, + alert.TypeSquadcast, alert.TypeTeams, alert.TypeTeamsWorkflows, alert.TypeTelegram, alert.TypeTwilio, + alert.TypeVonage, + alert.TypeWebex, + alert.TypeZapier, alert.TypeZulip, } var validProviders, invalidProviders []alert.Type diff --git a/config/config_test.go b/config/config_test.go index 1b51090b..dc210684 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -13,6 +13,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/provider" "github.com/TwiN/gatus/v5/alerting/provider/awsses" "github.com/TwiN/gatus/v5/alerting/provider/custom" + "github.com/TwiN/gatus/v5/alerting/provider/datadog" "github.com/TwiN/gatus/v5/alerting/provider/discord" "github.com/TwiN/gatus/v5/alerting/provider/email" "github.com/TwiN/gatus/v5/alerting/provider/gitea" @@ -20,19 +21,35 @@ import ( "github.com/TwiN/gatus/v5/alerting/provider/gitlab" "github.com/TwiN/gatus/v5/alerting/provider/googlechat" "github.com/TwiN/gatus/v5/alerting/provider/gotify" + "github.com/TwiN/gatus/v5/alerting/provider/homeassistant" + "github.com/TwiN/gatus/v5/alerting/provider/ifttt" + "github.com/TwiN/gatus/v5/alerting/provider/ilert" + "github.com/TwiN/gatus/v5/alerting/provider/incidentio" "github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace" + "github.com/TwiN/gatus/v5/alerting/provider/line" "github.com/TwiN/gatus/v5/alerting/provider/matrix" "github.com/TwiN/gatus/v5/alerting/provider/mattermost" "github.com/TwiN/gatus/v5/alerting/provider/messagebird" + "github.com/TwiN/gatus/v5/alerting/provider/newrelic" "github.com/TwiN/gatus/v5/alerting/provider/ntfy" "github.com/TwiN/gatus/v5/alerting/provider/opsgenie" "github.com/TwiN/gatus/v5/alerting/provider/pagerduty" + "github.com/TwiN/gatus/v5/alerting/provider/plivo" "github.com/TwiN/gatus/v5/alerting/provider/pushover" + "github.com/TwiN/gatus/v5/alerting/provider/rocketchat" + "github.com/TwiN/gatus/v5/alerting/provider/sendgrid" + "github.com/TwiN/gatus/v5/alerting/provider/signal" + "github.com/TwiN/gatus/v5/alerting/provider/signl4" "github.com/TwiN/gatus/v5/alerting/provider/slack" + "github.com/TwiN/gatus/v5/alerting/provider/splunk" + "github.com/TwiN/gatus/v5/alerting/provider/squadcast" "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/vonage" + "github.com/TwiN/gatus/v5/alerting/provider/webex" + "github.com/TwiN/gatus/v5/alerting/provider/zapier" "github.com/TwiN/gatus/v5/alerting/provider/zulip" "github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/config/endpoint" @@ -1885,6 +1902,7 @@ func TestGetAlertingProviderByAlertType(t *testing.T) { alertingConfig := &alerting.Config{ AWSSimpleEmailService: &awsses.AlertProvider{}, Custom: &custom.AlertProvider{}, + Datadog: &datadog.AlertProvider{}, Discord: &discord.AlertProvider{}, Email: &email.AlertProvider{}, Gitea: &gitea.AlertProvider{}, @@ -1892,19 +1910,35 @@ func TestGetAlertingProviderByAlertType(t *testing.T) { GitLab: &gitlab.AlertProvider{}, GoogleChat: &googlechat.AlertProvider{}, Gotify: &gotify.AlertProvider{}, + HomeAssistant: &homeassistant.AlertProvider{}, + IFTTT: &ifttt.AlertProvider{}, + Ilert: &ilert.AlertProvider{}, + IncidentIO: &incidentio.AlertProvider{}, JetBrainsSpace: &jetbrainsspace.AlertProvider{}, + Line: &line.AlertProvider{}, Matrix: &matrix.AlertProvider{}, Mattermost: &mattermost.AlertProvider{}, Messagebird: &messagebird.AlertProvider{}, + NewRelic: &newrelic.AlertProvider{}, Ntfy: &ntfy.AlertProvider{}, Opsgenie: &opsgenie.AlertProvider{}, PagerDuty: &pagerduty.AlertProvider{}, + Plivo: &plivo.AlertProvider{}, Pushover: &pushover.AlertProvider{}, + RocketChat: &rocketchat.AlertProvider{}, + SendGrid: &sendgrid.AlertProvider{}, + Signal: &signal.AlertProvider{}, + SIGNL4: &signl4.AlertProvider{}, Slack: &slack.AlertProvider{}, + Splunk: &splunk.AlertProvider{}, + Squadcast: &squadcast.AlertProvider{}, Telegram: &telegram.AlertProvider{}, Teams: &teams.AlertProvider{}, TeamsWorkflows: &teamsworkflows.AlertProvider{}, Twilio: &twilio.AlertProvider{}, + Vonage: &vonage.AlertProvider{}, + Webex: &webex.AlertProvider{}, + Zapier: &zapier.AlertProvider{}, Zulip: &zulip.AlertProvider{}, } scenarios := []struct { @@ -1913,6 +1947,7 @@ func TestGetAlertingProviderByAlertType(t *testing.T) { }{ {alertType: alert.TypeAWSSES, expected: alertingConfig.AWSSimpleEmailService}, {alertType: alert.TypeCustom, expected: alertingConfig.Custom}, + {alertType: alert.TypeDatadog, expected: alertingConfig.Datadog}, {alertType: alert.TypeDiscord, expected: alertingConfig.Discord}, {alertType: alert.TypeEmail, expected: alertingConfig.Email}, {alertType: alert.TypeGitea, expected: alertingConfig.Gitea}, @@ -1920,19 +1955,35 @@ func TestGetAlertingProviderByAlertType(t *testing.T) { {alertType: alert.TypeGitLab, expected: alertingConfig.GitLab}, {alertType: alert.TypeGoogleChat, expected: alertingConfig.GoogleChat}, {alertType: alert.TypeGotify, expected: alertingConfig.Gotify}, + {alertType: alert.TypeHomeAssistant, expected: alertingConfig.HomeAssistant}, + {alertType: alert.TypeIFTTT, expected: alertingConfig.IFTTT}, + {alertType: alert.TypeIlert, expected: alertingConfig.Ilert}, + {alertType: alert.TypeIncidentIO, expected: alertingConfig.IncidentIO}, {alertType: alert.TypeJetBrainsSpace, expected: alertingConfig.JetBrainsSpace}, + {alertType: alert.TypeLine, expected: alertingConfig.Line}, {alertType: alert.TypeMatrix, expected: alertingConfig.Matrix}, {alertType: alert.TypeMattermost, expected: alertingConfig.Mattermost}, {alertType: alert.TypeMessagebird, expected: alertingConfig.Messagebird}, + {alertType: alert.TypeNewRelic, expected: alertingConfig.NewRelic}, {alertType: alert.TypeNtfy, expected: alertingConfig.Ntfy}, {alertType: alert.TypeOpsgenie, expected: alertingConfig.Opsgenie}, {alertType: alert.TypePagerDuty, expected: alertingConfig.PagerDuty}, + {alertType: alert.TypePlivo, expected: alertingConfig.Plivo}, {alertType: alert.TypePushover, expected: alertingConfig.Pushover}, + {alertType: alert.TypeRocketChat, expected: alertingConfig.RocketChat}, + {alertType: alert.TypeSendGrid, expected: alertingConfig.SendGrid}, + {alertType: alert.TypeSignal, expected: alertingConfig.Signal}, + {alertType: alert.TypeSIGNL4, expected: alertingConfig.SIGNL4}, {alertType: alert.TypeSlack, expected: alertingConfig.Slack}, + {alertType: alert.TypeSplunk, expected: alertingConfig.Splunk}, + {alertType: alert.TypeSquadcast, expected: alertingConfig.Squadcast}, {alertType: alert.TypeTelegram, expected: alertingConfig.Telegram}, {alertType: alert.TypeTeams, expected: alertingConfig.Teams}, {alertType: alert.TypeTeamsWorkflows, expected: alertingConfig.TeamsWorkflows}, {alertType: alert.TypeTwilio, expected: alertingConfig.Twilio}, + {alertType: alert.TypeVonage, expected: alertingConfig.Vonage}, + {alertType: alert.TypeWebex, expected: alertingConfig.Webex}, + {alertType: alert.TypeZapier, expected: alertingConfig.Zapier}, {alertType: alert.TypeZulip, expected: alertingConfig.Zulip}, } for _, scenario := range scenarios { diff --git a/watchdog/alerting_test.go b/watchdog/alerting_test.go index 21bd7434..d8314885 100644 --- a/watchdog/alerting_test.go +++ b/watchdog/alerting_test.go @@ -8,18 +8,26 @@ import ( "github.com/TwiN/gatus/v5/alerting" "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/provider/custom" + "github.com/TwiN/gatus/v5/alerting/provider/datadog" "github.com/TwiN/gatus/v5/alerting/provider/discord" "github.com/TwiN/gatus/v5/alerting/provider/email" + "github.com/TwiN/gatus/v5/alerting/provider/ifttt" "github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace" + "github.com/TwiN/gatus/v5/alerting/provider/line" "github.com/TwiN/gatus/v5/alerting/provider/matrix" "github.com/TwiN/gatus/v5/alerting/provider/mattermost" "github.com/TwiN/gatus/v5/alerting/provider/messagebird" + "github.com/TwiN/gatus/v5/alerting/provider/newrelic" "github.com/TwiN/gatus/v5/alerting/provider/pagerduty" + "github.com/TwiN/gatus/v5/alerting/provider/plivo" "github.com/TwiN/gatus/v5/alerting/provider/pushover" + "github.com/TwiN/gatus/v5/alerting/provider/signl4" "github.com/TwiN/gatus/v5/alerting/provider/slack" "github.com/TwiN/gatus/v5/alerting/provider/teams" "github.com/TwiN/gatus/v5/alerting/provider/telegram" "github.com/TwiN/gatus/v5/alerting/provider/twilio" + "github.com/TwiN/gatus/v5/alerting/provider/vonage" + "github.com/TwiN/gatus/v5/alerting/provider/zapier" "github.com/TwiN/gatus/v5/config" "github.com/TwiN/gatus/v5/config/endpoint" ) @@ -268,6 +276,17 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) { }, }, }, + { + Name: "datadog", + AlertType: alert.TypeDatadog, + AlertingConfig: &alerting.Config{ + Datadog: &datadog.AlertProvider{ + DefaultConfig: datadog.Config{ + APIKey: "test-key", + }, + }, + }, + }, { Name: "discord", AlertType: alert.TypeDiscord, @@ -294,6 +313,18 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) { }, }, }, + { + Name: "ifttt", + AlertType: alert.TypeIFTTT, + AlertingConfig: &alerting.Config{ + IFTTT: &ifttt.AlertProvider{ + DefaultConfig: ifttt.Config{ + WebhookKey: "test-key", + EventName: "test-event", + }, + }, + }, + }, { Name: "jetbrainsspace", AlertType: alert.TypeJetBrainsSpace, @@ -307,6 +338,18 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) { }, }, }, + { + Name: "line", + AlertType: alert.TypeLine, + AlertingConfig: &alerting.Config{ + Line: &line.AlertProvider{ + DefaultConfig: line.Config{ + ChannelAccessToken: "test-token", + UserIDs: []string{"test-user"}, + }, + }, + }, + }, { Name: "mattermost", AlertType: alert.TypeMattermost, @@ -331,6 +374,18 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) { }, }, }, + { + Name: "newrelic", + AlertType: alert.TypeNewRelic, + AlertingConfig: &alerting.Config{ + NewRelic: &newrelic.AlertProvider{ + DefaultConfig: newrelic.Config{ + InsertKey: "test-key", + AccountID: "test-account", + }, + }, + }, + }, { Name: "pagerduty", AlertType: alert.TypePagerDuty, @@ -342,6 +397,20 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) { }, }, }, + { + Name: "plivo", + AlertType: alert.TypePlivo, + AlertingConfig: &alerting.Config{ + Plivo: &plivo.AlertProvider{ + DefaultConfig: plivo.Config{ + AuthID: "test-id", + AuthToken: "test-token", + From: "test-from", + To: []string{"test-to"}, + }, + }, + }, + }, { Name: "pushover", AlertType: alert.TypePushover, @@ -354,6 +423,17 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) { }, }, }, + { + Name: "signl4", + AlertType: alert.TypeSIGNL4, + AlertingConfig: &alerting.Config{ + SIGNL4: &signl4.AlertProvider{ + DefaultConfig: signl4.Config{ + TeamSecret: "test-secret", + }, + }, + }, + }, { Name: "slack", AlertType: alert.TypeSlack, @@ -402,6 +482,31 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) { }, }, }, + { + Name: "vonage", + AlertType: alert.TypeVonage, + AlertingConfig: &alerting.Config{ + Vonage: &vonage.AlertProvider{ + DefaultConfig: vonage.Config{ + APIKey: "test-key", + APISecret: "test-secret", + From: "test-from", + To: []string{"test-to"}, + }, + }, + }, + }, + { + Name: "zapier", + AlertType: alert.TypeZapier, + AlertingConfig: &alerting.Config{ + Zapier: &zapier.AlertProvider{ + DefaultConfig: zapier.Config{ + WebhookURL: "https://example.com", + }, + }, + }, + }, { Name: "matrix", AlertType: alert.TypeMatrix,