Skip to content

Commit 05e69f3

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 5515308 commit 05e69f3

File tree

4 files changed

+247
-0
lines changed

4 files changed

+247
-0
lines changed

notifications/client.go

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

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)