Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
6cb2c4f
Introduce notifications `Severity` type
yhabteab Aug 4, 2025
27906db
notifications: introduce `Event` & `Type` types
yhabteab Aug 4, 2025
364ecd5
Add `types.MakeBool` with variadic transformer functions
yhabteab Aug 5, 2025
e862f5f
Add a base Icinga Notifications `Client`
yhabteab Aug 5, 2025
289258d
Add `Iter()` function to `RulesResult`
yhabteab Aug 5, 2025
8ce520d
notifications: Move Rule Version and IDs to Event
oxzi Sep 9, 2025
9018d42
notifications: Change Rule IDs to Strings
oxzi Sep 29, 2025
7b04d0b
notifications: Remove Client.JoinIcingaWeb2Path
oxzi Sep 29, 2025
5e202ca
notifications: Simplify and document API
oxzi Oct 2, 2025
882b902
Enhance channel
sukhwinder33445 Sep 27, 2023
3141f2a
pkg/plugin/plugin.go: Introduce `Object` struct All object related va…
sukhwinder33445 Oct 4, 2023
f964d45
Handle unexpected termination of plugin
sukhwinder33445 Oct 10, 2023
a2a8d10
Enhance channel plugins
sukhwinder33445 Oct 10, 2023
11a9225
Improve logs
sukhwinder33445 Nov 6, 2023
10ab5f2
channel/plugin.go: `UpsertPlugins()`: use filename as type
sukhwinder33445 Nov 10, 2023
b6a6da1
pkg/rpc/rpc.go: Remove unused variable from `RPC` struct
sukhwinder33445 Nov 14, 2023
0d7cda4
pkg/plugin/plugin.go: Plugin shutdown should wait for pending resposes
sukhwinder33445 Nov 14, 2023
dfc046c
pkg/rpc/rpc.go: `rpc.Close()` should not cause `rpc.Done()`
sukhwinder33445 Nov 14, 2023
97a5c49
pkg/rpc/rpc.go: SetErr() calls rpc.Close()
sukhwinder33445 Nov 14, 2023
1058736
pkg/rpc/rpc.go: Remove method SetErr as it is only used once
sukhwinder33445 Nov 15, 2023
a1770aa
pkg/rpc/rpc.go: Rename err variables
sukhwinder33445 Nov 16, 2023
ee03f11
pkg/rpc/rpc.go: Add helper function to avoid multiple unlock calls
sukhwinder33445 Nov 20, 2023
854eb38
channels: Use `incident.severity` instead of `event.severity`
sukhwinder33445 Nov 23, 2023
e7892c5
Plugin#ConfigOptions: Remove placeholder and use default instead
sukhwinder33445 Dec 12, 2023
7e4e265
plugin: Document ConfigOption's Help field
oxzi Jan 12, 2024
cab7d8d
utils.IterateOrderedMap for plugin.FormatMessage
oxzi Apr 24, 2024
6e557fe
Format notification subject and message depending on event type
sukhwinder33445 Jan 10, 2024
731aab7
Switch to `icinga-go-library`
yhabteab May 24, 2024
11c2eb2
Channel default value by changing default logic
oxzi Jul 15, 2024
ae5a528
Increase details of Channels documentation
oxzi Jul 16, 2024
9630e82
Use utils.IterateOrderedMap() as intended, via for:=range (require Go…
Al2Klimov Apr 17, 2025
8c3cc2d
internal/utils: Cleanup and IGL unification
oxzi May 8, 2025
89dce8b
Drop `object_extra_tags` & all its references
yhabteab Aug 4, 2025
e2de424
Use the newly introduced notifications event utils from `igl`
yhabteab Aug 4, 2025
2bf06de
notifications: Fix import after IGL import
oxzi Oct 9, 2025
f684950
notifications.source.Client: Remove unused cfg
oxzi Oct 21, 2025
6600889
notifications: Reintroduce Extra Tags
oxzi Oct 22, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,7 @@ require (
golang.org/x/sys v0.26.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

tool (
golang.org/x/tools/cmd/stringer
)
58 changes: 58 additions & 0 deletions notifications/event/event.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package event

import (
"github.com/icinga/icinga-go-library/types"
)

// Event represents an Icinga Notifications event that can be sent to the Icinga Notifications API.
//
// It contains all the necessary fields to fully describe an Icinga Notifications event and can be used to
// serialize the event to JSON for transmission over HTTP as well as to deserialize it from JSON requests.
type Event struct {
Name string `json:"name"` // Name is the name of the object this event is all about.

// URL represents a URL or a relative reference to the object in Icinga Web 2.
//
// If the URL field does not contain a URL, but only a reference relative to an Icinga Web URL, the Icinga
// Notifications daemon will create a URL. This allows a source to set this to something like
// "/icingadb/host?name=example.com" without having to know the Icinga Web 2 root URL by itself.
URL string `json:"url"`

// Tags contains additional metadata for the event that uniquely identifies the object it's referring to.
//
// It is a map of string keys to string values, allowing for flexible tagging of events if the event
// name alone is not sufficient to identify the object. In the case of using Icinga DB as a source, the
// tags will typically look like this:
// For hosts: {"host": "host_name"} and for services: {"host": "host_name", "service": "service_name"}.
Tags map[string]string `json:"tags"`

// ExtraTags supplement Tags, for example with host or service groups for an Icinga DB source.
ExtraTags map[string]string `json:"extra_tags"`

// Type indicates the type of the event.
Type Type `json:"type"`
// Severity of the event.
Severity Severity `json:"severity,omitempty"`
// Username is the name of the user who triggered the event.
Username string `json:"username"`
// Message is a human-readable message describing the event.
Message string `json:"message"`

// Mute indicates whether the object this event is referring to should be muted or not.
//
// If set to true, the object will be muted in Icinga Web 2, meaning that notifications for this object
// will not be sent out. The MuteReason field can be used to provide a reason for muting the object.
// If you don't set this field to anything, it will be omitted from the generated JSON.
Mute types.Bool `json:"mute,omitzero"`

// MuteReason provides a reason for muting the object if Mute is set to true.
//
// Setting this field to an empty string while Mute is true will cause the request to fail,
// as Icinga Notifications requires a reason for muting an object. Otherwise, it will be omitted
// from the encoded JSON.
MuteReason string `json:"mute_reason,omitempty"`

// RulesVersion and RuleIds are the source rules matching for this Event.
RulesVersion string `json:"rules_version"`
RuleIds []string `json:"rule_ids"`
}
74 changes: 74 additions & 0 deletions notifications/event/event_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package event

import (
"encoding/json"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestEvent(t *testing.T) {
t.Parallel()

t.Run("JsonEncode", func(t *testing.T) {
t.Parallel()

t.Run("Valid Event", func(t *testing.T) {
t.Parallel()

event := &Event{
Name: "TestEvent",
URL: "/icingadb/service?name=https%20ssl%20v3.0%20compatibility%20IE%206.0&host.name=example%20host",
Tags: map[string]string{"tag1": "value1"},
ExtraTags: map[string]string{},
Type: TypeState,
Severity: SeverityOK,
Username: "testuser",
Message: "Test",
RulesVersion: "0x1",
RuleIds: []string{"1", "2", "3", "6"},
}

data, err := json.Marshal(event)
require.NoError(t, err)

expected := `
{
"name":"TestEvent",
"url":"/icingadb/service?name=https%20ssl%20v3.0%20compatibility%20IE%206.0&host.name=example%20host",
"tags":{"tag1":"value1"},
"extra_tags":{},
"type":"state",
"severity":"ok",
"username":"testuser",
"message":"Test",
"rules_version": "0x1",
"rule_ids": ["1", "2", "3", "6"]
}`
assert.JSONEq(t, expected, string(data), "JSON encoding does not match expected output")
})

t.Run("Empty Severity", func(t *testing.T) {
t.Parallel()

event := &Event{
Name: "TestEvent",
URL: "https://example.com/icingaweb2/icingadb/service?name=https%20ssl%20v3.0%20compatibility%20IE%206.0&host.name=example%20host",
Tags: map[string]string{"tag1": "value1"},
Type: TypeMute,
Username: "testuser",
Message: "Test",
}

data, err := json.Marshal(event)
require.NoError(t, err)
assert.NotContains(t, string(data), "\"severity\":", "severity should be omitted when empty")

event.Severity = SeverityNone
data, err = json.Marshal(event)
require.NoError(t, err)
assert.NotContains(t, string(data), "\"severity\":", "severity should be omitted when set to none")
})
})
}
106 changes: 106 additions & 0 deletions notifications/event/severity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
//go:generate go tool stringer -linecomment -type Severity -output severity_string.go

package event

import (
"database/sql/driver"
"encoding/json"
"fmt"
)

// Severity represents the severity level of an event in Icinga notifications.
// It is an integer type with predefined constants for different severity levels.
type Severity uint8

const (
SeverityNone Severity = iota // none

SeverityOK // ok
SeverityDebug // debug
SeverityInfo // info
SeverityNotice // notice
SeverityWarning // warning
SeverityErr // err
SeverityCrit // crit
SeverityAlert // alert
SeverityEmerg // emerg

severityMax // internal
)

// MarshalJSON implements the [json.Marshaler] interface for Severity.
func (s Severity) MarshalJSON() ([]byte, error) {
if s != SeverityNone {
return json.Marshal(s.String())
} else {
return json.Marshal(nil)
}
}

// UnmarshalJSON implements the [json.Unmarshaler] interface for Severity.
func (s *Severity) UnmarshalJSON(data []byte) error {
if string(data) == "null" {
*s = SeverityNone
return nil
}

var severityStr string
if err := json.Unmarshal(data, &severityStr); err != nil {
return err
}

severity, err := ParseSeverity(severityStr)
if err != nil {
return err
}

*s = severity
return nil
}

// Scan implements the [sql.Scanner] interface for Severity.
// Supports SQL NULL values.
func (s *Severity) Scan(src any) error {
if src == nil {
*s = SeverityNone
return nil
}

var severityStr string
switch val := src.(type) {
case string:
severityStr = val
case []byte:
severityStr = string(val)
default:
return fmt.Errorf("cannot scan severity from type %T", src)
}

severity, err := ParseSeverity(severityStr)
if err != nil {
return err
}

*s = severity
return nil
}

// Value implements the [driver.Valuer] interface for Severity.
func (s Severity) Value() (driver.Value, error) {
if s != SeverityNone {
return s.String(), nil
}
return nil, nil // Return nil for SeverityNone or invalid values
}

// ParseSeverity parses a string representation of a severity level and returns the corresponding Severity value.
// If the string does not match any known severity, it returns an error indicating the unknown severity.
func ParseSeverity(name string) (Severity, error) {
for s := range severityMax {
if s.String() == name {
return s, nil
}
}

return SeverityNone, fmt.Errorf("unknown severity %q", name)
}
33 changes: 33 additions & 0 deletions notifications/event/severity_string.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

109 changes: 109 additions & 0 deletions notifications/event/severity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package event

import (
"database/sql/driver"
"testing"

"github.com/icinga/icinga-go-library/testutils"
)

func TestSeverity(t *testing.T) {
t.Parallel()

t.Run("MarshalJson", func(t *testing.T) {
t.Parallel()

testdata := []testutils.TestCase[string, Severity]{
{Name: "None", Expected: "null", Data: SeverityNone, Error: nil},
{Name: "Ok", Expected: `"ok"`, Data: SeverityOK, Error: nil},
{Name: "Debug", Expected: `"debug"`, Data: SeverityDebug, Error: nil},
{Name: "Info", Expected: `"info"`, Data: SeverityInfo, Error: nil},
{Name: "Notice", Expected: `"notice"`, Data: SeverityNotice, Error: nil},
{Name: "Warning", Expected: `"warning"`, Data: SeverityWarning, Error: nil},
{Name: "Err", Expected: `"err"`, Data: SeverityErr, Error: nil},
{Name: "Crit", Expected: `"crit"`, Data: SeverityCrit, Error: nil},
{Name: "Alert", Expected: `"alert"`, Data: SeverityAlert, Error: nil},
{Name: "Emerg", Expected: `"emerg"`, Data: SeverityEmerg, Error: nil},
}

for _, tt := range testdata {
t.Run(tt.Name, tt.F(func(s Severity) (string, error) {
data, err := s.MarshalJSON()
return string(data), err
}))
}
})

t.Run("UnmarshalJson", func(t *testing.T) {
t.Parallel()

testData := []testutils.TestCase[Severity, string]{
{Name: "None", Expected: SeverityNone, Data: `null`, Error: nil},
{Name: "Ok", Expected: SeverityOK, Data: `"ok"`, Error: nil},
{Name: "Debug", Expected: SeverityDebug, Data: `"debug"`, Error: nil},
{Name: "Info", Expected: SeverityInfo, Data: `"info"`, Error: nil},
{Name: "Notice", Expected: SeverityNotice, Data: `"notice"`, Error: nil},
{Name: "Warning", Expected: SeverityWarning, Data: `"warning"`, Error: nil},
{Name: "Err", Expected: SeverityErr, Data: `"err"`, Error: nil},
{Name: "Crit", Expected: SeverityCrit, Data: `"crit"`, Error: nil},
{Name: "Alert", Expected: SeverityAlert, Data: `"alert"`, Error: nil},
{Name: "Emerg", Expected: SeverityEmerg, Data: `"emerg"`, Error: nil},
{Name: "Invalid", Expected: SeverityNone, Data: `"invalid"`, Error: testutils.ErrorContains(`unknown severity "invalid"`)},
}

for _, tt := range testData {
t.Run(tt.Name, tt.F(func(input string) (Severity, error) {
var s Severity
return s, s.UnmarshalJSON([]byte(input))
}))
}
})

t.Run("Scan", func(t *testing.T) {
t.Parallel()

testData := []testutils.TestCase[Severity, any]{
{Name: "None", Expected: SeverityNone, Data: nil, Error: nil},
{Name: "Ok", Expected: SeverityOK, Data: `ok`, Error: nil},
{Name: "Debug", Expected: SeverityDebug, Data: `debug`, Error: nil},
{Name: "Info", Expected: SeverityInfo, Data: `info`, Error: nil},
{Name: "Notice", Expected: SeverityNotice, Data: `notice`, Error: nil},
{Name: "Warning", Expected: SeverityWarning, Data: `warning`, Error: nil},
{Name: "Err", Expected: SeverityErr, Data: `err`, Error: nil},
{Name: "Crit", Expected: SeverityCrit, Data: `crit`, Error: nil},
{Name: "Alert", Expected: SeverityAlert, Data: `alert`, Error: nil},
{Name: "Alert Bytes", Expected: SeverityAlert, Data: []byte("alert"), Error: nil},
{Name: "Emerg", Expected: SeverityEmerg, Data: `emerg`, Error: nil},
{Name: "Invalid Number", Expected: SeverityNone, Data: 150, Error: testutils.ErrorContains(`cannot scan severity from type int`)},
{Name: "Invalid String", Expected: SeverityNone, Data: `invalid`, Error: testutils.ErrorContains(`unknown severity "invalid"`)},
}

for _, tt := range testData {
t.Run(tt.Name, tt.F(func(input any) (Severity, error) {
var s Severity
return s, s.Scan(input)
}))
}
})

t.Run("Value", func(t *testing.T) {
t.Parallel()

testdata := []testutils.TestCase[driver.Value, Severity]{
{Name: "None", Expected: nil, Data: SeverityNone, Error: nil},
{Name: "Ok", Expected: `ok`, Data: SeverityOK, Error: nil},
{Name: "Debug", Expected: `debug`, Data: SeverityDebug, Error: nil},
{Name: "Info", Expected: `info`, Data: SeverityInfo, Error: nil},
{Name: "Notice", Expected: `notice`, Data: SeverityNotice, Error: nil},
{Name: "Warning", Expected: `warning`, Data: SeverityWarning, Error: nil},
{Name: "Err", Expected: `err`, Data: SeverityErr, Error: nil},
{Name: "Crit", Expected: `crit`, Data: SeverityCrit, Error: nil},
{Name: "Alert", Expected: `alert`, Data: SeverityAlert, Error: nil},
{Name: "Emerg", Expected: `emerg`, Data: SeverityEmerg, Error: nil},
}

for _, tt := range testdata {
t.Run(tt.Name, tt.F(func(s Severity) (driver.Value, error) { return s.Value() }))
}
})
}
Loading
Loading