refactor!: port shell scripts to Python package

Bash scripts were untestable and duplicated device/LUKS/mount logic;
the lim/ package centralizes it behind one subprocess wrapper and a
YAML image catalog (single point of truth).

BREAKING CHANGE: scripts/*.sh removed. Use `lim --type <cmd>`; new
types mount/umount/single-boot/raid1-boot/lock/unlock/import/export
replace direct script calls. --extra is deprecated and ignored.

- distributions.yml + lim/catalog.py hold the image catalog (PyYAML)
- pytest suite: 102 tests with mocked subprocess (tests/unit) and a
  250-line max file-length guard (tests/lint)
- ruff strict (select ALL), GitHub Actions CI, Dependabot; Travis gone
- Makefile: install (symlink ~/.local/bin/lim) and test targets
- fixes over bash: SUDO_USER-aware chown, mmcblk/nvme partition paths,
  sha512 checksum support, whole-pipeline failure detection, blkid
  UUID fallback for pre-mounted images, conditional fstab seeding for
  PARTUUID/LABEL images, clean errors for missing binaries
This commit is contained in:
Kevin Veen-Birkenbach
2026-07-14 11:27:49 +02:00
parent c420dd164d
commit ccdef065df
77 changed files with 3402 additions and 1614 deletions

190
lim/image/session.py Normal file
View File

@@ -0,0 +1,190 @@
"""Mount/unmount lifecycle for working on an image on a block device."""
import time
from pathlib import Path
from lim import device as device_module
from lim import runner, ui
from lim.device import Device
from lim.errors import LimError
class ImageSession:
"""Working folder, partition paths and (chroot) mounts for one device.
Call ``destructor()`` in a finally block: it unmounts and removes
everything best-effort, mirroring partially completed setups.
"""
def __init__(self, device: Device) -> None:
self.device = device
self.working_folder: Path | None = None
self.boot_mount_path: Path | None = None
self.root_mount_path: Path | None = None
self.boot_partition_path = device.partition(1)
self.root_partition_path = device.partition(2)
# Overridden by decrypt_root() when the root partition is LUKS.
self.root_mapper_path = self.root_partition_path
self.root_mapper_name: str | None = None
self.boot_partition_uuid: str | None = None
self.root_partition_uuid: str | None = None
def make_working_folder(self) -> None:
# Mount points must live on the root fs; mkdir() fails on collisions.
self.working_folder = Path(f"/tmp/linux-image-manager-{int(time.time())}") # noqa: S108
ui.info(f"Create temporary working folder in {self.working_folder}")
self.working_folder.mkdir(parents=True)
def make_mount_folders(self) -> None:
if self.working_folder is None:
self.make_working_folder()
ui.info("Preparing mount paths...")
self.boot_mount_path = self.working_folder / "boot"
self.root_mount_path = self.working_folder / "root"
self.boot_mount_path.mkdir()
self.root_mount_path.mkdir()
def root_is_luks(self) -> bool:
return device_module.blkid_value(self.root_partition_path, "TYPE") == "crypto_LUKS"
def read_partition_uuids(self) -> None:
"""Fetch partition UUIDs via blkid; derive the LUKS mapper name if unset."""
self.root_partition_uuid = device_module.blkid_value(
self.root_partition_path, "UUID"
)
self.boot_partition_uuid = device_module.blkid_value(
self.boot_partition_path, "UUID"
)
if self.root_mapper_name is None and self.root_is_luks():
# Same deterministic name decrypt_root() would have used.
self.root_mapper_name = f"linux-image-manager-{self.root_partition_uuid}"
self.root_mapper_path = f"/dev/mapper/{self.root_mapper_name}"
def decrypt_root(self) -> None:
if not self.root_is_luks():
return
self.root_partition_uuid = device_module.blkid_value(
self.root_partition_path, "UUID"
)
self.root_mapper_name = f"linux-image-manager-{self.root_partition_uuid}"
self.root_mapper_path = f"/dev/mapper/{self.root_mapper_name}"
ui.info(f"Decrypting of {self.root_partition_path} is neccessary...")
runner.run(
[
"cryptsetup",
"-v",
"luksOpen",
self.root_partition_path,
self.root_mapper_name,
],
sudo=True,
)
def mount_partitions(self) -> None:
if self.boot_mount_path is None:
raise LimError("Mount folders are not prepared yet.")
ui.info("Mount boot and root partition...")
runner.run(
["mount", "-v", self.boot_partition_path, str(self.boot_mount_path)],
sudo=True,
)
runner.run(
["mount", "-v", self.root_mapper_path, str(self.root_mount_path)], sudo=True
)
ui.info("Setting uuid variables...")
self.read_partition_uuids()
ui.info("The following mounts refering this setup exist:")
runner.run(["findmnt", "-R", str(self.working_folder)], check=False)
def mount_chroot_binds(self) -> None:
ui.info("Mount chroot environments...")
root = self.root_mount_path
runner.run(["mount", "--bind", str(self.boot_mount_path), f"{root}/boot"], sudo=True)
runner.run(["mount", "--bind", "/dev", f"{root}/dev"], sudo=True)
runner.run(["mount", "--bind", "/sys", f"{root}/sys"], sudo=True)
runner.run(["mount", "--bind", "/proc", f"{root}/proc"], sudo=True)
runner.run(["mount", "--bind", "/dev/pts", f"{root}/dev/pts"], sudo=True)
def copy_qemu(self) -> None:
ui.info("Copy qemu binary...")
runner.run(
["cp", "-v", "/usr/bin/qemu-arm-static", f"{self.root_mount_path}/usr/bin/"],
sudo=True,
)
def copy_resolv_conf(self) -> None:
ui.info("Copy resolv.conf...")
copied = runner.run(
[
"cp",
"--remove-destination",
"-v",
"/etc/resolv.conf",
f"{self.root_mount_path}/etc/",
],
sudo=True,
check=False,
)
if copied.returncode != 0:
ui.warning("Failed. Probably there is no internet connection available.")
def _umount(self, path: str, *, lazy: bool = False) -> None:
flags = ["-lv"] if lazy else ["-v"]
result = runner.run(["umount", *flags, path], sudo=True, check=False)
if result.returncode != 0:
ui.warning(f"Umounting {path} failed!")
def _rmdir(self, path: Path | None) -> None:
if path is None:
return
result = runner.run(["rmdir", "-v", str(path)], sudo=True, check=False)
if result.returncode != 0:
ui.warning(f"Removing {path} failed!")
def destructor(self) -> None:
ui.info("Cleaning up...")
ui.info("Unmounting everything...")
root = self.root_mount_path
if root is not None:
self._umount(f"{root}/dev/pts", lazy=True)
self._umount(f"{root}/dev", lazy=True)
self._umount(f"{root}/proc")
self._umount(f"{root}/sys")
self._umount(f"{root}/boot")
self._umount(str(root))
if self.boot_mount_path is not None:
self._umount(str(self.boot_mount_path))
ui.info("Deleting mount folders...")
self._rmdir(self.root_mount_path)
self._rmdir(self.boot_mount_path)
self._rmdir(self.working_folder)
if self.root_mapper_name and self.root_is_luks():
ui.info(f"Trying to close decrypted {self.root_mapper_name}...")
closed = runner.run(
["cryptsetup", "-v", "luksClose", self.root_mapper_name],
sudo=True,
check=False,
)
if closed.returncode != 0:
ui.warning("Failed.")
def chroot_bash(root_mount_path: Path | str, script: str, error_msg: str | None = None) -> None:
"""Run a bash script inside the image via chroot."""
runner.run(
["chroot", str(root_mount_path), "/bin/bash"],
input_text=script,
sudo=True,
error_msg=error_msg,
)
def install_packages(distribution: str, root_mount_path: Path, package_names: str) -> None:
"""Install packages inside the image with the distribution's package manager."""
ui.info(f"Installing {package_names}...")
if distribution in ("arch", "manjaro"):
chroot_bash(root_mount_path, f"pacman --noconfirm -S --needed {package_names}")
elif distribution in ("moode", "retropie"):
chroot_bash(root_mount_path, f"yes | apt install {package_names}")
else:
raise LimError("Package manager not supported.")