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:
@@ -33,12 +33,13 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type App struct {
|
type App struct {
|
||||||
network networkManager
|
network networkManager
|
||||||
services serviceManager
|
services serviceManager
|
||||||
exports exportManager
|
exports exportManager
|
||||||
tools toolManager
|
tools toolManager
|
||||||
sat satRunner
|
sat satRunner
|
||||||
runtime runtimeChecker
|
runtime runtimeChecker
|
||||||
|
installer installer
|
||||||
}
|
}
|
||||||
|
|
||||||
type ActionResult struct {
|
type ActionResult struct {
|
||||||
@@ -70,6 +71,11 @@ type toolManager interface {
|
|||||||
CheckTools(names []string) []platform.ToolStatus
|
CheckTools(names []string) []platform.ToolStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type installer interface {
|
||||||
|
ListInstallDisks() ([]platform.InstallDisk, error)
|
||||||
|
InstallToDisk(ctx context.Context, device string, logFile string) error
|
||||||
|
}
|
||||||
|
|
||||||
type satRunner interface {
|
type satRunner interface {
|
||||||
RunNvidiaAcceptancePack(baseDir string) (string, error)
|
RunNvidiaAcceptancePack(baseDir string) (string, error)
|
||||||
RunNvidiaAcceptancePackWithOptions(ctx context.Context, baseDir string, diagLevel int, gpuIndices []int) (string, error)
|
RunNvidiaAcceptancePackWithOptions(ctx context.Context, baseDir string, diagLevel int, gpuIndices []int) (string, error)
|
||||||
@@ -91,12 +97,13 @@ type runtimeChecker interface {
|
|||||||
|
|
||||||
func New(platform *platform.System) *App {
|
func New(platform *platform.System) *App {
|
||||||
return &App{
|
return &App{
|
||||||
network: platform,
|
network: platform,
|
||||||
services: platform,
|
services: platform,
|
||||||
exports: platform,
|
exports: platform,
|
||||||
tools: platform,
|
tools: platform,
|
||||||
sat: platform,
|
sat: platform,
|
||||||
runtime: platform,
|
runtime: platform,
|
||||||
|
installer: platform,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1003,3 +1010,11 @@ func firstNonEmpty(values ...string) string {
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) ListInstallDisks() ([]platform.InstallDisk, error) {
|
||||||
|
return a.installer.ListInstallDisks()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) InstallToDisk(ctx context.Context, device string, logFile string) error {
|
||||||
|
return a.installer.InstallToDisk(ctx, device, logFile)
|
||||||
|
}
|
||||||
|
|||||||
105
audit/internal/platform/install.go
Normal file
105
audit/internal/platform/install.go
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListInstallDisks returns block devices suitable for installation.
|
||||||
|
// Excludes USB drives and the current live boot medium.
|
||||||
|
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
|
||||||
|
tran := fields[len(fields)-1]
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if strings.EqualFold(tran, "usb") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
device := "/dev/" + name
|
||||||
|
if device == bootDev {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
disks = append(disks, InstallDisk{
|
||||||
|
Device: device,
|
||||||
|
Model: strings.TrimSpace(model),
|
||||||
|
Size: size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return disks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiskLabel returns a display label for a disk.
|
||||||
|
func (d InstallDisk) Label() string {
|
||||||
|
model := d.Model
|
||||||
|
if model == "" {
|
||||||
|
model = "Unknown"
|
||||||
|
}
|
||||||
|
sizeBytes, err := strconv.ParseInt(strings.TrimSuffix(d.Size, "B"), 10, 64)
|
||||||
|
_ = sizeBytes
|
||||||
|
_ = err
|
||||||
|
return fmt.Sprintf("%s %s %s", d.Device, d.Size, model)
|
||||||
|
}
|
||||||
@@ -141,7 +141,9 @@ func (m model) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
)
|
)
|
||||||
case actionRunFanStress:
|
case actionRunFanStress:
|
||||||
return m.startGPUStressTest()
|
return m.startGPUStressTest()
|
||||||
case actionRunNCCLTests:
|
case actionInstallToDisk:
|
||||||
|
return m.startInstall()
|
||||||
|
case actionRunNCCLTests:
|
||||||
m.busy = true
|
m.busy = true
|
||||||
m.busyTitle = "NCCL bandwidth test"
|
m.busyTitle = "NCCL bandwidth test"
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
@@ -165,6 +167,8 @@ func (m model) confirmCancelTarget() screen {
|
|||||||
return screenHealthCheck
|
return screenHealthCheck
|
||||||
case actionRunFanStress, actionRunNCCLTests:
|
case actionRunFanStress, actionRunNCCLTests:
|
||||||
return screenBurnInTests
|
return screenBurnInTests
|
||||||
|
case actionInstallToDisk:
|
||||||
|
return screenInstallDiskPick
|
||||||
default:
|
default:
|
||||||
return screenMain
|
return screenMain
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,3 +50,12 @@ type gpuLiveTickMsg struct {
|
|||||||
rows []platform.GPUMetricRow
|
rows []platform.GPUMetricRow
|
||||||
indices []int
|
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
|
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 {
|
func fmtDurMs(ms int) string {
|
||||||
if ms < 1000 {
|
if ms < 1000 {
|
||||||
return fmt.Sprintf("%dms", ms)
|
return fmt.Sprintf("%dms", ms)
|
||||||
|
|||||||
@@ -44,17 +44,10 @@ func (m model) handleSettingsMenu() (tea.Model, tea.Cmd) {
|
|||||||
result := m.app.AuditLogTailResult()
|
result := m.app.AuditLogTailResult()
|
||||||
return resultMsg{title: result.Title, body: result.Body, back: screenSettings}
|
return resultMsg{title: result.Title, body: result.Body, back: screenSettings}
|
||||||
}
|
}
|
||||||
case 6: // Check tools
|
case 6: // Tools
|
||||||
m.busy = true
|
m.screen = screenTools
|
||||||
m.busyTitle = "Check tools"
|
m.cursor = 0
|
||||||
return m, func() tea.Msg {
|
return m, nil
|
||||||
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 7: // Back
|
case 7: // Back
|
||||||
m.screen = screenMain
|
m.screen = screenMain
|
||||||
m.cursor = 0
|
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"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// DefaultInstallLogFile is where bee-install writes its progress log.
|
||||||
|
const DefaultInstallLogFile = "/tmp/bee-install.log"
|
||||||
|
|
||||||
type screen string
|
type screen string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -29,6 +32,8 @@ const (
|
|||||||
screenNvidiaSATSetup screen = "nvidia_sat_setup"
|
screenNvidiaSATSetup screen = "nvidia_sat_setup"
|
||||||
screenNvidiaSATRunning screen = "nvidia_sat_running"
|
screenNvidiaSATRunning screen = "nvidia_sat_running"
|
||||||
screenGPUStressRunning screen = "gpu_stress_running"
|
screenGPUStressRunning screen = "gpu_stress_running"
|
||||||
|
screenTools screen = "tools"
|
||||||
|
screenInstallDiskPick screen = "install_disk_pick"
|
||||||
)
|
)
|
||||||
|
|
||||||
type actionKind string
|
type actionKind string
|
||||||
@@ -45,6 +50,7 @@ const (
|
|||||||
actionRunAMDGPUSAT actionKind = "run_amd_gpu_sat"
|
actionRunAMDGPUSAT actionKind = "run_amd_gpu_sat"
|
||||||
actionRunFanStress actionKind = "run_fan_stress"
|
actionRunFanStress actionKind = "run_fan_stress"
|
||||||
actionRunNCCLTests actionKind = "run_nccl_tests"
|
actionRunNCCLTests actionKind = "run_nccl_tests"
|
||||||
|
actionInstallToDisk actionKind = "install_to_disk"
|
||||||
)
|
)
|
||||||
|
|
||||||
type model struct {
|
type model struct {
|
||||||
@@ -62,13 +68,16 @@ type model struct {
|
|||||||
settingsMenu []string
|
settingsMenu []string
|
||||||
networkMenu []string
|
networkMenu []string
|
||||||
serviceMenu []string
|
serviceMenu []string
|
||||||
|
toolsMenu []string
|
||||||
|
|
||||||
services []string
|
services []string
|
||||||
interfaces []platform.InterfaceInfo
|
interfaces []platform.InterfaceInfo
|
||||||
targets []platform.RemovableTarget
|
targets []platform.RemovableTarget
|
||||||
|
installDisks []platform.InstallDisk
|
||||||
selectedService string
|
selectedService string
|
||||||
selectedIface string
|
selectedIface string
|
||||||
selectedTarget *platform.RemovableTarget
|
selectedTarget *platform.RemovableTarget
|
||||||
|
selectedDisk string
|
||||||
pendingAction actionKind
|
pendingAction actionKind
|
||||||
|
|
||||||
formFields []formField
|
formFields []formField
|
||||||
@@ -102,6 +111,10 @@ type model struct {
|
|||||||
// NCCL tests running
|
// NCCL tests running
|
||||||
ncclCancel func()
|
ncclCancel func()
|
||||||
|
|
||||||
|
// Install to disk
|
||||||
|
installCancel func()
|
||||||
|
installSince time.Time
|
||||||
|
|
||||||
// GPU Platform Stress Test running
|
// GPU Platform Stress Test running
|
||||||
gpuStressCancel func()
|
gpuStressCancel func()
|
||||||
gpuStressAborted bool
|
gpuStressAborted bool
|
||||||
@@ -152,6 +165,11 @@ func newModel(application *app.App, runtimeMode runtimeenv.Mode) model {
|
|||||||
"Run self-check",
|
"Run self-check",
|
||||||
"Runtime issues",
|
"Runtime issues",
|
||||||
"Audit logs",
|
"Audit logs",
|
||||||
|
"Tools",
|
||||||
|
"Back",
|
||||||
|
},
|
||||||
|
toolsMenu: []string{
|
||||||
|
"Install to disk",
|
||||||
"Check tools",
|
"Check tools",
|
||||||
"Back",
|
"Back",
|
||||||
},
|
},
|
||||||
@@ -206,6 +224,12 @@ func (m model) confirmBody() (string, string) {
|
|||||||
return "CPU test", "Run stress-ng? Mode: " + modes[m.hcMode]
|
return "CPU test", "Run stress-ng? Mode: " + modes[m.hcMode]
|
||||||
case actionRunAMDGPUSAT:
|
case actionRunAMDGPUSAT:
|
||||||
return "AMD GPU test", "Run AMD GPU diagnostic pack (rocm-smi)?"
|
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:
|
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."
|
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:
|
case actionRunFanStress:
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m, pollSATProgress(m.progressPrefix, m.progressSince)
|
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
|
return m, nil
|
||||||
case snapshotMsg:
|
case snapshotMsg:
|
||||||
m.banner = msg.banner
|
m.banner = msg.banner
|
||||||
@@ -112,6 +118,44 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.screen = screenExportTargets
|
m.screen = screenExportTargets
|
||||||
m.cursor = 0
|
m.cursor = 0
|
||||||
return m, m.refreshSnapshotCmd()
|
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:
|
case nvtopClosedMsg:
|
||||||
return m, nil
|
return m, nil
|
||||||
case gpuStressDoneMsg:
|
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)
|
return m.updateMenu(msg, len(m.targets), m.handleExportTargetsMenu)
|
||||||
case screenInterfacePick:
|
case screenInterfacePick:
|
||||||
return m.updateMenu(msg, len(m.interfaces), m.handleInterfacePickMenu)
|
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:
|
case screenOutput:
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "esc", "enter", "q":
|
case "esc", "enter", "q":
|
||||||
@@ -300,6 +348,12 @@ func (m model) updateMenu(msg tea.KeyMsg, size int, onEnter func() (tea.Model, t
|
|||||||
case screenInterfacePick:
|
case screenInterfacePick:
|
||||||
m.screen = screenNetwork
|
m.screen = screenNetwork
|
||||||
m.cursor = 0
|
m.cursor = 0
|
||||||
|
case screenTools:
|
||||||
|
m.screen = screenSettings
|
||||||
|
m.cursor = 0
|
||||||
|
case screenInstallDiskPick:
|
||||||
|
m.screen = screenTools
|
||||||
|
m.cursor = 0
|
||||||
}
|
}
|
||||||
case "q", "ctrl+c":
|
case "q", "ctrl+c":
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
|
|||||||
@@ -76,6 +76,10 @@ func (m model) View() string {
|
|||||||
)
|
)
|
||||||
case screenInterfacePick:
|
case screenInterfacePick:
|
||||||
body = renderMenu("Interfaces", "Select interface", renderInterfaceItems(m.interfaces), m.cursor)
|
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:
|
case screenStaticForm:
|
||||||
body = renderForm("Static IPv4: "+m.selectedIface, m.formFields, m.formIndex)
|
body = renderForm("Static IPv4: "+m.selectedIface, m.formFields, m.formIndex)
|
||||||
case screenConfirm:
|
case screenConfirm:
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ qemu-guest-agent
|
|||||||
# SSH
|
# SSH
|
||||||
openssh-server
|
openssh-server
|
||||||
|
|
||||||
|
# Disk installer
|
||||||
|
squashfs-tools
|
||||||
|
parted
|
||||||
|
grub-pc
|
||||||
|
grub-efi-amd64
|
||||||
|
|
||||||
# Filesystem support for USB export targets
|
# Filesystem support for USB export targets
|
||||||
exfatprogs
|
exfatprogs
|
||||||
exfat-fuse
|
exfat-fuse
|
||||||
|
|||||||
189
iso/overlay/usr/local/bin/bee-install
Executable file
189
iso/overlay/usr/local/bin/bee-install
Executable file
@@ -0,0 +1,189 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# bee-install — install the live system to a local disk.
|
||||||
|
#
|
||||||
|
# Usage: bee-install <device> [logfile]
|
||||||
|
# device — target block device, e.g. /dev/sda (will be WIPED)
|
||||||
|
# logfile — optional path to write progress log (default: /tmp/bee-install.log)
|
||||||
|
#
|
||||||
|
# Layout (UEFI): GPT, /dev/sdX1=EFI 512MB vfat, /dev/sdX2=root ext4
|
||||||
|
# Layout (BIOS): MBR, /dev/sdX1=root ext4
|
||||||
|
#
|
||||||
|
# Squashfs source: /run/live/medium/live/filesystem.squashfs
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DEVICE="${1:-}"
|
||||||
|
LOGFILE="${2:-/tmp/bee-install.log}"
|
||||||
|
|
||||||
|
if [ -z "$DEVICE" ]; then
|
||||||
|
echo "Usage: bee-install <device> [logfile]" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ ! -b "$DEVICE" ]; then
|
||||||
|
echo "ERROR: $DEVICE is not a block device" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SQUASHFS="/run/live/medium/live/filesystem.squashfs"
|
||||||
|
if [ ! -f "$SQUASHFS" ]; then
|
||||||
|
echo "ERROR: squashfs not found at $SQUASHFS" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
MOUNT_ROOT="/mnt/bee-install-root"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOGFILE"; }
|
||||||
|
die() { log "ERROR: $*"; exit 1; }
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Detect UEFI
|
||||||
|
if [ -d /sys/firmware/efi ]; then
|
||||||
|
UEFI=1
|
||||||
|
log "Boot mode: UEFI"
|
||||||
|
else
|
||||||
|
UEFI=0
|
||||||
|
log "Boot mode: BIOS/legacy"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Determine partition names (nvme uses p-suffix: nvme0n1p1)
|
||||||
|
if echo "$DEVICE" | grep -qE 'nvme|mmcblk'; then
|
||||||
|
PART_PREFIX="${DEVICE}p"
|
||||||
|
else
|
||||||
|
PART_PREFIX="${DEVICE}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$UEFI" = "1" ]; then
|
||||||
|
PART_EFI="${PART_PREFIX}1"
|
||||||
|
PART_ROOT="${PART_PREFIX}2"
|
||||||
|
else
|
||||||
|
PART_ROOT="${PART_PREFIX}1"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
log "=== BEE DISK INSTALLER ==="
|
||||||
|
log "Target device : $DEVICE"
|
||||||
|
log "Root partition: $PART_ROOT"
|
||||||
|
[ "$UEFI" = "1" ] && log "EFI partition : $PART_EFI"
|
||||||
|
log "Squashfs : $SQUASHFS ($(du -sh "$SQUASHFS" | cut -f1))"
|
||||||
|
log "Log : $LOGFILE"
|
||||||
|
log ""
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
log "--- Step 1/7: Unmounting target device ---"
|
||||||
|
# Unmount any partitions on target device
|
||||||
|
for part in "${DEVICE}"* ; do
|
||||||
|
if [ "$part" = "$DEVICE" ]; then continue; fi
|
||||||
|
if mountpoint -q "$part" 2>/dev/null; then
|
||||||
|
log " umount $part"
|
||||||
|
umount "$part" || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
# Also unmount our mount point if leftover
|
||||||
|
umount "${MOUNT_ROOT}" 2>/dev/null || true
|
||||||
|
umount "${MOUNT_ROOT}/boot/efi" 2>/dev/null || true
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
log "--- Step 2/7: Partitioning $DEVICE ---"
|
||||||
|
if [ "$UEFI" = "1" ]; then
|
||||||
|
parted -s "$DEVICE" mklabel gpt
|
||||||
|
parted -s "$DEVICE" mkpart EFI fat32 1MiB 513MiB
|
||||||
|
parted -s "$DEVICE" set 1 esp on
|
||||||
|
parted -s "$DEVICE" mkpart root ext4 513MiB 100%
|
||||||
|
else
|
||||||
|
parted -s "$DEVICE" mklabel msdos
|
||||||
|
parted -s "$DEVICE" mkpart primary ext4 1MiB 100%
|
||||||
|
parted -s "$DEVICE" set 1 boot on
|
||||||
|
fi
|
||||||
|
# Wait for kernel to see new partitions
|
||||||
|
sleep 1
|
||||||
|
partprobe "$DEVICE" 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
log " Partitioning done."
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
log "--- Step 3/7: Formatting ---"
|
||||||
|
if [ "$UEFI" = "1" ]; then
|
||||||
|
mkfs.vfat -F32 -n EFI "$PART_EFI"
|
||||||
|
log " $PART_EFI formatted as vfat (EFI)"
|
||||||
|
fi
|
||||||
|
mkfs.ext4 -F -L bee-root "$PART_ROOT"
|
||||||
|
log " $PART_ROOT formatted as ext4"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
log "--- Step 4/7: Mounting target ---"
|
||||||
|
mkdir -p "$MOUNT_ROOT"
|
||||||
|
mount "$PART_ROOT" "$MOUNT_ROOT"
|
||||||
|
if [ "$UEFI" = "1" ]; then
|
||||||
|
mkdir -p "${MOUNT_ROOT}/boot/efi"
|
||||||
|
mount "$PART_EFI" "${MOUNT_ROOT}/boot/efi"
|
||||||
|
fi
|
||||||
|
log " Mounted."
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
log "--- Step 5/7: Unpacking filesystem (this takes 10-20 minutes) ---"
|
||||||
|
log " Source: $SQUASHFS"
|
||||||
|
log " Target: $MOUNT_ROOT"
|
||||||
|
unsquashfs -f -d "$MOUNT_ROOT" "$SQUASHFS" 2>&1 | \
|
||||||
|
grep -E '^\[|^inod|^created|^extract' | \
|
||||||
|
while read -r line; do log " $line"; done || true
|
||||||
|
log " Unpack complete."
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
log "--- Step 6/7: Configuring installed system ---"
|
||||||
|
|
||||||
|
# Write /etc/fstab
|
||||||
|
ROOT_UUID=$(blkid -s UUID -o value "$PART_ROOT")
|
||||||
|
log " Root UUID: $ROOT_UUID"
|
||||||
|
cat > "${MOUNT_ROOT}/etc/fstab" <<FSTAB
|
||||||
|
# Generated by bee-install
|
||||||
|
UUID=${ROOT_UUID} / ext4 defaults,errors=remount-ro 0 1
|
||||||
|
tmpfs /tmp tmpfs defaults,size=512m 0 0
|
||||||
|
FSTAB
|
||||||
|
if [ "$UEFI" = "1" ]; then
|
||||||
|
EFI_UUID=$(blkid -s UUID -o value "$PART_EFI")
|
||||||
|
echo "UUID=${EFI_UUID} /boot/efi vfat umask=0077 0 1" >> "${MOUNT_ROOT}/etc/fstab"
|
||||||
|
fi
|
||||||
|
log " fstab written."
|
||||||
|
|
||||||
|
# Remove live-boot persistence markers so installed system boots normally
|
||||||
|
rm -f "${MOUNT_ROOT}/etc/live/boot.conf" 2>/dev/null || true
|
||||||
|
rm -f "${MOUNT_ROOT}/etc/live/live.conf" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Bind mount virtual filesystems for chroot
|
||||||
|
mount --bind /dev "${MOUNT_ROOT}/dev"
|
||||||
|
mount --bind /proc "${MOUNT_ROOT}/proc"
|
||||||
|
mount --bind /sys "${MOUNT_ROOT}/sys"
|
||||||
|
[ "$UEFI" = "1" ] && mount --bind /sys/firmware/efi/efivars "${MOUNT_ROOT}/sys/firmware/efi/efivars" 2>/dev/null || true
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
log "--- Step 7/7: Installing GRUB bootloader ---"
|
||||||
|
if [ "$UEFI" = "1" ]; then
|
||||||
|
chroot "$MOUNT_ROOT" grub-install \
|
||||||
|
--target=x86_64-efi \
|
||||||
|
--efi-directory=/boot/efi \
|
||||||
|
--bootloader-id=bee \
|
||||||
|
--recheck 2>&1 | while read -r line; do log " $line"; done || true
|
||||||
|
else
|
||||||
|
chroot "$MOUNT_ROOT" grub-install \
|
||||||
|
--target=i386-pc \
|
||||||
|
--recheck \
|
||||||
|
"$DEVICE" 2>&1 | while read -r line; do log " $line"; done || true
|
||||||
|
fi
|
||||||
|
chroot "$MOUNT_ROOT" update-grub 2>&1 | while read -r line; do log " $line"; done || true
|
||||||
|
log " GRUB installed."
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Cleanup
|
||||||
|
log "--- Cleanup ---"
|
||||||
|
umount "${MOUNT_ROOT}/sys/firmware/efi/efivars" 2>/dev/null || true
|
||||||
|
umount "${MOUNT_ROOT}/sys" 2>/dev/null || true
|
||||||
|
umount "${MOUNT_ROOT}/proc" 2>/dev/null || true
|
||||||
|
umount "${MOUNT_ROOT}/dev" 2>/dev/null || true
|
||||||
|
[ "$UEFI" = "1" ] && umount "${MOUNT_ROOT}/boot/efi" 2>/dev/null || true
|
||||||
|
umount "$MOUNT_ROOT" 2>/dev/null || true
|
||||||
|
rmdir "$MOUNT_ROOT" 2>/dev/null || true
|
||||||
|
|
||||||
|
log ""
|
||||||
|
log "=== INSTALLATION COMPLETE ==="
|
||||||
|
log "Remove the ISO and reboot to start the installed system."
|
||||||
Reference in New Issue
Block a user