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
This commit is contained in:
Robin Moser
2025-07-15 02:23:59 +02:00
parent e99c10917d
commit f245869582
12 changed files with 313 additions and 16 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -247,6 +247,7 @@ Can be transposed to:
* [amqp](../notif/amqp.md) * [amqp](../notif/amqp.md)
* [apprise](../notif/apprise.md) * [apprise](../notif/apprise.md)
* [discord](../notif/discord.md) * [discord](../notif/discord.md)
* [elasticsearch](../notif/elasticsearch.md)
* [gotify](../notif/gotify.md) * [gotify](../notif/gotify.md)
* [mail](../notif/mail.md) * [mail](../notif/mail.md)
* [matrix](../notif/matrix.md) * [matrix](../notif/matrix.md)

View File

@@ -3,6 +3,7 @@
* [`amqp`](../notif/amqp.md) * [`amqp`](../notif/amqp.md)
* [`apprise`](../notif/apprise.md) * [`apprise`](../notif/apprise.md)
* [`discord`](../notif/discord.md) * [`discord`](../notif/discord.md)
* [`elasticsearch`](../notif/elasticsearch.md)
* [`gotify`](../notif/gotify.md) * [`gotify`](../notif/gotify.md)
* [`mail`](../notif/mail.md) * [`mail`](../notif/mail.md)
* [`matrix`](../notif/matrix.md) * [`matrix`](../notif/matrix.md)

View File

@@ -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

View File

@@ -94,6 +94,17 @@ func TestLoadFile(t *testing.T) {
Timeout: utl.NewDuration(10 * time.Second), Timeout: utl.NewDuration(10 * time.Second),
TemplateBody: model.NotifDefaultTemplateBody, 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{ Gotify: &model.NotifGotify{
Endpoint: "http://gotify.foo.com", Endpoint: "http://gotify.foo.com",
Token: "Token123456", Token: "Token123456",

View File

@@ -39,6 +39,16 @@ notif:
- "<@&200>" - "<@&200>"
renderFields: true renderFields: true
timeout: 10s timeout: 10s
elasticsearch:
scheme: https
host: localhost
port: 9200
username: elastic
password: password
client: diun
index: diun-notifications
timeout: 10s
insecureSkipVerify: false
gotify: gotify:
endpoint: http://gotify.foo.com endpoint: http://gotify.foo.com
token: Token123456 token: Token123456

View File

@@ -28,6 +28,16 @@ notif:
- "<@&200>" - "<@&200>"
renderFields: true renderFields: true
timeout: 10s timeout: 10s
elasticsearch:
scheme: https
host: localhost
port: 9200
username: elastic
password: password
client: diun
index: diun-notifications
timeout: 10s
insecureSkipVerify: false
gotify: gotify:
endpoint: http://gotify.foo.com endpoint: http://gotify.foo.com
token: Token123456 token: Token123456

View File

@@ -35,6 +35,7 @@ type Notif struct {
Amqp *NotifAmqp `yaml:"amqp,omitempty" json:"amqp,omitempty"` Amqp *NotifAmqp `yaml:"amqp,omitempty" json:"amqp,omitempty"`
Apprise *NotifApprise `yaml:"apprise,omitempty" json:"apprise,omitempty"` Apprise *NotifApprise `yaml:"apprise,omitempty" json:"apprise,omitempty"`
Discord *NotifDiscord `yaml:"discord,omitempty" json:"discord,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"` Gotify *NotifGotify `yaml:"gotify,omitempty" json:"gotify,omitempty"`
Mail *NotifMail `yaml:"mail,omitempty" json:"mail,omitempty"` Mail *NotifMail `yaml:"mail,omitempty" json:"mail,omitempty"`
Matrix *NotifMatrix `yaml:"matrix,omitempty" json:"matrix,omitempty"` Matrix *NotifMatrix `yaml:"matrix,omitempty" json:"matrix,omitempty"`

View File

@@ -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
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/crazy-max/diun/v4/internal/notif/amqp" "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/apprise"
"github.com/crazy-max/diun/v4/internal/notif/discord" "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/gotify"
"github.com/crazy-max/diun/v4/internal/notif/mail" "github.com/crazy-max/diun/v4/internal/notif/mail"
"github.com/crazy-max/diun/v4/internal/notif/matrix" "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 { if config.Discord != nil {
c.notifiers = append(c.notifiers, discord.New(config.Discord, meta)) 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 { if config.Gotify != nil {
c.notifiers = append(c.notifiers, gotify.New(config.Gotify, meta)) c.notifiers = append(c.notifiers, gotify.New(config.Gotify, meta))
} }

View File

@@ -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
}

View File

@@ -120,6 +120,7 @@ nav:
- Amqp: notif/amqp.md - Amqp: notif/amqp.md
- Apprise: notif/apprise.md - Apprise: notif/apprise.md
- Discord: notif/discord.md - Discord: notif/discord.md
- Elasticsearch: notif/elasticsearch.md
- Gotify: notif/gotify.md - Gotify: notif/gotify.md
- Mail: notif/mail.md - Mail: notif/mail.md
- Matrix: notif/matrix.md - Matrix: notif/matrix.md