linux-image-manager 2.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lim/image/session.py ADDED
@@ -0,0 +1,190 @@
1
+ """Mount/unmount lifecycle for working on an image on a block device."""
2
+
3
+ import time
4
+ from pathlib import Path
5
+
6
+ from lim import device as device_module
7
+ from lim import runner, ui
8
+ from lim.device import Device
9
+ from lim.errors import LimError
10
+
11
+
12
+ class ImageSession:
13
+ """Working folder, partition paths and (chroot) mounts for one device.
14
+
15
+ Call ``destructor()`` in a finally block: it unmounts and removes
16
+ everything best-effort, mirroring partially completed setups.
17
+ """
18
+
19
+ def __init__(self, device: Device) -> None:
20
+ self.device = device
21
+ self.working_folder: Path | None = None
22
+ self.boot_mount_path: Path | None = None
23
+ self.root_mount_path: Path | None = None
24
+ self.boot_partition_path = device.partition(1)
25
+ self.root_partition_path = device.partition(2)
26
+ # Overridden by decrypt_root() when the root partition is LUKS.
27
+ self.root_mapper_path = self.root_partition_path
28
+ self.root_mapper_name: str | None = None
29
+ self.boot_partition_uuid: str | None = None
30
+ self.root_partition_uuid: str | None = None
31
+
32
+ def make_working_folder(self) -> None:
33
+ # Mount points must live on the root fs; mkdir() fails on collisions.
34
+ self.working_folder = Path(f"/tmp/linux-image-manager-{int(time.time())}") # noqa: S108
35
+ ui.info(f"Create temporary working folder in {self.working_folder}")
36
+ self.working_folder.mkdir(parents=True)
37
+
38
+ def make_mount_folders(self) -> None:
39
+ if self.working_folder is None:
40
+ self.make_working_folder()
41
+ ui.info("Preparing mount paths...")
42
+ self.boot_mount_path = self.working_folder / "boot"
43
+ self.root_mount_path = self.working_folder / "root"
44
+ self.boot_mount_path.mkdir()
45
+ self.root_mount_path.mkdir()
46
+
47
+ def root_is_luks(self) -> bool:
48
+ return device_module.blkid_value(self.root_partition_path, "TYPE") == "crypto_LUKS"
49
+
50
+ def read_partition_uuids(self) -> None:
51
+ """Fetch partition UUIDs via blkid; derive the LUKS mapper name if unset."""
52
+ self.root_partition_uuid = device_module.blkid_value(
53
+ self.root_partition_path, "UUID"
54
+ )
55
+ self.boot_partition_uuid = device_module.blkid_value(
56
+ self.boot_partition_path, "UUID"
57
+ )
58
+ if self.root_mapper_name is None and self.root_is_luks():
59
+ # Same deterministic name decrypt_root() would have used.
60
+ self.root_mapper_name = f"linux-image-manager-{self.root_partition_uuid}"
61
+ self.root_mapper_path = f"/dev/mapper/{self.root_mapper_name}"
62
+
63
+ def decrypt_root(self) -> None:
64
+ if not self.root_is_luks():
65
+ return
66
+ self.root_partition_uuid = device_module.blkid_value(
67
+ self.root_partition_path, "UUID"
68
+ )
69
+ self.root_mapper_name = f"linux-image-manager-{self.root_partition_uuid}"
70
+ self.root_mapper_path = f"/dev/mapper/{self.root_mapper_name}"
71
+ ui.info(f"Decrypting of {self.root_partition_path} is neccessary...")
72
+ runner.run(
73
+ [
74
+ "cryptsetup",
75
+ "-v",
76
+ "luksOpen",
77
+ self.root_partition_path,
78
+ self.root_mapper_name,
79
+ ],
80
+ sudo=True,
81
+ )
82
+
83
+ def mount_partitions(self) -> None:
84
+ if self.boot_mount_path is None:
85
+ raise LimError("Mount folders are not prepared yet.")
86
+ ui.info("Mount boot and root partition...")
87
+ runner.run(
88
+ ["mount", "-v", self.boot_partition_path, str(self.boot_mount_path)],
89
+ sudo=True,
90
+ )
91
+ runner.run(
92
+ ["mount", "-v", self.root_mapper_path, str(self.root_mount_path)], sudo=True
93
+ )
94
+ ui.info("Setting uuid variables...")
95
+ self.read_partition_uuids()
96
+ ui.info("The following mounts refering this setup exist:")
97
+ runner.run(["findmnt", "-R", str(self.working_folder)], check=False)
98
+
99
+ def mount_chroot_binds(self) -> None:
100
+ ui.info("Mount chroot environments...")
101
+ root = self.root_mount_path
102
+ runner.run(["mount", "--bind", str(self.boot_mount_path), f"{root}/boot"], sudo=True)
103
+ runner.run(["mount", "--bind", "/dev", f"{root}/dev"], sudo=True)
104
+ runner.run(["mount", "--bind", "/sys", f"{root}/sys"], sudo=True)
105
+ runner.run(["mount", "--bind", "/proc", f"{root}/proc"], sudo=True)
106
+ runner.run(["mount", "--bind", "/dev/pts", f"{root}/dev/pts"], sudo=True)
107
+
108
+ def copy_qemu(self) -> None:
109
+ ui.info("Copy qemu binary...")
110
+ runner.run(
111
+ ["cp", "-v", "/usr/bin/qemu-arm-static", f"{self.root_mount_path}/usr/bin/"],
112
+ sudo=True,
113
+ )
114
+
115
+ def copy_resolv_conf(self) -> None:
116
+ ui.info("Copy resolv.conf...")
117
+ copied = runner.run(
118
+ [
119
+ "cp",
120
+ "--remove-destination",
121
+ "-v",
122
+ "/etc/resolv.conf",
123
+ f"{self.root_mount_path}/etc/",
124
+ ],
125
+ sudo=True,
126
+ check=False,
127
+ )
128
+ if copied.returncode != 0:
129
+ ui.warning("Failed. Probably there is no internet connection available.")
130
+
131
+ def _umount(self, path: str, *, lazy: bool = False) -> None:
132
+ flags = ["-lv"] if lazy else ["-v"]
133
+ result = runner.run(["umount", *flags, path], sudo=True, check=False)
134
+ if result.returncode != 0:
135
+ ui.warning(f"Umounting {path} failed!")
136
+
137
+ def _rmdir(self, path: Path | None) -> None:
138
+ if path is None:
139
+ return
140
+ result = runner.run(["rmdir", "-v", str(path)], sudo=True, check=False)
141
+ if result.returncode != 0:
142
+ ui.warning(f"Removing {path} failed!")
143
+
144
+ def destructor(self) -> None:
145
+ ui.info("Cleaning up...")
146
+ ui.info("Unmounting everything...")
147
+ root = self.root_mount_path
148
+ if root is not None:
149
+ self._umount(f"{root}/dev/pts", lazy=True)
150
+ self._umount(f"{root}/dev", lazy=True)
151
+ self._umount(f"{root}/proc")
152
+ self._umount(f"{root}/sys")
153
+ self._umount(f"{root}/boot")
154
+ self._umount(str(root))
155
+ if self.boot_mount_path is not None:
156
+ self._umount(str(self.boot_mount_path))
157
+ ui.info("Deleting mount folders...")
158
+ self._rmdir(self.root_mount_path)
159
+ self._rmdir(self.boot_mount_path)
160
+ self._rmdir(self.working_folder)
161
+ if self.root_mapper_name and self.root_is_luks():
162
+ ui.info(f"Trying to close decrypted {self.root_mapper_name}...")
163
+ closed = runner.run(
164
+ ["cryptsetup", "-v", "luksClose", self.root_mapper_name],
165
+ sudo=True,
166
+ check=False,
167
+ )
168
+ if closed.returncode != 0:
169
+ ui.warning("Failed.")
170
+
171
+
172
+ def chroot_bash(root_mount_path: Path | str, script: str, error_msg: str | None = None) -> None:
173
+ """Run a bash script inside the image via chroot."""
174
+ runner.run(
175
+ ["chroot", str(root_mount_path), "/bin/bash"],
176
+ input_text=script,
177
+ sudo=True,
178
+ error_msg=error_msg,
179
+ )
180
+
181
+
182
+ def install_packages(distribution: str, root_mount_path: Path, package_names: str) -> None:
183
+ """Install packages inside the image with the distribution's package manager."""
184
+ ui.info(f"Installing {package_names}...")
185
+ if distribution in ("arch", "manjaro"):
186
+ chroot_bash(root_mount_path, f"pacman --noconfirm -S --needed {package_names}")
187
+ elif distribution in ("moode", "retropie"):
188
+ chroot_bash(root_mount_path, f"yes | apt install {package_names}")
189
+ else:
190
+ raise LimError("Package manager not supported.")
lim/image/setup.py ADDED
@@ -0,0 +1,96 @@
1
+ """Interactive Linux image setup: download, verify, transfer, configure.
2
+
3
+ Reference for encrypted Raspberry Pi images:
4
+ https://wiki.polaire.nl/doku.php?id=archlinux-raspberry-encrypted
5
+ """
6
+
7
+ import pwd
8
+ from pathlib import Path
9
+
10
+ from lim import device as device_module
11
+ from lim import system, ui
12
+ from lim.errors import LimError
13
+ from lim.image import choosers, transfer, verify
14
+ from lim.image.plan import ImagePlan
15
+ from lim.image.raspberry import configure_raspberry_image
16
+ from lim.image.session import ImageSession
17
+
18
+
19
+ def _prepare_image_folder(plan: ImagePlan) -> None:
20
+ ui.info("Configure user...")
21
+ origin_username = ui.ask("Please type in a valid working username:")
22
+ try:
23
+ pwd.getpwnam(origin_username)
24
+ except KeyError:
25
+ raise LimError(f"User {origin_username} doesn't exist.") from None
26
+
27
+ ui.info("Image routine starts...")
28
+ plan.image_folder = Path(f"/home/{origin_username}/Software/Images")
29
+ ui.info(f'The images will be stored in "{plan.image_folder}".')
30
+ if not plan.image_folder.is_dir():
31
+ ui.info(f'Folder "{plan.image_folder}" doesn\'t exist. It will be created now.')
32
+ plan.image_folder.mkdir(parents=True)
33
+
34
+
35
+ def _select_unmounted_device() -> device_module.Device:
36
+ device = device_module.select_device()
37
+ if device_module.is_mounted(device.path):
38
+ raise LimError(
39
+ f'Device {device.path} is allready mounted. '
40
+ f'Umount with "umount {device.path}*".'
41
+ )
42
+ return device
43
+
44
+
45
+ def _choose_and_verify_image(plan: ImagePlan) -> None:
46
+ plan.operation_system = ui.ask(
47
+ "Which operation system would you like to use [linux,windows,...]?"
48
+ )
49
+ if plan.operation_system == "linux":
50
+ choosers.choose_linux_image(plan)
51
+ plan.encrypt_system = ui.confirm("Should the system be encrypted?")
52
+ ui.info("Generating os-image...")
53
+ transfer.download_image(plan)
54
+ else:
55
+ choosers.choose_local_image(plan)
56
+
57
+ ui.info("Verifying image...")
58
+ ui.info("Verifying checksum...")
59
+ if plan.image_checksum is None and plan.download_url is not None:
60
+ plan.image_checksum = verify.resolve_checksum(plan.download_url)
61
+ verify.verify_checksum(plan.image_path, plan.image_checksum)
62
+ if plan.download_url is not None:
63
+ verify.verify_signature(plan.download_url, plan.image_path, plan.image_folder)
64
+
65
+
66
+ def run_setup() -> None:
67
+ ui.info("Setupscript for images started...")
68
+ ui.info("Checking if root...")
69
+ if not system.is_root():
70
+ raise LimError("This script must be executed as root!")
71
+
72
+ plan = ImagePlan()
73
+ _prepare_image_folder(plan)
74
+ device = _select_unmounted_device()
75
+ _choose_and_verify_image(plan)
76
+
77
+ session = ImageSession(device)
78
+ try:
79
+ session.make_working_folder()
80
+ session.make_mount_folders()
81
+
82
+ plan.root_filesystem = ui.ask(
83
+ "Which filesystem should be used? E.g.:btrfs,ext4... (none):"
84
+ )
85
+
86
+ if ui.confirm(f"Should the image be transfered to {device.path}?"):
87
+ transfer.transfer_image(plan, session)
88
+ else:
89
+ ui.info("Skipping image transfer...")
90
+
91
+ if plan.raspberry_pi_version:
92
+ configure_raspberry_image(plan, session)
93
+ finally:
94
+ session.destructor()
95
+
96
+ ui.success("Setup successfull :)")
lim/image/transfer.py ADDED
@@ -0,0 +1,140 @@
1
+ """Download the selected image and transfer it onto the target device."""
2
+
3
+ from pathlib import Path
4
+
5
+ from lim import device as device_module
6
+ from lim import runner, ui
7
+ from lim.errors import LimError
8
+ from lim.image.plan import DEFAULT_BOOT_SIZE, ImagePlan
9
+ from lim.image.session import ImageSession
10
+
11
+
12
+ def download_image(plan: ImagePlan) -> None:
13
+ if ui.confirm("Should the image download be forced?"):
14
+ if plan.image_path.is_file():
15
+ ui.info(f"Removing image {plan.image_path}.")
16
+ plan.image_path.unlink()
17
+ else:
18
+ ui.info(
19
+ "Forcing download wasn't neccessary. "
20
+ f"File {plan.image_path} doesn't exist."
21
+ )
22
+
23
+ ui.info("Start Download procedure...")
24
+ if plan.image_path.is_file():
25
+ ui.info("Image exist local. Download skipped.")
26
+ return
27
+ ui.info(f'Image "{plan.image_name}" doesn\'t exist under local path "{plan.image_path}".')
28
+ ui.info(f'Image "{plan.image_name}" gets downloaded from "{plan.download_url}"...')
29
+ runner.run(
30
+ ["wget", plan.download_url, "-O", str(plan.image_path)],
31
+ error_msg=f'Download from "{plan.download_url}" failed.',
32
+ )
33
+
34
+
35
+ def arch_partition_input(boot_size: str) -> str:
36
+ """Fdisk answers: FAT32 boot partition of boot_size plus root partition."""
37
+ return (
38
+ "o\n" # clear out any partitions on the drive
39
+ "p\n" # list partitions (should be empty)
40
+ "n\np\n1\n\n" # new primary partition 1, default start sector
41
+ f"{boot_size}\n"
42
+ "t\nc\n" # set partition 1 to type W95 FAT32 (LBA)
43
+ "n\np\n2\n\n\n" # new primary partition 2 over the remaining space
44
+ "w\n" # write partition table
45
+ )
46
+
47
+
48
+ def decompress_command(image_path: Path) -> list[str]:
49
+ """Build the command that streams the raw image to stdout for dd."""
50
+ suffix = image_path.suffix
51
+ if suffix == ".zip":
52
+ return ["unzip", "-p", str(image_path)]
53
+ if suffix == ".gz":
54
+ return ["gunzip", "-c", str(image_path)]
55
+ if suffix == ".iso":
56
+ return ["pv", str(image_path)]
57
+ if suffix == ".xz":
58
+ return ["unxz", "-c", str(image_path)]
59
+ raise LimError(f'Image transfer for "{image_path.name}" is not supported yet!')
60
+
61
+
62
+ def _luks_format_root(plan: ImagePlan, session: ImageSession) -> None:
63
+ luks_format = [
64
+ "cryptsetup", "-v", "luksFormat",
65
+ "-c", "aes-xts-plain64", "-s", "512", "-h", "sha512",
66
+ "--use-random", "-i", "1000",
67
+ ]
68
+ if plan.luks_memory_cost:
69
+ ui.info(
70
+ f"Formating {session.root_partition_path} with LUKS "
71
+ f"with --pbkdf-memory set to {plan.luks_memory_cost}"
72
+ )
73
+ luks_format += ["--pbkdf-memory", plan.luks_memory_cost]
74
+ else:
75
+ ui.info(f"Formating {session.root_partition_path} with LUKS")
76
+ runner.run([*luks_format, session.root_partition_path], sudo=True)
77
+
78
+
79
+ def transfer_arch_image(plan: ImagePlan, session: ImageSession) -> None:
80
+ boot_size = plan.boot_size or DEFAULT_BOOT_SIZE
81
+ ui.info(f"The boot partition will be set to {boot_size}.")
82
+ ui.info("Creating partitions...")
83
+ runner.run(
84
+ ["fdisk", session.device.path],
85
+ input_text=arch_partition_input(boot_size),
86
+ sudo=True,
87
+ )
88
+
89
+ ui.info("Format boot partition...")
90
+ runner.run(["mkfs.vfat", session.boot_partition_path], sudo=True)
91
+
92
+ if plan.encrypt_system:
93
+ _luks_format_root(plan, session)
94
+ session.decrypt_root()
95
+
96
+ ui.info("Format root partition...")
97
+ runner.run([f"mkfs.{plan.root_filesystem}", "-f", session.root_mapper_path], sudo=True)
98
+ session.mount_partitions()
99
+
100
+ ui.info("Root files will be transfered to device...")
101
+ runner.run(
102
+ ["bsdtar", "-xpf", str(plan.image_path), "-C", str(session.root_mount_path)],
103
+ sudo=True,
104
+ )
105
+ runner.sync_disks()
106
+
107
+ ui.info("Boot files will be transfered to device...")
108
+ boot_source = session.root_mount_path / "boot"
109
+ for entry in sorted(boot_source.iterdir()):
110
+ runner.run(["mv", "-v", str(entry), str(session.boot_mount_path)], sudo=True)
111
+
112
+
113
+ def transfer_image(plan: ImagePlan, session: ImageSession) -> None:
114
+ if ui.confirm(f"Should the partition table of {session.device.path} be deleted?"):
115
+ ui.info("Deleting...")
116
+ runner.run(["wipefs", "-a", session.device.path], sudo=True)
117
+ else:
118
+ ui.info("Skipping partition table deletion...")
119
+
120
+ device_module.overwrite_device(session.device)
121
+
122
+ ui.info("Starting image transfer...")
123
+ if plan.distribution == "arch":
124
+ transfer_arch_image(plan, session)
125
+ return
126
+ blocksize = session.device.optimal_blocksize
127
+ ui.info(f"Transfering {plan.image_path.suffix} file...")
128
+ runner.pipeline(
129
+ decompress_command(plan.image_path),
130
+ [
131
+ "dd",
132
+ f"of={session.device.path}",
133
+ f"bs={blocksize}",
134
+ "conv=fsync",
135
+ "status=progress",
136
+ ],
137
+ sudo_last=True,
138
+ error_msg=f"DD {plan.image_path} to {session.device.path} failed.",
139
+ )
140
+ runner.sync_disks()
lim/image/verify.py ADDED
@@ -0,0 +1,100 @@
1
+ """Image integrity (checksums) and authenticity (GPG signatures) checks."""
2
+
3
+ import hashlib
4
+ import re
5
+ from pathlib import Path
6
+
7
+ from lim import runner, ui
8
+ from lim.errors import LimError
9
+
10
+ ALGORITHM_BY_DIGEST_LENGTH = {
11
+ 32: "md5",
12
+ 40: "sha1",
13
+ 64: "sha256",
14
+ 128: "sha512",
15
+ }
16
+
17
+
18
+ def url_exists(url: str) -> bool:
19
+ return runner.succeeds(["wget", "-q", "--method=HEAD", url])
20
+
21
+
22
+ def resolve_checksum(download_url: str) -> str | None:
23
+ """Try to fetch a published checksum next to the image download."""
24
+ for extension in ("sha1", "sha512", "md5"):
25
+ checksum_url = f"{download_url}.{extension}"
26
+ ui.info(
27
+ "Image Checksum is not defined. "
28
+ f"Try to download image signature from {checksum_url}."
29
+ )
30
+ if url_exists(checksum_url):
31
+ content = runner.output(["wget", checksum_url, "-q", "-O", "-"])
32
+ checksum = content.split()[0] if content.split() else ""
33
+ if checksum:
34
+ ui.info(f"Defined image_checksum as {checksum}")
35
+ return checksum
36
+ ui.warning(f"No checksum found under {checksum_url}.")
37
+ return None
38
+
39
+
40
+ def verify_checksum(image_path: str | Path, checksum: str | None) -> None:
41
+ """Verify the image against an md5/sha1/sha256/sha512 hex checksum."""
42
+ if not checksum:
43
+ ui.warning("No checksum is defined. Skipping checksum verification.")
44
+ return
45
+ algorithm = ALGORITHM_BY_DIGEST_LENGTH.get(len(checksum))
46
+ if algorithm is None:
47
+ raise LimError(
48
+ f"Checksum '{checksum}' has no recognized digest length "
49
+ "(expected md5, sha1, sha256 or sha512)."
50
+ )
51
+ ui.info(f"Checking {algorithm} checksum...")
52
+ digest = hashlib.new(algorithm)
53
+ with Path(image_path).open("rb") as handle:
54
+ while chunk := handle.read(1024 * 1024):
55
+ digest.update(chunk)
56
+ if digest.hexdigest() != checksum.lower():
57
+ raise LimError("Verification failed. HINT: Force the download of the image.")
58
+ ui.info(f"{algorithm} checksum verified.")
59
+
60
+
61
+ def verify_signature(download_url: str, image_path: str | Path, image_folder: Path) -> None:
62
+ """Best-effort GPG signature verification of the downloaded image."""
63
+ ui.info("Note: Checksums verify integrity but do not confirm authenticity.")
64
+ ui.info(
65
+ "Proceeding to signature verification, "
66
+ "which ensures the file comes from a trusted source."
67
+ )
68
+ signature_url = f"{download_url}.sig"
69
+ ui.info(f"Attempting to download the image signature from: {signature_url}")
70
+ if not url_exists(signature_url):
71
+ ui.warning(f"No signature found under {signature_url}.")
72
+ return
73
+
74
+ signature_path = image_folder / (Path(str(image_path)).name + ".sig")
75
+ if not runner.succeeds(["wget", "-q", "-O", str(signature_path), signature_url]):
76
+ ui.warning("Failed to download the signature file.")
77
+ return
78
+
79
+ ui.info("Extract the key ID from the signature file")
80
+ verification = runner.output(
81
+ ["gpg", "--status-fd", "1", "--verify", str(signature_path), str(image_path)],
82
+ check=False,
83
+ )
84
+ missing_keys = re.findall(r"NO_PUBKEY (\S+)", verification)
85
+ if missing_keys:
86
+ key_id = missing_keys[-1]
87
+ if runner.succeeds(["gpg", "--list-keys", key_id]):
88
+ ui.info(f"Key {key_id} already in keyring.")
89
+ else:
90
+ ui.info("Import the public key")
91
+ runner.run(
92
+ ["gpg", "--keyserver", "keyserver.ubuntu.com", "--recv-keys", key_id],
93
+ check=False,
94
+ )
95
+
96
+ ui.info("Verify the signature")
97
+ if runner.succeeds(["gpg", "--verify", str(signature_path), str(image_path)]):
98
+ ui.info("Signature verification succeeded.")
99
+ else:
100
+ ui.warning("Signature verification failed.")
lim/luks.py ADDED
@@ -0,0 +1,80 @@
1
+ """LUKS key management plus crypttab/fstab bookkeeping."""
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ from lim import fsutil, runner, ui
7
+ from lim.errors import LimError
8
+
9
+ LUKS_KEY_DIRECTORY = Path("/etc/luks-keys")
10
+ CRYPTTAB_PATH = Path("/etc/crypttab")
11
+ FSTAB_PATH = Path("/etc/fstab")
12
+
13
+
14
+ def luks_uuid(partition_path: str) -> str:
15
+ dump = runner.output(["cryptsetup", "luksDump", partition_path], sudo=True)
16
+ match = re.search(r"UUID:\s*(\S+)", dump)
17
+ if not match:
18
+ raise LimError(f"Could not read LUKS UUID of {partition_path}.")
19
+ return match.group(1)
20
+
21
+
22
+ def create_luks_key_and_update_crypttab(
23
+ mapper_name: str,
24
+ partition_path: str,
25
+ *,
26
+ key_directory: Path = LUKS_KEY_DIRECTORY,
27
+ crypttab_path: Path = CRYPTTAB_PATH,
28
+ ) -> None:
29
+ """Generate a random keyfile, register it with LUKS and /etc/crypttab."""
30
+ ui.info("Creating luks-key-directory...")
31
+ key_directory.mkdir(parents=True, exist_ok=True)
32
+ secret_key_path = key_directory / f"{mapper_name}.keyfile"
33
+ ui.info(f"Generate secret key under: {secret_key_path}")
34
+ if secret_key_path.exists():
35
+ ui.warning("File already exists. Overwriting!")
36
+ runner.run(
37
+ ["dd", "if=/dev/urandom", f"of={secret_key_path}", "bs=512", "count=8"],
38
+ sudo=True,
39
+ )
40
+ runner.sync_disks()
41
+
42
+ ui.info("Opening and closing device to verify that everything works fine...")
43
+ closed = runner.run(
44
+ ["cryptsetup", "-v", "luksClose", mapper_name], sudo=True, check=False
45
+ )
46
+ if closed.returncode != 0:
47
+ ui.info(f"No need to luksClose {mapper_name}. Device isn't open.")
48
+ runner.run(
49
+ ["cryptsetup", "luksAddKey", partition_path, str(secret_key_path)], sudo=True
50
+ )
51
+ runner.run(
52
+ [
53
+ "cryptsetup",
54
+ "-v",
55
+ "luksOpen",
56
+ partition_path,
57
+ mapper_name,
58
+ f"--key-file={secret_key_path}",
59
+ ],
60
+ sudo=True,
61
+ )
62
+ runner.run(["cryptsetup", "-v", "luksClose", mapper_name], sudo=True)
63
+
64
+ ui.info("Reading UUID...")
65
+ uuid = luks_uuid(partition_path)
66
+ entry = f"{mapper_name} UUID={uuid} {secret_key_path} luks"
67
+ ui.info("Adding crypttab entry...")
68
+ fsutil.ensure_line_in_file(entry, crypttab_path)
69
+ ui.info(f"The file {crypttab_path} contains now the following:")
70
+ print(crypttab_path.read_text())
71
+
72
+
73
+ def update_fstab(
74
+ mapper_path: str, mount_path: str, *, fstab_path: Path = FSTAB_PATH
75
+ ) -> None:
76
+ entry = f"{mapper_path} {mount_path} btrfs defaults 0 2"
77
+ ui.info("Adding fstab entry...")
78
+ fsutil.ensure_line_in_file(entry, fstab_path)
79
+ ui.info(f"The file {fstab_path} contains now the following:")
80
+ print(fstab_path.read_text())
lim/packages.py ADDED
@@ -0,0 +1,18 @@
1
+ """Reads package collections from configuration/packages/."""
2
+
3
+ from lim import config
4
+ from lim.errors import LimError
5
+
6
+
7
+ def get_packages(*collections: str) -> list[str]:
8
+ """Package names from the given collections, comments stripped."""
9
+ names: list[str] = []
10
+ for collection in collections:
11
+ path = config.PACKAGE_PATH / f"{collection}.txt"
12
+ if not path.is_file():
13
+ raise LimError(f"Package collection {path} does not exist.")
14
+ for line in path.read_text().splitlines():
15
+ name = line.split("#", 1)[0].strip()
16
+ if name:
17
+ names.append(name)
18
+ return names