package main import ( "context" "encoding/json" "fmt" "os/exec" "sort" "strconv" "strings" "sync" "time" ) type GuestMetrics struct { VMID int `json:"vmid"` Name string `json:"name"` Type string `json:"type"` Node string `json:"node"` Status string `json:"status"` CPU float64 `json:"cpu"` MaxCPU float64 `json:"maxcpu"` MemoryBytes uint64 `json:"mem"` MaxMemory uint64 `json:"maxmem"` Uptime uint64 `json:"uptime"` Template int `json:"template"` DiskBytes uint64 `json:"disk"` MaxDisk uint64 `json:"maxdisk"` NetInBytes uint64 `json:"netin"` NetOutBytes uint64 `json:"netout"` OnBoot bool `json:"onboot"` Agent bool `json:"agent"` IPAddresses []string `json:"ipAddresses"` Storage []string `json:"storage"` } type GuestsMetrics struct { Available bool `json:"available"` Guests []GuestMetrics `json:"guests"` } type guestCollector struct { mu sync.Mutex cached GuestsMetrics updatedAt time.Time details map[int]guestDetails detailsUpdatedAt time.Time detailsRefreshing bool } type guestDetails struct { OnBoot, Agent bool IPAddresses, Storage []string } func (c *guestCollector) collect() GuestsMetrics { c.mu.Lock() defer c.mu.Unlock() if time.Since(c.updatedAt) < 3*time.Second { return c.cached } c.cached = readGuests() if time.Since(c.detailsUpdatedAt) > time.Minute && !c.detailsRefreshing { c.detailsRefreshing = true guests := append([]GuestMetrics(nil), c.cached.Guests...) go func() { details := readGuestDetails(guests) c.mu.Lock() c.details = details c.detailsUpdatedAt = time.Now() c.detailsRefreshing = false c.mu.Unlock() }() } for i := range c.cached.Guests { if detail, ok := c.details[c.cached.Guests[i].VMID]; ok { c.cached.Guests[i].OnBoot = detail.OnBoot c.cached.Guests[i].Agent = detail.Agent c.cached.Guests[i].IPAddresses = detail.IPAddresses c.cached.Guests[i].Storage = detail.Storage } } c.updatedAt = time.Now() return c.cached } func readGuestDetails(guests []GuestMetrics) map[int]guestDetails { result := map[int]guestDetails{} for _, guest := range guests { if guest.Template != 0 { continue } kind := guest.Type if kind != "qemu" { kind = "lxc" } data := commandOutput(5*time.Second, "pvesh", "get", "/nodes/"+guest.Node+"/"+kind+"/"+strconv.Itoa(guest.VMID)+"/config", "--output-format", "json") var config map[string]any if json.Unmarshal([]byte(data), &config) != nil { continue } detail := guestDetails{} detail.OnBoot = numberValue(config["onboot"]) == 1 detail.Agent = agentEnabled(config["agent"]) storageSet := map[string]bool{} ipSet := map[string]bool{} for key, value := range config { s := fmt.Sprint(value) if key == "rootfs" || strings.HasPrefix(key, "scsi") || strings.HasPrefix(key, "sata") || strings.HasPrefix(key, "virtio") { if name := storageName(s); name != "" { storageSet[name] = true } } if strings.HasPrefix(key, "net") || strings.HasPrefix(key, "ipconfig") { for _, ip := range ipsFromConfig(s) { ipSet[ip] = true } } } if guest.Status == "running" && kind == "qemu" { agent := commandOutput(5*time.Second, "pvesh", "get", "/nodes/"+guest.Node+"/qemu/"+strconv.Itoa(guest.VMID)+"/agent/network-get-interfaces", "--output-format", "json") for _, ip := range ipsFromAgent(agent) { ipSet[ip] = true } if agent != "" { detail.Agent = true } } for value := range storageSet { detail.Storage = append(detail.Storage, value) } for value := range ipSet { detail.IPAddresses = append(detail.IPAddresses, value) } sort.Strings(detail.Storage) sort.Strings(detail.IPAddresses) result[guest.VMID] = detail } return result } func numberValue(value any) float64 { switch v := value.(type) { case float64: return v case string: n, _ := strconv.ParseFloat(v, 64) return n } return 0 } func agentEnabled(value any) bool { s := strings.ToLower(fmt.Sprint(value)) return s == "1" || strings.Contains(s, "enabled=1") } func storageName(value string) string { first := strings.SplitN(value, ",", 2)[0] parts := strings.SplitN(first, ":", 2) if len(parts) == 2 { return parts[0] } return "" } func ipsFromConfig(value string) []string { var result []string for _, part := range strings.Split(value, ",") { part = strings.TrimSpace(part) if strings.HasPrefix(part, "ip=") || strings.HasPrefix(part, "ip6=") { ip := strings.SplitN(part, "=", 2)[1] if ip != "dhcp" && ip != "auto" { result = append(result, strings.SplitN(ip, "/", 2)[0]) } } } return result } func ipsFromAgent(data string) []string { var payload struct { Result []struct { IPAddresses []struct { Address string `json:"ip-address"` Type string `json:"ip-address-type"` } `json:"ip-addresses"` } `json:"result"` } if json.Unmarshal([]byte(data), &payload) != nil { return nil } var result []string for _, iface := range payload.Result { for _, ip := range iface.IPAddresses { if ip.Address != "127.0.0.1" && ip.Address != "::1" && !strings.HasPrefix(ip.Address, "fe80:") { result = append(result, ip.Address) } } } return result } func readGuests() GuestsMetrics { if _, err := exec.LookPath("pvesh"); err != nil { return GuestsMetrics{} } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() output, err := exec.CommandContext(ctx, "pvesh", "get", "/cluster/resources", "--type", "vm", "--output-format", "json").Output() result := GuestsMetrics{Available: true} if err != nil { return result } if json.Unmarshal(output, &result.Guests) != nil { return result } sort.Slice(result.Guests, func(i, j int) bool { return result.Guests[i].VMID < result.Guests[j].VMID }) return result }