TUISTREAM/internal/drives/pool_import.go
28allday 959e507a83 Initial commit: TUISTREAM — headless Jellyfin TUI for Omarchy/Arch
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>
2026-05-31 16:47:18 +01:00

193 lines
5.9 KiB
Go

package drives
import (
"fmt"
"os/exec"
"path/filepath"
"tuistream/internal/step"
)
// DetachedPool is a multi-device btrfs filesystem whose member devices are all
// present but none is currently mounted — a pool left over from a previous
// setup that can be re-attached WITHOUT reformatting.
//
// The classifier marks these devices RolePoolMember and (rightly) refuses to
// offer them for the destructive Add-drive flow, because wiping any one member
// destroys the whole pool. Import is the safe counterpart: it mounts the pool
// as-is. Single-device btrfs drives are NOT pools — they're handled by the
// normal Add-drive "Keep existing filesystem" path.
type DetachedPool struct {
UUID string // shared across every member device
Label string // btrfs label, "" if unlabelled
Members []Drive // every device carrying this UUID, in inventory order
}
// FirstDevice returns the device path to mount the pool via; btrfs resolves the
// remaining members from the kernel's device scan. "" if the pool has no
// members (shouldn't happen for a value returned by DetachedPools).
func (p DetachedPool) FirstDevice() string {
if len(p.Members) == 0 {
return ""
}
return p.Members[0].Path
}
// DisplayLabel returns the label or a placeholder for the UI.
func (p DetachedPool) DisplayLabel() string {
if p.Label != "" {
return p.Label
}
return "(unlabelled)"
}
// DetachedPools groups the inventory's btrfs devices by UUID and returns those
// that look like an importable detached pool: 2+ member devices, none mounted,
// and none living on a system disk. These are exactly the devices the role
// classifier flags as "btrfs pool … (multi-device, detached)".
func (inv *Inventory) DetachedPools() []DetachedPool {
type group struct {
label string
members []Drive
mounted bool
onSys bool
}
var order []string
groups := map[string]*group{}
for _, d := range inv.All {
if d.FSType != "btrfs" || d.UUID == "" {
continue
}
g := groups[d.UUID]
if g == nil {
g = &group{}
groups[d.UUID] = g
order = append(order, d.UUID)
}
g.members = append(g.members, d)
if d.Label != "" {
g.label = d.Label
}
if d.MountPoint != "" {
g.mounted = true
}
if inv.SystemDisks[d.Name] || inv.SystemDisks[d.ParentDisk] {
g.onSys = true
}
}
var out []DetachedPool
for _, u := range order {
g := groups[u]
// 2+ members → genuinely a multi-device pool (single-device btrfs is the
// Add-drive Keep path). All unmounted → "detached". Not on a system disk.
if len(g.members) < 2 || g.mounted || g.onSys {
continue
}
out = append(out, DetachedPool{UUID: u, Label: g.label, Members: g.members})
}
return out
}
// ImportPoolOptions captures an import-existing-pool run.
type ImportPoolOptions struct {
Pool DetachedPool
Label string // friendly directory name under /media/<user>/
User string
// SeedStarterFolders creates the Jellyfin library folders under
// <mount>/JellyfinMedia/. Left off when the pool already holds content so
// we don't clutter an existing library layout.
SeedStarterFolders bool
// RecursiveACL grants jellyfin read access over the whole existing tree
// (setfacl -R), not just the mount root. Set when the pool already holds
// media so pre-existing files copied with tight permissions stay readable.
RecursiveACL bool
}
// ImportPoolPlan builds the ordered, NON-DESTRUCTIVE commands to re-attach an
// existing btrfs pool at /media/<user>/<label>:
//
// 0. Ensure btrfs-progs + acl are present
// 1. mkdir the mount point
// 2. Back up /etc/fstab
// 3. Append a UUID-based fstab line (btrfs assembles all members from the UUID)
// 4. daemon-reload + btrfs device scan + mount -a
// 5. ACL: grant the jellyfin user read (if the user exists)
// 6. Optionally seed the Jellyfin starter folders
//
// Nothing here formats, wipes, or partitions — the pool's data is untouched.
func ImportPoolPlan(opts ImportPoolOptions) ([]step.Step, error) {
dev := opts.Pool.FirstDevice()
if dev == "" {
return nil, fmt.Errorf("ImportPoolPlan: pool has no member devices")
}
if opts.Label == "" {
return nil, fmt.Errorf("ImportPoolPlan: empty label")
}
if opts.User == "" {
return nil, fmt.Errorf("ImportPoolPlan: no target user (SUDO_USER)")
}
mountPoint := filepath.Join("/media", opts.User, opts.Label)
var steps []step.Step
if prereq, ok := PrereqStepImportPool(); ok {
steps = append(steps, prereq)
}
steps = append(steps, step.Step{
Title: "Create mount point " + mountPoint,
Cmd: exec.Command("install", "-d", "-o", opts.User, "-g", opts.User, "-m", "0755", mountPoint),
})
steps = append(steps, step.Step{
Title: "Back up /etc/fstab",
Cmd: bashAsRoot(`cp -a /etc/fstab "/etc/fstab.bak.$(date +%s)"`),
})
steps = append(steps, step.Step{
Title: "Add UUID-based /etc/fstab entry for the pool",
Cmd: bashAsRoot(fmt.Sprintf(`
set -e
DEV=%s
MP=%s
LABEL=%s
# Make sure the kernel knows every member device before we read the UUID,
# otherwise a cold pool can read back as not-yet-assembled.
btrfs device scan >/dev/null 2>&1 || true
UUID="$(blkid -s UUID -o value "$DEV")"
if [ -z "$UUID" ]; then
echo "Couldn't read UUID for $DEV"
exit 1
fi
# btrfs mounts via any constituent device. nofail so a missing drive at boot
# doesn't drop the box into emergency mode.
OPTS="defaults,nofail,x-gvfs-show,x-gvfs-name=$LABEL"
LINE="UUID=${UUID} ${MP} btrfs ${OPTS} 0 0"
grep -qF -- "$LINE" /etc/fstab || printf '%%s\n' "$LINE" >> /etc/fstab
echo "fstab: $LINE"
`,
shellQuote(dev),
shellQuote(mountPoint),
shellQuote(opts.Label),
)),
})
steps = append(steps, step.Step{
Title: "Reload systemd & mount the pool",
Cmd: bashAsRoot("systemctl daemon-reload; btrfs device scan >/dev/null 2>&1 || true; mount -a"),
})
steps = append(steps, aclGrantStep(mountPoint, opts.RecursiveACL))
if opts.SeedStarterFolders {
steps = append(steps, starterFoldersStep(opts.User, mountPoint))
}
return steps, nil
}