feat(installer): add 'Install to disk' in Tools submenu
Copies the live system to a local disk via unsquashfs — no debootstrap, no network required. Supports UEFI (GPT+EFI) and BIOS (MBR) layouts. ISO: - Add squashfs-tools, parted, grub-pc, grub-efi-amd64 to package list - New overlay script bee-install: partitions, formats, unsquashfs, writes fstab, runs grub-install+update-grub in chroot Go TUI: - Settings → Tools submenu (Install to disk, Check tools) - Disk picker screen: lists non-USB, non-boot disks via lsblk - Confirm screen warns about data loss - Runs with live progress tail of /tmp/bee-install.log - platform/install.go: ListInstallDisks, InstallToDisk, findLiveBootDevice Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -141,7 +141,9 @@ func (m model) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
)
|
||||
case actionRunFanStress:
|
||||
return m.startGPUStressTest()
|
||||
case actionRunNCCLTests:
|
||||
case actionInstallToDisk:
|
||||
return m.startInstall()
|
||||
case actionRunNCCLTests:
|
||||
m.busy = true
|
||||
m.busyTitle = "NCCL bandwidth test"
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
@@ -165,6 +167,8 @@ func (m model) confirmCancelTarget() screen {
|
||||
return screenHealthCheck
|
||||
case actionRunFanStress, actionRunNCCLTests:
|
||||
return screenBurnInTests
|
||||
case actionInstallToDisk:
|
||||
return screenInstallDiskPick
|
||||
default:
|
||||
return screenMain
|
||||
}
|
||||
|
||||
@@ -50,3 +50,12 @@ type gpuLiveTickMsg struct {
|
||||
rows []platform.GPUMetricRow
|
||||
indices []int
|
||||
}
|
||||
|
||||
type installDisksMsg struct {
|
||||
disks []platform.InstallDisk
|
||||
err error
|
||||
}
|
||||
|
||||
type installDoneMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
@@ -123,6 +123,26 @@ func cleanSATStepName(name string) string {
|
||||
return name
|
||||
}
|
||||
|
||||
// pollInstallProgress tails the install log file and returns recent lines as progress.
|
||||
func pollInstallProgress(logFile string) tea.Cmd {
|
||||
return tea.Tick(500*time.Millisecond, func(_ time.Time) tea.Msg {
|
||||
return satProgressMsg{lines: readInstallProgressLines(logFile)}
|
||||
})
|
||||
}
|
||||
|
||||
func readInstallProgressLines(logFile string) []string {
|
||||
raw, err := os.ReadFile(logFile)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
|
||||
// Show last 12 lines
|
||||
if len(lines) > 12 {
|
||||
lines = lines[len(lines)-12:]
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func fmtDurMs(ms int) string {
|
||||
if ms < 1000 {
|
||||
return fmt.Sprintf("%dms", ms)
|
||||
|
||||
@@ -44,17 +44,10 @@ func (m model) handleSettingsMenu() (tea.Model, tea.Cmd) {
|
||||
result := m.app.AuditLogTailResult()
|
||||
return resultMsg{title: result.Title, body: result.Body, back: screenSettings}
|
||||
}
|
||||
case 6: // Check tools
|
||||
m.busy = true
|
||||
m.busyTitle = "Check tools"
|
||||
return m, func() tea.Msg {
|
||||
result := m.app.ToolCheckResult([]string{
|
||||
"dmidecode", "smartctl", "nvme", "ipmitool", "lspci",
|
||||
"ethtool", "bee", "nvidia-smi", "bee-gpu-stress",
|
||||
"memtester", "dhclient", "lsblk", "mount",
|
||||
})
|
||||
return resultMsg{title: result.Title, body: result.Body, back: screenSettings}
|
||||
}
|
||||
case 6: // Tools
|
||||
m.screen = screenTools
|
||||
m.cursor = 0
|
||||
return m, nil
|
||||
case 7: // Back
|
||||
m.screen = screenMain
|
||||
m.cursor = 0
|
||||
|
||||
108
audit/internal/tui/screen_tools.go
Normal file
108
audit/internal/tui/screen_tools.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bee/audit/internal/platform"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// handleToolsMenu handles the Tools submenu selection.
|
||||
func (m model) handleToolsMenu() (tea.Model, tea.Cmd) {
|
||||
switch m.cursor {
|
||||
case 0: // Install to disk
|
||||
m.busy = true
|
||||
m.busyTitle = "Scanning disks"
|
||||
return m, func() tea.Msg {
|
||||
disks, err := m.app.ListInstallDisks()
|
||||
return installDisksMsg{disks: disks, err: err}
|
||||
}
|
||||
case 1: // Check tools
|
||||
m.busy = true
|
||||
m.busyTitle = "Check tools"
|
||||
return m, func() tea.Msg {
|
||||
result := m.app.ToolCheckResult([]string{
|
||||
"dmidecode", "smartctl", "nvme", "ipmitool", "lspci",
|
||||
"ethtool", "bee", "nvidia-smi", "bee-gpu-stress",
|
||||
"memtester", "dhclient", "lsblk", "mount",
|
||||
"unsquashfs", "parted", "grub-install", "bee-install",
|
||||
"all_reduce_perf", "dcgmi",
|
||||
})
|
||||
return resultMsg{title: result.Title, body: result.Body, back: screenTools}
|
||||
}
|
||||
case 2: // Back
|
||||
m.screen = screenSettings
|
||||
m.cursor = 0
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// handleInstallDiskPickMenu handles disk selection for installation.
|
||||
func (m model) handleInstallDiskPickMenu() (tea.Model, tea.Cmd) {
|
||||
if m.cursor >= len(m.installDisks) {
|
||||
return m, nil
|
||||
}
|
||||
m.selectedDisk = m.installDisks[m.cursor].Device
|
||||
m.pendingAction = actionInstallToDisk
|
||||
m.screen = screenConfirm
|
||||
m.cursor = 0
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// startInstall launches the bee-install script and polls its log for progress.
|
||||
func (m model) startInstall() (tea.Model, tea.Cmd) {
|
||||
device := m.selectedDisk
|
||||
logFile := DefaultInstallLogFile
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
m.installCancel = cancel
|
||||
m.installSince = time.Now()
|
||||
m.busy = true
|
||||
m.busyTitle = "Install to disk: " + device
|
||||
m.progressLines = nil
|
||||
|
||||
installCmd := func() tea.Msg {
|
||||
err := m.app.InstallToDisk(ctx, device, logFile)
|
||||
return installDoneMsg{err: err}
|
||||
}
|
||||
|
||||
return m, tea.Batch(installCmd, pollInstallProgress(logFile))
|
||||
}
|
||||
|
||||
func renderInstallDiskPick(m model) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintln(&b, "INSTALL TO DISK")
|
||||
fmt.Fprintln(&b)
|
||||
fmt.Fprintln(&b, " WARNING: the selected disk will be completely WIPED.")
|
||||
fmt.Fprintln(&b)
|
||||
fmt.Fprintln(&b, " Available disks (USB and boot media excluded):")
|
||||
fmt.Fprintln(&b)
|
||||
if len(m.installDisks) == 0 {
|
||||
fmt.Fprintln(&b, " (no suitable disks found)")
|
||||
} else {
|
||||
for i, d := range m.installDisks {
|
||||
pfx := " "
|
||||
if m.cursor == i {
|
||||
pfx = "> "
|
||||
}
|
||||
fmt.Fprintf(&b, "%s%s\n", pfx, diskLabel(d))
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(&b)
|
||||
fmt.Fprintln(&b, "─────────────────────────────────────────────────────────────────")
|
||||
fmt.Fprint(&b, "[↑/↓] move [enter] select [esc] back [ctrl+c] quit")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func diskLabel(d platform.InstallDisk) string {
|
||||
model := d.Model
|
||||
if model == "" {
|
||||
model = "Unknown"
|
||||
}
|
||||
return fmt.Sprintf("%-12s %-8s %s", d.Device, d.Size, model)
|
||||
}
|
||||
@@ -11,6 +11,9 @@ import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// DefaultInstallLogFile is where bee-install writes its progress log.
|
||||
const DefaultInstallLogFile = "/tmp/bee-install.log"
|
||||
|
||||
type screen string
|
||||
|
||||
const (
|
||||
@@ -29,6 +32,8 @@ const (
|
||||
screenNvidiaSATSetup screen = "nvidia_sat_setup"
|
||||
screenNvidiaSATRunning screen = "nvidia_sat_running"
|
||||
screenGPUStressRunning screen = "gpu_stress_running"
|
||||
screenTools screen = "tools"
|
||||
screenInstallDiskPick screen = "install_disk_pick"
|
||||
)
|
||||
|
||||
type actionKind string
|
||||
@@ -45,6 +50,7 @@ const (
|
||||
actionRunAMDGPUSAT actionKind = "run_amd_gpu_sat"
|
||||
actionRunFanStress actionKind = "run_fan_stress"
|
||||
actionRunNCCLTests actionKind = "run_nccl_tests"
|
||||
actionInstallToDisk actionKind = "install_to_disk"
|
||||
)
|
||||
|
||||
type model struct {
|
||||
@@ -62,13 +68,16 @@ type model struct {
|
||||
settingsMenu []string
|
||||
networkMenu []string
|
||||
serviceMenu []string
|
||||
toolsMenu []string
|
||||
|
||||
services []string
|
||||
interfaces []platform.InterfaceInfo
|
||||
targets []platform.RemovableTarget
|
||||
installDisks []platform.InstallDisk
|
||||
selectedService string
|
||||
selectedIface string
|
||||
selectedTarget *platform.RemovableTarget
|
||||
selectedDisk string
|
||||
pendingAction actionKind
|
||||
|
||||
formFields []formField
|
||||
@@ -102,6 +111,10 @@ type model struct {
|
||||
// NCCL tests running
|
||||
ncclCancel func()
|
||||
|
||||
// Install to disk
|
||||
installCancel func()
|
||||
installSince time.Time
|
||||
|
||||
// GPU Platform Stress Test running
|
||||
gpuStressCancel func()
|
||||
gpuStressAborted bool
|
||||
@@ -152,6 +165,11 @@ func newModel(application *app.App, runtimeMode runtimeenv.Mode) model {
|
||||
"Run self-check",
|
||||
"Runtime issues",
|
||||
"Audit logs",
|
||||
"Tools",
|
||||
"Back",
|
||||
},
|
||||
toolsMenu: []string{
|
||||
"Install to disk",
|
||||
"Check tools",
|
||||
"Back",
|
||||
},
|
||||
@@ -206,6 +224,12 @@ func (m model) confirmBody() (string, string) {
|
||||
return "CPU test", "Run stress-ng? Mode: " + modes[m.hcMode]
|
||||
case actionRunAMDGPUSAT:
|
||||
return "AMD GPU test", "Run AMD GPU diagnostic pack (rocm-smi)?"
|
||||
case actionInstallToDisk:
|
||||
dev := m.selectedDisk
|
||||
if dev == "" {
|
||||
dev = "unknown"
|
||||
}
|
||||
return "Install to disk", "WARNING: " + dev + " will be completely WIPED.\n\nAll data on the disk will be lost!\n\nInstall bee live system to " + dev + "?"
|
||||
case actionRunNCCLTests:
|
||||
return "NCCL bandwidth test", "Run all_reduce_perf across all GPUs?\n\nMeasures collective bandwidth over NVLink/PCIe.\nRequires 2+ GPUs for meaningful results."
|
||||
case actionRunFanStress:
|
||||
|
||||
@@ -33,6 +33,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, pollSATProgress(m.progressPrefix, m.progressSince)
|
||||
}
|
||||
if m.busy && m.installCancel != nil {
|
||||
if len(msg.lines) > 0 {
|
||||
m.progressLines = msg.lines
|
||||
}
|
||||
return m, pollInstallProgress(DefaultInstallLogFile)
|
||||
}
|
||||
return m, nil
|
||||
case snapshotMsg:
|
||||
m.banner = msg.banner
|
||||
@@ -112,6 +118,44 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.screen = screenExportTargets
|
||||
m.cursor = 0
|
||||
return m, m.refreshSnapshotCmd()
|
||||
case installDisksMsg:
|
||||
m.busy = false
|
||||
m.busyTitle = ""
|
||||
if msg.err != nil {
|
||||
m.title = "Install to disk"
|
||||
m.body = msg.err.Error()
|
||||
m.prevScreen = screenTools
|
||||
m.screen = screenOutput
|
||||
return m, m.refreshSnapshotCmd()
|
||||
}
|
||||
if len(msg.disks) == 0 {
|
||||
m.title = "Install to disk"
|
||||
m.body = "No suitable disks found.\n\nOnly non-USB, non-boot disks are shown.\nAttach a target disk and try again."
|
||||
m.prevScreen = screenTools
|
||||
m.screen = screenOutput
|
||||
return m, m.refreshSnapshotCmd()
|
||||
}
|
||||
m.installDisks = msg.disks
|
||||
m.screen = screenInstallDiskPick
|
||||
m.cursor = 0
|
||||
return m, m.refreshSnapshotCmd()
|
||||
case installDoneMsg:
|
||||
if m.installCancel != nil {
|
||||
m.installCancel()
|
||||
m.installCancel = nil
|
||||
}
|
||||
m.busy = false
|
||||
m.busyTitle = ""
|
||||
m.progressLines = nil
|
||||
m.prevScreen = screenTools
|
||||
m.screen = screenOutput
|
||||
m.title = "Install to disk"
|
||||
if msg.err != nil {
|
||||
m.body = fmt.Sprintf("Installation FAILED.\n\nLog: %s\n\nERROR: %v", DefaultInstallLogFile, msg.err)
|
||||
} else {
|
||||
m.body = fmt.Sprintf("Installation complete.\n\nRemove the ISO and reboot to start the installed system.\n\nLog: %s", DefaultInstallLogFile)
|
||||
}
|
||||
return m, m.refreshSnapshotCmd()
|
||||
case nvtopClosedMsg:
|
||||
return m, nil
|
||||
case gpuStressDoneMsg:
|
||||
@@ -204,6 +248,10 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m.updateMenu(msg, len(m.targets), m.handleExportTargetsMenu)
|
||||
case screenInterfacePick:
|
||||
return m.updateMenu(msg, len(m.interfaces), m.handleInterfacePickMenu)
|
||||
case screenTools:
|
||||
return m.updateMenu(msg, len(m.toolsMenu), m.handleToolsMenu)
|
||||
case screenInstallDiskPick:
|
||||
return m.updateMenu(msg, len(m.installDisks), m.handleInstallDiskPickMenu)
|
||||
case screenOutput:
|
||||
switch msg.String() {
|
||||
case "esc", "enter", "q":
|
||||
@@ -300,6 +348,12 @@ func (m model) updateMenu(msg tea.KeyMsg, size int, onEnter func() (tea.Model, t
|
||||
case screenInterfacePick:
|
||||
m.screen = screenNetwork
|
||||
m.cursor = 0
|
||||
case screenTools:
|
||||
m.screen = screenSettings
|
||||
m.cursor = 0
|
||||
case screenInstallDiskPick:
|
||||
m.screen = screenTools
|
||||
m.cursor = 0
|
||||
}
|
||||
case "q", "ctrl+c":
|
||||
return m, tea.Quit
|
||||
|
||||
@@ -76,6 +76,10 @@ func (m model) View() string {
|
||||
)
|
||||
case screenInterfacePick:
|
||||
body = renderMenu("Interfaces", "Select interface", renderInterfaceItems(m.interfaces), m.cursor)
|
||||
case screenTools:
|
||||
body = renderMenu("Tools", "Select action", m.toolsMenu, m.cursor)
|
||||
case screenInstallDiskPick:
|
||||
body = renderInstallDiskPick(m)
|
||||
case screenStaticForm:
|
||||
body = renderForm("Static IPv4: "+m.selectedIface, m.formFields, m.formIndex)
|
||||
case screenConfirm:
|
||||
|
||||
Reference in New Issue
Block a user