202 lines
5.1 KiB
Go
202 lines
5.1 KiB
Go
package proxmox
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Client struct {
|
|
baseURL string
|
|
tokenID string
|
|
tokenSecret string
|
|
httpClient *http.Client
|
|
}
|
|
|
|
type APIError struct {
|
|
StatusCode int
|
|
Message string
|
|
Path string
|
|
}
|
|
|
|
func (e APIError) Error() string {
|
|
if e.Message == "" {
|
|
return fmt.Sprintf("proxmox returned %d for %s", e.StatusCode, e.Path)
|
|
}
|
|
return fmt.Sprintf("proxmox returned %d for %s: %s", e.StatusCode, e.Path, e.Message)
|
|
}
|
|
|
|
func (e APIError) IsPermissionDenied() bool {
|
|
return e.StatusCode == http.StatusForbidden
|
|
}
|
|
|
|
type clusterNodesResponse struct {
|
|
Data []struct {
|
|
Node string `json:"node"`
|
|
Status string `json:"status"`
|
|
} `json:"data"`
|
|
}
|
|
|
|
type nodeStatusResponse struct {
|
|
Data struct {
|
|
CPU float64 `json:"cpu"`
|
|
Memory struct {
|
|
Used uint64 `json:"used"`
|
|
Total uint64 `json:"total"`
|
|
} `json:"memory"`
|
|
RootFS struct {
|
|
Avail uint64 `json:"avail"`
|
|
Total uint64 `json:"total"`
|
|
} `json:"rootfs"`
|
|
} `json:"data"`
|
|
}
|
|
|
|
type networkResponse struct {
|
|
Data []struct {
|
|
Iface string `json:"iface"`
|
|
Type string `json:"type"`
|
|
Active int `json:"active"`
|
|
} `json:"data"`
|
|
}
|
|
|
|
type storageResponse struct {
|
|
Data []struct {
|
|
Storage string `json:"storage"`
|
|
Type string `json:"type"`
|
|
Avail uint64 `json:"avail"`
|
|
Total uint64 `json:"total"`
|
|
} `json:"data"`
|
|
}
|
|
|
|
type NodeInfo struct {
|
|
Name string
|
|
Networks []string
|
|
Storages []StorageInfo
|
|
FreeMemoryMB int
|
|
TotalMemoryMB int
|
|
FreeDiskGB int
|
|
}
|
|
|
|
type StorageInfo struct {
|
|
Name string
|
|
Type string
|
|
FreeGB int
|
|
SupportsISO bool
|
|
SupportsDisk bool
|
|
}
|
|
|
|
func New(baseURL, tokenID, tokenSecret string, insecure bool) *Client {
|
|
transport := &http.Transport{}
|
|
if insecure {
|
|
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
|
}
|
|
return &Client{
|
|
baseURL: strings.TrimRight(baseURL, "/"),
|
|
tokenID: tokenID,
|
|
tokenSecret: tokenSecret,
|
|
httpClient: &http.Client{Timeout: 15 * time.Second, Transport: transport},
|
|
}
|
|
}
|
|
|
|
func (c *Client) Discover() ([]NodeInfo, error) {
|
|
var nodesResp clusterNodesResponse
|
|
if err := c.getJSON("/cluster/resources?type=node", &nodesResp); err != nil {
|
|
return nil, err
|
|
}
|
|
result := make([]NodeInfo, 0, len(nodesResp.Data))
|
|
for _, node := range nodesResp.Data {
|
|
if node.Node == "" || node.Status != "online" {
|
|
continue
|
|
}
|
|
info, err := c.describeNode(node.Node)
|
|
if err != nil {
|
|
var apiErr APIError
|
|
if ok := AsAPIError(err, &apiErr); ok && apiErr.IsPermissionDenied() {
|
|
continue
|
|
}
|
|
return nil, err
|
|
}
|
|
result = append(result, info)
|
|
}
|
|
if len(result) == 0 {
|
|
return nil, fmt.Errorf("no Proxmox nodes could be inspected; token likely needs Sys.Audit on target nodes")
|
|
}
|
|
sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
|
|
return result, nil
|
|
}
|
|
|
|
func (c *Client) describeNode(node string) (NodeInfo, error) {
|
|
var status nodeStatusResponse
|
|
if err := c.getJSON("/nodes/"+node+"/status", &status); err != nil {
|
|
return NodeInfo{}, err
|
|
}
|
|
var networks networkResponse
|
|
if err := c.getJSON("/nodes/"+node+"/network", &networks); err != nil {
|
|
return NodeInfo{}, err
|
|
}
|
|
var storages storageResponse
|
|
if err := c.getJSON("/nodes/"+node+"/storage", &storages); err != nil {
|
|
return NodeInfo{}, err
|
|
}
|
|
info := NodeInfo{
|
|
Name: node,
|
|
FreeMemoryMB: int((status.Data.Memory.Total - status.Data.Memory.Used) / 1024 / 1024),
|
|
TotalMemoryMB: int(status.Data.Memory.Total / 1024 / 1024),
|
|
FreeDiskGB: int(status.Data.RootFS.Avail / 1024 / 1024 / 1024),
|
|
}
|
|
for _, net := range networks.Data {
|
|
if net.Active == 1 && (net.Type == "bridge" || net.Type == "eth") {
|
|
info.Networks = append(info.Networks, net.Iface)
|
|
}
|
|
}
|
|
for _, storage := range storages.Data {
|
|
info.Storages = append(info.Storages, StorageInfo{
|
|
Name: storage.Storage,
|
|
Type: storage.Type,
|
|
FreeGB: int(storage.Avail / 1024 / 1024 / 1024),
|
|
SupportsISO: storage.Type == "dir" || storage.Type == "nfs",
|
|
SupportsDisk: storage.Type == "dir" || storage.Type == "nfs" || storage.Type == "lvm" || storage.Type == "lvmthin" || storage.Type == "zfspool" || storage.Type == "btrfs",
|
|
})
|
|
}
|
|
return info, nil
|
|
}
|
|
|
|
func (c *Client) getJSON(path string, target any) error {
|
|
request, err := http.NewRequest(http.MethodGet, c.baseURL+"/api2/json"+path, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
request.Header.Set("Authorization", fmt.Sprintf("PVEAPIToken=%s=%s", c.tokenID, c.tokenSecret))
|
|
response, err := c.httpClient.Do(request)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer response.Body.Close()
|
|
if response.StatusCode >= 300 {
|
|
body, _ := io.ReadAll(response.Body)
|
|
return APIError{StatusCode: response.StatusCode, Message: strings.TrimSpace(string(body)), Path: path}
|
|
}
|
|
return json.NewDecoder(response.Body).Decode(target)
|
|
}
|
|
|
|
func AsAPIError(err error, target *APIError) bool {
|
|
apiErr, ok := err.(APIError)
|
|
if ok {
|
|
*target = apiErr
|
|
}
|
|
return ok
|
|
}
|
|
|
|
func BuildAPIURL(host string) string {
|
|
if strings.HasPrefix(host, "http") {
|
|
return strings.TrimRight(host, "/")
|
|
}
|
|
return (&url.URL{Scheme: "https", Host: host, Path: "/api2/json"}).String()
|
|
}
|