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