Skip to content

Commit ec64634

Browse files
yhabteaboxzi
andcommitted
Add a base Icinga Notifications Client
Alvar did most of the work here in the Icinga DB repo, I'm just trying to outsource the basic functionality to this library, so that they can also be used by other projects. Co-Authored-By: Alvar Penning <[email protected]>
1 parent 0912f81 commit ec64634

File tree

4 files changed

+219
-0
lines changed

4 files changed

+219
-0
lines changed

notifications/client.go

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
package notifications
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"iter"
10+
"net/http"
11+
"net/url"
12+
"strconv"
13+
"strings"
14+
15+
"github.com/icinga/icinga-go-library/notifications/event"
16+
"github.com/pkg/errors"
17+
)
18+
19+
// ErrRulesOutdated implies that the rules version between Icinga DB and Icinga Notifications mismatches.
20+
var ErrRulesOutdated = fmt.Errorf("rules version is outdated")
21+
22+
// BasicAuthTransport is an http.RoundTripper that adds basic authentication and a User-Agent header to HTTP requests.
23+
type BasicAuthTransport struct {
24+
http.RoundTripper // RoundTripper is the underlying HTTP transport to use for making requests.
25+
26+
Username string
27+
Password string
28+
ClientName string // ClientName is used to set the User-Agent header.
29+
}
30+
31+
// RoundTrip adds basic authentication headers to the request and executes the HTTP request.
32+
// If RoundTripper is nil, it defaults to http.DefaultTransport.
33+
func (b *BasicAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
34+
req.SetBasicAuth(b.Username, b.Password)
35+
// As long as our round tripper is used for the client, the User-Agent header below
36+
// overrides any other value set by the user.
37+
req.Header.Set("User-Agent", b.ClientName)
38+
39+
return b.RoundTripper.RoundTrip(req)
40+
}
41+
42+
// Client provides a common interface to interact with the Icinga Notifications API.
43+
// It holds the configuration for the API endpoint and the HTTP client used to make requests.
44+
type Client struct {
45+
cfg Config // cfg holds base API endpoint URL and authentication details.
46+
47+
client http.Client // HTTP client used for making requests to the Icinga Notifications API.
48+
49+
IcingaWebBasUrl *url.URL // IcingaWebBaseUrl holds the base URL for Icinga Web 2.
50+
Endpoints struct {
51+
EventRules string // EventRules holds the URL for the event rules endpoint.
52+
ProcessEvent string // ProcessEvent holds the URL for the process event endpoint.
53+
}
54+
}
55+
56+
// NewClient creates a new Client instance with the provided configuration.
57+
//
58+
// The projectName is used to set the User-Agent header in HTTP requests sent by this client and should be
59+
// set to the name of the project using this client (e.g., "Icinga DB").
60+
//
61+
// It may return an error if the API base URL or Icinga Web 2 base URL cannot be parsed.
62+
func NewClient(cfg Config, projectName string) (*Client, error) {
63+
client := &Client{
64+
cfg: cfg,
65+
client: http.Client{
66+
//Timeout: cfg.Timeout, // Uncomment once Timeout is (should be?) user configurable.
67+
Transport: &BasicAuthTransport{
68+
RoundTripper: http.DefaultTransport,
69+
Username: cfg.Username,
70+
Password: cfg.Password,
71+
ClientName: projectName,
72+
},
73+
},
74+
}
75+
76+
baseUrl, err := url.Parse(cfg.ApiBaseUrl)
77+
if err != nil {
78+
return nil, errors.Wrap(err, "unable to parse API base URL")
79+
}
80+
81+
client.Endpoints.ProcessEvent = baseUrl.ResolveReference(&url.URL{Path: "/process-event"}).String()
82+
83+
client.IcingaWebBasUrl, err = url.Parse(cfg.IcingaWeb2BaseUrl)
84+
if err != nil {
85+
return nil, errors.Wrap(err, "unable to parse Icinga Web 2 base URL")
86+
}
87+
88+
return client, nil
89+
}
90+
91+
// ProcessEvent submits an event to the Icinga Notifications /process-event API endpoint.
92+
//
93+
// It serializes the event into JSON and sends it as a POST request to the process event endpoint.
94+
// The given ruleVersion is transmitted as [XIcingaRulesVersion] header along with the [XIcingaRulesId] header,
95+
// containing a comma-separated list of rule IDs.
96+
//
97+
// It may return an ErrRulesOutdated error, implying that the provided ruleVersion does not match the current rules
98+
// version in Icinga Notifications daemon. In this case, it will also return the current rules specific to your source
99+
// and their version, so you can retry the event submission after re-evaluating them. This way, no extra HTTP request
100+
// is needed to fetch the rules, as Icinga Notifications will respond with the newest ones whenever it detects that
101+
// you're using an outdated event rules config.
102+
//
103+
// If the request fails or the response is not as expected, it returns an error; otherwise, it returns nil.
104+
func (c *Client) ProcessEvent(ctx context.Context, ev *event.Event, ruleVersion string, ruleIDs ...int64) (*SourceRulesInfo, error) {
105+
body, err := json.Marshal(ev)
106+
if err != nil {
107+
return nil, errors.Wrap(err, "cannot encode event to JSON")
108+
}
109+
110+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Endpoints.ProcessEvent, bytes.NewReader(body))
111+
if err != nil {
112+
return nil, errors.Wrap(err, "cannot create HTTP request")
113+
}
114+
115+
ruleIdsStrArr := make([]string, 0, len(ruleIDs))
116+
for _, ruleId := range ruleIDs {
117+
ruleIdsStrArr = append(ruleIdsStrArr, strconv.FormatInt(ruleId, 10))
118+
}
119+
120+
req.Header.Add("Content-Type", "application/json")
121+
req.Header.Add("Accept", "application/json")
122+
req.Header.Add(XIcingaRulesVersion, ruleVersion)
123+
req.Header.Add(XIcingaRulesId, strings.Join(ruleIdsStrArr, ","))
124+
125+
resp, err := c.client.Do(req)
126+
if err != nil {
127+
return nil, errors.Wrap(err, "cannot POST HTTP request to process event")
128+
}
129+
130+
defer func() {
131+
_, _ = io.Copy(io.Discard, resp.Body)
132+
_ = resp.Body.Close()
133+
}()
134+
135+
if resp.StatusCode == http.StatusPreconditionFailed {
136+
// Indicates that the rules version is outdated and the body should contain the current rules and their version.
137+
// So, we read the body to extract the rules and return an ErrRulesOutdated error, so the caller can retry
138+
// the event submission after it has reevaluated them.
139+
var rulesInfo SourceRulesInfo
140+
if err := json.NewDecoder(resp.Body).Decode(&rulesInfo); err != nil {
141+
return nil, errors.Wrap(err, "cannot decode new rules from process event response")
142+
}
143+
144+
return &rulesInfo, ErrRulesOutdated
145+
}
146+
147+
if resp.StatusCode >= http.StatusOK && resp.StatusCode <= 299 {
148+
return nil, nil // Successfully processed the event.
149+
}
150+
151+
if resp.StatusCode == http.StatusNotAcceptable {
152+
return nil, nil // Either superfluous state change event or empty rule IDs provided.
153+
}
154+
155+
var buf bytes.Buffer
156+
_, _ = io.Copy(&buf, &io.LimitedReader{R: resp.Body, N: 1 << 20}) // Limit the error message length to avoid memory exhaustion.
157+
158+
return nil, errors.Errorf("unexpected response from process event API, status %q (%d): %q",
159+
resp.Status, resp.StatusCode, strings.TrimSpace(buf.String()))
160+
}
161+
162+
// JoinIcingaWeb2Path constructs a URL by joining the Icinga Web 2 base URL with the provided relative URL.
163+
//
164+
// It is used to convert any relative URL into an absolute URL that points to the Icinga Web 2 instance.
165+
// A relative URL like "/icingadb/host" is transformed to e.g. "https://icinga.example.com/icingaweb2/icingadb/host"
166+
// after passing through this method, assuming the Icinga Web 2 base URL is "https://icinga.example.com/icingaweb2".
167+
func (c *Client) JoinIcingaWeb2Path(relativePath string) *url.URL {
168+
return c.IcingaWebBasUrl.JoinPath(relativePath)
169+
}
170+
171+
// EmptyRulesVersion is a constant representing the version of the rules when no rules are present.
172+
// It is used to indicate that there are no rules available for a given source.
173+
const EmptyRulesVersion = "0x0"
174+
175+
// SourceRulesInfo holds information about the event rules for a specific source.
176+
type SourceRulesInfo struct {
177+
Version string // Version of the event rules fetched from the API.
178+
Rules map[int64]RuleResp // Rules is a map of rule IDs to their corresponding RuleResp objects.
179+
}
180+
181+
// RuleResp represents a response object for a rule in the Icinga Notifications API.
182+
type RuleResp struct {
183+
Id int64 // Id is the unique identifier of the rule.
184+
Name string // Name is the name of the rule.
185+
ObjectFilterExpr string // ObjectFilterExpr is the object filter expression of the rule, if any.
186+
}

