From 2e1ef44ecd049445db0a97cf0efae231bbf973cc Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 12 Nov 2025 09:47:02 +0000 Subject: [PATCH] Filter read-only filesystems from host agent disk metrics (related to #690) Squashfs snap mounts on Ubuntu (and similar read-only filesystems like erofs on Home Assistant OS) always report near-full usage and trigger false disk alerts. The filter logic existed in Proxmox monitoring but wasn't applied to host agents. Changes: - Extract read-only filesystem filter to shared pkg/fsfilters package - Apply filter in hostmetrics.collectDisks() for host/docker agents - Apply filter in monitor.ApplyHostReport() for backward compatibility - Convert internal/monitoring/fs_filters.go to wrapper functions This prevents squashfs, erofs, iso9660, cdfs, udf, cramfs, romfs, and saturated overlay filesystems from generating alerts. Filtering happens at both collection time (agents) and ingestion time (server) to ensure older agents don't cause false alerts until they're updated. --- internal/hostmetrics/collector.go | 8 ++ internal/monitoring/fs_filters.go | 54 ++--------- internal/monitoring/monitor.go | 7 ++ pkg/fsfilters/filters.go | 56 ++++++++++++ pkg/fsfilters/filters_test.go | 143 ++++++++++++++++++++++++++++++ 5 files changed, 221 insertions(+), 47 deletions(-) create mode 100644 pkg/fsfilters/filters.go create mode 100644 pkg/fsfilters/filters_test.go diff --git a/internal/hostmetrics/collector.go b/internal/hostmetrics/collector.go index f5d31d3ba..3f64e9736 100644 --- a/internal/hostmetrics/collector.go +++ b/internal/hostmetrics/collector.go @@ -8,6 +8,7 @@ import ( "time" agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host" + "github.com/rcourtman/pulse-go-rewrite/pkg/fsfilters" gocpu "github.com/shirou/gopsutil/v4/cpu" godisk "github.com/shirou/gopsutil/v4/disk" goload "github.com/shirou/gopsutil/v4/load" @@ -114,6 +115,13 @@ func collectDisks(ctx context.Context) []agentshost.Disk { continue } + // Skip read-only filesystems like squashfs (snap mounts), erofs, iso9660, etc. + // These are immutable and always report near-full usage, which causes false alerts. + // See issues #505 (Home Assistant OS) and #690 (Ubuntu snap mounts). + if fsfilters.ShouldIgnoreReadOnlyFilesystem(part.Fstype, usage.Total, usage.Used) { + continue + } + disks = append(disks, agentshost.Disk{ Device: part.Device, Mountpoint: part.Mountpoint, diff --git a/internal/monitoring/fs_filters.go b/internal/monitoring/fs_filters.go index efddb19c4..174f40204 100644 --- a/internal/monitoring/fs_filters.go +++ b/internal/monitoring/fs_filters.go @@ -1,55 +1,15 @@ package monitoring -import "strings" +import "github.com/rcourtman/pulse-go-rewrite/pkg/fsfilters" -var readOnlyFilesystemPatterns = []struct { - reason string - substrings []string -}{ - {reason: "erofs", substrings: []string{"erofs"}}, - {reason: "squashfs", substrings: []string{"squashfs", "squash-fs"}}, - {reason: "iso9660", substrings: []string{"iso9660"}}, - {reason: "cdfs", substrings: []string{"cdfs"}}, - {reason: "udf", substrings: []string{"udf"}}, - {reason: "cramfs", substrings: []string{"cramfs"}}, - {reason: "romfs", substrings: []string{"romfs"}}, -} - -// readOnlyFilesystemReason returns a label explaining why a filesystem should be -// ignored for usage calculations, along with a boolean indicating whether it is -// a read-only filesystem that always reports full usage. This helps us avoid -// false positives from immutable root images (overlay/squashfs/erofs) that ship -// with Home Assistant OS and similar appliances (see issue #505). +// readOnlyFilesystemReason is a compatibility wrapper around the shared filter. +// Use fsfilters.ReadOnlyFilesystemReason directly in new code. func readOnlyFilesystemReason(fsType string, totalBytes, usedBytes uint64) (string, bool) { - ft := strings.ToLower(strings.TrimSpace(fsType)) - if ft == "" { - return "", false - } - - // Common read-only filesystem types used for immutable system partitions. - for _, pattern := range readOnlyFilesystemPatterns { - for _, needle := range pattern.substrings { - if strings.Contains(ft, needle) { - return pattern.reason, true - } - } - } - - // Overlay-style filesystems can report 100% usage even though writes are - // redirected elsewhere. Treat them as read-only when the reported usage is - // saturated so we fall back to the writable layer metrics instead. - if strings.Contains(ft, "overlay") || strings.Contains(ft, "overlayfs") { - if totalBytes > 0 && usedBytes >= totalBytes { - return "overlay", true - } - } - - return "", false + return fsfilters.ReadOnlyFilesystemReason(fsType, totalBytes, usedBytes) } -// shouldIgnoreReadOnlyFilesystem reports whether the filesystem should be -// skipped from usage aggregation. +// shouldIgnoreReadOnlyFilesystem is a compatibility wrapper around the shared filter. +// Use fsfilters.ShouldIgnoreReadOnlyFilesystem directly in new code. func shouldIgnoreReadOnlyFilesystem(fsType string, totalBytes, usedBytes uint64) bool { - _, skip := readOnlyFilesystemReason(fsType, totalBytes, usedBytes) - return skip + return fsfilters.ShouldIgnoreReadOnlyFilesystem(fsType, totalBytes, usedBytes) } diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 04370faf8..0903dba60 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -1975,6 +1975,13 @@ func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config. disks := make([]models.Disk, 0, len(report.Disks)) for _, disk := range report.Disks { + // Filter read-only filesystems for backward compatibility with older host agents + // that don't have the filter built in. Prevents false alerts for snap mounts, + // immutable OS images, etc. (issues #505, #690). + if shouldIgnoreReadOnlyFilesystem(disk.Type, uint64(disk.TotalBytes), uint64(disk.UsedBytes)) { + continue + } + usage := safeFloat(disk.Usage) if usage <= 0 && disk.TotalBytes > 0 { usage = safePercentage(float64(disk.UsedBytes), float64(disk.TotalBytes)) diff --git a/pkg/fsfilters/filters.go b/pkg/fsfilters/filters.go new file mode 100644 index 000000000..e72552320 --- /dev/null +++ b/pkg/fsfilters/filters.go @@ -0,0 +1,56 @@ +package fsfilters + +import "strings" + +var readOnlyFilesystemPatterns = []struct { + reason string + substrings []string +}{ + {reason: "erofs", substrings: []string{"erofs"}}, + {reason: "squashfs", substrings: []string{"squashfs", "squash-fs"}}, + {reason: "iso9660", substrings: []string{"iso9660"}}, + {reason: "cdfs", substrings: []string{"cdfs"}}, + {reason: "udf", substrings: []string{"udf"}}, + {reason: "cramfs", substrings: []string{"cramfs"}}, + {reason: "romfs", substrings: []string{"romfs"}}, +} + +// ReadOnlyFilesystemReason returns a label explaining why a filesystem should be +// ignored for usage calculations, along with a boolean indicating whether it is +// a read-only filesystem that always reports full usage. This helps us avoid +// false positives from immutable root images (overlay/squashfs/erofs) that ship +// with Home Assistant OS and similar appliances, as well as snap mounts on Ubuntu +// systems (see issues #505, #690). +func ReadOnlyFilesystemReason(fsType string, totalBytes, usedBytes uint64) (string, bool) { + ft := strings.ToLower(strings.TrimSpace(fsType)) + if ft == "" { + return "", false + } + + // Common read-only filesystem types used for immutable system partitions. + for _, pattern := range readOnlyFilesystemPatterns { + for _, needle := range pattern.substrings { + if strings.Contains(ft, needle) { + return pattern.reason, true + } + } + } + + // Overlay-style filesystems can report 100% usage even though writes are + // redirected elsewhere. Treat them as read-only when the reported usage is + // saturated so we fall back to the writable layer metrics instead. + if strings.Contains(ft, "overlay") || strings.Contains(ft, "overlayfs") { + if totalBytes > 0 && usedBytes >= totalBytes { + return "overlay", true + } + } + + return "", false +} + +// ShouldIgnoreReadOnlyFilesystem reports whether the filesystem should be +// skipped from usage aggregation. +func ShouldIgnoreReadOnlyFilesystem(fsType string, totalBytes, usedBytes uint64) bool { + _, skip := ReadOnlyFilesystemReason(fsType, totalBytes, usedBytes) + return skip +} diff --git a/pkg/fsfilters/filters_test.go b/pkg/fsfilters/filters_test.go new file mode 100644 index 000000000..d3ec25878 --- /dev/null +++ b/pkg/fsfilters/filters_test.go @@ -0,0 +1,143 @@ +package fsfilters + +import "testing" + +func TestReadOnlyFilesystemReason(t *testing.T) { + tests := []struct { + name string + fsType string + totalBytes uint64 + usedBytes uint64 + reason string + skip bool + }{ + { + name: "erofs filesystem", + fsType: "erofs", + totalBytes: 10, + usedBytes: 5, + reason: "erofs", + skip: true, + }, + { + name: "fuse.erofs filesystem", + fsType: "fuse.erofs", + totalBytes: 10, + usedBytes: 8, + reason: "erofs", + skip: true, + }, + { + name: "overlay at capacity", + fsType: "overlay", + totalBytes: 100, + usedBytes: 100, + reason: "overlay", + skip: true, + }, + { + name: "squashfs filesystem", + fsType: "squashfs", + totalBytes: 4096, + usedBytes: 4096, + reason: "squashfs", + skip: true, + }, + { + name: "squash-fs alias", + fsType: "Squash-FS", + totalBytes: 2048, + usedBytes: 2048, + reason: "squashfs", + skip: true, + }, + { + name: "iso9660 filesystem", + fsType: "iso9660", + totalBytes: 700 * 1024 * 1024, + usedBytes: 700 * 1024 * 1024, + reason: "iso9660", + skip: true, + }, + { + name: "cdfs filesystem", + fsType: "CDFS", + totalBytes: 600 * 1024 * 1024, + usedBytes: 600 * 1024 * 1024, + reason: "cdfs", + skip: true, + }, + { + name: "udf optical media", + fsType: "udf", + totalBytes: 4 * 1024 * 1024 * 1024, + usedBytes: 4 * 1024 * 1024 * 1024, + reason: "udf", + skip: true, + }, + { + name: "cramfs image", + fsType: "cramfs", + totalBytes: 128 * 1024 * 1024, + usedBytes: 128 * 1024 * 1024, + reason: "cramfs", + skip: true, + }, + { + name: "romfs firmware partition", + fsType: "romfs", + totalBytes: 16 * 1024 * 1024, + usedBytes: 16 * 1024 * 1024, + reason: "romfs", + skip: true, + }, + { + name: "fuse iso image", + fsType: "fuse.cdfs", + totalBytes: 700 * 1024 * 1024, + usedBytes: 700 * 1024 * 1024, + reason: "cdfs", + skip: true, + }, + { + name: "overlay below capacity", + fsType: "overlay", + totalBytes: 100, + usedBytes: 50, + reason: "", + skip: false, + }, + { + name: "regular ext4", + fsType: "ext4", + totalBytes: 100, + usedBytes: 50, + reason: "", + skip: false, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + reason, skip := ReadOnlyFilesystemReason(tc.fsType, tc.totalBytes, tc.usedBytes) + if reason != tc.reason || skip != tc.skip { + t.Errorf("expected (%q, %t), got (%q, %t)", tc.reason, tc.skip, reason, skip) + } + }) + } +} + +func TestShouldIgnoreReadOnlyFilesystem(t *testing.T) { + if !ShouldIgnoreReadOnlyFilesystem("erofs", 100, 10) { + t.Fatalf("expected erofs to be ignored") + } + + if !ShouldIgnoreReadOnlyFilesystem("squashfs", 100, 100) { + t.Fatalf("expected squashfs to be ignored") + } + + if ShouldIgnoreReadOnlyFilesystem("ext4", 100, 10) { + t.Fatalf("expected ext4 to be included") + } +}