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
This commit is contained in:
parent
0e05bef667
commit
5ddda5302a
4 changed files with 308 additions and 4 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -9,6 +9,17 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r
|
||||||
|
|
||||||
### Fixed
|
### 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
|
- **Console banner always shows the IP fallback.** On both the live ISO and
|
||||||
the installed system the `/etc/issue` welcome was written once, after
|
the installed system the `/etc/issue` welcome was written once, after
|
||||||
`network-online.target`, and simply omitted the `http://<ip>` line when no
|
`network-online.target`, and simply omitted the `http://<ip>` line when no
|
||||||
|
|
|
||||||
146
tests/test_diskprep.py
Normal file
146
tests/test_diskprep.py
Normal file
|
|
@ -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"]
|
||||||
|
|
@ -11,6 +11,7 @@ import sys
|
||||||
from datetime import UTC
|
from datetime import UTC
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from diskprep import release_disk
|
||||||
from drives import list_scored_devices
|
from drives import list_scored_devices
|
||||||
from flask import Flask, jsonify, redirect, render_template, request, url_for
|
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:")
|
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):
|
def parse_install_progress(log):
|
||||||
percent = 2
|
percent = 2
|
||||||
|
|
@ -110,6 +121,10 @@ def parse_install_progress(log):
|
||||||
elif any(m in log for m in PROGRESS_ERROR_MARKERS):
|
elif any(m in log for m in PROGRESS_ERROR_MARKERS):
|
||||||
status = "error"
|
status = "error"
|
||||||
phase = "Installation failed — open Show details below"
|
phase = "Installation failed — open Show details below"
|
||||||
|
for marker, hint in PROGRESS_ERROR_HINTS:
|
||||||
|
if marker in log:
|
||||||
|
phase = hint
|
||||||
|
break
|
||||||
else:
|
else:
|
||||||
status = "running"
|
status = "running"
|
||||||
|
|
||||||
|
|
@ -264,6 +279,9 @@ _FURTKA_UNITS = (
|
||||||
"furtka-status.service",
|
"furtka-status.service",
|
||||||
"furtka-status.timer",
|
"furtka-status.timer",
|
||||||
"furtka-welcome.service",
|
"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
|
# Daily apps-catalog pull. Timer drives the service; the .service itself
|
||||||
# is oneshot and also callable ad-hoc via `furtka catalog sync`.
|
# is oneshot and also callable ad-hoc via `furtka catalog sync`.
|
||||||
"furtka-catalog-sync.service",
|
"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
|
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,
|
of the install still works — the resource manager just won't be installed,
|
||||||
and nothing else on the system references furtka-* units.
|
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():
|
if not RESOURCE_MANAGER_PAYLOAD.exists():
|
||||||
print(
|
print(
|
||||||
|
|
@ -537,7 +552,9 @@ def write_install_files(s, state_dir):
|
||||||
|
|
||||||
|
|
||||||
def spawn_archinstall(config_path, creds_path, log_path):
|
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(
|
return subprocess.Popen(
|
||||||
[
|
[
|
||||||
"archinstall",
|
"archinstall",
|
||||||
|
|
@ -609,6 +626,11 @@ def install_run():
|
||||||
f"--creds {creds_path} --silent\n"
|
f"--creds {creds_path} --silent\n"
|
||||||
)
|
)
|
||||||
else:
|
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)
|
spawn_archinstall(config_path, creds_path, INSTALL_LOG)
|
||||||
return redirect(url_for("install_log_view"))
|
return redirect(url_for("install_log_view"))
|
||||||
|
|
||||||
|
|
|
||||||
125
webinstaller/diskprep.py
Normal file
125
webinstaller/diskprep.py
Normal file
|
|
@ -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
|
||||||
Loading…
Add table
Reference in a new issue