diff --git a/README.md b/README.md index a809ae42..bc52cf04 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga - [Configuring GitLab alerts](#configuring-gitlab-alerts) - [Configuring Google Chat alerts](#configuring-google-chat-alerts) - [Configuring Gotify alerts](#configuring-gotify-alerts) + - [Configuring HomeAssistant alerts](#configuring-homeassistant-alerts) - [Configuring Incident.io alerts](#configuring-incidentio-alerts) - [Configuring JetBrains Space alerts](#configuring-jetbrains-space-alerts) - [Configuring Matrix alerts](#configuring-matrix-alerts) @@ -604,6 +605,7 @@ endpoints: | `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.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 @@ -920,6 +922,75 @@ Here's an example of what the notifications look like: ![Gotify notifications](.github/assets/gotify-alerts.png) +#### Configuring HomeAssistant alerts +To configure HomeAssistant alerts, you'll need to add the following to your configuration file: + +```yaml +alerting: + homeassistant: + url: "http://homeassistant:8123" # URL of your HomeAssistant instance + token: "YOUR_LONG_LIVED_ACCESS_TOKEN" # Long-lived access token from HomeAssistant + +endpoints: + - name: my-service + url: "https://my-service.com" + interval: 5m + conditions: + - "[STATUS] == 200" + alerts: + - type: homeassistant + enabled: true + send-on-resolved: true + description: "My service health check" + failure-threshold: 3 + success-threshold: 2 +``` + +The alerts will be sent as events to HomeAssistant with the event type `gatus_alert`. The event data includes: +- `status`: "triggered" or "resolved" +- `endpoint`: The name of the monitored endpoint +- `description`: The alert description if provided +- `conditions`: List of conditions and their results +- `failure_count`: Number of consecutive failures (when triggered) +- `success_count`: Number of consecutive successes (when resolved) + +You can use these events in HomeAssistant automations to: +- Send notifications +- Control devices +- Trigger scenes +- Log to history +- And more + +Example HomeAssistant automation: +```yaml +automation: + - alias: "Gatus Alert Handler" + trigger: + platform: event + event_type: gatus_alert + action: + - service: notify.notify + data_template: + title: "Gatus Alert: {{ trigger.event.data.endpoint }}" + message: > + Status: {{ trigger.event.data.status }} + {% if trigger.event.data.description %} + Description: {{ trigger.event.data.description }} + {% endif %} + {% for condition in trigger.event.data.conditions %} + {{ '✅' if condition.success else '❌' }} {{ condition.condition }} + {% endfor %} +``` + +To get your HomeAssistant long-lived access token: +1. Open HomeAssistant +2. Click on your profile name (bottom left) +3. Scroll down to "Long-Lived Access Tokens" +4. Click "Create Token" +5. Give it a name (e.g., "Gatus") +6. Copy the token - you'll only see it once! + + #### Configuring Incident.io alerts | Parameter | Description | Default | |:-----------------------------------------|:-------------------------------------------------------------------------------------------|:--------------| diff --git a/alerting/alert/type.go b/alerting/alert/type.go index 3875d4d8..1f36d6e1 100644 --- a/alerting/alert/type.go +++ b/alerting/alert/type.go @@ -32,6 +32,9 @@ const ( // TypeGotify is the Type for the gotify alerting provider TypeGotify Type = "gotify" + // TypeHomeAssistant is the Type for the homeassistant alerting provider + TypeHomeAssistant Type = "homeassistant" + // TypeIncidentIO is the Type for the incident-io alerting provider TypeIncidentIO Type = "incident-io" diff --git a/alerting/config.go b/alerting/config.go index 8378a840..58c1d647 100644 --- a/alerting/config.go +++ b/alerting/config.go @@ -15,6 +15,7 @@ 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/incidentio" "github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace" "github.com/TwiN/gatus/v5/alerting/provider/matrix" @@ -62,6 +63,9 @@ type Config struct { // Gotify is the configuration for the gotify alerting provider Gotify *gotify.AlertProvider `yaml:"gotify,omitempty"` + // HomeAssistant is the configuration for the homeassistant alerting provider + HomeAssistant *homeassistant.AlertProvider `yaml:"homeassistant,omitempty"` + // IncidentIO is the configuration for the incident-io alerting provider IncidentIO *incidentio.AlertProvider `yaml:"incident-io,omitempty"` diff --git a/alerting/provider/homeassistant/homeassistant.go b/alerting/provider/homeassistant/homeassistant.go new file mode 100644 index 00000000..1b4ad684 --- /dev/null +++ b/alerting/provider/homeassistant/homeassistant.go @@ -0,0 +1,196 @@ +package homeassistant + +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 ( + ErrURLNotSet = errors.New("url not set") + ErrTokenNotSet = errors.New("token not set") + ErrDuplicateGroupOverride = errors.New("duplicate group override") +) + +type Config struct { + URL string `yaml:"url"` + Token string `yaml:"token"` +} + +func (cfg *Config) Validate() error { + if len(cfg.URL) == 0 { + return ErrURLNotSet + } + if len(cfg.Token) == 0 { + return ErrTokenNotSet + } + return nil +} + +func (cfg *Config) Merge(override *Config) { + if len(override.URL) > 0 { + cfg.URL = override.URL + } + if len(override.Token) > 0 { + cfg.Token = override.Token + } +} + +// AlertProvider is the configuration necessary for sending an alert using HomeAssistant +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 + } + buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved)) + request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/events/gatus_alert", cfg.URL), buffer) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Authorization", "Bearer "+cfg.Token) + response, err := client.GetHTTPClient(nil).Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode > 399 { + body, _ := io.ReadAll(response.Body) + return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body)) + } + return err +} + +type Body struct { + EventType string `json:"event_type"` + EventData struct { + Status string `json:"status"` + Endpoint string `json:"endpoint"` + Description string `json:"description,omitempty"` + Conditions []struct { + Condition string `json:"condition"` + Success bool `json:"success"` + } `json:"conditions,omitempty"` + FailureCount int `json:"failure_count,omitempty"` + SuccessCount int `json:"success_count,omitempty"` + } `json:"event_data"` +} + +// buildRequestBody builds the request body for the provider +func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { + body := Body{ + EventType: "gatus_alert", + EventData: struct { + Status string `json:"status"` + Endpoint string `json:"endpoint"` + Description string `json:"description,omitempty"` + Conditions []struct { + Condition string `json:"condition"` + Success bool `json:"success"` + } `json:"conditions,omitempty"` + FailureCount int `json:"failure_count,omitempty"` + SuccessCount int `json:"success_count,omitempty"` + }{ + Status: "resolved", + Endpoint: ep.DisplayName(), + }, + } + + if !resolved { + body.EventData.Status = "triggered" + body.EventData.FailureCount = alert.FailureThreshold + } else { + body.EventData.SuccessCount = alert.SuccessThreshold + } + + if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { + body.EventData.Description = alertDescription + } + + if len(result.ConditionResults) > 0 { + for _, conditionResult := range result.ConditionResults { + body.EventData.Conditions = append(body.EventData.Conditions, struct { + Condition string `json:"condition"` + Success bool `json:"success"` + }{ + Condition: conditionResult.Condition, + Success: conditionResult.Success, + }) + } + } + + bodyAsJSON, _ := json.Marshal(body) + return bodyAsJSON +} + +// 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 + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if group == override.Group { + cfg.Merge(&override.Config) + break + } + } + } + if len(alert.ProviderOverride) != 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) + } + 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/homeassistant/homeassistant_test.go b/alerting/provider/homeassistant/homeassistant_test.go new file mode 100644 index 00000000..3bc04ac5 --- /dev/null +++ b/alerting/provider/homeassistant/homeassistant_test.go @@ -0,0 +1,158 @@ +package homeassistant + +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{URL: "", Token: ""}} + if err := invalidProvider.Validate(); err == nil { + t.Error("provider shouldn't have been valid") + } + invalidProviderNoToken := AlertProvider{DefaultConfig: Config{URL: "http://homeassistant:8123", Token: ""}} + if err := invalidProviderNoToken.Validate(); err == nil { + t.Error("provider shouldn't have been valid") + } + validProvider := AlertProvider{DefaultConfig: Config{URL: "http://homeassistant:8123", Token: "token"}} + 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{URL: "http://homeassistant:8123", Token: "token"}, + Group: "", + }, + }, + } + if err := providerWithInvalidOverrideGroup.Validate(); err == nil { + t.Error("provider Group shouldn't have been valid") + } + providerWithValidOverride := AlertProvider{ + DefaultConfig: Config{URL: "http://homeassistant:8123", Token: "token"}, + Overrides: []Override{ + { + Config: Config{URL: "http://homeassistant:8123", Token: "token"}, + 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{URL: "http://homeassistant:8123", Token: "token"}}, + 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{URL: "http://homeassistant:8123", Token: "token"}}, + 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{URL: "http://homeassistant:8123", Token: "token"}}, + 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, + }, + } + 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: "SUCCESSFUL_CONDITION", Success: true}, + {Condition: "FAILING_CONDITION", Success: false}, + }, + }, + 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) { + description := "test-description" + provider := AlertProvider{DefaultConfig: Config{URL: "http://homeassistant:8123", Token: "token"}} + body := provider.buildRequestBody( + &endpoint.Endpoint{Name: "endpoint-name"}, + &alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, + &endpoint.Result{ + ConditionResults: []*endpoint.ConditionResult{ + {Condition: "SUCCESSFUL_CONDITION", Success: true}, + {Condition: "FAILING_CONDITION", Success: false}, + }, + }, + false, + ) + var decodedBody Body + if err := json.Unmarshal(body, &decodedBody); err != nil { + t.Error("expected body to be valid JSON, got error:", err.Error()) + } + if decodedBody.EventType != "gatus_alert" { + t.Errorf("expected event_type to be gatus_alert, got %s", decodedBody.EventType) + } + if decodedBody.EventData.Status != "triggered" { + t.Errorf("expected status to be triggered, got %s", decodedBody.EventData.Status) + } + if decodedBody.EventData.Description != description { + t.Errorf("expected description to be %s, got %s", description, decodedBody.EventData.Description) + } + if len(decodedBody.EventData.Conditions) != 2 { + t.Errorf("expected 2 conditions, got %d", len(decodedBody.EventData.Conditions)) + } + if !decodedBody.EventData.Conditions[0].Success { + t.Error("expected first condition to be successful") + } + if decodedBody.EventData.Conditions[1].Success { + t.Error("expected second condition to be unsuccessful") + } +} diff --git a/alerting/provider/provider.go b/alerting/provider/provider.go index 94e600f0..b1c3bfaa 100644 --- a/alerting/provider/provider.go +++ b/alerting/provider/provider.go @@ -10,6 +10,7 @@ import ( "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/homeassistant" "github.com/TwiN/gatus/v5/alerting/provider/incidentio" "github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace" "github.com/TwiN/gatus/v5/alerting/provider/matrix" @@ -80,6 +81,7 @@ var ( _ AlertProvider = (*github.AlertProvider)(nil) _ AlertProvider = (*gitlab.AlertProvider)(nil) _ AlertProvider = (*googlechat.AlertProvider)(nil) + _ AlertProvider = (*homeassistant.AlertProvider)(nil) _ AlertProvider = (*jetbrainsspace.AlertProvider)(nil) _ AlertProvider = (*matrix.AlertProvider)(nil) _ AlertProvider = (*mattermost.AlertProvider)(nil) @@ -105,6 +107,7 @@ var ( _ Config[github.Config] = (*github.Config)(nil) _ Config[gitlab.Config] = (*gitlab.Config)(nil) _ Config[googlechat.Config] = (*googlechat.Config)(nil) + _ Config[homeassistant.Config] = (*homeassistant.Config)(nil) _ Config[incidentio.Config] = (*incidentio.Config)(nil) _ Config[jetbrainsspace.Config] = (*jetbrainsspace.Config)(nil) _ Config[matrix.Config] = (*matrix.Config)(nil) diff --git a/config/config.go b/config/config.go index 701dd84e..e31a4225 100644 --- a/config/config.go +++ b/config/config.go @@ -411,6 +411,7 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi alert.TypeGitea, alert.TypeGoogleChat, alert.TypeGotify, + alert.TypeHomeAssistant, alert.TypeJetBrainsSpace, alert.TypeMatrix, alert.TypeMattermost,