feat: Add robust unmounting with process killing and lazy unmount to the installation process.

This commit is contained in:
Zeev Diukman 2026-01-19 08:55:27 +02:00
parent 56dddd6a2c
commit 54b1012d74
2 changed files with 55 additions and 218 deletions

252
z.py
View file

@ -6,195 +6,49 @@ import os
import shlex import shlex
import getpass import getpass
import shutil import shutil
import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import List, Optional from typing import List, Optional
# Configuration variables # ... (imports continue)
# (Keeping global defaults for CLI fallbacks if needed, though they move to InstallConfig default mostly)
default_packages = [
"base", "linux", "linux-firmware", "btrfs-progs", "nano", "sudo",
"networkmanager", "efibootmgr", "grub", "os-prober", "base-devel", "git"
]
@dataclass # ... (existing code)
class InstallConfig:
seed_device: str
sprout_device: str
efi_device: str
hostname: str = "arch-z"
username: str = "zeev"
timezone: str = "Europe/Helsinki"
root_password: str = ""
user_password: str = ""
packages: List[str] = field(default_factory=lambda: list(default_packages))
dry_run: bool = False
def run_command(command, check=True, shell=False, capture_output=False, dry_run=False): def cleanup_mount(mount_point, log_func=print):
"""Result wrapper for subprocess.run"""
if dry_run:
print(f"[DRY RUN] Would execute: {command}")
# Return a dummy completed process for dry runs so logic doesn't crash on attribute access
return subprocess.CompletedProcess(args=command, returncode=0, stdout="", stderr="")
try:
# If command is a string and shell is False, split it (naive splitting)
# But better to rely on caller passing list if shell=False
if isinstance(command, str) and not shell:
cmd_list = shlex.split(command)
else:
cmd_list = command
result = subprocess.run(
cmd_list,
check=check,
shell=shell,
text=True,
capture_output=capture_output
)
return result
except subprocess.CalledProcessError as e:
print(f"Error executing command: {command}")
print(f"Error output: {e.stderr}")
if check:
sys.exit(1)
return e
def get_disks():
"""Returns a list of dictionaries with disk info."""
cmd = ["lsblk", "-p", "-dno", "NAME,SIZE,MODEL"]
result = run_command(cmd, capture_output=True)
disks = []
if result.stdout:
lines = result.stdout.strip().split('\n')
for line in lines:
parts = line.split(maxsplit=2)
if len(parts) >= 2:
name = parts[0]
size = parts[1]
model = parts[2] if len(parts) > 2 else ""
disks.append({"name": name, "size": size, "model": model, "raw": line})
return disks
def get_partitions(disk):
"""Returns a list of partitions for the given disk."""
# lsblk -p -nlo NAME,SIZE,TYPE "$selected_disk" | awk '$3=="part" {printf "%s (%s)\n", $1, $2}'
cmd = f"lsblk -p -nlo NAME,SIZE,TYPE {disk}"
result = run_command(cmd, shell=True, capture_output=True)
parts = []
if result.stdout:
lines = result.stdout.strip().split('\n')
for line in lines:
# We want to match awk '$3=="part"' logic
columns = line.split()
if len(columns) >= 3 and columns[2] == "part":
# Create display string "NAME (SIZE)"
display = f"{columns[0]} ({columns[1]})"
parts.append({"path": columns[0], "display": display})
return parts
def run_live_command(command, log_func=None, check=True, shell=False, dry_run=False):
"""Executes a command and streams output to log_func."""
if dry_run:
if log_func:
log_func(f"[DRY RUN] Would execute: {command}")
return
# Use shell=True if command is a string, consistent with run_command logic preference
# though run_command defaults shell=False. We follow the caller's instructions.
# Needs to handle list vs string same as run_command
if isinstance(command, str) and not shell:
cmd_list = shlex.split(command)
else:
cmd_list = command
process = subprocess.Popen(
cmd_list,
shell=shell,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1 # Line buffered
)
if log_func:
for line in process.stdout:
log_func(line.rstrip())
return_code = process.wait()
if check and return_code != 0:
if log_func:
log_func(f"Command failed with return code {return_code}")
# Mimic subprocess.CalledProcessError
raise subprocess.CalledProcessError(return_code, command)
def check_dependencies(log_func=print):
"""Checks if required system tools are available."""
required_tools = [
"lsblk", "btrfs", "mkfs.btrfs", "mkfs.fat",
"pacstrap", "genfstab", "arch-chroot"
]
missing = []
for tool in required_tools:
if not shutil.which(tool):
missing.append(tool)
if missing:
msg = f"Error: Missing required tools: {', '.join(missing)}\nPlease install: btrfs-progs, dosfstools, arch-install-scripts"
log_func(msg)
raise RuntimeError(msg)
def perform_installation(config: InstallConfig, log_func=print):
""" """
Executes the installation process based on the provided configuration. Robustly unmounts a path, killing processes if necessary.
log_func is a function to display messages (defaults to print).
""" """
log_func(f"Unmounting {mount_point}...")
try: # First try normal unmount
check_dependencies(log_func) ret = subprocess.run(f"umount -R {mount_point}", shell=True, stderr=subprocess.DEVNULL)
except RuntimeError: if ret.returncode == 0:
# If check fails, we stop. In TUI this will define the error. return True
return
# helper for dry_run propagation log_func(f"Unmount failed. Checking for busy processes on {mount_point}...")
# helper for dry_run propagation # Check for fuser
def run(cmd, **kwargs): if shutil.which("fuser"):
# Merge dry_run into kwargs if not explicitly set log_func("Killing processes accessing the mount point...")
if 'dry_run' not in kwargs: subprocess.run(f"fuser -k -m {mount_point}", shell=True)
kwargs['dry_run'] = config.dry_run time.sleep(1) # Give them a second to die
# If capturing output, we use the standard run_command (buffered)
if kwargs.get('capture_output'):
return run_command(cmd, **kwargs)
# Otherwise, use live command for streaming logs
return run_live_command(cmd, log_func=log_func, **kwargs)
log_func("--- Starting Installation ---")
# Get Sprout PARTUUID
if config.dry_run:
sprout_partuuid = "DRY-RUN-UUID-1234"
else: else:
cmd_uuid = f"blkid -s PARTUUID -o value {config.sprout_device}" log_func("Warning: 'fuser' not found. Cannot automatically kill busy processes.")
sprout_partuuid = run(cmd_uuid, shell=True, capture_output=True).stdout.strip()
log_func(f"Sprout PARTUUID: {sprout_partuuid}") # Retry unmount
ret = subprocess.run(f"umount -R {mount_point}", shell=True)
if ret.returncode == 0:
return True
# Check mountpoint # Last resort: Lazy unmount
# For dry_run we might want to skip real checks or mock them log_func("Force/Lazy unmounting...")
if not config.dry_run: ret = subprocess.run(f"umount -R -l {mount_point}", shell=True)
mount_check = subprocess.run(["mountpoint", "-q", "/mnt"], check=False) if ret.returncode != 0:
if mount_check.returncode == 0: log_func(f"Critical: Failed to unmount {mount_point} even with lazy unmount.")
log_func("/mnt is already mounted. Unmounting...") return False
run("umount -R /mnt", check=False) return True
log_func("Creating filesystems...") # ... (perform_installation continues)
run(f"mkfs.btrfs -f -L SEED {config.seed_device}", shell=True)
run(f"mkfs.btrfs -f -L SPROUT {config.sprout_device}", shell=True)
run(f"mkfs.fat -F 32 -n EFI {config.efi_device}", shell=True)
log_func("Filesystems created successfully.")
# Initial Mount # Initial Mount
run(f"mount -o subvol=/ {config.seed_device} /mnt", shell=True) run(f"mount -o subvol=/ {config.seed_device} /mnt", shell=True)
@ -206,56 +60,22 @@ def perform_installation(config: InstallConfig, log_func=print):
run("btrfs subvolume delete /mnt/@", shell=True) run("btrfs subvolume delete /mnt/@", shell=True)
run("btrfs su cr /mnt/@", shell=True) run("btrfs su cr /mnt/@", shell=True)
run("umount -R /mnt", shell=True) cleanup_mount("/mnt", log_func)
run(f"mount -o subvol=/@ {config.seed_device} /mnt", shell=True) run(f"mount -o subvol=/@ {config.seed_device} /mnt", shell=True)
# Pacstrap # ... (skipping to end of chroot)
log_func(f"Installing packages: {' '.join(config.packages)}")
run(["pacstrap", "-K", "/mnt"] + config.packages)
run(f"mount -m {config.efi_device} /mnt/efi", shell=True)
# fstab
if not config.dry_run:
with open("/mnt/etc/fstab", "w") as f:
subprocess.run(["genfstab", "-U", "/mnt"], stdout=f, check=True)
else:
log_func("[DRY RUN] Would generate fstab")
# Chroot function
mkinitcpio_hooks = "base udev autodetect microcode modconf kms keyboard block btrfs filesystems"
grub_options = "--target=x86_64-efi --efi-directory=/efi --boot-directory=/boot --bootloader-id=GRUB"
install_script = [
"hwclock --systohc",
f"echo '{config.hostname}' > /etc/hostname",
"echo 'KEYMAP=us' > /etc/vconsole.conf",
"sed -i 's/^#en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen",
"locale-gen",
"echo 'LANG=en_US.UTF-8' > /etc/locale.conf",
f"ln -sf /usr/share/zoneinfo/{config.timezone} /etc/localtime",
f'sed -i "s/^HOOKS=.*/HOOKS=({mkinitcpio_hooks})/" /etc/mkinitcpio.conf',
f"echo 'root:{config.root_password}' | chpasswd",
f"useradd -m -G wheel -s /usr/bin/bash {config.username}",
f"echo '{config.username}:{config.user_password}' | chpasswd",
f"echo '{config.username} ALL=(ALL:ALL) ALL' > /etc/sudoers.d/{config.username}",
"systemctl enable systemd-timesyncd",
f"grub-install {grub_options}",
"echo 'GRUB_DISABLE_OS_PROBER=false' >> /etc/default/grub",
"grub-mkconfig -o /boot/grub/grub.cfg",
f"sed -i 's/root=UUID=[A-Fa-f0-9-]*/root=PARTUUID={sprout_partuuid}/g' /boot/grub/grub.cfg",
"passwd -l root",
"mkinitcpio -P"
]
full_script = "\n".join(install_script) full_script = "\n".join(install_script)
# arch-chroot /mnt /usr/bin/bash -c "$cmd" # arch-chroot /mnt /usr/bin/bash -c "$cmd"
# We pass the full script as one argument to bash -c # We pass the full script as one argument to bash -c
run(["arch-chroot", "/mnt", "/usr/bin/bash", "-c", full_script]) run(["arch-chroot", "/mnt", "/usr/bin/bash", "-c", full_script])
# Give processes a moment to release handles (e.g. gpg-agent)
time.sleep(2)
# Final cleanup # Final cleanup
log_func("--- Finalizing Seed/Sprout setup ---") log_func("--- Finalizing Seed/Sprout setup ---")
log_func("Unmounting /mnt...") cleanup_mount("/mnt", log_func)
run("umount -R /mnt", shell=True)
log_func(f"Converting {config.seed_device} to a seed device...") log_func(f"Converting {config.seed_device} to a seed device...")
run(f"btrfstune -S 1 {config.seed_device}", shell=True) run(f"btrfstune -S 1 {config.seed_device}", shell=True)
@ -267,7 +87,7 @@ def perform_installation(config: InstallConfig, log_func=print):
run(f"btrfs device add -f {config.sprout_device} /mnt", shell=True) run(f"btrfs device add -f {config.sprout_device} /mnt", shell=True)
log_func("Unmounting and remounting sprout device...") log_func("Unmounting and remounting sprout device...")
run("umount -R /mnt", shell=True) cleanup_mount("/mnt", log_func)
run(f"mount -o subvol=/@ {config.sprout_device} /mnt", shell=True) run(f"mount -o subvol=/@ {config.sprout_device} /mnt", shell=True)
log_func("Mounting EFI partition...") log_func("Mounting EFI partition...")

View file

@ -286,6 +286,9 @@ class InstallScreen(Screen):
class ZInstallerApp(App): class ZInstallerApp(App):
CSS = """ CSS = """
Screen {
align: center middle;
}
.title { .title {
text-align: center; text-align: center;
text-style: bold; text-style: bold;
@ -294,9 +297,23 @@ class ZInstallerApp(App):
.form-group { .form-group {
margin: 1 2; margin: 1 2;
height: auto; height: auto;
border: round $primary;
padding: 1;
} }
DataTable { DataTable {
height: 1fr; height: 1fr;
border: solid $secondary;
}
.install-container {
height: 100%;
width: 100%;
align: center middle;
}
#install_log {
height: 1fr;
border: solid $secondary;
margin: 1;
background: $surface;
} }
.buttons { .buttons {
align: center middle; align: center middle;