功能:扩展云管资源运维、账号授权及监控管理
This commit is contained in:
1 parent
eef485306f
commit
6134d49c16
87 files changed
+29498
-126
No files matched your search
@@ -51,18 +51,28 @@ func New(base, id, secret, caFile string) (*Client, error) {
|
||||
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()
|
||||
}
|
||||
pem := ""
|
||||
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")
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package proxmox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type PCIDevice struct {
|
||||
ID string `json:"id"`
|
||||
Class string `json:"class"`
|
||||
Vendor string `json:"vendor"`
|
||||
VendorName string `json:"vendor_name"`
|
||||
Device string `json:"device"`
|
||||
DeviceName string `json:"device_name"`
|
||||
IOMMUGroup *int `json:"iommugroup"`
|
||||
}
|
||||
|
||||
type PCIMapping struct {
|
||||
ID string `json:"id"`
|
||||
Map []string `json:"map"`
|
||||
}
|
||||
|
||||
type PendingOption struct {
|
||||
Key string `json:"key"`
|
||||
Value json.RawMessage `json:"value"`
|
||||
Pending json.RawMessage `json:"pending"`
|
||||
Delete json.RawMessage `json:"delete"`
|
||||
}
|
||||
|
||||
func (c *Client) PCIDevices(ctx context.Context, node string) ([]PCIDevice, error) {
|
||||
var items []PCIDevice
|
||||
err := c.request(ctx, "GET", "/nodes/"+url.PathEscape(node)+"/hardware/pci?verbose=1", nil, &items)
|
||||
return items, err
|
||||
}
|
||||
|
||||
func (c *Client) PCIMappings(ctx context.Context) ([]PCIMapping, error) {
|
||||
var items []PCIMapping
|
||||
err := c.request(ctx, "GET", "/cluster/mapping/pci", nil, &items)
|
||||
return items, err
|
||||
}
|
||||
|
||||
func (c *Client) VMPending(ctx context.Context, node string, vmid int) ([]PendingOption, error) {
|
||||
var items []PendingOption
|
||||
err := c.request(ctx, "GET", fmt.Sprintf("/nodes/%s/qemu/%d/pending", url.PathEscape(node), vmid), nil, &items)
|
||||
return items, err
|
||||
}
|
||||
|
||||
var pciAddressPattern = regexp.MustCompile(`^(?:[0-9a-f]{4}:)?[0-9a-f]{2}:[0-1][0-9a-f](?:\.[0-7])?$`)
|
||||
var hostPCIKeyPattern = regexp.MustCompile(`^hostpci[0-9]+$`)
|
||||
|
||||
// A functionless address selects every function in that PCI slot.
|
||||
func NormalizePCIAddress(value string) (string, bool) {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if !pciAddressPattern.MatchString(value) {
|
||||
return "", false
|
||||
}
|
||||
if strings.Count(value, ":") == 1 {
|
||||
value = "0000:" + value
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func PCIAddressMatches(selector, address string) bool {
|
||||
selector, ok := NormalizePCIAddress(selector)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
address, ok = NormalizePCIAddress(address)
|
||||
return ok && (selector == address || !strings.Contains(selector, ".") && strings.HasPrefix(address, selector+"."))
|
||||
}
|
||||
|
||||
func (d PCIDevice) IsGPU() bool {
|
||||
class := strings.TrimPrefix(strings.ToLower(d.Class), "0x")
|
||||
return len(class) == 6 && strings.HasPrefix(class, "03")
|
||||
}
|
||||
|
||||
type HostPCI struct {
|
||||
Addresses []string
|
||||
Mapping string
|
||||
Mediated bool
|
||||
}
|
||||
|
||||
func IsHostPCIKey(key string) bool { return hostPCIKeyPattern.MatchString(key) }
|
||||
|
||||
func PCIProperties(value string) (map[string]string, error) {
|
||||
properties := map[string]string{}
|
||||
for i, part := range strings.Split(value, ",") {
|
||||
key, val, found := strings.Cut(strings.TrimSpace(part), "=")
|
||||
if !found && i == 0 {
|
||||
key, val = "host", key
|
||||
} else if !found {
|
||||
return nil, errors.New("invalid PCI property")
|
||||
}
|
||||
if _, duplicate := properties[key]; key == "" || val == "" || duplicate {
|
||||
return nil, errors.New("invalid PCI property")
|
||||
}
|
||||
properties[key] = val
|
||||
}
|
||||
return properties, nil
|
||||
}
|
||||
|
||||
func ParseHostPCI(value string) (HostPCI, error) {
|
||||
var out HostPCI
|
||||
properties, err := PCIProperties(value)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.Mapping = properties["mapping"]
|
||||
out.Mediated = properties["mdev"] != ""
|
||||
if (properties["host"] == "") == (out.Mapping == "") {
|
||||
return out, errors.New("expected a host or mapping")
|
||||
}
|
||||
if properties["host"] != "" {
|
||||
for _, address := range strings.Split(properties["host"], ";") {
|
||||
normalized, ok := NormalizePCIAddress(address)
|
||||
if !ok {
|
||||
return out, errors.New("invalid PCI address")
|
||||
}
|
||||
out.Addresses = append(out.Addresses, normalized)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package proxmox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPCIAddressAndHostPCIParsing(t *testing.T) {
|
||||
for _, value := range []string{"01:00.0", "0000:01:00.0", " 0000:01:00.0 "} {
|
||||
got, ok := NormalizePCIAddress(value)
|
||||
if !ok || got != "0000:01:00.0" {
|
||||
t.Fatalf("normalization %q: %q %v", value, got, ok)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"01:20.0", "01:00.8", "../../config", "01:00.0,pcie=1", "000000:01:00.0"} {
|
||||
if _, ok := NormalizePCIAddress(value); ok {
|
||||
t.Fatalf("accepted invalid address %q", value)
|
||||
}
|
||||
}
|
||||
if !PCIAddressMatches("01:00", "0000:01:00.1") || PCIAddressMatches("01:00.0", "0000:01:00.1") || PCIAddressMatches("0001:01:00", "0000:01:00.0") {
|
||||
t.Fatal("function/domain matching incorrect")
|
||||
}
|
||||
parsed, err := ParseHostPCI("host=01:00;0001:02:00.0,pcie=1,x-vga=1")
|
||||
if err != nil || len(parsed.Addresses) != 2 || parsed.Addresses[0] != "0000:01:00" {
|
||||
t.Fatalf("parse multi-function PCI: %+v %v", parsed, err)
|
||||
}
|
||||
parsed, err = ParseHostPCI("mapping=gpu-pool,mdev=nvidia-1")
|
||||
if err != nil || parsed.Mapping != "gpu-pool" || !parsed.Mediated {
|
||||
t.Fatalf("parse mediated mapping: %+v %v", parsed, err)
|
||||
}
|
||||
for _, value := range []string{"", "mapping=x,host=01:00", "host=01:00,host=02:00", "host=invalid", "mapping=", "pcie=1"} {
|
||||
if _, err := ParseHostPCI(value); err == nil {
|
||||
t.Fatalf("accepted invalid hostpci %q", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGPUReadOnlyPVEContract(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
t.Errorf("unexpected write: %s", r.Method)
|
||||
}
|
||||
var data any
|
||||
switch r.URL.Path {
|
||||
case "/api2/json/nodes/node-a/hardware/pci":
|
||||
if r.URL.Query().Get("verbose") != "1" {
|
||||
t.Error("missing verbose PCI properties")
|
||||
}
|
||||
data = []map[string]any{{"id": "0000:00:02.0", "class": "0x030000", "iommugroup": 0}, {"id": "0000:01:00.1", "class": "0x040300", "iommugroup": 2}}
|
||||
case "/api2/json/nodes/node-a/qemu/8000/pending":
|
||||
data = []map[string]any{{"key": "hostpci0", "value": "01:00.0", "pending": "02:00.0", "delete": 1}}
|
||||
case "/api2/json/cluster/mapping/pci":
|
||||
data = []map[string]any{{"id": "gpu-map", "map": []string{"node=node-a,path=0000:01:00.0,id=10de:1234"}}}
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"data": data})
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := &Client{BaseURL: srv.URL, HTTP: srv.Client()}
|
||||
devices, err := c.PCIDevices(context.Background(), "node-a")
|
||||
if err != nil || len(devices) != 2 || !devices[0].IsGPU() || devices[1].IsGPU() || devices[0].IOMMUGroup == nil || *devices[0].IOMMUGroup != 0 {
|
||||
t.Fatalf("PCI decode: %+v %v", devices, err)
|
||||
}
|
||||
pending, err := c.VMPending(context.Background(), "node-a", 8000)
|
||||
if err != nil || len(pending) != 1 || string(pending[0].Delete) != "1" {
|
||||
t.Fatalf("pending decode: %+v %v", pending, err)
|
||||
}
|
||||
mappings, err := c.PCIMappings(context.Background())
|
||||
if err != nil || len(mappings) != 1 || len(mappings[0].Map) != 1 {
|
||||
t.Fatalf("mapping decode: %+v %v", mappings, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package proxmox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type VMDisk struct {
|
||||
Key string `json:"key"`
|
||||
Storage string `json:"storage"`
|
||||
SizeGiB float64 `json:"sizeGiB"`
|
||||
}
|
||||
type VMHardware struct {
|
||||
Cores int `json:"cores"`
|
||||
Sockets int `json:"sockets"`
|
||||
MemoryMiB int `json:"memoryMiB"`
|
||||
Digest string `json:"digest"`
|
||||
Disks []VMDisk `json:"disks"`
|
||||
PowerState string `json:"powerState"`
|
||||
Blockers []string `json:"blockers"`
|
||||
Balloon int `json:"-"`
|
||||
}
|
||||
|
||||
func ConfigInt(config map[string]any, key string, fallback int) int {
|
||||
value, ok := config[key]
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
n, err := strconv.Atoi(fmt.Sprint(value))
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
func ReadHardware(config map[string]any) (VMHardware, error) {
|
||||
out := VMHardware{Cores: ConfigInt(config, "cores", 1), Sockets: ConfigInt(config, "sockets", 1), MemoryMiB: ConfigInt(config, "memory", 512), Balloon: ConfigInt(config, "balloon", 0), Disks: []VMDisk{}, Blockers: []string{}}
|
||||
out.Digest, _ = config["digest"].(string)
|
||||
if out.Digest == "" {
|
||||
return out, errors.New("配置摘要缺失")
|
||||
}
|
||||
if ConfigInt(config, "template", 0) == 1 {
|
||||
out.Blockers = append(out.Blockers, "模板不能在桌面规格页修改")
|
||||
}
|
||||
if lock := config["lock"]; lock != nil && fmt.Sprint(lock) != "" {
|
||||
out.Blockers = append(out.Blockers, "虚拟机处于 PVE 锁定状态")
|
||||
}
|
||||
for key, value := range config {
|
||||
if strings.HasPrefix(key, "numa") && key != "numa" {
|
||||
out.Blockers = append(out.Blockers, "自定义 NUMA 拓扑请在 PVE 维护")
|
||||
}
|
||||
if !regexp.MustCompile(`^(scsi|sata|virtio|ide)\d+$`).MatchString(key) {
|
||||
continue
|
||||
}
|
||||
text := fmt.Sprint(value)
|
||||
if strings.Contains(text, "media=cdrom") || strings.Contains(text, "cloudinit") {
|
||||
continue
|
||||
}
|
||||
volume := strings.Split(text, ",")[0]
|
||||
storage, _, ok := strings.Cut(volume, ":")
|
||||
if !ok || storage == "" {
|
||||
continue
|
||||
}
|
||||
for _, part := range strings.Split(text, ",") {
|
||||
if !strings.HasPrefix(part, "size=") {
|
||||
continue
|
||||
}
|
||||
size := strings.TrimPrefix(part, "size=")
|
||||
if len(size) == 0 {
|
||||
continue
|
||||
}
|
||||
factor := float64(1)
|
||||
if i := strings.IndexByte("KMGT", size[len(size)-1]); i >= 0 {
|
||||
factor = math.Pow(1024, float64(i+1))
|
||||
size = size[:len(size)-1]
|
||||
}
|
||||
n, e := strconv.ParseFloat(size, 64)
|
||||
if e == nil && n > 0 && !math.IsInf(n, 0) && !math.IsNaN(n) {
|
||||
out.Disks = append(out.Disks, VMDisk{key, storage, n * factor / (1 << 30)})
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(out.Disks, func(i, j int) bool { return out.Disks[i].Key < out.Disks[j].Key })
|
||||
return out, nil
|
||||
}
|
||||
func (c *Client) Hardware(ctx context.Context, node string, vmid int) (VMHardware, map[string]any, error) {
|
||||
var config map[string]any
|
||||
path := fmt.Sprintf("/nodes/%s/qemu/%d", url.PathEscape(node), vmid)
|
||||
err := c.request(ctx, "GET", path+"/config?current=1", nil, &config)
|
||||
if err != nil {
|
||||
return VMHardware{}, nil, err
|
||||
}
|
||||
hardware, err := ReadHardware(config)
|
||||
if err != nil {
|
||||
return hardware, config, err
|
||||
}
|
||||
hardware.PowerState, err = c.Power(ctx, node, vmid)
|
||||
if err != nil {
|
||||
return hardware, config, err
|
||||
}
|
||||
var pending []map[string]any
|
||||
err = c.request(ctx, "GET", path+"/pending", nil, &pending)
|
||||
if err != nil {
|
||||
return hardware, config, err
|
||||
}
|
||||
for _, item := range pending {
|
||||
if _, ok := item["pending"]; ok || ConfigInt(item, "delete", 0) > 0 {
|
||||
hardware.Blockers = append(hardware.Blockers, "存在待应用配置,请先在 PVE 处理")
|
||||
break
|
||||
}
|
||||
}
|
||||
return hardware, config, nil
|
||||
}
|
||||
func (c *Client) ConfigureHardware(ctx context.Context, node string, vmid, cores, sockets, memory int, digest string, current map[string]any) (string, error) {
|
||||
form := url.Values{"digest": {digest}}
|
||||
cpuChanged := cores != ConfigInt(current, "cores", 1) || sockets != ConfigInt(current, "sockets", 1)
|
||||
if cpuChanged {
|
||||
form.Set("cores", strconv.Itoa(cores))
|
||||
form.Set("sockets", strconv.Itoa(sockets))
|
||||
}
|
||||
if memory != ConfigInt(current, "memory", 512) {
|
||||
form.Set("memory", strconv.Itoa(memory))
|
||||
}
|
||||
if _, ok := current["vcpus"]; ok && cpuChanged {
|
||||
form.Set("vcpus", strconv.Itoa(cores*sockets))
|
||||
}
|
||||
var upid string
|
||||
err := c.request(ctx, "POST", fmt.Sprintf("/nodes/%s/qemu/%d/config", url.PathEscape(node), vmid), form, &upid)
|
||||
if err == nil && upid == "" {
|
||||
err = errors.New("PVE did not return a configuration task ID")
|
||||
}
|
||||
return upid, err
|
||||
}
|
||||
func (c *Client) ResizeDisk(ctx context.Context, node string, vmid int, disk string, sizeGiB int, digest string) (string, error) {
|
||||
var upid string
|
||||
err := c.request(ctx, "PUT", fmt.Sprintf("/nodes/%s/qemu/%d/resize", url.PathEscape(node), vmid), url.Values{"disk": {disk}, "size": {strconv.Itoa(sizeGiB) + "G"}, "digest": {digest}}, &upid)
|
||||
if err == nil && upid == "" {
|
||||
err = errors.New("PVE did not return a resize task ID")
|
||||
}
|
||||
return upid, err
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package proxmox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHardwareDisksExcludeDevicesAndDetectUnsafeTopology(t *testing.T) {
|
||||
h, err := ReadHardware(map[string]any{"digest": "abc", "cores": 4, "memory": 4096, "scsi0": "local:vm-1,size=60G", "virtio1": "fast:vm-1,size=1T", "sata0": "/dev/disk/by-id/physical,size=60G", "ide2": "local:iso/boot.iso,media=cdrom,size=2G", "ide0": "local:cloudinit,size=4M", "efidisk0": "local:efi,size=4M", "tpmstate0": "local:tpm,size=4M", "numa0": "cpus=0-3,hostnodes=0", "lock": "backup"})
|
||||
if err != nil || len(h.Disks) != 2 || h.Disks[0].SizeGiB != 60 || h.Disks[1].SizeGiB != 1024 || len(h.Blockers) != 2 {
|
||||
t.Fatal(h, err)
|
||||
}
|
||||
if _, err = ReadHardware(map[string]any{}); err == nil {
|
||||
t.Fatal("missing digest accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHardwareMemoryOnlyDoesNotRequireCPUPermissions(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
if r.Method != "POST" || len(r.Form) != 2 || r.Form.Get("memory") != "4096" || r.Form.Get("digest") != "original" {
|
||||
t.Error("unexpected mutation", r.Method, r.Form)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"data": "UPID:memory-only"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := Client{BaseURL: srv.URL, HTTP: srv.Client()}
|
||||
_, err := c.ConfigureHardware(context.Background(), "node", 1, 2, 1, 4096, "original", map[string]any{"cores": 2, "sockets": 1, "memory": 2048, "vcpus": 2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package proxmox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Storage struct {
|
||||
Name string `json:"storage"`
|
||||
Available float64 `json:"avail"`
|
||||
Active int `json:"active"`
|
||||
Enabled int `json:"enabled"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
func (c *Client) VMConfig(ctx context.Context, node string, vmid int) (map[string]any, error) {
|
||||
var config map[string]any
|
||||
err := c.request(ctx, "GET", fmt.Sprintf("/nodes/%s/qemu/%d/config", url.PathEscape(node), vmid), nil, &config)
|
||||
return config, err
|
||||
}
|
||||
func (c *Client) Storages(ctx context.Context, node string) ([]Storage, error) {
|
||||
var items []Storage
|
||||
err := c.request(ctx, "GET", "/nodes/"+url.PathEscape(node)+"/storage", nil, &items)
|
||||
return items, err
|
||||
}
|
||||
func (c *Client) HasPermission(ctx context.Context, path, permission string) (bool, error) {
|
||||
var permissions map[string]map[string]int
|
||||
err := c.request(ctx, "GET", "/access/permissions?path="+url.QueryEscape(path), nil, &permissions)
|
||||
return permissions[path][permission] == 1, err
|
||||
}
|
||||
func (c *Client) CanClone(ctx context.Context, node string, vmid int, full bool) (bool, error) {
|
||||
feature := "clone"
|
||||
if full {
|
||||
feature = "copy"
|
||||
}
|
||||
var data struct {
|
||||
HasFeature json.RawMessage `json:"hasFeature"`
|
||||
}
|
||||
err := c.request(ctx, "GET", fmt.Sprintf("/nodes/%s/qemu/%d/feature?feature=%s", url.PathEscape(node), vmid, feature), nil, &data)
|
||||
return string(data.HasFeature) == "1" || string(data.HasFeature) == "true", err
|
||||
}
|
||||
func (c *Client) Clone(ctx context.Context, node string, source, target int, full bool, storage string) (string, error) {
|
||||
form := url.Values{"newid": {strconv.Itoa(target)}, "name": {fmt.Sprintf("vdi-%d", target)}, "full": {"0"}}
|
||||
if full {
|
||||
form.Set("full", "1")
|
||||
if storage != "" {
|
||||
form.Set("storage", storage)
|
||||
}
|
||||
}
|
||||
var upid string
|
||||
err := c.request(ctx, "POST", fmt.Sprintf("/nodes/%s/qemu/%d/clone", url.PathEscape(node), source), form, &upid)
|
||||
if err == nil && upid == "" {
|
||||
err = errors.New("PVE did not return a clone task ID")
|
||||
}
|
||||
return upid, err
|
||||
}
|
||||
|
||||
// TemplateDisks rejects passthrough and unmeasurable disks before a clone is queued.
|
||||
// It returns per-storage virtual disk sizes for a conservative capacity check.
|
||||
func TemplateDisks(config map[string]any) (map[string]float64, error) {
|
||||
if fmt.Sprint(config["template"]) != "1" {
|
||||
return nil, errors.New("所选 VM 不是 Proxmox 模板")
|
||||
}
|
||||
if lock, ok := config["lock"]; ok && fmt.Sprint(lock) != "" {
|
||||
return nil, errors.New("模板处于锁定状态,请等待 PVE 操作结束")
|
||||
}
|
||||
disks := map[string]float64{}
|
||||
pattern := regexp.MustCompile(`^((scsi|sata|ide|virtio)\d+|efidisk0|tpmstate0)$`)
|
||||
for key, value := range config {
|
||||
if strings.HasPrefix(key, "hostpci") || strings.HasPrefix(key, "usb") || key == "args" || key == "hookscript" {
|
||||
return nil, errors.New("模板包含直通设备或宿主自定义脚本,请先制作通用模板")
|
||||
}
|
||||
if !pattern.MatchString(key) {
|
||||
continue
|
||||
}
|
||||
disk := fmt.Sprint(value)
|
||||
if strings.Contains(disk, "media=cdrom") || strings.Contains(disk, "cloudinit") {
|
||||
continue
|
||||
}
|
||||
volume := strings.Split(disk, ",")[0]
|
||||
storage, _, ok := strings.Cut(volume, ":")
|
||||
if !ok || storage == "" {
|
||||
return nil, errors.New("模板包含非存储卷磁盘,不能自动克隆")
|
||||
}
|
||||
size := float64(0)
|
||||
for _, part := range strings.Split(disk, ",") {
|
||||
if !strings.HasPrefix(part, "size=") {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimPrefix(part, "size=")
|
||||
if len(text) == 0 {
|
||||
continue
|
||||
}
|
||||
multiplier := float64(1)
|
||||
if index := strings.IndexByte("KMGT", text[len(text)-1]); index >= 0 {
|
||||
for n := 0; n <= index; n++ {
|
||||
multiplier *= 1024
|
||||
}
|
||||
text = text[:len(text)-1]
|
||||
}
|
||||
amount, err := strconv.ParseFloat(text, 64)
|
||||
if err != nil || amount <= 0 || math.IsNaN(amount) || math.IsInf(amount, 0) {
|
||||
return nil, errors.New("无法识别模板磁盘容量")
|
||||
}
|
||||
size = amount * multiplier
|
||||
}
|
||||
if size <= 0 {
|
||||
return nil, errors.New("模板磁盘缺少容量信息")
|
||||
}
|
||||
disks[storage] += size
|
||||
}
|
||||
if len(disks) == 0 {
|
||||
return nil, errors.New("模板没有可克隆的系统磁盘")
|
||||
}
|
||||
return disks, nil
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package proxmox
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTemplateDisksRejectUnsafeSources(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
config map[string]any
|
||||
}{
|
||||
{"ordinary-vm", map[string]any{"template": 0, "scsi0": "local:disk,size=32G"}},
|
||||
{"locked", map[string]any{"template": 1, "lock": "clone", "scsi0": "local:disk,size=32G"}},
|
||||
{"gpu", map[string]any{"template": 1, "hostpci0": "0000:01:00.0", "scsi0": "local:disk,size=32G"}},
|
||||
{"host-disk", map[string]any{"template": 1, "scsi0": "/dev/sda,size=32G"}},
|
||||
{"unknown-size", map[string]any{"template": 1, "scsi0": "local:disk"}},
|
||||
{"nan-size", map[string]any{"template": 1, "scsi0": "local:disk,size=NaNG"}},
|
||||
{"inf-size", map[string]any{"template": 1, "scsi0": "local:disk,size=+InfG"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := TemplateDisks(tc.config); err == nil {
|
||||
t.Fatal("unsafe template accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
disks, err := TemplateDisks(map[string]any{"template": 1, "scsi0": "local:disk,size=32G", "efidisk0": "local:efi,size=4M", "tpmstate0": "local:tpm,size=4M", "ide2": "none,media=cdrom"})
|
||||
if err != nil || disks["local"] != 32*(1<<30)+8*(1<<20) {
|
||||
t.Fatalf("incorrect capacity including firmware disks: %v %v", disks, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package proxmox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Snapshot struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Time int64 `json:"snaptime"`
|
||||
Parent string `json:"parent"`
|
||||
Memory int `json:"vmstate"`
|
||||
State string `json:"snapstate"`
|
||||
}
|
||||
|
||||
func vmPath(node string, vmid int) string {
|
||||
return fmt.Sprintf("/nodes/%s/qemu/%d", url.PathEscape(node), vmid)
|
||||
}
|
||||
func (c *Client) Snapshots(ctx context.Context, node string, vmid int) ([]Snapshot, error) {
|
||||
var items []Snapshot
|
||||
err := c.request(ctx, "GET", vmPath(node, vmid)+"/snapshot", nil, &items)
|
||||
out := []Snapshot{}
|
||||
for _, s := range items {
|
||||
if s.Name != "current" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Time == out[j].Time {
|
||||
return out[i].Name < out[j].Name
|
||||
}
|
||||
return out[i].Time > out[j].Time
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
func (c *Client) SnapshotConfig(ctx context.Context, node string, vmid int, name string) (map[string]any, error) {
|
||||
var out map[string]any
|
||||
err := c.request(ctx, "GET", vmPath(node, vmid)+"/snapshot/"+url.PathEscape(name)+"/config", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
func (c *Client) CanSnapshot(ctx context.Context, node string, vmid int) (bool, error) {
|
||||
var out struct {
|
||||
HasFeature json.RawMessage `json:"hasFeature"`
|
||||
}
|
||||
err := c.request(ctx, "GET", vmPath(node, vmid)+"/feature?feature=snapshot", nil, &out)
|
||||
return string(out.HasFeature) == "1" || string(out.HasFeature) == "true", err
|
||||
}
|
||||
func (c *Client) SnapshotAction(ctx context.Context, node string, vmid int, action, name, description string) (string, error) {
|
||||
path := vmPath(node, vmid) + "/snapshot"
|
||||
method := "POST"
|
||||
form := url.Values{}
|
||||
switch action {
|
||||
case "create":
|
||||
form.Set("snapname", name)
|
||||
form.Set("description", description)
|
||||
form.Set("vmstate", "0")
|
||||
case "delete":
|
||||
path += "/" + url.PathEscape(name)
|
||||
method = "DELETE"
|
||||
case "rollback":
|
||||
path += "/" + url.PathEscape(name) + "/rollback"
|
||||
form.Set("start", "0")
|
||||
default:
|
||||
return "", errors.New("unsupported snapshot action")
|
||||
}
|
||||
var upid string
|
||||
err := c.request(ctx, method, path, form, &upid)
|
||||
if err == nil && upid == "" {
|
||||
err = errors.New("PVE did not return a snapshot task ID")
|
||||
}
|
||||
return upid, err
|
||||
}
|
||||
|
||||
// Limit automatic rollback to ordinary virtual disks. No memory, external device
|
||||
// state, custom host scripts or omitted/shared disks can be restored safely here.
|
||||
var snapshotDiskPattern = regexp.MustCompile(`^((scsi|sata|virtio|ide)[0-9]+|efidisk0|tpmstate0)$`)
|
||||
|
||||
func SnapshotDisks(config map[string]any) (map[string]string, error) {
|
||||
out := map[string]string{}
|
||||
for key, value := range config {
|
||||
if strings.HasPrefix(key, "hostpci") || strings.HasPrefix(key, "usb") || strings.HasPrefix(key, "unused") || key == "args" || key == "hookscript" {
|
||||
return nil, errors.New("包含直通设备、未使用磁盘或宿主自定义配置,请在 PVE 人工管理快照")
|
||||
}
|
||||
if !snapshotDiskPattern.MatchString(key) {
|
||||
continue
|
||||
}
|
||||
disk := fmt.Sprint(value)
|
||||
if strings.Contains(disk, "media=cdrom") || strings.Contains(disk, "cloudinit") {
|
||||
continue
|
||||
}
|
||||
volume := strings.Split(disk, ",")[0]
|
||||
storage, _, ok := strings.Cut(volume, ":")
|
||||
if !ok || storage == "" || strings.ContainsAny(storage, "/\\") {
|
||||
return nil, errors.New("包含物理磁盘或无法识别的卷,请在 PVE 人工管理快照")
|
||||
}
|
||||
for _, option := range strings.Split(disk, ",")[1:] {
|
||||
if option == "snapshot=0" || option == "shared=1" {
|
||||
return nil, errors.New("存在不参与快照或共享的磁盘,不能保证完整还原")
|
||||
}
|
||||
}
|
||||
out[key] = disk
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, errors.New("没有可管理快照的虚拟磁盘")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package proxmox
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSnapshotDiskScope(t *testing.T) {
|
||||
base := map[string]any{"scsi0": "local:vm-9000,size=4G", "efidisk0": "local:vm-9000-efi,size=4M", "tpmstate0": "local:vm-9000-tpm,size=4M", "ide2": "local:iso/setup.iso,media=cdrom", "ide0": "local:vm-9000-cloudinit,media=cdrom"}
|
||||
disks, err := SnapshotDisks(base)
|
||||
if err != nil || len(disks) != 3 {
|
||||
t.Fatal(disks, err)
|
||||
}
|
||||
for _, key := range []string{"hostpci0", "usb0", "unused0", "args", "hookscript"} {
|
||||
base[key] = "test"
|
||||
if _, err = SnapshotDisks(base); err == nil {
|
||||
t.Fatal("accepted external state", key)
|
||||
}
|
||||
delete(base, key)
|
||||
}
|
||||
for _, disk := range []string{"/dev/disk/by-id/data,size=4G", "local:vm-9000,size=4G,snapshot=0", "local:vm-9000,size=4G,shared=1"} {
|
||||
base["scsi0"] = disk
|
||||
if _, err = SnapshotDisks(base); err == nil {
|
||||
t.Fatal("accepted excluded disk", disk)
|
||||
}
|
||||
}
|
||||
if _, err = SnapshotDisks(map[string]any{"ide2": "none,media=cdrom"}); err == nil {
|
||||
t.Fatal("accepted no disks")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user