Skip to content

Commit f0f141e

Browse files
committed
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
1 parent e99c109 commit f0f141e

File tree

11 files changed

+309
-16
lines changed

11 files changed

+309
-16
lines changed

docs/config/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,7 @@ Can be transposed to:
247247
* [amqp](../notif/amqp.md)
248248
* [apprise](../notif/apprise.md)
249249
* [discord](../notif/discord.md)
250+
* [elasticsearch](../notif/elasticsearch.md)
250251
* [gotify](../notif/gotify.md)
251252
* [mail](../notif/mail.md)
252253
* [matrix](../notif/matrix.md)

docs/config/notif.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* [`amqp`](../notif/amqp.md)
44
* [`apprise`](../notif/apprise.md)
55
* [`discord`](../notif/discord.md)
6+
* [`elasticsearch`](../notif/elasticsearch.md)
67
* [`gotify`](../notif/gotify.md)
78
* [`mail`](../notif/mail.md)
89
* [`matrix`](../notif/matrix.md)

docs/notif/elasticsearch.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Elasticsearch notifications
2+
3+
Send notifications to your Elasticsearch cluster as structured documents.
4+
5+
## Configuration
6+
7+
!!! example "File"
8+
```yaml
9+
notif:
10+
elasticsearch:
11+
scheme: https
12+
host: localhost
13+
port: 9200
14+
username: elastic
15+
password: password
16+
client: diun
17+
index: diun-notifications
18+
timeout: 10s
19+
insecureSkipVerify: false
20+
```
21+
22+
| Name | Default | Description |
23+
| -------------------- | -------------------- | ------------------------------------------------------------------- |
24+
| `scheme`[^1] | `http` | Elasticsearch scheme (`http` or `https`) |
25+
| `host`[^1] | `localhost` | Elasticsearch host |
26+
| `port`[^1] | `9200` | Elasticsearch port |
27+
| `username` | | Elasticsearch username for authentication |
28+
| `usernameFile` | | Use content of secret file as username if `username` is not defined |
29+
| `password` | | Elasticsearch password for authentication |
30+
| `passwordFile` | | Use content of secret file as password if `password` is not defined |
31+
| `client`[^1] | `diun` | Client name to identify the source of notifications |
32+
| `index`[^1] | `diun-notifications` | Elasticsearch index name where notifications will be stored |
33+
| `timeout`[^1] | `10s` | Timeout specifies a time limit for the request to be made |
34+
| `insecureSkipVerify` | `false` | Skip TLS certificate verification |
35+
36+
!!! abstract "Environment variables"
37+
* `DIUN_NOTIF_ELASTICSEARCH_SCHEME`
38+
* `DIUN_NOTIF_ELASTICSEARCH_HOST`
39+
* `DIUN_NOTIF_ELASTICSEARCH_PORT`
40+
* `DIUN_NOTIF_ELASTICSEARCH_USERNAME`
41+
* `DIUN_NOTIF_ELASTICSEARCH_USERNAMEFILE`
42+
* `DIUN_NOTIF_ELASTICSEARCH_PASSWORD`
43+
* `DIUN_NOTIF_ELASTICSEARCH_PASSWORDFILE`
44+
* `DIUN_NOTIF_ELASTICSEARCH_CLIENT`
45+
* `DIUN_NOTIF_ELASTICSEARCH_INDEX`
46+
* `DIUN_NOTIF_ELASTICSEARCH_TIMEOUT`
47+
* `DIUN_NOTIF_ELASTICSEARCH_INSECURESKIPVERIFY`
48+
49+
## Document Structure
50+
51+
Each notification is stored as a JSON document with following structure:
52+
53+
```json
54+
{
55+
"diun_version": "4.24.0",
56+
"hostname": "myserver",
57+
"status": "new",
58+
"provider": "file",
59+
"image": "docker.io/crazymax/diun:latest",
60+
"hub_link": "https://hub.docker.com/r/crazymax/diun",
61+
"mime_type": "application/vnd.docker.distribution.manifest.list.v2+json",
62+
"digest": "sha256:216e3ae7de4ca8b553eb11ef7abda00651e79e537e85c46108284e5e91673e01",
63+
"created": "2020-03-26T12:23:56Z",
64+
"platform": "linux/amd64",
65+
"client": "diun",
66+
"metadata": {
67+
"ctn_command": "diun serve",
68+
"ctn_createdat": "2022-12-29 10:22:15 +0100 CET",
69+
"ctn_id": "0dbd10e15b31add2c48856fd34451adabf50d276efa466fe19a8ef5fbd87ad7c",
70+
"ctn_names": "diun",
71+
"ctn_size": "0B",
72+
"ctn_state": "running",
73+
"ctn_status": "Up Less than a second (health: starting)"
74+
}
75+
}
76+
```
77+
78+
[^1]: Value required

