vdi-cloud/platform/pkg/proxmox/client.go

157 lines
4.8 KiB
Go

package proxmox
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
)
type Client struct {
BaseURL, TokenID, TokenSecret string
HTTP *http.Client
}
type Resource struct {
Type string `json:"type"`
Node string `json:"node"`
Name string `json:"name"`
Status string `json:"status"`
VMID int `json:"vmid"`
CPU float64 `json:"cpu"`
Memory float64 `json:"mem"`
MaxMemory float64 `json:"maxmem"`
Disk float64 `json:"disk"`
MaxDisk float64 `json:"maxdisk"`
Template int `json:"template"`
}
type TaskStatus struct {
Status string `json:"status"`
ExitStatus string `json:"exitstatus"`
}
var ErrTaskFailed = errors.New("PVE task failed; inspect task logs in PVE")
type HTTPError struct{ Status int }
func (e *HTTPError) Error() string { return fmt.Sprintf("PVE returned HTTP %d", e.Status) }
func New(base, id, secret, caFile string) (*Client, error) {
u, err := url.Parse(base)
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
return nil, errors.New("PVE_URL must be an HTTPS origin without credentials or query")
}
if u.Path != "" && u.Path != "/" {
return nil, errors.New("PVE_URL must not include a path")
}
pem := ""
if caFile != "" {
data, err := os.ReadFile(caFile)
if err != nil {
return nil, err
}
pem = string(data)
}
return NewWithCA(base, id, secret, pem)
}
func NewWithCA(base, id, secret, pem string) (*Client, error) {
u, err := url.Parse(base)
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") {
return nil, errors.New("invalid HTTPS origin")
}
pool, err := x509.SystemCertPool()
if err != nil {
pool = x509.NewCertPool()
}
if pem != "" && !pool.AppendCertsFromPEM([]byte(pem)) {
return nil, errors.New("invalid CA certificate")
}
return &Client{BaseURL: strings.TrimRight(base, "/"), TokenID: id, TokenSecret: secret, HTTP: &http.Client{Timeout: 15 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }, Transport: &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: pool}}}}, nil
}
func (c *Client) request(ctx context.Context, method, path string, form url.Values, out any) error {
var body io.Reader
if form != nil {
body = strings.NewReader(form.Encode())
}
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+"/api2/json"+path, body)
if err != nil {
return err
}
req.Header.Set("Authorization", "PVEAPIToken="+c.TokenID+"="+c.TokenSecret)
if form != nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
res, err := c.HTTP.Do(req)
if err != nil {
return fmt.Errorf("PVE request failed: %w", err)
}
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode >= 300 {
return &HTTPError{Status: res.StatusCode}
}
var envelope struct {
Data json.RawMessage `json:"data"`
}
if err = json.NewDecoder(io.LimitReader(res.Body, 4<<20)).Decode(&envelope); err != nil {
return err
}
return json.Unmarshal(envelope.Data, out)
}
func (c *Client) Resources(ctx context.Context) ([]Resource, error) {
var list []Resource
err := c.request(ctx, "GET", "/cluster/resources", nil, &list)
return list, err
}
func (c *Client) Action(ctx context.Context, node string, vmid int, action string) (string, error) {
if action != "start" && action != "shutdown" && action != "reboot" {
return "", errors.New("unsupported VM action")
}
var upid string
err := c.request(ctx, "POST", fmt.Sprintf("/nodes/%s/qemu/%d/status/%s", url.PathEscape(node), vmid, action), url.Values{}, &upid)
if err == nil && upid == "" {
err = errors.New("PVE did not return a task ID")
}
return upid, err
}
func (c *Client) Task(ctx context.Context, node, upid string) (TaskStatus, error) {
var status TaskStatus
err := c.request(ctx, "GET", "/nodes/"+url.PathEscape(node)+"/tasks/"+url.PathEscape(upid)+"/status", nil, &status)
return status, err
}
func (c *Client) Power(ctx context.Context, node string, vmid int) (string, error) {
var status struct {
Status string `json:"status"`
}
err := c.request(ctx, "GET", fmt.Sprintf("/nodes/%s/qemu/%d/status/current", url.PathEscape(node), vmid), nil, &status)
return status.Status, err
}
func (c *Client) Wait(ctx context.Context, node, upid string, interval time.Duration) error {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
s, err := c.Task(ctx, node, upid)
if err != nil {
return err
}
if s.Status == "stopped" {
if s.ExitStatus != "OK" {
return ErrTaskFailed
}
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
}
}
}