diff --git a/go.mod b/go.mod index 035828c..aac7005 100755 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/starttoaster/proxmox-exporter go 1.21 require ( + github.com/google/go-querystring v1.1.0 github.com/gorilla/mux v1.8.1 github.com/luthermonson/go-proxmox v0.0.0-beta3 github.com/patrickmn/go-cache v2.1.0+incompatible diff --git a/go.sum b/go.sum index 2113bd0..a879f6f 100755 --- a/go.sum +++ b/go.sum @@ -17,8 +17,11 @@ github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nos github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= @@ -99,6 +102,7 @@ golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/prometheus/prometheus.go b/internal/prometheus/prometheus.go index c22e5e7..a5dfe93 100755 --- a/internal/prometheus/prometheus.go +++ b/internal/prometheus/prometheus.go @@ -1,30 +1,72 @@ package prometheus import ( + "github.com/luthermonson/go-proxmox" "github.com/prometheus/client_golang/prometheus" + "github.com/starttoaster/proxmox-exporter/internal/logger" + wrappedProxmox "github.com/starttoaster/proxmox-exporter/internal/proxmox" ) // Collector contains all prometheus metric Descs type Collector struct { - nodeUp *prometheus.Desc + up *prometheus.Desc } // NewCollector constructor function for Collector func NewCollector() *Collector { return &Collector{ - nodeUp: prometheus.NewDesc(fqAddPrefix("node_up"), - "Shows whether nodes in a proxmox cluster are up.", - []string{}, nil, + up: prometheus.NewDesc(fqAddPrefix("up"), + "Shows whether nodes and vms in a proxmox cluster are up. (0=down,1=up)", + []string{"type", "name"}, + nil, ), } } // Describe contains all the prometheus descriptors for this metric collector func (c *Collector) Describe(ch chan<- *prometheus.Desc) { - ch <- c.nodeUp + ch <- c.up } // Collect instructs the prometheus client how to collect the metrics for each descriptor func (c *Collector) Collect(ch chan<- prometheus.Metric) { - ch <- prometheus.MustNewConstMetric(c.nodeUp, prometheus.GaugeValue, 1.0) + // Retrieve node statuses for the cluster + nodeStatuses, err := wrappedProxmox.Nodes() + if err != nil { + logger.Logger.Error(err.Error()) + return + } + + // Retrieve node info for each node from statuses + var nodes []*proxmox.Node + for _, nodeStatus := range nodeStatuses { + // Add node up metric + ch <- prometheus.MustNewConstMetric(c.up, prometheus.GaugeValue, float64(nodeStatus.Online), "node", nodeStatus.Name) + + // Get node from node status's name + node, err := wrappedProxmox.Node(nodeStatus.Name) + if err != nil { + logger.Logger.Error(err.Error()) + return + } + nodes = append(nodes, node) + } + + for _, node := range nodes { + // Get VMs for node + vms, err := wrappedProxmox.VirtualMachinesOnNode(node) + if err != nil { + logger.Logger.Error(err.Error()) + return + } + + for _, vm := range vms { + // Add vm up metric + var vmUp float64 = 0.0 + if vm.IsRunning() { + vmUp = 1.0 + } + ch <- prometheus.MustNewConstMetric(c.up, prometheus.GaugeValue, vmUp, "qemu", vm.Name) + } + } } diff --git a/internal/proxmox/vms.go b/internal/proxmox/vms.go index 7b99359..5c38d29 100644 --- a/internal/proxmox/vms.go +++ b/internal/proxmox/vms.go @@ -56,3 +56,28 @@ func VirtualMachinesAllNodes() (proxmox.VirtualMachines, error) { return vms, nil } + +// VirtualMachinesOnNode returns the virtual machines for a node +func VirtualMachinesOnNode(node *proxmox.Node) (proxmox.VirtualMachines, error) { + // Chech cache + var vms proxmox.VirtualMachines + if x, found := cash.Get(fmt.Sprintf("VirtualMachinesOnNode_%s", node.Name)); found { + var ok bool + vms, ok = x.(proxmox.VirtualMachines) + if ok { + log.Logger.Debug("proxmox request was found in cache for VirtualMachinesOnNode", "node", node.Name) + return vms, nil + } + } + + // Get VMs on the node for this iteration + vms, err := node.VirtualMachines(context.Background()) + if err != nil { + return nil, fmt.Errorf("encountered error making request to /nodes/%s/qemu: \n%v", node.Name, err) + } + + // Update per-node cache since we have it + cash.Set(fmt.Sprintf("VirtualMachinesOnNode_%s", node.Name), vms, cache.DefaultExpiration) + + return vms, nil +} diff --git a/pkg/proxmox/client.go b/pkg/proxmox/client.go new file mode 100644 index 0000000..b8f4c16 --- /dev/null +++ b/pkg/proxmox/client.go @@ -0,0 +1,110 @@ +package proxmox + +import ( + "fmt" + "net/http" + "net/url" + "strings" +) + +const ( + defaultBaseURL = "https://localhost:8006/" + apiPath = "api2/json/" +) + +// Client for the Proxmox API +type Client struct { + // HTTP retryable client for the API + client *http.Client + + // Base URL for API requests. Defaults to https://localhost:8006/, + // but can be changed to any remote endpoint. + baseURL *url.URL + + // tokenID is the identifier given for a Proxmox API token + tokenID string + + // token is the token secret + token string + + // Services for each resource in the Proxmox API + Nodes *NodeService +} + +// NewClient returns a new Proxmox API client +func NewClient(tokenID string, token string, options ...ClientOptionFunc) (*Client, error) { + if token == "" || tokenID == "" { + return nil, fmt.Errorf("can not create Proxmox API client without a token ID and token") + } + + c := &Client{ + tokenID: tokenID, + token: token, + } + + // Set the client default fields + c.setBaseURL(defaultBaseURL) + c.setHttpClient(&http.Client{}) + + // Apply any given options + for _, fn := range options { + if fn == nil { + continue + } + if err := fn(c); err != nil { + return nil, err + } + } + + // Create all the Proxmox API services + c.Nodes = &NodeService{client: c} + + return c, nil +} + +// ClientOptionFunc can be used to customize a new Proxmox API client +type ClientOptionFunc func(*Client) error + +// WithBaseURL sets the URL for API requests to something other than localhost. +// API path is applied automatically if unspecified. +// Default: "https://localhost:8006/" +func WithBaseURL(urlStr string) ClientOptionFunc { + return func(c *Client) error { + return c.setBaseURL(urlStr) + } +} + +// setBaseURL sets the URL for API requests +func (c *Client) setBaseURL(urlStr string) error { + // Make sure the given URL end with a slash + if !strings.HasSuffix(urlStr, "/") { + urlStr += "/" + } + + baseURL, err := url.Parse(urlStr) + if err != nil { + return err + } + + if !strings.HasSuffix(baseURL.Path, apiPath) { + baseURL.Path += apiPath + } + + // Update the base URL of the client + c.baseURL = baseURL + + return nil +} + +// WithHttpClient sets the HTTP client for API requests to something other than the default Go http Client +func WithHttpClient(client *http.Client) ClientOptionFunc { + return func(c *Client) error { + return c.setHttpClient(client) + } +} + +// setHttpClient sets the HTTP client for API requests +func (c *Client) setHttpClient(client *http.Client) error { + c.client = client + return nil +} diff --git a/pkg/proxmox/nodes.go b/pkg/proxmox/nodes.go new file mode 100644 index 0000000..3ccc070 --- /dev/null +++ b/pkg/proxmox/nodes.go @@ -0,0 +1,47 @@ +package proxmox + +import ( + "net/http" +) + +// NodeService is the service that encapsulates node API methods +type NodeService struct { + client *Client +} + +// Nodes is a list of Node types +type Nodes []Node + +// Node contains data attributes for a node in the Proxmox nodes API +type Node struct { + CPU float64 `json:"cpu"` + Disk int64 `json:"disk"` + ID string `json:"id"` + Level string `json:"level"` + Maxcpu int `json:"maxcpu"` + Maxdisk int64 `json:"maxdisk"` + Maxmem int64 `json:"maxmem"` + Mem int64 `json:"mem"` + Node string `json:"node"` + SslFingerprint string `json:"ssl_fingerprint"` + Status string `json:"status"` + Type string `json:"type"` + Uptime int `json:"uptime"` +} + +// Get makes a GET request to the /nodes endpoint +func (s *NodeService) Get() (*Nodes, *http.Response, error) { + u := "nodes" + req, err := s.client.NewRequest(http.MethodGet, u, nil) + if err != nil { + return nil, nil, err + } + + d := new(Nodes) + resp, err := s.client.Do(req, &d) + if err != nil { + return nil, resp, err + } + + return d, resp, nil +} diff --git a/pkg/proxmox/request.go b/pkg/proxmox/request.go new file mode 100644 index 0000000..5a3bdee --- /dev/null +++ b/pkg/proxmox/request.go @@ -0,0 +1,86 @@ +package proxmox + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + + "github.com/google/go-querystring/query" +) + +// NewRequest creates a new request. +// Method should be a valid http request method. +// Path should be an API path relative to the client's base URL. +// Path should not have a preceding '/' +// If specified, the value pointed to by opt is encoded into the query string of the URL. +func (c *Client) NewRequest(method, path string, opt interface{}) (*http.Request, error) { + u := *c.baseURL + unescaped, err := url.PathUnescape(path) + if err != nil { + return nil, err + } + + // Set the encoded path data + u.RawPath = c.baseURL.Path + path + u.Path = c.baseURL.Path + unescaped + + // Set query parameters if any are provided + if opt != nil { + q, err := query.Values(opt) + if err != nil { + return nil, err + } + u.RawQuery = q.Encode() + } + + // Create request + req, err := http.NewRequest(method, u.String(), nil) + if err != nil { + return nil, err + } + + // Set request header if making a POST or PUT + if req.Method == http.MethodPost || req.Method == http.MethodPut { + req.Header.Set("Content-Type", "x-www-form-urlencoded") + } + + return req, nil +} + +// Do sends an API request. The response is stored in the value 'v' or returned as an error. +// If v implements the io.Writer interface, the raw response body will be written to v, without json decoding it. +func (c *Client) Do(req *http.Request, v interface{}) (*http.Response, error) { + // Set auth header according to https://pve.proxmox.com/wiki/Proxmox_VE_API#Authentication + req.Header.Set("Authorization", fmt.Sprintf("PVEAPIToken=%s=%s", c.tokenID, c.token)) + + // Do request + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + defer io.Copy(io.Discard, resp.Body) + + // Check for error API response and capture it as an error + if resp.StatusCode > 399 { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading Proxmox response body: %v", err) + } + + return resp, fmt.Errorf(string(body)) + } + + // Copy body into v + if v != nil { + if w, ok := v.(io.Writer); ok { + _, err = io.Copy(w, resp.Body) + } else { + err = json.NewDecoder(resp.Body).Decode(v) + } + } + + return resp, err +}