internal/config/config_test.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,17 @@ func TestLoadFile(t *testing.T) {
9494
Timeout: utl.NewDuration(10 * time.Second),
9595
TemplateBody: model.NotifDefaultTemplateBody,
9696
},
97+
Elasticsearch: &model.NotifElasticsearch{
98+
Scheme: "https",
99+
Host: "localhost",
100+
Port: 9200,
101+
Username: "elastic",
102+
Password: "password",
103+
Client: "diun",
104+
Index: "diun-notifications",
105+
Timeout: utl.NewDuration(10 * time.Second),
106+
InsecureSkipVerify: false,
107+
},
97108
Gotify: &model.NotifGotify{
98109
Endpoint: "http://gotify.foo.com",
99110
Token: "Token123456",

internal/config/fixtures/config.test.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,16 @@ notif:
3939
- "<@&200>"
4040
renderFields: true
4141
timeout: 10s
42+
elasticsearch:
43+
scheme: https
44+
host: localhost
45+
port: 9200
46+
username: elastic
47+
password: password
48+
client: diun
49+
index: diun-notifications
50+
timeout: 10s
51+
insecureSkipVerify: false
4252
gotify:
4353
endpoint: http://gotify.foo.com
4454
token: Token123456

internal/config/fixtures/config.validate.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,16 @@ notif:
2828
- "<@&200>"
2929
renderFields: true
3030
timeout: 10s
31+
elasticsearch:
32+
scheme: https
33+
host: localhost
34+
port: 9200
35+
username: elastic
36+
password: password
37+
client: diun
38+
index: diun-notifications
39+
timeout: 10s
40+
insecureSkipVerify: false
3141
gotify:
3242
endpoint: http://gotify.foo.com
3343
token: Token123456

internal/model/notif.go

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -32,22 +32,23 @@ type NotifEntry struct {
3232

3333
// Notif holds data necessary for notification configuration
3434
type Notif struct {
35-
Amqp *NotifAmqp `yaml:"amqp,omitempty" json:"amqp,omitempty"`
36-
Apprise *NotifApprise `yaml:"apprise,omitempty" json:"apprise,omitempty"`
37-
Discord *NotifDiscord `yaml:"discord,omitempty" json:"discord,omitempty"`
38-
Gotify *NotifGotify `yaml:"gotify,omitempty" json:"gotify,omitempty"`
39-
Mail *NotifMail `yaml:"mail,omitempty" json:"mail,omitempty"`
40-
Matrix *NotifMatrix `yaml:"matrix,omitempty" json:"matrix,omitempty"`
41-
Mqtt *NotifMqtt `yaml:"mqtt,omitempty" json:"mqtt,omitempty"`
42-
Ntfy *NotifNtfy `yaml:"ntfy,omitempty" json:"ntfy,omitempty"`
43-
Pushover *NotifPushover `yaml:"pushover,omitempty" json:"pushover,omitempty"`
44-
RocketChat *NotifRocketChat `yaml:"rocketchat,omitempty" json:"rocketchat,omitempty"`
45-
Script *NotifScript `yaml:"script,omitempty" json:"script,omitempty"`
46-
SignalRest *NotifSignalRest `yaml:"signalrest,omitempty" json:"signalrest,omitempty"`
47-
Slack *NotifSlack `yaml:"slack,omitempty" json:"slack,omitempty"`
48-
Teams *NotifTeams `yaml:"teams,omitempty" json:"teams,omitempty"`
49-
Telegram *NotifTelegram `yaml:"telegram,omitempty" json:"telegram,omitempty"`
50-
Webhook *NotifWebhook `yaml:"webhook,omitempty" json:"webhook,omitempty"`
35+
Amqp *NotifAmqp `yaml:"amqp,omitempty" json:"amqp,omitempty"`
36+
Apprise *NotifApprise `yaml:"apprise,omitempty" json:"apprise,omitempty"`
37+
Discord *NotifDiscord `yaml:"discord,omitempty" json:"discord,omitempty"`
38+
Elasticsearch *NotifElasticsearch `yaml:"elasticsearch,omitempty" json:"elasticsearch,omitempty"`
39+
Gotify *NotifGotify `yaml:"gotify,omitempty" json:"gotify,omitempty"`
40+
Mail *NotifMail `yaml:"mail,omitempty" json:"mail,omitempty"`
41+
Matrix *NotifMatrix `yaml:"matrix,omitempty" json:"matrix,omitempty"`
42+
Mqtt *NotifMqtt `yaml:"mqtt,omitempty" json:"mqtt,omitempty"`
43+
Ntfy *NotifNtfy `yaml:"ntfy,omitempty" json:"ntfy,omitempty"`
44+
Pushover *NotifPushover `yaml:"pushover,omitempty" json:"pushover,omitempty"`
45+
RocketChat *NotifRocketChat `yaml:"rocketchat,omitempty" json:"rocketchat,omitempty"`
46+
Script *NotifScript `yaml:"script,omitempty" json:"script,omitempty"`
47+
SignalRest *NotifSignalRest `yaml:"signalrest,omitempty" json:"signalrest,omitempty"`
48+
Slack *NotifSlack `yaml:"slack,omitempty" json:"slack,omitempty"`
49+
Teams *NotifTeams `yaml:"teams,omitempty" json:"teams,omitempty"`
50+
Telegram *NotifTelegram `yaml:"telegram,omitempty" json:"telegram,omitempty"`
51+
Webhook *NotifWebhook `yaml:"webhook,omitempty" json:"webhook,omitempty"`
5152
}
5253

5354
// GetDefaults gets the default values
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package model
2+
3+
import (
4+
"time"
5+
6+
"github.com/crazy-max/diun/v4/pkg/utl"
7+
)
8+
9+
type NotifElasticsearch struct {
10+
Scheme string `yaml:"scheme,omitempty" json:"scheme,omitempty" validate:"required,oneof=http https"`
11+
Host string `yaml:"host,omitempty" json:"host,omitempty" validate:"required"`
12+
Port int `yaml:"port,omitempty" json:"port,omitempty" validate:"required,min=1"`
13+
Username string `yaml:"username,omitempty" json:"username,omitempty" validate:"omitempty"`
14+
UsernameFile string `yaml:"usernameFile,omitempty" json:"usernameFile,omitempty" validate:"omitempty,file"`
15+
Password string `yaml:"password,omitempty" json:"password,omitempty" validate:"omitempty"`
16+
PasswordFile string `yaml:"passwordFile,omitempty" json:"passwordFile,omitempty" validate:"omitempty,file"`
17+
Client string `yaml:"client,omitempty" json:"client,omitempty" validate:"required"`
18+
Index string `yaml:"index,omitempty" json:"index,omitempty" validate:"required"`
19+
Timeout *time.Duration `yaml:"timeout,omitempty" json:"timeout,omitempty" validate:"required"`
20+
InsecureSkipVerify bool `yaml:"insecureSkipVerify,omitempty" json:"insecureSkipVerify,omitempty" validate:"omitempty"`
21+
}
22+
23+
// GetDefaults gets the default values
24+
func (s *NotifElasticsearch) GetDefaults() *NotifElasticsearch {
25+
n := &NotifElasticsearch{}
26+
n.SetDefaults()
27+
return n
28+
}
29+
30+
// SetDefaults sets the default values
31+
func (s *NotifElasticsearch) SetDefaults() {
32+
s.Scheme = "http"
33+
s.Host = "localhost"
34+
s.Port = 9200
35+
s.Client = "diun"
36+
s.Index = "diun-notifications"
37+
s.Timeout = utl.NewDuration(10 * time.Second)
38+
s.InsecureSkipVerify = false
39+
}

internal/notif/client.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"github.com/crazy-max/diun/v4/internal/notif/amqp"
88
"github.com/crazy-max/diun/v4/internal/notif/apprise"
99
"github.com/crazy-max/diun/v4/internal/notif/discord"
10+
"github.com/crazy-max/diun/v4/internal/notif/elasticsearch"
1011
"github.com/crazy-max/diun/v4/internal/notif/gotify"
1112
"github.com/crazy-max/diun/v4/internal/notif/mail"
1213
"github.com/crazy-max/diun/v4/internal/notif/matrix"
@@ -54,6 +55,9 @@ func New(config *model.Notif, meta model.Meta) (*Client, error) {
5455
if config.Discord != nil {
5556
c.notifiers = append(c.notifiers, discord.New(config.Discord, meta))
5657
}
58+
if config.Elasticsearch != nil {
59+
c.notifiers = append(c.notifiers, elasticsearch.New(config.Elasticsearch, meta))
60+
}
5761
if config.Gotify != nil {
5862
c.notifiers = append(c.notifiers, gotify.New(config.Gotify, meta))
5963
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package elasticsearch
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"crypto/tls"
7+
"encoding/json"
8+
"fmt"
9+
"net/http"
10+
"time"
11+
12+
"github.com/crazy-max/diun/v4/internal/model"
13+
"github.com/crazy-max/diun/v4/internal/msg"
14+
"github.com/crazy-max/diun/v4/internal/notif/notifier"
15+
"github.com/crazy-max/diun/v4/pkg/utl"
16+
"github.com/pkg/errors"
17+
)
18+
19+
// Client represents an active elasticsearch notification object
20+
type Client struct {
21+
*notifier.Notifier
22+
cfg *model.NotifElasticsearch
23+
meta model.Meta
24+
}
25+
26+
// New creates a new elasticsearch notification instance
27+
func New(config *model.NotifElasticsearch, meta model.Meta) notifier.Notifier {
28+
return notifier.Notifier{
29+
Handler: &Client{
30+
cfg: config,
31+
meta: meta,
32+
},
33+
}
34+
}
35+
36+
// Name returns notifier's name
37+
func (c *Client) Name() string {
38+
return "elasticsearch"
39+
}
40+
41+
// Send creates and sends an elasticsearch notification with an entry
42+
func (c *Client) Send(entry model.NotifEntry) error {
43+
username, err := utl.GetSecret(c.cfg.Username, c.cfg.UsernameFile)
44+
if err != nil {
45+
return err
46+
}
47+
48+
password, err := utl.GetSecret(c.cfg.Password, c.cfg.PasswordFile)
49+
if err != nil {
50+
return err
51+
}
52+
53+
// Use the same JSON structure as webhook notifier
54+
message, err := msg.New(msg.Options{
55+
Meta: c.meta,
56+
Entry: entry,
57+
})
58+
if err != nil {
59+
return err
60+
}
61+
62+
body, err := message.RenderJSON()
63+
if err != nil {
64+
return err
65+
}
66+
67+
// Parse the JSON to add the client field
68+
var doc map[string]any
69+
if err := json.Unmarshal(body, &doc); err != nil {
70+
return err
71+
}
72+
73+
// Add the current time
74+
doc["@timestamp"] = time.Now().Format(time.RFC3339Nano)
75+
76+
// Add the client field from the configuration
77+
doc["client"] = c.cfg.Client
78+
79+
// Re-marshal the JSON with the client field
80+
body, err = json.Marshal(doc)
81+
if err != nil {
82+
return err
83+
}
84+
85+
// Build the Elasticsearch indexing URL
86+
// This uses the Index API (POST /{index}/_doc) to create a document with an auto-generated _id:
87+
// https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-create
88+
url := fmt.Sprintf("%s://%s:%d/%s/_doc", c.cfg.Scheme, c.cfg.Host, c.cfg.Port, c.cfg.Index)
89+
90+
cancelCtx, cancel := context.WithCancelCause(context.Background())
91+
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
92+
defer func() { cancel(errors.WithStack(context.Canceled)) }()
93+
94+
hc := http.Client{
95+
Transport: &http.Transport{
96+
TLSClientConfig: &tls.Config{
97+
InsecureSkipVerify: c.cfg.InsecureSkipVerify,
98+
},
99+
},
100+
}
101+
102+
req, err := http.NewRequestWithContext(timeoutCtx, "POST", url, bytes.NewBuffer(body))
103+
if err != nil {
104+
return err
105+
}
106+
107+
req.Header.Set("Content-Type", "application/json")
108+
req.Header.Set("User-Agent", c.meta.UserAgent)
109+
110+
// Add authentication if provided
111+
if username != "" && password != "" {
112+
req.SetBasicAuth(username, password)
113+
}
114+
115+
resp, err := hc.Do(req)
116+
if err != nil {
117+
return err
118+
}
119+
defer resp.Body.Close()
120+
121+
if resp.StatusCode != http.StatusCreated {
122+
var errBody struct {
123+
Status int `json:"status"`
124+
Error struct {
125+
Type string `json:"type"`
126+
Reason string `json:"reason"`
127+
} `json:"error"`
128+
}
129+
if err := json.NewDecoder(resp.Body).Decode(&errBody); err != nil {
130+
return errors.Wrapf(err, "cannot decode JSON error response for HTTP %d %s status",
131+
resp.StatusCode, http.StatusText(resp.StatusCode))
132+
}
133+
return errors.Errorf("%d %s: %s", errBody.Status, errBody.Error.Type, errBody.Error.Reason)
134+
}
135+
136+
return nil
137+
}

0 commit comments

Comments
 (0)