|
| 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 | +} |
0 commit comments