main 6af0a61

post_install_handler.go RawHistoryFolder

1package handlers
2
3import (
4	"context"
5	"fmt"
6	"path/filepath"
7	"regexp"
8	"strings"
9
10	"github.com/bnema/archup/internal/application/commands"
11	"github.com/bnema/archup/internal/application/dto"
12	"github.com/bnema/archup/internal/config"
13	"github.com/bnema/archup/internal/domain/ports"
14)
15
16// PostInstallHandler handles post-installation tasks
17type PostInstallHandler struct {
18	fs         ports.FileSystem
19	httpClient ports.HTTPClient
20	chrExec    ports.ChrootExecutor
21	scriptExec ports.ScriptExecutor
22	logger     ports.Logger
23	rawURL     string
24}
25
26// NewPostInstallHandler creates a new post-installation handler
27func NewPostInstallHandler(fs ports.FileSystem, httpClient ports.HTTPClient, chrExec ports.ChrootExecutor, scriptExec ports.ScriptExecutor, logger ports.Logger, rawURL string) *PostInstallHandler {
28	return &PostInstallHandler{
29		fs:         fs,
30		httpClient: httpClient,
31		chrExec:    chrExec,
32		scriptExec: scriptExec,
33		logger:     logger,
34		rawURL:     rawURL,
35	}
36}
37
38// Handle executes post-installation tasks
39func (h *PostInstallHandler) Handle(ctx context.Context, cmd commands.PostInstallCommand) (*dto.PostInstallResult, error) {
40	h.logger.Info("Starting post-installation tasks", "username", cmd.Username)
41
42	result := &dto.PostInstallResult{
43		Success:     false,
44		TasksRun:    []string{},
45		ErrorDetail: "",
46	}
47
48	// Run post-boot scripts if requested
49	if cmd.RunPostBootScripts {
50		h.logger.Info("Running post-boot scripts")
51		result.TasksRun = append(result.TasksRun, "post-boot-scripts")
52
53		if err := h.setupPostBoot(ctx, cmd.MountPoint, cmd.Username, cmd.UserEmail); err != nil {
54			h.logger.Error("Failed to setup post-boot scripts", "error", err)
55			result.ErrorDetail = fmt.Sprintf("Failed to setup post-boot scripts: %v", err)
56			return result, err
57		}
58
59		h.logger.Info("Post-boot scripts prepared")
60	}
61
62	// Write Dank Linux flag file if user opted in
63	if cmd.InstallDankLinux {
64		if err := h.writeDankLinuxFlag(cmd.MountPoint); err != nil {
65			result.ErrorDetail = fmt.Sprintf("Failed to write Dank Linux flag: %v", err)
66			return result, fmt.Errorf("failed to write dank linux flag: %w", err)
67		}
68		result.TasksRun = append(result.TasksRun, "dank-linux-flag")
69		h.logger.Info("Dank Linux flag file written")
70	}
71
72	// Setup limine-snapper-sync for btrfs snapshot bootability
73	if err := h.setupSnapperSync(ctx, cmd.MountPoint); err != nil {
74		h.logger.Warn("Failed to setup limine-snapper-sync", "error", err)
75	}
76
77	// Install Plymouth theme if specified
78	if cmd.PlymouthTheme != "" {
79		h.logger.Info("Installing Plymouth theme", "theme", cmd.PlymouthTheme)
80
81		themeDir := filepath.Join(cmd.MountPoint, "usr", "share", "plymouth", "themes", cmd.PlymouthTheme)
82		if err := h.fs.MkdirAll(themeDir, 0755); err != nil {
83			return result, fmt.Errorf("failed to create Plymouth theme directory: %w", err)
84		}
85
86		for _, file := range config.PlymouthFiles {
87			content, err := h.downloadTemplate(filepath.Join("assets", "plymouth", file))
88			if err != nil {
89				return result, fmt.Errorf("failed to download Plymouth file %s: %w", file, err)
90			}
91			if err := h.fs.WriteFile(filepath.Join(themeDir, file), content, 0644); err != nil {
92				return result, fmt.Errorf("failed to write Plymouth file %s: %w", file, err)
93			}
94		}
95
96		if _, err := h.chrExec.ExecuteInChroot(ctx, cmd.MountPoint, "plymouth-set-default-theme", cmd.PlymouthTheme); err != nil {
97			return result, fmt.Errorf("failed to set Plymouth theme: %w", err)
98		}
99
100		// Rebuild initramfs so the Plymouth theme takes effect at boot
101		if _, err := h.chrExec.ExecuteInChroot(ctx, cmd.MountPoint, "mkinitcpio", "-P"); err != nil {
102			return result, fmt.Errorf("failed to rebuild initramfs after Plymouth theme: %w", err)
103		}
104
105		result.TasksRun = append(result.TasksRun, fmt.Sprintf("plymouth-theme-%s", cmd.PlymouthTheme))
106		h.logger.Info("Plymouth theme installed and initramfs rebuilt")
107	}
108
109	if err := h.tunePacmanConfig(cmd.MountPoint); err != nil {
110		h.logger.Warn("Failed to tune pacman.conf", "error", err)
111	}
112
113	if err := h.installLimineHook(cmd.MountPoint, cmd.TargetDisk); err != nil {
114		h.logger.Warn("Failed to install limine hook", "error", err)
115	}
116
117	// Final cleanup and verification
118	result.VerificationWarnings = h.verifyInstallation(cmd.MountPoint, cmd.Encrypted)
119	if len(result.VerificationWarnings) > 0 {
120		h.logger.Warn("Post-install verification warnings", "warnings", result.VerificationWarnings)
121	}
122
123	h.logger.Info("Post-installation tasks completed")
124	result.Success = true
125
126	return result, nil
127}
128
129func (h *PostInstallHandler) setupPostBoot(ctx context.Context, mountPoint, username, email string) error {
130	postBootPath := filepath.Join(mountPoint, "usr", "local", "share", "archup", "post-boot")
131	if err := h.fs.MkdirAll(postBootPath, 0755); err != nil {
132		return fmt.Errorf("failed to create post-boot directory: %w", err)
133	}
134
135	// Write service file with placeholder replacement for email and username
136	serviceDest := filepath.Join(mountPoint, "etc", "systemd", "system", "archup-first-boot.service")
137	if err := h.writeServiceFile("install/mandatory/post-boot/archup-first-boot.service", serviceDest, username, email); err != nil {
138		return fmt.Errorf("failed to write service file: %w", err)
139	}
140
141	for _, script := range config.PostBootScripts {
142		src := filepath.Join("install", "mandatory", "post-boot", script)
143		dst := filepath.Join(postBootPath, script)
144		if err := h.writeFromTemplate(src, dst); err != nil {
145			return fmt.Errorf("failed to write %s: %w", script, err)
146		}
147		// Only set executable bit on shell scripts
148		if strings.HasSuffix(script, ".sh") {
149			if err := h.fs.Chmod(dst, 0755); err != nil {
150				return fmt.Errorf("failed to set permissions for %s: %w", script, err)
151			}
152		}
153	}
154
155	if err := h.chrExec.ChrootSystemctl(ctx, h.logger.LogPath(), mountPoint, "enable", config.PostBootServiceName); err != nil {
156		return fmt.Errorf("failed to enable first-boot service: %w", err)
157	}
158
159	return nil
160}
161
162func (h *PostInstallHandler) writeFromTemplate(src string, dst string) error {
163	content, err := h.tryReadLocal(src)
164	if err != nil {
165		content, err = h.downloadTemplate(src)
166		if err != nil {
167			return err
168		}
169	}
170
171	return h.fs.WriteFile(dst, content, 0644)
172}
173
174// writeServiceFile writes the systemd service file with placeholder replacement
175func (h *PostInstallHandler) writeServiceFile(src, dst, username, email string) error {
176	content, err := h.tryReadLocal(src)
177	if err != nil {
178		content, err = h.downloadTemplate(src)
179		if err != nil {
180			return err
181		}
182	}
183
184	// Replace placeholders with actual values
185	serviceContent := string(content)
186	serviceContent = strings.ReplaceAll(serviceContent, "__ARCHUP_USERNAME__", username)
187	serviceContent = strings.ReplaceAll(serviceContent, "__ARCHUP_EMAIL__", email)
188
189	return h.fs.WriteFile(dst, []byte(serviceContent), 0644)
190}
191
192func (h *PostInstallHandler) tryReadLocal(path string) ([]byte, error) {
193	localPath := filepath.Join(config.DefaultInstallDir, path)
194	if exists, err := h.fs.Exists(localPath); err == nil && exists {
195		return h.fs.ReadFile(localPath)
196	}
197	if exists, err := h.fs.Exists(path); err == nil && exists {
198		return h.fs.ReadFile(path)
199	}
200	return nil, fmt.Errorf("local template not found")
201}
202
203// writeDankLinuxFlag writes a flag file to the installed system so that
204// dms-opt-in.sh auto-runs without prompting on first boot.
205func (h *PostInstallHandler) writeDankLinuxFlag(mountPoint string) error {
206	if err := h.fs.MkdirAll(filepath.Join(mountPoint, "var", "lib"), 0755); err != nil {
207		return fmt.Errorf("failed to create /var/lib: %w", err)
208	}
209	flagPath := filepath.Join(mountPoint, "var", "lib", "archup-install-danklinux")
210	return h.fs.WriteFile(flagPath, []byte(""), 0644)
211}
212
213func (h *PostInstallHandler) setupSnapperSync(ctx context.Context, mountPoint string) error {
214	if _, err := h.chrExec.ExecuteInChroot(ctx, mountPoint, "pacman", "-S", "--noconfirm", "--needed", "limine-snapper-sync"); err != nil {
215		return fmt.Errorf("failed to install limine-snapper-sync: %w", err)
216	}
217	if err := h.chrExec.ChrootSystemctl(ctx, h.logger.LogPath(), mountPoint, "enable", "limine-snapper-sync.service"); err != nil {
218		return fmt.Errorf("failed to enable limine-snapper-sync.service: %w", err)
219	}
220	// Sanitize config: comment out enrollment commands when limine-entry-tool is absent
221	if err := h.sanitizeLimineSnapperSyncConfig(mountPoint); err != nil {
222		h.logger.Warn("Failed to sanitize limine-snapper-sync config", "error", err)
223	}
224	return nil
225}
226
227func (h *PostInstallHandler) sanitizeLimineSnapperSyncConfig(mountPoint string) error {
228	confPath := filepath.Join(mountPoint, "etc", "limine-snapper-sync.conf")
229	exists, err := h.fs.Exists(confPath)
230	if err != nil || !exists {
231		return err // config missing, nothing to sanitize
232	}
233
234	// If the binary is present, enrollment workflow is intentionally configured — leave config alone
235	enrollBin := filepath.Join(mountPoint, "usr", "bin", "limine-reset-enroll")
236	if _, err := h.fs.Stat(enrollBin); err == nil {
237		return nil
238	}
239
240	b, err := h.fs.ReadFile(confPath)
241	if err != nil {
242		return fmt.Errorf("failed to read limine-snapper-sync.conf: %w", err)
243	}
244
245	s := string(b)
246	reBefore := regexp.MustCompile(`(?m)^COMMANDS_BEFORE_SAVE=.*$`)
247	reAfter := regexp.MustCompile(`(?m)^COMMANDS_AFTER_SAVE=.*$`)
248	s = reBefore.ReplaceAllString(s, `# COMMANDS_BEFORE_SAVE="" # disabled: limine-entry-tool not installed`)
249	s = reAfter.ReplaceAllString(s, `# COMMANDS_AFTER_SAVE="" # disabled: limine-entry-tool not installed`)
250
251	return h.fs.WriteFile(confPath, []byte(s), 0644)
252}
253
254// limineDiskPlaceholder is the placeholder in limine-update.hook replaced at install time.
255const limineDiskPlaceholder = "DISK_PLACEHOLDER"
256
257func (h *PostInstallHandler) installLimineHook(mountPoint, targetDisk string) error {
258	if targetDisk == "" {
259		return fmt.Errorf("target disk is required for limine hook installation")
260	}
261	hooksDir := filepath.Join(mountPoint, "etc", "pacman.d", "hooks")
262	if err := h.fs.MkdirAll(hooksDir, 0755); err != nil {
263		return fmt.Errorf("failed to create hooks dir: %w", err)
264	}
265	content, err := h.tryReadLocal("install/configs/limine-update.hook")
266	if err != nil {
267		content, err = h.downloadTemplate("install/configs/limine-update.hook")
268		if err != nil {
269			return fmt.Errorf("failed to get limine hook template: %w", err)
270		}
271	}
272	hookContent := strings.ReplaceAll(string(content), limineDiskPlaceholder, targetDisk)
273	return h.fs.WriteFile(filepath.Join(hooksDir, "limine-update.hook"), []byte(hookContent), 0644)
274}
275
276func (h *PostInstallHandler) tunePacmanConfig(mountPoint string) error {
277	confPath := filepath.Join(mountPoint, "etc", "pacman.conf")
278	content, err := h.fs.ReadFile(confPath)
279	if err != nil {
280		return fmt.Errorf("failed to read pacman.conf: %w", err)
281	}
282	conf := string(content)
283	conf = uncommentPacmanOption(conf, "Color")
284	conf = uncommentPacmanOption(conf, "ParallelDownloads")
285	conf = uncommentPacmanOption(conf, "ILoveCandy")
286	return h.fs.WriteFile(confPath, []byte(conf), 0644)
287}
288
289// uncommentPacmanOption uncomments a pacman.conf option, handling optional spaces after #.
290func uncommentPacmanOption(conf, option string) string {
291	re := regexp.MustCompile(`(?m)^#\s*(` + regexp.QuoteMeta(option) + `.*)$`)
292	return re.ReplaceAllString(conf, "$1")
293}
294
295func (h *PostInstallHandler) verifyInstallation(mountPoint string, encrypted bool) []string {
296	warnings := []string{}
297	checks := []struct{ path, name string }{
298		{filepath.Join(mountPoint, "etc", "fstab"), "fstab"},
299		{filepath.Join(mountPoint, "boot", "limine.conf"), "limine.conf"},
300		{filepath.Join(mountPoint, "boot", "EFI", "BOOT", "BOOTX64.EFI"), "EFI boot file"},
301	}
302	if encrypted {
303		checks = append(checks, struct{ path, name string }{filepath.Join(mountPoint, "etc", "crypttab"), "crypttab"})
304	}
305	for _, c := range checks {
306		if _, err := h.fs.Stat(c.path); err != nil {
307			warnings = append(warnings, fmt.Sprintf("missing %s: %s", c.name, c.path))
308		}
309	}
310	return warnings
311}
312
313func (h *PostInstallHandler) downloadTemplate(path string) ([]byte, error) {
314	url := fmt.Sprintf("%s/%s", h.rawURL, path)
315	h.logger.Info("Downloading template", "url", url)
316	resp, err := h.httpClient.Get(url)
317	if err != nil {
318		return nil, err
319	}
320	defer func() {
321		if closeErr := resp.Close(); closeErr != nil {
322			h.logger.Warn("Failed to close HTTP response", "error", closeErr)
323		}
324	}()
325	if resp.StatusCode() < 200 || resp.StatusCode() > 299 {
326		return nil, fmt.Errorf("unexpected status %d from %s", resp.StatusCode(), url)
327	}
328	return resp.Body(), nil
329}

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