From f2458695824f2d757a332696f6df38e7d954c39e Mon Sep 17 00:00:00 2001 From: Robin Moser Date: Tue, 15 Jul 2025 02:23:59 +0200 Subject: [PATCH] Add Elasticsearch notification Elsasticsearch notification: add test cases Elasticsearch notification: make timeout configurable Elsasticsearch notification: add @timestamp field to JSON data Elsasticsearch notification: improve error handling use context.WithTimeoutCause Co-authored-by: CrazyMax <1951866+crazy-max@users.noreply.github.com> better comment the elaticsearch api endpoint add screenshot to documentation --- docs/assets/notif/elasticsearch.png | Bin 0 -> 13831 bytes docs/config/index.md | 1 + docs/config/notif.md | 1 + docs/notif/elasticsearch.md | 82 +++++++++++ internal/config/config_test.go | 11 ++ internal/config/fixtures/config.test.yml | 10 ++ internal/config/fixtures/config.validate.yml | 10 ++ internal/model/notif.go | 33 ++--- internal/model/notif_elasticsearch.go | 39 ++++++ internal/notif/client.go | 4 + internal/notif/elasticsearch/client.go | 137 +++++++++++++++++++ mkdocs.yml | 1 + 12 files changed, 313 insertions(+), 16 deletions(-) create mode 100644 docs/assets/notif/elasticsearch.png create mode 100644 docs/notif/elasticsearch.md create mode 100644 internal/model/notif_elasticsearch.go create mode 100644 internal/notif/elasticsearch/client.go diff --git a/docs/assets/notif/elasticsearch.png b/docs/assets/notif/elasticsearch.png new file mode 100644 index 0000000000000000000000000000000000000000..0c9bb38552180ed237d5b1b839bf47353b0a20ad GIT binary patch literal 13831 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 literal 0 HcmV?d00001 diff --git a/docs/config/index.md b/docs/config/index.md index 47e8cbc3..e59ef187 100644 --- a/docs/config/index.md +++ b/docs/config/index.md @@ -247,6 +247,7 @@ Can be transposed to: * [amqp](../notif/amqp.md) * [apprise](../notif/apprise.md) * [discord](../notif/discord.md) + * [elasticsearch](../notif/elasticsearch.md) * [gotify](../notif/gotify.md) * [mail](../notif/mail.md) * [matrix](../notif/matrix.md) diff --git a/docs/config/notif.md b/docs/config/notif.md index ddfeac5a..b1e4097c 100644 --- a/docs/config/notif.md +++ b/docs/config/notif.md @@ -3,6 +3,7 @@ * [`amqp`](../notif/amqp.md) * [`apprise`](../notif/apprise.md) * [`discord`](../notif/discord.md) +* [`elasticsearch`](../notif/elasticsearch.md) * [`gotify`](../notif/gotify.md) * [`mail`](../notif/mail.md) * [`matrix`](../notif/matrix.md) diff --git a/docs/notif/elasticsearch.md b/docs/notif/elasticsearch.md new file mode 100644 index 00000000..62e8663d --- /dev/null +++ b/docs/notif/elasticsearch.md @@ -0,0 +1,82 @@ +# Elasticsearch notifications + +Send notifications to your Elasticsearch cluster as structured documents. + +## Configuration + +!!! example "File" + ```yaml + notif: + elasticsearch: + scheme: https + host: localhost + port: 9200 + username: elastic + password: password + client: diun + index: diun-notifications + timeout: 10s + insecureSkipVerify: false + ``` + +| Name | Default | Description | +| -------------------- | -------------------- | ------------------------------------------------------------------- | +| `scheme`[^1] | `http` | Elasticsearch scheme (`http` or `https`) | +| `host`[^1] | `localhost` | Elasticsearch host | +| `port`[^1] | `9200` | Elasticsearch port | +| `username` | | Elasticsearch username for authentication | +| `usernameFile` | | Use content of secret file as username if `username` is not defined | +| `password` | | Elasticsearch password for authentication | +| `passwordFile` | | Use content of secret file as password if `password` is not defined | +| `client`[^1] | `diun` | Client name to identify the source of notifications | +| `index`[^1] | `diun-notifications` | Elasticsearch index name where notifications will be stored | +| `timeout`[^1] | `10s` | Timeout specifies a time limit for the request to be made | +| `insecureSkipVerify` | `false` | Skip TLS certificate verification | + +!!! abstract "Environment variables" + * `DIUN_NOTIF_ELASTICSEARCH_SCHEME` + * `DIUN_NOTIF_ELASTICSEARCH_HOST` + * `DIUN_NOTIF_ELASTICSEARCH_PORT` + * `DIUN_NOTIF_ELASTICSEARCH_USERNAME` + * `DIUN_NOTIF_ELASTICSEARCH_USERNAMEFILE` + * `DIUN_NOTIF_ELASTICSEARCH_PASSWORD` + * `DIUN_NOTIF_ELASTICSEARCH_PASSWORDFILE` + * `DIUN_NOTIF_ELASTICSEARCH_CLIENT` + * `DIUN_NOTIF_ELASTICSEARCH_INDEX` + * `DIUN_NOTIF_ELASTICSEARCH_TIMEOUT` + * `DIUN_NOTIF_ELASTICSEARCH_INSECURESKIPVERIFY` + +## Document Structure + +Each notification is stored as a JSON document with following structure: + +```json +{ + "diun_version": "4.24.0", + "hostname": "myserver", + "status": "new", + "provider": "file", + "image": "docker.io/crazymax/diun:latest", + "hub_link": "https://hub.docker.com/r/crazymax/diun", + "mime_type": "application/vnd.docker.distribution.manifest.list.v2+json", + "digest": "sha256:216e3ae7de4ca8b553eb11ef7abda00651e79e537e85c46108284e5e91673e01", + "created": "2020-03-26T12:23:56Z", + "platform": "linux/amd64", + "client": "diun", + "metadata": { + "ctn_command": "diun serve", + "ctn_createdat": "2022-12-29 10:22:15 +0100 CET", + "ctn_id": "0dbd10e15b31add2c48856fd34451adabf50d276efa466fe19a8ef5fbd87ad7c", + "ctn_names": "diun", + "ctn_size": "0B", + "ctn_state": "running", + "ctn_status": "Up Less than a second (health: starting)" + } +} +``` + +## Sample + +![](../assets/notif/elasticsearch.png) + +[^1]: Value required diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d3b0a5d4..07ee313e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -94,6 +94,17 @@ func TestLoadFile(t *testing.T) { Timeout: utl.NewDuration(10 * time.Second), TemplateBody: model.NotifDefaultTemplateBody, }, + Elasticsearch: &model.NotifElasticsearch{ + Scheme: "https", + Host: "localhost", + Port: 9200, + Username: "elastic", + Password: "password", + Client: "diun", + Index: "diun-notifications", + Timeout: utl.NewDuration(10 * time.Second), + InsecureSkipVerify: false, + }, Gotify: &model.NotifGotify{ Endpoint: "http://gotify.foo.com", Token: "Token123456", diff --git a/internal/config/fixtures/config.test.yml b/internal/config/fixtures/config.test.yml index cf9d6592..73052b54 100644 --- a/internal/config/fixtures/config.test.yml +++ b/internal/config/fixtures/config.test.yml @@ -39,6 +39,16 @@ notif: - "<@&200>" renderFields: true timeout: 10s + elasticsearch: + scheme: https + host: localhost + port: 9200 + username: elastic + password: password + client: diun + index: diun-notifications + timeout: 10s + insecureSkipVerify: false gotify: endpoint: http://gotify.foo.com token: Token123456 diff --git a/internal/config/fixtures/config.validate.yml b/internal/config/fixtures/config.validate.yml index 0c5576dc..d6738dbe 100644 --- a/internal/config/fixtures/config.validate.yml +++ b/internal/config/fixtures/config.validate.yml @@ -28,6 +28,16 @@ notif: - "<@&200>" renderFields: true timeout: 10s + elasticsearch: + scheme: https + host: localhost + port: 9200 + username: elastic + password: password + client: diun + index: diun-notifications + timeout: 10s + insecureSkipVerify: false gotify: endpoint: http://gotify.foo.com token: Token123456 diff --git a/internal/model/notif.go b/internal/model/notif.go index 7261a624..9ef9b073 100644 --- a/internal/model/notif.go +++ b/internal/model/notif.go @@ -32,22 +32,23 @@ type NotifEntry struct { // Notif holds data necessary for notification configuration type Notif struct { - Amqp *NotifAmqp `yaml:"amqp,omitempty" json:"amqp,omitempty"` - Apprise *NotifApprise `yaml:"apprise,omitempty" json:"apprise,omitempty"` - Discord *NotifDiscord `yaml:"discord,omitempty" json:"discord,omitempty"` - Gotify *NotifGotify `yaml:"gotify,omitempty" json:"gotify,omitempty"` - Mail *NotifMail `yaml:"mail,omitempty" json:"mail,omitempty"` - Matrix *NotifMatrix `yaml:"matrix,omitempty" json:"matrix,omitempty"` - Mqtt *NotifMqtt `yaml:"mqtt,omitempty" json:"mqtt,omitempty"` - Ntfy *NotifNtfy `yaml:"ntfy,omitempty" json:"ntfy,omitempty"` - Pushover *NotifPushover `yaml:"pushover,omitempty" json:"pushover,omitempty"` - RocketChat *NotifRocketChat `yaml:"rocketchat,omitempty" json:"rocketchat,omitempty"` - Script *NotifScript `yaml:"script,omitempty" json:"script,omitempty"` - SignalRest *NotifSignalRest `yaml:"signalrest,omitempty" json:"signalrest,omitempty"` - Slack *NotifSlack `yaml:"slack,omitempty" json:"slack,omitempty"` - Teams *NotifTeams `yaml:"teams,omitempty" json:"teams,omitempty"` - Telegram *NotifTelegram `yaml:"telegram,omitempty" json:"telegram,omitempty"` - Webhook *NotifWebhook `yaml:"webhook,omitempty" json:"webhook,omitempty"` + Amqp *NotifAmqp `yaml:"amqp,omitempty" json:"amqp,omitempty"` + Apprise *NotifApprise `yaml:"apprise,omitempty" json:"apprise,omitempty"` + Discord *NotifDiscord `yaml:"discord,omitempty" json:"discord,omitempty"` + Elasticsearch *NotifElasticsearch `yaml:"elasticsearch,omitempty" json:"elasticsearch,omitempty"` + Gotify *NotifGotify `yaml:"gotify,omitempty" json:"gotify,omitempty"` + Mail *NotifMail `yaml:"mail,omitempty" json:"mail,omitempty"` + Matrix *NotifMatrix `yaml:"matrix,omitempty" json:"matrix,omitempty"` + Mqtt *NotifMqtt `yaml:"mqtt,omitempty" json:"mqtt,omitempty"` + Ntfy *NotifNtfy `yaml:"ntfy,omitempty" json:"ntfy,omitempty"` + Pushover *NotifPushover `yaml:"pushover,omitempty" json:"pushover,omitempty"` + RocketChat *NotifRocketChat `yaml:"rocketchat,omitempty" json:"rocketchat,omitempty"` + Script *NotifScript `yaml:"script,omitempty" json:"script,omitempty"` + SignalRest *NotifSignalRest `yaml:"signalrest,omitempty" json:"signalrest,omitempty"` + Slack *NotifSlack `yaml:"slack,omitempty" json:"slack,omitempty"` + Teams *NotifTeams `yaml:"teams,omitempty" json:"teams,omitempty"` + Telegram *NotifTelegram `yaml:"telegram,omitempty" json:"telegram,omitempty"` + Webhook *NotifWebhook `yaml:"webhook,omitempty" json:"webhook,omitempty"` } // GetDefaults gets the default values diff --git a/internal/model/notif_elasticsearch.go b/internal/model/notif_elasticsearch.go new file mode 100644 index 00000000..8872330f --- /dev/null +++ b/internal/model/notif_elasticsearch.go @@ -0,0 +1,39 @@ +package model + +import ( + "time" + + "github.com/crazy-max/diun/v4/pkg/utl" +) + +type NotifElasticsearch struct { + Scheme string `yaml:"scheme,omitempty" json:"scheme,omitempty" validate:"required,oneof=http https"` + Host string `yaml:"host,omitempty" json:"host,omitempty" validate:"required"` + Port int `yaml:"port,omitempty" json:"port,omitempty" validate:"required,min=1"` + Username string `yaml:"username,omitempty" json:"username,omitempty" validate:"omitempty"` + UsernameFile string `yaml:"usernameFile,omitempty" json:"usernameFile,omitempty" validate:"omitempty,file"` + Password string `yaml:"password,omitempty" json:"password,omitempty" validate:"omitempty"` + PasswordFile string `yaml:"passwordFile,omitempty" json:"passwordFile,omitempty" validate:"omitempty,file"` + Client string `yaml:"client,omitempty" json:"client,omitempty" validate:"required"` + Index string `yaml:"index,omitempty" json:"index,omitempty" validate:"required"` + Timeout *time.Duration `yaml:"timeout,omitempty" json:"timeout,omitempty" validate:"required"` + InsecureSkipVerify bool `yaml:"insecureSkipVerify,omitempty" json:"insecureSkipVerify,omitempty" validate:"omitempty"` +} + +// GetDefaults gets the default values +func (s *NotifElasticsearch) GetDefaults() *NotifElasticsearch { + n := &NotifElasticsearch{} + n.SetDefaults() + return n +} + +// SetDefaults sets the default values +func (s *NotifElasticsearch) SetDefaults() { + s.Scheme = "http" + s.Host = "localhost" + s.Port = 9200 + s.Client = "diun" + s.Index = "diun-notifications" + s.Timeout = utl.NewDuration(10 * time.Second) + s.InsecureSkipVerify = false +} diff --git a/internal/notif/client.go b/internal/notif/client.go index fc71ed5b..930be373 100644 --- a/internal/notif/client.go +++ b/internal/notif/client.go @@ -7,6 +7,7 @@ import ( "github.com/crazy-max/diun/v4/internal/notif/amqp" "github.com/crazy-max/diun/v4/internal/notif/apprise" "github.com/crazy-max/diun/v4/internal/notif/discord" + "github.com/crazy-max/diun/v4/internal/notif/elasticsearch" "github.com/crazy-max/diun/v4/internal/notif/gotify" "github.com/crazy-max/diun/v4/internal/notif/mail" "github.com/crazy-max/diun/v4/internal/notif/matrix" @@ -54,6 +55,9 @@ func New(config *model.Notif, meta model.Meta) (*Client, error) { if config.Discord != nil { c.notifiers = append(c.notifiers, discord.New(config.Discord, meta)) } + if config.Elasticsearch != nil { + c.notifiers = append(c.notifiers, elasticsearch.New(config.Elasticsearch, meta)) + } if config.Gotify != nil { c.notifiers = append(c.notifiers, gotify.New(config.Gotify, meta)) } diff --git a/internal/notif/elasticsearch/client.go b/internal/notif/elasticsearch/client.go new file mode 100644 index 00000000..929936c5 --- /dev/null +++ b/internal/notif/elasticsearch/client.go @@ -0,0 +1,137 @@ +package elasticsearch + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/crazy-max/diun/v4/internal/model" + "github.com/crazy-max/diun/v4/internal/msg" + "github.com/crazy-max/diun/v4/internal/notif/notifier" + "github.com/crazy-max/diun/v4/pkg/utl" + "github.com/pkg/errors" +) + +// Client represents an active elasticsearch notification object +type Client struct { + *notifier.Notifier + cfg *model.NotifElasticsearch + meta model.Meta +} + +// New creates a new elasticsearch notification instance +func New(config *model.NotifElasticsearch, meta model.Meta) notifier.Notifier { + return notifier.Notifier{ + Handler: &Client{ + cfg: config, + meta: meta, + }, + } +} + +// Name returns notifier's name +func (c *Client) Name() string { + return "elasticsearch" +} + +// Send creates and sends an elasticsearch notification with an entry +func (c *Client) Send(entry model.NotifEntry) error { + username, err := utl.GetSecret(c.cfg.Username, c.cfg.UsernameFile) + if err != nil { + return err + } + + password, err := utl.GetSecret(c.cfg.Password, c.cfg.PasswordFile) + if err != nil { + return err + } + + // Use the same JSON structure as webhook notifier + message, err := msg.New(msg.Options{ + Meta: c.meta, + Entry: entry, + }) + if err != nil { + return err + } + + body, err := message.RenderJSON() + if err != nil { + return err + } + + // Parse the JSON to add the client field + var doc map[string]any + if err := json.Unmarshal(body, &doc); err != nil { + return err + } + + // Add the current time + doc["@timestamp"] = time.Now().Format(time.RFC3339Nano) + + // Add the client field from the configuration + doc["client"] = c.cfg.Client + + // Re-marshal the JSON with the client field + body, err = json.Marshal(doc) + if err != nil { + return err + } + + // Build the Elasticsearch indexing URL + // This uses the Index API (POST /{index}/_doc) to create a document with an auto-generated _id: + // https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-create + url := fmt.Sprintf("%s://%s:%d/%s/_doc", c.cfg.Scheme, c.cfg.Host, c.cfg.Port, c.cfg.Index) + + cancelCtx, cancel := context.WithCancelCause(context.Background()) + timeoutCtx, _ := context.WithTimeoutCause(cancelCtx, *c.cfg.Timeout, errors.WithStack(context.DeadlineExceeded)) //nolint:govet // no need to manually cancel this context as we already rely on parent + defer func() { cancel(errors.WithStack(context.Canceled)) }() + + hc := http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: c.cfg.InsecureSkipVerify, + }, + }, + } + + req, err := http.NewRequestWithContext(timeoutCtx, "POST", url, bytes.NewBuffer(body)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", c.meta.UserAgent) + + // Add authentication if provided + if username != "" && password != "" { + req.SetBasicAuth(username, password) + } + + resp, err := hc.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + var errBody struct { + Status int `json:"status"` + Error struct { + Type string `json:"type"` + Reason string `json:"reason"` + } `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&errBody); err != nil { + return errors.Wrapf(err, "cannot decode JSON error response for HTTP %d %s status", + resp.StatusCode, http.StatusText(resp.StatusCode)) + } + return errors.Errorf("%d %s: %s", errBody.Status, errBody.Error.Type, errBody.Error.Reason) + } + + return nil +} diff --git a/mkdocs.yml b/mkdocs.yml index 23016ad7..5aa8e040 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -120,6 +120,7 @@ nav: - Amqp: notif/amqp.md - Apprise: notif/apprise.md - Discord: notif/discord.md + - Elasticsearch: notif/elasticsearch.md - Gotify: notif/gotify.md - Mail: notif/mail.md - Matrix: notif/matrix.md