fix(bootloader): omit fallback initramfs stanza when fallback image is absent
- commit
- a3b57b93f2bd20636eb6d414513a040d8b079426
- author
- brice · · 2026-03-02 05:49 UTC
- committer
- brice · · 2026-03-02 05:54 UTC
- parent
- 84702fe
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +189 -15
M install/configs/limine.conf.template +1 -7
| @@ -14,11 +14,5 @@ comment: machine-id={{MACHINE_ID}} | |||
| 14 | 14 | path: boot():/vmlinuz-{{KERNEL}} | |
| 15 | 15 | cmdline: {{KERNEL_PARAMS}} | |
| 16 | 16 | module_path: boot():/initramfs-{{KERNEL}}.img | |
| 17 | - | ||
| 18 | - | //{{KERNEL}}-fallback | |
| 19 | - | protocol: linux | |
| 20 | - | path: boot():/vmlinuz-{{KERNEL}} | |
| 21 | - | cmdline: {{KERNEL_PARAMS}} | |
| 22 | - | module_path: boot():/initramfs-{{KERNEL}}-fallback.img | |
| 23 | - | ||
| 17 | + | {{FALLBACK_ENTRY}} | |
| 24 | 18 | //Snapshots | |
| @@ -14,11 +14,5 @@ comment: machine-id={{MACHINE_ID}} | |||||
| 14 | path: boot():/vmlinuz-{{KERNEL}} | 14 | path: boot():/vmlinuz-{{KERNEL}} | ||
| 15 | cmdline: {{KERNEL_PARAMS}} | 15 | cmdline: {{KERNEL_PARAMS}} | ||
| 16 | module_path: boot():/initramfs-{{KERNEL}}.img | 16 | module_path: boot():/initramfs-{{KERNEL}}.img | ||
| 17 | - | 17 | + | {{FALLBACK_ENTRY}} | |
| 18 | - | //{{KERNEL}}-fallback | |||
| 19 | - | protocol: linux | |||
| 20 | - | path: boot():/vmlinuz-{{KERNEL}} | |||
| 21 | - | cmdline: {{KERNEL_PARAMS}} | |||
| 22 | - | module_path: boot():/initramfs-{{KERNEL}}-fallback.img | |||
| 23 | - | ||||
| 24 | //Snapshots | 18 | //Snapshots | ||
M internal/application/handlers/bootloader_handler.go +14 -0
| @@ -3,6 +3,7 @@ package handlers | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | + | "os" | |
| 6 | 7 | "path/filepath" | |
| 7 | 8 | "regexp" | |
| 8 | 9 | "strings" | |
| @@ -223,6 +224,19 @@ func (h *BootloaderHandler) configureLimine(ctx context.Context, cmd commands.In | |||
| 223 | 224 | limineConfig = strings.ReplaceAll(limineConfig, "{{KERNEL_PARAMS}}", kernelParams) | |
| 224 | 225 | limineConfig = strings.ReplaceAll(limineConfig, "{{MACHINE_ID}}", machineID) | |
| 225 | 226 | ||
| 227 | + | // Conditionally include fallback initramfs stanza only if the file exists | |
| 228 | + | fallbackImgPath := filepath.Join(cmd.MountPoint, "boot", fmt.Sprintf("initramfs-%s-fallback.img", kernelName)) | |
| 229 | + | fallbackEntry := "" | |
| 230 | + | if _, err := h.fs.Stat(fallbackImgPath); err == nil { | |
| 231 | + | fallbackEntry = fmt.Sprintf( | |
| 232 | + | "\n //%s-fallback\n protocol: linux\n path: boot():/vmlinuz-%s\n cmdline: %s\n module_path: boot():/initramfs-%s-fallback.img\n", | |
| 233 | + | kernelName, kernelName, kernelParams, kernelName, | |
| 234 | + | ) | |
| 235 | + | } else if !os.IsNotExist(err) { | |
| 236 | + | h.logger.Warn("Could not stat fallback initramfs, omitting fallback entry", "path", fallbackImgPath, "error", err) | |
| 237 | + | } | |
| 238 | + | limineConfig = strings.ReplaceAll(limineConfig, "{{FALLBACK_ENTRY}}", fallbackEntry) | |
| 239 | + | ||
| 226 | 240 | limineConfigPath := filepath.Join(cmd.MountPoint, "boot", "limine.conf") | |
| 227 | 241 | if err := h.fs.WriteFile(limineConfigPath, []byte(limineConfig), 0644); err != nil { | |
| 228 | 242 | h.logger.Error("Failed to write Limine config", "error", err) | |
| @@ -3,6 +3,7 @@ package handlers | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | + | "os" | |||
| 6 | "path/filepath" | 7 | "path/filepath" | ||
| 7 | "regexp" | 8 | "regexp" | ||
| 8 | "strings" | 9 | "strings" | ||
| @@ -223,6 +224,19 @@ func (h *BootloaderHandler) configureLimine(ctx context.Context, cmd commands.In | |||||
| 223 | limineConfig = strings.ReplaceAll(limineConfig, "{{KERNEL_PARAMS}}", kernelParams) | 224 | limineConfig = strings.ReplaceAll(limineConfig, "{{KERNEL_PARAMS}}", kernelParams) | ||
| 224 | limineConfig = strings.ReplaceAll(limineConfig, "{{MACHINE_ID}}", machineID) | 225 | limineConfig = strings.ReplaceAll(limineConfig, "{{MACHINE_ID}}", machineID) | ||
| 225 | 226 | ||||
| 227 | + | // Conditionally include fallback initramfs stanza only if the file exists | |||
| 228 | + | fallbackImgPath := filepath.Join(cmd.MountPoint, "boot", fmt.Sprintf("initramfs-%s-fallback.img", kernelName)) | |||
| 229 | + | fallbackEntry := "" | |||
| 230 | + | if _, err := h.fs.Stat(fallbackImgPath); err == nil { | |||
| 231 | + | fallbackEntry = fmt.Sprintf( | |||
| 232 | + | "\n //%s-fallback\n protocol: linux\n path: boot():/vmlinuz-%s\n cmdline: %s\n module_path: boot():/initramfs-%s-fallback.img\n", | |||
| 233 | + | kernelName, kernelName, kernelParams, kernelName, | |||
| 234 | + | ) | |||
| 235 | + | } else if !os.IsNotExist(err) { | |||
| 236 | + | h.logger.Warn("Could not stat fallback initramfs, omitting fallback entry", "path", fallbackImgPath, "error", err) | |||
| 237 | + | } | |||
| 238 | + | limineConfig = strings.ReplaceAll(limineConfig, "{{FALLBACK_ENTRY}}", fallbackEntry) | |||
| 239 | + | ||||
| 226 | limineConfigPath := filepath.Join(cmd.MountPoint, "boot", "limine.conf") | 240 | limineConfigPath := filepath.Join(cmd.MountPoint, "boot", "limine.conf") | ||
| 227 | if err := h.fs.WriteFile(limineConfigPath, []byte(limineConfig), 0644); err != nil { | 241 | if err := h.fs.WriteFile(limineConfigPath, []byte(limineConfig), 0644); err != nil { | ||
| 228 | h.logger.Error("Failed to write Limine config", "error", err) | 242 | h.logger.Error("Failed to write Limine config", "error", err) | ||
M internal/application/handlers/bootloader_handler_test.go +174 -8
| @@ -2,6 +2,8 @@ package handlers | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "os" | |
| 6 | + | "strings" | |
| 5 | 7 | "testing" | |
| 6 | 8 | ||
| 7 | 9 | "github.com/bnema/archup/internal/application/commands" | |
| @@ -12,17 +14,32 @@ import ( | |||
| 12 | 14 | "go.uber.org/mock/gomock" | |
| 13 | 15 | ) | |
| 14 | 16 | ||
| 15 | - | func TestBootloaderHandler_Handle_Limine(t *testing.T) { | |
| 16 | - | ctrl := gomock.NewController(t) | |
| 17 | - | defer ctrl.Finish() | |
| 17 | + | // limineTemplate is the minimal template content used in tests to avoid reading real files. | |
| 18 | + | // It mirrors the structure of limine.conf.template for the purpose of testing placeholder replacement. | |
| 19 | + | const limineTemplate = `timeout: {{TIMEOUT}} | |
| 20 | + | default_entry: 2 | |
| 21 | + | quiet: yes | |
| 22 | + | interface_branding: {{BRANDING}} | |
| 23 | + | interface_branding_colour: cyan | |
| 24 | + | graphics: yes | |
| 25 | + | backdrop: 000000 | |
| 18 | 26 | ||
| 19 | - | mockFS := mocks.NewMockFileSystem(ctrl) | |
| 20 | - | mockExec := mocks.NewMockCommandExecutor(ctrl) | |
| 21 | - | mockChrExec := mocks.NewMockChrootExecutor(ctrl) | |
| 22 | - | mockLogger := mocks.NewMockLogger(ctrl) | |
| 27 | + | /+{{KERNEL}} | |
| 28 | + | comment: machine-id={{MACHINE_ID}} | |
| 29 | + | ||
| 30 | + | //{{KERNEL}} | |
| 31 | + | protocol: linux | |
| 32 | + | path: boot():/vmlinuz-{{KERNEL}} | |
| 33 | + | cmdline: {{KERNEL_PARAMS}} | |
| 34 | + | module_path: boot():/initramfs-{{KERNEL}}.img | |
| 35 | + | {{FALLBACK_ENTRY}} | |
| 36 | + | //Snapshots | |
| 37 | + | ` | |
| 23 | 38 | ||
| 39 | + | // setupCommonMocks wires the expectations shared across bootloader handler tests. | |
| 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) { | |
| 24 | 42 | mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes() | |
| 25 | - | mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte("HOOKS=(base)\n"), nil).AnyTimes() | |
| 26 | 43 | mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() | |
| 27 | 44 | mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() | |
| 28 | 45 | mockFS.EXPECT().Exists(gomock.Any()).Return(true, nil).AnyTimes() | |
| @@ -30,6 +47,27 @@ func TestBootloaderHandler_Handle_Limine(t *testing.T) { | |||
| 30 | 47 | mockExec.EXPECT().Execute(gomock.Any(), "cp", gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes() | |
| 31 | 48 | mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes() | |
| 32 | 49 | mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes() | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestBootloaderHandler_Handle_Limine(t *testing.T) { | |
| 53 | + | ctrl := gomock.NewController(t) | |
| 54 | + | defer ctrl.Finish() | |
| 55 | + | ||
| 56 | + | mockFS := mocks.NewMockFileSystem(ctrl) | |
| 57 | + | mockExec := mocks.NewMockCommandExecutor(ctrl) | |
| 58 | + | mockChrExec := mocks.NewMockChrootExecutor(ctrl) | |
| 59 | + | mockLogger := mocks.NewMockLogger(ctrl) | |
| 60 | + | ||
| 61 | + | setupCommonMocks(mockFS, mockExec, mockChrExec, mockLogger) | |
| 62 | + | // ReadFile: mkinitcpio.conf and machine-id return defaults; template returns limineTemplate | |
| 63 | + | mockFS.EXPECT().ReadFile(gomock.Any()).DoAndReturn(func(path string) ([]byte, error) { | |
| 64 | + | if strings.HasSuffix(path, "limine.conf.template") { | |
| 65 | + | return []byte(limineTemplate), nil | |
| 66 | + | } | |
| 67 | + | return []byte("HOOKS=(base)\n"), nil | |
| 68 | + | }).AnyTimes() | |
| 69 | + | // Stat: fallback image absent (os.ErrNotExist so the warn branch is not taken) | |
| 70 | + | mockFS.EXPECT().Stat(gomock.Any()).Return(nil, os.ErrNotExist).AnyTimes() | |
| 33 | 71 | ||
| 34 | 72 | handler := NewBootloaderHandler(mockFS, mockExec, mockChrExec, mockLogger) | |
| 35 | 73 | ||
| @@ -64,6 +102,134 @@ func TestBootloaderHandler_Handle_Limine(t *testing.T) { | |||
| 64 | 102 | } | |
| 65 | 103 | } | |
| 66 | 104 | ||
| 105 | + | // TestConfigureLimine_FallbackAbsent verifies that when the fallback initramfs image does not | |
| 106 | + | // exist, the written limine.conf contains no "fallback" reference. | |
| 107 | + | func TestConfigureLimine_FallbackAbsent(t *testing.T) { | |
| 108 | + | ctrl := gomock.NewController(t) | |
| 109 | + | defer ctrl.Finish() | |
| 110 | + | ||
| 111 | + | mockFS := mocks.NewMockFileSystem(ctrl) | |
| 112 | + | mockExec := mocks.NewMockCommandExecutor(ctrl) | |
| 113 | + | mockChrExec := mocks.NewMockChrootExecutor(ctrl) | |
| 114 | + | mockLogger := mocks.NewMockLogger(ctrl) | |
| 115 | + | ||
| 116 | + | mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes() | |
| 117 | + | ||
| 118 | + | // blkid returns a UUID | |
| 119 | + | mockExec.EXPECT().Execute(gomock.Any(), "blkid", "-s", "UUID", "-o", "value", gomock.Any()). | |
| 120 | + | Return([]byte("test-uuid"), nil) | |
| 121 | + | ||
| 122 | + | // Template lookup: Exists returns true for the template path | |
| 123 | + | mockFS.EXPECT().Exists(gomock.Any()).Return(true, nil).AnyTimes() | |
| 124 | + | ||
| 125 | + | // ReadFile returns the limine template for the template file, and machine-id otherwise | |
| 126 | + | mockFS.EXPECT().ReadFile(gomock.Any()).DoAndReturn(func(path string) ([]byte, error) { | |
| 127 | + | if strings.HasSuffix(path, "limine.conf.template") { | |
| 128 | + | return []byte(limineTemplate), nil | |
| 129 | + | } | |
| 130 | + | return []byte("abc123\n"), nil | |
| 131 | + | }).AnyTimes() | |
| 132 | + | ||
| 133 | + | // Stat: fallback image does NOT exist (os.ErrNotExist so the warn branch is not taken) | |
| 134 | + | mockFS.EXPECT().Stat("/mnt/boot/initramfs-linux-fallback.img").Return(nil, os.ErrNotExist) | |
| 135 | + | ||
| 136 | + | // Capture the written config | |
| 137 | + | var writtenConfig string | |
| 138 | + | mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( | |
| 139 | + | func(path string, data []byte, perm interface{}) error { | |
| 140 | + | if strings.HasSuffix(path, "limine.conf") { | |
| 141 | + | writtenConfig = string(data) | |
| 142 | + | } | |
| 143 | + | return nil | |
| 144 | + | }, | |
| 145 | + | ) | |
| 146 | + | ||
| 147 | + | handler := NewBootloaderHandler(mockFS, mockExec, mockChrExec, mockLogger) | |
| 148 | + | ||
| 149 | + | cmd := commands.InstallBootloaderCommand{ | |
| 150 | + | MountPoint: "/mnt", | |
| 151 | + | BootloaderType: bootloader.BootloaderTypeLimine, | |
| 152 | + | TimeoutSeconds: 5, | |
| 153 | + | Branding: "ArchUp", | |
| 154 | + | KernelVariant: packages.KernelStable, | |
| 155 | + | RootPartition: "/dev/sda2", | |
| 156 | + | EncryptionType: disk.EncryptionTypeNone, | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | err := handler.configureLimine(context.Background(), cmd, "linux") | |
| 160 | + | if err != nil { | |
| 161 | + | t.Fatalf("expected no error, got %v", err) | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | if strings.Contains(writtenConfig, "fallback") { | |
| 165 | + | t.Errorf("expected no 'fallback' in limine.conf when fallback image is absent, got:\n%s", writtenConfig) | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | // TestConfigureLimine_FallbackPresent verifies that when the fallback initramfs image exists, | |
| 170 | + | // the written limine.conf contains the full fallback stanza. | |
| 171 | + | func TestConfigureLimine_FallbackPresent(t *testing.T) { | |
| 172 | + | ctrl := gomock.NewController(t) | |
| 173 | + | defer ctrl.Finish() | |
| 174 | + | ||
| 175 | + | mockFS := mocks.NewMockFileSystem(ctrl) | |
| 176 | + | mockExec := mocks.NewMockCommandExecutor(ctrl) | |
| 177 | + | mockChrExec := mocks.NewMockChrootExecutor(ctrl) | |
| 178 | + | mockLogger := mocks.NewMockLogger(ctrl) | |
| 179 | + | ||
| 180 | + | mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes() | |
| 181 | + | ||
| 182 | + | // blkid returns a UUID | |
| 183 | + | mockExec.EXPECT().Execute(gomock.Any(), "blkid", "-s", "UUID", "-o", "value", gomock.Any()). | |
| 184 | + | Return([]byte("test-uuid"), nil) | |
| 185 | + | ||
| 186 | + | // Template lookup | |
| 187 | + | mockFS.EXPECT().Exists(gomock.Any()).Return(true, nil).AnyTimes() | |
| 188 | + | ||
| 189 | + | // ReadFile returns the limine template for the template file, and machine-id otherwise | |
| 190 | + | mockFS.EXPECT().ReadFile(gomock.Any()).DoAndReturn(func(path string) ([]byte, error) { | |
| 191 | + | if strings.HasSuffix(path, "limine.conf.template") { | |
| 192 | + | return []byte(limineTemplate), nil | |
| 193 | + | } | |
| 194 | + | return []byte("abc123\n"), nil | |
| 195 | + | }).AnyTimes() | |
| 196 | + | ||
| 197 | + | // Stat: fallback image EXISTS (nil error) | |
| 198 | + | mockFS.EXPECT().Stat("/mnt/boot/initramfs-linux-fallback.img").Return(nil, nil) | |
| 199 | + | ||
| 200 | + | // Capture the written config | |
| 201 | + | var writtenConfig string | |
| 202 | + | mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( | |
| 203 | + | func(path string, data []byte, perm interface{}) error { | |
| 204 | + | if strings.HasSuffix(path, "limine.conf") { | |
| 205 | + | writtenConfig = string(data) | |
| 206 | + | } | |
| 207 | + | return nil | |
| 208 | + | }, | |
| 209 | + | ) | |
| 210 | + | ||
| 211 | + | handler := NewBootloaderHandler(mockFS, mockExec, mockChrExec, mockLogger) | |
| 212 | + | ||
| 213 | + | cmd := commands.InstallBootloaderCommand{ | |
| 214 | + | MountPoint: "/mnt", | |
| 215 | + | BootloaderType: bootloader.BootloaderTypeLimine, | |
| 216 | + | TimeoutSeconds: 5, | |
| 217 | + | Branding: "ArchUp", | |
| 218 | + | KernelVariant: packages.KernelStable, | |
| 219 | + | RootPartition: "/dev/sda2", | |
| 220 | + | EncryptionType: disk.EncryptionTypeNone, | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | err := handler.configureLimine(context.Background(), cmd, "linux") | |
| 224 | + | if err != nil { | |
| 225 | + | t.Fatalf("expected no error, got %v", err) | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | if !strings.Contains(writtenConfig, "initramfs-linux-fallback.img") { | |
| 229 | + | t.Errorf("expected 'initramfs-linux-fallback.img' in limine.conf when fallback image is present, got:\n%s", writtenConfig) | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | ||
| 67 | 233 | func TestBootloaderHandler_Handle_InvalidTimeout(t *testing.T) { | |
| 68 | 234 | ctrl := gomock.NewController(t) | |
| 69 | 235 | defer ctrl.Finish() | |
| @@ -2,6 +2,8 @@ package handlers | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "os" | |||
| 6 | + | "strings" | |||
| 5 | "testing" | 7 | "testing" | ||
| 6 | 8 | ||||
| 7 | "github.com/bnema/archup/internal/application/commands" | 9 | "github.com/bnema/archup/internal/application/commands" | ||
| @@ -12,17 +14,32 @@ import ( | |||||
| 12 | "go.uber.org/mock/gomock" | 14 | "go.uber.org/mock/gomock" | ||
| 13 | ) | 15 | ) | ||
| 14 | 16 | ||||
| 15 | - | func TestBootloaderHandler_Handle_Limine(t *testing.T) { | 17 | + | // limineTemplate is the minimal template content used in tests to avoid reading real files. |
| 16 | - | ctrl := gomock.NewController(t) | 18 | + | // It mirrors the structure of limine.conf.template for the purpose of testing placeholder replacement. |
| 17 | - | defer ctrl.Finish() | 19 | + | const limineTemplate = `timeout: {{TIMEOUT}} |
| 20 | + | default_entry: 2 | |||
| 21 | + | quiet: yes | |||
| 22 | + | interface_branding: {{BRANDING}} | |||
| 23 | + | interface_branding_colour: cyan | |||
| 24 | + | graphics: yes | |||
| 25 | + | backdrop: 000000 | |||
| 18 | 26 | ||||
| 19 | - | mockFS := mocks.NewMockFileSystem(ctrl) | 27 | + | /+{{KERNEL}} |
| 20 | - | mockExec := mocks.NewMockCommandExecutor(ctrl) | 28 | + | comment: machine-id={{MACHINE_ID}} |
| 21 | - | mockChrExec := mocks.NewMockChrootExecutor(ctrl) | 29 | + | |
| 22 | - | mockLogger := mocks.NewMockLogger(ctrl) | 30 | + | //{{KERNEL}} |
| 31 | + | protocol: linux | |||
| 32 | + | path: boot():/vmlinuz-{{KERNEL}} | |||
| 33 | + | cmdline: {{KERNEL_PARAMS}} | |||
| 34 | + | module_path: boot():/initramfs-{{KERNEL}}.img | |||
| 35 | + | {{FALLBACK_ENTRY}} | |||
| 36 | + | //Snapshots | |||
| 37 | + | ` | |||
| 23 | 38 | ||||
| 39 | + | // setupCommonMocks wires the expectations shared across bootloader handler tests. | |||
| 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) { | |||
| 24 | mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes() | 42 | mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes() | ||
| 25 | - | mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte("HOOKS=(base)\n"), nil).AnyTimes() | |||
| 26 | mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() | 43 | mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() | ||
| 27 | mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() | 44 | mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() | ||
| 28 | mockFS.EXPECT().Exists(gomock.Any()).Return(true, nil).AnyTimes() | 45 | mockFS.EXPECT().Exists(gomock.Any()).Return(true, nil).AnyTimes() | ||
| @@ -30,6 +47,27 @@ func TestBootloaderHandler_Handle_Limine(t *testing.T) { | |||||
| 30 | mockExec.EXPECT().Execute(gomock.Any(), "cp", gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes() | 47 | mockExec.EXPECT().Execute(gomock.Any(), "cp", gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes() | ||
| 31 | mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes() | 48 | mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes() | ||
| 32 | mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes() | 49 | mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes() | ||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func TestBootloaderHandler_Handle_Limine(t *testing.T) { | |||
| 53 | + | ctrl := gomock.NewController(t) | |||
| 54 | + | defer ctrl.Finish() | |||
| 55 | + | ||||
| 56 | + | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 57 | + | mockExec := mocks.NewMockCommandExecutor(ctrl) | |||
| 58 | + | mockChrExec := mocks.NewMockChrootExecutor(ctrl) | |||
| 59 | + | mockLogger := mocks.NewMockLogger(ctrl) | |||
| 60 | + | ||||
| 61 | + | setupCommonMocks(mockFS, mockExec, mockChrExec, mockLogger) | |||
| 62 | + | // ReadFile: mkinitcpio.conf and machine-id return defaults; template returns limineTemplate | |||
| 63 | + | mockFS.EXPECT().ReadFile(gomock.Any()).DoAndReturn(func(path string) ([]byte, error) { | |||
| 64 | + | if strings.HasSuffix(path, "limine.conf.template") { | |||
| 65 | + | return []byte(limineTemplate), nil | |||
| 66 | + | } | |||
| 67 | + | return []byte("HOOKS=(base)\n"), nil | |||
| 68 | + | }).AnyTimes() | |||
| 69 | + | // Stat: fallback image absent (os.ErrNotExist so the warn branch is not taken) | |||
| 70 | + | mockFS.EXPECT().Stat(gomock.Any()).Return(nil, os.ErrNotExist).AnyTimes() | |||
| 33 | 71 | ||||
| 34 | handler := NewBootloaderHandler(mockFS, mockExec, mockChrExec, mockLogger) | 72 | handler := NewBootloaderHandler(mockFS, mockExec, mockChrExec, mockLogger) | ||
| 35 | 73 | ||||
| @@ -64,6 +102,134 @@ func TestBootloaderHandler_Handle_Limine(t *testing.T) { | |||||
| 64 | } | 102 | } | ||
| 65 | } | 103 | } | ||
| 66 | 104 | ||||
| 105 | + | // TestConfigureLimine_FallbackAbsent verifies that when the fallback initramfs image does not | |||
| 106 | + | // exist, the written limine.conf contains no "fallback" reference. | |||
| 107 | + | func TestConfigureLimine_FallbackAbsent(t *testing.T) { | |||
| 108 | + | ctrl := gomock.NewController(t) | |||
| 109 | + | defer ctrl.Finish() | |||
| 110 | + | ||||
| 111 | + | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 112 | + | mockExec := mocks.NewMockCommandExecutor(ctrl) | |||
| 113 | + | mockChrExec := mocks.NewMockChrootExecutor(ctrl) | |||
| 114 | + | mockLogger := mocks.NewMockLogger(ctrl) | |||
| 115 | + | ||||
| 116 | + | mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes() | |||
| 117 | + | ||||
| 118 | + | // blkid returns a UUID | |||
| 119 | + | mockExec.EXPECT().Execute(gomock.Any(), "blkid", "-s", "UUID", "-o", "value", gomock.Any()). | |||
| 120 | + | Return([]byte("test-uuid"), nil) | |||
| 121 | + | ||||
| 122 | + | // Template lookup: Exists returns true for the template path | |||
| 123 | + | mockFS.EXPECT().Exists(gomock.Any()).Return(true, nil).AnyTimes() | |||
| 124 | + | ||||
| 125 | + | // ReadFile returns the limine template for the template file, and machine-id otherwise | |||
| 126 | + | mockFS.EXPECT().ReadFile(gomock.Any()).DoAndReturn(func(path string) ([]byte, error) { | |||
| 127 | + | if strings.HasSuffix(path, "limine.conf.template") { | |||
| 128 | + | return []byte(limineTemplate), nil | |||
| 129 | + | } | |||
| 130 | + | return []byte("abc123\n"), nil | |||
| 131 | + | }).AnyTimes() | |||
| 132 | + | ||||
| 133 | + | // Stat: fallback image does NOT exist (os.ErrNotExist so the warn branch is not taken) | |||
| 134 | + | mockFS.EXPECT().Stat("/mnt/boot/initramfs-linux-fallback.img").Return(nil, os.ErrNotExist) | |||
| 135 | + | ||||
| 136 | + | // Capture the written config | |||
| 137 | + | var writtenConfig string | |||
| 138 | + | mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( | |||
| 139 | + | func(path string, data []byte, perm interface{}) error { | |||
| 140 | + | if strings.HasSuffix(path, "limine.conf") { | |||
| 141 | + | writtenConfig = string(data) | |||
| 142 | + | } | |||
| 143 | + | return nil | |||
| 144 | + | }, | |||
| 145 | + | ) | |||
| 146 | + | ||||
| 147 | + | handler := NewBootloaderHandler(mockFS, mockExec, mockChrExec, mockLogger) | |||
| 148 | + | ||||
| 149 | + | cmd := commands.InstallBootloaderCommand{ | |||
| 150 | + | MountPoint: "/mnt", | |||
| 151 | + | BootloaderType: bootloader.BootloaderTypeLimine, | |||
| 152 | + | TimeoutSeconds: 5, | |||
| 153 | + | Branding: "ArchUp", | |||
| 154 | + | KernelVariant: packages.KernelStable, | |||
| 155 | + | RootPartition: "/dev/sda2", | |||
| 156 | + | EncryptionType: disk.EncryptionTypeNone, | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | err := handler.configureLimine(context.Background(), cmd, "linux") | |||
| 160 | + | if err != nil { | |||
| 161 | + | t.Fatalf("expected no error, got %v", err) | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | if strings.Contains(writtenConfig, "fallback") { | |||
| 165 | + | t.Errorf("expected no 'fallback' in limine.conf when fallback image is absent, got:\n%s", writtenConfig) | |||
| 166 | + | } | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | // TestConfigureLimine_FallbackPresent verifies that when the fallback initramfs image exists, | |||
| 170 | + | // the written limine.conf contains the full fallback stanza. | |||
| 171 | + | func TestConfigureLimine_FallbackPresent(t *testing.T) { | |||
| 172 | + | ctrl := gomock.NewController(t) | |||
| 173 | + | defer ctrl.Finish() | |||
| 174 | + | ||||
| 175 | + | mockFS := mocks.NewMockFileSystem(ctrl) | |||
| 176 | + | mockExec := mocks.NewMockCommandExecutor(ctrl) | |||
| 177 | + | mockChrExec := mocks.NewMockChrootExecutor(ctrl) | |||
| 178 | + | mockLogger := mocks.NewMockLogger(ctrl) | |||
| 179 | + | ||||
| 180 | + | mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes() | |||
| 181 | + | ||||
| 182 | + | // blkid returns a UUID | |||
| 183 | + | mockExec.EXPECT().Execute(gomock.Any(), "blkid", "-s", "UUID", "-o", "value", gomock.Any()). | |||
| 184 | + | Return([]byte("test-uuid"), nil) | |||
| 185 | + | ||||
| 186 | + | // Template lookup | |||
| 187 | + | mockFS.EXPECT().Exists(gomock.Any()).Return(true, nil).AnyTimes() | |||
| 188 | + | ||||
| 189 | + | // ReadFile returns the limine template for the template file, and machine-id otherwise | |||
| 190 | + | mockFS.EXPECT().ReadFile(gomock.Any()).DoAndReturn(func(path string) ([]byte, error) { | |||
| 191 | + | if strings.HasSuffix(path, "limine.conf.template") { | |||
| 192 | + | return []byte(limineTemplate), nil | |||
| 193 | + | } | |||
| 194 | + | return []byte("abc123\n"), nil | |||
| 195 | + | }).AnyTimes() | |||
| 196 | + | ||||
| 197 | + | // Stat: fallback image EXISTS (nil error) | |||
| 198 | + | mockFS.EXPECT().Stat("/mnt/boot/initramfs-linux-fallback.img").Return(nil, nil) | |||
| 199 | + | ||||
| 200 | + | // Capture the written config | |||
| 201 | + | var writtenConfig string | |||
| 202 | + | mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( | |||
| 203 | + | func(path string, data []byte, perm interface{}) error { | |||
| 204 | + | if strings.HasSuffix(path, "limine.conf") { | |||
| 205 | + | writtenConfig = string(data) | |||
| 206 | + | } | |||
| 207 | + | return nil | |||
| 208 | + | }, | |||
| 209 | + | ) | |||
| 210 | + | ||||
| 211 | + | handler := NewBootloaderHandler(mockFS, mockExec, mockChrExec, mockLogger) | |||
| 212 | + | ||||
| 213 | + | cmd := commands.InstallBootloaderCommand{ | |||
| 214 | + | MountPoint: "/mnt", | |||
| 215 | + | BootloaderType: bootloader.BootloaderTypeLimine, | |||
| 216 | + | TimeoutSeconds: 5, | |||
| 217 | + | Branding: "ArchUp", | |||
| 218 | + | KernelVariant: packages.KernelStable, | |||
| 219 | + | RootPartition: "/dev/sda2", | |||
| 220 | + | EncryptionType: disk.EncryptionTypeNone, | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | err := handler.configureLimine(context.Background(), cmd, "linux") | |||
| 224 | + | if err != nil { | |||
| 225 | + | t.Fatalf("expected no error, got %v", err) | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | if !strings.Contains(writtenConfig, "initramfs-linux-fallback.img") { | |||
| 229 | + | t.Errorf("expected 'initramfs-linux-fallback.img' in limine.conf when fallback image is present, got:\n%s", writtenConfig) | |||
| 230 | + | } | |||
| 231 | + | } | |||
| 232 | + | ||||
| 67 | func TestBootloaderHandler_Handle_InvalidTimeout(t *testing.T) { | 233 | func TestBootloaderHandler_Handle_InvalidTimeout(t *testing.T) { | ||
| 68 | ctrl := gomock.NewController(t) | 234 | ctrl := gomock.NewController(t) | ||
| 69 | defer ctrl.Finish() | 235 | defer ctrl.Finish() | ||