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") + } +}