Expose the existing bee-install script through the web UI: - platform/install.go: remove USB exclusion, add SizeBytes/MountedParts fields, add MinInstallBytes()/DiskWarnings() safety checks (size, mounted partitions, toram+low-RAM warning) - webui: add GET /api/install/disks, POST /api/install/run, GET /api/install/stream endpoints - webui: add Install to Disk page with disk table, warning badges, device-name confirmation gate, SSE progress terminal, reboot button Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
215 lines
5.8 KiB
Go
215 lines
5.8 KiB
Go
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 "<part> at <mountpoint>" 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 <device> <logfile> 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)
|
||
}
|