commit 76669f9b1f06b7299f2261667b4b21db38db7cae from: Oliver Lowe date: Tue Jan 11 05:46:22 2022 UTC I learned how to do some generic programming New type "object" is an interface to represent an Icinga2 object. I added heaps of other stuff! On our way to 1000 lines of code! commit - 514f2cd100380e2d124fa33c56f4e9a9308b907e commit + 76669f9b1f06b7299f2261667b4b21db38db7cae blob - bdf22d2e28488f143a1bac4f1483ab50641bc261 blob + 6a3c6313b97f01ce8acaec5a60fe3dda545aaa67 --- host.go +++ host.go @@ -1,36 +1,55 @@ package icinga import ( - "bytes" "encoding/json" - "errors" "fmt" - "net/http" ) // Host represents a Host object. type Host struct { - Name string `json:"name"` - Address string `json:"address"` - Address6 string `json:"address6"` - Groups []string `json:"groups"` - State int `json:"state"` - CheckCommand string `json:"check_command"` - DisplayName string `json:"display_name"` + Name string `json:"name"` + Address string `json:"address"` + Address6 string `json:"address6"` + Groups []string `json:"groups"` + State HostState `json:"state"` + CheckCommand string `json:"check_command"` + DisplayName string `json:"display_name"` } -type hostresults struct { - Results []hostresult `json:"results"` - results +type HostState int + +const ( + HostUp HostState = 0 + iota + HostDown + HostUnreachable +) + +func (s HostState) String() string { + switch s { + case HostUp: + return "HostUp" + case HostDown: + return "HostDown" + case HostUnreachable: + return "HostUnreachable" + } + return "unhandled host state" } -type hostresult struct { - Host Host `json:"attrs"` - result +func (h Host) name() string { + return h.Name } -var ErrNoHost = errors.New("no such host") +func (h Host) path() string { + return "/objects/hosts/" + h.Name +} +func (h Host) attrs() map[string]interface{} { + m := make(map[string]interface{}) + m["display_name"] = h.DisplayName + return m +} + func (h Host) MarshalJSON() ([]byte, error) { type Attrs struct { Address string `json:"address"` @@ -52,55 +71,67 @@ func (h Host) MarshalJSON() ([]byte, error) { // Hosts returns all Hosts in the Icinga2 configuration. func (c *Client) Hosts() ([]Host, error) { - resp, err := c.get("/objects/hosts") + objects, err := c.allObjects("/objects/hosts") if err != nil { - return nil, err + return nil, fmt.Errorf("get all hosts: %w", err) } - defer resp.Body.Close() - var res hostresults - if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { - return nil, err + var hosts []Host + for _, o := range objects { + v, ok := o.(Host) + if !ok { + return nil, fmt.Errorf("get all hosts: %T in response", v) + } + hosts = append(hosts, v) } - if res.Err() != nil { - return nil, res.Err() + return hosts, nil +} + +// FilterHosts returns any matching hosts after applying the filter +// expression expr. If no hosts match, an empty slice and an error wrapping +// ErrNoMatch is returned. +func (c *Client) FilterHosts(expr string) ([]Host, error) { + objects, err := c.filterObjects("/objects/hosts", expr) + if err != nil { + return nil, fmt.Errorf("filter hosts %q: %w", expr, err) } var hosts []Host - for _, r := range res.Results { - hosts = append(hosts, r.Host) + for _, o := range objects { + v, ok := o.(Host) + if !ok { + return nil, fmt.Errorf("filter hosts %q: %T in response", expr, v) + } + hosts = append(hosts, v) } return hosts, nil } -// LookupHost returns the Host identified by name. -// If no Host is found, error wraps ErrNoHost. +// LookupHost returns the Host identified by name. If no Host is found, +// error wraps ErrNotExist. func (c *Client) LookupHost(name string) (Host, error) { - resp, err := c.get("/objects/hosts/" + name) + obj, err := c.lookupObject("/objects/hosts/" + name) if err != nil { - return Host{}, err + return Host{}, fmt.Errorf("lookup %s: %w", name, err) } - if resp.StatusCode == http.StatusNotFound { - return Host{}, fmt.Errorf("lookup %s: %w", name, ErrNoHost) + v, ok := obj.(Host) + if !ok { + return Host{}, fmt.Errorf("lookup %s: result type %T is not host", name, obj) } - return Host{}, err + return v, nil } // CreateHost creates the Host host. // The Name and CheckCommand fields of host must be non-zero. func (c *Client) CreateHost(host Host) error { - buf := &bytes.Buffer{} - if err := json.NewEncoder(buf).Encode(host); err != nil { - return err - } - if err := c.put("/objects/hosts/"+host.Name, buf); err != nil { + if err := c.createObject(host); err != nil { return fmt.Errorf("create host %s: %w", host.Name, err) } return nil } // DeleteHost deletes the Host identified by name. -// If no Host is found, error wraps ErrNoObject. +// If no Host is found, error wraps ErrNotExist. func (c *Client) DeleteHost(name string) error { - if err := c.delete("/objects/hosts/" + name); err != nil { + if err := c.deleteObject("/objects/hosts/" + name); err != nil { return fmt.Errorf("delete host %s: %w", name, err) } return nil blob - 3b165dbbfba9ae07b6bf6d4342b515dcc892d2ba blob + 3707d01283233e4cd393c9b40e7c60481a44f64a --- http.go +++ http.go @@ -1,30 +1,14 @@ package icinga import ( - "encoding/json" - "errors" "fmt" "io" "net/http" - "strings" + "net/url" ) const versionPrefix = "/v1" -type results struct { - Results []result -} - -type result struct { - Attrs map[string]interface{} - Code int - Errors []string - Name string - Type string -} - -var ErrNoObject = errors.New("no such object") - // NewRequest returns an authenticated HTTP request with appropriate header // for sending to an Icinga2 server. func NewRequest(method, url, username, password string, body io.Reader) (*http.Request, error) { @@ -33,9 +17,7 @@ func NewRequest(method, url, username, password string return nil, err } switch req.Method { - case http.MethodGet: - break - case http.MethodDelete: + case http.MethodGet, http.MethodDelete: req.Header.Set("Accept", "application/json") case http.MethodPost, http.MethodPut: req.Header.Set("Accept", "application/json") @@ -47,23 +29,6 @@ func NewRequest(method, url, username, password string return req, nil } -func (res results) Err() error { - if len(res.Results) == 0 { - return nil - } - var errs []string - for _, r := range res.Results { - if len(r.Errors) == 0 { - continue - } - errs = append(errs, strings.Join(r.Errors, ", ")) - } - if len(errs) == 0 { - return nil - } - return errors.New(strings.Join(errs, ", ")) -} - func (c *Client) get(path string) (*http.Response, error) { url := "https://" + c.addr + versionPrefix + path req, err := NewRequest(http.MethodGet, url, c.username, c.password, nil) @@ -73,6 +38,21 @@ func (c *Client) get(path string) (*http.Response, err return c.Do(req) } +func (c *Client) getFilter(path, filter string) (*http.Response, error) { + u, err := url.Parse("https://" + c.addr + versionPrefix + path) + if err != nil { + return nil, err + } + v := url.Values{} + v.Set("filter", filter) + u.RawQuery = v.Encode() + req, err := NewRequest(http.MethodGet, u.String(), c.username, c.password, nil) + if err != nil { + return nil, err + } + return c.Do(req) +} + func (c *Client) post(path string, body io.Reader) (*http.Response, error) { url := "https://" + c.addr + versionPrefix + path req, err := NewRequest(http.MethodPost, url, c.username, c.password, body) @@ -82,46 +62,20 @@ func (c *Client) post(path string, body io.Reader) (*h return c.Do(req) } -func (c *Client) put(path string, body io.Reader) error { +func (c *Client) put(path string, body io.Reader) (*http.Response, error) { url := "https://" + c.addr + versionPrefix + path - req, err := NewRequest(http.MethodPost, url, c.username, c.password, body) + req, err := NewRequest(http.MethodPut, url, c.username, c.password, body) if err != nil { - return err + return nil, err } - resp, err := c.Do(req) - if err != nil { - return err - } - if resp.StatusCode == http.StatusOK { - return nil - } - defer resp.Body.Close() - var results results - if err := json.NewDecoder(resp.Body).Decode(&results); err != nil { - return fmt.Errorf("decode response: %w", err) - } - return results.Err() + return c.Do(req) } -func (c *Client) delete(path string) error { +func (c *Client) delete(path string) (*http.Response, error) { url := "https://" + c.addr + versionPrefix + path - req, err := NewRequest(http.MethodPost, url, c.username, c.password, body) + req, err := NewRequest(http.MethodDelete, url, c.username, c.password, nil) if err != nil { - return err + return nil, err } - resp, err := c.Do(req) - if err != nil { - return err - } - if resp.StatusCode == http.StatusOK { - return nil - } else if resp.StatusCode == http.StatusNotFound { - return ErrNoObject - } - defer resp.Body.Close() - var results results - if err := json.NewDecoder(resp.Body).Decode(&results); err != nil { - return fmt.Errorf("decode response: %w", err) - } - return results.Err() + return c.Do(req) } blob - 2ccea26cf1583b0a427e2c2edda3753be3558ac8 blob + 48e63b7551f4a6a4cc152c7f1718598485dade49 --- icinga.go +++ icinga.go @@ -24,25 +24,10 @@ package icinga import ( - "fmt" + "errors" "net/http" ) -type State int - -const ( - StateOK State = 0 + iota - StateWarning - StateCritical - StateUnknown -) - -const ( - HostUp State = 0 + iota - HostDown - HostUnknown -) - // A Client represents a client connection to the Icinga2 HTTP API. // It should be created using Dial. // Since Client wraps http.Client, standard methods such as Get and @@ -55,25 +40,29 @@ type Client struct { *http.Client } +var ErrNotExist = errors.New("object does not exist") +var ErrExist = errors.New("object already exists") +var ErrNoMatch = errors.New("no object matches filter") + // Dial returns a new Client connected to the Icinga2 server at addr. // The recommended value for client is http.DefaultClient. // But it may also be a modified client which, for example, // skips TLS certificate verification. func Dial(addr, username, password string, client *http.Client) (*Client, error) { c := &Client{addr, username, password, client} - if _, err := c.Status(); err != nil { + if _, err := c.Permissions(); err != nil { return nil, err } return c, nil } -func (c *Client) Status() (*http.Response, error) { - resp, err := c.get("/status") +func (c *Client) Permissions() (response, error) { + resp, err := c.get("") if err != nil { - return nil, err + return response{}, err } - if resp.StatusCode != http.StatusOK { - return resp, fmt.Errorf("status %s", resp.Status) + if resp.StatusCode == http.StatusOK { + return response{}, nil } - return resp, nil + return response{}, errors.New(resp.Status) } blob - 37e0a850e9dca14d633665a1a9b36fe32429d3cc blob + 3a95a2a727cb7f5b20f7c920e4a44f4a2ba5fc19 --- user.go +++ user.go @@ -1,11 +1,8 @@ package icinga import ( - "bytes" "encoding/json" - "errors" "fmt" - "net/http" ) // User represents a User object. @@ -21,51 +18,77 @@ var testUser = User{ Email: "test@example.com", } -var ErrNoUser = errors.New("no such user") - func (u User) MarshalJSON() ([]byte, error) { - type Alias User + type attrs struct { + Email string `json:"email"` + Groups []string `json:"groups,omitempty"` + } return json.Marshal(&struct { - Attrs Alias - }{Attrs: (Alias)(u)}) + Attrs attrs `json:"attrs"` + }{ + Attrs: attrs{ + Email: u.Email, + Groups: u.Groups, + }, + }) } +func (u User) name() string { + return u.Name +} + +func (u User) path() string { + return "/objects/users/" + u.Name +} + +func (u User) attrs() map[string]interface{} { + m := make(map[string]interface{}) + m["groups"] = u.Groups + m["email"] = u.Email + return m +} + func (c *Client) Users() ([]User, error) { - _, err := c.get("/objects/users") + objects, err := c.allObjects("/objects/users") if err != nil { - return nil, err + return nil, fmt.Errorf("get all users: %w", err) } - return []User{testUser}, nil + var users []User + for _, o := range objects { + v, ok := o.(User) + if !ok { + return nil, fmt.Errorf("get all users: %T in response", v) + } + users = append(users, v) + } + return users, nil } func (c *Client) LookupUser(name string) (User, error) { - resp, err := c.get("/objects/users/" + name) + obj, err := c.lookupObject("/objects/users/" + name) if err != nil { - return User{}, err + return User{}, fmt.Errorf("lookup %s: %w", name, err) } - if resp.StatusCode == http.StatusNotFound { - return User{}, fmt.Errorf("lookup %s: %w", name, ErrNoUser) + v, ok := obj.(User) + if !ok { + return User{}, fmt.Errorf("lookup %s: result type %T is not user", name, v) } - return testUser, nil + return v, nil } // CreateUser creates user. // An error is returned if the User already exists or on any other error. func (c *Client) CreateUser(user User) error { - buf := &bytes.Buffer{} - if err := json.NewEncoder(buf).Encode(user); err != nil { - return err + if err := c.createObject(user); err != nil { + return fmt.Errorf("create user %s: %w", user.Name, err) } - if err := c.put("/objects/users/"+user.Name, buf); err != nil { - return fmt.Errorf("create %s: %w", user.Name, err) - } return nil } // DeleteUser deletes the User identified by name. -// ErrNoUser is returned if the User doesn't exist. +// ErrNotExist is returned if the User doesn't exist. func (c *Client) DeleteUser(name string) error { - if err := c.delete("/objects/users/" + name); err != nil { + if err := c.deleteObject("/objects/users/" + name); err != nil { return fmt.Errorf("delete user %s: %w", name, err) } return nil blob - /dev/null blob + 4eee2ed00738ee81a70d73a95f2702910311639b (mode 644) --- /dev/null +++ object.go @@ -0,0 +1,127 @@ +package icinga + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" +) + +type object interface { + name() string + attrs() map[string]interface{} + path() string +} + +func (c *Client) lookupObject(objpath string) (object, error) { + resp, err := c.get(objpath) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return nil, ErrNotExist + } + iresp, err := parseResponse(resp.Body) + if err != nil { + return nil, fmt.Errorf("parse response: %v", err) + } else if iresp.Error != nil { + return nil, iresp.Error + } else if resp.StatusCode != http.StatusOK { + return nil, errors.New(resp.Status) + } + return objectFromLookup(iresp) +} + +func (c *Client) allObjects(objpath string) ([]object, error) { + resp, err := c.get(objpath) + if err != nil { + return nil, err + } + defer resp.Body.Close() + iresp, err := parseResponse(resp.Body) + if err != nil { + return nil, err + } else if iresp.Error != nil { + return nil, iresp.Error + } else if resp.StatusCode != http.StatusOK { + return nil, errors.New(resp.Status) + } + return iresp.Results, nil +} + +func (c *Client) filterObjects(objpath, expr string) ([]object, error) { + resp, err := c.getFilter(objpath, expr) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return nil, ErrNoMatch + } + iresp, err := parseResponse(resp.Body) + if err != nil { + return nil, err + } else if iresp.Error != nil { + return nil, iresp.Error + } else if resp.StatusCode != http.StatusOK { + return nil, errors.New(resp.Status) + } + return iresp.Results, nil +} + +func (c *Client) createObject(obj object) error { + buf := &bytes.Buffer{} + switch v := obj.(type) { + case Host: + if err := json.NewEncoder(buf).Encode(v); err != nil { + return err + } + case Service: + if err := json.NewEncoder(buf).Encode(v); err != nil { + return err + } + case User: + if err := json.NewEncoder(buf).Encode(v); err != nil { + return err + } + default: + return fmt.Errorf("create type %T unsupported", v) + } + resp, err := c.put(obj.path(), buf) + if err != nil { + return err + } + if resp.StatusCode == http.StatusOK { + return nil + } + defer resp.Body.Close() + iresp, err := parseResponse(resp.Body) + if err != nil { + return fmt.Errorf("parse response: %v", err) + } + if strings.Contains(iresp.Error.Error(), "already exists") { + return ErrExist + } + return iresp.Error +} + +func (c *Client) deleteObject(objpath string) error { + resp, err := c.delete(objpath) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return nil + } else if resp.StatusCode == http.StatusNotFound { + return ErrNotExist + } + iresp, err := parseResponse(resp.Body) + if err != nil { + return fmt.Errorf("parse response: %v", err) + } + return iresp.Error +} blob - /dev/null blob + faa8e3f3aaff33d56a12411ce4bb55b2269ac130 (mode 644) --- /dev/null +++ response.go @@ -0,0 +1,88 @@ +package icinga + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "strings" +) + +type apiResponse struct { + Results []struct { + Name string + Type string + Errors []string + Attrs json.RawMessage + } + Status string +} + +type response struct { + Results []object + Error error +} + +func parseAPIResponse(r io.Reader) (apiResponse, error) { + var apiresp apiResponse + if err := json.NewDecoder(r).Decode(&apiresp); err != nil { + return apiResponse{}, err + } + return apiresp, nil +} + +func parseResponse(r io.Reader) (*response, error) { + apiresp, err := parseAPIResponse(r) + if err != nil { + return nil, err + } + // Confusingly the top-level status field in an API response contains + // an error message. Successful statuses are actually held in the + // status field in Results! + if apiresp.Status != "" { + return &response{Error: errors.New(apiresp.Status)}, nil + } + resp := &response{} + for _, r := range apiresp.Results { + if len(r.Errors) > 0 { + resp.Error = errors.New(strings.Join(r.Errors, ", ")) + // got an error so nothing left in the API response + break + } + if r.Type == "" { + continue // + } + switch r.Type { + case "Host": + var h Host + if err := json.Unmarshal(r.Attrs, &h); err != nil { + return nil, err + } + resp.Results = append(resp.Results, h) + case "Service": + var s Service + if err := json.Unmarshal(r.Attrs, &s); err != nil { + return nil, err + } + resp.Results = append(resp.Results, s) + case "User": + var u User + if err := json.Unmarshal(r.Attrs, &u); err != nil { + return nil, err + } + resp.Results = append(resp.Results, u) + default: + return nil, fmt.Errorf("unsupported unmarshal of type %s", r.Type) + } + } + return resp, nil +} + +func objectFromLookup(resp *response) (object, error) { + if len(resp.Results) == 0 { + return nil, errors.New("empty results") + } else if len(resp.Results) > 1 { + return nil, errors.New("too many results") + } + return resp.Results[0], nil +} blob - /dev/null blob + 8d8a80f0fe3ffcf01b3a2054aaf32e0b6a8dccbf (mode 644) --- /dev/null +++ service.go @@ -0,0 +1,84 @@ +package icinga + +import ( + "encoding/json" + "fmt" +) + +func (s Service) name() string { + return s.Name +} + +func (s Service) path() string { + return "/objects/services/" + s.Name +} + +func (s Service) attrs() map[string]interface{} { + m := make(map[string]interface{}) + m["display_name"] = s.DisplayName + return m +} + +// Service represents a Service object. +type Service struct { + Name string `json:"__name"` + Groups []string + State ServiceState + CheckCommand string `json:"check_command"` + DisplayName string `json:"display_name:"` +} + +type ServiceState int + +const ( + ServiceOK ServiceState = 0 + iota + ServiceWarning + ServiceCritical + ServiceUnknown +) + +func (s ServiceState) String() string { + switch s { + case ServiceOK: + return "ServiceOK" + case ServiceWarning: + return "ServiceWarning" + case ServiceCritical: + return "ServiceCritical" + case ServiceUnknown: + return "ServiceUnknown" + } + return "unhandled service state" +} + +func (s Service) MarshalJSON() ([]byte, error) { + attrs := make(map[string]interface{}) + if len(s.Groups) > 0 { + attrs["groups"] = s.Groups + } + attrs["check_command"] = s.CheckCommand + attrs["display_name"] = s.DisplayName + jservice := &struct { + Attrs map[string]interface{} `json:"attrs"` + }{Attrs: attrs} + return json.Marshal(jservice) +} + +func (c *Client) CreateService(service Service) error { + if err := c.createObject(service); err != nil { + return fmt.Errorf("create service %s: %w", service.Name, err) + } + return nil +} + +func (c *Client) LookupService(name string) (Service, error) { + obj, err := c.lookupObject("/objects/services/" + name) + if err != nil { + return Service{}, fmt.Errorf("lookup %s: %w", name, err) + } + v, ok := obj.(Service) + if !ok { + return Service{}, fmt.Errorf("lookup %s: result type %T is not service", name, obj) + } + return v, nil +}