mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-21 07:24:33 +00:00
286 lines
8.5 KiB
Go
286 lines
8.5 KiB
Go
//go:build linux
|
|
|
|
package btrfs
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"unsafe"
|
|
|
|
"github.com/henrygd/beszel/agent/utils"
|
|
"golang.org/x/sys/unix"
|
|
)
|
|
|
|
var (
|
|
sysfsPath = "/sys/fs/btrfs"
|
|
mountsPath = "/proc/self/mounts"
|
|
mountinfoPath = "/proc/self/mountinfo"
|
|
mountUUID = MountID
|
|
deviceSize = ioctlDeviceSize
|
|
filesystemUsage = statfsUsage
|
|
)
|
|
|
|
// Filesystems returns all mounted btrfs filesystems, or nil when there are none.
|
|
func Filesystems() ([]Filesystem, error) {
|
|
entries, err := os.ReadDir(sysfsPath)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
mounts := mountpointsByDevice()
|
|
var filesystems []Filesystem
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() || entry.Name() == "features" {
|
|
continue
|
|
}
|
|
fs, err := readFilesystem(filepath.Join(sysfsPath, entry.Name()), mounts)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("btrfs %s: %w", entry.Name(), err)
|
|
}
|
|
filesystems = append(filesystems, fs)
|
|
}
|
|
return filesystems, nil
|
|
}
|
|
|
|
func readFilesystem(dir string, mounts map[string]string) (Filesystem, error) {
|
|
fs := Filesystem{UUID: filepath.Base(dir), Name: utils.ReadStringFile(filepath.Join(dir, "label")), Health: "UNKNOWN"}
|
|
for _, kind := range []string{"data", "metadata", "system"} {
|
|
if value, ok := utils.ReadUintFile(filepath.Join(dir, "allocation", kind, "disk_used")); ok {
|
|
fs.Alloc += value
|
|
}
|
|
}
|
|
// devices/<name> links to the block device's sysfs directory.
|
|
devices, err := os.ReadDir(filepath.Join(dir, "devices"))
|
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return fs, err
|
|
}
|
|
mountpoint := mounts["uuid:"+fs.UUID]
|
|
if fs.Name == "" {
|
|
fs.Name = mountpoint
|
|
}
|
|
var backingSize uint64
|
|
for _, dev := range devices {
|
|
if mountpoint == "" {
|
|
mountpoint = mounts[dev.Name()]
|
|
}
|
|
if fs.Name == "" {
|
|
fs.Name = mountpoint
|
|
}
|
|
devDir := filepath.Join(dir, "devices", dev.Name())
|
|
if size, ok := utils.ReadUintFile(filepath.Join(devDir, "size")); ok {
|
|
backingSize += size * 512
|
|
}
|
|
if stat := strings.Fields(utils.ReadStringFile(filepath.Join(devDir, "stat"))); len(stat) >= 7 {
|
|
fs.NRead += parseUint(stat[2]) * 512
|
|
fs.NWrite += parseUint(stat[6]) * 512
|
|
}
|
|
}
|
|
devids, err := os.ReadDir(filepath.Join(dir, "devinfo"))
|
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return fs, err
|
|
}
|
|
capacityAvailable := len(devids) > 0
|
|
healthKnown := len(devids) > 0
|
|
for _, devid := range devids {
|
|
devDir := filepath.Join(dir, "devinfo", devid.Name())
|
|
// Replacement targets do not add filesystem capacity.
|
|
replaceTarget, _ := utils.ReadUintFile(filepath.Join(devDir, "replace_target"))
|
|
if replaceTarget != 1 {
|
|
devid, err := strconv.ParseUint(devid.Name(), 10, 64)
|
|
if err != nil {
|
|
return fs, err
|
|
}
|
|
size, err := deviceSize(mountpoint, devid)
|
|
if err != nil {
|
|
capacityAvailable = false
|
|
}
|
|
fs.Size += size
|
|
}
|
|
dev := Device{Name: "devid " + devid.Name(), State: "ONLINE"}
|
|
missing := utils.ReadStringFile(filepath.Join(devDir, "missing"))
|
|
if missing != "0" && missing != "1" {
|
|
healthKnown = false
|
|
dev.State = "UNKNOWN"
|
|
}
|
|
if missing == "1" {
|
|
dev.State = "MISSING"
|
|
fs.Health = "DEGRADED"
|
|
}
|
|
for line := range strings.Lines(utils.ReadStringFile(filepath.Join(devDir, "error_stats"))) {
|
|
if fields := strings.Fields(line); len(fields) == 2 {
|
|
switch fields[0] {
|
|
case "read_errs":
|
|
dev.ReadErrs = parseUint(fields[1])
|
|
case "write_errs":
|
|
dev.WriteErrs = parseUint(fields[1])
|
|
case "corruption_errs":
|
|
dev.CorruptionErrs = parseUint(fields[1])
|
|
}
|
|
}
|
|
}
|
|
fs.Devices = append(fs.Devices, dev)
|
|
}
|
|
// Use one capacity source for the whole filesystem: device IDs cannot be
|
|
// reliably matched to block-device names in sysfs. A partial ioctl result
|
|
// must not be added to the complete backing-device total.
|
|
if !capacityAvailable {
|
|
fs.Size = backingSize
|
|
}
|
|
if fs.Health != "DEGRADED" && healthKnown {
|
|
fs.Health = "ONLINE"
|
|
}
|
|
fs.MountID = mountUUID(mountpoint)
|
|
if len(devices) == 1 && len(devids) == 1 && fs.Health == "ONLINE" {
|
|
fs.IODevice = devices[0].Name()
|
|
}
|
|
fs.Raw = true
|
|
if used, available, err := filesystemUsage(mountpoint); err == nil {
|
|
// Effective capacity excludes reserved/unavailable space, so Size-Alloc
|
|
// is available to applications and the usage ratio matches df.
|
|
fs.Size, fs.Alloc, fs.Raw = used+available, used, false
|
|
}
|
|
if fs.Name == "" {
|
|
fs.Name = filepath.Base(dir)
|
|
}
|
|
return fs, nil
|
|
}
|
|
|
|
// mountpointsByDevice prefers UUID matches from mountinfo and retains source
|
|
// device names as a fallback for environments where FS_INFO is unavailable.
|
|
func mountpointsByDevice() map[string]string {
|
|
mounts := mountpointsByUUID(utils.ReadStringFile(mountinfoPath), mountUUID)
|
|
for line := range strings.Lines(utils.ReadStringFile(mountsPath)) {
|
|
fields := strings.Fields(line)
|
|
if len(fields) < 3 || fields[2] != "btrfs" {
|
|
continue
|
|
}
|
|
device := fields[0]
|
|
if resolved, err := filepath.EvalSymlinks(device); err == nil {
|
|
device = resolved
|
|
}
|
|
if _, seen := mounts[filepath.Base(device)]; !seen {
|
|
mounts[filepath.Base(device)] = unescapeMountPath(fields[1])
|
|
}
|
|
}
|
|
return mounts
|
|
}
|
|
|
|
func parseUint(s string) uint64 {
|
|
n, _ := strconv.ParseUint(s, 10, 64)
|
|
return n
|
|
}
|
|
|
|
// ioctlDeviceSize reads Btrfs's recorded device size, which can be smaller
|
|
// than the block device after a filesystem resize. BTRFS_IOC_DEV_INFO is
|
|
// _IOWR(0x94, 30, struct btrfs_ioctl_dev_info_args), a 4096-byte ABI structure.
|
|
func ioctlDeviceSize(mountpoint string, devid uint64) (uint64, error) {
|
|
if mountpoint == "" {
|
|
return 0, errors.New("no accessible mountpoint")
|
|
}
|
|
f, err := os.Open(mountpoint)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer f.Close()
|
|
args := struct {
|
|
Devid uint64
|
|
UUID [16]byte
|
|
BytesUsed uint64
|
|
TotalBytes uint64
|
|
Reserved [4096 - 40]byte
|
|
}{Devid: devid}
|
|
_, _, errno := unix.Syscall(unix.SYS_IOCTL, f.Fd(), 0xd000941e, uintptr(unsafe.Pointer(&args)))
|
|
if errno != 0 {
|
|
return 0, errno
|
|
}
|
|
return args.TotalBytes, nil
|
|
}
|
|
|
|
// The filesystem magic is unsigned even when Statfs_t.Type is int32.
|
|
func isBtrfs(stat *unix.Statfs_t) bool {
|
|
return uint32(stat.Type) == unix.BTRFS_SUPER_MAGIC
|
|
}
|
|
|
|
func statfsUsage(path string) (used, available uint64, err error) {
|
|
if path == "" {
|
|
return 0, 0, errors.New("no accessible mountpoint")
|
|
}
|
|
var stat unix.Statfs_t
|
|
if err = unix.Statfs(path, &stat); err != nil {
|
|
return
|
|
}
|
|
if !isBtrfs(&stat) {
|
|
return 0, 0, errors.New("mountpoint is not Btrfs")
|
|
}
|
|
blockSize := uint64(stat.Bsize)
|
|
return (stat.Blocks - min(stat.Blocks, stat.Bfree)) * blockSize, min(stat.Blocks, stat.Bavail) * blockSize, nil
|
|
}
|
|
|
|
// MountID returns the filesystem UUID via BTRFS_IOC_FS_INFO. Unlike statfs
|
|
// f_fsid, this identity is shared by all subvolumes and bind mounts.
|
|
func MountID(path string) string {
|
|
if path == "" {
|
|
return ""
|
|
}
|
|
var stat unix.Statfs_t
|
|
if unix.Statfs(path, &stat) != nil || !isBtrfs(&stat) {
|
|
return ""
|
|
}
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
defer f.Close()
|
|
args := struct {
|
|
MaxID uint64
|
|
NumDevices uint64
|
|
FSID [16]byte
|
|
Reserved [992]byte
|
|
}{}
|
|
// _IOR(0x94, 31, 1024). Reuse the platform's read-direction bits;
|
|
// MIPS/PowerPC use a different encoding than asm-generic.
|
|
request := uintptr(unix.FS_IOC_GETFLAGS&0xe0000000) | 0x0400941f
|
|
_, _, errno := unix.Syscall(unix.SYS_IOCTL, f.Fd(), request, uintptr(unsafe.Pointer(&args)))
|
|
if errno != 0 {
|
|
return ""
|
|
}
|
|
id := args.FSID
|
|
return fmt.Sprintf("%x-%x-%x-%x-%x", id[:4], id[4:6], id[6:8], id[8:10], id[10:])
|
|
}
|
|
|
|
// Btrfs mountinfo device numbers can be virtual (0:N), so query the UUID
|
|
// through the mount instead of comparing those numbers with sysfs block devs.
|
|
// Retry another path when a bind mount is inaccessible. Once resolved, reuse
|
|
// the result for that mount device to avoid opening every Docker bind mount.
|
|
func mountpointsByUUID(mountinfo string, identify func(string) string) map[string]string {
|
|
mounts := make(map[string]string)
|
|
resolved := make(map[string]bool)
|
|
for line := range strings.Lines(mountinfo) {
|
|
before, after, ok := strings.Cut(line, " - ")
|
|
fields, fs := strings.Fields(before), strings.Fields(after)
|
|
if !ok || len(fields) < 6 || len(fs) < 3 || fs[0] != "btrfs" || resolved[fields[2]] {
|
|
continue
|
|
}
|
|
path := unescapeMountPath(fields[4])
|
|
uuid := identify(path)
|
|
if uuid == "" {
|
|
continue
|
|
}
|
|
resolved[fields[2]] = true
|
|
if mounts["uuid:"+uuid] == "" {
|
|
mounts["uuid:"+uuid] = path
|
|
}
|
|
}
|
|
return mounts
|
|
}
|
|
|
|
func unescapeMountPath(path string) string {
|
|
return strings.NewReplacer(`\040`, " ", `\011`, "\t", `\012`, "\n", `\134`, `\`).Replace(path)
|
|
}
|