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