190 lines
5.7 KiB
Go
190 lines
5.7 KiB
Go
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
|
||
}
|