1package handlers
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "regexp"
10 "strings"
11
12 "github.com/bnema/archup/internal/application/commands"
13 "github.com/bnema/archup/internal/application/dto"
14 "github.com/bnema/archup/internal/config"
15 "github.com/bnema/archup/internal/domain/bootloader"
16 "github.com/bnema/archup/internal/domain/disk"
17 "github.com/bnema/archup/internal/domain/packages"
18 "github.com/bnema/archup/internal/domain/ports"
19)
20
21// BootloaderHandler handles bootloader installation
22type BootloaderHandler struct {
23 fs ports.FileSystem
24 cmdExec ports.CommandExecutor
25 chrExec ports.ChrootExecutor
26 logger ports.Logger
27}
28
29// NewBootloaderHandler creates a new bootloader handler
30func NewBootloaderHandler(fs ports.FileSystem, cmdExec ports.CommandExecutor, chrExec ports.ChrootExecutor, logger ports.Logger) *BootloaderHandler {
31 return &BootloaderHandler{
32 fs: fs,
33 cmdExec: cmdExec,
34 chrExec: chrExec,
35 logger: logger,
36 }
37}
38
39// Handle installs the bootloader
40func (h *BootloaderHandler) Handle(ctx context.Context, cmd commands.InstallBootloaderCommand) (*dto.BootloaderResult, error) {
41 h.logger.Info("Starting bootloader installation", "type", cmd.BootloaderType)
42
43 h.logger.Info("Validating bootloader configuration...")
44 result := &dto.BootloaderResult{
45 Success: false,
46 BootloaderType: "",
47 Timeout: cmd.TimeoutSeconds,
48 ErrorDetail: "",
49 }
50
51 // Create bootloader domain object
52 bl, err := bootloader.NewBootloader(cmd.BootloaderType, cmd.TimeoutSeconds, cmd.Branding)
53 if err != nil {
54 h.logger.Error("Invalid bootloader configuration", "error", err)
55 result.ErrorDetail = fmt.Sprintf("Invalid bootloader configuration: %v", err)
56 return result, err
57 }
58
59 h.logger.Info("Bootloader configuration validated", "type", bl.Type())
60
61 kernel, err := packages.NewKernel(cmd.KernelVariant)
62 if err != nil {
63 h.logger.Error("Invalid kernel variant", "error", err)
64 result.ErrorDetail = fmt.Sprintf("Invalid kernel variant: %v", err)
65 return result, err
66 }
67
68 if err := h.configureMkinitcpio(ctx, cmd.MountPoint, cmd.EncryptionType, cmd.GPUVendor, kernel.PackageName()); err != nil {
69 result.ErrorDetail = err.Error()
70 return result, err
71 }
72
73 if err := h.installLimine(ctx, cmd.MountPoint); err != nil {
74 result.ErrorDetail = err.Error()
75 return result, err
76 }
77
78 if err := h.configureLimine(ctx, cmd, kernel.PackageName()); err != nil {
79 result.ErrorDetail = err.Error()
80 return result, err
81 }
82
83 if err := h.createBootEntry(ctx, cmd.TargetDisk, cmd.EFIPartition, cmd.MountPoint); err != nil {
84 result.ErrorDetail = err.Error()
85 return result, err
86 }
87
88 result.Success = true
89 result.BootloaderType = bl.Type().String()
90 result.Timeout = bl.Timeout()
91
92 h.logger.Info("Bootloader installation completed successfully")
93 return result, nil
94}
95
96func (h *BootloaderHandler) configureMkinitcpio(ctx context.Context, mountPoint string, encType disk.EncryptionType, gpuVendor, kernelName string) error {
97 confPath := filepath.Join(mountPoint, "etc", "mkinitcpio.conf")
98 content, err := h.fs.ReadFile(confPath)
99 if err != nil {
100 h.logger.Error("Failed to read mkinitcpio.conf", "error", err)
101 return fmt.Errorf("failed to read mkinitcpio.conf: %w", err)
102 }
103
104 hooks := config.MkinitcpioHooksPlymouth
105 if encType != disk.EncryptionTypeNone {
106 hooks = config.MkinitcpioHooksEncrypted
107 }
108
109 updated := replaceHooksLine(string(content), hooks)
110
111 // Set the KMS module for early framebuffer so Plymouth loads cleanly.
112 // Without this, the kms hook has nothing to load and Plymouth appears late.
113 kmsModule := kmsModuleForGPU(gpuVendor)
114 updated = replaceModulesLine(updated, kmsModule)
115
116 if err := h.fs.WriteFile(confPath, []byte(updated), 0644); err != nil {
117 h.logger.Error("Failed to write mkinitcpio.conf", "error", err)
118 return fmt.Errorf("failed to write mkinitcpio.conf: %w", err)
119 }
120
121 if _, err := h.chrExec.ExecuteInChroot(ctx, mountPoint, "mkinitcpio", "-P"); err != nil {
122 h.logger.Error("Failed to regenerate initramfs", "error", err)
123 return fmt.Errorf("failed to regenerate initramfs: %w", err)
124 }
125
126 // Warn if the fallback initramfs was not produced; limine-snapper-notify depends on it
127 // for creating snapshot boot entries. The bootloader config already tolerates a missing
128 // fallback, but an early warning here helps diagnose first-boot notification failures.
129 fallbackPath := fallbackInitramfsPath(mountPoint, kernelName)
130 if _, statErr := h.fs.Stat(fallbackPath); statErr != nil {
131 if errors.Is(statErr, os.ErrNotExist) {
132 h.logger.Warn(
133 "Fallback initramfs was not generated; snapshot boot entries may not include fallback",
134 "kernel", kernelName,
135 "path", fallbackPath,
136 )
137 } else {
138 h.logger.Warn(
139 "Could not verify fallback initramfs after mkinitcpio",
140 "kernel", kernelName,
141 "path", fallbackPath,
142 "error", statErr,
143 )
144 }
145 }
146
147 return nil
148}
149
150// fallbackInitramfsPath returns the expected path of the fallback initramfs image
151// for the given kernel name inside the installed system mount point.
152func fallbackInitramfsPath(mountPoint, kernelName string) string {
153 return filepath.Join(mountPoint, "boot", fmt.Sprintf("initramfs-%s-fallback.img", kernelName))
154}
155
156// kmsModuleForGPU returns the kernel module name required for early KMS on the given GPU vendor.
157// Returns an empty string for NVIDIA (uses proprietary driver, no early KMS) or unknown GPUs.
158func kmsModuleForGPU(vendor string) string {
159 switch vendor {
160 case "amd":
161 return "amdgpu"
162 case "intel":
163 return "i915"
164 default:
165 return ""
166 }
167}
168
169// replaceModulesLine replaces the MODULES=(...) line in mkinitcpio.conf.
170// If module is empty, MODULES=() is left unchanged (or set to empty).
171func replaceModulesLine(content, module string) string {
172 re := regexp.MustCompile(`(?m)^MODULES=\(.*\)$`)
173 if module == "" {
174 return re.ReplaceAllString(content, "MODULES=()")
175 }
176 return re.ReplaceAllString(content, fmt.Sprintf("MODULES=(%s)", module))
177}
178
179func (h *BootloaderHandler) installLimine(ctx context.Context, mountPoint string) error {
180 limineDir := filepath.Join(mountPoint, "boot", "EFI", "limine")
181 if err := h.fs.MkdirAll(limineDir, 0755); err != nil {
182 h.logger.Error("Failed to create Limine directory", "error", err)
183 return fmt.Errorf("failed to create Limine directory: %w", err)
184 }
185
186 src := filepath.Join(mountPoint, "usr", "share", "limine", "BOOTX64.EFI")
187 dst := filepath.Join(limineDir, "BOOTX64.EFI")
188 if _, err := h.cmdExec.Execute(ctx, "cp", src, dst); err != nil {
189 h.logger.Error("Failed to copy Limine EFI", "error", err)
190 return fmt.Errorf("failed to copy Limine EFI: %w", err)
191 }
192
193 // Also copy to default/fallback boot path for UEFI firmwares that don't honor boot entries
194 fallbackDir := filepath.Join(mountPoint, "boot", "EFI", "BOOT")
195 if err := h.fs.MkdirAll(fallbackDir, 0755); err != nil {
196 h.logger.Error("Failed to create fallback EFI directory", "error", err)
197 return fmt.Errorf("failed to create fallback EFI directory: %w", err)
198 }
199 fallbackDst := filepath.Join(fallbackDir, "BOOTX64.EFI")
200 if _, err := h.cmdExec.Execute(ctx, "cp", src, fallbackDst); err != nil {
201 h.logger.Error("Failed to copy Limine EFI to fallback path", "error", err)
202 return fmt.Errorf("failed to copy Limine EFI to fallback path: %w", err)
203 }
204
205 return nil
206}
207
208func (h *BootloaderHandler) configureLimine(ctx context.Context, cmd commands.InstallBootloaderCommand, kernelName string) error {
209 rootUUIDBytes, err := h.cmdExec.Execute(ctx, "blkid", "-s", "UUID", "-o", "value", cmd.RootPartition)
210 if err != nil {
211 h.logger.Error("Failed to get root UUID", "error", err)
212 return fmt.Errorf("failed to get root UUID: %w", err)
213 }
214
215 rootUUID := strings.TrimSpace(string(rootUUIDBytes))
216 var kernelParams string
217 if cmd.EncryptionType != disk.EncryptionTypeNone {
218 kernelParams = fmt.Sprintf("cryptdevice=UUID=%s:cryptroot root=/dev/mapper/cryptroot rootflags=subvol=@ rw", rootUUID)
219 } else {
220 kernelParams = fmt.Sprintf("root=UUID=%s rootflags=subvol=@ rw", rootUUID)
221 }
222
223 kernelParams = strings.TrimSpace(fmt.Sprintf("%s %s", kernelParams, config.KernelParamsQuiet))
224 if extra := strings.TrimSpace(cmd.KernelParamsExtra); extra != "" {
225 kernelParams = strings.TrimSpace(kernelParams + " " + extra)
226 }
227
228 templatePath, err := h.resolveLimineTemplate()
229 if err != nil {
230 h.logger.Error("Failed to locate Limine template", "error", err)
231 return fmt.Errorf("failed to locate Limine template: %w", err)
232 }
233
234 templateBytes, err := h.fs.ReadFile(templatePath)
235 if err != nil {
236 h.logger.Error("Failed to read Limine template", "error", err)
237 return fmt.Errorf("failed to read Limine template: %w", err)
238 }
239
240 // Read machine-id from the installed system for limine-snapper-sync identification
241 machineIDBytes, err := h.fs.ReadFile(filepath.Join(cmd.MountPoint, "etc", "machine-id"))
242 machineID := strings.TrimSpace(string(machineIDBytes))
243 if err != nil || machineID == "" {
244 machineID = "unknown"
245 }
246
247 limineConfig := string(templateBytes)
248 limineConfig = strings.ReplaceAll(limineConfig, "{{TIMEOUT}}", fmt.Sprintf("%d", cmd.TimeoutSeconds))
249 limineConfig = strings.ReplaceAll(limineConfig, "{{BRANDING}}", cmd.Branding)
250 limineConfig = strings.ReplaceAll(limineConfig, "{{COLOR}}", config.LimineColor)
251 limineConfig = strings.ReplaceAll(limineConfig, "{{KERNEL}}", kernelName)
252 limineConfig = strings.ReplaceAll(limineConfig, "{{KERNEL_PARAMS}}", kernelParams)
253 limineConfig = strings.ReplaceAll(limineConfig, "{{MACHINE_ID}}", machineID)
254
255 // Conditionally include fallback initramfs stanza only if the file exists
256 fallbackImgPath := fallbackInitramfsPath(cmd.MountPoint, kernelName)
257 fallbackEntry := ""
258 if _, err := h.fs.Stat(fallbackImgPath); err == nil {
259 fallbackEntry = fmt.Sprintf(
260 "\n //%s-fallback\n protocol: linux\n path: boot():/vmlinuz-%s\n cmdline: %s\n module_path: boot():/initramfs-%s-fallback.img\n",
261 kernelName, kernelName, kernelParams, kernelName,
262 )
263 } else if !errors.Is(err, os.ErrNotExist) {
264 h.logger.Warn("Could not stat fallback initramfs, omitting fallback entry", "path", fallbackImgPath, "error", err)
265 }
266 limineConfig = strings.ReplaceAll(limineConfig, "{{FALLBACK_ENTRY}}", fallbackEntry)
267
268 limineConfigPath := filepath.Join(cmd.MountPoint, "boot", "limine.conf")
269 if err := h.fs.WriteFile(limineConfigPath, []byte(limineConfig), 0644); err != nil {
270 h.logger.Error("Failed to write Limine config", "error", err)
271 return fmt.Errorf("failed to write Limine config: %w", err)
272 }
273
274 return nil
275}
276
277func (h *BootloaderHandler) createBootEntry(ctx context.Context, targetDisk, efiPartition, mountPoint string) error {
278 partNum := extractPartitionNumber(efiPartition)
279 if partNum == "" {
280 return fmt.Errorf("failed to determine EFI partition number from %s", efiPartition)
281 }
282
283 if _, err := h.chrExec.ExecuteInChroot(ctx, mountPoint, "efibootmgr", "--create", "--disk", targetDisk, "--part", partNum, "--label", config.UEFIBootLabel, "--loader", config.UEFIBootLoader, "--unicode"); err != nil {
284 h.logger.Error("Failed to create EFI boot entry", "error", err)
285 return fmt.Errorf("failed to create EFI boot entry: %w", err)
286 }
287
288 return nil
289}
290
291func replaceHooksLine(content string, hooks string) string {
292 re := regexp.MustCompile(`(?m)^HOOKS=.*$`)
293 return re.ReplaceAllString(content, hooks)
294}
295
296func extractPartitionNumber(partition string) string {
297 re := regexp.MustCompile(`[0-9]+$`)
298 return re.FindString(partition)
299}
300
301func (h *BootloaderHandler) resolveLimineTemplate() (string, error) {
302 candidates := []string{
303 filepath.Join(config.DefaultInstallDir, "configs", "limine.conf.template"),
304 filepath.Join("install", "configs", "limine.conf.template"),
305 }
306
307 for _, candidate := range candidates {
308 exists, err := h.fs.Exists(candidate)
309 if err == nil && exists {
310 return candidate, nil
311 }
312 }
313
314 return "", fmt.Errorf("limine.conf.template not found")
315}