fix(install): survive the live ISO offline and its non-interactive bash

Found by running the unattended install in an offline QEMU VM:
- compgen is missing from nixpkgs' non-interactive bash (the package
  shebang) — replaced with plain glob tests in hardware-db and prewipe
- disko's eval resolves <nixpkgs> via NIX_PATH (a dead channel on the
  ISO) and tried channels.nixos.org — export NIX_PATH to the pinned
  nixpkgs source and point the flake registry at an empty baked file
- lock nomarchy by path (the source the ISO carries) instead of
  git+https: git inputs clone even when narHash is known, which kills
  offline installs; `original` keeps the forge URL so a later
  `nix flake update` re-resolves normally. Works from dirty-tree ISOs too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bernardo Magri
2026-06-10 16:48:33 +01:00
parent 71614fa6ca
commit 479193b10b
5 changed files with 42 additions and 25 deletions

View File

@@ -44,16 +44,19 @@
hardwareModuleNames = pkgs.writeText "nixos-hardware-modules.txt"
(nixpkgs.lib.concatStringsSep "\n"
(builtins.attrNames nixos-hardware.nixosModules));
nixpkgsPath = nixpkgs.outPath;
flakeUrl = "git+${gitUrl}";
# Future updates re-resolve from the forge (original); the install
# itself resolves from the ISO store (locked = path) — fully
# offline, even from a dirty-tree ISO.
originalNode = { type = "git"; url = gitUrl; };
lockedNode = {
type = "git";
url = gitUrl;
rev = self.rev or null; # null on a dirty tree → installer needs network
type = "path";
path = self.outPath;
narHash = self.narHash;
lastModified = self.lastModified or 0;
revCount = self.revCount or 0;
};
rev = self.rev or null;
};
in
{

View File

@@ -27,8 +27,8 @@ def main() -> None:
src_lock, out, locked_json, original_json = sys.argv[1:5]
locked = json.loads(locked_json)
if not locked.get("rev"):
sys.exit("compose-lock: no rev in locked metadata (ISO built from a dirty tree?)")
if not locked.get("narHash"):
sys.exit("compose-lock: no narHash in locked metadata")
with open(src_lock) as f:
upstream = json.load(f)
@@ -50,7 +50,8 @@ def main() -> None:
with open(out, "w") as f:
json.dump({"nodes": nodes, "root": "root", "version": 7}, f, indent=2)
f.write("\n")
print(f"compose-lock: wrote {out} (nomarchy @ {locked['rev'][:12]})")
pin = locked.get("rev", locked["narHash"])[:12]
print(f"compose-lock: wrote {out} (nomarchy @ {pin})")
if __name__ == "__main__":

View File

@@ -18,10 +18,13 @@
, templateDir # templates/downstream (home.nix, theme-state.json)
, nomarchyLock # the distro's flake.lock (for offline lock composition)
, hardwareModuleNames # newline-separated nixos-hardware module names
, nixpkgsPath # pinned nixpkgs source (NIX_PATH for disko's eval)
, flakeUrl # flake-style URL written into the generated flake.nix
, lockedNode # attrset: the flake.lock "locked" node for nomarchy
# (rev = null when the ISO was built from a dirty tree)
, originalNode # attrset: the flake.lock "original" node
# (path-type: resolves from the ISO store, offline)
, originalNode # attrset: the flake.lock "original" node (the forge,
# so `nix flake update` later re-resolves normally)
, rev ? null # informational: rev the ISO was built from
}:
stdenvNoCC.mkDerivation {
@@ -44,6 +47,8 @@ stdenvNoCC.mkDerivation {
install -Dm644 compose-lock.py "$share/compose-lock.py"
install -Dm644 ${nomarchyLock} "$share/flake.lock"
install -Dm644 ${hardwareModuleNames} "$share/hardware-modules.txt"
# Empty flake registry: no network lookups for indirect refs.
echo '{"flakes":[],"version":2}' > "$share/registry.json"
mkdir -p "$share/template"
cp ${templateDir}/home.nix ${templateDir}/theme-state.json "$share/template/"
@@ -55,8 +60,9 @@ stdenvNoCC.mkDerivation {
util-linux gptfdisk parted cryptsetup lvm2 pciutils btrfs-progs
]} \
--set NOMARCHY_INSTALL_SHARE "$share" \
--set NOMARCHY_NIXPKGS ${nixpkgsPath} \
--set NOMARCHY_FLAKE_URL ${lib.escapeShellArg flakeUrl} \
--set NOMARCHY_REV ${lib.escapeShellArg (toString (lockedNode.rev or ""))} \
--set NOMARCHY_REV ${lib.escapeShellArg (toString rev)} \
--set NOMARCHY_LOCKED_JSON ${lib.escapeShellArg (builtins.toJSON lockedNode)} \
--set NOMARCHY_ORIGINAL_JSON ${lib.escapeShellArg (builtins.toJSON originalNode)}

View File

@@ -140,8 +140,10 @@ nomarchy_detect_hw() {
(( intelgpu )) && { echo "MODULE common-gpu-intel"; echo "DETAIL gpu: Intel"; detected=1; }
fi
# Chassis
if compgen -G "/sys/class/power_supply/BAT*" >/dev/null; then
# Chassis (glob test, not compgen — nixpkgs' non-interactive bash
# is built without the completion builtins)
local bats=(/sys/class/power_supply/BAT*)
if [[ -e "${bats[0]}" ]]; then
echo "MODULE common-pc-laptop"
echo "DETAIL chassis: laptop (battery present)"
else

View File

@@ -62,6 +62,12 @@ command -v nixos-install >/dev/null \
grep -q nomarchy-live /etc/hostname 2>/dev/null \
|| warn "This doesn't look like the Nomarchy live ISO; proceeding anyway."
# Offline-proof nix: disko's eval resolves <nixpkgs> (a dead channel on
# the live ISO) and flake commands may consult the global registry —
# point both at what the ISO already carries.
export NIX_PATH="nixpkgs=$NOMARCHY_NIXPKGS"
export NIX_CONFIG="flake-registry = $SHARE/registry.json"
# ─── Disk selection ─────────────────────────────────────────────────────
section "Target disk"
@@ -201,7 +207,7 @@ gum style --border normal --padding "0 2" \
"Timezone: $TIMEZONE" \
"Hardware: ${HW_PROFILES[*]:-none}" \
"Snapshots: snapper timeline on /" \
"Offline: $([[ -n "${NOMARCHY_REV:-}" ]] && echo "yes (pinned ${NOMARCHY_REV:0:12})" || echo "no (will fetch nomarchy over the network)")"
"Source: nomarchy ${NOMARCHY_REV:0:12}${NOMARCHY_REV:+ }$([[ -z "${NOMARCHY_REV:-}" ]] && echo "(dirty tree) ")— pinned into the ISO, no network needed"
if [[ "$UNATTENDED" != "1" ]]; then
typed=$(gum input --placeholder "type the disk name ($(basename "$TARGET_DISK")) to confirm the wipe")
@@ -230,11 +236,11 @@ prewipe() {
cryptsetup close "$name"
done < <(dmsetup ls --target crypt 2>/dev/null)
fi
if compgen -G "${drive}?*" >/dev/null; then
for part in "${drive}"?*; do
[[ -b "$part" ]] && wipefs -af "$part" >/dev/null
done
fi
for part in "${drive}"?*; do
# unmatched glob stays literal; -b filters it out
[[ -b "$part" ]] || continue
wipefs -af "$part" >/dev/null
done
wipefs -af "$drive" >/dev/null
sgdisk --zap-all "$drive" >/dev/null
# 16 MiB covers LUKS2 headers and the first BTRFS superblock —
@@ -363,13 +369,12 @@ $AUTOLOGIN_CONFIG$RESUME_CONFIG
}
EOF
# The flake.lock: offline when the ISO knows what it was built from,
# otherwise resolve over the network.
if [[ -n "${NOMARCHY_REV:-}" ]]; then
python3 "$SHARE/compose-lock.py" "$SHARE/flake.lock" "$FLAKE_DIR/flake.lock" \
"$NOMARCHY_LOCKED_JSON" "$NOMARCHY_ORIGINAL_JSON"
else
warn "ISO built from a dirty tree — resolving nomarchy from its forge (needs network)."
# The flake.lock: composed offline — nomarchy is path-locked to the very
# source the ISO carries (original stays the forge URL, so a later
# `nix flake update` on the installed machine re-resolves normally).
if ! python3 "$SHARE/compose-lock.py" "$SHARE/flake.lock" "$FLAKE_DIR/flake.lock" \
"$NOMARCHY_LOCKED_JSON" "$NOMARCHY_ORIGINAL_JSON"; then
warn "Offline lock composition failed — resolving over the network."
(cd "$FLAKE_DIR" && nix --extra-experimental-features "nix-command flakes" flake lock)
fi