From 2c07b0cb39bf86108ffc67fc45f5b2a273616331 Mon Sep 17 00:00:00 2001 From: Adam Hall Date: Tue, 30 Jun 2026 17:16:41 +0930 Subject: [PATCH 1/6] feat(install): add chroot-aware Runner + mount/finalize stages (Phase A round 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for migrating Phase B customization into the Phase A chroot so first boot lands on an already-set-up system. - run.Runner: ChrootRoot/AsUser target — when set, Cmd/Root/Shell/RootShell transparently wrap in arch-chroot (Root as root, Cmd as the user via `sudo -iu --`). Host path stays byte-identical. Adds chroot-safe Has/PathExists probes for Round 2 to replace host LookPath/Stat. - archinstall postInstall now leaves the target mounted; extracted ensureTargetMounted (idempotent)/unmountTarget/isMounted. - New Install-phase stages bracket the chroot: mount(20) opens it and points the Runner at /mnt; finalize(99) stages the Phase B binary+config and unmounts. Phase A: preflight(0) archinstall(10) mount(20) finalize(99). Co-Authored-By: Claude Opus 4.8 --- internal/run/run.go | 149 ++++++++++++++++++++++++++------- internal/run/run_test.go | 63 ++++++++++++++ internal/stages/archinstall.go | 101 +++++++++++++++------- internal/stages/finalize.go | 37 ++++++++ internal/stages/mount.go | 36 ++++++++ internal/stages/stages_test.go | 29 ++++++- 6 files changed, 348 insertions(+), 67 deletions(-) create mode 100644 internal/stages/finalize.go create mode 100644 internal/stages/mount.go diff --git a/internal/run/run.go b/internal/run/run.go index fb63d4e..52466bb 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -18,6 +18,15 @@ 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) + // ChrootRoot, when set, routes every command through `arch-chroot ` + // so a stage's existing Cmd/Root/Shell calls execute inside the installed target + // (Phase A customization). AsUser, when set alongside it, runs *unprivileged* + // commands (Cmd/Shell) as that user inside the target via `sudo -iu` — root→user + // needs no password and -i supplies the user's login env. Both are empty in the + // normal (host) execution path, where behaviour is byte-identical to before. + ChrootRoot string + AsUser string + // 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 @@ -46,8 +55,17 @@ func (r *Runner) prepare(cmd *exec.Cmd) { // Cmd runs a program with args, streaming its output (we deliberately do not // hide it behind a spinner so installer progress and errors stay visible). In -// dry-run it only logs and records. +// dry-run it only logs and records. With a chroot target set it runs inside the +// target (as AsUser when set) — see chrootWrap. func (r *Runner) Cmd(name string, args ...string) error { + name, args = r.chrootWrap(false, name, args) + return r.exec(name, args) +} + +// exec records and runs a fully-resolved command — no privilege or chroot wrapping +// is applied here. It is the shared tail of Cmd/Root/Chroot once that wrapping has +// been decided, so the recorded `.Plan` line is exactly what executes. +func (r *Runner) exec(name string, args []string) error { line := strings.TrimSpace(name + " " + strings.Join(args, " ")) r.record(line) ui.Step("%s", line) @@ -63,32 +81,77 @@ func (r *Runner) Cmd(name string, args ...string) error { return 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). +// chrootWrap prefixes a command with `arch-chroot ` when a chroot +// target is set. For an unprivileged command (priv=false) with AsUser set it also +// runs as that user inside the target via `sudo -iu --` (root→user needs no +// password; -i gives the login env). With no chroot target it returns name/args +// unchanged — Phase A is already root, so no live-ISO sudo is added here (Root +// handles the host sudo path separately). +func (r *Runner) chrootWrap(priv bool, name string, args []string) (string, []string) { + if r.ChrootRoot == "" { + return name, args + } + pre := []string{r.ChrootRoot} + if !priv && r.AsUser != "" { + pre = append(pre, "sudo", "-iu", r.AsUser, "--") + } + pre = append(pre, name) + return "arch-chroot", append(pre, args...) +} + +// Root runs a command with root privileges: inside the chroot target via +// arch-chroot when one is set (Phase A customization, already root), via sudo when +// running as the user (Phase B), or directly when already root with no chroot. func (r *Runner) Root(name string, args ...string) error { + if r.ChrootRoot != "" { + name, args = r.chrootWrap(true, name, args) + return r.exec(name, args) + } if r.Sudo { - return r.Cmd("sudo", append([]string{name}, args...)...) + return r.exec("sudo", append([]string{name}, args...)) } - return r.Cmd(name, args...) + return r.exec(name, args) } -// RootShell runs a shell script with root privileges through `bash -c`: via -// `sudo bash -c