diff --git a/cmd/channels/email/main.go b/cmd/channels/email/main.go index a42a90eb..66f04b32 100644 --- a/cmd/channels/email/main.go +++ b/cmd/channels/email/main.go @@ -8,9 +8,9 @@ import ( "github.com/emersion/go-sasl" "github.com/emersion/go-smtp" "github.com/google/uuid" + "github.com/icinga/icinga-go-library/notifications/plugin" "github.com/icinga/icinga-go-library/types" "github.com/icinga/icinga-notifications/internal" - "github.com/icinga/icinga-notifications/pkg/plugin" "github.com/jhillyerd/enmime" "net" "net/mail" diff --git a/cmd/channels/rocketchat/main.go b/cmd/channels/rocketchat/main.go index 39b7aa5a..516a7b35 100644 --- a/cmd/channels/rocketchat/main.go +++ b/cmd/channels/rocketchat/main.go @@ -5,8 +5,8 @@ import ( "encoding/json" "errors" "fmt" + "github.com/icinga/icinga-go-library/notifications/plugin" "github.com/icinga/icinga-notifications/internal" - "github.com/icinga/icinga-notifications/pkg/plugin" "net/http" "time" ) diff --git a/cmd/channels/webhook/main.go b/cmd/channels/webhook/main.go index 90a72701..011a5f8a 100644 --- a/cmd/channels/webhook/main.go +++ b/cmd/channels/webhook/main.go @@ -4,8 +4,8 @@ import ( "bytes" "encoding/json" "fmt" + "github.com/icinga/icinga-go-library/notifications/plugin" "github.com/icinga/icinga-notifications/internal" - "github.com/icinga/icinga-notifications/pkg/plugin" "io" "net/http" "slices" diff --git a/cmd/icinga-notifications/main.go b/cmd/icinga-notifications/main.go index 9e9c0ffe..6ac3f434 100644 --- a/cmd/icinga-notifications/main.go +++ b/cmd/icinga-notifications/main.go @@ -9,7 +9,6 @@ import ( "github.com/icinga/icinga-notifications/internal/channel" "github.com/icinga/icinga-notifications/internal/config" "github.com/icinga/icinga-notifications/internal/daemon" - "github.com/icinga/icinga-notifications/internal/icinga2" "github.com/icinga/icinga-notifications/internal/incident" "github.com/icinga/icinga-notifications/internal/listener" "github.com/icinga/icinga-notifications/internal/object" @@ -48,20 +47,11 @@ func main() { channel.UpsertPlugins(ctx, conf.ChannelsDir, logs.GetChildLogger("channel"), db) - icinga2Launcher := &icinga2.Launcher{ - Ctx: ctx, - Logs: logs, - Db: db, - RuntimeConfig: nil, // Will be set below as it is interconnected.. - } - - runtimeConfig := config.NewRuntimeConfig(icinga2Launcher.Launch, logs, db) + runtimeConfig := config.NewRuntimeConfig(logs, db) if err := runtimeConfig.UpdateFromDatabase(ctx); err != nil { logger.Fatalf("Failed to load config from database %+v", err) } - icinga2Launcher.RuntimeConfig = runtimeConfig - go runtimeConfig.PeriodicUpdates(ctx, 1*time.Second) err = incident.LoadOpenIncidents(ctx, db, logs.GetChildLogger("incident"), runtimeConfig) @@ -75,9 +65,6 @@ func main() { logger.Fatalf("Failed to restore muted objects: %+v", err) } - // Wait to load open incidents from the database before either starting Event Stream Clients or starting the Listener. - icinga2Launcher.Ready() - // When Icinga Notifications is started by systemd, we've to notify systemd that we're ready. _ = sdnotify.Ready() diff --git a/config.example.yml b/config.example.yml index 07c2b8b3..b786792a 100644 --- a/config.example.yml +++ b/config.example.yml @@ -66,7 +66,6 @@ database: # options: #channel: #database: - #icinga2: #incident: #listener: #runtime-updates: diff --git a/doc/03-Configuration.md b/doc/03-Configuration.md index c033d05e..257ec259 100644 --- a/doc/03-Configuration.md +++ b/doc/03-Configuration.md @@ -95,7 +95,6 @@ Configuration of the logging component used by Icinga Notifications. |-----------------|---------------------------------------------------------------------------| | channel | Notification channels, their configuration and output. | | database | Database connection status and queries. | -| icinga2 | Icinga 2 API communications, including the Event Stream. | | incident | Incident management and changes. | | listener | HTTP listener for event submission and debugging. | | runtime-updates | Configuration changes through Icinga Notifications Web from the database. | diff --git a/doc/20-HTTP-API.md b/doc/20-HTTP-API.md index a5a469e7..4d27280d 100644 --- a/doc/20-HTTP-API.md +++ b/doc/20-HTTP-API.md @@ -5,14 +5,25 @@ via `listen` and `debug-password`. ## Process Event -One possible source next to the Icinga 2 API is event submission to the Icinga Notifications HTTP API listener. -After creating a source with type _Other_ in Icinga Notifications Web, -the specified credentials can be used for HTTP Basic Authentication of a JSON-encoded -[Event](https://github.com/Icinga/icinga-notifications/blob/main/internal/event/event.go). +Events can be submitted to Icinga Notifications using the `/process-event` HTTP API endpoint. + +After creating a source in Icinga Notifications Web, +the specified credentials can be used via HTTP Basic Authentication to submit a JSON-encoded +[`Event`](https://github.com/Icinga/icinga-go-library/blob/main/notifications/event/event.go). The authentication is performed via HTTP Basic Authentication, expecting `source-${id}` as the username, `${id}` being the source's `id` within the database, and the configured password. +Events sent to Icinga Notifications are expected to match rules that describe further event escalations. +These rules can be created in the web interface. +Next to an array of `rule_ids`, a `rules_version` must be provided to ensure that the source has no outdated state. + +When the submitted `rules_version` is either outdated or empty, the `/process-event` endpoint returns an HTTP 412 response. +The response's body is a JSON-encoded version of the +[`RulesInfo`](https://github.com/Icinga/icinga-go-library/blob/main/notifications/source/client.go), +containing the latest `rules_version` together with all rules for this source. +After reevaluating these rules, one can resubmit the event with the updated `rules_version`. + ``` curl -v -u 'source-2:insecureinsecure' -d '@-' 'http://localhost:5680/process-event' < ThresholdHigh = flapping start). -// -// https://icinga.com/docs/icinga-2/latest/doc/12-icinga2-api/#event-stream-type-flapping -type Flapping struct { - Timestamp UnixFloat `json:"timestamp"` - Host string `json:"host"` - Service string `json:"service"` - IsFlapping bool `json:"is_flapping"` - State int `json:"state"` - CurrentFlapping int `json:"current_flapping"` - ThresholdLow int `json:"threshold_low"` - ThresholdHigh int `json:"threshold_high"` -} - -// ObjectCreatedDeleted represents the Icinga 2 API stream object created/deleted response. -// -// NOTE: -// - The ObjectName field already contains the composed name of the checkable if the ObjectType is `Service`. -// - The EventType field indicates which event type is currently being streamed and is either -// set to typeObjectCreated or typeObjectDeleted. -type ObjectCreatedDeleted struct { - ObjectName string `json:"object_name"` - ObjectType string `json:"object_type"` - EventType string `json:"type"` -} - -// IcingaApplication represents the Icinga 2 API status endpoint query result of type IcingaApplication. -// https://icinga.com/docs/icinga-2/latest/doc/12-icinga2-api/#status-and-statistics -type IcingaApplication struct { - App struct { - EnableFlapping bool `json:"enable_flapping"` - } `json:"app"` -} - -// UnmarshalEventStreamResponse unmarshal a JSON response line from the Icinga 2 API Event Stream. -// -// The function expects an Icinga 2 API Event Stream Response in its JSON form and tries to unmarshal it into one of the -// implemented types based on its type argument. Thus, the returned any value will be a pointer to such a struct type. -func UnmarshalEventStreamResponse(bytes []byte) (any, error) { - // Due to the overlapping fields of the different Event Stream response objects, a struct composition with - // decompositions in different variables will result in multiple manual fixes. Thus, a two-way deserialization - // was chosen which selects the target type based on the first parsed type field. - - var ( - responseType string - responseError int - ) - err := json.Unmarshal(bytes, &struct { - Type *string `json:"type"` - Error *int `json:"error"` - }{&responseType, &responseError}) - if err != nil { - return nil, err - } - - // Please note: An Event Stream Response SHOULD NOT contain an error field. However, it might be possible that a - // message not produced by the Event Stream API might end up here, e.g., a generic API error message. There are - // already checks for HTTP error codes in place, so this is more like a second layer of protection. - if responseError > 0 { - return nil, fmt.Errorf("error field is present, faulty message is %q", bytes) - } - - var resp any - switch responseType { - case typeStateChange: - resp = new(StateChange) - case typeAcknowledgementSet, typeAcknowledgementCleared: - resp = new(Acknowledgement) - case typeCommentAdded: - resp = new(CommentAdded) - case typeCommentRemoved: - resp = new(CommentRemoved) - case typeDowntimeAdded: - resp = new(DowntimeAdded) - case typeDowntimeRemoved: - resp = new(DowntimeRemoved) - case typeDowntimeStarted: - resp = new(DowntimeStarted) - case typeDowntimeTriggered: - resp = new(DowntimeTriggered) - case typeFlapping: - resp = new(Flapping) - case typeObjectCreated, typeObjectDeleted: - resp = new(ObjectCreatedDeleted) - default: - return nil, fmt.Errorf("unsupported type %q", responseType) - } - err = json.Unmarshal(bytes, resp) - return resp, err -} diff --git a/internal/icinga2/api_responses_test.go b/internal/icinga2/api_responses_test.go deleted file mode 100644 index 80cab35c..00000000 --- a/internal/icinga2/api_responses_test.go +++ /dev/null @@ -1,690 +0,0 @@ -package icinga2 - -import ( - "encoding/json" - "testing" - "time" - - "github.com/stretchr/testify/assert" -) - -func TestUnixFloat_UnmarshalJSON(t *testing.T) { - tests := []struct { - name string - jsonData string - isError bool - expected UnixFloat - }{ - { - name: "json-empty", - jsonData: "", - isError: true, - }, - { - name: "json-invalid", - jsonData: "{", - isError: true, - }, - { - name: "json-wrong-type", - jsonData: `"AAA"`, - isError: true, - }, - { - name: "epoch-time", - jsonData: "0.0", - expected: UnixFloat(time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC)), - }, - { - name: "example-time", - jsonData: "1697207144.746333", - expected: UnixFloat(time.Date(2023, time.October, 13, 14, 25, 44, 746333000, time.UTC)), - }, - { - name: "example-time-location", - jsonData: "1697207144.746333", - expected: UnixFloat(time.Date(2023, time.October, 13, 16, 25, 44, 746333000, - time.FixedZone("Europe/Berlin summer", 2*60*60))), - }, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - var ici2time UnixFloat - err := json.Unmarshal([]byte(test.jsonData), &ici2time) - assert.Equal(t, test.isError, err != nil, "unexpected error state; %v", err) - if err != nil { - return - } - - assert.WithinDuration(t, test.expected.Time(), ici2time.Time(), time.Duration(0)) - }) - } -} - -func TestObjectQueriesResult_UnmarshalJSON(t *testing.T) { - tests := []struct { - name string - jsonData string - isError bool - resp any - expected any - }{ - { - name: "invalid-json", - jsonData: `{":}"`, - isError: true, - }, - { - name: "invalid-typed-json", - jsonData: `{"name": 23, "type": [], "attrs": null}`, - isError: true, - }, - { - name: "unknown-type", - jsonData: `{"type": "ihopethisstringwillneverappearinicinga2asavalidtype"}`, - isError: true, - }, - { - // $ curl -k -s -u root:icinga 'https://localhost:5665/v1/objects/comments' | jq -c '[.results[] | select(.attrs.service_name == "")][0]' - name: "comment-host", - jsonData: `{"attrs":{"__name":"dummy-0!f1239b7d-6e13-4031-b7dd-4055fdd2cd80","active":true,"author":"icingaadmin","entry_time":1697454753.536457,"entry_type":1,"expire_time":0,"ha_mode":0,"host_name":"dummy-0","legacy_id":3,"name":"f1239b7d-6e13-4031-b7dd-4055fdd2cd80","original_attributes":null,"package":"_api","paused":false,"persistent":false,"service_name":"","source_location":{"first_column":0,"first_line":1,"last_column":68,"last_line":1,"path":"/var/lib/icinga2/api/packages/_api/997346d3-374d-443f-b734-80789fd59b31/conf.d/comments/dummy-0!f1239b7d-6e13-4031-b7dd-4055fdd2cd80.conf"},"templates":["f1239b7d-6e13-4031-b7dd-4055fdd2cd80"],"text":"foo bar","type":"Comment","version":1697454753.53647,"zone":"master"},"joins":{},"meta":{},"name":"dummy-0!f1239b7d-6e13-4031-b7dd-4055fdd2cd80","type":"Comment"}`, - resp: &ObjectQueriesResult[Comment]{}, - expected: &ObjectQueriesResult[Comment]{ - Name: "dummy-0!f1239b7d-6e13-4031-b7dd-4055fdd2cd80", - Type: "Comment", - Attrs: Comment{ - Host: "dummy-0", - Author: "icingaadmin", - Text: "foo bar", - EntryTime: UnixFloat(time.UnixMicro(1697454753536457)), - EntryType: EntryTypeUser, - }, - }, - }, - { - // $ curl -k -s -u root:icinga 'https://localhost:5665/v1/objects/comments' | jq -c '[.results[] | select(.attrs.service_name != "")][0]' - name: "comment-service", - jsonData: `{"attrs":{"__name":"dummy-912!ping6!1b29580d-0a09-4265-ad1f-5e16f462443d","active":true,"author":"icingaadmin","entry_time":1697197701.307516,"entry_type":1,"expire_time":0,"ha_mode":0,"host_name":"dummy-912","legacy_id":1,"name":"1b29580d-0a09-4265-ad1f-5e16f462443d","original_attributes":null,"package":"_api","paused":false,"persistent":false,"service_name":"ping6","source_location":{"first_column":0,"first_line":1,"last_column":68,"last_line":1,"path":"/var/lib/icinga2/api/packages/_api/997346d3-374d-443f-b734-80789fd59b31/conf.d/comments/dummy-912!ping6!1b29580d-0a09-4265-ad1f-5e16f462443d.conf"},"templates":["1b29580d-0a09-4265-ad1f-5e16f462443d"],"text":"adfadsfasdfasdf","type":"Comment","version":1697197701.307536,"zone":"master"},"joins":{},"meta":{},"name":"dummy-912!ping6!1b29580d-0a09-4265-ad1f-5e16f462443d","type":"Comment"}`, - resp: &ObjectQueriesResult[Comment]{}, - expected: &ObjectQueriesResult[Comment]{ - Name: "dummy-912!ping6!1b29580d-0a09-4265-ad1f-5e16f462443d", - Type: "Comment", - Attrs: Comment{ - Host: "dummy-912", - Service: "ping6", - Author: "icingaadmin", - Text: "adfadsfasdfasdf", - EntryType: EntryTypeUser, - EntryTime: UnixFloat(time.UnixMicro(1697197701307516)), - }, - }, - }, - { - // $ curl -k -s -u root:icinga 'https://localhost:5665/v1/objects/downtimes' | jq -c '[.results[] | select(.attrs.service_name == "")][0]' - name: "downtime-host", - jsonData: `{"attrs":{"__name":"dummy-11!af73f9d9-2ed8-45f8-b541-cce3f3fe0f6c","active":true,"author":"icingaadmin","authoritative_zone":"","comment":"turn down for what","config_owner":"","config_owner_hash":"","duration":0,"end_time":1698096240,"entry_time":1697456415.667442,"fixed":true,"ha_mode":0,"host_name":"dummy-11","legacy_id":2,"name":"af73f9d9-2ed8-45f8-b541-cce3f3fe0f6c","original_attributes":null,"package":"_api","parent":"","paused":false,"remove_time":0,"scheduled_by":"","service_name":"","source_location":{"first_column":0,"first_line":1,"last_column":69,"last_line":1,"path":"/var/lib/icinga2/api/packages/_api/997346d3-374d-443f-b734-80789fd59b31/conf.d/downtimes/dummy-11!af73f9d9-2ed8-45f8-b541-cce3f3fe0f6c.conf"},"start_time":1697456292,"templates":["af73f9d9-2ed8-45f8-b541-cce3f3fe0f6c"],"trigger_time":1697456415.667442,"triggered_by":"","triggers":[],"type":"Downtime","version":1697456415.667458,"was_cancelled":false,"zone":"master"},"joins":{},"meta":{},"name":"dummy-11!af73f9d9-2ed8-45f8-b541-cce3f3fe0f6c","type":"Downtime"}`, - resp: &ObjectQueriesResult[Downtime]{}, - expected: &ObjectQueriesResult[Downtime]{ - Name: "dummy-11!af73f9d9-2ed8-45f8-b541-cce3f3fe0f6c", - Type: "Downtime", - Attrs: Downtime{ - Host: "dummy-11", - Author: "icingaadmin", - Comment: "turn down for what", - RemoveTime: UnixFloat(time.UnixMilli(0)), - IsFixed: true, - }, - }, - }, - { - // $ curl -k -s -u root:icinga 'https://localhost:5665/v1/objects/downtimes' | jq -c '[.results[] | select(.attrs.fixed == false)][1]' - name: "flexible-downtime-host", - jsonData: `{"attrs":{"__name":"dummy-7!691d508b-c93f-4565-819c-3e46ffef1555","active":true,"author":"icingaadmin","authoritative_zone":"","comment":"Flexible","config_owner":"","config_owner_hash":"","duration":7200,"end_time":1714043658,"entry_time":1714040073.241627,"fixed":false,"ha_mode":0,"host_name":"dummy-7","legacy_id":4,"name":"691d508b-c93f-4565-819c-3e46ffef1555","original_attributes":null,"package":"_api","parent":"","paused":false,"remove_time":0,"scheduled_by":"","service_name":"","source_location":{"first_column":0,"first_line":1,"last_column":69,"last_line":1,"path":"/var/lib/icinga2/api/packages/_api/e5ec468f-6d29-4055-9cd4-495dbbef16e3/conf.d/downtimes/dummy-7!691d508b-c93f-4565-819c-3e46ffef1555.conf"},"start_time":1714040058,"templates":["691d508b-c93f-4565-819c-3e46ffef1555"],"trigger_time":1714040073.241627,"triggered_by":"","triggers":[],"type":"Downtime","version":1714040073.241642,"was_cancelled":false,"zone":"master"},"joins":{},"meta":{},"name":"dummy-7!691d508b-c93f-4565-819c-3e46ffef1555","type":"Downtime"}`, - resp: &ObjectQueriesResult[Downtime]{}, - expected: &ObjectQueriesResult[Downtime]{ - Name: "dummy-7!691d508b-c93f-4565-819c-3e46ffef1555", - Type: "Downtime", - Attrs: Downtime{ - Host: "dummy-7", - Author: "icingaadmin", - Comment: "Flexible", - RemoveTime: UnixFloat(time.UnixMilli(0)), - IsFixed: false, - }, - }, - }, - { - // $ curl -k -s -u root:icinga 'https://localhost:5665/v1/objects/downtimes' | jq -c '[.results[] | select(.attrs.fixed == false)][0]' - name: "flexible-downtime-service", - jsonData: `{"attrs":{"__name":"docker-master!disk /!97078a44-8902-495a-9f2a-c1f6802bc63d","active":true,"author":"icingaadmin","authoritative_zone":"","comment":"Flexible","config_owner":"","config_owner_hash":"","duration":7200,"end_time":1714042731,"entry_time":1714039143.459298,"fixed":false,"ha_mode":0,"host_name":"docker-master","legacy_id":3,"name":"97078a44-8902-495a-9f2a-c1f6802bc63d","original_attributes":null,"package":"_api","parent":"","paused":false,"remove_time":0,"scheduled_by":"","service_name":"disk /","source_location":{"first_column":0,"first_line":1,"last_column":69,"last_line":1,"path":"/var/lib/icinga2/api/packages/_api/e5ec468f-6d29-4055-9cd4-495dbbef16e3/conf.d/downtimes/docker-master!disk %2F!97078a44-8902-495a-9f2a-c1f6802bc63d.conf"},"start_time":1714039131,"templates":["97078a44-8902-495a-9f2a-c1f6802bc63d"],"trigger_time":1714039143.459298,"triggered_by":"","triggers":[],"type":"Downtime","version":1714039143.459324,"was_cancelled":false,"zone":""},"joins":{},"meta":{},"name":"docker-master!disk /!97078a44-8902-495a-9f2a-c1f6802bc63d","type":"Downtime"}`, - resp: &ObjectQueriesResult[Downtime]{}, - expected: &ObjectQueriesResult[Downtime]{ - Name: "docker-master!disk /!97078a44-8902-495a-9f2a-c1f6802bc63d", - Type: "Downtime", - Attrs: Downtime{ - Host: "docker-master", - Service: "disk /", - Author: "icingaadmin", - Comment: "Flexible", - RemoveTime: UnixFloat(time.UnixMilli(0)), - IsFixed: false, - }, - }, - }, - { - // $ curl -k -s -u root:icinga 'https://localhost:5665/v1/objects/downtimes' | jq -c '[.results[] | select(.attrs.service_name != "")][0]' - name: "downtime-service", - jsonData: `{"attrs":{"__name":"docker-master!load!c27b27c2-e0ab-45ff-8b9b-e95f29851eb0","active":true,"author":"icingaadmin","authoritative_zone":"master","comment":"Scheduled downtime for backup","config_owner":"docker-master!load!backup-downtime","config_owner_hash":"ca9502dc8fa5d29c1cb2686808b5d2ccf3ea4a9c6dc3f3c09bfc54614c03c765","duration":0,"end_time":1697511600,"entry_time":1697439555.095232,"fixed":true,"ha_mode":0,"host_name":"docker-master","legacy_id":1,"name":"c27b27c2-e0ab-45ff-8b9b-e95f29851eb0","original_attributes":null,"package":"_api","parent":"","paused":false,"remove_time":0,"scheduled_by":"docker-master!load!backup-downtime","service_name":"load","source_location":{"first_column":0,"first_line":1,"last_column":69,"last_line":1,"path":"/var/lib/icinga2/api/packages/_api/997346d3-374d-443f-b734-80789fd59b31/conf.d/downtimes/docker-master!load!c27b27c2-e0ab-45ff-8b9b-e95f29851eb0.conf"},"start_time":1697508000,"templates":["c27b27c2-e0ab-45ff-8b9b-e95f29851eb0"],"trigger_time":0,"triggered_by":"","triggers":[],"type":"Downtime","version":1697439555.095272,"was_cancelled":false,"zone":""},"joins":{},"meta":{},"name":"docker-master!load!c27b27c2-e0ab-45ff-8b9b-e95f29851eb0","type":"Downtime"}`, - resp: &ObjectQueriesResult[Downtime]{}, - expected: &ObjectQueriesResult[Downtime]{ - Name: "docker-master!load!c27b27c2-e0ab-45ff-8b9b-e95f29851eb0", - Type: "Downtime", - Attrs: Downtime{ - Host: "docker-master", - Service: "load", - Author: "icingaadmin", - Comment: "Scheduled downtime for backup", - ConfigOwner: "docker-master!load!backup-downtime", - RemoveTime: UnixFloat(time.UnixMilli(0)), - IsFixed: true, - }, - }, - }, - { - // $ curl -k -s -u root:icinga 'https://localhost:5665/v1/objects/hosts' | jq -c '.results[0]' - name: "host", - jsonData: `{"attrs":{"__name":"dummy-244","acknowledgement":0,"acknowledgement_expiry":0,"acknowledgement_last_change":0,"action_url":"","active":true,"address":"127.0.0.1","address6":"::1","check_attempt":1,"check_command":"random fortune","check_interval":300,"check_period":"","check_timeout":null,"command_endpoint":"","display_name":"dummy-244","downtime_depth":0,"enable_active_checks":true,"enable_event_handler":true,"enable_flapping":false,"enable_notifications":true,"enable_passive_checks":true,"enable_perfdata":true,"event_command":"icinga-notifications-host-events","executions":null,"flapping":false,"flapping_current":0,"flapping_ignore_states":null,"flapping_last_change":0,"flapping_threshold":0,"flapping_threshold_high":30,"flapping_threshold_low":25,"force_next_check":false,"force_next_notification":false,"groups":["app-network","department-dev","env-qa","location-rome"],"ha_mode":0,"handled":false,"icon_image":"","icon_image_alt":"","last_check":1697459643.869006,"last_check_result":{"active":true,"check_source":"docker-master","command":["/bin/bash","-c","/usr/games/fortune; exit $0","0"],"execution_end":1697459643.868893,"execution_start":1697459643.863147,"exit_status":0,"output":"If you think last Tuesday was a drag, wait till you see what happens tomorrow!","performance_data":[],"previous_hard_state":99,"schedule_end":1697459643.869006,"schedule_start":1697459643.86287,"scheduling_source":"docker-master","state":0,"ttl":0,"type":"CheckResult","vars_after":{"attempt":1,"reachable":true,"state":0,"state_type":1},"vars_before":{"attempt":1,"reachable":true,"state":0,"state_type":1}},"last_hard_state":0,"last_hard_state_change":1697099900.637215,"last_reachable":true,"last_state":0,"last_state_change":1697099900.637215,"last_state_down":0,"last_state_type":1,"last_state_unreachable":0,"last_state_up":1697459643.868893,"max_check_attempts":3,"name":"dummy-244","next_check":1697459943.019035,"next_update":1697460243.031081,"notes":"","notes_url":"","original_attributes":null,"package":"_etc","paused":false,"previous_state_change":1697099900.637215,"problem":false,"retry_interval":60,"severity":0,"source_location":{"first_column":5,"first_line":2,"last_column":38,"last_line":2,"path":"/etc/icinga2/zones.d/master/03-dummys-hosts.conf"},"state":0,"state_type":1,"templates":["dummy-244","generic-icinga-notifications-host"],"type":"Host","vars":{"app":"network","department":"dev","env":"qa","is_dummy":true,"location":"rome"},"version":0,"volatile":false,"zone":"master"},"joins":{},"meta":{},"name":"dummy-244","type":"Host"}`, - resp: &ObjectQueriesResult[HostServiceRuntimeAttributes]{}, - expected: &ObjectQueriesResult[HostServiceRuntimeAttributes]{ - Name: "dummy-244", - Type: "Host", - Attrs: HostServiceRuntimeAttributes{ - Name: "dummy-244", - Groups: []string{"app-network", "department-dev", "env-qa", "location-rome"}, - State: StateHostUp, - StateType: StateTypeHard, - LastCheckResult: CheckResult{ - ExitStatus: 0, - Output: "If you think last Tuesday was a drag, wait till you see what happens tomorrow!", - State: StateHostUp, - ExecutionStart: UnixFloat(time.UnixMicro(1697459643863147)), - ExecutionEnd: UnixFloat(time.UnixMicro(1697459643868893)), - }, - LastStateChange: UnixFloat(time.UnixMicro(1697099900637215)), - DowntimeDepth: 0, - Acknowledgement: AcknowledgementNone, - AcknowledgementLastChange: UnixFloat(time.UnixMilli(0)), - }, - }, - }, - { - // $ curl -k -s -u root:icinga -d '{"filter": "service.acknowledgement != 0"}' -H 'Accept: application/json' -H 'X-HTTP-Method-Override: GET' 'https://localhost:5665/v1/objects/services' | jq -c '.results[0]' - name: "service", - jsonData: `{"attrs":{"__name":"docker-master!ssh","acknowledgement":1,"acknowledgement_expiry":0,"acknowledgement_last_change":1697460655.878141,"action_url":"","active":true,"check_attempt":1,"check_command":"ssh","check_interval":60,"check_period":"","check_timeout":null,"command_endpoint":"","display_name":"ssh","downtime_depth":0,"enable_active_checks":true,"enable_event_handler":true,"enable_flapping":false,"enable_notifications":true,"enable_passive_checks":true,"enable_perfdata":true,"event_command":"icinga-notifications-service-events","executions":null,"flapping":false,"flapping_current":0,"flapping_ignore_states":null,"flapping_last_change":0,"flapping_threshold":0,"flapping_threshold_high":30,"flapping_threshold_low":25,"force_next_check":false,"force_next_notification":false,"groups":[],"ha_mode":0,"handled":true,"host_name":"docker-master","icon_image":"","icon_image_alt":"","last_check":1697460711.134904,"last_check_result":{"active":true,"check_source":"docker-master","command":["/usr/lib/nagios/plugins/check_ssh","127.0.0.1"],"execution_end":1697460711.134875,"execution_start":1697460711.130247,"exit_status":2,"output":"connect to address 127.0.0.1 and port 22: Connection refused","performance_data":[],"previous_hard_state":99,"schedule_end":1697460711.134904,"schedule_start":1697460711.13,"scheduling_source":"docker-master","state":2,"ttl":0,"type":"CheckResult","vars_after":{"attempt":1,"reachable":true,"state":2,"state_type":1},"vars_before":{"attempt":1,"reachable":true,"state":2,"state_type":1}},"last_hard_state":2,"last_hard_state_change":1697099980.820806,"last_reachable":true,"last_state":2,"last_state_change":1697099896.120829,"last_state_critical":1697460711.134875,"last_state_ok":0,"last_state_type":1,"last_state_unknown":0,"last_state_unreachable":0,"last_state_warning":0,"max_check_attempts":5,"name":"ssh","next_check":1697460771.1299999,"next_update":1697460831.1397498,"notes":"","notes_url":"","original_attributes":null,"package":"_etc","paused":false,"previous_state_change":1697099896.120829,"problem":true,"retry_interval":30,"severity":640,"source_location":{"first_column":1,"first_line":47,"last_column":19,"last_line":47,"path":"/etc/icinga2/conf.d/services.conf"},"state":2,"state_type":1,"templates":["ssh","generic-icinga-notifications-service","generic-service"],"type":"Service","vars":null,"version":0,"volatile":false,"zone":""},"joins":{},"meta":{},"name":"docker-master!ssh","type":"Service"}`, - resp: &ObjectQueriesResult[HostServiceRuntimeAttributes]{}, - expected: &ObjectQueriesResult[HostServiceRuntimeAttributes]{ - Name: "docker-master!ssh", - Type: "Service", - Attrs: HostServiceRuntimeAttributes{ - Name: "ssh", - Host: "docker-master", - Groups: []string{}, - State: StateServiceCritical, - StateType: StateTypeHard, - LastCheckResult: CheckResult{ - ExitStatus: 2, - Output: "connect to address 127.0.0.1 and port 22: Connection refused", - State: StateServiceCritical, - ExecutionStart: UnixFloat(time.UnixMicro(1697460711130247)), - ExecutionEnd: UnixFloat(time.UnixMicro(1697460711134875)), - }, - LastStateChange: UnixFloat(time.UnixMicro(1697099896120829)), - DowntimeDepth: 0, - Acknowledgement: AcknowledgementNormal, - AcknowledgementLastChange: UnixFloat(time.UnixMicro(1697460655878141)), - }, - }, - }, - { - // $ curl -k -s -u root:icinga 'https://localhost:5665/v1/objects/services' | jq -c '[.results[] | select(.attrs.last_check_result.command|type=="string")][0]' - name: "service-single-command", - jsonData: `{"attrs":{"__name":"docker-master!icinga","acknowledgement":0,"acknowledgement_expiry":0,"acknowledgement_last_change":0,"action_url":"","active":true,"check_attempt":1,"check_command":"icinga","check_interval":60,"check_period":"","check_timeout":null,"command_endpoint":"","display_name":"icinga","downtime_depth":0,"enable_active_checks":true,"enable_event_handler":true,"enable_flapping":false,"enable_notifications":true,"enable_passive_checks":true,"enable_perfdata":true,"event_command":"","executions":null,"flapping":false,"flapping_current":0,"flapping_ignore_states":null,"flapping_last_change":0,"flapping_threshold":0,"flapping_threshold_high":30,"flapping_threshold_low":25,"force_next_check":false,"force_next_notification":false,"groups":[],"ha_mode":0,"handled":false,"host_name":"docker-master","icon_image":"","icon_image_alt":"","last_check":1698673636.071483,"last_check_result":{"active":true,"check_source":"docker-master","command":"icinga","execution_end":1698673636.071483,"execution_start":1698673636.068106,"exit_status":0,"output":"Icinga 2 has been running for 26 seconds. Version: v2.14.0-35-g31b1294ac","performance_data":[{"counter":false,"crit":null,"label":"api_num_conn_endpoints","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0,"warn":null},{"counter":false,"crit":null,"label":"api_num_endpoints","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0,"warn":null},{"counter":false,"crit":null,"label":"api_num_http_clients","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0,"warn":null},{"counter":false,"crit":null,"label":"api_num_json_rpc_anonymous_clients","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0,"warn":null},{"counter":false,"crit":null,"label":"api_num_json_rpc_relay_queue_item_rate","max":null,"min":null,"type":"PerfdataValue","unit":"","value":186.86666666666667,"warn":null},{"counter":false,"crit":null,"label":"api_num_json_rpc_relay_queue_items","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0,"warn":null},{"counter":false,"crit":null,"label":"api_num_json_rpc_sync_queue_item_rate","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0,"warn":null},{"counter":false,"crit":null,"label":"api_num_json_rpc_sync_queue_items","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0,"warn":null},{"counter":false,"crit":null,"label":"api_num_json_rpc_work_queue_item_rate","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0,"warn":null},{"counter":false,"crit":null,"label":"api_num_not_conn_endpoints","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0,"warn":null},{"counter":false,"crit":null,"label":"checkercomponent_checker_idle","max":null,"min":null,"type":"PerfdataValue","unit":"","value":4020,"warn":null},{"counter":false,"crit":null,"label":"checkercomponent_checker_pending","max":null,"min":null,"type":"PerfdataValue","unit":"","value":1,"warn":null},{"counter":false,"crit":null,"label":"idomysqlconnection_ido-mysql_queries_rate","max":null,"min":null,"type":"PerfdataValue","unit":"","value":1526.9166666666667,"warn":null},{"counter":false,"crit":null,"label":"idomysqlconnection_ido-mysql_queries_1min","max":null,"min":null,"type":"PerfdataValue","unit":"","value":91615,"warn":null},{"counter":false,"crit":null,"label":"idomysqlconnection_ido-mysql_queries_5mins","max":null,"min":null,"type":"PerfdataValue","unit":"","value":91615,"warn":null},{"counter":false,"crit":null,"label":"idomysqlconnection_ido-mysql_queries_15mins","max":null,"min":null,"type":"PerfdataValue","unit":"","value":91615,"warn":null},{"counter":false,"crit":null,"label":"idomysqlconnection_ido-mysql_query_queue_items","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0,"warn":null},{"counter":false,"crit":null,"label":"idomysqlconnection_ido-mysql_query_queue_item_rate","max":null,"min":null,"type":"PerfdataValue","unit":"","value":381.5833333333333,"warn":null},{"counter":false,"crit":null,"label":"idopgsqlconnection_ido-pgsql_queries_rate","max":null,"min":null,"type":"PerfdataValue","unit":"","value":1527.15,"warn":null},{"counter":false,"crit":null,"label":"idopgsqlconnection_ido-pgsql_queries_1min","max":null,"min":null,"type":"PerfdataValue","unit":"","value":91629,"warn":null},{"counter":false,"crit":null,"label":"idopgsqlconnection_ido-pgsql_queries_5mins","max":null,"min":null,"type":"PerfdataValue","unit":"","value":91629,"warn":null},{"counter":false,"crit":null,"label":"idopgsqlconnection_ido-pgsql_queries_15mins","max":null,"min":null,"type":"PerfdataValue","unit":"","value":91629,"warn":null},{"counter":false,"crit":null,"label":"idopgsqlconnection_ido-pgsql_query_queue_items","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0,"warn":null},{"counter":false,"crit":null,"label":"idopgsqlconnection_ido-pgsql_query_queue_item_rate","max":null,"min":null,"type":"PerfdataValue","unit":"","value":381.56666666666666,"warn":null},{"counter":false,"crit":null,"label":"active_host_checks","max":null,"min":null,"type":"PerfdataValue","unit":"","value":16.286730297242745,"warn":null},{"counter":false,"crit":null,"label":"passive_host_checks","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0,"warn":null},{"counter":false,"crit":null,"label":"active_host_checks_1min","max":null,"min":null,"type":"PerfdataValue","unit":"","value":451,"warn":null},{"counter":false,"crit":null,"label":"passive_host_checks_1min","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0,"warn":null},{"counter":false,"crit":null,"label":"active_host_checks_5min","max":null,"min":null,"type":"PerfdataValue","unit":"","value":451,"warn":null},{"counter":false,"crit":null,"label":"passive_host_checks_5min","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0,"warn":null},{"counter":false,"crit":null,"label":"active_host_checks_15min","max":null,"min":null,"type":"PerfdataValue","unit":"","value":451,"warn":null},{"counter":false,"crit":null,"label":"passive_host_checks_15min","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0,"warn":null},{"counter":false,"crit":null,"label":"active_service_checks","max":null,"min":null,"type":"PerfdataValue","unit":"","value":47.34161464023706,"warn":null},{"counter":false,"crit":null,"label":"passive_service_checks","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0,"warn":null},{"counter":false,"crit":null,"label":"active_service_checks_1min","max":null,"min":null,"type":"PerfdataValue","unit":"","value":1295,"warn":null},{"counter":false,"crit":null,"label":"passive_service_checks_1min","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0,"warn":null},{"counter":false,"crit":null,"label":"active_service_checks_5min","max":null,"min":null,"type":"PerfdataValue","unit":"","value":1295,"warn":null},{"counter":false,"crit":null,"label":"passive_service_checks_5min","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0,"warn":null},{"counter":false,"crit":null,"label":"active_service_checks_15min","max":null,"min":null,"type":"PerfdataValue","unit":"","value":1295,"warn":null},{"counter":false,"crit":null,"label":"passive_service_checks_15min","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0,"warn":null},{"counter":false,"crit":null,"label":"current_pending_callbacks","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0,"warn":null},{"counter":false,"crit":null,"label":"current_concurrent_checks","max":null,"min":null,"type":"PerfdataValue","unit":"","value":68,"warn":null},{"counter":false,"crit":null,"label":"remote_check_queue","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0,"warn":null},{"counter":false,"crit":null,"label":"min_latency","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0.00010800361633300781,"warn":null},{"counter":false,"crit":null,"label":"max_latency","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0.003133535385131836,"warn":null},{"counter":false,"crit":null,"label":"avg_latency","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0.0004072719851866463,"warn":null},{"counter":false,"crit":null,"label":"min_execution_time","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0.0009090900421142578,"warn":null},{"counter":false,"crit":null,"label":"max_execution_time","max":null,"min":null,"type":"PerfdataValue","unit":"","value":4.142040014266968,"warn":null},{"counter":false,"crit":null,"label":"avg_execution_time","max":null,"min":null,"type":"PerfdataValue","unit":"","value":1.3660419934632761,"warn":null},{"counter":false,"crit":null,"label":"num_services_ok","max":null,"min":null,"type":"PerfdataValue","unit":"","value":1972,"warn":null},{"counter":false,"crit":null,"label":"num_services_warning","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0,"warn":null},{"counter":false,"crit":null,"label":"num_services_critical","max":null,"min":null,"type":"PerfdataValue","unit":"","value":47,"warn":null},{"counter":false,"crit":null,"label":"num_services_unknown","max":null,"min":null,"type":"PerfdataValue","unit":"","value":1001,"warn":null},{"counter":false,"crit":null,"label":"num_services_pending","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0,"warn":null},{"counter":false,"crit":null,"label":"num_services_unreachable","max":null,"min":null,"type":"PerfdataValue","unit":"","value":138,"warn":null},{"counter":false,"crit":null,"label":"num_services_flapping","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0,"warn":null},{"counter":false,"crit":null,"label":"num_services_in_downtime","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0,"warn":null},{"counter":false,"crit":null,"label":"num_services_acknowledged","max":null,"min":null,"type":"PerfdataValue","unit":"","value":2,"warn":null},{"counter":false,"crit":null,"label":"num_services_handled","max":null,"min":null,"type":"PerfdataValue","unit":"","value":149,"warn":null},{"counter":false,"crit":null,"label":"num_services_problem","max":null,"min":null,"type":"PerfdataValue","unit":"","value":1048,"warn":null},{"counter":false,"crit":null,"label":"uptime","max":null,"min":null,"type":"PerfdataValue","unit":"","value":26.343533039093018,"warn":null},{"counter":false,"crit":null,"label":"num_hosts_up","max":null,"min":null,"type":"PerfdataValue","unit":"","value":952,"warn":null},{"counter":false,"crit":null,"label":"num_hosts_down","max":null,"min":null,"type":"PerfdataValue","unit":"","value":49,"warn":null},{"counter":false,"crit":null,"label":"num_hosts_pending","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0,"warn":null},{"counter":false,"crit":null,"label":"num_hosts_unreachable","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0,"warn":null},{"counter":false,"crit":null,"label":"num_hosts_flapping","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0,"warn":null},{"counter":false,"crit":null,"label":"num_hosts_in_downtime","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0,"warn":null},{"counter":false,"crit":null,"label":"num_hosts_acknowledged","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0,"warn":null},{"counter":false,"crit":null,"label":"num_hosts_handled","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0,"warn":null},{"counter":false,"crit":null,"label":"num_hosts_problem","max":null,"min":null,"type":"PerfdataValue","unit":"","value":49,"warn":null},{"counter":false,"crit":null,"label":"last_messages_sent","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0,"warn":null},{"counter":false,"crit":null,"label":"last_messages_received","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0,"warn":null},{"counter":false,"crit":null,"label":"sum_messages_sent_per_second","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0,"warn":null},{"counter":false,"crit":null,"label":"sum_messages_received_per_second","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0,"warn":null},{"counter":false,"crit":null,"label":"sum_bytes_sent_per_second","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0,"warn":null},{"counter":false,"crit":null,"label":"sum_bytes_received_per_second","max":null,"min":null,"type":"PerfdataValue","unit":"","value":0,"warn":null}],"previous_hard_state":99,"schedule_end":1698673636.071483,"schedule_start":1698673636.0680327,"scheduling_source":"docker-master","state":0,"ttl":0,"type":"CheckResult","vars_after":{"attempt":1,"reachable":true,"state":0,"state_type":1},"vars_before":{"attempt":1,"reachable":true,"state":0,"state_type":1}},"last_hard_state":0,"last_hard_state_change":1697704135.75631,"last_reachable":true,"last_state":0,"last_state_change":1697704135.75631,"last_state_critical":0,"last_state_ok":1698673636.071483,"last_state_type":1,"last_state_unknown":0,"last_state_unreachable":0,"last_state_warning":0,"max_check_attempts":5,"name":"icinga","next_check":1698673695.12149,"next_update":1698673755.1283903,"notes":"","notes_url":"","original_attributes":null,"package":"_etc","paused":false,"previous_state_change":1697704135.75631,"problem":false,"retry_interval":30,"severity":0,"source_location":{"first_column":1,"first_line":73,"last_column":22,"last_line":73,"path":"/etc/icinga2/conf.d/services.conf"},"state":0,"state_type":1,"templates":["icinga","generic-service"],"type":"Service","vars":null,"version":0,"volatile":false,"zone":""},"joins":{},"meta":{},"name":"docker-master!icinga","type":"Service"}`, - resp: &ObjectQueriesResult[HostServiceRuntimeAttributes]{}, - expected: &ObjectQueriesResult[HostServiceRuntimeAttributes]{ - Name: "docker-master!icinga", - Type: "Service", - Attrs: HostServiceRuntimeAttributes{ - Name: "icinga", - Host: "docker-master", - Groups: []string{}, - State: StateServiceOk, - StateType: StateTypeHard, - LastCheckResult: CheckResult{ - ExitStatus: 0, - Output: "Icinga 2 has been running for 26 seconds. Version: v2.14.0-35-g31b1294ac", - State: StateServiceOk, - ExecutionStart: UnixFloat(time.UnixMicro(1698673636068106)), - ExecutionEnd: UnixFloat(time.UnixMicro(1698673636071483)), - }, - LastStateChange: UnixFloat(time.UnixMicro(1697704135756310)), - DowntimeDepth: 0, - Acknowledgement: AcknowledgementNone, - AcknowledgementLastChange: UnixFloat(time.UnixMilli(0)), - }, - }, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - err := json.Unmarshal([]byte(test.jsonData), test.resp) - assert.Equal(t, test.isError, err != nil, "unexpected error state; %v", err) - if err != nil { - return - } - - assert.EqualValuesf(t, test.expected, test.resp, "unexpected ObjectQueriesResult") - }) - } -} - -func TestApiResponseUnmarshal(t *testing.T) { - tests := []struct { - name string - jsonData string - isError bool - expected any - }{ - { - name: "empty", - jsonData: ``, - isError: true, - }, - { - name: "invalid-json", - jsonData: `{":}"`, - isError: true, - }, - { - name: "empty-json-struct", - jsonData: `{}`, - isError: true, - }, - { - name: "error-field", - jsonData: `{"error":401,"status":"Unauthorized. Please check your user credentials."}`, - isError: true, - }, - { - name: "error-field-with-valid-type", - jsonData: `{"type":"StateChange","error":401,"status":"Unauthorized. Please check your user credentials."}`, - isError: true, - }, - { - name: "error-field-with-invalid-error", - jsonData: `{"error":"tja"}`, - isError: true, - }, - { - name: "unknown-type", - jsonData: `{"type": "ihopethisstringwillneverappearinicinga2asavalidtype"}`, - isError: true, - }, - { - name: "statechange-host-valid", - jsonData: `{"acknowledgement":false,"check_result":{"active":true,"check_source":"docker-master","command":["/bin/bash","-c","/usr/games/fortune; exit $0","2"],"execution_end":1697188278.202986,"execution_start":1697188278.194409,"exit_status":2,"output":"If two people love each other, there can be no happy end to it.\n\t\t-- Ernest Hemingway","performance_data":[],"previous_hard_state":99,"schedule_end":1697188278.203036,"schedule_start":1697188278.1938322,"scheduling_source":"docker-master","state":2,"ttl":0,"type":"CheckResult","vars_after":{"attempt":2,"reachable":true,"state":2,"state_type":0},"vars_before":{"attempt":1,"reachable":true,"state":2,"state_type":0}},"downtime_depth":0,"host":"dummy-158","state":1,"state_type":0,"timestamp":1697188278.203504,"type":"StateChange"}`, - expected: &StateChange{ - Timestamp: UnixFloat(time.UnixMicro(1697188278203504)), - Host: "dummy-158", - State: StateHostDown, - StateType: StateTypeSoft, - CheckResult: CheckResult{ - ExitStatus: 2, - Output: "If two people love each other, there can be no happy end to it.\n\t\t-- Ernest Hemingway", - // The State will be mapped to StateHostDown within Icinga 2, as shown in the outer StateChange - // State field. https://github.com/Icinga/icinga2/blob/v2.14.1/lib/icinga/host.cpp#L141-L155 - State: StateServiceCritical, - ExecutionStart: UnixFloat(time.UnixMicro(1697188278194409)), - ExecutionEnd: UnixFloat(time.UnixMicro(1697188278202986)), - }, - DowntimeDepth: 0, - Acknowledgement: false, - }, - }, - { - name: "statechange-service-valid", - jsonData: `{"acknowledgement":false,"check_result":{"active":true,"check_source":"docker-master","command":["/bin/bash","-c","/usr/games/fortune; exit $0","2"],"execution_end":1697184778.611465,"execution_start":1697184778.600973,"exit_status":2,"output":"You're growing out of some of your problems, but there are others that\nyou're growing into.","performance_data":[],"previous_hard_state":0,"schedule_end":1697184778.611557,"schedule_start":1697184778.6,"scheduling_source":"docker-master","state":2,"ttl":0,"type":"CheckResult","vars_after":{"attempt":2,"reachable":false,"state":2,"state_type":0},"vars_before":{"attempt":1,"reachable":false,"state":2,"state_type":0}},"downtime_depth":0,"host":"dummy-280","service":"random fortune","state":2,"state_type":0,"timestamp":1697184778.612108,"type":"StateChange"}`, - expected: &StateChange{ - Timestamp: UnixFloat(time.UnixMicro(1697184778612108)), - Host: "dummy-280", - Service: "random fortune", - State: StateServiceCritical, - StateType: StateTypeSoft, - CheckResult: CheckResult{ - ExitStatus: 2, - Output: "You're growing out of some of your problems, but there are others that\nyou're growing into.", - State: StateServiceCritical, - ExecutionStart: UnixFloat(time.UnixMicro(1697184778600973)), - ExecutionEnd: UnixFloat(time.UnixMicro(1697184778611465)), - }, - DowntimeDepth: 0, - Acknowledgement: false, - }, - }, - { - name: "acknowledgementset-host", - jsonData: `{"acknowledgement_type":1,"author":"icingaadmin","comment":"working on it","expiry":0,"host":"dummy-805","notify":true,"persistent":false,"state":1,"state_type":1,"timestamp":1697201074.579106,"type":"AcknowledgementSet"}`, - expected: &Acknowledgement{ - Timestamp: UnixFloat(time.UnixMicro(1697201074579106)), - Host: "dummy-805", - State: StateHostDown, - StateType: StateTypeHard, - Author: "icingaadmin", - Comment: "working on it", - EventType: typeAcknowledgementSet, - }, - }, - { - name: "acknowledgementset-service", - jsonData: `{"acknowledgement_type":1,"author":"icingaadmin","comment":"will be fixed soon","expiry":0,"host":"docker-master","notify":true,"persistent":false,"service":"ssh","state":2,"state_type":1,"timestamp":1697201107.64792,"type":"AcknowledgementSet"}`, - expected: &Acknowledgement{ - Timestamp: UnixFloat(time.UnixMicro(1697201107647920)), - Host: "docker-master", - Service: "ssh", - State: StateServiceCritical, - StateType: StateTypeHard, - Author: "icingaadmin", - Comment: "will be fixed soon", - EventType: typeAcknowledgementSet, - }, - }, - { - name: "acknowledgementcleared-host", - jsonData: `{"acknowledgement_type":0,"host":"dummy-805","state":1,"state_type":1,"timestamp":1697201082.440148,"type":"AcknowledgementCleared"}`, - expected: &Acknowledgement{ - Timestamp: UnixFloat(time.UnixMicro(1697201082440148)), - Host: "dummy-805", - State: StateHostDown, - StateType: StateTypeHard, - EventType: typeAcknowledgementCleared, - }, - }, - { - name: "acknowledgementcleared-service", - jsonData: `{"acknowledgement_type":0,"host":"docker-master","service":"ssh","state":2,"state_type":1,"timestamp":1697201110.220349,"type":"AcknowledgementCleared"}`, - expected: &Acknowledgement{ - Timestamp: UnixFloat(time.UnixMicro(1697201110220349)), - Host: "docker-master", - Service: "ssh", - State: StateServiceCritical, - StateType: StateTypeHard, - EventType: typeAcknowledgementCleared, - }, - }, - { - name: "commentadded-host", - jsonData: `{"comment":{"__name":"dummy-912!f653e951-2210-432d-bca6-e3719ea74ca3","author":"icingaadmin","entry_time":1697191791.097852,"entry_type":1,"expire_time":0,"host_name":"dummy-912","legacy_id":1,"name":"f653e951-2210-432d-bca6-e3719ea74ca3","package":"_api","persistent":false,"service_name":"","source_location":{"first_column":0,"first_line":1,"last_column":68,"last_line":1,"path":"/var/lib/icinga2/api/packages/_api/997346d3-374d-443f-b734-80789fd59b31/conf.d/comments/dummy-912!f653e951-2210-432d-bca6-e3719ea74ca3.conf"},"sticky":false,"templates":["f653e951-2210-432d-bca6-e3719ea74ca3"],"text":"oh noes","type":"Comment","version":1697191791.097867,"zone":"master"},"timestamp":1697191791.099201,"type":"CommentAdded"}`, - expected: &CommentAdded{ - Timestamp: UnixFloat(time.UnixMicro(1697191791099201)), - Comment: Comment{ - Host: "dummy-912", - Author: "icingaadmin", - Text: "oh noes", - EntryType: EntryTypeUser, - EntryTime: UnixFloat(time.UnixMicro(1697191791097852)), - }, - }, - }, - { - name: "commentadded-service", - jsonData: `{"comment":{"__name":"dummy-912!ping4!8c00fb6a-5948-4249-a9d5-d1b6eb8945d0","author":"icingaadmin","entry_time":1697197990.035889,"entry_type":1,"expire_time":0,"host_name":"dummy-912","legacy_id":8,"name":"8c00fb6a-5948-4249-a9d5-d1b6eb8945d0","package":"_api","persistent":false,"service_name":"ping4","source_location":{"first_column":0,"first_line":1,"last_column":68,"last_line":1,"path":"/var/lib/icinga2/api/packages/_api/997346d3-374d-443f-b734-80789fd59b31/conf.d/comments/dummy-912!ping4!8c00fb6a-5948-4249-a9d5-d1b6eb8945d0.conf"},"sticky":false,"templates":["8c00fb6a-5948-4249-a9d5-d1b6eb8945d0"],"text":"if in doubt, check ticket #23","type":"Comment","version":1697197990.035905,"zone":"master"},"timestamp":1697197990.037244,"type":"CommentAdded"}`, - expected: &CommentAdded{ - Timestamp: UnixFloat(time.UnixMicro(1697197990037244)), - Comment: Comment{ - Host: "dummy-912", - Service: "ping4", - Author: "icingaadmin", - Text: "if in doubt, check ticket #23", - EntryType: EntryTypeUser, - EntryTime: UnixFloat(time.UnixMicro(1697197990035889)), - }, - }, - }, - { - name: "commentremoved-host", - jsonData: `{"comment":{"__name":"dummy-912!f653e951-2210-432d-bca6-e3719ea74ca3","author":"icingaadmin","entry_time":1697191791.097852,"entry_type":1,"expire_time":0,"host_name":"dummy-912","legacy_id":1,"name":"f653e951-2210-432d-bca6-e3719ea74ca3","package":"_api","persistent":false,"service_name":"","source_location":{"first_column":0,"first_line":1,"last_column":68,"last_line":1,"path":"/var/lib/icinga2/api/packages/_api/997346d3-374d-443f-b734-80789fd59b31/conf.d/comments/dummy-912!f653e951-2210-432d-bca6-e3719ea74ca3.conf"},"sticky":false,"templates":["f653e951-2210-432d-bca6-e3719ea74ca3"],"text":"oh noes","type":"Comment","version":1697191791.097867,"zone":"master"},"timestamp":1697191807.910093,"type":"CommentRemoved"}`, - expected: &CommentRemoved{ - Timestamp: UnixFloat(time.UnixMicro(1697191807910093)), - Comment: Comment{ - Host: "dummy-912", - Author: "icingaadmin", - Text: "oh noes", - EntryType: EntryTypeUser, - EntryTime: UnixFloat(time.UnixMicro(1697191791097852)), - }, - }, - }, - { - name: "commentremoved-service", - jsonData: `{"comment":{"__name":"dummy-912!ping4!8c00fb6a-5948-4249-a9d5-d1b6eb8945d0","author":"icingaadmin","entry_time":1697197990.035889,"entry_type":1,"expire_time":0,"host_name":"dummy-912","legacy_id":8,"name":"8c00fb6a-5948-4249-a9d5-d1b6eb8945d0","package":"_api","persistent":false,"service_name":"ping4","source_location":{"first_column":0,"first_line":1,"last_column":68,"last_line":1,"path":"/var/lib/icinga2/api/packages/_api/997346d3-374d-443f-b734-80789fd59b31/conf.d/comments/dummy-912!ping4!8c00fb6a-5948-4249-a9d5-d1b6eb8945d0.conf"},"sticky":false,"templates":["8c00fb6a-5948-4249-a9d5-d1b6eb8945d0"],"text":"if in doubt, check ticket #23","type":"Comment","version":1697197990.035905,"zone":"master"},"timestamp":1697197996.584392,"type":"CommentRemoved"}`, - expected: &CommentRemoved{ - Timestamp: UnixFloat(time.UnixMicro(1697197996584392)), - Comment: Comment{ - Host: "dummy-912", - Service: "ping4", - Author: "icingaadmin", - Text: "if in doubt, check ticket #23", - EntryType: EntryTypeUser, - EntryTime: UnixFloat(time.UnixMicro(1697197990035889)), - }, - }, - }, - { - name: "downtimeadded-host", - jsonData: `{"downtime":{"__name":"dummy-157!e5d4d4ac-615a-4995-ab8f-09d9cd9503b1","author":"icingaadmin","authoritative_zone":"","comment":"updates","config_owner":"","config_owner_hash":"","duration":0,"end_time":1697210639,"entry_time":1697207050.509957,"fixed":true,"host_name":"dummy-157","legacy_id":3,"name":"e5d4d4ac-615a-4995-ab8f-09d9cd9503b1","package":"_api","parent":"","remove_time":0,"scheduled_by":"","service_name":"","source_location":{"first_column":0,"first_line":1,"last_column":69,"last_line":1,"path":"/var/lib/icinga2/api/packages/_api/997346d3-374d-443f-b734-80789fd59b31/conf.d/downtimes/dummy-157!e5d4d4ac-615a-4995-ab8f-09d9cd9503b1.conf"},"start_time":1697207039,"templates":["e5d4d4ac-615a-4995-ab8f-09d9cd9503b1"],"trigger_time":0,"triggered_by":"","triggers":[],"type":"Downtime","version":1697207050.509971,"zone":"master"},"timestamp":1697207050.511293,"type":"DowntimeAdded"}`, - expected: &DowntimeAdded{ - Timestamp: UnixFloat(time.UnixMicro(1697207050511293)), - Downtime: Downtime{ - Host: "dummy-157", - Author: "icingaadmin", - Comment: "updates", - RemoveTime: UnixFloat(time.UnixMilli(0)), - IsFixed: true, - }, - }, - }, - { - name: "downtimeadded-service", - jsonData: `{"downtime":{"__name":"docker-master!http!3dabe7e7-32b2-4112-ba8f-a6567e5be79f","author":"icingaadmin","authoritative_zone":"","comment":"broken until Monday","config_owner":"","config_owner_hash":"","duration":0,"end_time":1697210716,"entry_time":1697207141.216009,"fixed":true,"host_name":"docker-master","legacy_id":4,"name":"3dabe7e7-32b2-4112-ba8f-a6567e5be79f","package":"_api","parent":"","remove_time":0,"scheduled_by":"","service_name":"http","source_location":{"first_column":0,"first_line":1,"last_column":69,"last_line":1,"path":"/var/lib/icinga2/api/packages/_api/997346d3-374d-443f-b734-80789fd59b31/conf.d/downtimes/docker-master!http!3dabe7e7-32b2-4112-ba8f-a6567e5be79f.conf"},"start_time":1697207116,"templates":["3dabe7e7-32b2-4112-ba8f-a6567e5be79f"],"trigger_time":0,"triggered_by":"","triggers":[],"type":"Downtime","version":1697207141.216025,"zone":""},"timestamp":1697207141.217425,"type":"DowntimeAdded"}`, - expected: &DowntimeAdded{ - Timestamp: UnixFloat(time.UnixMicro(1697207141217425)), - Downtime: Downtime{ - Host: "docker-master", - Service: "http", - Author: "icingaadmin", - Comment: "broken until Monday", - RemoveTime: UnixFloat(time.UnixMilli(0)), - IsFixed: true, - }, - }, - }, - { - name: "downtimestarted-host", - jsonData: `{"downtime":{"__name":"dummy-157!e5d4d4ac-615a-4995-ab8f-09d9cd9503b1","author":"icingaadmin","authoritative_zone":"","comment":"updates","config_owner":"","config_owner_hash":"","duration":0,"end_time":1697210639,"entry_time":1697207050.509957,"fixed":true,"host_name":"dummy-157","legacy_id":3,"name":"e5d4d4ac-615a-4995-ab8f-09d9cd9503b1","package":"_api","parent":"","remove_time":0,"scheduled_by":"","service_name":"","source_location":{"first_column":0,"first_line":1,"last_column":69,"last_line":1,"path":"/var/lib/icinga2/api/packages/_api/997346d3-374d-443f-b734-80789fd59b31/conf.d/downtimes/dummy-157!e5d4d4ac-615a-4995-ab8f-09d9cd9503b1.conf"},"start_time":1697207039,"templates":["e5d4d4ac-615a-4995-ab8f-09d9cd9503b1"],"trigger_time":0,"triggered_by":"","triggers":[],"type":"Downtime","version":1697207050.509971,"zone":"master"},"timestamp":1697207050.511378,"type":"DowntimeStarted"}`, - expected: &DowntimeStarted{ - Timestamp: UnixFloat(time.UnixMicro(1697207050511378)), - Downtime: Downtime{ - Host: "dummy-157", - Author: "icingaadmin", - Comment: "updates", - RemoveTime: UnixFloat(time.UnixMilli(0)), - IsFixed: true, - }, - }, - }, - { - name: "downtimestarted-service", - jsonData: `{"downtime":{"__name":"docker-master!http!3dabe7e7-32b2-4112-ba8f-a6567e5be79f","author":"icingaadmin","authoritative_zone":"","comment":"broken until Monday","config_owner":"","config_owner_hash":"","duration":0,"end_time":1697210716,"entry_time":1697207141.216009,"fixed":true,"host_name":"docker-master","legacy_id":4,"name":"3dabe7e7-32b2-4112-ba8f-a6567e5be79f","package":"_api","parent":"","remove_time":0,"scheduled_by":"","service_name":"http","source_location":{"first_column":0,"first_line":1,"last_column":69,"last_line":1,"path":"/var/lib/icinga2/api/packages/_api/997346d3-374d-443f-b734-80789fd59b31/conf.d/downtimes/docker-master!http!3dabe7e7-32b2-4112-ba8f-a6567e5be79f.conf"},"start_time":1697207116,"templates":["3dabe7e7-32b2-4112-ba8f-a6567e5be79f"],"trigger_time":0,"triggered_by":"","triggers":[],"type":"Downtime","version":1697207141.216025,"zone":""},"timestamp":1697207141.217507,"type":"DowntimeStarted"}`, - expected: &DowntimeStarted{ - Timestamp: UnixFloat(time.UnixMicro(1697207141217507)), - Downtime: Downtime{ - Host: "docker-master", - Service: "http", - Author: "icingaadmin", - Comment: "broken until Monday", - RemoveTime: UnixFloat(time.UnixMilli(0)), - IsFixed: true, - }, - }, - }, - { - name: "downtimetriggered-host", - jsonData: `{"downtime":{"__name":"dummy-157!e5d4d4ac-615a-4995-ab8f-09d9cd9503b1","author":"icingaadmin","authoritative_zone":"","comment":"updates","config_owner":"","config_owner_hash":"","duration":0,"end_time":1697210639,"entry_time":1697207050.509957,"fixed":true,"host_name":"dummy-157","legacy_id":3,"name":"e5d4d4ac-615a-4995-ab8f-09d9cd9503b1","package":"_api","parent":"","remove_time":0,"scheduled_by":"","service_name":"","source_location":{"first_column":0,"first_line":1,"last_column":69,"last_line":1,"path":"/var/lib/icinga2/api/packages/_api/997346d3-374d-443f-b734-80789fd59b31/conf.d/downtimes/dummy-157!e5d4d4ac-615a-4995-ab8f-09d9cd9503b1.conf"},"start_time":1697207039,"templates":["e5d4d4ac-615a-4995-ab8f-09d9cd9503b1"],"trigger_time":1697207050.509957,"triggered_by":"","triggers":[],"type":"Downtime","version":1697207050.509971,"zone":"master"},"timestamp":1697207050.511608,"type":"DowntimeTriggered"}`, - expected: &DowntimeTriggered{ - Timestamp: UnixFloat(time.UnixMicro(1697207050511608)), - Downtime: Downtime{ - Host: "dummy-157", - Author: "icingaadmin", - Comment: "updates", - RemoveTime: UnixFloat(time.UnixMilli(0)), - IsFixed: true, - }, - }, - }, - { - name: "flexible-downtimetriggered-host", - jsonData: `{"downtime":{"__name":"dummy-7!691d508b-c93f-4565-819c-3e46ffef1555","author":"icingaadmin","authoritative_zone":"","comment":"Flexible","config_owner":"","config_owner_hash":"","duration":7200,"end_time":1714043658,"entry_time":1714040073.241627,"fixed":false,"host_name":"dummy-7","legacy_id":4,"name":"691d508b-c93f-4565-819c-3e46ffef1555","package":"_api","parent":"","remove_time":0,"scheduled_by":"","service_name":"","source_location":{"first_column":0,"first_line":1,"last_column":69,"last_line":1,"path":"/var/lib/icinga2/api/packages/_api/e5ec468f-6d29-4055-9cd4-495dbbef16e3/conf.d/downtimes/dummy-7!691d508b-c93f-4565-819c-3e46ffef1555.conf"},"start_time":1714040058,"templates":["691d508b-c93f-4565-819c-3e46ffef1555"],"trigger_time":0,"triggered_by":"","triggers":[],"type":"Downtime","version":1714040073.241642,"zone":"master"},"timestamp":1714040073.242575,"type":"DowntimeAdded"}`, - expected: &DowntimeTriggered{ - Timestamp: UnixFloat(time.UnixMicro(1714040073242575)), - Downtime: Downtime{ - Host: "dummy-7", - Author: "icingaadmin", - Comment: "Flexible", - RemoveTime: UnixFloat(time.UnixMilli(0)), - IsFixed: false, - }, - }, - }, - { - name: "downtimetriggered-service", - jsonData: `{"downtime":{"__name":"docker-master!http!3dabe7e7-32b2-4112-ba8f-a6567e5be79f","author":"icingaadmin","authoritative_zone":"","comment":"broken until Monday","config_owner":"","config_owner_hash":"","duration":0,"end_time":1697210716,"entry_time":1697207141.216009,"fixed":true,"host_name":"docker-master","legacy_id":4,"name":"3dabe7e7-32b2-4112-ba8f-a6567e5be79f","package":"_api","parent":"","remove_time":0,"scheduled_by":"","service_name":"http","source_location":{"first_column":0,"first_line":1,"last_column":69,"last_line":1,"path":"/var/lib/icinga2/api/packages/_api/997346d3-374d-443f-b734-80789fd59b31/conf.d/downtimes/docker-master!http!3dabe7e7-32b2-4112-ba8f-a6567e5be79f.conf"},"start_time":1697207116,"templates":["3dabe7e7-32b2-4112-ba8f-a6567e5be79f"],"trigger_time":1697207141.216009,"triggered_by":"","triggers":[],"type":"Downtime","version":1697207141.216025,"zone":""},"timestamp":1697207141.217726,"type":"DowntimeTriggered"}`, - expected: &DowntimeTriggered{ - Timestamp: UnixFloat(time.UnixMicro(1697207141217726)), - Downtime: Downtime{ - Host: "docker-master", - Service: "http", - Author: "icingaadmin", - Comment: "broken until Monday", - RemoveTime: UnixFloat(time.UnixMilli(0)), - IsFixed: true, - }, - }, - }, - { - name: "flexible-downtimetriggered-service", - jsonData: `{"downtime":{"__name":"docker-master!disk /!97078a44-8902-495a-9f2a-c1f6802bc63d","author":"icingaadmin","authoritative_zone":"","comment":"Flexible","config_owner":"","config_owner_hash":"","duration":7200,"end_time":1714042731,"entry_time":1714039143.459298,"fixed":false,"host_name":"docker-master","legacy_id":3,"name":"97078a44-8902-495a-9f2a-c1f6802bc63d","package":"_api","parent":"","remove_time":0,"scheduled_by":"","service_name":"disk /","source_location":{"first_column":0,"first_line":1,"last_column":69,"last_line":1,"path":"/var/lib/icinga2/api/packages/_api/e5ec468f-6d29-4055-9cd4-495dbbef16e3/conf.d/downtimes/docker-master!disk %2F!97078a44-8902-495a-9f2a-c1f6802bc63d.conf"},"start_time":1714039131,"templates":["97078a44-8902-495a-9f2a-c1f6802bc63d"],"trigger_time":1714039143.459298,"triggered_by":"","triggers":[],"type":"Downtime","version":1714039143.459324,"zone":""},"timestamp":1714039143.460918,"type":"DowntimeTriggered"}`, - expected: &DowntimeTriggered{ - Timestamp: UnixFloat(time.UnixMicro(1714039143460918)), - Downtime: Downtime{ - Host: "docker-master", - Service: "disk /", - Author: "icingaadmin", - Comment: "Flexible", - RemoveTime: UnixFloat(time.UnixMilli(0)), - IsFixed: false, - }, - }, - }, - { - name: "downtimeended-host", - jsonData: `{"downtime":{"__name":"dummy-157!e5d4d4ac-615a-4995-ab8f-09d9cd9503b1","author":"icingaadmin","authoritative_zone":"","comment":"updates","config_owner":"","config_owner_hash":"","duration":0,"end_time":1697210639,"entry_time":1697207050.509957,"fixed":true,"host_name":"dummy-157","legacy_id":3,"name":"e5d4d4ac-615a-4995-ab8f-09d9cd9503b1","package":"_api","parent":"","remove_time":0.0,"scheduled_by":"","service_name":"","source_location":{"first_column":0,"first_line":1,"last_column":69,"last_line":1,"path":"/var/lib/icinga2/api/packages/_api/997346d3-374d-443f-b734-80789fd59b31/conf.d/downtimes/dummy-157!e5d4d4ac-615a-4995-ab8f-09d9cd9503b1.conf"},"start_time":1697207039,"templates":["e5d4d4ac-615a-4995-ab8f-09d9cd9503b1"],"trigger_time":1697207050.509957,"triggered_by":"","triggers":[],"type":"Downtime","version":1697207050.509971,"zone":"master"},"timestamp":1697207096.187866,"type":"DowntimeRemoved"}`, - expected: &DowntimeRemoved{ - Timestamp: UnixFloat(time.UnixMicro(1697207096187866)), - Downtime: Downtime{ - Host: "dummy-157", - Author: "icingaadmin", - Comment: "updates", - RemoveTime: UnixFloat(time.UnixMilli(0)), - IsFixed: true, - }, - }, - }, - { - name: "downtimeremoved-host", - jsonData: `{"downtime":{"__name":"dummy-157!e5d4d4ac-615a-4995-ab8f-09d9cd9503b1","author":"icingaadmin","authoritative_zone":"","comment":"updates","config_owner":"","config_owner_hash":"","duration":0,"end_time":1697210639,"entry_time":1697207050.509957,"fixed":true,"host_name":"dummy-157","legacy_id":3,"name":"e5d4d4ac-615a-4995-ab8f-09d9cd9503b1","package":"_api","parent":"","remove_time":1697207096.187718,"scheduled_by":"","service_name":"","source_location":{"first_column":0,"first_line":1,"last_column":69,"last_line":1,"path":"/var/lib/icinga2/api/packages/_api/997346d3-374d-443f-b734-80789fd59b31/conf.d/downtimes/dummy-157!e5d4d4ac-615a-4995-ab8f-09d9cd9503b1.conf"},"start_time":1697207039,"templates":["e5d4d4ac-615a-4995-ab8f-09d9cd9503b1"],"trigger_time":1697207050.509957,"triggered_by":"","triggers":[],"type":"Downtime","version":1697207050.509971,"zone":"master"},"timestamp":1697207096.187866,"type":"DowntimeRemoved"}`, - expected: &DowntimeRemoved{ - Timestamp: UnixFloat(time.UnixMicro(1697207096187866)), - Downtime: Downtime{ - Host: "dummy-157", - Author: "icingaadmin", - Comment: "updates", - RemoveTime: UnixFloat(time.UnixMicro(1697207096187718)), - IsFixed: true, - }, - }, - }, - { - name: "downtimeremoved-service", - jsonData: `{"downtime":{"__name":"docker-master!http!3dabe7e7-32b2-4112-ba8f-a6567e5be79f","author":"icingaadmin","authoritative_zone":"","comment":"broken until Monday","config_owner":"","config_owner_hash":"","duration":0,"end_time":1697210716,"entry_time":1697207141.216009,"fixed":true,"host_name":"docker-master","legacy_id":4,"name":"3dabe7e7-32b2-4112-ba8f-a6567e5be79f","package":"_api","parent":"","remove_time":1697207144.746117,"scheduled_by":"","service_name":"http","source_location":{"first_column":0,"first_line":1,"last_column":69,"last_line":1,"path":"/var/lib/icinga2/api/packages/_api/997346d3-374d-443f-b734-80789fd59b31/conf.d/downtimes/docker-master!http!3dabe7e7-32b2-4112-ba8f-a6567e5be79f.conf"},"start_time":1697207116,"templates":["3dabe7e7-32b2-4112-ba8f-a6567e5be79f"],"trigger_time":1697207141.216009,"triggered_by":"","triggers":[],"type":"Downtime","version":1697207141.216025,"zone":""},"timestamp":1697207144.746333,"type":"DowntimeRemoved"}`, - expected: &DowntimeRemoved{ - Timestamp: UnixFloat(time.UnixMicro(1697207144746333)), - Downtime: Downtime{ - Host: "docker-master", - Service: "http", - Author: "icingaadmin", - Comment: "broken until Monday", - RemoveTime: UnixFloat(time.UnixMicro(1697207144746117)), - IsFixed: true, - }, - }, - }, - { - name: "objectcreated-host", - jsonData: `{"object_name":"event-stream","object_type":"Host","timestamp":1716542256.769028,"type":"ObjectCreated"}`, - expected: &ObjectCreatedDeleted{ - ObjectName: "event-stream", - ObjectType: "Host", - EventType: "ObjectCreated", - }, - }, - { - name: "objectcreated-service", - jsonData: `{"object_name":"event-stream!ssh","object_type":"Service","timestamp":1716542256.783502,"type":"ObjectCreated"}`, - expected: &ObjectCreatedDeleted{ - ObjectName: "event-stream!ssh", - ObjectType: "Service", - EventType: "ObjectCreated", - }, - }, - { - name: "objectdeleted-host", - jsonData: `{"object_name":"event-stream","object_type":"Host","timestamp":1716542070.492318,"type":"ObjectDeleted"}`, - expected: &ObjectCreatedDeleted{ - ObjectName: "event-stream", - ObjectType: "Host", - EventType: "ObjectDeleted", - }, - }, - { - name: "objectdeleted-service", - jsonData: `{"object_name":"event-stream!ssh","object_type":"Service","timestamp":1716542070.492095,"type":"ObjectDeleted"}`, - expected: &ObjectCreatedDeleted{ - ObjectName: "event-stream!ssh", - ObjectType: "Service", - EventType: "ObjectDeleted", - }, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - resp, err := UnmarshalEventStreamResponse([]byte(test.jsonData)) - assert.Equal(t, test.isError, err != nil, "unexpected error state; %v", err) - if err != nil { - return - } - - assert.EqualValuesf(t, test.expected, resp, "unexpected Event Stream response") - }) - } -} diff --git a/internal/icinga2/client.go b/internal/icinga2/client.go deleted file mode 100644 index a334c9ad..00000000 --- a/internal/icinga2/client.go +++ /dev/null @@ -1,596 +0,0 @@ -package icinga2 - -import ( - "context" - "errors" - "fmt" - "github.com/google/uuid" - lru "github.com/hashicorp/golang-lru/v2" - "github.com/icinga/icinga-notifications/internal/event" - "github.com/icinga/icinga-notifications/internal/object" - "go.uber.org/zap" - "golang.org/x/sync/errgroup" - "net/http" - "net/url" - "strings" - "time" -) - -// This file contains the main resp. common methods for the Client. - -// eventMsg is an internal struct for passing events with additional information from producers to the dispatcher. -type eventMsg struct { - event *event.Event - apiTime time.Time -} - -// catchupEventMsg propagates either an eventMsg or an error back from the catch-up worker. -// -// The type must be used as a sum-type like data structure holding either an error or an eventMsg pointer. The error has -// a higher precedence than the eventMsg. -type catchupEventMsg struct { - *eventMsg - error -} - -// Client for the Icinga 2 Event Stream API with support for other Icinga 2 APIs to gather additional information and -// perform a catch-up of unknown events either when starting up to or in case of a connection loss. -// -// Within the icinga-notifications scope, one or multiple Client instances can be generated by the Launcher. -// -// A Client must be started by calling its Process method, which blocks until Ctx is marked as done. Reconnections and -// the necessary state replaying in an internal catch-up-phase from the Icinga 2 API will be taken care off. Internally, -// the Client executes a worker within its own goroutine, which dispatches event.Event to the CallbackFn and enforces -// order during catching up after (re-)connections. -type Client struct { - // ApiBaseURL et al. configure where and how the Icinga 2 API can be reached. - ApiBaseURL string - ApiBasicAuthUser string - ApiBasicAuthPass string - - // ApiHttpTransport is a shared http.Transport and http.RoundTripper for all API connections, set by the Launcher. - ApiHttpTransport http.RoundTripper - - // ApiTimeout specifies the timeout for API connections outside the Event Stream API. - // - // If set to 0 or no value was explicitly configured, its value will be raised to 1m within Client.Process. - // - // Depending on both the Icinga 2 server power and the amount of objects to be queried, the initial flood of - // request might take a bit longer. However, one minute is still a very huge time which shouldn't be reached - // for most setups unless the system is under immense stress or other issues are also present. - ApiTimeout time.Duration - - // EventSourceId to be reflected in generated event.Events. - EventSourceId int64 - // IcingaWebRoot points to the Icinga Web 2 endpoint for generated URLs. - IcingaWebRoot string - - // CallbackFn receives generated event.Event objects. - CallbackFn func(*event.Event) - // Ctx for all web requests as well as internal wait loops. The CtxCancel can be used to stop this Client. - // Both fields are being populated with a new context from the NewClientFromConfig function. - Ctx context.Context - CtxCancel context.CancelFunc - // Logger to log to. - Logger *zap.SugaredLogger - - // eventDispatcherEventStream communicates Events to be processed from the Event Stream API. - eventDispatcherEventStream chan *eventMsg - // catchupPhaseRequest requests the main worker to switch to the catch-up-phase to query the API for missed events. - catchupPhaseRequest chan struct{} - - // eventExtraTags is used to cache Checkable groups once they have been fetched from the Icinga 2 API so that they - // don't have to be fetched over again with each ongoing event. Host/Service groups are never supposed to change at - // runtime, so this cache is being refreshed once in a while when Icinga 2 dispatches an object created/deleted - // event and thus should not overload the Icinga 2 API in a large environment with numerous Checkables. - // The LRU cache size is defined as 2^17, and when the actual cached items reach this size, the least used values - // will simply be overwritten by the new ones. - eventExtraTagsCache *lru.Cache[string, map[string]string] -} - -// buildCommonEvent creates an event.Event based on Host and (optional) Service attributes to be specified later. -// -// The new Event's Time will be the current timestamp. -// -// The following fields will NOT be populated and might be altered later: -// - Type -// - Severity -// - Username -// - Message -// - ID -func (client *Client) buildCommonEvent(ctx context.Context, host, service string) (*event.Event, error) { - var ( - eventName string - eventUrl *url.URL - eventTags map[string]string - ) - - eventUrl, err := url.Parse(client.IcingaWebRoot) - if err != nil { - return nil, err - } - - if service != "" { - eventName = host + "!" + service - - eventUrl = eventUrl.JoinPath("/icingadb/service") - eventUrl.RawQuery = "name=" + rawurlencode(service) + "&host.name=" + rawurlencode(host) - - eventTags = map[string]string{ - "host": host, - "service": service, - } - } else { - eventName = host - - eventUrl = eventUrl.JoinPath("/icingadb/host") - eventUrl.RawQuery = "name=" + rawurlencode(host) - - eventTags = map[string]string{ - "host": host, - } - } - - extraTags, err := client.fetchExtraTagsFor(ctx, host, service) - if err != nil { - return nil, err - } - - return &event.Event{ - Time: time.Now(), - SourceId: client.EventSourceId, - Name: eventName, - URL: eventUrl.String(), - Tags: eventTags, - ExtraTags: extraTags, - }, nil -} - -// fetchExtraTagsFor fetches event extra tags for the given Host/Service name. -// -// If there are already existing extra tags in the cache, this function will just return those, otherwise -// it will fetch the groups from the Icinga 2 API, map them to the event extra tags, add them to the client -// Cache store and return them. -// -// Returns an error if it fails to successfully fetch the host/service groups from the API. -func (client *Client) fetchExtraTagsFor(ctx context.Context, host, service string) (map[string]string, error) { - objectName := host - if service != "" { - objectName = host + "!" + service - } - if extraTags, ok := client.eventExtraTagsCache.Get(objectName); ok { - return extraTags, nil - } - - extraTags := make(map[string]string) - queryResult, err := client.fetchCheckable(ctx, host, "") - if err != nil { - return nil, err - } - for _, hostGroup := range queryResult.Attrs.Groups { - extraTags["hostgroup/"+hostGroup] = "" - } - - if service != "" { - queryResult, err := client.fetchCheckable(ctx, host, service) - if err != nil { - return nil, err - } - for _, serviceGroup := range queryResult.Attrs.Groups { - extraTags["servicegroup/"+serviceGroup] = "" - } - } - - client.eventExtraTagsCache.Add(objectName, extraTags) - - return extraTags, nil -} - -// deleteExtraTagsCacheFor deletes any existing event extra tags of the given Object from the cache store. -func (client *Client) deleteExtraTagsCacheFor(result *ObjectCreatedDeleted) error { - if result.ObjectType != "Host" && result.ObjectType != "Service" { - return fmt.Errorf("cannot delete object extra tags for unknown object_type %q", result.ObjectType) - } - - if result.EventType == typeObjectDeleted { - names := strings.Split(result.ObjectName, "!") - tags := map[string]string{"host": names[0]} - if len(names) == 2 { - tags["service"] = names[1] - } - - // Delete the object from our global cache to avoid having huge dangling objects that don't exist in Icinga 2. - object.DeleteFromCache(object.ID(client.EventSourceId, tags)) - } - - // The checkable has just been either deleted or created, so delete all existing extra tags from our cache - // store as well and will be refreshed on the next access when Icinga 2 emits any other event for that object. - client.eventExtraTagsCache.Remove(result.ObjectName) - - return nil -} - -// buildHostServiceEvent constructs an event.Event based on a CheckResult, a Host or Service state, a Host name and an -// optional Service name if the Event should represent a Service object. -func (client *Client) buildHostServiceEvent(ctx context.Context, result CheckResult, state int, host, service string) (*event.Event, error) { - var eventSeverity event.Severity - - if service != "" { - switch state { - case StateServiceOk: - eventSeverity = event.SeverityOK - case StateServiceWarning: - eventSeverity = event.SeverityWarning - case StateServiceCritical: - eventSeverity = event.SeverityCrit - default: // UNKNOWN or faulty - eventSeverity = event.SeverityErr - } - } else { - switch state { - case StateHostUp: - eventSeverity = event.SeverityOK - case StateHostDown: - eventSeverity = event.SeverityCrit - default: // faulty - eventSeverity = event.SeverityErr - } - } - - ev, err := client.buildCommonEvent(ctx, host, service) - if err != nil { - return nil, err - } - - ev.Type = event.TypeState - ev.Severity = eventSeverity - ev.Message = result.Output - - return ev, nil -} - -// buildAcknowledgementEvent from the given fields. -func (client *Client) buildAcknowledgementEvent(ctx context.Context, ack *Acknowledgement) (*event.Event, error) { - ev, err := client.buildCommonEvent(ctx, ack.Host, ack.Service) - if err != nil { - return nil, err - } - - ev.Username = ack.Author - if ack.EventType == typeAcknowledgementCleared { - ev.Type = event.TypeAcknowledgementCleared - ev.Message = "Acknowledgement cleared" - - queryResult, err := client.fetchCheckable(ctx, ack.Host, ack.Service) - if err != nil { - return nil, err - } - if muted, err := isMuted(ctx, client, queryResult); err != nil { - return nil, err - } else if !muted { - ev.Message = queryResult.Attrs.LastCheckResult.Output - ev.SetMute(false, "Acknowledgement cleared") - } - } else { - ev.Type = event.TypeAcknowledgementSet - ev.Message = ack.Comment - ev.SetMute(true, fmt.Sprintf("Checkable acknowledged by %q: %s", ack.Author, ack.Comment)) - } - - return ev, nil -} - -// buildDowntimeEvent from the given fields. -func (client *Client) buildDowntimeEvent(ctx context.Context, d Downtime, startEvent bool) (*event.Event, error) { - ev, err := client.buildCommonEvent(ctx, d.Host, d.Service) - if err != nil { - return nil, err - } - - var reason string - if startEvent { - ev.Type = event.TypeDowntimeStart - ev.SetMute(true, "Checkable is in downtime") - ev.Message = d.Comment - } else if !d.WasCancelled() { - ev.Type = event.TypeDowntimeEnd - reason = "Downtime expired" - } else { - ev.Type = event.TypeDowntimeRemoved - if d.ConfigOwner != "" { - reason = fmt.Sprintf("Downtime was cancelled by config owner (%s)", d.ConfigOwner) - } else { - reason = "Downtime was cancelled by user" - } - } - - ev.Username = d.Author - if ev.Type != event.TypeDowntimeStart { - ev.Message = reason - - queryResult, err := client.fetchCheckable(ctx, d.Host, d.Service) - if err != nil { - return nil, err - } - if muted, err := isMuted(ctx, client, queryResult); err != nil { - return nil, err - } else if !muted { - // When a downtime is cancelled/expired and there's no other active downtime/ack, we're going to send some - // notifications if there's still an active incident. Therefore, we need the most recent CheckResult of - // that Checkable to use it for the notifications. - ev.Message = queryResult.Attrs.LastCheckResult.Output - ev.SetMute(false, reason) - } - } - - return ev, nil -} - -// buildFlappingEvent from the given fields. -func (client *Client) buildFlappingEvent(ctx context.Context, flapping *Flapping) (*event.Event, error) { - ev, err := client.buildCommonEvent(ctx, flapping.Host, flapping.Service) - if err != nil { - return nil, err - } - - if flapping.IsFlapping { - ev.Type = event.TypeFlappingStart - ev.SetMute(true, fmt.Sprintf( - "Checkable started flapping (Current flapping value %d%% > high threshold %d%%)", - flapping.CurrentFlapping, flapping.ThresholdHigh, - )) - } else { - reason := fmt.Sprintf( - "Checkable stopped flapping (Current flapping value %d%% < low threshold %d%%)", - flapping.CurrentFlapping, flapping.ThresholdLow, - ) - ev.Type = event.TypeFlappingEnd - ev.Message = reason - - queryResult, err := client.fetchCheckable(ctx, flapping.Host, flapping.Service) - if err != nil { - return nil, err - } - if muted, err := isMuted(ctx, client, queryResult); err != nil { - return nil, err - } else if !muted { - ev.Message = queryResult.Attrs.LastCheckResult.Output - ev.SetMute(false, reason) - } - } - - return ev, nil -} - -// startCatchupWorkers launches goroutines for catching up the Icinga 2 API state. -// -// Each event will be sent to the returned channel. When all launched workers have finished - either because all are -// done or one has failed and the others were interrupted -, the channel will be closed. In case of a failure, _one_ -// final error will be sent back. -// -// Those workers honor a context derived from the Client.Ctx and would either stop when this context is done or when the -// context.CancelFunc is called. -// -// The startup time might be delayed through the parameter. This lets the goroutines sleep to rate-limit reconnection -// attempts during network hiccups. -// -// To distinguish different catch-up-phase workers - for example, when one worker was canceled by its context and -// another one was just started -, all log their debug messages with a UUID. -func (client *Client) startCatchupWorkers(delay time.Duration) (chan *catchupEventMsg, context.CancelFunc) { - workerId := uuid.New() - startTime := time.Now() - catchupEventCh := make(chan *catchupEventMsg) - - client.Logger.Debugw("Catch-up-phase worker has started", - zap.Stringer("worker", workerId), - zap.Duration("delay", delay)) - - // Unfortunately, the errgroup context is hidden, that's why another context is necessary. - ctx, cancel := context.WithCancel(client.Ctx) - group, groupCtx := errgroup.WithContext(ctx) - - objTypes := []string{"host", "service"} - for _, objType := range objTypes { - objType := objType // https://go.dev/doc/faq#closures_and_goroutines - group.Go(func() error { - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(delay): - } - - err := client.checkMissedChanges(groupCtx, objType, catchupEventCh) - if err != nil && !errors.Is(err, context.Canceled) { - client.Logger.Debugw("Catch-up-phase event worker failed", - zap.Stringer("worker", workerId), - zap.String("object_type", objType), - zap.Error(err)) - } - return err - }) - } - - go func() { - err := group.Wait() - if err == nil { - client.Logger.Debugw("Catching up the API has finished", - zap.Stringer("worker", workerId), - zap.Duration("duration", time.Since(startTime))) - } else if errors.Is(err, context.Canceled) { - // The context is either canceled when the Client got canceled or, more likely, when another catch-up-worker - // was requested. In the first case, the already sent messages will be discarded as the worker's main loop - // was left. In the other case, the message buffers will be reset to an empty state. - client.Logger.Debugw("Catching up the API was interrupted", - zap.Stringer("worker", workerId), - zap.Duration("duration", time.Since(startTime))) - } else { - client.Logger.Debugw("Catching up the API failed", - zap.Stringer("worker", workerId), - zap.Error(err), - zap.Duration("duration", time.Since(startTime))) - - select { - case <-ctx.Done(): - case catchupEventCh <- &catchupEventMsg{error: err}: - } - } - - cancel() - close(catchupEventCh) - }() - - return catchupEventCh, cancel -} - -// worker is the Client's main background worker, taking care of event.Event dispatching and mode switching. -// -// When the Client is in the catch-up-phase, requested by catchupPhaseRequest, events from the Event Stream API will -// be cached until the catch-up-phase has finished, while replayed events will be delivered directly. -// -// Communication takes place over the eventDispatcherEventStream and catchupPhaseRequest channels. -func (client *Client) worker() { - var ( - // catchupEventCh either emits events generated during the catch-up-phase from catch-up-workers or one final - // error if something went wrong. It will be closed when catching up is done, which indicates the select below - // to switch phases. When this variable is nil, this Client is in the normal operating phase. - catchupEventCh chan *catchupEventMsg - // catchupCancel cancels, if not nil, all running catch-up-workers, e.g., when restarting catching-up. - catchupCancel context.CancelFunc - - // catchupBuffer holds Event Stream events to be replayed after the catch-up-phase has finished. - catchupBuffer = make([]*event.Event, 0) - // catchupCache maps event.Events.Name to API time to skip replaying outdated events. - catchupCache = make(map[string]time.Time) - - // catchupWorkerDelay slows down future catch-up-phase workers if prior attempts have failed. - catchupWorkerDelay time.Duration - ) - - // catchupReset resets all catchup variables to their initial empty state. - catchupReset := func() { - catchupEventCh, catchupCancel = nil, nil - catchupBuffer = make([]*event.Event, 0) - catchupCache = make(map[string]time.Time) - } - - // catchupCacheUpdate updates the catchupCache if this eventMsg seems to be the latest of its kind. - catchupCacheUpdate := func(ev *eventMsg) { - ts, ok := catchupCache[ev.event.Name] - if !ok || ev.apiTime.After(ts) { - catchupCache[ev.event.Name] = ev.apiTime - } - } - - // catchupWorkerStart starts a catch-up-phase worker and stops already running workers, if necessary. - catchupWorkerStart := func() { - if catchupEventCh != nil { - client.Logger.Debug("Switching to catch-up-phase was requested while still catching up, stopping old worker") - catchupCancel() - } - - client.Logger.Info("Worker enters catch-up-phase, start caching up on Event Stream events") - catchupReset() - catchupEventCh, catchupCancel = client.startCatchupWorkers(catchupWorkerDelay) - } - - for { - select { - case <-client.Ctx.Done(): - client.Logger.Warnw("Closing down main worker as context is finished", zap.Error(client.Ctx.Err())) - return - - case <-client.catchupPhaseRequest: - catchupWorkerStart() - - case catchupMsg, ok := <-catchupEventCh: - // Process an incoming event - if ok && catchupMsg.error == nil { - client.CallbackFn(catchupMsg.event) - catchupCacheUpdate(catchupMsg.eventMsg) - break - } - - // Abort and restart the catch-up-phase when receiving an error. - if ok && catchupMsg.error != nil { - if catchupWorkerDelay == 0 { - catchupWorkerDelay = time.Second - } else { - catchupWorkerDelay = min(3*time.Minute, 2*catchupWorkerDelay) - } - - client.Logger.Warnw("Catch-up-phase was interrupted by an error, another attempt will be made", - zap.Error(catchupMsg.error), - zap.Duration("delay", catchupWorkerDelay)) - - catchupWorkerStart() - break - } - - // The channel is closed, replay cache and eventually switch modes - if len(catchupBuffer) > 0 { - // To not block the select and all channels too long, only one event will be processed per iteration. - ev := catchupBuffer[0] - catchupBuffer = catchupBuffer[1:] - - ts, ok := catchupCache[ev.Name] - if !ok { - client.Logger.Debugw("Event to be replayed is not in cache", zap.Stringer("event", ev)) - } else if ev.Time.Before(ts) { - client.Logger.Debugw("Skip replaying outdated Event Stream event", zap.Stringer("event", ev), - zap.Time("event_timestamp", ev.Time), zap.Time("cache_timestamp", ts)) - break - } - - client.CallbackFn(ev) - break - } - - client.Logger.Info("Worker leaves catch-up-phase, returning to normal operation") - catchupReset() - catchupWorkerDelay = 0 - - case ev := <-client.eventDispatcherEventStream: - // During catch-up-phase, buffer Event Stream events - if catchupEventCh != nil { - catchupBuffer = append(catchupBuffer, ev.event) - catchupCacheUpdate(ev) - break - } - - client.CallbackFn(ev.event) - } - } -} - -// Process incoming events and reconnect to the Event Stream with catching up on missed objects if necessary. -// -// This method blocks as long as the Client runs, which, unless Ctx is cancelled, is forever. While its internal loop -// takes care of reconnections, messages are being logged while generated event.Event will be dispatched to the -// CallbackFn function. -func (client *Client) Process() { - if client.ApiTimeout == 0 { - client.ApiTimeout = time.Minute - } - - client.eventDispatcherEventStream = make(chan *eventMsg) - client.catchupPhaseRequest = make(chan struct{}) - - cache, err := lru.New[string, map[string]string](1 << 17) - if err != nil { - // Is unlikely to happen, as the only error being returned is triggered by - // specifying negative numbers as the cache size. - client.Logger.Fatalw("Failed to initialise event extra tags cache", zap.Error(err)) - } - client.eventExtraTagsCache = cache - - go client.worker() - - for client.Ctx.Err() == nil { - err := client.listenEventStream() - if err != nil { - client.Logger.Errorw("Event Stream processing was interrupted", zap.Error(err)) - } else { - client.Logger.Errorw("Event Stream processing was closed") - } - } -} diff --git a/internal/icinga2/client_api.go b/internal/icinga2/client_api.go deleted file mode 100644 index 8bf351a1..00000000 --- a/internal/icinga2/client_api.go +++ /dev/null @@ -1,640 +0,0 @@ -package icinga2 - -import ( - "bufio" - "bytes" - "cmp" - "context" - "crypto/rand" - "encoding/json" - "errors" - "fmt" - "github.com/icinga/icinga-notifications/internal/event" - "go.uber.org/zap" - "io" - "net/http" - "net/url" - "slices" - "time" -) - -// This file contains Icinga 2 API related methods. - -// checkHTTPResponseStatusCode compares an HTTP response status code against supported Icinga 2 API codes. -// -// An error will be returned for invalid status codes according to the documentation. Undefined status codes are going -// to result in errors as well. -// -// https://icinga.com/docs/icinga-2/latest/doc/12-icinga2-api/#http-statuses -func checkHTTPResponseStatusCode(statusCode int, status string) error { - switch { - case 200 <= statusCode && statusCode <= 299: - return nil - case 400 <= statusCode && statusCode <= 499: - return fmt.Errorf("invalid HTTP request: %q", status) - case 500 <= statusCode && statusCode <= 599: - return fmt.Errorf("server HTTP error: %q", status) - default: - return fmt.Errorf("unknown HTTP error: %q", status) - } -} - -// transport wraps http.Transport and overrides http.RoundTripper to set a custom User-Agent for all requests. -type transport struct { - http.Transport - userAgent string -} - -// RoundTrip implements http.RoundTripper to set a custom User-Agent header. -func (trans *transport) RoundTrip(req *http.Request) (*http.Response, error) { - req.Header.Set("User-Agent", trans.userAgent) - return trans.Transport.RoundTrip(req) -} - -// extractObjectQueriesResult parses a typed ObjectQueriesResult array out of a JSON io.ReaderCloser. -// -// The generic type T is currently limited to all later needed types, even when the API might also return other known or -// unknown types. When another type becomes necessary, T can be exceeded. -// -// As Go 1.21 does not allow type parameters in methods[0], the logic was extracted into a function transforming the -// JSON response - passed as an io.ReaderCloser which will be closed within this function - into the typed response to -// be used within the methods below. -// -// [0] https://github.com/golang/go/issues/49085 -func extractObjectQueriesResult[T Comment | HostServiceRuntimeAttributes](jsonResp io.ReadCloser) ([]ObjectQueriesResult[T], error) { - defer func() { - _, _ = io.Copy(io.Discard, jsonResp) - _ = jsonResp.Close() - }() - - var objQueriesResults []ObjectQueriesResult[T] - err := json.NewDecoder(jsonResp).Decode(&struct { - Results *[]ObjectQueriesResult[T] `json:"results"` - }{&objQueriesResults}) - if err != nil { - return nil, err - } - return objQueriesResults, nil -} - -// queryObjectsApi performs a configurable HTTP request against the Icinga 2 API and returns its raw response. -// -// The returned io.ReaderCloser MUST be both read to completion and closed to reuse connections. -func (client *Client) queryObjectsApi( - ctx context.Context, - urlPaths []string, - method string, - body io.Reader, - headers map[string]string, -) (io.ReadCloser, error) { - apiUrl, err := url.JoinPath(client.ApiBaseURL, urlPaths...) - if err != nil { - return nil, err - } - req, err := http.NewRequestWithContext(ctx, method, apiUrl, body) - if err != nil { - return nil, err - } - - req.SetBasicAuth(client.ApiBasicAuthUser, client.ApiBasicAuthPass) - for k, v := range headers { - req.Header.Set(k, v) - } - - // The underlying network connection is reused by using client.ApiHttpTransport. - httpClient := &http.Client{ - Transport: client.ApiHttpTransport, - Timeout: client.ApiTimeout, - } - res, err := httpClient.Do(req) - if err != nil { - return nil, err - } - - err = checkHTTPResponseStatusCode(res.StatusCode, res.Status) - if err != nil { - _, _ = io.Copy(io.Discard, res.Body) - _ = res.Body.Close() - return nil, err - } - - return res.Body, nil -} - -// queryObjectsApiDirect performs a direct resp. "fast" API query against an object, optionally identified by its name. -func (client *Client) queryObjectsApiDirect(ctx context.Context, objType, objName string) (io.ReadCloser, error) { - return client.queryObjectsApi( - ctx, - []string{"/v1/objects/", objType + "s/", url.PathEscape(objName)}, - http.MethodGet, - nil, - map[string]string{"Accept": "application/json"}) -} - -// queryObjectsApiQuery sends a query to the Icinga 2 API /v1/objects to receive data of the given objType. -func (client *Client) queryObjectsApiQuery(ctx context.Context, objType string, query map[string]any) (io.ReadCloser, error) { - reqBody, err := json.Marshal(query) - if err != nil { - return nil, err - } - - return client.queryObjectsApi( - ctx, - []string{"/v1/objects/", objType + "s"}, - http.MethodPost, - bytes.NewReader(reqBody), - map[string]string{ - "Accept": "application/json", - "Content-Type": "application/json", - "X-Http-Method-Override": "GET", - }) -} - -// fetchIcingaAppStatus retrieves the global state of the IcingaApplication type via the /v1/status endpoint. -func (client *Client) fetchIcingaAppStatus(ctx context.Context) (*IcingaApplication, error) { - response, err := client.queryObjectsApi( - ctx, - []string{"/v1/status/IcingaApplication/"}, - http.MethodGet, - nil, - map[string]string{"Accept": "application/json"}) - if err != nil { - return nil, err - } - - defer func() { - _, _ = io.Copy(io.Discard, response) - _ = response.Close() - }() - - type status struct { - Status struct { - IcingaApplication *IcingaApplication `json:"icingaapplication"` - } `json:"status"` - } - - var results []status - err = json.NewDecoder(response).Decode(&struct { - Results *[]status `json:"results"` - }{&results}) - if err != nil { - return nil, err - } - - if len(results) == 0 { - return nil, fmt.Errorf("unable to fetch IcingaApplication status") - } - - return results[0].Status.IcingaApplication, nil -} - -// fetchCheckable fetches the Checkable config state of the given Host/Service name from the Icinga 2 API. -func (client *Client) fetchCheckable(ctx context.Context, host, service string) (*ObjectQueriesResult[HostServiceRuntimeAttributes], error) { - objType, objName := "host", host - if service != "" { - objType = "service" - objName += "!" + service - } - - jsonRaw, err := client.queryObjectsApiDirect(ctx, objType, objName) - if err != nil { - return nil, err - } - objQueriesResults, err := extractObjectQueriesResult[HostServiceRuntimeAttributes](jsonRaw) - if err != nil { - return nil, err - } - - if len(objQueriesResults) != 1 { - return nil, fmt.Errorf("expected exactly one result for %q as object type %q instead of %d", - objName, objType, len(objQueriesResults)) - } - - return &objQueriesResults[0], nil -} - -// errMissingAcknowledgementComment is an error indicating that no Comment for an Acknowledgement exists. -// -// This error should only be wrapped and returned from the fetchAcknowledgementComment method and only if no Comment was -// found. For other errors, like network errors, this error must not be used. -var errMissingAcknowledgementComment = errors.New("found no acknowledgement comment") - -// fetchAcknowledgementComment fetches an Acknowledgement Comment for a Host (empty service) or for a Service at a Host. -// -// Unfortunately, there is no direct link between ACK'ed Host or Service objects and their acknowledgement Comment. The -// closest we can do, is query for Comments with the Acknowledgement Service Type and the host/service name. In addition, -// the Host's or Service's AcknowledgementLastChange field has NOT the same timestamp as the Comment; there is a -// difference of some milliseconds. As there might be even multiple ACK comments, we have to find the closest one. -// -// Please note that not every Acknowledgement has a Comment. It is possible to delete the Comment, while still having an -// active Acknowledgement. Thus, if no Comment was found, a wrapped errMissingAcknowledgementComment is returned. -func (client *Client) fetchAcknowledgementComment(ctx context.Context, host, service string, ackTime time.Time) (*Comment, error) { - // comment.entry_type = 4 is an Acknowledgement comment; Comment.EntryType - objectName := host - filterExpr := "comment.entry_type == 4 && comment.host_name == comment_host_name" - filterVars := map[string]string{"comment_host_name": host} - if service != "" { - objectName += "!" + service - filterExpr += " && comment.service_name == comment_service_name" - filterVars["comment_service_name"] = service - } - - jsonRaw, err := client.queryObjectsApiQuery(ctx, "comment", map[string]any{"filter": filterExpr, "filter_vars": filterVars}) - if err != nil { - return nil, err - } - objQueriesResults, err := extractObjectQueriesResult[Comment](jsonRaw) - if err != nil { - return nil, err - } - - if len(objQueriesResults) == 0 { - return nil, fmt.Errorf("%w for %q at all", errMissingAcknowledgementComment, objectName) - } - - slices.SortFunc(objQueriesResults, func(a, b ObjectQueriesResult[Comment]) int { - distA := a.Attrs.EntryTime.Time().Sub(ackTime).Abs() - distB := b.Attrs.EntryTime.Time().Sub(ackTime).Abs() - return cmp.Compare(distA, distB) - }) - if objQueriesResults[0].Attrs.EntryTime.Time().Sub(ackTime).Abs() > time.Second { - return nil, fmt.Errorf("%w for %q near %v", errMissingAcknowledgementComment, objectName, ackTime) - } - - return &objQueriesResults[0].Attrs, nil -} - -// checkMissedChanges queries objType (host, service) from the Icinga 2 API to catch up on missed events. -// -// If the object's acknowledgement field is non-zero, an Acknowledgement Event will be constructed following the Host or -// Service object. Each event will be delivered to the channel. -func (client *Client) checkMissedChanges(ctx context.Context, objType string, catchupEventCh chan *catchupEventMsg) error { - jsonRaw, err := client.queryObjectsApiDirect(ctx, objType, "") - if err != nil { - return err - } - objQueriesResults, err := extractObjectQueriesResult[HostServiceRuntimeAttributes](jsonRaw) - if err != nil { - return err - } - - var stateChangeEvents, muteEvents, unmuteEvents int - defer func() { - client.Logger.Debugw("Querying API emitted events", - zap.String("object_type", objType), - zap.Int("state_changes", stateChangeEvents), - zap.Int("mute_events", muteEvents), - zap.Int("unmute_events", unmuteEvents)) - }() - - for _, objQueriesResult := range objQueriesResults { - var hostName, serviceName, objectName string - switch objQueriesResult.Type { - case "Host": - hostName = objQueriesResult.Attrs.Name - objectName = hostName - - case "Service": - hostName = objQueriesResult.Attrs.Host - serviceName = objQueriesResult.Attrs.Name - objectName = hostName + "!" + serviceName - - default: - return fmt.Errorf("querying API delivered a wrong object type %q", objQueriesResult.Type) - } - - // Only process HARD states - if objQueriesResult.Attrs.StateType == StateTypeSoft { - client.Logger.Debugw("Skipping SOFT event", zap.Inline(&objQueriesResult.Attrs)) - continue - } - - attrs := objQueriesResult.Attrs - checkableIsMuted, err := isMuted(ctx, client, &objQueriesResult) - if err != nil { - return err - } - - var fakeEv *event.Event - if checkableIsMuted && attrs.Acknowledgement != AcknowledgementNone { - ackComment, err := client.fetchAcknowledgementComment(ctx, hostName, serviceName, attrs.AcknowledgementLastChange.Time()) - if errors.Is(err, errMissingAcknowledgementComment) { - // Unfortunately, there is no Acknowledgement object in Icinga 2, but only related runtime attributes - // attached to Host or Service objects. Those attributes contain no authorship. The only way to link an - // acknowledgement to a contact, when being fetched through the Config Objects API, is to find a - // matching Comment object, which contains an author field. - // - // This is not the case for the Event Stream API, where AcknowledgementSet has an author field. - // - // However, when no author is present, the Acknowledgement Event cannot be processed. Eventually, the - // Incident.processAcknowledgementEvent method will fail hard. - - client.Logger.Infow("Cannot find the comment for an acknowledgement, creating a generic muted event", - zap.String("object", objectName), zap.NamedError("reason", err)) - - fakeEv, err = client.buildCommonEvent(ctx, hostName, serviceName) - if err != nil { - return fmt.Errorf("failed to construct checkable fake unmute event: %w", err) - } - - fakeEv.Type = event.TypeMute - fakeEv.SetMute(true, "Checkable is acknowledged, but we could not find its corresponding comment") - } else if err != nil { - return fmt.Errorf("fetching acknowledgement comment for %q failed, %w", objectName, err) - } else { - ack := &Acknowledgement{Host: hostName, Service: serviceName, Author: ackComment.Author, Comment: ackComment.Text} - // We do not need to fake ACK set events as they are handled correctly by an incident and any - // redundant/successive ACK set events are discarded accordingly. - ack.EventType = typeAcknowledgementSet - fakeEv, err = client.buildAcknowledgementEvent(ctx, ack) - if err != nil { - return fmt.Errorf("failed to construct Event from Acknowledgement response, %w", err) - } - } - } else if checkableIsMuted { - fakeEv, err = client.buildCommonEvent(ctx, hostName, serviceName) - if err != nil { - return fmt.Errorf("failed to construct checkable fake mute event: %w", err) - } - - fakeEv.Type = event.TypeMute - if attrs.DowntimeDepth != 0 { - fakeEv.SetMute(true, "Checkable is in downtime, but we missed the Icinga 2 DowntimeStart event") - } else { - fakeEv.SetMute(true, "Checkable is flapping, but we missed the Icinga 2 FlappingStart event") - } - } else { - // This could potentially produce numerous superfluous database (event table) entries if we generate such - // dummy events after each Icinga 2 / Notifications reload, thus they are being identified as such in - // incident#ProcessEvent() and Client.CallbackFn and suppressed accordingly. - fakeEv, err = client.buildCommonEvent(ctx, hostName, serviceName) - if err != nil { - return fmt.Errorf("failed to construct checkable fake unmute event: %w", err) - } - - fakeEv.Type = event.TypeUnmute - fakeEv.SetMute(false, "All mute reasons of the checkable are cleared, but we missed the appropriate unmute event") - } - - fakeEv.Message = attrs.LastCheckResult.Output - ackEvent := *fakeEv - select { - case catchupEventCh <- &catchupEventMsg{eventMsg: &eventMsg{fakeEv, attrs.LastStateChange.Time()}}: - if fakeEv.Type == event.TypeUnmute { - unmuteEvents++ - } else { - muteEvents++ - } - case <-ctx.Done(): - return ctx.Err() - } - - ev, err := client.buildHostServiceEvent(ctx, attrs.LastCheckResult, attrs.State, hostName, serviceName) - if err != nil { - return fmt.Errorf("failed to construct Event from Host/Service response, %w", err) - } - select { - case <-ctx.Done(): - return ctx.Err() - case catchupEventCh <- &catchupEventMsg{eventMsg: &eventMsg{ev, attrs.LastStateChange.Time()}}: - stateChangeEvents++ - if fakeEv.Type == event.TypeAcknowledgementSet { - select { - // Retry the AckSet event so that the author of the ack is set as the incident - // manager if there was no existing incident before the above state change event. - case catchupEventCh <- &catchupEventMsg{eventMsg: &eventMsg{&ackEvent, attrs.LastStateChange.Time()}}: - case <-ctx.Done(): - return ctx.Err() - } - } - } - } - return nil -} - -// connectEventStreamReadCloser wraps io.ReadCloser with a context.CancelFunc to be returned in connectEventStream. -type connectEventStreamReadCloser struct { - io.ReadCloser - cancel context.CancelFunc -} - -// Close the internal ReadCloser with canceling the internal http.Request's context first. -func (e *connectEventStreamReadCloser) Close() error { - e.cancel() - return e.ReadCloser.Close() -} - -// connectEventStream connects to the EventStream, retries until a connection was established. -// -// The esTypes is a string array of required Event Stream types. -// -// An error will only be returned if reconnecting - retrying the (almost) same thing - will not help. -func (client *Client) connectEventStream(esTypes []string) (io.ReadCloser, error) { - apiUrl, err := url.JoinPath(client.ApiBaseURL, "/v1/events") - if err != nil { - return nil, err - } - - for retryDelay := time.Second; ; retryDelay = min(3*time.Minute, 2*retryDelay) { - // Always ensure an unique queue name to mitigate possible naming conflicts. - queueNameRndBuff := make([]byte, 16) - _, _ = rand.Read(queueNameRndBuff) - - reqBody, err := json.Marshal(map[string]any{ - "queue": fmt.Sprintf("icinga-notifications-%x", queueNameRndBuff), - "types": esTypes, - "filter": fmt.Sprintf( - `(event.type == %q || event.type == %q) ? (event.object_type == "Host" || event.object_type == "Service") : true`, - typeObjectCreated, typeObjectDeleted, - ), - }) - if err != nil { - return nil, err - } - - // Sub-context which might get canceled early if connecting takes to long. - // The reqCancel function will be called after the select below or when leaving the function with an error. - // When leaving the function without an error, it is being called in connectEventStreamReadCloser.Close(). - reqCtx, reqCancel := context.WithCancel(client.Ctx) - - req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, apiUrl, bytes.NewReader(reqBody)) - if err != nil { - reqCancel() - return nil, err - } - - req.SetBasicAuth(client.ApiBasicAuthUser, client.ApiBasicAuthPass) - req.Header.Set("Accept", "application/json") - req.Header.Set("Content-Type", "application/json") - - resCh := make(chan *http.Response) - - go func() { - defer close(resCh) - - client.Logger.Debugw("Try to establish an Event Stream API connection", zap.String("request_body", string(reqBody))) - httpClient := &http.Client{Transport: client.ApiHttpTransport} - res, err := httpClient.Do(req) - if err != nil { - client.Logger.Warnw("Establishing an Event Stream API connection failed, will be retried", - zap.Error(err), - zap.Duration("delay", retryDelay)) - return - } - - err = checkHTTPResponseStatusCode(res.StatusCode, res.Status) - if err != nil { - client.Logger.Errorw("Establishing an Event Stream API connection failed with an HTTP error, will be retried", - zap.Error(err), - zap.Duration("delay", retryDelay)) - return - } - - select { - case <-reqCtx.Done(): - // This case might happen when this httpClient.Do and the time.After in the select below finish at round - // about the exact same time, but httpClient.Do was slightly faster than reqCancel(). - _, _ = io.Copy(io.Discard, res.Body) - _ = res.Body.Close() - case resCh <- res: - } - }() - - select { - case res, ok := <-resCh: - if ok { - esReadCloser := &connectEventStreamReadCloser{ - ReadCloser: res.Body, - cancel: reqCancel, - } - return esReadCloser, nil - } - - case <-time.After(3 * time.Second): - } - reqCancel() - - // Rate limit API reconnections: slow down for successive failed attempts but limit to three minutes. - // 1s, 2s, 4s, 8s, 16s, 32s, 1m4s, 2m8s, 3m, 3m, 3m, ... - select { - case <-time.After(retryDelay): - case <-client.Ctx.Done(): - return nil, client.Ctx.Err() - } - } -} - -// listenEventStream subscribes to the Icinga 2 API Event Stream and handles received objects. -// -// In case of a parsing or handling error, this error will be returned. If the server closes the connection, nil will -// be returned. -func (client *Client) listenEventStream() error { - // Ensure to implement a handler case in the type switch below for each requested type. - eventStream, err := client.connectEventStream([]string{ - typeStateChange, - typeAcknowledgementSet, - typeAcknowledgementCleared, - // typeCommentAdded, - // typeCommentRemoved, - // typeDowntimeAdded, - typeDowntimeRemoved, - typeDowntimeStarted, - typeDowntimeTriggered, - typeFlapping, - typeObjectCreated, - typeObjectDeleted, - }) - if err != nil { - return err - } - defer func() { _ = eventStream.Close() }() - // Purge all event extra tags from our cache store, otherwise we might miss the typeObjectCreated event for - // some objects and never get their updated groups when Icinga 2 is reloaded/restarted. - defer client.eventExtraTagsCache.Purge() - - select { - case <-client.Ctx.Done(): - client.Logger.Warnw("Cannot request catch-up-phase as context is finished", zap.Error(client.Ctx.Err())) - return client.Ctx.Err() - case client.catchupPhaseRequest <- struct{}{}: - } - - client.Logger.Info("Start listening on Icinga 2 Event Stream") - - lineScanner := bufio.NewScanner(eventStream) - for lineScanner.Scan() { - rawJson := lineScanner.Bytes() - - resp, err := UnmarshalEventStreamResponse(rawJson) - if err != nil { - return err - } - - var ( - ev *event.Event - evTime time.Time - ) - switch respT := resp.(type) { - case *StateChange: - // Only process HARD states - if respT.StateType == StateTypeSoft { - client.Logger.Debugw("Skipping SOFT State Change", zap.Inline(respT)) - continue - } - - ev, err = client.buildHostServiceEvent(client.Ctx, respT.CheckResult, respT.State, respT.Host, respT.Service) - evTime = respT.Timestamp.Time() - case *Acknowledgement: - ev, err = client.buildAcknowledgementEvent(client.Ctx, respT) - evTime = respT.Timestamp.Time() - // case *CommentAdded: - // case *CommentRemoved: - // case *DowntimeAdded: - case *DowntimeRemoved: - ev, err = client.buildDowntimeEvent(client.Ctx, respT.Downtime, false) - evTime = respT.Timestamp.Time() - case *DowntimeStarted: - if !respT.Downtime.IsFixed { - // This may never happen, but Icinga 2 does the same thing, and we need to ignore the start - // event for flexible downtime, as there will definitely be a triggered event for it. - client.Logger.Debugw("Skipping flexible downtime start event", - zap.Time("timestamp", respT.Timestamp.Time()), zap.Inline(&respT.Downtime)) - continue - } - - ev, err = client.buildDowntimeEvent(client.Ctx, respT.Downtime, true) - evTime = respT.Timestamp.Time() - case *DowntimeTriggered: - if respT.Downtime.IsFixed { - // Fixed downtimes generate two events (start, triggered), the latter applies here and must - // be ignored, since we're going to process its start event to avoid duplicated notifications. - client.Logger.Debugw("Skipping fixed downtime triggered event", - zap.Time("timestamp", respT.Timestamp.Time()), zap.Inline(&respT.Downtime)) - continue - } - - ev, err = client.buildDowntimeEvent(client.Ctx, respT.Downtime, true) - evTime = respT.Timestamp.Time() - case *Flapping: - ev, err = client.buildFlappingEvent(client.Ctx, respT) - evTime = respT.Timestamp.Time() - case *ObjectCreatedDeleted: - if err = client.deleteExtraTagsCacheFor(respT); err == nil { - continue - } - default: - err = fmt.Errorf("unsupported type %T", resp) - } - if err != nil { - return err - } - - select { - case <-client.Ctx.Done(): - client.Logger.Warnw("Cannot dispatch Event Stream event as context is finished", zap.Error(client.Ctx.Err())) - return client.Ctx.Err() - case client.eventDispatcherEventStream <- &eventMsg{ev, evTime}: - } - } - return lineScanner.Err() -} diff --git a/internal/icinga2/client_api_test.go b/internal/icinga2/client_api_test.go deleted file mode 100644 index c4326738..00000000 --- a/internal/icinga2/client_api_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package icinga2 - -import ( - "github.com/stretchr/testify/assert" - "net/http" - "testing" -) - -func TestCheckHTTPResponseStatusCode(t *testing.T) { - tests := []struct { - name string - statusCode int - status string - wantErr assert.ErrorAssertionFunc - }{ - {"http-200", http.StatusOK, "200 OK", assert.NoError}, - {"http-299", 299, "299 ???", assert.NoError}, - {"http-204", http.StatusNoContent, "204 No Content", assert.NoError}, - {"http-401", http.StatusUnauthorized, "401 Unauthorized", assert.Error}, - {"http-500", http.StatusInternalServerError, "500 Internal Server Error", assert.Error}, - {"http-900", 900, "900 ???", assert.Error}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tt.wantErr(t, checkHTTPResponseStatusCode(tt.statusCode, tt.status)) - }) - } -} diff --git a/internal/icinga2/launcher.go b/internal/icinga2/launcher.go deleted file mode 100644 index becc9226..00000000 --- a/internal/icinga2/launcher.go +++ /dev/null @@ -1,151 +0,0 @@ -package icinga2 - -// This file contains the Launcher type to, well, launch new Event Stream Clients through a callback function. - -import ( - "context" - "crypto/tls" - "crypto/x509" - "errors" - "github.com/icinga/icinga-go-library/database" - "github.com/icinga/icinga-go-library/logging" - "github.com/icinga/icinga-notifications/internal" - "github.com/icinga/icinga-notifications/internal/config" - "github.com/icinga/icinga-notifications/internal/daemon" - "github.com/icinga/icinga-notifications/internal/event" - "github.com/icinga/icinga-notifications/internal/incident" - "go.uber.org/zap" - "net/http" - "sync" -) - -// Launcher allows starting a new Icinga 2 Event Stream API Client through a callback from within the config package. -// -// This architecture became kind of necessary to work around circular imports due to the RuntimeConfig's omnipresence. -type Launcher struct { - Ctx context.Context - Logs *logging.Logging - Db *database.DB - RuntimeConfig *config.RuntimeConfig - - mutex sync.Mutex - isReady bool - waitingSources []*config.Source -} - -// Launch either directly launches an Icinga 2 Event Stream Client for this Source or enqueues it until the Launcher is Ready. -func (launcher *Launcher) Launch(src *config.Source) { - launcher.mutex.Lock() - defer launcher.mutex.Unlock() - - if !launcher.isReady { - launcher.Logs.GetChildLogger("icinga2"). - With(zap.Int64("source_id", src.ID)). - Debug("Postponing Event Stream Client Launch as Launcher is not ready yet") - launcher.waitingSources = append(launcher.waitingSources, src) - return - } - - launcher.launch(src) -} - -// Ready marks the Launcher as ready and launches all enqueued, postponed Sources. -func (launcher *Launcher) Ready() { - launcher.mutex.Lock() - defer launcher.mutex.Unlock() - - launcher.isReady = true - for _, src := range launcher.waitingSources { - launcher.Logs.GetChildLogger("icinga2"). - With(zap.Int64("source_id", src.ID)). - Debug("Launching postponed Event Stream Client") - launcher.launch(src) - } - launcher.waitingSources = nil -} - -// launch a new Icinga 2 Event Stream API Client based on the config.Source configuration. -func (launcher *Launcher) launch(src *config.Source) { - logger := launcher.Logs.GetChildLogger("icinga2").With(zap.Int64("source_id", src.ID)) - - if src.Type != config.SourceTypeIcinga2 || - !src.Icinga2BaseURL.Valid || - !src.Icinga2AuthUser.Valid || - !src.Icinga2AuthPass.Valid { - logger.Error("Source is either not of type icinga2 or not fully populated") - return - } - - trans := &transport{ - Transport: http.Transport{ - // Hardened TLS config adjusted to Icinga 2's configuration: - // - https://icinga.com/docs/icinga-2/latest/doc/09-object-types/#objecttype-apilistener - // - https://icinga.com/docs/icinga-2/latest/doc/12-icinga2-api/#security - // - https://ssl-config.mozilla.org/#server=go&config=intermediate - TLSClientConfig: &tls.Config{ - MinVersion: tls.VersionTLS12, - CipherSuites: []uint16{ - tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, - tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, - tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, - tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, - tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, - tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, - }, - }, - }, - userAgent: "icinga-notifications/" + internal.Version.Version, - } - - if src.Icinga2CAPem.Valid { - certPool := x509.NewCertPool() - if !certPool.AppendCertsFromPEM([]byte(src.Icinga2CAPem.String)) { - logger.Error("Cannot add custom CA file to CA pool") - return - } - - trans.TLSClientConfig.RootCAs = certPool - } - if src.Icinga2CommonName.Valid { - trans.TLSClientConfig.ServerName = src.Icinga2CommonName.String - } - if src.Icinga2InsecureTLS.Valid && src.Icinga2InsecureTLS.Bool { - trans.TLSClientConfig.InsecureSkipVerify = true - } - - subCtx, subCtxCancel := context.WithCancel(launcher.Ctx) - client := &Client{ - ApiBaseURL: src.Icinga2BaseURL.String, - ApiBasicAuthUser: src.Icinga2AuthUser.String, - ApiBasicAuthPass: src.Icinga2AuthPass.String, - - ApiHttpTransport: trans, - - ApiTimeout: daemon.Config().ApiTimeout, - - EventSourceId: src.ID, - IcingaWebRoot: daemon.Config().Icingaweb2URL, - - CallbackFn: func(ev *event.Event) { - l := logger.With(zap.Stringer("event", ev)) - - err := incident.ProcessEvent(subCtx, launcher.Db, launcher.Logs, launcher.RuntimeConfig, ev) - switch { - case errors.Is(err, event.ErrSuperfluousStateChange): - l.Debugw("Stopped processing event with superfluous state change", zap.Error(err)) - case errors.Is(err, event.ErrSuperfluousMuteUnmuteEvent): - l.Debugw("Stopped processing event with superfluous (un)mute object", zap.Error(err)) - case err != nil: - l.Errorw("Cannot process event", zap.Error(err)) - default: - l.Debug("Successfully processed event over callback") - } - }, - Ctx: subCtx, - CtxCancel: subCtxCancel, - Logger: logger, - } - - go client.Process() - src.Icinga2SourceCancel = subCtxCancel -} diff --git a/internal/icinga2/util.go b/internal/icinga2/util.go deleted file mode 100644 index 440f0289..00000000 --- a/internal/icinga2/util.go +++ /dev/null @@ -1,42 +0,0 @@ -package icinga2 - -import ( - "context" - "net/url" - "strings" -) - -// rawurlencode mimics PHP's rawurlencode to be used for parameter encoding. -// -// Icinga Web uses rawurldecode instead of urldecode, which, as its main difference, does not honor the plus char ('+') -// as a valid substitution for space (' '). Unfortunately, Go's url.QueryEscape does this very substitution and -// url.PathEscape does a bit too less and has a misleading name on top. -// -// - https://www.php.net/manual/en/function.rawurlencode.php -// - https://github.com/php/php-src/blob/php-8.2.12/ext/standard/url.c#L538 -func rawurlencode(s string) string { - return strings.ReplaceAll(url.QueryEscape(s), "+", "%20") -} - -// isMuted returns true if the given checkable is either in Downtime, Flapping or acknowledged, otherwise false. -// -// When the checkable is Flapping, and neither the flapping detection for that Checkable nor for the entire zone is -// enabled, this will always return false. -// -// Returns an error if it fails to query the status of IcingaApplication from the /v1/status endpoint. -func isMuted(ctx context.Context, client *Client, checkable *ObjectQueriesResult[HostServiceRuntimeAttributes]) (bool, error) { - if checkable.Attrs.Acknowledgement != AcknowledgementNone || checkable.Attrs.DowntimeDepth != 0 { - return true, nil - } - - if checkable.Attrs.IsFlapping && checkable.Attrs.EnableFlapping { - status, err := client.fetchIcingaAppStatus(ctx) - if err != nil { - return false, err - } - - return status.App.EnableFlapping, nil - } - - return false, nil -} diff --git a/internal/icinga2/util_test.go b/internal/icinga2/util_test.go deleted file mode 100644 index d7e4c73e..00000000 --- a/internal/icinga2/util_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package icinga2 - -import ( - "github.com/stretchr/testify/assert" - "testing" -) - -func TestRawurlencode(t *testing.T) { - tests := []struct { - name string - in string - want string - }{ - {"empty", "", ""}, - {"printable", "abcABC0123", "abcABC0123"}, - {"space", "foo bar", "foo%20bar"}, - {"plus", "foo+bar", "foo%2Bbar"}, - {"slash", "foo/bar", "foo%2Fbar"}, - {"percent", "foo%bar", "foo%25bar"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, rawurlencode(tt.in)) - }) - } -} diff --git a/internal/incident/db_types.go b/internal/incident/db_types.go index 78a26b7f..ab7749d9 100644 --- a/internal/incident/db_types.go +++ b/internal/incident/db_types.go @@ -3,8 +3,8 @@ package incident import ( "context" "github.com/icinga/icinga-go-library/database" + "github.com/icinga/icinga-go-library/notifications/event" "github.com/icinga/icinga-go-library/types" - "github.com/icinga/icinga-notifications/internal/event" "github.com/icinga/icinga-notifications/internal/recipient" "github.com/jmoiron/sqlx" ) diff --git a/internal/incident/incident.go b/internal/incident/incident.go index 6bf5affe..83988271 100644 --- a/internal/incident/incident.go +++ b/internal/incident/incident.go @@ -4,10 +4,8 @@ import ( "context" "errors" "fmt" - "sync" - "time" - "github.com/icinga/icinga-go-library/database" + baseEv "github.com/icinga/icinga-go-library/notifications/event" "github.com/icinga/icinga-go-library/types" "github.com/icinga/icinga-notifications/internal/config" "github.com/icinga/icinga-notifications/internal/contracts" @@ -18,6 +16,9 @@ import ( "github.com/icinga/icinga-notifications/internal/rule" "github.com/jmoiron/sqlx" "go.uber.org/zap" + "strconv" + "sync" + "time" ) type ruleID = int64 @@ -28,7 +29,7 @@ type Incident struct { ObjectID types.Binary `db:"object_id"` StartedAt types.UnixMilli `db:"started_at"` RecoveredAt types.UnixMilli `db:"recovered_at"` - Severity event.Severity `db:"severity"` + Severity baseEv.Severity `db:"severity"` Object *object.Object `db:"-"` @@ -79,8 +80,8 @@ func (i *Incident) IncidentObject() *object.Object { return i.Object } -func (i *Incident) SeverityString() string { - return i.Severity.String() +func (i *Incident) IncidentSeverity() baseEv.Severity { + return i.Severity } func (i *Incident) String() string { @@ -128,10 +129,10 @@ func (i *Incident) ProcessEvent(ctx context.Context, ev *event.Event) error { // These event types are not like the others used to mute an object/incident, such as DowntimeStart, which // uniquely identify themselves why an incident is being muted, but are rather super generic types, and as // such, we are ignoring superfluous ones that don't have any effect on that incident. - if i.isMuted && ev.Type == event.TypeMute { + if i.isMuted && ev.Type == baseEv.TypeMute { i.logger.Debugw("Ignoring superfluous mute event", zap.String("event", ev.String())) return event.ErrSuperfluousMuteUnmuteEvent - } else if !i.isMuted && ev.Type == event.TypeUnmute { + } else if !i.isMuted && ev.Type == baseEv.TypeUnmute { i.logger.Debugw("Ignoring superfluous unmute event", zap.String("event", ev.String())) return event.ErrSuperfluousMuteUnmuteEvent } @@ -169,17 +170,14 @@ func (i *Incident) ProcessEvent(ctx context.Context, ev *event.Event) error { } switch ev.Type { - case event.TypeState: + case baseEv.TypeState: if !isNew { if err := i.processSeverityChangedEvent(ctx, tx, ev); err != nil { return err } } - // Check if any (additional) rules match this object. Filters of rules that already have a state don't have - // to be checked again, these rules already matched and stay effective for the ongoing incident. - err = i.evaluateRules(ctx, tx, ev.ID) - if err != nil { + if err := i.applyMatchingRules(ctx, tx, ev); err != nil { return err } @@ -192,7 +190,7 @@ func (i *Incident) ProcessEvent(ctx context.Context, ev *event.Event) error { if err := i.triggerEscalations(ctx, tx, ev, escalations); err != nil { return err } - case event.TypeAcknowledgementSet: + case baseEv.TypeAcknowledgementSet: if err := i.processAcknowledgementEvent(ctx, tx, ev); err != nil { if errors.Is(err, errSuperfluousAckEvent) { // That ack error type indicates that the acknowledgement author was already a manager, thus @@ -311,7 +309,7 @@ func (i *Incident) processSeverityChangedEvent(ctx context.Context, tx *sqlx.Tx, return err } - if newSeverity == event.SeverityOK { + if newSeverity == baseEv.SeverityOK { i.RecoveredAt = types.UnixMilli(time.Now()) i.logger.Info("All sources recovered, closing incident") @@ -395,29 +393,38 @@ func (i *Incident) handleMuteUnmute(ctx context.Context, tx *sqlx.Tx, ev *event. return hr.Sync(ctx, i.db, tx) } -// evaluateRules evaluates all the configured rules for this *incident.Object and -// generates history entries for each matched rule. -// Returns error on database failure. -func (i *Incident) evaluateRules(ctx context.Context, tx *sqlx.Tx, eventID int64) error { +// applyMatchingRules walks through the rule IDs obtained from source and generates a RuleMatched history entry. +func (i *Incident) applyMatchingRules(ctx context.Context, tx *sqlx.Tx, ev *event.Event) error { if i.Rules == nil { i.Rules = make(map[int64]struct{}) } - for _, r := range i.runtimeConfig.Rules { - if _, ok := i.Rules[r.ID]; !ok { - matched, err := r.Eval(i.Object) - if err != nil { - i.logger.Warnw("Failed to evaluate object filter", zap.Object("rule", r), zap.Error(err)) - } + for _, ruleId := range ev.RuleIds { + ruleIdInt, err := strconv.ParseInt(ruleId, 10, 64) + if err != nil { + i.logger.Errorw("Event rule is not an integer", zap.String("rule_id", ruleId), zap.Error(err)) + return fmt.Errorf("cannot convert rule id %q to an int: %w", ruleId, err) + } - if err != nil || !matched { - continue - } + r, ok := i.runtimeConfig.Rules[ruleIdInt] + if !ok { + i.logger.Errorw("Event refers to non-existing event rule, might got deleted", zap.Int64("rule_id", ruleIdInt)) + return fmt.Errorf("cannot apply unknown rule %d", ruleIdInt) + } + + if r.SourceID != ev.SourceId { + i.logger.Errorw("Rule source ID does not match event source ID", + zap.Int64("event_source_id", ev.SourceId), + zap.Int64("rule_source_id", r.SourceID), + zap.Int64("rule_id", ruleIdInt)) + return fmt.Errorf("rule %d source ID %d does not match event source %d", ruleIdInt, r.SourceID, ev.SourceId) + } + if _, ok := i.Rules[r.ID]; !ok { i.Rules[r.ID] = struct{}{} i.logger.Infow("Rule matches", zap.Object("rule", r)) - err = i.AddRuleMatched(ctx, tx, r) + err := i.AddRuleMatched(ctx, tx, r) if err != nil { i.logger.Errorw("Failed to upsert incident rule", zap.Object("rule", r), zap.Error(err)) return err @@ -426,7 +433,7 @@ func (i *Incident) evaluateRules(ctx context.Context, tx *sqlx.Tx, eventID int64 hr := &HistoryRow{ IncidentID: i.Id, Time: types.UnixMilli(time.Now()), - EventID: types.MakeInt(eventID, types.TransformZeroIntToNull), + EventID: types.MakeInt(ev.ID, types.TransformZeroIntToNull), RuleID: types.MakeInt(r.ID, types.TransformZeroIntToNull), Type: RuleMatched, } @@ -498,9 +505,11 @@ func (i *Incident) evaluateEscalations(eventTime time.Time) ([]*rule.Escalation, i.logger.Info("Reevaluating escalations") i.RetriggerEscalations(&event.Event{ - Time: nextEvalAt, - Type: event.TypeIncidentAge, - Message: fmt.Sprintf("Incident reached age %v", nextEvalAt.Sub(i.StartedAt.Time())), + Time: nextEvalAt, + Event: baseEv.Event{ + Type: baseEv.TypeIncidentAge, + Message: fmt.Sprintf("Incident reached age %v", nextEvalAt.Sub(i.StartedAt.Time())), + }, }) }) } @@ -599,7 +608,7 @@ func (i *Incident) notifyContact(contact *recipient.Contact, ev *event.Event, ch } i.logger.Infow(fmt.Sprintf("Notify contact %q via %q of type %q", contact.FullName, ch.Name, ch.Type), - zap.Int64("channel_id", chID), zap.String("event_type", ev.Type)) + zap.Int64("channel_id", chID), zap.String("event_type", ev.Type.String())) err := ch.Notify(contact, i, ev, daemon.Config().Icingaweb2URL) if err != nil { @@ -608,7 +617,7 @@ func (i *Incident) notifyContact(contact *recipient.Contact, ev *event.Event, ch } i.logger.Infow("Successfully sent a notification via channel plugin", zap.String("type", ch.Type), - zap.String("contact", contact.FullName), zap.String("event_type", ev.Type)) + zap.String("contact", contact.FullName), zap.String("event_type", ev.Type.String())) return nil } diff --git a/internal/incident/incidents.go b/internal/incident/incidents.go index 07d32795..90497088 100644 --- a/internal/incident/incidents.go +++ b/internal/incident/incidents.go @@ -6,6 +6,7 @@ import ( "github.com/icinga/icinga-go-library/com" "github.com/icinga/icinga-go-library/database" "github.com/icinga/icinga-go-library/logging" + baseEv "github.com/icinga/icinga-go-library/notifications/event" "github.com/icinga/icinga-go-library/types" "github.com/icinga/icinga-notifications/internal/config" "github.com/icinga/icinga-notifications/internal/event" @@ -129,9 +130,11 @@ func LoadOpenIncidents(ctx context.Context, db *database.DB, logger *logging.Log currentIncidentsMu.Unlock() i.RetriggerEscalations(&event.Event{ - Time: time.Now(), - Type: event.TypeIncidentAge, - Message: fmt.Sprintf("Incident reached age %v (daemon was restarted)", time.Since(i.StartedAt.Time())), + Time: time.Now(), + Event: baseEv.Event{ + Type: baseEv.TypeIncidentAge, + Message: fmt.Sprintf("Incident reached age %v (daemon was restarted)", time.Since(i.StartedAt.Time())), + }, }) } @@ -220,7 +223,7 @@ func ProcessEvent( return fmt.Errorf("cannot sync event object: %w", err) } - createIncident := ev.Severity != event.SeverityNone && ev.Severity != event.SeverityOK + createIncident := ev.Severity != baseEv.SeverityNone && ev.Severity != baseEv.SeverityOK currentIncident, err := GetCurrent( ctx, db, @@ -234,13 +237,13 @@ func ProcessEvent( if currentIncident == nil { switch { - case ev.Severity == event.SeverityNone: + case ev.Severity == baseEv.SeverityNone: // We need to ignore superfluous mute and unmute events here, as would be the case with an existing // incident, otherwise the event stream catch-up phase will generate useless events after each // Icinga 2 reload and overwhelm the database with the very same mute/unmute events. - if wasObjectMuted && ev.Type == event.TypeMute { + if wasObjectMuted && ev.Type == baseEv.TypeMute { return event.ErrSuperfluousMuteUnmuteEvent - } else if !wasObjectMuted && ev.Type == event.TypeUnmute { + } else if !wasObjectMuted && ev.Type == baseEv.TypeUnmute { return event.ErrSuperfluousMuteUnmuteEvent } @@ -251,7 +254,7 @@ func ProcessEvent( } return nil - case ev.Severity != event.SeverityOK: + case ev.Severity != baseEv.SeverityOK: panic(fmt.Sprintf("cannot process event %v with a non-OK state %v without a known incident", ev, ev.Severity)) default: return fmt.Errorf("%w: ok state event from source %d", event.ErrSuperfluousStateChange, ev.SourceId) diff --git a/internal/incident/incidents_test.go b/internal/incident/incidents_test.go index 8e620646..fd06b7ed 100644 --- a/internal/incident/incidents_test.go +++ b/internal/incident/incidents_test.go @@ -4,6 +4,7 @@ import ( "context" "github.com/icinga/icinga-go-library/database" "github.com/icinga/icinga-go-library/logging" + baseEv "github.com/icinga/icinga-go-library/notifications/event" "github.com/icinga/icinga-go-library/types" "github.com/icinga/icinga-notifications/internal/config" "github.com/icinga/icinga-notifications/internal/event" @@ -22,10 +23,9 @@ func TestLoadOpenIncidents(t *testing.T) { db := testutils.GetTestDB(ctx, t) // Insert a dummy source for our test cases! - source := config.Source{ - Type: "notifications", - Name: "Icinga Notifications", - Icinga2InsecureTLS: types.Bool{Bool: false, Valid: true}, + source := &config.Source{ + Type: "notifications", + Name: "Icinga Notifications", } source.ChangedAt = types.UnixMilli(time.Date(2009, time.November, 10, 23, 0, 0, 0, time.UTC)) source.Deleted = types.Bool{Bool: false, Valid: true} @@ -149,14 +149,16 @@ func makeIncident(ctx context.Context, db *database.DB, t *testing.T, sourceID i ev := &event.Event{ Time: time.Time{}, SourceId: sourceID, - Name: testutils.MakeRandomString(t), - Tags: map[string]string{ // Always generate unique object tags not to produce same object ID! - "host": testutils.MakeRandomString(t), - "service": testutils.MakeRandomString(t), - }, - ExtraTags: map[string]string{ - "hostgroup/database-server": "", - "servicegroup/webserver": "", + Event: baseEv.Event{ + Name: testutils.MakeRandomString(t), + Tags: map[string]string{ // Always generate unique object tags not to produce same object ID! + "host": testutils.MakeRandomString(t), + "service": testutils.MakeRandomString(t), + }, + ExtraTags: map[string]string{ + "hostgroup/database-server": "", + "servicegroup/webserver": "", + }, }, } @@ -165,9 +167,9 @@ func makeIncident(ctx context.Context, db *database.DB, t *testing.T, sourceID i i := NewIncident(db, o, &config.RuntimeConfig{}, nil) i.StartedAt = types.UnixMilli(time.Now().Add(-2 * time.Hour).Truncate(time.Second)) - i.Severity = event.SeverityCrit + i.Severity = baseEv.SeverityCrit if recovered { - i.Severity = event.SeverityOK + i.Severity = baseEv.SeverityOK i.RecoveredAt = types.UnixMilli(time.Now()) } diff --git a/internal/incident/sync.go b/internal/incident/sync.go index 5d292347..4b434d5a 100644 --- a/internal/incident/sync.go +++ b/internal/incident/sync.go @@ -3,21 +3,21 @@ package incident import ( "context" "fmt" - "time" - "github.com/icinga/icinga-go-library/database" + baseEv "github.com/icinga/icinga-go-library/notifications/event" "github.com/icinga/icinga-go-library/types" "github.com/icinga/icinga-notifications/internal/event" "github.com/icinga/icinga-notifications/internal/recipient" "github.com/icinga/icinga-notifications/internal/rule" "github.com/jmoiron/sqlx" "go.uber.org/zap" + "time" ) // Upsert implements the contracts.Upserter interface. func (i *Incident) Upsert() interface{} { return &struct { - Severity event.Severity `db:"severity"` + Severity baseEv.Severity `db:"severity"` RecoveredAt types.UnixMilli `db:"recovered_at"` }{Severity: i.Severity, RecoveredAt: i.RecoveredAt} } diff --git a/internal/listener/listener.go b/internal/listener/listener.go index 98198604..683a5529 100644 --- a/internal/listener/listener.go +++ b/internal/listener/listener.go @@ -8,6 +8,8 @@ import ( "fmt" "github.com/icinga/icinga-go-library/database" "github.com/icinga/icinga-go-library/logging" + baseEv "github.com/icinga/icinga-go-library/notifications/event" + baseSource "github.com/icinga/icinga-go-library/notifications/source" "github.com/icinga/icinga-notifications/internal" "github.com/icinga/icinga-notifications/internal/config" "github.com/icinga/icinga-notifications/internal/daemon" @@ -15,6 +17,7 @@ import ( "github.com/icinga/icinga-notifications/internal/incident" "go.uber.org/zap" "net/http" + "strconv" "time" ) @@ -39,6 +42,7 @@ func NewListener(db *database.DB, runtimeConfig *config.RuntimeConfig, logs *log debugMux.HandleFunc("/dump-config", l.DumpConfig) debugMux.HandleFunc("/dump-incidents", l.DumpIncidents) debugMux.HandleFunc("/dump-schedules", l.DumpSchedules) + debugMux.HandleFunc("/dump-rules", l.DumpRules) l.mux.Handle("/debug/", http.StripPrefix("/debug", l.requireDebugAuth(debugMux))) l.mux.HandleFunc("/process-event", l.ProcessEvent) @@ -82,7 +86,23 @@ func (l *Listener) Run(ctx context.Context) error { } } -func (l *Listener) ProcessEvent(w http.ResponseWriter, req *http.Request) { +// sourceFromAuthOrAbort extracts a *config.Source from the HTTP Basic Auth. If the credentials are wrong, (nil, false) is +// returned and 401 was written back to the response writer. +func (l *Listener) sourceFromAuthOrAbort(w http.ResponseWriter, r *http.Request) (*config.Source, bool) { + if authUser, authPass, authOk := r.BasicAuth(); authOk { + src := l.runtimeConfig.GetSourceFromCredentials(authUser, authPass, l.logger) + if src != nil { + return src, true + } + } + + w.Header().Set("WWW-Authenticate", `Basic realm="icinga-notifications source"`) + w.WriteHeader(http.StatusUnauthorized) + _, _ = fmt.Fprintln(w, "expected valid icinga-notifications source basic auth credentials") + return nil, false +} + +func (l *Listener) ProcessEvent(w http.ResponseWriter, r *http.Request) { // abort the current connection by sending the status code and an error both to the log and back to the client. abort := func(statusCode int, ev *event.Event, format string, a ...any) { msg := format @@ -99,45 +119,54 @@ func (l *Listener) ProcessEvent(w http.ResponseWriter, req *http.Request) { logger.Debugw("Abort listener submitted event processing") } - if req.Method != http.MethodPost { + if r.Method != http.MethodPost { abort(http.StatusMethodNotAllowed, nil, "POST required") return } - var source *config.Source - if authUser, authPass, authOk := req.BasicAuth(); authOk { - source = l.runtimeConfig.GetSourceFromCredentials(authUser, authPass, l.logger) - } - if source == nil { - w.Header().Set("WWW-Authenticate", `Basic realm="icinga-notifications"`) - abort(http.StatusUnauthorized, nil, "HTTP authorization required") + src, isAuthenticated := l.sourceFromAuthOrAbort(w, r) + if !isAuthenticated { + // Listener.sourceFromAuthOrAbort writes 401 response by itself; no abort() necessary. return } var ev event.Event - err := json.NewDecoder(req.Body).Decode(&ev) - if err != nil { + if err := json.NewDecoder(r.Body).Decode(&ev); err != nil { abort(http.StatusBadRequest, nil, "cannot parse JSON body: %v", err) return } + // If the client uses an outdated rules version, reject the request but also send the current rules version + // and rules for this source back to the client, so it can retry the request with the updated rules. + if latestRuleVersion := l.runtimeConfig.GetRulesVersionFor(src.ID); ev.RulesVersion != latestRuleVersion { + w.WriteHeader(http.StatusPreconditionFailed) + l.writeSourceRulesInfo(w, src) + + l.logger.Debugw("Abort event processing due to outdated rules version", + zap.String("current_version", latestRuleVersion), + zap.String("provided_version", ev.RulesVersion), + zap.String("source", src.Name)) + return + } + + ev.CompleteURL(daemon.Config().Icingaweb2URL) ev.Time = time.Now() - ev.SourceId = source.ID - if ev.Type == "" { - ev.Type = event.TypeState - } else if !ev.Mute.Valid && ev.Type == event.TypeMute { + ev.SourceId = src.ID + if ev.Type == baseEv.TypeUnknown { + ev.Type = baseEv.TypeState + } else if !ev.Mute.Valid && ev.Type == baseEv.TypeMute { ev.SetMute(true, ev.MuteReason) - } else if !ev.Mute.Valid && ev.Type == event.TypeUnmute { + } else if !ev.Mute.Valid && ev.Type == baseEv.TypeUnmute { ev.SetMute(false, ev.MuteReason) } if err := ev.Validate(); err != nil { - abort(http.StatusBadRequest, &ev, err.Error()) + abort(http.StatusBadRequest, &ev, "%v", err) return } l.logger.Infow("Processing event", zap.String("event", ev.String())) - err = incident.ProcessEvent(context.Background(), l.db, l.logs, l.runtimeConfig, &ev) + err := incident.ProcessEvent(context.Background(), l.db, l.logs, l.runtimeConfig, &ev) if errors.Is(err, event.ErrSuperfluousStateChange) || errors.Is(err, event.ErrSuperfluousMuteUnmuteEvent) { abort(http.StatusNotAcceptable, &ev, "%v", err) return @@ -149,7 +178,7 @@ func (l *Listener) ProcessEvent(w http.ResponseWriter, req *http.Request) { l.logger.Infow("Successfully processed event", zap.String("event", ev.String())) - w.WriteHeader(http.StatusOK) + w.WriteHeader(http.StatusAccepted) _, _ = fmt.Fprintln(w, "event processed successfully") _, _ = fmt.Fprintln(w) } @@ -256,3 +285,50 @@ func (l *Listener) DumpSchedules(w http.ResponseWriter, r *http.Request) { _, _ = fmt.Fprintln(w) } } + +// DumpRules is used as /debug prefixed endpoint to dump all rules. The authorization has to be done beforehand. +func (l *Listener) DumpRules(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + _, _ = fmt.Fprintln(w, "GET required") + return + } + + l.runtimeConfig.RLock() + defer l.runtimeConfig.RUnlock() + + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + _ = enc.Encode(l.runtimeConfig.Rules) +} + +// writeSourceRulesInfo writes the rules information for a specific source to the response writer. +// +// Internally, it converts the data to [baseSource.RulesInfo], being serialized JSON-encoded. +func (l *Listener) writeSourceRulesInfo(w http.ResponseWriter, source *config.Source) { + var rulesInfo baseSource.RulesInfo + + func() { // Use a function to ensure that the RLock and RUnlock are called before writing the response. + l.runtimeConfig.RLock() + defer l.runtimeConfig.RUnlock() + + if sourceInfo, ok := l.runtimeConfig.RulesBySource[source.ID]; ok { + rulesInfo.Version = sourceInfo.Version.String() + rulesInfo.Rules = make(map[string]string) + + for _, rID := range sourceInfo.RuleIDs { + id := strconv.FormatInt(rID, 10) + filterExpr := "" + if l.runtimeConfig.Rules[rID].ObjectFilterExpr.Valid { + filterExpr = l.runtimeConfig.Rules[rID].ObjectFilterExpr.String + } + + rulesInfo.Rules[id] = filterExpr + } + } + }() + + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + _ = enc.Encode(rulesInfo) +} diff --git a/internal/object/object.go b/internal/object/object.go index 88f0b256..a4b30951 100644 --- a/internal/object/object.go +++ b/internal/object/object.go @@ -12,9 +12,7 @@ import ( "github.com/icinga/icinga-go-library/database" "github.com/icinga/icinga-go-library/types" "github.com/icinga/icinga-notifications/internal/event" - "regexp" "sort" - "strings" ) type Object struct { @@ -186,84 +184,6 @@ func (o *Object) String() string { return b.String() } -func (o *Object) EvalEqual(key string, value string) (bool, error) { - tagVal, ok := o.Tags[key] - if ok && tagVal == value { - return true, nil - } - - tagVal, ok = o.ExtraTags[key] - if ok && tagVal == value { - return true, nil - } - - return false, nil -} - -// EvalLike returns true when the objects tag/value matches the filter.Conditional value. -func (o *Object) EvalLike(key string, value string) (bool, error) { - segments := strings.Split(value, "*") - builder := &strings.Builder{} - for _, segment := range segments { - if segment == "" { - builder.WriteString(".*") - } - - builder.WriteString(regexp.QuoteMeta(segment)) - } - - regex := regexp.MustCompile("^" + builder.String() + "$") - tagVal, ok := o.Tags[key] - if ok && regex.MatchString(tagVal) { - return true, nil - } - - tagVal, ok = o.ExtraTags[key] - if ok && regex.MatchString(tagVal) { - return true, nil - } - - return false, nil -} - -func (o *Object) EvalLess(key string, value string) (bool, error) { - tagVal, ok := o.Tags[key] - if ok && tagVal < value { - return true, nil - } - - tagVal, ok = o.ExtraTags[key] - if ok && tagVal < value { - return true, nil - } - - return false, nil -} - -func (o *Object) EvalLessOrEqual(key string, value string) (bool, error) { - tagVal, ok := o.Tags[key] - if ok && tagVal <= value { - return true, nil - } - - tagVal, ok = o.ExtraTags[key] - if ok && tagVal <= value { - return true, nil - } - - return false, nil -} - -func (o *Object) EvalExists(key string) bool { - _, ok := o.Tags[key] - if ok { - return true - } - - _, ok = o.ExtraTags[key] - return ok -} - // ID generates a stable identifier based on a source ID and tags. // // TODO: the return value of this function must be stable like forever diff --git a/internal/object/object_test.go b/internal/object/object_test.go deleted file mode 100644 index ee6f250f..00000000 --- a/internal/object/object_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package object - -import ( - "github.com/icinga/icinga-notifications/internal/filter" - "github.com/stretchr/testify/assert" - "testing" -) - -func TestFilter(t *testing.T) { - obj := &Object{ - Tags: map[string]string{ - "host": "db1.example.com", - }, - ExtraTags: map[string]string{ - "hostgroup/database-server": "", - "hostgroup/Nuremberg (Germany)": "", - "country": "DE", - }, - } - - testdata := []struct { - Expression string - Expected bool - }{ - {"host=db1.example.com", true}, - {"host=db2.example.com", false}, - {"Host=db1.example.com", false}, - {"host", true}, - {"Host", false}, - {"service", false}, - {"!service", true}, - {"host=*.example.com&hostgroup/database-server", true}, - {"host=*.example.com&!hostgroup/database-server", false}, - {"!service&(country=DE&hostgroup/database-server)", true}, - {"!service&!(country=AT|country=CH)", true}, - {"hostgroup/Nuremberg %28Germany%29", true}, - {"host>a", true}, - {"host>z", false}, - {"host>=db1&host<=db2", true}, - } - - for _, td := range testdata { - f, err := filter.Parse(td.Expression) - if assert.NoError(t, err, "parsing %q should not return an error", td.Expression) { - matched, err := f.Eval(obj) - assert.NoError(t, err) - assert.Equal(t, td.Expected, matched, "unexpected filter result for %q", td.Expression) - } - } -} diff --git a/internal/object/objects_test.go b/internal/object/objects_test.go index cfbb583e..9a056447 100644 --- a/internal/object/objects_test.go +++ b/internal/object/objects_test.go @@ -3,6 +3,7 @@ package object import ( "context" "github.com/icinga/icinga-go-library/database" + baseEv "github.com/icinga/icinga-go-library/notifications/event" "github.com/icinga/icinga-go-library/types" "github.com/icinga/icinga-notifications/internal/event" "github.com/icinga/icinga-notifications/internal/testutils" @@ -23,9 +24,14 @@ func TestRestoreMutedObjects(t *testing.T) { "type": "notifications", "name": "Icinga Notifications", "changed_at": int64(1720702049000), + "pwd_hash": "$2y$", // Needed to pass the database constraint. } // We can't use config.Source here unfortunately due to cyclic import error! - id, err := database.InsertObtainID(ctx, tx, `INSERT INTO source (type, name, changed_at) VALUES (:type, :name, :changed_at)`, args) + id, err := database.InsertObtainID( + ctx, + tx, + `INSERT INTO source (type, name, changed_at, listener_password_hash) VALUES (:type, :name, :changed_at, :pwd_hash)`, + args) require.NoError(t, err, "populating source table should not fail") sourceID = id @@ -79,18 +85,20 @@ func TestRestoreMutedObjects(t *testing.T) { func makeObject(ctx context.Context, db *database.DB, t *testing.T, sourceID int64, mute bool) *Object { ev := &event.Event{ - Time: time.Time{}, - SourceId: sourceID, - Name: testutils.MakeRandomString(t), - Mute: types.Bool{Valid: true, Bool: mute}, - MuteReason: "Just for testing", - Tags: map[string]string{ // Always generate unique object tags not to produce same object ID! - "host": testutils.MakeRandomString(t), - "service": testutils.MakeRandomString(t), - }, - ExtraTags: map[string]string{ - "hostgroup/database-server": "", - "servicegroup/webserver": "", + Time: time.Time{}, + SourceId: sourceID, + Event: baseEv.Event{ + Name: testutils.MakeRandomString(t), + Mute: types.Bool{Valid: true, Bool: mute}, + MuteReason: "Just for testing", + Tags: map[string]string{ // Always generate unique object tags not to produce same object ID! + "host": testutils.MakeRandomString(t), + "service": testutils.MakeRandomString(t), + }, + ExtraTags: map[string]string{ + "hostgroup/database-server": "", + "servicegroup/webserver": "", + }, }, } diff --git a/internal/rule/condition.go b/internal/rule/condition.go index 97368e44..485cedff 100644 --- a/internal/rule/condition.go +++ b/internal/rule/condition.go @@ -2,7 +2,7 @@ package rule import ( "fmt" - "github.com/icinga/icinga-notifications/internal/event" + "github.com/icinga/icinga-go-library/notifications/event" "github.com/icinga/icinga-notifications/internal/filter" "math" "time" @@ -51,7 +51,7 @@ func (e *EscalationFilter) EvalEqual(key string, value string) (bool, error) { return e.IncidentAge == age, nil case "incident_severity": - severity, err := event.GetSeverityByName(value) + severity, err := event.ParseSeverity(value) if err != nil { return false, err } @@ -72,7 +72,7 @@ func (e *EscalationFilter) EvalLess(key string, value string) (bool, error) { return e.IncidentAge < age, nil case "incident_severity": - severity, err := event.GetSeverityByName(value) + severity, err := event.ParseSeverity(value) if err != nil { return false, err } @@ -97,7 +97,7 @@ func (e *EscalationFilter) EvalLessOrEqual(key string, value string) (bool, erro return e.IncidentAge <= age, nil case "incident_severity": - severity, err := event.GetSeverityByName(value) + severity, err := event.ParseSeverity(value) if err != nil { return false, err } diff --git a/internal/rule/rule.go b/internal/rule/rule.go index 8b1fbcef..a4fa1a49 100644 --- a/internal/rule/rule.go +++ b/internal/rule/rule.go @@ -3,7 +3,6 @@ package rule import ( "github.com/icinga/icinga-go-library/types" "github.com/icinga/icinga-notifications/internal/config/baseconf" - "github.com/icinga/icinga-notifications/internal/filter" "github.com/icinga/icinga-notifications/internal/recipient" "github.com/icinga/icinga-notifications/internal/timeperiod" "go.uber.org/zap/zapcore" @@ -16,22 +15,13 @@ type Rule struct { Name string `db:"name"` TimePeriod *timeperiod.TimePeriod `db:"-"` TimePeriodID types.Int `db:"timeperiod_id"` - ObjectFilter filter.Filter `db:"-"` + SourceID int64 `db:"source_id"` ObjectFilterExpr types.String `db:"object_filter"` Escalations map[int64]*Escalation `db:"-"` } // IncrementalInitAndValidate implements the config.IncrementalConfigurableInitAndValidatable interface. func (r *Rule) IncrementalInitAndValidate() error { - if r.ObjectFilterExpr.Valid { - f, err := filter.Parse(r.ObjectFilterExpr.String) - if err != nil { - return err - } - - r.ObjectFilter = f - } - return nil } @@ -39,6 +29,7 @@ func (r *Rule) IncrementalInitAndValidate() error { func (r *Rule) MarshalLogObject(encoder zapcore.ObjectEncoder) error { encoder.AddInt64("id", r.ID) encoder.AddString("name", r.Name) + encoder.AddInt64("source_id", r.SourceID) if r.TimePeriodID.Valid && r.TimePeriodID.Int64 != 0 { encoder.AddInt64("timeperiod_id", r.TimePeriodID.Int64) @@ -50,16 +41,6 @@ func (r *Rule) MarshalLogObject(encoder zapcore.ObjectEncoder) error { return nil } -// Eval evaluates the configured object filter for the provided filterable. -// Returns always true if the current rule doesn't have a configured object filter. -func (r *Rule) Eval(filterable filter.Filterable) (bool, error) { - if r.ObjectFilter == nil { - return true, nil - } - - return r.ObjectFilter.Eval(filterable) -} - // ContactChannels stores a set of channel IDs for each set of individual contacts. type ContactChannels map[*recipient.Contact]map[int64]bool diff --git a/pkg/plugin/plugin.go b/pkg/plugin/plugin.go deleted file mode 100644 index ba292b95..00000000 --- a/pkg/plugin/plugin.go +++ /dev/null @@ -1,326 +0,0 @@ -package plugin - -import ( - "database/sql/driver" - "encoding/json" - "errors" - "fmt" - "github.com/icinga/icinga-go-library/types" - "github.com/icinga/icinga-go-library/utils" - "github.com/icinga/icinga-notifications/internal/event" - "github.com/icinga/icinga-notifications/pkg/rpc" - "io" - "log" - "os" - "sync" - "time" -) - -const ( - MethodGetInfo = "GetInfo" - MethodSetConfig = "SetConfig" - MethodSendNotification = "SendNotification" -) - -// ConfigOption describes a config element. -type ConfigOption struct { - // Element name - Name string `json:"name"` - - // Element type: - // - // string = text, number = number, bool = checkbox, text = textarea, option = select, options = select[multiple], secret = password - Type string `json:"type"` - - // Element label map. Locale in the standard format (language_REGION) as key and corresponding label as value. - // Locale is assumed to be UTF-8 encoded (Without the suffix in the locale) - // - // e.g. {"en_US": "Save", "de_DE": "Speichern"} - // An "en_US" locale must be given as a fallback - Label map[string]string `json:"label"` - - // Element description map. Locale in the standard format (language_REGION) as key and corresponding label as value. - // Locale is assumed to be UTF-8 encoded (Without the suffix in the locale) - // - // When the user moves the mouse pointer over an element in the web UI, a tooltip is displayed with a given message. - // - // e.g. {"en_US": "HTTP request method for the request.", "de_DE": "HTTP-Methode für die Anfrage."} - // An "en_US" locale must be given as a fallback - Help map[string]string `json:"help,omitempty"` - - // Element default: bool for checkbox default value, string for other elements (used as placeholder) - Default any `json:"default,omitempty"` - - // Set true if this element is required, omit otherwise - Required bool `json:"required,omitempty"` - - // Options of a select element: key => value. - // Only required for the type option or options - // - // e.g., map[string]string{ - // "1": "January", - // "2": "February", - // } - Options map[string]string `json:"options,omitempty"` - - // Element's min option defines the minimum allowed number value. It can only be used for the type number. - Min types.Int `json:"min,omitempty"` - - // Element's max option defines the maximum allowed number value. It can only be used for the type number. - Max types.Int `json:"max,omitempty"` -} - -// ConfigOptions describes all ConfigOption entries. -// -// This type became necessary to implement the database.sql.driver.Valuer to marshal it into JSON. -type ConfigOptions []ConfigOption - -// Value implements database.sql's driver.Valuer to represent all ConfigOptions as a JSON array. -func (c ConfigOptions) Value() (driver.Value, error) { - return json.Marshal(c) -} - -// Info contains channel plugin information. -type Info struct { - // Type of the channel plugin. - // - // Not part of the JSON object. Will be set to the channel plugin file name before database insertion. - Type string `db:"type" json:"-"` - - // Name of this channel plugin in a human-readable value. - Name string `db:"name" json:"name"` - - // Version of this channel plugin. - Version string `db:"version" json:"version"` - - // Author of this channel plugin. - Author string `db:"author" json:"author"` - - // ConfigAttributes contains multiple ConfigOption(s) as JSON-encoded list. - ConfigAttributes ConfigOptions `db:"config_attrs" json:"config_attrs"` -} - -// TableName implements the contracts.TableNamer interface. -func (i *Info) TableName() string { - return "available_channel_type" -} - -// Contact to receive notifications for the NotificationRequest. -type Contact struct { - // FullName of a Contact as defined in Icinga Notifications. - FullName string `json:"full_name"` - - // Addresses of a Contact with a type. - Addresses []*Address `json:"addresses"` -} - -// Address to receive this notification. Each Contact might have multiple addresses. -type Address struct { - // Type field matches the Info.Type, effectively being the channel plugin file name. - Type string `json:"type"` - - // Address is the associated Type-specific address, e.g., an email address for type email. - Address string `json:"address"` -} - -// Object which this NotificationRequest is all about, e.g., an Icinga 2 Host or Service object. -type Object struct { - // Name depending on its source, may be "host!service" when from Icinga 2. - Name string `json:"name"` - - // Url pointing to this Object, may be to Icinga Web. - Url string `json:"url"` - - // Tags defining this Object, may be "host" and "service" when from Icinga 2. - Tags map[string]string `json:"tags"` - - // ExtraTags attached, may be a host or service groups when form Icinga 2. - ExtraTags map[string]string `json:"extra_tags"` -} - -// Incident of this NotificationRequest, grouping Events for this Object. -type Incident struct { - // Id is the unique identifier for this Icinga Notifications Incident, allows linking related events. - Id int64 `json:"id"` - - // Url pointing to the Icinga Notifications Web module's Incident page. - Url string `json:"url"` - - // Severity of this Incident. - Severity string `json:"severity"` -} - -// Event indicating this NotificationRequest. -type Event struct { - // Time when this event occurred, being encoded according to RFC 3339 when passed as JSON. - Time time.Time `json:"time"` - - // Type of this event, e.g., a "state" change, "mute" or "unmute". See further ./internal/event/event.go - Type string `json:"type"` - - // Username may contain a user triggering this event, depending on the event's source. - Username string `json:"username"` - - // Message of this event, might be a check output when the related Object is an Icinga 2 object. - Message string `json:"message"` -} - -// NotificationRequest is being sent to a channel plugin via Plugin.SendNotification to request notification dispatching. -type NotificationRequest struct { - // Contact to receive this NotificationRequest. - Contact *Contact `json:"contact"` - - // Object associated with this NotificationRequest, e.g., an Icinga 2 Service Object. - Object *Object `json:"object"` - - // Incident associated with this NotificationRequest. - Incident *Incident `json:"incident"` - - // Event being responsible for creating this NotificationRequest, e.g., a firing Icinga 2 Service Check. - Event *Event `json:"event"` -} - -// Plugin defines necessary methods for a channel plugin. -// -// Those methods are being called via the internal JSON-RPC and allow channel interaction. Within the channel's main -// function, the channel should be launched via RunPlugin. -type Plugin interface { - // GetInfo returns the corresponding plugin *Info. - GetInfo() *Info - - // SetConfig sets the plugin config, returns an error on failure. - SetConfig(jsonStr json.RawMessage) error - - // SendNotification sends the notification, returns an error on failure. - SendNotification(req *NotificationRequest) error -} - -// PopulateDefaults sets the struct fields from Info.ConfigAttributes where ConfigOption.Default is set. -// -// It should be called from each channel plugin within its Plugin.SetConfig before doing any further configuration. -func PopulateDefaults(typePtr Plugin) error { - defaults := make(map[string]any) - for _, confAttr := range typePtr.GetInfo().ConfigAttributes { - if confAttr.Default != nil { - defaults[confAttr.Name] = confAttr.Default - } - } - - defaultConf, err := json.Marshal(defaults) - if err != nil { - return err - } - - return json.Unmarshal(defaultConf, typePtr) -} - -// RunPlugin serves the RPC for a Channel Plugin. -// -// This function reads requests from stdin, calls the associated RPC method, and writes the responses to stdout. As this -// function blocks, it should be called last in a channel plugin's main function. -func RunPlugin(plugin Plugin) { - encoder := json.NewEncoder(os.Stdout) - decoder := json.NewDecoder(os.Stdin) - var encoderMu sync.Mutex - - wg := sync.WaitGroup{} - - for { - var req rpc.Request - err := decoder.Decode(&req) - if err != nil { - if errors.Is(err, io.EOF) { - // plugin shutdown requested - break - } - - log.Fatal("failed to read request:", err) - } - - wg.Add(1) - go func(request rpc.Request) { - defer wg.Done() - var response = rpc.Response{Id: request.Id} - switch request.Method { - case MethodGetInfo: - result, err := json.Marshal(plugin.GetInfo()) - if err != nil { - response.Error = fmt.Errorf("failed to collect plugin info: %w", err).Error() - } else { - response.Result = result - } - - case MethodSetConfig: - if err = plugin.SetConfig(request.Params); err != nil { - response.Error = fmt.Errorf("failed to set plugin config: %w", err).Error() - } - - case MethodSendNotification: - var nr NotificationRequest - if err = json.Unmarshal(request.Params, &nr); err != nil { - response.Error = fmt.Errorf("failed to json.Unmarshal request: %w", err).Error() - } else if err = plugin.SendNotification(&nr); err != nil { - response.Error = err.Error() - } - - default: - response.Error = fmt.Sprintf("unknown method: %q", request.Method) - } - - encoderMu.Lock() - err = encoder.Encode(response) - encoderMu.Unlock() - if err != nil { - panic(fmt.Errorf("failed to write response: %w", err)) - } - }(req) - } - - wg.Wait() -} - -// FormatMessage formats a NotificationRequest message and adds to the given io.Writer. -// -// The created message is a multi-line message as one might expect it in an email. -func FormatMessage(writer io.Writer, req *NotificationRequest) { - if req.Event.Message != "" { - msgTitle := "Comment" - if req.Event.Type == event.TypeState { - msgTitle = "Output" - } - - _, _ = fmt.Fprintf(writer, "%s: %s\n\n", msgTitle, req.Event.Message) - } - - _, _ = fmt.Fprintf(writer, "When: %s\n\n", req.Event.Time.Format("2006-01-02 15:04:05 MST")) - - if req.Event.Username != "" { - _, _ = fmt.Fprintf(writer, "Author: %s\n\n", req.Event.Username) - } - _, _ = fmt.Fprintf(writer, "Object: %s\n\n", req.Object.Url) - _, _ = writer.Write([]byte("Tags:\n")) - for k, v := range utils.IterateOrderedMap(req.Object.Tags) { - _, _ = fmt.Fprintf(writer, "%s: %s\n", k, v) - } - - if len(req.Object.ExtraTags) > 0 { - _, _ = writer.Write([]byte("\nExtra Tags:\n")) - for k, v := range utils.IterateOrderedMap(req.Object.ExtraTags) { - _, _ = fmt.Fprintf(writer, "%s: %s\n", k, v) - } - } - - _, _ = fmt.Fprintf(writer, "\nIncident: %s", req.Incident.Url) -} - -// FormatSubject returns the formatted subject string based on the event type. -func FormatSubject(req *NotificationRequest) string { - switch req.Event.Type { - case event.TypeState: - return fmt.Sprintf("[#%d] %s %s is %s", req.Incident.Id, req.Event.Type, req.Object.Name, req.Incident.Severity) - case event.TypeAcknowledgementCleared, event.TypeDowntimeRemoved: - return fmt.Sprintf("[#%d] %s from %s", req.Incident.Id, req.Event.Type, req.Object.Name) - default: - return fmt.Sprintf("[#%d] %s on %s", req.Incident.Id, req.Event.Type, req.Object.Name) - } -} diff --git a/pkg/rpc/rpc.go b/pkg/rpc/rpc.go deleted file mode 100644 index c0279e5e..00000000 --- a/pkg/rpc/rpc.go +++ /dev/null @@ -1,173 +0,0 @@ -package rpc - -import ( - "encoding/json" - "errors" - "fmt" - "go.uber.org/zap" - "io" - "sync" -) - -type Request struct { - Method string `json:"method"` - Params json.RawMessage `json:"params"` - Id uint64 `json:"id"` -} - -type Response struct { - Result json.RawMessage `json:"result,omitempty"` - Error string `json:"error,omitempty"` - Id uint64 `json:"id"` -} - -type Error struct { - cause error -} - -func (err *Error) Error() string { - return fmt.Sprintf("RPC error: %s", err.cause.Error()) -} - -func (err *Error) Unwrap() error { - return err.cause -} - -type RPC struct { - writer io.Closer // use encoder for writing instead - encoder *json.Encoder - encoderMu sync.Mutex - - decoder *json.Decoder - logger *zap.SugaredLogger - - pendingRequests map[uint64]chan Response - lastRequestId uint64 - requestsMu sync.Mutex - - processResponsesErrCh chan struct{} // never transports a value, only closed through processResponses() to signal an occurred error - processResponsesErr *Error // only initialized via processResponses() when decoder fails (Fatal/non-recoverable) -} - -// NewRPC creates and returns an RPC instance -func NewRPC(writer io.WriteCloser, reader io.Reader, logger *zap.SugaredLogger) *RPC { - rpc := &RPC{ - writer: writer, - encoder: json.NewEncoder(writer), - decoder: json.NewDecoder(reader), - pendingRequests: map[uint64]chan Response{}, - logger: logger, - processResponsesErrCh: make(chan struct{}), - } - - go rpc.processResponses() - - return rpc -} - -// Call sends a request with given parameters. -// Returns the Response.Result or an error. -// -// Two different kinds of error can be returned: -// - rpc.Error: Communication failed and future calls on this instance won't work and a new *RPC has to be created. -// - Response.Error: The response contains an error (that's non-fatal for the RPC object). -func (r *RPC) Call(method string, params json.RawMessage) (json.RawMessage, error) { - if err := r.Err(); err != nil { - return nil, err - } - - promise := make(chan Response, 1) - - r.requestsMu.Lock() - r.lastRequestId++ - newId := r.lastRequestId - r.pendingRequests[newId] = promise - r.requestsMu.Unlock() - - encodeReq := func() error { - r.encoderMu.Lock() - defer r.encoderMu.Unlock() - if r.encoder == nil { - return errors.New("cannot process any further requests, writer already closed") - } - - err := r.encoder.Encode(Request{Method: method, Params: params, Id: newId}) - if err != nil { - r.encoder = nil - _ = r.writer.Close() - return fmt.Errorf("failed to write request: %w", err) - } - - return nil - } - - if err := encodeReq(); err != nil { - return nil, err - } - - select { - case response := <-promise: - if response.Error != "" { - return nil, errors.New(response.Error) - } - - return response.Result, nil - - case <-r.Done(): - return nil, r.Err() - } -} - -// Err returns a non-nil error, If Done sends. Otherwise, nil is returned -func (r *RPC) Err() error { - select { - case <-r.Done(): - return r.processResponsesErr - default: - return nil - } -} - -// Done sends when the processResponsesErrCh has been closed. -// processResponsesErrCh is closed when decoder fails to read -func (r *RPC) Done() <-chan struct{} { - return r.processResponsesErrCh -} - -// Close closes the RPC.writer. -// All further calls to Call lead to an error. -// The Process will be terminated as soon as all pending requests have been processed. -func (r *RPC) Close() error { - r.encoderMu.Lock() - defer r.encoderMu.Unlock() - - r.encoder = nil - - return r.writer.Close() -} - -// processResponses sends responses to its channel (identified by response.id) -// In case of any error, all pending requests are dropped -func (r *RPC) processResponses() { - for r.Err() == nil { - var response Response - if err := r.decoder.Decode(&response); err != nil { - r.processResponsesErr = &Error{cause: fmt.Errorf("failed to read json response: %w", err)} - close(r.processResponsesErrCh) - _ = r.Close() - - return - } - - r.requestsMu.Lock() - promise := r.pendingRequests[response.Id] - delete(r.pendingRequests, response.Id) - r.requestsMu.Unlock() - - if promise != nil { - promise <- response - } else { - r.logger.Warn("Ignored response for unknown ID:", response.Id) - } - } -} diff --git a/pkg/rpc/rpc_test.go b/pkg/rpc/rpc_test.go deleted file mode 100644 index 9aa813a3..00000000 --- a/pkg/rpc/rpc_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package rpc - -import ( - "encoding/json" - "fmt" - "github.com/stretchr/testify/assert" - "go.uber.org/zap/zaptest" - "io" - "sync" - "testing" -) - -func TestRPC(t *testing.T) { - writer, reader := dummyRemote() - rpc := NewRPC(writer, reader, zaptest.NewLogger(t).Sugar()) - - wg := sync.WaitGroup{} - for i := 0; i < 5; i++ { - wg.Add(1) - go func(i int) { - for j := 0; j < 100; j++ { - params := fmt.Sprintf(`{"go":"%d-%d"}`, i, j) - - res, err := rpc.Call("hello", json.RawMessage(params)) - if err != nil { - panic(err) - } - - t.Log(string(res)) - assert.Equal(t, params, string(res)) - } - wg.Done() - }(i) - } - wg.Wait() -} - -func dummyRemote() (io.WriteCloser, io.Reader) { - reqReader, reqWriter := io.Pipe() - resReader, resWriter := io.Pipe() - - go func() { - dec := json.NewDecoder(reqReader) - enc := json.NewEncoder(resWriter) - - for { - var req Request - err := dec.Decode(&req) - if err != nil { - panic(err) - } - - var res Response - - res.Id = req.Id - res.Result = req.Params - - err = enc.Encode(&res) - if err != nil { - panic(err) - } - } - }() - - return reqWriter, resReader -} diff --git a/schema/mysql/schema.sql b/schema/mysql/schema.sql index 3f4f2889..e0ce1b37 100644 --- a/schema/mysql/schema.sql +++ b/schema/mysql/schema.sql @@ -207,37 +207,22 @@ CREATE INDEX idx_timeperiod_entry_changed_at ON timeperiod_entry(changed_at); CREATE TABLE source ( id bigint NOT NULL AUTO_INCREMENT, - -- The type "icinga2" is special and requires (at least some of) the icinga2_ prefixed columns. type text NOT NULL, name text NOT NULL COLLATE utf8mb4_unicode_ci, -- will likely need a distinguishing value for multiple sources of the same type in the future, like for example -- the Icinga DB environment ID for Icinga 2 sources - -- The column listener_password_hash is type-dependent. - -- If type is not "icinga2", listener_password_hash is required to limit API access for incoming connections - -- to the Listener. The username will be "source-${id}", allowing early verification. + -- This column is required to limit API access for incoming connections to the Listener. + -- The username will be "source-${id}", allowing early verification. listener_password_hash text, - -- Following columns are for the "icinga2" type. - -- At least icinga2_base_url, icinga2_auth_user, and icinga2_auth_pass are required - see CHECK below. - icinga2_base_url text, - icinga2_auth_user text, - icinga2_auth_pass text, - -- icinga2_ca_pem specifies a custom CA to be used in the PEM format, if not NULL. - icinga2_ca_pem text, - -- icinga2_common_name requires Icinga 2's certificate to hold this Common Name if not NULL. This allows using a - -- differing Common Name - maybe an Icinga 2 Endpoint object name - from the FQDN within icinga2_base_url. - icinga2_common_name text, - icinga2_insecure_tls enum('n', 'y') NOT NULL DEFAULT 'n', - changed_at bigint NOT NULL, deleted enum('n', 'y') NOT NULL DEFAULT 'n', -- The hash is a PHP password_hash with PASSWORD_DEFAULT algorithm, defaulting to bcrypt. This check roughly ensures -- that listener_password_hash can only be populated with bcrypt hashes. -- https://icinga.com/docs/icinga-web/latest/doc/20-Advanced-Topics/#manual-user-creation-for-database-authentication-backend - CONSTRAINT ck_source_bcrypt_listener_password_hash CHECK (listener_password_hash IS NULL OR listener_password_hash LIKE '$2y$%'), - CONSTRAINT ck_source_icinga2_has_config CHECK (type != 'icinga2' OR (icinga2_base_url IS NOT NULL AND icinga2_auth_user IS NOT NULL AND icinga2_auth_pass IS NOT NULL)), + CONSTRAINT ck_source_bcrypt_listener_password_hash CHECK (listener_password_hash IS NULL OR listener_password_hash LIKE '$2_$%'), CONSTRAINT pk_source PRIMARY KEY (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; @@ -296,13 +281,15 @@ CREATE TABLE rule ( id bigint NOT NULL AUTO_INCREMENT, name text NOT NULL COLLATE utf8mb4_unicode_ci, timeperiod_id bigint, + source_id bigint NOT NULL, -- the source this rule belongs to object_filter text, changed_at bigint NOT NULL, deleted enum('n', 'y') NOT NULL DEFAULT 'n', CONSTRAINT pk_rule PRIMARY KEY (id), - CONSTRAINT fk_rule_timeperiod FOREIGN KEY (timeperiod_id) REFERENCES timeperiod(id) + CONSTRAINT fk_rule_timeperiod FOREIGN KEY (timeperiod_id) REFERENCES timeperiod(id), + CONSTRAINT fk_rule_source FOREIGN KEY (source_id) REFERENCES source(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; CREATE INDEX idx_rule_changed_at ON rule(changed_at); diff --git a/schema/mysql/upgrades/0.2.0-source-rules.sql b/schema/mysql/upgrades/0.2.0-source-rules.sql new file mode 100644 index 00000000..ae7074dc --- /dev/null +++ b/schema/mysql/upgrades/0.2.0-source-rules.sql @@ -0,0 +1,19 @@ +ALTER TABLE source + DROP CONSTRAINT ck_source_icinga2_has_config, + DROP CONSTRAINT ck_source_bcrypt_listener_password_hash; +ALTER TABLE source + DROP COLUMN icinga2_base_url, + DROP COLUMN icinga2_auth_user, + DROP COLUMN icinga2_auth_pass, + DROP COLUMN icinga2_ca_pem, + DROP COLUMN icinga2_common_name, + DROP COLUMN icinga2_insecure_tls, + ADD CONSTRAINT ck_source_bcrypt_listener_password_hash CHECK ( + listener_password_hash IS NULL OR listener_password_hash LIKE '$2_$%'); + +ALTER TABLE rule + ADD COLUMN source_id bigint DEFAULT NULL AFTER timeperiod_id, + ADD CONSTRAINT fk_rule_source FOREIGN KEY (source_id) REFERENCES source(id); + +UPDATE rule SET source_id = (SELECT id FROM source WHERE type = 'icinga2'); +ALTER TABLE rule MODIFY COLUMN source_id bigint NOT NULL; diff --git a/schema/pgsql/schema.sql b/schema/pgsql/schema.sql index 3f2d943c..35381d3a 100644 --- a/schema/pgsql/schema.sql +++ b/schema/pgsql/schema.sql @@ -239,37 +239,22 @@ CREATE INDEX idx_timeperiod_entry_changed_at ON timeperiod_entry(changed_at); CREATE TABLE source ( id bigserial, - -- The type "icinga2" is special and requires (at least some of) the icinga2_ prefixed columns. type text NOT NULL, name citext NOT NULL, -- will likely need a distinguishing value for multiple sources of the same type in the future, like for example -- the Icinga DB environment ID for Icinga 2 sources - -- The column listener_password_hash is type-dependent. - -- If type is not "icinga2", listener_password_hash is required to limit API access for incoming connections - -- to the Listener. The username will be "source-${id}", allowing early verification. + -- This column is required to limit API access for incoming connections to the Listener. + -- The username will be "source-${id}", allowing early verification. listener_password_hash text, - -- Following columns are for the "icinga2" type. - -- At least icinga2_base_url, icinga2_auth_user, and icinga2_auth_pass are required - see CHECK below. - icinga2_base_url text, - icinga2_auth_user text, - icinga2_auth_pass text, - -- icinga2_ca_pem specifies a custom CA to be used in the PEM format, if not NULL. - icinga2_ca_pem text, - -- icinga2_common_name requires Icinga 2's certificate to hold this Common Name if not NULL. This allows using a - -- differing Common Name - maybe an Icinga 2 Endpoint object name - from the FQDN within icinga2_base_url. - icinga2_common_name text, - icinga2_insecure_tls boolenum NOT NULL DEFAULT 'n', - changed_at bigint NOT NULL, deleted boolenum NOT NULL DEFAULT 'n', -- The hash is a PHP password_hash with PASSWORD_DEFAULT algorithm, defaulting to bcrypt. This check roughly ensures -- that listener_password_hash can only be populated with bcrypt hashes. -- https://icinga.com/docs/icinga-web/latest/doc/20-Advanced-Topics/#manual-user-creation-for-database-authentication-backend - CONSTRAINT ck_source_bcrypt_listener_password_hash CHECK (listener_password_hash IS NULL OR listener_password_hash LIKE '$2y$%'), - CONSTRAINT ck_source_icinga2_has_config CHECK (type != 'icinga2' OR (icinga2_base_url IS NOT NULL AND icinga2_auth_user IS NOT NULL AND icinga2_auth_pass IS NOT NULL)), + CONSTRAINT ck_source_bcrypt_listener_password_hash CHECK (listener_password_hash IS NULL OR listener_password_hash LIKE '$2_$%'), CONSTRAINT pk_source PRIMARY KEY (id) ); @@ -343,13 +328,15 @@ CREATE TABLE rule ( id bigserial, name citext NOT NULL, timeperiod_id bigint, + source_id bigint NOT NULL, -- the source this rule belongs to object_filter text, changed_at bigint NOT NULL, deleted boolenum NOT NULL DEFAULT 'n', CONSTRAINT pk_rule PRIMARY KEY (id), - CONSTRAINT fk_rule_timeperiod FOREIGN KEY (timeperiod_id) REFERENCES timeperiod(id) + CONSTRAINT fk_rule_timeperiod FOREIGN KEY (timeperiod_id) REFERENCES timeperiod(id), + CONSTRAINT fk_rule_source FOREIGN KEY (source_id) REFERENCES source(id) ); CREATE INDEX idx_rule_changed_at ON rule(changed_at); diff --git a/schema/pgsql/upgrades/0.2.0-source-rules.sql b/schema/pgsql/upgrades/0.2.0-source-rules.sql new file mode 100644 index 00000000..8b092ca1 --- /dev/null +++ b/schema/pgsql/upgrades/0.2.0-source-rules.sql @@ -0,0 +1,19 @@ +ALTER TABLE source + DROP CONSTRAINT ck_source_icinga2_has_config, + DROP CONSTRAINT ck_source_bcrypt_listener_password_hash; +ALTER TABLE source + DROP COLUMN icinga2_base_url, + DROP COLUMN icinga2_auth_user, + DROP COLUMN icinga2_auth_pass, + DROP COLUMN icinga2_ca_pem, + DROP COLUMN icinga2_common_name, + DROP COLUMN icinga2_insecure_tls, + ADD CONSTRAINT ck_source_bcrypt_listener_password_hash CHECK ( + listener_password_hash IS NULL OR listener_password_hash LIKE '$2_$%'); + +ALTER TABLE rule + ADD COLUMN source_id bigint DEFAULT NULL, + ADD CONSTRAINT fk_rule_source FOREIGN KEY (source_id) REFERENCES source(id); + +UPDATE rule SET source_id = (SELECT id FROM source WHERE type = 'icinga2'); +ALTER TABLE rule ALTER COLUMN source_id SET NOT NULL;