From 5ddda5302a12e98fbf7f0ff5fce527c53265a324 Mon Sep 17 00:00:00 2001 From: Daniel Maksymilian Syrnicki Date: Tue, 25 Aug 2026 15:18:39 +0200 Subject: [PATCH] fix(installer): release the target disk before archinstall partitions it The live ISO auto-activates LVM/md/swap found on attached drives. The first hardware bench's SSD still carried a Proxmox VE 'pve' VG, so six device-mapper nodes sat on sda3 and archinstall died at 12 % with 'unable to inform the kernel of the change ... in use'. install_run now calls diskprep.release_disk() first: swapoff/umount leaves-first, dmsetup remove / mdadm --stop for every dm/md node stacked on the disk (retrying until the stack is gone), wipefs -a on partitions and disk, blockdev --rereadpt. Steps are logged at the top of the install log (spawn_archinstall opens it in append mode now). The progress page names this failure explicitly if it still happens. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MScAinbyMdeNc7H2BZdnnG --- CHANGELOG.md | 11 +++ tests/test_diskprep.py | 146 +++++++++++++++++++++++++++++++++++++++ webinstaller/app.py | 30 ++++++-- webinstaller/diskprep.py | 125 +++++++++++++++++++++++++++++++++ 4 files changed, 308 insertions(+), 4 deletions(-) create mode 100644 tests/test_diskprep.py create mode 100644 webinstaller/diskprep.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ef26bb..863a3c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r ### Fixed +- **Installer frees the target disk before partitioning.** The live ISO + auto-activates whatever an attached drive carries (LVM volume groups, md + arrays, swap) — the first hardware bench's SSD still had a Proxmox VE + `pve` VG on it — and archinstall then failed at 12 % with `unable to + inform the kernel of the change, probably because it/they are in use`. + `install_run` now runs `diskprep.release_disk()` first: swapoff/umount + leaves-first, `dmsetup remove` / `mdadm --stop` for every dm/md node + stacked on the disk, `wipefs -a` on partitions + disk, then + `blockdev --rereadpt`. Every step is written to the top of the install + log. The progress page also names this failure explicitly instead of a + bare "hit a snag" if it does still happen. - **Console banner always shows the IP fallback.** On both the live ISO and the installed system the `/etc/issue` welcome was written once, after `network-online.target`, and simply omitted the `http://` line when no diff --git a/tests/test_diskprep.py b/tests/test_diskprep.py new file mode 100644 index 0000000..97545f9 --- /dev/null +++ b/tests/test_diskprep.py @@ -0,0 +1,146 @@ +"""release_disk() against a recorded `lsblk -P` picture of the first +hardware bench: an SSD with an old Proxmox VE `pve` volume group stacked on +sda3 (swap, root, thin pool). Commands are captured through the injected +runner — nothing touches real devices.""" + +import subprocess +from pathlib import Path + +import pytest +from diskprep import release_disk + +# What the bench looked like before cleanup (lsblk -lnP -o NAME,TYPE,MOUNTPOINTS /dev/sda). +BENCH_BEFORE = """NAME="sda" TYPE="disk" MOUNTPOINTS="" +NAME="sda1" TYPE="part" MOUNTPOINTS="" +NAME="sda3" TYPE="part" MOUNTPOINTS="" +NAME="pve-swap" TYPE="lvm" MOUNTPOINTS="[SWAP]" +NAME="pve-root" TYPE="lvm" MOUNTPOINTS="/mnt/old" +NAME="pve-data_tmeta" TYPE="lvm" MOUNTPOINTS="" +NAME="pve-data-tpool" TYPE="lvm" MOUNTPOINTS="" +NAME="pve-data" TYPE="lvm" MOUNTPOINTS="" +NAME="pve-data_tdata" TYPE="lvm" MOUNTPOINTS="" +NAME="pve-data-tpool" TYPE="lvm" MOUNTPOINTS="" +NAME="pve-data" TYPE="lvm" MOUNTPOINTS="" +""" + +BENCH_CLEAN = """NAME="sda" TYPE="disk" MOUNTPOINTS="" +NAME="sda1" TYPE="part" MOUNTPOINTS="" +NAME="sda3" TYPE="part" MOUNTPOINTS="" +""" + +EMPTY = """NAME="sda" TYPE="disk" MOUNTPOINTS="" +""" + + +class FakeRunner: + """Answers lsblk from a script of snapshots (one per call, last one + sticks) and records everything else.""" + + def __init__(self, lsblk_outputs): + self.lsblk_outputs = list(lsblk_outputs) + self.calls = [] + + def __call__(self, cmd, capture_output=True, text=True): + if cmd[0] == "lsblk": + if len(self.lsblk_outputs) > 1: + out = self.lsblk_outputs.pop(0) + else: + out = self.lsblk_outputs[0] + return subprocess.CompletedProcess(cmd, 0, out, "") + self.calls.append(cmd) + return subprocess.CompletedProcess(cmd, 0, "", "") + + +def test_release_tears_down_lvm_stack_leaves_first(): + # lsblk #1 (inventory) and #2 (first removal pass) see the full stack; + # everything after that is clean — the dm removals "worked". + runner = FakeRunner([BENCH_BEFORE, BENCH_BEFORE, BENCH_CLEAN]) + lines = release_disk("/dev/sda", run=runner) + calls = runner.calls + + assert ["swapoff", "/dev/pve-swap"] in calls + assert ["umount", "--all-targets", "/dev/pve-root"] in calls + removed = [c[2] for c in calls if c[:2] == ["dmsetup", "remove"]] + # Thin LV before its pool, pool before tmeta/tdata. + assert removed.index("pve-data") < removed.index("pve-data-tpool") + assert removed.index("pve-data-tpool") < removed.index("pve-data_tmeta") + assert set(removed) == { + "pve-swap", + "pve-root", + "pve-data_tmeta", + "pve-data-tpool", + "pve-data", + "pve-data_tdata", + } + assert ["wipefs", "-a", "/dev/sda3"] in calls + assert ["wipefs", "-a", "/dev/sda1"] in calls + assert ["wipefs", "-a", "/dev/sda"] in calls + assert calls[-1] == ["blockdev", "--rereadpt", "/dev/sda"] + assert not any("WARNING" in line for line in lines) + assert lines[0].startswith("Releasing /dev/sda") + + +def test_release_warns_when_stack_survives(): + runner = FakeRunner([BENCH_BEFORE]) # never becomes clean + lines = release_disk("/dev/sda", run=runner) + assert any("WARNING: still in use" in line for line in lines) + # Still tries to give archinstall a chance. + assert ["blockdev", "--rereadpt", "/dev/sda"] in runner.calls + + +def test_release_empty_disk_only_wipes_and_rereads(): + runner = FakeRunner([EMPTY]) + lines = release_disk("sda", run=runner) # bare name is accepted too + assert runner.calls == [["wipefs", "-a", "/dev/sda"], ["blockdev", "--rereadpt", "/dev/sda"]] + assert any("nothing stacked" in line for line in lines) + + +def test_release_survives_missing_tools(): + def runner(cmd, capture_output=True, text=True): + if cmd[0] == "lsblk": + return subprocess.CompletedProcess(cmd, 0, BENCH_BEFORE, "") + raise FileNotFoundError(cmd[0]) + + lines = release_disk("/dev/sda", run=runner) + assert any("command not found" in line for line in lines) + + +@pytest.fixture +def app_module(monkeypatch, tmp_path): + import app as app_module + + monkeypatch.setattr(app_module, "INSTALL_LOG", tmp_path / "install.log") + monkeypatch.setattr(app_module, "STATE_DIR", tmp_path / "state") + return app_module + + +def test_install_run_releases_disk_before_spawning(app_module, monkeypatch): + order = [] + + def fake_release(disk): + order.append(("release", disk)) + return ["Releasing X"] + + monkeypatch.setattr(app_module, "release_disk", fake_release) + monkeypatch.setattr(app_module, "spawn_archinstall", lambda *a: order.append(("spawn",))) + monkeypatch.setattr(app_module, "build_archinstall_config", lambda s: {}) + monkeypatch.setattr(app_module, "build_archinstall_creds", lambda s: {}) + monkeypatch.delenv("FURTKA_DRY_RUN", raising=False) + app_module.settings.update(boot_drive="/dev/sda", username="u", password="pw12345678") + + client = app_module.app.test_client() + resp = client.post("/install/run") + + assert resp.status_code == 302 + assert order == [("release", "/dev/sda"), ("spawn",)] + assert Path(app_module.INSTALL_LOG).read_text().startswith("Releasing X") + + +def test_progress_names_disk_in_use_failure(app_module): + log = ( + "Traceback (most recent call last):\n" + "_ped.IOException: ... unable to inform the kernel of the change ..." + ) + progress = app_module.parse_install_progress(log) + assert progress["status"] == "error" + assert "still in use" in progress["phase"] diff --git a/webinstaller/app.py b/webinstaller/app.py index d19fcf1..7210ab2 100644 --- a/webinstaller/app.py +++ b/webinstaller/app.py @@ -11,6 +11,7 @@ import sys from datetime import UTC from pathlib import Path +from diskprep import release_disk from drives import list_scored_devices from flask import Flask, jsonify, redirect, render_template, request, url_for @@ -96,6 +97,16 @@ PROGRESS_PHASES = [ PROGRESS_ERROR_MARKERS = ("Traceback (most recent call last)", "archinstall: error:") +# Known failure signatures → a phase label that tells the user what to do +# instead of a bare "open Show details". Checked in order; first hit wins. +PROGRESS_ERROR_HINTS = [ + ( + "unable to inform the kernel of the change", + "Installation failed — the disk was still in use (old LVM/RAID/swap " + "from a previous system). Reboot the installer and try again", + ), +] + def parse_install_progress(log): percent = 2 @@ -110,6 +121,10 @@ def parse_install_progress(log): elif any(m in log for m in PROGRESS_ERROR_MARKERS): status = "error" phase = "Installation failed — open Show details below" + for marker, hint in PROGRESS_ERROR_HINTS: + if marker in log: + phase = hint + break else: status = "running" @@ -264,6 +279,9 @@ _FURTKA_UNITS = ( "furtka-status.service", "furtka-status.timer", "furtka-welcome.service", + # Re-runs the welcome banner every few seconds so the console always + # shows the current IP fallback (or a "no IP yet" hint). + "furtka-welcome.timer", # Daily apps-catalog pull. Timer drives the service; the .service itself # is oneshot and also callable ad-hoc via `furtka catalog sync`. "furtka-catalog-sync.service", @@ -279,9 +297,6 @@ def _resource_manager_commands(): file isn't present (dev box without an ISO build), returns [] so the rest of the install still works — the resource manager just won't be installed, and nothing else on the system references furtka-* units. - # Re-runs the welcome banner every few seconds so the console always - # shows the current IP fallback (or a "no IP yet" hint). - "furtka-welcome.timer", """ if not RESOURCE_MANAGER_PAYLOAD.exists(): print( @@ -537,7 +552,9 @@ def write_install_files(s, state_dir): def spawn_archinstall(config_path, creds_path, log_path): - log_fh = open(log_path, "wb") + # Append: install_run() already truncated the log and may have written + # the disk-release lines we want to keep visible above archinstall's own. + log_fh = open(log_path, "ab") return subprocess.Popen( [ "archinstall", @@ -609,6 +626,11 @@ def install_run(): f"--creds {creds_path} --silent\n" ) else: + # Free the target disk first: udev auto-activates old LVM/RAID/swap + # on the live ISO, and archinstall can't re-read a partition table + # while those hold the partitions open (first hardware bench, 26.19). + with INSTALL_LOG.open("a") as fh: + fh.write("\n".join(release_disk(settings["boot_drive"])) + "\n\n") spawn_archinstall(config_path, creds_path, INSTALL_LOG) return redirect(url_for("install_log_view")) diff --git a/webinstaller/diskprep.py b/webinstaller/diskprep.py new file mode 100644 index 0000000..fa01f3c --- /dev/null +++ b/webinstaller/diskprep.py @@ -0,0 +1,125 @@ +"""Release a target disk before handing it to archinstall. + +The live ISO ships lvm2, mdadm, dmraid and cryptsetup (releng defaults), and +udev happily auto-activates whatever it finds on attached disks at boot. A +drive that used to live in another machine — the first hardware bench had an +old Proxmox VE install on its SSD — therefore comes up with device-mapper +volumes (or swap, or md arrays) stacked on top of its partitions. archinstall +then writes the new partition table fine but `BLKRRPART` fails with + + Partition(s) 2, 3 on /dev/sda have been written, but we have been unable + to inform the kernel of the change, probably because it/they are in use. + +and the install dies at 12 %. Nothing on a fresh VM ever triggers this, +which is why it survived every smoke run. + +`release_disk()` tears that stack down leaves-first (swapoff, umount, dmsetup +remove / mdadm --stop), wipes signatures and asks the kernel to re-read the +partition table. Every step is logged so the install log shows what was +done. All commands are best-effort: the point is to leave archinstall a +disk the kernel can re-partition, not to be a full disk-management tool. +""" + +import re +import subprocess + +_PAIR_RE = re.compile(r'(\w+)="([^"]*)"') + + +def _run(cmd, run): + try: + result = run(cmd, capture_output=True, text=True) + except FileNotFoundError: + return 127, f"{cmd[0]}: command not found" + out = ((result.stdout or "") + (result.stderr or "")).strip() + return result.returncode, out + + +def _parse_pairs(output): + """Parse `lsblk -P` key="value" lines into dicts.""" + rows = [] + for line in output.splitlines(): + row = {} + for match in _PAIR_RE.finditer(line): + row[match.group(1)] = match.group(2) + if row.get("NAME"): + rows.append(row) + return rows + + +def _children(disk, run): + """Everything stacked on `disk`, in lsblk tree order, disk itself excluded.""" + rc, out = _run(["lsblk", "-lnP", "-o", "NAME,TYPE,MOUNTPOINTS", disk], run) + if rc != 0: + return [] + rows = _parse_pairs(out) + seen = set() + children = [] + for row in rows: + if row.get("TYPE") == "disk" or row["NAME"] in seen: + continue + seen.add(row["NAME"]) + children.append(row) + return children + + +def release_disk(disk, run=subprocess.run): + """Free `disk` (e.g. "/dev/sda") of anything that would block a + partition-table reload. Returns the log lines describing what happened.""" + if not disk.startswith("/dev/"): + disk = f"/dev/{disk}" + lines = [f"Releasing {disk} before install…"] + + children = _children(disk, run) + if not children: + lines.append(" nothing stacked on the disk — good.") + else: + lines.append(" found: " + ", ".join(f"{c['NAME']} ({c['TYPE']})" for c in children)) + + # Leaves first: mountpoints and swap sit on the outermost devices. + for child in reversed(children): + dev = f"/dev/{child['NAME']}" + mounts = child.get("MOUNTPOINTS", "") + if "[SWAP]" in mounts: + rc, out = _run(["swapoff", dev], run) + lines.append(f" swapoff {dev}: {'ok' if rc == 0 else out}") + elif mounts: + rc, out = _run(["umount", "--all-targets", dev], run) + lines.append(f" umount {dev}: {'ok' if rc == 0 else out}") + + # Device-mapper (LVM, thin pools, LUKS) and md stacks. Thin pools need + # their thin LVs gone before the pool goes, and lsblk lists shared nodes + # more than once, so go leaves-first and retry a couple of passes until + # nothing dm/md-shaped is left. + for _ in range(3): + stacked = [c for c in reversed(_children(disk, run)) if c["TYPE"] != "part"] + if not stacked: + break + for child in stacked: + name, typ = child["NAME"], child["TYPE"] + if typ.startswith("raid") or name.startswith("md"): + cmd = ["mdadm", "--stop", f"/dev/{name}"] + else: + cmd = ["dmsetup", "remove", name] + rc, out = _run(cmd, run) + lines.append(f" {' '.join(cmd)}: {'ok' if rc == 0 else out}") + + leftovers = [c["NAME"] for c in _children(disk, run) if c["TYPE"] != "part"] + if leftovers: + lines.append( + " WARNING: still in use after cleanup: " + + ", ".join(leftovers) + + " — the install may fail to re-read the partition table." + ) + + # Old signatures (LVM PV headers, RAID superblocks) would otherwise be + # re-activated by udev the moment the kernel re-reads the table. + for child in reversed(_children(disk, run)): + if child["TYPE"] == "part": + rc, out = _run(["wipefs", "-a", f"/dev/{child['NAME']}"], run) + lines.append(f" wipefs /dev/{child['NAME']}: {'ok' if rc == 0 else out}") + rc, out = _run(["wipefs", "-a", disk], run) + lines.append(f" wipefs {disk}: {'ok' if rc == 0 else out}") + rc, out = _run(["blockdev", "--rereadpt", disk], run) + lines.append(f" re-read partition table: {'ok' if rc == 0 else out}") + return lines