功能:扩展云管资源运维、账号授权及监控管理

This commit is contained in:
songyiming committed 2026-11-10 17:52:52 +08:00
1 parent eef485306f
commit 6134d49c16
87 files changed
+29498 -126

No files matched your search

+17 -7
View File
@@ -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
}
+128
View File
@@ -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
}
+78
View File
@@ -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)
}
}
+147
View File
@@ -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
}
+35
View File
@@ -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)
}
}
+123
View File
@@ -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
}
+28
View File
@@ -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)
}
}
+114
View File
@@ -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
}
+27
View File
@@ -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")
}
}