furtka/tests/test_diskprep.py
Daniel Maksymilian Syrnicki 5ddda5302a 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MScAinbyMdeNc7H2BZdnnG
2026-08-25 15:18:39 +02:00

146 lines
5.4 KiB
Python

"""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"]