package main import ( "encoding/json" "fmt" "os" "os/exec" "sort" "strconv" "strings" "sync" "time" ) type ProxmoxTask struct { UPID string `json:"upid"` Node string `json:"node"` Type string `json:"type"` ID string `json:"id"` User string `json:"user"` Status string `json:"status"` StartTime int64 `json:"starttime"` EndTime int64 `json:"endtime"` Duration int64 `json:"duration"` } type GuestSnapshot struct { VMID int `json:"vmid"` GuestName string `json:"guestName"` GuestType string `json:"guestType"` Name string `json:"name"` Description string `json:"description"` CreatedAt int64 `json:"createdAt"` Running bool `json:"running"` Parent string `json:"parent,omitempty"` SizeBytes *uint64 `json:"sizeBytes,omitempty"` AgeSeconds int64 `json:"ageSeconds"` } type ProxmoxActivity struct { Available bool `json:"available"` Tasks []ProxmoxTask `json:"tasks"` Snapshots []GuestSnapshot `json:"snapshots"` CheckedAt int64 `json:"checkedAt"` } type activityCollector struct { mu sync.RWMutex value ProxmoxActivity refreshing bool } func newActivityCollector() *activityCollector { c := &activityCollector{refreshing: true} go c.refresh() return c } func (c *activityCollector) collect(guests GuestsMetrics) ProxmoxActivity { c.mu.RLock() value := c.value c.mu.RUnlock() c.mu.Lock() if !c.refreshing && time.Since(time.Unix(value.CheckedAt, 0)) > 15*time.Second { c.refreshing = true go c.refreshWithGuests(guests) } c.mu.Unlock() return value } func (c *activityCollector) refresh() { c.refreshWithGuests(readGuests()) } func (c *activityCollector) refreshWithGuests(guests GuestsMetrics) { value := readProxmoxActivity(guests) c.mu.Lock() c.value = value c.refreshing = false c.mu.Unlock() } func readProxmoxActivity(guests GuestsMetrics) ProxmoxActivity { result := ProxmoxActivity{CheckedAt: time.Now().Unix()} if _, err := exec.LookPath("pvesh"); err != nil { return result } node, err := os.Hostname() if err != nil { return result } result.Available = true out := commandOutput(8*time.Second, "pvesh", "get", "/nodes/"+node+"/tasks", "--limit", "100", "--output-format", "json") if out != "" && json.Unmarshal([]byte(out), &result.Tasks) == nil { for i := range result.Tasks { if result.Tasks[i].EndTime > result.Tasks[i].StartTime { result.Tasks[i].Duration = result.Tasks[i].EndTime - result.Tasks[i].StartTime } } } for _, guest := range guests.Guests { if guest.Template != 0 { continue } kind := "qemu" label := "VM" if guest.Type != "qemu" { kind = "lxc" label = "LXC" } out := commandOutput(5*time.Second, "pvesh", "get", fmt.Sprintf("/nodes/%s/%s/%d/snapshot", guest.Node, kind, guest.VMID), "--output-format", "json") if out == "" { continue } var raw []struct { Name string `json:"name"` Description string `json:"description"` SnapTime int64 `json:"snaptime"` Running int `json:"running"` Parent string `json:"parent"` VMState any `json:"vmstate"` } if json.Unmarshal([]byte(out), &raw) != nil { continue } for _, s := range raw { if s.Name == "current" { continue } item := GuestSnapshot{VMID: guest.VMID, GuestName: guest.Name, GuestType: label, Name: s.Name, Description: s.Description, CreatedAt: s.SnapTime, Running: s.Running != 0, Parent: s.Parent} if item.CreatedAt > 0 { item.AgeSeconds = time.Now().Unix() - item.CreatedAt } if size := uintValue(s.VMState); size > 1 { item.SizeBytes = &size } result.Snapshots = append(result.Snapshots, item) } } sort.Slice(result.Snapshots, func(i, j int) bool { return result.Snapshots[i].CreatedAt > result.Snapshots[j].CreatedAt }) return result } func uintValue(v any) uint64 { switch n := v.(type) { case float64: return uint64(n) case string: value, _ := strconv.ParseUint(n, 10, 64) return value } return 0 } type BackupCheckResult struct { OK bool `json:"ok"` Level string `json:"level"` Message string `json:"message"` CheckedAt int64 `json:"checkedAt"` } func verifyBackup(volid string) BackupCheckResult { result := BackupCheckResult{CheckedAt: time.Now().Unix(), Level: "metadata"} if volid == "" || strings.ContainsAny(volid, "\r\n") { result.Message = "Некорректный идентификатор backup" return result } out, err := exec.Command("pvesm", "path", volid).CombinedOutput() if err != nil { result.Message = "Backup не найден в подключённом storage: " + strings.TrimSpace(string(out)) return result } path := strings.TrimSpace(string(out)) if strings.HasPrefix(path, "pbs://") { ctxOutput := commandOutput(20*time.Second, "pvesm", "extractconfig", volid) if ctxOutput == "" { result.Message = "Snapshot найден в PBS, но конфигурацию для тестового чтения получить не удалось" return result } result.OK = true result.Level = "pbs-metadata" result.Message = "PBS snapshot и его конфигурация успешно прочитаны. Полное восстановление VM не запускалось." return result } info, err := os.Stat(path) if err != nil { result.Message = "Метаданные доступны, но локальный файл не открыт: " + err.Error() return result } if info.Size() == 0 { result.Message = "Файл backup пуст" return result } result.OK = true result.Level = "readability" result.Message = fmt.Sprintf("Файл доступен для чтения, размер %d байт. Полное тестовое восстановление не запускалось.", info.Size()) return result }