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.
This commit is contained in:
rcourtman 2025-11-12 09:47:02 +00:00
parent d13a1e372a
commit 2e1ef44ecd
5 changed files with 221 additions and 47 deletions

View file

@ -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,

View file

@ -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)
}

View file

@ -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))

56
pkg/fsfilters/filters.go Normal file
View file

@ -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
}

View file

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