Three-tab Bubble Tea TUI (Setup / Manage / Monitor) for setting up and running a headless Jellyfin server over SSH: - Setup: install/uninstall Jellyfin, add media drives (single or btrfs RAID pool), import an existing detached btrfs pool non-destructively, firewall, move Jellyfin storage onto a media drive - Boot-drive-safe drive classifier (walks LUKS/LVM/RAID to the physical disk; never offers a disk hosting /, /boot or swap) - Keep-existing-filesystem path previews on-disk content, skips starter folders when content exists, and grants Jellyfin recursive read access - Manage: copy from external drives, mount/eject, rename/delete - Monitor: per-core CPU, load/mem/uptime, btrfs error counters, SMART Single static binary; installs to /usr/local/bin so sudo can find it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
610 lines
19 KiB
Go
610 lines
19 KiB
Go
// Package drives detects block devices on the host and classifies each one.
|
|
//
|
|
// The classifier is the safety mechanism that prevents the boot drive from
|
|
// ever being offered as a media drive: it walks every candidate partition
|
|
// down through any LUKS / LVM / RAID layers to its underlying physical disk
|
|
// and refuses to offer it if that disk also hosts /, /boot, swap, or any of
|
|
// the other "system" mounts.
|
|
package drives
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// probeTimeout caps each block-device probe. A half-connected or failing USB
|
|
// drive can make lsblk/findmnt/mountpoint block in uninterruptible kernel I/O,
|
|
// which would otherwise freeze launch (detection runs before the first paint).
|
|
// On timeout the probe reports an error and detection degrades gracefully.
|
|
const probeTimeout = 8 * time.Second
|
|
|
|
// cmdOutput runs a command with a deadline and returns its stdout. On timeout
|
|
// the process is killed and a non-nil error is returned.
|
|
func cmdOutput(name string, args ...string) ([]byte, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), probeTimeout)
|
|
defer cancel()
|
|
return exec.CommandContext(ctx, name, args...).Output()
|
|
}
|
|
|
|
// cmdRunOK reports whether a command exits 0 within the deadline.
|
|
func cmdRunOK(name string, args ...string) bool {
|
|
ctx, cancel := context.WithTimeout(context.Background(), probeTimeout)
|
|
defer cancel()
|
|
return exec.CommandContext(ctx, name, args...).Run() == nil
|
|
}
|
|
|
|
// Role labels what a partition is currently being used for. Used both in the
|
|
// inventory display and to filter candidates in the picker.
|
|
type Role string
|
|
|
|
const (
|
|
RoleSystem Role = "system" // on a disk that hosts /, /boot, swap, etc.
|
|
RoleSwap Role = "swap" // swap partition
|
|
RoleLUKS Role = "luks" // crypto_LUKS container
|
|
RoleLVM Role = "lvm-pv" // LVM physical volume
|
|
RoleRAID Role = "raid" // mdadm RAID member
|
|
RoleMounted Role = "mounted" // mounted at some non-system path
|
|
RoleAvailable Role = "available" // has a filesystem, nowhere mounted — pickable
|
|
RoleEmpty Role = "empty" // no filesystem at all
|
|
RoleManagedOurs Role = "managed-ours" // we put this in fstab under /media/<user>/
|
|
RolePoolMember Role = "pool-member" // device of a multi-device btrfs pool (mounted via a sibling) — off-limits
|
|
)
|
|
|
|
// Drive represents one block device row from lsblk.
|
|
type Drive struct {
|
|
Path string // /dev/sda1
|
|
Name string // sda1
|
|
Size string // "3.6T"
|
|
FSType string // ext4, btrfs, swap, crypto_LUKS, "" if none
|
|
Label string // filesystem label, "" if none
|
|
UUID string // filesystem UUID
|
|
Model string // device model (top-level disks only)
|
|
Transport string // sata / usb / nvme (top-level disks only)
|
|
Type string // disk / part / crypt / lvm
|
|
MountPoint string // "" if not mounted
|
|
ParentDisk string // physical disk this lives on (sda, nvme0n1) — "" for top-level disks
|
|
Hotpluggable bool // best-effort: USB / removable
|
|
Role Role // computed
|
|
RoleDetail string // free-text like "→ /boot" or "encrypted (LUKS)"
|
|
}
|
|
|
|
// Inventory is the full list of drives on the system, in lsblk order, with
|
|
// each row's Role and ParentDisk filled in. Calling site uses this for the
|
|
// Setup tab's overview panel and for filtering candidates.
|
|
type Inventory struct {
|
|
All []Drive
|
|
|
|
// SystemDisks is the set of physical disks the OS lives on. Their short
|
|
// names (sda, nvme0n1) — never offered as media targets.
|
|
SystemDisks map[string]bool
|
|
}
|
|
|
|
// Load runs lsblk and findmnt, returns a fully-classified Inventory.
|
|
func Load(managedUser string) (*Inventory, error) {
|
|
sysDisks, err := systemDisks()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
managed := managedMounts(managedUser)
|
|
|
|
all, err := lsblkAll()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// First pass: fill ParentDisk for every row.
|
|
for i := range all {
|
|
if all[i].Type == "disk" {
|
|
all[i].ParentDisk = ""
|
|
continue
|
|
}
|
|
all[i].ParentDisk = physicalDiskOf(all[i].Path)
|
|
}
|
|
|
|
// Multi-device btrfs filesystems share one UUID across every member device.
|
|
// Map UUID → device count and UUID → "is any member mounted", so the
|
|
// classifier can recognise a pool member that has no mountpoint of its own
|
|
// (e.g. a 2-disk RAID mounted via /dev/sda while /dev/sdb looks idle).
|
|
uuidCount := map[string]int{}
|
|
uuidMounted := map[string]bool{}
|
|
for i := range all {
|
|
u := all[i].UUID
|
|
if u == "" {
|
|
continue
|
|
}
|
|
uuidCount[u]++
|
|
if all[i].MountPoint != "" {
|
|
uuidMounted[u] = true
|
|
}
|
|
}
|
|
|
|
// Second pass: classify Role. Membership of a system disk always wins so
|
|
// the UI consistently marks every partition on the boot drive as off-limits,
|
|
// regardless of whether it's mounted, encrypted, or empty.
|
|
for i := range all {
|
|
d := &all[i]
|
|
switch d.Type {
|
|
case "disk":
|
|
if sysDisks[d.Name] {
|
|
d.Role = RoleSystem
|
|
d.RoleDetail = "SYSTEM DISK (off-limits)"
|
|
continue
|
|
}
|
|
// Whole disk with a filesystem directly on it (no partition
|
|
// table) — e.g. after `mkfs.btrfs /dev/sda`. Apply the same
|
|
// mount/managed classification we use for partitions.
|
|
if d.MountPoint != "" {
|
|
if managed[d.MountPoint] {
|
|
d.Role = RoleManagedOurs
|
|
d.RoleDetail = "TUISTREAM media → " + d.MountPoint
|
|
} else {
|
|
d.Role = RoleMounted
|
|
d.RoleDetail = "mounted → " + d.MountPoint
|
|
}
|
|
continue
|
|
}
|
|
if d.FSType != "" {
|
|
if member, detail := btrfsPoolMember(d, uuidCount, uuidMounted); member {
|
|
d.Role = RolePoolMember
|
|
d.RoleDetail = detail
|
|
} else {
|
|
// Formatted but unmounted (e.g. partial setup, manual umount).
|
|
d.Role = RoleAvailable
|
|
d.RoleDetail = "AVAILABLE"
|
|
}
|
|
}
|
|
continue
|
|
case "part", "crypt":
|
|
// fall through
|
|
default:
|
|
continue
|
|
}
|
|
|
|
// What the partition "looks like" on its own — used to make the
|
|
// RoleSystem detail informative when we override below.
|
|
intrinsic := ""
|
|
intrinsicRole := Role("")
|
|
switch d.FSType {
|
|
case "swap":
|
|
intrinsic, intrinsicRole = "swap", RoleSwap
|
|
case "crypto_LUKS":
|
|
intrinsic, intrinsicRole = "encrypted (LUKS container)", RoleLUKS
|
|
case "LVM2_member":
|
|
intrinsic, intrinsicRole = "LVM physical volume", RoleLVM
|
|
case "linux_raid_member":
|
|
intrinsic, intrinsicRole = "RAID member", RoleRAID
|
|
}
|
|
|
|
// System-disk membership trumps everything else.
|
|
if sysDisks[d.ParentDisk] {
|
|
d.Role = RoleSystem
|
|
switch {
|
|
case d.MountPoint != "":
|
|
d.RoleDetail = "boot disk — mounted at " + d.MountPoint
|
|
case intrinsic != "":
|
|
d.RoleDetail = "boot disk — " + intrinsic
|
|
default:
|
|
d.RoleDetail = "boot disk — off-limits"
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Off the boot disk: classify normally.
|
|
if d.MountPoint != "" {
|
|
if managed[d.MountPoint] {
|
|
d.Role = RoleManagedOurs
|
|
d.RoleDetail = "TUISTREAM media → " + d.MountPoint
|
|
} else {
|
|
d.Role = RoleMounted
|
|
d.RoleDetail = "mounted → " + d.MountPoint
|
|
}
|
|
continue
|
|
}
|
|
|
|
if intrinsicRole != "" {
|
|
d.Role = intrinsicRole
|
|
d.RoleDetail = intrinsic
|
|
continue
|
|
}
|
|
|
|
if d.FSType == "" {
|
|
d.Role = RoleEmpty
|
|
d.RoleDetail = "empty / unformatted"
|
|
} else if member, detail := btrfsPoolMember(d, uuidCount, uuidMounted); member {
|
|
d.Role = RolePoolMember
|
|
d.RoleDetail = detail
|
|
} else {
|
|
d.Role = RoleAvailable
|
|
d.RoleDetail = "AVAILABLE"
|
|
}
|
|
}
|
|
|
|
return &Inventory{All: all, SystemDisks: sysDisks}, nil
|
|
}
|
|
|
|
// Candidates returns drives suitable for being added as a media drive.
|
|
//
|
|
// Three kinds qualify, in lsblk order:
|
|
//
|
|
// 1. Whole disks that aren't system disks AND have no currently-mounted
|
|
// children. Includes the "freshly-wiped, no partition table" case
|
|
// (where lsblk shows just the bare disk with no children) as well as
|
|
// "blank USB that's never been partitioned".
|
|
// 2. Partitions with a real filesystem that aren't mounted anywhere
|
|
// (RoleAvailable).
|
|
// 3. Empty/unformatted partitions on a non-system disk (RoleEmpty) —
|
|
// the user can format them during Add.
|
|
//
|
|
// If a disk and one of its partitions both qualify, both appear; the picker
|
|
// auto-deselects any conflicting peer when the user toggles a row.
|
|
func (inv *Inventory) Candidates() []Drive {
|
|
var out []Drive
|
|
for _, d := range inv.All {
|
|
switch d.Type {
|
|
case "disk":
|
|
if !inv.diskIsCandidate(d) {
|
|
continue
|
|
}
|
|
out = append(out, d)
|
|
case "part", "crypt":
|
|
if d.Role == RoleAvailable || d.Role == RoleEmpty {
|
|
out = append(out, d)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// IsPseudoDisk reports whether a whole-disk device is a kernel/firmware
|
|
// pseudo-device that should never be shown or offered as a usable drive:
|
|
// zram swap, loopback mounts, device-mapper targets, and the tiny eMMC
|
|
// hardware boot / RPMB areas (mmcblk0boot0, mmcblk0boot1, mmcblk0rpmb …).
|
|
// Exported so the TUI and the health monitor apply the exact same filter.
|
|
func IsPseudoDisk(name string) bool {
|
|
switch {
|
|
case strings.HasPrefix(name, "zram"),
|
|
strings.HasPrefix(name, "loop"),
|
|
strings.HasPrefix(name, "dm-"):
|
|
return true
|
|
case strings.HasPrefix(name, "mmcblk") &&
|
|
(strings.Contains(name, "boot") || strings.Contains(name, "rpmb")):
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// diskIsCandidate is the gate for whole-disk picker eligibility. We refuse
|
|
// system disks, virtual / firmware pseudo-disks (zram / loop / dm / eMMC boot
|
|
// areas), and any disk that has ANY currently-mounted child (mount-point != "")
|
|
// so the user can't nuke storage that's actively in use.
|
|
func (inv *Inventory) diskIsCandidate(d Drive) bool {
|
|
if inv.SystemDisks[d.Name] {
|
|
return false
|
|
}
|
|
if IsPseudoDisk(d.Name) {
|
|
return false
|
|
}
|
|
// A whole-disk btrfs that's a live pool member (mounted via a sibling disk).
|
|
if d.Role == RolePoolMember {
|
|
return false
|
|
}
|
|
for _, c := range inv.All {
|
|
if c.ParentDisk != d.Name {
|
|
continue
|
|
}
|
|
// Any mounted child, or any child that belongs to a btrfs pool, means
|
|
// wiping this disk would destroy storage that's actively in use.
|
|
if c.MountPoint != "" || c.Role == RolePoolMember {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// btrfsPoolMember reports whether an unmounted, formatted device is actually a
|
|
// member of a multi-device btrfs filesystem — recognised because btrfs gives
|
|
// every device of one filesystem the same UUID. Two cases qualify:
|
|
//
|
|
// - another device sharing this UUID is currently mounted (a live pool
|
|
// reached through a sibling, e.g. a 2-disk RAID mounted via /dev/sda while
|
|
// /dev/sdb shows no mountpoint of its own), or
|
|
// - two or more devices share the UUID (a detached multi-device pool).
|
|
//
|
|
// Such a device must never be offered for formatting: wiping any one member
|
|
// destroys the whole pool. Returns a human-readable detail for the inventory.
|
|
func btrfsPoolMember(d *Drive, uuidCount map[string]int, uuidMounted map[string]bool) (bool, string) {
|
|
if d.FSType != "btrfs" || d.UUID == "" {
|
|
return false, ""
|
|
}
|
|
name := d.Label
|
|
if name == "" {
|
|
name = "(unlabelled)"
|
|
}
|
|
switch {
|
|
case uuidMounted[d.UUID]:
|
|
return true, "IN USE — btrfs pool '" + name + "' (mounted via another device)"
|
|
case uuidCount[d.UUID] >= 2:
|
|
return true, "IN USE — btrfs pool '" + name + "' (multi-device, detached)"
|
|
}
|
|
return false, ""
|
|
}
|
|
|
|
// Managed returns drives the installer has previously set up (mounted under
|
|
// /media/<user>/). Used by the Manage tab as the destination list.
|
|
func (inv *Inventory) Managed() []Drive {
|
|
var out []Drive
|
|
for _, d := range inv.All {
|
|
if d.Role == RoleManagedOurs {
|
|
out = append(out, d)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// FindDisk returns the whole-disk Drive whose short name matches `name`
|
|
// (e.g. "sda" or "nvme0n1"), or nil if none. Used by the Add-drive flow when
|
|
// auto-collapsing same-parent selections into whole-disk single-drive mode.
|
|
func (inv *Inventory) FindDisk(name string) *Drive {
|
|
for i := range inv.All {
|
|
d := &inv.All[i]
|
|
if d.Type == "disk" && d.Name == name {
|
|
return d
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ChildrenOf returns every partition / crypt mapping whose underlying
|
|
// physical disk is `parentName` (e.g. "sda"). Used by the Add-drive confirm
|
|
// screen to list what would be erased if WipeWholeDisks is set.
|
|
func (inv *Inventory) ChildrenOf(parentName string) []Drive {
|
|
var out []Drive
|
|
for _, d := range inv.All {
|
|
if d.Type == "disk" {
|
|
continue
|
|
}
|
|
if d.ParentDisk == parentName {
|
|
out = append(out, d)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// UniqueParents returns the set of parent-disk names referenced by the
|
|
// passed drives, in first-seen order. Empty entries (drives that have no
|
|
// resolvable parent, like top-level disks themselves) are skipped.
|
|
func UniqueParents(ds []Drive) []string {
|
|
seen := map[string]bool{}
|
|
var out []string
|
|
for _, d := range ds {
|
|
if d.ParentDisk == "" || seen[d.ParentDisk] {
|
|
continue
|
|
}
|
|
seen[d.ParentDisk] = true
|
|
out = append(out, d.ParentDisk)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Mountable returns drives that have a recognisable filesystem on them but
|
|
// aren't currently mounted anywhere. Used by the Manage tab's "mount drive"
|
|
// flow on headless boxes where no auto-mount daemon runs. System disks and
|
|
// partitions on system disks are already excluded by the role classifier.
|
|
func (inv *Inventory) Mountable() []Drive {
|
|
supported := map[string]bool{
|
|
"ext4": true, "btrfs": true, "xfs": true,
|
|
"vfat": true, "exfat": true, "ntfs": true, "ntfs3": true,
|
|
"iso9660": true, "udf": true, "f2fs": true,
|
|
}
|
|
var out []Drive
|
|
for _, d := range inv.All {
|
|
if d.Role != RoleAvailable {
|
|
continue
|
|
}
|
|
if !supported[d.FSType] {
|
|
continue
|
|
}
|
|
out = append(out, d)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// External returns mounted drives that are NOT system mounts and NOT managed
|
|
// by us — anything the user has plugged in and that the desktop has
|
|
// auto-mounted (e.g. a USB stick at /run/media/<user>/MovieDump). Used by
|
|
// the Manage tab as the copy-source list.
|
|
func (inv *Inventory) External() []Drive {
|
|
var out []Drive
|
|
for _, d := range inv.All {
|
|
if d.Role != RoleMounted {
|
|
continue
|
|
}
|
|
// Anything mounted under /run/media, /media, /mnt is plausibly external.
|
|
mp := d.MountPoint
|
|
if strings.HasPrefix(mp, "/run/media/") || strings.HasPrefix(mp, "/media/") || strings.HasPrefix(mp, "/mnt/") {
|
|
out = append(out, d)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ---------------- internals ----------------
|
|
|
|
// lsblk's JSON output structure. We only model the fields we need.
|
|
type lsblkNode struct {
|
|
Name string `json:"name"` // bare name, e.g. "sda1"
|
|
Path string `json:"path"` // full path, e.g. "/dev/sda1"
|
|
Size string `json:"size"` // "3.6T"
|
|
FSType string `json:"fstype"` // ext4, swap, crypto_LUKS, ""
|
|
Label string `json:"label"` // fs label
|
|
UUID string `json:"uuid"` // fs uuid
|
|
Mountpoint string `json:"mountpoint"` // single-mountpoint field (older lsblk)
|
|
Mountpoints []string `json:"mountpoints"` // newer lsblk uses an array
|
|
Model string `json:"model"` // disk model (top-level only)
|
|
Tran string `json:"tran"` // transport (sata/usb/nvme)
|
|
Type string `json:"type"` // disk/part/crypt/lvm
|
|
RM bool `json:"rm"` // removable flag
|
|
HotPlug bool `json:"hotplug"` // hotplug flag
|
|
Children []lsblkNode `json:"children"`
|
|
}
|
|
|
|
func lsblkAll() ([]Drive, error) {
|
|
// No -b: SIZE comes back as a human-readable string like "3.6T".
|
|
out, err := cmdOutput(
|
|
"lsblk", "-J", "-p", "-o",
|
|
"NAME,PATH,SIZE,FSTYPE,LABEL,UUID,MOUNTPOINT,MOUNTPOINTS,MODEL,TRAN,TYPE,RM,HOTPLUG",
|
|
)
|
|
if err != nil {
|
|
// Older lsblk doesn't know MOUNTPOINTS — retry without it.
|
|
out, err = cmdOutput(
|
|
"lsblk", "-J", "-p", "-o",
|
|
"NAME,PATH,SIZE,FSTYPE,LABEL,UUID,MOUNTPOINT,MODEL,TRAN,TYPE,RM,HOTPLUG",
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
var root struct {
|
|
BlockDevices []lsblkNode `json:"blockdevices"`
|
|
}
|
|
if err := json.Unmarshal(out, &root); err != nil {
|
|
return nil, err
|
|
}
|
|
var flat []Drive
|
|
for _, n := range root.BlockDevices {
|
|
flat = append(flat, flatten(n, "")...)
|
|
}
|
|
return flat, nil
|
|
}
|
|
|
|
func flatten(n lsblkNode, _ string) []Drive {
|
|
d := Drive{
|
|
Path: n.Path,
|
|
Name: strings.TrimPrefix(n.Path, "/dev/"),
|
|
Size: n.Size,
|
|
FSType: n.FSType,
|
|
Label: n.Label,
|
|
UUID: n.UUID,
|
|
Model: n.Model,
|
|
Transport: n.Tran,
|
|
Type: n.Type,
|
|
Hotpluggable: n.RM || n.HotPlug,
|
|
}
|
|
d.MountPoint = n.Mountpoint
|
|
if d.MountPoint == "" && len(n.Mountpoints) > 0 {
|
|
for _, mp := range n.Mountpoints {
|
|
if mp != "" {
|
|
d.MountPoint = mp
|
|
break
|
|
}
|
|
}
|
|
}
|
|
out := []Drive{d}
|
|
for _, c := range n.Children {
|
|
out = append(out, flatten(c, n.Name)...)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// physicalDiskOf walks a device down to its underlying physical disk.
|
|
// Returns the disk's short name (e.g. "sda", "nvme0n1") or "" if unresolved.
|
|
func physicalDiskOf(devPath string) string {
|
|
out, err := cmdOutput("lsblk", "-s", "-n", "-l", "-o", "NAME,TYPE", devPath)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
sc := bufio.NewScanner(strings.NewReader(string(out)))
|
|
for sc.Scan() {
|
|
fields := strings.Fields(sc.Text())
|
|
if len(fields) >= 2 && fields[len(fields)-1] == "disk" {
|
|
return fields[0]
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// systemDisks returns the set of physical disks that host any system mount
|
|
// or active swap. Names are short (sda, nvme0n1).
|
|
func systemDisks() (map[string]bool, error) {
|
|
out := map[string]bool{}
|
|
mounts := []string{
|
|
"/", "/boot", "/boot/efi", "/efi", "/home", "/usr",
|
|
"/var", "/var/lib", "/var/log", "/tmp", "/opt", "/srv", "/nix",
|
|
}
|
|
for _, mp := range mounts {
|
|
src := findmntSource(mp)
|
|
if src == "" {
|
|
continue
|
|
}
|
|
if disk := physicalDiskOf(src); disk != "" {
|
|
out[disk] = true
|
|
}
|
|
}
|
|
// Swap from /proc/swaps
|
|
if f, err := os.Open("/proc/swaps"); err == nil {
|
|
defer f.Close()
|
|
sc := bufio.NewScanner(f)
|
|
first := true
|
|
for sc.Scan() {
|
|
if first { // header row
|
|
first = false
|
|
continue
|
|
}
|
|
fields := strings.Fields(sc.Text())
|
|
if len(fields) == 0 || strings.HasPrefix(fields[0], "#") {
|
|
continue
|
|
}
|
|
dev := fields[0]
|
|
if !strings.HasPrefix(dev, "/dev/") {
|
|
continue
|
|
}
|
|
if _, err := os.Stat(dev); err != nil {
|
|
continue
|
|
}
|
|
if disk := physicalDiskOf(dev); disk != "" {
|
|
out[disk] = true
|
|
}
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func findmntSource(mp string) string {
|
|
out, err := cmdOutput("findmnt", "-no", "SOURCE", mp)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(string(out))
|
|
}
|
|
|
|
// managedMounts returns the set of mountpoints under /media/<user>/ that this
|
|
// installer has previously added via fstab. Best-effort: we just enumerate
|
|
// the directories under /media/<user>/ that are currently mounted.
|
|
func managedMounts(user string) map[string]bool {
|
|
out := map[string]bool{}
|
|
if user == "" {
|
|
return out
|
|
}
|
|
root := filepath.Join("/media", user)
|
|
entries, err := os.ReadDir(root)
|
|
if err != nil {
|
|
return out
|
|
}
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
continue
|
|
}
|
|
mp := filepath.Join(root, e.Name())
|
|
// Treat as managed if something is mounted there.
|
|
if cmdRunOK("mountpoint", "-q", mp) {
|
|
out[mp] = true
|
|
}
|
|
}
|
|
return out
|
|
}
|