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

115 lines
3.7 KiB
Go

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
}