package platform import ( "context" "fmt" "os" "os/exec" "strconv" "strings" ) // InstallDisk describes a candidate disk for installation. type InstallDisk struct { Device string // e.g. /dev/sda Model string Size string // human-readable, e.g. "500G" SizeBytes int64 // raw byte count from lsblk MountedParts []string // partition mount points currently active } const squashfsPath = "/run/live/medium/live/filesystem.squashfs" // ListInstallDisks returns block devices suitable for installation. // Excludes the current live boot medium but includes USB drives. func (s *System) ListInstallDisks() ([]InstallDisk, error) { out, err := exec.Command("lsblk", "-dn", "-o", "NAME,MODEL,SIZE,TYPE,TRAN").Output() if err != nil { return nil, fmt.Errorf("lsblk: %w", err) } bootDev := findLiveBootDevice() var disks []InstallDisk for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { fields := strings.Fields(line) // NAME MODEL SIZE TYPE TRAN — model may have spaces so we parse from end if len(fields) < 4 { continue } // Last field: TRAN, second-to-last: TYPE, third-to-last: SIZE typ := fields[len(fields)-2] size := fields[len(fields)-3] name := fields[0] model := strings.Join(fields[1:len(fields)-3], " ") if typ != "disk" { continue } device := "/dev/" + name if device == bootDev { continue } sizeBytes := diskSizeBytes(device) mounted := mountedParts(device) disks = append(disks, InstallDisk{ Device: device, Model: strings.TrimSpace(model), Size: size, SizeBytes: sizeBytes, MountedParts: mounted, }) } return disks, nil } // diskSizeBytes returns the byte size of a block device using lsblk. func diskSizeBytes(device string) int64 { out, err := exec.Command("lsblk", "-bdn", "-o", "SIZE", device).Output() if err != nil { return 0 } n, _ := strconv.ParseInt(strings.TrimSpace(string(out)), 10, 64) return n } // mountedParts returns a list of " at " strings for any // mounted partitions on the given device. func mountedParts(device string) []string { out, err := exec.Command("lsblk", "-n", "-o", "NAME,MOUNTPOINT", device).Output() if err != nil { return nil } var result []string for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { fields := strings.Fields(line) if len(fields) < 2 { continue } mp := fields[1] if mp == "" || mp == "[SWAP]" { continue } result = append(result, "/dev/"+strings.TrimLeft(fields[0], "└─├─")+" at "+mp) } return result } // findLiveBootDevice returns the block device backing /run/live/medium (if any). func findLiveBootDevice() string { out, err := exec.Command("findmnt", "-n", "-o", "SOURCE", "/run/live/medium").Output() if err != nil { return "" } src := strings.TrimSpace(string(out)) if src == "" { return "" } // Strip partition suffix to get the whole disk device. // e.g. /dev/sdb1 → /dev/sdb, /dev/nvme0n1p1 → /dev/nvme0n1 out2, err := exec.Command("lsblk", "-no", "PKNAME", src).Output() if err != nil || strings.TrimSpace(string(out2)) == "" { return src } return "/dev/" + strings.TrimSpace(string(out2)) } // MinInstallBytes returns the minimum recommended disk size for installation: // squashfs size × 1.5 to allow for extracted filesystem and bootloader. // Returns 0 if the squashfs is not available (non-live environment). func MinInstallBytes() int64 { fi, err := os.Stat(squashfsPath) if err != nil { return 0 } return fi.Size() * 3 / 2 } // toramActive returns true when the live system was booted with toram. func toramActive() bool { data, err := os.ReadFile("/proc/cmdline") if err != nil { return false } return strings.Contains(string(data), "toram") } // freeMemBytes returns MemAvailable from /proc/meminfo. func freeMemBytes() int64 { data, err := os.ReadFile("/proc/meminfo") if err != nil { return 0 } for _, line := range strings.Split(string(data), "\n") { if strings.HasPrefix(line, "MemAvailable:") { fields := strings.Fields(line) if len(fields) >= 2 { n, _ := strconv.ParseInt(fields[1], 10, 64) return n * 1024 // kB → bytes } } } return 0 } // DiskWarnings returns advisory warning strings for a disk candidate. func DiskWarnings(d InstallDisk) []string { var w []string if len(d.MountedParts) > 0 { w = append(w, "has mounted partitions: "+strings.Join(d.MountedParts, ", ")) } min := MinInstallBytes() if min > 0 && d.SizeBytes > 0 && d.SizeBytes < min { w = append(w, fmt.Sprintf("disk may be too small (need ≥ %s, have %s)", humanBytes(min), humanBytes(d.SizeBytes))) } if toramActive() { sqFi, err := os.Stat(squashfsPath) if err == nil { free := freeMemBytes() if free > 0 && free < sqFi.Size()*2 { w = append(w, "toram mode — low RAM, extraction may be slow or fail") } } } return w } func humanBytes(b int64) string { const unit = 1024 if b < unit { return fmt.Sprintf("%d B", b) } div, exp := int64(unit), 0 for n := b / unit; n >= unit; n /= unit { div *= unit exp++ } return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp]) } // InstallToDisk runs bee-install and streams output to logFile. // The context can be used to cancel. func (s *System) InstallToDisk(ctx context.Context, device string, logFile string) error { cmd := exec.CommandContext(ctx, "bee-install", device, logFile) return cmd.Run() } // InstallLogPath returns the default install log path for a given device. func InstallLogPath(device string) string { safe := strings.NewReplacer("/", "_", " ", "_").Replace(device) return "/tmp/bee-install" + safe + ".log" } // Label returns a display label for a disk. func (d InstallDisk) Label() string { model := d.Model if model == "" { model = "Unknown" } return fmt.Sprintf("%s %s %s", d.Device, d.Size, model) }