refactor: remove dead internal/ui and internal/phases code paths
- delete internal/ui/ (unused huh-based form path, never wired to install cmd) - delete internal/phases/ (only used by dead internal/ui path) - move internal/ui/assets/ -> internal/assets/ (only live dependency) - update post_install_handler.go import to internal/assets - wire version string through tui.NewApp -> RenderForm for title display - hostname default: 'myarch' -> 'arch', username default: 'archuser' -> '' - update tests to match new defaults and title
- commit
- 374e98b0a300de4e97616eb7ca4df999ecab78c0
- author
- brice · · 2026-02-28 04:42 UTC
- parent
- fc93cf5
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
52 files · +25 -10885
M cmd/install.go +1 -1
| @@ -91,7 +91,7 @@ func runInstall(dryRun bool) error { | |||||
| 91 | ) | 91 | ) | ||
| 92 | 92 | ||||
| 93 | gpuHandler := apphandlers.NewGPUHandler(shellExec, slogAdapter) | 93 | gpuHandler := apphandlers.NewGPUHandler(shellExec, slogAdapter) | ||
| 94 | - | tuiApp := tui.NewApp(installService, installService.Tracker(), gpuHandler, slogAdapter) | 94 | + | tuiApp := tui.NewApp(installService, installService.Tracker(), gpuHandler, slogAdapter, version) |
| 95 | 95 | ||||
| 96 | oldLog.Info("Starting TUI application", "version", version) | 96 | oldLog.Info("Starting TUI application", "version", version) | ||
| 97 | p := tea.NewProgram(tuiApp, tea.WithAltScreen()) | 97 | p := tea.NewProgram(tuiApp, tea.WithAltScreen()) | ||
M go.mod +0 -5
| @@ -5,7 +5,6 @@ go 1.25.3 | |||||
| 5 | require ( | 5 | require ( | ||
| 6 | github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 | 6 | github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 | ||
| 7 | github.com/charmbracelet/bubbletea v1.3.10 | 7 | github.com/charmbracelet/bubbletea v1.3.10 | ||
| 8 | - | github.com/charmbracelet/huh v0.8.0 | |||
| 9 | github.com/charmbracelet/lipgloss v1.1.0 | 8 | github.com/charmbracelet/lipgloss v1.1.0 | ||
| 10 | github.com/google/uuid v1.6.0 | 9 | github.com/google/uuid v1.6.0 | ||
| 11 | github.com/spf13/cobra v1.10.2 | 10 | github.com/spf13/cobra v1.10.2 | ||
| @@ -15,23 +14,19 @@ require ( | |||||
| 15 | require ( | 14 | require ( | ||
| 16 | github.com/atotto/clipboard v0.1.4 // indirect | 15 | github.com/atotto/clipboard v0.1.4 // indirect | ||
| 17 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | 16 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | ||
| 18 | - | github.com/catppuccin/go v0.3.0 // indirect | |||
| 19 | github.com/charmbracelet/colorprofile v0.4.1 // indirect | 17 | github.com/charmbracelet/colorprofile v0.4.1 // indirect | ||
| 20 | github.com/charmbracelet/x/ansi v0.11.4 // indirect | 18 | github.com/charmbracelet/x/ansi v0.11.4 // indirect | ||
| 21 | github.com/charmbracelet/x/cellbuf v0.0.14 // indirect | 19 | github.com/charmbracelet/x/cellbuf v0.0.14 // indirect | ||
| 22 | - | github.com/charmbracelet/x/exp/strings v0.0.0-20260122224438-b01af16209d9 // indirect | |||
| 23 | github.com/charmbracelet/x/term v0.2.2 // indirect | 20 | github.com/charmbracelet/x/term v0.2.2 // indirect | ||
| 24 | github.com/clipperhouse/displaywidth v0.7.0 // indirect | 21 | github.com/clipperhouse/displaywidth v0.7.0 // indirect | ||
| 25 | github.com/clipperhouse/stringish v0.1.1 // indirect | 22 | github.com/clipperhouse/stringish v0.1.1 // indirect | ||
| 26 | github.com/clipperhouse/uax29/v2 v2.4.0 // indirect | 23 | github.com/clipperhouse/uax29/v2 v2.4.0 // indirect | ||
| 27 | - | github.com/dustin/go-humanize v1.0.1 // indirect | |||
| 28 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | 24 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | ||
| 29 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | 25 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | ||
| 30 | github.com/lucasb-eyer/go-colorful v1.3.0 // indirect | 26 | github.com/lucasb-eyer/go-colorful v1.3.0 // indirect | ||
| 31 | github.com/mattn/go-isatty v0.0.20 // indirect | 27 | github.com/mattn/go-isatty v0.0.20 // indirect | ||
| 32 | github.com/mattn/go-localereader v0.0.1 // indirect | 28 | github.com/mattn/go-localereader v0.0.1 // indirect | ||
| 33 | github.com/mattn/go-runewidth v0.0.19 // indirect | 29 | github.com/mattn/go-runewidth v0.0.19 // indirect | ||
| 34 | - | github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect | |||
| 35 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect | 30 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect | ||
| 36 | github.com/muesli/cancelreader v0.2.2 // indirect | 31 | github.com/muesli/cancelreader v0.2.2 // indirect | ||
| 37 | github.com/muesli/termenv v0.16.0 // indirect | 32 | github.com/muesli/termenv v0.16.0 // indirect | ||
M go.sum +0 -26 generated file: collapsed
M internal/application/handlers/post_install_handler.go +1 -1
| @@ -9,9 +9,9 @@ import ( | |||||
| 9 | 9 | ||||
| 10 | "github.com/bnema/archup/internal/application/commands" | 10 | "github.com/bnema/archup/internal/application/commands" | ||
| 11 | "github.com/bnema/archup/internal/application/dto" | 11 | "github.com/bnema/archup/internal/application/dto" | ||
| 12 | + | "github.com/bnema/archup/internal/assets" | |||
| 12 | "github.com/bnema/archup/internal/config" | 13 | "github.com/bnema/archup/internal/config" | ||
| 13 | "github.com/bnema/archup/internal/domain/ports" | 14 | "github.com/bnema/archup/internal/domain/ports" | ||
| 14 | - | "github.com/bnema/archup/internal/ui/assets" | |||
| 15 | ) | 15 | ) | ||
| 16 | 16 | ||||
| 17 | // PostInstallHandler handles post-installation tasks | 17 | // PostInstallHandler handles post-installation tasks | ||
R internal/ui/assets/arch-logo.png → internal/assets/arch-logo.png +0 -0 binary file: no text diff · renamed, 100% similar
binary file: no text diff · renamed, 100% similar
R internal/ui/assets/assets.go → internal/assets/assets.go +0 -0 renamed, 100% similar
renamed, 100% similar
R internal/ui/assets/logo.txt → internal/assets/logo.txt +0 -0 renamed, 100% similar
renamed, 100% similar
M internal/interfaces/tui/app.go +5 -2
| @@ -41,6 +41,7 @@ type App struct { | |||||
| 41 | 41 | ||||
| 42 | // Application state | 42 | // Application state | ||
| 43 | currentScreen Screen | 43 | currentScreen Screen | ||
| 44 | + | version string | |||
| 44 | ctx context.Context | 45 | ctx context.Context | ||
| 45 | cancel context.CancelFunc | 46 | cancel context.CancelFunc | ||
| 46 | 47 | ||||
| @@ -75,6 +76,7 @@ func NewApp( | |||||
| 75 | progressTracker *services.ProgressTracker, | 76 | progressTracker *services.ProgressTracker, | ||
| 76 | gpuHandler *apphandlers.GPUHandler, | 77 | gpuHandler *apphandlers.GPUHandler, | ||
| 77 | logger ports.Logger, | 78 | logger ports.Logger, | ||
| 79 | + | version string, | |||
| 78 | ) *App { | 80 | ) *App { | ||
| 79 | ctx, cancel := context.WithCancel(context.Background()) | 81 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 80 | 82 | ||||
| @@ -83,6 +85,7 @@ func NewApp( | |||||
| 83 | progressTracker: progressTracker, | 85 | progressTracker: progressTracker, | ||
| 84 | gpuHandler: gpuHandler, | 86 | gpuHandler: gpuHandler, | ||
| 85 | logger: logger, | 87 | logger: logger, | ||
| 88 | + | version: version, | |||
| 86 | formModel: models.NewFormModel(), | 89 | formModel: models.NewFormModel(), | ||
| 87 | diskModel: models.NewDiskModel(), | 90 | diskModel: models.NewDiskModel(), | ||
| 88 | encryptionModel: models.NewEncryptionModel(), | 91 | encryptionModel: models.NewEncryptionModel(), | ||
| @@ -158,7 +161,7 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |||||
| 158 | func (a *App) View() string { | 161 | func (a *App) View() string { | ||
| 159 | switch a.currentScreen { | 162 | switch a.currentScreen { | ||
| 160 | case ScreenForm: | 163 | case ScreenForm: | ||
| 161 | - | return views.RenderForm(a.formModel) | 164 | + | return views.RenderForm(a.formModel, a.version) |
| 162 | case ScreenDisk: | 165 | case ScreenDisk: | ||
| 163 | return views.RenderDiskSelection(a.diskModel) | 166 | return views.RenderDiskSelection(a.diskModel) | ||
| 164 | case ScreenEncryption: | 167 | case ScreenEncryption: | ||
| @@ -180,7 +183,7 @@ func (a *App) View() string { | |||||
| 180 | case ScreenError: | 183 | case ScreenError: | ||
| 181 | return views.RenderError(a.installationModel.GetError()) | 184 | return views.RenderError(a.installationModel.GetError()) | ||
| 182 | default: | 185 | default: | ||
| 183 | - | return views.RenderForm(a.formModel) | 186 | + | return views.RenderForm(a.formModel, a.version) |
| 184 | } | 187 | } | ||
| 185 | } | 188 | } | ||
| 186 | 189 | ||||
M internal/interfaces/tui/handlers/form_handler_test.go +2 -2
| @@ -107,8 +107,8 @@ func TestFormModelDataExtraction(t *testing.T) { | |||||
| 107 | 107 | ||||
| 108 | // Set test data | 108 | // Set test data | ||
| 109 | testData := models.FormData{ | 109 | testData := models.FormData{ | ||
| 110 | - | Hostname: "myarch", | 110 | + | Hostname: "arch", |
| 111 | - | Username: "archuser", | 111 | + | Username: "", |
| 112 | UserPassword: "password123", | 112 | UserPassword: "password123", | ||
| 113 | RootPassword: "rootpass123", | 113 | RootPassword: "rootpass123", | ||
| 114 | TargetDisk: "/dev/sda", | 114 | TargetDisk: "/dev/sda", | ||
M internal/interfaces/tui/models/form.go +2 -2
| @@ -75,10 +75,10 @@ func NewFormModel() *FormModelImpl { | |||||
| 75 | 75 | ||||
| 76 | // Initialize text inputs with default values | 76 | // Initialize text inputs with default values | ||
| 77 | fm.hostname = createTextInput("Hostname", "", "Enter system hostname") | 77 | fm.hostname = createTextInput("Hostname", "", "Enter system hostname") | ||
| 78 | - | fm.hostname.SetValue("myarch") | 78 | + | fm.hostname.SetValue("arch") |
| 79 | 79 | ||||
| 80 | fm.username = createTextInput("Username", "", "Enter regular user name") | 80 | fm.username = createTextInput("Username", "", "Enter regular user name") | ||
| 81 | - | fm.username.SetValue("archuser") | 81 | + | fm.username.SetValue("") |
| 82 | 82 | ||||
| 83 | fm.userEmail = createTextInput("Email", "", "Enter email for git config") | 83 | fm.userEmail = createTextInput("Email", "", "Enter email for git config") | ||
| 84 | 84 | ||||
M internal/interfaces/tui/views/form_view.go +7 -2
| @@ -8,14 +8,19 @@ import ( | |||||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | // RenderForm renders the form model to a styled string | 10 | // RenderForm renders the form model to a styled string | ||
| 11 | - | func RenderForm(fm *models.FormModelImpl) string { | 11 | + | func RenderForm(fm *models.FormModelImpl, version string) string { |
| 12 | var b strings.Builder | 12 | var b strings.Builder | ||
| 13 | 13 | ||||
| 14 | + | title := "ArchUp Installer" | |||
| 15 | + | if version != "" { | |||
| 16 | + | title += " " + version | |||
| 17 | + | } | |||
| 18 | + | ||||
| 14 | b.WriteString("\n") | 19 | b.WriteString("\n") | ||
| 15 | b.WriteString(lipgloss.NewStyle(). | 20 | b.WriteString(lipgloss.NewStyle(). | ||
| 16 | Bold(true). | 21 | Bold(true). | ||
| 17 | Foreground(lipgloss.Color("12")). | 22 | Foreground(lipgloss.Color("12")). | ||
| 18 | - | Render("ArchUp Installer Configuration")) | 23 | + | Render(title)) |
| 19 | b.WriteString("\n\n") | 24 | b.WriteString("\n\n") | ||
| 20 | 25 | ||||
| 21 | labels := []string{ | 26 | labels := []string{ | ||
M internal/interfaces/tui/views/form_view_test.go +7 -7
| @@ -13,12 +13,12 @@ func TestRenderForm(t *testing.T) { | |||||
| 13 | fm := models.NewFormModel() | 13 | fm := models.NewFormModel() | ||
| 14 | 14 | ||||
| 15 | // Render the form | 15 | // Render the form | ||
| 16 | - | output := RenderForm(fm) | 16 | + | output := RenderForm(fm, "") |
| 17 | 17 | ||||
| 18 | // Verify key content is present | 18 | // Verify key content is present | ||
| 19 | checks := []string{ | 19 | checks := []string{ | ||
| 20 | - | "ArchUp Installer Configuration", // Title | 20 | + | "ArchUp Installer", // Title |
| 21 | - | "Hostname:", // Field labels | 21 | + | "Hostname:", // Field labels |
| 22 | "Username:", | 22 | "Username:", | ||
| 23 | "↑↓ Navigate", // Instructions | 23 | "↑↓ Navigate", // Instructions | ||
| 24 | } | 24 | } | ||
| @@ -44,7 +44,7 @@ func TestRenderFormWithError(t *testing.T) { | |||||
| 44 | fm.SetError(errors.New(testErr)) | 44 | fm.SetError(errors.New(testErr)) | ||
| 45 | 45 | ||||
| 46 | // Render the form | 46 | // Render the form | ||
| 47 | - | output := RenderForm(fm) | 47 | + | output := RenderForm(fm, "") |
| 48 | 48 | ||||
| 49 | // Verify error is displayed | 49 | // Verify error is displayed | ||
| 50 | if !strings.Contains(output, testErr) { | 50 | if !strings.Contains(output, testErr) { | ||
| @@ -57,13 +57,13 @@ func TestRenderFormWithDifferentFocus(t *testing.T) { | |||||
| 57 | fm := models.NewFormModel() | 57 | fm := models.NewFormModel() | ||
| 58 | 58 | ||||
| 59 | // Render initial form | 59 | // Render initial form | ||
| 60 | - | output1 := RenderForm(fm) | 60 | + | output1 := RenderForm(fm, "") |
| 61 | 61 | ||||
| 62 | // Move focus | 62 | // Move focus | ||
| 63 | fm.FocusNext() | 63 | fm.FocusNext() | ||
| 64 | 64 | ||||
| 65 | // Render form with different focus | 65 | // Render form with different focus | ||
| 66 | - | output2 := RenderForm(fm) | 66 | + | output2 := RenderForm(fm, "") |
| 67 | 67 | ||||
| 68 | // Both should render successfully | 68 | // Both should render successfully | ||
| 69 | if len(output1) == 0 || len(output2) == 0 { | 69 | if len(output1) == 0 || len(output2) == 0 { | ||
| @@ -142,6 +142,6 @@ func BenchmarkRenderForm(b *testing.B) { | |||||
| 142 | 142 | ||||
| 143 | b.ResetTimer() | 143 | b.ResetTimer() | ||
| 144 | for i := 0; i < b.N; i++ { | 144 | for i := 0; i < b.N; i++ { | ||
| 145 | - | _ = RenderForm(fm) | 145 | + | _ = RenderForm(fm, "") |
| 146 | } | 146 | } | ||
| 147 | } | 147 | } | ||
D internal/phases/base.go +0 -463
| @@ -1,463 +0,0 @@ | |||||
| 1 | - | package phases | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "bufio" | |||
| 5 | - | "fmt" | |||
| 6 | - | "strings" | |||
| 7 | - | ||||
| 8 | - | "github.com/bnema/archup/internal/config" | |||
| 9 | - | "github.com/bnema/archup/internal/interfaces" | |||
| 10 | - | "github.com/bnema/archup/internal/logger" | |||
| 11 | - | ) | |||
| 12 | - | ||||
| 13 | - | // BaseInstallPhase handles base system installation | |||
| 14 | - | type BaseInstallPhase struct { | |||
| 15 | - | *BasePhase | |||
| 16 | - | fs interfaces.FileSystem | |||
| 17 | - | sysExec interfaces.SystemExecutor | |||
| 18 | - | } | |||
| 19 | - | ||||
| 20 | - | // NewBaseInstallPhase creates a new base installation phase | |||
| 21 | - | func NewBaseInstallPhase(cfg *config.Config, log *logger.Logger, fs interfaces.FileSystem, sysExec interfaces.SystemExecutor) *BaseInstallPhase { | |||
| 22 | - | return &BaseInstallPhase{ | |||
| 23 | - | BasePhase: NewBasePhase("base", "Base System Installation", cfg, log), | |||
| 24 | - | fs: fs, | |||
| 25 | - | sysExec: sysExec, | |||
| 26 | - | } | |||
| 27 | - | } | |||
| 28 | - | ||||
| 29 | - | // PreCheck validates prerequisites | |||
| 30 | - | func (p *BaseInstallPhase) PreCheck() error { | |||
| 31 | - | // Verify /mnt is mounted | |||
| 32 | - | result := p.sysExec.RunSimple("mountpoint", "-q", "/mnt") | |||
| 33 | - | switch { | |||
| 34 | - | case result.ExitCode != 0: | |||
| 35 | - | return fmt.Errorf("/mnt is not mounted") | |||
| 36 | - | } | |||
| 37 | - | ||||
| 38 | - | return nil | |||
| 39 | - | } | |||
| 40 | - | ||||
| 41 | - | // Execute runs the base phase | |||
| 42 | - | func (p *BaseInstallPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult { | |||
| 43 | - | p.SendProgress(progressChan, "Starting base installation...", 1, 5) | |||
| 44 | - | ||||
| 45 | - | // Step 1: Configure ISO pacman | |||
| 46 | - | switch err := p.configurePacman(progressChan); { | |||
| 47 | - | case err != nil: | |||
| 48 | - | p.SendError(progressChan, err) | |||
| 49 | - | return PhaseResult{Success: false, Error: err} | |||
| 50 | - | } | |||
| 51 | - | ||||
| 52 | - | p.SendProgress(progressChan, "Detecting CPU and microcode...", 2, 5) | |||
| 53 | - | ||||
| 54 | - | // Step 2: Detect CPU and select microcode | |||
| 55 | - | switch err := p.detectCPU(progressChan); { | |||
| 56 | - | case err != nil: | |||
| 57 | - | p.SendError(progressChan, err) | |||
| 58 | - | return PhaseResult{Success: false, Error: err} | |||
| 59 | - | } | |||
| 60 | - | ||||
| 61 | - | // Step 2.5: Configure CachyOS repository on ISO if needed | |||
| 62 | - | if p.config.KernelChoice == config.KernelLinuxCachyOS { | |||
| 63 | - | p.SendProgress(progressChan, "Configuring CachyOS repository...", 2, 5) | |||
| 64 | - | switch err := p.configureCachyOSRepo(progressChan); { | |||
| 65 | - | case err != nil: | |||
| 66 | - | p.SendError(progressChan, err) | |||
| 67 | - | return PhaseResult{Success: false, Error: err} | |||
| 68 | - | } | |||
| 69 | - | } | |||
| 70 | - | ||||
| 71 | - | p.SendProgress(progressChan, "Installing base system...", 3, 5) | |||
| 72 | - | ||||
| 73 | - | // Step 3: Run pacstrap | |||
| 74 | - | switch err := p.runPacstrap(progressChan); { | |||
| 75 | - | case err != nil: | |||
| 76 | - | p.SendError(progressChan, err) | |||
| 77 | - | return PhaseResult{Success: false, Error: err} | |||
| 78 | - | } | |||
| 79 | - | ||||
| 80 | - | p.SendProgress(progressChan, "Generating fstab...", 4, 5) | |||
| 81 | - | ||||
| 82 | - | // Step 4: Generate fstab | |||
| 83 | - | switch err := p.generateFstab(progressChan); { | |||
| 84 | - | case err != nil: | |||
| 85 | - | p.SendError(progressChan, err) | |||
| 86 | - | return PhaseResult{Success: false, Error: err} | |||
| 87 | - | } | |||
| 88 | - | ||||
| 89 | - | p.SendProgress(progressChan, "Base installation complete", 5, 5) | |||
| 90 | - | p.SendComplete(progressChan, "Base system installed successfully") | |||
| 91 | - | ||||
| 92 | - | return PhaseResult{Success: true, Message: "Base installation complete"} | |||
| 93 | - | } | |||
| 94 | - | ||||
| 95 | - | // configurePacman enables parallel downloads on ISO | |||
| 96 | - | func (p *BaseInstallPhase) configurePacman(progressChan chan<- ProgressUpdate) error { | |||
| 97 | - | p.SendOutput(progressChan, "Configuring pacman for faster downloads...") | |||
| 98 | - | ||||
| 99 | - | // Enable ParallelDownloads=10 | |||
| 100 | - | result := p.logger.ExecCommand("sed", "-i", | |||
| 101 | - | "s/^#ParallelDownloads = 5$/ParallelDownloads = 10/", | |||
| 102 | - | "/etc/pacman.conf") | |||
| 103 | - | ||||
| 104 | - | switch { | |||
| 105 | - | case result.ExitCode != 0: | |||
| 106 | - | return fmt.Errorf("pacman configuration failed: %w", result.Error) | |||
| 107 | - | } | |||
| 108 | - | ||||
| 109 | - | p.SendOutput(progressChan, "[OK] Pacman configured: ParallelDownloads=10") | |||
| 110 | - | return nil | |||
| 111 | - | } | |||
| 112 | - | ||||
| 113 | - | // configureCachyOSRepo configures CachyOS repository on the ISO before pacstrap | |||
| 114 | - | func (p *BaseInstallPhase) configureCachyOSRepo(progressChan chan<- ProgressUpdate) error { | |||
| 115 | - | p.SendOutput(progressChan, "Configuring CachyOS repository on live system...") | |||
| 116 | - | ||||
| 117 | - | // Import CachyOS GPG key | |||
| 118 | - | p.SendOutput(progressChan, "Importing CachyOS GPG key...") | |||
| 119 | - | result := p.logger.ExecCommand("pacman-key", "--recv-keys", "F3B607488DB35A47", "--keyserver", "keyserver.ubuntu.com") | |||
| 120 | - | switch { | |||
| 121 | - | case result.ExitCode != 0: | |||
| 122 | - | return fmt.Errorf("failed to receive CachyOS GPG key: %w", result.Error) | |||
| 123 | - | } | |||
| 124 | - | ||||
| 125 | - | result = p.logger.ExecCommand("pacman-key", "--lsign-key", "F3B607488DB35A47") | |||
| 126 | - | switch { | |||
| 127 | - | case result.ExitCode != 0: | |||
| 128 | - | return fmt.Errorf("failed to sign CachyOS GPG key: %w", result.Error) | |||
| 129 | - | } | |||
| 130 | - | ||||
| 131 | - | p.SendOutput(progressChan, "[OK] GPG key imported") | |||
| 132 | - | ||||
| 133 | - | // Check if CachyOS repo already configured | |||
| 134 | - | content, err := p.fs.ReadFile("/etc/pacman.conf") | |||
| 135 | - | switch { | |||
| 136 | - | case err != nil: | |||
| 137 | - | return fmt.Errorf("failed to read pacman.conf: %w", err) | |||
| 138 | - | } | |||
| 139 | - | ||||
| 140 | - | contentStr := string(content) | |||
| 141 | - | if !strings.Contains(contentStr, "[cachyos]") { | |||
| 142 | - | // Add CachyOS repo before [core] repository | |||
| 143 | - | p.SendOutput(progressChan, "Adding CachyOS repository to pacman.conf...") | |||
| 144 | - | ||||
| 145 | - | // Find [core] and insert before it | |||
| 146 | - | lines := strings.Split(contentStr, "\n") | |||
| 147 | - | var newLines []string | |||
| 148 | - | inserted := false | |||
| 149 | - | ||||
| 150 | - | for _, line := range lines { | |||
| 151 | - | if !inserted && strings.TrimSpace(line) == "[core]" { | |||
| 152 | - | newLines = append(newLines, "# CachyOS repositories") | |||
| 153 | - | newLines = append(newLines, "[cachyos]") | |||
| 154 | - | newLines = append(newLines, "Include = /etc/pacman.d/cachyos-mirrorlist") | |||
| 155 | - | newLines = append(newLines, "") | |||
| 156 | - | inserted = true | |||
| 157 | - | } | |||
| 158 | - | newLines = append(newLines, line) | |||
| 159 | - | } | |||
| 160 | - | ||||
| 161 | - | contentStr = strings.Join(newLines, "\n") | |||
| 162 | - | ||||
| 163 | - | switch err := p.fs.WriteFile("/etc/pacman.conf", []byte(contentStr), 0644); { | |||
| 164 | - | case err != nil: | |||
| 165 | - | return fmt.Errorf("failed to write pacman.conf: %w", err) | |||
| 166 | - | } | |||
| 167 | - | } | |||
| 168 | - | ||||
| 169 | - | // Create CachyOS mirrorlist | |||
| 170 | - | p.SendOutput(progressChan, "Creating CachyOS mirrorlist...") | |||
| 171 | - | mirrorlist := "## CachyOS mirrorlist\nServer = https://mirror.cachyos.org/repo/$arch/$repo\n" | |||
| 172 | - | ||||
| 173 | - | switch err := p.fs.WriteFile("/etc/pacman.d/cachyos-mirrorlist", []byte(mirrorlist), 0644); { | |||
| 174 | - | case err != nil: | |||
| 175 | - | return fmt.Errorf("failed to create cachyos-mirrorlist: %w", err) | |||
| 176 | - | } | |||
| 177 | - | ||||
| 178 | - | // Sync databases | |||
| 179 | - | p.SendOutput(progressChan, "Syncing package databases...") | |||
| 180 | - | result = p.logger.ExecCommand("pacman", "-Sy") | |||
| 181 | - | switch { | |||
| 182 | - | case result.ExitCode != 0: | |||
| 183 | - | return fmt.Errorf("failed to sync databases: %w", result.Error) | |||
| 184 | - | } | |||
| 185 | - | ||||
| 186 | - | p.SendOutput(progressChan, "[OK] CachyOS repository configured") | |||
| 187 | - | return nil | |||
| 188 | - | } | |||
| 189 | - | ||||
| 190 | - | // copyCachyOSConfig copies CachyOS repository configuration to installed system | |||
| 191 | - | func (p *BaseInstallPhase) copyCachyOSConfig(progressChan chan<- ProgressUpdate) error { | |||
| 192 | - | // Read ISO's pacman.conf to check if CachyOS is configured | |||
| 193 | - | content, err := p.fs.ReadFile("/etc/pacman.conf") | |||
| 194 | - | switch { | |||
| 195 | - | case err != nil: | |||
| 196 | - | return fmt.Errorf("failed to read ISO pacman.conf: %w", err) | |||
| 197 | - | } | |||
| 198 | - | ||||
| 199 | - | isoContent := string(content) | |||
| 200 | - | ||||
| 201 | - | // Read installed system's pacman.conf | |||
| 202 | - | mntContent, err := p.fs.ReadFile("/mnt/etc/pacman.conf") | |||
| 203 | - | switch { | |||
| 204 | - | case err != nil: | |||
| 205 | - | return fmt.Errorf("failed to read /mnt/etc/pacman.conf: %w", err) | |||
| 206 | - | } | |||
| 207 | - | ||||
| 208 | - | mntContentStr := string(mntContent) | |||
| 209 | - | ||||
| 210 | - | // Add CachyOS repo before [core] if not already present | |||
| 211 | - | if !strings.Contains(mntContentStr, "[cachyos]") && strings.Contains(isoContent, "[cachyos]") { | |||
| 212 | - | lines := strings.Split(mntContentStr, "\n") | |||
| 213 | - | var newLines []string | |||
| 214 | - | inserted := false | |||
| 215 | - | ||||
| 216 | - | for _, line := range lines { | |||
| 217 | - | if !inserted && strings.TrimSpace(line) == "[core]" { | |||
| 218 | - | newLines = append(newLines, "# CachyOS repositories") | |||
| 219 | - | newLines = append(newLines, "[cachyos]") | |||
| 220 | - | newLines = append(newLines, "Include = /etc/pacman.d/cachyos-mirrorlist") | |||
| 221 | - | newLines = append(newLines, "") | |||
| 222 | - | inserted = true | |||
| 223 | - | } | |||
| 224 | - | newLines = append(newLines, line) | |||
| 225 | - | } | |||
| 226 | - | ||||
| 227 | - | mntContentStr = strings.Join(newLines, "\n") | |||
| 228 | - | ||||
| 229 | - | switch err := p.fs.WriteFile("/mnt/etc/pacman.conf", []byte(mntContentStr), 0644); { | |||
| 230 | - | case err != nil: | |||
| 231 | - | return fmt.Errorf("failed to write /mnt/etc/pacman.conf: %w", err) | |||
| 232 | - | } | |||
| 233 | - | ||||
| 234 | - | p.SendOutput(progressChan, "[OK] Added CachyOS to installed system's pacman.conf") | |||
| 235 | - | } | |||
| 236 | - | ||||
| 237 | - | // Copy mirrorlist from ISO to installed system | |||
| 238 | - | result := p.logger.ExecCommand("mkdir", "-p", "/mnt/etc/pacman.d") | |||
| 239 | - | switch { | |||
| 240 | - | case result.ExitCode != 0: | |||
| 241 | - | return fmt.Errorf("failed to create /mnt/etc/pacman.d: %w", result.Error) | |||
| 242 | - | } | |||
| 243 | - | ||||
| 244 | - | result = p.logger.ExecCommand("cp", "/etc/pacman.d/cachyos-mirrorlist", "/mnt/etc/pacman.d/cachyos-mirrorlist") | |||
| 245 | - | switch { | |||
| 246 | - | case result.ExitCode != 0: | |||
| 247 | - | return fmt.Errorf("failed to copy cachyos-mirrorlist: %w", result.Error) | |||
| 248 | - | } | |||
| 249 | - | ||||
| 250 | - | p.SendOutput(progressChan, "[OK] CachyOS repository configured in installed system") | |||
| 251 | - | return nil | |||
| 252 | - | } | |||
| 253 | - | ||||
| 254 | - | // detectCPU detects CPU vendor and selects microcode | |||
| 255 | - | func (p *BaseInstallPhase) detectCPU(progressChan chan<- ProgressUpdate) error { | |||
| 256 | - | p.SendOutput(progressChan, "Detecting CPU vendor...") | |||
| 257 | - | ||||
| 258 | - | // Read /proc/cpuinfo to detect vendor | |||
| 259 | - | file, err := p.fs.Open("/proc/cpuinfo") | |||
| 260 | - | switch { | |||
| 261 | - | case err != nil: | |||
| 262 | - | return fmt.Errorf("failed to read /proc/cpuinfo: %w", err) | |||
| 263 | - | } | |||
| 264 | - | defer func() { | |||
| 265 | - | if err := file.Close(); err != nil { | |||
| 266 | - | p.logger.Warn("Failed to close file", "error", err) | |||
| 267 | - | } | |||
| 268 | - | }() | |||
| 269 | - | ||||
| 270 | - | scanner := bufio.NewScanner(file) | |||
| 271 | - | var vendor string | |||
| 272 | - | ||||
| 273 | - | for scanner.Scan() { | |||
| 274 | - | line := scanner.Text() | |||
| 275 | - | if strings.HasPrefix(line, "vendor_id") { | |||
| 276 | - | parts := strings.Fields(line) | |||
| 277 | - | if len(parts) >= 3 { | |||
| 278 | - | vendor = parts[2] | |||
| 279 | - | break | |||
| 280 | - | } | |||
| 281 | - | } | |||
| 282 | - | } | |||
| 283 | - | ||||
| 284 | - | // Determine microcode package | |||
| 285 | - | var microcode, cpuType string | |||
| 286 | - | ||||
| 287 | - | switch vendor { | |||
| 288 | - | case "GenuineIntel": | |||
| 289 | - | microcode = "intel-ucode" | |||
| 290 | - | cpuType = "Intel" | |||
| 291 | - | case "AuthenticAMD": | |||
| 292 | - | microcode = "amd-ucode" | |||
| 293 | - | cpuType = "AMD" | |||
| 294 | - | default: | |||
| 295 | - | microcode = "" | |||
| 296 | - | cpuType = "Unknown" | |||
| 297 | - | p.SendOutput(progressChan, fmt.Sprintf("[WARN] Unknown CPU vendor: %s", vendor)) | |||
| 298 | - | } | |||
| 299 | - | ||||
| 300 | - | p.config.Microcode = microcode | |||
| 301 | - | p.config.CPUVendor = cpuType | |||
| 302 | - | ||||
| 303 | - | switch { | |||
| 304 | - | case microcode != "": | |||
| 305 | - | p.SendOutput(progressChan, fmt.Sprintf("[OK] Detected %s CPU", cpuType)) | |||
| 306 | - | p.SendOutput(progressChan, fmt.Sprintf(" Microcode: %s", microcode)) | |||
| 307 | - | } | |||
| 308 | - | ||||
| 309 | - | return nil | |||
| 310 | - | } | |||
| 311 | - | ||||
| 312 | - | // runPacstrap installs base system with pacstrap | |||
| 313 | - | func (p *BaseInstallPhase) runPacstrap(progressChan chan<- ProgressUpdate) error { | |||
| 314 | - | // Load base packages from embedded list | |||
| 315 | - | packages, err := p.loadBasePackages() | |||
| 316 | - | switch { | |||
| 317 | - | case err != nil: | |||
| 318 | - | return fmt.Errorf("failed to load package list: %w", err) | |||
| 319 | - | } | |||
| 320 | - | ||||
| 321 | - | // Add kernel | |||
| 322 | - | switch { | |||
| 323 | - | case p.config.KernelChoice != "": | |||
| 324 | - | packages = append(packages, p.config.KernelChoice) | |||
| 325 | - | p.SendOutput(progressChan, fmt.Sprintf("Adding kernel: %s", p.config.KernelChoice)) | |||
| 326 | - | default: | |||
| 327 | - | packages = append(packages, config.KernelLinux) | |||
| 328 | - | p.config.KernelChoice = config.KernelLinux | |||
| 329 | - | p.SendOutput(progressChan, "Adding kernel: linux (default)") | |||
| 330 | - | } | |||
| 331 | - | ||||
| 332 | - | // Add microcode | |||
| 333 | - | switch { | |||
| 334 | - | case p.config.Microcode != "": | |||
| 335 | - | packages = append(packages, p.config.Microcode) | |||
| 336 | - | p.SendOutput(progressChan, fmt.Sprintf("Adding microcode: %s", p.config.Microcode)) | |||
| 337 | - | } | |||
| 338 | - | ||||
| 339 | - | // Add cryptsetup if LUKS enabled | |||
| 340 | - | switch p.config.EncryptionType { | |||
| 341 | - | case config.EncryptionLUKS, config.EncryptionLUKSLVM: | |||
| 342 | - | packages = append(packages, "cryptsetup") | |||
| 343 | - | p.SendOutput(progressChan, "Adding cryptsetup for LUKS encryption") | |||
| 344 | - | } | |||
| 345 | - | ||||
| 346 | - | p.SendOutput(progressChan, fmt.Sprintf("Installing %d packages:", len(packages))) | |||
| 347 | - | ||||
| 348 | - | // Show package list | |||
| 349 | - | for _, pkg := range packages { | |||
| 350 | - | p.SendOutput(progressChan, fmt.Sprintf(" - %s", pkg)) | |||
| 351 | - | } | |||
| 352 | - | ||||
| 353 | - | p.SendOutput(progressChan, "") | |||
| 354 | - | p.SendOutput(progressChan, "Starting installation (this may take several minutes)...") | |||
| 355 | - | ||||
| 356 | - | // Run pacstrap | |||
| 357 | - | args := append([]string{"/mnt"}, packages...) | |||
| 358 | - | result := p.logger.ExecCommand("pacstrap", args...) | |||
| 359 | - | ||||
| 360 | - | switch { | |||
| 361 | - | case result.ExitCode != 0: | |||
| 362 | - | return fmt.Errorf("pacstrap failed: %w", result.Error) | |||
| 363 | - | } | |||
| 364 | - | ||||
| 365 | - | p.SendOutput(progressChan, fmt.Sprintf("[OK] Installed %d packages", len(packages))) | |||
| 366 | - | ||||
| 367 | - | // Copy CachyOS configuration to installed system if needed | |||
| 368 | - | if p.config.KernelChoice == config.KernelLinuxCachyOS { | |||
| 369 | - | p.SendOutput(progressChan, "Configuring CachyOS repository in installed system...") | |||
| 370 | - | if err := p.copyCachyOSConfig(progressChan); err != nil { | |||
| 371 | - | return fmt.Errorf("failed to configure CachyOS in installed system: %w", err) | |||
| 372 | - | } | |||
| 373 | - | } | |||
| 374 | - | ||||
| 375 | - | return nil | |||
| 376 | - | } | |||
| 377 | - | ||||
| 378 | - | // loadBasePackages reads the base package list from file | |||
| 379 | - | func (p *BaseInstallPhase) loadBasePackages() ([]string, error) { | |||
| 380 | - | // Read from install directory (downloaded during bootstrap) | |||
| 381 | - | // Use DefaultInstallDir directly to match where bootstrap downloads files | |||
| 382 | - | packageFile := config.DefaultInstallDir + "/" + config.BasePackagesFile | |||
| 383 | - | ||||
| 384 | - | file, err := p.fs.Open(packageFile) | |||
| 385 | - | switch { | |||
| 386 | - | case err != nil: | |||
| 387 | - | return nil, fmt.Errorf("failed to open %s: %w", packageFile, err) | |||
| 388 | - | } | |||
| 389 | - | defer func() { | |||
| 390 | - | if err := file.Close(); err != nil { | |||
| 391 | - | p.logger.Warn("Failed to close file", "error", err) | |||
| 392 | - | } | |||
| 393 | - | }() | |||
| 394 | - | ||||
| 395 | - | var packages []string | |||
| 396 | - | scanner := bufio.NewScanner(file) | |||
| 397 | - | ||||
| 398 | - | for scanner.Scan() { | |||
| 399 | - | line := strings.TrimSpace(scanner.Text()) | |||
| 400 | - | ||||
| 401 | - | // Skip comments and empty lines | |||
| 402 | - | switch { | |||
| 403 | - | case line == "": | |||
| 404 | - | continue | |||
| 405 | - | case strings.HasPrefix(line, "#"): | |||
| 406 | - | continue | |||
| 407 | - | } | |||
| 408 | - | ||||
| 409 | - | packages = append(packages, line) | |||
| 410 | - | } | |||
| 411 | - | ||||
| 412 | - | switch err := scanner.Err(); { | |||
| 413 | - | case err != nil: | |||
| 414 | - | return nil, fmt.Errorf("error reading package file: %w", err) | |||
| 415 | - | } | |||
| 416 | - | ||||
| 417 | - | return packages, nil | |||
| 418 | - | } | |||
| 419 | - | ||||
| 420 | - | // generateFstab generates /etc/fstab using UUIDs | |||
| 421 | - | func (p *BaseInstallPhase) generateFstab(progressChan chan<- ProgressUpdate) error { | |||
| 422 | - | p.SendOutput(progressChan, "Generating fstab with UUIDs...") | |||
| 423 | - | ||||
| 424 | - | result := p.sysExec.RunSimple("sh", "-c", "genfstab -U /mnt >> /mnt/etc/fstab") | |||
| 425 | - | switch { | |||
| 426 | - | case result.ExitCode != 0: | |||
| 427 | - | return fmt.Errorf("genfstab failed: %w", result.Error) | |||
| 428 | - | } | |||
| 429 | - | ||||
| 430 | - | p.SendOutput(progressChan, "[OK] fstab generated") | |||
| 431 | - | return nil | |||
| 432 | - | } | |||
| 433 | - | ||||
| 434 | - | // PostCheck validates installation | |||
| 435 | - | func (p *BaseInstallPhase) PostCheck() error { | |||
| 436 | - | // Check if base system exists | |||
| 437 | - | result := p.sysExec.RunSimple("test", "-d", "/mnt/usr") | |||
| 438 | - | switch { | |||
| 439 | - | case result.ExitCode != 0: | |||
| 440 | - | return fmt.Errorf("/mnt/usr does not exist") | |||
| 441 | - | } | |||
| 442 | - | ||||
| 443 | - | // Check if fstab exists | |||
| 444 | - | result = p.sysExec.RunSimple("test", "-f", "/mnt/etc/fstab") | |||
| 445 | - | switch { | |||
| 446 | - | case result.ExitCode != 0: | |||
| 447 | - | return fmt.Errorf("/mnt/etc/fstab was not created") | |||
| 448 | - | } | |||
| 449 | - | ||||
| 450 | - | return p.config.Save() | |||
| 451 | - | } | |||
| 452 | - | ||||
| 453 | - | // Rollback removes installed system | |||
| 454 | - | func (p *BaseInstallPhase) Rollback() error { | |||
| 455 | - | // Just log - actual cleanup happens in partitioning rollback | |||
| 456 | - | p.SendOutput(nil, "[WARN] Base installation failed, partitions will be cleaned up") | |||
| 457 | - | return nil | |||
| 458 | - | } | |||
| 459 | - | ||||
| 460 | - | // CanSkip returns false - base cannot be skipped | |||
| 461 | - | func (p *BaseInstallPhase) CanSkip() bool { | |||
| 462 | - | return false | |||
| 463 | - | } | |||
D internal/phases/base_test.go +0 -491
| @@ -1,491 +0,0 @@ | |||||
| 1 | - | package phases | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "bytes" | |||
| 5 | - | "fmt" | |||
| 6 | - | "io" | |||
| 7 | - | "path/filepath" | |||
| 8 | - | "testing" | |||
| 9 | - | ||||
| 10 | - | "github.com/bnema/archup/internal/config" | |||
| 11 | - | "github.com/bnema/archup/internal/interfaces/mocks" | |||
| 12 | - | "github.com/bnema/archup/internal/logger" | |||
| 13 | - | "github.com/bnema/archup/internal/system" | |||
| 14 | - | "go.uber.org/mock/gomock" | |||
| 15 | - | ) | |||
| 16 | - | ||||
| 17 | - | // TestBaseInstallPhasePreCheck tests /mnt mount validation | |||
| 18 | - | func TestBaseInstallPhasePreCheck(t *testing.T) { | |||
| 19 | - | tests := []struct { | |||
| 20 | - | name string | |||
| 21 | - | mountResult system.CommandResult | |||
| 22 | - | wantErr bool | |||
| 23 | - | errContains string | |||
| 24 | - | }{ | |||
| 25 | - | { | |||
| 26 | - | name: "/mnt is mounted", | |||
| 27 | - | mountResult: system.CommandResult{ExitCode: 0}, | |||
| 28 | - | wantErr: false, | |||
| 29 | - | }, | |||
| 30 | - | { | |||
| 31 | - | name: "/mnt is not mounted", | |||
| 32 | - | mountResult: system.CommandResult{ExitCode: 32}, | |||
| 33 | - | wantErr: true, | |||
| 34 | - | errContains: "/mnt is not mounted", | |||
| 35 | - | }, | |||
| 36 | - | } | |||
| 37 | - | ||||
| 38 | - | for _, tt := range tests { | |||
| 39 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 40 | - | ctrl := gomock.NewController(t) | |||
| 41 | - | defer ctrl.Finish() | |||
| 42 | - | ||||
| 43 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 44 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 45 | - | ||||
| 46 | - | mockSysExec.EXPECT().RunSimple("mountpoint", "-q", "/mnt").Return(tt.mountResult).Times(1) | |||
| 47 | - | ||||
| 48 | - | tmpDir := t.TempDir() | |||
| 49 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 50 | - | log, err := logger.New(logPath, false) | |||
| 51 | - | if err != nil { | |||
| 52 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 53 | - | } | |||
| 54 | - | defer func() { | |||
| 55 | - | if err := log.Close(); err != nil { | |||
| 56 | - | t.Fatalf("failed to close log: %v", err) | |||
| 57 | - | } | |||
| 58 | - | }() | |||
| 59 | - | ||||
| 60 | - | cfg := config.NewConfig("test") | |||
| 61 | - | phase := NewBaseInstallPhase(cfg, log, mockFS, mockSysExec) | |||
| 62 | - | ||||
| 63 | - | err = phase.PreCheck() | |||
| 64 | - | if (err != nil) != tt.wantErr { | |||
| 65 | - | t.Errorf("PreCheck() error = %v, wantErr %v", err, tt.wantErr) | |||
| 66 | - | return | |||
| 67 | - | } | |||
| 68 | - | if tt.wantErr && err != nil { | |||
| 69 | - | if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) { | |||
| 70 | - | t.Errorf("PreCheck() error = %q, should contain %q", errMsg, tt.errContains) | |||
| 71 | - | } | |||
| 72 | - | } | |||
| 73 | - | }) | |||
| 74 | - | } | |||
| 75 | - | } | |||
| 76 | - | ||||
| 77 | - | // TestBaseInstallPhaseDetectCPU tests CPU vendor detection | |||
| 78 | - | func TestBaseInstallPhaseDetectCPU(t *testing.T) { | |||
| 79 | - | tests := []struct { | |||
| 80 | - | name string | |||
| 81 | - | cpuinfoContent string | |||
| 82 | - | expectedVendor string | |||
| 83 | - | expectedMicrocode string | |||
| 84 | - | wantErr bool | |||
| 85 | - | }{ | |||
| 86 | - | { | |||
| 87 | - | name: "Intel CPU detected", | |||
| 88 | - | cpuinfoContent: "processor\t: 0\nvendor_id\t: GenuineIntel\nflags\t: fpu\n", | |||
| 89 | - | expectedVendor: "Intel", | |||
| 90 | - | expectedMicrocode: "intel-ucode", | |||
| 91 | - | wantErr: false, | |||
| 92 | - | }, | |||
| 93 | - | { | |||
| 94 | - | name: "AMD CPU detected", | |||
| 95 | - | cpuinfoContent: "processor\t: 0\nvendor_id\t: AuthenticAMD\nflags\t: fpu\n", | |||
| 96 | - | expectedVendor: "AMD", | |||
| 97 | - | expectedMicrocode: "amd-ucode", | |||
| 98 | - | wantErr: false, | |||
| 99 | - | }, | |||
| 100 | - | { | |||
| 101 | - | name: "Unknown CPU vendor", | |||
| 102 | - | cpuinfoContent: "processor\t: 0\nvendor_id\t: UnknownVendor\nflags\t: fpu\n", | |||
| 103 | - | expectedVendor: "Unknown", | |||
| 104 | - | expectedMicrocode: "", | |||
| 105 | - | wantErr: false, | |||
| 106 | - | }, | |||
| 107 | - | { | |||
| 108 | - | name: "Empty cpuinfo", | |||
| 109 | - | cpuinfoContent: "", | |||
| 110 | - | expectedVendor: "Unknown", | |||
| 111 | - | expectedMicrocode: "", | |||
| 112 | - | wantErr: false, | |||
| 113 | - | }, | |||
| 114 | - | } | |||
| 115 | - | ||||
| 116 | - | for _, tt := range tests { | |||
| 117 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 118 | - | ctrl := gomock.NewController(t) | |||
| 119 | - | defer ctrl.Finish() | |||
| 120 | - | ||||
| 121 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 122 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 123 | - | ||||
| 124 | - | reader := io.NopCloser(bytes.NewBufferString(tt.cpuinfoContent)) | |||
| 125 | - | mockFS.EXPECT().Open("/proc/cpuinfo").Return(reader, nil).Times(1) | |||
| 126 | - | ||||
| 127 | - | tmpDir := t.TempDir() | |||
| 128 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 129 | - | log, err := logger.New(logPath, false) | |||
| 130 | - | if err != nil { | |||
| 131 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 132 | - | } | |||
| 133 | - | defer func() { | |||
| 134 | - | if err := log.Close(); err != nil { | |||
| 135 | - | t.Fatalf("failed to close log: %v", err) | |||
| 136 | - | } | |||
| 137 | - | }() | |||
| 138 | - | ||||
| 139 | - | cfg := config.NewConfig("test") | |||
| 140 | - | phase := NewBaseInstallPhase(cfg, log, mockFS, mockSysExec) | |||
| 141 | - | ||||
| 142 | - | progressChan := make(chan ProgressUpdate, 10) | |||
| 143 | - | err = phase.detectCPU(progressChan) | |||
| 144 | - | ||||
| 145 | - | if (err != nil) != tt.wantErr { | |||
| 146 | - | t.Errorf("detectCPU() error = %v, wantErr %v", err, tt.wantErr) | |||
| 147 | - | return | |||
| 148 | - | } | |||
| 149 | - | ||||
| 150 | - | if cfg.CPUVendor != tt.expectedVendor { | |||
| 151 | - | t.Errorf("CPUVendor = %q, want %q", cfg.CPUVendor, tt.expectedVendor) | |||
| 152 | - | } | |||
| 153 | - | if cfg.Microcode != tt.expectedMicrocode { | |||
| 154 | - | t.Errorf("Microcode = %q, want %q", cfg.Microcode, tt.expectedMicrocode) | |||
| 155 | - | } | |||
| 156 | - | }) | |||
| 157 | - | } | |||
| 158 | - | } | |||
| 159 | - | ||||
| 160 | - | // TestBaseInstallPhaseLoadBasePackages tests package file parsing | |||
| 161 | - | func TestBaseInstallPhaseLoadBasePackages(t *testing.T) { | |||
| 162 | - | tests := []struct { | |||
| 163 | - | name string | |||
| 164 | - | fileContent string | |||
| 165 | - | expectedPackages []string | |||
| 166 | - | wantErr bool | |||
| 167 | - | errContains string | |||
| 168 | - | }{ | |||
| 169 | - | { | |||
| 170 | - | name: "Valid package list", | |||
| 171 | - | fileContent: "base\nlinux\nlinux-firmware\ngit\n", | |||
| 172 | - | expectedPackages: []string{"base", "linux", "linux-firmware", "git"}, | |||
| 173 | - | wantErr: false, | |||
| 174 | - | }, | |||
| 175 | - | { | |||
| 176 | - | name: "Package list with comments and empty lines", | |||
| 177 | - | fileContent: "# Core packages\nbase\n\n# Additional\nlinux\n# Another comment\n\ngit\n", | |||
| 178 | - | expectedPackages: []string{"base", "linux", "git"}, | |||
| 179 | - | wantErr: false, | |||
| 180 | - | }, | |||
| 181 | - | { | |||
| 182 | - | name: "Empty file", | |||
| 183 | - | fileContent: "", | |||
| 184 | - | expectedPackages: []string{}, | |||
| 185 | - | wantErr: false, | |||
| 186 | - | }, | |||
| 187 | - | { | |||
| 188 | - | name: "Only comments", | |||
| 189 | - | fileContent: "# Comment 1\n# Comment 2\n", | |||
| 190 | - | expectedPackages: []string{}, | |||
| 191 | - | wantErr: false, | |||
| 192 | - | }, | |||
| 193 | - | { | |||
| 194 | - | name: "Only whitespace", | |||
| 195 | - | fileContent: " \n\n \n", | |||
| 196 | - | expectedPackages: []string{}, | |||
| 197 | - | wantErr: false, | |||
| 198 | - | }, | |||
| 199 | - | } | |||
| 200 | - | ||||
| 201 | - | for _, tt := range tests { | |||
| 202 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 203 | - | ctrl := gomock.NewController(t) | |||
| 204 | - | defer ctrl.Finish() | |||
| 205 | - | ||||
| 206 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 207 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 208 | - | ||||
| 209 | - | reader := io.NopCloser(bytes.NewBufferString(tt.fileContent)) | |||
| 210 | - | packageFile := config.DefaultInstallDir + "/" + config.BasePackagesFile | |||
| 211 | - | mockFS.EXPECT().Open(packageFile).Return(reader, nil).Times(1) | |||
| 212 | - | ||||
| 213 | - | tmpDir := t.TempDir() | |||
| 214 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 215 | - | log, err := logger.New(logPath, false) | |||
| 216 | - | if err != nil { | |||
| 217 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 218 | - | } | |||
| 219 | - | defer func() { | |||
| 220 | - | if err := log.Close(); err != nil { | |||
| 221 | - | t.Fatalf("failed to close log: %v", err) | |||
| 222 | - | } | |||
| 223 | - | }() | |||
| 224 | - | ||||
| 225 | - | cfg := config.NewConfig("test") | |||
| 226 | - | phase := NewBaseInstallPhase(cfg, log, mockFS, mockSysExec) | |||
| 227 | - | ||||
| 228 | - | packages, err := phase.loadBasePackages() | |||
| 229 | - | ||||
| 230 | - | if (err != nil) != tt.wantErr { | |||
| 231 | - | t.Errorf("loadBasePackages() error = %v, wantErr %v", err, tt.wantErr) | |||
| 232 | - | return | |||
| 233 | - | } | |||
| 234 | - | ||||
| 235 | - | if len(packages) != len(tt.expectedPackages) { | |||
| 236 | - | t.Errorf("loadBasePackages() returned %d packages, want %d", len(packages), len(tt.expectedPackages)) | |||
| 237 | - | return | |||
| 238 | - | } | |||
| 239 | - | ||||
| 240 | - | for i, pkg := range packages { | |||
| 241 | - | if pkg != tt.expectedPackages[i] { | |||
| 242 | - | t.Errorf("Package %d = %q, want %q", i, pkg, tt.expectedPackages[i]) | |||
| 243 | - | } | |||
| 244 | - | } | |||
| 245 | - | }) | |||
| 246 | - | } | |||
| 247 | - | } | |||
| 248 | - | ||||
| 249 | - | // TestBaseInstallPhaseGenerateFstab tests fstab generation | |||
| 250 | - | func TestBaseInstallPhaseGenerateFstab(t *testing.T) { | |||
| 251 | - | tests := []struct { | |||
| 252 | - | name string | |||
| 253 | - | genfstabResult system.CommandResult | |||
| 254 | - | wantErr bool | |||
| 255 | - | errContains string | |||
| 256 | - | }{ | |||
| 257 | - | { | |||
| 258 | - | name: "Fstab generation success", | |||
| 259 | - | genfstabResult: system.CommandResult{ExitCode: 0}, | |||
| 260 | - | wantErr: false, | |||
| 261 | - | }, | |||
| 262 | - | { | |||
| 263 | - | name: "Genfstab command failed", | |||
| 264 | - | genfstabResult: system.CommandResult{ExitCode: 1, Error: fmt.Errorf("permission denied")}, | |||
| 265 | - | wantErr: true, | |||
| 266 | - | errContains: "genfstab failed", | |||
| 267 | - | }, | |||
| 268 | - | } | |||
| 269 | - | ||||
| 270 | - | for _, tt := range tests { | |||
| 271 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 272 | - | ctrl := gomock.NewController(t) | |||
| 273 | - | defer ctrl.Finish() | |||
| 274 | - | ||||
| 275 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 276 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 277 | - | ||||
| 278 | - | mockSysExec.EXPECT().RunSimple("sh", "-c", "genfstab -U /mnt >> /mnt/etc/fstab").Return(tt.genfstabResult).Times(1) | |||
| 279 | - | ||||
| 280 | - | tmpDir := t.TempDir() | |||
| 281 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 282 | - | log, err := logger.New(logPath, false) | |||
| 283 | - | if err != nil { | |||
| 284 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 285 | - | } | |||
| 286 | - | defer func() { | |||
| 287 | - | if err := log.Close(); err != nil { | |||
| 288 | - | t.Fatalf("failed to close log: %v", err) | |||
| 289 | - | } | |||
| 290 | - | }() | |||
| 291 | - | ||||
| 292 | - | cfg := config.NewConfig("test") | |||
| 293 | - | phase := NewBaseInstallPhase(cfg, log, mockFS, mockSysExec) | |||
| 294 | - | ||||
| 295 | - | progressChan := make(chan ProgressUpdate, 10) | |||
| 296 | - | err = phase.generateFstab(progressChan) | |||
| 297 | - | ||||
| 298 | - | if (err != nil) != tt.wantErr { | |||
| 299 | - | t.Errorf("generateFstab() error = %v, wantErr %v", err, tt.wantErr) | |||
| 300 | - | return | |||
| 301 | - | } | |||
| 302 | - | if tt.wantErr && err != nil { | |||
| 303 | - | if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) { | |||
| 304 | - | t.Errorf("generateFstab() error = %q, should contain %q", errMsg, tt.errContains) | |||
| 305 | - | } | |||
| 306 | - | } | |||
| 307 | - | }) | |||
| 308 | - | } | |||
| 309 | - | } | |||
| 310 | - | ||||
| 311 | - | // TestBaseInstallPhasePostCheck tests installation validation | |||
| 312 | - | func TestBaseInstallPhasePostCheck(t *testing.T) { | |||
| 313 | - | tests := []struct { | |||
| 314 | - | name string | |||
| 315 | - | usrDirExists bool | |||
| 316 | - | fstabFileExists bool | |||
| 317 | - | wantErr bool | |||
| 318 | - | errContains string | |||
| 319 | - | }{ | |||
| 320 | - | { | |||
| 321 | - | name: "Both /mnt/usr and /mnt/etc/fstab exist", | |||
| 322 | - | usrDirExists: true, | |||
| 323 | - | fstabFileExists: true, | |||
| 324 | - | wantErr: false, | |||
| 325 | - | }, | |||
| 326 | - | { | |||
| 327 | - | name: "/mnt/usr does not exist", | |||
| 328 | - | usrDirExists: false, | |||
| 329 | - | fstabFileExists: true, | |||
| 330 | - | wantErr: true, | |||
| 331 | - | errContains: "/mnt/usr does not exist", | |||
| 332 | - | }, | |||
| 333 | - | { | |||
| 334 | - | name: "/mnt/etc/fstab does not exist", | |||
| 335 | - | usrDirExists: true, | |||
| 336 | - | fstabFileExists: false, | |||
| 337 | - | wantErr: true, | |||
| 338 | - | errContains: "fstab was not created", | |||
| 339 | - | }, | |||
| 340 | - | } | |||
| 341 | - | ||||
| 342 | - | for _, tt := range tests { | |||
| 343 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 344 | - | ctrl := gomock.NewController(t) | |||
| 345 | - | defer ctrl.Finish() | |||
| 346 | - | ||||
| 347 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 348 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 349 | - | ||||
| 350 | - | usrResult := system.CommandResult{ExitCode: 1} | |||
| 351 | - | if tt.usrDirExists { | |||
| 352 | - | usrResult.ExitCode = 0 | |||
| 353 | - | } | |||
| 354 | - | mockSysExec.EXPECT().RunSimple("test", "-d", "/mnt/usr").Return(usrResult).Times(1) | |||
| 355 | - | ||||
| 356 | - | // Only expect fstab check if usr dir exists | |||
| 357 | - | if tt.usrDirExists { | |||
| 358 | - | fstabResult := system.CommandResult{ExitCode: 1} | |||
| 359 | - | if tt.fstabFileExists { | |||
| 360 | - | fstabResult.ExitCode = 0 | |||
| 361 | - | } | |||
| 362 | - | mockSysExec.EXPECT().RunSimple("test", "-f", "/mnt/etc/fstab").Return(fstabResult).Times(1) | |||
| 363 | - | } | |||
| 364 | - | ||||
| 365 | - | tmpDir := t.TempDir() | |||
| 366 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 367 | - | log, err := logger.New(logPath, false) | |||
| 368 | - | if err != nil { | |||
| 369 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 370 | - | } | |||
| 371 | - | defer func() { | |||
| 372 | - | if err := log.Close(); err != nil { | |||
| 373 | - | t.Fatalf("failed to close log: %v", err) | |||
| 374 | - | } | |||
| 375 | - | }() | |||
| 376 | - | ||||
| 377 | - | cfg := config.NewConfig("test") | |||
| 378 | - | cfg.ConfigPath = filepath.Join(tmpDir, "config") | |||
| 379 | - | ||||
| 380 | - | phase := NewBaseInstallPhase(cfg, log, mockFS, mockSysExec) | |||
| 381 | - | ||||
| 382 | - | err = phase.PostCheck() | |||
| 383 | - | ||||
| 384 | - | if (err != nil) != tt.wantErr { | |||
| 385 | - | t.Errorf("PostCheck() error = %v, wantErr %v", err, tt.wantErr) | |||
| 386 | - | return | |||
| 387 | - | } | |||
| 388 | - | if tt.wantErr && err != nil { | |||
| 389 | - | if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) { | |||
| 390 | - | t.Errorf("PostCheck() error = %q, should contain %q", errMsg, tt.errContains) | |||
| 391 | - | } | |||
| 392 | - | } | |||
| 393 | - | }) | |||
| 394 | - | } | |||
| 395 | - | } | |||
| 396 | - | ||||
| 397 | - | // TestBaseInstallPhaseExecute tests Execute calls correct steps | |||
| 398 | - | func TestBaseInstallPhaseExecute(t *testing.T) { | |||
| 399 | - | // Note: Full Execute testing is complex due to logger.ExecCommand() calls | |||
| 400 | - | // This test verifies the phase structure and progress updates | |||
| 401 | - | tmpDir := t.TempDir() | |||
| 402 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 403 | - | log, err := logger.New(logPath, false) | |||
| 404 | - | if err != nil { | |||
| 405 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 406 | - | } | |||
| 407 | - | defer func() { | |||
| 408 | - | if err := log.Close(); err != nil { | |||
| 409 | - | t.Fatalf("failed to close log: %v", err) | |||
| 410 | - | } | |||
| 411 | - | }() | |||
| 412 | - | ||||
| 413 | - | ctrl := gomock.NewController(t) | |||
| 414 | - | defer ctrl.Finish() | |||
| 415 | - | ||||
| 416 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 417 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 418 | - | ||||
| 419 | - | cfg := config.NewConfig("test") | |||
| 420 | - | phase := NewBaseInstallPhase(cfg, log, mockFS, mockSysExec) | |||
| 421 | - | ||||
| 422 | - | progressChan := make(chan ProgressUpdate, 100) | |||
| 423 | - | ||||
| 424 | - | // Just verify that Execute sends progress updates (higher-level behavior) | |||
| 425 | - | // since testing the full Execute requires mocking logger.ExecCommand | |||
| 426 | - | result := phase.Execute(progressChan) | |||
| 427 | - | ||||
| 428 | - | // Result may be error due to logger.ExecCommand, but we should get a PhaseResult | |||
| 429 | - | if result.Message == "" && !result.Success { | |||
| 430 | - | // This is expected when logger.ExecCommand is called | |||
| 431 | - | t.Logf("Execute correctly attempted to run - logger.ExecCommand not mocked") | |||
| 432 | - | } | |||
| 433 | - | } | |||
| 434 | - | ||||
| 435 | - | // TestBaseInstallPhaseRollback tests rollback | |||
| 436 | - | func TestBaseInstallPhaseRollback(t *testing.T) { | |||
| 437 | - | ctrl := gomock.NewController(t) | |||
| 438 | - | defer ctrl.Finish() | |||
| 439 | - | ||||
| 440 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 441 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 442 | - | ||||
| 443 | - | tmpDir := t.TempDir() | |||
| 444 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 445 | - | log, err := logger.New(logPath, false) | |||
| 446 | - | if err != nil { | |||
| 447 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 448 | - | } | |||
| 449 | - | defer func() { | |||
| 450 | - | if err := log.Close(); err != nil { | |||
| 451 | - | t.Fatalf("failed to close log: %v", err) | |||
| 452 | - | } | |||
| 453 | - | }() | |||
| 454 | - | ||||
| 455 | - | cfg := config.NewConfig("test") | |||
| 456 | - | phase := NewBaseInstallPhase(cfg, log, mockFS, mockSysExec) | |||
| 457 | - | ||||
| 458 | - | // BaseInstallPhase Rollback should be no-op | |||
| 459 | - | err = phase.Rollback() | |||
| 460 | - | if err != nil { | |||
| 461 | - | t.Errorf("Rollback() error = %v, want nil", err) | |||
| 462 | - | } | |||
| 463 | - | } | |||
| 464 | - | ||||
| 465 | - | // TestBaseInstallPhaseCanSkip tests CanSkip | |||
| 466 | - | func TestBaseInstallPhaseCanSkip(t *testing.T) { | |||
| 467 | - | ctrl := gomock.NewController(t) | |||
| 468 | - | defer ctrl.Finish() | |||
| 469 | - | ||||
| 470 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 471 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 472 | - | ||||
| 473 | - | tmpDir := t.TempDir() | |||
| 474 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 475 | - | log, err := logger.New(logPath, false) | |||
| 476 | - | if err != nil { | |||
| 477 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 478 | - | } | |||
| 479 | - | defer func() { | |||
| 480 | - | if err := log.Close(); err != nil { | |||
| 481 | - | t.Fatalf("failed to close log: %v", err) | |||
| 482 | - | } | |||
| 483 | - | }() | |||
| 484 | - | ||||
| 485 | - | cfg := config.NewConfig("test") | |||
| 486 | - | phase := NewBaseInstallPhase(cfg, log, mockFS, mockSysExec) | |||
| 487 | - | ||||
| 488 | - | if phase.CanSkip() { | |||
| 489 | - | t.Error("CanSkip() = true, want false") | |||
| 490 | - | } | |||
| 491 | - | } | |||
D internal/phases/boot.go +0 -321
| @@ -1,321 +0,0 @@ | |||||
| 1 | - | package phases | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "fmt" | |||
| 5 | - | "path/filepath" | |||
| 6 | - | "regexp" | |||
| 7 | - | "strings" | |||
| 8 | - | ||||
| 9 | - | "github.com/bnema/archup/internal/config" | |||
| 10 | - | "github.com/bnema/archup/internal/interfaces" | |||
| 11 | - | "github.com/bnema/archup/internal/logger" | |||
| 12 | - | "github.com/bnema/archup/internal/system" | |||
| 13 | - | ) | |||
| 14 | - | ||||
| 15 | - | // BootPhase handles bootloader installation | |||
| 16 | - | type BootPhase struct { | |||
| 17 | - | *BasePhase | |||
| 18 | - | fs interfaces.FileSystem | |||
| 19 | - | sysExec interfaces.SystemExecutor | |||
| 20 | - | chrExec interfaces.ChrootExecutor | |||
| 21 | - | } | |||
| 22 | - | ||||
| 23 | - | // NewBootPhase creates a new boot phase | |||
| 24 | - | func NewBootPhase(cfg *config.Config, log *logger.Logger, fs interfaces.FileSystem, sysExec interfaces.SystemExecutor, chrExec interfaces.ChrootExecutor) *BootPhase { | |||
| 25 | - | return &BootPhase{ | |||
| 26 | - | BasePhase: NewBasePhase("boot", "Bootloader Installation", cfg, log), | |||
| 27 | - | fs: fs, | |||
| 28 | - | sysExec: sysExec, | |||
| 29 | - | chrExec: chrExec, | |||
| 30 | - | } | |||
| 31 | - | } | |||
| 32 | - | ||||
| 33 | - | // PreCheck validates boot prerequisites | |||
| 34 | - | func (p *BootPhase) PreCheck() error { | |||
| 35 | - | // Verify /mnt is mounted | |||
| 36 | - | result := p.sysExec.RunSimple("mountpoint", "-q", config.PathMnt) | |||
| 37 | - | if result.ExitCode != 0 { | |||
| 38 | - | return fmt.Errorf("%s is not mounted", config.PathMnt) | |||
| 39 | - | } | |||
| 40 | - | ||||
| 41 | - | // Verify boot partition is mounted | |||
| 42 | - | result = p.sysExec.RunSimple("mountpoint", "-q", config.PathMntBoot) | |||
| 43 | - | if result.ExitCode != 0 { | |||
| 44 | - | return fmt.Errorf("%s is not mounted", config.PathMntBoot) | |||
| 45 | - | } | |||
| 46 | - | ||||
| 47 | - | return nil | |||
| 48 | - | } | |||
| 49 | - | ||||
| 50 | - | // Execute runs the boot phase | |||
| 51 | - | func (p *BootPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult { | |||
| 52 | - | p.SendProgress(progressChan, "Starting bootloader installation...", 1, 4) | |||
| 53 | - | ||||
| 54 | - | // Step 1: Configure initramfs | |||
| 55 | - | if err := p.configureMkinitcpio(progressChan); err != nil { | |||
| 56 | - | p.SendError(progressChan, err) | |||
| 57 | - | return PhaseResult{Success: false, Error: err} | |||
| 58 | - | } | |||
| 59 | - | ||||
| 60 | - | p.SendProgress(progressChan, "Installing Limine bootloader...", 2, 4) | |||
| 61 | - | ||||
| 62 | - | // Step 2: Install Limine | |||
| 63 | - | if err := p.installLimine(progressChan); err != nil { | |||
| 64 | - | p.SendError(progressChan, err) | |||
| 65 | - | return PhaseResult{Success: false, Error: err} | |||
| 66 | - | } | |||
| 67 | - | ||||
| 68 | - | p.SendProgress(progressChan, "Configuring Limine...", 3, 4) | |||
| 69 | - | ||||
| 70 | - | // Step 3: Configure Limine | |||
| 71 | - | if err := p.configureLimine(progressChan); err != nil { | |||
| 72 | - | p.SendError(progressChan, err) | |||
| 73 | - | return PhaseResult{Success: false, Error: err} | |||
| 74 | - | } | |||
| 75 | - | ||||
| 76 | - | p.SendProgress(progressChan, "Creating UEFI boot entry...", 4, 4) | |||
| 77 | - | ||||
| 78 | - | // Step 4: Create UEFI boot entry | |||
| 79 | - | if err := p.createUEFIEntry(progressChan); err != nil { | |||
| 80 | - | p.SendError(progressChan, err) | |||
| 81 | - | return PhaseResult{Success: false, Error: err} | |||
| 82 | - | } | |||
| 83 | - | ||||
| 84 | - | p.SendProgress(progressChan, "Bootloader installation complete", 4, 4) | |||
| 85 | - | p.SendComplete(progressChan, "Limine installed successfully") | |||
| 86 | - | ||||
| 87 | - | return PhaseResult{Success: true, Message: "Boot configuration complete"} | |||
| 88 | - | } | |||
| 89 | - | ||||
| 90 | - | // configureMkinitcpio configures initramfs hooks | |||
| 91 | - | func (p *BootPhase) configureMkinitcpio(progressChan chan<- ProgressUpdate) error { | |||
| 92 | - | p.SendOutput(progressChan, "Configuring initramfs...") | |||
| 93 | - | ||||
| 94 | - | // Read current mkinitcpio.conf | |||
| 95 | - | content, err := p.fs.ReadFile(config.PathMntEtcMkinitcpio) | |||
| 96 | - | if err != nil { | |||
| 97 | - | return fmt.Errorf("failed to read mkinitcpio.conf: %w", err) | |||
| 98 | - | } | |||
| 99 | - | ||||
| 100 | - | // Determine hooks based on encryption | |||
| 101 | - | var hooks string | |||
| 102 | - | switch p.config.EncryptionType { | |||
| 103 | - | case config.EncryptionLUKS, config.EncryptionLUKSLVM: | |||
| 104 | - | hooks = config.MkinitcpioHooksEncrypted | |||
| 105 | - | p.SendOutput(progressChan, "Configuring initramfs for encrypted Plymouth boot") | |||
| 106 | - | default: | |||
| 107 | - | hooks = config.MkinitcpioHooksPlymouth | |||
| 108 | - | p.SendOutput(progressChan, "Configuring initramfs for Plymouth boot") | |||
| 109 | - | } | |||
| 110 | - | ||||
| 111 | - | // Replace HOOKS line | |||
| 112 | - | hooksRegex := regexp.MustCompile(`(?m)^HOOKS=.*$`) | |||
| 113 | - | newContent := hooksRegex.ReplaceAllString(string(content), hooks) | |||
| 114 | - | ||||
| 115 | - | // Write updated config | |||
| 116 | - | if err := p.fs.WriteFile(config.PathMntEtcMkinitcpio, []byte(newContent), 0644); err != nil { | |||
| 117 | - | return fmt.Errorf("failed to write mkinitcpio.conf: %w", err) | |||
| 118 | - | } | |||
| 119 | - | ||||
| 120 | - | // Enable fallback preset for the kernel (some kernels like linux-cachyos have it disabled by default) | |||
| 121 | - | presetPath := fmt.Sprintf("/etc/mkinitcpio.d/%s.preset", p.config.KernelChoice) | |||
| 122 | - | enableFallbackCmd := fmt.Sprintf( | |||
| 123 | - | "sed -i \"s/#PRESETS=('default' 'fallback')/PRESETS=('default' 'fallback')/\" %s && "+ | |||
| 124 | - | "sed -i 's/#fallback_image/fallback_image/' %s && "+ | |||
| 125 | - | "sed -i 's/#fallback_options/fallback_options/' %s", | |||
| 126 | - | presetPath, presetPath, presetPath) | |||
| 127 | - | if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, enableFallbackCmd); err != nil { | |||
| 128 | - | p.SendOutput(progressChan, "[WARN] Could not enable fallback preset (may already be enabled)") | |||
| 129 | - | } | |||
| 130 | - | ||||
| 131 | - | // Regenerate initramfs | |||
| 132 | - | p.SendOutput(progressChan, "Regenerating initramfs...") | |||
| 133 | - | if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, "mkinitcpio -P"); err != nil { | |||
| 134 | - | return fmt.Errorf("failed to regenerate initramfs: %w", err) | |||
| 135 | - | } | |||
| 136 | - | ||||
| 137 | - | p.SendOutput(progressChan, "[OK] Initramfs configured") | |||
| 138 | - | ||||
| 139 | - | return nil | |||
| 140 | - | } | |||
| 141 | - | ||||
| 142 | - | // installLimine installs Limine bootloader files | |||
| 143 | - | func (p *BootPhase) installLimine(progressChan chan<- ProgressUpdate) error { | |||
| 144 | - | p.SendOutput(progressChan, "Installing Limine bootloader...") | |||
| 145 | - | ||||
| 146 | - | // Install Limine to disk (BIOS - optional, may fail on UEFI-only) | |||
| 147 | - | biosCmd := fmt.Sprintf("limine bios-install %s", p.config.TargetDisk) | |||
| 148 | - | switch err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, biosCmd); { | |||
| 149 | - | case err != nil: | |||
| 150 | - | p.SendOutput(progressChan, "[WARN] BIOS installation skipped (UEFI-only system)") | |||
| 151 | - | default: | |||
| 152 | - | p.SendOutput(progressChan, "[OK] BIOS bootloader installed") | |||
| 153 | - | } | |||
| 154 | - | ||||
| 155 | - | // Create Limine directory | |||
| 156 | - | if err := p.fs.MkdirAll(config.PathMntBootEFILimine, 0755); err != nil { | |||
| 157 | - | return fmt.Errorf("failed to create Limine directory: %w", err) | |||
| 158 | - | } | |||
| 159 | - | ||||
| 160 | - | // Copy Limine EFI bootloader | |||
| 161 | - | srcBootloader := filepath.Join(config.PathUsrShareLimine, config.FileLimineBootloader) | |||
| 162 | - | dstBootloader := filepath.Join(config.PathMntBootEFILimine, config.FileLimineBootloader) | |||
| 163 | - | ||||
| 164 | - | cpCmd := fmt.Sprintf("cp %s %s", srcBootloader, dstBootloader) | |||
| 165 | - | result := p.logger.ExecCommand("sh", "-c", cpCmd) | |||
| 166 | - | if result.ExitCode != 0 { | |||
| 167 | - | return fmt.Errorf("failed to copy Limine bootloader: %w", result.Error) | |||
| 168 | - | } | |||
| 169 | - | ||||
| 170 | - | p.SendOutput(progressChan, "[OK] Limine files installed") | |||
| 171 | - | ||||
| 172 | - | return nil | |||
| 173 | - | } | |||
| 174 | - | ||||
| 175 | - | // configureLimine creates Limine configuration from template | |||
| 176 | - | func (p *BootPhase) configureLimine(progressChan chan<- ProgressUpdate) error { | |||
| 177 | - | p.SendOutput(progressChan, "Configuring Limine...") | |||
| 178 | - | ||||
| 179 | - | // Get root partition UUID | |||
| 180 | - | rootPartition := p.config.RootPartition | |||
| 181 | - | result := p.sysExec.RunSimple("blkid", "-s", "UUID", "-o", "value", rootPartition) | |||
| 182 | - | if result.ExitCode != 0 { | |||
| 183 | - | return fmt.Errorf("failed to get root partition UUID: %w", result.Error) | |||
| 184 | - | } | |||
| 185 | - | ||||
| 186 | - | rootUUID := strings.TrimSpace(result.Output) | |||
| 187 | - | ||||
| 188 | - | // Build kernel parameters | |||
| 189 | - | var kernelParams string | |||
| 190 | - | switch p.config.EncryptionType { | |||
| 191 | - | case config.EncryptionLUKS, config.EncryptionLUKSLVM: | |||
| 192 | - | // For encrypted setups | |||
| 193 | - | kernelParams = fmt.Sprintf("cryptdevice=UUID=%s:cryptroot root=/dev/mapper/cryptroot rootflags=subvol=@ rw", rootUUID) | |||
| 194 | - | p.SendOutput(progressChan, "Configured for encrypted root") | |||
| 195 | - | default: | |||
| 196 | - | // For non-encrypted setups | |||
| 197 | - | kernelParams = fmt.Sprintf("root=UUID=%s rootflags=subvol=@ rw", rootUUID) | |||
| 198 | - | } | |||
| 199 | - | ||||
| 200 | - | // Add AMD-specific kernel parameters if configured | |||
| 201 | - | if p.config.AMDPState != "" { | |||
| 202 | - | // Detect CPU to get Zen generation for proper kernel params | |||
| 203 | - | cpuInfo, err := p.sysExec.DetectCPUInfo() | |||
| 204 | - | var amdParams string | |||
| 205 | - | if err == nil && cpuInfo.AMDZenGen != nil { | |||
| 206 | - | // Use helper function that adds extra params for Zen 2 if needed | |||
| 207 | - | amdParams = system.GetAMDPStateKernelParams(cpuInfo.AMDZenGen, system.AMDPStateMode(p.config.AMDPState)) | |||
| 208 | - | } else { | |||
| 209 | - | // Fallback to simple parameter if detection fails | |||
| 210 | - | amdParams = fmt.Sprintf("amd_pstate=%s", p.config.AMDPState) | |||
| 211 | - | } | |||
| 212 | - | ||||
| 213 | - | if amdParams != "" { | |||
| 214 | - | kernelParams = fmt.Sprintf("%s %s", kernelParams, amdParams) | |||
| 215 | - | p.SendOutput(progressChan, fmt.Sprintf("Added AMD P-State: %s", amdParams)) | |||
| 216 | - | } | |||
| 217 | - | } | |||
| 218 | - | ||||
| 219 | - | // Add quiet/splash parameters | |||
| 220 | - | kernelParams = fmt.Sprintf("%s %s", kernelParams, config.KernelParamsQuiet) | |||
| 221 | - | ||||
| 222 | - | // Read Limine config template | |||
| 223 | - | templatePath := p.getInstallPath(config.LimineConfigTemplate) | |||
| 224 | - | templateContent, err := p.fs.ReadFile(templatePath) | |||
| 225 | - | if err != nil { | |||
| 226 | - | return fmt.Errorf("failed to read Limine template: %w", err) | |||
| 227 | - | } | |||
| 228 | - | ||||
| 229 | - | // Replace template variables | |||
| 230 | - | limineConfig := string(templateContent) | |||
| 231 | - | limineConfig = strings.ReplaceAll(limineConfig, "{{TIMEOUT}}", config.LimineTimeout) | |||
| 232 | - | limineConfig = strings.ReplaceAll(limineConfig, "{{BRANDING}}", config.LimineBranding) | |||
| 233 | - | limineConfig = strings.ReplaceAll(limineConfig, "{{COLOR}}", config.LimineColor) | |||
| 234 | - | limineConfig = strings.ReplaceAll(limineConfig, "{{KERNEL}}", p.config.KernelChoice) | |||
| 235 | - | limineConfig = strings.ReplaceAll(limineConfig, "{{KERNEL_PARAMS}}", kernelParams) | |||
| 236 | - | ||||
| 237 | - | // Write Limine configuration to /boot/limine.conf (standard Limine search path) | |||
| 238 | - | limineConfigPath := config.PathMntBootLimineConf | |||
| 239 | - | if err := p.fs.WriteFile(limineConfigPath, []byte(limineConfig), 0644); err != nil { | |||
| 240 | - | return fmt.Errorf("failed to write Limine config: %w", err) | |||
| 241 | - | } | |||
| 242 | - | ||||
| 243 | - | p.SendOutput(progressChan, "[OK] Limine configured") | |||
| 244 | - | ||||
| 245 | - | return nil | |||
| 246 | - | } | |||
| 247 | - | ||||
| 248 | - | // createUEFIEntry creates UEFI boot entry | |||
| 249 | - | func (p *BootPhase) createUEFIEntry(progressChan chan<- ProgressUpdate) error { | |||
| 250 | - | p.SendOutput(progressChan, "Creating UEFI boot entry...") | |||
| 251 | - | ||||
| 252 | - | // Extract EFI partition number | |||
| 253 | - | efiPartNum := p.extractPartitionNumber(p.config.EFIPartition) | |||
| 254 | - | if efiPartNum == "" { | |||
| 255 | - | return fmt.Errorf("failed to extract EFI partition number from %s", p.config.EFIPartition) | |||
| 256 | - | } | |||
| 257 | - | ||||
| 258 | - | // Create UEFI boot entry | |||
| 259 | - | efiCmd := fmt.Sprintf("efibootmgr --create --disk %s --part %s --label \"%s\" --loader \"%s\" --unicode", | |||
| 260 | - | p.config.TargetDisk, | |||
| 261 | - | efiPartNum, | |||
| 262 | - | config.UEFIBootLabel, | |||
| 263 | - | config.UEFIBootLoader, | |||
| 264 | - | ) | |||
| 265 | - | ||||
| 266 | - | if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, efiCmd); err != nil { | |||
| 267 | - | return fmt.Errorf("failed to create UEFI boot entry: %w", err) | |||
| 268 | - | } | |||
| 269 | - | ||||
| 270 | - | p.SendOutput(progressChan, "[OK] UEFI boot entry created") | |||
| 271 | - | ||||
| 272 | - | return nil | |||
| 273 | - | } | |||
| 274 | - | ||||
| 275 | - | // getInstallPath constructs full path to install file | |||
| 276 | - | func (p *BootPhase) getInstallPath(filename string) string { | |||
| 277 | - | // Use DefaultInstallDir directly to match where bootstrap downloads files | |||
| 278 | - | return filepath.Join(config.DefaultInstallDir, filename) | |||
| 279 | - | } | |||
| 280 | - | ||||
| 281 | - | // extractPartitionNumber extracts partition number from device path | |||
| 282 | - | func (p *BootPhase) extractPartitionNumber(partPath string) string { | |||
| 283 | - | // Match partition number at end (e.g., /dev/sda1 -> 1, /dev/nvme0n1p1 -> 1) | |||
| 284 | - | re := regexp.MustCompile(`[0-9]+$`) | |||
| 285 | - | match := re.FindString(partPath) | |||
| 286 | - | ||||
| 287 | - | // Remove leading 'p' if present (nvme style) | |||
| 288 | - | return strings.TrimPrefix(match, "p") | |||
| 289 | - | } | |||
| 290 | - | ||||
| 291 | - | // PostCheck validates boot configuration | |||
| 292 | - | func (p *BootPhase) PostCheck() error { | |||
| 293 | - | // Verify Limine bootloader exists | |||
| 294 | - | bootloaderPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineBootloader) | |||
| 295 | - | if _, err := p.fs.Stat(bootloaderPath); p.fs.IsNotExist(err) { | |||
| 296 | - | return fmt.Errorf("limine bootloader was not installed") | |||
| 297 | - | } | |||
| 298 | - | ||||
| 299 | - | // Verify Limine config exists | |||
| 300 | - | configPath := config.PathMntBootLimineConf | |||
| 301 | - | if _, err := p.fs.Stat(configPath); p.fs.IsNotExist(err) { | |||
| 302 | - | return fmt.Errorf("limine config was not created") | |||
| 303 | - | } | |||
| 304 | - | ||||
| 305 | - | return p.config.Save() | |||
| 306 | - | } | |||
| 307 | - | ||||
| 308 | - | // Rollback for boot phase | |||
| 309 | - | func (p *BootPhase) Rollback() error { | |||
| 310 | - | // Remove Limine files if they exist | |||
| 311 | - | if err := p.fs.RemoveAll(config.PathMntBootEFILimine); err != nil { | |||
| 312 | - | p.SendOutput(nil, fmt.Sprintf("[WARN] Failed to remove Limine directory: %v", err)) | |||
| 313 | - | } | |||
| 314 | - | ||||
| 315 | - | return nil | |||
| 316 | - | } | |||
| 317 | - | ||||
| 318 | - | // CanSkip returns false - boot cannot be skipped | |||
| 319 | - | func (p *BootPhase) CanSkip() bool { | |||
| 320 | - | return false | |||
| 321 | - | } | |||
D internal/phases/boot_test.go +0 -562
| @@ -1,562 +0,0 @@ | |||||
| 1 | - | package phases | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "fmt" | |||
| 5 | - | "path/filepath" | |||
| 6 | - | "testing" | |||
| 7 | - | ||||
| 8 | - | "github.com/bnema/archup/internal/config" | |||
| 9 | - | "github.com/bnema/archup/internal/interfaces/mocks" | |||
| 10 | - | "github.com/bnema/archup/internal/logger" | |||
| 11 | - | "github.com/bnema/archup/internal/system" | |||
| 12 | - | "go.uber.org/mock/gomock" | |||
| 13 | - | ) | |||
| 14 | - | ||||
| 15 | - | // TestBootPhasePreCheck tests boot prerequisites | |||
| 16 | - | func TestBootPhasePreCheck(t *testing.T) { | |||
| 17 | - | tests := []struct { | |||
| 18 | - | name string | |||
| 19 | - | setupMocks func(*mocks.MockSystemExecutor) | |||
| 20 | - | wantErr bool | |||
| 21 | - | errContains string | |||
| 22 | - | }{ | |||
| 23 | - | { | |||
| 24 | - | name: "Both /mnt and /mnt/boot mounted", | |||
| 25 | - | setupMocks: func(mockSysExec *mocks.MockSystemExecutor) { | |||
| 26 | - | mockSysExec.EXPECT().RunSimple("mountpoint", "-q", config.PathMnt).Return(system.CommandResult{ExitCode: 0}).Times(1) | |||
| 27 | - | mockSysExec.EXPECT().RunSimple("mountpoint", "-q", config.PathMntBoot).Return(system.CommandResult{ExitCode: 0}).Times(1) | |||
| 28 | - | }, | |||
| 29 | - | wantErr: false, | |||
| 30 | - | }, | |||
| 31 | - | { | |||
| 32 | - | name: "/mnt not mounted", | |||
| 33 | - | setupMocks: func(mockSysExec *mocks.MockSystemExecutor) { | |||
| 34 | - | mockSysExec.EXPECT().RunSimple("mountpoint", "-q", config.PathMnt).Return(system.CommandResult{ExitCode: 32}).Times(1) | |||
| 35 | - | }, | |||
| 36 | - | wantErr: true, | |||
| 37 | - | errContains: "is not mounted", | |||
| 38 | - | }, | |||
| 39 | - | { | |||
| 40 | - | name: "/mnt/boot not mounted", | |||
| 41 | - | setupMocks: func(mockSysExec *mocks.MockSystemExecutor) { | |||
| 42 | - | mockSysExec.EXPECT().RunSimple("mountpoint", "-q", config.PathMnt).Return(system.CommandResult{ExitCode: 0}).Times(1) | |||
| 43 | - | mockSysExec.EXPECT().RunSimple("mountpoint", "-q", config.PathMntBoot).Return(system.CommandResult{ExitCode: 32}).Times(1) | |||
| 44 | - | }, | |||
| 45 | - | wantErr: true, | |||
| 46 | - | errContains: "is not mounted", | |||
| 47 | - | }, | |||
| 48 | - | } | |||
| 49 | - | ||||
| 50 | - | for _, tt := range tests { | |||
| 51 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 52 | - | ctrl := gomock.NewController(t) | |||
| 53 | - | defer ctrl.Finish() | |||
| 54 | - | ||||
| 55 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 56 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 57 | - | mockChrExec := mocks.NewMockChrootExecutor(ctrl) | |||
| 58 | - | ||||
| 59 | - | tt.setupMocks(mockSysExec) | |||
| 60 | - | ||||
| 61 | - | tmpDir := t.TempDir() | |||
| 62 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 63 | - | log, err := logger.New(logPath, false) | |||
| 64 | - | if err != nil { | |||
| 65 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 66 | - | } | |||
| 67 | - | defer func() { | |||
| 68 | - | if err := log.Close(); err != nil { | |||
| 69 | - | t.Fatalf("failed to close log: %v", err) | |||
| 70 | - | } | |||
| 71 | - | }() | |||
| 72 | - | ||||
| 73 | - | cfg := config.NewConfig("test") | |||
| 74 | - | phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec) | |||
| 75 | - | ||||
| 76 | - | err = phase.PreCheck() | |||
| 77 | - | if (err != nil) != tt.wantErr { | |||
| 78 | - | t.Errorf("PreCheck() error = %v, wantErr %v", err, tt.wantErr) | |||
| 79 | - | return | |||
| 80 | - | } | |||
| 81 | - | if tt.wantErr && err != nil { | |||
| 82 | - | if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) { | |||
| 83 | - | t.Errorf("PreCheck() error = %q, should contain %q", errMsg, tt.errContains) | |||
| 84 | - | } | |||
| 85 | - | } | |||
| 86 | - | }) | |||
| 87 | - | } | |||
| 88 | - | } | |||
| 89 | - | ||||
| 90 | - | // TestBootPhaseExtractPartitionNumber tests partition number extraction | |||
| 91 | - | func TestBootPhaseExtractPartitionNumber(t *testing.T) { | |||
| 92 | - | tests := []struct { | |||
| 93 | - | name string | |||
| 94 | - | devicePath string | |||
| 95 | - | expectedNum string | |||
| 96 | - | }{ | |||
| 97 | - | { | |||
| 98 | - | name: "SATA partition /dev/sda1", | |||
| 99 | - | devicePath: "/dev/sda1", | |||
| 100 | - | expectedNum: "1", | |||
| 101 | - | }, | |||
| 102 | - | { | |||
| 103 | - | name: "SATA partition /dev/sdb2", | |||
| 104 | - | devicePath: "/dev/sdb2", | |||
| 105 | - | expectedNum: "2", | |||
| 106 | - | }, | |||
| 107 | - | { | |||
| 108 | - | name: "NVMe partition /dev/nvme0n1p1", | |||
| 109 | - | devicePath: "/dev/nvme0n1p1", | |||
| 110 | - | expectedNum: "1", | |||
| 111 | - | }, | |||
| 112 | - | { | |||
| 113 | - | name: "NVMe partition /dev/nvme0n1p2", | |||
| 114 | - | devicePath: "/dev/nvme0n1p2", | |||
| 115 | - | expectedNum: "2", | |||
| 116 | - | }, | |||
| 117 | - | { | |||
| 118 | - | name: "VirtIO partition /dev/vda1", | |||
| 119 | - | devicePath: "/dev/vda1", | |||
| 120 | - | expectedNum: "1", | |||
| 121 | - | }, | |||
| 122 | - | { | |||
| 123 | - | name: "Multiple digit partition /dev/sda12", | |||
| 124 | - | devicePath: "/dev/sda12", | |||
| 125 | - | expectedNum: "12", | |||
| 126 | - | }, | |||
| 127 | - | { | |||
| 128 | - | name: "No partition number - returns empty", | |||
| 129 | - | devicePath: "/dev/sda", | |||
| 130 | - | expectedNum: "", | |||
| 131 | - | }, | |||
| 132 | - | } | |||
| 133 | - | ||||
| 134 | - | for _, tt := range tests { | |||
| 135 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 136 | - | ctrl := gomock.NewController(t) | |||
| 137 | - | defer ctrl.Finish() | |||
| 138 | - | ||||
| 139 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 140 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 141 | - | mockChrExec := mocks.NewMockChrootExecutor(ctrl) | |||
| 142 | - | ||||
| 143 | - | tmpDir := t.TempDir() | |||
| 144 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 145 | - | log, err := logger.New(logPath, false) | |||
| 146 | - | if err != nil { | |||
| 147 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 148 | - | } | |||
| 149 | - | defer func() { | |||
| 150 | - | if err := log.Close(); err != nil { | |||
| 151 | - | t.Fatalf("failed to close log: %v", err) | |||
| 152 | - | } | |||
| 153 | - | }() | |||
| 154 | - | ||||
| 155 | - | cfg := config.NewConfig("test") | |||
| 156 | - | phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec) | |||
| 157 | - | ||||
| 158 | - | result := phase.extractPartitionNumber(tt.devicePath) | |||
| 159 | - | if result != tt.expectedNum { | |||
| 160 | - | t.Errorf("extractPartitionNumber(%q) = %q, want %q", tt.devicePath, result, tt.expectedNum) | |||
| 161 | - | } | |||
| 162 | - | }) | |||
| 163 | - | } | |||
| 164 | - | } | |||
| 165 | - | ||||
| 166 | - | // TestBootPhasePostCheck tests boot configuration validation | |||
| 167 | - | func TestBootPhasePostCheck(t *testing.T) { | |||
| 168 | - | tests := []struct { | |||
| 169 | - | name string | |||
| 170 | - | setupMocks func(*mocks.MockFileSystem) | |||
| 171 | - | wantErr bool | |||
| 172 | - | errContains string | |||
| 173 | - | }{ | |||
| 174 | - | { | |||
| 175 | - | name: "Both bootloader and config exist", | |||
| 176 | - | setupMocks: func(mockFS *mocks.MockFileSystem) { | |||
| 177 | - | bootloaderPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineBootloader) | |||
| 178 | - | configPath := config.PathMntBootLimineConf | |||
| 179 | - | ||||
| 180 | - | mockFS.EXPECT().Stat(bootloaderPath).Return(mockFileInfo{}, nil).Times(1) | |||
| 181 | - | mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1) | |||
| 182 | - | mockFS.EXPECT().Stat(configPath).Return(mockFileInfo{}, nil).Times(1) | |||
| 183 | - | mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1) | |||
| 184 | - | }, | |||
| 185 | - | wantErr: false, | |||
| 186 | - | }, | |||
| 187 | - | { | |||
| 188 | - | name: "Bootloader missing", | |||
| 189 | - | setupMocks: func(mockFS *mocks.MockFileSystem) { | |||
| 190 | - | bootloaderPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineBootloader) | |||
| 191 | - | ||||
| 192 | - | mockFS.EXPECT().Stat(bootloaderPath).Return(nil, fmt.Errorf("not found")).Times(1) | |||
| 193 | - | mockFS.EXPECT().IsNotExist(gomock.Any()).Return(true).Times(1) | |||
| 194 | - | }, | |||
| 195 | - | wantErr: true, | |||
| 196 | - | errContains: "bootloader was not installed", | |||
| 197 | - | }, | |||
| 198 | - | { | |||
| 199 | - | name: "Config missing", | |||
| 200 | - | setupMocks: func(mockFS *mocks.MockFileSystem) { | |||
| 201 | - | bootloaderPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineBootloader) | |||
| 202 | - | configPath := config.PathMntBootLimineConf | |||
| 203 | - | ||||
| 204 | - | mockFS.EXPECT().Stat(bootloaderPath).Return(mockFileInfo{}, nil).Times(1) | |||
| 205 | - | mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1) | |||
| 206 | - | mockFS.EXPECT().Stat(configPath).Return(nil, fmt.Errorf("not found")).Times(1) | |||
| 207 | - | mockFS.EXPECT().IsNotExist(gomock.Any()).Return(true).Times(1) | |||
| 208 | - | }, | |||
| 209 | - | wantErr: true, | |||
| 210 | - | errContains: "config was not created", | |||
| 211 | - | }, | |||
| 212 | - | } | |||
| 213 | - | ||||
| 214 | - | for _, tt := range tests { | |||
| 215 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 216 | - | ctrl := gomock.NewController(t) | |||
| 217 | - | defer ctrl.Finish() | |||
| 218 | - | ||||
| 219 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 220 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 221 | - | mockChrExec := mocks.NewMockChrootExecutor(ctrl) | |||
| 222 | - | ||||
| 223 | - | tt.setupMocks(mockFS) | |||
| 224 | - | ||||
| 225 | - | tmpDir := t.TempDir() | |||
| 226 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 227 | - | log, err := logger.New(logPath, false) | |||
| 228 | - | if err != nil { | |||
| 229 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 230 | - | } | |||
| 231 | - | defer func() { | |||
| 232 | - | if err := log.Close(); err != nil { | |||
| 233 | - | t.Fatalf("failed to close log: %v", err) | |||
| 234 | - | } | |||
| 235 | - | }() | |||
| 236 | - | ||||
| 237 | - | cfg := config.NewConfig("test") | |||
| 238 | - | cfg.ConfigPath = filepath.Join(tmpDir, "config") | |||
| 239 | - | ||||
| 240 | - | phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec) | |||
| 241 | - | ||||
| 242 | - | err = phase.PostCheck() | |||
| 243 | - | if (err != nil) != tt.wantErr { | |||
| 244 | - | t.Errorf("PostCheck() error = %v, wantErr %v", err, tt.wantErr) | |||
| 245 | - | return | |||
| 246 | - | } | |||
| 247 | - | if tt.wantErr && err != nil { | |||
| 248 | - | if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) { | |||
| 249 | - | t.Errorf("PostCheck() error = %q, should contain %q", errMsg, tt.errContains) | |||
| 250 | - | } | |||
| 251 | - | } | |||
| 252 | - | }) | |||
| 253 | - | } | |||
| 254 | - | } | |||
| 255 | - | ||||
| 256 | - | // TestBootPhaseRollback tests boot phase rollback | |||
| 257 | - | func TestBootPhaseRollback(t *testing.T) { | |||
| 258 | - | tests := []struct { | |||
| 259 | - | name string | |||
| 260 | - | setupMocks func(*mocks.MockFileSystem) | |||
| 261 | - | wantErr bool | |||
| 262 | - | }{ | |||
| 263 | - | { | |||
| 264 | - | name: "Rollback success", | |||
| 265 | - | setupMocks: func(mockFS *mocks.MockFileSystem) { | |||
| 266 | - | mockFS.EXPECT().RemoveAll(config.PathMntBootEFILimine).Return(nil).Times(1) | |||
| 267 | - | }, | |||
| 268 | - | wantErr: false, | |||
| 269 | - | }, | |||
| 270 | - | { | |||
| 271 | - | name: "Rollback with directory removal failure - continues", | |||
| 272 | - | setupMocks: func(mockFS *mocks.MockFileSystem) { | |||
| 273 | - | mockFS.EXPECT().RemoveAll(config.PathMntBootEFILimine).Return(fmt.Errorf("permission denied")).Times(1) | |||
| 274 | - | }, | |||
| 275 | - | wantErr: false, // Rollback doesn't fail on removal errors | |||
| 276 | - | }, | |||
| 277 | - | } | |||
| 278 | - | ||||
| 279 | - | for _, tt := range tests { | |||
| 280 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 281 | - | ctrl := gomock.NewController(t) | |||
| 282 | - | defer ctrl.Finish() | |||
| 283 | - | ||||
| 284 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 285 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 286 | - | mockChrExec := mocks.NewMockChrootExecutor(ctrl) | |||
| 287 | - | ||||
| 288 | - | tt.setupMocks(mockFS) | |||
| 289 | - | ||||
| 290 | - | tmpDir := t.TempDir() | |||
| 291 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 292 | - | log, err := logger.New(logPath, false) | |||
| 293 | - | if err != nil { | |||
| 294 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 295 | - | } | |||
| 296 | - | defer func() { | |||
| 297 | - | if err := log.Close(); err != nil { | |||
| 298 | - | t.Fatalf("failed to close log: %v", err) | |||
| 299 | - | } | |||
| 300 | - | }() | |||
| 301 | - | ||||
| 302 | - | cfg := config.NewConfig("test") | |||
| 303 | - | phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec) | |||
| 304 | - | ||||
| 305 | - | err = phase.Rollback() | |||
| 306 | - | if (err != nil) != tt.wantErr { | |||
| 307 | - | t.Errorf("Rollback() error = %v, wantErr %v", err, tt.wantErr) | |||
| 308 | - | } | |||
| 309 | - | }) | |||
| 310 | - | } | |||
| 311 | - | } | |||
| 312 | - | ||||
| 313 | - | // TestBootPhaseCanSkip tests the CanSkip method | |||
| 314 | - | func TestBootPhaseCanSkip(t *testing.T) { | |||
| 315 | - | ctrl := gomock.NewController(t) | |||
| 316 | - | defer ctrl.Finish() | |||
| 317 | - | ||||
| 318 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 319 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 320 | - | mockChrExec := mocks.NewMockChrootExecutor(ctrl) | |||
| 321 | - | ||||
| 322 | - | tmpDir := t.TempDir() | |||
| 323 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 324 | - | log, err := logger.New(logPath, false) | |||
| 325 | - | if err != nil { | |||
| 326 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 327 | - | } | |||
| 328 | - | defer func() { | |||
| 329 | - | if err := log.Close(); err != nil { | |||
| 330 | - | t.Fatalf("failed to close log: %v", err) | |||
| 331 | - | } | |||
| 332 | - | }() | |||
| 333 | - | ||||
| 334 | - | cfg := config.NewConfig("test") | |||
| 335 | - | phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec) | |||
| 336 | - | ||||
| 337 | - | if phase.CanSkip() { | |||
| 338 | - | t.Error("CanSkip() = true, want false") | |||
| 339 | - | } | |||
| 340 | - | } | |||
| 341 | - | ||||
| 342 | - | // TestBootPhaseExecute tests the Execute method structure | |||
| 343 | - | func TestBootPhaseExecute(t *testing.T) { | |||
| 344 | - | ctrl := gomock.NewController(t) | |||
| 345 | - | defer ctrl.Finish() | |||
| 346 | - | ||||
| 347 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 348 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 349 | - | mockChrExec := mocks.NewMockChrootExecutor(ctrl) | |||
| 350 | - | ||||
| 351 | - | // Allow any file system operations since Execute calls various methods | |||
| 352 | - | mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte{}, nil).AnyTimes() | |||
| 353 | - | mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() | |||
| 354 | - | mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() | |||
| 355 | - | mockChrExec.EXPECT().ChrootExec(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() | |||
| 356 | - | mockSysExec.EXPECT().RunSimple(gomock.Any(), gomock.Any(), gomock.Any()).Return(system.CommandResult{Output: "1234-5678"}).AnyTimes() | |||
| 357 | - | ||||
| 358 | - | tmpDir := t.TempDir() | |||
| 359 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 360 | - | log, err := logger.New(logPath, false) | |||
| 361 | - | if err != nil { | |||
| 362 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 363 | - | } | |||
| 364 | - | defer func() { | |||
| 365 | - | if err := log.Close(); err != nil { | |||
| 366 | - | t.Fatalf("failed to close log: %v", err) | |||
| 367 | - | } | |||
| 368 | - | }() | |||
| 369 | - | ||||
| 370 | - | cfg := config.NewConfig("test") | |||
| 371 | - | cfg.TargetDisk = "/dev/sda" | |||
| 372 | - | cfg.EFIPartition = "/dev/sda1" | |||
| 373 | - | cfg.RootPartition = "/dev/sda2" | |||
| 374 | - | cfg.KernelChoice = "linux" | |||
| 375 | - | ||||
| 376 | - | phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec) | |||
| 377 | - | ||||
| 378 | - | progressChan := make(chan ProgressUpdate, 100) | |||
| 379 | - | ||||
| 380 | - | // Note: Full Execute testing is complex due to logger.ExecCommand() and various file system calls | |||
| 381 | - | // We just verify that Execute returns a PhaseResult | |||
| 382 | - | result := phase.Execute(progressChan) | |||
| 383 | - | ||||
| 384 | - | // Verify result type is correct - either success or error, the structure is valid | |||
| 385 | - | _ = result // If we got here, Execute returned a valid result structure | |||
| 386 | - | } | |||
| 387 | - | ||||
| 388 | - | // TestBootPhaseKernelParameterConstruction tests kernel parameter building | |||
| 389 | - | func TestBootPhaseKernelParameterConstruction(t *testing.T) { | |||
| 390 | - | tests := []struct { | |||
| 391 | - | name string | |||
| 392 | - | encryptionType string | |||
| 393 | - | rootUUID string | |||
| 394 | - | expectedParams string // partial match check | |||
| 395 | - | }{ | |||
| 396 | - | { | |||
| 397 | - | name: "Unencrypted root", | |||
| 398 | - | encryptionType: config.EncryptionNone, | |||
| 399 | - | rootUUID: "1234-5678", | |||
| 400 | - | expectedParams: "root=UUID=1234-5678", | |||
| 401 | - | }, | |||
| 402 | - | { | |||
| 403 | - | name: "LUKS encrypted root", | |||
| 404 | - | encryptionType: config.EncryptionLUKS, | |||
| 405 | - | rootUUID: "1234-5678", | |||
| 406 | - | expectedParams: "cryptdevice=UUID=1234-5678:cryptroot", | |||
| 407 | - | }, | |||
| 408 | - | { | |||
| 409 | - | name: "LUKS+LVM encrypted root", | |||
| 410 | - | encryptionType: config.EncryptionLUKSLVM, | |||
| 411 | - | rootUUID: "1234-5678", | |||
| 412 | - | expectedParams: "cryptdevice=UUID=1234-5678:cryptroot", | |||
| 413 | - | }, | |||
| 414 | - | } | |||
| 415 | - | ||||
| 416 | - | for _, tt := range tests { | |||
| 417 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 418 | - | ctrl := gomock.NewController(t) | |||
| 419 | - | defer ctrl.Finish() | |||
| 420 | - | ||||
| 421 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 422 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 423 | - | mockChrExec := mocks.NewMockChrootExecutor(ctrl) | |||
| 424 | - | ||||
| 425 | - | tmpDir := t.TempDir() | |||
| 426 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 427 | - | log, err := logger.New(logPath, false) | |||
| 428 | - | if err != nil { | |||
| 429 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 430 | - | } | |||
| 431 | - | defer func() { | |||
| 432 | - | if err := log.Close(); err != nil { | |||
| 433 | - | t.Fatalf("failed to close log: %v", err) | |||
| 434 | - | } | |||
| 435 | - | }() | |||
| 436 | - | ||||
| 437 | - | cfg := config.NewConfig("test") | |||
| 438 | - | cfg.EncryptionType = tt.encryptionType | |||
| 439 | - | cfg.RootPartition = "/dev/sda2" | |||
| 440 | - | ||||
| 441 | - | phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec) | |||
| 442 | - | ||||
| 443 | - | // Verify encryption type is stored correctly | |||
| 444 | - | if phase.config.EncryptionType != tt.encryptionType { | |||
| 445 | - | t.Errorf("Encryption type = %q, want %q", phase.config.EncryptionType, tt.encryptionType) | |||
| 446 | - | } | |||
| 447 | - | }) | |||
| 448 | - | } | |||
| 449 | - | } | |||
| 450 | - | ||||
| 451 | - | // TestBootPhaseInstallPathConstruction tests install path building | |||
| 452 | - | func TestBootPhaseInstallPathConstruction(t *testing.T) { | |||
| 453 | - | tests := []struct { | |||
| 454 | - | name string | |||
| 455 | - | filename string | |||
| 456 | - | expected string | |||
| 457 | - | }{ | |||
| 458 | - | { | |||
| 459 | - | name: "Limine config template", | |||
| 460 | - | filename: config.LimineConfigTemplate, | |||
| 461 | - | expected: filepath.Join(config.DefaultInstallDir, config.LimineConfigTemplate), | |||
| 462 | - | }, | |||
| 463 | - | { | |||
| 464 | - | name: "Custom filename", | |||
| 465 | - | filename: "test.conf", | |||
| 466 | - | expected: filepath.Join(config.DefaultInstallDir, "test.conf"), | |||
| 467 | - | }, | |||
| 468 | - | } | |||
| 469 | - | ||||
| 470 | - | for _, tt := range tests { | |||
| 471 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 472 | - | ctrl := gomock.NewController(t) | |||
| 473 | - | defer ctrl.Finish() | |||
| 474 | - | ||||
| 475 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 476 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 477 | - | mockChrExec := mocks.NewMockChrootExecutor(ctrl) | |||
| 478 | - | ||||
| 479 | - | tmpDir := t.TempDir() | |||
| 480 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 481 | - | log, err := logger.New(logPath, false) | |||
| 482 | - | if err != nil { | |||
| 483 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 484 | - | } | |||
| 485 | - | defer func() { | |||
| 486 | - | if err := log.Close(); err != nil { | |||
| 487 | - | t.Fatalf("failed to close log: %v", err) | |||
| 488 | - | } | |||
| 489 | - | }() | |||
| 490 | - | ||||
| 491 | - | cfg := config.NewConfig("test") | |||
| 492 | - | phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec) | |||
| 493 | - | ||||
| 494 | - | result := phase.getInstallPath(tt.filename) | |||
| 495 | - | if result != tt.expected { | |||
| 496 | - | t.Errorf("getInstallPath(%q) = %q, want %q", tt.filename, result, tt.expected) | |||
| 497 | - | } | |||
| 498 | - | }) | |||
| 499 | - | } | |||
| 500 | - | } | |||
| 501 | - | ||||
| 502 | - | // TestBootPhaseFileSystemOperations tests file system interaction patterns | |||
| 503 | - | func TestBootPhaseFileSystemOperations(t *testing.T) { | |||
| 504 | - | tests := []struct { | |||
| 505 | - | name string | |||
| 506 | - | operation string // "read", "write", "mkdir" | |||
| 507 | - | }{ | |||
| 508 | - | { | |||
| 509 | - | name: "Mkinitcpio config read", | |||
| 510 | - | operation: "read", | |||
| 511 | - | }, | |||
| 512 | - | { | |||
| 513 | - | name: "Limine config write", | |||
| 514 | - | operation: "write", | |||
| 515 | - | }, | |||
| 516 | - | { | |||
| 517 | - | name: "Create Limine directory", | |||
| 518 | - | operation: "mkdir", | |||
| 519 | - | }, | |||
| 520 | - | } | |||
| 521 | - | ||||
| 522 | - | for _, tt := range tests { | |||
| 523 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 524 | - | ctrl := gomock.NewController(t) | |||
| 525 | - | defer ctrl.Finish() | |||
| 526 | - | ||||
| 527 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 528 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 529 | - | mockChrExec := mocks.NewMockChrootExecutor(ctrl) | |||
| 530 | - | ||||
| 531 | - | // Setup basic expectations based on operation | |||
| 532 | - | switch tt.operation { | |||
| 533 | - | case "read": | |||
| 534 | - | mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte("content"), nil).AnyTimes() | |||
| 535 | - | case "write": | |||
| 536 | - | mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() | |||
| 537 | - | case "mkdir": | |||
| 538 | - | mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() | |||
| 539 | - | } | |||
| 540 | - | ||||
| 541 | - | tmpDir := t.TempDir() | |||
| 542 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 543 | - | log, err := logger.New(logPath, false) | |||
| 544 | - | if err != nil { | |||
| 545 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 546 | - | } | |||
| 547 | - | defer func() { | |||
| 548 | - | if err := log.Close(); err != nil { | |||
| 549 | - | t.Fatalf("failed to close log: %v", err) | |||
| 550 | - | } | |||
| 551 | - | }() | |||
| 552 | - | ||||
| 553 | - | cfg := config.NewConfig("test") | |||
| 554 | - | phase := NewBootPhase(cfg, log, mockFS, mockSysExec, mockChrExec) | |||
| 555 | - | ||||
| 556 | - | // Verify phase can be created | |||
| 557 | - | if phase == nil { | |||
| 558 | - | t.Error("BootPhase is nil") | |||
| 559 | - | } | |||
| 560 | - | }) | |||
| 561 | - | } | |||
| 562 | - | } | |||
D internal/phases/bootstrap.go +0 -290
| @@ -1,290 +0,0 @@ | |||||
| 1 | - | package phases | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "fmt" | |||
| 5 | - | "io" | |||
| 6 | - | "net/http" | |||
| 7 | - | "os" | |||
| 8 | - | "os/exec" | |||
| 9 | - | "path/filepath" | |||
| 10 | - | "strings" | |||
| 11 | - | ||||
| 12 | - | "github.com/bnema/archup/internal/config" | |||
| 13 | - | "github.com/bnema/archup/internal/interfaces" | |||
| 14 | - | "github.com/bnema/archup/internal/logger" | |||
| 15 | - | ) | |||
| 16 | - | ||||
| 17 | - | // BootstrapPhase handles initial setup and file downloads | |||
| 18 | - | type BootstrapPhase struct { | |||
| 19 | - | *BasePhase | |||
| 20 | - | httpClient interfaces.HTTPClient | |||
| 21 | - | fs interfaces.FileSystem | |||
| 22 | - | } | |||
| 23 | - | ||||
| 24 | - | // NewBootstrapPhase creates a new bootstrap phase | |||
| 25 | - | func NewBootstrapPhase(cfg *config.Config, log *logger.Logger, httpClient interfaces.HTTPClient, fs interfaces.FileSystem) *BootstrapPhase { | |||
| 26 | - | return &BootstrapPhase{ | |||
| 27 | - | BasePhase: NewBasePhase("bootstrap", "Bootstrap", cfg, log), | |||
| 28 | - | httpClient: httpClient, | |||
| 29 | - | fs: fs, | |||
| 30 | - | } | |||
| 31 | - | } | |||
| 32 | - | ||||
| 33 | - | // PreCheck validates bootstrap prerequisites | |||
| 34 | - | func (p *BootstrapPhase) PreCheck() error { | |||
| 35 | - | // Check internet connectivity | |||
| 36 | - | resp, err := p.httpClient.Get("https://raw.githubusercontent.com") | |||
| 37 | - | if err != nil { | |||
| 38 | - | return fmt.Errorf("no internet connectivity: %w", err) | |||
| 39 | - | } | |||
| 40 | - | if err := resp.Body.Close(); err != nil { | |||
| 41 | - | return fmt.Errorf("failed to close connectivity response: %w", err) | |||
| 42 | - | } | |||
| 43 | - | ||||
| 44 | - | return nil | |||
| 45 | - | } | |||
| 46 | - | ||||
| 47 | - | // Execute runs the bootstrap phase | |||
| 48 | - | func (p *BootstrapPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult { | |||
| 49 | - | p.SendOutput(progressChan, "Starting bootstrap phase...") | |||
| 50 | - | ||||
| 51 | - | // Create install directory | |||
| 52 | - | if err := p.fs.MkdirAll(config.DefaultInstallDir, 0755); err != nil { | |||
| 53 | - | p.SendError(progressChan, err) | |||
| 54 | - | return PhaseResult{Success: false, Error: err} | |||
| 55 | - | } | |||
| 56 | - | ||||
| 57 | - | // Local mode: files pre-seeded by push-binary-to-vm.sh --local, skip all network activity | |||
| 58 | - | if os.Getenv("ARCHUP_LOCAL") == "1" { | |||
| 59 | - | p.SendOutput(progressChan, "[LOCAL] Skipping download — using pre-seeded files in "+config.DefaultInstallDir) | |||
| 60 | - | p.SendComplete(progressChan, "Bootstrap complete") | |||
| 61 | - | return PhaseResult{Success: true, Message: "Local mode: using pre-seeded configuration files"} | |||
| 62 | - | } | |||
| 63 | - | ||||
| 64 | - | if err := p.cloneRepo(); err == nil { | |||
| 65 | - | if err := p.copyRepoFiles(progressChan); err != nil { | |||
| 66 | - | p.SendError(progressChan, err) | |||
| 67 | - | return PhaseResult{Success: false, Error: err} | |||
| 68 | - | } | |||
| 69 | - | p.SendComplete(progressChan, "Bootstrap complete") | |||
| 70 | - | return PhaseResult{Success: true, Message: "Configuration files prepared from repo clone"} | |||
| 71 | - | } else { | |||
| 72 | - | p.SendOutput(progressChan, fmt.Sprintf("[WARN] Repo clone failed, falling back to downloads: %v", err)) | |||
| 73 | - | } | |||
| 74 | - | ||||
| 75 | - | // Files to download | |||
| 76 | - | files := []struct { | |||
| 77 | - | url string | |||
| 78 | - | destPath string | |||
| 79 | - | }{ | |||
| 80 | - | // Package lists | |||
| 81 | - | { | |||
| 82 | - | url: fmt.Sprintf("%s/install/base.packages", p.config.RawURL), | |||
| 83 | - | destPath: filepath.Join(config.DefaultInstallDir, "base.packages"), | |||
| 84 | - | }, | |||
| 85 | - | { | |||
| 86 | - | url: fmt.Sprintf("%s/install/extra.packages", p.config.RawURL), | |||
| 87 | - | destPath: filepath.Join(config.DefaultInstallDir, "extra.packages"), | |||
| 88 | - | }, | |||
| 89 | - | // Config files | |||
| 90 | - | { | |||
| 91 | - | url: fmt.Sprintf("%s/install/configs/limine.conf.template", p.config.RawURL), | |||
| 92 | - | destPath: filepath.Join(config.DefaultInstallDir, "configs", "limine.conf.template"), | |||
| 93 | - | }, | |||
| 94 | - | { | |||
| 95 | - | url: fmt.Sprintf("%s/install/configs/chaotic-aur.conf", p.config.RawURL), | |||
| 96 | - | destPath: filepath.Join(config.DefaultInstallDir, "configs", "chaotic-aur.conf"), | |||
| 97 | - | }, | |||
| 98 | - | // Shell config files | |||
| 99 | - | { | |||
| 100 | - | url: fmt.Sprintf("%s/install/configs/shell/shell", p.config.RawURL), | |||
| 101 | - | destPath: filepath.Join(config.DefaultInstallDir, "configs", "shell", "shell"), | |||
| 102 | - | }, | |||
| 103 | - | { | |||
| 104 | - | url: fmt.Sprintf("%s/install/configs/shell/starship.toml", p.config.RawURL), | |||
| 105 | - | destPath: filepath.Join(config.DefaultInstallDir, "configs", "shell", "starship.toml"), | |||
| 106 | - | }, | |||
| 107 | - | { | |||
| 108 | - | url: fmt.Sprintf("%s/install/configs/shell/init", p.config.RawURL), | |||
| 109 | - | destPath: filepath.Join(config.DefaultInstallDir, "configs", "shell", "init"), | |||
| 110 | - | }, | |||
| 111 | - | { | |||
| 112 | - | url: fmt.Sprintf("%s/install/configs/shell/aliases", p.config.RawURL), | |||
| 113 | - | destPath: filepath.Join(config.DefaultInstallDir, "configs", "shell", "aliases"), | |||
| 114 | - | }, | |||
| 115 | - | { | |||
| 116 | - | url: fmt.Sprintf("%s/install/configs/shell/envs", p.config.RawURL), | |||
| 117 | - | destPath: filepath.Join(config.DefaultInstallDir, "configs", "shell", "envs"), | |||
| 118 | - | }, | |||
| 119 | - | { | |||
| 120 | - | url: fmt.Sprintf("%s/install/configs/shell/rc", p.config.RawURL), | |||
| 121 | - | destPath: filepath.Join(config.DefaultInstallDir, "configs", "shell", "rc"), | |||
| 122 | - | }, | |||
| 123 | - | { | |||
| 124 | - | url: fmt.Sprintf("%s/install/configs/shell/bashrc", p.config.RawURL), | |||
| 125 | - | destPath: filepath.Join(config.DefaultInstallDir, "configs", "shell", "bashrc"), | |||
| 126 | - | }, | |||
| 127 | - | } | |||
| 128 | - | ||||
| 129 | - | totalFiles := len(files) | |||
| 130 | - | p.SendOutput(progressChan, fmt.Sprintf("Downloading %d configuration files...", totalFiles)) | |||
| 131 | - | ||||
| 132 | - | for i, file := range files { | |||
| 133 | - | fileName := filepath.Base(file.destPath) | |||
| 134 | - | p.SendProgress(progressChan, fmt.Sprintf("Downloading %s...", fileName), i+1, totalFiles) | |||
| 135 | - | ||||
| 136 | - | if err := p.downloadFile(file.url, file.destPath); err != nil { | |||
| 137 | - | errMsg := fmt.Errorf("failed to download %s: %w", fileName, err) | |||
| 138 | - | p.SendError(progressChan, errMsg) | |||
| 139 | - | return PhaseResult{Success: false, Error: errMsg} | |||
| 140 | - | } | |||
| 141 | - | ||||
| 142 | - | p.SendOutput(progressChan, fmt.Sprintf("[OK] %s", fileName)) | |||
| 143 | - | } | |||
| 144 | - | ||||
| 145 | - | p.SendComplete(progressChan, "Bootstrap complete") | |||
| 146 | - | return PhaseResult{Success: true, Message: "All configuration files downloaded"} | |||
| 147 | - | } | |||
| 148 | - | ||||
| 149 | - | func (p *BootstrapPhase) cloneRepo() error { | |||
| 150 | - | if p.config.RepoURL == "" { | |||
| 151 | - | return fmt.Errorf("repo URL not configured") | |||
| 152 | - | } | |||
| 153 | - | if err := p.fs.RemoveAll(config.DefaultInstallRepoDir); err != nil { | |||
| 154 | - | return fmt.Errorf("failed to clear repo directory: %w", err) | |||
| 155 | - | } | |||
| 156 | - | ||||
| 157 | - | branch := branchFromRawURL(p.config.RawURL) | |||
| 158 | - | args := []string{"clone", "--depth", "1", "--branch", branch, "--single-branch", p.config.RepoURL, config.DefaultInstallRepoDir} | |||
| 159 | - | cmd := exec.Command("git", args...) | |||
| 160 | - | output, err := cmd.CombinedOutput() | |||
| 161 | - | if err != nil { | |||
| 162 | - | return fmt.Errorf("failed to clone repo: %w (output: %s)", err, strings.TrimSpace(string(output))) | |||
| 163 | - | } | |||
| 164 | - | ||||
| 165 | - | return nil | |||
| 166 | - | } | |||
| 167 | - | ||||
| 168 | - | func (p *BootstrapPhase) copyRepoFiles(progressChan chan<- ProgressUpdate) error { | |||
| 169 | - | repoRoot := config.DefaultInstallRepoDir | |||
| 170 | - | files := []struct { | |||
| 171 | - | src string | |||
| 172 | - | dest string | |||
| 173 | - | }{ | |||
| 174 | - | {filepath.Join(repoRoot, "install", "base.packages"), filepath.Join(config.DefaultInstallDir, "base.packages")}, | |||
| 175 | - | {filepath.Join(repoRoot, "install", "extra.packages"), filepath.Join(config.DefaultInstallDir, "extra.packages")}, | |||
| 176 | - | {filepath.Join(repoRoot, "install", "configs", "limine.conf.template"), filepath.Join(config.DefaultInstallDir, "configs", "limine.conf.template")}, | |||
| 177 | - | {filepath.Join(repoRoot, "install", "configs", "chaotic-aur.conf"), filepath.Join(config.DefaultInstallDir, "configs", "chaotic-aur.conf")}, | |||
| 178 | - | {filepath.Join(repoRoot, "install", "configs", "shell", "shell"), filepath.Join(config.DefaultInstallDir, "configs", "shell", "shell")}, | |||
| 179 | - | {filepath.Join(repoRoot, "install", "configs", "shell", "starship.toml"), filepath.Join(config.DefaultInstallDir, "configs", "shell", "starship.toml")}, | |||
| 180 | - | {filepath.Join(repoRoot, "install", "configs", "shell", "init"), filepath.Join(config.DefaultInstallDir, "configs", "shell", "init")}, | |||
| 181 | - | {filepath.Join(repoRoot, "install", "configs", "shell", "aliases"), filepath.Join(config.DefaultInstallDir, "configs", "shell", "aliases")}, | |||
| 182 | - | {filepath.Join(repoRoot, "install", "configs", "shell", "envs"), filepath.Join(config.DefaultInstallDir, "configs", "shell", "envs")}, | |||
| 183 | - | {filepath.Join(repoRoot, "install", "configs", "shell", "rc"), filepath.Join(config.DefaultInstallDir, "configs", "shell", "rc")}, | |||
| 184 | - | {filepath.Join(repoRoot, "install", "configs", "shell", "bashrc"), filepath.Join(config.DefaultInstallDir, "configs", "shell", "bashrc")}, | |||
| 185 | - | {filepath.Join(repoRoot, config.ArchLogoURL), filepath.Join(config.DefaultInstallDir, config.ArchLogoURL)}, | |||
| 186 | - | } | |||
| 187 | - | ||||
| 188 | - | for _, file := range config.PlymouthFiles { | |||
| 189 | - | files = append(files, struct { | |||
| 190 | - | src string | |||
| 191 | - | dest string | |||
| 192 | - | }{ | |||
| 193 | - | src: filepath.Join(repoRoot, "assets", "plymouth", file), | |||
| 194 | - | dest: filepath.Join(config.DefaultInstallDir, "assets", "plymouth", file), | |||
| 195 | - | }) | |||
| 196 | - | } | |||
| 197 | - | ||||
| 198 | - | for i, file := range files { | |||
| 199 | - | fileName := filepath.Base(file.dest) | |||
| 200 | - | p.SendProgress(progressChan, fmt.Sprintf("Preparing %s...", fileName), i+1, len(files)) | |||
| 201 | - | if err := p.copyFile(file.src, file.dest); err != nil { | |||
| 202 | - | return fmt.Errorf("failed to prepare %s: %w", fileName, err) | |||
| 203 | - | } | |||
| 204 | - | } | |||
| 205 | - | ||||
| 206 | - | return nil | |||
| 207 | - | } | |||
| 208 | - | ||||
| 209 | - | func (p *BootstrapPhase) copyFile(src, dest string) error { | |||
| 210 | - | if err := p.fs.MkdirAll(filepath.Dir(dest), 0755); err != nil { | |||
| 211 | - | return fmt.Errorf("failed to create directory: %w", err) | |||
| 212 | - | } | |||
| 213 | - | content, err := p.fs.ReadFile(src) | |||
| 214 | - | if err != nil { | |||
| 215 | - | return fmt.Errorf("failed to read %s: %w", src, err) | |||
| 216 | - | } | |||
| 217 | - | if err := p.fs.WriteFile(dest, content, 0644); err != nil { | |||
| 218 | - | return fmt.Errorf("failed to write %s: %w", dest, err) | |||
| 219 | - | } | |||
| 220 | - | return nil | |||
| 221 | - | } | |||
| 222 | - | ||||
| 223 | - | func branchFromRawURL(rawURL string) string { | |||
| 224 | - | const prefix = "https://raw.githubusercontent.com/" | |||
| 225 | - | if !strings.HasPrefix(rawURL, prefix) { | |||
| 226 | - | return "main" | |||
| 227 | - | } | |||
| 228 | - | trimmed := strings.TrimPrefix(rawURL, prefix) | |||
| 229 | - | parts := strings.Split(trimmed, "/") | |||
| 230 | - | if len(parts) < 3 { | |||
| 231 | - | return "main" | |||
| 232 | - | } | |||
| 233 | - | return parts[2] | |||
| 234 | - | } | |||
| 235 | - | ||||
| 236 | - | // downloadFile downloads a file from URL to destPath | |||
| 237 | - | func (p *BootstrapPhase) downloadFile(url, destPath string) error { | |||
| 238 | - | // Create destination directory if needed | |||
| 239 | - | destDir := filepath.Dir(destPath) | |||
| 240 | - | if err := p.fs.MkdirAll(destDir, 0755); err != nil { | |||
| 241 | - | return fmt.Errorf("failed to create directory: %w", err) | |||
| 242 | - | } | |||
| 243 | - | ||||
| 244 | - | // Download file | |||
| 245 | - | resp, err := p.httpClient.Get(url) | |||
| 246 | - | if err != nil { | |||
| 247 | - | return fmt.Errorf("HTTP request failed: %w", err) | |||
| 248 | - | } | |||
| 249 | - | ||||
| 250 | - | if resp.StatusCode != http.StatusOK { | |||
| 251 | - | if closeErr := resp.Body.Close(); closeErr != nil { | |||
| 252 | - | return fmt.Errorf("failed to close response: %w", closeErr) | |||
| 253 | - | } | |||
| 254 | - | return fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status) | |||
| 255 | - | } | |||
| 256 | - | ||||
| 257 | - | // Create destination file | |||
| 258 | - | destFile, err := p.fs.Create(destPath) | |||
| 259 | - | if err != nil { | |||
| 260 | - | return fmt.Errorf("failed to create file: %w", err) | |||
| 261 | - | } | |||
| 262 | - | defer func() { | |||
| 263 | - | if err := destFile.Close(); err != nil { | |||
| 264 | - | p.logger.Warn("Failed to close file", "error", err) | |||
| 265 | - | } | |||
| 266 | - | }() | |||
| 267 | - | ||||
| 268 | - | // Copy content | |||
| 269 | - | if _, err := io.Copy(destFile, resp.Body); err != nil { | |||
| 270 | - | if closeErr := resp.Body.Close(); closeErr != nil { | |||
| 271 | - | return fmt.Errorf("failed to close response: %w", closeErr) | |||
| 272 | - | } | |||
| 273 | - | return fmt.Errorf("failed to write file: %w", err) | |||
| 274 | - | } | |||
| 275 | - | if closeErr := resp.Body.Close(); closeErr != nil { | |||
| 276 | - | return fmt.Errorf("failed to close response: %w", closeErr) | |||
| 277 | - | } | |||
| 278 | - | ||||
| 279 | - | return nil | |||
| 280 | - | } | |||
| 281 | - | ||||
| 282 | - | // Rollback removes downloaded files | |||
| 283 | - | func (p *BootstrapPhase) Rollback() error { | |||
| 284 | - | // Remove install directory | |||
| 285 | - | if err := p.fs.RemoveAll(config.DefaultInstallDir); err != nil { | |||
| 286 | - | return fmt.Errorf("failed to remove install directory: %w", err) | |||
| 287 | - | } | |||
| 288 | - | ||||
| 289 | - | return nil | |||
| 290 | - | } | |||
D internal/phases/bootstrap_test.go +0 -366
| @@ -1,366 +0,0 @@ | |||||
| 1 | - | package phases | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "bytes" | |||
| 5 | - | "fmt" | |||
| 6 | - | "io" | |||
| 7 | - | "net/http" | |||
| 8 | - | "path/filepath" | |||
| 9 | - | "testing" | |||
| 10 | - | ||||
| 11 | - | "github.com/bnema/archup/internal/config" | |||
| 12 | - | "github.com/bnema/archup/internal/interfaces/mocks" | |||
| 13 | - | "github.com/bnema/archup/internal/logger" | |||
| 14 | - | "go.uber.org/mock/gomock" | |||
| 15 | - | ) | |||
| 16 | - | ||||
| 17 | - | // TestBootstrapPhasePreCheck tests internet connectivity validation | |||
| 18 | - | func TestBootstrapPhasePreCheck(t *testing.T) { | |||
| 19 | - | tests := []struct { | |||
| 20 | - | name string | |||
| 21 | - | setupMocks func(*mocks.MockHTTPClient) | |||
| 22 | - | wantErr bool | |||
| 23 | - | errContains string | |||
| 24 | - | }{ | |||
| 25 | - | { | |||
| 26 | - | name: "Internet connectivity - success", | |||
| 27 | - | setupMocks: func(mockHTTP *mocks.MockHTTPClient) { | |||
| 28 | - | resp := &http.Response{ | |||
| 29 | - | StatusCode: http.StatusOK, | |||
| 30 | - | Body: io.NopCloser(bytes.NewBufferString("")), | |||
| 31 | - | } | |||
| 32 | - | mockHTTP.EXPECT().Get("https://raw.githubusercontent.com").Return(resp, nil).Times(1) | |||
| 33 | - | }, | |||
| 34 | - | wantErr: false, | |||
| 35 | - | }, | |||
| 36 | - | { | |||
| 37 | - | name: "Internet connectivity - connection failed", | |||
| 38 | - | setupMocks: func(mockHTTP *mocks.MockHTTPClient) { | |||
| 39 | - | mockHTTP.EXPECT().Get("https://raw.githubusercontent.com").Return(nil, fmt.Errorf("connection refused")).Times(1) | |||
| 40 | - | }, | |||
| 41 | - | wantErr: true, | |||
| 42 | - | errContains: "no internet connectivity", | |||
| 43 | - | }, | |||
| 44 | - | { | |||
| 45 | - | name: "Internet connectivity - timeout", | |||
| 46 | - | setupMocks: func(mockHTTP *mocks.MockHTTPClient) { | |||
| 47 | - | mockHTTP.EXPECT().Get("https://raw.githubusercontent.com").Return(nil, fmt.Errorf("context deadline exceeded")).Times(1) | |||
| 48 | - | }, | |||
| 49 | - | wantErr: true, | |||
| 50 | - | errContains: "no internet connectivity", | |||
| 51 | - | }, | |||
| 52 | - | } | |||
| 53 | - | ||||
| 54 | - | for _, tt := range tests { | |||
| 55 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 56 | - | ctrl := gomock.NewController(t) | |||
| 57 | - | defer ctrl.Finish() | |||
| 58 | - | ||||
| 59 | - | mockHTTP := mocks.NewMockHTTPClient(ctrl) | |||
| 60 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 61 | - | ||||
| 62 | - | tt.setupMocks(mockHTTP) | |||
| 63 | - | ||||
| 64 | - | tmpDir := t.TempDir() | |||
| 65 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 66 | - | log, err := logger.New(logPath, false) | |||
| 67 | - | if err != nil { | |||
| 68 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 69 | - | } | |||
| 70 | - | defer func() { | |||
| 71 | - | if err := log.Close(); err != nil { | |||
| 72 | - | t.Fatalf("failed to close log: %v", err) | |||
| 73 | - | } | |||
| 74 | - | }() | |||
| 75 | - | ||||
| 76 | - | cfg := config.NewConfig("test") | |||
| 77 | - | phase := NewBootstrapPhase(cfg, log, mockHTTP, mockFS) | |||
| 78 | - | ||||
| 79 | - | err = phase.PreCheck() | |||
| 80 | - | if (err != nil) != tt.wantErr { | |||
| 81 | - | t.Errorf("PreCheck() error = %v, wantErr %v", err, tt.wantErr) | |||
| 82 | - | return | |||
| 83 | - | } | |||
| 84 | - | if tt.wantErr && err != nil { | |||
| 85 | - | if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) { | |||
| 86 | - | t.Errorf("PreCheck() error = %q, should contain %q", errMsg, tt.errContains) | |||
| 87 | - | } | |||
| 88 | - | } | |||
| 89 | - | }) | |||
| 90 | - | } | |||
| 91 | - | } | |||
| 92 | - | ||||
| 93 | - | // TestBootstrapPhaseDownloadFile tests file download functionality | |||
| 94 | - | func TestBootstrapPhaseDownloadFile(t *testing.T) { | |||
| 95 | - | tests := []struct { | |||
| 96 | - | name string | |||
| 97 | - | url string | |||
| 98 | - | setupMocks func(*mocks.MockHTTPClient, *mocks.MockFileSystem) | |||
| 99 | - | wantErr bool | |||
| 100 | - | errContains string | |||
| 101 | - | }{ | |||
| 102 | - | { | |||
| 103 | - | name: "Download file - success", | |||
| 104 | - | url: "https://example.com/file.txt", | |||
| 105 | - | setupMocks: func(mockHTTP *mocks.MockHTTPClient, mockFS *mocks.MockFileSystem) { | |||
| 106 | - | mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).Times(1) | |||
| 107 | - | ||||
| 108 | - | resp := &http.Response{ | |||
| 109 | - | StatusCode: http.StatusOK, | |||
| 110 | - | Status: "200 OK", | |||
| 111 | - | Body: io.NopCloser(bytes.NewBufferString("file content")), | |||
| 112 | - | } | |||
| 113 | - | mockHTTP.EXPECT().Get("https://example.com/file.txt").Return(resp, nil).Times(1) | |||
| 114 | - | ||||
| 115 | - | mockFile := NewMockWriteCloser() | |||
| 116 | - | mockFS.EXPECT().Create(gomock.Any()).Return(mockFile, nil).Times(1) | |||
| 117 | - | }, | |||
| 118 | - | wantErr: false, | |||
| 119 | - | }, | |||
| 120 | - | { | |||
| 121 | - | name: "Download file - HTTP 404 not found", | |||
| 122 | - | url: "https://example.com/missing.txt", | |||
| 123 | - | setupMocks: func(mockHTTP *mocks.MockHTTPClient, mockFS *mocks.MockFileSystem) { | |||
| 124 | - | mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).Times(1) | |||
| 125 | - | ||||
| 126 | - | resp := &http.Response{ | |||
| 127 | - | StatusCode: http.StatusNotFound, | |||
| 128 | - | Status: "404 Not Found", | |||
| 129 | - | Body: io.NopCloser(bytes.NewBufferString("")), | |||
| 130 | - | } | |||
| 131 | - | mockHTTP.EXPECT().Get("https://example.com/missing.txt").Return(resp, nil).Times(1) | |||
| 132 | - | }, | |||
| 133 | - | wantErr: true, | |||
| 134 | - | errContains: "HTTP 404", | |||
| 135 | - | }, | |||
| 136 | - | { | |||
| 137 | - | name: "Download file - HTTP 500 server error", | |||
| 138 | - | url: "https://example.com/error.txt", | |||
| 139 | - | setupMocks: func(mockHTTP *mocks.MockHTTPClient, mockFS *mocks.MockFileSystem) { | |||
| 140 | - | mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).Times(1) | |||
| 141 | - | ||||
| 142 | - | resp := &http.Response{ | |||
| 143 | - | StatusCode: http.StatusInternalServerError, | |||
| 144 | - | Status: "500 Internal Server Error", | |||
| 145 | - | Body: io.NopCloser(bytes.NewBufferString("")), | |||
| 146 | - | } | |||
| 147 | - | mockHTTP.EXPECT().Get("https://example.com/error.txt").Return(resp, nil).Times(1) | |||
| 148 | - | }, | |||
| 149 | - | wantErr: true, | |||
| 150 | - | errContains: "HTTP 500", | |||
| 151 | - | }, | |||
| 152 | - | { | |||
| 153 | - | name: "Download file - HTTP request failed", | |||
| 154 | - | url: "https://example.com/file.txt", | |||
| 155 | - | setupMocks: func(mockHTTP *mocks.MockHTTPClient, mockFS *mocks.MockFileSystem) { | |||
| 156 | - | mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).Times(1) | |||
| 157 | - | ||||
| 158 | - | mockHTTP.EXPECT().Get("https://example.com/file.txt").Return(nil, fmt.Errorf("connection timeout")).Times(1) | |||
| 159 | - | }, | |||
| 160 | - | wantErr: true, | |||
| 161 | - | errContains: "HTTP request failed", | |||
| 162 | - | }, | |||
| 163 | - | { | |||
| 164 | - | name: "Download file - directory creation failed", | |||
| 165 | - | url: "https://example.com/file.txt", | |||
| 166 | - | setupMocks: func(mockHTTP *mocks.MockHTTPClient, mockFS *mocks.MockFileSystem) { | |||
| 167 | - | mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(fmt.Errorf("permission denied")).Times(1) | |||
| 168 | - | }, | |||
| 169 | - | wantErr: true, | |||
| 170 | - | errContains: "failed to create directory", | |||
| 171 | - | }, | |||
| 172 | - | { | |||
| 173 | - | name: "Download file - file creation failed", | |||
| 174 | - | url: "https://example.com/file.txt", | |||
| 175 | - | setupMocks: func(mockHTTP *mocks.MockHTTPClient, mockFS *mocks.MockFileSystem) { | |||
| 176 | - | mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).Times(1) | |||
| 177 | - | ||||
| 178 | - | resp := &http.Response{ | |||
| 179 | - | StatusCode: http.StatusOK, | |||
| 180 | - | Status: "200 OK", | |||
| 181 | - | Body: io.NopCloser(bytes.NewBufferString("content")), | |||
| 182 | - | } | |||
| 183 | - | mockHTTP.EXPECT().Get("https://example.com/file.txt").Return(resp, nil).Times(1) | |||
| 184 | - | ||||
| 185 | - | mockFS.EXPECT().Create(gomock.Any()).Return(nil, fmt.Errorf("no space left")).Times(1) | |||
| 186 | - | }, | |||
| 187 | - | wantErr: true, | |||
| 188 | - | errContains: "failed to create file", | |||
| 189 | - | }, | |||
| 190 | - | } | |||
| 191 | - | ||||
| 192 | - | for _, tt := range tests { | |||
| 193 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 194 | - | ctrl := gomock.NewController(t) | |||
| 195 | - | defer ctrl.Finish() | |||
| 196 | - | ||||
| 197 | - | mockHTTP := mocks.NewMockHTTPClient(ctrl) | |||
| 198 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 199 | - | ||||
| 200 | - | tt.setupMocks(mockHTTP, mockFS) | |||
| 201 | - | ||||
| 202 | - | tmpDir := t.TempDir() | |||
| 203 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 204 | - | log, err := logger.New(logPath, false) | |||
| 205 | - | if err != nil { | |||
| 206 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 207 | - | } | |||
| 208 | - | defer func() { | |||
| 209 | - | if err := log.Close(); err != nil { | |||
| 210 | - | t.Fatalf("failed to close log: %v", err) | |||
| 211 | - | } | |||
| 212 | - | }() | |||
| 213 | - | ||||
| 214 | - | cfg := config.NewConfig("test") | |||
| 215 | - | phase := NewBootstrapPhase(cfg, log, mockHTTP, mockFS) | |||
| 216 | - | ||||
| 217 | - | err = phase.downloadFile(tt.url, filepath.Join(tmpDir, "dest.txt")) | |||
| 218 | - | if (err != nil) != tt.wantErr { | |||
| 219 | - | t.Errorf("downloadFile() error = %v, wantErr %v", err, tt.wantErr) | |||
| 220 | - | return | |||
| 221 | - | } | |||
| 222 | - | if tt.wantErr && err != nil { | |||
| 223 | - | if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) { | |||
| 224 | - | t.Errorf("downloadFile() error = %q, should contain %q", errMsg, tt.errContains) | |||
| 225 | - | } | |||
| 226 | - | } | |||
| 227 | - | }) | |||
| 228 | - | } | |||
| 229 | - | } | |||
| 230 | - | ||||
| 231 | - | // TestBootstrapPhaseExecute tests the Execute method | |||
| 232 | - | func TestBootstrapPhaseExecute(t *testing.T) { | |||
| 233 | - | ctrl := gomock.NewController(t) | |||
| 234 | - | defer ctrl.Finish() | |||
| 235 | - | ||||
| 236 | - | mockHTTP := mocks.NewMockHTTPClient(ctrl) | |||
| 237 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 238 | - | ||||
| 239 | - | // Setup successful downloads | |||
| 240 | - | mockFS.EXPECT().MkdirAll(config.DefaultInstallDir, gomock.Any()).Return(nil).Times(1) | |||
| 241 | - | ||||
| 242 | - | // Expect MkdirAll calls for each file's directory | |||
| 243 | - | mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() | |||
| 244 | - | ||||
| 245 | - | // Expect Create calls for each file | |||
| 246 | - | mockFile := NewMockWriteCloser() | |||
| 247 | - | mockFS.EXPECT().Create(gomock.Any()).Return(mockFile, nil).AnyTimes() | |||
| 248 | - | ||||
| 249 | - | // Expect HTTP Get calls for each file | |||
| 250 | - | resp := &http.Response{ | |||
| 251 | - | StatusCode: http.StatusOK, | |||
| 252 | - | Status: "200 OK", | |||
| 253 | - | Body: io.NopCloser(bytes.NewBufferString("file content")), | |||
| 254 | - | } | |||
| 255 | - | mockHTTP.EXPECT().Get(gomock.Any()).Return(resp, nil).AnyTimes() | |||
| 256 | - | ||||
| 257 | - | tmpDir := t.TempDir() | |||
| 258 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 259 | - | log, err := logger.New(logPath, false) | |||
| 260 | - | if err != nil { | |||
| 261 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 262 | - | } | |||
| 263 | - | defer func() { | |||
| 264 | - | if err := log.Close(); err != nil { | |||
| 265 | - | t.Fatalf("failed to close log: %v", err) | |||
| 266 | - | } | |||
| 267 | - | }() | |||
| 268 | - | ||||
| 269 | - | cfg := config.NewConfig("test") | |||
| 270 | - | cfg.RepoURL = "" | |||
| 271 | - | cfg.RawURL = "https://example.com" | |||
| 272 | - | phase := NewBootstrapPhase(cfg, log, mockHTTP, mockFS) | |||
| 273 | - | ||||
| 274 | - | progressChan := make(chan ProgressUpdate, 100) | |||
| 275 | - | result := phase.Execute(progressChan) | |||
| 276 | - | ||||
| 277 | - | if !result.Success { | |||
| 278 | - | t.Errorf("Execute() Success = %v, want true", result.Success) | |||
| 279 | - | } | |||
| 280 | - | if result.Message != "All configuration files downloaded" { | |||
| 281 | - | t.Errorf("Execute() Message = %q, want 'All configuration files downloaded'", result.Message) | |||
| 282 | - | } | |||
| 283 | - | } | |||
| 284 | - | ||||
| 285 | - | // TestBootstrapPhaseRollback tests rollback cleanup | |||
| 286 | - | func TestBootstrapPhaseRollback(t *testing.T) { | |||
| 287 | - | tests := []struct { | |||
| 288 | - | name string | |||
| 289 | - | setupMocks func(*mocks.MockFileSystem) | |||
| 290 | - | wantErr bool | |||
| 291 | - | errContains string | |||
| 292 | - | }{ | |||
| 293 | - | { | |||
| 294 | - | name: "Rollback - success", | |||
| 295 | - | setupMocks: func(mockFS *mocks.MockFileSystem) { | |||
| 296 | - | mockFS.EXPECT().RemoveAll(config.DefaultInstallDir).Return(nil).Times(1) | |||
| 297 | - | }, | |||
| 298 | - | wantErr: false, | |||
| 299 | - | }, | |||
| 300 | - | { | |||
| 301 | - | name: "Rollback - directory removal failed", | |||
| 302 | - | setupMocks: func(mockFS *mocks.MockFileSystem) { | |||
| 303 | - | mockFS.EXPECT().RemoveAll(config.DefaultInstallDir).Return(fmt.Errorf("permission denied")).Times(1) | |||
| 304 | - | }, | |||
| 305 | - | wantErr: true, | |||
| 306 | - | errContains: "failed to remove install directory", | |||
| 307 | - | }, | |||
| 308 | - | } | |||
| 309 | - | ||||
| 310 | - | for _, tt := range tests { | |||
| 311 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 312 | - | ctrl := gomock.NewController(t) | |||
| 313 | - | defer ctrl.Finish() | |||
| 314 | - | ||||
| 315 | - | mockHTTP := mocks.NewMockHTTPClient(ctrl) | |||
| 316 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 317 | - | ||||
| 318 | - | tt.setupMocks(mockFS) | |||
| 319 | - | ||||
| 320 | - | tmpDir := t.TempDir() | |||
| 321 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 322 | - | log, err := logger.New(logPath, false) | |||
| 323 | - | if err != nil { | |||
| 324 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 325 | - | } | |||
| 326 | - | defer func() { | |||
| 327 | - | if err := log.Close(); err != nil { | |||
| 328 | - | t.Fatalf("failed to close log: %v", err) | |||
| 329 | - | } | |||
| 330 | - | }() | |||
| 331 | - | ||||
| 332 | - | cfg := config.NewConfig("test") | |||
| 333 | - | phase := NewBootstrapPhase(cfg, log, mockHTTP, mockFS) | |||
| 334 | - | ||||
| 335 | - | err = phase.Rollback() | |||
| 336 | - | if (err != nil) != tt.wantErr { | |||
| 337 | - | t.Errorf("Rollback() error = %v, wantErr %v", err, tt.wantErr) | |||
| 338 | - | return | |||
| 339 | - | } | |||
| 340 | - | if tt.wantErr && err != nil { | |||
| 341 | - | if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) { | |||
| 342 | - | t.Errorf("Rollback() error = %q, should contain %q", errMsg, tt.errContains) | |||
| 343 | - | } | |||
| 344 | - | } | |||
| 345 | - | }) | |||
| 346 | - | } | |||
| 347 | - | } | |||
| 348 | - | ||||
| 349 | - | // MockWriteCloser is a mock implementation of io.WriteCloser | |||
| 350 | - | type MockWriteCloser struct { | |||
| 351 | - | buf bytes.Buffer | |||
| 352 | - | } | |||
| 353 | - | ||||
| 354 | - | func NewMockWriteCloser() *MockWriteCloser { | |||
| 355 | - | return &MockWriteCloser{} | |||
| 356 | - | } | |||
| 357 | - | ||||
| 358 | - | func (m *MockWriteCloser) Write(p []byte) (n int, err error) { | |||
| 359 | - | return m.buf.Write(p) | |||
| 360 | - | } | |||
| 361 | - | ||||
| 362 | - | func (m *MockWriteCloser) Close() error { | |||
| 363 | - | return nil | |||
| 364 | - | } | |||
| 365 | - | ||||
| 366 | - | // containsSubstring is defined in helpers_test.go to avoid duplication | |||
D internal/phases/helpers_test.go +0 -26
| @@ -1,26 +0,0 @@ | |||||
| 1 | - | package phases | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "os" | |||
| 5 | - | "time" | |||
| 6 | - | ) | |||
| 7 | - | ||||
| 8 | - | // containsSubstring checks if a string contains a substring | |||
| 9 | - | func containsSubstring(s, substr string) bool { | |||
| 10 | - | for i := 0; i < len(s)-len(substr)+1; i++ { | |||
| 11 | - | if s[i:i+len(substr)] == substr { | |||
| 12 | - | return true | |||
| 13 | - | } | |||
| 14 | - | } | |||
| 15 | - | return false | |||
| 16 | - | } | |||
| 17 | - | ||||
| 18 | - | // mockFileInfo implements os.FileInfo for testing | |||
| 19 | - | type mockFileInfo struct{} | |||
| 20 | - | ||||
| 21 | - | func (m mockFileInfo) Name() string { return "mock" } | |||
| 22 | - | func (m mockFileInfo) Size() int64 { return 0 } | |||
| 23 | - | func (m mockFileInfo) Mode() os.FileMode { return 0 } | |||
| 24 | - | func (m mockFileInfo) ModTime() time.Time { return time.Now() } | |||
| 25 | - | func (m mockFileInfo) IsDir() bool { return false } | |||
| 26 | - | func (m mockFileInfo) Sys() interface{} { return nil } | |||
D internal/phases/mocks/mock_interfaces.go +0 -477
| @@ -1,477 +0,0 @@ | |||||
| 1 | - | // Code generated by MockGen. DO NOT EDIT. | |||
| 2 | - | // Source: internal/interfaces/interfaces.go | |||
| 3 | - | // | |||
| 4 | - | // Generated by this command: | |||
| 5 | - | // | |||
| 6 | - | // mockgen -source=internal/interfaces/interfaces.go -destination=internal/phases/mocks/mock_interfaces.go -package=mocks | |||
| 7 | - | // | |||
| 8 | - | ||||
| 9 | - | // Package mocks is a generated GoMock package. | |||
| 10 | - | package mocks | |||
| 11 | - | ||||
| 12 | - | import ( | |||
| 13 | - | context "context" | |||
| 14 | - | io "io" | |||
| 15 | - | http "net/http" | |||
| 16 | - | os "os" | |||
| 17 | - | reflect "reflect" | |||
| 18 | - | ||||
| 19 | - | system "github.com/bnema/archup/internal/system" | |||
| 20 | - | gomock "go.uber.org/mock/gomock" | |||
| 21 | - | ) | |||
| 22 | - | ||||
| 23 | - | // MockFileSystem is a mock of FileSystem interface. | |||
| 24 | - | type MockFileSystem struct { | |||
| 25 | - | ctrl *gomock.Controller | |||
| 26 | - | recorder *MockFileSystemMockRecorder | |||
| 27 | - | isgomock struct{} | |||
| 28 | - | } | |||
| 29 | - | ||||
| 30 | - | // MockFileSystemMockRecorder is the mock recorder for MockFileSystem. | |||
| 31 | - | type MockFileSystemMockRecorder struct { | |||
| 32 | - | mock *MockFileSystem | |||
| 33 | - | } | |||
| 34 | - | ||||
| 35 | - | // NewMockFileSystem creates a new mock instance. | |||
| 36 | - | func NewMockFileSystem(ctrl *gomock.Controller) *MockFileSystem { | |||
| 37 | - | mock := &MockFileSystem{ctrl: ctrl} | |||
| 38 | - | mock.recorder = &MockFileSystemMockRecorder{mock} | |||
| 39 | - | return mock | |||
| 40 | - | } | |||
| 41 | - | ||||
| 42 | - | // EXPECT returns an object that allows the caller to indicate expected use. | |||
| 43 | - | func (m *MockFileSystem) EXPECT() *MockFileSystemMockRecorder { | |||
| 44 | - | return m.recorder | |||
| 45 | - | } | |||
| 46 | - | ||||
| 47 | - | // Chmod mocks base method. | |||
| 48 | - | func (m *MockFileSystem) Chmod(name string, mode os.FileMode) error { | |||
| 49 | - | m.ctrl.T.Helper() | |||
| 50 | - | ret := m.ctrl.Call(m, "Chmod", name, mode) | |||
| 51 | - | ret0, _ := ret[0].(error) | |||
| 52 | - | return ret0 | |||
| 53 | - | } | |||
| 54 | - | ||||
| 55 | - | // Chmod indicates an expected call of Chmod. | |||
| 56 | - | func (mr *MockFileSystemMockRecorder) Chmod(name, mode any) *gomock.Call { | |||
| 57 | - | mr.mock.ctrl.T.Helper() | |||
| 58 | - | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Chmod", reflect.TypeOf((*MockFileSystem)(nil).Chmod), name, mode) | |||
| 59 | - | } | |||
| 60 | - | ||||
| 61 | - | // Create mocks base method. | |||
| 62 | - | func (m *MockFileSystem) Create(name string) (io.WriteCloser, error) { | |||
| 63 | - | m.ctrl.T.Helper() | |||
| 64 | - | ret := m.ctrl.Call(m, "Create", name) | |||
| 65 | - | ret0, _ := ret[0].(io.WriteCloser) | |||
| 66 | - | ret1, _ := ret[1].(error) | |||
| 67 | - | return ret0, ret1 | |||
| 68 | - | } | |||
| 69 | - | ||||
| 70 | - | // Create indicates an expected call of Create. | |||
| 71 | - | func (mr *MockFileSystemMockRecorder) Create(name any) *gomock.Call { | |||
| 72 | - | mr.mock.ctrl.T.Helper() | |||
| 73 | - | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockFileSystem)(nil).Create), name) | |||
| 74 | - | } | |||
| 75 | - | ||||
| 76 | - | // IsNotExist mocks base method. | |||
| 77 | - | func (m *MockFileSystem) IsNotExist(err error) bool { | |||
| 78 | - | m.ctrl.T.Helper() | |||
| 79 | - | ret := m.ctrl.Call(m, "IsNotExist", err) | |||
| 80 | - | ret0, _ := ret[0].(bool) | |||
| 81 | - | return ret0 | |||
| 82 | - | } | |||
| 83 | - | ||||
| 84 | - | // IsNotExist indicates an expected call of IsNotExist. | |||
| 85 | - | func (mr *MockFileSystemMockRecorder) IsNotExist(err any) *gomock.Call { | |||
| 86 | - | mr.mock.ctrl.T.Helper() | |||
| 87 | - | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsNotExist", reflect.TypeOf((*MockFileSystem)(nil).IsNotExist), err) | |||
| 88 | - | } | |||
| 89 | - | ||||
| 90 | - | // MkdirAll mocks base method. | |||
| 91 | - | func (m *MockFileSystem) MkdirAll(path string, perm os.FileMode) error { | |||
| 92 | - | m.ctrl.T.Helper() | |||
| 93 | - | ret := m.ctrl.Call(m, "MkdirAll", path, perm) | |||
| 94 | - | ret0, _ := ret[0].(error) | |||
| 95 | - | return ret0 | |||
| 96 | - | } | |||
| 97 | - | ||||
| 98 | - | // MkdirAll indicates an expected call of MkdirAll. | |||
| 99 | - | func (mr *MockFileSystemMockRecorder) MkdirAll(path, perm any) *gomock.Call { | |||
| 100 | - | mr.mock.ctrl.T.Helper() | |||
| 101 | - | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MkdirAll", reflect.TypeOf((*MockFileSystem)(nil).MkdirAll), path, perm) | |||
| 102 | - | } | |||
| 103 | - | ||||
| 104 | - | // Open mocks base method. | |||
| 105 | - | func (m *MockFileSystem) Open(name string) (io.ReadCloser, error) { | |||
| 106 | - | m.ctrl.T.Helper() | |||
| 107 | - | ret := m.ctrl.Call(m, "Open", name) | |||
| 108 | - | ret0, _ := ret[0].(io.ReadCloser) | |||
| 109 | - | ret1, _ := ret[1].(error) | |||
| 110 | - | return ret0, ret1 | |||
| 111 | - | } | |||
| 112 | - | ||||
| 113 | - | // Open indicates an expected call of Open. | |||
| 114 | - | func (mr *MockFileSystemMockRecorder) Open(name any) *gomock.Call { | |||
| 115 | - | mr.mock.ctrl.T.Helper() | |||
| 116 | - | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Open", reflect.TypeOf((*MockFileSystem)(nil).Open), name) | |||
| 117 | - | } | |||
| 118 | - | ||||
| 119 | - | // ReadFile mocks base method. | |||
| 120 | - | func (m *MockFileSystem) ReadFile(filename string) ([]byte, error) { | |||
| 121 | - | m.ctrl.T.Helper() | |||
| 122 | - | ret := m.ctrl.Call(m, "ReadFile", filename) | |||
| 123 | - | ret0, _ := ret[0].([]byte) | |||
| 124 | - | ret1, _ := ret[1].(error) | |||
| 125 | - | return ret0, ret1 | |||
| 126 | - | } | |||
| 127 | - | ||||
| 128 | - | // ReadFile indicates an expected call of ReadFile. | |||
| 129 | - | func (mr *MockFileSystemMockRecorder) ReadFile(filename any) *gomock.Call { | |||
| 130 | - | mr.mock.ctrl.T.Helper() | |||
| 131 | - | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadFile", reflect.TypeOf((*MockFileSystem)(nil).ReadFile), filename) | |||
| 132 | - | } | |||
| 133 | - | ||||
| 134 | - | // RemoveAll mocks base method. | |||
| 135 | - | func (m *MockFileSystem) RemoveAll(path string) error { | |||
| 136 | - | m.ctrl.T.Helper() | |||
| 137 | - | ret := m.ctrl.Call(m, "RemoveAll", path) | |||
| 138 | - | ret0, _ := ret[0].(error) | |||
| 139 | - | return ret0 | |||
| 140 | - | } | |||
| 141 | - | ||||
| 142 | - | // RemoveAll indicates an expected call of RemoveAll. | |||
| 143 | - | func (mr *MockFileSystemMockRecorder) RemoveAll(path any) *gomock.Call { | |||
| 144 | - | mr.mock.ctrl.T.Helper() | |||
| 145 | - | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveAll", reflect.TypeOf((*MockFileSystem)(nil).RemoveAll), path) | |||
| 146 | - | } | |||
| 147 | - | ||||
| 148 | - | // Stat mocks base method. | |||
| 149 | - | func (m *MockFileSystem) Stat(name string) (os.FileInfo, error) { | |||
| 150 | - | m.ctrl.T.Helper() | |||
| 151 | - | ret := m.ctrl.Call(m, "Stat", name) | |||
| 152 | - | ret0, _ := ret[0].(os.FileInfo) | |||
| 153 | - | ret1, _ := ret[1].(error) | |||
| 154 | - | return ret0, ret1 | |||
| 155 | - | } | |||
| 156 | - | ||||
| 157 | - | // Stat indicates an expected call of Stat. | |||
| 158 | - | func (mr *MockFileSystemMockRecorder) Stat(name any) *gomock.Call { | |||
| 159 | - | mr.mock.ctrl.T.Helper() | |||
| 160 | - | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stat", reflect.TypeOf((*MockFileSystem)(nil).Stat), name) | |||
| 161 | - | } | |||
| 162 | - | ||||
| 163 | - | // WriteFile mocks base method. | |||
| 164 | - | func (m *MockFileSystem) WriteFile(filename string, data []byte, perm os.FileMode) error { | |||
| 165 | - | m.ctrl.T.Helper() | |||
| 166 | - | ret := m.ctrl.Call(m, "WriteFile", filename, data, perm) | |||
| 167 | - | ret0, _ := ret[0].(error) | |||
| 168 | - | return ret0 | |||
| 169 | - | } | |||
| 170 | - | ||||
| 171 | - | // WriteFile indicates an expected call of WriteFile. | |||
| 172 | - | func (mr *MockFileSystemMockRecorder) WriteFile(filename, data, perm any) *gomock.Call { | |||
| 173 | - | mr.mock.ctrl.T.Helper() | |||
| 174 | - | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WriteFile", reflect.TypeOf((*MockFileSystem)(nil).WriteFile), filename, data, perm) | |||
| 175 | - | } | |||
| 176 | - | ||||
| 177 | - | // MockCommandExecutor is a mock of CommandExecutor interface. | |||
| 178 | - | type MockCommandExecutor struct { | |||
| 179 | - | ctrl *gomock.Controller | |||
| 180 | - | recorder *MockCommandExecutorMockRecorder | |||
| 181 | - | isgomock struct{} | |||
| 182 | - | } | |||
| 183 | - | ||||
| 184 | - | // MockCommandExecutorMockRecorder is the mock recorder for MockCommandExecutor. | |||
| 185 | - | type MockCommandExecutorMockRecorder struct { | |||
| 186 | - | mock *MockCommandExecutor | |||
| 187 | - | } | |||
| 188 | - | ||||
| 189 | - | // NewMockCommandExecutor creates a new mock instance. | |||
| 190 | - | func NewMockCommandExecutor(ctrl *gomock.Controller) *MockCommandExecutor { | |||
| 191 | - | mock := &MockCommandExecutor{ctrl: ctrl} | |||
| 192 | - | mock.recorder = &MockCommandExecutorMockRecorder{mock} | |||
| 193 | - | return mock | |||
| 194 | - | } | |||
| 195 | - | ||||
| 196 | - | // EXPECT returns an object that allows the caller to indicate expected use. | |||
| 197 | - | func (m *MockCommandExecutor) EXPECT() *MockCommandExecutorMockRecorder { | |||
| 198 | - | return m.recorder | |||
| 199 | - | } | |||
| 200 | - | ||||
| 201 | - | // Execute mocks base method. | |||
| 202 | - | func (m *MockCommandExecutor) Execute(name string, args ...string) ([]byte, error) { | |||
| 203 | - | m.ctrl.T.Helper() | |||
| 204 | - | varargs := []any{name} | |||
| 205 | - | for _, a := range args { | |||
| 206 | - | varargs = append(varargs, a) | |||
| 207 | - | } | |||
| 208 | - | ret := m.ctrl.Call(m, "Execute", varargs...) | |||
| 209 | - | ret0, _ := ret[0].([]byte) | |||
| 210 | - | ret1, _ := ret[1].(error) | |||
| 211 | - | return ret0, ret1 | |||
| 212 | - | } | |||
| 213 | - | ||||
| 214 | - | // Execute indicates an expected call of Execute. | |||
| 215 | - | func (mr *MockCommandExecutorMockRecorder) Execute(name any, args ...any) *gomock.Call { | |||
| 216 | - | mr.mock.ctrl.T.Helper() | |||
| 217 | - | varargs := append([]any{name}, args...) | |||
| 218 | - | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Execute", reflect.TypeOf((*MockCommandExecutor)(nil).Execute), varargs...) | |||
| 219 | - | } | |||
| 220 | - | ||||
| 221 | - | // MockSystemExecutor is a mock of SystemExecutor interface. | |||
| 222 | - | type MockSystemExecutor struct { | |||
| 223 | - | ctrl *gomock.Controller | |||
| 224 | - | recorder *MockSystemExecutorMockRecorder | |||
| 225 | - | isgomock struct{} | |||
| 226 | - | } | |||
| 227 | - | ||||
| 228 | - | // MockSystemExecutorMockRecorder is the mock recorder for MockSystemExecutor. | |||
| 229 | - | type MockSystemExecutorMockRecorder struct { | |||
| 230 | - | mock *MockSystemExecutor | |||
| 231 | - | } | |||
| 232 | - | ||||
| 233 | - | // NewMockSystemExecutor creates a new mock instance. | |||
| 234 | - | func NewMockSystemExecutor(ctrl *gomock.Controller) *MockSystemExecutor { | |||
| 235 | - | mock := &MockSystemExecutor{ctrl: ctrl} | |||
| 236 | - | mock.recorder = &MockSystemExecutorMockRecorder{mock} | |||
| 237 | - | return mock | |||
| 238 | - | } | |||
| 239 | - | ||||
| 240 | - | // EXPECT returns an object that allows the caller to indicate expected use. | |||
| 241 | - | func (m *MockSystemExecutor) EXPECT() *MockSystemExecutorMockRecorder { | |||
| 242 | - | return m.recorder | |||
| 243 | - | } | |||
| 244 | - | ||||
| 245 | - | // DetectCPUInfo mocks base method. | |||
| 246 | - | func (m *MockSystemExecutor) DetectCPUInfo() (*system.CPUInfo, error) { | |||
| 247 | - | m.ctrl.T.Helper() | |||
| 248 | - | ret := m.ctrl.Call(m, "DetectCPUInfo") | |||
| 249 | - | ret0, _ := ret[0].(*system.CPUInfo) | |||
| 250 | - | ret1, _ := ret[1].(error) | |||
| 251 | - | return ret0, ret1 | |||
| 252 | - | } | |||
| 253 | - | ||||
| 254 | - | // DetectCPUInfo indicates an expected call of DetectCPUInfo. | |||
| 255 | - | func (mr *MockSystemExecutorMockRecorder) DetectCPUInfo() *gomock.Call { | |||
| 256 | - | mr.mock.ctrl.T.Helper() | |||
| 257 | - | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DetectCPUInfo", reflect.TypeOf((*MockSystemExecutor)(nil).DetectCPUInfo)) | |||
| 258 | - | } | |||
| 259 | - | ||||
| 260 | - | // RunSimple mocks base method. | |||
| 261 | - | func (m *MockSystemExecutor) RunSimple(command string, args ...string) system.CommandResult { | |||
| 262 | - | m.ctrl.T.Helper() | |||
| 263 | - | varargs := []any{command} | |||
| 264 | - | for _, a := range args { | |||
| 265 | - | varargs = append(varargs, a) | |||
| 266 | - | } | |||
| 267 | - | ret := m.ctrl.Call(m, "RunSimple", varargs...) | |||
| 268 | - | ret0, _ := ret[0].(system.CommandResult) | |||
| 269 | - | return ret0 | |||
| 270 | - | } | |||
| 271 | - | ||||
| 272 | - | // RunSimple indicates an expected call of RunSimple. | |||
| 273 | - | func (mr *MockSystemExecutorMockRecorder) RunSimple(command any, args ...any) *gomock.Call { | |||
| 274 | - | mr.mock.ctrl.T.Helper() | |||
| 275 | - | varargs := append([]any{command}, args...) | |||
| 276 | - | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RunSimple", reflect.TypeOf((*MockSystemExecutor)(nil).RunSimple), varargs...) | |||
| 277 | - | } | |||
| 278 | - | ||||
| 279 | - | // MockChrootExecutor is a mock of ChrootExecutor interface. | |||
| 280 | - | type MockChrootExecutor struct { | |||
| 281 | - | ctrl *gomock.Controller | |||
| 282 | - | recorder *MockChrootExecutorMockRecorder | |||
| 283 | - | isgomock struct{} | |||
| 284 | - | } | |||
| 285 | - | ||||
| 286 | - | // MockChrootExecutorMockRecorder is the mock recorder for MockChrootExecutor. | |||
| 287 | - | type MockChrootExecutorMockRecorder struct { | |||
| 288 | - | mock *MockChrootExecutor | |||
| 289 | - | } | |||
| 290 | - | ||||
| 291 | - | // NewMockChrootExecutor creates a new mock instance. | |||
| 292 | - | func NewMockChrootExecutor(ctrl *gomock.Controller) *MockChrootExecutor { | |||
| 293 | - | mock := &MockChrootExecutor{ctrl: ctrl} | |||
| 294 | - | mock.recorder = &MockChrootExecutorMockRecorder{mock} | |||
| 295 | - | return mock | |||
| 296 | - | } | |||
| 297 | - | ||||
| 298 | - | // EXPECT returns an object that allows the caller to indicate expected use. | |||
| 299 | - | func (m *MockChrootExecutor) EXPECT() *MockChrootExecutorMockRecorder { | |||
| 300 | - | return m.recorder | |||
| 301 | - | } | |||
| 302 | - | ||||
| 303 | - | // ChrootExec mocks base method. | |||
| 304 | - | func (m *MockChrootExecutor) ChrootExec(logPath, mountPoint, command string, args ...string) error { | |||
| 305 | - | m.ctrl.T.Helper() | |||
| 306 | - | varargs := []any{logPath, mountPoint, command} | |||
| 307 | - | for _, a := range args { | |||
| 308 | - | varargs = append(varargs, a) | |||
| 309 | - | } | |||
| 310 | - | ret := m.ctrl.Call(m, "ChrootExec", varargs...) | |||
| 311 | - | ret0, _ := ret[0].(error) | |||
| 312 | - | return ret0 | |||
| 313 | - | } | |||
| 314 | - | ||||
| 315 | - | // ChrootExec indicates an expected call of ChrootExec. | |||
| 316 | - | func (mr *MockChrootExecutorMockRecorder) ChrootExec(logPath, mountPoint, command any, args ...any) *gomock.Call { | |||
| 317 | - | mr.mock.ctrl.T.Helper() | |||
| 318 | - | varargs := append([]any{logPath, mountPoint, command}, args...) | |||
| 319 | - | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChrootExec", reflect.TypeOf((*MockChrootExecutor)(nil).ChrootExec), varargs...) | |||
| 320 | - | } | |||
| 321 | - | ||||
| 322 | - | // ChrootExecWithContext mocks base method. | |||
| 323 | - | func (m *MockChrootExecutor) ChrootExecWithContext(ctx context.Context, logPath, mountPoint, command string) error { | |||
| 324 | - | m.ctrl.T.Helper() | |||
| 325 | - | ret := m.ctrl.Call(m, "ChrootExecWithContext", ctx, logPath, mountPoint, command) | |||
| 326 | - | ret0, _ := ret[0].(error) | |||
| 327 | - | return ret0 | |||
| 328 | - | } | |||
| 329 | - | ||||
| 330 | - | // ChrootExecWithContext indicates an expected call of ChrootExecWithContext. | |||
| 331 | - | func (mr *MockChrootExecutorMockRecorder) ChrootExecWithContext(ctx, logPath, mountPoint, command any) *gomock.Call { | |||
| 332 | - | mr.mock.ctrl.T.Helper() | |||
| 333 | - | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChrootExecWithContext", reflect.TypeOf((*MockChrootExecutor)(nil).ChrootExecWithContext), ctx, logPath, mountPoint, command) | |||
| 334 | - | } | |||
| 335 | - | ||||
| 336 | - | // ChrootExecWithStdin mocks base method. | |||
| 337 | - | func (m *MockChrootExecutor) ChrootExecWithStdin(logPath, mountPoint, command, stdin string) error { | |||
| 338 | - | m.ctrl.T.Helper() | |||
| 339 | - | ret := m.ctrl.Call(m, "ChrootExecWithStdin", logPath, mountPoint, command, stdin) | |||
| 340 | - | ret0, _ := ret[0].(error) | |||
| 341 | - | return ret0 | |||
| 342 | - | } | |||
| 343 | - | ||||
| 344 | - | // ChrootExecWithStdin indicates an expected call of ChrootExecWithStdin. | |||
| 345 | - | func (mr *MockChrootExecutorMockRecorder) ChrootExecWithStdin(logPath, mountPoint, command, stdin any) *gomock.Call { | |||
| 346 | - | mr.mock.ctrl.T.Helper() | |||
| 347 | - | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChrootExecWithStdin", reflect.TypeOf((*MockChrootExecutor)(nil).ChrootExecWithStdin), logPath, mountPoint, command, stdin) | |||
| 348 | - | } | |||
| 349 | - | ||||
| 350 | - | // ChrootPacman mocks base method. | |||
| 351 | - | func (m *MockChrootExecutor) ChrootPacman(logPath, mountPoint, operation string, packages ...string) error { | |||
| 352 | - | m.ctrl.T.Helper() | |||
| 353 | - | varargs := []any{logPath, mountPoint, operation} | |||
| 354 | - | for _, a := range packages { | |||
| 355 | - | varargs = append(varargs, a) | |||
| 356 | - | } | |||
| 357 | - | ret := m.ctrl.Call(m, "ChrootPacman", varargs...) | |||
| 358 | - | ret0, _ := ret[0].(error) | |||
| 359 | - | return ret0 | |||
| 360 | - | } | |||
| 361 | - | ||||
| 362 | - | // ChrootPacman indicates an expected call of ChrootPacman. | |||
| 363 | - | func (mr *MockChrootExecutorMockRecorder) ChrootPacman(logPath, mountPoint, operation any, packages ...any) *gomock.Call { | |||
| 364 | - | mr.mock.ctrl.T.Helper() | |||
| 365 | - | varargs := append([]any{logPath, mountPoint, operation}, packages...) | |||
| 366 | - | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChrootPacman", reflect.TypeOf((*MockChrootExecutor)(nil).ChrootPacman), varargs...) | |||
| 367 | - | } | |||
| 368 | - | ||||
| 369 | - | // ChrootSystemctl mocks base method. | |||
| 370 | - | func (m *MockChrootExecutor) ChrootSystemctl(logPath, mountPoint, action, service string) error { | |||
| 371 | - | m.ctrl.T.Helper() | |||
| 372 | - | ret := m.ctrl.Call(m, "ChrootSystemctl", logPath, mountPoint, action, service) | |||
| 373 | - | ret0, _ := ret[0].(error) | |||
| 374 | - | return ret0 | |||
| 375 | - | } | |||
| 376 | - | ||||
| 377 | - | // ChrootSystemctl indicates an expected call of ChrootSystemctl. | |||
| 378 | - | func (mr *MockChrootExecutorMockRecorder) ChrootSystemctl(logPath, mountPoint, action, service any) *gomock.Call { | |||
| 379 | - | mr.mock.ctrl.T.Helper() | |||
| 380 | - | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChrootSystemctl", reflect.TypeOf((*MockChrootExecutor)(nil).ChrootSystemctl), logPath, mountPoint, action, service) | |||
| 381 | - | } | |||
| 382 | - | ||||
| 383 | - | // DownloadAndInstallPackages mocks base method. | |||
| 384 | - | func (m *MockChrootExecutor) DownloadAndInstallPackages(logPath, chrootPath string, urls ...string) error { | |||
| 385 | - | m.ctrl.T.Helper() | |||
| 386 | - | varargs := []any{logPath, chrootPath} | |||
| 387 | - | for _, a := range urls { | |||
| 388 | - | varargs = append(varargs, a) | |||
| 389 | - | } | |||
| 390 | - | ret := m.ctrl.Call(m, "DownloadAndInstallPackages", varargs...) | |||
| 391 | - | ret0, _ := ret[0].(error) | |||
| 392 | - | return ret0 | |||
| 393 | - | } | |||
| 394 | - | ||||
| 395 | - | // DownloadAndInstallPackages indicates an expected call of DownloadAndInstallPackages. | |||
| 396 | - | func (mr *MockChrootExecutorMockRecorder) DownloadAndInstallPackages(logPath, chrootPath any, urls ...any) *gomock.Call { | |||
| 397 | - | mr.mock.ctrl.T.Helper() | |||
| 398 | - | varargs := append([]any{logPath, chrootPath}, urls...) | |||
| 399 | - | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DownloadAndInstallPackages", reflect.TypeOf((*MockChrootExecutor)(nil).DownloadAndInstallPackages), varargs...) | |||
| 400 | - | } | |||
| 401 | - | ||||
| 402 | - | // MockHTTPClient is a mock of HTTPClient interface. | |||
| 403 | - | type MockHTTPClient struct { | |||
| 404 | - | ctrl *gomock.Controller | |||
| 405 | - | recorder *MockHTTPClientMockRecorder | |||
| 406 | - | isgomock struct{} | |||
| 407 | - | } | |||
| 408 | - | ||||
| 409 | - | // MockHTTPClientMockRecorder is the mock recorder for MockHTTPClient. | |||
| 410 | - | type MockHTTPClientMockRecorder struct { | |||
| 411 | - | mock *MockHTTPClient | |||
| 412 | - | } | |||
| 413 | - | ||||
| 414 | - | // NewMockHTTPClient creates a new mock instance. | |||
| 415 | - | func NewMockHTTPClient(ctrl *gomock.Controller) *MockHTTPClient { | |||
| 416 | - | mock := &MockHTTPClient{ctrl: ctrl} | |||
| 417 | - | mock.recorder = &MockHTTPClientMockRecorder{mock} | |||
| 418 | - | return mock | |||
| 419 | - | } | |||
| 420 | - | ||||
| 421 | - | // EXPECT returns an object that allows the caller to indicate expected use. | |||
| 422 | - | func (m *MockHTTPClient) EXPECT() *MockHTTPClientMockRecorder { | |||
| 423 | - | return m.recorder | |||
| 424 | - | } | |||
| 425 | - | ||||
| 426 | - | // Get mocks base method. | |||
| 427 | - | func (m *MockHTTPClient) Get(url string) (*http.Response, error) { | |||
| 428 | - | m.ctrl.T.Helper() | |||
| 429 | - | ret := m.ctrl.Call(m, "Get", url) | |||
| 430 | - | ret0, _ := ret[0].(*http.Response) | |||
| 431 | - | ret1, _ := ret[1].(error) | |||
| 432 | - | return ret0, ret1 | |||
| 433 | - | } | |||
| 434 | - | ||||
| 435 | - | // Get indicates an expected call of Get. | |||
| 436 | - | func (mr *MockHTTPClientMockRecorder) Get(url any) *gomock.Call { | |||
| 437 | - | mr.mock.ctrl.T.Helper() | |||
| 438 | - | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockHTTPClient)(nil).Get), url) | |||
| 439 | - | } | |||
| 440 | - | ||||
| 441 | - | // MockConfigSaver is a mock of ConfigSaver interface. | |||
| 442 | - | type MockConfigSaver struct { | |||
| 443 | - | ctrl *gomock.Controller | |||
| 444 | - | recorder *MockConfigSaverMockRecorder | |||
| 445 | - | isgomock struct{} | |||
| 446 | - | } | |||
| 447 | - | ||||
| 448 | - | // MockConfigSaverMockRecorder is the mock recorder for MockConfigSaver. | |||
| 449 | - | type MockConfigSaverMockRecorder struct { | |||
| 450 | - | mock *MockConfigSaver | |||
| 451 | - | } | |||
| 452 | - | ||||
| 453 | - | // NewMockConfigSaver creates a new mock instance. | |||
| 454 | - | func NewMockConfigSaver(ctrl *gomock.Controller) *MockConfigSaver { | |||
| 455 | - | mock := &MockConfigSaver{ctrl: ctrl} | |||
| 456 | - | mock.recorder = &MockConfigSaverMockRecorder{mock} | |||
| 457 | - | return mock | |||
| 458 | - | } | |||
| 459 | - | ||||
| 460 | - | // EXPECT returns an object that allows the caller to indicate expected use. | |||
| 461 | - | func (m *MockConfigSaver) EXPECT() *MockConfigSaverMockRecorder { | |||
| 462 | - | return m.recorder | |||
| 463 | - | } | |||
| 464 | - | ||||
| 465 | - | // Save mocks base method. | |||
| 466 | - | func (m *MockConfigSaver) Save() error { | |||
| 467 | - | m.ctrl.T.Helper() | |||
| 468 | - | ret := m.ctrl.Call(m, "Save") | |||
| 469 | - | ret0, _ := ret[0].(error) | |||
| 470 | - | return ret0 | |||
| 471 | - | } | |||
| 472 | - | ||||
| 473 | - | // Save indicates an expected call of Save. | |||
| 474 | - | func (mr *MockConfigSaverMockRecorder) Save() *gomock.Call { | |||
| 475 | - | mr.mock.ctrl.T.Helper() | |||
| 476 | - | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Save", reflect.TypeOf((*MockConfigSaver)(nil).Save)) | |||
| 477 | - | } | |||
D internal/phases/mocks/mock_phase.go +0 -165
| @@ -1,165 +0,0 @@ | |||||
| 1 | - | // Code generated by MockGen. DO NOT EDIT. | |||
| 2 | - | // Source: internal/phases/phase.go | |||
| 3 | - | // | |||
| 4 | - | // Generated by this command: | |||
| 5 | - | // | |||
| 6 | - | // mockgen -source=internal/phases/phase.go -destination=internal/phases/mocks/mock_phase.go -package=mocks | |||
| 7 | - | // | |||
| 8 | - | ||||
| 9 | - | // Package mocks is a generated GoMock package. | |||
| 10 | - | package mocks | |||
| 11 | - | ||||
| 12 | - | import ( | |||
| 13 | - | reflect "reflect" | |||
| 14 | - | ||||
| 15 | - | phases "github.com/bnema/archup/internal/phases" | |||
| 16 | - | gomock "go.uber.org/mock/gomock" | |||
| 17 | - | ) | |||
| 18 | - | ||||
| 19 | - | // MockPhase is a mock of Phase interface. | |||
| 20 | - | type MockPhase struct { | |||
| 21 | - | ctrl *gomock.Controller | |||
| 22 | - | recorder *MockPhaseMockRecorder | |||
| 23 | - | isgomock struct{} | |||
| 24 | - | } | |||
| 25 | - | ||||
| 26 | - | // MockPhaseMockRecorder is the mock recorder for MockPhase. | |||
| 27 | - | type MockPhaseMockRecorder struct { | |||
| 28 | - | mock *MockPhase | |||
| 29 | - | } | |||
| 30 | - | ||||
| 31 | - | // NewMockPhase creates a new mock instance. | |||
| 32 | - | func NewMockPhase(ctrl *gomock.Controller) *MockPhase { | |||
| 33 | - | mock := &MockPhase{ctrl: ctrl} | |||
| 34 | - | mock.recorder = &MockPhaseMockRecorder{mock} | |||
| 35 | - | return mock | |||
| 36 | - | } | |||
| 37 | - | ||||
| 38 | - | // EXPECT returns an object that allows the caller to indicate expected use. | |||
| 39 | - | func (m *MockPhase) EXPECT() *MockPhaseMockRecorder { | |||
| 40 | - | return m.recorder | |||
| 41 | - | } | |||
| 42 | - | ||||
| 43 | - | // CanSkip mocks base method. | |||
| 44 | - | func (m *MockPhase) CanSkip() bool { | |||
| 45 | - | m.ctrl.T.Helper() | |||
| 46 | - | ret := m.ctrl.Call(m, "CanSkip") | |||
| 47 | - | ret0, _ := ret[0].(bool) | |||
| 48 | - | return ret0 | |||
| 49 | - | } | |||
| 50 | - | ||||
| 51 | - | // CanSkip indicates an expected call of CanSkip. | |||
| 52 | - | func (mr *MockPhaseMockRecorder) CanSkip() *gomock.Call { | |||
| 53 | - | mr.mock.ctrl.T.Helper() | |||
| 54 | - | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CanSkip", reflect.TypeOf((*MockPhase)(nil).CanSkip)) | |||
| 55 | - | } | |||
| 56 | - | ||||
| 57 | - | // Description mocks base method. | |||
| 58 | - | func (m *MockPhase) Description() string { | |||
| 59 | - | m.ctrl.T.Helper() | |||
| 60 | - | ret := m.ctrl.Call(m, "Description") | |||
| 61 | - | ret0, _ := ret[0].(string) | |||
| 62 | - | return ret0 | |||
| 63 | - | } | |||
| 64 | - | ||||
| 65 | - | // Description indicates an expected call of Description. | |||
| 66 | - | func (mr *MockPhaseMockRecorder) Description() *gomock.Call { | |||
| 67 | - | mr.mock.ctrl.T.Helper() | |||
| 68 | - | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Description", reflect.TypeOf((*MockPhase)(nil).Description)) | |||
| 69 | - | } | |||
| 70 | - | ||||
| 71 | - | // Execute mocks base method. | |||
| 72 | - | func (m *MockPhase) Execute(progressChan chan<- phases.ProgressUpdate) phases.PhaseResult { | |||
| 73 | - | m.ctrl.T.Helper() | |||
| 74 | - | ret := m.ctrl.Call(m, "Execute", progressChan) | |||
| 75 | - | ret0, _ := ret[0].(phases.PhaseResult) | |||
| 76 | - | return ret0 | |||
| 77 | - | } | |||
| 78 | - | ||||
| 79 | - | // Execute indicates an expected call of Execute. | |||
| 80 | - | func (mr *MockPhaseMockRecorder) Execute(progressChan any) *gomock.Call { | |||
| 81 | - | mr.mock.ctrl.T.Helper() | |||
| 82 | - | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Execute", reflect.TypeOf((*MockPhase)(nil).Execute), progressChan) | |||
| 83 | - | } | |||
| 84 | - | ||||
| 85 | - | // Name mocks base method. | |||
| 86 | - | func (m *MockPhase) Name() string { | |||
| 87 | - | m.ctrl.T.Helper() | |||
| 88 | - | ret := m.ctrl.Call(m, "Name") | |||
| 89 | - | ret0, _ := ret[0].(string) | |||
| 90 | - | return ret0 | |||
| 91 | - | } | |||
| 92 | - | ||||
| 93 | - | // Name indicates an expected call of Name. | |||
| 94 | - | func (mr *MockPhaseMockRecorder) Name() *gomock.Call { | |||
| 95 | - | mr.mock.ctrl.T.Helper() | |||
| 96 | - | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Name", reflect.TypeOf((*MockPhase)(nil).Name)) | |||
| 97 | - | } | |||
| 98 | - | ||||
| 99 | - | // PostCheck mocks base method. | |||
| 100 | - | func (m *MockPhase) PostCheck() error { | |||
| 101 | - | m.ctrl.T.Helper() | |||
| 102 | - | ret := m.ctrl.Call(m, "PostCheck") | |||
| 103 | - | ret0, _ := ret[0].(error) | |||
| 104 | - | return ret0 | |||
| 105 | - | } | |||
| 106 | - | ||||
| 107 | - | // PostCheck indicates an expected call of PostCheck. | |||
| 108 | - | func (mr *MockPhaseMockRecorder) PostCheck() *gomock.Call { | |||
| 109 | - | mr.mock.ctrl.T.Helper() | |||
| 110 | - | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PostCheck", reflect.TypeOf((*MockPhase)(nil).PostCheck)) | |||
| 111 | - | } | |||
| 112 | - | ||||
| 113 | - | // PreCheck mocks base method. | |||
| 114 | - | func (m *MockPhase) PreCheck() error { | |||
| 115 | - | m.ctrl.T.Helper() | |||
| 116 | - | ret := m.ctrl.Call(m, "PreCheck") | |||
| 117 | - | ret0, _ := ret[0].(error) | |||
| 118 | - | return ret0 | |||
| 119 | - | } | |||
| 120 | - | ||||
| 121 | - | // PreCheck indicates an expected call of PreCheck. | |||
| 122 | - | func (mr *MockPhaseMockRecorder) PreCheck() *gomock.Call { | |||
| 123 | - | mr.mock.ctrl.T.Helper() | |||
| 124 | - | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PreCheck", reflect.TypeOf((*MockPhase)(nil).PreCheck)) | |||
| 125 | - | } | |||
| 126 | - | ||||
| 127 | - | // Rollback mocks base method. | |||
| 128 | - | func (m *MockPhase) Rollback() error { | |||
| 129 | - | m.ctrl.T.Helper() | |||
| 130 | - | ret := m.ctrl.Call(m, "Rollback") | |||
| 131 | - | ret0, _ := ret[0].(error) | |||
| 132 | - | return ret0 | |||
| 133 | - | } | |||
| 134 | - | ||||
| 135 | - | // Rollback indicates an expected call of Rollback. | |||
| 136 | - | func (mr *MockPhaseMockRecorder) Rollback() *gomock.Call { | |||
| 137 | - | mr.mock.ctrl.T.Helper() | |||
| 138 | - | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Rollback", reflect.TypeOf((*MockPhase)(nil).Rollback)) | |||
| 139 | - | } | |||
| 140 | - | ||||
| 141 | - | // SetStatus mocks base method. | |||
| 142 | - | func (m *MockPhase) SetStatus(status phases.PhaseStatus) { | |||
| 143 | - | m.ctrl.T.Helper() | |||
| 144 | - | m.ctrl.Call(m, "SetStatus", status) | |||
| 145 | - | } | |||
| 146 | - | ||||
| 147 | - | // SetStatus indicates an expected call of SetStatus. | |||
| 148 | - | func (mr *MockPhaseMockRecorder) SetStatus(status any) *gomock.Call { | |||
| 149 | - | mr.mock.ctrl.T.Helper() | |||
| 150 | - | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetStatus", reflect.TypeOf((*MockPhase)(nil).SetStatus), status) | |||
| 151 | - | } | |||
| 152 | - | ||||
| 153 | - | // Status mocks base method. | |||
| 154 | - | func (m *MockPhase) Status() phases.PhaseStatus { | |||
| 155 | - | m.ctrl.T.Helper() | |||
| 156 | - | ret := m.ctrl.Call(m, "Status") | |||
| 157 | - | ret0, _ := ret[0].(phases.PhaseStatus) | |||
| 158 | - | return ret0 | |||
| 159 | - | } | |||
| 160 | - | ||||
| 161 | - | // Status indicates an expected call of Status. | |||
| 162 | - | func (mr *MockPhaseMockRecorder) Status() *gomock.Call { | |||
| 163 | - | mr.mock.ctrl.T.Helper() | |||
| 164 | - | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Status", reflect.TypeOf((*MockPhase)(nil).Status)) | |||
| 165 | - | } | |||
D internal/phases/orchestrator.go +0 -202
| @@ -1,202 +0,0 @@ | |||||
| 1 | - | package phases | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "fmt" | |||
| 5 | - | "time" | |||
| 6 | - | ||||
| 7 | - | "github.com/bnema/archup/internal/config" | |||
| 8 | - | ) | |||
| 9 | - | ||||
| 10 | - | // Orchestrator manages the execution of installation phases | |||
| 11 | - | type Orchestrator struct { | |||
| 12 | - | phases []Phase | |||
| 13 | - | currentIdx int | |||
| 14 | - | config *config.Config | |||
| 15 | - | logPath string | |||
| 16 | - | progressCh chan ProgressUpdate | |||
| 17 | - | installStartTime time.Time | |||
| 18 | - | phaseDurations map[string]time.Duration | |||
| 19 | - | } | |||
| 20 | - | ||||
| 21 | - | // NewOrchestrator creates a new phase orchestrator | |||
| 22 | - | func NewOrchestrator(cfg *config.Config, logPath string) *Orchestrator { | |||
| 23 | - | return &Orchestrator{ | |||
| 24 | - | phases: make([]Phase, 0), | |||
| 25 | - | currentIdx: -1, | |||
| 26 | - | config: cfg, | |||
| 27 | - | logPath: logPath, | |||
| 28 | - | progressCh: make(chan ProgressUpdate, 100), | |||
| 29 | - | installStartTime: time.Now(), | |||
| 30 | - | phaseDurations: make(map[string]time.Duration), | |||
| 31 | - | } | |||
| 32 | - | } | |||
| 33 | - | ||||
| 34 | - | // RegisterPhase adds a phase to the execution sequence | |||
| 35 | - | func (o *Orchestrator) RegisterPhase(phase Phase) error { | |||
| 36 | - | if phase == nil { | |||
| 37 | - | return fmt.Errorf("cannot register nil phase") | |||
| 38 | - | } | |||
| 39 | - | ||||
| 40 | - | // Check for duplicate phase names | |||
| 41 | - | for _, p := range o.phases { | |||
| 42 | - | if p.Name() == phase.Name() { | |||
| 43 | - | return fmt.Errorf("phase %s already registered", phase.Name()) | |||
| 44 | - | } | |||
| 45 | - | } | |||
| 46 | - | ||||
| 47 | - | o.phases = append(o.phases, phase) | |||
| 48 | - | return nil | |||
| 49 | - | } | |||
| 50 | - | ||||
| 51 | - | // Phases returns all registered phases | |||
| 52 | - | func (o *Orchestrator) Phases() []Phase { | |||
| 53 | - | return o.phases | |||
| 54 | - | } | |||
| 55 | - | ||||
| 56 | - | // CurrentPhase returns the currently executing phase, or nil | |||
| 57 | - | func (o *Orchestrator) CurrentPhase() Phase { | |||
| 58 | - | if o.currentIdx < 0 || o.currentIdx >= len(o.phases) { | |||
| 59 | - | return nil | |||
| 60 | - | } | |||
| 61 | - | return o.phases[o.currentIdx] | |||
| 62 | - | } | |||
| 63 | - | ||||
| 64 | - | // NextPhase returns the next phase to execute, or nil if done | |||
| 65 | - | func (o *Orchestrator) NextPhase() Phase { | |||
| 66 | - | // Find next pending phase | |||
| 67 | - | for i := o.currentIdx + 1; i < len(o.phases); i++ { | |||
| 68 | - | phase := o.phases[i] | |||
| 69 | - | if phase.Status() == StatusPending { | |||
| 70 | - | return phase | |||
| 71 | - | } | |||
| 72 | - | } | |||
| 73 | - | return nil | |||
| 74 | - | } | |||
| 75 | - | ||||
| 76 | - | // ProgressChannel returns the progress update channel | |||
| 77 | - | func (o *Orchestrator) ProgressChannel() <-chan ProgressUpdate { | |||
| 78 | - | return o.progressCh | |||
| 79 | - | } | |||
| 80 | - | ||||
| 81 | - | // ExecutePhase runs a specific phase | |||
| 82 | - | func (o *Orchestrator) ExecutePhase(phase Phase) error { | |||
| 83 | - | for i, p := range o.phases { | |||
| 84 | - | if p.Name() == phase.Name() { | |||
| 85 | - | o.currentIdx = i | |||
| 86 | - | break | |||
| 87 | - | } | |||
| 88 | - | } | |||
| 89 | - | ||||
| 90 | - | startTime := time.Now() | |||
| 91 | - | ||||
| 92 | - | phase.SetStatus(StatusRunning) | |||
| 93 | - | if err := phase.PreCheck(); err != nil { | |||
| 94 | - | phase.SetStatus(StatusFailed) | |||
| 95 | - | return fmt.Errorf("pre-check failed: %w", err) | |||
| 96 | - | } | |||
| 97 | - | ||||
| 98 | - | result := phase.Execute(o.progressCh) | |||
| 99 | - | duration := time.Since(startTime) | |||
| 100 | - | o.phaseDurations[phase.Name()] = duration | |||
| 101 | - | ||||
| 102 | - | if !result.Success { | |||
| 103 | - | phase.SetStatus(StatusFailed) | |||
| 104 | - | ||||
| 105 | - | if rbErr := phase.Rollback(); rbErr != nil { | |||
| 106 | - | return fmt.Errorf("execution failed: %v, rollback failed: %v", result.Error, rbErr) | |||
| 107 | - | } | |||
| 108 | - | ||||
| 109 | - | return fmt.Errorf("execution failed: %w", result.Error) | |||
| 110 | - | } | |||
| 111 | - | ||||
| 112 | - | if err := phase.PostCheck(); err != nil { | |||
| 113 | - | phase.SetStatus(StatusFailed) | |||
| 114 | - | return fmt.Errorf("post-check failed: %w", err) | |||
| 115 | - | } | |||
| 116 | - | ||||
| 117 | - | phase.SetStatus(StatusCompleted) | |||
| 118 | - | return nil | |||
| 119 | - | } | |||
| 120 | - | ||||
| 121 | - | // ExecuteNext executes the next pending phase | |||
| 122 | - | func (o *Orchestrator) ExecuteNext() error { | |||
| 123 | - | phase := o.NextPhase() | |||
| 124 | - | if phase == nil { | |||
| 125 | - | return fmt.Errorf("no pending phases") | |||
| 126 | - | } | |||
| 127 | - | return o.ExecutePhase(phase) | |||
| 128 | - | } | |||
| 129 | - | ||||
| 130 | - | // ExecuteAll executes all pending phases in sequence | |||
| 131 | - | func (o *Orchestrator) ExecuteAll() error { | |||
| 132 | - | for { | |||
| 133 | - | phase := o.NextPhase() | |||
| 134 | - | if phase == nil { | |||
| 135 | - | break | |||
| 136 | - | } | |||
| 137 | - | ||||
| 138 | - | if err := o.ExecutePhase(phase); err != nil { | |||
| 139 | - | return err | |||
| 140 | - | } | |||
| 141 | - | } | |||
| 142 | - | return nil | |||
| 143 | - | } | |||
| 144 | - | ||||
| 145 | - | // SkipPhase marks a phase as skipped | |||
| 146 | - | func (o *Orchestrator) SkipPhase(phase Phase) error { | |||
| 147 | - | if !phase.CanSkip() { | |||
| 148 | - | return fmt.Errorf("phase %s cannot be skipped", phase.Name()) | |||
| 149 | - | } | |||
| 150 | - | phase.SetStatus(StatusSkipped) | |||
| 151 | - | return nil | |||
| 152 | - | } | |||
| 153 | - | ||||
| 154 | - | // Reset resets all phases to pending state | |||
| 155 | - | func (o *Orchestrator) Reset() { | |||
| 156 | - | o.currentIdx = -1 | |||
| 157 | - | for _, phase := range o.phases { | |||
| 158 | - | phase.SetStatus(StatusPending) | |||
| 159 | - | } | |||
| 160 | - | } | |||
| 161 | - | ||||
| 162 | - | // Progress returns overall installation progress | |||
| 163 | - | func (o *Orchestrator) Progress() (completed int, total int) { | |||
| 164 | - | total = len(o.phases) | |||
| 165 | - | for _, phase := range o.phases { | |||
| 166 | - | if phase.Status() == StatusCompleted || phase.Status() == StatusSkipped { | |||
| 167 | - | completed++ | |||
| 168 | - | } | |||
| 169 | - | } | |||
| 170 | - | return completed, total | |||
| 171 | - | } | |||
| 172 | - | ||||
| 173 | - | // IsComplete returns true if all phases are done | |||
| 174 | - | func (o *Orchestrator) IsComplete() bool { | |||
| 175 | - | for _, phase := range o.phases { | |||
| 176 | - | status := phase.Status() | |||
| 177 | - | if status != StatusCompleted && status != StatusSkipped { | |||
| 178 | - | return false | |||
| 179 | - | } | |||
| 180 | - | } | |||
| 181 | - | return true | |||
| 182 | - | } | |||
| 183 | - | ||||
| 184 | - | // HasFailed returns true if any phase has failed | |||
| 185 | - | func (o *Orchestrator) HasFailed() bool { | |||
| 186 | - | for _, phase := range o.phases { | |||
| 187 | - | if phase.Status() == StatusFailed { | |||
| 188 | - | return true | |||
| 189 | - | } | |||
| 190 | - | } | |||
| 191 | - | return false | |||
| 192 | - | } | |||
| 193 | - | ||||
| 194 | - | // GetTotalDuration returns total installation time | |||
| 195 | - | func (o *Orchestrator) GetTotalDuration() time.Duration { | |||
| 196 | - | return time.Since(o.installStartTime) | |||
| 197 | - | } | |||
| 198 | - | ||||
| 199 | - | // GetPhaseDurations returns timing breakdown by phase | |||
| 200 | - | func (o *Orchestrator) GetPhaseDurations() map[string]time.Duration { | |||
| 201 | - | return o.phaseDurations | |||
| 202 | - | } | |||
D internal/phases/orchestrator_test.go +0 -611
| @@ -1,611 +0,0 @@ | |||||
| 1 | - | package phases | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "fmt" | |||
| 5 | - | "testing" | |||
| 6 | - | ||||
| 7 | - | "github.com/bnema/archup/internal/config" | |||
| 8 | - | ) | |||
| 9 | - | ||||
| 10 | - | // testPhase is a simple Phase implementation for testing orchestrator | |||
| 11 | - | type testPhase struct { | |||
| 12 | - | *BasePhase | |||
| 13 | - | preCheckErr error | |||
| 14 | - | executeErr error | |||
| 15 | - | postCheckErr error | |||
| 16 | - | rollbackErr error | |||
| 17 | - | canSkip bool | |||
| 18 | - | } | |||
| 19 | - | ||||
| 20 | - | func newTestPhase(name string, cfg *config.Config) *testPhase { | |||
| 21 | - | return &testPhase{ | |||
| 22 | - | BasePhase: NewBasePhase(name, "Test "+name, cfg, nil), | |||
| 23 | - | canSkip: false, | |||
| 24 | - | } | |||
| 25 | - | } | |||
| 26 | - | ||||
| 27 | - | func (p *testPhase) PreCheck() error { | |||
| 28 | - | return p.preCheckErr | |||
| 29 | - | } | |||
| 30 | - | ||||
| 31 | - | func (p *testPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult { | |||
| 32 | - | if p.executeErr != nil { | |||
| 33 | - | return PhaseResult{ | |||
| 34 | - | Success: false, | |||
| 35 | - | Error: p.executeErr, | |||
| 36 | - | } | |||
| 37 | - | } | |||
| 38 | - | return PhaseResult{ | |||
| 39 | - | Success: true, | |||
| 40 | - | Message: "completed", | |||
| 41 | - | } | |||
| 42 | - | } | |||
| 43 | - | ||||
| 44 | - | func (p *testPhase) PostCheck() error { | |||
| 45 | - | return p.postCheckErr | |||
| 46 | - | } | |||
| 47 | - | ||||
| 48 | - | func (p *testPhase) Rollback() error { | |||
| 49 | - | return p.rollbackErr | |||
| 50 | - | } | |||
| 51 | - | ||||
| 52 | - | func (p *testPhase) CanSkip() bool { | |||
| 53 | - | return p.canSkip | |||
| 54 | - | } | |||
| 55 | - | ||||
| 56 | - | // TestOrchestratorRegistration tests phase registration | |||
| 57 | - | func TestOrchestratorRegistration(t *testing.T) { | |||
| 58 | - | cfg := config.NewConfig("test") | |||
| 59 | - | orch := NewOrchestrator(cfg, "test.log") | |||
| 60 | - | ||||
| 61 | - | t.Run("register valid phase", func(t *testing.T) { | |||
| 62 | - | phase := newTestPhase("test-phase", cfg) | |||
| 63 | - | err := orch.RegisterPhase(phase) | |||
| 64 | - | if err != nil { | |||
| 65 | - | t.Errorf("Failed to register valid phase: %v", err) | |||
| 66 | - | } | |||
| 67 | - | ||||
| 68 | - | phases := orch.Phases() | |||
| 69 | - | if len(phases) != 1 { | |||
| 70 | - | t.Errorf("Expected 1 phase, got %d", len(phases)) | |||
| 71 | - | } | |||
| 72 | - | }) | |||
| 73 | - | ||||
| 74 | - | t.Run("register nil phase", func(t *testing.T) { | |||
| 75 | - | orch2 := NewOrchestrator(cfg, "test.log") | |||
| 76 | - | err := orch2.RegisterPhase(nil) | |||
| 77 | - | if err == nil { | |||
| 78 | - | t.Error("Expected error for nil phase, got nil") | |||
| 79 | - | } | |||
| 80 | - | }) | |||
| 81 | - | ||||
| 82 | - | t.Run("register duplicate phase name", func(t *testing.T) { | |||
| 83 | - | orch2 := NewOrchestrator(cfg, "test.log") | |||
| 84 | - | phase1 := newTestPhase("duplicate", cfg) | |||
| 85 | - | phase2 := newTestPhase("duplicate", cfg) | |||
| 86 | - | ||||
| 87 | - | if err := orch2.RegisterPhase(phase1); err != nil { | |||
| 88 | - | t.Fatalf("expected no error, got %v", err) | |||
| 89 | - | } | |||
| 90 | - | err := orch2.RegisterPhase(phase2) | |||
| 91 | - | if err == nil { | |||
| 92 | - | t.Error("Expected error for duplicate phase name, got nil") | |||
| 93 | - | } | |||
| 94 | - | }) | |||
| 95 | - | } | |||
| 96 | - | ||||
| 97 | - | // TestOrchestratorCurrentPhase tests CurrentPhase navigation | |||
| 98 | - | func TestOrchestratorCurrentPhase(t *testing.T) { | |||
| 99 | - | cfg := config.NewConfig("test") | |||
| 100 | - | orch := NewOrchestrator(cfg, "test.log") | |||
| 101 | - | ||||
| 102 | - | phase := newTestPhase("test", cfg) | |||
| 103 | - | if err := orch.RegisterPhase(phase); err != nil { | |||
| 104 | - | t.Fatalf("expected no error, got %v", err) | |||
| 105 | - | } | |||
| 106 | - | ||||
| 107 | - | // Before execution, current should be nil | |||
| 108 | - | if orch.CurrentPhase() != nil { | |||
| 109 | - | t.Error("CurrentPhase should be nil before execution") | |||
| 110 | - | } | |||
| 111 | - | ||||
| 112 | - | // After executing, current should be set | |||
| 113 | - | if err := orch.ExecutePhase(phase); err != nil { | |||
| 114 | - | t.Fatalf("expected no error, got %v", err) | |||
| 115 | - | } | |||
| 116 | - | current := orch.CurrentPhase() | |||
| 117 | - | if current == nil { | |||
| 118 | - | t.Error("CurrentPhase should not be nil after execution") | |||
| 119 | - | } | |||
| 120 | - | if current.Name() != "test" { | |||
| 121 | - | t.Errorf("CurrentPhase name = %q, want %q", current.Name(), "test") | |||
| 122 | - | } | |||
| 123 | - | } | |||
| 124 | - | ||||
| 125 | - | // TestOrchestratorNextPhase tests NextPhase logic | |||
| 126 | - | func TestOrchestratorNextPhase(t *testing.T) { | |||
| 127 | - | cfg := config.NewConfig("test") | |||
| 128 | - | orch := NewOrchestrator(cfg, "test.log") | |||
| 129 | - | ||||
| 130 | - | phase1 := newTestPhase("phase-1", cfg) | |||
| 131 | - | phase2 := newTestPhase("phase-2", cfg) | |||
| 132 | - | phase3 := newTestPhase("phase-3", cfg) | |||
| 133 | - | ||||
| 134 | - | if err := orch.RegisterPhase(phase1); err != nil { | |||
| 135 | - | t.Fatalf("expected no error, got %v", err) | |||
| 136 | - | } | |||
| 137 | - | if err := orch.RegisterPhase(phase2); err != nil { | |||
| 138 | - | t.Fatalf("expected no error, got %v", err) | |||
| 139 | - | } | |||
| 140 | - | if err := orch.RegisterPhase(phase3); err != nil { | |||
| 141 | - | t.Fatalf("expected no error, got %v", err) | |||
| 142 | - | } | |||
| 143 | - | ||||
| 144 | - | // Next should be phase-1 | |||
| 145 | - | next := orch.NextPhase() | |||
| 146 | - | if next == nil || next.Name() != "phase-1" { | |||
| 147 | - | t.Errorf("NextPhase = %v, want phase-1", next) | |||
| 148 | - | } | |||
| 149 | - | ||||
| 150 | - | // Mark phase-1 completed | |||
| 151 | - | phase1.SetStatus(StatusCompleted) | |||
| 152 | - | ||||
| 153 | - | // Next should be phase-2 | |||
| 154 | - | next = orch.NextPhase() | |||
| 155 | - | if next == nil || next.Name() != "phase-2" { | |||
| 156 | - | t.Errorf("NextPhase = %v, want phase-2", next) | |||
| 157 | - | } | |||
| 158 | - | ||||
| 159 | - | // Mark phase-2 skipped | |||
| 160 | - | phase2.SetStatus(StatusSkipped) | |||
| 161 | - | ||||
| 162 | - | // Next should be phase-3 | |||
| 163 | - | next = orch.NextPhase() | |||
| 164 | - | if next == nil || next.Name() != "phase-3" { | |||
| 165 | - | t.Errorf("NextPhase = %v, want phase-3", next) | |||
| 166 | - | } | |||
| 167 | - | ||||
| 168 | - | // Mark all done | |||
| 169 | - | phase3.SetStatus(StatusCompleted) | |||
| 170 | - | ||||
| 171 | - | // Next should be nil | |||
| 172 | - | next = orch.NextPhase() | |||
| 173 | - | if next != nil { | |||
| 174 | - | t.Errorf("NextPhase = %v, want nil when all done", next) | |||
| 175 | - | } | |||
| 176 | - | } | |||
| 177 | - | ||||
| 178 | - | // TestOrchestratorExecutePhaseSuccess tests successful phase execution | |||
| 179 | - | func TestOrchestratorExecutePhaseSuccess(t *testing.T) { | |||
| 180 | - | cfg := config.NewConfig("test") | |||
| 181 | - | orch := NewOrchestrator(cfg, "test.log") | |||
| 182 | - | ||||
| 183 | - | phase := newTestPhase("test", cfg) | |||
| 184 | - | if err := orch.RegisterPhase(phase); err != nil { | |||
| 185 | - | t.Fatalf("expected no error, got %v", err) | |||
| 186 | - | } | |||
| 187 | - | ||||
| 188 | - | err := orch.ExecutePhase(phase) | |||
| 189 | - | if err != nil { | |||
| 190 | - | t.Errorf("ExecutePhase failed: %v", err) | |||
| 191 | - | } | |||
| 192 | - | ||||
| 193 | - | if phase.Status() != StatusCompleted { | |||
| 194 | - | t.Errorf("Status = %v, want %v", phase.Status(), StatusCompleted) | |||
| 195 | - | } | |||
| 196 | - | } | |||
| 197 | - | ||||
| 198 | - | // TestOrchestratorExecutePhasePreCheckFails tests PreCheck failure | |||
| 199 | - | func TestOrchestratorExecutePhasePreCheckFails(t *testing.T) { | |||
| 200 | - | cfg := config.NewConfig("test") | |||
| 201 | - | orch := NewOrchestrator(cfg, "test.log") | |||
| 202 | - | ||||
| 203 | - | phase := newTestPhase("test", cfg) | |||
| 204 | - | phase.preCheckErr = fmt.Errorf("precondition failed") | |||
| 205 | - | if err := orch.RegisterPhase(phase); err != nil { | |||
| 206 | - | t.Fatalf("expected no error, got %v", err) | |||
| 207 | - | } | |||
| 208 | - | ||||
| 209 | - | err := orch.ExecutePhase(phase) | |||
| 210 | - | if err == nil { | |||
| 211 | - | t.Error("Expected error from failed PreCheck") | |||
| 212 | - | } | |||
| 213 | - | ||||
| 214 | - | if phase.Status() != StatusFailed { | |||
| 215 | - | t.Errorf("Status = %v, want %v", phase.Status(), StatusFailed) | |||
| 216 | - | } | |||
| 217 | - | } | |||
| 218 | - | ||||
| 219 | - | // TestOrchestratorExecutePhaseExecuteFails tests Execute failure with Rollback | |||
| 220 | - | func TestOrchestratorExecutePhaseExecuteFails(t *testing.T) { | |||
| 221 | - | cfg := config.NewConfig("test") | |||
| 222 | - | orch := NewOrchestrator(cfg, "test.log") | |||
| 223 | - | ||||
| 224 | - | phase := newTestPhase("test", cfg) | |||
| 225 | - | phase.executeErr = fmt.Errorf("execution failed") | |||
| 226 | - | if err := orch.RegisterPhase(phase); err != nil { | |||
| 227 | - | t.Fatalf("expected no error, got %v", err) | |||
| 228 | - | } | |||
| 229 | - | ||||
| 230 | - | err := orch.ExecutePhase(phase) | |||
| 231 | - | if err == nil { | |||
| 232 | - | t.Error("Expected error from failed Execute") | |||
| 233 | - | } | |||
| 234 | - | ||||
| 235 | - | if phase.Status() != StatusFailed { | |||
| 236 | - | t.Errorf("Status = %v, want %v", phase.Status(), StatusFailed) | |||
| 237 | - | } | |||
| 238 | - | } | |||
| 239 | - | ||||
| 240 | - | // TestOrchestratorExecutePhaseRollbackFails tests Rollback failure | |||
| 241 | - | func TestOrchestratorExecutePhaseRollbackFails(t *testing.T) { | |||
| 242 | - | cfg := config.NewConfig("test") | |||
| 243 | - | orch := NewOrchestrator(cfg, "test.log") | |||
| 244 | - | ||||
| 245 | - | phase := newTestPhase("test", cfg) | |||
| 246 | - | phase.executeErr = fmt.Errorf("execution failed") | |||
| 247 | - | phase.rollbackErr = fmt.Errorf("rollback also failed") | |||
| 248 | - | if err := orch.RegisterPhase(phase); err != nil { | |||
| 249 | - | t.Fatalf("expected no error, got %v", err) | |||
| 250 | - | } | |||
| 251 | - | ||||
| 252 | - | err := orch.ExecutePhase(phase) | |||
| 253 | - | if err == nil { | |||
| 254 | - | t.Error("Expected error from failed Rollback") | |||
| 255 | - | } | |||
| 256 | - | ||||
| 257 | - | if phase.Status() != StatusFailed { | |||
| 258 | - | t.Errorf("Status = %v, want %v", phase.Status(), StatusFailed) | |||
| 259 | - | } | |||
| 260 | - | } | |||
| 261 | - | ||||
| 262 | - | // TestOrchestratorExecutePhasePostCheckFails tests PostCheck failure | |||
| 263 | - | func TestOrchestratorExecutePhasePostCheckFails(t *testing.T) { | |||
| 264 | - | cfg := config.NewConfig("test") | |||
| 265 | - | orch := NewOrchestrator(cfg, "test.log") | |||
| 266 | - | ||||
| 267 | - | phase := newTestPhase("test", cfg) | |||
| 268 | - | phase.postCheckErr = fmt.Errorf("validation failed") | |||
| 269 | - | if err := orch.RegisterPhase(phase); err != nil { | |||
| 270 | - | t.Fatalf("expected no error, got %v", err) | |||
| 271 | - | } | |||
| 272 | - | ||||
| 273 | - | err := orch.ExecutePhase(phase) | |||
| 274 | - | if err == nil { | |||
| 275 | - | t.Error("Expected error from failed PostCheck") | |||
| 276 | - | } | |||
| 277 | - | ||||
| 278 | - | if phase.Status() != StatusFailed { | |||
| 279 | - | t.Errorf("Status = %v, want %v", phase.Status(), StatusFailed) | |||
| 280 | - | } | |||
| 281 | - | } | |||
| 282 | - | ||||
| 283 | - | // TestOrchestratorSkipPhase tests skipping phases | |||
| 284 | - | func TestOrchestratorSkipPhase(t *testing.T) { | |||
| 285 | - | cfg := config.NewConfig("test") | |||
| 286 | - | orch := NewOrchestrator(cfg, "test.log") | |||
| 287 | - | ||||
| 288 | - | t.Run("skip skippable phase", func(t *testing.T) { | |||
| 289 | - | phase := newTestPhase("skippable", cfg) | |||
| 290 | - | phase.canSkip = true | |||
| 291 | - | if err := orch.RegisterPhase(phase); err != nil { | |||
| 292 | - | t.Fatalf("expected no error, got %v", err) | |||
| 293 | - | } | |||
| 294 | - | ||||
| 295 | - | err := orch.SkipPhase(phase) | |||
| 296 | - | if err != nil { | |||
| 297 | - | t.Errorf("SkipPhase failed: %v", err) | |||
| 298 | - | } | |||
| 299 | - | ||||
| 300 | - | if phase.Status() != StatusSkipped { | |||
| 301 | - | t.Errorf("Status = %v, want %v", phase.Status(), StatusSkipped) | |||
| 302 | - | } | |||
| 303 | - | }) | |||
| 304 | - | ||||
| 305 | - | t.Run("skip non-skippable phase", func(t *testing.T) { | |||
| 306 | - | orch2 := NewOrchestrator(cfg, "test.log") | |||
| 307 | - | phase := newTestPhase("non-skippable", cfg) | |||
| 308 | - | phase.canSkip = false | |||
| 309 | - | if err := orch2.RegisterPhase(phase); err != nil { | |||
| 310 | - | t.Fatalf("expected no error, got %v", err) | |||
| 311 | - | } | |||
| 312 | - | ||||
| 313 | - | err := orch2.SkipPhase(phase) | |||
| 314 | - | if err == nil { | |||
| 315 | - | t.Error("Expected error when skipping non-skippable phase") | |||
| 316 | - | } | |||
| 317 | - | }) | |||
| 318 | - | } | |||
| 319 | - | ||||
| 320 | - | // TestOrchestratorProgress tests progress calculation | |||
| 321 | - | func TestOrchestratorProgress(t *testing.T) { | |||
| 322 | - | cfg := config.NewConfig("test") | |||
| 323 | - | orch := NewOrchestrator(cfg, "test.log") | |||
| 324 | - | ||||
| 325 | - | phase1 := newTestPhase("phase-1", cfg) | |||
| 326 | - | phase2 := newTestPhase("phase-2", cfg) | |||
| 327 | - | phase3 := newTestPhase("phase-3", cfg) | |||
| 328 | - | ||||
| 329 | - | if err := orch.RegisterPhase(phase1); err != nil { | |||
| 330 | - | t.Fatalf("expected no error, got %v", err) | |||
| 331 | - | } | |||
| 332 | - | if err := orch.RegisterPhase(phase2); err != nil { | |||
| 333 | - | t.Fatalf("expected no error, got %v", err) | |||
| 334 | - | } | |||
| 335 | - | if err := orch.RegisterPhase(phase3); err != nil { | |||
| 336 | - | t.Fatalf("expected no error, got %v", err) | |||
| 337 | - | } | |||
| 338 | - | ||||
| 339 | - | // Initially all pending | |||
| 340 | - | completed, total := orch.Progress() | |||
| 341 | - | if total != 3 { | |||
| 342 | - | t.Errorf("Total = %d, want 3", total) | |||
| 343 | - | } | |||
| 344 | - | if completed != 0 { | |||
| 345 | - | t.Errorf("Completed = %d, want 0", completed) | |||
| 346 | - | } | |||
| 347 | - | ||||
| 348 | - | // Mark one completed, one skipped | |||
| 349 | - | phase1.SetStatus(StatusCompleted) | |||
| 350 | - | phase2.SetStatus(StatusSkipped) | |||
| 351 | - | ||||
| 352 | - | completed, _ = orch.Progress() | |||
| 353 | - | if completed != 2 { | |||
| 354 | - | t.Errorf("Completed = %d, want 2 (completed + skipped)", completed) | |||
| 355 | - | } | |||
| 356 | - | } | |||
| 357 | - | ||||
| 358 | - | // TestOrchestratorIsComplete tests completion detection | |||
| 359 | - | func TestOrchestratorIsComplete(t *testing.T) { | |||
| 360 | - | cfg := config.NewConfig("test") | |||
| 361 | - | ||||
| 362 | - | t.Run("all completed", func(t *testing.T) { | |||
| 363 | - | orch := NewOrchestrator(cfg, "test.log") | |||
| 364 | - | phase := newTestPhase("test", cfg) | |||
| 365 | - | phase.SetStatus(StatusCompleted) | |||
| 366 | - | if err := orch.RegisterPhase(phase); err != nil { | |||
| 367 | - | t.Fatalf("expected no error, got %v", err) | |||
| 368 | - | } | |||
| 369 | - | ||||
| 370 | - | if !orch.IsComplete() { | |||
| 371 | - | t.Error("IsComplete should be true when all phases completed") | |||
| 372 | - | } | |||
| 373 | - | }) | |||
| 374 | - | ||||
| 375 | - | t.Run("with skipped", func(t *testing.T) { | |||
| 376 | - | orch := NewOrchestrator(cfg, "test.log") | |||
| 377 | - | phase := newTestPhase("test", cfg) | |||
| 378 | - | phase.SetStatus(StatusSkipped) | |||
| 379 | - | if err := orch.RegisterPhase(phase); err != nil { | |||
| 380 | - | t.Fatalf("expected no error, got %v", err) | |||
| 381 | - | } | |||
| 382 | - | ||||
| 383 | - | if !orch.IsComplete() { | |||
| 384 | - | t.Error("IsComplete should be true when all phases skipped") | |||
| 385 | - | } | |||
| 386 | - | }) | |||
| 387 | - | ||||
| 388 | - | t.Run("with pending", func(t *testing.T) { | |||
| 389 | - | orch := NewOrchestrator(cfg, "test.log") | |||
| 390 | - | phase := newTestPhase("test", cfg) | |||
| 391 | - | if err := orch.RegisterPhase(phase); err != nil { | |||
| 392 | - | t.Fatalf("expected no error, got %v", err) | |||
| 393 | - | } | |||
| 394 | - | ||||
| 395 | - | if orch.IsComplete() { | |||
| 396 | - | t.Error("IsComplete should be false with pending phases") | |||
| 397 | - | } | |||
| 398 | - | }) | |||
| 399 | - | } | |||
| 400 | - | ||||
| 401 | - | // TestOrchestratorHasFailed tests failure detection | |||
| 402 | - | func TestOrchestratorHasFailed(t *testing.T) { | |||
| 403 | - | cfg := config.NewConfig("test") | |||
| 404 | - | ||||
| 405 | - | t.Run("no failures", func(t *testing.T) { | |||
| 406 | - | orch := NewOrchestrator(cfg, "test.log") | |||
| 407 | - | phase := newTestPhase("test", cfg) | |||
| 408 | - | phase.SetStatus(StatusCompleted) | |||
| 409 | - | if err := orch.RegisterPhase(phase); err != nil { | |||
| 410 | - | t.Fatalf("expected no error, got %v", err) | |||
| 411 | - | } | |||
| 412 | - | ||||
| 413 | - | if orch.HasFailed() { | |||
| 414 | - | t.Error("HasFailed should be false when no phases failed") | |||
| 415 | - | } | |||
| 416 | - | }) | |||
| 417 | - | ||||
| 418 | - | t.Run("with failure", func(t *testing.T) { | |||
| 419 | - | orch := NewOrchestrator(cfg, "test.log") | |||
| 420 | - | phase := newTestPhase("test", cfg) | |||
| 421 | - | phase.SetStatus(StatusFailed) | |||
| 422 | - | if err := orch.RegisterPhase(phase); err != nil { | |||
| 423 | - | t.Fatalf("expected no error, got %v", err) | |||
| 424 | - | } | |||
| 425 | - | ||||
| 426 | - | if !orch.HasFailed() { | |||
| 427 | - | t.Error("HasFailed should be true when phase failed") | |||
| 428 | - | } | |||
| 429 | - | }) | |||
| 430 | - | } | |||
| 431 | - | ||||
| 432 | - | // TestOrchestratorReset tests Reset functionality | |||
| 433 | - | func TestOrchestratorReset(t *testing.T) { | |||
| 434 | - | cfg := config.NewConfig("test") | |||
| 435 | - | orch := NewOrchestrator(cfg, "test.log") | |||
| 436 | - | ||||
| 437 | - | phase1 := newTestPhase("phase-1", cfg) | |||
| 438 | - | phase2 := newTestPhase("phase-2", cfg) | |||
| 439 | - | ||||
| 440 | - | phase1.SetStatus(StatusCompleted) | |||
| 441 | - | phase2.SetStatus(StatusFailed) | |||
| 442 | - | ||||
| 443 | - | if err := orch.RegisterPhase(phase1); err != nil { | |||
| 444 | - | t.Fatalf("expected no error, got %v", err) | |||
| 445 | - | } | |||
| 446 | - | if err := orch.RegisterPhase(phase2); err != nil { | |||
| 447 | - | t.Fatalf("expected no error, got %v", err) | |||
| 448 | - | } | |||
| 449 | - | ||||
| 450 | - | // Execute to set currentIdx | |||
| 451 | - | if err := orch.ExecutePhase(phase1); err != nil { | |||
| 452 | - | t.Fatalf("expected no error, got %v", err) | |||
| 453 | - | } | |||
| 454 | - | ||||
| 455 | - | // Reset | |||
| 456 | - | orch.Reset() | |||
| 457 | - | ||||
| 458 | - | // Verify currentIdx reset | |||
| 459 | - | if orch.CurrentPhase() != nil { | |||
| 460 | - | t.Error("CurrentPhase should be nil after Reset") | |||
| 461 | - | } | |||
| 462 | - | ||||
| 463 | - | // Verify statuses reset | |||
| 464 | - | if phase1.Status() != StatusPending { | |||
| 465 | - | t.Errorf("phase1 Status = %v, want %v", phase1.Status(), StatusPending) | |||
| 466 | - | } | |||
| 467 | - | if phase2.Status() != StatusPending { | |||
| 468 | - | t.Errorf("phase2 Status = %v, want %v", phase2.Status(), StatusPending) | |||
| 469 | - | } | |||
| 470 | - | } | |||
| 471 | - | ||||
| 472 | - | // TestOrchestratorExecuteAll tests full sequence execution | |||
| 473 | - | func TestOrchestratorExecuteAll(t *testing.T) { | |||
| 474 | - | cfg := config.NewConfig("test") | |||
| 475 | - | orch := NewOrchestrator(cfg, "test.log") | |||
| 476 | - | ||||
| 477 | - | phase1 := newTestPhase("phase-1", cfg) | |||
| 478 | - | phase2 := newTestPhase("phase-2", cfg) | |||
| 479 | - | phase3 := newTestPhase("phase-3", cfg) | |||
| 480 | - | ||||
| 481 | - | if err := orch.RegisterPhase(phase1); err != nil { | |||
| 482 | - | t.Fatalf("expected no error, got %v", err) | |||
| 483 | - | } | |||
| 484 | - | if err := orch.RegisterPhase(phase2); err != nil { | |||
| 485 | - | t.Fatalf("expected no error, got %v", err) | |||
| 486 | - | } | |||
| 487 | - | if err := orch.RegisterPhase(phase3); err != nil { | |||
| 488 | - | t.Fatalf("expected no error, got %v", err) | |||
| 489 | - | } | |||
| 490 | - | ||||
| 491 | - | err := orch.ExecuteAll() | |||
| 492 | - | if err != nil { | |||
| 493 | - | t.Errorf("ExecuteAll failed: %v", err) | |||
| 494 | - | } | |||
| 495 | - | ||||
| 496 | - | // Verify all completed | |||
| 497 | - | if phase1.Status() != StatusCompleted { | |||
| 498 | - | t.Errorf("phase1 Status = %v, want %v", phase1.Status(), StatusCompleted) | |||
| 499 | - | } | |||
| 500 | - | if phase2.Status() != StatusCompleted { | |||
| 501 | - | t.Errorf("phase2 Status = %v, want %v", phase2.Status(), StatusCompleted) | |||
| 502 | - | } | |||
| 503 | - | if phase3.Status() != StatusCompleted { | |||
| 504 | - | t.Errorf("phase3 Status = %v, want %v", phase3.Status(), StatusCompleted) | |||
| 505 | - | } | |||
| 506 | - | } | |||
| 507 | - | ||||
| 508 | - | // TestOrchestratorExecuteAllStopsOnFailure tests that ExecuteAll stops on first failure | |||
| 509 | - | func TestOrchestratorExecuteAllStopsOnFailure(t *testing.T) { | |||
| 510 | - | cfg := config.NewConfig("test") | |||
| 511 | - | orch := NewOrchestrator(cfg, "test.log") | |||
| 512 | - | ||||
| 513 | - | phase1 := newTestPhase("phase-1", cfg) | |||
| 514 | - | phase2 := newTestPhase("phase-2", cfg) | |||
| 515 | - | phase3 := newTestPhase("phase-3", cfg) | |||
| 516 | - | ||||
| 517 | - | // Make phase-2 fail | |||
| 518 | - | phase2.executeErr = fmt.Errorf("execution failed") | |||
| 519 | - | ||||
| 520 | - | if err := orch.RegisterPhase(phase1); err != nil { | |||
| 521 | - | t.Fatalf("expected no error, got %v", err) | |||
| 522 | - | } | |||
| 523 | - | if err := orch.RegisterPhase(phase2); err != nil { | |||
| 524 | - | t.Fatalf("expected no error, got %v", err) | |||
| 525 | - | } | |||
| 526 | - | if err := orch.RegisterPhase(phase3); err != nil { | |||
| 527 | - | t.Fatalf("expected no error, got %v", err) | |||
| 528 | - | } | |||
| 529 | - | ||||
| 530 | - | err := orch.ExecuteAll() | |||
| 531 | - | if err == nil { | |||
| 532 | - | t.Error("Expected error from ExecuteAll when phase-2 fails") | |||
| 533 | - | } | |||
| 534 | - | ||||
| 535 | - | // Verify phase-1 completed | |||
| 536 | - | if phase1.Status() != StatusCompleted { | |||
| 537 | - | t.Errorf("phase1 Status = %v, want %v", phase1.Status(), StatusCompleted) | |||
| 538 | - | } | |||
| 539 | - | ||||
| 540 | - | // Verify phase-2 failed | |||
| 541 | - | if phase2.Status() != StatusFailed { | |||
| 542 | - | t.Errorf("phase2 Status = %v, want %v", phase2.Status(), StatusFailed) | |||
| 543 | - | } | |||
| 544 | - | ||||
| 545 | - | // Verify phase-3 never executed | |||
| 546 | - | if phase3.Status() != StatusPending { | |||
| 547 | - | t.Errorf("phase3 Status = %v, want %v (should not execute)", phase3.Status(), StatusPending) | |||
| 548 | - | } | |||
| 549 | - | } | |||
| 550 | - | ||||
| 551 | - | // TestOrchestratorExecuteNext tests ExecuteNext | |||
| 552 | - | func TestOrchestratorExecuteNext(t *testing.T) { | |||
| 553 | - | cfg := config.NewConfig("test") | |||
| 554 | - | orch := NewOrchestrator(cfg, "test.log") | |||
| 555 | - | ||||
| 556 | - | phase := newTestPhase("test", cfg) | |||
| 557 | - | if err := orch.RegisterPhase(phase); err != nil { | |||
| 558 | - | t.Fatalf("expected no error, got %v", err) | |||
| 559 | - | } | |||
| 560 | - | ||||
| 561 | - | err := orch.ExecuteNext() | |||
| 562 | - | if err != nil { | |||
| 563 | - | t.Errorf("ExecuteNext failed: %v", err) | |||
| 564 | - | } | |||
| 565 | - | ||||
| 566 | - | if phase.Status() != StatusCompleted { | |||
| 567 | - | t.Errorf("Status = %v, want %v", phase.Status(), StatusCompleted) | |||
| 568 | - | } | |||
| 569 | - | } | |||
| 570 | - | ||||
| 571 | - | // TestOrchestratorExecuteNextNoPending tests ExecuteNext with no pending phases | |||
| 572 | - | func TestOrchestratorExecuteNextNoPending(t *testing.T) { | |||
| 573 | - | cfg := config.NewConfig("test") | |||
| 574 | - | orch := NewOrchestrator(cfg, "test.log") | |||
| 575 | - | ||||
| 576 | - | phase := newTestPhase("test", cfg) | |||
| 577 | - | phase.SetStatus(StatusCompleted) | |||
| 578 | - | if err := orch.RegisterPhase(phase); err != nil { | |||
| 579 | - | t.Fatalf("expected no error, got %v", err) | |||
| 580 | - | } | |||
| 581 | - | ||||
| 582 | - | err := orch.ExecuteNext() | |||
| 583 | - | if err == nil { | |||
| 584 | - | t.Error("Expected error when no pending phases") | |||
| 585 | - | } | |||
| 586 | - | } | |||
| 587 | - | ||||
| 588 | - | // TestOrchestratorPhaseDurations tests duration tracking | |||
| 589 | - | func TestOrchestratorPhaseDurations(t *testing.T) { | |||
| 590 | - | cfg := config.NewConfig("test") | |||
| 591 | - | orch := NewOrchestrator(cfg, "test.log") | |||
| 592 | - | ||||
| 593 | - | phase := newTestPhase("test", cfg) | |||
| 594 | - | if err := orch.RegisterPhase(phase); err != nil { | |||
| 595 | - | t.Fatalf("expected no error, got %v", err) | |||
| 596 | - | } | |||
| 597 | - | ||||
| 598 | - | err := orch.ExecutePhase(phase) | |||
| 599 | - | if err != nil { | |||
| 600 | - | t.Fatalf("ExecutePhase failed: %v", err) | |||
| 601 | - | } | |||
| 602 | - | ||||
| 603 | - | durations := orch.GetPhaseDurations() | |||
| 604 | - | duration, exists := durations["test"] | |||
| 605 | - | if !exists { | |||
| 606 | - | t.Error("Expected duration for 'test' phase") | |||
| 607 | - | } | |||
| 608 | - | if duration == 0 { | |||
| 609 | - | t.Error("Duration should be greater than 0") | |||
| 610 | - | } | |||
| 611 | - | } | |||
D internal/phases/partitioning.go +0 -342
| @@ -1,342 +0,0 @@ | |||||
| 1 | - | package phases | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "fmt" | |||
| 5 | - | "strings" | |||
| 6 | - | ||||
| 7 | - | "github.com/bnema/archup/internal/config" | |||
| 8 | - | "github.com/bnema/archup/internal/interfaces" | |||
| 9 | - | "github.com/bnema/archup/internal/logger" | |||
| 10 | - | ) | |||
| 11 | - | ||||
| 12 | - | // PartitioningPhase handles disk partitioning, formatting, and mounting | |||
| 13 | - | type PartitioningPhase struct { | |||
| 14 | - | *BasePhase | |||
| 15 | - | sysExec interfaces.SystemExecutor | |||
| 16 | - | } | |||
| 17 | - | ||||
| 18 | - | // NewPartitioningPhase creates a new partitioning phase | |||
| 19 | - | func NewPartitioningPhase(cfg *config.Config, log *logger.Logger, sysExec interfaces.SystemExecutor) *PartitioningPhase { | |||
| 20 | - | return &PartitioningPhase{ | |||
| 21 | - | BasePhase: NewBasePhase("partitioning", "Disk Partitioning", cfg, log), | |||
| 22 | - | sysExec: sysExec, | |||
| 23 | - | } | |||
| 24 | - | } | |||
| 25 | - | ||||
| 26 | - | // PreCheck validates partitioning prerequisites | |||
| 27 | - | func (p *PartitioningPhase) PreCheck() error { | |||
| 28 | - | if p.config.TargetDisk == "" { | |||
| 29 | - | return fmt.Errorf("target disk not selected") | |||
| 30 | - | } | |||
| 31 | - | ||||
| 32 | - | return nil | |||
| 33 | - | } | |||
| 34 | - | ||||
| 35 | - | // Execute runs the partitioning phase | |||
| 36 | - | func (p *PartitioningPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult { | |||
| 37 | - | p.SendProgress(progressChan, "Starting partitioning...", 1, 5) | |||
| 38 | - | ||||
| 39 | - | // Step 1: Wipe and partition | |||
| 40 | - | if err := p.createPartitions(progressChan); err != nil { | |||
| 41 | - | p.SendError(progressChan, err) | |||
| 42 | - | return PhaseResult{Success: false, Error: err} | |||
| 43 | - | } | |||
| 44 | - | ||||
| 45 | - | p.SendProgress(progressChan, "Formatting partitions...", 2, 5) | |||
| 46 | - | ||||
| 47 | - | // Step 2: Format partitions | |||
| 48 | - | if err := p.formatPartitions(progressChan); err != nil { | |||
| 49 | - | p.SendError(progressChan, err) | |||
| 50 | - | return PhaseResult{Success: false, Error: err} | |||
| 51 | - | } | |||
| 52 | - | ||||
| 53 | - | p.SendProgress(progressChan, "Creating btrfs subvolumes...", 3, 5) | |||
| 54 | - | ||||
| 55 | - | // Step 3: Create subvolumes | |||
| 56 | - | if err := p.createSubvolumes(progressChan); err != nil { | |||
| 57 | - | p.SendError(progressChan, err) | |||
| 58 | - | return PhaseResult{Success: false, Error: err} | |||
| 59 | - | } | |||
| 60 | - | ||||
| 61 | - | p.SendProgress(progressChan, "Mounting filesystems...", 4, 5) | |||
| 62 | - | ||||
| 63 | - | // Step 4: Mount | |||
| 64 | - | if err := p.mountFilesystems(progressChan); err != nil { | |||
| 65 | - | p.SendError(progressChan, err) | |||
| 66 | - | return PhaseResult{Success: false, Error: err} | |||
| 67 | - | } | |||
| 68 | - | ||||
| 69 | - | p.SendProgress(progressChan, "Partitioning complete", 5, 5) | |||
| 70 | - | p.SendComplete(progressChan, "Disk prepared successfully") | |||
| 71 | - | ||||
| 72 | - | return PhaseResult{Success: true, Message: "Partitioning complete"} | |||
| 73 | - | } | |||
| 74 | - | ||||
| 75 | - | // createPartitions wipes disk and creates GPT partitions | |||
| 76 | - | func (p *PartitioningPhase) createPartitions(progressChan chan<- ProgressUpdate) error { | |||
| 77 | - | disk := p.config.TargetDisk | |||
| 78 | - | p.SendOutput(progressChan, fmt.Sprintf("Wiping disk %s...", disk)) | |||
| 79 | - | ||||
| 80 | - | // Wipe filesystem signatures | |||
| 81 | - | result := p.logger.ExecCommand("wipefs", "-af", disk) | |||
| 82 | - | if result.ExitCode != 0 { | |||
| 83 | - | return fmt.Errorf("wipefs failed: %w", result.Error) | |||
| 84 | - | } | |||
| 85 | - | ||||
| 86 | - | // Zap GPT and MBR | |||
| 87 | - | result = p.logger.ExecCommand("sgdisk", "--zap-all", disk) | |||
| 88 | - | if result.ExitCode != 0 { | |||
| 89 | - | return fmt.Errorf("sgdisk zap failed: %w", result.Error) | |||
| 90 | - | } | |||
| 91 | - | ||||
| 92 | - | p.SendOutput(progressChan, "Creating GPT partition table...") | |||
| 93 | - | ||||
| 94 | - | // Create partitions: 4GB EFI + remaining ROOT (4GB recommended for limine-snapper-sync) | |||
| 95 | - | result = p.logger.ExecCommand("sgdisk", | |||
| 96 | - | "--clear", | |||
| 97 | - | "--new=1:0:+4G", "--typecode=1:ef00", "--change-name=1:EFI", | |||
| 98 | - | "--new=2:0:0", "--typecode=2:8300", "--change-name=2:ROOT", | |||
| 99 | - | disk) | |||
| 100 | - | ||||
| 101 | - | if result.ExitCode != 0 { | |||
| 102 | - | return fmt.Errorf("sgdisk partition creation failed: %w", result.Error) | |||
| 103 | - | } | |||
| 104 | - | ||||
| 105 | - | // Inform kernel of partition table changes | |||
| 106 | - | result = p.logger.ExecCommand("partprobe", disk) | |||
| 107 | - | if result.ExitCode != 0 { | |||
| 108 | - | return fmt.Errorf("partprobe failed: %w", result.Error) | |||
| 109 | - | } | |||
| 110 | - | ||||
| 111 | - | // Determine partition naming (nvme vs sata/ssd) | |||
| 112 | - | var efiPart, rootPart string | |||
| 113 | - | switch { | |||
| 114 | - | case strings.Contains(disk, "nvme"): | |||
| 115 | - | efiPart = fmt.Sprintf("%sp1", disk) | |||
| 116 | - | rootPart = fmt.Sprintf("%sp2", disk) | |||
| 117 | - | default: | |||
| 118 | - | efiPart = fmt.Sprintf("%s1", disk) | |||
| 119 | - | rootPart = fmt.Sprintf("%s2", disk) | |||
| 120 | - | } | |||
| 121 | - | ||||
| 122 | - | p.config.EFIPartition = efiPart | |||
| 123 | - | p.config.RootPartition = rootPart | |||
| 124 | - | ||||
| 125 | - | p.SendOutput(progressChan, fmt.Sprintf("[OK] EFI: %s (4GB)", efiPart)) | |||
| 126 | - | p.SendOutput(progressChan, fmt.Sprintf("[OK] Root: %s", rootPart)) | |||
| 127 | - | ||||
| 128 | - | return nil | |||
| 129 | - | } | |||
| 130 | - | ||||
| 131 | - | // formatPartitions formats EFI and ROOT partitions | |||
| 132 | - | func (p *PartitioningPhase) formatPartitions(progressChan chan<- ProgressUpdate) error { | |||
| 133 | - | // Format EFI as FAT32 | |||
| 134 | - | p.SendOutput(progressChan, "Formatting EFI partition...") | |||
| 135 | - | ||||
| 136 | - | result := p.logger.ExecCommand("wipefs", "-af", p.config.EFIPartition) | |||
| 137 | - | if result.ExitCode != 0 { | |||
| 138 | - | return fmt.Errorf("wipefs EFI failed: %w", result.Error) | |||
| 139 | - | } | |||
| 140 | - | ||||
| 141 | - | result = p.logger.ExecCommand("mkfs.fat", "-F32", "-n", "EFI", p.config.EFIPartition) | |||
| 142 | - | if result.ExitCode != 0 { | |||
| 143 | - | return fmt.Errorf("mkfs.fat failed: %w", result.Error) | |||
| 144 | - | } | |||
| 145 | - | ||||
| 146 | - | p.SendOutput(progressChan, "[OK] EFI formatted as FAT32") | |||
| 147 | - | ||||
| 148 | - | // Handle root partition with optional encryption | |||
| 149 | - | switch p.config.EncryptionType { | |||
| 150 | - | case config.EncryptionLUKS, config.EncryptionLUKSLVM: | |||
| 151 | - | return p.formatEncryptedRoot(progressChan) | |||
| 152 | - | default: | |||
| 153 | - | return p.formatPlainRoot(progressChan) | |||
| 154 | - | } | |||
| 155 | - | } | |||
| 156 | - | ||||
| 157 | - | // formatEncryptedRoot sets up LUKS encryption on root partition | |||
| 158 | - | func (p *PartitioningPhase) formatEncryptedRoot(progressChan chan<- ProgressUpdate) error { | |||
| 159 | - | p.SendOutput(progressChan, "Setting up LUKS encryption...") | |||
| 160 | - | ||||
| 161 | - | // Use user password for encryption (as per preflight) | |||
| 162 | - | password := p.config.UserPassword | |||
| 163 | - | if password == "" { | |||
| 164 | - | return fmt.Errorf("password required for encryption") | |||
| 165 | - | } | |||
| 166 | - | ||||
| 167 | - | // Wipe root partition | |||
| 168 | - | result := p.logger.ExecCommand("wipefs", "-af", p.config.RootPartition) | |||
| 169 | - | if result.ExitCode != 0 { | |||
| 170 | - | return fmt.Errorf("wipefs root failed: %w", result.Error) | |||
| 171 | - | } | |||
| 172 | - | ||||
| 173 | - | // Create LUKS container with Argon2id | |||
| 174 | - | p.SendOutput(progressChan, "Creating LUKS container (this may take a moment)...") | |||
| 175 | - | ||||
| 176 | - | // Note: In real implementation, pipe password securely via stdin | |||
| 177 | - | // For now, documenting the approach | |||
| 178 | - | result = p.sysExec.RunSimple("sh", "-c", | |||
| 179 | - | fmt.Sprintf("printf '%%s' '%s' | cryptsetup luksFormat --type luks2 --batch-mode --pbkdf argon2id --iter-time 2000 --label ARCHUP_LUKS --key-file - %s", | |||
| 180 | - | password, p.config.RootPartition)) | |||
| 181 | - | ||||
| 182 | - | if result.ExitCode != 0 { | |||
| 183 | - | return fmt.Errorf("luksFormat failed: %w", result.Error) | |||
| 184 | - | } | |||
| 185 | - | ||||
| 186 | - | // Open LUKS container | |||
| 187 | - | result = p.sysExec.RunSimple("sh", "-c", | |||
| 188 | - | fmt.Sprintf("printf '%%s' '%s' | cryptsetup open --key-file=- %s cryptroot", | |||
| 189 | - | password, p.config.RootPartition)) | |||
| 190 | - | ||||
| 191 | - | if result.ExitCode != 0 { | |||
| 192 | - | return fmt.Errorf("cryptsetup open failed: %w", result.Error) | |||
| 193 | - | } | |||
| 194 | - | ||||
| 195 | - | p.config.CryptDevice = "/dev/mapper/cryptroot" | |||
| 196 | - | p.SendOutput(progressChan, "[OK] LUKS container created and opened") | |||
| 197 | - | ||||
| 198 | - | // Format encrypted device with btrfs | |||
| 199 | - | result = p.logger.ExecCommand("mkfs.btrfs", "-f", "-L", "ROOT", p.config.CryptDevice) | |||
| 200 | - | if result.ExitCode != 0 { | |||
| 201 | - | return fmt.Errorf("mkfs.btrfs on encrypted device failed: %w", result.Error) | |||
| 202 | - | } | |||
| 203 | - | ||||
| 204 | - | p.SendOutput(progressChan, "[OK] Encrypted partition formatted as btrfs") | |||
| 205 | - | return nil | |||
| 206 | - | } | |||
| 207 | - | ||||
| 208 | - | // formatPlainRoot formats root partition without encryption | |||
| 209 | - | func (p *PartitioningPhase) formatPlainRoot(progressChan chan<- ProgressUpdate) error { | |||
| 210 | - | p.SendOutput(progressChan, "Formatting root partition...") | |||
| 211 | - | ||||
| 212 | - | result := p.logger.ExecCommand("mkfs.btrfs", "-f", "-L", "ROOT", p.config.RootPartition) | |||
| 213 | - | if result.ExitCode != 0 { | |||
| 214 | - | return fmt.Errorf("mkfs.btrfs failed: %w", result.Error) | |||
| 215 | - | } | |||
| 216 | - | ||||
| 217 | - | p.SendOutput(progressChan, "[OK] Root formatted as btrfs") | |||
| 218 | - | return nil | |||
| 219 | - | } | |||
| 220 | - | ||||
| 221 | - | // createSubvolumes creates btrfs subvolumes | |||
| 222 | - | func (p *PartitioningPhase) createSubvolumes(progressChan chan<- ProgressUpdate) error { | |||
| 223 | - | // Determine device to mount | |||
| 224 | - | var device string | |||
| 225 | - | switch p.config.EncryptionType { | |||
| 226 | - | case config.EncryptionLUKS, config.EncryptionLUKSLVM: | |||
| 227 | - | device = p.config.CryptDevice | |||
| 228 | - | default: | |||
| 229 | - | device = p.config.RootPartition | |||
| 230 | - | } | |||
| 231 | - | ||||
| 232 | - | // Mount temporarily | |||
| 233 | - | result := p.logger.ExecCommand("mount", device, "/mnt") | |||
| 234 | - | if result.ExitCode != 0 { | |||
| 235 | - | return fmt.Errorf("temporary mount failed: %w", result.Error) | |||
| 236 | - | } | |||
| 237 | - | ||||
| 238 | - | // Create @ subvolume | |||
| 239 | - | result = p.logger.ExecCommand("btrfs", "subvolume", "create", "/mnt/@") | |||
| 240 | - | if result.ExitCode != 0 { | |||
| 241 | - | p.sysExec.RunSimple("umount", "/mnt") // Cleanup | |||
| 242 | - | return fmt.Errorf("@ subvolume creation failed: %w", result.Error) | |||
| 243 | - | } | |||
| 244 | - | ||||
| 245 | - | // Create @home subvolume | |||
| 246 | - | result = p.logger.ExecCommand("btrfs", "subvolume", "create", "/mnt/@home") | |||
| 247 | - | if result.ExitCode != 0 { | |||
| 248 | - | p.sysExec.RunSimple("umount", "/mnt") // Cleanup | |||
| 249 | - | return fmt.Errorf("@home subvolume creation failed: %w", result.Error) | |||
| 250 | - | } | |||
| 251 | - | ||||
| 252 | - | // Unmount | |||
| 253 | - | result = p.logger.ExecCommand("umount", "/mnt") | |||
| 254 | - | if result.ExitCode != 0 { | |||
| 255 | - | return fmt.Errorf("unmount after subvolume creation failed: %w", result.Error) | |||
| 256 | - | } | |||
| 257 | - | ||||
| 258 | - | p.SendOutput(progressChan, "[OK] Btrfs subvolumes created: @ and @home") | |||
| 259 | - | return nil | |||
| 260 | - | } | |||
| 261 | - | ||||
| 262 | - | // mountFilesystems mounts all filesystems to /mnt | |||
| 263 | - | func (p *PartitioningPhase) mountFilesystems(progressChan chan<- ProgressUpdate) error { | |||
| 264 | - | // Determine device | |||
| 265 | - | var device string | |||
| 266 | - | switch p.config.EncryptionType { | |||
| 267 | - | case config.EncryptionLUKS, config.EncryptionLUKSLVM: | |||
| 268 | - | device = p.config.CryptDevice | |||
| 269 | - | default: | |||
| 270 | - | device = p.config.RootPartition | |||
| 271 | - | } | |||
| 272 | - | ||||
| 273 | - | // Mount @ subvolume as root | |||
| 274 | - | result := p.logger.ExecCommand("mount", "-o", "noatime,compress=zstd,subvol=@", device, "/mnt") | |||
| 275 | - | if result.ExitCode != 0 { | |||
| 276 | - | return fmt.Errorf("mount @ failed: %w", result.Error) | |||
| 277 | - | } | |||
| 278 | - | ||||
| 279 | - | p.SendOutput(progressChan, "[OK] Mounted @ to /mnt") | |||
| 280 | - | ||||
| 281 | - | // Create home directory | |||
| 282 | - | result = p.logger.ExecCommand("mkdir", "-p", "/mnt/home") | |||
| 283 | - | if result.ExitCode != 0 { | |||
| 284 | - | return fmt.Errorf("mkdir /mnt/home failed: %w", result.Error) | |||
| 285 | - | } | |||
| 286 | - | ||||
| 287 | - | // Mount @home subvolume | |||
| 288 | - | result = p.logger.ExecCommand("mount", "-o", "noatime,compress=zstd,subvol=@home", device, "/mnt/home") | |||
| 289 | - | if result.ExitCode != 0 { | |||
| 290 | - | return fmt.Errorf("mount @home failed: %w", result.Error) | |||
| 291 | - | } | |||
| 292 | - | ||||
| 293 | - | p.SendOutput(progressChan, "[OK] Mounted @home to /mnt/home") | |||
| 294 | - | ||||
| 295 | - | // Create and mount EFI | |||
| 296 | - | result = p.logger.ExecCommand("mkdir", "-p", "/mnt/boot") | |||
| 297 | - | if result.ExitCode != 0 { | |||
| 298 | - | return fmt.Errorf("mkdir /mnt/boot failed: %w", result.Error) | |||
| 299 | - | } | |||
| 300 | - | ||||
| 301 | - | result = p.logger.ExecCommand("mount", p.config.EFIPartition, "/mnt/boot") | |||
| 302 | - | if result.ExitCode != 0 { | |||
| 303 | - | return fmt.Errorf("mount EFI failed: %w", result.Error) | |||
| 304 | - | } | |||
| 305 | - | ||||
| 306 | - | p.SendOutput(progressChan, "[OK] Mounted EFI to /mnt/boot") | |||
| 307 | - | return nil | |||
| 308 | - | } | |||
| 309 | - | ||||
| 310 | - | // PostCheck validates mounts | |||
| 311 | - | func (p *PartitioningPhase) PostCheck() error { | |||
| 312 | - | // Verify /mnt is mounted | |||
| 313 | - | result := p.sysExec.RunSimple("mountpoint", "-q", "/mnt") | |||
| 314 | - | if result.ExitCode != 0 { | |||
| 315 | - | return fmt.Errorf("/mnt is not mounted") | |||
| 316 | - | } | |||
| 317 | - | ||||
| 318 | - | return p.config.Save() | |||
| 319 | - | } | |||
| 320 | - | ||||
| 321 | - | // Rollback unmounts filesystems | |||
| 322 | - | func (p *PartitioningPhase) Rollback() error { | |||
| 323 | - | // Unmount in reverse order | |||
| 324 | - | p.sysExec.RunSimple("umount", "/mnt/boot") | |||
| 325 | - | p.sysExec.RunSimple("umount", "/mnt/home") | |||
| 326 | - | p.sysExec.RunSimple("umount", "/mnt") | |||
| 327 | - | ||||
| 328 | - | // Close LUKS if encrypted | |||
| 329 | - | switch p.config.EncryptionType { | |||
| 330 | - | case config.EncryptionLUKS, config.EncryptionLUKSLVM: | |||
| 331 | - | if p.config.CryptDevice != "" { | |||
| 332 | - | p.sysExec.RunSimple("cryptsetup", "close", "cryptroot") | |||
| 333 | - | } | |||
| 334 | - | } | |||
| 335 | - | ||||
| 336 | - | return nil | |||
| 337 | - | } | |||
| 338 | - | ||||
| 339 | - | // CanSkip returns false - partitioning cannot be skipped | |||
| 340 | - | func (p *PartitioningPhase) CanSkip() bool { | |||
| 341 | - | return false | |||
| 342 | - | } | |||
D internal/phases/partitioning_test.go +0 -560
| @@ -1,560 +0,0 @@ | |||||
| 1 | - | package phases | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "fmt" | |||
| 5 | - | "path/filepath" | |||
| 6 | - | "reflect" | |||
| 7 | - | "testing" | |||
| 8 | - | ||||
| 9 | - | "github.com/bnema/archup/internal/config" | |||
| 10 | - | "github.com/bnema/archup/internal/interfaces/mocks" | |||
| 11 | - | "github.com/bnema/archup/internal/logger" | |||
| 12 | - | "github.com/bnema/archup/internal/system" | |||
| 13 | - | "go.uber.org/mock/gomock" | |||
| 14 | - | ) | |||
| 15 | - | ||||
| 16 | - | // TestPartitioningPhasePreCheck tests partitioning prerequisites | |||
| 17 | - | func TestPartitioningPhasePreCheck(t *testing.T) { | |||
| 18 | - | tests := []struct { | |||
| 19 | - | name string | |||
| 20 | - | targetDisk string | |||
| 21 | - | wantErr bool | |||
| 22 | - | errContains string | |||
| 23 | - | }{ | |||
| 24 | - | { | |||
| 25 | - | name: "Valid target disk", | |||
| 26 | - | targetDisk: "/dev/sda", | |||
| 27 | - | wantErr: false, | |||
| 28 | - | errContains: "", | |||
| 29 | - | }, | |||
| 30 | - | { | |||
| 31 | - | name: "Missing target disk", | |||
| 32 | - | targetDisk: "", | |||
| 33 | - | wantErr: true, | |||
| 34 | - | errContains: "target disk not selected", | |||
| 35 | - | }, | |||
| 36 | - | { | |||
| 37 | - | name: "NVMe target disk", | |||
| 38 | - | targetDisk: "/dev/nvme0n1", | |||
| 39 | - | wantErr: false, | |||
| 40 | - | errContains: "", | |||
| 41 | - | }, | |||
| 42 | - | } | |||
| 43 | - | ||||
| 44 | - | for _, tt := range tests { | |||
| 45 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 46 | - | ctrl := gomock.NewController(t) | |||
| 47 | - | defer ctrl.Finish() | |||
| 48 | - | ||||
| 49 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 50 | - | ||||
| 51 | - | tmpDir := t.TempDir() | |||
| 52 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 53 | - | log, err := logger.New(logPath, false) | |||
| 54 | - | if err != nil { | |||
| 55 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 56 | - | } | |||
| 57 | - | defer func() { | |||
| 58 | - | if err := log.Close(); err != nil { | |||
| 59 | - | t.Fatalf("failed to close log: %v", err) | |||
| 60 | - | } | |||
| 61 | - | }() | |||
| 62 | - | ||||
| 63 | - | cfg := config.NewConfig("test") | |||
| 64 | - | cfg.TargetDisk = tt.targetDisk | |||
| 65 | - | ||||
| 66 | - | phase := NewPartitioningPhase(cfg, log, mockSysExec) | |||
| 67 | - | ||||
| 68 | - | err = phase.PreCheck() | |||
| 69 | - | if (err != nil) != tt.wantErr { | |||
| 70 | - | t.Errorf("PreCheck() error = %v, wantErr %v", err, tt.wantErr) | |||
| 71 | - | return | |||
| 72 | - | } | |||
| 73 | - | if tt.wantErr && err != nil { | |||
| 74 | - | if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) { | |||
| 75 | - | t.Errorf("PreCheck() error = %q, should contain %q", errMsg, tt.errContains) | |||
| 76 | - | } | |||
| 77 | - | } | |||
| 78 | - | }) | |||
| 79 | - | } | |||
| 80 | - | } | |||
| 81 | - | ||||
| 82 | - | // TestPartitioningPhasePartitionNaming tests partition naming logic | |||
| 83 | - | func TestPartitioningPhasePartitionNaming(t *testing.T) { | |||
| 84 | - | tests := []struct { | |||
| 85 | - | name string | |||
| 86 | - | targetDisk string | |||
| 87 | - | expectedEFI string | |||
| 88 | - | expectedRoot string | |||
| 89 | - | }{ | |||
| 90 | - | { | |||
| 91 | - | name: "NVMe disk naming", | |||
| 92 | - | targetDisk: "/dev/nvme0n1", | |||
| 93 | - | expectedEFI: "/dev/nvme0n1p1", | |||
| 94 | - | expectedRoot: "/dev/nvme0n1p2", | |||
| 95 | - | }, | |||
| 96 | - | { | |||
| 97 | - | name: "SATA disk naming", | |||
| 98 | - | targetDisk: "/dev/sda", | |||
| 99 | - | expectedEFI: "/dev/sda1", | |||
| 100 | - | expectedRoot: "/dev/sda2", | |||
| 101 | - | }, | |||
| 102 | - | { | |||
| 103 | - | name: "VirtIO disk naming", | |||
| 104 | - | targetDisk: "/dev/vda", | |||
| 105 | - | expectedEFI: "/dev/vda1", | |||
| 106 | - | expectedRoot: "/dev/vda2", | |||
| 107 | - | }, | |||
| 108 | - | } | |||
| 109 | - | ||||
| 110 | - | for _, tt := range tests { | |||
| 111 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 112 | - | ctrl := gomock.NewController(t) | |||
| 113 | - | defer ctrl.Finish() | |||
| 114 | - | ||||
| 115 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 116 | - | ||||
| 117 | - | tmpDir := t.TempDir() | |||
| 118 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 119 | - | log, err := logger.New(logPath, false) | |||
| 120 | - | if err != nil { | |||
| 121 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 122 | - | } | |||
| 123 | - | defer func() { | |||
| 124 | - | if err := log.Close(); err != nil { | |||
| 125 | - | t.Fatalf("failed to close log: %v", err) | |||
| 126 | - | } | |||
| 127 | - | }() | |||
| 128 | - | ||||
| 129 | - | cfg := config.NewConfig("test") | |||
| 130 | - | cfg.TargetDisk = tt.targetDisk | |||
| 131 | - | ||||
| 132 | - | phase := NewPartitioningPhase(cfg, log, mockSysExec) | |||
| 133 | - | ||||
| 134 | - | // Verify partition names are set correctly in createPartitions logic | |||
| 135 | - | // This is done by checking the naming convention | |||
| 136 | - | phase.config.EFIPartition = tt.expectedEFI | |||
| 137 | - | phase.config.RootPartition = tt.expectedRoot | |||
| 138 | - | ||||
| 139 | - | if phase.config.EFIPartition != tt.expectedEFI { | |||
| 140 | - | t.Errorf("EFI partition = %q, want %q", phase.config.EFIPartition, tt.expectedEFI) | |||
| 141 | - | } | |||
| 142 | - | if phase.config.RootPartition != tt.expectedRoot { | |||
| 143 | - | t.Errorf("Root partition = %q, want %q", phase.config.RootPartition, tt.expectedRoot) | |||
| 144 | - | } | |||
| 145 | - | }) | |||
| 146 | - | } | |||
| 147 | - | } | |||
| 148 | - | ||||
| 149 | - | // TestPartitioningPhaseFormatRoot tests format method branching | |||
| 150 | - | func TestPartitioningPhaseFormatRoot(t *testing.T) { | |||
| 151 | - | tests := []struct { | |||
| 152 | - | name string | |||
| 153 | - | encryptionType string | |||
| 154 | - | expectedCallType string // "LUKS" or "Plain" | |||
| 155 | - | }{ | |||
| 156 | - | { | |||
| 157 | - | name: "Unencrypted root", | |||
| 158 | - | encryptionType: config.EncryptionNone, | |||
| 159 | - | expectedCallType: "Plain", | |||
| 160 | - | }, | |||
| 161 | - | { | |||
| 162 | - | name: "LUKS encrypted root", | |||
| 163 | - | encryptionType: config.EncryptionLUKS, | |||
| 164 | - | expectedCallType: "LUKS", | |||
| 165 | - | }, | |||
| 166 | - | { | |||
| 167 | - | name: "LUKS with LVM", | |||
| 168 | - | encryptionType: config.EncryptionLUKSLVM, | |||
| 169 | - | expectedCallType: "LUKS", | |||
| 170 | - | }, | |||
| 171 | - | } | |||
| 172 | - | ||||
| 173 | - | for _, tt := range tests { | |||
| 174 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 175 | - | ctrl := gomock.NewController(t) | |||
| 176 | - | defer ctrl.Finish() | |||
| 177 | - | ||||
| 178 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 179 | - | ||||
| 180 | - | tmpDir := t.TempDir() | |||
| 181 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 182 | - | log, err := logger.New(logPath, false) | |||
| 183 | - | if err != nil { | |||
| 184 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 185 | - | } | |||
| 186 | - | defer func() { | |||
| 187 | - | if err := log.Close(); err != nil { | |||
| 188 | - | t.Fatalf("failed to close log: %v", err) | |||
| 189 | - | } | |||
| 190 | - | }() | |||
| 191 | - | ||||
| 192 | - | cfg := config.NewConfig("test") | |||
| 193 | - | cfg.TargetDisk = "/dev/sda" | |||
| 194 | - | cfg.EFIPartition = "/dev/sda1" | |||
| 195 | - | cfg.RootPartition = "/dev/sda2" | |||
| 196 | - | cfg.EncryptionType = tt.encryptionType | |||
| 197 | - | cfg.UserPassword = "password123" | |||
| 198 | - | ||||
| 199 | - | phase := NewPartitioningPhase(cfg, log, mockSysExec) | |||
| 200 | - | ||||
| 201 | - | // Verify encryption type branching | |||
| 202 | - | if phase.config.EncryptionType != tt.encryptionType { | |||
| 203 | - | t.Errorf("Encryption type = %q, want %q", phase.config.EncryptionType, tt.encryptionType) | |||
| 204 | - | } | |||
| 205 | - | }) | |||
| 206 | - | } | |||
| 207 | - | } | |||
| 208 | - | ||||
| 209 | - | // TestPartitioningPhaseEncryptionValidation tests encryption password requirement | |||
| 210 | - | func TestPartitioningPhaseEncryptionValidation(t *testing.T) { | |||
| 211 | - | tests := []struct { | |||
| 212 | - | name string | |||
| 213 | - | encryptionType string | |||
| 214 | - | password string | |||
| 215 | - | shouldRequire bool | |||
| 216 | - | }{ | |||
| 217 | - | { | |||
| 218 | - | name: "Unencrypted doesn't require password", | |||
| 219 | - | encryptionType: config.EncryptionNone, | |||
| 220 | - | password: "", | |||
| 221 | - | shouldRequire: false, | |||
| 222 | - | }, | |||
| 223 | - | { | |||
| 224 | - | name: "LUKS requires password", | |||
| 225 | - | encryptionType: config.EncryptionLUKS, | |||
| 226 | - | password: "secure123", | |||
| 227 | - | shouldRequire: true, | |||
| 228 | - | }, | |||
| 229 | - | { | |||
| 230 | - | name: "LUKS with empty password fails validation", | |||
| 231 | - | encryptionType: config.EncryptionLUKS, | |||
| 232 | - | password: "", | |||
| 233 | - | shouldRequire: true, // Should have required one | |||
| 234 | - | }, | |||
| 235 | - | } | |||
| 236 | - | ||||
| 237 | - | for _, tt := range tests { | |||
| 238 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 239 | - | ctrl := gomock.NewController(t) | |||
| 240 | - | defer ctrl.Finish() | |||
| 241 | - | ||||
| 242 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 243 | - | ||||
| 244 | - | tmpDir := t.TempDir() | |||
| 245 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 246 | - | log, err := logger.New(logPath, false) | |||
| 247 | - | if err != nil { | |||
| 248 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 249 | - | } | |||
| 250 | - | defer func() { | |||
| 251 | - | if err := log.Close(); err != nil { | |||
| 252 | - | t.Fatalf("failed to close log: %v", err) | |||
| 253 | - | } | |||
| 254 | - | }() | |||
| 255 | - | ||||
| 256 | - | cfg := config.NewConfig("test") | |||
| 257 | - | cfg.TargetDisk = "/dev/sda" | |||
| 258 | - | cfg.EFIPartition = "/dev/sda1" | |||
| 259 | - | cfg.RootPartition = "/dev/sda2" | |||
| 260 | - | cfg.EncryptionType = tt.encryptionType | |||
| 261 | - | cfg.UserPassword = tt.password | |||
| 262 | - | ||||
| 263 | - | phase := NewPartitioningPhase(cfg, log, mockSysExec) | |||
| 264 | - | ||||
| 265 | - | // Validate password requirement | |||
| 266 | - | if tt.shouldRequire && tt.encryptionType != config.EncryptionNone { | |||
| 267 | - | if phase.config.UserPassword == "" && tt.password == "" { | |||
| 268 | - | // Empty password for encryption - should fail in actual execution | |||
| 269 | - | if phase.config.EncryptionType != config.EncryptionNone { | |||
| 270 | - | // Password is required for encryption | |||
| 271 | - | t.Log("password required for encryption") | |||
| 272 | - | } | |||
| 273 | - | } | |||
| 274 | - | } | |||
| 275 | - | }) | |||
| 276 | - | } | |||
| 277 | - | } | |||
| 278 | - | ||||
| 279 | - | // TestPartitioningPhasePostCheck tests mount point validation | |||
| 280 | - | func TestPartitioningPhasePostCheck(t *testing.T) { | |||
| 281 | - | tests := []struct { | |||
| 282 | - | name string | |||
| 283 | - | setupMocks func(*mocks.MockSystemExecutor) | |||
| 284 | - | wantErr bool | |||
| 285 | - | errContains string | |||
| 286 | - | }{ | |||
| 287 | - | { | |||
| 288 | - | name: "/mnt is mounted - postcheck passes", | |||
| 289 | - | setupMocks: func(mockSysExec *mocks.MockSystemExecutor) { | |||
| 290 | - | mockSysExec.EXPECT().RunSimple("mountpoint", "-q", "/mnt").Return(system.CommandResult{ExitCode: 0}).Times(1) | |||
| 291 | - | }, | |||
| 292 | - | wantErr: false, | |||
| 293 | - | }, | |||
| 294 | - | { | |||
| 295 | - | name: "/mnt not mounted - postcheck fails", | |||
| 296 | - | setupMocks: func(mockSysExec *mocks.MockSystemExecutor) { | |||
| 297 | - | mockSysExec.EXPECT().RunSimple("mountpoint", "-q", "/mnt").Return(system.CommandResult{ExitCode: 32}).Times(1) | |||
| 298 | - | }, | |||
| 299 | - | wantErr: true, | |||
| 300 | - | errContains: "is not mounted", | |||
| 301 | - | }, | |||
| 302 | - | } | |||
| 303 | - | ||||
| 304 | - | for _, tt := range tests { | |||
| 305 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 306 | - | ctrl := gomock.NewController(t) | |||
| 307 | - | defer ctrl.Finish() | |||
| 308 | - | ||||
| 309 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 310 | - | ||||
| 311 | - | tt.setupMocks(mockSysExec) | |||
| 312 | - | ||||
| 313 | - | tmpDir := t.TempDir() | |||
| 314 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 315 | - | log, err := logger.New(logPath, false) | |||
| 316 | - | if err != nil { | |||
| 317 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 318 | - | } | |||
| 319 | - | defer func() { | |||
| 320 | - | if err := log.Close(); err != nil { | |||
| 321 | - | t.Fatalf("failed to close log: %v", err) | |||
| 322 | - | } | |||
| 323 | - | }() | |||
| 324 | - | ||||
| 325 | - | cfg := config.NewConfig("test") | |||
| 326 | - | cfg.ConfigPath = filepath.Join(tmpDir, "config") | |||
| 327 | - | ||||
| 328 | - | phase := NewPartitioningPhase(cfg, log, mockSysExec) | |||
| 329 | - | ||||
| 330 | - | err = phase.PostCheck() | |||
| 331 | - | if (err != nil) != tt.wantErr { | |||
| 332 | - | t.Errorf("PostCheck() error = %v, wantErr %v", err, tt.wantErr) | |||
| 333 | - | return | |||
| 334 | - | } | |||
| 335 | - | if tt.wantErr && err != nil { | |||
| 336 | - | if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) { | |||
| 337 | - | t.Errorf("PostCheck() error = %q, should contain %q", errMsg, tt.errContains) | |||
| 338 | - | } | |||
| 339 | - | } | |||
| 340 | - | }) | |||
| 341 | - | } | |||
| 342 | - | } | |||
| 343 | - | ||||
| 344 | - | // TestPartitioningPhaseRollback tests rollback cleanup | |||
| 345 | - | func TestPartitioningPhaseRollback(t *testing.T) { | |||
| 346 | - | tests := []struct { | |||
| 347 | - | name string | |||
| 348 | - | setupMocks func(*mocks.MockSystemExecutor) | |||
| 349 | - | encryption string | |||
| 350 | - | cryptDevice string | |||
| 351 | - | wantErr bool | |||
| 352 | - | }{ | |||
| 353 | - | { | |||
| 354 | - | name: "Rollback unencrypted - success", | |||
| 355 | - | setupMocks: func(mockSysExec *mocks.MockSystemExecutor) { | |||
| 356 | - | mockSysExec.EXPECT().RunSimple("umount", "/mnt/boot").Return(system.CommandResult{}).Times(1) | |||
| 357 | - | mockSysExec.EXPECT().RunSimple("umount", "/mnt/home").Return(system.CommandResult{}).Times(1) | |||
| 358 | - | mockSysExec.EXPECT().RunSimple("umount", "/mnt").Return(system.CommandResult{}).Times(1) | |||
| 359 | - | }, | |||
| 360 | - | encryption: config.EncryptionNone, | |||
| 361 | - | cryptDevice: "", | |||
| 362 | - | wantErr: false, | |||
| 363 | - | }, | |||
| 364 | - | { | |||
| 365 | - | name: "Rollback encrypted - closes LUKS", | |||
| 366 | - | setupMocks: func(mockSysExec *mocks.MockSystemExecutor) { | |||
| 367 | - | mockSysExec.EXPECT().RunSimple("umount", "/mnt/boot").Return(system.CommandResult{}).Times(1) | |||
| 368 | - | mockSysExec.EXPECT().RunSimple("umount", "/mnt/home").Return(system.CommandResult{}).Times(1) | |||
| 369 | - | mockSysExec.EXPECT().RunSimple("umount", "/mnt").Return(system.CommandResult{}).Times(1) | |||
| 370 | - | mockSysExec.EXPECT().RunSimple("cryptsetup", "close", "cryptroot").Return(system.CommandResult{}).Times(1) | |||
| 371 | - | }, | |||
| 372 | - | encryption: config.EncryptionLUKS, | |||
| 373 | - | cryptDevice: "/dev/mapper/cryptroot", | |||
| 374 | - | wantErr: false, | |||
| 375 | - | }, | |||
| 376 | - | { | |||
| 377 | - | name: "Rollback umount failure - continues anyway", | |||
| 378 | - | setupMocks: func(mockSysExec *mocks.MockSystemExecutor) { | |||
| 379 | - | mockSysExec.EXPECT().RunSimple("umount", "/mnt/boot").Return(system.CommandResult{ExitCode: 32, Error: fmt.Errorf("not mounted")}).Times(1) | |||
| 380 | - | mockSysExec.EXPECT().RunSimple("umount", "/mnt/home").Return(system.CommandResult{ExitCode: 32}).Times(1) | |||
| 381 | - | mockSysExec.EXPECT().RunSimple("umount", "/mnt").Return(system.CommandResult{ExitCode: 32}).Times(1) | |||
| 382 | - | }, | |||
| 383 | - | encryption: config.EncryptionNone, | |||
| 384 | - | cryptDevice: "", | |||
| 385 | - | wantErr: false, // Rollback doesn't fail on umount errors | |||
| 386 | - | }, | |||
| 387 | - | } | |||
| 388 | - | ||||
| 389 | - | for _, tt := range tests { | |||
| 390 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 391 | - | ctrl := gomock.NewController(t) | |||
| 392 | - | defer ctrl.Finish() | |||
| 393 | - | ||||
| 394 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 395 | - | ||||
| 396 | - | tt.setupMocks(mockSysExec) | |||
| 397 | - | ||||
| 398 | - | tmpDir := t.TempDir() | |||
| 399 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 400 | - | log, err := logger.New(logPath, false) | |||
| 401 | - | if err != nil { | |||
| 402 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 403 | - | } | |||
| 404 | - | defer func() { | |||
| 405 | - | if err := log.Close(); err != nil { | |||
| 406 | - | t.Fatalf("failed to close log: %v", err) | |||
| 407 | - | } | |||
| 408 | - | }() | |||
| 409 | - | ||||
| 410 | - | cfg := config.NewConfig("test") | |||
| 411 | - | cfg.EncryptionType = tt.encryption | |||
| 412 | - | cfg.CryptDevice = tt.cryptDevice | |||
| 413 | - | ||||
| 414 | - | phase := NewPartitioningPhase(cfg, log, mockSysExec) | |||
| 415 | - | ||||
| 416 | - | err = phase.Rollback() | |||
| 417 | - | if (err != nil) != tt.wantErr { | |||
| 418 | - | t.Errorf("Rollback() error = %v, wantErr %v", err, tt.wantErr) | |||
| 419 | - | } | |||
| 420 | - | }) | |||
| 421 | - | } | |||
| 422 | - | } | |||
| 423 | - | ||||
| 424 | - | // TestPartitioningPhaseCanSkip tests the CanSkip method | |||
| 425 | - | func TestPartitioningPhaseCanSkip(t *testing.T) { | |||
| 426 | - | ctrl := gomock.NewController(t) | |||
| 427 | - | defer ctrl.Finish() | |||
| 428 | - | ||||
| 429 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 430 | - | ||||
| 431 | - | tmpDir := t.TempDir() | |||
| 432 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 433 | - | log, err := logger.New(logPath, false) | |||
| 434 | - | if err != nil { | |||
| 435 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 436 | - | } | |||
| 437 | - | defer func() { | |||
| 438 | - | if err := log.Close(); err != nil { | |||
| 439 | - | t.Fatalf("failed to close log: %v", err) | |||
| 440 | - | } | |||
| 441 | - | }() | |||
| 442 | - | ||||
| 443 | - | cfg := config.NewConfig("test") | |||
| 444 | - | phase := NewPartitioningPhase(cfg, log, mockSysExec) | |||
| 445 | - | ||||
| 446 | - | if phase.CanSkip() { | |||
| 447 | - | t.Error("CanSkip() = true, want false") | |||
| 448 | - | } | |||
| 449 | - | } | |||
| 450 | - | ||||
| 451 | - | // TestPartitioningPhaseExecute tests the Execute method structure | |||
| 452 | - | func TestPartitioningPhaseExecute(t *testing.T) { | |||
| 453 | - | ctrl := gomock.NewController(t) | |||
| 454 | - | defer ctrl.Finish() | |||
| 455 | - | ||||
| 456 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 457 | - | ||||
| 458 | - | tmpDir := t.TempDir() | |||
| 459 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 460 | - | log, err := logger.New(logPath, false) | |||
| 461 | - | if err != nil { | |||
| 462 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 463 | - | } | |||
| 464 | - | defer func() { | |||
| 465 | - | if err := log.Close(); err != nil { | |||
| 466 | - | t.Fatalf("failed to close log: %v", err) | |||
| 467 | - | } | |||
| 468 | - | }() | |||
| 469 | - | ||||
| 470 | - | cfg := config.NewConfig("test") | |||
| 471 | - | cfg.TargetDisk = "/dev/sda" | |||
| 472 | - | ||||
| 473 | - | phase := NewPartitioningPhase(cfg, log, mockSysExec) | |||
| 474 | - | ||||
| 475 | - | progressChan := make(chan ProgressUpdate, 100) | |||
| 476 | - | ||||
| 477 | - | // Verify phase can be created and Execute returns a PhaseResult | |||
| 478 | - | // Note: Full Execute testing is complex due to logger.ExecCommand() calls | |||
| 479 | - | // The return type and structure are validated here | |||
| 480 | - | result := phase.Execute(progressChan) | |||
| 481 | - | ||||
| 482 | - | // Verify result has the expected type | |||
| 483 | - | if reflect.TypeOf(result) != reflect.TypeOf(PhaseResult{}) { | |||
| 484 | - | t.Errorf("Execute() returned unexpected type: %T", result) | |||
| 485 | - | } | |||
| 486 | - | } | |||
| 487 | - | ||||
| 488 | - | // TestPartitioningPhaseDeviceHandling tests both encrypted and unencrypted device handling | |||
| 489 | - | func TestPartitioningPhaseDeviceHandling(t *testing.T) { | |||
| 490 | - | tests := []struct { | |||
| 491 | - | name string | |||
| 492 | - | encryptionType string | |||
| 493 | - | cryptDevice string | |||
| 494 | - | rootPartition string | |||
| 495 | - | expectedDevice string | |||
| 496 | - | }{ | |||
| 497 | - | { | |||
| 498 | - | name: "Unencrypted uses root partition", | |||
| 499 | - | encryptionType: config.EncryptionNone, | |||
| 500 | - | cryptDevice: "", | |||
| 501 | - | rootPartition: "/dev/sda2", | |||
| 502 | - | expectedDevice: "/dev/sda2", | |||
| 503 | - | }, | |||
| 504 | - | { | |||
| 505 | - | name: "Encrypted uses crypt device", | |||
| 506 | - | encryptionType: config.EncryptionLUKS, | |||
| 507 | - | cryptDevice: "/dev/mapper/cryptroot", | |||
| 508 | - | rootPartition: "/dev/sda2", | |||
| 509 | - | expectedDevice: "/dev/mapper/cryptroot", | |||
| 510 | - | }, | |||
| 511 | - | { | |||
| 512 | - | name: "LUKS+LVM uses crypt device", | |||
| 513 | - | encryptionType: config.EncryptionLUKSLVM, | |||
| 514 | - | cryptDevice: "/dev/mapper/cryptroot", | |||
| 515 | - | rootPartition: "/dev/sda2", | |||
| 516 | - | expectedDevice: "/dev/mapper/cryptroot", | |||
| 517 | - | }, | |||
| 518 | - | } | |||
| 519 | - | ||||
| 520 | - | for _, tt := range tests { | |||
| 521 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 522 | - | ctrl := gomock.NewController(t) | |||
| 523 | - | defer ctrl.Finish() | |||
| 524 | - | ||||
| 525 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 526 | - | ||||
| 527 | - | tmpDir := t.TempDir() | |||
| 528 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 529 | - | log, err := logger.New(logPath, false) | |||
| 530 | - | if err != nil { | |||
| 531 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 532 | - | } | |||
| 533 | - | defer func() { | |||
| 534 | - | if err := log.Close(); err != nil { | |||
| 535 | - | t.Fatalf("failed to close log: %v", err) | |||
| 536 | - | } | |||
| 537 | - | }() | |||
| 538 | - | ||||
| 539 | - | cfg := config.NewConfig("test") | |||
| 540 | - | cfg.EncryptionType = tt.encryptionType | |||
| 541 | - | cfg.CryptDevice = tt.cryptDevice | |||
| 542 | - | cfg.RootPartition = tt.rootPartition | |||
| 543 | - | ||||
| 544 | - | phase := NewPartitioningPhase(cfg, log, mockSysExec) | |||
| 545 | - | ||||
| 546 | - | // Determine which device would be used | |||
| 547 | - | var device string | |||
| 548 | - | switch phase.config.EncryptionType { | |||
| 549 | - | case config.EncryptionLUKS, config.EncryptionLUKSLVM: | |||
| 550 | - | device = phase.config.CryptDevice | |||
| 551 | - | default: | |||
| 552 | - | device = phase.config.RootPartition | |||
| 553 | - | } | |||
| 554 | - | ||||
| 555 | - | if device != tt.expectedDevice { | |||
| 556 | - | t.Errorf("Device selection = %q, want %q", device, tt.expectedDevice) | |||
| 557 | - | } | |||
| 558 | - | }) | |||
| 559 | - | } | |||
| 560 | - | } | |||
D internal/phases/phase.go +0 -216
| @@ -1,216 +0,0 @@ | |||||
| 1 | - | package phases | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "fmt" | |||
| 5 | - | ||||
| 6 | - | "github.com/bnema/archup/internal/config" | |||
| 7 | - | "github.com/bnema/archup/internal/logger" | |||
| 8 | - | ) | |||
| 9 | - | ||||
| 10 | - | // PhaseStatus represents the current status of a phase | |||
| 11 | - | type PhaseStatus int | |||
| 12 | - | ||||
| 13 | - | const ( | |||
| 14 | - | StatusPending PhaseStatus = iota | |||
| 15 | - | StatusRunning | |||
| 16 | - | StatusCompleted | |||
| 17 | - | StatusFailed | |||
| 18 | - | StatusSkipped | |||
| 19 | - | ) | |||
| 20 | - | ||||
| 21 | - | func (s PhaseStatus) String() string { | |||
| 22 | - | switch s { | |||
| 23 | - | case StatusPending: | |||
| 24 | - | return "pending" | |||
| 25 | - | case StatusRunning: | |||
| 26 | - | return "running" | |||
| 27 | - | case StatusCompleted: | |||
| 28 | - | return "completed" | |||
| 29 | - | case StatusFailed: | |||
| 30 | - | return "failed" | |||
| 31 | - | case StatusSkipped: | |||
| 32 | - | return "skipped" | |||
| 33 | - | default: | |||
| 34 | - | return "unknown" | |||
| 35 | - | } | |||
| 36 | - | } | |||
| 37 | - | ||||
| 38 | - | // PhaseResult represents the result of a phase execution | |||
| 39 | - | type PhaseResult struct { | |||
| 40 | - | Success bool | |||
| 41 | - | Message string | |||
| 42 | - | Error error | |||
| 43 | - | } | |||
| 44 | - | ||||
| 45 | - | // ProgressUpdate represents a progress update from a phase | |||
| 46 | - | type ProgressUpdate struct { | |||
| 47 | - | PhaseName string | |||
| 48 | - | Step string | |||
| 49 | - | OutputLine string | |||
| 50 | - | Current int | |||
| 51 | - | Total int | |||
| 52 | - | IsComplete bool | |||
| 53 | - | IsError bool | |||
| 54 | - | ErrorMsg string | |||
| 55 | - | } | |||
| 56 | - | ||||
| 57 | - | // Phase represents a single installation phase | |||
| 58 | - | type Phase interface { | |||
| 59 | - | // Name returns the phase identifier | |||
| 60 | - | Name() string | |||
| 61 | - | ||||
| 62 | - | // Description returns a human-readable description | |||
| 63 | - | Description() string | |||
| 64 | - | ||||
| 65 | - | // PreCheck validates if the phase can run | |||
| 66 | - | // Returns nil if ready, error if preconditions not met | |||
| 67 | - | PreCheck() error | |||
| 68 | - | ||||
| 69 | - | // Execute runs the phase logic | |||
| 70 | - | // Should send ProgressUpdate via progressChan | |||
| 71 | - | Execute(progressChan chan<- ProgressUpdate) PhaseResult | |||
| 72 | - | ||||
| 73 | - | // PostCheck validates the phase completed successfully | |||
| 74 | - | // Returns nil if validation passes, error otherwise | |||
| 75 | - | PostCheck() error | |||
| 76 | - | ||||
| 77 | - | // Rollback attempts to undo phase changes on failure | |||
| 78 | - | // Returns nil if rollback successful, error otherwise | |||
| 79 | - | Rollback() error | |||
| 80 | - | ||||
| 81 | - | // CanSkip returns true if this phase can be skipped | |||
| 82 | - | CanSkip() bool | |||
| 83 | - | ||||
| 84 | - | // Status returns the current phase status | |||
| 85 | - | Status() PhaseStatus | |||
| 86 | - | ||||
| 87 | - | // SetStatus updates the phase status | |||
| 88 | - | SetStatus(status PhaseStatus) | |||
| 89 | - | } | |||
| 90 | - | ||||
| 91 | - | // BasePhase provides common functionality for all phases | |||
| 92 | - | type BasePhase struct { | |||
| 93 | - | name string | |||
| 94 | - | description string | |||
| 95 | - | status PhaseStatus | |||
| 96 | - | config *config.Config | |||
| 97 | - | logger *logger.Logger | |||
| 98 | - | } | |||
| 99 | - | ||||
| 100 | - | // NewBasePhase creates a new base phase | |||
| 101 | - | func NewBasePhase(name, description string, cfg *config.Config, log *logger.Logger) *BasePhase { | |||
| 102 | - | return &BasePhase{ | |||
| 103 | - | name: name, | |||
| 104 | - | description: description, | |||
| 105 | - | status: StatusPending, | |||
| 106 | - | config: cfg, | |||
| 107 | - | logger: log, | |||
| 108 | - | } | |||
| 109 | - | } | |||
| 110 | - | ||||
| 111 | - | // Name returns the phase name | |||
| 112 | - | func (b *BasePhase) Name() string { | |||
| 113 | - | return b.name | |||
| 114 | - | } | |||
| 115 | - | ||||
| 116 | - | // Description returns the phase description | |||
| 117 | - | func (b *BasePhase) Description() string { | |||
| 118 | - | return b.description | |||
| 119 | - | } | |||
| 120 | - | ||||
| 121 | - | // Status returns the current status | |||
| 122 | - | func (b *BasePhase) Status() PhaseStatus { | |||
| 123 | - | return b.status | |||
| 124 | - | } | |||
| 125 | - | ||||
| 126 | - | // SetStatus updates the status | |||
| 127 | - | func (b *BasePhase) SetStatus(status PhaseStatus) { | |||
| 128 | - | b.status = status | |||
| 129 | - | } | |||
| 130 | - | ||||
| 131 | - | // Config returns the configuration | |||
| 132 | - | func (b *BasePhase) Config() *config.Config { | |||
| 133 | - | return b.config | |||
| 134 | - | } | |||
| 135 | - | ||||
| 136 | - | // Logger returns the logger | |||
| 137 | - | func (b *BasePhase) Logger() *logger.Logger { | |||
| 138 | - | return b.logger | |||
| 139 | - | } | |||
| 140 | - | ||||
| 141 | - | // SendProgress is a helper to send progress updates | |||
| 142 | - | func (b *BasePhase) SendProgress(progressChan chan<- ProgressUpdate, step string, current, total int) { | |||
| 143 | - | if progressChan != nil { | |||
| 144 | - | progressChan <- ProgressUpdate{ | |||
| 145 | - | PhaseName: b.name, | |||
| 146 | - | Step: step, | |||
| 147 | - | Current: current, | |||
| 148 | - | Total: total, | |||
| 149 | - | IsComplete: false, | |||
| 150 | - | IsError: false, | |||
| 151 | - | } | |||
| 152 | - | } | |||
| 153 | - | } | |||
| 154 | - | ||||
| 155 | - | // SendComplete is a helper to send completion | |||
| 156 | - | func (b *BasePhase) SendComplete(progressChan chan<- ProgressUpdate, message string) { | |||
| 157 | - | if progressChan != nil { | |||
| 158 | - | progressChan <- ProgressUpdate{ | |||
| 159 | - | PhaseName: b.name, | |||
| 160 | - | Step: message, | |||
| 161 | - | IsComplete: true, | |||
| 162 | - | IsError: false, | |||
| 163 | - | } | |||
| 164 | - | } | |||
| 165 | - | } | |||
| 166 | - | ||||
| 167 | - | // SendError is a helper to send error | |||
| 168 | - | func (b *BasePhase) SendError(progressChan chan<- ProgressUpdate, err error) { | |||
| 169 | - | if progressChan != nil { | |||
| 170 | - | progressChan <- ProgressUpdate{ | |||
| 171 | - | PhaseName: b.name, | |||
| 172 | - | IsComplete: true, | |||
| 173 | - | IsError: true, | |||
| 174 | - | ErrorMsg: err.Error(), | |||
| 175 | - | } | |||
| 176 | - | } | |||
| 177 | - | } | |||
| 178 | - | ||||
| 179 | - | // SendOutput is a helper to send command output line | |||
| 180 | - | func (b *BasePhase) SendOutput(progressChan chan<- ProgressUpdate, line string) { | |||
| 181 | - | // Log the output | |||
| 182 | - | if b.logger != nil { | |||
| 183 | - | b.logger.Info(line) | |||
| 184 | - | } | |||
| 185 | - | ||||
| 186 | - | // Send to progress channel for TUI display | |||
| 187 | - | if progressChan != nil { | |||
| 188 | - | progressChan <- ProgressUpdate{ | |||
| 189 | - | PhaseName: b.name, | |||
| 190 | - | OutputLine: line, | |||
| 191 | - | } | |||
| 192 | - | } | |||
| 193 | - | } | |||
| 194 | - | ||||
| 195 | - | // DefaultPreCheck provides a basic pre-check (override in specific phases) | |||
| 196 | - | func (b *BasePhase) PreCheck() error { | |||
| 197 | - | if b.config == nil { | |||
| 198 | - | return fmt.Errorf("config not initialized") | |||
| 199 | - | } | |||
| 200 | - | return nil | |||
| 201 | - | } | |||
| 202 | - | ||||
| 203 | - | // DefaultPostCheck provides a basic post-check (override in specific phases) | |||
| 204 | - | func (b *BasePhase) PostCheck() error { | |||
| 205 | - | return nil | |||
| 206 | - | } | |||
| 207 | - | ||||
| 208 | - | // DefaultRollback provides a basic rollback (override in specific phases) | |||
| 209 | - | func (b *BasePhase) Rollback() error { | |||
| 210 | - | return fmt.Errorf("rollback not implemented for phase: %s", b.name) | |||
| 211 | - | } | |||
| 212 | - | ||||
| 213 | - | // DefaultCanSkip returns false by default (override in specific phases) | |||
| 214 | - | func (b *BasePhase) CanSkip() bool { | |||
| 215 | - | return false | |||
| 216 | - | } | |||
D internal/phases/phase_test.go +0 -372
| @@ -1,372 +0,0 @@ | |||||
| 1 | - | package phases | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "fmt" | |||
| 5 | - | "path/filepath" | |||
| 6 | - | "testing" | |||
| 7 | - | ||||
| 8 | - | "github.com/bnema/archup/internal/config" | |||
| 9 | - | "github.com/bnema/archup/internal/logger" | |||
| 10 | - | ) | |||
| 11 | - | ||||
| 12 | - | // TestPhaseStatusString tests the String() method of PhaseStatus enum | |||
| 13 | - | func TestPhaseStatusString(t *testing.T) { | |||
| 14 | - | tests := []struct { | |||
| 15 | - | status PhaseStatus | |||
| 16 | - | expected string | |||
| 17 | - | }{ | |||
| 18 | - | {StatusPending, "pending"}, | |||
| 19 | - | {StatusRunning, "running"}, | |||
| 20 | - | {StatusCompleted, "completed"}, | |||
| 21 | - | {StatusFailed, "failed"}, | |||
| 22 | - | {StatusSkipped, "skipped"}, | |||
| 23 | - | {PhaseStatus(999), "unknown"}, | |||
| 24 | - | } | |||
| 25 | - | ||||
| 26 | - | for _, tt := range tests { | |||
| 27 | - | t.Run(tt.expected, func(t *testing.T) { | |||
| 28 | - | got := tt.status.String() | |||
| 29 | - | if got != tt.expected { | |||
| 30 | - | t.Errorf("PhaseStatus.String() = %q, want %q", got, tt.expected) | |||
| 31 | - | } | |||
| 32 | - | }) | |||
| 33 | - | } | |||
| 34 | - | } | |||
| 35 | - | ||||
| 36 | - | // TestNewBasePhase verifies BasePhase initialization | |||
| 37 | - | func TestNewBasePhase(t *testing.T) { | |||
| 38 | - | tmpDir := t.TempDir() | |||
| 39 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 40 | - | ||||
| 41 | - | log, err := logger.New(logPath, false) | |||
| 42 | - | if err != nil { | |||
| 43 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 44 | - | } | |||
| 45 | - | defer func() { | |||
| 46 | - | if err := log.Close(); err != nil { | |||
| 47 | - | t.Fatalf("failed to close log: %v", err) | |||
| 48 | - | } | |||
| 49 | - | }() | |||
| 50 | - | ||||
| 51 | - | cfg := config.NewConfig("test") | |||
| 52 | - | ||||
| 53 | - | phase := NewBasePhase("test-phase", "Test phase description", cfg, log) | |||
| 54 | - | ||||
| 55 | - | if phase.Name() != "test-phase" { | |||
| 56 | - | t.Errorf("Name() = %q, want %q", phase.Name(), "test-phase") | |||
| 57 | - | } | |||
| 58 | - | ||||
| 59 | - | if phase.Description() != "Test phase description" { | |||
| 60 | - | t.Errorf("Description() = %q, want %q", phase.Description(), "Test phase description") | |||
| 61 | - | } | |||
| 62 | - | ||||
| 63 | - | if phase.Status() != StatusPending { | |||
| 64 | - | t.Errorf("Status() = %v, want %v", phase.Status(), StatusPending) | |||
| 65 | - | } | |||
| 66 | - | ||||
| 67 | - | if phase.Config() != cfg { | |||
| 68 | - | t.Errorf("Config() returned different instance") | |||
| 69 | - | } | |||
| 70 | - | ||||
| 71 | - | if phase.Logger() != log { | |||
| 72 | - | t.Errorf("Logger() returned different instance") | |||
| 73 | - | } | |||
| 74 | - | } | |||
| 75 | - | ||||
| 76 | - | // TestBasePhaseSetStatus verifies status updates | |||
| 77 | - | func TestBasePhaseSetStatus(t *testing.T) { | |||
| 78 | - | tmpDir := t.TempDir() | |||
| 79 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 80 | - | ||||
| 81 | - | log, _ := logger.New(logPath, false) | |||
| 82 | - | defer func() { | |||
| 83 | - | if err := log.Close(); err != nil { | |||
| 84 | - | t.Fatalf("failed to close log: %v", err) | |||
| 85 | - | } | |||
| 86 | - | }() | |||
| 87 | - | ||||
| 88 | - | cfg := config.NewConfig("test") | |||
| 89 | - | phase := NewBasePhase("test", "test", cfg, log) | |||
| 90 | - | ||||
| 91 | - | statuses := []PhaseStatus{ | |||
| 92 | - | StatusPending, | |||
| 93 | - | StatusRunning, | |||
| 94 | - | StatusCompleted, | |||
| 95 | - | StatusFailed, | |||
| 96 | - | StatusSkipped, | |||
| 97 | - | } | |||
| 98 | - | ||||
| 99 | - | for _, status := range statuses { | |||
| 100 | - | t.Run(status.String(), func(t *testing.T) { | |||
| 101 | - | phase.SetStatus(status) | |||
| 102 | - | if phase.Status() != status { | |||
| 103 | - | t.Errorf("Status() = %v, want %v", phase.Status(), status) | |||
| 104 | - | } | |||
| 105 | - | }) | |||
| 106 | - | } | |||
| 107 | - | } | |||
| 108 | - | ||||
| 109 | - | // TestBasePhaseDefaultPreCheck verifies default PreCheck implementation | |||
| 110 | - | func TestBasePhaseDefaultPreCheck(t *testing.T) { | |||
| 111 | - | tmpDir := t.TempDir() | |||
| 112 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 113 | - | ||||
| 114 | - | log, _ := logger.New(logPath, false) | |||
| 115 | - | defer func() { | |||
| 116 | - | if err := log.Close(); err != nil { | |||
| 117 | - | t.Fatalf("failed to close log: %v", err) | |||
| 118 | - | } | |||
| 119 | - | }() | |||
| 120 | - | ||||
| 121 | - | tests := []struct { | |||
| 122 | - | name string | |||
| 123 | - | cfg *config.Config | |||
| 124 | - | wantError bool | |||
| 125 | - | }{ | |||
| 126 | - | { | |||
| 127 | - | name: "with valid config", | |||
| 128 | - | cfg: config.NewConfig("test"), | |||
| 129 | - | wantError: false, | |||
| 130 | - | }, | |||
| 131 | - | { | |||
| 132 | - | name: "with nil config", | |||
| 133 | - | cfg: nil, | |||
| 134 | - | wantError: true, | |||
| 135 | - | }, | |||
| 136 | - | } | |||
| 137 | - | ||||
| 138 | - | for _, tt := range tests { | |||
| 139 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 140 | - | phase := NewBasePhase("test", "test", tt.cfg, log) | |||
| 141 | - | err := phase.PreCheck() | |||
| 142 | - | ||||
| 143 | - | if (err != nil) != tt.wantError { | |||
| 144 | - | t.Errorf("PreCheck() error = %v, wantError %v", err, tt.wantError) | |||
| 145 | - | } | |||
| 146 | - | }) | |||
| 147 | - | } | |||
| 148 | - | } | |||
| 149 | - | ||||
| 150 | - | // TestBasePhaseDefaultPostCheck verifies default PostCheck (always succeeds) | |||
| 151 | - | func TestBasePhaseDefaultPostCheck(t *testing.T) { | |||
| 152 | - | tmpDir := t.TempDir() | |||
| 153 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 154 | - | ||||
| 155 | - | log, _ := logger.New(logPath, false) | |||
| 156 | - | defer func() { | |||
| 157 | - | if err := log.Close(); err != nil { | |||
| 158 | - | t.Fatalf("failed to close log: %v", err) | |||
| 159 | - | } | |||
| 160 | - | }() | |||
| 161 | - | ||||
| 162 | - | cfg := config.NewConfig("test") | |||
| 163 | - | phase := NewBasePhase("test", "test", cfg, log) | |||
| 164 | - | ||||
| 165 | - | err := phase.PostCheck() | |||
| 166 | - | if err != nil { | |||
| 167 | - | t.Errorf("PostCheck() error = %v, want nil", err) | |||
| 168 | - | } | |||
| 169 | - | } | |||
| 170 | - | ||||
| 171 | - | // TestBasePhaseDefaultRollback verifies default Rollback returns error | |||
| 172 | - | func TestBasePhaseDefaultRollback(t *testing.T) { | |||
| 173 | - | tmpDir := t.TempDir() | |||
| 174 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 175 | - | ||||
| 176 | - | log, _ := logger.New(logPath, false) | |||
| 177 | - | defer func() { | |||
| 178 | - | if err := log.Close(); err != nil { | |||
| 179 | - | t.Fatalf("failed to close log: %v", err) | |||
| 180 | - | } | |||
| 181 | - | }() | |||
| 182 | - | ||||
| 183 | - | cfg := config.NewConfig("test") | |||
| 184 | - | phase := NewBasePhase("my-phase", "test", cfg, log) | |||
| 185 | - | ||||
| 186 | - | err := phase.Rollback() | |||
| 187 | - | if err == nil { | |||
| 188 | - | t.Error("Rollback() error = nil, want error") | |||
| 189 | - | } | |||
| 190 | - | ||||
| 191 | - | expectedMsg := "rollback not implemented for phase: my-phase" | |||
| 192 | - | if err.Error() != expectedMsg { | |||
| 193 | - | t.Errorf("Rollback() error = %q, want %q", err.Error(), expectedMsg) | |||
| 194 | - | } | |||
| 195 | - | } | |||
| 196 | - | ||||
| 197 | - | // TestBasePhaseDefaultCanSkip verifies default CanSkip returns false | |||
| 198 | - | func TestBasePhaseDefaultCanSkip(t *testing.T) { | |||
| 199 | - | tmpDir := t.TempDir() | |||
| 200 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 201 | - | ||||
| 202 | - | log, _ := logger.New(logPath, false) | |||
| 203 | - | defer func() { | |||
| 204 | - | if err := log.Close(); err != nil { | |||
| 205 | - | t.Fatalf("failed to close log: %v", err) | |||
| 206 | - | } | |||
| 207 | - | }() | |||
| 208 | - | ||||
| 209 | - | cfg := config.NewConfig("test") | |||
| 210 | - | phase := NewBasePhase("test", "test", cfg, log) | |||
| 211 | - | ||||
| 212 | - | if phase.CanSkip() { | |||
| 213 | - | t.Error("CanSkip() = true, want false") | |||
| 214 | - | } | |||
| 215 | - | } | |||
| 216 | - | ||||
| 217 | - | // TestSendProgress verifies progress updates sent to channel | |||
| 218 | - | func TestSendProgress(t *testing.T) { | |||
| 219 | - | tmpDir := t.TempDir() | |||
| 220 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 221 | - | ||||
| 222 | - | log, _ := logger.New(logPath, false) | |||
| 223 | - | defer func() { | |||
| 224 | - | if err := log.Close(); err != nil { | |||
| 225 | - | t.Fatalf("failed to close log: %v", err) | |||
| 226 | - | } | |||
| 227 | - | }() | |||
| 228 | - | ||||
| 229 | - | cfg := config.NewConfig("test") | |||
| 230 | - | phase := NewBasePhase("test-phase", "test", cfg, log) | |||
| 231 | - | ||||
| 232 | - | progressChan := make(chan ProgressUpdate, 5) | |||
| 233 | - | ||||
| 234 | - | phase.SendProgress(progressChan, "step-1", 1, 3) | |||
| 235 | - | phase.SendProgress(progressChan, "step-2", 2, 3) | |||
| 236 | - | ||||
| 237 | - | // Verify first update | |||
| 238 | - | update1 := <-progressChan | |||
| 239 | - | if update1.PhaseName != "test-phase" { | |||
| 240 | - | t.Errorf("PhaseName = %q, want %q", update1.PhaseName, "test-phase") | |||
| 241 | - | } | |||
| 242 | - | if update1.Step != "step-1" { | |||
| 243 | - | t.Errorf("Step = %q, want %q", update1.Step, "step-1") | |||
| 244 | - | } | |||
| 245 | - | if update1.Current != 1 || update1.Total != 3 { | |||
| 246 | - | t.Errorf("Progress = %d/%d, want 1/3", update1.Current, update1.Total) | |||
| 247 | - | } | |||
| 248 | - | if update1.IsComplete || update1.IsError { | |||
| 249 | - | t.Error("Flags should be false for regular progress update") | |||
| 250 | - | } | |||
| 251 | - | ||||
| 252 | - | // Verify second update | |||
| 253 | - | update2 := <-progressChan | |||
| 254 | - | if update2.Step != "step-2" { | |||
| 255 | - | t.Errorf("Step = %q, want %q", update2.Step, "step-2") | |||
| 256 | - | } | |||
| 257 | - | if update2.Current != 2 { | |||
| 258 | - | t.Errorf("Current = %d, want 2", update2.Current) | |||
| 259 | - | } | |||
| 260 | - | } | |||
| 261 | - | ||||
| 262 | - | // TestSendProgressWithNilChannel verifies no panic with nil channel | |||
| 263 | - | func TestSendProgressWithNilChannel(t *testing.T) { | |||
| 264 | - | tmpDir := t.TempDir() | |||
| 265 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 266 | - | ||||
| 267 | - | log, _ := logger.New(logPath, false) | |||
| 268 | - | defer func() { | |||
| 269 | - | if err := log.Close(); err != nil { | |||
| 270 | - | t.Fatalf("failed to close log: %v", err) | |||
| 271 | - | } | |||
| 272 | - | }() | |||
| 273 | - | ||||
| 274 | - | cfg := config.NewConfig("test") | |||
| 275 | - | phase := NewBasePhase("test", "test", cfg, log) | |||
| 276 | - | ||||
| 277 | - | // Should not panic with nil channel | |||
| 278 | - | phase.SendProgress(nil, "step", 1, 2) | |||
| 279 | - | } | |||
| 280 | - | ||||
| 281 | - | // TestSendComplete verifies completion signal | |||
| 282 | - | func TestSendComplete(t *testing.T) { | |||
| 283 | - | tmpDir := t.TempDir() | |||
| 284 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 285 | - | ||||
| 286 | - | log, _ := logger.New(logPath, false) | |||
| 287 | - | defer func() { | |||
| 288 | - | if err := log.Close(); err != nil { | |||
| 289 | - | t.Fatalf("failed to close log: %v", err) | |||
| 290 | - | } | |||
| 291 | - | }() | |||
| 292 | - | ||||
| 293 | - | cfg := config.NewConfig("test") | |||
| 294 | - | phase := NewBasePhase("test-phase", "test", cfg, log) | |||
| 295 | - | ||||
| 296 | - | progressChan := make(chan ProgressUpdate, 1) | |||
| 297 | - | ||||
| 298 | - | phase.SendComplete(progressChan, "Installation complete") | |||
| 299 | - | ||||
| 300 | - | update := <-progressChan | |||
| 301 | - | if !update.IsComplete { | |||
| 302 | - | t.Error("IsComplete = false, want true") | |||
| 303 | - | } | |||
| 304 | - | if update.IsError { | |||
| 305 | - | t.Error("IsError = true, want false") | |||
| 306 | - | } | |||
| 307 | - | if update.Step != "Installation complete" { | |||
| 308 | - | t.Errorf("Step = %q, want %q", update.Step, "Installation complete") | |||
| 309 | - | } | |||
| 310 | - | } | |||
| 311 | - | ||||
| 312 | - | // TestSendError verifies error signal | |||
| 313 | - | func TestSendError(t *testing.T) { | |||
| 314 | - | tmpDir := t.TempDir() | |||
| 315 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 316 | - | ||||
| 317 | - | log, _ := logger.New(logPath, false) | |||
| 318 | - | defer func() { | |||
| 319 | - | if err := log.Close(); err != nil { | |||
| 320 | - | t.Fatalf("failed to close log: %v", err) | |||
| 321 | - | } | |||
| 322 | - | }() | |||
| 323 | - | ||||
| 324 | - | cfg := config.NewConfig("test") | |||
| 325 | - | phase := NewBasePhase("test-phase", "test", cfg, log) | |||
| 326 | - | ||||
| 327 | - | progressChan := make(chan ProgressUpdate, 1) | |||
| 328 | - | ||||
| 329 | - | testErr := "test error message" | |||
| 330 | - | err := fmt.Errorf("%s", testErr) | |||
| 331 | - | phase.SendError(progressChan, err) | |||
| 332 | - | ||||
| 333 | - | update := <-progressChan | |||
| 334 | - | if !update.IsComplete { | |||
| 335 | - | t.Error("IsComplete = false, want true") | |||
| 336 | - | } | |||
| 337 | - | if !update.IsError { | |||
| 338 | - | t.Error("IsError = false, want true") | |||
| 339 | - | } | |||
| 340 | - | if update.ErrorMsg != testErr { | |||
| 341 | - | t.Errorf("ErrorMsg = %q, want %q", update.ErrorMsg, testErr) | |||
| 342 | - | } | |||
| 343 | - | } | |||
| 344 | - | ||||
| 345 | - | // TestSendOutput verifies output line sending | |||
| 346 | - | func TestSendOutput(t *testing.T) { | |||
| 347 | - | tmpDir := t.TempDir() | |||
| 348 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 349 | - | ||||
| 350 | - | log, _ := logger.New(logPath, false) | |||
| 351 | - | defer func() { | |||
| 352 | - | if err := log.Close(); err != nil { | |||
| 353 | - | t.Fatalf("failed to close log: %v", err) | |||
| 354 | - | } | |||
| 355 | - | }() | |||
| 356 | - | ||||
| 357 | - | cfg := config.NewConfig("test") | |||
| 358 | - | phase := NewBasePhase("test-phase", "test", cfg, log) | |||
| 359 | - | ||||
| 360 | - | progressChan := make(chan ProgressUpdate, 1) | |||
| 361 | - | ||||
| 362 | - | outputLine := "output from command" | |||
| 363 | - | phase.SendOutput(progressChan, outputLine) | |||
| 364 | - | ||||
| 365 | - | update := <-progressChan | |||
| 366 | - | if update.OutputLine != outputLine { | |||
| 367 | - | t.Errorf("OutputLine = %q, want %q", update.OutputLine, outputLine) | |||
| 368 | - | } | |||
| 369 | - | if update.IsComplete || update.IsError { | |||
| 370 | - | t.Error("Completion/Error flags should be false for output update") | |||
| 371 | - | } | |||
| 372 | - | } | |||
D internal/phases/postinstall.go +0 -644
| @@ -1,644 +0,0 @@ | |||||
| 1 | - | package phases | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "fmt" | |||
| 5 | - | "io" | |||
| 6 | - | "net/http" | |||
| 7 | - | "os" | |||
| 8 | - | "path/filepath" | |||
| 9 | - | "regexp" | |||
| 10 | - | "strings" | |||
| 11 | - | ||||
| 12 | - | "github.com/bnema/archup/internal/config" | |||
| 13 | - | "github.com/bnema/archup/internal/interfaces" | |||
| 14 | - | "github.com/bnema/archup/internal/logger" | |||
| 15 | - | "github.com/bnema/archup/internal/system" | |||
| 16 | - | ) | |||
| 17 | - | ||||
| 18 | - | // PostInstallPhase handles post-installation tasks | |||
| 19 | - | type PostInstallPhase struct { | |||
| 20 | - | *BasePhase | |||
| 21 | - | fs interfaces.FileSystem | |||
| 22 | - | http interfaces.HTTPClient | |||
| 23 | - | sysExec interfaces.SystemExecutor | |||
| 24 | - | chrExec interfaces.ChrootExecutor | |||
| 25 | - | } | |||
| 26 | - | ||||
| 27 | - | // NewPostInstallPhase creates a new post-install phase | |||
| 28 | - | func NewPostInstallPhase(cfg *config.Config, log *logger.Logger, fs interfaces.FileSystem, http interfaces.HTTPClient, sysExec interfaces.SystemExecutor, chrExec interfaces.ChrootExecutor) *PostInstallPhase { | |||
| 29 | - | return &PostInstallPhase{ | |||
| 30 | - | BasePhase: NewBasePhase("postinstall", "Post-Installation", cfg, log), | |||
| 31 | - | fs: fs, | |||
| 32 | - | http: http, | |||
| 33 | - | sysExec: sysExec, | |||
| 34 | - | chrExec: chrExec, | |||
| 35 | - | } | |||
| 36 | - | } | |||
| 37 | - | ||||
| 38 | - | // fetchFile returns the content of a repo-relative path. | |||
| 39 | - | // It checks /tmp/archup-install/<repoPath> first (populated by push-binary-to-vm.sh --local), | |||
| 40 | - | // then falls back to downloading from GitHub. | |||
| 41 | - | func (p *PostInstallPhase) fetchFile(repoPath string) ([]byte, error) { | |||
| 42 | - | localPath := filepath.Join(config.DefaultInstallDir, repoPath) | |||
| 43 | - | if content, err := p.fs.ReadFile(localPath); err == nil { | |||
| 44 | - | return content, nil | |||
| 45 | - | } | |||
| 46 | - | url := fmt.Sprintf("%s/%s", p.config.RawURL, repoPath) | |||
| 47 | - | resp, err := p.http.Get(url) | |||
| 48 | - | if err != nil { | |||
| 49 | - | return nil, fmt.Errorf("failed to fetch %s: %w", repoPath, err) | |||
| 50 | - | } | |||
| 51 | - | defer resp.Body.Close() | |||
| 52 | - | if resp.StatusCode != http.StatusOK { | |||
| 53 | - | return nil, fmt.Errorf("failed to fetch %s: HTTP %d", repoPath, resp.StatusCode) | |||
| 54 | - | } | |||
| 55 | - | data, err := io.ReadAll(resp.Body) | |||
| 56 | - | if err != nil { | |||
| 57 | - | return nil, fmt.Errorf("failed to read %s: %w", repoPath, err) | |||
| 58 | - | } | |||
| 59 | - | return data, nil | |||
| 60 | - | } | |||
| 61 | - | ||||
| 62 | - | // PreCheck validates post-install prerequisites | |||
| 63 | - | func (p *PostInstallPhase) PreCheck() error { | |||
| 64 | - | // Verify /mnt is mounted | |||
| 65 | - | result := p.sysExec.RunSimple("mountpoint", "-q", config.PathMnt) | |||
| 66 | - | switch { | |||
| 67 | - | case result.ExitCode != 0: | |||
| 68 | - | return fmt.Errorf("%s is not mounted", config.PathMnt) | |||
| 69 | - | } | |||
| 70 | - | ||||
| 71 | - | // Verify boot directory exists | |||
| 72 | - | switch _, err := p.fs.Stat(config.PathMntBoot); { | |||
| 73 | - | case p.fs.IsNotExist(err): | |||
| 74 | - | return fmt.Errorf("boot directory not found") | |||
| 75 | - | } | |||
| 76 | - | ||||
| 77 | - | return nil | |||
| 78 | - | } | |||
| 79 | - | ||||
| 80 | - | // Execute runs the post-install phase | |||
| 81 | - | func (p *PostInstallPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult { | |||
| 82 | - | totalSteps := 8 | |||
| 83 | - | currentStep := 0 | |||
| 84 | - | ||||
| 85 | - | // Step 1: Plymouth | |||
| 86 | - | currentStep++ | |||
| 87 | - | p.SendProgress(progressChan, "Configuring Plymouth splash screen...", currentStep, totalSteps) | |||
| 88 | - | switch err := p.configurePlymouth(progressChan); { | |||
| 89 | - | case err != nil: | |||
| 90 | - | // Non-fatal, continue | |||
| 91 | - | p.SendOutput(progressChan, fmt.Sprintf("[WARN] Plymouth failed: %v", err)) | |||
| 92 | - | } | |||
| 93 | - | ||||
| 94 | - | // Step 3: Snapper | |||
| 95 | - | currentStep++ | |||
| 96 | - | p.SendProgress(progressChan, "Configuring btrfs snapshots...", currentStep, totalSteps) | |||
| 97 | - | switch err := p.configureSnapper(progressChan); { | |||
| 98 | - | case err != nil: | |||
| 99 | - | // Non-fatal, continue | |||
| 100 | - | p.SendOutput(progressChan, fmt.Sprintf("[WARN] Snapper failed: %v", err)) | |||
| 101 | - | } | |||
| 102 | - | ||||
| 103 | - | // Step 4: Pacman configuration | |||
| 104 | - | currentStep++ | |||
| 105 | - | p.SendProgress(progressChan, "Configuring pacman...", currentStep, totalSteps) | |||
| 106 | - | switch err := p.configurePacman(progressChan); { | |||
| 107 | - | case err != nil: | |||
| 108 | - | p.SendError(progressChan, err) | |||
| 109 | - | return PhaseResult{Success: false, Error: err} | |||
| 110 | - | } | |||
| 111 | - | ||||
| 112 | - | // Step 5: Post-boot setup | |||
| 113 | - | currentStep++ | |||
| 114 | - | p.SendProgress(progressChan, "Setting up post-boot scripts...", currentStep, totalSteps) | |||
| 115 | - | switch err := p.setupPostBoot(progressChan); { | |||
| 116 | - | case err != nil: | |||
| 117 | - | p.SendError(progressChan, err) | |||
| 118 | - | return PhaseResult{Success: false, Error: err} | |||
| 119 | - | } | |||
| 120 | - | ||||
| 121 | - | // Step 6: Pacman hooks | |||
| 122 | - | currentStep++ | |||
| 123 | - | p.SendProgress(progressChan, "Installing pacman hooks...", currentStep, totalSteps) | |||
| 124 | - | switch err := p.installPacmanHooks(progressChan); { | |||
| 125 | - | case err != nil: | |||
| 126 | - | p.SendError(progressChan, err) | |||
| 127 | - | return PhaseResult{Success: false, Error: err} | |||
| 128 | - | } | |||
| 129 | - | ||||
| 130 | - | // Step 7: Shell configuration | |||
| 131 | - | currentStep++ | |||
| 132 | - | p.SendProgress(progressChan, "Configuring shell environment...", currentStep, totalSteps) | |||
| 133 | - | switch err := p.configureShell(progressChan); { | |||
| 134 | - | case err != nil: | |||
| 135 | - | p.SendError(progressChan, err) | |||
| 136 | - | return PhaseResult{Success: false, Error: err} | |||
| 137 | - | } | |||
| 138 | - | ||||
| 139 | - | // Step 8: Verification | |||
| 140 | - | currentStep++ | |||
| 141 | - | p.SendProgress(progressChan, "Verifying installation...", currentStep, totalSteps) | |||
| 142 | - | switch err := p.verifyInstallation(progressChan); { | |||
| 143 | - | case err != nil: | |||
| 144 | - | p.SendOutput(progressChan, fmt.Sprintf("[WARN] Verification warnings: %v", err)) | |||
| 145 | - | } | |||
| 146 | - | ||||
| 147 | - | // Step 9: Unmount | |||
| 148 | - | currentStep++ | |||
| 149 | - | p.SendProgress(progressChan, "Unmounting filesystems...", currentStep, totalSteps) | |||
| 150 | - | switch err := p.unmountAll(progressChan); { | |||
| 151 | - | case err != nil: | |||
| 152 | - | p.SendError(progressChan, err) | |||
| 153 | - | return PhaseResult{Success: false, Error: err} | |||
| 154 | - | } | |||
| 155 | - | ||||
| 156 | - | p.SendComplete(progressChan, "Post-installation complete") | |||
| 157 | - | return PhaseResult{Success: true, Message: "Post-installation complete"} | |||
| 158 | - | } | |||
| 159 | - | ||||
| 160 | - | // configurePlymouth sets up Plymouth splash screen | |||
| 161 | - | func (p *PostInstallPhase) configurePlymouth(progressChan chan<- ProgressUpdate) error { | |||
| 162 | - | p.SendOutput(progressChan, "Setting up Plymouth theme...") | |||
| 163 | - | ||||
| 164 | - | // Create theme directory | |||
| 165 | - | themeDir := filepath.Join(config.PathMntPlymouthThemes, config.PlymouthThemeName) | |||
| 166 | - | switch err := p.fs.MkdirAll(themeDir, 0755); { | |||
| 167 | - | case err != nil: | |||
| 168 | - | return fmt.Errorf("failed to create theme directory: %w", err) | |||
| 169 | - | } | |||
| 170 | - | ||||
| 171 | - | // Fetch all Plymouth files (local-first, then remote) | |||
| 172 | - | for _, filename := range config.PlymouthFiles { | |||
| 173 | - | p.SendOutput(progressChan, fmt.Sprintf("Fetching %s...", filename)) | |||
| 174 | - | ||||
| 175 | - | data, err := p.fetchFile(filepath.Join("assets", "plymouth", filename)) | |||
| 176 | - | if err != nil { | |||
| 177 | - | return fmt.Errorf("failed to fetch %s: %w", filename, err) | |||
| 178 | - | } | |||
| 179 | - | ||||
| 180 | - | destPath := filepath.Join(themeDir, filename) | |||
| 181 | - | if err := p.fs.WriteFile(destPath, data, 0644); err != nil { | |||
| 182 | - | return fmt.Errorf("failed to write %s: %w", filename, err) | |||
| 183 | - | } | |||
| 184 | - | } | |||
| 185 | - | ||||
| 186 | - | p.SendOutput(progressChan, "[OK] Plymouth files downloaded") | |||
| 187 | - | ||||
| 188 | - | // Set as default theme | |||
| 189 | - | setThemeCmd := fmt.Sprintf("plymouth-set-default-theme %s", config.PlymouthThemeName) | |||
| 190 | - | switch err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, setThemeCmd); { | |||
| 191 | - | case err != nil: | |||
| 192 | - | return fmt.Errorf("failed to set Plymouth theme: %w", err) | |||
| 193 | - | } | |||
| 194 | - | ||||
| 195 | - | p.SendOutput(progressChan, "[OK] Plymouth theme set") | |||
| 196 | - | ||||
| 197 | - | // Regenerate initramfs | |||
| 198 | - | p.SendOutput(progressChan, "Regenerating initramfs with Plymouth...") | |||
| 199 | - | switch err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, "mkinitcpio -P"); { | |||
| 200 | - | case err != nil: | |||
| 201 | - | return fmt.Errorf("failed to regenerate initramfs: %w", err) | |||
| 202 | - | } | |||
| 203 | - | ||||
| 204 | - | p.SendOutput(progressChan, "[OK] Plymouth configured") | |||
| 205 | - | return nil | |||
| 206 | - | } | |||
| 207 | - | ||||
| 208 | - | // configureSnapper sets up btrfs snapshots with Limine integration | |||
| 209 | - | func (p *PostInstallPhase) configureSnapper(progressChan chan<- ProgressUpdate) error { | |||
| 210 | - | p.SendOutput(progressChan, "Installing limine-snapper-sync...") | |||
| 211 | - | ||||
| 212 | - | // Install limine-snapper-sync | |||
| 213 | - | switch err := p.chrExec.ChrootPacman(p.logger.LogPath(), config.PathMnt, "-S", "--needed", "limine-snapper-sync"); { | |||
| 214 | - | case err != nil: | |||
| 215 | - | return fmt.Errorf("failed to install limine-snapper-sync: %w", err) | |||
| 216 | - | } | |||
| 217 | - | ||||
| 218 | - | p.SendOutput(progressChan, "[OK] Package installed") | |||
| 219 | - | ||||
| 220 | - | // Get kernel cmdline from existing Limine config | |||
| 221 | - | limineConf := config.PathMntBootLimineConf | |||
| 222 | - | content, err := p.fs.ReadFile(limineConf) | |||
| 223 | - | switch { | |||
| 224 | - | case err != nil: | |||
| 225 | - | return fmt.Errorf("failed to read limine.conf: %w", err) | |||
| 226 | - | } | |||
| 227 | - | ||||
| 228 | - | // Extract cmdline | |||
| 229 | - | cmdlineRegex := regexp.MustCompile(`(?m)^\s*cmdline:\s*(.+)$`) | |||
| 230 | - | matches := cmdlineRegex.FindStringSubmatch(string(content)) | |||
| 231 | - | cmdline := "" | |||
| 232 | - | switch { | |||
| 233 | - | case len(matches) > 1: | |||
| 234 | - | cmdline = strings.TrimSpace(matches[1]) | |||
| 235 | - | } | |||
| 236 | - | ||||
| 237 | - | // Create Limine snapper config | |||
| 238 | - | limineDefaultConfig := fmt.Sprintf(`TARGET_OS_NAME="%s" | |||
| 239 | - | ||||
| 240 | - | ESP_PATH="/boot" | |||
| 241 | - | ||||
| 242 | - | KERNEL_CMDLINE[default]="%s" | |||
| 243 | - | ||||
| 244 | - | ENABLE_UKI=no | |||
| 245 | - | ENABLE_LIMINE_FALLBACK=yes | |||
| 246 | - | ||||
| 247 | - | FIND_BOOTLOADERS=yes | |||
| 248 | - | ||||
| 249 | - | BOOT_ORDER="*, *fallback, Snapshots" | |||
| 250 | - | ||||
| 251 | - | MAX_SNAPSHOT_ENTRIES=5 | |||
| 252 | - | ||||
| 253 | - | SNAPSHOT_FORMAT_CHOICE=5 | |||
| 254 | - | `, config.LimineBranding, cmdline) | |||
| 255 | - | ||||
| 256 | - | switch err := p.fs.WriteFile(config.PathMntEtcDefaultLimine, []byte(limineDefaultConfig), 0644); { | |||
| 257 | - | case err != nil: | |||
| 258 | - | return fmt.Errorf("failed to write limine config: %w", err) | |||
| 259 | - | } | |||
| 260 | - | ||||
| 261 | - | p.SendOutput(progressChan, "[OK] Limine snapper config created") | |||
| 262 | - | ||||
| 263 | - | // Enable service | |||
| 264 | - | switch err := p.chrExec.ChrootSystemctl(p.logger.LogPath(), config.PathMnt, "enable", "limine-snapper-sync.service"); { | |||
| 265 | - | case err != nil: | |||
| 266 | - | return fmt.Errorf("failed to enable limine-snapper-sync: %w", err) | |||
| 267 | - | } | |||
| 268 | - | ||||
| 269 | - | p.SendOutput(progressChan, "[OK] Btrfs snapshots configured") | |||
| 270 | - | return nil | |||
| 271 | - | } | |||
| 272 | - | ||||
| 273 | - | // configurePacman configures pacman for better UX | |||
| 274 | - | func (p *PostInstallPhase) configurePacman(progressChan chan<- ProgressUpdate) error { | |||
| 275 | - | p.SendOutput(progressChan, "Configuring pacman UX...") | |||
| 276 | - | ||||
| 277 | - | content, err := p.fs.ReadFile(config.PathMntEtcPacmanConf) | |||
| 278 | - | switch { | |||
| 279 | - | case err != nil: | |||
| 280 | - | return fmt.Errorf("failed to read pacman.conf: %w", err) | |||
| 281 | - | } | |||
| 282 | - | ||||
| 283 | - | contentStr := string(content) | |||
| 284 | - | ||||
| 285 | - | // Enable Color | |||
| 286 | - | colorRegex := regexp.MustCompile(`(?m)^#Color$`) | |||
| 287 | - | contentStr = colorRegex.ReplaceAllString(contentStr, "Color") | |||
| 288 | - | ||||
| 289 | - | // Enable ParallelDownloads | |||
| 290 | - | parallelRegex := regexp.MustCompile(`(?m)^#ParallelDownloads = 5$`) | |||
| 291 | - | contentStr = parallelRegex.ReplaceAllString(contentStr, "ParallelDownloads = 5") | |||
| 292 | - | ||||
| 293 | - | // Add ILoveCandy after Color | |||
| 294 | - | switch { | |||
| 295 | - | case !strings.Contains(contentStr, "ILoveCandy"): | |||
| 296 | - | colorLineRegex := regexp.MustCompile(`(?m)^Color$`) | |||
| 297 | - | contentStr = colorLineRegex.ReplaceAllString(contentStr, "Color\nILoveCandy") | |||
| 298 | - | } | |||
| 299 | - | ||||
| 300 | - | switch err := p.fs.WriteFile(config.PathMntEtcPacmanConf, []byte(contentStr), 0644); { | |||
| 301 | - | case err != nil: | |||
| 302 | - | return fmt.Errorf("failed to write pacman.conf: %w", err) | |||
| 303 | - | } | |||
| 304 | - | ||||
| 305 | - | p.SendOutput(progressChan, "[OK] Pacman configured (color, parallel downloads, candy)") | |||
| 306 | - | return nil | |||
| 307 | - | } | |||
| 308 | - | ||||
| 309 | - | // installPacmanHooks installs pacman hooks for maintenance | |||
| 310 | - | func (p *PostInstallPhase) installPacmanHooks(progressChan chan<- ProgressUpdate) error { | |||
| 311 | - | p.SendOutput(progressChan, "Installing pacman hooks...") | |||
| 312 | - | ||||
| 313 | - | // Create hooks directory | |||
| 314 | - | switch err := p.fs.MkdirAll(config.PathMntEtcPacmanDHooks, 0755); { | |||
| 315 | - | case err != nil: | |||
| 316 | - | return fmt.Errorf("failed to create hooks directory: %w", err) | |||
| 317 | - | } | |||
| 318 | - | ||||
| 319 | - | // Limine update hook | |||
| 320 | - | limineHook := `[Trigger] | |||
| 321 | - | Operation = Install | |||
| 322 | - | Operation = Upgrade | |||
| 323 | - | Type = Package | |||
| 324 | - | Target = limine | |||
| 325 | - | ||||
| 326 | - | [Action] | |||
| 327 | - | Description = Deploying Limine after upgrade... | |||
| 328 | - | When = PostTransaction | |||
| 329 | - | Exec = /usr/bin/cp /usr/share/limine/BOOTX64.EFI /boot/EFI/limine/ | |||
| 330 | - | ` | |||
| 331 | - | ||||
| 332 | - | hookPath := filepath.Join(config.PathMntEtcPacmanDHooks, "99-limine.hook") | |||
| 333 | - | switch err := p.fs.WriteFile(hookPath, []byte(limineHook), 0644); { | |||
| 334 | - | case err != nil: | |||
| 335 | - | return fmt.Errorf("failed to write limine hook: %w", err) | |||
| 336 | - | } | |||
| 337 | - | ||||
| 338 | - | p.SendOutput(progressChan, "[OK] Limine update hook installed") | |||
| 339 | - | return nil | |||
| 340 | - | } | |||
| 341 | - | ||||
| 342 | - | // configureShell sets up bash shell with ArchUp defaults | |||
| 343 | - | func (p *PostInstallPhase) configureShell(progressChan chan<- ProgressUpdate) error { | |||
| 344 | - | p.SendOutput(progressChan, "Configuring shell environment...") | |||
| 345 | - | ||||
| 346 | - | // Get username from /mnt/home | |||
| 347 | - | homeDir := filepath.Join(config.PathMnt, "home") | |||
| 348 | - | entries, err := os.ReadDir(homeDir) | |||
| 349 | - | switch { | |||
| 350 | - | case err != nil: | |||
| 351 | - | return fmt.Errorf("failed to read home directory: %w", err) | |||
| 352 | - | case len(entries) == 0: | |||
| 353 | - | return fmt.Errorf("no user found in /home") | |||
| 354 | - | } | |||
| 355 | - | ||||
| 356 | - | username := entries[0].Name() | |||
| 357 | - | userHome := filepath.Join(config.PathMnt, "home", username) | |||
| 358 | - | archupDefault := filepath.Join(userHome, ".local", "share", "archup", "default") | |||
| 359 | - | archupDefaultBash := filepath.Join(archupDefault, "bash") | |||
| 360 | - | ||||
| 361 | - | p.SendOutput(progressChan, fmt.Sprintf("Configuring shell for user: %s", username)) | |||
| 362 | - | ||||
| 363 | - | // Create directory structure | |||
| 364 | - | switch err := p.fs.MkdirAll(archupDefaultBash, 0755); { | |||
| 365 | - | case err != nil: | |||
| 366 | - | return fmt.Errorf("failed to create shell config directory: %w", err) | |||
| 367 | - | } | |||
| 368 | - | ||||
| 369 | - | // Copy shell configuration files from templates | |||
| 370 | - | switch err := p.copyShellTemplates(archupDefault, archupDefaultBash); { | |||
| 371 | - | case err != nil: | |||
| 372 | - | return fmt.Errorf("failed to copy shell templates: %w", err) | |||
| 373 | - | } | |||
| 374 | - | ||||
| 375 | - | // Create .bashrc | |||
| 376 | - | bashrcContent, err := p.readTemplate(config.BashrcTemplate) | |||
| 377 | - | switch { | |||
| 378 | - | case err != nil: | |||
| 379 | - | return fmt.Errorf("failed to read bashrc template: %w", err) | |||
| 380 | - | } | |||
| 381 | - | ||||
| 382 | - | bashrcPath := filepath.Join(userHome, ".bashrc") | |||
| 383 | - | switch err := p.fs.WriteFile(bashrcPath, bashrcContent, 0644); { | |||
| 384 | - | case err != nil: | |||
| 385 | - | return fmt.Errorf("failed to write .bashrc: %w", err) | |||
| 386 | - | } | |||
| 387 | - | ||||
| 388 | - | // Set ownership to user | |||
| 389 | - | switch err := p.setShellOwnership(userHome, username); { | |||
| 390 | - | case err != nil: | |||
| 391 | - | return fmt.Errorf("failed to set ownership: %w", err) | |||
| 392 | - | } | |||
| 393 | - | ||||
| 394 | - | // Configure git delta | |||
| 395 | - | gitCommands := []string{ | |||
| 396 | - | fmt.Sprintf("su - %s -c 'git config --global core.pager delta'", username), | |||
| 397 | - | fmt.Sprintf("su - %s -c 'git config --global interactive.diffFilter \"delta --color-only\"'", username), | |||
| 398 | - | fmt.Sprintf("su - %s -c 'git config --global delta.navigate true'", username), | |||
| 399 | - | fmt.Sprintf("su - %s -c 'git config --global delta.light false'", username), | |||
| 400 | - | fmt.Sprintf("su - %s -c 'git config --global delta.side-by-side true'", username), | |||
| 401 | - | fmt.Sprintf("su - %s -c 'git config --global merge.conflictstyle diff3'", username), | |||
| 402 | - | fmt.Sprintf("su - %s -c 'git config --global diff.colorMoved default'", username), | |||
| 403 | - | } | |||
| 404 | - | ||||
| 405 | - | for _, gitCmd := range gitCommands { | |||
| 406 | - | switch err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, gitCmd); { | |||
| 407 | - | case err != nil: | |||
| 408 | - | // Non-fatal | |||
| 409 | - | p.SendOutput(progressChan, "[WARN] Failed to configure git delta") | |||
| 410 | - | } | |||
| 411 | - | } | |||
| 412 | - | ||||
| 413 | - | p.SendOutput(progressChan, "[OK] Shell environment configured") | |||
| 414 | - | return nil | |||
| 415 | - | } | |||
| 416 | - | ||||
| 417 | - | // copyShellTemplates copies shell config templates to user directory | |||
| 418 | - | func (p *PostInstallPhase) copyShellTemplates(archupDefault, archupDefaultBash string) error { | |||
| 419 | - | templates := map[string]string{ | |||
| 420 | - | config.ShellConfigTemplate: filepath.Join(archupDefaultBash, "shell"), | |||
| 421 | - | config.ShellInitTemplate: filepath.Join(archupDefaultBash, "init"), | |||
| 422 | - | config.ShellAliasesTemplate: filepath.Join(archupDefaultBash, "aliases"), | |||
| 423 | - | config.ShellEnvsTemplate: filepath.Join(archupDefaultBash, "envs"), | |||
| 424 | - | config.ShellRcTemplate: filepath.Join(archupDefaultBash, "rc"), | |||
| 425 | - | } | |||
| 426 | - | ||||
| 427 | - | for template, dest := range templates { | |||
| 428 | - | content, err := p.readTemplate(template) | |||
| 429 | - | switch { | |||
| 430 | - | case err != nil: | |||
| 431 | - | return fmt.Errorf("failed to read template %s: %w", template, err) | |||
| 432 | - | } | |||
| 433 | - | ||||
| 434 | - | switch err := p.fs.WriteFile(dest, content, 0644); { | |||
| 435 | - | case err != nil: | |||
| 436 | - | return fmt.Errorf("failed to write %s: %w", dest, err) | |||
| 437 | - | } | |||
| 438 | - | } | |||
| 439 | - | ||||
| 440 | - | return nil | |||
| 441 | - | } | |||
| 442 | - | ||||
| 443 | - | // readTemplate reads a template file from install path | |||
| 444 | - | func (p *PostInstallPhase) readTemplate(filename string) ([]byte, error) { | |||
| 445 | - | // Use DefaultInstallDir directly to match where bootstrap downloads files | |||
| 446 | - | templatePath := filepath.Join(config.DefaultInstallDir, filename) | |||
| 447 | - | return p.fs.ReadFile(templatePath) | |||
| 448 | - | } | |||
| 449 | - | ||||
| 450 | - | // setShellOwnership sets ownership of shell config files to user | |||
| 451 | - | func (p *PostInstallPhase) setShellOwnership(userHome, username string) error { | |||
| 452 | - | // Use chroot to run chown | |||
| 453 | - | relativeHome := strings.TrimPrefix(userHome, config.PathMnt) | |||
| 454 | - | chownCmd := fmt.Sprintf("chown -R %s:%s %s/.local %s/.bashrc", | |||
| 455 | - | username, username, relativeHome, relativeHome) | |||
| 456 | - | return p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, chownCmd) | |||
| 457 | - | } | |||
| 458 | - | ||||
| 459 | - | // verifyInstallation performs comprehensive verification | |||
| 460 | - | func (p *PostInstallPhase) verifyInstallation(progressChan chan<- ProgressUpdate) error { | |||
| 461 | - | p.SendOutput(progressChan, "Verifying installation components...") | |||
| 462 | - | ||||
| 463 | - | verificationFailed := false | |||
| 464 | - | ||||
| 465 | - | // Verify kernel | |||
| 466 | - | kernelName := p.config.KernelChoice | |||
| 467 | - | kernelPath := filepath.Join(config.PathMntBoot, fmt.Sprintf("vmlinuz-%s", kernelName)) | |||
| 468 | - | switch _, err := p.fs.Stat(kernelPath); { | |||
| 469 | - | case p.fs.IsNotExist(err): | |||
| 470 | - | p.SendOutput(progressChan, fmt.Sprintf("[ERROR] Kernel missing: %s", kernelPath)) | |||
| 471 | - | verificationFailed = true | |||
| 472 | - | default: | |||
| 473 | - | p.SendOutput(progressChan, "[OK] Kernel found") | |||
| 474 | - | } | |||
| 475 | - | ||||
| 476 | - | // Verify initramfs | |||
| 477 | - | initramfsPath := filepath.Join(config.PathMntBoot, fmt.Sprintf("initramfs-%s.img", kernelName)) | |||
| 478 | - | switch _, err := p.fs.Stat(initramfsPath); { | |||
| 479 | - | case p.fs.IsNotExist(err): | |||
| 480 | - | p.SendOutput(progressChan, fmt.Sprintf("[ERROR] Initramfs missing: %s", initramfsPath)) | |||
| 481 | - | verificationFailed = true | |||
| 482 | - | default: | |||
| 483 | - | p.SendOutput(progressChan, "[OK] Initramfs found") | |||
| 484 | - | } | |||
| 485 | - | ||||
| 486 | - | // Verify bootloader | |||
| 487 | - | bootloaderPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineBootloader) | |||
| 488 | - | switch _, err := p.fs.Stat(bootloaderPath); { | |||
| 489 | - | case p.fs.IsNotExist(err): | |||
| 490 | - | p.SendOutput(progressChan, "[ERROR] Bootloader missing") | |||
| 491 | - | verificationFailed = true | |||
| 492 | - | default: | |||
| 493 | - | p.SendOutput(progressChan, "[OK] Bootloader found") | |||
| 494 | - | } | |||
| 495 | - | ||||
| 496 | - | // Verify Limine config | |||
| 497 | - | limineConfPath := config.PathMntBootLimineConf | |||
| 498 | - | switch content, err := p.fs.ReadFile(limineConfPath); { | |||
| 499 | - | case err != nil: | |||
| 500 | - | p.SendOutput(progressChan, "[ERROR] Limine config missing") | |||
| 501 | - | verificationFailed = true | |||
| 502 | - | default: | |||
| 503 | - | contentStr := string(content) | |||
| 504 | - | switch { | |||
| 505 | - | case strings.Contains(contentStr, "protocol:") && strings.Contains(contentStr, "path:"): | |||
| 506 | - | p.SendOutput(progressChan, "[OK] Limine config validated") | |||
| 507 | - | default: | |||
| 508 | - | p.SendOutput(progressChan, "[WARN] Limine config may be incomplete") | |||
| 509 | - | } | |||
| 510 | - | } | |||
| 511 | - | ||||
| 512 | - | // Verify NetworkManager is enabled | |||
| 513 | - | result := p.sysExec.RunSimple("arch-chroot", config.PathMnt, "systemctl", "is-enabled", "NetworkManager") | |||
| 514 | - | switch { | |||
| 515 | - | case result.ExitCode != 0: | |||
| 516 | - | p.SendOutput(progressChan, "[ERROR] NetworkManager not enabled") | |||
| 517 | - | verificationFailed = true | |||
| 518 | - | default: | |||
| 519 | - | p.SendOutput(progressChan, "[OK] NetworkManager enabled") | |||
| 520 | - | } | |||
| 521 | - | ||||
| 522 | - | switch { | |||
| 523 | - | case verificationFailed: | |||
| 524 | - | p.SendOutput(progressChan, "[WARN] Verification completed with warnings") | |||
| 525 | - | return fmt.Errorf("verification completed with warnings") | |||
| 526 | - | default: | |||
| 527 | - | p.SendOutput(progressChan, "[OK] All critical components verified") | |||
| 528 | - | } | |||
| 529 | - | ||||
| 530 | - | return nil | |||
| 531 | - | } | |||
| 532 | - | ||||
| 533 | - | // unmountAll unmounts all filesystems | |||
| 534 | - | func (p *PostInstallPhase) unmountAll(progressChan chan<- ProgressUpdate) error { | |||
| 535 | - | p.SendOutput(progressChan, "Unmounting all partitions...") | |||
| 536 | - | ||||
| 537 | - | // Unmount /mnt recursively | |||
| 538 | - | switch err := system.Unmount(p.logger.LogPath(), config.PathMnt); { | |||
| 539 | - | case err != nil: | |||
| 540 | - | p.SendOutput(progressChan, "[WARN] Some partitions may still be mounted") | |||
| 541 | - | return fmt.Errorf("failed to unmount cleanly: %w", err) | |||
| 542 | - | } | |||
| 543 | - | ||||
| 544 | - | p.SendOutput(progressChan, "[OK] All partitions unmounted") | |||
| 545 | - | ||||
| 546 | - | // Close encrypted volume if encryption was used | |||
| 547 | - | switch p.config.EncryptionType { | |||
| 548 | - | case config.EncryptionLUKS, config.EncryptionLUKSLVM: | |||
| 549 | - | p.SendOutput(progressChan, "Closing encrypted volume...") | |||
| 550 | - | result := p.logger.ExecCommand("cryptsetup", "close", "cryptroot") | |||
| 551 | - | switch { | |||
| 552 | - | case result.ExitCode != 0: | |||
| 553 | - | p.SendOutput(progressChan, "[WARN] Failed to close encrypted volume") | |||
| 554 | - | default: | |||
| 555 | - | p.SendOutput(progressChan, "[OK] Encrypted volume closed") | |||
| 556 | - | } | |||
| 557 | - | } | |||
| 558 | - | ||||
| 559 | - | return nil | |||
| 560 | - | } | |||
| 561 | - | ||||
| 562 | - | // PostCheck validates post-install completion | |||
| 563 | - | func (p *PostInstallPhase) PostCheck() error { | |||
| 564 | - | // Verification already done in Execute | |||
| 565 | - | return p.config.Save() | |||
| 566 | - | } | |||
| 567 | - | ||||
| 568 | - | // Rollback for post-install phase | |||
| 569 | - | func (p *PostInstallPhase) Rollback() error { | |||
| 570 | - | // Attempt to unmount if something failed | |||
| 571 | - | _ = system.Unmount(p.logger.LogPath(), config.PathMnt) | |||
| 572 | - | return nil | |||
| 573 | - | } | |||
| 574 | - | ||||
| 575 | - | // CanSkip returns false - post-install cannot be skipped | |||
| 576 | - | func (p *PostInstallPhase) CanSkip() bool { | |||
| 577 | - | return false | |||
| 578 | - | } | |||
| 579 | - | ||||
| 580 | - | // setupPostBoot downloads and configures first-boot service | |||
| 581 | - | func (p *PostInstallPhase) setupPostBoot(progressChan chan<- ProgressUpdate) error { | |||
| 582 | - | p.SendOutput(progressChan, "Configuring first-boot service...") | |||
| 583 | - | ||||
| 584 | - | switch err := p.fs.MkdirAll(config.PathMntPostBoot, 0755); { | |||
| 585 | - | case err != nil: | |||
| 586 | - | return fmt.Errorf("failed to create post-boot directory: %w", err) | |||
| 587 | - | } | |||
| 588 | - | ||||
| 589 | - | // Fetch logo.txt (local-first, then remote) | |||
| 590 | - | p.SendOutput(progressChan, "Fetching logo.txt...") | |||
| 591 | - | logoData, err := p.fetchFile("logo.txt") | |||
| 592 | - | if err != nil { | |||
| 593 | - | return fmt.Errorf("failed to fetch logo.txt: %w", err) | |||
| 594 | - | } | |||
| 595 | - | logoPath := filepath.Join(config.PathMntPostBoot, "logo.txt") | |||
| 596 | - | if err := p.fs.WriteFile(logoPath, logoData, 0644); err != nil { | |||
| 597 | - | return fmt.Errorf("failed to write logo.txt: %w", err) | |||
| 598 | - | } | |||
| 599 | - | ||||
| 600 | - | // Fetch all post-boot scripts (local-first, then remote) | |||
| 601 | - | for _, script := range config.PostBootScripts { | |||
| 602 | - | p.SendOutput(progressChan, fmt.Sprintf("Fetching %s...", script)) | |||
| 603 | - | ||||
| 604 | - | data, err := p.fetchFile(filepath.Join("install", "mandatory", "post-boot", script)) | |||
| 605 | - | if err != nil { | |||
| 606 | - | return fmt.Errorf("failed to fetch %s: %w", script, err) | |||
| 607 | - | } | |||
| 608 | - | ||||
| 609 | - | scriptPath := filepath.Join(config.PathMntPostBoot, script) | |||
| 610 | - | if err := p.fs.WriteFile(scriptPath, data, 0755); err != nil { | |||
| 611 | - | return fmt.Errorf("failed to write %s: %w", script, err) | |||
| 612 | - | } | |||
| 613 | - | } | |||
| 614 | - | ||||
| 615 | - | p.SendOutput(progressChan, "[OK] Post-boot scripts ready") | |||
| 616 | - | ||||
| 617 | - | // Fetch service template (local-first, then remote) | |||
| 618 | - | p.SendOutput(progressChan, "Creating systemd service...") | |||
| 619 | - | templateBytes, err := p.fetchFile(config.PostBootServiceTemplate) | |||
| 620 | - | if err != nil { | |||
| 621 | - | return fmt.Errorf("failed to fetch service template: %w", err) | |||
| 622 | - | } | |||
| 623 | - | ||||
| 624 | - | serviceContent := string(templateBytes) | |||
| 625 | - | serviceContent = strings.ReplaceAll(serviceContent, "__ARCHUP_EMAIL__", p.config.Email) | |||
| 626 | - | serviceContent = strings.ReplaceAll(serviceContent, "__ARCHUP_USERNAME__", p.config.Username) | |||
| 627 | - | ||||
| 628 | - | servicePath := filepath.Join(config.PathMntSystemdSystem, config.PostBootServiceName) | |||
| 629 | - | switch err := p.fs.WriteFile(servicePath, []byte(serviceContent), 0644); { | |||
| 630 | - | case err != nil: | |||
| 631 | - | return fmt.Errorf("failed to write service file: %w", err) | |||
| 632 | - | } | |||
| 633 | - | ||||
| 634 | - | p.SendOutput(progressChan, "[OK] Service file created") | |||
| 635 | - | ||||
| 636 | - | p.SendOutput(progressChan, "Enabling first-boot service...") | |||
| 637 | - | switch err := p.chrExec.ChrootSystemctl(p.logger.LogPath(), config.PathMnt, "enable", config.PostBootServiceName); { | |||
| 638 | - | case err != nil: | |||
| 639 | - | return fmt.Errorf("failed to enable service: %w", err) | |||
| 640 | - | } | |||
| 641 | - | ||||
| 642 | - | p.SendOutput(progressChan, "[OK] First-boot service enabled") | |||
| 643 | - | return nil | |||
| 644 | - | } | |||
D internal/phases/postinstall_test.go +0 -491
| @@ -1,491 +0,0 @@ | |||||
| 1 | - | package phases | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "fmt" | |||
| 5 | - | "path/filepath" | |||
| 6 | - | "testing" | |||
| 7 | - | ||||
| 8 | - | "github.com/bnema/archup/internal/config" | |||
| 9 | - | "github.com/bnema/archup/internal/interfaces/mocks" | |||
| 10 | - | "github.com/bnema/archup/internal/logger" | |||
| 11 | - | "github.com/bnema/archup/internal/system" | |||
| 12 | - | "go.uber.org/mock/gomock" | |||
| 13 | - | ) | |||
| 14 | - | ||||
| 15 | - | // TestPostInstallPhasePreCheck tests post-install prerequisites | |||
| 16 | - | func TestPostInstallPhasePreCheck(t *testing.T) { | |||
| 17 | - | tests := []struct { | |||
| 18 | - | name string | |||
| 19 | - | setupMocks func(*mocks.MockFileSystem, *mocks.MockSystemExecutor) | |||
| 20 | - | wantErr bool | |||
| 21 | - | errContains string | |||
| 22 | - | }{ | |||
| 23 | - | { | |||
| 24 | - | name: "/mnt mounted and boot directory exists", | |||
| 25 | - | setupMocks: func(mockFS *mocks.MockFileSystem, mockSysExec *mocks.MockSystemExecutor) { | |||
| 26 | - | mockSysExec.EXPECT().RunSimple("mountpoint", "-q", config.PathMnt).Return(system.CommandResult{ExitCode: 0}).Times(1) | |||
| 27 | - | mockFS.EXPECT().Stat(config.PathMntBoot).Return(mockFileInfo{}, nil).Times(1) | |||
| 28 | - | mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1) | |||
| 29 | - | }, | |||
| 30 | - | wantErr: false, | |||
| 31 | - | }, | |||
| 32 | - | { | |||
| 33 | - | name: "/mnt not mounted", | |||
| 34 | - | setupMocks: func(mockFS *mocks.MockFileSystem, mockSysExec *mocks.MockSystemExecutor) { | |||
| 35 | - | mockSysExec.EXPECT().RunSimple("mountpoint", "-q", config.PathMnt).Return(system.CommandResult{ExitCode: 32}).Times(1) | |||
| 36 | - | }, | |||
| 37 | - | wantErr: true, | |||
| 38 | - | errContains: "is not mounted", | |||
| 39 | - | }, | |||
| 40 | - | { | |||
| 41 | - | name: "boot directory not found", | |||
| 42 | - | setupMocks: func(mockFS *mocks.MockFileSystem, mockSysExec *mocks.MockSystemExecutor) { | |||
| 43 | - | mockSysExec.EXPECT().RunSimple("mountpoint", "-q", config.PathMnt).Return(system.CommandResult{ExitCode: 0}).Times(1) | |||
| 44 | - | mockFS.EXPECT().Stat(config.PathMntBoot).Return(nil, fmt.Errorf("not found")).Times(1) | |||
| 45 | - | mockFS.EXPECT().IsNotExist(gomock.Any()).Return(true).Times(1) | |||
| 46 | - | }, | |||
| 47 | - | wantErr: true, | |||
| 48 | - | errContains: "boot directory not found", | |||
| 49 | - | }, | |||
| 50 | - | } | |||
| 51 | - | ||||
| 52 | - | for _, tt := range tests { | |||
| 53 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 54 | - | ctrl := gomock.NewController(t) | |||
| 55 | - | defer ctrl.Finish() | |||
| 56 | - | ||||
| 57 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 58 | - | mockHTTP := mocks.NewMockHTTPClient(ctrl) | |||
| 59 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 60 | - | mockChrExec := mocks.NewMockChrootExecutor(ctrl) | |||
| 61 | - | ||||
| 62 | - | tt.setupMocks(mockFS, mockSysExec) | |||
| 63 | - | ||||
| 64 | - | tmpDir := t.TempDir() | |||
| 65 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 66 | - | log, err := logger.New(logPath, false) | |||
| 67 | - | if err != nil { | |||
| 68 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 69 | - | } | |||
| 70 | - | defer func() { | |||
| 71 | - | if err := log.Close(); err != nil { | |||
| 72 | - | t.Fatalf("failed to close log: %v", err) | |||
| 73 | - | } | |||
| 74 | - | }() | |||
| 75 | - | ||||
| 76 | - | cfg := config.NewConfig("test") | |||
| 77 | - | phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec) | |||
| 78 | - | ||||
| 79 | - | err = phase.PreCheck() | |||
| 80 | - | if (err != nil) != tt.wantErr { | |||
| 81 | - | t.Errorf("PreCheck() error = %v, wantErr %v", err, tt.wantErr) | |||
| 82 | - | return | |||
| 83 | - | } | |||
| 84 | - | if tt.wantErr && err != nil { | |||
| 85 | - | if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) { | |||
| 86 | - | t.Errorf("PreCheck() error = %q, should contain %q", errMsg, tt.errContains) | |||
| 87 | - | } | |||
| 88 | - | } | |||
| 89 | - | }) | |||
| 90 | - | } | |||
| 91 | - | } | |||
| 92 | - | ||||
| 93 | - | // TestPostInstallPhaseShellConfiguration tests shell configuration integration | |||
| 94 | - | func TestPostInstallPhaseShellConfiguration(t *testing.T) { | |||
| 95 | - | tests := []struct { | |||
| 96 | - | name string | |||
| 97 | - | setupMocks func(*mocks.MockFileSystem, *mocks.MockChrootExecutor) | |||
| 98 | - | wantErr bool | |||
| 99 | - | }{ | |||
| 100 | - | { | |||
| 101 | - | name: "Successfully configure shell", | |||
| 102 | - | setupMocks: func(mockFS *mocks.MockFileSystem, mockChrExec *mocks.MockChrootExecutor) { | |||
| 103 | - | mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() | |||
| 104 | - | mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte("content"), nil).AnyTimes() | |||
| 105 | - | mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() | |||
| 106 | - | mockChrExec.EXPECT().ChrootExec(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() | |||
| 107 | - | }, | |||
| 108 | - | wantErr: false, | |||
| 109 | - | }, | |||
| 110 | - | } | |||
| 111 | - | ||||
| 112 | - | for _, tt := range tests { | |||
| 113 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 114 | - | ctrl := gomock.NewController(t) | |||
| 115 | - | defer ctrl.Finish() | |||
| 116 | - | ||||
| 117 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 118 | - | mockHTTP := mocks.NewMockHTTPClient(ctrl) | |||
| 119 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 120 | - | mockChrExec := mocks.NewMockChrootExecutor(ctrl) | |||
| 121 | - | ||||
| 122 | - | tt.setupMocks(mockFS, mockChrExec) | |||
| 123 | - | ||||
| 124 | - | tmpDir := t.TempDir() | |||
| 125 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 126 | - | log, err := logger.New(logPath, false) | |||
| 127 | - | if err != nil { | |||
| 128 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 129 | - | } | |||
| 130 | - | defer func() { | |||
| 131 | - | if err := log.Close(); err != nil { | |||
| 132 | - | t.Fatalf("failed to close log: %v", err) | |||
| 133 | - | } | |||
| 134 | - | }() | |||
| 135 | - | ||||
| 136 | - | cfg := config.NewConfig("test") | |||
| 137 | - | phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec) | |||
| 138 | - | ||||
| 139 | - | // Verify phase can be created | |||
| 140 | - | if phase == nil { | |||
| 141 | - | t.Error("PostInstallPhase is nil") | |||
| 142 | - | } | |||
| 143 | - | }) | |||
| 144 | - | } | |||
| 145 | - | } | |||
| 146 | - | ||||
| 147 | - | // TestPostInstallPhasePostCheck tests post-install validation | |||
| 148 | - | func TestPostInstallPhasePostCheck(t *testing.T) { | |||
| 149 | - | ctrl := gomock.NewController(t) | |||
| 150 | - | defer ctrl.Finish() | |||
| 151 | - | ||||
| 152 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 153 | - | mockHTTP := mocks.NewMockHTTPClient(ctrl) | |||
| 154 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 155 | - | mockChrExec := mocks.NewMockChrootExecutor(ctrl) | |||
| 156 | - | ||||
| 157 | - | tmpDir := t.TempDir() | |||
| 158 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 159 | - | log, err := logger.New(logPath, false) | |||
| 160 | - | if err != nil { | |||
| 161 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 162 | - | } | |||
| 163 | - | defer func() { | |||
| 164 | - | if err := log.Close(); err != nil { | |||
| 165 | - | t.Fatalf("failed to close log: %v", err) | |||
| 166 | - | } | |||
| 167 | - | }() | |||
| 168 | - | ||||
| 169 | - | cfg := config.NewConfig("test") | |||
| 170 | - | cfg.ConfigPath = filepath.Join(tmpDir, "config") | |||
| 171 | - | ||||
| 172 | - | phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec) | |||
| 173 | - | ||||
| 174 | - | err = phase.PostCheck() | |||
| 175 | - | // PostCheck should succeed as it just saves the config | |||
| 176 | - | if err != nil { | |||
| 177 | - | t.Errorf("PostCheck() error = %v, want nil", err) | |||
| 178 | - | } | |||
| 179 | - | } | |||
| 180 | - | ||||
| 181 | - | // TestPostInstallPhaseRollback tests post-install rollback | |||
| 182 | - | func TestPostInstallPhaseRollback(t *testing.T) { | |||
| 183 | - | ctrl := gomock.NewController(t) | |||
| 184 | - | defer ctrl.Finish() | |||
| 185 | - | ||||
| 186 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 187 | - | mockHTTP := mocks.NewMockHTTPClient(ctrl) | |||
| 188 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 189 | - | mockChrExec := mocks.NewMockChrootExecutor(ctrl) | |||
| 190 | - | ||||
| 191 | - | tmpDir := t.TempDir() | |||
| 192 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 193 | - | log, err := logger.New(logPath, false) | |||
| 194 | - | if err != nil { | |||
| 195 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 196 | - | } | |||
| 197 | - | defer func() { | |||
| 198 | - | if err := log.Close(); err != nil { | |||
| 199 | - | t.Fatalf("failed to close log: %v", err) | |||
| 200 | - | } | |||
| 201 | - | }() | |||
| 202 | - | ||||
| 203 | - | cfg := config.NewConfig("test") | |||
| 204 | - | phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec) | |||
| 205 | - | ||||
| 206 | - | // Rollback should always succeed (it's a cleanup operation) | |||
| 207 | - | err = phase.Rollback() | |||
| 208 | - | if err != nil { | |||
| 209 | - | t.Errorf("Rollback() error = %v, want nil", err) | |||
| 210 | - | } | |||
| 211 | - | } | |||
| 212 | - | ||||
| 213 | - | // TestPostInstallPhaseCanSkip tests the CanSkip method | |||
| 214 | - | func TestPostInstallPhaseCanSkip(t *testing.T) { | |||
| 215 | - | ctrl := gomock.NewController(t) | |||
| 216 | - | defer ctrl.Finish() | |||
| 217 | - | ||||
| 218 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 219 | - | mockHTTP := mocks.NewMockHTTPClient(ctrl) | |||
| 220 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 221 | - | mockChrExec := mocks.NewMockChrootExecutor(ctrl) | |||
| 222 | - | ||||
| 223 | - | tmpDir := t.TempDir() | |||
| 224 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 225 | - | log, err := logger.New(logPath, false) | |||
| 226 | - | if err != nil { | |||
| 227 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 228 | - | } | |||
| 229 | - | defer func() { | |||
| 230 | - | if err := log.Close(); err != nil { | |||
| 231 | - | t.Fatalf("failed to close log: %v", err) | |||
| 232 | - | } | |||
| 233 | - | }() | |||
| 234 | - | ||||
| 235 | - | cfg := config.NewConfig("test") | |||
| 236 | - | phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec) | |||
| 237 | - | ||||
| 238 | - | if phase.CanSkip() { | |||
| 239 | - | t.Error("CanSkip() = true, want false") | |||
| 240 | - | } | |||
| 241 | - | } | |||
| 242 | - | ||||
| 243 | - | // TestPostInstallPhaseExecute tests the Execute method | |||
| 244 | - | func TestPostInstallPhaseExecute(t *testing.T) { | |||
| 245 | - | ctrl := gomock.NewController(t) | |||
| 246 | - | defer ctrl.Finish() | |||
| 247 | - | ||||
| 248 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 249 | - | mockHTTP := mocks.NewMockHTTPClient(ctrl) | |||
| 250 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 251 | - | mockChrExec := mocks.NewMockChrootExecutor(ctrl) | |||
| 252 | - | ||||
| 253 | - | // Allow any file system and executor operations | |||
| 254 | - | mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte{}, nil).AnyTimes() | |||
| 255 | - | mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() | |||
| 256 | - | mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() | |||
| 257 | - | mockFS.EXPECT().Create(gomock.Any()).Return(&MockWriteCloser{}, nil).AnyTimes() | |||
| 258 | - | mockFS.EXPECT().Stat(gomock.Any()).Return(mockFileInfo{}, nil).AnyTimes() | |||
| 259 | - | mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).AnyTimes() | |||
| 260 | - | mockFS.EXPECT().Chmod(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() | |||
| 261 | - | mockHTTP.EXPECT().Get(gomock.Any()).Return(nil, fmt.Errorf("skip http")).AnyTimes() | |||
| 262 | - | mockChrExec.EXPECT().ChrootExec(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() | |||
| 263 | - | mockChrExec.EXPECT().ChrootPacman(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() | |||
| 264 | - | mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() | |||
| 265 | - | mockSysExec.EXPECT().RunSimple(gomock.Any(), gomock.Any()).Return(system.CommandResult{ExitCode: 0}).AnyTimes() | |||
| 266 | - | ||||
| 267 | - | tmpDir := t.TempDir() | |||
| 268 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 269 | - | log, err := logger.New(logPath, false) | |||
| 270 | - | if err != nil { | |||
| 271 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 272 | - | } | |||
| 273 | - | defer func() { | |||
| 274 | - | if err := log.Close(); err != nil { | |||
| 275 | - | t.Fatalf("failed to close log: %v", err) | |||
| 276 | - | } | |||
| 277 | - | }() | |||
| 278 | - | ||||
| 279 | - | cfg := config.NewConfig("test") | |||
| 280 | - | cfg.KernelChoice = "linux" | |||
| 281 | - | cfg.RawURL = "https://example.com" | |||
| 282 | - | ||||
| 283 | - | phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec) | |||
| 284 | - | ||||
| 285 | - | progressChan := make(chan ProgressUpdate, 100) | |||
| 286 | - | ||||
| 287 | - | // Note: Full Execute testing is complex due to multiple async operations | |||
| 288 | - | result := phase.Execute(progressChan) | |||
| 289 | - | ||||
| 290 | - | // Verify result structure is valid | |||
| 291 | - | _ = result // If we got here, Execute returned a valid result structure | |||
| 292 | - | } | |||
| 293 | - | ||||
| 294 | - | // TestPostInstallPhaseEncryptionHandling tests encryption-aware operations | |||
| 295 | - | func TestPostInstallPhaseEncryptionHandling(t *testing.T) { | |||
| 296 | - | tests := []struct { | |||
| 297 | - | name string | |||
| 298 | - | encryptionType string | |||
| 299 | - | expectedClosing bool | |||
| 300 | - | }{ | |||
| 301 | - | { | |||
| 302 | - | name: "Unencrypted - no closing needed", | |||
| 303 | - | encryptionType: config.EncryptionNone, | |||
| 304 | - | expectedClosing: false, | |||
| 305 | - | }, | |||
| 306 | - | { | |||
| 307 | - | name: "LUKS encrypted - closing needed", | |||
| 308 | - | encryptionType: config.EncryptionLUKS, | |||
| 309 | - | expectedClosing: true, | |||
| 310 | - | }, | |||
| 311 | - | { | |||
| 312 | - | name: "LUKS+LVM - closing needed", | |||
| 313 | - | encryptionType: config.EncryptionLUKSLVM, | |||
| 314 | - | expectedClosing: true, | |||
| 315 | - | }, | |||
| 316 | - | } | |||
| 317 | - | ||||
| 318 | - | for _, tt := range tests { | |||
| 319 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 320 | - | ctrl := gomock.NewController(t) | |||
| 321 | - | defer ctrl.Finish() | |||
| 322 | - | ||||
| 323 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 324 | - | mockHTTP := mocks.NewMockHTTPClient(ctrl) | |||
| 325 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 326 | - | mockChrExec := mocks.NewMockChrootExecutor(ctrl) | |||
| 327 | - | ||||
| 328 | - | tmpDir := t.TempDir() | |||
| 329 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 330 | - | log, err := logger.New(logPath, false) | |||
| 331 | - | if err != nil { | |||
| 332 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 333 | - | } | |||
| 334 | - | defer func() { | |||
| 335 | - | if err := log.Close(); err != nil { | |||
| 336 | - | t.Fatalf("failed to close log: %v", err) | |||
| 337 | - | } | |||
| 338 | - | }() | |||
| 339 | - | ||||
| 340 | - | cfg := config.NewConfig("test") | |||
| 341 | - | cfg.EncryptionType = tt.encryptionType | |||
| 342 | - | ||||
| 343 | - | phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec) | |||
| 344 | - | ||||
| 345 | - | // Verify encryption type is stored correctly | |||
| 346 | - | if phase.config.EncryptionType != tt.encryptionType { | |||
| 347 | - | t.Errorf("Encryption type = %q, want %q", phase.config.EncryptionType, tt.encryptionType) | |||
| 348 | - | } | |||
| 349 | - | ||||
| 350 | - | // Verify closing would be needed based on encryption | |||
| 351 | - | needsClosing := tt.encryptionType == config.EncryptionLUKS || tt.encryptionType == config.EncryptionLUKSLVM | |||
| 352 | - | if needsClosing != tt.expectedClosing { | |||
| 353 | - | t.Errorf("Closing needed = %v, want %v", needsClosing, tt.expectedClosing) | |||
| 354 | - | } | |||
| 355 | - | }) | |||
| 356 | - | } | |||
| 357 | - | } | |||
| 358 | - | ||||
| 359 | - | // TestPostInstallPhaseFileSystemOperations tests file system interaction patterns | |||
| 360 | - | func TestPostInstallPhaseFileSystemOperations(t *testing.T) { | |||
| 361 | - | tests := []struct { | |||
| 362 | - | name string | |||
| 363 | - | operation string // "read", "write", "mkdir", "create" | |||
| 364 | - | }{ | |||
| 365 | - | { | |||
| 366 | - | name: "Read pacman.conf", | |||
| 367 | - | operation: "read", | |||
| 368 | - | }, | |||
| 369 | - | { | |||
| 370 | - | name: "Write pacman.conf", | |||
| 371 | - | operation: "write", | |||
| 372 | - | }, | |||
| 373 | - | { | |||
| 374 | - | name: "Create hook directory", | |||
| 375 | - | operation: "mkdir", | |||
| 376 | - | }, | |||
| 377 | - | { | |||
| 378 | - | name: "Create hook file", | |||
| 379 | - | operation: "create", | |||
| 380 | - | }, | |||
| 381 | - | } | |||
| 382 | - | ||||
| 383 | - | for _, tt := range tests { | |||
| 384 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 385 | - | ctrl := gomock.NewController(t) | |||
| 386 | - | defer ctrl.Finish() | |||
| 387 | - | ||||
| 388 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 389 | - | mockHTTP := mocks.NewMockHTTPClient(ctrl) | |||
| 390 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 391 | - | mockChrExec := mocks.NewMockChrootExecutor(ctrl) | |||
| 392 | - | ||||
| 393 | - | // Setup basic expectations based on operation | |||
| 394 | - | switch tt.operation { | |||
| 395 | - | case "read": | |||
| 396 | - | mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte("content"), nil).AnyTimes() | |||
| 397 | - | case "write": | |||
| 398 | - | mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() | |||
| 399 | - | case "mkdir": | |||
| 400 | - | mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() | |||
| 401 | - | case "create": | |||
| 402 | - | mockFS.EXPECT().Create(gomock.Any()).Return(&MockWriteCloser{}, nil).AnyTimes() | |||
| 403 | - | } | |||
| 404 | - | ||||
| 405 | - | tmpDir := t.TempDir() | |||
| 406 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 407 | - | log, err := logger.New(logPath, false) | |||
| 408 | - | if err != nil { | |||
| 409 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 410 | - | } | |||
| 411 | - | defer func() { | |||
| 412 | - | if err := log.Close(); err != nil { | |||
| 413 | - | t.Fatalf("failed to close log: %v", err) | |||
| 414 | - | } | |||
| 415 | - | }() | |||
| 416 | - | ||||
| 417 | - | cfg := config.NewConfig("test") | |||
| 418 | - | phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec) | |||
| 419 | - | ||||
| 420 | - | // Verify phase can be created | |||
| 421 | - | if phase == nil { | |||
| 422 | - | t.Error("PostInstallPhase is nil") | |||
| 423 | - | } | |||
| 424 | - | }) | |||
| 425 | - | } | |||
| 426 | - | } | |||
| 427 | - | ||||
| 428 | - | // TestPostInstallPhaseKernelVerification tests kernel verification logic | |||
| 429 | - | func TestPostInstallPhaseKernelVerification(t *testing.T) { | |||
| 430 | - | tests := []struct { | |||
| 431 | - | name string | |||
| 432 | - | kernelChoice string | |||
| 433 | - | expectedFormat string | |||
| 434 | - | }{ | |||
| 435 | - | { | |||
| 436 | - | name: "Linux kernel", | |||
| 437 | - | kernelChoice: "linux", | |||
| 438 | - | expectedFormat: "vmlinuz-linux", | |||
| 439 | - | }, | |||
| 440 | - | { | |||
| 441 | - | name: "Linux LTS kernel", | |||
| 442 | - | kernelChoice: "linux-lts", | |||
| 443 | - | expectedFormat: "vmlinuz-linux-lts", | |||
| 444 | - | }, | |||
| 445 | - | { | |||
| 446 | - | name: "Linux Zen kernel", | |||
| 447 | - | kernelChoice: "linux-zen", | |||
| 448 | - | expectedFormat: "vmlinuz-linux-zen", | |||
| 449 | - | }, | |||
| 450 | - | } | |||
| 451 | - | ||||
| 452 | - | for _, tt := range tests { | |||
| 453 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 454 | - | ctrl := gomock.NewController(t) | |||
| 455 | - | defer ctrl.Finish() | |||
| 456 | - | ||||
| 457 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 458 | - | mockHTTP := mocks.NewMockHTTPClient(ctrl) | |||
| 459 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 460 | - | mockChrExec := mocks.NewMockChrootExecutor(ctrl) | |||
| 461 | - | ||||
| 462 | - | tmpDir := t.TempDir() | |||
| 463 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 464 | - | log, err := logger.New(logPath, false) | |||
| 465 | - | if err != nil { | |||
| 466 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 467 | - | } | |||
| 468 | - | defer func() { | |||
| 469 | - | if err := log.Close(); err != nil { | |||
| 470 | - | t.Fatalf("failed to close log: %v", err) | |||
| 471 | - | } | |||
| 472 | - | }() | |||
| 473 | - | ||||
| 474 | - | cfg := config.NewConfig("test") | |||
| 475 | - | cfg.KernelChoice = tt.kernelChoice | |||
| 476 | - | ||||
| 477 | - | phase := NewPostInstallPhase(cfg, log, mockFS, mockHTTP, mockSysExec, mockChrExec) | |||
| 478 | - | ||||
| 479 | - | // Verify kernel choice is stored | |||
| 480 | - | if phase.config.KernelChoice != tt.kernelChoice { | |||
| 481 | - | t.Errorf("Kernel choice = %q, want %q", phase.config.KernelChoice, tt.kernelChoice) | |||
| 482 | - | } | |||
| 483 | - | ||||
| 484 | - | // Verify expected kernel name format | |||
| 485 | - | expectedKernelName := fmt.Sprintf("vmlinuz-%s", tt.kernelChoice) | |||
| 486 | - | if !containsSubstring(expectedKernelName, "vmlinuz") { | |||
| 487 | - | t.Errorf("Expected kernel name format %q doesn't contain 'vmlinuz'", expectedKernelName) | |||
| 488 | - | } | |||
| 489 | - | }) | |||
| 490 | - | } | |||
| 491 | - | } | |||
D internal/phases/preflight.go +0 -127
| @@ -1,127 +0,0 @@ | |||||
| 1 | - | package phases | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "fmt" | |||
| 5 | - | "strings" | |||
| 6 | - | ||||
| 7 | - | "github.com/bnema/archup/internal/config" | |||
| 8 | - | "github.com/bnema/archup/internal/interfaces" | |||
| 9 | - | "github.com/bnema/archup/internal/logger" | |||
| 10 | - | ) | |||
| 11 | - | ||||
| 12 | - | // PreflightPhase handles system validation and initial configuration | |||
| 13 | - | type PreflightPhase struct { | |||
| 14 | - | *BasePhase | |||
| 15 | - | fs interfaces.FileSystem | |||
| 16 | - | cmdExec interfaces.CommandExecutor | |||
| 17 | - | } | |||
| 18 | - | ||||
| 19 | - | // NewPreflightPhase creates a new preflight phase | |||
| 20 | - | func NewPreflightPhase(cfg *config.Config, log *logger.Logger, fs interfaces.FileSystem, cmdExec interfaces.CommandExecutor) *PreflightPhase { | |||
| 21 | - | return &PreflightPhase{ | |||
| 22 | - | BasePhase: NewBasePhase("preflight", "Preflight Checks", cfg, log), | |||
| 23 | - | fs: fs, | |||
| 24 | - | cmdExec: cmdExec, | |||
| 25 | - | } | |||
| 26 | - | } | |||
| 27 | - | ||||
| 28 | - | // PreCheck validates system requirements | |||
| 29 | - | func (p *PreflightPhase) PreCheck() error { | |||
| 30 | - | // Check for Arch Linux | |||
| 31 | - | if _, err := p.fs.Stat("/etc/arch-release"); p.fs.IsNotExist(err) { | |||
| 32 | - | return fmt.Errorf("must be running on Arch Linux or Arch ISO") | |||
| 33 | - | } | |||
| 34 | - | ||||
| 35 | - | // Check for derivatives | |||
| 36 | - | derivatives := map[string]string{ | |||
| 37 | - | "/etc/cachyos-release": "CachyOS", | |||
| 38 | - | "/etc/eos-release": "EndeavourOS", | |||
| 39 | - | "/etc/garuda-release": "Garuda", | |||
| 40 | - | "/etc/manjaro-release": "Manjaro", | |||
| 41 | - | } | |||
| 42 | - | ||||
| 43 | - | for marker, name := range derivatives { | |||
| 44 | - | if _, err := p.fs.Stat(marker); err == nil { | |||
| 45 | - | return fmt.Errorf("must be vanilla Arch (detected %s)", name) | |||
| 46 | - | } | |||
| 47 | - | } | |||
| 48 | - | ||||
| 49 | - | // Check architecture | |||
| 50 | - | out, err := p.cmdExec.Execute("uname", "-m") | |||
| 51 | - | arch := strings.TrimSpace(string(out)) | |||
| 52 | - | ||||
| 53 | - | switch { | |||
| 54 | - | case err != nil: | |||
| 55 | - | return fmt.Errorf("failed to detect architecture: %w", err) | |||
| 56 | - | case arch != "x86_64": | |||
| 57 | - | return fmt.Errorf("must be x86_64 architecture (detected: %s)", arch) | |||
| 58 | - | } | |||
| 59 | - | ||||
| 60 | - | // Check UEFI | |||
| 61 | - | if _, err := p.fs.Stat("/sys/firmware/efi/efivars"); p.fs.IsNotExist(err) { | |||
| 62 | - | return fmt.Errorf("must be UEFI boot mode (legacy BIOS not supported)") | |||
| 63 | - | } | |||
| 64 | - | ||||
| 65 | - | // Check Secure Boot | |||
| 66 | - | out, err = p.cmdExec.Execute("bootctl", "status") | |||
| 67 | - | switch { | |||
| 68 | - | case err == nil && strings.Contains(string(out), "Secure Boot: enabled"): | |||
| 69 | - | return fmt.Errorf("secure boot must be disabled") | |||
| 70 | - | } | |||
| 71 | - | ||||
| 72 | - | return nil | |||
| 73 | - | } | |||
| 74 | - | ||||
| 75 | - | // Execute runs the preflight phase | |||
| 76 | - | func (p *PreflightPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult { | |||
| 77 | - | p.SendProgress(progressChan, "Running preflight checks...", 1, 2) | |||
| 78 | - | ||||
| 79 | - | // Set defaults | |||
| 80 | - | p.SetDefaults() | |||
| 81 | - | ||||
| 82 | - | p.SendProgress(progressChan, "Preflight checks complete", 2, 2) | |||
| 83 | - | p.SendComplete(progressChan, "Preflight complete") | |||
| 84 | - | ||||
| 85 | - | return PhaseResult{ | |||
| 86 | - | Success: true, | |||
| 87 | - | Message: "Preflight checks passed", | |||
| 88 | - | } | |||
| 89 | - | } | |||
| 90 | - | ||||
| 91 | - | // SetDefaults sets default configuration values | |||
| 92 | - | func (p *PreflightPhase) SetDefaults() { | |||
| 93 | - | if p.config.Hostname == "" { | |||
| 94 | - | p.config.Hostname = "archup" | |||
| 95 | - | } | |||
| 96 | - | ||||
| 97 | - | if p.config.Timezone == "" { | |||
| 98 | - | p.config.Timezone = "UTC" | |||
| 99 | - | } | |||
| 100 | - | ||||
| 101 | - | // Hardcoded bootloader | |||
| 102 | - | p.config.Bootloader = config.BootloaderLimine | |||
| 103 | - | } | |||
| 104 | - | ||||
| 105 | - | // PostCheck validates configuration after form submission | |||
| 106 | - | func (p *PreflightPhase) PostCheck() error { | |||
| 107 | - | switch { | |||
| 108 | - | case p.config.Username == "": | |||
| 109 | - | return fmt.Errorf("username is required") | |||
| 110 | - | case p.config.Hostname == "": | |||
| 111 | - | return fmt.Errorf("hostname is required") | |||
| 112 | - | case p.config.Timezone == "": | |||
| 113 | - | return fmt.Errorf("timezone is required") | |||
| 114 | - | } | |||
| 115 | - | ||||
| 116 | - | return p.config.Save() | |||
| 117 | - | } | |||
| 118 | - | ||||
| 119 | - | // Rollback for preflight (no-op as no system changes made) | |||
| 120 | - | func (p *PreflightPhase) Rollback() error { | |||
| 121 | - | return nil | |||
| 122 | - | } | |||
| 123 | - | ||||
| 124 | - | // CanSkip returns false - preflight cannot be skipped | |||
| 125 | - | func (p *PreflightPhase) CanSkip() bool { | |||
| 126 | - | return false | |||
| 127 | - | } | |||
D internal/phases/preflight_test.go +0 -477
| @@ -1,477 +0,0 @@ | |||||
| 1 | - | package phases | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "fmt" | |||
| 5 | - | "path/filepath" | |||
| 6 | - | "testing" | |||
| 7 | - | ||||
| 8 | - | "github.com/bnema/archup/internal/config" | |||
| 9 | - | "github.com/bnema/archup/internal/interfaces/mocks" | |||
| 10 | - | "github.com/bnema/archup/internal/logger" | |||
| 11 | - | "go.uber.org/mock/gomock" | |||
| 12 | - | ) | |||
| 13 | - | ||||
| 14 | - | // TestPreflightPhasePreCheck tests the PreCheck validation | |||
| 15 | - | func TestPreflightPhasePreCheck(t *testing.T) { | |||
| 16 | - | tests := []struct { | |||
| 17 | - | name string | |||
| 18 | - | setupMocks func(*mocks.MockFileSystem, *mocks.MockCommandExecutor) | |||
| 19 | - | wantErr bool | |||
| 20 | - | errContains string | |||
| 21 | - | }{ | |||
| 22 | - | { | |||
| 23 | - | name: "Arch Linux detection - file not found", | |||
| 24 | - | setupMocks: func(mockFS *mocks.MockFileSystem, mockCmd *mocks.MockCommandExecutor) { | |||
| 25 | - | mockFS.EXPECT().Stat("/etc/arch-release").Return(nil, fmt.Errorf("not found")).Times(1) | |||
| 26 | - | mockFS.EXPECT().IsNotExist(gomock.Not(nil)).Return(true).Times(1) | |||
| 27 | - | }, | |||
| 28 | - | wantErr: true, | |||
| 29 | - | errContains: "must be running on Arch Linux", | |||
| 30 | - | }, | |||
| 31 | - | { | |||
| 32 | - | name: "Arch Linux detected - CachyOS derivative detected", | |||
| 33 | - | setupMocks: func(mockFS *mocks.MockFileSystem, mockCmd *mocks.MockCommandExecutor) { | |||
| 34 | - | mockFS.EXPECT().Stat("/etc/arch-release").Return(mockFileInfo{}, nil).Times(1) | |||
| 35 | - | mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1) | |||
| 36 | - | // For derivatives, check all 4 since map iteration order isn't guaranteed | |||
| 37 | - | mockFS.EXPECT().Stat("/etc/cachyos-release").Return(mockFileInfo{}, nil).Times(1) | |||
| 38 | - | mockFS.EXPECT().Stat("/etc/eos-release").Return(nil, fmt.Errorf("not found")).AnyTimes() | |||
| 39 | - | mockFS.EXPECT().Stat("/etc/garuda-release").Return(nil, fmt.Errorf("not found")).AnyTimes() | |||
| 40 | - | mockFS.EXPECT().Stat("/etc/manjaro-release").Return(nil, fmt.Errorf("not found")).AnyTimes() | |||
| 41 | - | }, | |||
| 42 | - | wantErr: true, | |||
| 43 | - | errContains: "CachyOS", | |||
| 44 | - | }, | |||
| 45 | - | { | |||
| 46 | - | name: "Arch Linux detected - vanilla Arch no derivatives", | |||
| 47 | - | setupMocks: func(mockFS *mocks.MockFileSystem, mockCmd *mocks.MockCommandExecutor) { | |||
| 48 | - | mockFS.EXPECT().Stat("/etc/arch-release").Return(mockFileInfo{}, nil).Times(1) | |||
| 49 | - | mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(2) | |||
| 50 | - | // All derivatives should return not found | |||
| 51 | - | mockFS.EXPECT().Stat("/etc/cachyos-release").Return(nil, fmt.Errorf("not found")).Times(1) | |||
| 52 | - | mockFS.EXPECT().Stat("/etc/eos-release").Return(nil, fmt.Errorf("not found")).Times(1) | |||
| 53 | - | mockFS.EXPECT().Stat("/etc/garuda-release").Return(nil, fmt.Errorf("not found")).Times(1) | |||
| 54 | - | mockFS.EXPECT().Stat("/etc/manjaro-release").Return(nil, fmt.Errorf("not found")).Times(1) | |||
| 55 | - | mockCmd.EXPECT().Execute("uname", "-m").Return([]byte("x86_64\n"), nil).Times(1) | |||
| 56 | - | mockFS.EXPECT().Stat("/sys/firmware/efi/efivars").Return(mockFileInfo{}, nil).Times(1) | |||
| 57 | - | mockCmd.EXPECT().Execute("bootctl", "status").Return([]byte("Secure Boot: disabled\n"), nil).Times(1) | |||
| 58 | - | }, | |||
| 59 | - | wantErr: false, | |||
| 60 | - | }, | |||
| 61 | - | { | |||
| 62 | - | name: "Vanilla Arch - CPU detection fails", | |||
| 63 | - | setupMocks: func(mockFS *mocks.MockFileSystem, mockCmd *mocks.MockCommandExecutor) { | |||
| 64 | - | mockFS.EXPECT().Stat("/etc/arch-release").Return(mockFileInfo{}, nil).Times(1) | |||
| 65 | - | mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1) | |||
| 66 | - | // All derivatives should return not found | |||
| 67 | - | mockFS.EXPECT().Stat("/etc/cachyos-release").Return(nil, fmt.Errorf("not found")).Times(1) | |||
| 68 | - | mockFS.EXPECT().Stat("/etc/eos-release").Return(nil, fmt.Errorf("not found")).Times(1) | |||
| 69 | - | mockFS.EXPECT().Stat("/etc/garuda-release").Return(nil, fmt.Errorf("not found")).Times(1) | |||
| 70 | - | mockFS.EXPECT().Stat("/etc/manjaro-release").Return(nil, fmt.Errorf("not found")).Times(1) | |||
| 71 | - | mockCmd.EXPECT().Execute("uname", "-m").Return(nil, fmt.Errorf("uname failed")).Times(1) | |||
| 72 | - | }, | |||
| 73 | - | wantErr: true, | |||
| 74 | - | errContains: "failed to detect architecture", | |||
| 75 | - | }, | |||
| 76 | - | { | |||
| 77 | - | name: "Vanilla Arch - CPU not x86_64", | |||
| 78 | - | setupMocks: func(mockFS *mocks.MockFileSystem, mockCmd *mocks.MockCommandExecutor) { | |||
| 79 | - | mockFS.EXPECT().Stat("/etc/arch-release").Return(mockFileInfo{}, nil).Times(1) | |||
| 80 | - | mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1) | |||
| 81 | - | // All derivatives should return not found | |||
| 82 | - | mockFS.EXPECT().Stat("/etc/cachyos-release").Return(nil, fmt.Errorf("not found")).Times(1) | |||
| 83 | - | mockFS.EXPECT().Stat("/etc/eos-release").Return(nil, fmt.Errorf("not found")).Times(1) | |||
| 84 | - | mockFS.EXPECT().Stat("/etc/garuda-release").Return(nil, fmt.Errorf("not found")).Times(1) | |||
| 85 | - | mockFS.EXPECT().Stat("/etc/manjaro-release").Return(nil, fmt.Errorf("not found")).Times(1) | |||
| 86 | - | mockCmd.EXPECT().Execute("uname", "-m").Return([]byte("armv7l\n"), nil).Times(1) | |||
| 87 | - | }, | |||
| 88 | - | wantErr: true, | |||
| 89 | - | errContains: "must be x86_64", | |||
| 90 | - | }, | |||
| 91 | - | { | |||
| 92 | - | name: "x86_64 detected - UEFI not found", | |||
| 93 | - | setupMocks: func(mockFS *mocks.MockFileSystem, mockCmd *mocks.MockCommandExecutor) { | |||
| 94 | - | mockFS.EXPECT().Stat("/etc/arch-release").Return(mockFileInfo{}, nil).Times(1) | |||
| 95 | - | mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1) | |||
| 96 | - | // All derivatives should return not found | |||
| 97 | - | mockFS.EXPECT().Stat("/etc/cachyos-release").Return(nil, fmt.Errorf("not found")).Times(1) | |||
| 98 | - | mockFS.EXPECT().Stat("/etc/eos-release").Return(nil, fmt.Errorf("not found")).Times(1) | |||
| 99 | - | mockFS.EXPECT().Stat("/etc/garuda-release").Return(nil, fmt.Errorf("not found")).Times(1) | |||
| 100 | - | mockFS.EXPECT().Stat("/etc/manjaro-release").Return(nil, fmt.Errorf("not found")).Times(1) | |||
| 101 | - | mockCmd.EXPECT().Execute("uname", "-m").Return([]byte("x86_64\n"), nil).Times(1) | |||
| 102 | - | mockFS.EXPECT().Stat("/sys/firmware/efi/efivars").Return(nil, fmt.Errorf("not found")).Times(1) | |||
| 103 | - | mockFS.EXPECT().IsNotExist(gomock.Any()).Return(true).Times(1) | |||
| 104 | - | }, | |||
| 105 | - | wantErr: true, | |||
| 106 | - | errContains: "must be UEFI boot mode", | |||
| 107 | - | }, | |||
| 108 | - | { | |||
| 109 | - | name: "UEFI detected - Secure Boot enabled", | |||
| 110 | - | setupMocks: func(mockFS *mocks.MockFileSystem, mockCmd *mocks.MockCommandExecutor) { | |||
| 111 | - | mockFS.EXPECT().Stat("/etc/arch-release").Return(mockFileInfo{}, nil).Times(1) | |||
| 112 | - | mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(2) | |||
| 113 | - | // All derivatives should return not found | |||
| 114 | - | mockFS.EXPECT().Stat("/etc/cachyos-release").Return(nil, fmt.Errorf("not found")).Times(1) | |||
| 115 | - | mockFS.EXPECT().Stat("/etc/eos-release").Return(nil, fmt.Errorf("not found")).Times(1) | |||
| 116 | - | mockFS.EXPECT().Stat("/etc/garuda-release").Return(nil, fmt.Errorf("not found")).Times(1) | |||
| 117 | - | mockFS.EXPECT().Stat("/etc/manjaro-release").Return(nil, fmt.Errorf("not found")).Times(1) | |||
| 118 | - | mockCmd.EXPECT().Execute("uname", "-m").Return([]byte("x86_64\n"), nil).Times(1) | |||
| 119 | - | mockFS.EXPECT().Stat("/sys/firmware/efi/efivars").Return(mockFileInfo{}, nil).Times(1) | |||
| 120 | - | mockCmd.EXPECT().Execute("bootctl", "status").Return([]byte("Secure Boot: enabled\n"), nil).Times(1) | |||
| 121 | - | }, | |||
| 122 | - | wantErr: true, | |||
| 123 | - | errContains: "secure boot must be disabled", | |||
| 124 | - | }, | |||
| 125 | - | { | |||
| 126 | - | name: "Secure Boot disabled - all checks pass", | |||
| 127 | - | setupMocks: func(mockFS *mocks.MockFileSystem, mockCmd *mocks.MockCommandExecutor) { | |||
| 128 | - | mockFS.EXPECT().Stat("/etc/arch-release").Return(mockFileInfo{}, nil).Times(1) | |||
| 129 | - | mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(2) | |||
| 130 | - | // All derivatives should return not found | |||
| 131 | - | mockFS.EXPECT().Stat("/etc/cachyos-release").Return(nil, fmt.Errorf("not found")).Times(1) | |||
| 132 | - | mockFS.EXPECT().Stat("/etc/eos-release").Return(nil, fmt.Errorf("not found")).Times(1) | |||
| 133 | - | mockFS.EXPECT().Stat("/etc/garuda-release").Return(nil, fmt.Errorf("not found")).Times(1) | |||
| 134 | - | mockFS.EXPECT().Stat("/etc/manjaro-release").Return(nil, fmt.Errorf("not found")).Times(1) | |||
| 135 | - | mockCmd.EXPECT().Execute("uname", "-m").Return([]byte("x86_64\n"), nil).Times(1) | |||
| 136 | - | mockFS.EXPECT().Stat("/sys/firmware/efi/efivars").Return(mockFileInfo{}, nil).Times(1) | |||
| 137 | - | mockCmd.EXPECT().Execute("bootctl", "status").Return([]byte("Secure Boot: disabled\n"), nil).Times(1) | |||
| 138 | - | }, | |||
| 139 | - | wantErr: false, | |||
| 140 | - | }, | |||
| 141 | - | { | |||
| 142 | - | name: "bootctl command fails - precheck should still pass", | |||
| 143 | - | setupMocks: func(mockFS *mocks.MockFileSystem, mockCmd *mocks.MockCommandExecutor) { | |||
| 144 | - | mockFS.EXPECT().Stat("/etc/arch-release").Return(mockFileInfo{}, nil).Times(1) | |||
| 145 | - | mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(2) | |||
| 146 | - | // All derivatives should return not found | |||
| 147 | - | mockFS.EXPECT().Stat("/etc/cachyos-release").Return(nil, fmt.Errorf("not found")).Times(1) | |||
| 148 | - | mockFS.EXPECT().Stat("/etc/eos-release").Return(nil, fmt.Errorf("not found")).Times(1) | |||
| 149 | - | mockFS.EXPECT().Stat("/etc/garuda-release").Return(nil, fmt.Errorf("not found")).Times(1) | |||
| 150 | - | mockFS.EXPECT().Stat("/etc/manjaro-release").Return(nil, fmt.Errorf("not found")).Times(1) | |||
| 151 | - | mockCmd.EXPECT().Execute("uname", "-m").Return([]byte("x86_64\n"), nil).Times(1) | |||
| 152 | - | mockFS.EXPECT().Stat("/sys/firmware/efi/efivars").Return(mockFileInfo{}, nil).Times(1) | |||
| 153 | - | mockCmd.EXPECT().Execute("bootctl", "status").Return(nil, fmt.Errorf("bootctl not available")).Times(1) | |||
| 154 | - | }, | |||
| 155 | - | wantErr: false, | |||
| 156 | - | }, | |||
| 157 | - | } | |||
| 158 | - | ||||
| 159 | - | for _, tt := range tests { | |||
| 160 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 161 | - | ctrl := gomock.NewController(t) | |||
| 162 | - | defer ctrl.Finish() | |||
| 163 | - | ||||
| 164 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 165 | - | mockCmd := mocks.NewMockCommandExecutor(ctrl) | |||
| 166 | - | ||||
| 167 | - | tt.setupMocks(mockFS, mockCmd) | |||
| 168 | - | ||||
| 169 | - | tmpDir := t.TempDir() | |||
| 170 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 171 | - | log, err := logger.New(logPath, false) | |||
| 172 | - | if err != nil { | |||
| 173 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 174 | - | } | |||
| 175 | - | defer func() { | |||
| 176 | - | if err := log.Close(); err != nil { | |||
| 177 | - | t.Fatalf("failed to close log: %v", err) | |||
| 178 | - | } | |||
| 179 | - | }() | |||
| 180 | - | ||||
| 181 | - | cfg := config.NewConfig("test") | |||
| 182 | - | phase := NewPreflightPhase(cfg, log, mockFS, mockCmd) | |||
| 183 | - | ||||
| 184 | - | err = phase.PreCheck() | |||
| 185 | - | if (err != nil) != tt.wantErr { | |||
| 186 | - | t.Errorf("PreCheck() error = %v, wantErr %v", err, tt.wantErr) | |||
| 187 | - | return | |||
| 188 | - | } | |||
| 189 | - | if tt.wantErr && err != nil { | |||
| 190 | - | if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) { | |||
| 191 | - | t.Errorf("PreCheck() error = %q, should contain %q", errMsg, tt.errContains) | |||
| 192 | - | } | |||
| 193 | - | } | |||
| 194 | - | }) | |||
| 195 | - | } | |||
| 196 | - | } | |||
| 197 | - | ||||
| 198 | - | // TestPreflightPhaseSetDefaults tests setting default configuration values | |||
| 199 | - | func TestPreflightPhaseSetDefaults(t *testing.T) { | |||
| 200 | - | tests := []struct { | |||
| 201 | - | name string | |||
| 202 | - | initialHostname string | |||
| 203 | - | initialTimezone string | |||
| 204 | - | expectedHostname string | |||
| 205 | - | expectedTimezone string | |||
| 206 | - | expectedBootloader string | |||
| 207 | - | }{ | |||
| 208 | - | { | |||
| 209 | - | name: "Empty hostname and timezone - use defaults", | |||
| 210 | - | initialHostname: "", | |||
| 211 | - | initialTimezone: "", | |||
| 212 | - | expectedHostname: "archup", | |||
| 213 | - | expectedTimezone: "UTC", | |||
| 214 | - | expectedBootloader: config.BootloaderLimine, | |||
| 215 | - | }, | |||
| 216 | - | { | |||
| 217 | - | name: "Custom hostname provided - keep custom", | |||
| 218 | - | initialHostname: "my-arch", | |||
| 219 | - | initialTimezone: "", | |||
| 220 | - | expectedHostname: "my-arch", | |||
| 221 | - | expectedTimezone: "UTC", | |||
| 222 | - | expectedBootloader: config.BootloaderLimine, | |||
| 223 | - | }, | |||
| 224 | - | { | |||
| 225 | - | name: "Custom timezone provided - keep custom", | |||
| 226 | - | initialHostname: "", | |||
| 227 | - | initialTimezone: "Europe/London", | |||
| 228 | - | expectedHostname: "archup", | |||
| 229 | - | expectedTimezone: "Europe/London", | |||
| 230 | - | expectedBootloader: config.BootloaderLimine, | |||
| 231 | - | }, | |||
| 232 | - | { | |||
| 233 | - | name: "Both custom - keep both", | |||
| 234 | - | initialHostname: "arch-desktop", | |||
| 235 | - | initialTimezone: "America/New_York", | |||
| 236 | - | expectedHostname: "arch-desktop", | |||
| 237 | - | expectedTimezone: "America/New_York", | |||
| 238 | - | expectedBootloader: config.BootloaderLimine, | |||
| 239 | - | }, | |||
| 240 | - | } | |||
| 241 | - | ||||
| 242 | - | for _, tt := range tests { | |||
| 243 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 244 | - | ctrl := gomock.NewController(t) | |||
| 245 | - | defer ctrl.Finish() | |||
| 246 | - | ||||
| 247 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 248 | - | mockCmd := mocks.NewMockCommandExecutor(ctrl) | |||
| 249 | - | ||||
| 250 | - | tmpDir := t.TempDir() | |||
| 251 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 252 | - | log, err := logger.New(logPath, false) | |||
| 253 | - | if err != nil { | |||
| 254 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 255 | - | } | |||
| 256 | - | defer func() { | |||
| 257 | - | if err := log.Close(); err != nil { | |||
| 258 | - | t.Fatalf("failed to close log: %v", err) | |||
| 259 | - | } | |||
| 260 | - | }() | |||
| 261 | - | ||||
| 262 | - | cfg := config.NewConfig("test") | |||
| 263 | - | cfg.Hostname = tt.initialHostname | |||
| 264 | - | cfg.Timezone = tt.initialTimezone | |||
| 265 | - | ||||
| 266 | - | phase := NewPreflightPhase(cfg, log, mockFS, mockCmd) | |||
| 267 | - | phase.SetDefaults() | |||
| 268 | - | ||||
| 269 | - | if cfg.Hostname != tt.expectedHostname { | |||
| 270 | - | t.Errorf("Hostname = %q, want %q", cfg.Hostname, tt.expectedHostname) | |||
| 271 | - | } | |||
| 272 | - | if cfg.Timezone != tt.expectedTimezone { | |||
| 273 | - | t.Errorf("Timezone = %q, want %q", cfg.Timezone, tt.expectedTimezone) | |||
| 274 | - | } | |||
| 275 | - | if cfg.Bootloader != tt.expectedBootloader { | |||
| 276 | - | t.Errorf("Bootloader = %q, want %q", cfg.Bootloader, tt.expectedBootloader) | |||
| 277 | - | } | |||
| 278 | - | }) | |||
| 279 | - | } | |||
| 280 | - | } | |||
| 281 | - | ||||
| 282 | - | // TestPreflightPhasePostCheck tests configuration validation after form submission | |||
| 283 | - | func TestPreflightPhasePostCheck(t *testing.T) { | |||
| 284 | - | tests := []struct { | |||
| 285 | - | name string | |||
| 286 | - | username string | |||
| 287 | - | hostname string | |||
| 288 | - | timezone string | |||
| 289 | - | wantErr bool | |||
| 290 | - | errContains string | |||
| 291 | - | }{ | |||
| 292 | - | { | |||
| 293 | - | name: "All fields present - validation passes", | |||
| 294 | - | username: "archuser", | |||
| 295 | - | hostname: "my-arch", | |||
| 296 | - | timezone: "UTC", | |||
| 297 | - | wantErr: false, | |||
| 298 | - | }, | |||
| 299 | - | { | |||
| 300 | - | name: "Missing username - validation fails", | |||
| 301 | - | username: "", | |||
| 302 | - | hostname: "my-arch", | |||
| 303 | - | timezone: "UTC", | |||
| 304 | - | wantErr: true, | |||
| 305 | - | errContains: "username is required", | |||
| 306 | - | }, | |||
| 307 | - | { | |||
| 308 | - | name: "Missing hostname - validation fails", | |||
| 309 | - | username: "archuser", | |||
| 310 | - | hostname: "", | |||
| 311 | - | timezone: "UTC", | |||
| 312 | - | wantErr: true, | |||
| 313 | - | errContains: "hostname is required", | |||
| 314 | - | }, | |||
| 315 | - | { | |||
| 316 | - | name: "Missing timezone - validation fails", | |||
| 317 | - | username: "archuser", | |||
| 318 | - | hostname: "my-arch", | |||
| 319 | - | timezone: "", | |||
| 320 | - | wantErr: true, | |||
| 321 | - | errContains: "timezone is required", | |||
| 322 | - | }, | |||
| 323 | - | { | |||
| 324 | - | name: "All fields empty - validation fails on username", | |||
| 325 | - | username: "", | |||
| 326 | - | hostname: "", | |||
| 327 | - | timezone: "", | |||
| 328 | - | wantErr: true, | |||
| 329 | - | errContains: "username is required", | |||
| 330 | - | }, | |||
| 331 | - | } | |||
| 332 | - | ||||
| 333 | - | for _, tt := range tests { | |||
| 334 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 335 | - | ctrl := gomock.NewController(t) | |||
| 336 | - | defer ctrl.Finish() | |||
| 337 | - | ||||
| 338 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 339 | - | mockCmd := mocks.NewMockCommandExecutor(ctrl) | |||
| 340 | - | ||||
| 341 | - | tmpDir := t.TempDir() | |||
| 342 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 343 | - | log, err := logger.New(logPath, false) | |||
| 344 | - | if err != nil { | |||
| 345 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 346 | - | } | |||
| 347 | - | defer func() { | |||
| 348 | - | if err := log.Close(); err != nil { | |||
| 349 | - | t.Fatalf("failed to close log: %v", err) | |||
| 350 | - | } | |||
| 351 | - | }() | |||
| 352 | - | ||||
| 353 | - | cfg := config.NewConfig("test") | |||
| 354 | - | cfg.Username = tt.username | |||
| 355 | - | cfg.Hostname = tt.hostname | |||
| 356 | - | cfg.Timezone = tt.timezone | |||
| 357 | - | cfg.ConfigPath = filepath.Join(tmpDir, "config") // Set config path to temp dir so Save() works | |||
| 358 | - | ||||
| 359 | - | phase := NewPreflightPhase(cfg, log, mockFS, mockCmd) | |||
| 360 | - | ||||
| 361 | - | err = phase.PostCheck() | |||
| 362 | - | if (err != nil) != tt.wantErr { | |||
| 363 | - | t.Errorf("PostCheck() error = %v, wantErr %v", err, tt.wantErr) | |||
| 364 | - | return | |||
| 365 | - | } | |||
| 366 | - | if tt.wantErr && err != nil { | |||
| 367 | - | if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) { | |||
| 368 | - | t.Errorf("PostCheck() error = %q, should contain %q", errMsg, tt.errContains) | |||
| 369 | - | } | |||
| 370 | - | } | |||
| 371 | - | }) | |||
| 372 | - | } | |||
| 373 | - | } | |||
| 374 | - | ||||
| 375 | - | // TestPreflightPhaseExecute tests the Execute method | |||
| 376 | - | func TestPreflightPhaseExecute(t *testing.T) { | |||
| 377 | - | ctrl := gomock.NewController(t) | |||
| 378 | - | defer ctrl.Finish() | |||
| 379 | - | ||||
| 380 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 381 | - | mockCmd := mocks.NewMockCommandExecutor(ctrl) | |||
| 382 | - | ||||
| 383 | - | tmpDir := t.TempDir() | |||
| 384 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 385 | - | log, err := logger.New(logPath, false) | |||
| 386 | - | if err != nil { | |||
| 387 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 388 | - | } | |||
| 389 | - | defer func() { | |||
| 390 | - | if err := log.Close(); err != nil { | |||
| 391 | - | t.Fatalf("failed to close log: %v", err) | |||
| 392 | - | } | |||
| 393 | - | }() | |||
| 394 | - | ||||
| 395 | - | cfg := config.NewConfig("test") | |||
| 396 | - | phase := NewPreflightPhase(cfg, log, mockFS, mockCmd) | |||
| 397 | - | ||||
| 398 | - | progressChan := make(chan ProgressUpdate, 10) | |||
| 399 | - | result := phase.Execute(progressChan) | |||
| 400 | - | ||||
| 401 | - | if !result.Success { | |||
| 402 | - | t.Errorf("Execute() Success = %v, want true", result.Success) | |||
| 403 | - | } | |||
| 404 | - | ||||
| 405 | - | if result.Message != "Preflight checks passed" { | |||
| 406 | - | t.Errorf("Execute() Message = %q, want 'Preflight checks passed'", result.Message) | |||
| 407 | - | } | |||
| 408 | - | ||||
| 409 | - | // Verify progress updates were sent | |||
| 410 | - | updates := []ProgressUpdate{} | |||
| 411 | - | for len(progressChan) > 0 { | |||
| 412 | - | updates = append(updates, <-progressChan) | |||
| 413 | - | } | |||
| 414 | - | ||||
| 415 | - | if len(updates) < 1 { | |||
| 416 | - | t.Error("Execute() should send progress updates") | |||
| 417 | - | } | |||
| 418 | - | } | |||
| 419 | - | ||||
| 420 | - | // TestPreflightPhaseRollback tests the Rollback method | |||
| 421 | - | func TestPreflightPhaseRollback(t *testing.T) { | |||
| 422 | - | ctrl := gomock.NewController(t) | |||
| 423 | - | defer ctrl.Finish() | |||
| 424 | - | ||||
| 425 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 426 | - | mockCmd := mocks.NewMockCommandExecutor(ctrl) | |||
| 427 | - | ||||
| 428 | - | tmpDir := t.TempDir() | |||
| 429 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 430 | - | log, err := logger.New(logPath, false) | |||
| 431 | - | if err != nil { | |||
| 432 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 433 | - | } | |||
| 434 | - | defer func() { | |||
| 435 | - | if err := log.Close(); err != nil { | |||
| 436 | - | t.Fatalf("failed to close log: %v", err) | |||
| 437 | - | } | |||
| 438 | - | }() | |||
| 439 | - | ||||
| 440 | - | cfg := config.NewConfig("test") | |||
| 441 | - | phase := NewPreflightPhase(cfg, log, mockFS, mockCmd) | |||
| 442 | - | ||||
| 443 | - | err = phase.Rollback() | |||
| 444 | - | if err != nil { | |||
| 445 | - | t.Errorf("Rollback() error = %v, want nil", err) | |||
| 446 | - | } | |||
| 447 | - | } | |||
| 448 | - | ||||
| 449 | - | // TestPreflightPhaseCanSkip tests the CanSkip method | |||
| 450 | - | func TestPreflightPhaseCanSkip(t *testing.T) { | |||
| 451 | - | ctrl := gomock.NewController(t) | |||
| 452 | - | defer ctrl.Finish() | |||
| 453 | - | ||||
| 454 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 455 | - | mockCmd := mocks.NewMockCommandExecutor(ctrl) | |||
| 456 | - | ||||
| 457 | - | tmpDir := t.TempDir() | |||
| 458 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 459 | - | log, err := logger.New(logPath, false) | |||
| 460 | - | if err != nil { | |||
| 461 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 462 | - | } | |||
| 463 | - | defer func() { | |||
| 464 | - | if err := log.Close(); err != nil { | |||
| 465 | - | t.Fatalf("failed to close log: %v", err) | |||
| 466 | - | } | |||
| 467 | - | }() | |||
| 468 | - | ||||
| 469 | - | cfg := config.NewConfig("test") | |||
| 470 | - | phase := NewPreflightPhase(cfg, log, mockFS, mockCmd) | |||
| 471 | - | ||||
| 472 | - | if phase.CanSkip() { | |||
| 473 | - | t.Error("CanSkip() = true, want false") | |||
| 474 | - | } | |||
| 475 | - | } | |||
| 476 | - | ||||
| 477 | - | // Helper functions are defined in helpers_test.go to avoid duplication | |||
D internal/phases/repos.go +0 -572
| @@ -1,572 +0,0 @@ | |||||
| 1 | - | package phases | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "bufio" | |||
| 5 | - | "fmt" | |||
| 6 | - | "path/filepath" | |||
| 7 | - | "strings" | |||
| 8 | - | ||||
| 9 | - | "github.com/bnema/archup/internal/config" | |||
| 10 | - | "github.com/bnema/archup/internal/interfaces" | |||
| 11 | - | "github.com/bnema/archup/internal/logger" | |||
| 12 | - | "github.com/bnema/archup/internal/system" | |||
| 13 | - | ) | |||
| 14 | - | ||||
| 15 | - | // Pacman configuration constants | |||
| 16 | - | const ( | |||
| 17 | - | pacmanMultilibSectionDisabled = "#[multilib]" | |||
| 18 | - | pacmanMultilibSectionEnabled = "[multilib]" | |||
| 19 | - | pacmanMultilibIncludeDisabled = "#Include = /etc/pacman.d/mirrorlist" | |||
| 20 | - | pacmanMultilibIncludeEnabled = "Include = /etc/pacman.d/mirrorlist" | |||
| 21 | - | pacmanSectionPrefix = "[" | |||
| 22 | - | ) | |||
| 23 | - | ||||
| 24 | - | // AUR helper constants and validation | |||
| 25 | - | const ( | |||
| 26 | - | AURHelperParu = "paru" | |||
| 27 | - | AURHelperYay = "yay" | |||
| 28 | - | ) | |||
| 29 | - | ||||
| 30 | - | // ValidAURHelpers contains the list of supported AUR helpers | |||
| 31 | - | var ValidAURHelpers = []string{AURHelperParu, AURHelperYay} | |||
| 32 | - | ||||
| 33 | - | // IsValidAURHelper validates if the given AUR helper is supported | |||
| 34 | - | func IsValidAURHelper(helper string) bool { | |||
| 35 | - | if helper == "" { | |||
| 36 | - | return true // Empty is valid (no AUR helper) | |||
| 37 | - | } | |||
| 38 | - | for _, valid := range ValidAURHelpers { | |||
| 39 | - | if helper == valid { | |||
| 40 | - | return true | |||
| 41 | - | } | |||
| 42 | - | } | |||
| 43 | - | return false | |||
| 44 | - | } | |||
| 45 | - | ||||
| 46 | - | // ReposPhase handles repository configuration | |||
| 47 | - | type ReposPhase struct { | |||
| 48 | - | *BasePhase | |||
| 49 | - | fs interfaces.FileSystem | |||
| 50 | - | sysExec interfaces.SystemExecutor | |||
| 51 | - | chrExec interfaces.ChrootExecutor | |||
| 52 | - | chaoticConfig map[string]string | |||
| 53 | - | chaoticAUREnabled bool | |||
| 54 | - | } | |||
| 55 | - | ||||
| 56 | - | // NewReposPhase creates a new repos phase | |||
| 57 | - | func NewReposPhase(cfg *config.Config, log *logger.Logger, fs interfaces.FileSystem, sysExec interfaces.SystemExecutor, chrExec interfaces.ChrootExecutor) *ReposPhase { | |||
| 58 | - | return &ReposPhase{ | |||
| 59 | - | BasePhase: NewBasePhase("repos", "Repository Configuration", cfg, log), | |||
| 60 | - | fs: fs, | |||
| 61 | - | sysExec: sysExec, | |||
| 62 | - | chrExec: chrExec, | |||
| 63 | - | chaoticConfig: make(map[string]string), | |||
| 64 | - | } | |||
| 65 | - | } | |||
| 66 | - | ||||
| 67 | - | // PreCheck validates repos prerequisites | |||
| 68 | - | func (p *ReposPhase) PreCheck() error { | |||
| 69 | - | // Validate AUR helper first (config validation before system checks) | |||
| 70 | - | if p.config.AURHelper != "" && !IsValidAURHelper(p.config.AURHelper) { | |||
| 71 | - | return fmt.Errorf("invalid AUR helper: %s (must be one of: %v)", p.config.AURHelper, ValidAURHelpers) | |||
| 72 | - | } | |||
| 73 | - | ||||
| 74 | - | // Verify /mnt is mounted | |||
| 75 | - | result := p.sysExec.RunSimple("mountpoint", "-q", config.PathMnt) | |||
| 76 | - | if result.ExitCode != 0 { | |||
| 77 | - | return fmt.Errorf("%s is not mounted", config.PathMnt) | |||
| 78 | - | } | |||
| 79 | - | ||||
| 80 | - | // Verify pacman.conf exists | |||
| 81 | - | if _, err := p.fs.Stat(config.PathMntEtcPacmanConf); p.fs.IsNotExist(err) { | |||
| 82 | - | return fmt.Errorf("pacman.conf not found") | |||
| 83 | - | } | |||
| 84 | - | ||||
| 85 | - | return nil | |||
| 86 | - | } | |||
| 87 | - | ||||
| 88 | - | // Execute runs the repos phase | |||
| 89 | - | func (p *ReposPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult { | |||
| 90 | - | p.SendProgress(progressChan, "Starting repository configuration...", 1, 5) | |||
| 91 | - | ||||
| 92 | - | // Step 1: Enable multilib (optional) | |||
| 93 | - | switch { | |||
| 94 | - | case p.config.EnableMultilib: | |||
| 95 | - | if err := p.enableMultilib(progressChan); err != nil { | |||
| 96 | - | p.SendError(progressChan, err) | |||
| 97 | - | return PhaseResult{Success: false, Error: err} | |||
| 98 | - | } | |||
| 99 | - | default: | |||
| 100 | - | p.SendOutput(progressChan, "[SKIP] Multilib disabled") | |||
| 101 | - | } | |||
| 102 | - | ||||
| 103 | - | // Step 2: Sync package databases after enabling multilib | |||
| 104 | - | p.SendProgress(progressChan, "Syncing package databases...", 2, 5) | |||
| 105 | - | if err := p.syncDatabases(progressChan); err != nil { | |||
| 106 | - | p.logger.Warn("Database sync failed", "error", err) | |||
| 107 | - | p.SendOutput(progressChan, fmt.Sprintf("[WARN] Database sync failed: %v", err)) | |||
| 108 | - | // Don't fail the phase, just warn | |||
| 109 | - | } | |||
| 110 | - | ||||
| 111 | - | p.SendProgress(progressChan, "Installing extra packages...", 3, 5) | |||
| 112 | - | ||||
| 113 | - | // Step 3: Install extra packages (always, from official repos) | |||
| 114 | - | if err := p.installExtraPackages(progressChan); err != nil { | |||
| 115 | - | // Don't fail the phase, just warn | |||
| 116 | - | p.logger.Warn("Extra packages installation failed", "error", err) | |||
| 117 | - | p.SendOutput(progressChan, fmt.Sprintf("[WARN] Some extra packages failed: %v", err)) | |||
| 118 | - | } | |||
| 119 | - | ||||
| 120 | - | p.SendProgress(progressChan, "Configuring Chaotic-AUR...", 4, 5) | |||
| 121 | - | ||||
| 122 | - | // Step 4: Enable Chaotic-AUR (optional) | |||
| 123 | - | switch { | |||
| 124 | - | case p.config.EnableChaotic: | |||
| 125 | - | if err := p.enableChaoticAUR(progressChan); err != nil { | |||
| 126 | - | p.SendError(progressChan, err) | |||
| 127 | - | return PhaseResult{Success: false, Error: err} | |||
| 128 | - | } | |||
| 129 | - | default: | |||
| 130 | - | p.SendOutput(progressChan, "[SKIP] Chaotic-AUR disabled") | |||
| 131 | - | } | |||
| 132 | - | ||||
| 133 | - | p.SendProgress(progressChan, "Installing AUR helper...", 5, 5) | |||
| 134 | - | ||||
| 135 | - | // Step 5: Install AUR helper (optional) | |||
| 136 | - | switch { | |||
| 137 | - | case p.config.AURHelper != "": | |||
| 138 | - | if err := p.installAURHelper(progressChan); err != nil { | |||
| 139 | - | p.SendError(progressChan, err) | |||
| 140 | - | return PhaseResult{Success: false, Error: err} | |||
| 141 | - | } | |||
| 142 | - | default: | |||
| 143 | - | p.SendOutput(progressChan, "[SKIP] AUR helper disabled") | |||
| 144 | - | } | |||
| 145 | - | ||||
| 146 | - | p.SendProgress(progressChan, "Repository configuration complete", 5, 5) | |||
| 147 | - | p.SendComplete(progressChan, "Repositories configured successfully") | |||
| 148 | - | ||||
| 149 | - | return PhaseResult{Success: true, Message: "Repos configuration complete"} | |||
| 150 | - | } | |||
| 151 | - | ||||
| 152 | - | // syncDatabases syncs pacman package databases | |||
| 153 | - | func (p *ReposPhase) syncDatabases(progressChan chan<- ProgressUpdate) error { | |||
| 154 | - | p.SendOutput(progressChan, "Syncing package databases...") | |||
| 155 | - | ||||
| 156 | - | if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, "pacman -Sy --noconfirm"); err != nil { | |||
| 157 | - | return fmt.Errorf("failed to sync databases: %w", err) | |||
| 158 | - | } | |||
| 159 | - | ||||
| 160 | - | p.SendOutput(progressChan, "[OK] Package databases synced") | |||
| 161 | - | return nil | |||
| 162 | - | } | |||
| 163 | - | ||||
| 164 | - | // enableMultilib enables multilib repository | |||
| 165 | - | func (p *ReposPhase) enableMultilib(progressChan chan<- ProgressUpdate) error { | |||
| 166 | - | p.SendOutput(progressChan, "Enabling multilib repository...") | |||
| 167 | - | ||||
| 168 | - | // Read pacman.conf | |||
| 169 | - | content, err := p.fs.ReadFile(config.PathMntEtcPacmanConf) | |||
| 170 | - | if err != nil { | |||
| 171 | - | return fmt.Errorf("failed to read pacman.conf: %w", err) | |||
| 172 | - | } | |||
| 173 | - | ||||
| 174 | - | contentStr := string(content) | |||
| 175 | - | ||||
| 176 | - | // Check if already fully enabled (both section and Include line uncommented) | |||
| 177 | - | hasMultilibSection := strings.Contains(contentStr, "[multilib]") && !strings.Contains(contentStr, "#[multilib]") | |||
| 178 | - | hasMultilibInclude := strings.Contains(contentStr, pacmanMultilibIncludeEnabled) | |||
| 179 | - | ||||
| 180 | - | if hasMultilibSection && hasMultilibInclude { | |||
| 181 | - | p.SendOutput(progressChan, "[OK] Multilib already enabled") | |||
| 182 | - | return nil | |||
| 183 | - | } | |||
| 184 | - | ||||
| 185 | - | // Uncomment multilib section and its Include line in context-aware manner | |||
| 186 | - | // This ensures we only uncomment the Include that's right after [multilib] | |||
| 187 | - | lines := strings.Split(contentStr, "\n") | |||
| 188 | - | inMultilib := false | |||
| 189 | - | for i, line := range lines { | |||
| 190 | - | trimmedLine := strings.TrimSpace(line) | |||
| 191 | - | ||||
| 192 | - | switch { | |||
| 193 | - | case trimmedLine == pacmanMultilibSectionDisabled: | |||
| 194 | - | lines[i] = pacmanMultilibSectionEnabled | |||
| 195 | - | inMultilib = true | |||
| 196 | - | case trimmedLine == pacmanMultilibSectionEnabled: | |||
| 197 | - | // Already enabled, track that we're in multilib section | |||
| 198 | - | inMultilib = true | |||
| 199 | - | case inMultilib && trimmedLine == pacmanMultilibIncludeDisabled: | |||
| 200 | - | // Uncomment the Include line, preserving indentation | |||
| 201 | - | lines[i] = strings.Replace(line, "#Include", "Include", 1) | |||
| 202 | - | inMultilib = false // Only uncomment the first Include after [multilib] | |||
| 203 | - | case inMultilib && strings.HasPrefix(trimmedLine, pacmanSectionPrefix) && trimmedLine != pacmanMultilibSectionEnabled: | |||
| 204 | - | // Exited multilib section without finding Include | |||
| 205 | - | inMultilib = false | |||
| 206 | - | } | |||
| 207 | - | } | |||
| 208 | - | contentStr = strings.Join(lines, "\n") | |||
| 209 | - | ||||
| 210 | - | // Write updated config | |||
| 211 | - | if err := p.fs.WriteFile(config.PathMntEtcPacmanConf, []byte(contentStr), 0644); err != nil { | |||
| 212 | - | return fmt.Errorf("failed to write pacman.conf: %w", err) | |||
| 213 | - | } | |||
| 214 | - | ||||
| 215 | - | p.SendOutput(progressChan, "[OK] Multilib enabled") | |||
| 216 | - | ||||
| 217 | - | return nil | |||
| 218 | - | } | |||
| 219 | - | ||||
| 220 | - | // loadChaoticConfig loads Chaotic-AUR configuration from file | |||
| 221 | - | func (p *ReposPhase) loadChaoticConfig() error { | |||
| 222 | - | configPath := p.getInstallPath(config.ChaoticConfigFile) | |||
| 223 | - | ||||
| 224 | - | file, err := p.fs.Open(configPath) | |||
| 225 | - | if err != nil { | |||
| 226 | - | return fmt.Errorf("failed to open chaotic config: %w", err) | |||
| 227 | - | } | |||
| 228 | - | ||||
| 229 | - | scanner := bufio.NewScanner(file) | |||
| 230 | - | for scanner.Scan() { | |||
| 231 | - | line := strings.TrimSpace(scanner.Text()) | |||
| 232 | - | ||||
| 233 | - | // Skip comments and empty lines | |||
| 234 | - | switch { | |||
| 235 | - | case line == "": | |||
| 236 | - | continue | |||
| 237 | - | case strings.HasPrefix(line, "#"): | |||
| 238 | - | continue | |||
| 239 | - | } | |||
| 240 | - | ||||
| 241 | - | // Parse KEY=VALUE | |||
| 242 | - | parts := strings.SplitN(line, "=", 2) | |||
| 243 | - | if len(parts) == 2 { | |||
| 244 | - | p.chaoticConfig[parts[0]] = parts[1] | |||
| 245 | - | } | |||
| 246 | - | } | |||
| 247 | - | ||||
| 248 | - | if err := scanner.Err(); err != nil { | |||
| 249 | - | _ = file.Close() | |||
| 250 | - | return fmt.Errorf("error reading chaotic config: %w", err) | |||
| 251 | - | } | |||
| 252 | - | ||||
| 253 | - | if err := file.Close(); err != nil { | |||
| 254 | - | return fmt.Errorf("failed to close chaotic config: %w", err) | |||
| 255 | - | } | |||
| 256 | - | ||||
| 257 | - | return nil | |||
| 258 | - | } | |||
| 259 | - | ||||
| 260 | - | // enableChaoticAUR enables Chaotic-AUR repository | |||
| 261 | - | func (p *ReposPhase) enableChaoticAUR(progressChan chan<- ProgressUpdate) error { | |||
| 262 | - | p.SendOutput(progressChan, "Adding Chaotic-AUR repository...") | |||
| 263 | - | ||||
| 264 | - | // Load configuration for GPG key settings | |||
| 265 | - | if err := p.loadChaoticConfig(); err != nil { | |||
| 266 | - | return fmt.Errorf("failed to load Chaotic config: %w", err) | |||
| 267 | - | } | |||
| 268 | - | ||||
| 269 | - | keyID := p.chaoticConfig["CHAOTIC_KEY_ID"] | |||
| 270 | - | keyserver := p.chaoticConfig["CHAOTIC_KEYSERVER"] | |||
| 271 | - | repoName := p.chaoticConfig["CHAOTIC_REPO_NAME"] | |||
| 272 | - | mirrorlistPath := p.chaoticConfig["CHAOTIC_MIRRORLIST_PATH"] | |||
| 273 | - | ||||
| 274 | - | // Import GPG key | |||
| 275 | - | recvKeyCmd := fmt.Sprintf("pacman-key --recv-key %s --keyserver %s", keyID, keyserver) | |||
| 276 | - | if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, recvKeyCmd); err != nil { | |||
| 277 | - | p.logger.Warn("Failed to receive Chaotic GPG key, skipping Chaotic-AUR", "error", err) | |||
| 278 | - | p.SendOutput(progressChan, "[WARN] Chaotic-AUR unavailable (GPG key fetch failed)") | |||
| 279 | - | return nil // Don't fail, just skip | |||
| 280 | - | } | |||
| 281 | - | ||||
| 282 | - | // Locally sign the key | |||
| 283 | - | lsignKeyCmd := fmt.Sprintf("pacman-key --lsign-key %s", keyID) | |||
| 284 | - | if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, lsignKeyCmd); err != nil { | |||
| 285 | - | p.logger.Warn("Failed to sign Chaotic GPG key, skipping Chaotic-AUR", "error", err) | |||
| 286 | - | p.SendOutput(progressChan, "[WARN] Chaotic-AUR unavailable (GPG key signing failed)") | |||
| 287 | - | return nil // Don't fail, just skip | |||
| 288 | - | } | |||
| 289 | - | ||||
| 290 | - | p.SendOutput(progressChan, "[OK] Chaotic GPG key imported") | |||
| 291 | - | ||||
| 292 | - | // Fetch live mirrorlist from GitLab | |||
| 293 | - | p.SendOutput(progressChan, "Fetching Chaotic-AUR mirrors...") | |||
| 294 | - | mirrors, err := system.FetchChaoticMirrorlist() | |||
| 295 | - | if err != nil { | |||
| 296 | - | p.logger.Warn("Failed to fetch live Chaotic mirrors from GitLab, skipping Chaotic-AUR", "error", err) | |||
| 297 | - | p.SendOutput(progressChan, "[WARN] Chaotic-AUR unavailable (mirror fetch failed), skipping") | |||
| 298 | - | return nil // Don't fail, just skip | |||
| 299 | - | } | |||
| 300 | - | ||||
| 301 | - | // Count enabled vs disabled mirrors | |||
| 302 | - | enabledCount := 0 | |||
| 303 | - | for _, m := range mirrors { | |||
| 304 | - | if m.Enabled { | |||
| 305 | - | enabledCount++ | |||
| 306 | - | } | |||
| 307 | - | } | |||
| 308 | - | ||||
| 309 | - | p.logger.Info("Fetched live Chaotic-AUR mirrors from GitLab", | |||
| 310 | - | "total", len(mirrors), | |||
| 311 | - | "enabled", enabledCount, | |||
| 312 | - | "disabled", len(mirrors)-enabledCount) | |||
| 313 | - | ||||
| 314 | - | // Build package URLs from live mirrors | |||
| 315 | - | const ( | |||
| 316 | - | keyringPkg = "chaotic-keyring.pkg.tar.zst" | |||
| 317 | - | mirrorlistPkg = "chaotic-mirrorlist.pkg.tar.zst" | |||
| 318 | - | ) | |||
| 319 | - | ||||
| 320 | - | keyringURLs := system.BuildPackageURLs(mirrors, keyringPkg, true) | |||
| 321 | - | mirrorlistURLs := system.BuildPackageURLs(mirrors, mirrorlistPkg, true) | |||
| 322 | - | ||||
| 323 | - | if len(keyringURLs) == 0 || len(mirrorlistURLs) == 0 { | |||
| 324 | - | p.logger.Warn("Failed to build package URLs from mirrors, skipping Chaotic-AUR") | |||
| 325 | - | p.SendOutput(progressChan, "[WARN] Chaotic-AUR unavailable (no valid mirrors), skipping") | |||
| 326 | - | return nil // Don't fail, just skip | |||
| 327 | - | } | |||
| 328 | - | ||||
| 329 | - | // Combine all URLs for download | |||
| 330 | - | var urls []string | |||
| 331 | - | urls = append(urls, keyringURLs...) | |||
| 332 | - | urls = append(urls, mirrorlistURLs...) | |||
| 333 | - | ||||
| 334 | - | p.logger.Info("Built package URLs from live mirrors", "keyring_urls", len(keyringURLs), "mirrorlist_urls", len(mirrorlistURLs)) | |||
| 335 | - | ||||
| 336 | - | // Download and install chaotic-keyring and chaotic-mirrorlist | |||
| 337 | - | p.SendOutput(progressChan, "Downloading Chaotic-AUR packages...") | |||
| 338 | - | ||||
| 339 | - | if err := p.chrExec.DownloadAndInstallPackages(p.logger.LogPath(), config.PathMnt, urls...); err != nil { | |||
| 340 | - | p.logger.Warn("Failed to download/install Chaotic packages, skipping Chaotic-AUR", "error", err) | |||
| 341 | - | p.SendOutput(progressChan, "[WARN] Chaotic-AUR unavailable (package download failed), skipping") | |||
| 342 | - | return nil // IMPORTANT: Exit early, don't add to pacman.conf | |||
| 343 | - | } | |||
| 344 | - | ||||
| 345 | - | p.SendOutput(progressChan, "[OK] Chaotic packages installed") | |||
| 346 | - | ||||
| 347 | - | // Add Chaotic-AUR to pacman.conf | |||
| 348 | - | content, err := p.fs.ReadFile(config.PathMntEtcPacmanConf) | |||
| 349 | - | if err != nil { | |||
| 350 | - | return fmt.Errorf("failed to read pacman.conf: %w", err) | |||
| 351 | - | } | |||
| 352 | - | ||||
| 353 | - | contentStr := string(content) | |||
| 354 | - | ||||
| 355 | - | // Check if already added | |||
| 356 | - | switch { | |||
| 357 | - | case strings.Contains(contentStr, fmt.Sprintf("[%s]", repoName)): | |||
| 358 | - | p.SendOutput(progressChan, "[OK] Chaotic-AUR already in pacman.conf") | |||
| 359 | - | default: | |||
| 360 | - | // Append Chaotic-AUR section | |||
| 361 | - | chaoticSection := fmt.Sprintf("\n# Chaotic-AUR repository\n[%s]\nInclude = %s\n", repoName, mirrorlistPath) | |||
| 362 | - | contentStr += chaoticSection | |||
| 363 | - | ||||
| 364 | - | if err := p.fs.WriteFile(config.PathMntEtcPacmanConf, []byte(contentStr), 0644); err != nil { | |||
| 365 | - | return fmt.Errorf("failed to write pacman.conf: %w", err) | |||
| 366 | - | } | |||
| 367 | - | ||||
| 368 | - | p.SendOutput(progressChan, "[OK] Added Chaotic-AUR to pacman.conf") | |||
| 369 | - | } | |||
| 370 | - | ||||
| 371 | - | // Update package databases | |||
| 372 | - | if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, "pacman -Sy --noconfirm"); err != nil { | |||
| 373 | - | return fmt.Errorf("failed to update package databases: %w", err) | |||
| 374 | - | } | |||
| 375 | - | ||||
| 376 | - | // Verify Chaotic-AUR is working | |||
| 377 | - | verifyCmd := fmt.Sprintf("pacman -Sl %s", repoName) | |||
| 378 | - | result := p.sysExec.RunSimple("arch-chroot", config.PathMnt, "sh", "-c", verifyCmd) | |||
| 379 | - | if result.ExitCode != 0 { | |||
| 380 | - | p.SendOutput(progressChan, "[WARN] Chaotic-AUR verification failed") | |||
| 381 | - | return fmt.Errorf("Chaotic-AUR verification failed") | |||
| 382 | - | } | |||
| 383 | - | ||||
| 384 | - | p.SendOutput(progressChan, "[OK] Chaotic-AUR enabled successfully") | |||
| 385 | - | p.chaoticAUREnabled = true | |||
| 386 | - | ||||
| 387 | - | return nil | |||
| 388 | - | } | |||
| 389 | - | ||||
| 390 | - | // installPackagesIndividually tries to install packages one by one for resilience | |||
| 391 | - | func (p *ReposPhase) installPackagesIndividually(packages []string, progressChan chan<- ProgressUpdate) (int, []string) { | |||
| 392 | - | failedPkgs := []string{} | |||
| 393 | - | successCount := 0 | |||
| 394 | - | ||||
| 395 | - | for _, pkg := range packages { | |||
| 396 | - | individualCmd := fmt.Sprintf("pacman -S --noconfirm --needed %s", pkg) | |||
| 397 | - | switch err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, individualCmd); { | |||
| 398 | - | case err != nil: | |||
| 399 | - | p.logger.Warn("Failed to install package", "package", pkg, "error", err) | |||
| 400 | - | failedPkgs = append(failedPkgs, pkg) | |||
| 401 | - | default: | |||
| 402 | - | successCount++ | |||
| 403 | - | } | |||
| 404 | - | } | |||
| 405 | - | ||||
| 406 | - | return successCount, failedPkgs | |||
| 407 | - | } | |||
| 408 | - | ||||
| 409 | - | // installExtraPackages installs packages from extra.packages file | |||
| 410 | - | func (p *ReposPhase) installExtraPackages(progressChan chan<- ProgressUpdate) error { | |||
| 411 | - | p.SendOutput(progressChan, "Installing extra packages...") | |||
| 412 | - | ||||
| 413 | - | // Load extra packages | |||
| 414 | - | packages, err := p.loadExtraPackages() | |||
| 415 | - | switch { | |||
| 416 | - | case err != nil: | |||
| 417 | - | return fmt.Errorf("failed to load extra packages: %w", err) | |||
| 418 | - | case len(packages) == 0: | |||
| 419 | - | p.SendOutput(progressChan, "[SKIP] No extra packages to install") | |||
| 420 | - | return nil | |||
| 421 | - | } | |||
| 422 | - | ||||
| 423 | - | p.logger.Info("Installing extra packages", "count", len(packages), "packages", strings.Join(packages, ", ")) | |||
| 424 | - | p.SendOutput(progressChan, fmt.Sprintf("Installing %d extra packages...", len(packages))) | |||
| 425 | - | ||||
| 426 | - | // Try batch installation first | |||
| 427 | - | installCmd := fmt.Sprintf("pacman -S --noconfirm --needed %s", strings.Join(packages, " ")) | |||
| 428 | - | if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, installCmd); err == nil { | |||
| 429 | - | p.logger.Info("Extra packages installed successfully", "count", len(packages)) | |||
| 430 | - | p.SendOutput(progressChan, fmt.Sprintf("[OK] Installed %d extra packages", len(packages))) | |||
| 431 | - | return nil | |||
| 432 | - | } | |||
| 433 | - | ||||
| 434 | - | // Batch installation failed, try individual packages for resilience | |||
| 435 | - | p.logger.Warn("Batch installation failed, trying individual packages", "error", err) | |||
| 436 | - | p.SendOutput(progressChan, "[WARN] Batch installation failed, trying individual packages...") | |||
| 437 | - | ||||
| 438 | - | successCount, failedPkgs := p.installPackagesIndividually(packages, progressChan) | |||
| 439 | - | ||||
| 440 | - | // Report results | |||
| 441 | - | if len(failedPkgs) > 0 { | |||
| 442 | - | p.SendOutput(progressChan, fmt.Sprintf("[WARN] Failed to install: %s", strings.Join(failedPkgs, ", "))) | |||
| 443 | - | p.logger.Warn("Some packages failed to install", "failed_count", len(failedPkgs), "packages", failedPkgs) | |||
| 444 | - | } | |||
| 445 | - | ||||
| 446 | - | switch { | |||
| 447 | - | case successCount > 0: | |||
| 448 | - | p.SendOutput(progressChan, fmt.Sprintf("[OK] Installed %d/%d packages", successCount, len(packages))) | |||
| 449 | - | p.logger.Info("Partial package installation successful", "success_count", successCount, "total", len(packages)) | |||
| 450 | - | default: | |||
| 451 | - | p.SendOutput(progressChan, "[ERROR] Failed to install any packages") | |||
| 452 | - | return fmt.Errorf("failed to install all %d packages", len(packages)) | |||
| 453 | - | } | |||
| 454 | - | ||||
| 455 | - | return nil | |||
| 456 | - | } | |||
| 457 | - | ||||
| 458 | - | // loadExtraPackages reads extra package list from file | |||
| 459 | - | func (p *ReposPhase) loadExtraPackages() ([]string, error) { | |||
| 460 | - | packageFile := p.getInstallPath(config.ExtraPackagesFile) | |||
| 461 | - | ||||
| 462 | - | file, err := p.fs.Open(packageFile) | |||
| 463 | - | if err != nil { | |||
| 464 | - | return nil, fmt.Errorf("failed to open %s: %w", packageFile, err) | |||
| 465 | - | } | |||
| 466 | - | ||||
| 467 | - | var packages []string | |||
| 468 | - | scanner := bufio.NewScanner(file) | |||
| 469 | - | ||||
| 470 | - | for scanner.Scan() { | |||
| 471 | - | line := strings.TrimSpace(scanner.Text()) | |||
| 472 | - | ||||
| 473 | - | // Skip comments and empty lines | |||
| 474 | - | switch { | |||
| 475 | - | case line == "": | |||
| 476 | - | continue | |||
| 477 | - | case strings.HasPrefix(line, "#"): | |||
| 478 | - | continue | |||
| 479 | - | } | |||
| 480 | - | ||||
| 481 | - | packages = append(packages, line) | |||
| 482 | - | } | |||
| 483 | - | ||||
| 484 | - | if err := scanner.Err(); err != nil { | |||
| 485 | - | _ = file.Close() | |||
| 486 | - | return nil, fmt.Errorf("error reading package file: %w", err) | |||
| 487 | - | } | |||
| 488 | - | ||||
| 489 | - | if err := file.Close(); err != nil { | |||
| 490 | - | return nil, fmt.Errorf("failed to close %s: %w", packageFile, err) | |||
| 491 | - | } | |||
| 492 | - | ||||
| 493 | - | return packages, nil | |||
| 494 | - | } | |||
| 495 | - | ||||
| 496 | - | // installAURHelper installs the selected AUR helper from Chaotic-AUR | |||
| 497 | - | // Requires Chaotic-AUR to be enabled (no build-from-source fallback) | |||
| 498 | - | func (p *ReposPhase) installAURHelper(progressChan chan<- ProgressUpdate) error { | |||
| 499 | - | helper := p.config.AURHelper | |||
| 500 | - | ||||
| 501 | - | // Only install if Chaotic-AUR is enabled | |||
| 502 | - | if !p.chaoticAUREnabled { | |||
| 503 | - | p.SendOutput(progressChan, "[SKIP] AUR helper installation (Chaotic-AUR required)") | |||
| 504 | - | p.logger.Info("Skipping AUR helper installation - Chaotic-AUR not enabled", "helper", helper) | |||
| 505 | - | return nil | |||
| 506 | - | } | |||
| 507 | - | ||||
| 508 | - | // Install from Chaotic-AUR | |||
| 509 | - | p.SendOutput(progressChan, fmt.Sprintf("Installing %s from Chaotic-AUR...", helper)) | |||
| 510 | - | installCmd := fmt.Sprintf("pacman -S --noconfirm --needed %s", helper) | |||
| 511 | - | if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, installCmd); err != nil { | |||
| 512 | - | return fmt.Errorf("failed to install %s from Chaotic-AUR: %w", helper, err) | |||
| 513 | - | } | |||
| 514 | - | ||||
| 515 | - | p.SendOutput(progressChan, fmt.Sprintf("[OK] %s installed from Chaotic-AUR", helper)) | |||
| 516 | - | p.logger.Info("AUR helper installed from Chaotic-AUR", "helper", helper) | |||
| 517 | - | ||||
| 518 | - | return nil | |||
| 519 | - | } | |||
| 520 | - | ||||
| 521 | - | // getInstallPath constructs full path to install file | |||
| 522 | - | func (p *ReposPhase) getInstallPath(filename string) string { | |||
| 523 | - | // Use DefaultInstallDir directly to match where bootstrap downloads files | |||
| 524 | - | return filepath.Join(config.DefaultInstallDir, filename) | |||
| 525 | - | } | |||
| 526 | - | ||||
| 527 | - | // PostCheck validates repository configuration | |||
| 528 | - | func (p *ReposPhase) PostCheck() error { | |||
| 529 | - | // Verify pacman.conf was modified | |||
| 530 | - | content, err := p.fs.ReadFile(config.PathMntEtcPacmanConf) | |||
| 531 | - | if err != nil { | |||
| 532 | - | return fmt.Errorf("failed to read pacman.conf: %w", err) | |||
| 533 | - | } | |||
| 534 | - | ||||
| 535 | - | contentStr := string(content) | |||
| 536 | - | ||||
| 537 | - | // Check multilib if enabled | |||
| 538 | - | switch { | |||
| 539 | - | case p.config.EnableMultilib: | |||
| 540 | - | switch { | |||
| 541 | - | case !strings.Contains(contentStr, "[multilib]"): | |||
| 542 | - | return fmt.Errorf("multilib was not enabled in pacman.conf") | |||
| 543 | - | } | |||
| 544 | - | } | |||
| 545 | - | ||||
| 546 | - | // Check Chaotic-AUR if enabled | |||
| 547 | - | switch { | |||
| 548 | - | case p.config.EnableChaotic: | |||
| 549 | - | repoName := p.chaoticConfig["CHAOTIC_REPO_NAME"] | |||
| 550 | - | if repoName == "" { | |||
| 551 | - | repoName = "chaotic-aur" | |||
| 552 | - | } | |||
| 553 | - | ||||
| 554 | - | switch { | |||
| 555 | - | case !strings.Contains(contentStr, fmt.Sprintf("[%s]", repoName)): | |||
| 556 | - | return fmt.Errorf("Chaotic-AUR was not added to pacman.conf") | |||
| 557 | - | } | |||
| 558 | - | } | |||
| 559 | - | ||||
| 560 | - | return p.config.Save() | |||
| 561 | - | } | |||
| 562 | - | ||||
| 563 | - | // Rollback for repos phase | |||
| 564 | - | func (p *ReposPhase) Rollback() error { | |||
| 565 | - | // Repository changes are in chroot, cleaned up by partitioning rollback | |||
| 566 | - | return nil | |||
| 567 | - | } | |||
| 568 | - | ||||
| 569 | - | // CanSkip returns false - repos cannot be skipped | |||
| 570 | - | func (p *ReposPhase) CanSkip() bool { | |||
| 571 | - | return false | |||
| 572 | - | } | |||
D internal/phases/repos_test.go +0 -723
| @@ -1,723 +0,0 @@ | |||||
| 1 | - | package phases | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "path/filepath" | |||
| 5 | - | "strings" | |||
| 6 | - | "testing" | |||
| 7 | - | ||||
| 8 | - | "github.com/bnema/archup/internal/config" | |||
| 9 | - | "github.com/bnema/archup/internal/interfaces/mocks" | |||
| 10 | - | "github.com/bnema/archup/internal/logger" | |||
| 11 | - | "github.com/bnema/archup/internal/system" | |||
| 12 | - | "go.uber.org/mock/gomock" | |||
| 13 | - | ) | |||
| 14 | - | ||||
| 15 | - | // TestEnableMultilibParsing tests the multilib section and Include line parsing | |||
| 16 | - | func TestEnableMultilibParsing(t *testing.T) { | |||
| 17 | - | tests := []struct { | |||
| 18 | - | name string | |||
| 19 | - | input string | |||
| 20 | - | wantMultilib bool | |||
| 21 | - | wantInclude bool | |||
| 22 | - | shouldNotHave string | |||
| 23 | - | }{ | |||
| 24 | - | { | |||
| 25 | - | name: "Both commented - should uncomment both", | |||
| 26 | - | input: `[core] | |||
| 27 | - | #[multilib] | |||
| 28 | - | #Include = /etc/pacman.d/mirrorlist`, | |||
| 29 | - | wantMultilib: true, | |||
| 30 | - | wantInclude: true, | |||
| 31 | - | shouldNotHave: "#[multilib]", | |||
| 32 | - | }, | |||
| 33 | - | { | |||
| 34 | - | name: "Multilib already enabled - Include still commented", | |||
| 35 | - | input: `[core] | |||
| 36 | - | [multilib] | |||
| 37 | - | #Include = /etc/pacman.d/mirrorlist`, | |||
| 38 | - | wantMultilib: true, | |||
| 39 | - | wantInclude: true, | |||
| 40 | - | shouldNotHave: "#Include = /etc/pacman.d/mirrorlist", | |||
| 41 | - | }, | |||
| 42 | - | { | |||
| 43 | - | name: "Both already enabled - no changes", | |||
| 44 | - | input: `[core] | |||
| 45 | - | [multilib] | |||
| 46 | - | Include = /etc/pacman.d/mirrorlist`, | |||
| 47 | - | wantMultilib: true, | |||
| 48 | - | wantInclude: true, | |||
| 49 | - | shouldNotHave: "#", | |||
| 50 | - | }, | |||
| 51 | - | { | |||
| 52 | - | name: "Only uncomment multilib Include, not other Includes", | |||
| 53 | - | input: `[core] | |||
| 54 | - | Include = /etc/pacman.d/mirrorlist | |||
| 55 | - | #[multilib] | |||
| 56 | - | #Include = /etc/pacman.d/mirrorlist | |||
| 57 | - | [community] | |||
| 58 | - | #Include = /etc/pacman.d/mirrorlist`, | |||
| 59 | - | wantMultilib: true, | |||
| 60 | - | wantInclude: true, | |||
| 61 | - | shouldNotHave: "", | |||
| 62 | - | }, | |||
| 63 | - | { | |||
| 64 | - | name: "Preserve indentation in Include line", | |||
| 65 | - | input: `[core] | |||
| 66 | - | #[multilib] | |||
| 67 | - | #Include = /etc/pacman.d/mirrorlist`, | |||
| 68 | - | wantMultilib: true, | |||
| 69 | - | wantInclude: true, | |||
| 70 | - | shouldNotHave: "#Include", | |||
| 71 | - | }, | |||
| 72 | - | } | |||
| 73 | - | ||||
| 74 | - | for _, tt := range tests { | |||
| 75 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 76 | - | // Simulate the multilib enabling logic from repos.go | |||
| 77 | - | contentStr := tt.input | |||
| 78 | - | lines := strings.Split(contentStr, "\n") | |||
| 79 | - | inMultilib := false | |||
| 80 | - | ||||
| 81 | - | for i, line := range lines { | |||
| 82 | - | trimmedLine := strings.TrimSpace(line) | |||
| 83 | - | ||||
| 84 | - | switch { | |||
| 85 | - | case trimmedLine == pacmanMultilibSectionDisabled: | |||
| 86 | - | lines[i] = pacmanMultilibSectionEnabled | |||
| 87 | - | inMultilib = true | |||
| 88 | - | case trimmedLine == pacmanMultilibSectionEnabled: | |||
| 89 | - | // Already enabled, track that we're in multilib section | |||
| 90 | - | inMultilib = true | |||
| 91 | - | case inMultilib && trimmedLine == pacmanMultilibIncludeDisabled: | |||
| 92 | - | // Uncomment the Include line, preserving indentation | |||
| 93 | - | lines[i] = strings.Replace(line, "#Include", "Include", 1) | |||
| 94 | - | inMultilib = false // Only uncomment the first Include after [multilib] | |||
| 95 | - | case inMultilib && strings.HasPrefix(trimmedLine, pacmanSectionPrefix) && trimmedLine != pacmanMultilibSectionEnabled: | |||
| 96 | - | // Exited multilib section without finding Include | |||
| 97 | - | inMultilib = false | |||
| 98 | - | } | |||
| 99 | - | } | |||
| 100 | - | contentStr = strings.Join(lines, "\n") | |||
| 101 | - | ||||
| 102 | - | // Verify results | |||
| 103 | - | hasMultilib := strings.Contains(contentStr, "[multilib]") && !strings.Contains(contentStr, "#[multilib]") | |||
| 104 | - | hasInclude := strings.Contains(contentStr, "Include = /etc/pacman.d/mirrorlist") | |||
| 105 | - | ||||
| 106 | - | if tt.wantMultilib && !hasMultilib { | |||
| 107 | - | t.Errorf("Expected [multilib] to be uncommented, but it's not present or still commented") | |||
| 108 | - | } | |||
| 109 | - | ||||
| 110 | - | if tt.wantInclude && !hasInclude { | |||
| 111 | - | t.Errorf("Expected Include line to be uncommented under [multilib], but it's not") | |||
| 112 | - | } | |||
| 113 | - | ||||
| 114 | - | if tt.shouldNotHave != "" && strings.Contains(contentStr, tt.shouldNotHave) { | |||
| 115 | - | t.Errorf("Expected NOT to find '%s', but found it in output", tt.shouldNotHave) | |||
| 116 | - | } | |||
| 117 | - | }) | |||
| 118 | - | } | |||
| 119 | - | } | |||
| 120 | - | ||||
| 121 | - | // TestMultilibEdgeCases tests edge cases in multilib parsing | |||
| 122 | - | func TestMultilibEdgeCases(t *testing.T) { | |||
| 123 | - | tests := []struct { | |||
| 124 | - | name string | |||
| 125 | - | input string | |||
| 126 | - | expectError bool | |||
| 127 | - | checkFunc func(string) bool | |||
| 128 | - | }{ | |||
| 129 | - | { | |||
| 130 | - | name: "Empty file", | |||
| 131 | - | input: "", | |||
| 132 | - | expectError: false, | |||
| 133 | - | checkFunc: func(s string) bool { | |||
| 134 | - | return s == "" | |||
| 135 | - | }, | |||
| 136 | - | }, | |||
| 137 | - | { | |||
| 138 | - | name: "No multilib section", | |||
| 139 | - | input: `[core] | |||
| 140 | - | Include = /etc/pacman.d/mirrorlist | |||
| 141 | - | [extra] | |||
| 142 | - | Include = /etc/pacman.d/mirrorlist`, | |||
| 143 | - | expectError: false, | |||
| 144 | - | checkFunc: func(s string) bool { | |||
| 145 | - | return !strings.Contains(s, "[multilib]") | |||
| 146 | - | }, | |||
| 147 | - | }, | |||
| 148 | - | { | |||
| 149 | - | name: "Multiple Include lines with different paths", | |||
| 150 | - | input: `[core] | |||
| 151 | - | #[multilib] | |||
| 152 | - | #Include = /etc/pacman.d/mirrorlist | |||
| 153 | - | #Include = /etc/pacman.d/other`, | |||
| 154 | - | expectError: false, | |||
| 155 | - | checkFunc: func(s string) bool { | |||
| 156 | - | // Should only uncomment the first Include after [multilib] | |||
| 157 | - | lines := strings.Split(s, "\n") | |||
| 158 | - | var inMultilib bool | |||
| 159 | - | var foundInclude int | |||
| 160 | - | var commentedInclude int | |||
| 161 | - | for _, line := range lines { | |||
| 162 | - | trimmed := strings.TrimSpace(line) | |||
| 163 | - | if trimmed == "[multilib]" { | |||
| 164 | - | inMultilib = true | |||
| 165 | - | } else if inMultilib && strings.HasPrefix(trimmed, "Include = ") { | |||
| 166 | - | foundInclude++ | |||
| 167 | - | } else if inMultilib && strings.HasPrefix(trimmed, "#Include") { | |||
| 168 | - | // Still commented - that's another Include | |||
| 169 | - | commentedInclude++ | |||
| 170 | - | } | |||
| 171 | - | } | |||
| 172 | - | _ = commentedInclude | |||
| 173 | - | return foundInclude >= 1 | |||
| 174 | - | }, | |||
| 175 | - | }, | |||
| 176 | - | } | |||
| 177 | - | ||||
| 178 | - | for _, tt := range tests { | |||
| 179 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 180 | - | contentStr := tt.input | |||
| 181 | - | lines := strings.Split(contentStr, "\n") | |||
| 182 | - | inMultilib := false | |||
| 183 | - | ||||
| 184 | - | for i, line := range lines { | |||
| 185 | - | trimmedLine := strings.TrimSpace(line) | |||
| 186 | - | ||||
| 187 | - | switch { | |||
| 188 | - | case trimmedLine == pacmanMultilibSectionDisabled: | |||
| 189 | - | lines[i] = pacmanMultilibSectionEnabled | |||
| 190 | - | inMultilib = true | |||
| 191 | - | case inMultilib && trimmedLine == pacmanMultilibIncludeDisabled: | |||
| 192 | - | lines[i] = strings.Replace(line, "#Include", "Include", 1) | |||
| 193 | - | inMultilib = false | |||
| 194 | - | case inMultilib && strings.HasPrefix(trimmedLine, pacmanSectionPrefix) && trimmedLine != pacmanMultilibSectionEnabled: | |||
| 195 | - | inMultilib = false | |||
| 196 | - | } | |||
| 197 | - | } | |||
| 198 | - | contentStr = strings.Join(lines, "\n") | |||
| 199 | - | ||||
| 200 | - | if !tt.checkFunc(contentStr) { | |||
| 201 | - | t.Errorf("Check function failed for input:\n%s\n\nOutput:\n%s", tt.input, contentStr) | |||
| 202 | - | } | |||
| 203 | - | }) | |||
| 204 | - | } | |||
| 205 | - | } | |||
| 206 | - | ||||
| 207 | - | // TestInstallPackagesIndividuallyLogic tests the fallback package installation logic | |||
| 208 | - | func TestInstallPackagesIndividuallyLogic(t *testing.T) { | |||
| 209 | - | // This test verifies the logic of the installPackagesIndividually function | |||
| 210 | - | // by testing how it would handle a list of packages | |||
| 211 | - | ||||
| 212 | - | packages := []string{"package1", "package2", "package3"} | |||
| 213 | - | ||||
| 214 | - | // Simulate successful installation for some, failure for others | |||
| 215 | - | failedPackages := map[string]bool{ | |||
| 216 | - | "package2": true, // This one fails | |||
| 217 | - | } | |||
| 218 | - | ||||
| 219 | - | successCount := 0 | |||
| 220 | - | failedPkgs := []string{} | |||
| 221 | - | ||||
| 222 | - | for _, pkg := range packages { | |||
| 223 | - | if failedPackages[pkg] { | |||
| 224 | - | failedPkgs = append(failedPkgs, pkg) | |||
| 225 | - | } else { | |||
| 226 | - | successCount++ | |||
| 227 | - | } | |||
| 228 | - | } | |||
| 229 | - | ||||
| 230 | - | // Verify results | |||
| 231 | - | if successCount != 2 { | |||
| 232 | - | t.Errorf("Expected 2 successful installs, got %d", successCount) | |||
| 233 | - | } | |||
| 234 | - | ||||
| 235 | - | if len(failedPkgs) != 1 { | |||
| 236 | - | t.Errorf("Expected 1 failed package, got %d", len(failedPkgs)) | |||
| 237 | - | } | |||
| 238 | - | ||||
| 239 | - | if len(failedPkgs) > 0 && failedPkgs[0] != "package2" { | |||
| 240 | - | t.Errorf("Expected 'package2' to fail, got %v", failedPkgs) | |||
| 241 | - | } | |||
| 242 | - | } | |||
| 243 | - | ||||
| 244 | - | // BenchmarkMultilibParsing benchmarks the multilib parsing performance | |||
| 245 | - | func BenchmarkMultilibParsing(b *testing.B) { | |||
| 246 | - | largeConfig := strings.Repeat(`[section] | |||
| 247 | - | #Include = /etc/pacman.d/mirrorlist | |||
| 248 | - | `, 100) | |||
| 249 | - | largeConfig = strings.Replace(largeConfig, "[section]", "[core]\n#[multilib]\n#Include = /etc/pacman.d/mirrorlist", 1) | |||
| 250 | - | ||||
| 251 | - | b.ResetTimer() | |||
| 252 | - | for i := 0; i < b.N; i++ { | |||
| 253 | - | contentStr := largeConfig | |||
| 254 | - | lines := strings.Split(contentStr, "\n") | |||
| 255 | - | inMultilib := false | |||
| 256 | - | ||||
| 257 | - | for i, line := range lines { | |||
| 258 | - | trimmedLine := strings.TrimSpace(line) | |||
| 259 | - | ||||
| 260 | - | switch { | |||
| 261 | - | case trimmedLine == pacmanMultilibSectionDisabled: | |||
| 262 | - | lines[i] = pacmanMultilibSectionEnabled | |||
| 263 | - | inMultilib = true | |||
| 264 | - | case trimmedLine == pacmanMultilibSectionEnabled: | |||
| 265 | - | // Already enabled, track that we're in multilib section | |||
| 266 | - | inMultilib = true | |||
| 267 | - | case inMultilib && trimmedLine == pacmanMultilibIncludeDisabled: | |||
| 268 | - | lines[i] = strings.Replace(line, "#Include", "Include", 1) | |||
| 269 | - | inMultilib = false | |||
| 270 | - | case inMultilib && strings.HasPrefix(trimmedLine, pacmanSectionPrefix) && trimmedLine != pacmanMultilibSectionEnabled: | |||
| 271 | - | inMultilib = false | |||
| 272 | - | } | |||
| 273 | - | } | |||
| 274 | - | _ = strings.Join(lines, "\n") | |||
| 275 | - | } | |||
| 276 | - | } | |||
| 277 | - | ||||
| 278 | - | // TestReposPhaseStructure tests that ReposPhase can be properly initialized | |||
| 279 | - | func TestReposPhaseStructure(t *testing.T) { | |||
| 280 | - | ctrl := gomock.NewController(t) | |||
| 281 | - | defer ctrl.Finish() | |||
| 282 | - | ||||
| 283 | - | // Create a temporary directory for log file | |||
| 284 | - | tmpDir := t.TempDir() | |||
| 285 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 286 | - | ||||
| 287 | - | // Create logger | |||
| 288 | - | log, err := logger.New(logPath, false) | |||
| 289 | - | if err != nil { | |||
| 290 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 291 | - | } | |||
| 292 | - | defer func() { | |||
| 293 | - | if err := log.Close(); err != nil { | |||
| 294 | - | t.Fatalf("failed to close log: %v", err) | |||
| 295 | - | } | |||
| 296 | - | }() | |||
| 297 | - | ||||
| 298 | - | // Create config | |||
| 299 | - | cfg := config.NewConfig("test") | |||
| 300 | - | ||||
| 301 | - | // Create mocks | |||
| 302 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 303 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 304 | - | mockChrExec := mocks.NewMockChrootExecutor(ctrl) | |||
| 305 | - | ||||
| 306 | - | // Create ReposPhase | |||
| 307 | - | phase := NewReposPhase(cfg, log, mockFS, mockSysExec, mockChrExec) | |||
| 308 | - | ||||
| 309 | - | // Verify initialization | |||
| 310 | - | if phase == nil { | |||
| 311 | - | t.Fatal("ReposPhase is nil") | |||
| 312 | - | } | |||
| 313 | - | ||||
| 314 | - | if phase.BasePhase == nil { | |||
| 315 | - | t.Fatal("BasePhase is nil") | |||
| 316 | - | } | |||
| 317 | - | ||||
| 318 | - | if len(phase.chaoticConfig) != 0 { | |||
| 319 | - | t.Fatal("chaoticConfig should be initialized as empty map") | |||
| 320 | - | } | |||
| 321 | - | } | |||
| 322 | - | ||||
| 323 | - | // TestPacmanConfigConstants verifies the constants are properly defined | |||
| 324 | - | func TestPacmanConfigConstants(t *testing.T) { | |||
| 325 | - | tests := []struct { | |||
| 326 | - | name string | |||
| 327 | - | value string | |||
| 328 | - | expected string | |||
| 329 | - | }{ | |||
| 330 | - | {"pacmanMultilibSectionDisabled", pacmanMultilibSectionDisabled, "#[multilib]"}, | |||
| 331 | - | {"pacmanMultilibSectionEnabled", pacmanMultilibSectionEnabled, "[multilib]"}, | |||
| 332 | - | {"pacmanMultilibIncludeDisabled", pacmanMultilibIncludeDisabled, "#Include = /etc/pacman.d/mirrorlist"}, | |||
| 333 | - | {"pacmanMultilibIncludeEnabled", pacmanMultilibIncludeEnabled, "Include = /etc/pacman.d/mirrorlist"}, | |||
| 334 | - | {"pacmanSectionPrefix", pacmanSectionPrefix, "["}, | |||
| 335 | - | } | |||
| 336 | - | ||||
| 337 | - | for _, tt := range tests { | |||
| 338 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 339 | - | if tt.value != tt.expected { | |||
| 340 | - | t.Errorf("Expected %q, got %q", tt.expected, tt.value) | |||
| 341 | - | } | |||
| 342 | - | }) | |||
| 343 | - | } | |||
| 344 | - | } | |||
| 345 | - | ||||
| 346 | - | // TestMultilibParsingRealWorldExample tests with a real pacman.conf structure | |||
| 347 | - | func TestMultilibParsingRealWorldExample(t *testing.T) { | |||
| 348 | - | // Simulate a real pacman.conf from Arch Linux | |||
| 349 | - | realWorldConfig := `# | |||
| 350 | - | # Arch Linux repository mirrorlist | |||
| 351 | - | # Generated on 2025-10-28 | |||
| 352 | - | # | |||
| 353 | - | ||||
| 354 | - | [core] | |||
| 355 | - | Include = /etc/pacman.d/mirrorlist | |||
| 356 | - | ||||
| 357 | - | [extra] | |||
| 358 | - | Include = /etc/pacman.d/mirrorlist | |||
| 359 | - | ||||
| 360 | - | #[multilib] | |||
| 361 | - | #Include = /etc/pacman.d/mirrorlist | |||
| 362 | - | ||||
| 363 | - | [community] | |||
| 364 | - | Include = /etc/pacman.d/mirrorlist | |||
| 365 | - | ||||
| 366 | - | # An example of a custom package repository. See the pacman manpage for | |||
| 367 | - | # tips on creating your own repository. | |||
| 368 | - | #[custom] | |||
| 369 | - | #SigLevel = Optional TrustAll | |||
| 370 | - | #Server = file:///home/custompkgs | |||
| 371 | - | ` | |||
| 372 | - | ||||
| 373 | - | contentStr := realWorldConfig | |||
| 374 | - | lines := strings.Split(contentStr, "\n") | |||
| 375 | - | inMultilib := false | |||
| 376 | - | ||||
| 377 | - | for i, line := range lines { | |||
| 378 | - | trimmedLine := strings.TrimSpace(line) | |||
| 379 | - | ||||
| 380 | - | switch { | |||
| 381 | - | case trimmedLine == pacmanMultilibSectionDisabled: | |||
| 382 | - | lines[i] = pacmanMultilibSectionEnabled | |||
| 383 | - | inMultilib = true | |||
| 384 | - | case inMultilib && trimmedLine == pacmanMultilibIncludeDisabled: | |||
| 385 | - | lines[i] = strings.Replace(line, "#Include", "Include", 1) | |||
| 386 | - | inMultilib = false | |||
| 387 | - | case inMultilib && strings.HasPrefix(trimmedLine, pacmanSectionPrefix) && trimmedLine != pacmanMultilibSectionEnabled: | |||
| 388 | - | inMultilib = false | |||
| 389 | - | } | |||
| 390 | - | } | |||
| 391 | - | contentStr = strings.Join(lines, "\n") | |||
| 392 | - | ||||
| 393 | - | // Verify results | |||
| 394 | - | if !strings.Contains(contentStr, "[multilib]") { | |||
| 395 | - | t.Error("Expected [multilib] section to be uncommented") | |||
| 396 | - | } | |||
| 397 | - | ||||
| 398 | - | if strings.Contains(contentStr, "#[multilib]") { | |||
| 399 | - | t.Error("Expected #[multilib] to be removed") | |||
| 400 | - | } | |||
| 401 | - | ||||
| 402 | - | if !strings.Contains(contentStr, "Include = /etc/pacman.d/mirrorlist") { | |||
| 403 | - | t.Error("Expected Include line to be present") | |||
| 404 | - | } | |||
| 405 | - | ||||
| 406 | - | // Count Include lines for multilib section | |||
| 407 | - | lines = strings.Split(contentStr, "\n") | |||
| 408 | - | var multilibFound bool | |||
| 409 | - | var multilibIncludeFound bool | |||
| 410 | - | ||||
| 411 | - | for i, line := range lines { | |||
| 412 | - | trimmed := strings.TrimSpace(line) | |||
| 413 | - | if trimmed == "[multilib]" { | |||
| 414 | - | multilibFound = true | |||
| 415 | - | } else if multilibFound { | |||
| 416 | - | if trimmed == "Include = /etc/pacman.d/mirrorlist" { | |||
| 417 | - | multilibIncludeFound = true | |||
| 418 | - | break | |||
| 419 | - | } else if strings.HasPrefix(trimmed, "[") { | |||
| 420 | - | // Exited multilib section | |||
| 421 | - | break | |||
| 422 | - | } | |||
| 423 | - | } | |||
| 424 | - | _ = i | |||
| 425 | - | } | |||
| 426 | - | ||||
| 427 | - | if !multilibIncludeFound { | |||
| 428 | - | t.Error("Expected Include line directly under [multilib] section") | |||
| 429 | - | } | |||
| 430 | - | } | |||
| 431 | - | ||||
| 432 | - | // TestIsValidAURHelper tests AUR helper validation | |||
| 433 | - | func TestIsValidAURHelper(t *testing.T) { | |||
| 434 | - | tests := []struct { | |||
| 435 | - | name string | |||
| 436 | - | helper string | |||
| 437 | - | expected bool | |||
| 438 | - | }{ | |||
| 439 | - | {"Empty helper (no AUR helper)", "", true}, | |||
| 440 | - | {"Valid paru", AURHelperParu, true}, | |||
| 441 | - | {"Valid yay", AURHelperYay, true}, | |||
| 442 | - | {"Invalid helper", "invalid-helper", false}, | |||
| 443 | - | {"Invalid pacaur", "pacaur", false}, | |||
| 444 | - | {"Invalid trizen", "trizen", false}, | |||
| 445 | - | {"Case sensitive check", "Paru", false}, | |||
| 446 | - | } | |||
| 447 | - | ||||
| 448 | - | for _, tt := range tests { | |||
| 449 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 450 | - | result := IsValidAURHelper(tt.helper) | |||
| 451 | - | if result != tt.expected { | |||
| 452 | - | t.Errorf("IsValidAURHelper(%q) = %v, want %v", tt.helper, result, tt.expected) | |||
| 453 | - | } | |||
| 454 | - | }) | |||
| 455 | - | } | |||
| 456 | - | } | |||
| 457 | - | ||||
| 458 | - | // TestAURHelperConstants verifies AUR helper constants are properly defined | |||
| 459 | - | func TestAURHelperConstants(t *testing.T) { | |||
| 460 | - | tests := []struct { | |||
| 461 | - | name string | |||
| 462 | - | value string | |||
| 463 | - | expected string | |||
| 464 | - | }{ | |||
| 465 | - | {"AURHelperParu", AURHelperParu, "paru"}, | |||
| 466 | - | {"AURHelperYay", AURHelperYay, "yay"}, | |||
| 467 | - | } | |||
| 468 | - | ||||
| 469 | - | for _, tt := range tests { | |||
| 470 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 471 | - | if tt.value != tt.expected { | |||
| 472 | - | t.Errorf("Expected %q, got %q", tt.expected, tt.value) | |||
| 473 | - | } | |||
| 474 | - | }) | |||
| 475 | - | } | |||
| 476 | - | } | |||
| 477 | - | ||||
| 478 | - | // TestValidAURHelpersList verifies ValidAURHelpers array is correct | |||
| 479 | - | func TestValidAURHelpersList(t *testing.T) { | |||
| 480 | - | expected := []string{AURHelperParu, AURHelperYay} | |||
| 481 | - | ||||
| 482 | - | if len(ValidAURHelpers) != len(expected) { | |||
| 483 | - | t.Errorf("ValidAURHelpers length = %d, want %d", len(ValidAURHelpers), len(expected)) | |||
| 484 | - | } | |||
| 485 | - | ||||
| 486 | - | for i, helper := range expected { | |||
| 487 | - | if ValidAURHelpers[i] != helper { | |||
| 488 | - | t.Errorf("ValidAURHelpers[%d] = %q, want %q", i, ValidAURHelpers[i], helper) | |||
| 489 | - | } | |||
| 490 | - | } | |||
| 491 | - | } | |||
| 492 | - | ||||
| 493 | - | // TestReposPhasePreCheckValidation tests PreCheck validation logic | |||
| 494 | - | func TestReposPhasePreCheckValidation(t *testing.T) { | |||
| 495 | - | // Create a temporary directory for log file | |||
| 496 | - | tmpDir := t.TempDir() | |||
| 497 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 498 | - | ||||
| 499 | - | // Create logger | |||
| 500 | - | log, err := logger.New(logPath, false) | |||
| 501 | - | if err != nil { | |||
| 502 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 503 | - | } | |||
| 504 | - | defer func() { | |||
| 505 | - | if err := log.Close(); err != nil { | |||
| 506 | - | t.Fatalf("failed to close log: %v", err) | |||
| 507 | - | } | |||
| 508 | - | }() | |||
| 509 | - | ||||
| 510 | - | tests := []struct { | |||
| 511 | - | name string | |||
| 512 | - | aurHelper string | |||
| 513 | - | expectError bool | |||
| 514 | - | errorMsg string | |||
| 515 | - | }{ | |||
| 516 | - | { | |||
| 517 | - | name: "Valid paru", | |||
| 518 | - | aurHelper: AURHelperParu, | |||
| 519 | - | expectError: false, | |||
| 520 | - | }, | |||
| 521 | - | { | |||
| 522 | - | name: "Valid yay", | |||
| 523 | - | aurHelper: AURHelperYay, | |||
| 524 | - | expectError: false, | |||
| 525 | - | }, | |||
| 526 | - | { | |||
| 527 | - | name: "Empty AUR helper", | |||
| 528 | - | aurHelper: "", | |||
| 529 | - | expectError: false, | |||
| 530 | - | }, | |||
| 531 | - | { | |||
| 532 | - | name: "Invalid AUR helper", | |||
| 533 | - | aurHelper: "invalid-helper", | |||
| 534 | - | expectError: true, | |||
| 535 | - | errorMsg: "invalid AUR helper", | |||
| 536 | - | }, | |||
| 537 | - | } | |||
| 538 | - | ||||
| 539 | - | for _, tt := range tests { | |||
| 540 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 541 | - | ctrl := gomock.NewController(t) | |||
| 542 | - | defer ctrl.Finish() | |||
| 543 | - | ||||
| 544 | - | // Create config with test AUR helper | |||
| 545 | - | cfg := config.NewConfig("test") | |||
| 546 | - | cfg.AURHelper = tt.aurHelper | |||
| 547 | - | ||||
| 548 | - | // Create mocks | |||
| 549 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 550 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 551 | - | mockChrExec := mocks.NewMockChrootExecutor(ctrl) | |||
| 552 | - | ||||
| 553 | - | // Set up expected mock calls based on whether we'll pass AUR helper validation | |||
| 554 | - | // AUR helper validation happens first, so if invalid helper, we won't call RunSimple | |||
| 555 | - | if !tt.expectError || (tt.expectError && tt.errorMsg != "invalid AUR helper") { | |||
| 556 | - | mockSysExec.EXPECT().RunSimple("mountpoint", "-q", config.PathMnt).Return(system.CommandResult{ExitCode: 0}).Times(1) | |||
| 557 | - | mockFS.EXPECT().Stat(config.PathMntEtcPacmanConf).Return(mockFileInfo{}, nil).Times(1) | |||
| 558 | - | mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1) | |||
| 559 | - | } | |||
| 560 | - | ||||
| 561 | - | // Create ReposPhase | |||
| 562 | - | phase := NewReposPhase(cfg, log, mockFS, mockSysExec, mockChrExec) | |||
| 563 | - | ||||
| 564 | - | // Note: PreCheck validates AUR helper first, then /mnt is mounted, and pacman.conf exists | |||
| 565 | - | err := phase.PreCheck() | |||
| 566 | - | ||||
| 567 | - | if tt.expectError { | |||
| 568 | - | if err == nil { | |||
| 569 | - | t.Errorf("Expected error for AUR helper %q, but got nil", tt.aurHelper) | |||
| 570 | - | } else if !strings.Contains(err.Error(), tt.errorMsg) { | |||
| 571 | - | t.Errorf("Expected error message to contain %q, got %q", tt.errorMsg, err.Error()) | |||
| 572 | - | } | |||
| 573 | - | } else if !tt.expectError && err != nil { | |||
| 574 | - | // Only fail if the error is about AUR helper validation | |||
| 575 | - | if strings.Contains(err.Error(), "invalid AUR helper") { | |||
| 576 | - | t.Errorf("Did not expect AUR helper validation error for %q, but got: %v", tt.aurHelper, err) | |||
| 577 | - | } | |||
| 578 | - | // Other errors (like /mnt not mounted) are expected in test environment | |||
| 579 | - | } | |||
| 580 | - | }) | |||
| 581 | - | } | |||
| 582 | - | } | |||
| 583 | - | ||||
| 584 | - | // TestNoPackageManagerLeakage verifies that the parsing doesn't break other repos | |||
| 585 | - | func TestNoPackageManagerLeakage(t *testing.T) { | |||
| 586 | - | pacmanConf := `[core] | |||
| 587 | - | Include = /etc/pacman.d/mirrorlist | |||
| 588 | - | ||||
| 589 | - | [extra] | |||
| 590 | - | Include = /etc/pacman.d/mirrorlist | |||
| 591 | - | ||||
| 592 | - | #[multilib] | |||
| 593 | - | #Include = /etc/pacman.d/mirrorlist | |||
| 594 | - | ||||
| 595 | - | [community] | |||
| 596 | - | Include = /etc/pacman.d/mirrorlist | |||
| 597 | - | ||||
| 598 | - | [options] | |||
| 599 | - | Server = http://mirror.example.com/arch/$repo/os/$arch | |||
| 600 | - | #Include = /etc/pacman.d/custom-servers` | |||
| 601 | - | ||||
| 602 | - | contentStr := pacmanConf | |||
| 603 | - | lines := strings.Split(contentStr, "\n") | |||
| 604 | - | inMultilib := false | |||
| 605 | - | ||||
| 606 | - | for i, line := range lines { | |||
| 607 | - | trimmedLine := strings.TrimSpace(line) | |||
| 608 | - | ||||
| 609 | - | switch { | |||
| 610 | - | case trimmedLine == pacmanMultilibSectionDisabled: | |||
| 611 | - | lines[i] = pacmanMultilibSectionEnabled | |||
| 612 | - | inMultilib = true | |||
| 613 | - | case inMultilib && trimmedLine == pacmanMultilibIncludeDisabled: | |||
| 614 | - | lines[i] = strings.Replace(line, "#Include", "Include", 1) | |||
| 615 | - | inMultilib = false | |||
| 616 | - | case inMultilib && strings.HasPrefix(trimmedLine, pacmanSectionPrefix) && trimmedLine != pacmanMultilibSectionEnabled: | |||
| 617 | - | inMultilib = false | |||
| 618 | - | } | |||
| 619 | - | } | |||
| 620 | - | contentStr = strings.Join(lines, "\n") | |||
| 621 | - | ||||
| 622 | - | // Verify that other repo Includes are not affected | |||
| 623 | - | coreCount := strings.Count(contentStr, "[core]") | |||
| 624 | - | extraCount := strings.Count(contentStr, "[extra]") | |||
| 625 | - | communityCount := strings.Count(contentStr, "[community]") | |||
| 626 | - | ||||
| 627 | - | if coreCount != 1 { | |||
| 628 | - | t.Error("Expected exactly one [core] section") | |||
| 629 | - | } | |||
| 630 | - | if extraCount != 1 { | |||
| 631 | - | t.Error("Expected exactly one [extra] section") | |||
| 632 | - | } | |||
| 633 | - | if communityCount != 1 { | |||
| 634 | - | t.Error("Expected exactly one [community] section") | |||
| 635 | - | } | |||
| 636 | - | ||||
| 637 | - | // Verify [options] section still has commented Include | |||
| 638 | - | if !strings.Contains(contentStr, "#Include = /etc/pacman.d/custom-servers") { | |||
| 639 | - | t.Error("Expected custom-servers Include to remain commented") | |||
| 640 | - | } | |||
| 641 | - | } | |||
| 642 | - | ||||
| 643 | - | // TestPackageFiltering tests the package filtering logic for AUR helper installation | |||
| 644 | - | func TestPackageFiltering(t *testing.T) { | |||
| 645 | - | tests := []struct { | |||
| 646 | - | name string | |||
| 647 | - | output string | |||
| 648 | - | wantPackage string | |||
| 649 | - | wantError bool | |||
| 650 | - | }{ | |||
| 651 | - | { | |||
| 652 | - | name: "yay with debug package", | |||
| 653 | - | output: `/tmp/yay/yay-12.5.2-2-x86_64.pkg.tar.zst | |||
| 654 | - | /tmp/yay/yay-debug-12.5.2-2-x86_64.pkg.tar.zst`, | |||
| 655 | - | wantPackage: "/tmp/yay/yay-12.5.2-2-x86_64.pkg.tar.zst", | |||
| 656 | - | wantError: false, | |||
| 657 | - | }, | |||
| 658 | - | { | |||
| 659 | - | name: "paru with debug package", | |||
| 660 | - | output: `/tmp/paru/paru-2.1.0-1-x86_64.pkg.tar.zst | |||
| 661 | - | /tmp/paru/paru-debug-2.1.0-1-x86_64.pkg.tar.zst`, | |||
| 662 | - | wantPackage: "/tmp/paru/paru-2.1.0-1-x86_64.pkg.tar.zst", | |||
| 663 | - | wantError: false, | |||
| 664 | - | }, | |||
| 665 | - | { | |||
| 666 | - | name: "only debug package (should filter all)", | |||
| 667 | - | output: `/tmp/yay/yay-debug-12.5.2-2-x86_64.pkg.tar.zst`, | |||
| 668 | - | wantPackage: "", | |||
| 669 | - | wantError: true, | |||
| 670 | - | }, | |||
| 671 | - | { | |||
| 672 | - | name: "empty output", | |||
| 673 | - | output: "", | |||
| 674 | - | wantPackage: "", | |||
| 675 | - | wantError: true, | |||
| 676 | - | }, | |||
| 677 | - | { | |||
| 678 | - | name: "single main package without debug", | |||
| 679 | - | output: `/tmp/yay/yay-12.5.2-2-x86_64.pkg.tar.zst`, | |||
| 680 | - | wantPackage: "/tmp/yay/yay-12.5.2-2-x86_64.pkg.tar.zst", | |||
| 681 | - | wantError: false, | |||
| 682 | - | }, | |||
| 683 | - | { | |||
| 684 | - | name: "with whitespace and empty lines", | |||
| 685 | - | output: ` | |||
| 686 | - | /tmp/yay/yay-12.5.2-2-x86_64.pkg.tar.zst | |||
| 687 | - | ||||
| 688 | - | /tmp/yay/yay-debug-12.5.2-2-x86_64.pkg.tar.zst | |||
| 689 | - | `, | |||
| 690 | - | wantPackage: "/tmp/yay/yay-12.5.2-2-x86_64.pkg.tar.zst", | |||
| 691 | - | wantError: false, | |||
| 692 | - | }, | |||
| 693 | - | } | |||
| 694 | - | ||||
| 695 | - | for _, tt := range tests { | |||
| 696 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 697 | - | // Simulate the filtering logic from buildFromAUR | |||
| 698 | - | lines := strings.Split(strings.TrimSpace(tt.output), "\n") | |||
| 699 | - | var mainPackage string | |||
| 700 | - | for _, line := range lines { | |||
| 701 | - | line = strings.TrimSpace(line) | |||
| 702 | - | // Skip debug packages (contain "-debug-" in the filename) | |||
| 703 | - | if line != "" && !strings.Contains(line, "-debug-") { | |||
| 704 | - | mainPackage = line | |||
| 705 | - | break | |||
| 706 | - | } | |||
| 707 | - | } | |||
| 708 | - | ||||
| 709 | - | if tt.wantError && mainPackage != "" { | |||
| 710 | - | t.Errorf("Expected no package but got: %s", mainPackage) | |||
| 711 | - | } | |||
| 712 | - | ||||
| 713 | - | if !tt.wantError && mainPackage != tt.wantPackage { | |||
| 714 | - | t.Errorf("Expected package %q, got %q", tt.wantPackage, mainPackage) | |||
| 715 | - | } | |||
| 716 | - | ||||
| 717 | - | if tt.wantError && mainPackage == "" { | |||
| 718 | - | // This is expected | |||
| 719 | - | return | |||
| 720 | - | } | |||
| 721 | - | }) | |||
| 722 | - | } | |||
| 723 | - | } | |||
D internal/phases/systemconfig.go +0 -254
| @@ -1,254 +0,0 @@ | |||||
| 1 | - | package phases | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "fmt" | |||
| 5 | - | "path/filepath" | |||
| 6 | - | ||||
| 7 | - | "github.com/bnema/archup/internal/config" | |||
| 8 | - | "github.com/bnema/archup/internal/interfaces" | |||
| 9 | - | "github.com/bnema/archup/internal/logger" | |||
| 10 | - | ) | |||
| 11 | - | ||||
| 12 | - | // ConfigPhase handles system configuration | |||
| 13 | - | type ConfigPhase struct { | |||
| 14 | - | *BasePhase | |||
| 15 | - | fs interfaces.FileSystem | |||
| 16 | - | sysExec interfaces.SystemExecutor | |||
| 17 | - | chrExec interfaces.ChrootExecutor | |||
| 18 | - | } | |||
| 19 | - | ||||
| 20 | - | // NewConfigPhase creates a new config phase | |||
| 21 | - | func NewConfigPhase(cfg *config.Config, log *logger.Logger, fs interfaces.FileSystem, sysExec interfaces.SystemExecutor, chrExec interfaces.ChrootExecutor) *ConfigPhase { | |||
| 22 | - | return &ConfigPhase{ | |||
| 23 | - | BasePhase: NewBasePhase("config", "System Configuration", cfg, log), | |||
| 24 | - | fs: fs, | |||
| 25 | - | sysExec: sysExec, | |||
| 26 | - | chrExec: chrExec, | |||
| 27 | - | } | |||
| 28 | - | } | |||
| 29 | - | ||||
| 30 | - | // PreCheck validates configuration prerequisites | |||
| 31 | - | func (p *ConfigPhase) PreCheck() error { | |||
| 32 | - | // Verify /mnt is mounted | |||
| 33 | - | result := p.sysExec.RunSimple("mountpoint", "-q", config.PathMnt) | |||
| 34 | - | if result.ExitCode != 0 { | |||
| 35 | - | return fmt.Errorf("%s is not mounted", config.PathMnt) | |||
| 36 | - | } | |||
| 37 | - | ||||
| 38 | - | // Verify required config values | |||
| 39 | - | switch { | |||
| 40 | - | case p.config.Hostname == "": | |||
| 41 | - | return fmt.Errorf("hostname is required") | |||
| 42 | - | case p.config.Username == "": | |||
| 43 | - | return fmt.Errorf("username is required") | |||
| 44 | - | case p.config.UserPassword == "": | |||
| 45 | - | return fmt.Errorf("user password is required") | |||
| 46 | - | case p.config.Timezone == "": | |||
| 47 | - | return fmt.Errorf("timezone is required") | |||
| 48 | - | } | |||
| 49 | - | ||||
| 50 | - | return nil | |||
| 51 | - | } | |||
| 52 | - | ||||
| 53 | - | // Execute runs the config phase | |||
| 54 | - | func (p *ConfigPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult { | |||
| 55 | - | p.SendProgress(progressChan, "Starting system configuration...", 1, 4) | |||
| 56 | - | ||||
| 57 | - | // Step 1: System config (timezone, locale, hostname) | |||
| 58 | - | if err := p.configureSystem(progressChan); err != nil { | |||
| 59 | - | p.SendError(progressChan, err) | |||
| 60 | - | return PhaseResult{Success: false, Error: err} | |||
| 61 | - | } | |||
| 62 | - | ||||
| 63 | - | p.SendProgress(progressChan, "Configuring network...", 2, 4) | |||
| 64 | - | ||||
| 65 | - | // Step 2: Network config | |||
| 66 | - | if err := p.configureNetwork(progressChan); err != nil { | |||
| 67 | - | p.SendError(progressChan, err) | |||
| 68 | - | return PhaseResult{Success: false, Error: err} | |||
| 69 | - | } | |||
| 70 | - | ||||
| 71 | - | p.SendProgress(progressChan, "Creating user account...", 3, 4) | |||
| 72 | - | ||||
| 73 | - | // Step 3: User creation | |||
| 74 | - | if err := p.createUser(progressChan); err != nil { | |||
| 75 | - | p.SendError(progressChan, err) | |||
| 76 | - | return PhaseResult{Success: false, Error: err} | |||
| 77 | - | } | |||
| 78 | - | ||||
| 79 | - | p.SendProgress(progressChan, "Configuring zram swap...", 4, 4) | |||
| 80 | - | ||||
| 81 | - | // Step 4: Configure zram | |||
| 82 | - | if err := p.configureZram(progressChan); err != nil { | |||
| 83 | - | p.SendError(progressChan, err) | |||
| 84 | - | return PhaseResult{Success: false, Error: err} | |||
| 85 | - | } | |||
| 86 | - | ||||
| 87 | - | p.SendProgress(progressChan, "Configuration complete", 4, 4) | |||
| 88 | - | p.SendComplete(progressChan, "System configured successfully") | |||
| 89 | - | ||||
| 90 | - | return PhaseResult{Success: true, Message: "Configuration complete"} | |||
| 91 | - | } | |||
| 92 | - | ||||
| 93 | - | // configureSystem sets timezone, locale, hostname | |||
| 94 | - | func (p *ConfigPhase) configureSystem(progressChan chan<- ProgressUpdate) error { | |||
| 95 | - | p.SendOutput(progressChan, "Configuring system settings...") | |||
| 96 | - | ||||
| 97 | - | // Set timezone | |||
| 98 | - | timezonePath := filepath.Join(config.PathUsrShareZoneinfo, p.config.Timezone) | |||
| 99 | - | tzCmd := fmt.Sprintf("ln -sf %s %s", timezonePath, config.PathEtcLocaltime) | |||
| 100 | - | if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, tzCmd); err != nil { | |||
| 101 | - | return fmt.Errorf("failed to set timezone: %w", err) | |||
| 102 | - | } | |||
| 103 | - | ||||
| 104 | - | // Set hardware clock | |||
| 105 | - | if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, "hwclock --systohc"); err != nil { | |||
| 106 | - | return fmt.Errorf("failed to set hardware clock: %w", err) | |||
| 107 | - | } | |||
| 108 | - | ||||
| 109 | - | p.SendOutput(progressChan, fmt.Sprintf("[OK] Timezone set to: %s", p.config.Timezone)) | |||
| 110 | - | ||||
| 111 | - | // Set locale | |||
| 112 | - | localeGen := p.config.Locale + " UTF-8\n" | |||
| 113 | - | if err := p.fs.WriteFile(config.PathMntEtcLocaleGen, []byte(localeGen), 0644); err != nil { | |||
| 114 | - | return fmt.Errorf("failed to write locale.gen: %w", err) | |||
| 115 | - | } | |||
| 116 | - | ||||
| 117 | - | if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, "locale-gen"); err != nil { | |||
| 118 | - | return fmt.Errorf("failed to generate locale: %w", err) | |||
| 119 | - | } | |||
| 120 | - | ||||
| 121 | - | localeConf := fmt.Sprintf("LANG=%s\n", p.config.Locale) | |||
| 122 | - | if err := p.fs.WriteFile(config.PathMntEtcLocaleConf, []byte(localeConf), 0644); err != nil { | |||
| 123 | - | return fmt.Errorf("failed to write locale.conf: %w", err) | |||
| 124 | - | } | |||
| 125 | - | ||||
| 126 | - | p.SendOutput(progressChan, fmt.Sprintf("[OK] Locale set to: %s", p.config.Locale)) | |||
| 127 | - | ||||
| 128 | - | // Set console keymap | |||
| 129 | - | if p.config.Keymap != "" { | |||
| 130 | - | vconsoleConf := fmt.Sprintf("KEYMAP=%s\n", p.config.Keymap) | |||
| 131 | - | if err := p.fs.WriteFile(config.PathMntEtcVconsole, []byte(vconsoleConf), 0644); err != nil { | |||
| 132 | - | return fmt.Errorf("failed to write vconsole.conf: %w", err) | |||
| 133 | - | } | |||
| 134 | - | p.SendOutput(progressChan, fmt.Sprintf("[OK] Console keymap set to: %s", p.config.Keymap)) | |||
| 135 | - | } | |||
| 136 | - | ||||
| 137 | - | // Set hostname | |||
| 138 | - | if err := p.fs.WriteFile(config.PathMntEtcHostname, []byte(p.config.Hostname+"\n"), 0644); err != nil { | |||
| 139 | - | return fmt.Errorf("failed to write hostname: %w", err) | |||
| 140 | - | } | |||
| 141 | - | ||||
| 142 | - | // Configure hosts file | |||
| 143 | - | hostsContent := fmt.Sprintf(`127.0.0.1 localhost | |||
| 144 | - | ::1 localhost | |||
| 145 | - | 127.0.1.1 %s.localdomain %s | |||
| 146 | - | `, p.config.Hostname, p.config.Hostname) | |||
| 147 | - | ||||
| 148 | - | if err := p.fs.WriteFile(config.PathMntEtcHosts, []byte(hostsContent), 0644); err != nil { | |||
| 149 | - | return fmt.Errorf("failed to write hosts file: %w", err) | |||
| 150 | - | } | |||
| 151 | - | ||||
| 152 | - | p.SendOutput(progressChan, fmt.Sprintf("[OK] Hostname set to: %s", p.config.Hostname)) | |||
| 153 | - | ||||
| 154 | - | return nil | |||
| 155 | - | } | |||
| 156 | - | ||||
| 157 | - | // configureNetwork enables NetworkManager | |||
| 158 | - | func (p *ConfigPhase) configureNetwork(progressChan chan<- ProgressUpdate) error { | |||
| 159 | - | p.SendOutput(progressChan, "Configuring network...") | |||
| 160 | - | ||||
| 161 | - | // Enable NetworkManager service | |||
| 162 | - | if err := p.chrExec.ChrootSystemctl(p.logger.LogPath(), config.PathMnt, "enable", config.ServiceNetworkManager); err != nil { | |||
| 163 | - | return fmt.Errorf("failed to enable NetworkManager: %w", err) | |||
| 164 | - | } | |||
| 165 | - | ||||
| 166 | - | p.SendOutput(progressChan, "[OK] Network configured") | |||
| 167 | - | ||||
| 168 | - | return nil | |||
| 169 | - | } | |||
| 170 | - | ||||
| 171 | - | // createUser creates user account with sudo access | |||
| 172 | - | func (p *ConfigPhase) createUser(progressChan chan<- ProgressUpdate) error { | |||
| 173 | - | p.SendOutput(progressChan, "Creating user account...") | |||
| 174 | - | ||||
| 175 | - | // Create user with home directory | |||
| 176 | - | userAddCmd := fmt.Sprintf("useradd -m -G %s -s %s %s", config.GroupWheel, config.ShellBash, p.config.Username) | |||
| 177 | - | if err := p.chrExec.ChrootExec(p.logger.LogPath(), config.PathMnt, userAddCmd); err != nil { | |||
| 178 | - | return fmt.Errorf("failed to create user: %w", err) | |||
| 179 | - | } | |||
| 180 | - | ||||
| 181 | - | // Set user password using stdin (secure - not visible in process list) | |||
| 182 | - | userPassInput := fmt.Sprintf("%s:%s", p.config.Username, p.config.UserPassword) | |||
| 183 | - | if err := p.chrExec.ChrootExecWithStdin(p.logger.LogPath(), config.PathMnt, "chpasswd", userPassInput); err != nil { | |||
| 184 | - | return fmt.Errorf("failed to set user password: %w", err) | |||
| 185 | - | } | |||
| 186 | - | ||||
| 187 | - | // Enable sudo for wheel group | |||
| 188 | - | // Create sudoers.d directory if it doesn't exist | |||
| 189 | - | sudoersDir := filepath.Dir(config.PathMntEtcSudoersD) | |||
| 190 | - | if err := p.fs.MkdirAll(sudoersDir, 0755); err != nil { | |||
| 191 | - | return fmt.Errorf("failed to create sudoers.d directory: %w", err) | |||
| 192 | - | } | |||
| 193 | - | if err := p.fs.WriteFile(config.PathMntEtcSudoersD, []byte(config.SudoersWheelContent), config.SudoersWheelPerms); err != nil { | |||
| 194 | - | return fmt.Errorf("failed to write sudoers config: %w", err) | |||
| 195 | - | } | |||
| 196 | - | ||||
| 197 | - | p.SendOutput(progressChan, fmt.Sprintf("[OK] User created: %s", p.config.Username)) | |||
| 198 | - | p.SendOutput(progressChan, "[OK] Sudo enabled for wheel group") | |||
| 199 | - | ||||
| 200 | - | return nil | |||
| 201 | - | } | |||
| 202 | - | ||||
| 203 | - | // configureZram sets up zram compressed swap | |||
| 204 | - | func (p *ConfigPhase) configureZram(progressChan chan<- ProgressUpdate) error { | |||
| 205 | - | p.SendOutput(progressChan, "Configuring zram swap...") | |||
| 206 | - | ||||
| 207 | - | // Create zram-generator config | |||
| 208 | - | zramConfigPath := filepath.Join(config.PathMntEtcSystemd, config.FileZramGenerator) | |||
| 209 | - | if err := p.fs.WriteFile(zramConfigPath, []byte(config.ZramConfigContent), 0644); err != nil { | |||
| 210 | - | return fmt.Errorf("failed to write zram-generator.conf: %w", err) | |||
| 211 | - | } | |||
| 212 | - | ||||
| 213 | - | // Create sysctl config for zram optimization | |||
| 214 | - | sysctlConfigPath := filepath.Join(config.PathMntEtcSysctlD, config.FileSysctlZramParams) | |||
| 215 | - | if err := p.fs.WriteFile(sysctlConfigPath, []byte(config.ZramSysctlContent), 0644); err != nil { | |||
| 216 | - | return fmt.Errorf("failed to write sysctl zram config: %w", err) | |||
| 217 | - | } | |||
| 218 | - | ||||
| 219 | - | p.SendOutput(progressChan, "[OK] zram swap configured") | |||
| 220 | - | ||||
| 221 | - | return nil | |||
| 222 | - | } | |||
| 223 | - | ||||
| 224 | - | // PostCheck validates configuration | |||
| 225 | - | func (p *ConfigPhase) PostCheck() error { | |||
| 226 | - | // Verify hostname was set | |||
| 227 | - | if _, err := p.fs.Stat(config.PathMntEtcHostname); p.fs.IsNotExist(err) { | |||
| 228 | - | return fmt.Errorf("hostname file was not created") | |||
| 229 | - | } | |||
| 230 | - | ||||
| 231 | - | // Verify locale was set | |||
| 232 | - | if _, err := p.fs.Stat(config.PathMntEtcLocaleConf); p.fs.IsNotExist(err) { | |||
| 233 | - | return fmt.Errorf("locale.conf was not created") | |||
| 234 | - | } | |||
| 235 | - | ||||
| 236 | - | // Verify zram config exists | |||
| 237 | - | zramConfigPath := filepath.Join(config.PathMntEtcSystemd, config.FileZramGenerator) | |||
| 238 | - | if _, err := p.fs.Stat(zramConfigPath); p.fs.IsNotExist(err) { | |||
| 239 | - | return fmt.Errorf("zram-generator.conf was not created") | |||
| 240 | - | } | |||
| 241 | - | ||||
| 242 | - | return p.config.Save() | |||
| 243 | - | } | |||
| 244 | - | ||||
| 245 | - | // Rollback for config phase (limited - no-op) | |||
| 246 | - | func (p *ConfigPhase) Rollback() error { | |||
| 247 | - | // Configuration changes are in chroot, cleaned up by partitioning rollback | |||
| 248 | - | return nil | |||
| 249 | - | } | |||
| 250 | - | ||||
| 251 | - | // CanSkip returns false - config cannot be skipped | |||
| 252 | - | func (p *ConfigPhase) CanSkip() bool { | |||
| 253 | - | return false | |||
| 254 | - | } | |||
D internal/phases/systemconfig_test.go +0 -327
| @@ -1,327 +0,0 @@ | |||||
| 1 | - | package phases | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "fmt" | |||
| 5 | - | "path/filepath" | |||
| 6 | - | "testing" | |||
| 7 | - | ||||
| 8 | - | "github.com/bnema/archup/internal/config" | |||
| 9 | - | "github.com/bnema/archup/internal/interfaces/mocks" | |||
| 10 | - | "github.com/bnema/archup/internal/logger" | |||
| 11 | - | "github.com/bnema/archup/internal/system" | |||
| 12 | - | "go.uber.org/mock/gomock" | |||
| 13 | - | ) | |||
| 14 | - | ||||
| 15 | - | // TestConfigPhasePreCheck tests configuration prerequisites | |||
| 16 | - | func TestConfigPhasePreCheck(t *testing.T) { | |||
| 17 | - | tests := []struct { | |||
| 18 | - | name string | |||
| 19 | - | hostname string | |||
| 20 | - | username string | |||
| 21 | - | userPass string | |||
| 22 | - | timezone string | |||
| 23 | - | mountResult system.CommandResult | |||
| 24 | - | wantErr bool | |||
| 25 | - | errContains string | |||
| 26 | - | }{ | |||
| 27 | - | { | |||
| 28 | - | name: "All required fields present - /mnt mounted", | |||
| 29 | - | hostname: "archup-machine", | |||
| 30 | - | username: "archuser", | |||
| 31 | - | userPass: "password123", | |||
| 32 | - | timezone: "UTC", | |||
| 33 | - | mountResult: system.CommandResult{ExitCode: 0}, | |||
| 34 | - | wantErr: false, | |||
| 35 | - | }, | |||
| 36 | - | { | |||
| 37 | - | name: "Missing hostname", | |||
| 38 | - | hostname: "", | |||
| 39 | - | username: "archuser", | |||
| 40 | - | userPass: "password123", | |||
| 41 | - | timezone: "UTC", | |||
| 42 | - | mountResult: system.CommandResult{ExitCode: 0}, | |||
| 43 | - | wantErr: true, | |||
| 44 | - | errContains: "hostname is required", | |||
| 45 | - | }, | |||
| 46 | - | { | |||
| 47 | - | name: "Missing username", | |||
| 48 | - | hostname: "archup-machine", | |||
| 49 | - | username: "", | |||
| 50 | - | userPass: "password123", | |||
| 51 | - | timezone: "UTC", | |||
| 52 | - | mountResult: system.CommandResult{ExitCode: 0}, | |||
| 53 | - | wantErr: true, | |||
| 54 | - | errContains: "username is required", | |||
| 55 | - | }, | |||
| 56 | - | { | |||
| 57 | - | name: "Missing user password", | |||
| 58 | - | hostname: "archup-machine", | |||
| 59 | - | username: "archuser", | |||
| 60 | - | userPass: "", | |||
| 61 | - | timezone: "UTC", | |||
| 62 | - | mountResult: system.CommandResult{ExitCode: 0}, | |||
| 63 | - | wantErr: true, | |||
| 64 | - | errContains: "user password is required", | |||
| 65 | - | }, | |||
| 66 | - | { | |||
| 67 | - | name: "Missing timezone", | |||
| 68 | - | hostname: "archup-machine", | |||
| 69 | - | username: "archuser", | |||
| 70 | - | userPass: "password123", | |||
| 71 | - | timezone: "", | |||
| 72 | - | mountResult: system.CommandResult{ExitCode: 0}, | |||
| 73 | - | wantErr: true, | |||
| 74 | - | errContains: "timezone is required", | |||
| 75 | - | }, | |||
| 76 | - | { | |||
| 77 | - | name: "/mnt not mounted", | |||
| 78 | - | hostname: "archup-machine", | |||
| 79 | - | username: "archuser", | |||
| 80 | - | userPass: "password123", | |||
| 81 | - | timezone: "UTC", | |||
| 82 | - | mountResult: system.CommandResult{ExitCode: 32}, | |||
| 83 | - | wantErr: true, | |||
| 84 | - | errContains: "is not mounted", | |||
| 85 | - | }, | |||
| 86 | - | } | |||
| 87 | - | ||||
| 88 | - | for _, tt := range tests { | |||
| 89 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 90 | - | ctrl := gomock.NewController(t) | |||
| 91 | - | defer ctrl.Finish() | |||
| 92 | - | ||||
| 93 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 94 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 95 | - | mockChrExec := mocks.NewMockChrootExecutor(ctrl) | |||
| 96 | - | ||||
| 97 | - | mockSysExec.EXPECT().RunSimple("mountpoint", "-q", config.PathMnt).Return(tt.mountResult).Times(1) | |||
| 98 | - | ||||
| 99 | - | tmpDir := t.TempDir() | |||
| 100 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 101 | - | log, err := logger.New(logPath, false) | |||
| 102 | - | if err != nil { | |||
| 103 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 104 | - | } | |||
| 105 | - | defer func() { | |||
| 106 | - | if err := log.Close(); err != nil { | |||
| 107 | - | t.Fatalf("failed to close log: %v", err) | |||
| 108 | - | } | |||
| 109 | - | }() | |||
| 110 | - | ||||
| 111 | - | cfg := config.NewConfig("test") | |||
| 112 | - | cfg.Hostname = tt.hostname | |||
| 113 | - | cfg.Username = tt.username | |||
| 114 | - | cfg.UserPassword = tt.userPass | |||
| 115 | - | cfg.Timezone = tt.timezone | |||
| 116 | - | ||||
| 117 | - | phase := NewConfigPhase(cfg, log, mockFS, mockSysExec, mockChrExec) | |||
| 118 | - | ||||
| 119 | - | err = phase.PreCheck() | |||
| 120 | - | if (err != nil) != tt.wantErr { | |||
| 121 | - | t.Errorf("PreCheck() error = %v, wantErr %v", err, tt.wantErr) | |||
| 122 | - | return | |||
| 123 | - | } | |||
| 124 | - | if tt.wantErr && err != nil { | |||
| 125 | - | if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) { | |||
| 126 | - | t.Errorf("PreCheck() error = %q, should contain %q", errMsg, tt.errContains) | |||
| 127 | - | } | |||
| 128 | - | } | |||
| 129 | - | }) | |||
| 130 | - | } | |||
| 131 | - | } | |||
| 132 | - | ||||
| 133 | - | // TestConfigPhasePostCheck tests configuration validation | |||
| 134 | - | func TestConfigPhasePostCheck(t *testing.T) { | |||
| 135 | - | tests := []struct { | |||
| 136 | - | name string | |||
| 137 | - | setupMocks func(*mocks.MockFileSystem) | |||
| 138 | - | wantErr bool | |||
| 139 | - | errContains string | |||
| 140 | - | }{ | |||
| 141 | - | { | |||
| 142 | - | name: "All configuration files exist - postcheck passes", | |||
| 143 | - | setupMocks: func(mockFS *mocks.MockFileSystem) { | |||
| 144 | - | mockFS.EXPECT().Stat(config.PathMntEtcHostname).Return(mockFileInfo{}, nil).Times(1) | |||
| 145 | - | mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1) | |||
| 146 | - | mockFS.EXPECT().Stat(config.PathMntEtcLocaleConf).Return(mockFileInfo{}, nil).Times(1) | |||
| 147 | - | mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1) | |||
| 148 | - | zramPath := filepath.Join(config.PathMntEtcSystemd, config.FileZramGenerator) | |||
| 149 | - | mockFS.EXPECT().Stat(zramPath).Return(mockFileInfo{}, nil).Times(1) | |||
| 150 | - | mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1) | |||
| 151 | - | }, | |||
| 152 | - | wantErr: false, | |||
| 153 | - | }, | |||
| 154 | - | { | |||
| 155 | - | name: "Hostname file missing - postcheck fails", | |||
| 156 | - | setupMocks: func(mockFS *mocks.MockFileSystem) { | |||
| 157 | - | mockFS.EXPECT().Stat(config.PathMntEtcHostname).Return(nil, fmt.Errorf("not found")).Times(1) | |||
| 158 | - | mockFS.EXPECT().IsNotExist(gomock.Any()).Return(true).Times(1) | |||
| 159 | - | }, | |||
| 160 | - | wantErr: true, | |||
| 161 | - | errContains: "hostname file was not created", | |||
| 162 | - | }, | |||
| 163 | - | { | |||
| 164 | - | name: "Locale.conf file missing - postcheck fails", | |||
| 165 | - | setupMocks: func(mockFS *mocks.MockFileSystem) { | |||
| 166 | - | mockFS.EXPECT().Stat(config.PathMntEtcHostname).Return(mockFileInfo{}, nil).Times(1) | |||
| 167 | - | mockFS.EXPECT().IsNotExist(gomock.Any()).Return(false).Times(1) | |||
| 168 | - | mockFS.EXPECT().Stat(config.PathMntEtcLocaleConf).Return(nil, fmt.Errorf("not found")).Times(1) | |||
| 169 | - | mockFS.EXPECT().IsNotExist(gomock.Any()).Return(true).Times(1) | |||
| 170 | - | }, | |||
| 171 | - | wantErr: true, | |||
| 172 | - | errContains: "locale.conf was not created", | |||
| 173 | - | }, | |||
| 174 | - | } | |||
| 175 | - | ||||
| 176 | - | for _, tt := range tests { | |||
| 177 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 178 | - | ctrl := gomock.NewController(t) | |||
| 179 | - | defer ctrl.Finish() | |||
| 180 | - | ||||
| 181 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 182 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 183 | - | mockChrExec := mocks.NewMockChrootExecutor(ctrl) | |||
| 184 | - | ||||
| 185 | - | tt.setupMocks(mockFS) | |||
| 186 | - | ||||
| 187 | - | tmpDir := t.TempDir() | |||
| 188 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 189 | - | log, err := logger.New(logPath, false) | |||
| 190 | - | if err != nil { | |||
| 191 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 192 | - | } | |||
| 193 | - | defer func() { | |||
| 194 | - | if err := log.Close(); err != nil { | |||
| 195 | - | t.Fatalf("failed to close log: %v", err) | |||
| 196 | - | } | |||
| 197 | - | }() | |||
| 198 | - | ||||
| 199 | - | cfg := config.NewConfig("test") | |||
| 200 | - | cfg.ConfigPath = filepath.Join(tmpDir, "config") | |||
| 201 | - | ||||
| 202 | - | phase := NewConfigPhase(cfg, log, mockFS, mockSysExec, mockChrExec) | |||
| 203 | - | ||||
| 204 | - | err = phase.PostCheck() | |||
| 205 | - | if (err != nil) != tt.wantErr { | |||
| 206 | - | t.Errorf("PostCheck() error = %v, wantErr %v", err, tt.wantErr) | |||
| 207 | - | return | |||
| 208 | - | } | |||
| 209 | - | if tt.wantErr && err != nil { | |||
| 210 | - | if errMsg := err.Error(); tt.errContains != "" && !containsSubstring(errMsg, tt.errContains) { | |||
| 211 | - | t.Errorf("PostCheck() error = %q, should contain %q", errMsg, tt.errContains) | |||
| 212 | - | } | |||
| 213 | - | } | |||
| 214 | - | }) | |||
| 215 | - | } | |||
| 216 | - | } | |||
| 217 | - | ||||
| 218 | - | // TestConfigPhaseExecute tests the Execute method | |||
| 219 | - | func TestConfigPhaseExecute(t *testing.T) { | |||
| 220 | - | ctrl := gomock.NewController(t) | |||
| 221 | - | defer ctrl.Finish() | |||
| 222 | - | ||||
| 223 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 224 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 225 | - | mockChrExec := mocks.NewMockChrootExecutor(ctrl) | |||
| 226 | - | ||||
| 227 | - | // Setup successful calls for all configuration steps | |||
| 228 | - | mockChrExec.EXPECT().ChrootExec(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() | |||
| 229 | - | mockChrExec.EXPECT().ChrootExecWithStdin(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() | |||
| 230 | - | mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() | |||
| 231 | - | mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() | |||
| 232 | - | mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() | |||
| 233 | - | ||||
| 234 | - | tmpDir := t.TempDir() | |||
| 235 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 236 | - | log, err := logger.New(logPath, false) | |||
| 237 | - | if err != nil { | |||
| 238 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 239 | - | } | |||
| 240 | - | defer func() { | |||
| 241 | - | if err := log.Close(); err != nil { | |||
| 242 | - | t.Fatalf("failed to close log: %v", err) | |||
| 243 | - | } | |||
| 244 | - | }() | |||
| 245 | - | ||||
| 246 | - | cfg := config.NewConfig("test") | |||
| 247 | - | cfg.Hostname = "archup" | |||
| 248 | - | cfg.Username = "archuser" | |||
| 249 | - | cfg.UserPassword = "password" | |||
| 250 | - | cfg.Timezone = "UTC" | |||
| 251 | - | cfg.Locale = "en_US" | |||
| 252 | - | cfg.Keymap = "us" | |||
| 253 | - | ||||
| 254 | - | phase := NewConfigPhase(cfg, log, mockFS, mockSysExec, mockChrExec) | |||
| 255 | - | ||||
| 256 | - | progressChan := make(chan ProgressUpdate, 100) | |||
| 257 | - | result := phase.Execute(progressChan) | |||
| 258 | - | ||||
| 259 | - | if !result.Success { | |||
| 260 | - | t.Errorf("Execute() Success = %v, want true", result.Success) | |||
| 261 | - | } | |||
| 262 | - | if result.Message != "Configuration complete" { | |||
| 263 | - | t.Errorf("Execute() Message = %q, want 'Configuration complete'", result.Message) | |||
| 264 | - | } | |||
| 265 | - | } | |||
| 266 | - | ||||
| 267 | - | // TestConfigPhaseRollback tests the Rollback method | |||
| 268 | - | func TestConfigPhaseRollback(t *testing.T) { | |||
| 269 | - | ctrl := gomock.NewController(t) | |||
| 270 | - | defer ctrl.Finish() | |||
| 271 | - | ||||
| 272 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 273 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 274 | - | mockChrExec := mocks.NewMockChrootExecutor(ctrl) | |||
| 275 | - | ||||
| 276 | - | tmpDir := t.TempDir() | |||
| 277 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 278 | - | log, err := logger.New(logPath, false) | |||
| 279 | - | if err != nil { | |||
| 280 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 281 | - | } | |||
| 282 | - | defer func() { | |||
| 283 | - | if err := log.Close(); err != nil { | |||
| 284 | - | t.Fatalf("failed to close log: %v", err) | |||
| 285 | - | } | |||
| 286 | - | }() | |||
| 287 | - | ||||
| 288 | - | cfg := config.NewConfig("test") | |||
| 289 | - | phase := NewConfigPhase(cfg, log, mockFS, mockSysExec, mockChrExec) | |||
| 290 | - | ||||
| 291 | - | // ConfigPhase Rollback should be no-op | |||
| 292 | - | err = phase.Rollback() | |||
| 293 | - | if err != nil { | |||
| 294 | - | t.Errorf("Rollback() error = %v, want nil", err) | |||
| 295 | - | } | |||
| 296 | - | } | |||
| 297 | - | ||||
| 298 | - | // TestConfigPhaseCanSkip tests the CanSkip method | |||
| 299 | - | func TestConfigPhaseCanSkip(t *testing.T) { | |||
| 300 | - | ctrl := gomock.NewController(t) | |||
| 301 | - | defer ctrl.Finish() | |||
| 302 | - | ||||
| 303 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 304 | - | mockSysExec := mocks.NewMockSystemExecutor(ctrl) | |||
| 305 | - | mockChrExec := mocks.NewMockChrootExecutor(ctrl) | |||
| 306 | - | ||||
| 307 | - | tmpDir := t.TempDir() | |||
| 308 | - | logPath := filepath.Join(tmpDir, "test.log") | |||
| 309 | - | log, err := logger.New(logPath, false) | |||
| 310 | - | if err != nil { | |||
| 311 | - | t.Fatalf("Failed to create logger: %v", err) | |||
| 312 | - | } | |||
| 313 | - | defer func() { | |||
| 314 | - | if err := log.Close(); err != nil { | |||
| 315 | - | t.Fatalf("failed to close log: %v", err) | |||
| 316 | - | } | |||
| 317 | - | }() | |||
| 318 | - | ||||
| 319 | - | cfg := config.NewConfig("test") | |||
| 320 | - | phase := NewConfigPhase(cfg, log, mockFS, mockSysExec, mockChrExec) | |||
| 321 | - | ||||
| 322 | - | if phase.CanSkip() { | |||
| 323 | - | t.Error("CanSkip() = true, want false") | |||
| 324 | - | } | |||
| 325 | - | } | |||
| 326 | - | ||||
| 327 | - | // Helper functions are defined in helpers_test.go to avoid duplication | |||
D internal/ui/DEPRECATED.md +0 -126
| @@ -1,126 +0,0 @@ | |||||
| 1 | - | # Old TUI Implementation - DEPRECATED | |||
| 2 | - | ||||
| 3 | - | ## Status: Deprecated as of Phase 6 (2025-10-31) | |||
| 4 | - | ||||
| 5 | - | This directory contains the legacy BubbleTea TUI implementation used before the Domain-Driven Design refactoring. | |||
| 6 | - | ||||
| 7 | - | ## Migration Status | |||
| 8 | - | ||||
| 9 | - | **New Implementation:** `internal/interfaces/tui/` | |||
| 10 | - | ||||
| 11 | - | The new TUI layer has been completely refactored to: | |||
| 12 | - | - ✅ Separate concerns (Models → State Only, Views → Rendering, Handlers → Events) | |||
| 13 | - | - ✅ Use application layer services instead of phase-based logic | |||
| 14 | - | - ✅ Integrate with progress tracking from ProgressTracker | |||
| 15 | - | - ✅ Follow the MVC pattern properly | |||
| 16 | - | ||||
| 17 | - | ## Files in This Directory (Legacy) | |||
| 18 | - | ||||
| 19 | - | ``` | |||
| 20 | - | internal/ui/ | |||
| 21 | - | ├── assets/ | |||
| 22 | - | │ └── assets.go # Asset loading (DEPRECATED) | |||
| 23 | - | ├── components/ | |||
| 24 | - | │ ├── forms.go # Form components (DEPRECATED) | |||
| 25 | - | │ ├── keybinds.go # Key bindings (DEPRECATED) | |||
| 26 | - | │ └── output.go # Output formatting (DEPRECATED) | |||
| 27 | - | ├── model/ | |||
| 28 | - | │ └── model.go # Model structure (DEPRECATED) | |||
| 29 | - | ├── styles/ | |||
| 30 | - | │ ├── containers.go # Container styling (DEPRECATED) | |||
| 31 | - | │ └── theme.go # Theme definition (DEPRECATED) | |||
| 32 | - | ├── views/ | |||
| 33 | - | │ ├── confirmation.go # Confirmation view (DEPRECATED) | |||
| 34 | - | │ ├── execution.go # Execution view (DEPRECATED) | |||
| 35 | - | │ ├── forms.go # Forms view (DEPRECATED) | |||
| 36 | - | │ ├── helpers.go # View helpers (DEPRECATED) | |||
| 37 | - | │ ├── network.go # Network view (DEPRECATED) | |||
| 38 | - | │ ├── results.go # Results view (DEPRECATED) | |||
| 39 | - | │ └── welcome.go # Welcome view (DEPRECATED) | |||
| 40 | - | ├── installer_forms.go # Installer form definitions (DEPRECATED) | |||
| 41 | - | ├── model.go # Main model file (DEPRECATED) | |||
| 42 | - | └── sections.go # Section definitions (DEPRECATED) | |||
| 43 | - | ``` | |||
| 44 | - | ||||
| 45 | - | ## Why Deprecated? | |||
| 46 | - | ||||
| 47 | - | The old UI implementation was tightly coupled with business logic and phases: | |||
| 48 | - | ||||
| 49 | - | 1. **Mixed Concerns**: Views, models, and business logic were intertwined | |||
| 50 | - | 2. **Phase Coupling**: Direct dependencies on `internal/phases/` | |||
| 51 | - | 3. **Non-testable**: Hard to test views without running business logic | |||
| 52 | - | 4. **Inconsistent Patterns**: Multiple ways to handle state and events | |||
| 53 | - | ||||
| 54 | - | ## Migration Path | |||
| 55 | - | ||||
| 56 | - | The new implementation at `internal/interfaces/tui/` provides: | |||
| 57 | - | ||||
| 58 | - | ### New Structure | |||
| 59 | - | ``` | |||
| 60 | - | internal/interfaces/tui/ | |||
| 61 | - | ├── app.go # Main application coordinator | |||
| 62 | - | ├── models/ | |||
| 63 | - | │ ├── form.go # Form state (inputs, focus) - STATE ONLY | |||
| 64 | - | │ ├── installation.go # Installation state - STATE ONLY | |||
| 65 | - | │ └── progress.go # Progress state - STATE ONLY | |||
| 66 | - | ├── views/ | |||
| 67 | - | │ ├── form_view.go # Form rendering - RENDERING ONLY | |||
| 68 | - | │ ├── progress_view.go # Progress rendering - RENDERING ONLY | |||
| 69 | - | │ └── summary_view.go # Summary rendering - RENDERING ONLY | |||
| 70 | - | ├── handlers/ | |||
| 71 | - | │ ├── form_handler.go # Form event handling - EVENTS ONLY | |||
| 72 | - | │ └── install_handler.go # Installation flow handling - EVENTS ONLY | |||
| 73 | - | ├── interfaces.go # Interface definitions | |||
| 74 | - | ├── messages.go # Message types for events | |||
| 75 | - | └── errors.go # TUI-specific errors | |||
| 76 | - | ``` | |||
| 77 | - | ||||
| 78 | - | ### Key Improvements | |||
| 79 | - | ||||
| 80 | - | 1. **Clear Separation of Concerns** | |||
| 81 | - | - Models: Pure state data structures | |||
| 82 | - | - Views: Pure rendering functions | |||
| 83 | - | - Handlers: Pure event processing functions | |||
| 84 | - | ||||
| 85 | - | 2. **Service Integration** | |||
| 86 | - | - Forms submit to application layer commands | |||
| 87 | - | - Application layer handlers are invoked | |||
| 88 | - | - Progress comes from ProgressTracker, not direct phase polling | |||
| 89 | - | ||||
| 90 | - | 3. **Testability** | |||
| 91 | - | - View functions can be tested with mock models | |||
| 92 | - | - Handler functions can be tested with mock commands | |||
| 93 | - | - Models are simple DTOs, easy to test | |||
| 94 | - | ||||
| 95 | - | 4. **Maintainability** | |||
| 96 | - | - Easy to add new views (just create new rendering function) | |||
| 97 | - | - Easy to add new handlers (just create new event handler) | |||
| 98 | - | - Easy to modify business logic (doesn't affect views) | |||
| 99 | - | ||||
| 100 | - | ## Deletion Timeline | |||
| 101 | - | ||||
| 102 | - | - **Now (2025-10-31)**: Mark as deprecated | |||
| 103 | - | - **After Phase 6 Complete**: Keep for reference | |||
| 104 | - | - **After 4 Weeks (2025-11-28)**: Remove from main branch | |||
| 105 | - | ||||
| 106 | - | ## What To Do If You Find Bugs | |||
| 107 | - | ||||
| 108 | - | If you find bugs in the OLD UI: | |||
| 109 | - | 1. File an issue with "legacy-ui" label | |||
| 110 | - | 2. If it's critical, it may be fixed in the deprecated code | |||
| 111 | - | 3. Prefer fixing in the new UI at `internal/interfaces/tui/` | |||
| 112 | - | ||||
| 113 | - | If you find bugs in the NEW UI: | |||
| 114 | - | 1. File an issue with "tui" label | |||
| 115 | - | 2. Create a PR fixing it in `internal/interfaces/tui/` | |||
| 116 | - | 3. This is the primary implementation going forward | |||
| 117 | - | ||||
| 118 | - | ## References | |||
| 119 | - | ||||
| 120 | - | - **New TUI**: `/home/brice/dev/projects/archup/internal/interfaces/tui/` | |||
| 121 | - | - **DDD Architecture**: `/home/brice/dev/projects/archup/docs/architecture.md` | |||
| 122 | - | - **Reference Document**: `/home/brice/dev/projects/archup/DDD_REFACTORING_PLAN.md` | |||
| 123 | - | ||||
| 124 | - | ## Questions? | |||
| 125 | - | ||||
| 126 | - | See the architecture documentation or contact the team about the DDD migration. | |||
D internal/ui/components/forms.go +0 -161
| @@ -1,161 +0,0 @@ | |||||
| 1 | - | package components | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "github.com/bnema/archup/internal/ui/styles" | |||
| 5 | - | "github.com/charmbracelet/huh" | |||
| 6 | - | "github.com/charmbracelet/lipgloss" | |||
| 7 | - | ) | |||
| 8 | - | ||||
| 9 | - | // FormBuilder provides reusable form components | |||
| 10 | - | type FormBuilder struct { | |||
| 11 | - | accessible bool | |||
| 12 | - | width int | |||
| 13 | - | } | |||
| 14 | - | ||||
| 15 | - | // NewFormBuilder creates a new form builder with specified width | |||
| 16 | - | func NewFormBuilder(accessible bool, width int) *FormBuilder { | |||
| 17 | - | return &FormBuilder{ | |||
| 18 | - | accessible: accessible, | |||
| 19 | - | width: width, | |||
| 20 | - | } | |||
| 21 | - | } | |||
| 22 | - | ||||
| 23 | - | // TextInput creates a text input field | |||
| 24 | - | func (fb *FormBuilder) TextInput(title, prompt string, value *string, validator func(string) error) *huh.Input { | |||
| 25 | - | input := huh.NewInput(). | |||
| 26 | - | Title(title). | |||
| 27 | - | Prompt(prompt). | |||
| 28 | - | Value(value) | |||
| 29 | - | ||||
| 30 | - | if validator != nil { | |||
| 31 | - | input.Validate(validator) | |||
| 32 | - | } | |||
| 33 | - | ||||
| 34 | - | return input | |||
| 35 | - | } | |||
| 36 | - | ||||
| 37 | - | // Select creates a single-selection field | |||
| 38 | - | func (fb *FormBuilder) Select(title string, options []string, value *string) *huh.Select[string] { | |||
| 39 | - | opts := make([]huh.Option[string], len(options)) | |||
| 40 | - | for i, opt := range options { | |||
| 41 | - | opts[i] = huh.NewOption(opt, opt) | |||
| 42 | - | } | |||
| 43 | - | ||||
| 44 | - | return huh.NewSelect[string](). | |||
| 45 | - | Title(title). | |||
| 46 | - | Options(opts...). | |||
| 47 | - | Value(value) | |||
| 48 | - | } | |||
| 49 | - | ||||
| 50 | - | // MultiSelect creates a multi-selection field | |||
| 51 | - | func (fb *FormBuilder) MultiSelect(title string, options []string, value *[]string, limit int) *huh.MultiSelect[string] { | |||
| 52 | - | opts := make([]huh.Option[string], len(options)) | |||
| 53 | - | for i, opt := range options { | |||
| 54 | - | opts[i] = huh.NewOption(opt, opt) | |||
| 55 | - | } | |||
| 56 | - | ||||
| 57 | - | ms := huh.NewMultiSelect[string](). | |||
| 58 | - | Title(title). | |||
| 59 | - | Options(opts...). | |||
| 60 | - | Value(value) | |||
| 61 | - | ||||
| 62 | - | if limit > 0 { | |||
| 63 | - | ms.Limit(limit) | |||
| 64 | - | } | |||
| 65 | - | ||||
| 66 | - | return ms | |||
| 67 | - | } | |||
| 68 | - | ||||
| 69 | - | // Confirm creates a yes/no confirmation with left-aligned buttons | |||
| 70 | - | func (fb *FormBuilder) Confirm(title, affirmative, negative string, value *bool) *huh.Confirm { | |||
| 71 | - | return huh.NewConfirm(). | |||
| 72 | - | Title(title). | |||
| 73 | - | Affirmative(affirmative). | |||
| 74 | - | Negative(negative). | |||
| 75 | - | Value(value). | |||
| 76 | - | WithButtonAlignment(lipgloss.Left) | |||
| 77 | - | } | |||
| 78 | - | ||||
| 79 | - | // CreateForm creates a form with accessibility settings and custom theme | |||
| 80 | - | func (fb *FormBuilder) CreateForm(groups ...*huh.Group) *huh.Form { | |||
| 81 | - | formWidth := fb.width | |||
| 82 | - | // Cap to MaxFormWidth from styles | |||
| 83 | - | if formWidth > styles.MaxFormWidth { | |||
| 84 | - | formWidth = styles.MaxFormWidth | |||
| 85 | - | } | |||
| 86 | - | if formWidth < 40 { | |||
| 87 | - | formWidth = 40 | |||
| 88 | - | } | |||
| 89 | - | ||||
| 90 | - | form := huh.NewForm(groups...). | |||
| 91 | - | WithTheme(styles.HuhTheme()). | |||
| 92 | - | WithKeyMap(CreateTTYKeyMap()). | |||
| 93 | - | WithWidth(formWidth) | |||
| 94 | - | ||||
| 95 | - | if fb.accessible { | |||
| 96 | - | form.WithAccessible(true) | |||
| 97 | - | } | |||
| 98 | - | return form | |||
| 99 | - | } | |||
| 100 | - | ||||
| 101 | - | // PasswordInput creates a password input field | |||
| 102 | - | func (fb *FormBuilder) PasswordInput(title, prompt string, value *string, validator func(string) error) *huh.Input { | |||
| 103 | - | input := fb.TextInput(title, prompt, value, validator) | |||
| 104 | - | input.EchoMode(huh.EchoModePassword) | |||
| 105 | - | return input | |||
| 106 | - | } | |||
| 107 | - | ||||
| 108 | - | // SelectWithOptions creates a select field with option objects (for labels different from values) | |||
| 109 | - | func (fb *FormBuilder) SelectWithOptions(title string, options []huh.Option[string], value *string) *huh.Select[string] { | |||
| 110 | - | return huh.NewSelect[string](). | |||
| 111 | - | Title(title). | |||
| 112 | - | Options(options...). | |||
| 113 | - | Value(value) | |||
| 114 | - | } | |||
| 115 | - | ||||
| 116 | - | // CreateOption creates a labeled option for Select fields (DRY helper) | |||
| 117 | - | func CreateOption(label, description, value string) huh.Option[string] { | |||
| 118 | - | switch { | |||
| 119 | - | case description != "": | |||
| 120 | - | return huh.NewOption(label+" - "+description, value) | |||
| 121 | - | default: | |||
| 122 | - | return huh.NewOption(label, value) | |||
| 123 | - | } | |||
| 124 | - | } | |||
| 125 | - | ||||
| 126 | - | // CreateKernelOptions creates kernel selection options with descriptions | |||
| 127 | - | func CreateKernelOptions() []huh.Option[string] { | |||
| 128 | - | return []huh.Option[string]{ | |||
| 129 | - | CreateOption("linux", "Stable mainline kernel (recommended)", "linux"), | |||
| 130 | - | CreateOption("linux-lts", "Long-term support (maximum stability)", "linux-lts"), | |||
| 131 | - | CreateOption("linux-zen", "Performance-optimized for general use", "linux-zen"), | |||
| 132 | - | CreateOption("linux-hardened", "Security-focused kernel", "linux-hardened"), | |||
| 133 | - | CreateOption("linux-cachyos", "Gaming-optimized", "linux-cachyos"), | |||
| 134 | - | } | |||
| 135 | - | } | |||
| 136 | - | ||||
| 137 | - | // CreateAMDPStateOptions creates AMD P-State mode options with descriptions | |||
| 138 | - | func CreateAMDPStateOptions(modes []string) []huh.Option[string] { | |||
| 139 | - | var options []huh.Option[string] | |||
| 140 | - | ||||
| 141 | - | for _, mode := range modes { | |||
| 142 | - | var description string | |||
| 143 | - | switch mode { | |||
| 144 | - | case "active": | |||
| 145 | - | description = "Best performance (recommended for desktop/gaming)" | |||
| 146 | - | case "guided": | |||
| 147 | - | description = "Balanced performance and efficiency (recommended for laptops)" | |||
| 148 | - | case "passive": | |||
| 149 | - | description = "Maximum compatibility (older CPUs)" | |||
| 150 | - | } | |||
| 151 | - | ||||
| 152 | - | switch { | |||
| 153 | - | case description != "": | |||
| 154 | - | options = append(options, huh.NewOption(mode+" - "+description, mode)) | |||
| 155 | - | default: | |||
| 156 | - | options = append(options, huh.NewOption(mode, mode)) | |||
| 157 | - | } | |||
| 158 | - | } | |||
| 159 | - | ||||
| 160 | - | return options | |||
| 161 | - | } | |||
D internal/ui/components/keybinds.go +0 -46
| @@ -1,46 +0,0 @@ | |||||
| 1 | - | package components | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "github.com/charmbracelet/bubbles/key" | |||
| 5 | - | "github.com/charmbracelet/huh" | |||
| 6 | - | ) | |||
| 7 | - | ||||
| 8 | - | // CreateTTYKeyMap creates a custom keymap with TTY-friendly alternatives to Shift+Tab | |||
| 9 | - | // This is needed for QEMU console and other limited terminal environments where | |||
| 10 | - | // Shift+Tab is not properly transmitted. | |||
| 11 | - | func CreateTTYKeyMap() *huh.KeyMap { | |||
| 12 | - | km := huh.NewDefaultKeyMap() | |||
| 13 | - | ||||
| 14 | - | // Add Ctrl+P as alternative to Shift+Tab for "previous" navigation | |||
| 15 | - | // These work better in QEMU console and limited TTY environments | |||
| 16 | - | km.Input.Prev = key.NewBinding( | |||
| 17 | - | key.WithKeys("shift+tab", "ctrl+p"), | |||
| 18 | - | key.WithHelp("ctrl+p/shift+tab", "back"), | |||
| 19 | - | ) | |||
| 20 | - | km.Text.Prev = key.NewBinding( | |||
| 21 | - | key.WithKeys("shift+tab", "ctrl+p"), | |||
| 22 | - | key.WithHelp("ctrl+p/shift+tab", "back"), | |||
| 23 | - | ) | |||
| 24 | - | km.Select.Prev = key.NewBinding( | |||
| 25 | - | key.WithKeys("shift+tab", "ctrl+p"), | |||
| 26 | - | key.WithHelp("ctrl+p/shift+tab", "back"), | |||
| 27 | - | ) | |||
| 28 | - | km.MultiSelect.Prev = key.NewBinding( | |||
| 29 | - | key.WithKeys("shift+tab", "ctrl+p"), | |||
| 30 | - | key.WithHelp("ctrl+p/shift+tab", "back"), | |||
| 31 | - | ) | |||
| 32 | - | km.Note.Prev = key.NewBinding( | |||
| 33 | - | key.WithKeys("shift+tab", "ctrl+p"), | |||
| 34 | - | key.WithHelp("ctrl+p/shift+tab", "back"), | |||
| 35 | - | ) | |||
| 36 | - | km.Confirm.Prev = key.NewBinding( | |||
| 37 | - | key.WithKeys("shift+tab", "ctrl+p"), | |||
| 38 | - | key.WithHelp("ctrl+p/shift+tab", "back"), | |||
| 39 | - | ) | |||
| 40 | - | km.FilePicker.Prev = key.NewBinding( | |||
| 41 | - | key.WithKeys("shift+tab", "ctrl+p"), | |||
| 42 | - | key.WithHelp("ctrl+p/shift+tab", "back"), | |||
| 43 | - | ) | |||
| 44 | - | ||||
| 45 | - | return km | |||
| 46 | - | } | |||
D internal/ui/components/output.go +0 -67
| @@ -1,67 +0,0 @@ | |||||
| 1 | - | package components | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "strings" | |||
| 5 | - | ||||
| 6 | - | "github.com/bnema/archup/internal/ui/styles" | |||
| 7 | - | "github.com/charmbracelet/bubbles/viewport" | |||
| 8 | - | tea "github.com/charmbracelet/bubbletea" | |||
| 9 | - | ) | |||
| 10 | - | ||||
| 11 | - | // OutputViewer displays scrolling command output (last N lines) | |||
| 12 | - | type OutputViewer struct { | |||
| 13 | - | viewport viewport.Model | |||
| 14 | - | lines []string | |||
| 15 | - | maxLines int | |||
| 16 | - | } | |||
| 17 | - | ||||
| 18 | - | // NewOutputViewer creates a new output viewer | |||
| 19 | - | func NewOutputViewer(width, height, maxLines int) *OutputViewer { | |||
| 20 | - | vp := viewport.New(width, height) | |||
| 21 | - | vp.Style = styles.LogStyle | |||
| 22 | - | ||||
| 23 | - | return &OutputViewer{ | |||
| 24 | - | viewport: vp, | |||
| 25 | - | lines: make([]string, 0, maxLines), | |||
| 26 | - | maxLines: maxLines, | |||
| 27 | - | } | |||
| 28 | - | } | |||
| 29 | - | ||||
| 30 | - | // AddLine adds a new line of output (keeps only last N lines) | |||
| 31 | - | func (ov *OutputViewer) AddLine(line string) { | |||
| 32 | - | ov.lines = append(ov.lines, line) | |||
| 33 | - | ||||
| 34 | - | // Keep only last maxLines | |||
| 35 | - | if len(ov.lines) > ov.maxLines { | |||
| 36 | - | ov.lines = ov.lines[len(ov.lines)-ov.maxLines:] | |||
| 37 | - | } | |||
| 38 | - | ||||
| 39 | - | // Update viewport content | |||
| 40 | - | ov.viewport.SetContent(strings.Join(ov.lines, "\n")) | |||
| 41 | - | // Auto-scroll to bottom | |||
| 42 | - | ov.viewport.GotoBottom() | |||
| 43 | - | } | |||
| 44 | - | ||||
| 45 | - | // Clear clears all output | |||
| 46 | - | func (ov *OutputViewer) Clear() { | |||
| 47 | - | ov.lines = make([]string, 0, ov.maxLines) | |||
| 48 | - | ov.viewport.SetContent("") | |||
| 49 | - | } | |||
| 50 | - | ||||
| 51 | - | // Update handles viewport updates | |||
| 52 | - | func (ov *OutputViewer) Update(msg tea.Msg) tea.Cmd { | |||
| 53 | - | var cmd tea.Cmd | |||
| 54 | - | ov.viewport, cmd = ov.viewport.Update(msg) | |||
| 55 | - | return cmd | |||
| 56 | - | } | |||
| 57 | - | ||||
| 58 | - | // View renders the viewport | |||
| 59 | - | func (ov *OutputViewer) View() string { | |||
| 60 | - | return ov.viewport.View() | |||
| 61 | - | } | |||
| 62 | - | ||||
| 63 | - | // SetSize updates the viewport dimensions | |||
| 64 | - | func (ov *OutputViewer) SetSize(width, height int) { | |||
| 65 | - | ov.viewport.Width = width | |||
| 66 | - | ov.viewport.Height = height | |||
| 67 | - | } | |||
D internal/ui/installer_forms.go +0 -250
| @@ -1,250 +0,0 @@ | |||||
| 1 | - | package ui | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "fmt" | |||
| 5 | - | ||||
| 6 | - | "github.com/bnema/archup/internal/config" | |||
| 7 | - | "github.com/bnema/archup/internal/phases" | |||
| 8 | - | "github.com/bnema/archup/internal/system" | |||
| 9 | - | "github.com/bnema/archup/internal/ui/components" | |||
| 10 | - | "github.com/bnema/archup/internal/validation" | |||
| 11 | - | "github.com/charmbracelet/huh" | |||
| 12 | - | ) | |||
| 13 | - | ||||
| 14 | - | // CreatePreflightForm creates the initial configuration form with all user identity fields | |||
| 15 | - | func CreatePreflightForm(cfg *config.Config, fb *components.FormBuilder, sys *system.System, version string) *huh.Form { | |||
| 16 | - | ||||
| 17 | - | // Auto-detect timezone from API, fallback to default if detection fails | |||
| 18 | - | detectedTimezone := sys.DetectTimezone() | |||
| 19 | - | switch { | |||
| 20 | - | case detectedTimezone != "": | |||
| 21 | - | cfg.Timezone = detectedTimezone | |||
| 22 | - | default: | |||
| 23 | - | cfg.Timezone = config.TimezoneDefault | |||
| 24 | - | } | |||
| 25 | - | ||||
| 26 | - | // Set default keymap if not already set | |||
| 27 | - | if cfg.Keymap == "" { | |||
| 28 | - | cfg.Keymap = config.KeymapDefault | |||
| 29 | - | } | |||
| 30 | - | ||||
| 31 | - | title := "ArchUp Installer" | |||
| 32 | - | if version != "" { | |||
| 33 | - | title += " " + version | |||
| 34 | - | } | |||
| 35 | - | ||||
| 36 | - | return fb.CreateForm( | |||
| 37 | - | // System configuration | |||
| 38 | - | huh.NewGroup( | |||
| 39 | - | fb.TextInput("Hostname", "> ", &cfg.Hostname, validation.ValidateHostname), | |||
| 40 | - | fb.TextInput("Username", "> ", &cfg.Username, validation.ValidateUsername), | |||
| 41 | - | fb.TextInput("Email (optional)", "> ", &cfg.Email, nil). | |||
| 42 | - | Description(DescEmail), | |||
| 43 | - | ).Title(title), | |||
| 44 | - | ||||
| 45 | - | // Passwords | |||
| 46 | - | huh.NewGroup( | |||
| 47 | - | fb.PasswordInput("User Password", "> ", &cfg.UserPassword, validation.ValidatePassword), | |||
| 48 | - | fb.PasswordInput("Confirm Password", "> ", &cfg.ConfirmPassword, validation.ValidatePasswordConfirmation(&cfg.UserPassword)), | |||
| 49 | - | ||||
| 50 | - | fb.Confirm("Use same password for disk encryption?", "Yes", "No", &cfg.UseSamePasswordForEncryption), | |||
| 51 | - | ), | |||
| 52 | - | ||||
| 53 | - | // Disk Encryption Password (only shown when NOT using same password) | |||
| 54 | - | huh.NewGroup( | |||
| 55 | - | fb.PasswordInput("Disk Encryption Password", "> ", &cfg.EncryptPassword, validation.ValidatePassword). | |||
| 56 | - | Description("Enter a separate password to unlock your encrypted disk at boot"), | |||
| 57 | - | ).WithHideFunc(func() bool { | |||
| 58 | - | return cfg.UseSamePasswordForEncryption // Hide when using same password | |||
| 59 | - | }), | |||
| 60 | - | ||||
| 61 | - | // Localization | |||
| 62 | - | huh.NewGroup( | |||
| 63 | - | fb.TextInput("Timezone", "> ", &cfg.Timezone, validation.ValidateTimezone). | |||
| 64 | - | Description(DescTimezone), | |||
| 65 | - | fb.TextInput("Locale", "> ", &cfg.Locale, validation.ValidateLocale). | |||
| 66 | - | Description(DescLocale), | |||
| 67 | - | fb.TextInput("Keymap", "> ", &cfg.Keymap, nil). | |||
| 68 | - | Description(DescKeymap), | |||
| 69 | - | ), | |||
| 70 | - | ) | |||
| 71 | - | } | |||
| 72 | - | ||||
| 73 | - | // CreateDiskSelectionForm creates disk selection form with detailed disk information | |||
| 74 | - | func CreateDiskSelectionForm(cfg *config.Config, fb *components.FormBuilder) *huh.Form { | |||
| 75 | - | ||||
| 76 | - | // Detect available disks | |||
| 77 | - | disks, err := system.ListDisks() | |||
| 78 | - | ||||
| 79 | - | var diskOptions []huh.Option[string] | |||
| 80 | - | switch { | |||
| 81 | - | case err != nil: | |||
| 82 | - | // Fallback to empty option if detection fails | |||
| 83 | - | diskOptions = []huh.Option[string]{ | |||
| 84 | - | huh.NewOption("No disks detected", ""), | |||
| 85 | - | } | |||
| 86 | - | default: | |||
| 87 | - | // Format disk options with detailed info: "/dev/sda (500GB) Samsung SSD [S3Z9NB0K123456]" | |||
| 88 | - | // Use disk.Path as value and formatted string as display label | |||
| 89 | - | for _, disk := range disks { | |||
| 90 | - | label := disk.Path + " (" + disk.Size + ")" | |||
| 91 | - | ||||
| 92 | - | // Add model if available | |||
| 93 | - | if disk.Model != "" { | |||
| 94 | - | label += " " + disk.Model | |||
| 95 | - | } | |||
| 96 | - | ||||
| 97 | - | // Add serial if available | |||
| 98 | - | if disk.Serial != "" { | |||
| 99 | - | label += " [" + disk.Serial + "]" | |||
| 100 | - | } | |||
| 101 | - | ||||
| 102 | - | // Add vendor if available | |||
| 103 | - | if disk.Vendor != "" && disk.Model == "" { | |||
| 104 | - | label += " " + disk.Vendor | |||
| 105 | - | } | |||
| 106 | - | ||||
| 107 | - | diskOptions = append(diskOptions, huh.NewOption(label, disk.Path)) | |||
| 108 | - | } | |||
| 109 | - | } | |||
| 110 | - | ||||
| 111 | - | return fb.CreateForm( | |||
| 112 | - | huh.NewGroup( | |||
| 113 | - | fb.SelectWithOptions(SectionDiskSelection, diskOptions, &cfg.TargetDisk). | |||
| 114 | - | Description(WarnDiskErase), | |||
| 115 | - | ), | |||
| 116 | - | ) | |||
| 117 | - | } | |||
| 118 | - | ||||
| 119 | - | // CreateOptionsForm creates installation options form with CPU detection and descriptions | |||
| 120 | - | func CreateOptionsForm(cfg *config.Config, fb *components.FormBuilder) *huh.Form { | |||
| 121 | - | ||||
| 122 | - | // Auto-detect CPU information | |||
| 123 | - | cpuInfo, err := system.DetectCPUInfo() | |||
| 124 | - | if err == nil { | |||
| 125 | - | cfg.CPUVendor = string(cpuInfo.Vendor) | |||
| 126 | - | cfg.Microcode = cpuInfo.Microcode | |||
| 127 | - | } | |||
| 128 | - | ||||
| 129 | - | // Page 1: Kernel and Encryption (system base configuration) | |||
| 130 | - | encryptionOptions := []huh.Option[string]{ | |||
| 131 | - | huh.NewOption("No encryption", config.EncryptionNone), | |||
| 132 | - | huh.NewOption("LUKS encryption", config.EncryptionLUKS), | |||
| 133 | - | } | |||
| 134 | - | ||||
| 135 | - | systemBaseGroup := huh.NewGroup( | |||
| 136 | - | fb.SelectWithOptions("Kernel", components.CreateKernelOptions(), &cfg.KernelChoice), | |||
| 137 | - | fb.SelectWithOptions("Encryption", encryptionOptions, &cfg.EncryptionType). | |||
| 138 | - | Description(DescEncryption), | |||
| 139 | - | ).Title(SectionKernel) | |||
| 140 | - | ||||
| 141 | - | // Page 2: Repository and package management options | |||
| 142 | - | aurOptions := []huh.Option[string]{ | |||
| 143 | - | huh.NewOption("None", ""), | |||
| 144 | - | components.CreateOption("Paru", DescAURHelperParu, phases.AURHelperParu), | |||
| 145 | - | components.CreateOption("Yay", DescAURHelperYay, phases.AURHelperYay), | |||
| 146 | - | } | |||
| 147 | - | ||||
| 148 | - | packageManagementGroup := huh.NewGroup( | |||
| 149 | - | fb.Confirm("Enable multilib repository?", "Yes", "No", &cfg.EnableMultilib). | |||
| 150 | - | Description(DescMultilib), | |||
| 151 | - | fb.Confirm("Enable Chaotic-AUR?", "Yes", "No", &cfg.EnableChaotic). | |||
| 152 | - | Description(DescChaoticAUR), | |||
| 153 | - | ) | |||
| 154 | - | ||||
| 155 | - | // AUR Helper group (only shown when Chaotic-AUR is enabled) | |||
| 156 | - | aurHelperGroup := huh.NewGroup( | |||
| 157 | - | fb.SelectWithOptions("AUR Helper", aurOptions, &cfg.AURHelper). | |||
| 158 | - | Description(DescAURHelper), | |||
| 159 | - | ).WithHideFunc(func() bool { | |||
| 160 | - | return !cfg.EnableChaotic | |||
| 161 | - | }) | |||
| 162 | - | ||||
| 163 | - | // Build form groups list | |||
| 164 | - | groups := []*huh.Group{ | |||
| 165 | - | systemBaseGroup, | |||
| 166 | - | packageManagementGroup, | |||
| 167 | - | aurHelperGroup, | |||
| 168 | - | } | |||
| 169 | - | ||||
| 170 | - | // Add AMD P-State tuning group (conditional - only for AMD CPUs) | |||
| 171 | - | switch { | |||
| 172 | - | case cpuInfo != nil && cpuInfo.Vendor == system.CPUVendorAMD: | |||
| 173 | - | switch { | |||
| 174 | - | case len(cpuInfo.AMDPStateModes) == 1: | |||
| 175 | - | // Auto-select if only one mode available | |||
| 176 | - | cfg.AMDPState = string(cpuInfo.AMDPStateModes[0]) | |||
| 177 | - | case len(cpuInfo.AMDPStateModes) > 1: | |||
| 178 | - | // Show selection if multiple modes available | |||
| 179 | - | modeStrings := make([]string, len(cpuInfo.AMDPStateModes)) | |||
| 180 | - | for i, mode := range cpuInfo.AMDPStateModes { | |||
| 181 | - | modeStrings[i] = string(mode) | |||
| 182 | - | } | |||
| 183 | - | ||||
| 184 | - | amdGroup := huh.NewGroup( | |||
| 185 | - | fb.SelectWithOptions("AMD P-State Mode", | |||
| 186 | - | components.CreateAMDPStateOptions(modeStrings), | |||
| 187 | - | &cfg.AMDPState), | |||
| 188 | - | ).Title(SectionAMDTuning).Description(DescAMDPState) | |||
| 189 | - | ||||
| 190 | - | // Insert AMD group after system base group (between kernel/encryption and package management) | |||
| 191 | - | groups = append(groups[:1], append([]*huh.Group{amdGroup}, groups[1:]...)...) | |||
| 192 | - | } | |||
| 193 | - | } | |||
| 194 | - | ||||
| 195 | - | return fb.CreateForm(groups...) | |||
| 196 | - | } | |||
| 197 | - | ||||
| 198 | - | // CreateAMDPStateForm creates AMD P-State driver selection form | |||
| 199 | - | // This form is only shown for AMD CPUs that support P-State | |||
| 200 | - | func CreateAMDPStateForm(cfg *config.Config, cpuInfo *system.CPUInfo, fb *components.FormBuilder) *huh.Form { | |||
| 201 | - | // Build P-State mode options based on available modes | |||
| 202 | - | var modeOptions []huh.Option[string] | |||
| 203 | - | ||||
| 204 | - | for _, mode := range cpuInfo.AMDPStateModes { | |||
| 205 | - | desc := system.GetPStateModeDescription(mode) | |||
| 206 | - | label := fmt.Sprintf("%s - %s", mode, desc) | |||
| 207 | - | ||||
| 208 | - | // Mark recommended mode | |||
| 209 | - | if mode == cpuInfo.RecommendedPStateMode { | |||
| 210 | - | label += " (recommended)" | |||
| 211 | - | } | |||
| 212 | - | ||||
| 213 | - | modeOptions = append(modeOptions, huh.NewOption(label, string(mode))) | |||
| 214 | - | } | |||
| 215 | - | ||||
| 216 | - | // Build description text | |||
| 217 | - | zenLabel := "Unknown" | |||
| 218 | - | if cpuInfo.AMDZenGen != nil { | |||
| 219 | - | zenLabel = cpuInfo.AMDZenGen.Label | |||
| 220 | - | } | |||
| 221 | - | ||||
| 222 | - | description := fmt.Sprintf("Detected: %s\nCPU: %s\n\nNote: Requires CPPC enabled in UEFI (AMD CBS > NBIO > SMU > CPPC)", | |||
| 223 | - | zenLabel, cpuInfo.ModelName) | |||
| 224 | - | ||||
| 225 | - | return fb.CreateForm( | |||
| 226 | - | huh.NewGroup( | |||
| 227 | - | fb.SelectWithOptions("AMD P-State Mode", modeOptions, &cfg.AMDPState). | |||
| 228 | - | Description(description), | |||
| 229 | - | ).Title("AMD P-State Configuration"), | |||
| 230 | - | ) | |||
| 231 | - | } | |||
| 232 | - | ||||
| 233 | - | // FormatDiskOption formats a disk into a user-friendly option string (DRY helper) | |||
| 234 | - | func FormatDiskOption(disk system.Disk) string { | |||
| 235 | - | label := fmt.Sprintf("%s (%s)", disk.Path, disk.Size) | |||
| 236 | - | ||||
| 237 | - | if disk.Model != "" { | |||
| 238 | - | label += " " + disk.Model | |||
| 239 | - | } | |||
| 240 | - | ||||
| 241 | - | if disk.Serial != "" { | |||
| 242 | - | label += " [" + disk.Serial + "]" | |||
| 243 | - | } | |||
| 244 | - | ||||
| 245 | - | if disk.Vendor != "" && disk.Model == "" { | |||
| 246 | - | label += " " + disk.Vendor | |||
| 247 | - | } | |||
| 248 | - | ||||
| 249 | - | return label | |||
| 250 | - | } | |||
D internal/ui/model.go +0 -421
| @@ -1,421 +0,0 @@ | |||||
| 1 | - | package ui | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "fmt" | |||
| 5 | - | "time" | |||
| 6 | - | ||||
| 7 | - | "github.com/bnema/archup/internal/config" | |||
| 8 | - | "github.com/bnema/archup/internal/logger" | |||
| 9 | - | "github.com/bnema/archup/internal/phases" | |||
| 10 | - | "github.com/bnema/archup/internal/system" | |||
| 11 | - | "github.com/bnema/archup/internal/ui/components" | |||
| 12 | - | "github.com/bnema/archup/internal/ui/styles" | |||
| 13 | - | "github.com/bnema/archup/internal/ui/views" | |||
| 14 | - | "github.com/charmbracelet/bubbles/spinner" | |||
| 15 | - | tea "github.com/charmbracelet/bubbletea" | |||
| 16 | - | "github.com/charmbracelet/huh" | |||
| 17 | - | ) | |||
| 18 | - | ||||
| 19 | - | // ModelState represents the current UI state | |||
| 20 | - | type ModelState int | |||
| 21 | - | ||||
| 22 | - | const ( | |||
| 23 | - | StateShowLogo ModelState = iota | |||
| 24 | - | StateNetworkCheck | |||
| 25 | - | StatePreflightForm | |||
| 26 | - | StateDiskForm | |||
| 27 | - | StateOptionsForm | |||
| 28 | - | StateAMDPStatePrompt | |||
| 29 | - | StateConfirmation | |||
| 30 | - | StateExecuting | |||
| 31 | - | StateComplete | |||
| 32 | - | StateResultsSuccess | |||
| 33 | - | StateResultsError | |||
| 34 | - | StateError | |||
| 35 | - | ) | |||
| 36 | - | ||||
| 37 | - | // Model is the main Bubbletea application model | |||
| 38 | - | type Model struct { | |||
| 39 | - | state ModelState | |||
| 40 | - | orchestrator *phases.Orchestrator | |||
| 41 | - | config *config.Config | |||
| 42 | - | currentForm *huh.Form | |||
| 43 | - | formBuilder *components.FormBuilder | |||
| 44 | - | output *components.OutputViewer | |||
| 45 | - | logger *logger.Logger | |||
| 46 | - | system *system.System | |||
| 47 | - | spinner spinner.Model | |||
| 48 | - | version string | |||
| 49 | - | width int | |||
| 50 | - | height int | |||
| 51 | - | err error | |||
| 52 | - | cpuInfo *system.CPUInfo | |||
| 53 | - | installStartTime time.Time | |||
| 54 | - | installEndTime time.Duration | |||
| 55 | - | networkCheckDone bool | |||
| 56 | - | networkErr error | |||
| 57 | - | } | |||
| 58 | - | ||||
| 59 | - | // NewModel creates a new installer model | |||
| 60 | - | func NewModel(orchestrator *phases.Orchestrator, cfg *config.Config, log *logger.Logger, version string) *Model { | |||
| 61 | - | log.Info("UI Model created", "version", version) | |||
| 62 | - | ||||
| 63 | - | s := spinner.New() | |||
| 64 | - | s.Spinner = spinner.Line | |||
| 65 | - | s.Style = styles.SpinnerStyle | |||
| 66 | - | ||||
| 67 | - | return &Model{ | |||
| 68 | - | state: StateShowLogo, | |||
| 69 | - | orchestrator: orchestrator, | |||
| 70 | - | config: cfg, | |||
| 71 | - | formBuilder: components.NewFormBuilder(false, 80), // Initial width, will update on WindowSizeMsg | |||
| 72 | - | logger: log, | |||
| 73 | - | system: system.NewSystem(log.Slog()), | |||
| 74 | - | spinner: s, | |||
| 75 | - | version: version, | |||
| 76 | - | width: 80, | |||
| 77 | - | height: 24, | |||
| 78 | - | output: components.NewOutputViewer(80, 20, 1000), | |||
| 79 | - | } | |||
| 80 | - | } | |||
| 81 | - | ||||
| 82 | - | // Init initializes the model | |||
| 83 | - | func (m *Model) Init() tea.Cmd { | |||
| 84 | - | m.installStartTime = time.Now() | |||
| 85 | - | return m.spinner.Tick | |||
| 86 | - | } | |||
| 87 | - | ||||
| 88 | - | // Update handles messages | |||
| 89 | - | func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |||
| 90 | - | switch msg := msg.(type) { | |||
| 91 | - | case tea.WindowSizeMsg: | |||
| 92 | - | m.width = msg.Width | |||
| 93 | - | m.height = msg.Height | |||
| 94 | - | m.output.SetSize(msg.Width, msg.Height-10) | |||
| 95 | - | // Update formBuilder width for responsive forms | |||
| 96 | - | m.formBuilder = components.NewFormBuilder(false, msg.Width) | |||
| 97 | - | ||||
| 98 | - | case tea.KeyMsg: | |||
| 99 | - | m.logger.Info("Key pressed", "key", msg.String(), "state", m.state) | |||
| 100 | - | ||||
| 101 | - | switch msg.String() { | |||
| 102 | - | case "ctrl+c": | |||
| 103 | - | m.logger.Info("User cancelled installation") | |||
| 104 | - | return m, tea.Quit | |||
| 105 | - | ||||
| 106 | - | case "enter": | |||
| 107 | - | switch m.state { | |||
| 108 | - | case StateShowLogo: | |||
| 109 | - | m.logger.Info("Moving to network check") | |||
| 110 | - | m.state = StateNetworkCheck | |||
| 111 | - | return m, m.checkNetwork() | |||
| 112 | - | ||||
| 113 | - | case StateNetworkCheck: | |||
| 114 | - | // Handle retry if network check failed | |||
| 115 | - | if m.networkErr != nil { | |||
| 116 | - | m.logger.Info("Retrying network check") | |||
| 117 | - | return m, m.checkNetwork() | |||
| 118 | - | } | |||
| 119 | - | // Network check passed, move to preflight form | |||
| 120 | - | m.logger.Info("Moving to preflight form") | |||
| 121 | - | m.state = StatePreflightForm | |||
| 122 | - | m.currentForm = CreatePreflightForm(m.config, m.formBuilder, m.system, m.version) | |||
| 123 | - | return m, m.currentForm.Init() | |||
| 124 | - | ||||
| 125 | - | case StateConfirmation: | |||
| 126 | - | m.logger.Info("User confirmed installation - starting execution") | |||
| 127 | - | m.state = StateExecuting | |||
| 128 | - | return m, m.executeNextPhase() | |||
| 129 | - | } | |||
| 130 | - | ||||
| 131 | - | case "backspace", "esc": | |||
| 132 | - | m.logger.Info("User navigating back", "from_state", m.state) | |||
| 133 | - | switch m.state { | |||
| 134 | - | case StateDiskForm: | |||
| 135 | - | m.state = StatePreflightForm | |||
| 136 | - | m.currentForm = CreatePreflightForm(m.config, m.formBuilder, m.system, m.version) | |||
| 137 | - | return m, m.currentForm.Init() | |||
| 138 | - | ||||
| 139 | - | case StateOptionsForm: | |||
| 140 | - | m.state = StateDiskForm | |||
| 141 | - | m.currentForm = CreateDiskSelectionForm(m.config, m.formBuilder) | |||
| 142 | - | return m, m.currentForm.Init() | |||
| 143 | - | ||||
| 144 | - | case StateAMDPStatePrompt: | |||
| 145 | - | m.state = StateOptionsForm | |||
| 146 | - | m.currentForm = CreateOptionsForm(m.config, m.formBuilder) | |||
| 147 | - | return m, m.currentForm.Init() | |||
| 148 | - | ||||
| 149 | - | case StateConfirmation: | |||
| 150 | - | // Go back to AMD P-State prompt if we have AMD CPU, otherwise options | |||
| 151 | - | if m.cpuInfo != nil && m.cpuInfo.Vendor == system.CPUVendorAMD && len(m.cpuInfo.AMDPStateModes) > 0 { | |||
| 152 | - | m.state = StateAMDPStatePrompt | |||
| 153 | - | m.currentForm = CreateAMDPStateForm(m.config, m.cpuInfo, m.formBuilder) | |||
| 154 | - | } else { | |||
| 155 | - | m.state = StateOptionsForm | |||
| 156 | - | m.currentForm = CreateOptionsForm(m.config, m.formBuilder) | |||
| 157 | - | } | |||
| 158 | - | return m, m.currentForm.Init() | |||
| 159 | - | } | |||
| 160 | - | } | |||
| 161 | - | ||||
| 162 | - | case networkCheckMsg: | |||
| 163 | - | m.networkCheckDone = true | |||
| 164 | - | m.networkErr = msg.err | |||
| 165 | - | if msg.err != nil { | |||
| 166 | - | m.logger.Warn("Network check failed", "error", msg.err) | |||
| 167 | - | return m, nil | |||
| 168 | - | } | |||
| 169 | - | // Network check passed - automatically continue to preflight form | |||
| 170 | - | m.logger.Info("Network check passed - continuing automatically") | |||
| 171 | - | m.state = StatePreflightForm | |||
| 172 | - | m.currentForm = CreatePreflightForm(m.config, m.formBuilder, m.system, m.version) | |||
| 173 | - | return m, m.currentForm.Init() | |||
| 174 | - | ||||
| 175 | - | case phaseCompleteMsg: | |||
| 176 | - | if msg.err != nil { | |||
| 177 | - | m.logger.Error("Phase execution failed", "error", msg.err) | |||
| 178 | - | m.err = msg.err | |||
| 179 | - | m.installEndTime = time.Since(m.installStartTime) | |||
| 180 | - | m.state = StateResultsError | |||
| 181 | - | return m, tea.Quit | |||
| 182 | - | } | |||
| 183 | - | ||||
| 184 | - | // Check if all phases complete | |||
| 185 | - | switch { | |||
| 186 | - | case m.orchestrator.IsComplete(): | |||
| 187 | - | m.logger.Info("All phases completed successfully") | |||
| 188 | - | m.installEndTime = time.Since(m.installStartTime) | |||
| 189 | - | m.state = StateResultsSuccess | |||
| 190 | - | return m, tea.Quit | |||
| 191 | - | default: | |||
| 192 | - | m.logger.Info("Continuing to next phase") | |||
| 193 | - | return m, m.executeNextPhase() | |||
| 194 | - | } | |||
| 195 | - | ||||
| 196 | - | case phases.ProgressUpdate: | |||
| 197 | - | // Handle progress updates from phases | |||
| 198 | - | switch { | |||
| 199 | - | case msg.OutputLine != "": | |||
| 200 | - | m.output.AddLine(msg.OutputLine) | |||
| 201 | - | case msg.Step != "": | |||
| 202 | - | m.output.AddLine(fmt.Sprintf("[%s] %s", msg.PhaseName, msg.Step)) | |||
| 203 | - | case msg.IsError: | |||
| 204 | - | m.output.AddLine(fmt.Sprintf("[ERROR] %s", msg.ErrorMsg)) | |||
| 205 | - | case msg.IsComplete: | |||
| 206 | - | m.output.AddLine(fmt.Sprintf("[COMPLETE] %s", msg.Step)) | |||
| 207 | - | } | |||
| 208 | - | return m, waitForProgress(m.orchestrator.ProgressChannel()) | |||
| 209 | - | } | |||
| 210 | - | ||||
| 211 | - | // Update current form if showing one | |||
| 212 | - | switch m.state { | |||
| 213 | - | case StatePreflightForm, StateDiskForm, StateOptionsForm, StateAMDPStatePrompt: | |||
| 214 | - | switch { | |||
| 215 | - | case m.currentForm != nil: | |||
| 216 | - | // Don't pass WindowSizeMsg to form to preserve our max width | |||
| 217 | - | if _, isWindowSize := msg.(tea.WindowSizeMsg); isWindowSize { | |||
| 218 | - | return m, nil | |||
| 219 | - | } | |||
| 220 | - | ||||
| 221 | - | form, cmd := m.currentForm.Update(msg) | |||
| 222 | - | if f, ok := form.(*huh.Form); ok { | |||
| 223 | - | m.currentForm = f | |||
| 224 | - | ||||
| 225 | - | // Check if form is completed | |||
| 226 | - | if m.currentForm.State == huh.StateCompleted { | |||
| 227 | - | return m, m.handleFormComplete() | |||
| 228 | - | } | |||
| 229 | - | } | |||
| 230 | - | return m, cmd | |||
| 231 | - | } | |||
| 232 | - | case StateExecuting: | |||
| 233 | - | // Update spinner and output viewer | |||
| 234 | - | var cmd tea.Cmd | |||
| 235 | - | m.spinner, cmd = m.spinner.Update(msg) | |||
| 236 | - | return m, tea.Batch(cmd, m.output.Update(msg)) | |||
| 237 | - | } | |||
| 238 | - | ||||
| 239 | - | return m, nil | |||
| 240 | - | } | |||
| 241 | - | ||||
| 242 | - | // View renders the UI | |||
| 243 | - | func (m *Model) View() string { | |||
| 244 | - | switch m.state { | |||
| 245 | - | case StateShowLogo: | |||
| 246 | - | return views.RenderWelcome(m) | |||
| 247 | - | ||||
| 248 | - | case StateNetworkCheck: | |||
| 249 | - | return views.RenderNetworkCheck(m) | |||
| 250 | - | ||||
| 251 | - | case StatePreflightForm, StateDiskForm, StateOptionsForm, StateAMDPStatePrompt: | |||
| 252 | - | return views.RenderForm(m) | |||
| 253 | - | ||||
| 254 | - | case StateConfirmation: | |||
| 255 | - | return views.RenderConfirmation(m) | |||
| 256 | - | ||||
| 257 | - | case StateExecuting: | |||
| 258 | - | return views.RenderExecuting(m) | |||
| 259 | - | ||||
| 260 | - | case StateResultsSuccess: | |||
| 261 | - | phaseDurations := m.orchestrator.GetPhaseDurations() | |||
| 262 | - | return views.RenderSuccess(m, m.installEndTime, phaseDurations) | |||
| 263 | - | ||||
| 264 | - | case StateResultsError: | |||
| 265 | - | phaseName := "unknown" | |||
| 266 | - | if phase := m.orchestrator.CurrentPhase(); phase != nil { | |||
| 267 | - | phaseName = phase.Name() | |||
| 268 | - | } | |||
| 269 | - | return views.RenderError(m, m.err, phaseName, m.installEndTime) | |||
| 270 | - | ||||
| 271 | - | case StateComplete, StateError: | |||
| 272 | - | if m.err != nil { | |||
| 273 | - | return styles.RenderError(fmt.Sprintf("Error: %v", m.err)) | |||
| 274 | - | } | |||
| 275 | - | return styles.RenderSuccess("Installation complete!") | |||
| 276 | - | ||||
| 277 | - | default: | |||
| 278 | - | return "Unknown state" | |||
| 279 | - | } | |||
| 280 | - | } | |||
| 281 | - | ||||
| 282 | - | // handleFormComplete handles form completion and moves to next state | |||
| 283 | - | func (m *Model) handleFormComplete() tea.Cmd { | |||
| 284 | - | m.logger.Info("Form completed", "state", m.state) | |||
| 285 | - | ||||
| 286 | - | switch m.state { | |||
| 287 | - | case StatePreflightForm: | |||
| 288 | - | m.logger.Info("Preflight form completed", | |||
| 289 | - | "hostname", m.config.Hostname, | |||
| 290 | - | "username", m.config.Username, | |||
| 291 | - | "use_same_password_for_encryption", m.config.UseSamePasswordForEncryption) | |||
| 292 | - | ||||
| 293 | - | // Handle encryption password setting based on checkbox | |||
| 294 | - | if m.config.UseSamePasswordForEncryption { | |||
| 295 | - | m.config.EncryptPassword = m.config.UserPassword | |||
| 296 | - | } | |||
| 297 | - | ||||
| 298 | - | m.state = StateDiskForm | |||
| 299 | - | m.currentForm = CreateDiskSelectionForm(m.config, m.formBuilder) | |||
| 300 | - | return m.currentForm.Init() | |||
| 301 | - | ||||
| 302 | - | case StateDiskForm: | |||
| 303 | - | m.logger.Info("Disk selection completed", "disk", m.config.TargetDisk) | |||
| 304 | - | m.state = StateOptionsForm | |||
| 305 | - | m.currentForm = CreateOptionsForm(m.config, m.formBuilder) | |||
| 306 | - | return m.currentForm.Init() | |||
| 307 | - | ||||
| 308 | - | case StateOptionsForm: | |||
| 309 | - | m.logger.Info("Options form completed", | |||
| 310 | - | "kernel", m.config.KernelChoice, | |||
| 311 | - | "encryption", m.config.EncryptionType) | |||
| 312 | - | ||||
| 313 | - | // Detect CPU and check if we need to show AMD P-State prompt | |||
| 314 | - | cpuInfo, err := system.DetectCPUInfo() | |||
| 315 | - | if err == nil { | |||
| 316 | - | m.cpuInfo = cpuInfo | |||
| 317 | - | // Show AMD P-State prompt only for AMD CPUs with P-State support | |||
| 318 | - | if cpuInfo.Vendor == system.CPUVendorAMD && len(cpuInfo.AMDPStateModes) > 0 { | |||
| 319 | - | m.logger.Info("AMD CPU detected - showing P-State configuration", | |||
| 320 | - | "zen_gen", cpuInfo.AMDZenGen.Label, | |||
| 321 | - | "modes", len(cpuInfo.AMDPStateModes)) | |||
| 322 | - | m.state = StateAMDPStatePrompt | |||
| 323 | - | m.currentForm = CreateAMDPStateForm(m.config, cpuInfo, m.formBuilder) | |||
| 324 | - | return m.currentForm.Init() | |||
| 325 | - | } | |||
| 326 | - | } | |||
| 327 | - | ||||
| 328 | - | // Move directly to confirmation if not AMD or detection failed | |||
| 329 | - | m.state = StateConfirmation | |||
| 330 | - | return nil | |||
| 331 | - | ||||
| 332 | - | case StateAMDPStatePrompt: | |||
| 333 | - | m.logger.Info("AMD P-State configuration completed", "mode", m.config.AMDPState) | |||
| 334 | - | m.state = StateConfirmation | |||
| 335 | - | return nil | |||
| 336 | - | ||||
| 337 | - | default: | |||
| 338 | - | // Move to confirmation | |||
| 339 | - | m.state = StateConfirmation | |||
| 340 | - | return nil | |||
| 341 | - | } | |||
| 342 | - | } | |||
| 343 | - | ||||
| 344 | - | // Getter methods for views package | |||
| 345 | - | func (m *Model) Width() int { return m.width } | |||
| 346 | - | func (m *Model) Version() string { return m.version } | |||
| 347 | - | func (m *Model) Config() *config.Config { return m.config } | |||
| 348 | - | func (m *Model) CurrentForm() *huh.Form { return m.currentForm } | |||
| 349 | - | func (m *Model) Spinner() spinner.Model { return m.spinner } | |||
| 350 | - | func (m *Model) Output() *components.OutputViewer { return m.output } | |||
| 351 | - | func (m *Model) RenderPhaseHeader() string { return m.renderPhaseHeader() } | |||
| 352 | - | func (m *Model) NetworkCheckDone() bool { return m.networkCheckDone } | |||
| 353 | - | func (m *Model) NetworkErr() error { return m.networkErr } | |||
| 354 | - | ||||
| 355 | - | // executeNextPhase executes the next pending phase | |||
| 356 | - | func (m *Model) executeNextPhase() tea.Cmd { | |||
| 357 | - | // Execute the phase in the background AND start listening for progress updates | |||
| 358 | - | return tea.Batch( | |||
| 359 | - | func() tea.Msg { | |||
| 360 | - | err := m.orchestrator.ExecuteNext() | |||
| 361 | - | return phaseCompleteMsg{err: err} | |||
| 362 | - | }, | |||
| 363 | - | waitForProgress(m.orchestrator.ProgressChannel()), | |||
| 364 | - | ) | |||
| 365 | - | } | |||
| 366 | - | ||||
| 367 | - | // waitForProgress waits for progress updates | |||
| 368 | - | func waitForProgress(progressChan <-chan phases.ProgressUpdate) tea.Cmd { | |||
| 369 | - | return func() tea.Msg { | |||
| 370 | - | return <-progressChan | |||
| 371 | - | } | |||
| 372 | - | } | |||
| 373 | - | ||||
| 374 | - | // phaseCompleteMsg signals phase execution completion | |||
| 375 | - | type phaseCompleteMsg struct { | |||
| 376 | - | err error | |||
| 377 | - | } | |||
| 378 | - | ||||
| 379 | - | // networkCheckMsg signals network check completion | |||
| 380 | - | type networkCheckMsg struct { | |||
| 381 | - | err error | |||
| 382 | - | } | |||
| 383 | - | ||||
| 384 | - | // checkNetwork runs network connectivity check in background | |||
| 385 | - | func (m *Model) checkNetwork() tea.Cmd { | |||
| 386 | - | return func() tea.Msg { | |||
| 387 | - | err := system.CheckNetworkConnectivity() | |||
| 388 | - | return networkCheckMsg{err: err} | |||
| 389 | - | } | |||
| 390 | - | } | |||
| 391 | - | ||||
| 392 | - | // renderPhaseHeader renders phase progress header | |||
| 393 | - | func (m *Model) renderPhaseHeader() string { | |||
| 394 | - | var stepNum, stepName string | |||
| 395 | - | ||||
| 396 | - | switch m.state { | |||
| 397 | - | case StatePreflightForm: | |||
| 398 | - | stepNum = "1" | |||
| 399 | - | stepName = "System Configuration" | |||
| 400 | - | case StateDiskForm: | |||
| 401 | - | stepNum = "2" | |||
| 402 | - | stepName = "Disk Selection" | |||
| 403 | - | case StateOptionsForm: | |||
| 404 | - | stepNum = "3" | |||
| 405 | - | stepName = "Installation Options" | |||
| 406 | - | case StateAMDPStatePrompt: | |||
| 407 | - | stepNum = "4" | |||
| 408 | - | stepName = "AMD P-State Configuration" | |||
| 409 | - | default: | |||
| 410 | - | return "" | |||
| 411 | - | } | |||
| 412 | - | ||||
| 413 | - | // Total steps depends on whether AMD prompt is shown | |||
| 414 | - | totalSteps := "3" | |||
| 415 | - | if m.state == StateAMDPStatePrompt { | |||
| 416 | - | totalSteps = "4" | |||
| 417 | - | } | |||
| 418 | - | ||||
| 419 | - | header := fmt.Sprintf("ArchUp Install - Step %s/%s: %s", stepNum, totalSteps, stepName) | |||
| 420 | - | return styles.TitleStyle.Render(header) | |||
| 421 | - | } | |||
D internal/ui/model/model.go +0 -21
| @@ -1,21 +0,0 @@ | |||||
| 1 | - | package model | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "github.com/bnema/archup/internal/config" | |||
| 5 | - | "github.com/bnema/archup/internal/ui/components" | |||
| 6 | - | "github.com/charmbracelet/bubbles/spinner" | |||
| 7 | - | "github.com/charmbracelet/huh" | |||
| 8 | - | ) | |||
| 9 | - | ||||
| 10 | - | // UI is an interface that views use to access model data | |||
| 11 | - | type UI interface { | |||
| 12 | - | Width() int | |||
| 13 | - | Version() string | |||
| 14 | - | Config() *config.Config | |||
| 15 | - | CurrentForm() *huh.Form | |||
| 16 | - | Spinner() spinner.Model | |||
| 17 | - | Output() *components.OutputViewer | |||
| 18 | - | RenderPhaseHeader() string | |||
| 19 | - | NetworkCheckDone() bool | |||
| 20 | - | NetworkErr() error | |||
| 21 | - | } | |||
D internal/ui/sections.go +0 -53
| @@ -1,53 +0,0 @@ | |||||
| 1 | - | package ui | |||
| 2 | - | ||||
| 3 | - | import "github.com/charmbracelet/lipgloss" | |||
| 4 | - | ||||
| 5 | - | // CenterText centers text within a given width using lipgloss | |||
| 6 | - | func CenterText(text string, width int) string { | |||
| 7 | - | return lipgloss.NewStyle(). | |||
| 8 | - | Width(width). | |||
| 9 | - | AlignHorizontal(lipgloss.Center). | |||
| 10 | - | Render(text) | |||
| 11 | - | } | |||
| 12 | - | ||||
| 13 | - | // Form section descriptions (DRY: centralized text) | |||
| 14 | - | const ( | |||
| 15 | - | // Welcome | |||
| 16 | - | WelcomeTagline = "A minimal, slightly opinionated Arch Linux installer." | |||
| 17 | - | ||||
| 18 | - | // Preflight sections | |||
| 19 | - | SectionUserIdentity = "Configure your user account and system details" | |||
| 20 | - | DescEmail = "Email is used for SSH key generation and git configuration" | |||
| 21 | - | DescTimezone = "e.g. America/New_York, Europe/London, Asia/Tokyo" | |||
| 22 | - | DescLocale = "e.g. en_US.UTF-8, en_GB.UTF-8, de_DE.UTF-8" | |||
| 23 | - | DescKeymap = "e.g. us, uk, de, fr" | |||
| 24 | - | ||||
| 25 | - | // Disk selection | |||
| 26 | - | SectionDiskSelection = "Select installation disk (ALL DATA WILL BE ERASED)" | |||
| 27 | - | WarnDiskErase = "WARNING: This will delete all data on the selected disk!" | |||
| 28 | - | ||||
| 29 | - | // Encryption | |||
| 30 | - | SectionDiskEncryption = "Disk Encryption" | |||
| 31 | - | DescEncryption = "LUKS encryption protects your data with Argon2id (2000ms iteration)" | |||
| 32 | - | DescEncryptionPassword = "Leave empty to use the same password as your user account" | |||
| 33 | - | DescEncryptionSamePass = "Use the same password for disk encryption as your user account?" | |||
| 34 | - | ||||
| 35 | - | // Kernel | |||
| 36 | - | SectionKernel = "Kernel Selection" | |||
| 37 | - | DescKernelLinux = "Stable mainline kernel (recommended)" | |||
| 38 | - | DescKernelLTS = "Long-term support (maximum stability)" | |||
| 39 | - | DescKernelZen = "Performance-optimized for general use" | |||
| 40 | - | DescKernelHardened = "Security-focused kernel" | |||
| 41 | - | DescKernelCachyOS = "Gaming-optimized (requires CachyOS repo)" | |||
| 42 | - | ||||
| 43 | - | // AMD P-State | |||
| 44 | - | SectionAMDTuning = "AMD CPU Tuning" | |||
| 45 | - | DescAMDPState = "Select AMD P-State driver mode for CPU frequency scaling" | |||
| 46 | - | ||||
| 47 | - | // Repository options | |||
| 48 | - | DescMultilib = "Required for 32-bit applications and Wine" | |||
| 49 | - | DescChaoticAUR = "Community repository with pre-built AUR packages" | |||
| 50 | - | DescAURHelper = "Tool for installing packages from Arch User Repository" | |||
| 51 | - | DescAURHelperParu = "Rust-based AUR helper" | |||
| 52 | - | DescAURHelperYay = "Go-based AUR helper" | |||
| 53 | - | ) | |||
D internal/ui/styles/containers.go +0 -29
| @@ -1,29 +0,0 @@ | |||||
| 1 | - | package styles | |||
| 2 | - | ||||
| 3 | - | import "github.com/charmbracelet/lipgloss" | |||
| 4 | - | ||||
| 5 | - | // Layout constants | |||
| 6 | - | const ( | |||
| 7 | - | MaxFormWidth = 80 // Maximum width for form containers | |||
| 8 | - | MaxContentWidth = 120 // Maximum width for content (welcome, complete screens) | |||
| 9 | - | ) | |||
| 10 | - | ||||
| 11 | - | // Alignment aliases for clarity | |||
| 12 | - | const ( | |||
| 13 | - | LeftAlign = lipgloss.Left | |||
| 14 | - | CenterAlign = lipgloss.Center | |||
| 15 | - | ) | |||
| 16 | - | ||||
| 17 | - | // Container styles for consistent layout | |||
| 18 | - | var ( | |||
| 19 | - | // CenteredContainerStyle - Used for welcome, complete, error screens | |||
| 20 | - | CenteredContainerStyle = lipgloss.NewStyle(). | |||
| 21 | - | MaxWidth(MaxContentWidth). | |||
| 22 | - | Padding(0, 2). | |||
| 23 | - | AlignHorizontal(lipgloss.Center) | |||
| 24 | - | ||||
| 25 | - | // FormContainerStyle - Used for all forms (content left-aligned) | |||
| 26 | - | FormContainerStyle = lipgloss.NewStyle(). | |||
| 27 | - | Padding(0, 2). | |||
| 28 | - | AlignHorizontal(lipgloss.Left) | |||
| 29 | - | ) | |||
D internal/ui/styles/theme.go +0 -324
| @@ -1,324 +0,0 @@ | |||||
| 1 | - | package styles | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "github.com/charmbracelet/huh" | |||
| 5 | - | "github.com/charmbracelet/lipgloss" | |||
| 6 | - | ) | |||
| 7 | - | ||||
| 8 | - | // Bleu Theme - Based on the bleu-theme color palette | |||
| 9 | - | // https://github.com/bnema/bleu-theme | |||
| 10 | - | // Uses CompleteColor with TrueColor > ANSI256 > ANSI16 fallback | |||
| 11 | - | // Arch ISO supports 256 colors (TERM=xterm-256color) | |||
| 12 | - | ||||
| 13 | - | var ( | |||
| 14 | - | // Background Colors | |||
| 15 | - | ColorDeepNavy = lipgloss.CompleteColor{ | |||
| 16 | - | TrueColor: "#050a14", | |||
| 17 | - | ANSI256: "16", // Pure black | |||
| 18 | - | ANSI: "0", // Black | |||
| 19 | - | } | |||
| 20 | - | ||||
| 21 | - | ColorDarkBlue = lipgloss.CompleteColor{ | |||
| 22 | - | TrueColor: "#070c16", | |||
| 23 | - | ANSI256: "233", // Very dark grey | |||
| 24 | - | ANSI: "0", // Black | |||
| 25 | - | } | |||
| 26 | - | ||||
| 27 | - | ColorDarkerBlue = lipgloss.CompleteColor{ | |||
| 28 | - | TrueColor: "#0a1018", | |||
| 29 | - | ANSI256: "234", // Very dark grey | |||
| 30 | - | ANSI: "8", // Bright black (dark grey) | |||
| 31 | - | } | |||
| 32 | - | ||||
| 33 | - | ColorActiveTabBlue = lipgloss.CompleteColor{ | |||
| 34 | - | TrueColor: "#0f1520", | |||
| 35 | - | ANSI256: "235", // Dark grey | |||
| 36 | - | ANSI: "8", // Bright black | |||
| 37 | - | } | |||
| 38 | - | ||||
| 39 | - | ColorOceanBlue = lipgloss.CompleteColor{ | |||
| 40 | - | TrueColor: "#2d4a6b", | |||
| 41 | - | ANSI256: "24", // Deep blue | |||
| 42 | - | ANSI: "4", // Blue | |||
| 43 | - | } | |||
| 44 | - | ||||
| 45 | - | // Text Colors | |||
| 46 | - | ColorPrimaryText = lipgloss.CompleteColor{ | |||
| 47 | - | TrueColor: "#e8f4f8", | |||
| 48 | - | ANSI256: "255", // Bright white | |||
| 49 | - | ANSI: "7", // White | |||
| 50 | - | } | |||
| 51 | - | ||||
| 52 | - | ColorPureWhite = lipgloss.CompleteColor{ | |||
| 53 | - | TrueColor: "#fefefe", | |||
| 54 | - | ANSI256: "231", // Pure white | |||
| 55 | - | ANSI: "15", // Bright white | |||
| 56 | - | } | |||
| 57 | - | ||||
| 58 | - | ColorDimmedText = lipgloss.CompleteColor{ | |||
| 59 | - | TrueColor: "#708090", | |||
| 60 | - | ANSI256: "102", // Medium grey | |||
| 61 | - | ANSI: "8", // Bright black (grey) | |||
| 62 | - | } | |||
| 63 | - | ||||
| 64 | - | // Accent Colors | |||
| 65 | - | ColorBrightCyan = lipgloss.CompleteColor{ | |||
| 66 | - | TrueColor: "#00d4ff", | |||
| 67 | - | ANSI256: "45", // Bright cyan | |||
| 68 | - | ANSI: "14", // Bright cyan | |||
| 69 | - | } | |||
| 70 | - | ||||
| 71 | - | ColorPureBlue = lipgloss.CompleteColor{ | |||
| 72 | - | TrueColor: "#5588cc", | |||
| 73 | - | ANSI256: "69", // Steel blue | |||
| 74 | - | ANSI: "12", // Bright blue | |||
| 75 | - | } | |||
| 76 | - | ||||
| 77 | - | ColorLightSkyBlue = lipgloss.CompleteColor{ | |||
| 78 | - | TrueColor: "#87ceeb", | |||
| 79 | - | ANSI256: "117", // Light sky blue | |||
| 80 | - | ANSI: "14", // Bright cyan | |||
| 81 | - | } | |||
| 82 | - | ||||
| 83 | - | ColorSkyBlue = lipgloss.CompleteColor{ | |||
| 84 | - | TrueColor: "#4a7ba7", | |||
| 85 | - | ANSI256: "67", // Steel blue | |||
| 86 | - | ANSI: "4", // Blue | |||
| 87 | - | } | |||
| 88 | - | ||||
| 89 | - | // Status Colors | |||
| 90 | - | ColorSuccessGreen = lipgloss.CompleteColor{ | |||
| 91 | - | TrueColor: "#99FFE4", | |||
| 92 | - | ANSI256: "122", // Aquamarine green | |||
| 93 | - | ANSI: "10", // Bright green | |||
| 94 | - | } | |||
| 95 | - | ||||
| 96 | - | ColorSoftRed = lipgloss.CompleteColor{ | |||
| 97 | - | TrueColor: "#ff6b8a", | |||
| 98 | - | ANSI256: "204", // Light red | |||
| 99 | - | ANSI: "9", // Bright red | |||
| 100 | - | } | |||
| 101 | - | ||||
| 102 | - | ColorWarmOrange = lipgloss.CompleteColor{ | |||
| 103 | - | TrueColor: "#ffb347", | |||
| 104 | - | ANSI256: "215", // Light orange | |||
| 105 | - | ANSI: "11", // Bright yellow | |||
| 106 | - | } | |||
| 107 | - | ) | |||
| 108 | - | ||||
| 109 | - | // Styles | |||
| 110 | - | var ( | |||
| 111 | - | // Base styles | |||
| 112 | - | BaseStyle = lipgloss.NewStyle(). | |||
| 113 | - | Foreground(ColorPrimaryText) | |||
| 114 | - | ||||
| 115 | - | // Title/Header styles | |||
| 116 | - | TitleStyle = lipgloss.NewStyle(). | |||
| 117 | - | Foreground(ColorBrightCyan). | |||
| 118 | - | Bold(true). | |||
| 119 | - | MarginBottom(1) | |||
| 120 | - | ||||
| 121 | - | SubtitleStyle = lipgloss.NewStyle(). | |||
| 122 | - | Foreground(ColorPureBlue) | |||
| 123 | - | ||||
| 124 | - | // Interactive elements | |||
| 125 | - | SelectedStyle = lipgloss.NewStyle(). | |||
| 126 | - | Foreground(ColorPureWhite). | |||
| 127 | - | Background(ColorOceanBlue). | |||
| 128 | - | Bold(true) | |||
| 129 | - | ||||
| 130 | - | UnselectedStyle = lipgloss.NewStyle(). | |||
| 131 | - | Foreground(ColorDimmedText) | |||
| 132 | - | ||||
| 133 | - | FocusedStyle = lipgloss.NewStyle(). | |||
| 134 | - | Foreground(ColorBrightCyan). | |||
| 135 | - | Border(lipgloss.RoundedBorder()). | |||
| 136 | - | BorderForeground(ColorPureBlue) | |||
| 137 | - | ||||
| 138 | - | BlurredStyle = lipgloss.NewStyle(). | |||
| 139 | - | Foreground(ColorDimmedText). | |||
| 140 | - | Border(lipgloss.RoundedBorder()). | |||
| 141 | - | BorderForeground(ColorDarkBlue) | |||
| 142 | - | ||||
| 143 | - | // Status styles | |||
| 144 | - | SuccessStyle = lipgloss.NewStyle(). | |||
| 145 | - | Foreground(ColorSuccessGreen). | |||
| 146 | - | Bold(true) | |||
| 147 | - | ||||
| 148 | - | ErrorStyle = lipgloss.NewStyle(). | |||
| 149 | - | Foreground(ColorSoftRed). | |||
| 150 | - | Bold(true) | |||
| 151 | - | ||||
| 152 | - | WarningStyle = lipgloss.NewStyle(). | |||
| 153 | - | Foreground(ColorWarmOrange). | |||
| 154 | - | Bold(true) | |||
| 155 | - | ||||
| 156 | - | InfoStyle = lipgloss.NewStyle(). | |||
| 157 | - | Foreground(ColorLightSkyBlue) | |||
| 158 | - | ||||
| 159 | - | // Logo styles (for ASCII art display) | |||
| 160 | - | LogoCyanStyle = lipgloss.NewStyle(). | |||
| 161 | - | Foreground(ColorBrightCyan) | |||
| 162 | - | ||||
| 163 | - | LogoWhiteStyle = lipgloss.NewStyle(). | |||
| 164 | - | Foreground(ColorPureWhite) | |||
| 165 | - | ||||
| 166 | - | LogoBlueStyle = lipgloss.NewStyle(). | |||
| 167 | - | Foreground(ColorSkyBlue) | |||
| 168 | - | ||||
| 169 | - | // Progress/Spinner styles | |||
| 170 | - | SpinnerStyle = lipgloss.NewStyle(). | |||
| 171 | - | Foreground(ColorBrightCyan) | |||
| 172 | - | ||||
| 173 | - | ProgressBarStyle = lipgloss.NewStyle(). | |||
| 174 | - | Foreground(ColorPureBlue) | |||
| 175 | - | ||||
| 176 | - | // Prompt styles | |||
| 177 | - | PromptStyle = lipgloss.NewStyle(). | |||
| 178 | - | Foreground(ColorBrightCyan). | |||
| 179 | - | Bold(true) | |||
| 180 | - | ||||
| 181 | - | InputStyle = lipgloss.NewStyle(). | |||
| 182 | - | Foreground(ColorPureWhite). | |||
| 183 | - | Background(ColorActiveTabBlue). | |||
| 184 | - | Padding(0, 1) | |||
| 185 | - | ||||
| 186 | - | PlaceholderStyle = lipgloss.NewStyle(). | |||
| 187 | - | Foreground(ColorDimmedText) | |||
| 188 | - | ||||
| 189 | - | // Box/Panel styles | |||
| 190 | - | PanelStyle = lipgloss.NewStyle(). | |||
| 191 | - | Background(ColorDarkerBlue). | |||
| 192 | - | Border(lipgloss.RoundedBorder()). | |||
| 193 | - | BorderForeground(ColorDarkBlue). | |||
| 194 | - | Padding(1, 2) | |||
| 195 | - | ||||
| 196 | - | BorderStyle = lipgloss.NewStyle(). | |||
| 197 | - | BorderStyle(lipgloss.RoundedBorder()). | |||
| 198 | - | BorderForeground(ColorDarkBlue) | |||
| 199 | - | ||||
| 200 | - | // Log/Output styles | |||
| 201 | - | LogStyle = lipgloss.NewStyle(). | |||
| 202 | - | Foreground(ColorDimmedText). | |||
| 203 | - | Background(ColorDarkerBlue). | |||
| 204 | - | Padding(1, 2) | |||
| 205 | - | ||||
| 206 | - | // Help/Footer styles | |||
| 207 | - | HelpStyle = lipgloss.NewStyle(). | |||
| 208 | - | Foreground(ColorDimmedText). | |||
| 209 | - | MarginTop(1) | |||
| 210 | - | ||||
| 211 | - | KeyStyle = lipgloss.NewStyle(). | |||
| 212 | - | Foreground(ColorPureBlue). | |||
| 213 | - | Bold(true) | |||
| 214 | - | ||||
| 215 | - | DescStyle = lipgloss.NewStyle(). | |||
| 216 | - | Foreground(ColorDimmedText) | |||
| 217 | - | ) | |||
| 218 | - | ||||
| 219 | - | // Helper functions for common styling patterns | |||
| 220 | - | ||||
| 221 | - | // RenderTitle renders a section title | |||
| 222 | - | func RenderTitle(text string) string { | |||
| 223 | - | return TitleStyle.Render(text) | |||
| 224 | - | } | |||
| 225 | - | ||||
| 226 | - | // RenderSuccess renders success message | |||
| 227 | - | func RenderSuccess(text string) string { | |||
| 228 | - | return SuccessStyle.Render("[OK] " + text) | |||
| 229 | - | } | |||
| 230 | - | ||||
| 231 | - | // RenderError renders error message | |||
| 232 | - | func RenderError(text string) string { | |||
| 233 | - | return ErrorStyle.Render("[ERROR] " + text) | |||
| 234 | - | } | |||
| 235 | - | ||||
| 236 | - | // RenderWarning renders warning message | |||
| 237 | - | func RenderWarning(text string) string { | |||
| 238 | - | return WarningStyle.Render("[WARN] " + text) | |||
| 239 | - | } | |||
| 240 | - | ||||
| 241 | - | // RenderInfo renders info message | |||
| 242 | - | func RenderInfo(text string) string { | |||
| 243 | - | return InfoStyle.Render("[INFO] " + text) | |||
| 244 | - | } | |||
| 245 | - | ||||
| 246 | - | // RenderKeyValue renders a key-value pair | |||
| 247 | - | func RenderKeyValue(key, value string) string { | |||
| 248 | - | return KeyStyle.Render(key+": ") + lipgloss.NewStyle().Foreground(ColorPrimaryText).Render(value) | |||
| 249 | - | } | |||
| 250 | - | ||||
| 251 | - | // RenderPanel renders content in a panel | |||
| 252 | - | func RenderPanel(content string) string { | |||
| 253 | - | return PanelStyle.Render(content) | |||
| 254 | - | } | |||
| 255 | - | ||||
| 256 | - | // RenderHelp renders help text with key bindings | |||
| 257 | - | func RenderHelp(bindings map[string]string) string { | |||
| 258 | - | var help string | |||
| 259 | - | for key, desc := range bindings { | |||
| 260 | - | help += KeyStyle.Render(key) + " " + DescStyle.Render(desc) + " " | |||
| 261 | - | } | |||
| 262 | - | return HelpStyle.Render(help) | |||
| 263 | - | } | |||
| 264 | - | ||||
| 265 | - | // HuhTheme returns a custom huh.Theme matching the bleu color scheme | |||
| 266 | - | // Uses ThemeBase16 as foundation for maximum TTY compatibility (QEMU console, etc) | |||
| 267 | - | func HuhTheme() *huh.Theme { | |||
| 268 | - | // Start with Base16 theme for limited terminal support | |||
| 269 | - | theme := huh.ThemeBase16() | |||
| 270 | - | ||||
| 271 | - | // Customize with bleu theme colors using ANSI 16 codes | |||
| 272 | - | // ANSI Color mapping for bleu theme: | |||
| 273 | - | // 0=black, 4=blue, 6=cyan, 7=white, 8=grey, 10=bright green, 12=bright blue, 14=bright cyan, 15=bright white | |||
| 274 | - | ||||
| 275 | - | // Form and Group styles | |||
| 276 | - | theme.Group.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true) // Bright cyan | |||
| 277 | - | theme.Group.Description = lipgloss.NewStyle().Foreground(lipgloss.Color("12")) // Bright blue | |||
| 278 | - | ||||
| 279 | - | // Focused field styles | |||
| 280 | - | theme.Focused.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true) // Bright cyan | |||
| 281 | - | theme.Focused.Description = lipgloss.NewStyle().Foreground(lipgloss.Color("12")) // Bright blue | |||
| 282 | - | theme.Focused.SelectSelector = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).SetString("> ") | |||
| 283 | - | theme.Focused.Option = lipgloss.NewStyle().Foreground(lipgloss.Color("7")) // White | |||
| 284 | - | theme.Focused.MultiSelectSelector = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).SetString("> ") | |||
| 285 | - | theme.Focused.SelectedOption = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) // Bright cyan | |||
| 286 | - | theme.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).SetString("[OK] ") // Bright green | |||
| 287 | - | theme.Focused.UnselectedOption = lipgloss.NewStyle().Foreground(lipgloss.Color("7")) // White | |||
| 288 | - | theme.Focused.UnselectedPrefix = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).SetString("○ ") // Grey | |||
| 289 | - | ||||
| 290 | - | // Button styles with explicit background colors for TTY compatibility | |||
| 291 | - | // Active/focused button (currently selected - e.g., "Yes" when on Yes) | |||
| 292 | - | theme.Focused.FocusedButton = lipgloss.NewStyle(). | |||
| 293 | - | Foreground(lipgloss.Color("15")). // Bright white text | |||
| 294 | - | Background(lipgloss.Color("4")). // Blue background | |||
| 295 | - | Padding(0, 2). | |||
| 296 | - | Bold(true) | |||
| 297 | - | ||||
| 298 | - | // Inactive/blurred button (not selected - e.g., "No" when on Yes) | |||
| 299 | - | theme.Focused.BlurredButton = lipgloss.NewStyle(). | |||
| 300 | - | Foreground(lipgloss.Color("7")). // White text | |||
| 301 | - | Background(lipgloss.Color("0")). // Black background | |||
| 302 | - | Padding(0, 2) | |||
| 303 | - | ||||
| 304 | - | // Blurred field styles (when entire field loses focus) | |||
| 305 | - | theme.Blurred.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // Grey | |||
| 306 | - | theme.Blurred.Description = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // Grey | |||
| 307 | - | theme.Blurred.SelectSelector = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).SetString("> ") | |||
| 308 | - | theme.Blurred.Option = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // Grey | |||
| 309 | - | ||||
| 310 | - | // Override buttons in Blurred state too (inherited from Focused in Base16) | |||
| 311 | - | theme.Blurred.FocusedButton = theme.Focused.FocusedButton | |||
| 312 | - | theme.Blurred.BlurredButton = theme.Focused.BlurredButton | |||
| 313 | - | ||||
| 314 | - | // Text input styles | |||
| 315 | - | theme.Focused.TextInput.Cursor = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) // Bright cyan | |||
| 316 | - | theme.Focused.TextInput.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // Grey | |||
| 317 | - | theme.Focused.TextInput.Prompt = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true) // Bright cyan | |||
| 318 | - | ||||
| 319 | - | // Error styles | |||
| 320 | - | theme.Focused.ErrorIndicator = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).SetString("[KO] ") // Bright red | |||
| 321 | - | theme.Focused.ErrorMessage = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true) // Bright red | |||
| 322 | - | ||||
| 323 | - | return theme | |||
| 324 | - | } | |||
D internal/ui/views/confirmation.go +0 -30
| @@ -1,30 +0,0 @@ | |||||
| 1 | - | package views | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "fmt" | |||
| 5 | - | ||||
| 6 | - | "github.com/bnema/archup/internal/ui/model" | |||
| 7 | - | "github.com/bnema/archup/internal/ui/styles" | |||
| 8 | - | ) | |||
| 9 | - | ||||
| 10 | - | // RenderConfirmation renders the confirmation screen (left-aligned) | |||
| 11 | - | func RenderConfirmation(m model.UI) string { | |||
| 12 | - | cfg := m.Config() | |||
| 13 | - | ||||
| 14 | - | s := styles.TitleStyle.Render("Confirm Installation") + "\n\n" | |||
| 15 | - | s += fmt.Sprintf("Hostname: %s\n", cfg.Hostname) | |||
| 16 | - | s += fmt.Sprintf("Username: %s\n", cfg.Username) | |||
| 17 | - | s += fmt.Sprintf("Disk: %s\n", cfg.TargetDisk) | |||
| 18 | - | s += fmt.Sprintf("Kernel: %s\n", cfg.KernelChoice) | |||
| 19 | - | s += fmt.Sprintf("Encryption: %s\n", cfg.EncryptionType) | |||
| 20 | - | ||||
| 21 | - | if cfg.AMDPState != "" { | |||
| 22 | - | s += fmt.Sprintf("AMD P-State: %s\n", cfg.AMDPState) | |||
| 23 | - | } | |||
| 24 | - | ||||
| 25 | - | s += "\n" | |||
| 26 | - | s += styles.WarningStyle.Render("WARNING: This will erase all data on "+cfg.TargetDisk) + "\n\n" | |||
| 27 | - | s += styles.HelpStyle.Render("Press ENTER to continue, Ctrl+C to cancel") | |||
| 28 | - | ||||
| 29 | - | return styles.FormContainerStyle.Render(s) | |||
| 30 | - | } | |||
D internal/ui/views/execution.go +0 -12
| @@ -1,12 +0,0 @@ | |||||
| 1 | - | package views | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "github.com/bnema/archup/internal/ui/model" | |||
| 5 | - | "github.com/bnema/archup/internal/ui/styles" | |||
| 6 | - | ) | |||
| 7 | - | ||||
| 8 | - | // RenderExecuting renders the installation progress view | |||
| 9 | - | func RenderExecuting(m model.UI) string { | |||
| 10 | - | title := m.Spinner().View() + " Installation in progress..." | |||
| 11 | - | return styles.TitleStyle.Render(title) + "\n\n" + m.Output().View() | |||
| 12 | - | } | |||
D internal/ui/views/forms.go +0 -26
| @@ -1,26 +0,0 @@ | |||||
| 1 | - | package views | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "github.com/bnema/archup/internal/ui/model" | |||
| 5 | - | "github.com/bnema/archup/internal/ui/styles" | |||
| 6 | - | "github.com/charmbracelet/lipgloss" | |||
| 7 | - | ) | |||
| 8 | - | ||||
| 9 | - | // RenderForm renders any form view (container centered, content left-aligned) | |||
| 10 | - | func RenderForm(m model.UI) string { | |||
| 11 | - | if m.CurrentForm() == nil { | |||
| 12 | - | return "Loading form..." | |||
| 13 | - | } | |||
| 14 | - | ||||
| 15 | - | header := CenterText(m.RenderPhaseHeader(), m.Width()) | |||
| 16 | - | form := m.CurrentForm().View() | |||
| 17 | - | content := styles.FormContainerStyle.Render(header + "\n\n" + form) | |||
| 18 | - | ||||
| 19 | - | // Center the container horizontally on screen, keep form content left-aligned | |||
| 20 | - | return lipgloss.PlaceHorizontal( | |||
| 21 | - | m.Width(), | |||
| 22 | - | lipgloss.Center, | |||
| 23 | - | content, | |||
| 24 | - | lipgloss.WithWhitespaceChars(" "), | |||
| 25 | - | ) | |||
| 26 | - | } | |||
D internal/ui/views/helpers.go +0 -89
| @@ -1,89 +0,0 @@ | |||||
| 1 | - | package views | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "strings" | |||
| 5 | - | ||||
| 6 | - | "github.com/bnema/archup/internal/ui/assets" | |||
| 7 | - | "github.com/bnema/archup/internal/ui/styles" | |||
| 8 | - | "github.com/charmbracelet/lipgloss" | |||
| 9 | - | ) | |||
| 10 | - | ||||
| 11 | - | const ( | |||
| 12 | - | WelcomeTagline = "A minimal, slightly opinionated Arch Linux installer." | |||
| 13 | - | ) | |||
| 14 | - | ||||
| 15 | - | // Logo represents the ArchUp logo display | |||
| 16 | - | type Logo struct { | |||
| 17 | - | width int | |||
| 18 | - | height int | |||
| 19 | - | version string | |||
| 20 | - | } | |||
| 21 | - | ||||
| 22 | - | // NewLogo creates a new Logo instance | |||
| 23 | - | func NewLogo(version string) *Logo { | |||
| 24 | - | lines := strings.Split(assets.LogoASCII, "\n") | |||
| 25 | - | width := 0 | |||
| 26 | - | for _, line := range lines { | |||
| 27 | - | if len(line) > width { | |||
| 28 | - | width = len(line) | |||
| 29 | - | } | |||
| 30 | - | } | |||
| 31 | - | ||||
| 32 | - | if version == "" { | |||
| 33 | - | version = "dev" | |||
| 34 | - | } | |||
| 35 | - | ||||
| 36 | - | return &Logo{ | |||
| 37 | - | width: width, | |||
| 38 | - | height: len(lines), | |||
| 39 | - | version: version, | |||
| 40 | - | } | |||
| 41 | - | } | |||
| 42 | - | ||||
| 43 | - | // RenderCentered renders the logo centered using lipgloss alignment | |||
| 44 | - | func (l *Logo) RenderCentered(termWidth int) string { | |||
| 45 | - | return l.Render() | |||
| 46 | - | } | |||
| 47 | - | ||||
| 48 | - | // Render renders the logo with character-level styling | |||
| 49 | - | func (l *Logo) Render() string { | |||
| 50 | - | lines := strings.Split(assets.LogoASCII, "\n") | |||
| 51 | - | var rendered strings.Builder | |||
| 52 | - | ||||
| 53 | - | for _, line := range lines { | |||
| 54 | - | if len(line) == 0 { | |||
| 55 | - | rendered.WriteString("\n") | |||
| 56 | - | continue | |||
| 57 | - | } | |||
| 58 | - | ||||
| 59 | - | // Apply conditional styling based on character | |||
| 60 | - | var styledLine strings.Builder | |||
| 61 | - | for _, ch := range line { | |||
| 62 | - | if string(ch) == "░" { | |||
| 63 | - | // Use darker blue for border character | |||
| 64 | - | styledLine.WriteString(styles.LogoBlueStyle.Render(string(ch))) | |||
| 65 | - | } else { | |||
| 66 | - | // Use bright cyan for main logo | |||
| 67 | - | styledLine.WriteString(styles.LogoCyanStyle.Render(string(ch))) | |||
| 68 | - | } | |||
| 69 | - | } | |||
| 70 | - | rendered.WriteString(styledLine.String()) | |||
| 71 | - | rendered.WriteString("\n") | |||
| 72 | - | } | |||
| 73 | - | ||||
| 74 | - | // Add version below logo | |||
| 75 | - | rendered.WriteString("\n") | |||
| 76 | - | versionLine := " " + l.version | |||
| 77 | - | rendered.WriteString(styles.HelpStyle.Render(versionLine)) | |||
| 78 | - | rendered.WriteString("\n") | |||
| 79 | - | ||||
| 80 | - | return rendered.String() | |||
| 81 | - | } | |||
| 82 | - | ||||
| 83 | - | // CenterText centers text within a given width using lipgloss | |||
| 84 | - | func CenterText(text string, width int) string { | |||
| 85 | - | return lipgloss.NewStyle(). | |||
| 86 | - | Width(width). | |||
| 87 | - | AlignHorizontal(lipgloss.Center). | |||
| 88 | - | Render(text) | |||
| 89 | - | } | |||
D internal/ui/views/network.go +0 -34
| @@ -1,34 +0,0 @@ | |||||
| 1 | - | package views | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "fmt" | |||
| 5 | - | ||||
| 6 | - | "github.com/bnema/archup/internal/ui/model" | |||
| 7 | - | "github.com/bnema/archup/internal/ui/styles" | |||
| 8 | - | ) | |||
| 9 | - | ||||
| 10 | - | // RenderNetworkCheck renders the network connectivity check screen | |||
| 11 | - | func RenderNetworkCheck(m model.UI) string { | |||
| 12 | - | spinner := m.Spinner() | |||
| 13 | - | spinnerView := spinner.View() | |||
| 14 | - | ||||
| 15 | - | var content string | |||
| 16 | - | ||||
| 17 | - | // If network check is still running | |||
| 18 | - | if !m.NetworkCheckDone() { | |||
| 19 | - | content = CenterText(spinnerView+" Checking network connectivity...", m.Width()) + "\n\n" + | |||
| 20 | - | CenterText(styles.HelpStyle.Render("Please wait..."), m.Width()) | |||
| 21 | - | } else if m.NetworkErr() != nil { | |||
| 22 | - | // Network check failed | |||
| 23 | - | errorMsg := fmt.Sprintf("Network error: %v", m.NetworkErr()) | |||
| 24 | - | content = CenterText(styles.ErrorStyle.Render("✗ Network Check Failed"), m.Width()) + "\n\n" + | |||
| 25 | - | CenterText(errorMsg, m.Width()) + "\n\n" + | |||
| 26 | - | CenterText(styles.HelpStyle.Render("Press ENTER to retry or CTRL+C to exit"), m.Width()) | |||
| 27 | - | } else { | |||
| 28 | - | // Network check passed | |||
| 29 | - | content = CenterText(styles.SuccessStyle.Render("✓ Network Check Passed"), m.Width()) + "\n\n" + | |||
| 30 | - | CenterText(styles.HelpStyle.Render("Press ENTER to continue"), m.Width()) | |||
| 31 | - | } | |||
| 32 | - | ||||
| 33 | - | return styles.CenteredContainerStyle.Render(content) | |||
| 34 | - | } | |||
D internal/ui/views/results.go +0 -53
| @@ -1,53 +0,0 @@ | |||||
| 1 | - | package views | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "fmt" | |||
| 5 | - | "time" | |||
| 6 | - | ||||
| 7 | - | "github.com/bnema/archup/internal/ui/model" | |||
| 8 | - | "github.com/bnema/archup/internal/ui/styles" | |||
| 9 | - | ) | |||
| 10 | - | ||||
| 11 | - | // RenderSuccess renders the success screen with timing breakdown (centered) | |||
| 12 | - | func RenderSuccess(m model.UI, duration time.Duration, phaseDurations map[string]time.Duration) string { | |||
| 13 | - | cfg := m.Config() | |||
| 14 | - | ||||
| 15 | - | s := styles.SuccessStyle.Render("[OK] Installation Complete!") + "\n\n" | |||
| 16 | - | s += "Installation Summary:\n" | |||
| 17 | - | s += fmt.Sprintf(" Hostname: %s\n", cfg.Hostname) | |||
| 18 | - | s += fmt.Sprintf(" Disk: %s\n", cfg.TargetDisk) | |||
| 19 | - | s += fmt.Sprintf(" Kernel: %s\n", cfg.KernelChoice) | |||
| 20 | - | s += fmt.Sprintf(" Total Time: %s\n\n", formatDuration(duration)) | |||
| 21 | - | ||||
| 22 | - | s += "Next Steps:\n" | |||
| 23 | - | s += " 1. Remove installation media\n" | |||
| 24 | - | s += " 2. Reboot the system\n" | |||
| 25 | - | s += " 3. First-boot setup will run automatically\n\n" | |||
| 26 | - | s += styles.HelpStyle.Render("Press ENTER to exit") | |||
| 27 | - | ||||
| 28 | - | return styles.CenteredContainerStyle.Render(s) | |||
| 29 | - | } | |||
| 30 | - | ||||
| 31 | - | // RenderError renders the error screen with log tail (centered) | |||
| 32 | - | func RenderError(m model.UI, err error, phaseName string, duration time.Duration) string { | |||
| 33 | - | s := styles.ErrorStyle.Render("[KO] Installation Failed") + "\n\n" | |||
| 34 | - | s += fmt.Sprintf("Phase: %s\n", phaseName) | |||
| 35 | - | s += fmt.Sprintf("Error: %v\n", err) | |||
| 36 | - | s += fmt.Sprintf("Duration before failure: %s\n\n", formatDuration(duration)) | |||
| 37 | - | ||||
| 38 | - | s += "Recent log entries:\n" | |||
| 39 | - | // TODO: Add log tail here once LogViewer is implemented | |||
| 40 | - | ||||
| 41 | - | s += "\nLog file: " + m.Config().LogPath + "\n" | |||
| 42 | - | s += "Report issues: https://github.com/bnema/archup/issues\n\n" | |||
| 43 | - | s += styles.HelpStyle.Render("Press ENTER to exit") | |||
| 44 | - | ||||
| 45 | - | return styles.CenteredContainerStyle.Render(s) | |||
| 46 | - | } | |||
| 47 | - | ||||
| 48 | - | func formatDuration(d time.Duration) string { | |||
| 49 | - | if d < time.Minute { | |||
| 50 | - | return fmt.Sprintf("%ds", int(d.Seconds())) | |||
| 51 | - | } | |||
| 52 | - | return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60) | |||
| 53 | - | } | |||
D internal/ui/views/welcome.go +0 -16
| @@ -1,16 +0,0 @@ | |||||
| 1 | - | package views | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "github.com/bnema/archup/internal/ui/model" | |||
| 5 | - | "github.com/bnema/archup/internal/ui/styles" | |||
| 6 | - | ) | |||
| 7 | - | ||||
| 8 | - | // RenderWelcome renders the welcome logo screen (centered) | |||
| 9 | - | func RenderWelcome(m model.UI) string { | |||
| 10 | - | logo := NewLogo(m.Version()) | |||
| 11 | - | content := logo.RenderCentered(m.Width()) + "\n\n\n" + | |||
| 12 | - | CenterText(WelcomeTagline, m.Width()) + "\n\n" + | |||
| 13 | - | CenterText(styles.HelpStyle.Render("Press ENTER to start installation"), m.Width()) | |||
| 14 | - | ||||
| 15 | - | return styles.CenteredContainerStyle.Render(content) | |||
| 16 | - | } | |||