detached a3b57b9

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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}}
1414 path: boot():/vmlinuz-{{KERNEL}}
1515 cmdline: {{KERNEL_PARAMS}}
1616 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}}
2418 //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}}.img16 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 //Snapshots18 //Snapshots
M internal/application/handlers/bootloader_handler.go +14 -0
@@ -3,6 +3,7 @@ package handlers
33 import (
44 "context"
55 "fmt"
6+ "os"
67 "path/filepath"
78 "regexp"
89 "strings"
@@ -223,6 +224,19 @@ func (h *BootloaderHandler) configureLimine(ctx context.Context, cmd commands.In
223224 limineConfig = strings.ReplaceAll(limineConfig, "{{KERNEL_PARAMS}}", kernelParams)
224225 limineConfig = strings.ReplaceAll(limineConfig, "{{MACHINE_ID}}", machineID)
225226
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+
226240 limineConfigPath := filepath.Join(cmd.MountPoint, "boot", "limine.conf")
227241 if err := h.fs.WriteFile(limineConfigPath, []byte(limineConfig), 0644); err != nil {
228242 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
22
33 import (
44 "context"
5+ "os"
6+ "strings"
57 "testing"
68
79 "github.com/bnema/archup/internal/application/commands"
@@ -12,17 +14,32 @@ import (
1214 "go.uber.org/mock/gomock"
1315 )
1416
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
1826
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+`
2338
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) {
2442 mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
25- mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte("HOOKS=(base)\n"), nil).AnyTimes()
2643 mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
2744 mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
2845 mockFS.EXPECT().Exists(gomock.Any()).Return(true, nil).AnyTimes()
@@ -30,6 +47,27 @@ func TestBootloaderHandler_Handle_Limine(t *testing.T) {
3047 mockExec.EXPECT().Execute(gomock.Any(), "cp", gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
3148 mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
3249 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()
3371
3472 handler := NewBootloaderHandler(mockFS, mockExec, mockChrExec, mockLogger)
3573
@@ -64,6 +102,134 @@ func TestBootloaderHandler_Handle_Limine(t *testing.T) {
64102 }
65103 }
66104
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+
67233 func TestBootloaderHandler_Handle_InvalidTimeout(t *testing.T) {
68234 ctrl := gomock.NewController(t)
69235 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()

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