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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +62 -3
M install/extra.packages +1 -0
| @@ -122,3 +122,4 @@ duf | |||
| 122 | 122 | tealdeer | |
| 123 | 123 | yazi | |
| 124 | 124 | starship | |
| 125 | + | jq | |
| @@ -122,3 +122,4 @@ duf | |||||
| 122 | tealdeer | 122 | tealdeer | ||
| 123 | yazi | 123 | yazi | ||
| 124 | starship | 124 | 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 | |||
| 65 | 65 | 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 | 69 | result.ErrorDetail = err.Error() | |
| 70 | 70 | return result, err | |
| 71 | 71 | } | |
| @@ -93,7 +93,7 @@ func (h *BootloaderHandler) Handle(ctx context.Context, cmd commands.InstallBoot | |||
| 93 | 93 | 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 | 97 | confPath := filepath.Join(mountPoint, "etc", "mkinitcpio.conf") | |
| 98 | 98 | content, err := h.fs.ReadFile(confPath) | |
| 99 | 99 | if err != nil { | |
| @@ -123,9 +123,36 @@ func (h *BootloaderHandler) configureMkinitcpio(ctx context.Context, mountPoint | |||
| 123 | 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 | 147 | 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 | 156 | // kmsModuleForGPU returns the kernel module name required for early KMS on the given GPU vendor. | |
| 130 | 157 | // Returns an empty string for NVIDIA (uses proprietary driver, no early KMS) or unknown GPUs. | |
| 131 | 158 | func kmsModuleForGPU(vendor string) string { | |
| @@ -226,7 +253,7 @@ func (h *BootloaderHandler) configureLimine(ctx context.Context, cmd commands.In | |||
| 226 | 253 | limineConfig = strings.ReplaceAll(limineConfig, "{{MACHINE_ID}}", machineID) | |
| 227 | 254 | ||
| 228 | 255 | // 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 | 257 | fallbackEntry := "" | |
| 231 | 258 | if _, err := h.fs.Stat(fallbackImgPath); err == nil { | |
| 232 | 259 | fallbackEntry = fmt.Sprintf( | |
| @@ -65,7 +65,7 @@ func (h *BootloaderHandler) Handle(ctx context.Context, cmd commands.InstallBoot | |||||
| 65 | return result, err | 65 | 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, err | 70 | return result, err | ||
| 71 | } | 71 | } | ||
| @@ -93,7 +93,7 @@ func (h *BootloaderHandler) Handle(ctx context.Context, cmd commands.InstallBoot | |||||
| 93 | return result, nil | 93 | 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 nil | 147 | 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 exists | 255 | // 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}} | |||
| 40 | 40 | // Callers MUST register their own ReadFile and Stat expectations before calling configureLimine. | |
| 41 | 41 | func setupCommonMocks(mockFS *mocks.MockFileSystem, mockExec *mocks.MockCommandExecutor, mockChrExec *mocks.MockChrootExecutor, mockLogger *mocks.MockLogger) { | |
| 42 | 42 | mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes() | |
| 43 | + | mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any()).AnyTimes() | |
| 43 | 44 | mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() | |
| 44 | 45 | mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() | |
| 45 | 46 | mockFS.EXPECT().Exists(gomock.Any()).Return(true, nil).AnyTimes() | |
| @@ -304,3 +305,33 @@ func TestBootloaderHandler_Handle_InvalidBranding(t *testing.T) { | |||
| 304 | 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 | + | } | |
| @@ -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 | + | } | |||