115 lines
3.7 KiB
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
|
|
}
|