maidn-cli/internal/proxmox/client.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()
}