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 | 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 | 96 | oldLog.Info("Starting TUI application", "version", version) | |
| 97 | 97 | p := tea.NewProgram(tuiApp, tea.WithAltScreen()) | |
M go.mod +0 -5
| @@ -5,7 +5,6 @@ go 1.25.3 | |||
| 5 | 5 | require ( | |
| 6 | 6 | github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 | |
| 7 | 7 | github.com/charmbracelet/bubbletea v1.3.10 | |
| 8 | - | github.com/charmbracelet/huh v0.8.0 | |
| 9 | 8 | github.com/charmbracelet/lipgloss v1.1.0 | |
| 10 | 9 | github.com/google/uuid v1.6.0 | |
| 11 | 10 | github.com/spf13/cobra v1.10.2 | |
| @@ -15,23 +14,19 @@ require ( | |||
| 15 | 14 | require ( | |
| 16 | 15 | github.com/atotto/clipboard v0.1.4 // indirect | |
| 17 | 16 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | |
| 18 | - | github.com/catppuccin/go v0.3.0 // indirect | |
| 19 | 17 | github.com/charmbracelet/colorprofile v0.4.1 // indirect | |
| 20 | 18 | github.com/charmbracelet/x/ansi v0.11.4 // indirect | |
| 21 | 19 | github.com/charmbracelet/x/cellbuf v0.0.14 // indirect | |
| 22 | - | github.com/charmbracelet/x/exp/strings v0.0.0-20260122224438-b01af16209d9 // indirect | |
| 23 | 20 | github.com/charmbracelet/x/term v0.2.2 // indirect | |
| 24 | 21 | github.com/clipperhouse/displaywidth v0.7.0 // indirect | |
| 25 | 22 | github.com/clipperhouse/stringish v0.1.1 // indirect | |
| 26 | 23 | github.com/clipperhouse/uax29/v2 v2.4.0 // indirect | |
| 27 | - | github.com/dustin/go-humanize v1.0.1 // indirect | |
| 28 | 24 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | |
| 29 | 25 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | |
| 30 | 26 | github.com/lucasb-eyer/go-colorful v1.3.0 // indirect | |
| 31 | 27 | github.com/mattn/go-isatty v0.0.20 // indirect | |
| 32 | 28 | github.com/mattn/go-localereader v0.0.1 // indirect | |
| 33 | 29 | github.com/mattn/go-runewidth v0.0.19 // indirect | |
| 34 | - | github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect | |
| 35 | 30 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect | |
| 36 | 31 | github.com/muesli/cancelreader v0.2.2 // indirect | |
| 37 | 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 | 10 | "github.com/bnema/archup/internal/application/commands" | |
| 11 | 11 | "github.com/bnema/archup/internal/application/dto" | |
| 12 | + | "github.com/bnema/archup/internal/assets" | |
| 12 | 13 | "github.com/bnema/archup/internal/config" | |
| 13 | 14 | "github.com/bnema/archup/internal/domain/ports" | |
| 14 | - | "github.com/bnema/archup/internal/ui/assets" | |
| 15 | 15 | ) | |
| 16 | 16 | ||
| 17 | 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 | 42 | // Application state | |
| 43 | 43 | currentScreen Screen | |
| 44 | + | version string | |
| 44 | 45 | ctx context.Context | |
| 45 | 46 | cancel context.CancelFunc | |
| 46 | 47 | ||
| @@ -75,6 +76,7 @@ func NewApp( | |||
| 75 | 76 | progressTracker *services.ProgressTracker, | |
| 76 | 77 | gpuHandler *apphandlers.GPUHandler, | |
| 77 | 78 | logger ports.Logger, | |
| 79 | + | version string, | |
| 78 | 80 | ) *App { | |
| 79 | 81 | ctx, cancel := context.WithCancel(context.Background()) | |
| 80 | 82 | ||
| @@ -83,6 +85,7 @@ func NewApp( | |||
| 83 | 85 | progressTracker: progressTracker, | |
| 84 | 86 | gpuHandler: gpuHandler, | |
| 85 | 87 | logger: logger, | |
| 88 | + | version: version, | |
| 86 | 89 | formModel: models.NewFormModel(), | |
| 87 | 90 | diskModel: models.NewDiskModel(), | |
| 88 | 91 | encryptionModel: models.NewEncryptionModel(), | |
| @@ -158,7 +161,7 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |||
| 158 | 161 | func (a *App) View() string { | |
| 159 | 162 | switch a.currentScreen { | |
| 160 | 163 | case ScreenForm: | |
| 161 | - | return views.RenderForm(a.formModel) | |
| 164 | + | return views.RenderForm(a.formModel, a.version) | |
| 162 | 165 | case ScreenDisk: | |
| 163 | 166 | return views.RenderDiskSelection(a.diskModel) | |
| 164 | 167 | case ScreenEncryption: | |
| @@ -180,7 +183,7 @@ func (a *App) View() string { | |||
| 180 | 183 | case ScreenError: | |
| 181 | 184 | return views.RenderError(a.installationModel.GetError()) | |
| 182 | 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 | 108 | // Set test data | |
| 109 | 109 | testData := models.FormData{ | |
| 110 | - | Hostname: "myarch", | |
| 111 | - | Username: "archuser", | |
| 110 | + | Hostname: "arch", | |
| 111 | + | Username: "", | |
| 112 | 112 | UserPassword: "password123", | |
| 113 | 113 | RootPassword: "rootpass123", | |
| 114 | 114 | TargetDisk: "/dev/sda", | |
M internal/interfaces/tui/models/form.go +2 -2
| @@ -75,10 +75,10 @@ func NewFormModel() *FormModelImpl { | |||
| 75 | 75 | ||
| 76 | 76 | // Initialize text inputs with default values | |
| 77 | 77 | fm.hostname = createTextInput("Hostname", "", "Enter system hostname") | |
| 78 | - | fm.hostname.SetValue("myarch") | |
| 78 | + | fm.hostname.SetValue("arch") | |
| 79 | 79 | ||
| 80 | 80 | fm.username = createTextInput("Username", "", "Enter regular user name") | |
| 81 | - | fm.username.SetValue("archuser") | |
| 81 | + | fm.username.SetValue("") | |
| 82 | 82 | ||
| 83 | 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 | 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 | 12 | var b strings.Builder | |
| 13 | 13 | ||
| 14 | + | title := "ArchUp Installer" | |
| 15 | + | if version != "" { | |
| 16 | + | title += " " + version | |
| 17 | + | } | |
| 18 | + | ||
| 14 | 19 | b.WriteString("\n") | |
| 15 | 20 | b.WriteString(lipgloss.NewStyle(). | |
| 16 | 21 | Bold(true). | |
| 17 | 22 | Foreground(lipgloss.Color("12")). | |
| 18 | - | Render("ArchUp Installer Configuration")) | |
| 23 | + | Render(title)) | |
| 19 | 24 | b.WriteString("\n\n") | |
| 20 | 25 | ||
| 21 | 26 | labels := []string{ | |
M internal/interfaces/tui/views/form_view_test.go +7 -7
| @@ -13,12 +13,12 @@ func TestRenderForm(t *testing.T) { | |||
| 13 | 13 | fm := models.NewFormModel() | |
| 14 | 14 | ||
| 15 | 15 | // Render the form | |
| 16 | - | output := RenderForm(fm) | |
| 16 | + | output := RenderForm(fm, "") | |
| 17 | 17 | ||
| 18 | 18 | // Verify key content is present | |
| 19 | 19 | checks := []string{ | |
| 20 | - | "ArchUp Installer Configuration", // Title | |
| 21 | - | "Hostname:", // Field labels | |
| 20 | + | "ArchUp Installer", // Title | |
| 21 | + | "Hostname:", // Field labels | |
| 22 | 22 | "Username:", | |
| 23 | 23 | "↑↓ Navigate", // Instructions | |
| 24 | 24 | } | |
| @@ -44,7 +44,7 @@ func TestRenderFormWithError(t *testing.T) { | |||
| 44 | 44 | fm.SetError(errors.New(testErr)) | |
| 45 | 45 | ||
| 46 | 46 | // Render the form | |
| 47 | - | output := RenderForm(fm) | |
| 47 | + | output := RenderForm(fm, "") | |
| 48 | 48 | ||
| 49 | 49 | // Verify error is displayed | |
| 50 | 50 | if !strings.Contains(output, testErr) { | |
| @@ -57,13 +57,13 @@ func TestRenderFormWithDifferentFocus(t *testing.T) { | |||
| 57 | 57 | fm := models.NewFormModel() | |
| 58 | 58 | ||
| 59 | 59 | // Render initial form | |
| 60 | - | output1 := RenderForm(fm) | |
| 60 | + | output1 := RenderForm(fm, "") | |
| 61 | 61 | ||
| 62 | 62 | // Move focus | |
| 63 | 63 | fm.FocusNext() | |
| 64 | 64 | ||
| 65 | 65 | // Render form with different focus | |
| 66 | - | output2 := RenderForm(fm) | |
| 66 | + | output2 := RenderForm(fm, "") | |
| 67 | 67 | ||
| 68 | 68 | // Both should render successfully | |
| 69 | 69 | if len(output1) == 0 || len(output2) == 0 { | |
| @@ -142,6 +142,6 @@ func BenchmarkRenderForm(b *testing.B) { | |||
| 142 | 142 | ||
| 143 | 143 | b.ResetTimer() | |
| 144 | 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 | - | } | |