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 }