From 82ba9405249c20b072420bfe7cbdef55e20bbfbd Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 30 Apr 2026 14:37:43 +0100 Subject: [PATCH] Merge linked host disks into guest overviews Fixes #1438 --- internal/models/state_snapshot.go | 98 ++++++++++- internal/models/state_snapshot_merge_test.go | 170 +++++++++++++++++++ internal/monitoring/monitor.go | 4 + 3 files changed, 271 insertions(+), 1 deletion(-) create mode 100644 internal/models/state_snapshot_merge_test.go diff --git a/internal/models/state_snapshot.go b/internal/models/state_snapshot.go index 2ffb5a190..2f2e87e8e 100644 --- a/internal/models/state_snapshot.go +++ b/internal/models/state_snapshot.go @@ -1,6 +1,9 @@ package models -import "time" +import ( + "strings" + "time" +) // StateSnapshot represents a snapshot of the state without mutex type StateSnapshot struct { @@ -246,6 +249,99 @@ func (s *State) GetSnapshot() StateSnapshot { return snapshot } +// MergeLinkedHostDisksIntoGuests supplements the filesystem listings of VMs +// and containers with disks reported by a unified pulse-agent running inside +// the same guest (matched via Host.LinkedVMID / LinkedContainerID). +// +// The qemu-guest-agent's get-fsinfo can miss filesystems on certain guest +// configurations (notably ZFS mounts on PBS, see #1438), while the unified +// pulse-agent has direct OS-level visibility through hostmetrics. When a Host +// is linked to a guest, this method appends the host agent's Disks, deduped by +// mountpoint, to the guest's Disks slice and updates the guest aggregate Disk +// usage to include the new partitions. +// +// Disks already present on the guest from the qemu-guest-agent path take +// precedence; host-agent entries are only added for mountpoints the guest does +// not already list. +func (s *StateSnapshot) MergeLinkedHostDisksIntoGuests() { + if s == nil || len(s.Hosts) == 0 { + return + } + + hostByVMID := make(map[string]*Host) + hostByContainerID := make(map[string]*Host) + for i := range s.Hosts { + h := &s.Hosts[i] + if id := strings.TrimSpace(h.LinkedVMID); id != "" { + hostByVMID[id] = h + } + if id := strings.TrimSpace(h.LinkedContainerID); id != "" { + hostByContainerID[id] = h + } + } + + if len(hostByVMID) > 0 { + for i := range s.VMs { + mergeHostDisksIntoGuest(&s.VMs[i].Disk, &s.VMs[i].Disks, hostByVMID[s.VMs[i].ID]) + } + } + if len(hostByContainerID) > 0 { + for i := range s.Containers { + mergeHostDisksIntoGuest(&s.Containers[i].Disk, &s.Containers[i].Disks, hostByContainerID[s.Containers[i].ID]) + } + } +} + +func mergeHostDisksIntoGuest(guestAggregate *Disk, guestDisks *[]Disk, host *Host) { + if host == nil || len(host.Disks) == 0 || guestDisks == nil { + return + } + + existingMounts := make(map[string]struct{}, len(*guestDisks)) + for _, d := range *guestDisks { + mp := strings.TrimSpace(d.Mountpoint) + if mp != "" { + existingMounts[mp] = struct{}{} + } + } + + var added []Disk + for _, d := range host.Disks { + mp := strings.TrimSpace(d.Mountpoint) + if mp == "" { + continue + } + if _, exists := existingMounts[mp]; exists { + continue + } + existingMounts[mp] = struct{}{} + added = append(added, d) + } + + if len(added) == 0 { + return + } + + merged := make([]Disk, 0, len(*guestDisks)+len(added)) + merged = append(merged, *guestDisks...) + merged = append(merged, added...) + *guestDisks = merged + + if guestAggregate == nil { + return + } + for _, d := range added { + if d.Total > 0 { + guestAggregate.Total += d.Total + guestAggregate.Used += d.Used + guestAggregate.Free += d.Free + } + } + if guestAggregate.Total > 0 { + guestAggregate.Usage = float64(guestAggregate.Used) / float64(guestAggregate.Total) * 100 + } +} + // GetLastUpdate returns the current state freshness marker without cloning the // full snapshot payload. func (s *State) GetLastUpdate() time.Time { diff --git a/internal/models/state_snapshot_merge_test.go b/internal/models/state_snapshot_merge_test.go new file mode 100644 index 000000000..afccb985f --- /dev/null +++ b/internal/models/state_snapshot_merge_test.go @@ -0,0 +1,170 @@ +package models + +import "testing" + +// TestMergeLinkedHostDisksIntoGuests covers #1438: when a unified pulse-agent +// runs inside a VM (LinkedVMID set), filesystems it reports that the +// qemu-guest-agent's fsinfo missed, typically ZFS mounts on PBS, must be +// surfaced in the VM Overview filesystem list. Mountpoints already covered by +// qemu-guest-agent fsinfo take precedence and are not touched. +func TestMergeLinkedHostDisksIntoGuests(t *testing.T) { + t.Run("adds host-only mounts to a linked VM and updates aggregate", func(t *testing.T) { + s := &StateSnapshot{ + VMs: []VM{ + { + ID: "pve01-101", + Disk: Disk{ + Total: 85_100_000_000, + Used: 20_500_000_000, + Free: 64_600_000_000, + Usage: 24, + }, + Disks: []Disk{ + {Mountpoint: "/", Total: 25_600_000_000, Used: 6_990_000_000, Free: 18_610_000_000, Type: "ext4"}, + {Mountpoint: "/mnt/datastore/pbs01s3repl01", Total: 59_500_000_000, Used: 13_500_000_000, Free: 46_000_000_000, Type: "ext4"}, + }, + }, + }, + Hosts: []Host{ + { + ID: "host-pbs01", + Hostname: "pbs01", + LinkedVMID: "pve01-101", + Disks: []Disk{ + {Mountpoint: "/", Total: 27_000_000_000, Used: 6_990_000_000, Type: "ext4"}, + {Mountpoint: "/mnt/datastore/pbs01replic", Total: 934_000_000_000, Used: 575_000_000_000, Free: 359_000_000_000, Type: "zfs"}, + {Mountpoint: "/mnt/datastore/pbs01s3repl01", Total: 62_700_000_000, Used: 13_500_000_000, Type: "zfs"}, + }, + }, + }, + } + + s.MergeLinkedHostDisksIntoGuests() + + vm := s.VMs[0] + if got := len(vm.Disks); got != 3 { + t.Fatalf("expected 3 disks, got %d", got) + } + if vm.Disks[0].Mountpoint != "/" || vm.Disks[0].Type != "ext4" { + t.Errorf("first disk mutated: %+v", vm.Disks[0]) + } + if vm.Disks[1].Mountpoint != "/mnt/datastore/pbs01s3repl01" || vm.Disks[1].Type != "ext4" { + t.Errorf("second disk should remain qemu-guest-agent ext4 entry, got: %+v", vm.Disks[1]) + } + if vm.Disks[2].Mountpoint != "/mnt/datastore/pbs01replic" || vm.Disks[2].Type != "zfs" { + t.Errorf("expected ZFS dataset appended, got: %+v", vm.Disks[2]) + } + + expectedTotal := int64(85_100_000_000 + 934_000_000_000) + if vm.Disk.Total != expectedTotal { + t.Errorf("aggregate Total = %d, want %d", vm.Disk.Total, expectedTotal) + } + expectedUsed := int64(20_500_000_000 + 575_000_000_000) + if vm.Disk.Used != expectedUsed { + t.Errorf("aggregate Used = %d, want %d", vm.Disk.Used, expectedUsed) + } + if vm.Disk.Usage <= 0 || vm.Disk.Usage > 100 { + t.Errorf("aggregate Usage out of range: %.2f", vm.Disk.Usage) + } + }) + + t.Run("does not mutate VM when no host is linked", func(t *testing.T) { + s := &StateSnapshot{ + VMs: []VM{ + { + ID: "pve01-101", + Disk: Disk{Total: 1000, Used: 500, Usage: 50}, + Disks: []Disk{{Mountpoint: "/", Total: 1000, Used: 500}}, + }, + }, + Hosts: []Host{ + {ID: "host-other", LinkedVMID: "pve01-999", Disks: []Disk{{Mountpoint: "/data", Total: 9999}}}, + }, + } + + s.MergeLinkedHostDisksIntoGuests() + + if len(s.VMs[0].Disks) != 1 { + t.Errorf("expected VM to be unchanged, got %d disks", len(s.VMs[0].Disks)) + } + if s.VMs[0].Disk.Total != 1000 { + t.Errorf("expected aggregate unchanged, got Total=%d", s.VMs[0].Disk.Total) + } + }) + + t.Run("merges into linked container", func(t *testing.T) { + s := &StateSnapshot{ + Containers: []Container{ + { + ID: "pve01-200", + Disks: []Disk{{Mountpoint: "/", Total: 8_000_000_000, Used: 1_000_000_000, Type: "ext4"}}, + Disk: Disk{Total: 8_000_000_000, Used: 1_000_000_000, Usage: 12.5}, + }, + }, + Hosts: []Host{ + { + ID: "host-ct", + LinkedContainerID: "pve01-200", + Disks: []Disk{{Mountpoint: "/var/lib/zfs", Total: 100_000_000_000, Used: 50_000_000_000, Type: "zfs"}}, + }, + }, + } + + s.MergeLinkedHostDisksIntoGuests() + + if len(s.Containers[0].Disks) != 2 { + t.Fatalf("expected 2 disks on container, got %d", len(s.Containers[0].Disks)) + } + if s.Containers[0].Disks[1].Mountpoint != "/var/lib/zfs" { + t.Errorf("expected ZFS dataset appended to container, got %+v", s.Containers[0].Disks[1]) + } + if s.Containers[0].Disk.Total != 108_000_000_000 { + t.Errorf("aggregate Total = %d, want %d", s.Containers[0].Disk.Total, 108_000_000_000) + } + }) + + t.Run("ignores host disks with empty mountpoints", func(t *testing.T) { + s := &StateSnapshot{ + VMs: []VM{ + {ID: "pve01-101", Disks: []Disk{{Mountpoint: "/", Total: 1000}}}, + }, + Hosts: []Host{ + { + ID: "host-pbs01", + LinkedVMID: "pve01-101", + Disks: []Disk{ + {Mountpoint: "", Total: 9999}, + {Mountpoint: "/zfs", Total: 5000, Type: "zfs"}, + }, + }, + }, + } + + s.MergeLinkedHostDisksIntoGuests() + + if len(s.VMs[0].Disks) != 2 { + t.Fatalf("expected only valid mountpoints merged, got %d", len(s.VMs[0].Disks)) + } + if s.VMs[0].Disks[1].Mountpoint != "/zfs" { + t.Errorf("expected /zfs appended, got %+v", s.VMs[0].Disks[1]) + } + }) + + t.Run("does not mutate underlying state slice", func(t *testing.T) { + original := []Disk{{Mountpoint: "/", Total: 1000}} + s := &StateSnapshot{ + VMs: []VM{ + {ID: "pve01-101", Disks: original}, + }, + Hosts: []Host{ + {ID: "host-pbs01", LinkedVMID: "pve01-101", Disks: []Disk{{Mountpoint: "/zfs", Total: 5000}}}, + }, + } + + s.MergeLinkedHostDisksIntoGuests() + + if len(original) != 1 { + t.Errorf("merge mutated the original slice: len=%d, want 1", len(original)) + } + }) +} diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 015fb9f94..f9af043ae 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -2712,6 +2712,10 @@ func (m *Monitor) GetState() models.StateSnapshot { // counts or recently resolved incidents from cached state. state.ActiveAlerts = m.activeAlertsSnapshot() state.RecentlyResolved = m.recentlyResolvedAlertsSnapshot() + // Surface filesystems reported by a unified pulse-agent inside a guest + // (for example ZFS mounts that qemu-guest-agent's get-fsinfo cannot see + // on PBS, #1438) in the guest overview disk listing. + state.MergeLinkedHostDisksIntoGuests() return state }