b527430ec7
The guest-level backup layer + the journaled self-restore-test (restore→boot→verify→ teardown) that closes "a backup you haven't restored isn't a backup". All benign (reuses the slice-4 classifier/gate/journal; no new destructive class/crypto). Local target only; PBS = Phase B. Restore to a NEW guest only. Backups crash-consistent. - proxmox: DestroyLXC, VzdumpOptions.Notes (notes-template), LatestBackupVolID. - reconcile: Engine.RunRestoreTest (journal Scratch entry BEFORE mutation; net link-down pre-boot; defer teardown always; benign gated destroy) + Recover extended to reap a leaked scratch guest (Scratch flag, special-cased before the UPID path; idempotent). - internal/backup: runner (vzdump + archive resolve + bulk-gap = backup!=1) + cadence scheduler (4th daemon goroutine, default 24h) + in-memory report store. - hub: Backup/RestoreTest filled; collector seams; cross-repo golden byte-identical + bidirectional key-set tests; hub handler logs a FAILED restore-test prominently. - config BackupConfig (band 990000-990009 default); --selftest=backup / restore-test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
219 lines
7.5 KiB
Go
219 lines
7.5 KiB
Go
package hub
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
|
|
)
|
|
|
|
// proxmoxReader is the read-only subset the collector needs. Signatures match the
|
|
// REAL internal/proxmox.Client surface (slice 1): the node is held by the Client
|
|
// (no per-call node arg), reads return values (not pointers), and the guest type is
|
|
// proxmox.Guest. (The task's sketch used node-arg/pointer/LXC shapes; adapted to
|
|
// the actual exports per its instruction — no proxmox changes were needed: ListLXC
|
|
// already carries status/maxmem/maxdisk, GuestConfig carries cores.)
|
|
type proxmoxReader interface {
|
|
Node() string
|
|
NodeStatus(ctx context.Context) (proxmox.NodeStatus, error)
|
|
ListLXC(ctx context.Context) ([]proxmox.Guest, error)
|
|
GuestConfig(ctx context.Context, vmid int) (proxmox.GuestConfig, error)
|
|
}
|
|
|
|
// StorageObserver is the seam the storage layer (internal/storage) plugs into to fill the
|
|
// report's storage_targets. Defined here (consumer-side) so hub does NOT import storage —
|
|
// storage imports hub for the wire type, and main.go wires the concrete observer in. Same
|
|
// pattern as proxmoxReader / CloudflaredProber. A nil observer (slice-3 behaviour, or a
|
|
// host with no storage layer) yields an empty []StorageTarget without error.
|
|
type StorageObserver interface {
|
|
Observe(ctx context.Context) ([]StorageTarget, error)
|
|
}
|
|
|
|
// BackupReporter / RestoreTestReporter are the slice-6 seams the backup layer plugs into
|
|
// (same consumer-side pattern as StorageObserver — hub does not import the backup package).
|
|
// They return the agent's LATEST-known backup-per-target / restore-test result (point-in-time
|
|
// state the backup layer accumulates), not a live scan. A nil reporter → empty slice.
|
|
type BackupReporter interface {
|
|
Backups(ctx context.Context) []Backup
|
|
}
|
|
type RestoreTestReporter interface {
|
|
RestoreTests(ctx context.Context) []RestoreTest
|
|
}
|
|
|
|
// Collector builds a HostReport from read-only sources. All deps are behind narrow
|
|
// interfaces for unit testing.
|
|
type Collector struct {
|
|
px proxmoxReader
|
|
cf CloudflaredProber
|
|
storage StorageObserver
|
|
backups BackupReporter
|
|
restoreTests RestoreTestReporter
|
|
hostID string
|
|
agentVersion string
|
|
logger *slog.Logger
|
|
now func() time.Time
|
|
}
|
|
|
|
// NewCollector builds a collector. hostID echoes config.Hub.HostID; agentVersion is
|
|
// the binary version. storage/backups/restoreTests may be nil (their collections emit empty).
|
|
func NewCollector(px proxmoxReader, cf CloudflaredProber, storage StorageObserver, backups BackupReporter, restoreTests RestoreTestReporter, hostID, agentVersion string, logger *slog.Logger) *Collector {
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
return &Collector{
|
|
px: px,
|
|
cf: cf,
|
|
storage: storage,
|
|
backups: backups,
|
|
restoreTests: restoreTests,
|
|
hostID: hostID,
|
|
agentVersion: agentVersion,
|
|
logger: logger,
|
|
now: func() time.Time { return time.Now().UTC() },
|
|
}
|
|
}
|
|
|
|
// Collect builds the report. Best-effort liveness: a failed NodeStatus is a hard
|
|
// error (no useful report — the cycle skips the POST); a failed per-guest
|
|
// GuestConfig degrades that guest to status="unknown" without spec but still sends;
|
|
// a cloudflared probe failure yields status="unknown" and is never fatal.
|
|
func (c *Collector) Collect(ctx context.Context) (*HostReport, error) {
|
|
ns, err := c.px.NodeStatus(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("hub: NodeStatus failed (no useful report): %w", err)
|
|
}
|
|
|
|
report := &HostReport{
|
|
HostID: c.hostID,
|
|
ReportedAt: c.now().Format(time.RFC3339),
|
|
AgentVersion: c.agentVersion,
|
|
Host: hostMetrics(c.px.Node(), ns),
|
|
Guests: c.collectGuests(ctx),
|
|
// storage_targets populated this slice (slice 5) via the observer; the rest stay
|
|
// defined-but-empty (slice 6). Non-nil so they marshal as [].
|
|
StorageTargets: c.collectStorage(ctx),
|
|
Backups: c.collectBackups(ctx),
|
|
RestoreTests: c.collectRestoreTests(ctx),
|
|
PBSSnapshots: []PBSSnapshot{}, // Phase B
|
|
|
|
AuditTail: []AuditEntry{},
|
|
Cloudflared: Cloudflared{Status: c.cloudflaredStatus(ctx)},
|
|
}
|
|
return report, nil
|
|
}
|
|
|
|
func hostMetrics(node string, ns proxmox.NodeStatus) HostMetrics {
|
|
h := HostMetrics{
|
|
Node: node,
|
|
CPUPercent: ns.CPU * 100, // PVE cpu is a 0..1 fraction
|
|
MemoryTotalBytes: ns.Memory.Total,
|
|
MemoryUsedBytes: ns.Memory.Used,
|
|
DiskTotalBytes: ns.RootFS.Total,
|
|
DiskUsedBytes: ns.RootFS.Used,
|
|
LoadAvg: ns.LoadAvg,
|
|
UptimeSeconds: ns.Uptime,
|
|
}
|
|
h.MemoryPercent = percent(ns.Memory.Used, ns.Memory.Total)
|
|
h.DiskPercent = percent(ns.RootFS.Used, ns.RootFS.Total)
|
|
if h.LoadAvg == nil {
|
|
h.LoadAvg = []string{}
|
|
}
|
|
return h
|
|
}
|
|
|
|
func (c *Collector) collectGuests(ctx context.Context) []Guest {
|
|
lxc, err := c.px.ListLXC(ctx)
|
|
if err != nil {
|
|
// Not fatal: a report with no guest list still carries host liveness.
|
|
c.logger.Warn("hub: ListLXC failed; reporting no guests", "err", err)
|
|
return []Guest{}
|
|
}
|
|
guests := make([]Guest, 0, len(lxc))
|
|
for _, g := range lxc {
|
|
entry := Guest{VMID: g.VMID, Name: g.Name, Status: g.Status, ControllerVersion: ""}
|
|
// Normalize an empty run-status to "unknown" so the wire value is always one
|
|
// of running|stopped|unknown (matches the hub handler's empty→unknown default).
|
|
if entry.Status == "" {
|
|
entry.Status = "unknown"
|
|
}
|
|
// GuestConfig supplies cores; memory/disk come from the list entry (bytes).
|
|
// On failure, KEEP the known run-status from ListLXC — only the spec is lost.
|
|
cfg, err := c.px.GuestConfig(ctx, g.VMID)
|
|
if err != nil {
|
|
c.logger.Warn("hub: GuestConfig failed; spec omitted (run-status kept)",
|
|
"vmid", g.VMID, "err", err)
|
|
entry.Spec = nil
|
|
} else {
|
|
entry.Spec = &GuestSpec{
|
|
Cores: cfg.Cores,
|
|
MemoryBytes: g.MaxMem,
|
|
DiskBytes: g.MaxDisk,
|
|
}
|
|
}
|
|
guests = append(guests, entry)
|
|
}
|
|
return guests
|
|
}
|
|
|
|
// collectStorage builds the storage_targets via the observer. A nil observer (no storage
|
|
// layer wired) or an observe error degrades to an empty list — storage detail is
|
|
// best-effort and must never sink the heartbeat (host liveness is the priority).
|
|
func (c *Collector) collectStorage(ctx context.Context) []StorageTarget {
|
|
if c.storage == nil {
|
|
return []StorageTarget{}
|
|
}
|
|
targets, err := c.storage.Observe(ctx)
|
|
if err != nil {
|
|
c.logger.Warn("hub: storage observe failed; reporting no storage targets", "err", err)
|
|
return []StorageTarget{}
|
|
}
|
|
if targets == nil {
|
|
return []StorageTarget{}
|
|
}
|
|
return targets
|
|
}
|
|
|
|
// collectBackups / collectRestoreTests read the agent's latest backup + restore-test state
|
|
// via the seams. Best-effort: a nil reporter or nil slice degrades to an empty (non-nil)
|
|
// list so the collection always marshals as [].
|
|
func (c *Collector) collectBackups(ctx context.Context) []Backup {
|
|
if c.backups == nil {
|
|
return []Backup{}
|
|
}
|
|
if b := c.backups.Backups(ctx); b != nil {
|
|
return b
|
|
}
|
|
return []Backup{}
|
|
}
|
|
|
|
func (c *Collector) collectRestoreTests(ctx context.Context) []RestoreTest {
|
|
if c.restoreTests == nil {
|
|
return []RestoreTest{}
|
|
}
|
|
if r := c.restoreTests.RestoreTests(ctx); r != nil {
|
|
return r
|
|
}
|
|
return []RestoreTest{}
|
|
}
|
|
|
|
func (c *Collector) cloudflaredStatus(ctx context.Context) string {
|
|
if c.cf == nil {
|
|
return "unknown"
|
|
}
|
|
st, err := c.cf.Status(ctx)
|
|
if err != nil || st == "" {
|
|
c.logger.Warn("hub: cloudflared probe failed", "err", err)
|
|
return "unknown"
|
|
}
|
|
return st
|
|
}
|
|
|
|
func percent(used, total int64) float64 {
|
|
if total <= 0 {
|
|
return 0
|
|
}
|
|
return float64(used) / float64(total) * 100
|
|
}
|