Commit Diff


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
+}