功能:新增云管API与Web控制台并完成现场接入
This commit is contained in:
1 parent
18a9ce995d
commit
3fb7b4da8c
79 files changed
+17367
-3
No files matched your search
@@ -0,0 +1,146 @@
|
||||
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")
|
||||
}
|
||||
pool, err := x509.SystemCertPool()
|
||||
if err != nil {
|
||||
pool = x509.NewCertPool()
|
||||
}
|
||||
if caFile != "" {
|
||||
data, err := os.ReadFile(caFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !pool.AppendCertsFromPEM(data) {
|
||||
return nil, errors.New("invalid PVE CA file")
|
||||
}
|
||||
}
|
||||
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:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package proxmox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTLSAndTaskLifecycle(t *testing.T) {
|
||||
polls := 0
|
||||
ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "PVEAPIToken=vdi@pve!console=secret" {
|
||||
t.Error("missing PVE token")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/status/start"):
|
||||
if r.Method != "POST" {
|
||||
t.Error("action should POST")
|
||||
}
|
||||
w.Write([]byte(`{"data":"UPID:node:123"}`))
|
||||
case strings.HasSuffix(r.URL.Path, "/status/current"):
|
||||
w.Write([]byte(`{"data":{"status":"running"}}`))
|
||||
case strings.Contains(r.URL.Path, "/tasks/"):
|
||||
polls++
|
||||
if polls < 2 {
|
||||
w.Write([]byte(`{"data":{"status":"running"}}`))
|
||||
} else {
|
||||
w.Write([]byte(`{"data":{"status":"stopped","exitstatus":"OK"}}`))
|
||||
}
|
||||
default:
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
c, err := New(ts.URL, "vdi@pve!console", "secret", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = c.Action(context.Background(), "node", 201, "start"); err == nil {
|
||||
t.Fatal("untrusted PVE certificate must not be accepted")
|
||||
}
|
||||
c.HTTP = ts.Client()
|
||||
upid, err := c.Action(context.Background(), "node", 201, "start")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = c.Wait(context.Background(), "node", upid, time.Millisecond); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if polls != 2 {
|
||||
t.Fatalf("expected task polling; got %d", polls)
|
||||
}
|
||||
if _, err = c.Action(context.Background(), "node", 201, "restore"); err == nil {
|
||||
t.Fatal("restore must never map to hard reset")
|
||||
}
|
||||
}
|
||||
func TestFailedPVEJobIsNotSuccess(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"data":{"status":"stopped","exitstatus":"ERROR: disk full"}}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
c := &Client{BaseURL: ts.URL, HTTP: ts.Client()}
|
||||
if err := c.Wait(context.Background(), "node", "task", time.Millisecond); err == nil {
|
||||
t.Fatal("failed PVE job reported as success")
|
||||
}
|
||||
}
|
||||
func TestRejectInsecureOrigin(t *testing.T) {
|
||||
for _, base := range []string{"http://pve:8006", "https://user:password@pve:8006", "https://pve:8006/other"} {
|
||||
if _, err := New(base, "id", "secret", ""); err == nil {
|
||||
t.Fatalf("accepted %s", base)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user