detached 055de8d

fix(packages,bootloader): add jq to extra packages; warn on missing fallback initramfs

Closes #7: jq added to the Modern CLI Tools section of install/extra.packages.

Closes #4: configureMkinitcpio now verifies the fallback initramfs image after
mkinitcpio -P and emits a structured Warn log if it was not produced. This
surfaces the root cause of limine-snapper-notify failures on first boot without
failing the install. Also extracts fallbackInitramfsPath() helper to eliminate
the duplicate path construction in configureLimine.
commit
055de8dde27eddd98a3c29a44794bdec80343345
author
brice · · 2026-03-13 06:26 UTC
parent
bb7883c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +62 -3
M install/extra.packages +1 -0
@@ -122,3 +122,4 @@ duf
122122 tealdeer
123123 yazi
124124 starship
125+jq
@@ -122,3 +122,4 @@ duf
122 tealdeer122 tealdeer
123 yazi123 yazi
124 starship124 starship
125+jq
M internal/application/handlers/bootloader_handler.go +30 -3
@@ -65,7 +65,7 @@ func (h *BootloaderHandler) Handle(ctx context.Context, cmd commands.InstallBoot
6565 return result, err
6666 }
6767
68- if err := h.configureMkinitcpio(ctx, cmd.MountPoint, cmd.EncryptionType, cmd.GPUVendor); err != nil {
68+ if err := h.configureMkinitcpio(ctx, cmd.MountPoint, cmd.EncryptionType, cmd.GPUVendor, kernel.PackageName()); err != nil {
6969 result.ErrorDetail = err.Error()
7070 return result, err
7171 }
@@ -93,7 +93,7 @@ func (h *BootloaderHandler) Handle(ctx context.Context, cmd commands.InstallBoot
9393 return result, nil
9494 }
9595
96-func (h *BootloaderHandler) configureMkinitcpio(ctx context.Context, mountPoint string, encType disk.EncryptionType, gpuVendor string) error {
96+func (h *BootloaderHandler) configureMkinitcpio(ctx context.Context, mountPoint string, encType disk.EncryptionType, gpuVendor, kernelName string) error {
9797 confPath := filepath.Join(mountPoint, "etc", "mkinitcpio.conf")
9898 content, err := h.fs.ReadFile(confPath)
9999 if err != nil {
@@ -123,9 +123,36 @@ func (h *BootloaderHandler) configureMkinitcpio(ctx context.Context, mountPoint
123123 return fmt.Errorf("failed to regenerate initramfs: %w", err)
124124 }
125125
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+
126147 return nil
127148 }
128149
150+// fallbackInitramfsPath returns the expected path of the fallback initramfs image
151+// for the given kernel name inside the installed system mount point.
152+func fallbackInitramfsPath(mountPoint, kernelName string) string {
153+ return filepath.Join(mountPoint, "boot", fmt.Sprintf("initramfs-%s-fallback.img", kernelName))
154+}
155+
129156 // kmsModuleForGPU returns the kernel module name required for early KMS on the given GPU vendor.
130157 // Returns an empty string for NVIDIA (uses proprietary driver, no early KMS) or unknown GPUs.
131158 func kmsModuleForGPU(vendor string) string {
@@ -226,7 +253,7 @@ func (h *BootloaderHandler) configureLimine(ctx context.Context, cmd commands.In
226253 limineConfig = strings.ReplaceAll(limineConfig, "{{MACHINE_ID}}", machineID)
227254
228255 // Conditionally include fallback initramfs stanza only if the file exists
229- fallbackImgPath := filepath.Join(cmd.MountPoint, "boot", fmt.Sprintf("initramfs-%s-fallback.img", kernelName))
256+ fallbackImgPath := fallbackInitramfsPath(cmd.MountPoint, kernelName)
230257 fallbackEntry := ""
231258 if _, err := h.fs.Stat(fallbackImgPath); err == nil {
232259 fallbackEntry = fmt.Sprintf(
@@ -65,7 +65,7 @@ func (h *BootloaderHandler) Handle(ctx context.Context, cmd commands.InstallBoot
65 return result, err65 return result, err
66 }66 }
67 67
68- if err := h.configureMkinitcpio(ctx, cmd.MountPoint, cmd.EncryptionType, cmd.GPUVendor); err != nil {68+ if err := h.configureMkinitcpio(ctx, cmd.MountPoint, cmd.EncryptionType, cmd.GPUVendor, kernel.PackageName()); err != nil {
69 result.ErrorDetail = err.Error()69 result.ErrorDetail = err.Error()
70 return result, err70 return result, err
71 }71 }
@@ -93,7 +93,7 @@ func (h *BootloaderHandler) Handle(ctx context.Context, cmd commands.InstallBoot
93 return result, nil93 return result, nil
94 }94 }
95 95
96-func (h *BootloaderHandler) configureMkinitcpio(ctx context.Context, mountPoint string, encType disk.EncryptionType, gpuVendor string) error {96+func (h *BootloaderHandler) configureMkinitcpio(ctx context.Context, mountPoint string, encType disk.EncryptionType, gpuVendor, kernelName string) error {
97 confPath := filepath.Join(mountPoint, "etc", "mkinitcpio.conf")97 confPath := filepath.Join(mountPoint, "etc", "mkinitcpio.conf")
98 content, err := h.fs.ReadFile(confPath)98 content, err := h.fs.ReadFile(confPath)
99 if err != nil {99 if err != nil {
@@ -123,9 +123,36 @@ func (h *BootloaderHandler) configureMkinitcpio(ctx context.Context, mountPoint
123 return fmt.Errorf("failed to regenerate initramfs: %w", err)123 return fmt.Errorf("failed to regenerate initramfs: %w", err)
124 }124 }
125 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+
126 return nil147 return nil
127 }148 }
128 149
150+// fallbackInitramfsPath returns the expected path of the fallback initramfs image
151+// for the given kernel name inside the installed system mount point.
152+func fallbackInitramfsPath(mountPoint, kernelName string) string {
153+ return filepath.Join(mountPoint, "boot", fmt.Sprintf("initramfs-%s-fallback.img", kernelName))
154+}
155+
129 // kmsModuleForGPU returns the kernel module name required for early KMS on the given GPU vendor.156 // kmsModuleForGPU returns the kernel module name required for early KMS on the given GPU vendor.
130 // Returns an empty string for NVIDIA (uses proprietary driver, no early KMS) or unknown GPUs.157 // Returns an empty string for NVIDIA (uses proprietary driver, no early KMS) or unknown GPUs.
131 func kmsModuleForGPU(vendor string) string {158 func kmsModuleForGPU(vendor string) string {
@@ -226,7 +253,7 @@ func (h *BootloaderHandler) configureLimine(ctx context.Context, cmd commands.In
226 limineConfig = strings.ReplaceAll(limineConfig, "{{MACHINE_ID}}", machineID)253 limineConfig = strings.ReplaceAll(limineConfig, "{{MACHINE_ID}}", machineID)
227 254
228 // Conditionally include fallback initramfs stanza only if the file exists255 // Conditionally include fallback initramfs stanza only if the file exists
229- fallbackImgPath := filepath.Join(cmd.MountPoint, "boot", fmt.Sprintf("initramfs-%s-fallback.img", kernelName))256+ fallbackImgPath := fallbackInitramfsPath(cmd.MountPoint, kernelName)
230 fallbackEntry := ""257 fallbackEntry := ""
231 if _, err := h.fs.Stat(fallbackImgPath); err == nil {258 if _, err := h.fs.Stat(fallbackImgPath); err == nil {
232 fallbackEntry = fmt.Sprintf(259 fallbackEntry = fmt.Sprintf(
M internal/application/handlers/bootloader_handler_test.go +31 -0
@@ -40,6 +40,7 @@ comment: machine-id={{MACHINE_ID}}
4040 // Callers MUST register their own ReadFile and Stat expectations before calling configureLimine.
4141 func setupCommonMocks(mockFS *mocks.MockFileSystem, mockExec *mocks.MockCommandExecutor, mockChrExec *mocks.MockChrootExecutor, mockLogger *mocks.MockLogger) {
4242 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
43+ mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any()).AnyTimes()
4344 mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
4445 mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
4546 mockFS.EXPECT().Exists(gomock.Any()).Return(true, nil).AnyTimes()
@@ -304,3 +305,33 @@ func TestBootloaderHandler_Handle_InvalidBranding(t *testing.T) {
304305 t.Error("expected failure for empty branding")
305306 }
306307 }
308+
309+// TestConfigureMkinitcpio_WarnsWhenFallbackMissing verifies that configureMkinitcpio logs
310+// a warning when mkinitcpio -P succeeds but the fallback initramfs image is not produced.
311+func TestConfigureMkinitcpio_WarnsWhenFallbackMissing(t *testing.T) {
312+ ctrl := gomock.NewController(t)
313+ defer ctrl.Finish()
314+
315+ mockFS := mocks.NewMockFileSystem(ctrl)
316+ mockExec := mocks.NewMockCommandExecutor(ctrl)
317+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
318+ mockLogger := mocks.NewMockLogger(ctrl)
319+
320+ mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
321+ mockFS.EXPECT().ReadFile("/mnt/etc/mkinitcpio.conf").Return([]byte("MODULES=()\nHOOKS=(base udev)\n"), nil)
322+ mockFS.EXPECT().WriteFile("/mnt/etc/mkinitcpio.conf", gomock.Any(), os.FileMode(0644)).Return(nil)
323+ mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), "/mnt", "mkinitcpio", "-P").Return([]byte{}, nil)
324+ mockFS.EXPECT().Stat("/mnt/boot/initramfs-linux-fallback.img").Return(nil, os.ErrNotExist)
325+ mockLogger.EXPECT().Warn(
326+ "Fallback initramfs was not generated; snapshot boot entries may not include fallback",
327+ "kernel", "linux",
328+ "path", "/mnt/boot/initramfs-linux-fallback.img",
329+ )
330+
331+ handler := NewBootloaderHandler(mockFS, mockExec, mockChrExec, mockLogger)
332+
333+ err := handler.configureMkinitcpio(context.Background(), "/mnt", disk.EncryptionTypeNone, "", "linux")
334+ if err != nil {
335+ t.Fatalf("expected no error, got %v", err)
336+ }
337+}
@@ -40,6 +40,7 @@ comment: machine-id={{MACHINE_ID}}
40 // Callers MUST register their own ReadFile and Stat expectations before calling configureLimine.40 // Callers MUST register their own ReadFile and Stat expectations before calling configureLimine.
41 func setupCommonMocks(mockFS *mocks.MockFileSystem, mockExec *mocks.MockCommandExecutor, mockChrExec *mocks.MockChrootExecutor, mockLogger *mocks.MockLogger) {41 func setupCommonMocks(mockFS *mocks.MockFileSystem, mockExec *mocks.MockCommandExecutor, mockChrExec *mocks.MockChrootExecutor, mockLogger *mocks.MockLogger) {
42 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()42 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
43+ mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any()).AnyTimes()
43 mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()44 mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
44 mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()45 mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
45 mockFS.EXPECT().Exists(gomock.Any()).Return(true, nil).AnyTimes()46 mockFS.EXPECT().Exists(gomock.Any()).Return(true, nil).AnyTimes()
@@ -304,3 +305,33 @@ func TestBootloaderHandler_Handle_InvalidBranding(t *testing.T) {
304 t.Error("expected failure for empty branding")305 t.Error("expected failure for empty branding")
305 }306 }
306 }307 }
308+
309+// TestConfigureMkinitcpio_WarnsWhenFallbackMissing verifies that configureMkinitcpio logs
310+// a warning when mkinitcpio -P succeeds but the fallback initramfs image is not produced.
311+func TestConfigureMkinitcpio_WarnsWhenFallbackMissing(t *testing.T) {
312+ ctrl := gomock.NewController(t)
313+ defer ctrl.Finish()
314+
315+ mockFS := mocks.NewMockFileSystem(ctrl)
316+ mockExec := mocks.NewMockCommandExecutor(ctrl)
317+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
318+ mockLogger := mocks.NewMockLogger(ctrl)
319+
320+ mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
321+ mockFS.EXPECT().ReadFile("/mnt/etc/mkinitcpio.conf").Return([]byte("MODULES=()\nHOOKS=(base udev)\n"), nil)
322+ mockFS.EXPECT().WriteFile("/mnt/etc/mkinitcpio.conf", gomock.Any(), os.FileMode(0644)).Return(nil)
323+ mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), "/mnt", "mkinitcpio", "-P").Return([]byte{}, nil)
324+ mockFS.EXPECT().Stat("/mnt/boot/initramfs-linux-fallback.img").Return(nil, os.ErrNotExist)
325+ mockLogger.EXPECT().Warn(
326+ "Fallback initramfs was not generated; snapshot boot entries may not include fallback",
327+ "kernel", "linux",
328+ "path", "/mnt/boot/initramfs-linux-fallback.img",
329+ )
330+
331+ handler := NewBootloaderHandler(mockFS, mockExec, mockChrExec, mockLogger)
332+
333+ err := handler.configureMkinitcpio(context.Background(), "/mnt", disk.EncryptionTypeNone, "", "linux")
334+ if err != nil {
335+ t.Fatalf("expected no error, got %v", err)
336+ }
337+}

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