Add infrastructure reliability monitoring

This commit is contained in:
Maxim
2026-08-01 16:58:25 +03:00
parent f6ac7eeb1a
commit a570537e64
12 changed files with 926 additions and 51 deletions

226
reliability.go Normal file
View File

@@ -0,0 +1,226 @@
package main
import (
"context"
"fmt"
"log"
"net"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"syscall"
"time"
)
func notifySystemd(message string) bool {
address := os.Getenv("NOTIFY_SOCKET")
if address == "" {
return false
}
if strings.HasPrefix(address, "@") {
address = "\x00" + address[1:]
}
connection, err := net.DialUnix("unixgram", nil, &net.UnixAddr{Name: address, Net: "unixgram"})
if err != nil {
return false
}
defer connection.Close()
_, err = connection.Write([]byte(message))
return err == nil
}
func runSystemdWatchdog() {
if !notifySystemd("READY=1\nSTATUS=Dashboard collects Proxmox metrics") {
return
}
go func() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for range ticker.C {
notifySystemd("WATCHDOG=1")
}
}()
}
type MonitorHealth struct {
LastSuccessfulCollection time.Time `json:"lastSuccessfulCollection"`
LastCollectionError string `json:"lastCollectionError"`
CollectionDurationMs int64 `json:"collectionDurationMs"`
SlowCollector bool `json:"slowCollector"`
LastHistoryWrite time.Time `json:"lastHistoryWrite"`
HistoryWriteError string `json:"historyWriteError"`
DatabaseFreeBytes uint64 `json:"databaseFreeBytes"`
DatabasePath string `json:"databasePath"`
WatchdogEnabled bool `json:"watchdogEnabled"`
}
type monitorTracker struct {
mu sync.RWMutex
lastSuccess time.Time
lastError string
duration time.Duration
}
func (m *monitorTracker) record(start time.Time, err error) {
m.mu.Lock()
defer m.mu.Unlock()
m.duration = time.Since(start)
if err != nil {
m.lastError = err.Error()
return
}
m.lastSuccess = time.Now()
m.lastError = ""
}
func (m *monitorTracker) snapshot(store *Store) MonitorHealth {
m.mu.RLock()
result := MonitorHealth{LastSuccessfulCollection: m.lastSuccess, LastCollectionError: m.lastError, CollectionDurationMs: m.duration.Milliseconds(), SlowCollector: m.duration > 10*time.Second, DatabasePath: store.path, WatchdogEnabled: os.Getenv("NOTIFY_SOCKET") != ""}
m.mu.RUnlock()
result.LastHistoryWrite, result.HistoryWriteError = store.historyHealth()
path := store.path
if path == ":memory:" {
path = "/tmp"
} else {
path = filepath.Dir(path)
}
var stat syscall.Statfs_t
if syscall.Statfs(path, &stat) == nil {
result.DatabaseFreeBytes = stat.Bavail * uint64(stat.Bsize)
}
return result
}
type UPSShutdownSettings struct {
Enabled bool `json:"enabled"`
ChargePercent float64 `json:"chargePercent"`
RuntimeSeconds float64 `json:"runtimeSeconds"`
GraceSeconds int `json:"graceSeconds"`
GuestOrder []int `json:"guestOrder"`
}
func defaultUPSShutdownSettings() UPSShutdownSettings {
return UPSShutdownSettings{ChargePercent: 15, RuntimeSeconds: 600, GraceSeconds: 120}
}
func (s *Store) UPSShutdownSettings() UPSShutdownSettings {
value := defaultUPSShutdownSettings()
s.loadJSONSetting("ups_shutdown", &value)
return value
}
func (s *Store) SaveUPSShutdownSettings(value UPSShutdownSettings) error {
if value.ChargePercent < 1 || value.ChargePercent > 80 || value.RuntimeSeconds < 60 || value.RuntimeSeconds > 7200 || value.GraceSeconds < 30 || value.GraceSeconds > 1800 {
return fmt.Errorf("проверьте пороги UPS и задержку")
}
return s.saveJSONSetting("ups_shutdown", value)
}
type ShutdownStep struct {
Order int `json:"order"`
Target string `json:"target"`
Action string `json:"action"`
Detail string `json:"detail"`
}
func buildShutdownDryRun(settings UPSShutdownSettings, guests GuestsMetrics) []ShutdownStep {
byID := map[int]GuestMetrics{}
for _, g := range guests.Guests {
if g.Template == 0 && g.Status == "running" {
byID[g.VMID] = g
}
}
var ordered []GuestMetrics
for _, id := range settings.GuestOrder {
if g, ok := byID[id]; ok {
ordered = append(ordered, g)
delete(byID, id)
}
}
var rest []GuestMetrics
for _, g := range byID {
rest = append(rest, g)
}
sort.Slice(rest, func(i, j int) bool { return rest[i].VMID < rest[j].VMID })
ordered = append(ordered, rest...)
steps := []ShutdownStep{{Order: 1, Target: "UPS", Action: "Ожидание порога", Detail: fmt.Sprintf("заряд ≤ %.0f%% или автономность ≤ %.0f сек.", settings.ChargePercent, settings.RuntimeSeconds)}}
for _, g := range ordered {
kind := "LXC"
if g.Type == "qemu" {
kind = "VM"
}
steps = append(steps, ShutdownStep{Order: len(steps) + 1, Target: fmt.Sprintf("%s %d · %s", kind, g.VMID, g.Name), Action: "Корректное завершение", Detail: fmt.Sprintf("ожидание до %d сек.", settings.GraceSeconds)})
}
steps = append(steps, ShutdownStep{Order: len(steps) + 1, Target: "Proxmox host", Action: "Завершение работы", Detail: "только после остановки VM/LXC"})
return steps
}
func runUPSShutdownController(store *Store, ups *upsCollector, guests *guestCollector) {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
triggered := false
for range ticker.C {
settings := store.UPSShutdownSettings()
metrics := ups.collect()
onBattery := strings.Contains(metrics.Status, "OB") || metrics.Status == "ONBATT"
if !settings.Enabled || !metrics.Available || !onBattery {
if metrics.Available && !onBattery {
triggered = false
}
continue
}
lowCharge := metrics.ChargePercent != nil && *metrics.ChargePercent <= settings.ChargePercent
lowRuntime := metrics.RuntimeSeconds != nil && *metrics.RuntimeSeconds <= settings.RuntimeSeconds
if triggered || (!lowCharge && !lowRuntime) {
continue
}
triggered = true
log.Printf("UPS достиг порога безопасного выключения; запускаем остановку гостей")
go executeUPSShutdown(settings, guests.collect())
}
}
func executeUPSShutdown(settings UPSShutdownSettings, guests GuestsMetrics) {
steps := buildShutdownDryRun(settings, guests)
_ = steps
byID := map[int]GuestMetrics{}
for _, g := range guests.Guests {
if g.Template == 0 && g.Status == "running" {
byID[g.VMID] = g
}
}
var order []int
for _, id := range settings.GuestOrder {
if _, ok := byID[id]; ok {
order = append(order, id)
delete(byID, id)
}
}
var remaining []int
for id := range byID {
remaining = append(remaining, id)
}
sort.Ints(remaining)
order = append(order, remaining...)
for _, id := range order {
g := GuestMetrics{}
for _, candidate := range guests.Guests {
if candidate.VMID == id {
g = candidate
break
}
}
command := "pct"
if g.Type == "qemu" {
command = "qm"
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(settings.GraceSeconds+30)*time.Second)
err := exec.CommandContext(ctx, command, "shutdown", fmt.Sprint(id), "--timeout", fmt.Sprint(settings.GraceSeconds)).Run()
cancel()
if err != nil {
log.Printf("UPS shutdown: не удалось остановить VMID %d: %v", id, err)
}
}
log.Printf("UPS shutdown: гости обработаны, выключаем Proxmox host")
_ = exec.Command("shutdown", "-h", "now").Run()
}