From b6ddf6e1ffd7e0f0d766113f39660969495f6103 Mon Sep 17 00:00:00 2001 From: Adam Hall Date: Mon, 22 Jun 2026 19:36:21 +0930 Subject: [PATCH 1/5] refactor(archinstall): extract singleDiskRoot shared by plain/btrfs builders plainBuilder.build and btrfsBuilder.build were ~90% duplicated: probe disk1 geometry, build the ESP partition, optionally append a linux-swap partition, compute the remaining root bytes, and append the root partition. Only the root partition's fs_type, mount options and btrfs subvolume list differed. Extract singleDiskRoot(esp, swap, espBytes, geom, rootSpec) which builds the ESP + optional swap prefix and the root partition, taking the three varying fields via a small rootSpec struct. The newObjID() call order (ESP, swap, root) is preserved, so golden renders stay byte-identical and the archinstall golden tests pass without -update. Co-Authored-By: Claude Opus 4.8 --- internal/archinstall/archinstall.go | 97 ++++++++++++++--------------- 1 file changed, 47 insertions(+), 50 deletions(-) diff --git a/internal/archinstall/archinstall.go b/internal/archinstall/archinstall.go index 0c21ece..c26c678 100644 --- a/internal/archinstall/archinstall.go +++ b/internal/archinstall/archinstall.go @@ -575,22 +575,51 @@ type plainBuilder struct { } func (b *plainBuilder) build(geom Geometry) ([]Device, *LvmConfiguration, error) { - disk1 := b.esp.Device - espBytes := b.espBytes + rootFs := b.plain.Filesystem + devices, err := singleDiskRoot(b.esp, b.swap, b.espBytes, geom, rootSpec{ + fsType: rootFs, + mountOptions: []string{}, + btrfs: []any{}, + }) + if err != nil { + return nil, nil, err + } + return devices, nil, nil +} + +// rootSpec describes the per-layout root partition that follows the shared ESP + +// optional swap prefix on disk 1. Only these three fields differ between the +// plain and btrfs layouts; everything else (Status/Type/Start/Size/Mountpoint at +// "/"/Flags) is identical and supplied by singleDiskRoot. +type rootSpec struct { + fsType string + mountOptions []string + btrfs []any +} + +// singleDiskRoot builds the disk-1 layout shared by the plain and btrfs builders: +// the ESP partition, an optional linux-swap partition (when swap.type is +// "partition", sized from swap.size), and a root partition consuming the rest of +// disk 1. The root partition's fs_type, mount options and btrfs subvolume list +// come from spec; everything else is fixed. The newObjID() call order is ESP, +// swap (if present), then root — matching both original builders so golden renders +// stay byte-identical. +func singleDiskRoot(esp config.ESPConfig, swap config.SwapConfig, espBytes uint64, geom Geometry, spec rootSpec) ([]Device, error) { + disk1 := esp.Device disk1Total, ok := geom[disk1] if !ok || disk1Total == 0 { - return nil, nil, fmt.Errorf("no geometry for disk 1 (%s)", disk1) + return nil, fmt.Errorf("no geometry for disk 1 (%s)", disk1) } parts := []Partition{espPartition(espBytes)} offset := startOffset + espBytes // Optional swap partition (plain/btrfs only). Sized from swap.size. - if b.swap.EffectiveType() == "partition" { - swapBytes, err := parseSize(b.swap.Size) + if swap.EffectiveType() == "partition" { + swapBytes, err := parseSize(swap.Size) if err != nil { - return nil, nil, fmt.Errorf("swap size: %w", err) + return nil, fmt.Errorf("swap size: %w", err) } swapFs := "linux-swap" parts = append(parts, Partition{ @@ -603,20 +632,20 @@ func (b *plainBuilder) build(geom Geometry) ([]Device, *LvmConfiguration, error) used := offset + endReserve if disk1Total <= used { - return nil, nil, fmt.Errorf("disk 1 (%s, %d bytes) too small for ESP+swap (%d bytes)", disk1, disk1Total, used) + return nil, fmt.Errorf("disk 1 (%s, %d bytes) too small for ESP+swap (%d bytes)", disk1, disk1Total, used) } rootBytes := roundDownMiB(disk1Total - used) root := "/" - rootFs := b.plain.Filesystem + rootFs := spec.fsType parts = append(parts, Partition{ ObjID: newObjID(), Status: "create", Type: "primary", Start: bytes(offset), Size: bytes(rootBytes), FsType: &rootFs, Mountpoint: &root, - MountOptions: []string{}, Flags: []string{}, Btrfs: []any{}, + MountOptions: spec.mountOptions, Flags: []string{}, Btrfs: spec.btrfs, }) - return []Device{{Device: disk1, Wipe: true, Partitions: parts}}, nil, nil + return []Device{{Device: disk1, Wipe: true, Partitions: parts}}, nil } // --- btrfs layout ----------------------------------------------------------- @@ -642,39 +671,6 @@ type btrfsBuilder struct { } func (b *btrfsBuilder) build(geom Geometry) ([]Device, *LvmConfiguration, error) { - disk1 := b.esp.Device - espBytes := b.espBytes - - disk1Total, ok := geom[disk1] - if !ok || disk1Total == 0 { - return nil, nil, fmt.Errorf("no geometry for disk 1 (%s)", disk1) - } - - parts := []Partition{espPartition(espBytes)} - offset := startOffset + espBytes - - if b.swap.EffectiveType() == "partition" { - swapBytes, err := parseSize(b.swap.Size) - if err != nil { - return nil, nil, fmt.Errorf("swap size: %w", err) - } - swapFs := "linux-swap" - parts = append(parts, Partition{ - ObjID: newObjID(), Status: "create", Type: "primary", - Start: bytes(offset), Size: bytes(swapBytes), - FsType: &swapFs, MountOptions: []string{}, Flags: []string{"swap"}, Btrfs: []any{}, - }) - offset += swapBytes - } - - used := offset + endReserve - if disk1Total <= used { - return nil, nil, fmt.Errorf("disk 1 (%s, %d bytes) too small for ESP+swap (%d bytes)", disk1, disk1Total, used) - } - rootBytes := roundDownMiB(disk1Total - used) - - root := "/" - btrfsFs := "btrfs" mountOpts := []string{} if b.btrfs.Compress != "" { mountOpts = append(mountOpts, "compress="+b.btrfs.Compress) @@ -687,14 +683,15 @@ func (b *btrfsBuilder) build(geom Geometry) ([]Device, *LvmConfiguration, error) subvols = append(subvols, BtrfsSubvolume{Name: sv.Name, Mountpoint: &mp}) } - parts = append(parts, Partition{ - ObjID: newObjID(), Status: "create", Type: "primary", - Start: bytes(offset), Size: bytes(rootBytes), - FsType: &btrfsFs, Mountpoint: &root, - MountOptions: mountOpts, Flags: []string{}, Btrfs: subvols, + devices, err := singleDiskRoot(b.esp, b.swap, b.espBytes, geom, rootSpec{ + fsType: "btrfs", + mountOptions: mountOpts, + btrfs: subvols, }) - - return []Device{{Device: disk1, Wipe: true, Partitions: parts}}, nil, nil + if err != nil { + return nil, nil, err + } + return devices, nil, nil } // --- helpers ---------------------------------------------------------------- From b11d4690d85f43ef8cd6d7d86cfb8129da8ca028 Mon Sep 17 00:00:00 2001 From: Adam Hall Date: Mon, 22 Jun 2026 19:39:17 +0930 Subject: [PATCH 2/5] refactor(run): drop reverted-TUI dead code; unify root privilege via RootShell/TryRoot Issue #8: delete Capture (zero callers), the Out field + outWriter helper (fed only the reverted TUI viewport), wiring Cmd/Shell straight to os.Stdout/os.Stderr. Drop now-unused bytes/io imports. Add run_test.go covering the dry-run recording seam for Cmd/Shell/Root(Sudo on+off)/Try. Issue #6: add Runner.RootShell (run a shell pipeline as root: sudo bash -c in Phase B, bash -c in Phase A) and Runner.TryRoot (best-effort Root). Convert the snapper, grub-theme, and kernel-cmdline shell snippets that hardcoded inner sudo to RootShell with the inner sudo removed, and snapper set-config to TryRoot. The vinceliuice install.sh keeps inner sudo (cloneBuild runs as the user). Equivalent root behaviour in Phase B; updated the affected plan assertions. Co-Authored-By: Claude Opus 4.8 --- internal/run/run.go | 73 ++++++++---------- internal/run/run_test.go | 111 +++++++++++++++++++++++++++ internal/stages/bootloader_test.go | 4 +- internal/stages/grubtheme.go | 15 ++-- internal/stages/helpers.go | 11 +-- internal/stages/snapper.go | 6 +- internal/stages/snapper_test.go | 15 ++-- internal/stages/systemd_boot_test.go | 15 ++-- 8 files changed, 181 insertions(+), 69 deletions(-) create mode 100644 internal/run/run_test.go diff --git a/internal/run/run.go b/internal/run/run.go index 2d62ce1..fb63d4e 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -6,9 +6,7 @@ package run import ( - "bytes" "fmt" - "io" "os" "os/exec" "strings" @@ -20,10 +18,6 @@ type Runner struct { DryRun bool // print/record commands instead of executing them Sudo bool // prefix privileged commands with sudo (Phase B as user); false in Phase A (already root) - // Out, when non-nil, receives BOTH stdout and stderr of every executed - // command. It is the hook for a TUI viewport that captures streamed output; - // when nil, output streams to os.Stdout/os.Stderr exactly as before. - Out io.Writer // Env, when non-empty, is layered (key=value) on top of the inherited process // environment for every executed command. Dir, when set, is the working dir. Env map[string]string @@ -36,15 +30,6 @@ type Runner struct { func (r *Runner) record(line string) { r.Plan = append(r.Plan, line) } -// outWriter returns r.Out when set, else the given default stream, so a nil Out -// preserves the original os.Stdout/os.Stderr wiring exactly. -func (r *Runner) outWriter(def io.Writer) io.Writer { - if r.Out != nil { - return r.Out - } - return def -} - // prepare applies the Runner's Env and Dir (if any) to a command before it runs. func (r *Runner) prepare(cmd *exec.Cmd) { if len(r.Env) > 0 { @@ -70,7 +55,7 @@ func (r *Runner) Cmd(name string, args ...string) error { return nil } cmd := exec.Command(name, args...) - cmd.Stdout, cmd.Stderr, cmd.Stdin = r.outWriter(os.Stdout), r.outWriter(os.Stderr), os.Stdin + cmd.Stdout, cmd.Stderr, cmd.Stdin = os.Stdout, os.Stderr, os.Stdin r.prepare(cmd) if err := cmd.Run(); err != nil { return fmt.Errorf("%s: %w", name, err) @@ -78,28 +63,6 @@ func (r *Runner) Cmd(name string, args ...string) error { return nil } -// Capture runs name+args and returns the command's stdout as a string, recording -// it in .Plan exactly like Cmd. stderr still streams (to Out or os.Stderr). In -// dry-run it records and returns "" with a nil error. Use this for the rare -// state-querying command whose output a stage needs, rather than dropping to -// os/exec directly (which would bypass dry-run and the recorded plan). -func (r *Runner) Capture(name string, args ...string) (string, error) { - line := strings.TrimSpace(name + " " + strings.Join(args, " ")) - r.record(line) - ui.Step("%s", line) - if r.DryRun { - return "", nil - } - cmd := exec.Command(name, args...) - var out bytes.Buffer - cmd.Stdout, cmd.Stderr, cmd.Stdin = &out, r.outWriter(os.Stderr), os.Stdin - r.prepare(cmd) - if err := cmd.Run(); err != nil { - return out.String(), fmt.Errorf("%s: %w", name, err) - } - return out.String(), nil -} - // Root runs a command with root privileges: directly when already root (Phase A, // live ISO) or via sudo otherwise (Phase B, as the user). func (r *Runner) Root(name string, args ...string) error { @@ -109,12 +72,44 @@ func (r *Runner) Root(name string, args ...string) error { return r.Cmd(name, args...) } +// RootShell runs a shell script with root privileges through `bash -c`: via +// `sudo bash -c