notifications/config.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package notifications
2+
3+
// Config defines all the required configuration for the Icinga Notifications API client.
4+
type Config struct {
5+
ApiBaseUrl string `yaml:"api-base-url" env:"API_BASE_URL"`
6+
Username string `yaml:"username" env:"USERNAME"`
7+
Password string `yaml:"password" env:"PASSWORD,unset"`
8+
IcingaWeb2BaseUrl string `yaml:"icingaweb2-base-url" env:"ICINGAWEB2_BASE_URL"`
9+
}

notifications/xhttp_headers.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package notifications
2+
3+
// These headers are used to pass metadata about the request made to the Icinga Notifications API.
4+
// Currently, they are used to convey the version of the rules and the ID of the rules being used.
5+
//
6+
// They should be set in the HTTP request headers when making requests to the Icinga Notifications API,
7+
// and by the Icinga Notifications daemon when processing such requests.
8+
const (
9+
XIcingaRulesVersion = "X-Icinga-Rules-Version"
10+
XIcingaRulesId = "X-Icinga-Rules-Id"
11+
)

utils/utils.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"golang.org/x/exp/utf8string"
1212
"iter"
1313
"net"
14+
"net/url"
1415
"os"
1516
"path/filepath"
1617
"slices"
@@ -186,3 +187,15 @@ func IterateOrderedMap[K cmp.Ordered, V any](m map[K]V) iter.Seq2[K, V] {
186187
}
187188
}
188189
}
190+
191+
// RawUrlEncode mimics PHP's rawurlencode to be used for parameter encoding.
192+
//
193+
// Icinga Web uses rawurldecode instead of urldecode, which, as its main difference, does not honor the plus char ('+')
194+
// as a valid substitution for space (' '). Unfortunately, Go's url.QueryEscape does this very substitution and
195+
// url.PathEscape does a bit too less and has a misleading name on top.
196+
//
197+
// - https://www.php.net/manual/en/function.rawurlencode.php
198+
// - https://github.com/php/php-src/blob/php-8.2.12/ext/standard/url.c#L538
199+
func RawUrlEncode(s string) string {
200+
return strings.ReplaceAll(url.QueryEscape(s), "+", "%20")
201+
}

0 commit comments

Comments
 (0)