package hostcollect import ( "path/filepath" "sort" "strconv" "strings" "github.com/itworx/pulse/internal/host" ) // pseudoFilesystems never describe usable capacity; reporting them would fill the // filesystem list with kernel bookkeeping and push real volumes past the limit. var pseudoFilesystems = map[string]struct{}{ "autofs": {}, "bpf": {}, "binfmt_misc": {}, "cgroup": {}, "cgroup2": {}, "configfs": {}, "debugfs": {}, "devpts": {}, "devtmpfs": {}, "efivarfs": {}, "fuse.gvfsd-fuse": {}, "fusectl": {}, "hugetlbfs": {}, "mqueue": {}, "nsfs": {}, "overlay": {}, "proc": {}, "pstore": {}, "ramfs": {}, "rpc_pipefs": {}, "securityfs": {}, "selinuxfs": {}, "squashfs": {}, "sysfs": {}, "tmpfs": {}, "tracefs": {}, } type mountEntry struct { device string mount string fsType string } // parseMounts reads /proc/mounts. Fields are space separated and the kernel escapes // space, tab, newline and backslash inside the device and mount point as octal // sequences, so an Unraid share called "/mnt/user/Media Backup" arrives as // "/mnt/user/Media\040Backup" and must be unescaped before it can be used as a path. func parseMounts(data []byte) []mountEntry { all := lines(data) entries := make([]mountEntry, 0, len(all)) for _, line := range all { fields := strings.Fields(line) if len(fields) < 3 { continue } entries = append(entries, mountEntry{ device: unescapeMountField(fields[0]), mount: unescapeMountField(fields[1]), fsType: fields[2], }) } return entries } func unescapeMountField(value string) string { if !strings.Contains(value, `\`) { return value } var builder strings.Builder builder.Grow(len(value)) for index := 0; index < len(value); index++ { if value[index] == '\\' && index+3 < len(value) { if decoded, err := strconv.ParseUint(value[index+1:index+4], 8, 8); err == nil { builder.WriteByte(byte(decoded)) index += 3 continue } } builder.WriteByte(value[index]) } return builder.String() } // filesystems reports capacity and inode usage per real mount point. // // It is disabled unless FilesystemRoot is configured. Inside a container the host's // /proc/mounts lists host paths that do not exist in the container's mount namespace; // calling statfs on the same string would either fail or — worse, for "/" — silently // measure the container's own overlay and report it as the host's root filesystem. // Mounting a host root read-only and pointing FilesystemRoot at it is an explicit, // auditable decision rather than an accident of naming. func (c *Collector) filesystems(warnings *warningSet) []host.RawFilesystem { if c.options.FilesystemRoot == "" { warnings.add(WarningFilesystemDisabled) return nil } data, err := readLimited(c.procPath("mounts"), maxFileBytes) if err != nil { warnings.add(WarningMountsUnavailable) return nil } seen := make(map[string]struct{}) results := make([]host.RawFilesystem, 0, 16) failures := 0 for _, entry := range parseMounts(data) { if _, pseudo := pseudoFilesystems[entry.fsType]; pseudo { continue } if entry.mount == "" || len(entry.mount) > 512 { continue } if _, duplicate := seen[entry.mount]; duplicate { continue } seen[entry.mount] = struct{}{} usage, statErr := c.options.StatFS(filepath.Join(c.options.FilesystemRoot, entry.mount)) if statErr != nil { failures++ continue } if usage.CapacityBytes == 0 { continue } if usage.UsedBytes > usage.CapacityBytes { usage.UsedBytes = usage.CapacityBytes } item := host.RawFilesystem{ Mount: entry.mount, Filesystem: truncate(entry.fsType, 64), CapacityBytes: usage.CapacityBytes, UsedBytes: usage.UsedBytes, } if usage.InodesTotal > 0 { used := usage.InodesUsed if used > usage.InodesTotal { used = usage.InodesTotal } item.Inodes = &host.RawInodes{Total: usage.InodesTotal, Used: used} } results = append(results, item) } if failures > 0 { warnings.add(WarningFilesystemPartial) } sort.Slice(results, func(i, j int) bool { return results[i].Mount < results[j].Mount }) if len(results) > c.options.HostLimits.MaxFilesystems { results = results[:c.options.HostLimits.MaxFilesystems] warnings.add(WarningFilesystemTruncated) } if len(results) == 0 { return nil } return results }