main 6af0a61

bootloader_handler.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 0

    ↑↓ moveenter openesc close