index : archinstall32 | |
Archlinux32 installer | gitolite user |
summaryrefslogtreecommitdiff |
author | Andreas Baumann <mail@andreasbaumann.cc> | 2024-05-10 15:56:28 +0200 |
---|---|---|
committer | Andreas Baumann <mail@andreasbaumann.cc> | 2024-05-10 15:56:28 +0200 |
commit | 683da22298abbd90f51d4dd38a7ec4b0dfb04555 (patch) | |
tree | ec2ac04967f9277df038edc362201937b331abe5 /archinstall/lib | |
parent | af7ab9833c9f9944874f0162ae0975175ddc628d (diff) | |
parent | 3381cd55673e5105697d354cf4a1be9a7bcef062 (diff) |
diff --git a/archinstall/lib/systemd.py b/archinstall/lib/boot.py index 64ffcae4..62c50df3 100644 --- a/archinstall/lib/systemd.py +++ b/archinstall/lib/boot.py @@ -1,58 +1,17 @@ -import logging import time -from typing import Iterator +from typing import Iterator, Optional from .exceptions import SysCallError from .general import SysCommand, SysCommandWorker, locate_binary from .installer import Installer -from .output import log +from .output import error from .storage import storage -class Ini: - def __init__(self, *args :str, **kwargs :str): - """ - Limited INI handler for now. - Supports multiple keywords through dictionary list items. - """ - self.kwargs = kwargs - - def __str__(self) -> str: - result = '' - first_row_done = False - for top_level in self.kwargs: - if first_row_done: - result += f"\n[{top_level}]\n" - else: - result += f"[{top_level}]\n" - first_row_done = True - - for key, val in self.kwargs[top_level].items(): - if type(val) == list: - for item in val: - result += f"{key}={item}\n" - else: - result += f"{key}={val}\n" - - return result - - -class Systemd(Ini): - """ - Placeholder class to do systemd specific setups. - """ - - -class Networkd(Systemd): - """ - Placeholder class to do systemd-network specific setups. - """ - - class Boot: def __init__(self, installation: Installer): self.instance = installation self.container_name = 'archinstall' - self.session = None + self.session: Optional[SysCommandWorker] = None self.ready = False def __enter__(self) -> 'Boot': @@ -63,17 +22,18 @@ class Boot: self.session = existing_session.session self.ready = existing_session.ready else: + # '-P' or --console=pipe could help us not having to do a bunch + # of os.write() calls, but instead use pipes (stdin, stdout and stderr) as usual. self.session = SysCommandWorker([ '/usr/bin/systemd-nspawn', - '-D', self.instance.target, + '-D', str(self.instance.target), '--timezone=off', '-b', '--no-pager', '--machine', self.container_name ]) - # '-P' or --console=pipe could help us not having to do a bunch of os.write() calls, but instead use pipes (stdin, stdout and stderr) as usual. - if not self.ready: + if not self.ready and self.session: while self.session.is_alive(): if b' login:' in self.session: self.ready = True @@ -87,29 +47,37 @@ class Boot: # TODO: https://stackoverflow.com/questions/28157929/how-to-safely-handle-an-exception-inside-a-context-manager if len(args) >= 2 and args[1]: - log(args[1], level=logging.ERROR, fg='red') - log(f"The error above occurred in a temporary boot-up of the installation {self.instance}", level=logging.ERROR, fg="red") + error( + args[1], + f"The error above occurred in a temporary boot-up of the installation {self.instance}" + ) shutdown = None - shutdown_exit_code = -1 + shutdown_exit_code: Optional[int] = -1 try: shutdown = SysCommand(f'systemd-run --machine={self.container_name} --pty shutdown now') - except SysCallError as error: - shutdown_exit_code = error.exit_code + except SysCallError as err: + shutdown_exit_code = err.exit_code - while self.session.is_alive(): - time.sleep(0.25) + if self.session: + while self.session.is_alive(): + time.sleep(0.25) - if shutdown: + if shutdown and shutdown.exit_code: shutdown_exit_code = shutdown.exit_code - if self.session.exit_code == 0 or shutdown_exit_code == 0: + if self.session and (self.session.exit_code == 0 or shutdown_exit_code == 0): storage['active_boot'] = None else: - raise SysCallError(f"Could not shut down temporary boot of {self.instance}: {self.session.exit_code}/{shutdown_exit_code}", exit_code=next(filter(bool, [self.session.exit_code, shutdown_exit_code]))) + session_exit_code = self.session.exit_code if self.session else -1 + + raise SysCallError( + f"Could not shut down temporary boot of {self.instance}: {session_exit_code}/{shutdown_exit_code}", + exit_code=next(filter(bool, [session_exit_code, shutdown_exit_code])) + ) - def __iter__(self) -> Iterator[str]: + def __iter__(self) -> Iterator[bytes]: if self.session: for value in self.session: yield value diff --git a/archinstall/lib/configuration.py b/archinstall/lib/configuration.py index c036783f..95e237d7 100644 --- a/archinstall/lib/configuration.py +++ b/archinstall/lib/configuration.py @@ -1,28 +1,17 @@ import os import json import stat -import logging -import pathlib -from typing import Optional, Dict +import readline +from pathlib import Path +from typing import Optional, Dict, Any, TYPE_CHECKING -from .hsm.fido import Fido2 -from .models.disk_encryption import DiskEncryption +from .menu import Menu, MenuSelectionType from .storage import storage from .general import JSON, UNSAFE_JSON -from .output import log -from .exceptions import RequirementError - - -def configuration_sanity_check(): - disk_encryption: DiskEncryption = storage['arguments'].get('disk_encryption') - if disk_encryption is not None and disk_encryption.hsm_device: - if not Fido2.get_fido2_devices(): - raise RequirementError( - f"In order to use HSM to pair with the disk encryption," - + f" one needs to be accessible through /dev/hidraw* and support" - + f" the FIDO2 protocol. You can check this by running" - + f" 'systemd-cryptenroll --fido2-device=list'." - ) +from .output import debug, info, warn + +if TYPE_CHECKING: + _: Any class ConfigurationOutput: @@ -35,15 +24,13 @@ class ConfigurationOutput: :type config: Dict """ self._config = config - self._user_credentials = {} - self._disk_layout = None - self._user_config = {} - self._default_save_path = pathlib.Path(storage.get('LOG_PATH', '.')) + self._user_credentials: Dict[str, Any] = {} + self._user_config: Dict[str, Any] = {} + self._default_save_path = storage.get('LOG_PATH', Path('.')) self._user_config_file = 'user_configuration.json' self._user_creds_file = "user_credentials.json" - self._disk_layout_file = "user_disk_layout.json" - self._sensitive = ['!users'] + self._sensitive = ['!users', '!root-password'] self._ignore = ['abort', 'install', 'config', 'creds', 'dry_run'] self._process_config() @@ -56,23 +43,18 @@ class ConfigurationOutput: def user_configuration_file(self): return self._user_config_file - @property - def disk_layout_file(self): - return self._disk_layout_file - def _process_config(self): - for key in self._config: + for key, value in self._config.items(): if key in self._sensitive: - self._user_credentials[key] = self._config[key] - elif key == 'disk_layouts': - self._disk_layout = self._config[key] + self._user_credentials[key] = value elif key in self._ignore: pass else: - self._user_config[key] = self._config[key] + self._user_config[key] = value - if key == 'disk_encryption' and self._config[key]: # special handling for encryption password - self._user_credentials['encryption_password'] = self._config[key].encryption_password + # special handling for encryption password + if key == 'disk_encryption' and value: + self._user_credentials['encryption_password'] = value.encryption_password def user_config_to_json(self) -> str: return json.dumps({ @@ -81,11 +63,6 @@ class ConfigurationOutput: 'version': storage['__version__'] }, indent=4, sort_keys=True, cls=JSON) - def disk_layout_to_json(self) -> Optional[str]: - if self._disk_layout: - return json.dumps(self._disk_layout, indent=4, sort_keys=True, cls=JSON) - return None - def user_credentials_to_json(self) -> Optional[str]: if self._user_credentials: return json.dumps(self._user_credentials, indent=4, sort_keys=True, cls=UNSAFE_JSON) @@ -93,60 +70,112 @@ class ConfigurationOutput: def show(self): print(_('\nThis is your chosen configuration:')) - log(" -- Chosen configuration --", level=logging.DEBUG) - - user_conig = self.user_config_to_json() - disk_layout = self.disk_layout_to_json() - log(user_conig, level=logging.INFO) - - if disk_layout: - log(disk_layout, level=logging.INFO) + debug(" -- Chosen configuration --") + info(self.user_config_to_json()) print() - def _is_valid_path(self, dest_path :pathlib.Path) -> bool: - if (not dest_path.exists()) or not (dest_path.is_dir()): - log( - 'Destination directory {} does not exist or is not a directory,\n Configuration files can not be saved'.format(dest_path.resolve()), - fg="yellow" + def _is_valid_path(self, dest_path: Path) -> bool: + dest_path_ok = dest_path.exists() and dest_path.is_dir() + if not dest_path_ok: + warn( + f'Destination directory {dest_path.resolve()} does not exist or is not a directory\n.', + 'Configuration files can not be saved' ) - return False - return True + return dest_path_ok - def save_user_config(self, dest_path :pathlib.Path = None): + def save_user_config(self, dest_path: Path): if self._is_valid_path(dest_path): target = dest_path / self._user_config_file + target.write_text(self.user_config_to_json()) + os.chmod(target, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP) - with open(target, 'w') as config_file: - config_file.write(self.user_config_to_json()) - - os.chmod(str(dest_path / self._user_config_file), stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP) - - def save_user_creds(self, dest_path :pathlib.Path = None): + def save_user_creds(self, dest_path: Path): if self._is_valid_path(dest_path): if user_creds := self.user_credentials_to_json(): target = dest_path / self._user_creds_file + target.write_text(user_creds) + os.chmod(target, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP) - with open(target, 'w') as config_file: - config_file.write(user_creds) - - os.chmod(str(target), stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP) + def save(self, dest_path: Optional[Path] = None): + dest_path = dest_path or self._default_save_path - def save_disk_layout(self, dest_path :pathlib.Path = None): if self._is_valid_path(dest_path): - if disk_layout := self.disk_layout_to_json(): - target = dest_path / self._disk_layout_file - - with target.open('w') as config_file: - config_file.write(disk_layout) + self.save_user_config(dest_path) + self.save_user_creds(dest_path) - os.chmod(str(target), stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP) - def save(self, dest_path :pathlib.Path = None): - if not dest_path: - dest_path = self._default_save_path +def save_config(config: Dict): + def preview(selection: str): + match options[selection]: + case "user_config": + serialized = config_output.user_config_to_json() + return f"{config_output.user_configuration_file}\n{serialized}" + case "user_creds": + if maybe_serial := config_output.user_credentials_to_json(): + return f"{config_output.user_credentials_file}\n{maybe_serial}" + return str(_("No configuration")) + case "all": + output = [config_output.user_configuration_file] + if config_output.user_credentials_to_json(): + output.append(config_output.user_credentials_file) + return '\n'.join(output) + return None - if self._is_valid_path(dest_path): - self.save_user_config(dest_path) - self.save_user_creds(dest_path) - self.save_disk_layout(dest_path) + try: + config_output = ConfigurationOutput(config) + + options = { + str(_("Save user configuration (including disk layout)")): "user_config", + str(_("Save user credentials")): "user_creds", + str(_("Save all")): "all", + } + + save_choice = Menu( + _("Choose which configuration to save"), + list(options), + sort=False, + skip=True, + preview_size=0.75, + preview_command=preview, + ).run() + + if save_choice.type_ == MenuSelectionType.Skip: + return + + readline.set_completer_delims("\t\n=") + readline.parse_and_bind("tab: complete") + while True: + path = input( + _( + "Enter a directory for the configuration(s) to be saved (tab completion enabled)\nSave directory: " + ) + ).strip(" ") + dest_path = Path(path) + if dest_path.exists() and dest_path.is_dir(): + break + info(_("Not a valid directory: {}").format(dest_path), fg="red") + + if not path: + return + + prompt = _( + "Do you want to save {} configuration file(s) in the following location?\n\n{}" + ).format(options[str(save_choice.value)], dest_path.absolute()) + + save_confirmation = Menu(prompt, Menu.yes_no(), default_option=Menu.yes()).run() + if save_confirmation == Menu.no(): + return + + debug("Saving {} configuration files to {}".format(options[str(save_choice.value)], dest_path.absolute())) + + match options[str(save_choice.value)]: + case "user_config": + config_output.save_user_config(dest_path) + case "user_creds": + config_output.save_user_creds(dest_path) + case "all": + config_output.save(dest_path) + + except (KeyboardInterrupt, EOFError): + return diff --git a/archinstall/lib/disk/__init__.py b/archinstall/lib/disk/__init__.py index 352d04b9..7f881273 100644 --- a/archinstall/lib/disk/__init__.py +++ b/archinstall/lib/disk/__init__.py @@ -1,7 +1,48 @@ -from .btrfs import * -from .helpers import * -from .blockdevice import BlockDevice -from .filesystem import Filesystem, MBR, GPT -from .partition import * -from .user_guides import * -from .validators import *
\ No newline at end of file +from .device_handler import device_handler, disk_layouts +from .fido import Fido2 +from .filesystem import FilesystemHandler +from .subvolume_menu import SubvolumeMenu +from .partitioning_menu import ( + manual_partitioning, + PartitioningList +) +from .device_model import ( + _DeviceInfo, + BDevice, + DiskLayoutType, + DiskLayoutConfiguration, + LvmLayoutType, + LvmConfiguration, + LvmVolumeGroup, + LvmVolume, + LvmVolumeStatus, + PartitionTable, + Unit, + Size, + SectorSize, + SubvolumeModification, + DeviceGeometry, + PartitionType, + PartitionFlag, + FilesystemType, + ModificationStatus, + PartitionModification, + DeviceModification, + EncryptionType, + DiskEncryption, + Fido2Device, + LsblkInfo, + CleanType, + get_lsblk_info, + get_all_lsblk_info, + get_lsblk_by_mountpoint, +) +from .encryption_menu import ( + select_encryption_type, + select_encrypted_password, + select_hsm, + select_partitions_to_encrypt, + DiskEncryptionMenu, +) + +from .disk_menu import DiskLayoutConfigurationMenu diff --git a/archinstall/lib/disk/blockdevice.py b/archinstall/lib/disk/blockdevice.py deleted file mode 100644 index 178b786a..00000000 --- a/archinstall/lib/disk/blockdevice.py +++ /dev/null @@ -1,301 +0,0 @@ -from __future__ import annotations -import json -import logging -import time - -from collections import OrderedDict -from dataclasses import dataclass -from typing import Optional, Dict, Any, Iterator, List, TYPE_CHECKING - -from ..exceptions import DiskError, SysCallError -from ..output import log -from ..general import SysCommand -from ..storage import storage - - -if TYPE_CHECKING: - from .partition import Partition - _: Any - - -@dataclass -class BlockSizeInfo: - start: str - end: str - size: str - - -@dataclass -class BlockInfo: - pttype: str - ptuuid: str - size: int - tran: Optional[str] - rota: bool - free_space: Optional[List[BlockSizeInfo]] - - -class BlockDevice: - def __init__(self, path :str, info :Optional[Dict[str, Any]] = None): - if not info: - from .helpers import all_blockdevices - # If we don't give any information, we need to auto-fill it. - # Otherwise any subsequent usage will break. - self.info = all_blockdevices(partitions=False)[path].info - else: - self.info = info - - self._path = path - self.keep_partitions = True - self._block_info = self._fetch_information() - self._partitions: Dict[str, 'Partition'] = {} - - self._load_partitions() - - # TODO: Currently disk encryption is a BIT misleading. - # It's actually partition-encryption, but for future-proofing this - # I'm placing the encryption password on a BlockDevice level. - - def __repr__(self, *args :str, **kwargs :str) -> str: - return self._str_repr - - @property - def path(self) -> str: - return self._path - - @property - def _str_repr(self) -> str: - return f"BlockDevice({self._device_or_backfile}, size={self.size}GB, free_space={self._safe_free_space()}, bus_type={self.bus_type})" - - def as_json(self) -> Dict[str, Any]: - return { - str(_('Device')): self._device_or_backfile, - str(_('Size')): f'{self.size}GB', - str(_('Free space')): f'{self._safe_free_space()}', - str(_('Bus-type')): f'{self.bus_type}' - } - - def __iter__(self) -> Iterator['Partition']: - for partition in self.partitions: - yield self.partitions[partition] - - def __getitem__(self, key :str, *args :str, **kwargs :str) -> Any: - if hasattr(self, key): - return getattr(self, key) - - if self.info and key in self.info: - return self.info[key] - - raise KeyError(f'{self.info} does not contain information: "{key}"') - - def __lt__(self, left_comparitor :'BlockDevice') -> bool: - return self._path < left_comparitor.path - - def json(self) -> str: - """ - json() has precedence over __dump__, so this is a way - to give less/partial information for user readability. - """ - return self._path - - def __dump__(self) -> Dict[str, Dict[str, Any]]: - return { - self._path: { - 'partuuid': self.uuid, - 'wipe': self.info.get('wipe', None), - 'partitions': [part.__dump__() for part in self.partitions.values()] - } - } - - def _call_lsblk(self, path: str) -> Dict[str, Any]: - output = SysCommand(f'lsblk --json -b -o+SIZE,PTTYPE,ROTA,TRAN,PTUUID {self._path}').decode('UTF-8') - if output: - lsblk_info = json.loads(output) - return lsblk_info - - raise DiskError(f'Failed to read disk "{self.path}" with lsblk') - - def _load_partitions(self): - from .partition import Partition - - self._partitions.clear() - - lsblk_info = self._call_lsblk(self._path) - device = lsblk_info['blockdevices'][0] - self._partitions.clear() - - if children := device.get('children', None): - root = f'/dev/{device["name"]}' - for child in children: - part_id = child['name'].removeprefix(device['name']) - self._partitions[part_id] = Partition(root + part_id, block_device=self, part_id=part_id) - - def _get_free_space(self) -> Optional[List[BlockSizeInfo]]: - # NOTE: parted -s will default to `cancel` on prompt, skipping any partition - # that is "outside" the disk. in /dev/sr0 this is usually the case with Archiso, - # so the free will ignore the ESP partition and just give the "free" space. - # Doesn't harm us, but worth noting in case something weird happens. - try: - output = SysCommand(f"parted -s --machine {self._path} print free").decode('utf-8') - if output: - free_lines = [line for line in output.split('\n') if 'free' in line] - sizes = [] - for free_space in free_lines: - _, start, end, size, *_ = free_space.strip('\r\n;').split(':') - sizes.append(BlockSizeInfo(start, end, size)) - - return sizes - except SysCallError as error: - log(f"Could not get free space on {self._path}: {error}", level=logging.DEBUG) - - return None - - def _fetch_information(self) -> BlockInfo: - lsblk_info = self._call_lsblk(self._path) - device = lsblk_info['blockdevices'][0] - free_space = self._get_free_space() - - return BlockInfo( - pttype=device['pttype'], - ptuuid=device['ptuuid'], - size=device['size'], - tran=device['tran'], - rota=device['rota'], - free_space=free_space - ) - - @property - def _device_or_backfile(self) -> Optional[str]: - """ - Returns the actual device-endpoint of the BlockDevice. - If it's a loop-back-device it returns the back-file, - For other types it return self.device - """ - if self.info.get('type') == 'loop': - return self.info['back-file'] - else: - return self.device - - @property - def mountpoint(self) -> None: - """ - A dummy function to enable transparent comparisons of mountpoints. - As blockdevices can't be mounted directly, this will always be None - """ - return None - - @property - def device(self) -> Optional[str]: - """ - Returns the device file of the BlockDevice. - If it's a loop-back-device it returns the /dev/X device, - If it's a ATA-drive it returns the /dev/X device - And if it's a crypto-device it returns the parent device - """ - if "DEVTYPE" not in self.info: - raise DiskError(f'Could not locate backplane info for "{self._path}"') - - if self.info['DEVTYPE'] in ['disk','loop']: - return self._path - elif self.info['DEVTYPE'][:4] == 'raid': - # This should catch /dev/md## raid devices - return self._path - elif self.info['DEVTYPE'] == 'crypt': - if 'pkname' not in self.info: - raise DiskError(f'A crypt device ({self._path}) without a parent kernel device name.') - return f"/dev/{self.info['pkname']}" - else: - log(f"Unknown blockdevice type for {self._path}: {self.info['DEVTYPE']}", level=logging.DEBUG) - - return None - - @property - def partition_type(self) -> str: - return self._block_info.pttype - - @property - def uuid(self) -> str: - return self._block_info.ptuuid - - @property - def size(self) -> float: - from .helpers import convert_size_to_gb - return convert_size_to_gb(self._block_info.size) - - @property - def bus_type(self) -> Optional[str]: - return self._block_info.tran - - @property - def spinning(self) -> bool: - return self._block_info.rota - - @property - def partitions(self) -> Dict[str, 'Partition']: - return OrderedDict(sorted(self._partitions.items())) - - @property - def partition(self) -> List['Partition']: - return list(self.partitions.values()) - - @property - def first_free_sector(self) -> str: - if block_size := self._largest_free_space(): - return block_size.start - else: - return '512MB' - - @property - def first_end_sector(self) -> str: - if block_size := self._largest_free_space(): - return block_size.end - else: - return f"{self.size}GB" - - def _safe_free_space(self) -> str: - if self._block_info.free_space: - sizes = [free_space.size for free_space in self._block_info.free_space] - return '+'.join(sizes) - return '?' - - def _largest_free_space(self) -> Optional[BlockSizeInfo]: - if self._block_info.free_space: - sorted_sizes = sorted(self._block_info.free_space, key=lambda x: x.size, reverse=True) - return sorted_sizes[0] - return None - - def _partprobe(self) -> bool: - return SysCommand(['partprobe', self._path]).exit_code == 0 - - def flush_cache(self) -> None: - self._load_partitions() - - def get_partition(self, uuid :Optional[str] = None, partuuid :Optional[str] = None) -> Partition: - if not uuid and not partuuid: - raise ValueError(f"BlockDevice.get_partition() requires either a UUID or a PARTUUID for lookups.") - - log(f"Retrieving partition PARTUUID={partuuid} or UUID={uuid}", level=logging.DEBUG, fg="gray") - - for count in range(storage.get('DISK_RETRY_ATTEMPTS', 5)): - for partition_index, partition in self.partitions.items(): - try: - if uuid and partition.uuid and partition.uuid.lower() == uuid.lower(): - log(f"Matched UUID={uuid} against {partition.uuid}", level=logging.DEBUG, fg="gray") - return partition - elif partuuid and partition.part_uuid and partition.part_uuid.lower() == partuuid.lower(): - log(f"Matched PARTUUID={partuuid} against {partition.part_uuid}", level=logging.DEBUG, fg="gray") - return partition - except DiskError as error: - # Most likely a blockdevice that doesn't support or use UUID's - # (like Microsoft recovery partition) - log(f"Could not get UUID/PARTUUID of {partition}: {error}", level=logging.DEBUG, fg="gray") - pass - - log(f"uuid {uuid} or {partuuid} not found. Waiting {storage.get('DISK_TIMEOUTS', 1) * count}s for next attempt",level=logging.DEBUG) - self.flush_cache() - time.sleep(storage.get('DISK_TIMEOUTS', 1) * count) - - log(f"Could not find {uuid}/{partuuid} in disk after 5 retries", level=logging.INFO) - log(f"Cache: {self._partitions}") - log(f"Partitions: {self.partitions.items()}") - raise DiskError(f"Partition {uuid}/{partuuid} was never found on {self} despite several attempts.") diff --git a/archinstall/lib/disk/btrfs/__init__.py b/archinstall/lib/disk/btrfs/__init__.py deleted file mode 100644 index a26e0160..00000000 --- a/archinstall/lib/disk/btrfs/__init__.py +++ /dev/null @@ -1,56 +0,0 @@ -from __future__ import annotations -import pathlib -import glob -import logging -from typing import Union, Dict, TYPE_CHECKING - -# https://stackoverflow.com/a/39757388/929999 -if TYPE_CHECKING: - from ...installer import Installer - -from .btrfs_helpers import ( - subvolume_info_from_path as subvolume_info_from_path, - find_parent_subvolume as find_parent_subvolume, - setup_subvolumes as setup_subvolumes, - mount_subvolume as mount_subvolume -) -from .btrfssubvolumeinfo import BtrfsSubvolumeInfo as BtrfsSubvolume -from .btrfspartition import BTRFSPartition as BTRFSPartition - -from ...exceptions import DiskError, Deprecated -from ...general import SysCommand -from ...output import log - - -def create_subvolume(installation: Installer, subvolume_location :Union[pathlib.Path, str]) -> bool: - """ - This function uses btrfs to create a subvolume. - - @installation: archinstall.Installer instance - @subvolume_location: a localized string or path inside the installation / or /boot for instance without specifying /mnt/boot - """ - - installation_mountpoint = installation.target - if type(installation_mountpoint) == str: - installation_mountpoint = pathlib.Path(installation_mountpoint) - # Set up the required physical structure - if type(subvolume_location) == str: - subvolume_location = pathlib.Path(subvolume_location) - - target = installation_mountpoint / subvolume_location.relative_to(subvolume_location.anchor) - - # Difference from mount_subvolume: - # We only check if the parent exists, since we'll run in to "target path already exists" otherwise - if not target.parent.exists(): - target.parent.mkdir(parents=True) - - if glob.glob(str(target / '*')): - raise DiskError(f"Cannot create subvolume at {target} because it contains data (non-empty folder target)") - - # Remove the target if it exists - if target.exists(): - target.rmdir() - - log(f"Creating a subvolume on {target}", level=logging.INFO) - if (cmd := SysCommand(f"btrfs subvolume create {target}")).exit_code != 0: - raise DiskError(f"Could not create a subvolume at {target}: {cmd}") diff --git a/archinstall/lib/disk/btrfs/btrfs_helpers.py b/archinstall/lib/disk/btrfs/btrfs_helpers.py deleted file mode 100644 index f6d2734a..00000000 --- a/archinstall/lib/disk/btrfs/btrfs_helpers.py +++ /dev/null @@ -1,136 +0,0 @@ -import logging -import re -from pathlib import Path -from typing import Optional, Dict, Any, TYPE_CHECKING - -from ...models.subvolume import Subvolume -from ...exceptions import SysCallError, DiskError -from ...general import SysCommand -from ...output import log -from ...plugins import plugins -from ..helpers import get_mount_info -from .btrfssubvolumeinfo import BtrfsSubvolumeInfo - -if TYPE_CHECKING: - from .btrfspartition import BTRFSPartition - from ...installer import Installer - - -class fstab_btrfs_compression_plugin(): - def __init__(self, partition_dict): - self.partition_dict = partition_dict - - def on_genfstab(self, installation): - with open(f"{installation.target}/etc/fstab", 'r') as fh: - fstab = fh.read() - - # Replace the {installation}/etc/fstab with entries - # using the compress=zstd where the mountpoint has compression set. - with open(f"{installation.target}/etc/fstab", 'w') as fh: - for line in fstab.split('\n'): - # So first we grab the mount options by using subvol=.*? as a locator. - # And we also grab the mountpoint for the entry, for instance /var/log - if (subvoldef := re.findall(',.*?subvol=.*?[\t ]', line)) and (mountpoint := re.findall('[\t ]/.*?[\t ]', line)): - for subvolume in self.partition_dict.get('btrfs', {}).get('subvolumes', []): - # We then locate the correct subvolume and check if it's compressed - if subvolume.compress and subvolume.mountpoint == mountpoint[0].strip(): - # We then sneak in the compress=zstd option if it doesn't already exist: - # We skip entries where compression is already defined - if ',compress=zstd,' not in line: - line = line.replace(subvoldef[0], f",compress=zstd{subvoldef[0]}") - break - - fh.write(f"{line}\n") - - return True - - -def mount_subvolume(installation: 'Installer', device: 'BTRFSPartition', subvolume: Subvolume): - # we normalize the subvolume name (getting rid of slash at the start if exists. - # In our implementation has no semantic load. - # Every subvolume is created from the top of the hierarchy- and simplifies its further use - name = subvolume.name.lstrip('/') - mountpoint = Path(subvolume.mountpoint) - installation_target = Path(installation.target) - - mountpoint = installation_target / mountpoint.relative_to(mountpoint.anchor) - mountpoint.mkdir(parents=True, exist_ok=True) - mount_options = subvolume.options + [f'subvol={name}'] - - log(f"Mounting subvolume {name} on {device} to {mountpoint}", level=logging.INFO, fg="gray") - SysCommand(f"mount {device.path} {mountpoint} -o {','.join(mount_options)}") - - -def setup_subvolumes(installation: 'Installer', partition_dict: Dict[str, Any]): - log(f"Setting up subvolumes: {partition_dict['btrfs']['subvolumes']}", level=logging.INFO, fg="gray") - - for subvolume in partition_dict['btrfs']['subvolumes']: - # we normalize the subvolume name (getting rid of slash at the start if exists. In our implementation has no semantic load. - # Every subvolume is created from the top of the hierarchy- and simplifies its further use - name = subvolume.name.lstrip('/') - - # We create the subvolume using the BTRFSPartition instance. - # That way we ensure not only easy access, but also accurate mount locations etc. - partition_dict['device_instance'].create_subvolume(name, installation=installation) - - # Make the nodatacow processing now - # It will be the main cause of creation of subvolumes which are not to be mounted - # it is not an options which can be established by subvolume (but for whole file systems), and can be - # set up via a simple attribute change in a directory (if empty). And here the directories are brand new - if subvolume.nodatacow: - if (cmd := SysCommand(f"chattr +C {installation.target}/{name}")).exit_code != 0: - raise DiskError(f"Could not set nodatacow attribute at {installation.target}/{name}: {cmd}") - - # Make the compress processing now - # it is not an options which can be established by subvolume (but for whole file systems), and can be - # set up via a simple attribute change in a directory (if empty). And here the directories are brand new - # in this way only zstd compression is activaded - # TODO WARNING it is not clear if it should be a standard feature, so it might need to be deactivated - - if subvolume.compress: - if not any(['compress' in filesystem_option for filesystem_option in partition_dict.get('filesystem', {}).get('mount_options', [])]): - if (cmd := SysCommand(f"chattr +c {installation.target}/{name}")).exit_code != 0: - raise DiskError(f"Could not set compress attribute at {installation.target}/{name}: {cmd}") - - if 'fstab_btrfs_compression_plugin' not in plugins: - plugins['fstab_btrfs_compression_plugin'] = fstab_btrfs_compression_plugin(partition_dict) - - -def subvolume_info_from_path(path: Path) -> Optional[BtrfsSubvolumeInfo]: - try: - subvolume_name = '' - result = {} - for index, line in enumerate(SysCommand(f"btrfs subvolume show {path}")): - if index == 0: - subvolume_name = line.strip().decode('UTF-8') - continue - - if b':' in line: - key, value = line.strip().decode('UTF-8').split(':', 1) - - # A bit of a hack, until I figure out how @dataclass - # allows for hooking in a pre-processor to do this we have to do it here: - result[key.lower().replace(' ', '_').replace('(s)', 's')] = value.strip() - - return BtrfsSubvolumeInfo(**{'full_path' : path, 'name' : subvolume_name, **result}) # type: ignore - except SysCallError as error: - log(f"Could not retrieve subvolume information from {path}: {error}", level=logging.WARNING, fg="orange") - - return None - - -def find_parent_subvolume(path: Path, filters=[]) -> Optional[BtrfsSubvolumeInfo]: - # A root path cannot have a parent - if str(path) == '/': - return None - - if found_mount := get_mount_info(str(path.parent), traverse=True, ignore=filters): - if not (subvolume := subvolume_info_from_path(found_mount['target'])): - if found_mount['target'] == '/': - return None - - return find_parent_subvolume(path.parent, filters=[*filters, found_mount['target']]) - - return subvolume - - return None diff --git a/archinstall/lib/disk/btrfs/btrfspartition.py b/archinstall/lib/disk/btrfs/btrfspartition.py deleted file mode 100644 index d04c9b98..00000000 --- a/archinstall/lib/disk/btrfs/btrfspartition.py +++ /dev/null @@ -1,109 +0,0 @@ -import glob -import pathlib -import logging -from typing import Optional, TYPE_CHECKING - -from ...exceptions import DiskError -from ...storage import storage -from ...output import log -from ...general import SysCommand -from ..partition import Partition -from ..helpers import findmnt -from .btrfs_helpers import ( - subvolume_info_from_path -) - -if TYPE_CHECKING: - from ...installer import Installer - from .btrfssubvolumeinfo import BtrfsSubvolumeInfo - - -class BTRFSPartition(Partition): - def __init__(self, *args, **kwargs): - Partition.__init__(self, *args, **kwargs) - - @property - def subvolumes(self): - for filesystem in findmnt(pathlib.Path(self.path), recurse=True).get('filesystems', []): - if '[' in filesystem.get('source', ''): - yield subvolume_info_from_path(filesystem['target']) - - def iterate_children(struct): - for c in struct.get('children', []): - if '[' in child.get('source', ''): - yield subvolume_info_from_path(c['target']) - - for sub_child in iterate_children(c): - yield sub_child - - for child in iterate_children(filesystem): - yield child - - def create_subvolume(self, subvolume :pathlib.Path, installation :Optional['Installer'] = None) -> 'BtrfsSubvolumeInfo': - """ - Subvolumes have to be created within a mountpoint. - This means we need to get the current installation target. - After we get it, we need to verify it is a btrfs subvolume filesystem. - Finally, the destination must be empty. - """ - - # Allow users to override the installation session - if not installation: - installation = storage.get('installation_session') - - # Determain if the path given, is an absolute path or a relative path. - # We do this by checking if the path contains a known mountpoint. - if str(subvolume)[0] == '/': - if filesystems := findmnt(subvolume, traverse=True).get('filesystems'): - if (target := filesystems[0].get('target')) and target != '/' and str(subvolume).startswith(target): - # Path starts with a known mountpoint which isn't / - # Which means it's an absolute path to a mounted location. - pass - else: - # Since it's not an absolute position with a known start. - # We omit the anchor ('/' basically) and make sure it's appendable - # to the installation.target later - subvolume = subvolume.relative_to(subvolume.anchor) - # else: We don't need to do anything about relative paths, they should be appendable to installation.target as-is. - - # If the subvolume is not absolute, then we do two checks: - # 1. Check if the partition itself is mounted somewhere, and use that as a root - # 2. Use an active Installer().target as the root, assuming it's filesystem is btrfs - # If both above fail, we need to warn the user that such setup is not supported. - if str(subvolume)[0] != '/': - if self.mountpoint is None and installation is None: - raise DiskError("When creating a subvolume on BTRFSPartition()'s, you need to either initiate a archinstall.Installer() or give absolute paths when creating the subvoulme.") - elif self.mountpoint: - subvolume = self.mountpoint / subvolume - elif installation: - ongoing_installation_destination = installation.target - if type(ongoing_installation_destination) == str: - ongoing_installation_destination = pathlib.Path(ongoing_installation_destination) - - subvolume = ongoing_installation_destination / subvolume - - subvolume.parent.mkdir(parents=True, exist_ok=True) - - # <!-- - # We perform one more check from the given absolute position. - # And we traverse backwards in order to locate any if possible subvolumes above - # our new btrfs subvolume. This is because it needs to be mounted under it to properly - # function. - # if btrfs_parent := find_parent_subvolume(subvolume): - # print('Found parent:', btrfs_parent) - # --> - - log(f'Attempting to create subvolume at {subvolume}', level=logging.DEBUG, fg="grey") - - if glob.glob(str(subvolume / '*')): - raise DiskError(f"Cannot create subvolume at {subvolume} because it contains data (non-empty folder target is not supported by BTRFS)") - # Ideally we would like to check if the destination is already a subvolume. - # But then we would need the mount-point at this stage as well. - # So we'll comment out this check: - # elif subvolinfo := subvolume_info_from_path(subvolume): - # raise DiskError(f"Destination {subvolume} is already a subvolume: {subvolinfo}") - - # And deal with it here: - SysCommand(f"btrfs subvolume create {subvolume}") - - return subvolume_info_from_path(subvolume) diff --git a/archinstall/lib/disk/btrfs/btrfssubvolumeinfo.py b/archinstall/lib/disk/btrfs/btrfssubvolumeinfo.py deleted file mode 100644 index 5f5bdea6..00000000 --- a/archinstall/lib/disk/btrfs/btrfssubvolumeinfo.py +++ /dev/null @@ -1,192 +0,0 @@ -import pathlib -import datetime -import logging -import string -import random -import shutil -from dataclasses import dataclass -from typing import Optional, List# , TYPE_CHECKING -from functools import cached_property - -# if TYPE_CHECKING: -# from ..blockdevice import BlockDevice - -from ...exceptions import DiskError -from ...general import SysCommand -from ...output import log -from ...storage import storage - - -@dataclass -class BtrfsSubvolumeInfo: - full_path :pathlib.Path - name :str - uuid :str - parent_uuid :str - creation_time :datetime.datetime - subvolume_id :int - generation :int - gen_at_creation :int - parent_id :int - top_level_id :int - send_transid :int - send_time :datetime.datetime - receive_transid :int - received_uuid :Optional[str] = None - flags :Optional[str] = None - receive_time :Optional[datetime.datetime] = None - snapshots :Optional[List] = None - - def __post_init__(self): - self.full_path = pathlib.Path(self.full_path) - - # Convert "-" entries to `None` - if self.parent_uuid == "-": - self.parent_uuid = None - if self.received_uuid == "-": - self.received_uuid = None - if self.flags == "-": - self.flags = None - if self.receive_time == "-": - self.receive_time = None - if self.snapshots == "": - self.snapshots = [] - - # Convert timestamps into datetime workable objects (and preserve timezone by using ISO formats) - self.creation_time = datetime.datetime.fromisoformat(self.convert_to_ISO_format(self.creation_time)) - self.send_time = datetime.datetime.fromisoformat(self.convert_to_ISO_format(self.send_time)) - if self.receive_time: - self.receive_time = datetime.datetime.fromisoformat(self.convert_to_ISO_format(self.receive_time)) - - @property - def parent_subvolume(self): - from .btrfs_helpers import find_parent_subvolume - - return find_parent_subvolume(self.full_path) - - @property - def root(self) -> bool: - from .btrfs_helpers import subvolume_info_from_path - - # TODO: Make this function traverse storage['MOUNT_POINT'] and find the first - # occurrence of a mountpoint that is a btrfs volume instead of lazy assume / is a subvolume. - # It would also be nice if it could use findmnt(self.full_path) and traverse backwards - # finding the last occurrence of a subvolume which 'self' belongs to. - if volume := subvolume_info_from_path(storage['MOUNT_POINT']): - return self.full_path == volume.full_path - - return False - - @cached_property - def partition(self): - from ..helpers import findmnt, get_parent_of_partition, all_blockdevices - from ..partition import Partition - from ..blockdevice import BlockDevice - from ..mapperdev import MapperDev - from .btrfspartition import BTRFSPartition - from .btrfs_helpers import subvolume_info_from_path - - try: - # If the subvolume is mounted, it's pretty trivial to lookup the partition (parent) device. - if filesystem := findmnt(self.full_path).get('filesystems', []): - if source := filesystem[0].get('source', None): - # Strip away subvolume definitions from findmnt - if '[' in source: - source = source[:source.find('[')] - - if filesystem[0].get('fstype', '') == 'btrfs': - return BTRFSPartition(source, BlockDevice(get_parent_of_partition(pathlib.Path(source)))) - elif filesystem[0].get('source', '').startswith('/dev/mapper'): - return MapperDev(source) - else: - return Partition(source, BlockDevice(get_parent_of_partition(pathlib.Path(source)))) - except DiskError: - # Subvolume has never been mounted, we have no reliable way of finding where it is. - # But we have the UUID of the partition, and can begin looking for it by mounting - # all blockdevices that we can reliably support.. This is taxing tho and won't cover all devices. - - log(f"Looking up {self}, this might take time.", fg="orange", level=logging.WARNING) - for blockdevice, instance in all_blockdevices(mappers=True, partitions=True, error=True).items(): - if type(instance) in (Partition, MapperDev): - we_mounted_it = False - detection_mountpoint = instance.mountpoint - if not detection_mountpoint: - if type(instance) == Partition and instance.encrypted: - # TODO: Perhaps support unlocking encrypted volumes? - # This will cause a lot of potential user interactions tho. - log(f"Ignoring {blockdevice} because it's encrypted.", fg="gray", level=logging.DEBUG) - continue - - detection_mountpoint = pathlib.Path(f"/tmp/{''.join([random.choice(string.ascii_letters) for x in range(20)])}") - detection_mountpoint.mkdir(parents=True, exist_ok=True) - - instance.mount(str(detection_mountpoint)) - we_mounted_it = True - - if (filesystem := findmnt(detection_mountpoint)) and (filesystem := filesystem.get('filesystems', [])): - if subvolume := subvolume_info_from_path(filesystem[0]['target']): - if subvolume.uuid == self.uuid: - # The top level subvolume matched of ourselves, - # which means the instance we're iterating has the subvol we're looking for. - log(f"Found the subvolume on device {instance}", level=logging.DEBUG, fg="gray") - return instance - - def iterate_children(struct): - for child in struct.get('children', []): - if '[' in child.get('source', ''): - yield subvolume_info_from_path(child['target']) - - for sub_child in iterate_children(child): - yield sub_child - - for child in iterate_children(filesystem[0]): - if child.uuid == self.uuid: - # We found a child within the instance that has the subvol we're looking for. - log(f"Found the subvolume on device {instance}", level=logging.DEBUG, fg="gray") - return instance - - if we_mounted_it: - instance.unmount() - shutil.rmtree(detection_mountpoint) - - @cached_property - def mount_options(self) -> Optional[List[str]]: - from ..helpers import findmnt - - if filesystem := findmnt(self.full_path).get('filesystems', []): - return filesystem[0].get('options').split(',') - - def convert_to_ISO_format(self, time_string): - time_string_almost_done = time_string.replace(' ', 'T', 1).replace(' ', '') - iso_string = f"{time_string_almost_done[:-2]}:{time_string_almost_done[-2:]}" - return iso_string - - def mount(self, mountpoint :pathlib.Path, options=None, include_previously_known_options=True): - from ..helpers import findmnt - - try: - if mnt_info := findmnt(pathlib.Path(mountpoint), traverse=False): - log(f"Unmounting {mountpoint} as it was already mounted using {mnt_info}") - SysCommand(f"umount {mountpoint}") - except DiskError: - # No previously mounted device at the mountpoint - pass - - if not options: - options = [] - - try: - if include_previously_known_options and (cached_options := self.mount_options): - options += cached_options - except DiskError: - pass - - if not any('subvol=' in x for x in options): - options += f'subvol={self.name}' - - SysCommand(f"mount {self.partition.path} {mountpoint} -o {','.join(options)}") - log(f"{self} has successfully been mounted to {mountpoint}", level=logging.INFO, fg="gray") - - def unmount(self, recurse :bool = True): - SysCommand(f"umount {'-R' if recurse else ''} {self.full_path}") - log(f"Successfully unmounted {self}", level=logging.INFO, fg="gray") diff --git a/archinstall/lib/disk/device_handler.py b/archinstall/lib/disk/device_handler.py new file mode 100644 index 00000000..7ba70382 --- /dev/null +++ b/archinstall/lib/disk/device_handler.py @@ -0,0 +1,809 @@ +from __future__ import annotations + +import json +import os +import logging +import time +from pathlib import Path +from typing import List, Dict, Any, Optional, TYPE_CHECKING, Literal, Iterable + +from parted import ( # type: ignore + Disk, Geometry, FileSystem, + PartitionException, DiskLabelException, + getDevice, getAllDevices, freshDisk, Partition, Device +) + +from .device_model import ( + DeviceModification, PartitionModification, + BDevice, _DeviceInfo, _PartitionInfo, + FilesystemType, Unit, PartitionTable, + ModificationStatus, get_lsblk_info, LsblkInfo, + _BtrfsSubvolumeInfo, get_all_lsblk_info, DiskEncryption, LvmVolumeGroup, LvmVolume, Size, LvmGroupInfo, + SectorSize, LvmVolumeInfo, LvmPVInfo, SubvolumeModification, BtrfsMountOption +) + +from ..exceptions import DiskError, UnknownFilesystemFormat +from ..general import SysCommand, SysCallError, JSON, SysCommandWorker +from ..luks import Luks2 +from ..output import debug, error, info, warn, log +from ..utils.util import is_subpath + +if TYPE_CHECKING: + _: Any + + +class DeviceHandler(object): + _TMP_BTRFS_MOUNT = Path('/mnt/arch_btrfs') + + def __init__(self): + self._devices: Dict[Path, BDevice] = {} + self.load_devices() + + @property + def devices(self) -> List[BDevice]: + return list(self._devices.values()) + + def load_devices(self): + block_devices = {} + + devices = getAllDevices() + + try: + loop_devices = SysCommand(['losetup', '-a']) + for ld_info in str(loop_devices).splitlines(): + loop_device = getDevice(ld_info.split(':', maxsplit=1)[0]) + devices.append(loop_device) + except Exception as err: + debug(f'Failed to get loop devices: {err}') + + for device in devices: + if get_lsblk_info(device.path).type == 'rom': + continue + + try: + disk = Disk(device) + except DiskLabelException as err: + if 'unrecognised disk label' in getattr(error, 'message', str(err)): + disk = freshDisk(device, PartitionTable.GPT.value) + else: + debug(f'Unable to get disk from device: {device}') + continue + + device_info = _DeviceInfo.from_disk(disk) + partition_infos = [] + + for partition in disk.partitions: + lsblk_info = get_lsblk_info(partition.path) + fs_type = self._determine_fs_type(partition, lsblk_info) + subvol_infos = [] + + if fs_type == FilesystemType.Btrfs: + subvol_infos = self.get_btrfs_info(partition.path) + + partition_infos.append( + _PartitionInfo.from_partition( + partition, + fs_type, + lsblk_info.partn, + lsblk_info.partuuid, + lsblk_info.uuid, + lsblk_info.mountpoints, + subvol_infos + ) + ) + + block_device = BDevice(disk, device_info, partition_infos) + block_devices[block_device.device_info.path] = block_device + + self._devices = block_devices + + def _determine_fs_type( + self, + partition: Partition, + lsblk_info: Optional[LsblkInfo] = None + ) -> Optional[FilesystemType]: + try: + if partition.fileSystem: + return FilesystemType(partition.fileSystem.type) + elif lsblk_info is not None: + return FilesystemType(lsblk_info.fstype) if lsblk_info.fstype else None + return None + except ValueError: + debug(f'Could not determine the filesystem: {partition.fileSystem}') + + return None + + def get_device(self, path: Path) -> Optional[BDevice]: + return self._devices.get(path, None) + + def get_device_by_partition_path(self, partition_path: Path) -> Optional[BDevice]: + partition = self.find_partition(partition_path) + if partition: + device: Device = partition.disk.device + return self.get_device(Path(device.path)) + return None + + def find_partition(self, path: Path) -> Optional[_PartitionInfo]: + for device in self._devices.values(): + part = next(filter(lambda x: str(x.path) == str(path), device.partition_infos), None) + if part is not None: + return part + return None + + def get_parent_device_path(self, dev_path: Path) -> Path: + lsblk = get_lsblk_info(dev_path) + return Path(f'/dev/{lsblk.pkname}') + + def get_unique_path_for_device(self, dev_path: Path) -> Optional[Path]: + paths = Path('/dev/disk/by-id').glob('*') + linked_targets = {p.resolve(): p for p in paths} + linked_wwn_targets = {p: linked_targets[p] for p in linked_targets + if p.name.startswith('wwn-') or p.name.startswith('nvme-eui.')} + + if dev_path in linked_wwn_targets: + return linked_wwn_targets[dev_path] + + if dev_path in linked_targets: + return linked_targets[dev_path] + + return None + + def get_uuid_for_path(self, path: Path) -> Optional[str]: + partition = self.find_partition(path) + return partition.partuuid if partition else None + + def get_btrfs_info(self, dev_path: Path) -> List[_BtrfsSubvolumeInfo]: + lsblk_info = get_lsblk_info(dev_path) + subvol_infos: List[_BtrfsSubvolumeInfo] = [] + + if not lsblk_info.mountpoint: + self.mount(dev_path, self._TMP_BTRFS_MOUNT, create_target_mountpoint=True) + mountpoint = self._TMP_BTRFS_MOUNT + else: + # when multiple subvolumes are mounted then the lsblk output may look like + # "mountpoint": "/mnt/archinstall/.snapshots" + # "mountpoints": ["/mnt/archinstall/.snapshots", "/mnt/archinstall/home", ..] + # so we'll determine the minimum common path and assume that's the root + path_strings = [str(m) for m in lsblk_info.mountpoints] + common_prefix = os.path.commonprefix(path_strings) + mountpoint = Path(common_prefix) + + try: + result = SysCommand(f'btrfs subvolume list {mountpoint}').decode() + except SysCallError as err: + debug(f'Failed to read btrfs subvolume information: {err}') + return subvol_infos + + try: + # ID 256 gen 16 top level 5 path @ + for line in result.splitlines(): + # expected output format: + # ID 257 gen 8 top level 5 path @home + name = Path(line.split(' ')[-1]) + sub_vol_mountpoint = lsblk_info.btrfs_subvol_info.get(name, None) + subvol_infos.append(_BtrfsSubvolumeInfo(name, sub_vol_mountpoint)) + except json.decoder.JSONDecodeError as err: + error(f"Could not decode lsblk JSON: {result}") + raise err + + if not lsblk_info.mountpoint: + self.umount(dev_path) + + return subvol_infos + + def format( + self, + fs_type: FilesystemType, + path: Path, + additional_parted_options: List[str] = [] + ): + options = [] + command = '' + + match fs_type: + case FilesystemType.Btrfs: + options += ['-f'] + command += 'mkfs.btrfs' + case FilesystemType.Fat16: + options += ['-F16'] + command += 'mkfs.fat' + case FilesystemType.Fat32: + options += ['-F32'] + command += 'mkfs.fat' + case FilesystemType.Ext2: + options += ['-F'] + command += 'mkfs.ext2' + case FilesystemType.Ext3: + options += ['-F'] + command += 'mkfs.ext3' + case FilesystemType.Ext4: + options += ['-F'] + command += 'mkfs.ext4' + case FilesystemType.Xfs: + options += ['-f'] + command += 'mkfs.xfs' + case FilesystemType.F2fs: + options += ['-f'] + command += 'mkfs.f2fs' + case FilesystemType.Ntfs: + options += ['-f', '-Q'] + command += 'mkfs.ntfs' + case FilesystemType.Reiserfs: + command += 'mkfs.reiserfs' + case _: + raise UnknownFilesystemFormat(f'Filetype "{fs_type.value}" is not supported') + + options += additional_parted_options + options_str = ' '.join(options) + + debug(f'Formatting filesystem: /usr/bin/{command} {options_str} {path}') + + try: + SysCommand(f"/usr/bin/{command} {options_str} {path}") + except SysCallError as err: + msg = f'Could not format {path} with {fs_type.value}: {err.message}' + error(msg) + raise DiskError(msg) from err + + def encrypt( + self, + dev_path: Path, + mapper_name: Optional[str], + enc_password: str, + lock_after_create: bool = True + ) -> Luks2: + luks_handler = Luks2( + dev_path, + mapper_name=mapper_name, + password=enc_password + ) + + key_file = luks_handler.encrypt() + + luks_handler.unlock(key_file=key_file) + + if not luks_handler.mapper_dev: + raise DiskError('Failed to unlock luks device') + + if lock_after_create: + debug(f'luks2 locking device: {dev_path}') + luks_handler.lock() + + return luks_handler + + def format_encrypted( + self, + dev_path: Path, + mapper_name: Optional[str], + fs_type: FilesystemType, + enc_conf: DiskEncryption + ): + luks_handler = Luks2( + dev_path, + mapper_name=mapper_name, + password=enc_conf.encryption_password + ) + + key_file = luks_handler.encrypt() + + luks_handler.unlock(key_file=key_file) + + if not luks_handler.mapper_dev: + raise DiskError('Failed to unlock luks device') + + info(f'luks2 formatting mapper dev: {luks_handler.mapper_dev}') + self.format(fs_type, luks_handler.mapper_dev) + + info(f'luks2 locking device: {dev_path}') + luks_handler.lock() + + def _lvm_info( + self, + cmd: str, + info_type: Literal['lv', 'vg', 'pvseg'] + ) -> Optional[Any]: + raw_info = SysCommand(cmd).decode().split('\n') + + # for whatever reason the output sometimes contains + # "File descriptor X leaked leaked on vgs invocation + data = '\n'.join([raw for raw in raw_info if 'File descriptor' not in raw]) + + debug(f'LVM info: {data}') + + reports = json.loads(data) + + for report in reports['report']: + if len(report[info_type]) != 1: + raise ValueError(f'Report does not contain any entry') + + entry = report[info_type][0] + + match info_type: + case 'pvseg': + return LvmPVInfo( + pv_name=Path(entry['pv_name']), + lv_name=entry['lv_name'], + vg_name=entry['vg_name'], + ) + case 'lv': + return LvmVolumeInfo( + lv_name=entry['lv_name'], + vg_name=entry['vg_name'], + lv_size=Size(int(entry[f'lv_size'][:-1]), Unit.B, SectorSize.default()) + ) + case 'vg': + return LvmGroupInfo( + vg_uuid=entry['vg_uuid'], + vg_size=Size(int(entry[f'vg_size'][:-1]), Unit.B, SectorSize.default()) + ) + + return None + + def _lvm_info_with_retry(self, cmd: str, info_type: Literal['lv', 'vg', 'pvseg']) -> Optional[Any]: + attempts = 3 + + for attempt_nr in range(attempts): + try: + return self._lvm_info(cmd, info_type) + except ValueError: + time.sleep(attempt_nr + 1) + + raise ValueError(f'Failed to fetch {info_type} information') + + def lvm_vol_info(self, lv_name: str) -> Optional[LvmVolumeInfo]: + cmd = ( + 'lvs --reportformat json ' + '--unit B ' + f'-S lv_name={lv_name}' + ) + + return self._lvm_info_with_retry(cmd, 'lv') + + def lvm_group_info(self, vg_name: str) -> Optional[LvmGroupInfo]: + cmd = ( + 'vgs --reportformat json ' + '--unit B ' + '-o vg_name,vg_uuid,vg_size ' + f'-S vg_name={vg_name}' + ) + + return self._lvm_info_with_retry(cmd, 'vg') + + def lvm_pvseg_info(self, vg_name: str, lv_name: str) -> Optional[LvmPVInfo]: + cmd = ( + 'pvs ' + '--segments -o+lv_name,vg_name ' + f'-S vg_name={vg_name},lv_name={lv_name} ' + '--reportformat json ' + ) + + return self._lvm_info_with_retry(cmd, 'pvseg') + + def lvm_vol_change(self, vol: LvmVolume, activate: bool): + active_flag = 'y' if activate else 'n' + cmd = f'lvchange -a {active_flag} {vol.safe_dev_path}' + + debug(f'lvchange volume: {cmd}') + SysCommand(cmd) + + def lvm_export_vg(self, vg: LvmVolumeGroup): + cmd = f'vgexport {vg.name}' + + debug(f'vgexport: {cmd}') + SysCommand(cmd) + + def lvm_import_vg(self, vg: LvmVolumeGroup): + cmd = f'vgimport {vg.name}' + + debug(f'vgimport: {cmd}') + SysCommand(cmd) + + def lvm_vol_reduce(self, vol_path: Path, amount: Size): + val = amount.format_size(Unit.B, include_unit=False) + cmd = f'lvreduce -L -{val}B {vol_path}' + + debug(f'Reducing LVM volume size: {cmd}') + SysCommand(cmd) + + def lvm_pv_create(self, pvs: Iterable[Path]): + cmd = 'pvcreate ' + ' '.join([str(pv) for pv in pvs]) + debug(f'Creating LVM PVS: {cmd}') + + worker = SysCommandWorker(cmd) + worker.poll() + worker.write(b'y\n', line_ending=False) + + def lvm_vg_create(self, pvs: Iterable[Path], vg_name: str): + pvs_str = ' '.join([str(pv) for pv in pvs]) + cmd = f'vgcreate --yes {vg_name} {pvs_str}' + + debug(f'Creating LVM group: {cmd}') + + worker = SysCommandWorker(cmd) + worker.poll() + worker.write(b'y\n', line_ending=False) + + def lvm_vol_create(self, vg_name: str, volume: LvmVolume, offset: Optional[Size] = None): + if offset is not None: + length = volume.length - offset + else: + length = volume.length + + length_str = length.format_size(Unit.B, include_unit=False) + cmd = f'lvcreate --yes -L {length_str}B {vg_name} -n {volume.name}' + + debug(f'Creating volume: {cmd}') + + worker = SysCommandWorker(cmd) + worker.poll() + worker.write(b'y\n', line_ending=False) + + volume.vg_name = vg_name + volume.dev_path = Path(f'/dev/{vg_name}/{volume.name}') + + def _setup_partition( + self, + part_mod: PartitionModification, + block_device: BDevice, + disk: Disk, + requires_delete: bool + ): + # when we require a delete and the partition to be (re)created + # already exists then we have to delete it first + if requires_delete and part_mod.status in [ModificationStatus.Modify, ModificationStatus.Delete]: + info(f'Delete existing partition: {part_mod.safe_dev_path}') + part_info = self.find_partition(part_mod.safe_dev_path) + + if not part_info: + raise DiskError(f'No partition for dev path found: {part_mod.safe_dev_path}') + + disk.deletePartition(part_info.partition) + + if part_mod.status == ModificationStatus.Delete: + return + + start_sector = part_mod.start.convert( + Unit.sectors, + block_device.device_info.sector_size + ) + + length_sector = part_mod.length.convert( + Unit.sectors, + block_device.device_info.sector_size + ) + + geometry = Geometry( + device=block_device.disk.device, + start=start_sector.value, + length=length_sector.value + ) + + filesystem = FileSystem(type=part_mod.safe_fs_type.value, geometry=geometry) + + partition = Partition( + disk=disk, + type=part_mod.type.get_partition_code(), + fs=filesystem, + geometry=geometry + ) + + for flag in part_mod.flags: + partition.setFlag(flag.value) + + debug(f'\tType: {part_mod.type.value}') + debug(f'\tFilesystem: {part_mod.safe_fs_type.value}') + debug(f'\tGeometry: {start_sector.value} start sector, {length_sector.value} length') + + try: + disk.addPartition(partition=partition, constraint=disk.device.optimalAlignedConstraint) + except PartitionException as ex: + raise DiskError(f'Unable to add partition, most likely due to overlapping sectors: {ex}') from ex + + # the partition has a path now that it has been added + part_mod.dev_path = Path(partition.path) + + def fetch_part_info(self, path: Path) -> LsblkInfo: + lsblk_info = get_lsblk_info(path) + + if not lsblk_info.partn: + debug(f'Unable to determine new partition number: {path}\n{lsblk_info}') + raise DiskError(f'Unable to determine new partition number: {path}') + + if not lsblk_info.partuuid: + debug(f'Unable to determine new partition uuid: {path}\n{lsblk_info}') + raise DiskError(f'Unable to determine new partition uuid: {path}') + + if not lsblk_info.uuid: + debug(f'Unable to determine new uuid: {path}\n{lsblk_info}') + raise DiskError(f'Unable to determine new uuid: {path}') + + debug(f'partition information found: {lsblk_info.json()}') + + return lsblk_info + + def create_lvm_btrfs_subvolumes( + self, + path: Path, + btrfs_subvols: List[SubvolumeModification], + mount_options: List[str] + ): + info(f'Creating subvolumes: {path}') + + self.mount(path, self._TMP_BTRFS_MOUNT, create_target_mountpoint=True) + + for sub_vol in btrfs_subvols: + debug(f'Creating subvolume: {sub_vol.name}') + + subvol_path = self._TMP_BTRFS_MOUNT / sub_vol.name + + SysCommand(f"btrfs subvolume create {subvol_path}") + + if BtrfsMountOption.nodatacow.value in mount_options: + try: + SysCommand(f'chattr +C {subvol_path}') + except SysCallError as err: + raise DiskError(f'Could not set nodatacow attribute at {subvol_path}: {err}') + + if BtrfsMountOption.compress.value in mount_options: + try: + SysCommand(f'chattr +c {subvol_path}') + except SysCallError as err: + raise DiskError(f'Could not set compress attribute at {subvol_path}: {err}') + + self.umount(path) + + def create_btrfs_volumes( + self, + part_mod: PartitionModification, + enc_conf: Optional['DiskEncryption'] = None + ): + info(f'Creating subvolumes: {part_mod.safe_dev_path}') + + luks_handler = None + + # unlock the partition first if it's encrypted + if enc_conf is not None and part_mod in enc_conf.partitions: + if not part_mod.mapper_name: + raise ValueError('No device path specified for modification') + + luks_handler = self.unlock_luks2_dev( + part_mod.safe_dev_path, + part_mod.mapper_name, + enc_conf.encryption_password + ) + + if not luks_handler.mapper_dev: + raise DiskError('Failed to unlock luks device') + + self.mount( + luks_handler.mapper_dev, + self._TMP_BTRFS_MOUNT, + create_target_mountpoint=True, + options=part_mod.mount_options + ) + else: + self.mount( + part_mod.safe_dev_path, + self._TMP_BTRFS_MOUNT, + create_target_mountpoint=True, + options=part_mod.mount_options + ) + + for sub_vol in part_mod.btrfs_subvols: + debug(f'Creating subvolume: {sub_vol.name}') + + if luks_handler is not None: + subvol_path = self._TMP_BTRFS_MOUNT / sub_vol.name + else: + subvol_path = self._TMP_BTRFS_MOUNT / sub_vol.name + + SysCommand(f"btrfs subvolume create {subvol_path}") + + if luks_handler is not None and luks_handler.mapper_dev is not None: + self.umount(luks_handler.mapper_dev) + luks_handler.lock() + else: + self.umount(part_mod.safe_dev_path) + + def unlock_luks2_dev(self, dev_path: Path, mapper_name: str, enc_password: str) -> Luks2: + luks_handler = Luks2(dev_path, mapper_name=mapper_name, password=enc_password) + + if not luks_handler.is_unlocked(): + luks_handler.unlock() + + if not luks_handler.is_unlocked(): + raise DiskError(f'Failed to unlock luks2 device: {dev_path}') + + return luks_handler + + def umount_all_existing(self, device_path: Path): + debug(f'Unmounting all existing partitions: {device_path}') + + existing_partitions = self._devices[device_path].partition_infos + + for partition in existing_partitions: + debug(f'Unmounting: {partition.path}') + + # un-mount for existing encrypted partitions + if partition.fs_type == FilesystemType.Crypto_luks: + Luks2(partition.path).lock() + else: + self.umount(partition.path, recursive=True) + + def partition( + self, + modification: DeviceModification, + partition_table: Optional[PartitionTable] = None + ): + """ + Create a partition table on the block device and create all partitions. + """ + if modification.wipe: + if partition_table is None: + raise ValueError('Modification is marked as wipe but no partitioning table was provided') + + if partition_table.MBR and len(modification.partitions) > 3: + raise DiskError('Too many partitions on disk, MBR disks can only have 3 primary partitions') + + # make sure all devices are unmounted + self.umount_all_existing(modification.device_path) + + # WARNING: the entire device will be wiped and all data lost + if modification.wipe: + self.wipe_dev(modification.device) + part_table = partition_table.value if partition_table else None + disk = freshDisk(modification.device.disk.device, part_table) + else: + info(f'Use existing device: {modification.device_path}') + disk = modification.device.disk + + info(f'Creating partitions: {modification.device_path}') + + # don't touch existing partitions + filtered_part = [p for p in modification.partitions if not p.exists()] + + for part_mod in filtered_part: + # if the entire disk got nuked then we don't have to delete + # any existing partitions anymore because they're all gone already + requires_delete = modification.wipe is False + self._setup_partition(part_mod, modification.device, disk, requires_delete=requires_delete) + + disk.commit() + + def mount( + self, + dev_path: Path, + target_mountpoint: Path, + mount_fs: Optional[str] = None, + create_target_mountpoint: bool = True, + options: List[str] = [] + ): + if create_target_mountpoint and not target_mountpoint.exists(): + target_mountpoint.mkdir(parents=True, exist_ok=True) + + if not target_mountpoint.exists(): + raise ValueError('Target mountpoint does not exist') + + lsblk_info = get_lsblk_info(dev_path) + if target_mountpoint in lsblk_info.mountpoints: + info(f'Device already mounted at {target_mountpoint}') + return + + cmd = ['mount'] + + if len(options): + cmd.extend(('-o', ','.join(options))) + if mount_fs: + cmd.extend(('-t', mount_fs)) + + cmd.extend((str(dev_path), str(target_mountpoint))) + + command = ' '.join(cmd) + + debug(f'Mounting {dev_path}: {command}') + + try: + SysCommand(command) + except SysCallError as err: + raise DiskError(f'Could not mount {dev_path}: {command}\n{err.message}') + + def umount(self, mountpoint: Path, recursive: bool = False): + try: + lsblk_info = get_lsblk_info(mountpoint) + except SysCallError as ex: + # this could happen if before partitioning the device contained 3 partitions + # and after partitioning only 2 partitions were created, then the modifications object + # will have a reference to /dev/sX3 which is being tried to umount here now + if 'not a block device' in ex.message: + return + raise ex + + if len(lsblk_info.mountpoints) > 0: + debug(f'Partition {mountpoint} is currently mounted at: {[str(m) for m in lsblk_info.mountpoints]}') + + for mountpoint in lsblk_info.mountpoints: + debug(f'Unmounting mountpoint: {mountpoint}') + + command = 'umount' + + if recursive: + command += ' -R' + + SysCommand(f'{command} {mountpoint}') + + def detect_pre_mounted_mods(self, base_mountpoint: Path) -> List[DeviceModification]: + part_mods: Dict[Path, List[PartitionModification]] = {} + + for device in self.devices: + for part_info in device.partition_infos: + for mountpoint in part_info.mountpoints: + if is_subpath(mountpoint, base_mountpoint): + path = Path(part_info.disk.device.path) + part_mods.setdefault(path, []) + part_mod = PartitionModification.from_existing_partition(part_info) + if part_mod.mountpoint: + part_mod.mountpoint = mountpoint.root / mountpoint.relative_to(base_mountpoint) + else: + for subvol in part_mod.btrfs_subvols: + if sm := subvol.mountpoint: + subvol.mountpoint = sm.root / sm.relative_to(base_mountpoint) + part_mods[path].append(part_mod) + break + + device_mods: List[DeviceModification] = [] + for device_path, mods in part_mods.items(): + device_mod = DeviceModification(self._devices[device_path], False, mods) + device_mods.append(device_mod) + + return device_mods + + def partprobe(self, path: Optional[Path] = None): + if path is not None: + command = f'partprobe {path}' + else: + command = 'partprobe' + + try: + debug(f'Calling partprobe: {command}') + SysCommand(command) + except SysCallError as err: + if 'have been written, but we have been unable to inform the kernel of the change' in str(err): + log(f"Partprobe was not able to inform the kernel of the new disk state (ignoring error): {err}", fg="gray", level=logging.INFO) + else: + error(f'"{command}" failed to run (continuing anyway): {err}') + + def _wipe(self, dev_path: Path): + """ + Wipe a device (partition or otherwise) of meta-data, be it file system, LVM, etc. + @param dev_path: Device path of the partition to be wiped. + @type dev_path: str + """ + with open(dev_path, 'wb') as p: + p.write(bytearray(1024)) + + def wipe_dev(self, block_device: BDevice): + """ + Wipe the block device of meta-data, be it file system, LVM, etc. + This is not intended to be secure, but rather to ensure that + auto-discovery tools don't recognize anything here. + """ + info(f'Wiping partitions and metadata: {block_device.device_info.path}') + for partition in block_device.partition_infos: + self._wipe(partition.path) + + self._wipe(block_device.device_info.path) + + +device_handler = DeviceHandler() + + +def disk_layouts() -> str: + try: + lsblk_info = get_all_lsblk_info() + return json.dumps(lsblk_info, indent=4, sort_keys=True, cls=JSON) + except SysCallError as err: + warn(f"Could not return disk layouts: {err}") + return '' + except json.decoder.JSONDecodeError as err: + warn(f"Could not return disk layouts: {err}") + return '' diff --git a/archinstall/lib/disk/device_model.py b/archinstall/lib/disk/device_model.py new file mode 100644 index 00000000..f98d05fb --- /dev/null +++ b/archinstall/lib/disk/device_model.py @@ -0,0 +1,1499 @@ +from __future__ import annotations + +import dataclasses +import json +import math +import uuid +from dataclasses import dataclass, field +from enum import Enum +from enum import auto +from pathlib import Path +from typing import Optional, List, Dict, TYPE_CHECKING, Any +from typing import Union + +import parted # type: ignore +import _ped # type: ignore +from parted import Disk, Geometry, Partition + +from ..exceptions import DiskError, SysCallError +from ..general import SysCommand +from ..output import debug, error +from ..storage import storage + +if TYPE_CHECKING: + _: Any + + +class DiskLayoutType(Enum): + Default = 'default_layout' + Manual = 'manual_partitioning' + Pre_mount = 'pre_mounted_config' + + def display_msg(self) -> str: + match self: + case DiskLayoutType.Default: return str(_('Use a best-effort default partition layout')) + case DiskLayoutType.Manual: return str(_('Manual Partitioning')) + case DiskLayoutType.Pre_mount: return str(_('Pre-mounted configuration')) + + +@dataclass +class DiskLayoutConfiguration: + config_type: DiskLayoutType + device_modifications: List[DeviceModification] = field(default_factory=list) + lvm_config: Optional[LvmConfiguration] = None + + # used for pre-mounted config + mountpoint: Optional[Path] = None + + def json(self) -> Dict[str, Any]: + if self.config_type == DiskLayoutType.Pre_mount: + return { + 'config_type': self.config_type.value, + 'mountpoint': str(self.mountpoint) + } + else: + config: Dict[str, Any] = { + 'config_type': self.config_type.value, + 'device_modifications': [mod.json() for mod in self.device_modifications], + } + + if self.lvm_config: + config['lvm_config'] = self.lvm_config.json() + + return config + + @classmethod + def parse_arg(cls, disk_config: Dict[str, Any]) -> Optional[DiskLayoutConfiguration]: + from .device_handler import device_handler + + device_modifications: List[DeviceModification] = [] + config_type = disk_config.get('config_type', None) + + if not config_type: + raise ValueError('Missing disk layout configuration: config_type') + + config = DiskLayoutConfiguration( + config_type=DiskLayoutType(config_type), + device_modifications=device_modifications + ) + + if config_type == DiskLayoutType.Pre_mount.value: + if not (mountpoint := disk_config.get('mountpoint')): + raise ValueError('Must set a mountpoint when layout type is pre-mount') + + path = Path(str(mountpoint)) + + mods = device_handler.detect_pre_mounted_mods(path) + device_modifications.extend(mods) + + storage['MOUNT_POINT'] = path + + config.mountpoint = path + + return config + + for entry in disk_config.get('device_modifications', []): + device_path = Path(entry.get('device', None)) if entry.get('device', None) else None + + if not device_path: + continue + + device = device_handler.get_device(device_path) + + if not device: + continue + + device_modification = DeviceModification( + wipe=entry.get('wipe', False), + device=device + ) + + device_partitions: List[PartitionModification] = [] + + for partition in entry.get('partitions', []): + device_partition = PartitionModification( + status=ModificationStatus(partition['status']), + fs_type=FilesystemType(partition['fs_type']) if partition.get('fs_type') else None, + start=Size.parse_args(partition['start']), + length=Size.parse_args(partition['size']), + mount_options=partition['mount_options'], + mountpoint=Path(partition['mountpoint']) if partition['mountpoint'] else None, + dev_path=Path(partition['dev_path']) if partition['dev_path'] else None, + type=PartitionType(partition['type']), + flags=[PartitionFlag[f] for f in partition.get('flags', [])], + btrfs_subvols=SubvolumeModification.parse_args(partition.get('btrfs', [])), + ) + # special 'invisible attr to internally identify the part mod + setattr(device_partition, '_obj_id', partition['obj_id']) + device_partitions.append(device_partition) + + device_modification.partitions = device_partitions + device_modifications.append(device_modification) + + # Parse LVM configuration from settings + if (lvm_arg := disk_config.get('lvm_config', None)) is not None: + config.lvm_config = LvmConfiguration.parse_arg(lvm_arg, config) + + return config + + +class PartitionTable(Enum): + GPT = 'gpt' + MBR = 'msdos' + + +class Unit(Enum): + B = 1 # byte + kB = 1000 ** 1 # kilobyte + MB = 1000 ** 2 # megabyte + GB = 1000 ** 3 # gigabyte + TB = 1000 ** 4 # terabyte + PB = 1000 ** 5 # petabyte + EB = 1000 ** 6 # exabyte + ZB = 1000 ** 7 # zettabyte + YB = 1000 ** 8 # yottabyte + + KiB = 1024 ** 1 # kibibyte + MiB = 1024 ** 2 # mebibyte + GiB = 1024 ** 3 # gibibyte + TiB = 1024 ** 4 # tebibyte + PiB = 1024 ** 5 # pebibyte + EiB = 1024 ** 6 # exbibyte + ZiB = 1024 ** 7 # zebibyte + YiB = 1024 ** 8 # yobibyte + + sectors = 'sectors' # size in sector + + @staticmethod + def get_all_units() -> List[str]: + return [u.name for u in Unit] + + @staticmethod + def get_si_units() -> List[Unit]: + return [u for u in Unit if 'i' not in u.name and u.name != 'sectors'] + + +@dataclass +class SectorSize: + value: int + unit: Unit + + def __post_init__(self): + match self.unit: + case Unit.sectors: + raise ValueError('Unit type sector not allowed for SectorSize') + + @staticmethod + def default() -> SectorSize: + return SectorSize(512, Unit.B) + + def json(self) -> Dict[str, Any]: + return { + 'value': self.value, + 'unit': self.unit.name, + } + + @classmethod + def parse_args(cls, arg: Dict[str, Any]) -> SectorSize: + return SectorSize( + arg['value'], + Unit[arg['unit']] + ) + + def normalize(self) -> int: + """ + will normalize the value of the unit to Byte + """ + return int(self.value * self.unit.value) # type: ignore + + +@dataclass +class Size: + value: int + unit: Unit + sector_size: SectorSize + + def __post_init__(self): + if not isinstance(self.sector_size, SectorSize): + raise ValueError('sector size must be of type SectorSize') + + def json(self) -> Dict[str, Any]: + return { + 'value': self.value, + 'unit': self.unit.name, + 'sector_size': self.sector_size.json() if self.sector_size else None + } + + @classmethod + def parse_args(cls, size_arg: Dict[str, Any]) -> Size: + sector_size = size_arg['sector_size'] + + return Size( + size_arg['value'], + Unit[size_arg['unit']], + SectorSize.parse_args(sector_size), + ) + + def convert( + self, + target_unit: Unit, + sector_size: Optional[SectorSize] = None + ) -> Size: + if target_unit == Unit.sectors and sector_size is None: + raise ValueError('If target has unit sector, a sector size must be provided') + + if self.unit == target_unit: + return self + elif self.unit == Unit.sectors: + norm = self._normalize() + return Size(norm, Unit.B, self.sector_size).convert(target_unit, sector_size) + else: + if target_unit == Unit.sectors and sector_size is not None: + norm = self._normalize() + sectors = math.ceil(norm / sector_size.value) + return Size(sectors, Unit.sectors, sector_size) + else: + value = int(self._normalize() / target_unit.value) # type: ignore + return Size(value, target_unit, self.sector_size) + + def as_text(self) -> str: + return self.format_size( + self.unit, + self.sector_size + ) + + def format_size( + self, + target_unit: Unit, + sector_size: Optional[SectorSize] = None, + include_unit: bool = True + ) -> str: + target_size = self.convert(target_unit, sector_size) + + if include_unit: + return f'{target_size.value} {target_unit.name}' + return f'{target_size.value}' + + def format_highest(self, include_unit: bool = True) -> str: + si_units = Unit.get_si_units() + all_si_values = [self.convert(si) for si in si_units] + filtered = filter(lambda x: x.value >= 1, all_si_values) + + # we have to get the max by the unit value as we're interested + # in getting the value in the highest possible unit without floats + si_value = max(filtered, key=lambda x: x.unit.value) + + if include_unit: + return f'{si_value.value} {si_value.unit.name}' + return f'{si_value.value}' + + def _normalize(self) -> int: + """ + will normalize the value of the unit to Byte + """ + if self.unit == Unit.sectors and self.sector_size is not None: + return self.value * self.sector_size.normalize() + return int(self.value * self.unit.value) # type: ignore + + def __sub__(self, other: Size) -> Size: + src_norm = self._normalize() + dest_norm = other._normalize() + return Size(abs(src_norm - dest_norm), Unit.B, self.sector_size) + + def __add__(self, other: Size) -> Size: + src_norm = self._normalize() + dest_norm = other._normalize() + return Size(abs(src_norm + dest_norm), Unit.B, self.sector_size) + + def __lt__(self, other): + return self._normalize() < other._normalize() + + def __le__(self, other): + return self._normalize() <= other._normalize() + + def __eq__(self, other): + return self._normalize() == other._normalize() + + def __ne__(self, other): + return self._normalize() != other._normalize() + + def __gt__(self, other): + return self._normalize() > other._normalize() + + def __ge__(self, other): + return self._normalize() >= other._normalize() + + +class BtrfsMountOption(Enum): + compress = 'compress=zstd' + nodatacow = 'nodatacow' + + +@dataclass +class _BtrfsSubvolumeInfo: + name: Path + mountpoint: Optional[Path] + + +@dataclass +class _PartitionInfo: + partition: Partition + name: str + type: PartitionType + fs_type: Optional[FilesystemType] + path: Path + start: Size + length: Size + flags: List[PartitionFlag] + partn: Optional[int] + partuuid: Optional[str] + uuid: Optional[str] + disk: Disk + mountpoints: List[Path] + btrfs_subvol_infos: List[_BtrfsSubvolumeInfo] = field(default_factory=list) + + @property + def sector_size(self) -> SectorSize: + sector_size = self.partition.geometry.device.sectorSize + return SectorSize(sector_size, Unit.B) + + def table_data(self) -> Dict[str, Any]: + end = self.start + self.length + + part_info = { + 'Name': self.name, + 'Type': self.type.value, + 'Filesystem': self.fs_type.value if self.fs_type else str(_('Unknown')), + 'Path': str(self.path), + 'Start': self.start.format_size(Unit.sectors, self.sector_size, include_unit=False), + 'End': end.format_size(Unit.sectors, self.sector_size, include_unit=False), + 'Size': self.length.format_highest(), + 'Flags': ', '.join([f.name for f in self.flags]) + } + + if self.btrfs_subvol_infos: + part_info['Btrfs vol.'] = f'{len(self.btrfs_subvol_infos)} subvolumes' + + return part_info + + @classmethod + def from_partition( + cls, + partition: Partition, + fs_type: Optional[FilesystemType], + partn: Optional[int], + partuuid: Optional[str], + uuid: Optional[str], + mountpoints: List[Path], + btrfs_subvol_infos: List[_BtrfsSubvolumeInfo] = [] + ) -> _PartitionInfo: + partition_type = PartitionType.get_type_from_code(partition.type) + flags = [f for f in PartitionFlag if partition.getFlag(f.value)] + + start = Size( + partition.geometry.start, + Unit.sectors, + SectorSize(partition.disk.device.sectorSize, Unit.B) + ) + + length = Size( + int(partition.getLength(unit='B')), + Unit.B, + SectorSize(partition.disk.device.sectorSize, Unit.B) + ) + + return _PartitionInfo( + partition=partition, + name=partition.get_name(), + type=partition_type, + fs_type=fs_type, + path=partition.path, + start=start, + length=length, + flags=flags, + partn=partn, + partuuid=partuuid, + uuid=uuid, + disk=partition.disk, + mountpoints=mountpoints, + btrfs_subvol_infos=btrfs_subvol_infos + ) + + +@dataclass +class _DeviceInfo: + model: str + path: Path + type: str + total_size: Size + free_space_regions: List[DeviceGeometry] + sector_size: SectorSize + read_only: bool + dirty: bool + + def table_data(self) -> Dict[str, Any]: + total_free_space = sum([region.get_length(unit=Unit.MiB) for region in self.free_space_regions]) + return { + 'Model': self.model, + 'Path': str(self.path), + 'Type': self.type, + 'Size': self.total_size.format_highest(), + 'Free space': int(total_free_space), + 'Sector size': self.sector_size.value, + 'Read only': self.read_only + } + + @classmethod + def from_disk(cls, disk: Disk) -> _DeviceInfo: + device = disk.device + if device.type == 18: + device_type = 'loop' + elif device.type in parted.devices: + device_type = parted.devices[device.type] + else: + debug(f'Device code unknown: {device.type}') + device_type = parted.devices[parted.DEVICE_UNKNOWN] + + sector_size = SectorSize(device.sectorSize, Unit.B) + free_space = [DeviceGeometry(g, sector_size) for g in disk.getFreeSpaceRegions()] + + sector_size = SectorSize(device.sectorSize, Unit.B) + + return _DeviceInfo( + model=device.model.strip(), + path=Path(device.path), + type=device_type, + sector_size=sector_size, + total_size=Size(int(device.getLength(unit='B')), Unit.B, sector_size), + free_space_regions=free_space, + read_only=device.readOnly, + dirty=device.dirty + ) + + +@dataclass +class SubvolumeModification: + name: Path + mountpoint: Optional[Path] = None + + @classmethod + def from_existing_subvol_info(cls, info: _BtrfsSubvolumeInfo) -> SubvolumeModification: + return SubvolumeModification(info.name, mountpoint=info.mountpoint) + + @classmethod + def parse_args(cls, subvol_args: List[Dict[str, Any]]) -> List[SubvolumeModification]: + mods = [] + for entry in subvol_args: + if not entry.get('name', None) or not entry.get('mountpoint', None): + debug(f'Subvolume arg is missing name: {entry}') + continue + + mountpoint = Path(entry['mountpoint']) if entry['mountpoint'] else None + + mods.append(SubvolumeModification(entry['name'], mountpoint)) + + return mods + + @property + def relative_mountpoint(self) -> Path: + """ + Will return the relative path based on the anchor + e.g. Path('/mnt/test') -> Path('mnt/test') + """ + if self.mountpoint is not None: + return self.mountpoint.relative_to(self.mountpoint.anchor) + + raise ValueError('Mountpoint is not specified') + + def is_root(self) -> bool: + if self.mountpoint: + return self.mountpoint == Path('/') + return False + + def json(self) -> Dict[str, Any]: + return {'name': str(self.name), 'mountpoint': str(self.mountpoint)} + + def table_data(self) -> Dict[str, Any]: + return self.json() + + +class DeviceGeometry: + def __init__(self, geometry: Geometry, sector_size: SectorSize): + self._geometry = geometry + self._sector_size = sector_size + + @property + def start(self) -> int: + return self._geometry.start + + @property + def end(self) -> int: + return self._geometry.end + + def get_length(self, unit: Unit = Unit.sectors) -> int: + return self._geometry.getLength(unit.name) + + def table_data(self) -> Dict[str, Any]: + start = Size(self._geometry.start, Unit.sectors, self._sector_size) + end = Size(self._geometry.end, Unit.sectors, self._sector_size) + length = Size(self._geometry.getLength(), Unit.sectors, self._sector_size) + + start_str = f'{self._geometry.start} / {start.format_size(Unit.B, include_unit=False)}' + end_str = f'{self._geometry.end} / {end.format_size(Unit.B, include_unit=False)}' + length_str = f'{self._geometry.getLength()} / {length.format_size(Unit.B, include_unit=False)}' + + return { + 'Sector size': self._sector_size.value, + 'Start (sector/B)': start_str, + 'End (sector/B)': end_str, + 'Size (sectors/B)': length_str + } + + +@dataclass +class BDevice: + disk: Disk + device_info: _DeviceInfo + partition_infos: List[_PartitionInfo] + + def __hash__(self): + return hash(self.disk.device.path) + + +class PartitionType(Enum): + Boot = 'boot' + Primary = 'primary' + _Unknown = 'unknown' + + @classmethod + def get_type_from_code(cls, code: int) -> PartitionType: + if code == parted.PARTITION_NORMAL: + return PartitionType.Primary + else: + debug(f'Partition code not supported: {code}') + return PartitionType._Unknown + + def get_partition_code(self) -> Optional[int]: + if self == PartitionType.Primary: + return parted.PARTITION_NORMAL + elif self == PartitionType.Boot: + return parted.PARTITION_BOOT + return None + + +class PartitionFlag(Enum): + """ + Flags are taken from _ped because pyparted uses this to look + up their flag definitions: https://github.com/dcantrell/pyparted/blob/c4e0186dad45c8efbe67c52b02c8c4319df8aa9b/src/parted/__init__.py#L200-L202 + Which is the way libparted checks for its flags: https://git.savannah.gnu.org/gitweb/?p=parted.git;a=blob;f=libparted/labels/gpt.c;hb=4a0e468ed63fff85a1f9b923189f20945b32f4f1#l183 + """ + Boot = _ped.PARTITION_BOOT + XBOOTLDR = _ped.PARTITION_BLS_BOOT # Note: parted calls this bls_boot + ESP = _ped.PARTITION_ESP + + +# class PartitionGUIDs(Enum): +# """ +# A list of Partition type GUIDs (lsblk -o+PARTTYPE) can be found here: https://en.wikipedia.org/wiki/GUID_Partition_Table#Partition_type_GUIDs +# """ +# XBOOTLDR = 'bc13c2ff-59e6-4262-a352-b275fd6f7172' + + +class FilesystemType(Enum): + Btrfs = 'btrfs' + Ext2 = 'ext2' + Ext3 = 'ext3' + Ext4 = 'ext4' + F2fs = 'f2fs' + Fat16 = 'fat16' + Fat32 = 'fat32' + Ntfs = 'ntfs' + Reiserfs = 'reiserfs' + Xfs = 'xfs' + + # this is not a FS known to parted, so be careful + # with the usage from this enum + Crypto_luks = 'crypto_LUKS' + + def is_crypto(self) -> bool: + return self == FilesystemType.Crypto_luks + + @property + def fs_type_mount(self) -> str: + match self: + case FilesystemType.Ntfs: return 'ntfs3' + case FilesystemType.Fat32: return 'vfat' + case _: return self.value # type: ignore + + @property + def installation_pkg(self) -> Optional[str]: + match self: + case FilesystemType.Btrfs: return 'btrfs-progs' + case FilesystemType.Xfs: return 'xfsprogs' + case FilesystemType.F2fs: return 'f2fs-tools' + case _: return None + + @property + def installation_module(self) -> Optional[str]: + match self: + case FilesystemType.Btrfs: return 'btrfs' + case _: return None + + @property + def installation_binary(self) -> Optional[str]: + match self: + case FilesystemType.Btrfs: return '/usr/bin/btrfs' + case _: return None + + @property + def installation_hooks(self) -> Optional[str]: + match self: + case FilesystemType.Btrfs: return 'btrfs' + case _: return None + + +class ModificationStatus(Enum): + Exist = 'existing' + Modify = 'modify' + Delete = 'delete' + Create = 'create' + + +@dataclass +class PartitionModification: + status: ModificationStatus + type: PartitionType + start: Size + length: Size + fs_type: Optional[FilesystemType] = None + mountpoint: Optional[Path] = None + mount_options: List[str] = field(default_factory=list) + flags: List[PartitionFlag] = field(default_factory=list) + btrfs_subvols: List[SubvolumeModification] = field(default_factory=list) + + # only set if the device was created or exists + dev_path: Optional[Path] = None + partn: Optional[int] = None + partuuid: Optional[str] = None + uuid: Optional[str] = None + + _efi_indicator_flags = (PartitionFlag.Boot, PartitionFlag.ESP) + _boot_indicator_flags = (PartitionFlag.Boot, PartitionFlag.XBOOTLDR) + + def __post_init__(self): + # needed to use the object as a dictionary key due to hash func + if not hasattr(self, '_obj_id'): + self._obj_id = uuid.uuid4() + + if self.is_exists_or_modify() and not self.dev_path: + raise ValueError('If partition marked as existing a path must be set') + + if self.fs_type is None and self.status == ModificationStatus.Modify: + raise ValueError('FS type must not be empty on modifications with status type modify') + + def __hash__(self): + return hash(self._obj_id) + + @property + def end(self) -> Size: + return self.start + self.length + + @property + def obj_id(self) -> str: + if hasattr(self, '_obj_id'): + return str(self._obj_id) + return '' + + @property + def safe_dev_path(self) -> Path: + if self.dev_path is None: + raise ValueError('Device path was not set') + return self.dev_path + + @property + def safe_fs_type(self) -> FilesystemType: + if self.fs_type is None: + raise ValueError('File system type is not set') + return self.fs_type + + @classmethod + def from_existing_partition(cls, partition_info: _PartitionInfo) -> PartitionModification: + if partition_info.btrfs_subvol_infos: + mountpoint = None + subvol_mods = [] + for i in partition_info.btrfs_subvol_infos: + subvol_mods.append( + SubvolumeModification.from_existing_subvol_info(i) + ) + else: + mountpoint = partition_info.mountpoints[0] if partition_info.mountpoints else None + subvol_mods = [] + + return PartitionModification( + status=ModificationStatus.Exist, + type=partition_info.type, + start=partition_info.start, + length=partition_info.length, + fs_type=partition_info.fs_type, + dev_path=partition_info.path, + partn=partition_info.partn, + partuuid=partition_info.partuuid, + uuid=partition_info.uuid, + flags=partition_info.flags, + mountpoint=mountpoint, + btrfs_subvols=subvol_mods + ) + + @property + def relative_mountpoint(self) -> Path: + """ + Will return the relative path based on the anchor + e.g. Path('/mnt/test') -> Path('mnt/test') + """ + if self.mountpoint: + return self.mountpoint.relative_to(self.mountpoint.anchor) + + raise ValueError('Mountpoint is not specified') + + def is_efi(self) -> bool: + return ( + any(set(self.flags) & set(self._efi_indicator_flags)) + and self.fs_type == FilesystemType.Fat32 + and PartitionFlag.XBOOTLDR not in self.flags + ) + + def is_boot(self) -> bool: + """ + Returns True if any of the boot indicator flags are found in self.flags + """ + return any(set(self.flags) & set(self._boot_indicator_flags)) + + def is_root(self) -> bool: + if self.mountpoint is not None: + return Path('/') == self.mountpoint + else: + for subvol in self.btrfs_subvols: + if subvol.is_root(): + return True + + return False + + def is_modify(self) -> bool: + return self.status == ModificationStatus.Modify + + def exists(self) -> bool: + return self.status == ModificationStatus.Exist + + def is_exists_or_modify(self) -> bool: + return self.status in [ModificationStatus.Exist, ModificationStatus.Modify] + + def is_create_or_modify(self) -> bool: + return self.status in [ModificationStatus.Create, ModificationStatus.Modify] + + @property + def mapper_name(self) -> Optional[str]: + if self.dev_path: + return f'{storage.get("ENC_IDENTIFIER", "ai")}{self.dev_path.name}' + return None + + def set_flag(self, flag: PartitionFlag): + if flag not in self.flags: + self.flags.append(flag) + + def invert_flag(self, flag: PartitionFlag): + if flag in self.flags: + self.flags = [f for f in self.flags if f != flag] + else: + self.set_flag(flag) + + def json(self) -> Dict[str, Any]: + """ + Called for configuration settings + """ + return { + 'obj_id': self.obj_id, + 'status': self.status.value, + 'type': self.type.value, + 'start': self.start.json(), + 'size': self.length.json(), + 'fs_type': self.fs_type.value if self.fs_type else None, + 'mountpoint': str(self.mountpoint) if self.mountpoint else None, + 'mount_options': self.mount_options, + 'flags': [f.name for f in self.flags], + 'dev_path': str(self.dev_path) if self.dev_path else None, + 'btrfs': [vol.json() for vol in self.btrfs_subvols] + } + + def table_data(self) -> Dict[str, Any]: + """ + Called for displaying data in table format + """ + part_mod = { + 'Status': self.status.value, + 'Device': str(self.dev_path) if self.dev_path else '', + 'Type': self.type.value, + 'Start': self.start.format_size(Unit.sectors, self.start.sector_size, include_unit=False), + 'End': self.end.format_size(Unit.sectors, self.start.sector_size, include_unit=False), + 'Size': self.length.format_highest(), + 'FS type': self.fs_type.value if self.fs_type else 'Unknown', + 'Mountpoint': self.mountpoint if self.mountpoint else '', + 'Mount options': ', '.join(self.mount_options), + 'Flags': ', '.join([f.name for f in self.flags]), + } + + if self.btrfs_subvols: + part_mod['Btrfs vol.'] = f'{len(self.btrfs_subvols)} subvolumes' + + return part_mod + + +class LvmLayoutType(Enum): + Default = 'default' + + # Manual = 'manual_lvm' + + def display_msg(self) -> str: + match self: + case LvmLayoutType.Default: + return str(_('Default layout')) + # case LvmLayoutType.Manual: + # return str(_('Manual configuration')) + + raise ValueError(f'Unknown type: {self}') + + +@dataclass +class LvmVolumeGroup: + name: str + pvs: List[PartitionModification] + volumes: List[LvmVolume] = field(default_factory=list) + + def json(self) -> Dict[str, Any]: + return { + 'name': self.name, + 'lvm_pvs': [p.obj_id for p in self.pvs], + 'volumes': [vol.json() for vol in self.volumes] + } + + @staticmethod + def parse_arg(arg: Dict[str, Any], disk_config: DiskLayoutConfiguration) -> LvmVolumeGroup: + lvm_pvs = [] + for mod in disk_config.device_modifications: + for part in mod.partitions: + if part.obj_id in arg.get('lvm_pvs', []): + lvm_pvs.append(part) + + return LvmVolumeGroup( + arg['name'], + lvm_pvs, + [LvmVolume.parse_arg(vol) for vol in arg['volumes']] + ) + + def contains_lv(self, lv: LvmVolume) -> bool: + return lv in self.volumes + + +class LvmVolumeStatus(Enum): + Exist = 'existing' + Modify = 'modify' + Delete = 'delete' + Create = 'create' + + +@dataclass +class LvmVolume: + status: LvmVolumeStatus + name: str + fs_type: FilesystemType + length: Size + mountpoint: Optional[Path] + mount_options: List[str] = field(default_factory=list) + btrfs_subvols: List[SubvolumeModification] = field(default_factory=list) + + # volume group name + vg_name: Optional[str] = None + # mapper device path /dev/<vg>/<vol> + dev_path: Optional[Path] = None + + def __post_init__(self): + # needed to use the object as a dictionary key due to hash func + if not hasattr(self, '_obj_id'): + self._obj_id = uuid.uuid4() + + def __hash__(self): + return hash(self._obj_id) + + @property + def obj_id(self) -> str: + if hasattr(self, '_obj_id'): + return str(self._obj_id) + return '' + + @property + def mapper_name(self) -> Optional[str]: + if self.dev_path: + return f'{storage.get("ENC_IDENTIFIER", "ai")}{self.safe_dev_path.name}' + return None + + @property + def mapper_path(self) -> Path: + if self.mapper_name: + return Path(f'/dev/mapper/{self.mapper_name}') + + raise ValueError('No mapper path set') + + @property + def safe_dev_path(self) -> Path: + if self.dev_path: + return self.dev_path + raise ValueError('No device path for volume defined') + + @property + def safe_fs_type(self) -> FilesystemType: + if self.fs_type is None: + raise ValueError('File system type is not set') + return self.fs_type + + @property + def relative_mountpoint(self) -> Path: + """ + Will return the relative path based on the anchor + e.g. Path('/mnt/test') -> Path('mnt/test') + """ + if self.mountpoint is not None: + return self.mountpoint.relative_to(self.mountpoint.anchor) + + raise ValueError('Mountpoint is not specified') + + @staticmethod + def parse_arg(arg: Dict[str, Any]) -> LvmVolume: + volume = LvmVolume( + status=LvmVolumeStatus(arg['status']), + name=arg['name'], + fs_type=FilesystemType(arg['fs_type']), + length=Size.parse_args(arg['length']), + mountpoint=Path(arg['mountpoint']) if arg['mountpoint'] else None, + mount_options=arg.get('mount_options', []), + btrfs_subvols=SubvolumeModification.parse_args(arg.get('btrfs', [])) + ) + + setattr(volume, '_obj_id', arg['obj_id']) + + return volume + + def json(self) -> Dict[str, Any]: + return { + 'obj_id': self.obj_id, + 'status': self.status.value, + 'name': self.name, + 'fs_type': self.fs_type.value, + 'length': self.length.json(), + 'mountpoint': str(self.mountpoint) if self.mountpoint else None, + 'mount_options': self.mount_options, + 'btrfs': [vol.json() for vol in self.btrfs_subvols] + } + + def table_data(self) -> Dict[str, Any]: + part_mod = { + 'Type': self.status.value, + 'Name': self.name, + 'Size': self.length.format_highest(), + 'FS type': self.fs_type.value, + 'Mountpoint': str(self.mountpoint) if self.mountpoint else '', + 'Mount options': ', '.join(self.mount_options), + 'Btrfs': '{} {}'.format(str(len(self.btrfs_subvols)), 'vol') + } + return part_mod + + def is_modify(self) -> bool: + return self.status == LvmVolumeStatus.Modify + + def exists(self) -> bool: + return self.status == LvmVolumeStatus.Exist + + def is_exists_or_modify(self) -> bool: + return self.status in [LvmVolumeStatus.Exist, LvmVolumeStatus.Modify] + + def is_root(self) -> bool: + if self.mountpoint is not None: + return Path('/') == self.mountpoint + else: + for subvol in self.btrfs_subvols: + if subvol.is_root(): + return True + + return False + + +@dataclass +class LvmGroupInfo: + vg_size: Size + vg_uuid: str + + +@dataclass +class LvmVolumeInfo: + lv_name: str + vg_name: str + lv_size: Size + + +@dataclass +class LvmPVInfo: + pv_name: Path + lv_name: str + vg_name: str + + +@dataclass +class LvmConfiguration: + config_type: LvmLayoutType + vol_groups: List[LvmVolumeGroup] + + def __post_init__(self): + # make sure all volume groups have unique PVs + pvs = [] + for group in self.vol_groups: + for pv in group.pvs: + if pv in pvs: + raise ValueError('A PV cannot be used in multiple volume groups') + pvs.append(pv) + + def json(self) -> Dict[str, Any]: + return { + 'config_type': self.config_type.value, + 'vol_groups': [vol_gr.json() for vol_gr in self.vol_groups] + } + + @staticmethod + def parse_arg(arg: Dict[str, Any], disk_config: DiskLayoutConfiguration) -> LvmConfiguration: + lvm_pvs = [] + for mod in disk_config.device_modifications: + for part in mod.partitions: + if part.obj_id in arg.get('lvm_pvs', []): + lvm_pvs.append(part) + + return LvmConfiguration( + config_type=LvmLayoutType(arg['config_type']), + vol_groups=[LvmVolumeGroup.parse_arg(vol_group, disk_config) for vol_group in arg['vol_groups']], + ) + + def get_all_pvs(self) -> List[PartitionModification]: + pvs = [] + for vg in self.vol_groups: + pvs += vg.pvs + + return pvs + + def get_all_volumes(self) -> List[LvmVolume]: + volumes = [] + + for vg in self.vol_groups: + volumes += vg.volumes + + return volumes + + def get_root_volume(self) -> Optional[LvmVolume]: + for vg in self.vol_groups: + filtered = next(filter(lambda x: x.is_root(), vg.volumes), None) + if filtered: + return filtered + + return None + + +# def get_lv_crypt_uuid(self, lv: LvmVolume, encryption: EncryptionType) -> str: +# """ +# Find the LUKS superblock UUID for the device that +# contains the given logical volume +# """ +# for vg in self.vol_groups: +# if vg.contains_lv(lv): + + +@dataclass +class DeviceModification: + device: BDevice + wipe: bool + partitions: List[PartitionModification] = field(default_factory=list) + + @property + def device_path(self) -> Path: + return self.device.device_info.path + + def add_partition(self, partition: PartitionModification): + self.partitions.append(partition) + + def get_efi_partition(self) -> Optional[PartitionModification]: + """ + Similar to get_boot_partition() but excludes XBOOTLDR partitions from it's candidates. + """ + filtered = filter(lambda x: x.is_efi() and x.mountpoint, self.partitions) + return next(filtered, None) + + def get_boot_partition(self) -> Optional[PartitionModification]: + """ + Returns the first partition marked as XBOOTLDR (PARTTYPE id of bc13c2ff-...) or Boot and has a mountpoint. + Only returns XBOOTLDR if separate EFI is detected using self.get_efi_partition() + Will return None if no suitable partition is found. + """ + if efi_partition := self.get_efi_partition(): + filtered = filter(lambda x: x.is_boot() and x != efi_partition and x.mountpoint, self.partitions) + if boot_partition := next(filtered, None): + return boot_partition + return efi_partition + else: + filtered = filter(lambda x: x.is_boot() and x.mountpoint, self.partitions) + return next(filtered, None) + + def get_root_partition(self) -> Optional[PartitionModification]: + filtered = filter(lambda x: x.is_root(), self.partitions) + return next(filtered, None) + + def json(self) -> Dict[str, Any]: + """ + Called when generating configuration files + """ + return { + 'device': str(self.device.device_info.path), + 'wipe': self.wipe, + 'partitions': [p.json() for p in self.partitions] + } + + +class EncryptionType(Enum): + NoEncryption = "no_encryption" + Luks = "luks" + LvmOnLuks = 'lvm_on_luks' + LuksOnLvm = 'luks_on_lvm' + + @classmethod + def _encryption_type_mapper(cls) -> Dict[str, 'EncryptionType']: + return { + str(_('No Encryption')): EncryptionType.NoEncryption, + str(_('LUKS')): EncryptionType.Luks, + str(_('LVM on LUKS')): EncryptionType.LvmOnLuks, + str(_('LUKS on LVM')): EncryptionType.LuksOnLvm + } + + @classmethod + def text_to_type(cls, text: str) -> 'EncryptionType': + mapping = cls._encryption_type_mapper() + return mapping[text] + + @classmethod + def type_to_text(cls, type_: 'EncryptionType') -> str: + mapping = cls._encryption_type_mapper() + type_to_text = {type_: text for text, type_ in mapping.items()} + return type_to_text[type_] + + +@dataclass +class DiskEncryption: + encryption_type: EncryptionType = EncryptionType.NoEncryption + encryption_password: str = '' + partitions: List[PartitionModification] = field(default_factory=list) + lvm_volumes: List[LvmVolume] = field(default_factory=list) + hsm_device: Optional[Fido2Device] = None + + def __post_init__(self): + if self.encryption_type in [EncryptionType.Luks, EncryptionType.LvmOnLuks] and not self.partitions: + raise ValueError('Luks or LvmOnLuks encryption require partitions to be defined') + + if self.encryption_type == EncryptionType.LuksOnLvm and not self.lvm_volumes: + raise ValueError('LuksOnLvm encryption require LMV volumes to be defined') + + def should_generate_encryption_file(self, dev: PartitionModification | LvmVolume) -> bool: + if isinstance(dev, PartitionModification): + return dev in self.partitions and dev.mountpoint != Path('/') + elif isinstance(dev, LvmVolume): + return dev in self.lvm_volumes and dev.mountpoint != Path('/') + return False + + def json(self) -> Dict[str, Any]: + obj: Dict[str, Any] = { + 'encryption_type': self.encryption_type.value, + 'partitions': [p.obj_id for p in self.partitions], + 'lvm_volumes': [vol.obj_id for vol in self.lvm_volumes] + } + + if self.hsm_device: + obj['hsm_device'] = self.hsm_device.json() + + return obj + + @classmethod + def validate_enc(cls, disk_config: DiskLayoutConfiguration) -> bool: + partitions = [] + + for mod in disk_config.device_modifications: + for part in mod.partitions: + partitions.append(part) + + if len(partitions) > 2: # assume one boot and at least 2 additional + if disk_config.lvm_config: + return False + + return True + + @classmethod + def parse_arg( + cls, + disk_config: DiskLayoutConfiguration, + arg: Dict[str, Any], + password: str = '' + ) -> Optional['DiskEncryption']: + if not cls.validate_enc(disk_config): + return None + + enc_partitions = [] + for mod in disk_config.device_modifications: + for part in mod.partitions: + if part.obj_id in arg.get('partitions', []): + enc_partitions.append(part) + + volumes = [] + if disk_config.lvm_config: + for vol in disk_config.lvm_config.get_all_volumes(): + if vol.obj_id in arg.get('lvm_volumes', []): + volumes.append(vol) + + enc = DiskEncryption( + EncryptionType(arg['encryption_type']), + password, + enc_partitions, + volumes + ) + + if hsm := arg.get('hsm_device', None): + enc.hsm_device = Fido2Device.parse_arg(hsm) + + return enc + + +@dataclass +class Fido2Device: + path: Path + manufacturer: str + product: str + + def json(self) -> Dict[str, str]: + return { + 'path': str(self.path), + 'manufacturer': self.manufacturer, + 'product': self.product + } + + def table_data(self) -> Dict[str, str]: + return { + 'Path': str(self.path), + 'Manufacturer': self.manufacturer, + 'Product': self.product + } + + @classmethod + def parse_arg(cls, arg: Dict[str, str]) -> 'Fido2Device': + return Fido2Device( + Path(arg['path']), + arg['manufacturer'], + arg['product'] + ) + + +@dataclass +class LsblkInfo: + name: str = '' + path: Path = Path() + pkname: str = '' + size: Size = field(default_factory=lambda: Size(0, Unit.B, SectorSize.default())) + log_sec: int = 0 + pttype: str = '' + ptuuid: str = '' + rota: bool = False + tran: Optional[str] = None + partn: Optional[int] = None + partuuid: Optional[str] = None + parttype: Optional[str] = None + uuid: Optional[str] = None + fstype: Optional[str] = None + fsver: Optional[str] = None + fsavail: Optional[str] = None + fsuse_percentage: Optional[str] = None + type: Optional[str] = None + mountpoint: Optional[Path] = None + mountpoints: List[Path] = field(default_factory=list) + fsroots: List[Path] = field(default_factory=list) + children: List[LsblkInfo] = field(default_factory=list) + + def json(self) -> Dict[str, Any]: + return { + 'name': self.name, + 'path': str(self.path), + 'pkname': self.pkname, + 'size': self.size.format_size(Unit.MiB), + 'log_sec': self.log_sec, + 'pttype': self.pttype, + 'ptuuid': self.ptuuid, + 'rota': self.rota, + 'tran': self.tran, + 'partn': self.partn, + 'partuuid': self.partuuid, + 'parttype': self.parttype, + 'uuid': self.uuid, + 'fstype': self.fstype, + 'fsver': self.fsver, + 'fsavail': self.fsavail, + 'fsuse_percentage': self.fsuse_percentage, + 'type': self.type, + 'mountpoint': self.mountpoint, + 'mountpoints': [str(m) for m in self.mountpoints], + 'fsroots': [str(r) for r in self.fsroots], + 'children': [c.json() for c in self.children] + } + + @property + def btrfs_subvol_info(self) -> Dict[Path, Path]: + """ + It is assumed that lsblk will contain the fields as + + "mountpoints": ["/mnt/archinstall/log", "/mnt/archinstall/home", "/mnt/archinstall", ...] + "fsroots": ["/@log", "/@home", "/@"...] + + we'll thereby map the fsroot, which are the mounted filesystem roots + to the corresponding mountpoints + """ + return dict(zip(self.fsroots, self.mountpoints)) + + @classmethod + def exclude(cls) -> List[str]: + return ['children'] + + @classmethod + def fields(cls) -> List[str]: + return [f.name for f in dataclasses.fields(LsblkInfo) if f.name not in cls.exclude()] + + @classmethod + def from_json(cls, blockdevice: Dict[str, Any]) -> LsblkInfo: + lsblk_info = cls() + + for f in cls.fields(): + lsblk_field = _clean_field(f, CleanType.Blockdevice) + data_field = _clean_field(f, CleanType.Dataclass) + + val: Any = None + if isinstance(getattr(lsblk_info, data_field), Path): + val = Path(blockdevice[lsblk_field]) + elif isinstance(getattr(lsblk_info, data_field), Size): + sector_size = SectorSize(blockdevice['log-sec'], Unit.B) + val = Size(blockdevice[lsblk_field], Unit.B, sector_size) + else: + val = blockdevice[lsblk_field] + + setattr(lsblk_info, data_field, val) + + lsblk_info.children = [LsblkInfo.from_json(child) for child in blockdevice.get('children', [])] + + # sometimes lsblk returns 'mountpoints': [null] + lsblk_info.mountpoints = [Path(mnt) for mnt in lsblk_info.mountpoints if mnt] + + fs_roots = [] + for r in lsblk_info.fsroots: + if r: + path = Path(r) + # store the fsroot entries without the leading / + fs_roots.append(path.relative_to(path.anchor)) + lsblk_info.fsroots = fs_roots + + return lsblk_info + + +class CleanType(Enum): + Blockdevice = auto() + Dataclass = auto() + Lsblk = auto() + + +def _clean_field(name: str, clean_type: CleanType) -> str: + match clean_type: + case CleanType.Blockdevice: + return name.replace('_percentage', '%').replace('_', '-') + case CleanType.Dataclass: + return name.lower().replace('-', '_').replace('%', '_percentage') + case CleanType.Lsblk: + return name.replace('_percentage', '%').replace('_', '-') + + +def _fetch_lsblk_info( + dev_path: Optional[Union[Path, str]] = None, + reverse: bool = False, + full_dev_path: bool = False +) -> List[LsblkInfo]: + fields = [_clean_field(f, CleanType.Lsblk) for f in LsblkInfo.fields()] + cmd = ['lsblk', '--json', '--bytes', '--output', '+' + ','.join(fields)] + + if dev_path: + cmd.append(str(dev_path)) + + if reverse: + cmd.append('--inverse') + + if full_dev_path: + cmd.append('--paths') + + try: + result = SysCommand(cmd).decode() + except SysCallError as err: + # Get the output minus the message/info from lsblk if it returns a non-zero exit code. + if err.worker: + err_str = err.worker.decode() + debug(f'Error calling lsblk: {err_str}') + + if dev_path: + raise DiskError(f'Failed to read disk "{dev_path}" with lsblk') + + raise err + + try: + block_devices = json.loads(result) + except json.decoder.JSONDecodeError as err: + error(f"Could not decode lsblk JSON: {result}") + raise err + + blockdevices = block_devices['blockdevices'] + return [LsblkInfo.from_json(device) for device in blockdevices] + + +def get_lsblk_info( + dev_path: Union[Path, str], + reverse: bool = False, + full_dev_path: bool = False +) -> LsblkInfo: + if infos := _fetch_lsblk_info(dev_path, reverse=reverse, full_dev_path=full_dev_path): + return infos[0] + + raise DiskError(f'lsblk failed to retrieve information for "{dev_path}"') + + +def get_all_lsblk_info() -> List[LsblkInfo]: + return _fetch_lsblk_info() + + +def get_lsblk_by_mountpoint(mountpoint: Path, as_prefix: bool = False) -> List[LsblkInfo]: + def _check(infos: List[LsblkInfo]) -> List[LsblkInfo]: + devices = [] + for entry in infos: + if as_prefix: + matches = [m for m in entry.mountpoints if str(m).startswith(str(mountpoint))] + if matches: + devices += [entry] + elif mountpoint in entry.mountpoints: + devices += [entry] + + if len(entry.children) > 0: + if len(match := _check(entry.children)) > 0: + devices += match + + return devices + + all_info = get_all_lsblk_info() + return _check(all_info) diff --git a/archinstall/lib/disk/disk_menu.py b/archinstall/lib/disk/disk_menu.py new file mode 100644 index 00000000..a7d9ccc3 --- /dev/null +++ b/archinstall/lib/disk/disk_menu.py @@ -0,0 +1,140 @@ +from typing import Dict, Optional, Any, TYPE_CHECKING, List + +from . import DiskLayoutConfiguration, DiskLayoutType +from .device_model import LvmConfiguration +from ..disk import ( + DeviceModification +) +from ..interactions import select_disk_config +from ..interactions.disk_conf import select_lvm_config +from ..menu import ( + Selector, + AbstractSubMenu +) +from ..output import FormattedOutput + +if TYPE_CHECKING: + _: Any + + +class DiskLayoutConfigurationMenu(AbstractSubMenu): + def __init__( + self, + disk_layout_config: Optional[DiskLayoutConfiguration], + data_store: Dict[str, Any], + advanced: bool = False + ): + self._disk_layout_config = disk_layout_config + self._advanced = advanced + + super().__init__(data_store=data_store, preview_size=0.5) + + def setup_selection_menu_options(self): + self._menu_options['disk_config'] = \ + Selector( + _('Partitioning'), + lambda x: self._select_disk_layout_config(x), + display_func=lambda x: self._display_disk_layout(x), + preview_func=self._prev_disk_layouts, + default=self._disk_layout_config, + enabled=True + ) + self._menu_options['lvm_config'] = \ + Selector( + _('Logical Volume Management (LVM)'), + lambda x: self._select_lvm_config(x), + display_func=lambda x: self.defined_text if x else '', + preview_func=self._prev_lvm_config, + default=self._disk_layout_config.lvm_config if self._disk_layout_config else None, + dependencies=[self._check_dep_lvm], + enabled=True + ) + + def run(self, allow_reset: bool = True) -> Optional[DiskLayoutConfiguration]: + super().run(allow_reset=allow_reset) + + disk_layout_config: Optional[DiskLayoutConfiguration] = self._data_store.get('disk_config', None) + + if disk_layout_config: + disk_layout_config.lvm_config = self._data_store.get('lvm_config', None) + + return disk_layout_config + + def _check_dep_lvm(self) -> bool: + disk_layout_conf: Optional[DiskLayoutConfiguration] = self._menu_options['disk_config'].current_selection + + if disk_layout_conf and disk_layout_conf.config_type == DiskLayoutType.Default: + return True + + return False + + def _select_disk_layout_config( + self, + preset: Optional[DiskLayoutConfiguration] + ) -> Optional[DiskLayoutConfiguration]: + disk_config = select_disk_config(preset, advanced_option=self._advanced) + + if disk_config != preset: + self._menu_options['lvm_config'].set_current_selection(None) + + return disk_config + + def _select_lvm_config(self, preset: Optional[LvmConfiguration]) -> Optional[LvmConfiguration]: + disk_config: Optional[DiskLayoutConfiguration] = self._menu_options['disk_config'].current_selection + if disk_config: + return select_lvm_config(disk_config, preset=preset) + return preset + + def _display_disk_layout(self, current_value: Optional[DiskLayoutConfiguration] = None) -> str: + if current_value: + return current_value.config_type.display_msg() + return '' + + def _prev_disk_layouts(self) -> Optional[str]: + disk_layout_conf: Optional[DiskLayoutConfiguration] = self._menu_options['disk_config'].current_selection + + if disk_layout_conf: + device_mods: List[DeviceModification] = \ + list(filter(lambda x: len(x.partitions) > 0, disk_layout_conf.device_modifications)) + + if device_mods: + output_partition = '{}: {}\n'.format(str(_('Configuration')), disk_layout_conf.config_type.display_msg()) + output_btrfs = '' + + for mod in device_mods: + # create partition table + partition_table = FormattedOutput.as_table(mod.partitions) + + output_partition += f'{mod.device_path}: {mod.device.device_info.model}\n' + output_partition += partition_table + '\n' + + # create btrfs table + btrfs_partitions = list( + filter(lambda p: len(p.btrfs_subvols) > 0, mod.partitions) + ) + for partition in btrfs_partitions: + output_btrfs += FormattedOutput.as_table(partition.btrfs_subvols) + '\n' + + output = output_partition + output_btrfs + return output.rstrip() + + return None + + def _prev_lvm_config(self) -> Optional[str]: + lvm_config: Optional[LvmConfiguration] = self._menu_options['lvm_config'].current_selection + + if lvm_config: + output = '{}: {}\n'.format(str(_('Configuration')), lvm_config.config_type.display_msg()) + + for vol_gp in lvm_config.vol_groups: + pv_table = FormattedOutput.as_table(vol_gp.pvs) + output += '{}:\n{}'.format(str(_('Physical volumes')), pv_table) + + output += f'\nVolume Group: {vol_gp.name}' + + lvm_volumes = FormattedOutput.as_table(vol_gp.volumes) + output += '\n\n{}:\n{}'.format(str(_('Volumes')), lvm_volumes) + + return output + + return None diff --git a/archinstall/lib/disk/diskinfo.py b/archinstall/lib/disk/diskinfo.py deleted file mode 100644 index b56ba282..00000000 --- a/archinstall/lib/disk/diskinfo.py +++ /dev/null @@ -1,40 +0,0 @@ -import dataclasses -import json -from dataclasses import dataclass, field -from typing import Optional, List - -from ..general import SysCommand -from ..exceptions import DiskError - -@dataclass -class LsblkInfo: - size: int = 0 - log_sec: int = 0 - pttype: Optional[str] = None - rota: bool = False - tran: Optional[str] = None - ptuuid: Optional[str] = None - partuuid: Optional[str] = None - uuid: Optional[str] = None - fstype: Optional[str] = None - type: Optional[str] = None - mountpoints: List[str] = field(default_factory=list) - - -def get_lsblk_info(dev_path: str) -> LsblkInfo: - fields = [f.name for f in dataclasses.fields(LsblkInfo)] - lsblk_fields = ','.join([f.upper().replace('_', '-') for f in fields]) - - output = SysCommand(f'lsblk --json -b -o+{lsblk_fields} {dev_path}').decode('UTF-8') - - if output: - block_devices = json.loads(output) - info = block_devices['blockdevices'][0] - lsblk_info = LsblkInfo() - - for f in fields: - setattr(lsblk_info, f, info[f.replace('_', '-')]) - - return lsblk_info - - raise DiskError(f'Failed to read disk "{dev_path}" with lsblk') diff --git a/archinstall/lib/disk/dmcryptdev.py b/archinstall/lib/disk/dmcryptdev.py deleted file mode 100644 index 63392ffb..00000000 --- a/archinstall/lib/disk/dmcryptdev.py +++ /dev/null @@ -1,48 +0,0 @@ -import pathlib -import logging -import json -from dataclasses import dataclass -from typing import Optional -from ..exceptions import SysCallError -from ..general import SysCommand -from ..output import log -from .mapperdev import MapperDev - -@dataclass -class DMCryptDev: - dev_path :pathlib.Path - - @property - def name(self): - with open(f"/sys/devices/virtual/block/{pathlib.Path(self.path).name}/dm/name", "r") as fh: - return fh.read().strip() - - @property - def path(self): - return f"/dev/mapper/{self.dev_path}" - - @property - def blockdev(self): - pass - - @property - def MapperDev(self): - return MapperDev(mappername=self.name) - - @property - def mountpoint(self) -> Optional[str]: - try: - data = json.loads(SysCommand(f"findmnt --json -R {self.dev_path}").decode()) - for filesystem in data['filesystems']: - return filesystem.get('target') - - except SysCallError as error: - # Not mounted anywhere most likely - log(f"Could not locate mount information for {self.dev_path}: {error}", level=logging.WARNING, fg="yellow") - pass - - return None - - @property - def filesystem(self) -> Optional[str]: - return self.MapperDev.filesystem
\ No newline at end of file diff --git a/archinstall/lib/disk/encryption.py b/archinstall/lib/disk/encryption.py deleted file mode 100644 index c7496bfa..00000000 --- a/archinstall/lib/disk/encryption.py +++ /dev/null @@ -1,174 +0,0 @@ -from typing import Dict, Optional, Any, TYPE_CHECKING, List - -from ..menu.abstract_menu import Selector, AbstractSubMenu -from ..menu.menu import MenuSelectionType -from ..menu.table_selection_menu import TableMenu -from ..models.disk_encryption import EncryptionType, DiskEncryption -from ..user_interaction.partitioning_conf import current_partition_layout -from ..user_interaction.utils import get_password -from ..menu import Menu -from ..general import secret -from ..hsm.fido import Fido2Device, Fido2 - -if TYPE_CHECKING: - _: Any - - -class DiskEncryptionMenu(AbstractSubMenu): - def __init__(self, data_store: Dict[str, Any], preset: Optional[DiskEncryption], disk_layouts: Dict[str, Any]): - if preset: - self._preset = preset - else: - self._preset = DiskEncryption() - - self._disk_layouts = disk_layouts - super().__init__(data_store=data_store) - - def _setup_selection_menu_options(self): - self._menu_options['encryption_password'] = \ - Selector( - _('Encryption password'), - lambda x: select_encrypted_password(), - display_func=lambda x: secret(x) if x else '', - default=self._preset.encryption_password, - enabled=True - ) - self._menu_options['encryption_type'] = \ - Selector( - _('Encryption type'), - func=lambda preset: select_encryption_type(preset), - display_func=lambda x: EncryptionType.type_to_text(x) if x else None, - dependencies=['encryption_password'], - default=self._preset.encryption_type, - enabled=True - ) - self._menu_options['partitions'] = \ - Selector( - _('Partitions'), - func=lambda preset: select_partitions_to_encrypt(self._disk_layouts, preset), - display_func=lambda x: f'{sum([len(y) for y in x.values()])} {_("Partitions")}' if x else None, - dependencies=['encryption_password'], - default=self._preset.partitions, - preview_func=self._prev_disk_layouts, - enabled=True - ) - self._menu_options['HSM'] = \ - Selector( - description=_('Use HSM to unlock encrypted drive'), - func=lambda preset: select_hsm(preset), - display_func=lambda x: self._display_hsm(x), - dependencies=['encryption_password'], - default=self._preset.hsm_device, - enabled=True - ) - - def run(self, allow_reset: bool = True) -> Optional[DiskEncryption]: - super().run(allow_reset=allow_reset) - - if self._data_store.get('encryption_password', None): - return DiskEncryption( - encryption_password=self._data_store.get('encryption_password', None), - encryption_type=self._data_store['encryption_type'], - partitions=self._data_store.get('partitions', None), - hsm_device=self._data_store.get('HSM', None) - ) - - return None - - def _display_hsm(self, device: Optional[Fido2Device]) -> Optional[str]: - if device: - return device.manufacturer - - if not Fido2.get_fido2_devices(): - return str(_('No HSM devices available')) - return None - - def _prev_disk_layouts(self) -> Optional[str]: - selector = self._menu_options['partitions'] - if selector.has_selection(): - partitions: Dict[str, Any] = selector.current_selection - - all_partitions = [] - for parts in partitions.values(): - all_partitions += parts - - output = str(_('Partitions to be encrypted')) + '\n' - output += current_partition_layout(all_partitions, with_title=False) - return output.rstrip() - return None - - -def select_encryption_type(preset: EncryptionType) -> Optional[EncryptionType]: - title = str(_('Select disk encryption option')) - options = [ - # _type_to_text(EncryptionType.FullDiskEncryption), - EncryptionType.type_to_text(EncryptionType.Partition) - ] - - preset_value = EncryptionType.type_to_text(preset) - choice = Menu(title, options, preset_values=preset_value).run() - - match choice.type_: - case MenuSelectionType.Reset: return None - case MenuSelectionType.Skip: return preset - case MenuSelectionType.Selection: return EncryptionType.text_to_type(choice.value) # type: ignore - - -def select_encrypted_password() -> Optional[str]: - if passwd := get_password(prompt=str(_('Enter disk encryption password (leave blank for no encryption): '))): - return passwd - return None - - -def select_hsm(preset: Optional[Fido2Device] = None) -> Optional[Fido2Device]: - title = _('Select a FIDO2 device to use for HSM') - fido_devices = Fido2.get_fido2_devices() - - if fido_devices: - choice = TableMenu(title, data=fido_devices).run() - match choice.type_: - case MenuSelectionType.Reset: - return None - case MenuSelectionType.Skip: - return preset - case MenuSelectionType.Selection: - return choice.value # type: ignore - - return None - - -def select_partitions_to_encrypt(disk_layouts: Dict[str, Any], preset: Dict[str, Any]) -> Dict[str, Any]: - # If no partitions was marked as encrypted, but a password was supplied and we have some disks to format.. - # Then we need to identify which partitions to encrypt. This will default to / (root). - all_partitions = [] - for blockdevice in disk_layouts.values(): - if partitions := blockdevice.get('partitions'): - partitions = [p for p in partitions if p['mountpoint'] != '/boot'] - all_partitions += partitions - - if all_partitions: - title = str(_('Select which partitions to encrypt')) - partition_table = current_partition_layout(all_partitions, with_title=False).strip() - - choice = TableMenu( - title, - table_data=(all_partitions, partition_table), - multi=True - ).run() - - match choice.type_: - case MenuSelectionType.Reset: - return {} - case MenuSelectionType.Skip: - return preset - case MenuSelectionType.Selection: - selections: List[Any] = choice.value # type: ignore - partitions = {} - - for path, device in disk_layouts.items(): - for part in selections: - if part in device.get('partitions', []): - partitions.setdefault(path, []).append(part) - - return partitions - return {} diff --git a/archinstall/lib/disk/encryption_menu.py b/archinstall/lib/disk/encryption_menu.py new file mode 100644 index 00000000..b0e292ce --- /dev/null +++ b/archinstall/lib/disk/encryption_menu.py @@ -0,0 +1,288 @@ +from pathlib import Path +from typing import Dict, Optional, Any, TYPE_CHECKING, List + +from . import LvmConfiguration, LvmVolume +from ..disk import ( + DeviceModification, + DiskLayoutConfiguration, + PartitionModification, + DiskEncryption, + EncryptionType +) +from ..menu import ( + Selector, + AbstractSubMenu, + MenuSelectionType, + TableMenu +) +from ..interactions.utils import get_password +from ..menu import Menu +from ..general import secret +from .fido import Fido2Device, Fido2 +from ..output import FormattedOutput + +if TYPE_CHECKING: + _: Any + + +class DiskEncryptionMenu(AbstractSubMenu): + def __init__( + self, + disk_config: DiskLayoutConfiguration, + data_store: Dict[str, Any], + preset: Optional[DiskEncryption] = None + ): + if preset: + self._preset = preset + else: + self._preset = DiskEncryption() + + self._disk_config = disk_config + super().__init__(data_store=data_store) + + def setup_selection_menu_options(self): + self._menu_options['encryption_type'] = \ + Selector( + _('Encryption type'), + func=lambda preset: select_encryption_type(self._disk_config, preset), + display_func=lambda x: EncryptionType.type_to_text(x) if x else None, + default=self._preset.encryption_type, + enabled=True, + ) + self._menu_options['encryption_password'] = \ + Selector( + _('Encryption password'), + lambda x: select_encrypted_password(), + dependencies=[self._check_dep_enc_type], + display_func=lambda x: secret(x) if x else '', + default=self._preset.encryption_password, + enabled=True + ) + self._menu_options['partitions'] = \ + Selector( + _('Partitions'), + func=lambda preset: select_partitions_to_encrypt(self._disk_config.device_modifications, preset), + display_func=lambda x: f'{len(x)} {_("Partitions")}' if x else None, + dependencies=[self._check_dep_partitions], + default=self._preset.partitions, + preview_func=self._prev_partitions, + enabled=True + ) + self._menu_options['lvm_vols'] = \ + Selector( + _('LVM volumes'), + func=lambda preset: self._select_lvm_vols(preset), + display_func=lambda x: f'{len(x)} {_("LVM volumes")}' if x else None, + dependencies=[self._check_dep_lvm_vols], + default=self._preset.lvm_volumes, + preview_func=self._prev_lvm_vols, + enabled=True + ) + self._menu_options['HSM'] = \ + Selector( + description=_('Use HSM to unlock encrypted drive'), + func=lambda preset: select_hsm(preset), + display_func=lambda x: self._display_hsm(x), + preview_func=self._prev_hsm, + dependencies=[self._check_dep_enc_type], + default=self._preset.hsm_device, + enabled=True + ) + + def _select_lvm_vols(self, preset: List[LvmVolume]) -> List[LvmVolume]: + if self._disk_config.lvm_config: + return select_lvm_vols_to_encrypt(self._disk_config.lvm_config, preset=preset) + return [] + + def _check_dep_enc_type(self) -> bool: + enc_type: Optional[EncryptionType] = self._menu_options['encryption_type'].current_selection + if enc_type and enc_type != EncryptionType.NoEncryption: + return True + return False + + def _check_dep_partitions(self) -> bool: + enc_type: Optional[EncryptionType] = self._menu_options['encryption_type'].current_selection + if enc_type and enc_type in [EncryptionType.Luks, EncryptionType.LvmOnLuks]: + return True + return False + + def _check_dep_lvm_vols(self) -> bool: + enc_type: Optional[EncryptionType] = self._menu_options['encryption_type'].current_selection + if enc_type and enc_type == EncryptionType.LuksOnLvm: + return True + return False + + def run(self, allow_reset: bool = True) -> Optional[DiskEncryption]: + super().run(allow_reset=allow_reset) + + enc_type = self._data_store.get('encryption_type', None) + enc_password = self._data_store.get('encryption_password', None) + enc_partitions = self._data_store.get('partitions', None) + enc_lvm_vols = self._data_store.get('lvm_vols', None) + + if enc_type in [EncryptionType.Luks, EncryptionType.LvmOnLuks] and enc_partitions: + enc_lvm_vols = [] + + if enc_type == EncryptionType.LuksOnLvm: + enc_partitions = [] + + if enc_type != EncryptionType.NoEncryption and enc_password and (enc_partitions or enc_lvm_vols): + return DiskEncryption( + encryption_password=enc_password, + encryption_type=enc_type, + partitions=enc_partitions, + lvm_volumes=enc_lvm_vols, + hsm_device=self._data_store.get('HSM', None) + ) + + return None + + def _display_hsm(self, device: Optional[Fido2Device]) -> Optional[str]: + if device: + return device.manufacturer + + return None + + def _prev_partitions(self) -> Optional[str]: + partitions: Optional[List[PartitionModification]] = self._menu_options['partitions'].current_selection + if partitions: + output = str(_('Partitions to be encrypted')) + '\n' + output += FormattedOutput.as_table(partitions) + return output.rstrip() + + return None + + def _prev_lvm_vols(self) -> Optional[str]: + volumes: Optional[List[PartitionModification]] = self._menu_options['lvm_vols'].current_selection + if volumes: + output = str(_('LVM volumes to be encrypted')) + '\n' + output += FormattedOutput.as_table(volumes) + return output.rstrip() + + return None + + def _prev_hsm(self) -> Optional[str]: + try: + Fido2.get_fido2_devices() + except ValueError: + return str(_('Unable to determine fido2 devices. Is libfido2 installed?')) + + fido_device: Optional[Fido2Device] = self._menu_options['HSM'].current_selection + + if fido_device: + output = '{}: {}'.format(str(_('Path')), fido_device.path) + output += '{}: {}'.format(str(_('Manufacturer')), fido_device.manufacturer) + output += '{}: {}'.format(str(_('Product')), fido_device.product) + return output + + return None + + +def select_encryption_type(disk_config: DiskLayoutConfiguration, preset: EncryptionType) -> Optional[EncryptionType]: + title = str(_('Select disk encryption option')) + + if disk_config.lvm_config: + options = [ + EncryptionType.type_to_text(EncryptionType.LvmOnLuks), + EncryptionType.type_to_text(EncryptionType.LuksOnLvm) + ] + else: + options = [EncryptionType.type_to_text(EncryptionType.Luks)] + + preset_value = EncryptionType.type_to_text(preset) + + choice = Menu(title, options, preset_values=preset_value).run() + + match choice.type_: + case MenuSelectionType.Reset: return None + case MenuSelectionType.Skip: return preset + case MenuSelectionType.Selection: return EncryptionType.text_to_type(choice.value) # type: ignore + + +def select_encrypted_password() -> Optional[str]: + if passwd := get_password(prompt=str(_('Enter disk encryption password (leave blank for no encryption): '))): + return passwd + return None + + +def select_hsm(preset: Optional[Fido2Device] = None) -> Optional[Fido2Device]: + title = _('Select a FIDO2 device to use for HSM') + + try: + fido_devices = Fido2.get_fido2_devices() + except ValueError: + return None + + if fido_devices: + choice = TableMenu(title, data=fido_devices).run() + match choice.type_: + case MenuSelectionType.Reset: + return None + case MenuSelectionType.Skip: + return preset + case MenuSelectionType.Selection: + return choice.value # type: ignore + + return None + + +def select_partitions_to_encrypt( + modification: List[DeviceModification], + preset: List[PartitionModification] +) -> List[PartitionModification]: + partitions: List[PartitionModification] = [] + + # do not allow encrypting the boot partition + for mod in modification: + partitions += list(filter(lambda x: x.mountpoint != Path('/boot'), mod.partitions)) + + # do not allow encrypting existing partitions that are not marked as wipe + avail_partitions = list(filter(lambda x: not x.exists(), partitions)) + + if avail_partitions: + title = str(_('Select which partitions to encrypt')) + partition_table = FormattedOutput.as_table(avail_partitions) + + choice = TableMenu( + title, + table_data=(avail_partitions, partition_table), + preset=preset, + multi=True + ).run() + + match choice.type_: + case MenuSelectionType.Reset: + return [] + case MenuSelectionType.Skip: + return preset + case MenuSelectionType.Selection: + return choice.multi_value + return [] + + +def select_lvm_vols_to_encrypt( + lvm_config: LvmConfiguration, + preset: List[LvmVolume] +) -> List[LvmVolume]: + volumes: List[LvmVolume] = lvm_config.get_all_volumes() + + if volumes: + title = str(_('Select which LVM volumes to encrypt')) + partition_table = FormattedOutput.as_table(volumes) + + choice = TableMenu( + title, + table_data=(volumes, partition_table), + preset=preset, + multi=True + ).run() + + match choice.type_: + case MenuSelectionType.Reset: + return [] + case MenuSelectionType.Skip: + return preset + case MenuSelectionType.Selection: + return choice.multi_value + + return [] diff --git a/archinstall/lib/hsm/fido.py b/archinstall/lib/disk/fido.py index 1c226322..5a139534 100644 --- a/archinstall/lib/hsm/fido.py +++ b/archinstall/lib/disk/fido.py @@ -1,37 +1,13 @@ from __future__ import annotations import getpass -import logging - -from dataclasses import dataclass from pathlib import Path -from typing import List, Dict +from typing import List +from .device_model import Fido2Device from ..general import SysCommand, SysCommandWorker, clear_vt100_escape_codes -from ..disk.partition import Partition -from ..general import log - - -@dataclass -class Fido2Device: - path: Path - manufacturer: str - product: str - - def json(self) -> Dict[str, str]: - return { - 'path': str(self.path), - 'manufacturer': self.manufacturer, - 'product': self.product - } - - @classmethod - def parse_arg(cls, arg: Dict[str, str]) -> 'Fido2Device': - return Fido2Device( - Path(arg['path']), - arg['manufacturer'], - arg['product'] - ) +from ..output import error, info +from ..exceptions import SysCallError class Fido2: @@ -58,15 +34,16 @@ class Fido2: /dev/hidraw1 Yubico YubiKey OTP+FIDO+CCID """ - # to prevent continous reloading which will slow + # to prevent continuous reloading which will slow # down moving the cursor in the menu if not cls._loaded or reload: - ret = SysCommand(f"systemd-cryptenroll --fido2-device=list").decode('UTF-8') - if not ret: - log('Unable to retrieve fido2 devices', level=logging.ERROR) - return [] + try: + ret = SysCommand("systemd-cryptenroll --fido2-device=list").decode() + except SysCallError: + error('fido2 support is most likely not installed') + raise ValueError('HSM devices can not be detected, is libfido2 installed?') - fido_devices = clear_vt100_escape_codes(ret) + fido_devices: str = clear_vt100_escape_codes(ret) # type: ignore manufacturer_pos = 0 product_pos = 0 @@ -83,7 +60,7 @@ class Fido2: product = line[product_pos:] devices.append( - Fido2Device(path, manufacturer, product) + Fido2Device(Path(path), manufacturer, product) ) cls._loaded = True @@ -92,18 +69,24 @@ class Fido2: return cls._fido2_devices @classmethod - def fido2_enroll(cls, hsm_device: Fido2Device, partition :Partition, password :str): - worker = SysCommandWorker(f"systemd-cryptenroll --fido2-device={hsm_device.path} {partition.real_device}", peek_output=True) + def fido2_enroll( + cls, + hsm_device: Fido2Device, + dev_path: Path, + password: str + ): + worker = SysCommandWorker(f"systemd-cryptenroll --fido2-device={hsm_device.path} {dev_path}", peek_output=True) pw_inputted = False pin_inputted = False while worker.is_alive(): - if pw_inputted is False and bytes(f"please enter current passphrase for disk {partition.real_device}", 'UTF-8') in worker._trace_log.lower(): - worker.write(bytes(password, 'UTF-8')) - pw_inputted = True - - elif pin_inputted is False and bytes(f"please enter security token pin", 'UTF-8') in worker._trace_log.lower(): - worker.write(bytes(getpass.getpass(" "), 'UTF-8')) - pin_inputted = True - - log(f"You might need to touch the FIDO2 device to unlock it if no prompt comes up after 3 seconds.", level=logging.INFO, fg="yellow") + if pw_inputted is False: + if bytes(f"please enter current passphrase for disk {dev_path}", 'UTF-8') in worker._trace_log.lower(): + worker.write(bytes(password, 'UTF-8')) + pw_inputted = True + elif pin_inputted is False: + if bytes(f"please enter security token pin", 'UTF-8') in worker._trace_log.lower(): + worker.write(bytes(getpass.getpass(" "), 'UTF-8')) + pin_inputted = True + + info('You might need to touch the FIDO2 device to unlock it if no prompt comes up after 3 seconds') diff --git a/archinstall/lib/disk/filesystem.py b/archinstall/lib/disk/filesystem.py index 1083df53..5c11896e 100644 --- a/archinstall/lib/disk/filesystem.py +++ b/archinstall/lib/disk/filesystem.py @@ -1,301 +1,381 @@ from __future__ import annotations + +import signal +import sys import time -import logging -import json -import pathlib -from typing import Optional, Dict, Any, TYPE_CHECKING -# https://stackoverflow.com/a/39757388/929999 -from ..models.disk_encryption import DiskEncryption +from pathlib import Path +from typing import Any, Optional, TYPE_CHECKING, List, Dict, Set + +from .device_handler import device_handler +from .device_model import ( + DiskLayoutConfiguration, DiskLayoutType, PartitionTable, + FilesystemType, DiskEncryption, LvmVolumeGroup, + Size, Unit, SectorSize, PartitionModification, EncryptionType, + LvmVolume, LvmConfiguration +) +from ..hardware import SysInfo +from ..luks import Luks2 +from ..menu import Menu +from ..output import debug, info +from ..general import SysCommand if TYPE_CHECKING: - from .blockdevice import BlockDevice _: Any -from .partition import Partition -from .validators import valid_fs_type -from ..exceptions import DiskError, SysCallError -from ..general import SysCommand -from ..output import log -from ..storage import storage -GPT = 0b00000001 -MBR = 0b00000010 +class FilesystemHandler: + def __init__( + self, + disk_config: DiskLayoutConfiguration, + enc_conf: Optional[DiskEncryption] = None + ): + self._disk_config = disk_config + self._enc_config = enc_conf + + def perform_filesystem_operations(self, show_countdown: bool = True): + if self._disk_config.config_type == DiskLayoutType.Pre_mount: + debug('Disk layout configuration is set to pre-mount, not performing any operations') + return + + device_mods = list(filter(lambda x: len(x.partitions) > 0, self._disk_config.device_modifications)) + + if not device_mods: + debug('No modifications required') + return + + device_paths = ', '.join([str(mod.device.device_info.path) for mod in device_mods]) + + # Issue a final warning before we continue with something un-revertable. + # We mention the drive one last time, and count from 5 to 0. + print(str(_(' ! Formatting {} in ')).format(device_paths)) + + if show_countdown: + self._do_countdown() + + # Setup the blockdevice, filesystem (and optionally encryption). + # Once that's done, we'll hand over to perform_installation() + partition_table = PartitionTable.GPT + if SysInfo.has_uefi() is False: + partition_table = PartitionTable.MBR + + for mod in device_mods: + device_handler.partition(mod, partition_table=partition_table) + + if self._disk_config.lvm_config: + for mod in device_mods: + if boot_part := mod.get_boot_partition(): + debug(f'Formatting boot partition: {boot_part.dev_path}') + self._format_partitions( + [boot_part], + mod.device_path + ) + + self.perform_lvm_operations() + else: + for mod in device_mods: + self._format_partitions( + mod.partitions, + mod.device_path + ) -# A sane default is 5MiB, that allows for plenty of buffer for GRUB on MBR -# but also 4MiB for memory cards for instance. And another 1MiB to avoid issues. -# (we've been pestered by disk issues since the start, so please let this be here for a few versions) -DEFAULT_PARTITION_START = '5MiB' + for part_mod in mod.partitions: + if part_mod.fs_type == FilesystemType.Btrfs: + device_handler.create_btrfs_volumes(part_mod, enc_conf=self._enc_config) -class Filesystem: - # TODO: - # When instance of a HDD is selected, check all usages and gracefully unmount them - # as well as close any crypto handles. - def __init__(self, blockdevice :BlockDevice, mode :int): - self.blockdevice = blockdevice - self.mode = mode + def _format_partitions( + self, + partitions: List[PartitionModification], + device_path: Path + ): + """ + Format can be given an overriding path, for instance /dev/null to test + the formatting functionality and in essence the support for the given filesystem. + """ - def __enter__(self, *args :str, **kwargs :str) -> 'Filesystem': - return self + # don't touch existing partitions + create_or_modify_parts = [p for p in partitions if p.is_create_or_modify()] - def __repr__(self) -> str: - return f"Filesystem(blockdevice={self.blockdevice}, mode={self.mode})" + self._validate_partitions(create_or_modify_parts) - def __exit__(self, *args :str, **kwargs :str) -> bool: - # TODO: https://stackoverflow.com/questions/28157929/how-to-safely-handle-an-exception-inside-a-context-manager - if len(args) >= 2 and args[1]: - raise args[1] + # make sure all devices are unmounted + device_handler.umount_all_existing(device_path) - SysCommand('sync') - return True + for part_mod in create_or_modify_parts: + # partition will be encrypted + if self._enc_config is not None and part_mod in self._enc_config.partitions: + device_handler.format_encrypted( + part_mod.safe_dev_path, + part_mod.mapper_name, + part_mod.safe_fs_type, + self._enc_config + ) + else: + device_handler.format(part_mod.safe_fs_type, part_mod.safe_dev_path) + + # synchronize with udev before using lsblk + SysCommand('udevadm settle') + + lsblk_info = device_handler.fetch_part_info(part_mod.safe_dev_path) + + part_mod.partn = lsblk_info.partn + part_mod.partuuid = lsblk_info.partuuid + part_mod.uuid = lsblk_info.uuid + + def _validate_partitions(self, partitions: List[PartitionModification]): + checks = { + # verify that all partitions have a path set (which implies that they have been created) + lambda x: x.dev_path is None: ValueError('When formatting, all partitions must have a path set'), + # crypto luks is not a valid file system type + lambda x: x.fs_type is FilesystemType.Crypto_luks: ValueError( + 'Crypto luks cannot be set as a filesystem type'), + # file system type must be set + lambda x: x.fs_type is None: ValueError('File system type must be set for modification') + } + + for check, exc in checks.items(): + found = next(filter(check, partitions), None) + if found is not None: + raise exc + + def perform_lvm_operations(self): + info('Setting up LVM config...') + + if not self._disk_config.lvm_config: + return + + if self._enc_config: + self._setup_lvm_encrypted( + self._disk_config.lvm_config, + self._enc_config + ) + else: + self._setup_lvm(self._disk_config.lvm_config) + self._format_lvm_vols(self._disk_config.lvm_config) + + def _setup_lvm_encrypted(self, lvm_config: LvmConfiguration, enc_config: DiskEncryption): + if enc_config.encryption_type == EncryptionType.LvmOnLuks: + enc_mods = self._encrypt_partitions(enc_config, lock_after_create=False) + + self._setup_lvm(lvm_config, enc_mods) + self._format_lvm_vols(lvm_config) + + # export the lvm group safely otherwise the Luks cannot be closed + self._safely_close_lvm(lvm_config) + + for luks in enc_mods.values(): + luks.lock() + elif enc_config.encryption_type == EncryptionType.LuksOnLvm: + self._setup_lvm(lvm_config) + enc_vols = self._encrypt_lvm_vols(lvm_config, enc_config, False) + self._format_lvm_vols(lvm_config, enc_vols) + + for luks in enc_vols.values(): + luks.lock() + + self._safely_close_lvm(lvm_config) + + def _safely_close_lvm(self, lvm_config: LvmConfiguration): + for vg in lvm_config.vol_groups: + for vol in vg.volumes: + device_handler.lvm_vol_change(vol, False) + + device_handler.lvm_export_vg(vg) + + def _setup_lvm( + self, + lvm_config: LvmConfiguration, + enc_mods: Dict[PartitionModification, Luks2] = {} + ): + self._lvm_create_pvs(lvm_config, enc_mods) + + for vg in lvm_config.vol_groups: + pv_dev_paths = self._get_all_pv_dev_paths(vg.pvs, enc_mods) + + device_handler.lvm_vg_create(pv_dev_paths, vg.name) + + # figure out what the actual available size in the group is + vg_info = device_handler.lvm_group_info(vg.name) + + if not vg_info: + raise ValueError('Unable to fetch VG info') + + # the actual available LVM Group size will be smaller than the + # total PVs size due to reserved metadata storage etc. + # so we'll have a look at the total avail. size, check the delta + # to the desired sizes and subtract some equally from the actually + # created volume + avail_size = vg_info.vg_size + desired_size = sum([vol.length for vol in vg.volumes], Size(0, Unit.B, SectorSize.default())) + + delta = desired_size - avail_size + max_vol_offset = delta.convert(Unit.B) + + max_vol = max(vg.volumes, key=lambda x: x.length) + + for lv in vg.volumes: + offset = max_vol_offset if lv == max_vol else None + + debug(f'vg: {vg.name}, vol: {lv.name}, offset: {offset}') + device_handler.lvm_vol_create(vg.name, lv, offset) - def partuuid_to_index(self, uuid :str) -> Optional[int]: - for i in range(storage['DISK_RETRY_ATTEMPTS']): - self.partprobe() - time.sleep(max(0.1, storage['DISK_TIMEOUTS'] * i)) - - # We'll use unreliable lbslk to grab children under the /dev/<device> - output = json.loads(SysCommand(f"lsblk --json {self.blockdevice.device}").decode('UTF-8')) - - for device in output['blockdevices']: - for index, partition in enumerate(device.get('children', [])): - # But we'll use blkid to reliably grab the PARTUUID for that child device (partition) - partition_uuid = SysCommand(f"blkid -s PARTUUID -o value /dev/{partition.get('name')}").decode().strip() - if partition_uuid.lower() == uuid.lower(): - return index - - raise DiskError(f"Failed to convert PARTUUID {uuid} to a partition index number on blockdevice {self.blockdevice.device}") - - def load_layout(self, layout :Dict[str, Any]) -> None: - from ..luks import luks2 - from .btrfs import BTRFSPartition - - # If the layout tells us to wipe the drive, we do so - if layout.get('wipe', False): - if self.mode == GPT: - if not self.parted_mklabel(self.blockdevice.device, "gpt"): - raise KeyError(f"Could not create a GPT label on {self}") - elif self.mode == MBR: - if not self.parted_mklabel(self.blockdevice.device, "msdos"): - raise KeyError(f"Could not create a MS-DOS label on {self}") - - self.blockdevice.flush_cache() - time.sleep(3) - - prev_partition = None - # We then iterate the partitions in order - for partition in layout.get('partitions', []): - # We don't want to re-add an existing partition (those containing a UUID already) - if partition.get('wipe', False) and not partition.get('PARTUUID', None): - start = partition.get('start') or ( - prev_partition and f'{prev_partition["device_instance"].end_sectors}s' or DEFAULT_PARTITION_START) - partition['device_instance'] = self.add_partition(partition.get('type', 'primary'), - start=start, - end=partition.get('size', '100%'), - partition_format=partition.get('filesystem', {}).get('format', 'btrfs'), - skip_mklabel=layout.get('wipe', False) is not False) - - elif (partition_uuid := partition.get('PARTUUID')): - # We try to deal with both UUID and PARTUUID of a partition when it's being re-used. - # We should re-name or separate this logi based on partition.get('PARTUUID') and partition.get('UUID') - # but for now, lets just attempt to deal with both. - try: - partition['device_instance'] = self.blockdevice.get_partition(uuid=partition_uuid) - except DiskError: - partition['device_instance'] = self.blockdevice.get_partition(partuuid=partition_uuid) - - log(_("Re-using partition instance: {}").format(partition['device_instance']), level=logging.DEBUG, fg="gray") + while True: + debug('Fetching LVM volume info') + lv_info = device_handler.lvm_vol_info(lv.name) + if lv_info is not None: + break + + time.sleep(1) + + self._lvm_vol_handle_e2scrub(vg) + + def _format_lvm_vols( + self, + lvm_config: LvmConfiguration, + enc_vols: Dict[LvmVolume, Luks2] = {} + ): + for vol in lvm_config.get_all_volumes(): + if enc_vol := enc_vols.get(vol, None): + if not enc_vol.mapper_dev: + raise ValueError('No mapper device defined') + path = enc_vol.mapper_dev else: - log(f"{self}.load_layout() doesn't know how to work without 'wipe' being set or UUID ({partition.get('PARTUUID')}) was given and found.", fg="yellow", level=logging.WARNING) - continue - - if partition.get('filesystem', {}).get('format', False): - # needed for backward compatibility with the introduction of the new "format_options" - format_options = partition.get('options',[]) + partition.get('filesystem',{}).get('format_options',[]) - disk_encryption: DiskEncryption = storage['arguments'].get('disk_encryption') - - if disk_encryption and partition in disk_encryption.all_partitions: - if not partition['device_instance']: - raise DiskError(f"Internal error caused us to loose the partition. Please report this issue upstream!") - - if partition.get('mountpoint',None): - loopdev = f"{storage.get('ENC_IDENTIFIER', 'ai')}{pathlib.Path(partition['mountpoint']).name}loop" - else: - loopdev = f"{storage.get('ENC_IDENTIFIER', 'ai')}{pathlib.Path(partition['device_instance'].path).name}" - - partition['device_instance'].encrypt(password=disk_encryption.encryption_password) - # Immediately unlock the encrypted device to format the inner volume - with luks2(partition['device_instance'], loopdev, disk_encryption.encryption_password, auto_unmount=True) as unlocked_device: - if not partition.get('wipe'): - if storage['arguments'] == 'silent': - raise ValueError(f"Missing fs-type to format on newly created encrypted partition {partition['device_instance']}") - else: - if not partition.get('filesystem'): - partition['filesystem'] = {} - - if not partition['filesystem'].get('format', False): - while True: - partition['filesystem']['format'] = input(f"Enter a valid fs-type for newly encrypted partition {partition['filesystem']['format']}: ").strip() - if not partition['filesystem']['format'] or valid_fs_type(partition['filesystem']['format']) is False: - log(_("You need to enter a valid fs-type in order to continue. See `man parted` for valid fs-type's.")) - continue - break - - unlocked_device.format(partition['filesystem']['format'], options=format_options) - - elif partition.get('wipe', False): - if not partition['device_instance']: - raise DiskError(f"Internal error caused us to loose the partition. Please report this issue upstream!") - - partition['device_instance'].format(partition['filesystem']['format'], options=format_options) - - if partition['filesystem']['format'] == 'btrfs': - # We upgrade the device instance to a BTRFSPartition if we format it as such. - # This is so that we can gain access to more features than otherwise available in Partition() - partition['device_instance'] = BTRFSPartition( - partition['device_instance'].path, - block_device=partition['device_instance'].block_device, - encrypted=False, - filesystem='btrfs', - autodetect_filesystem=False - ) - - if partition.get('boot', False): - log(f"Marking partition {partition['device_instance']} as bootable.") - self.set(self.partuuid_to_index(partition['device_instance'].part_uuid), 'boot on') - - prev_partition = partition - - def find_partition(self, mountpoint :str) -> Partition: - for partition in self.blockdevice: - if partition.target_mountpoint == mountpoint or partition.mountpoint == mountpoint: - return partition - - def partprobe(self) -> bool: - try: - SysCommand(f'partprobe {self.blockdevice.device}') - except SysCallError as error: - log(f"Could not execute partprobe: {error!r}", level=logging.ERROR, fg="red") - raise DiskError(f"Could not run partprobe on {self.blockdevice.device}: {error!r}") + path = vol.safe_dev_path - return True + # wait a bit otherwise the mkfs will fail as it can't + # find the mapper device yet + device_handler.format(vol.fs_type, path) - def raw_parted(self, string: str) -> SysCommand: - try: - cmd_handle = SysCommand(f'/usr/bin/parted -s {string}') - time.sleep(0.5) - return cmd_handle - except SysCallError as error: - log(f"Parted ended with a bad exit code: {error.exit_code} ({error})", level=logging.ERROR, fg="red") - return error + if vol.fs_type == FilesystemType.Btrfs: + device_handler.create_lvm_btrfs_subvolumes(path, vol.btrfs_subvols, vol.mount_options) - def parted(self, string: str) -> bool: - """ - Performs a parted execution of the given string + def _lvm_create_pvs( + self, + lvm_config: LvmConfiguration, + enc_mods: Dict[PartitionModification, Luks2] = {} + ): + pv_paths: Set[Path] = set() - :param string: A raw string passed to /usr/bin/parted -s <string> - :type string: str - """ - if (parted_handle := self.raw_parted(string)).exit_code == 0: - return self.partprobe() - else: - raise DiskError(f"Parted failed to add a partition: {parted_handle}") + for vg in lvm_config.vol_groups: + pv_paths |= self._get_all_pv_dev_paths(vg.pvs, enc_mods) - def use_entire_disk(self, root_filesystem_type :str = 'ext4') -> Partition: - # TODO: Implement this with declarative profiles instead. - raise ValueError("Installation().use_entire_disk() has to be re-worked.") + device_handler.lvm_pv_create(pv_paths) - def add_partition( + def _get_all_pv_dev_paths( self, - partition_type :str, - start :str, - end :str, - partition_format :Optional[str] = None, - skip_mklabel :bool = False - ) -> Partition: - log(f'Adding partition to {self.blockdevice}, {start}->{end}', level=logging.INFO) - - if len(self.blockdevice.partitions) == 0 and skip_mklabel is False: - # If it's a completely empty drive, and we're about to add partitions to it - # we need to make sure there's a filesystem label. - if self.mode == GPT: - if not self.parted_mklabel(self.blockdevice.device, "gpt"): - raise KeyError(f"Could not create a GPT label on {self}") - elif self.mode == MBR: - if not self.parted_mklabel(self.blockdevice.device, "msdos"): - raise KeyError(f"Could not create a MS-DOS label on {self}") - - self.blockdevice.flush_cache() - - previous_partuuids = [] - for partition in self.blockdevice.partitions.values(): - try: - previous_partuuids.append(partition.part_uuid) - except DiskError: - pass - - # TODO this check should probably run in the setup process rather than during the installation - if self.mode == MBR: - if len(self.blockdevice.partitions) > 3: - DiskError("Too many partitions on disk, MBR disks can only have 3 primary partitions") - - if partition_format: - parted_string = f'{self.blockdevice.device} mkpart {partition_type} {partition_format} {start} {end}' - else: - parted_string = f'{self.blockdevice.device} mkpart {partition_type} {start} {end}' - - log(f"Adding partition using the following parted command: {parted_string}", level=logging.DEBUG) - - if self.parted(parted_string): - for count in range(storage.get('DISK_RETRY_ATTEMPTS', 3)): - self.blockdevice.flush_cache() - - new_partition_uuids = [partition.part_uuid for partition in self.blockdevice.partitions.values()] - new_partuuid_set = (set(previous_partuuids) ^ set(new_partition_uuids)) - - if len(new_partuuid_set) and (new_partuuid := new_partuuid_set.pop()): - try: - return self.blockdevice.get_partition(partuuid=new_partuuid) - except Exception as err: - log(f'Blockdevice: {self.blockdevice}', level=logging.ERROR, fg="red") - log(f'Partitions: {self.blockdevice.partitions}', level=logging.ERROR, fg="red") - log(f'Partition set: {new_partuuid_set}', level=logging.ERROR, fg="red") - log(f'New PARTUUID: {[new_partuuid]}', level=logging.ERROR, fg="red") - log(f'get_partition(): {self.blockdevice.get_partition}', level=logging.ERROR, fg="red") - raise err - else: - log(f"Could not get UUID for partition. Waiting {storage.get('DISK_TIMEOUTS', 1) * count}s before retrying.",level=logging.DEBUG) - self.partprobe() - time.sleep(max(0.1, storage.get('DISK_TIMEOUTS', 1))) - else: - print("Parted did not return True during partition creation") + pvs: List[PartitionModification], + enc_mods: Dict[PartitionModification, Luks2] = {} + ) -> Set[Path]: + pv_paths: Set[Path] = set() + + for pv in pvs: + if enc_pv := enc_mods.get(pv, None): + if mapper := enc_pv.mapper_dev: + pv_paths.add(mapper) + else: + pv_paths.add(pv.safe_dev_path) + + return pv_paths + + def _encrypt_lvm_vols( + self, + lvm_config: LvmConfiguration, + enc_config: DiskEncryption, + lock_after_create: bool = True + ) -> Dict[LvmVolume, Luks2]: + enc_vols: Dict[LvmVolume, Luks2] = {} + + for vol in lvm_config.get_all_volumes(): + if vol in enc_config.lvm_volumes: + luks_handler = device_handler.encrypt( + vol.safe_dev_path, + vol.mapper_name, + enc_config.encryption_password, + lock_after_create + ) + + enc_vols[vol] = luks_handler + + return enc_vols + + def _encrypt_partitions( + self, + enc_config: DiskEncryption, + lock_after_create: bool = True + ) -> Dict[PartitionModification, Luks2]: + enc_mods: Dict[PartitionModification, Luks2] = {} + + for mod in self._disk_config.device_modifications: + partitions = mod.partitions + + # don't touch existing partitions + filtered_part = [p for p in partitions if not p.exists()] + + self._validate_partitions(filtered_part) + + # make sure all devices are unmounted + device_handler.umount_all_existing(mod.device_path) + + enc_mods = {} + + for part_mod in filtered_part: + if part_mod in enc_config.partitions: + luks_handler = device_handler.encrypt( + part_mod.safe_dev_path, + part_mod.mapper_name, + enc_config.encryption_password, + lock_after_create=lock_after_create + ) + + enc_mods[part_mod] = luks_handler - total_partitions = set([partition.part_uuid for partition in self.blockdevice.partitions.values()]) - total_partitions.update(previous_partuuids) + return enc_mods - # TODO: This should never be able to happen - log(f"Could not find the new PARTUUID after adding the partition.", level=logging.ERROR, fg="red") - log(f"Previous partitions: {previous_partuuids}", level=logging.ERROR, fg="red") - log(f"New partitions: {total_partitions}", level=logging.ERROR, fg="red") + def _lvm_vol_handle_e2scrub(self, vol_gp: LvmVolumeGroup): + # from arch wiki: + # If a logical volume will be formatted with ext4, leave at least 256 MiB + # free space in the volume group to allow using e2scrub + if any([vol.fs_type == FilesystemType.Ext4 for vol in vol_gp.volumes]): + largest_vol = max(vol_gp.volumes, key=lambda x: x.length) - raise DiskError(f"Could not add partition using: {parted_string}") + device_handler.lvm_vol_reduce( + largest_vol.safe_dev_path, + Size(256, Unit.MiB, SectorSize.default()) + ) - def set_name(self, partition: int, name: str) -> bool: - return self.parted(f'{self.blockdevice.device} name {partition + 1} "{name}"') == 0 + def _do_countdown(self) -> bool: + SIG_TRIGGER = False - def set(self, partition: int, string: str) -> bool: - log(f"Setting {string} on (parted) partition index {partition+1}", level=logging.INFO) - return self.parted(f'{self.blockdevice.device} set {partition + 1} {string}') == 0 + def kill_handler(sig: int, frame: Any) -> None: + print() + exit(0) - def parted_mklabel(self, device: str, disk_label: str) -> bool: - log(f"Creating a new partition label on {device}", level=logging.INFO, fg="yellow") - # Try to unmount devices before attempting to run mklabel - try: - SysCommand(f'bash -c "umount {device}?"') - except: - pass + def sig_handler(sig: int, frame: Any) -> None: + signal.signal(signal.SIGINT, kill_handler) - self.partprobe() - worked = self.raw_parted(f'{device} mklabel {disk_label}').exit_code == 0 - self.partprobe() + original_sigint_handler = signal.getsignal(signal.SIGINT) + signal.signal(signal.SIGINT, sig_handler) - return worked + for i in range(5, 0, -1): + print(f"{i}", end='') + + for x in range(4): + sys.stdout.flush() + time.sleep(0.25) + print(".", end='') + + if SIG_TRIGGER: + prompt = _('Do you really want to abort?') + choice = Menu(prompt, Menu.yes_no(), skip=False).run() + if choice.value == Menu.yes(): + exit(0) + + if SIG_TRIGGER is False: + sys.stdin.read() + + SIG_TRIGGER = False + signal.signal(signal.SIGINT, sig_handler) + + print() + signal.signal(signal.SIGINT, original_sigint_handler) + + return True diff --git a/archinstall/lib/disk/helpers.py b/archinstall/lib/disk/helpers.py deleted file mode 100644 index 80d0cb53..00000000 --- a/archinstall/lib/disk/helpers.py +++ /dev/null @@ -1,556 +0,0 @@ -from __future__ import annotations -import json -import logging -import os # type: ignore -import pathlib -import re -import time -import glob - -from typing import Union, List, Iterator, Dict, Optional, Any, TYPE_CHECKING -# https://stackoverflow.com/a/39757388/929999 -from .diskinfo import get_lsblk_info -from ..models.subvolume import Subvolume - -from .blockdevice import BlockDevice -from .dmcryptdev import DMCryptDev -from .mapperdev import MapperDev -from ..exceptions import SysCallError, DiskError -from ..general import SysCommand -from ..output import log -from ..storage import storage - -if TYPE_CHECKING: - from .partition import Partition - - -ROOT_DIR_PATTERN = re.compile('^.*?/devices') -GIGA = 2 ** 30 - -def convert_size_to_gb(size :Union[int, float]) -> float: - return round(size / GIGA,1) - -def sort_block_devices_based_on_performance(block_devices :List[BlockDevice]) -> Dict[BlockDevice, int]: - result = {device: 0 for device in block_devices} - - for device, weight in result.items(): - if device.spinning: - weight -= 10 - else: - weight += 5 - - if device.bus_type == 'nvme': - weight += 20 - elif device.bus_type == 'sata': - weight += 10 - - result[device] = weight - - return result - -def filter_disks_below_size_in_gb(devices :List[BlockDevice], gigabytes :int) -> Iterator[BlockDevice]: - for disk in devices: - if disk.size >= gigabytes: - yield disk - -def select_largest_device(devices :List[BlockDevice], gigabytes :int, filter_out :Optional[List[BlockDevice]] = None) -> BlockDevice: - if not filter_out: - filter_out = [] - - copy_devices = [*devices] - for filter_device in filter_out: - if filter_device in copy_devices: - copy_devices.pop(copy_devices.index(filter_device)) - - copy_devices = list(filter_disks_below_size_in_gb(copy_devices, gigabytes)) - - if not len(copy_devices): - return None - - return max(copy_devices, key=(lambda device : device.size)) - -def select_disk_larger_than_or_close_to(devices :List[BlockDevice], gigabytes :int, filter_out :Optional[List[BlockDevice]] = None) -> BlockDevice: - if not filter_out: - filter_out = [] - - copy_devices = [*devices] - for filter_device in filter_out: - if filter_device in copy_devices: - copy_devices.pop(copy_devices.index(filter_device)) - - if not len(copy_devices): - return None - - return min(copy_devices, key=(lambda device : abs(device.size - gigabytes))) - -def convert_to_gigabytes(string :str) -> float: - unit = string.strip()[-1] - size = float(string.strip()[:-1]) - - if unit == 'M': - size = size / 1024 - elif unit == 'T': - size = size * 1024 - - return size - -def device_state(name :str, *args :str, **kwargs :str) -> Optional[bool]: - # Based out of: https://askubuntu.com/questions/528690/how-to-get-list-of-all-non-removable-disk-device-names-ssd-hdd-and-sata-ide-onl/528709#528709 - if os.path.isfile('/sys/block/{}/device/block/{}/removable'.format(name, name)): - with open('/sys/block/{}/device/block/{}/removable'.format(name, name)) as f: - if f.read(1) == '1': - return - - path = ROOT_DIR_PATTERN.sub('', os.readlink('/sys/block/{}'.format(name))) - hotplug_buses = ("usb", "ieee1394", "mmc", "pcmcia", "firewire") - for bus in hotplug_buses: - if os.path.exists('/sys/bus/{}'.format(bus)): - for device_bus in os.listdir('/sys/bus/{}/devices'.format(bus)): - device_link = ROOT_DIR_PATTERN.sub('', os.readlink('/sys/bus/{}/devices/{}'.format(bus, device_bus))) - if re.search(device_link, path): - return - return True - - -def cleanup_bash_escapes(data :str) -> str: - return data.replace(r'\ ', ' ') - -def blkid(cmd :str) -> Dict[str, Any]: - if '-o' in cmd and '-o export' not in cmd: - raise ValueError(f"blkid() requires '-o export' to be used and can therefore not continue reliably.") - elif '-o' not in cmd: - cmd += ' -o export' - - try: - raw_data = SysCommand(cmd).decode() - except SysCallError as error: - log(f"Could not get block device information using blkid() using command {cmd}", level=logging.DEBUG) - raise error - - result = {} - # Process the raw result - devname = None - for line in raw_data.split('\r\n'): - if not len(line): - devname = None - continue - - key, val = line.split('=', 1) - if key.lower() == 'devname': - devname = val - # Lowercase for backwards compatibility with all_disks() previous use cases - result[devname] = { - "path": devname, - "PATH": devname - } - continue - - result[devname][key] = cleanup_bash_escapes(val) - - return result - -def get_loop_info(path :str) -> Dict[str, Any]: - for drive in json.loads(SysCommand(['losetup', '--json']).decode('UTF_8'))['loopdevices']: - if not drive['name'] == path: - continue - - return { - path: { - **drive, - 'type' : 'loop', - 'TYPE' : 'loop', - 'DEVTYPE' : 'loop', - 'PATH' : drive['name'], - 'path' : drive['name'] - } - } - - return {} - -def enrich_blockdevice_information(information :Dict[str, Any]) -> Dict[str, Any]: - result = {} - for device_path, device_information in information.items(): - dev_name = pathlib.Path(device_information['PATH']).name - if not device_information.get('TYPE') or not device_information.get('DEVTYPE'): - with open(f"/sys/class/block/{dev_name}/uevent") as fh: - device_information.update(uevent(fh.read())) - - if (dmcrypt_name := pathlib.Path(f"/sys/class/block/{dev_name}/dm/name")).exists(): - with dmcrypt_name.open('r') as fh: - device_information['DMCRYPT_NAME'] = fh.read().strip() - - result[device_path] = device_information - - return result - -def uevent(data :str) -> Dict[str, Any]: - information = {} - - for line in data.replace('\r\n', '\n').split('\n'): - if len((line := line.strip())): - key, val = line.split('=', 1) - information[key] = val - - return information - -def get_blockdevice_uevent(dev_name :str) -> Dict[str, Any]: - device_information = {} - with open(f"/sys/class/block/{dev_name}/uevent") as fh: - device_information.update(uevent(fh.read())) - - return { - f"/dev/{dev_name}" : { - **device_information, - 'path' : f'/dev/{dev_name}', - 'PATH' : f'/dev/{dev_name}', - 'PTTYPE' : None - } - } - - -def all_disks() -> List[BlockDevice]: - log(f"[Deprecated] archinstall.all_disks() is deprecated. Use archinstall.all_blockdevices() with the appropriate filters instead.", level=logging.WARNING, fg="yellow") - return all_blockdevices(partitions=False, mappers=False) - -def get_blockdevice_info(device_path, exclude_iso_dev :bool = True) -> Dict[str, Any]: - for retry_attempt in range(storage['DISK_RETRY_ATTEMPTS']): - partprobe(device_path) - time.sleep(max(0.1, storage['DISK_TIMEOUTS'] * retry_attempt)) - - try: - if exclude_iso_dev: - # exclude all devices associated with the iso boot locations - iso_devs = ['/run/archiso/airootfs', '/run/archiso/bootmnt'] - - try: - lsblk_info = get_lsblk_info(device_path) - except DiskError: - continue - - if any([dev in lsblk_info.mountpoints for dev in iso_devs]): - continue - - information = blkid(f'blkid -p -o export {device_path}') - return enrich_blockdevice_information(information) - except SysCallError as ex: - if ex.exit_code == 2: - # Assume that it's a loop device, and try to get info on it - try: - resolved_device_name = device_path.readlink().name - except OSError: - resolved_device_name = device_path.name - - try: - information = get_loop_info(device_path) - if not information: - raise SysCallError(f"Could not get loop information for {resolved_device_name}", exit_code=1) - return enrich_blockdevice_information(information) - - except SysCallError: - information = get_blockdevice_uevent(resolved_device_name) - return enrich_blockdevice_information(information) - else: - # We could not reliably get any information, perhaps the disk is clean of information? - if retry_attempt == storage['DISK_RETRY_ATTEMPTS'] - 1: - raise ex - -def all_blockdevices( - mappers: bool = False, - partitions: bool = False, - error: bool = False, - exclude_iso_dev: bool = True -) -> Dict[str, Any]: - """ - Returns BlockDevice() and Partition() objects for all available devices. - """ - from .partition import Partition - - instances = {} - - # Due to lsblk being highly unreliable for this use case, - # we'll iterate the /sys/class definitions and find the information - # from there. - for block_device in glob.glob("/sys/class/block/*"): - try: - device_path = pathlib.Path(f"/dev/{pathlib.Path(block_device).readlink().name}") - except FileNotFoundError: - log(f"Unknown device found by '/sys/class/block/*', ignoring: {device_path}", level=logging.WARNING, fg="yellow") - - if device_path.exists() is False: - log(f"Unknown device found by '/sys/class/block/*', ignoring: {device_path}", level=logging.WARNING, fg="yellow") - continue - - information = get_blockdevice_info(device_path) - if not information: - continue - - for path, path_info in information.items(): - if path_info.get('DMCRYPT_NAME'): - instances[path] = DMCryptDev(dev_path=path) - elif path_info.get('PARTUUID') or path_info.get('PART_ENTRY_NUMBER'): - if partitions: - instances[path] = Partition(path, block_device=BlockDevice(get_parent_of_partition(pathlib.Path(path)))) - elif path_info.get('PTTYPE', False) is not False or path_info.get('TYPE') == 'loop': - instances[path] = BlockDevice(path, path_info) - elif path_info.get('TYPE') in ('squashfs', 'erofs'): - # We can ignore squashfs devices (usually /dev/loop0 on Arch ISO) - continue - else: - log(f"Unknown device found by all_blockdevices(), ignoring: {information}", level=logging.WARNING, fg="yellow") - - if mappers: - for block_device in glob.glob("/dev/mapper/*"): - if (pathobj := pathlib.Path(block_device)).is_symlink(): - instances[f"/dev/mapper/{pathobj.name}"] = MapperDev(mappername=pathobj.name) - - return instances - - -def get_parent_of_partition(path :pathlib.Path) -> pathlib.Path: - partition_name = path.name - pci_device = (pathlib.Path("/sys/class/block") / partition_name).resolve() - return f"/dev/{pci_device.parent.name}" - -def harddrive(size :Optional[float] = None, model :Optional[str] = None, fuzzy :bool = False) -> Optional[BlockDevice]: - collection = all_blockdevices(partitions=False) - for drive in collection: - if size and convert_to_gigabytes(collection[drive]['size']) != size: - continue - if model and (collection[drive]['model'] is None or collection[drive]['model'].lower() != model.lower()): - continue - - return collection[drive] - -def split_bind_name(path :Union[pathlib.Path, str]) -> list: - # log(f"[Deprecated] Partition().subvolumes now contain the split bind name via it's subvolume.name instead.", level=logging.WARNING, fg="yellow") - # we check for the bind notation. if exist we'll only use the "true" device path - if '[' in str(path) : # is a bind path (btrfs subvolume path) - device_path, bind_path = str(path).split('[') - bind_path = bind_path[:-1].strip() # remove the ] - else: - device_path = path - bind_path = None - return device_path,bind_path - -def find_mountpoint(device_path :str) -> Dict[str, Any]: - try: - for filesystem in json.loads(SysCommand(f'/usr/bin/findmnt -R --json {device_path}').decode())['filesystems']: - yield filesystem - except SysCallError: - return {} - -def findmnt(path :pathlib.Path, traverse :bool = False, ignore :List = [], recurse :bool = True) -> Dict[str, Any]: - for traversal in list(map(str, [str(path)] + list(path.parents))): - if traversal in ignore: - continue - - try: - log(f"Getting mount information for device path {traversal}", level=logging.DEBUG) - if (output := SysCommand(f"/usr/bin/findmnt --json {'--submounts' if recurse else ''} {traversal}").decode('UTF-8')): - return json.loads(output) - - except SysCallError as error: - log(f"Could not get mount information on {path} but continuing and ignoring: {error}", level=logging.INFO, fg="gray") - pass - - if not traverse: - break - - raise DiskError(f"Could not get mount information for path {path}") - - -def get_mount_info(path :Union[pathlib.Path, str], traverse :bool = False, return_real_path :bool = False, ignore :List = []) -> Dict[str, Any]: - import traceback - - log(f"Deprecated: archinstall.get_mount_info(). Use archinstall.findmnt() instead, which does not do any automatic parsing. Please change at:\n{''.join(traceback.format_stack())}") - device_path, bind_path = split_bind_name(path) - output = {} - - for traversal in list(map(str, [str(device_path)] + list(pathlib.Path(str(device_path)).parents))): - if traversal in ignore: - continue - - try: - log(f"Getting mount information for device path {traversal}", level=logging.DEBUG) - if (output := SysCommand(f'/usr/bin/findmnt --json {traversal}').decode('UTF-8')): - break - - except SysCallError as error: - print('ERROR:', error) - pass - - if not traverse: - break - - if not output: - raise DiskError(f"Could not get mount information for device path {device_path}") - - output = json.loads(output) - - # for btrfs partitions we redice the filesystem list to the one with the source equals to the parameter - # i.e. the subvolume filesystem we're searching for - if 'filesystems' in output and len(output['filesystems']) > 1 and bind_path is not None: - output['filesystems'] = [entry for entry in output['filesystems'] if entry['source'] == str(path)] - - if 'filesystems' in output: - if len(output['filesystems']) > 1: - raise DiskError(f"Path '{device_path}' contains multiple mountpoints: {output['filesystems']}") - - if return_real_path: - return output['filesystems'][0], traversal - else: - return output['filesystems'][0] - - if return_real_path: - return {}, traversal - else: - return {} - - -def get_all_targets(data :Dict[str, Any], filters :Dict[str, None] = {}) -> Dict[str, None]: - for info in data: - if info.get('target') not in filters: - filters[info.get('target')] = None - - filters.update(get_all_targets(info.get('children', []))) - - return filters - -def get_partitions_in_use(mountpoint :str) -> Dict[str, Any]: - from .partition import Partition - - try: - output = SysCommand(f"/usr/bin/findmnt --json -R {mountpoint}").decode('UTF-8') - except SysCallError: - return {} - - if not output: - return {} - - output = json.loads(output) - - mounts = {} - - block_devices_available = all_blockdevices(mappers=True, partitions=True, error=True) - - block_devices_mountpoints = {} - for blockdev in block_devices_available.values(): - if not type(blockdev) in (Partition, MapperDev): - continue - - if isinstance(blockdev, Partition): - if blockdev.mountpoints: - for blockdev_mountpoint in blockdev.mountpoints: - block_devices_mountpoints[blockdev_mountpoint] = blockdev - else: - if blockdev.mount_information: - for blockdev_mountpoint in blockdev.mount_information: - block_devices_mountpoints[blockdev_mountpoint['target']] = blockdev - - log(f'Filtering available mounts {block_devices_mountpoints} to those under {mountpoint}', level=logging.DEBUG) - - for mountpoint in list(get_all_targets(output['filesystems']).keys()): - # Since all_blockdevices() returns PosixPath objects, we need to convert - # findmnt paths to pathlib.Path() first: - mountpoint = pathlib.Path(mountpoint) - - if mountpoint in block_devices_mountpoints: - if mountpoint not in mounts: - mounts[mountpoint] = block_devices_mountpoints[mountpoint] - # If the already defined mountpoint is a DMCryptDev, and the newly found - # mountpoint is a MapperDev, it has precedence and replaces the old mountpoint definition. - elif type(mounts[mountpoint]) == DMCryptDev and type(block_devices_mountpoints[mountpoint]) == MapperDev: - mounts[mountpoint] = block_devices_mountpoints[mountpoint] - - log(f"Available partitions: {mounts}", level=logging.DEBUG) - - return mounts - - -def get_filesystem_type(path :str) -> Optional[str]: - try: - return SysCommand(f"blkid -o value -s TYPE {path}").decode('UTF-8').strip() - except SysCallError: - return None - - -def disk_layouts() -> Optional[Dict[str, Any]]: - try: - if (handle := SysCommand("lsblk -f -o+TYPE,SIZE -J")).exit_code == 0: - return {str(key): val for key, val in json.loads(handle.decode('UTF-8')).items()} - else: - log(f"Could not return disk layouts: {handle}", level=logging.WARNING, fg="yellow") - return None - except SysCallError as err: - log(f"Could not return disk layouts: {err}", level=logging.WARNING, fg="yellow") - return None - except json.decoder.JSONDecodeError as err: - log(f"Could not return disk layouts: {err}", level=logging.WARNING, fg="yellow") - return None - - -def find_partition_by_mountpoint(block_devices :List[BlockDevice], relative_mountpoint :str) -> Partition: - for device in block_devices: - for partition in block_devices[device]['partitions']: - if partition.get('mountpoint', None) == relative_mountpoint: - return partition - -def partprobe(path :str = '') -> bool: - try: - if SysCommand(f'bash -c "partprobe {path}"').exit_code == 0: - return True - except SysCallError: - pass - return False - -def convert_device_to_uuid(path :str) -> str: - device_name, bind_name = split_bind_name(path) - - for i in range(storage['DISK_RETRY_ATTEMPTS']): - partprobe(device_name) - time.sleep(max(0.1, storage['DISK_TIMEOUTS'] * i)) # TODO: Remove, we should be relying on blkid instead of lsblk - - # TODO: Convert lsblk to blkid - # (lsblk supports BlockDev and Partition UUID grabbing, blkid requires you to pick PTUUID and PARTUUID) - output = json.loads(SysCommand(f"lsblk --json -o+UUID {device_name}").decode('UTF-8')) - - for device in output['blockdevices']: - if (dev_uuid := device.get('uuid', None)): - return dev_uuid - - raise DiskError(f"Could not retrieve the UUID of {path} within a timely manner.") - - -def has_mountpoint(partition: Union[dict,Partition,MapperDev], target: str, strict: bool = True) -> bool: - """ Determine if a certain partition is mounted (or has a mountpoint) as specific target (path) - Coded for clarity rather than performance - - Input parms: - :parm partition the partition we check - :type Either a Partition object or a dict with the contents of a partition definition in the disk_layouts schema - - :parm target (a string representing a mount path we want to check for. - :type str - - :parm strict if the check will be strict, target is exactly the mountpoint, or no, where the target is a leaf (f.i. to check if it is in /mnt/archinstall/). Not available for root check ('/') for obvious reasons - - """ - # we create the mountpoint list - if isinstance(partition,dict): - subvolumes: List[Subvolume] = partition.get('btrfs',{}).get('subvolumes', []) - mountpoints = [partition.get('mountpoint')] - mountpoints += [volume.mountpoint for volume in subvolumes] - else: - mountpoints = [partition.mountpoint,] + [subvol.target for subvol in partition.subvolumes] - - # we check - if strict or target == '/': - if target in mountpoints: - return True - else: - return False - else: - for mp in mountpoints: - if mp and mp.endswith(target): - return True - return False diff --git a/archinstall/lib/disk/mapperdev.py b/archinstall/lib/disk/mapperdev.py deleted file mode 100644 index bf1b3583..00000000 --- a/archinstall/lib/disk/mapperdev.py +++ /dev/null @@ -1,92 +0,0 @@ -import glob -import pathlib -import logging -import json -from dataclasses import dataclass -from typing import Optional, List, Dict, Any, Iterator, TYPE_CHECKING - -from ..exceptions import SysCallError -from ..general import SysCommand -from ..output import log - -if TYPE_CHECKING: - from .btrfs import BtrfsSubvolumeInfo - -@dataclass -class MapperDev: - mappername :str - - @property - def name(self): - return self.mappername - - @property - def path(self): - return f"/dev/mapper/{self.mappername}" - - @property - def part_uuid(self): - return self.partition.part_uuid - - @property - def partition(self): - from .helpers import uevent, get_parent_of_partition - from .partition import Partition - from .blockdevice import BlockDevice - - for mapper in glob.glob('/dev/mapper/*'): - path_obj = pathlib.Path(mapper) - if path_obj.name == self.mappername and pathlib.Path(mapper).is_symlink(): - dm_device = (pathlib.Path("/dev/mapper/") / path_obj.readlink()).resolve() - - for slave in glob.glob(f"/sys/class/block/{dm_device.name}/slaves/*"): - partition_belonging_to_dmcrypt_device = pathlib.Path(slave).name - - try: - uevent_data = SysCommand(f"blkid -o export /dev/{partition_belonging_to_dmcrypt_device}").decode() - except SysCallError as error: - log(f"Could not get information on device /dev/{partition_belonging_to_dmcrypt_device}: {error}", level=logging.ERROR, fg="red") - - information = uevent(uevent_data) - block_device = BlockDevice(get_parent_of_partition('/dev/' / pathlib.Path(information['DEVNAME']))) - - return Partition(information['DEVNAME'], block_device=block_device) - - raise ValueError(f"Could not convert {self.mappername} to a real dm-crypt device") - - @property - def mountpoint(self) -> Optional[pathlib.Path]: - try: - data = json.loads(SysCommand(f"findmnt --json -R {self.path}").decode()) - for filesystem in data['filesystems']: - return pathlib.Path(filesystem.get('target')) - - except SysCallError as error: - # Not mounted anywhere most likely - log(f"Could not locate mount information for {self.path}: {error}", level=logging.WARNING, fg="yellow") - pass - - return None - - @property - def mountpoints(self) -> List[Dict[str, Any]]: - return [obj['target'] for obj in self.mount_information] - - @property - def mount_information(self) -> List[Dict[str, Any]]: - from .helpers import find_mountpoint - return [{**obj, 'target' : pathlib.Path(obj.get('target', '/dev/null'))} for obj in find_mountpoint(self.path)] - - @property - def filesystem(self) -> Optional[str]: - from .helpers import get_filesystem_type - return get_filesystem_type(self.path) - - @property - def subvolumes(self) -> Iterator['BtrfsSubvolumeInfo']: - from .btrfs import subvolume_info_from_path - - for mountpoint in self.mount_information: - if target := mountpoint.get('target'): - if subvolume := subvolume_info_from_path(pathlib.Path(target)): - yield subvolume diff --git a/archinstall/lib/disk/partition.py b/archinstall/lib/disk/partition.py deleted file mode 100644 index 87eaa6a7..00000000 --- a/archinstall/lib/disk/partition.py +++ /dev/null @@ -1,661 +0,0 @@ -import glob -import time -import logging -import json -import os -import hashlib -import typing -from dataclasses import dataclass, field -from pathlib import Path -from typing import Optional, Dict, Any, List, Union, Iterator - -from .blockdevice import BlockDevice -from .helpers import get_filesystem_type, convert_size_to_gb, split_bind_name -from ..storage import storage -from ..exceptions import DiskError, SysCallError, UnknownFilesystemFormat -from ..output import log -from ..general import SysCommand -from .btrfs.btrfs_helpers import subvolume_info_from_path -from .btrfs.btrfssubvolumeinfo import BtrfsSubvolumeInfo - -@dataclass -class PartitionInfo: - partition_object: 'Partition' - device_path: str # This would be /dev/sda1 for instance - bootable: bool - size: float - sector_size: int - start: Optional[int] - end: Optional[int] - pttype: Optional[str] - filesystem_type: Optional[str] - partuuid: Optional[str] - uuid: Optional[str] - mountpoints: List[Path] = field(default_factory=list) - - def __post_init__(self): - if not all([self.partuuid, self.uuid]): - for i in range(storage['DISK_RETRY_ATTEMPTS']): - lsblk_info = SysCommand(f"lsblk --json -b -o+LOG-SEC,SIZE,PTTYPE,PARTUUID,UUID,FSTYPE {self.device_path}").decode('UTF-8') - try: - lsblk_info = json.loads(lsblk_info) - except json.decoder.JSONDecodeError: - log(f"Could not decode JSON: {lsblk_info}", fg="red", level=logging.ERROR) - raise DiskError(f'Failed to retrieve information for "{self.device_path}" with lsblk') - - if not (device := lsblk_info.get('blockdevices', [None])[0]): - raise DiskError(f'Failed to retrieve information for "{self.device_path}" with lsblk') - - self.partuuid = device.get('partuuid') - self.uuid = device.get('uuid') - - # Lets build a list of requirements that we would like - # to retry and build (stuff that can take time between partprobes) - requirements = [] - requirements.append(self.partuuid) - - # Unformatted partitions won't have a UUID - if lsblk_info.get('fstype') is not None: - requirements.append(self.uuid) - - if all(requirements): - break - - self.partition_object.partprobe() - time.sleep(max(0.1, storage['DISK_TIMEOUTS'] * i)) - - def get_first_mountpoint(self) -> Optional[Path]: - if len(self.mountpoints) > 0: - return self.mountpoints[0] - return None - - -class Partition: - def __init__( - self, - path: str, - block_device: BlockDevice, - part_id :Optional[str] = None, - filesystem :Optional[str] = None, - mountpoint :Optional[str] = None, - encrypted :bool = False, - autodetect_filesystem :bool = True, - ): - if not part_id: - part_id = os.path.basename(path) - - if type(block_device) is str: - raise ValueError(f"Partition()'s 'block_device' parameter has to be a archinstall.BlockDevice() instance!") - - self.block_device = block_device - self._path = path - self._part_id = part_id - self._target_mountpoint = mountpoint - self._encrypted = encrypted - self._wipe = False - self._type = 'primary' - - if mountpoint: - self.mount(mountpoint) - - try: - self._partition_info = self._fetch_information() - - if not autodetect_filesystem and filesystem: - self._partition_info.filesystem_type = filesystem - - if self._partition_info.filesystem_type == 'crypto_LUKS': - self._encrypted = True - except DiskError: - self._partition_info = None - - @typing.no_type_check # I hate doint this but I'm currently unsure where this is used. - def __lt__(self, left_comparitor :BlockDevice) -> bool: - if type(left_comparitor) == Partition: - left_comparitor = left_comparitor.path - else: - left_comparitor = str(left_comparitor) - - # The goal is to check if /dev/nvme0n1p1 comes before /dev/nvme0n1p5 - return self._path < left_comparitor - - def __repr__(self, *args :str, **kwargs :str) -> str: - mount_repr = '' - if self._partition_info: - if mountpoint := self._partition_info.get_first_mountpoint(): - mount_repr = f", mounted={mountpoint}" - elif self._target_mountpoint: - mount_repr = f", rel_mountpoint={self._target_mountpoint}" - - classname = self.__class__.__name__ - - if not self._partition_info: - return f'{classname}(path={self._path})' - elif self._encrypted: - return f'{classname}(path={self._path}, size={self.size}, PARTUUID={self.part_uuid}, parent={self.real_device}, fs={self._partition_info.filesystem_type}{mount_repr})' - else: - return f'{classname}(path={self._path}, size={self.size}, PARTUUID={self.part_uuid}, fs={self._partition_info.filesystem_type}{mount_repr})' - - def as_json(self) -> Dict[str, Any]: - """ - this is used for the table representation of the partition (see FormattedOutput) - """ - partition_info = { - 'type': self._type, - 'PARTUUID': self.part_uuid, - 'wipe': self._wipe, - 'boot': self.boot, - 'ESP': self.boot, - 'mountpoint': self._target_mountpoint, - 'encrypted': self._encrypted, - 'start': self.start, - 'size': self.end, - 'filesystem': self._partition_info.filesystem_type if self._partition_info else 'Unknown' - } - - return partition_info - - def __dump__(self) -> Dict[str, Any]: - # TODO remove this in favour of as_json - return { - 'type': self._type, - 'PARTUUID': self.part_uuid, - 'wipe': self._wipe, - 'boot': self.boot, - 'ESP': self.boot, - 'mountpoint': self._target_mountpoint, - 'encrypted': self._encrypted, - 'start': self.start, - 'size': self.end, - 'filesystem': { - 'format': self._partition_info.filesystem_type if self._partition_info else 'None' - } - } - - def _call_lsblk(self) -> Dict[str, Any]: - for retry_attempt in range(storage['DISK_RETRY_ATTEMPTS']): - self.partprobe() - time.sleep(max(0.1, storage['DISK_TIMEOUTS'] * retry_attempt)) # TODO: Remove, we should be relying on blkid instead of lsblk - # This sleep might be overkill, but lsblk is known to - # work against a chaotic cache that can change during call - # causing no information to be returned (blkid is better) - # time.sleep(1) - - # TODO: Maybe incorporate a re-try system here based on time.sleep(max(0.1, storage.get('DISK_TIMEOUTS', 1))) - - try: - output = SysCommand(f"lsblk --json -b -o+LOG-SEC,SIZE,PTTYPE,PARTUUID,UUID,FSTYPE {self.device_path}").decode('UTF-8') - except SysCallError as error: - # Get the output minus the message/info from lsblk if it returns a non-zero exit code. - output = error.worker.decode('UTF-8') - if '{' in output: - output = output[output.find('{'):] - - if output: - try: - lsblk_info = json.loads(output) - return lsblk_info - except json.decoder.JSONDecodeError: - log(f"Could not decode JSON: {output}", fg="red", level=logging.ERROR) - - raise DiskError(f'Failed to get partition information "{self.device_path}" with lsblk') - - def _call_sfdisk(self) -> Dict[str, Any]: - output = SysCommand(f"sfdisk --json {self.block_device.path}").decode('UTF-8') - - if output: - sfdisk_info = json.loads(output) - partitions = sfdisk_info.get('partitiontable', {}).get('partitions', []) - node = list(filter(lambda x: x['node'] == self._path, partitions)) - - if len(node) > 0: - return node[0] - - return {} - - raise DiskError(f'Failed to read disk "{self.block_device.path}" with sfdisk') - - def _fetch_information(self) -> PartitionInfo: - lsblk_info = self._call_lsblk() - sfdisk_info = self._call_sfdisk() - - if not (device := lsblk_info.get('blockdevices', [])): - raise DiskError(f'Failed to retrieve information for "{self.device_path}" with lsblk') - - # Grab the first (and only) block device in the list as we're targeting a specific partition - device = device[0] - - mountpoints = [Path(mountpoint) for mountpoint in device['mountpoints'] if mountpoint] - bootable = sfdisk_info.get('bootable', False) or sfdisk_info.get('type', '') == 'C12A7328-F81F-11D2-BA4B-00A0C93EC93B' - - return PartitionInfo( - partition_object=self, - device_path=self._path, - pttype=device['pttype'], - partuuid=device['partuuid'], - uuid=device['uuid'], - sector_size=device['log-sec'], - size=convert_size_to_gb(device['size']), - start=sfdisk_info.get('start', None), - end=sfdisk_info.get('size', None), - bootable=bootable, - filesystem_type=device['fstype'], - mountpoints=mountpoints - ) - - @property - def target_mountpoint(self) -> Optional[str]: - return self._target_mountpoint - - @property - def path(self) -> str: - return self._path - - @property - def filesystem(self) -> str: - if self._partition_info: - return self._partition_info.filesystem_type - - @property - def mountpoint(self) -> Optional[Path]: - if len(self.mountpoints) > 0: - return self.mountpoints[0] - return None - - @property - def mountpoints(self) -> List[Path]: - if self._partition_info: - return self._partition_info.mountpoints - - @property - def sector_size(self) -> int: - if self._partition_info: - return self._partition_info.sector_size - - @property - def start(self) -> Optional[int]: - if self._partition_info: - return self._partition_info.start - - @property - def end(self) -> Optional[int]: - if self._partition_info: - return self._partition_info.end - - @property - def end_sectors(self) -> Optional[int]: - if self._partition_info: - start = self._partition_info.start - end = self._partition_info.end - if start and end: - return start + end - - @property - def size(self) -> Optional[float]: - if self._partition_info: - return self._partition_info.size - - @property - def boot(self) -> bool: - if self._partition_info: - return self._partition_info.bootable - - @property - def partition_type(self) -> Optional[str]: - if self._partition_info: - return self._partition_info.pttype - - @property - def part_uuid(self) -> str: - if self._partition_info: - return self._partition_info.partuuid - - @property - def uuid(self) -> Optional[str]: - """ - Returns the UUID as returned by lsblk for the **partition**. - This is more reliable than relying on /dev/disk/by-uuid as - it doesn't seam to be able to detect md raid partitions. - For bind mounts all the subvolumes share the same uuid - """ - for i in range(storage['DISK_RETRY_ATTEMPTS']): - if not self.partprobe(): - raise DiskError(f"Could not perform partprobe on {self.device_path}") - - time.sleep(storage.get('DISK_TIMEOUTS', 1) * i) - - partuuid = self._safe_uuid - if partuuid: - return partuuid - - raise DiskError(f"Could not get PARTUUID for {self.path} using 'blkid -s PARTUUID -o value {self.path}'") - - @property - def _safe_uuid(self) -> Optional[str]: - """ - A near copy of self.uuid but without any delays. - This function should only be used where uuid is not crucial. - For instance when you want to get a __repr__ of the class. - """ - if not self.partprobe(): - if self.block_device.partition_type == 'iso9660': - return None - - log(f"Could not reliably refresh PARTUUID of partition {self.device_path} due to partprobe error.", level=logging.DEBUG) - - try: - return SysCommand(f'blkid -s UUID -o value {self.device_path}').decode('UTF-8').strip() - except SysCallError as error: - if self.block_device.partition_type == 'iso9660': - # Parent device is a Optical Disk (.iso dd'ed onto a device for instance) - return None - - log(f"Could not get PARTUUID of partition using 'blkid -s UUID -o value {self.device_path}': {error}") - - @property - def _safe_part_uuid(self) -> Optional[str]: - """ - A near copy of self.uuid but without any delays. - This function should only be used where uuid is not crucial. - For instance when you want to get a __repr__ of the class. - """ - if not self.partprobe(): - if self.block_device.partition_type == 'iso9660': - return None - - log(f"Could not reliably refresh PARTUUID of partition {self.device_path} due to partprobe error.", level=logging.DEBUG) - - try: - return SysCommand(f'blkid -s PARTUUID -o value {self.device_path}').decode('UTF-8').strip() - except SysCallError as error: - if self.block_device.partition_type == 'iso9660': - # Parent device is a Optical Disk (.iso dd'ed onto a device for instance) - return None - - log(f"Could not get PARTUUID of partition using 'blkid -s PARTUUID -o value {self.device_path}': {error}") - - if self._partition_info: - return self._partition_info.uuid - - @property - def encrypted(self) -> Union[bool, None]: - return self._encrypted - - @property - def parent(self) -> str: - return self.real_device - - @property - def real_device(self) -> str: - output = SysCommand('lsblk -J').decode('UTF-8') - - if output: - for blockdevice in json.loads(output)['blockdevices']: - if parent := self.find_parent_of(blockdevice, os.path.basename(self.device_path)): - return f"/dev/{parent}" - return self._path - - raise DiskError('Unable to get disk information for command "lsblk -J"') - - @property - def device_path(self) -> str: - """ for bind mounts returns the physical path of the partition - """ - device_path, bind_name = split_bind_name(self._path) - return device_path - - @property - def bind_name(self) -> str: - """ for bind mounts returns the bind name (subvolume path). - Returns none if this property does not exist - """ - device_path, bind_name = split_bind_name(self._path) - return bind_name - - @property - def subvolumes(self) -> Iterator[BtrfsSubvolumeInfo]: - from .helpers import findmnt - - def iterate_children_recursively(information): - for child in information.get('children', []): - if target := child.get('target'): - if child.get('fstype') == 'btrfs': - if subvolume := subvolume_info_from_path(Path(target)): - yield subvolume - - if child.get('children'): - for subchild in iterate_children_recursively(child): - yield subchild - - if self._partition_info.filesystem_type == 'btrfs': - for mountpoint in self._partition_info.mountpoints: - if result := findmnt(mountpoint): - for filesystem in result.get('filesystems', []): - if subvolume := subvolume_info_from_path(mountpoint): - yield subvolume - - for child in iterate_children_recursively(filesystem): - yield child - - def partprobe(self) -> bool: - try: - if self.block_device: - return 0 == SysCommand(f'partprobe {self.block_device.device}').exit_code - except SysCallError as error: - log(f"Unreliable results might be given for {self._path} due to partprobe error: {error}", level=logging.DEBUG) - - return False - - def detect_inner_filesystem(self, password :str) -> Optional[str]: - log(f'Trying to detect inner filesystem format on {self} (This might take a while)', level=logging.INFO) - from ..luks import luks2 - - try: - with luks2(self, storage.get('ENC_IDENTIFIER', 'ai') + 'loop', password, auto_unmount=True) as unlocked_device: - return unlocked_device.filesystem - except SysCallError: - pass - return None - - def has_content(self) -> bool: - fs_type = self._partition_info.filesystem_type - if not fs_type or "swap" in fs_type: - return False - - temporary_mountpoint = '/tmp/' + hashlib.md5(bytes(f"{time.time()}", 'UTF-8') + os.urandom(12)).hexdigest() - temporary_path = Path(temporary_mountpoint) - - temporary_path.mkdir(parents=True, exist_ok=True) - if (handle := SysCommand(f'/usr/bin/mount {self._path} {temporary_mountpoint}')).exit_code != 0: - raise DiskError(f'Could not mount and check for content on {self._path} because: {handle}') - - files = len(glob.glob(f"{temporary_mountpoint}/*")) - iterations = 0 - while SysCommand(f"/usr/bin/umount -R {temporary_mountpoint}").exit_code != 0 and (iterations := iterations + 1) < 10: - time.sleep(1) - - temporary_path.rmdir() - - return True if files > 0 else False - - def encrypt(self, password: Optional[str] = None) -> str: - """ - A wrapper function for luks2() instances and the .encrypt() method of that instance. - """ - from ..luks import luks2 - - handle = luks2(self, None, None) - return handle.encrypt(self, password=password) - - def format(self, filesystem :Optional[str] = None, path :Optional[str] = None, log_formatting :bool = True, options :List[str] = [], retry :bool = True) -> bool: - """ - Format can be given an overriding path, for instance /dev/null to test - the formatting functionality and in essence the support for the given filesystem. - """ - if filesystem is None: - filesystem = self._partition_info.filesystem_type - - if path is None: - path = self._path - - # This converts from fat32 -> vfat to unify filesystem names - filesystem = get_mount_fs_type(filesystem) - - # To avoid "unable to open /dev/x: No such file or directory" - start_wait = time.time() - while Path(path).exists() is False and time.time() - start_wait < 10: - time.sleep(0.025) - - if log_formatting: - log(f'Formatting {path} -> {filesystem}', level=logging.INFO) - - try: - if filesystem == 'btrfs': - options = ['-f'] + options - - mkfs = SysCommand(f"/usr/bin/mkfs.btrfs {' '.join(options)} {path}").decode('UTF-8') - if mkfs and 'UUID:' not in mkfs: - raise DiskError(f'Could not format {path} with {filesystem} because: {mkfs}') - self._partition_info.filesystem_type = filesystem - - elif filesystem == 'vfat': - options = ['-F32'] + options - log(f"/usr/bin/mkfs.vfat {' '.join(options)} {path}") - if (handle := SysCommand(f"/usr/bin/mkfs.vfat {' '.join(options)} {path}")).exit_code != 0: - raise DiskError(f"Could not format {path} with {filesystem} because: {handle.decode('UTF-8')}") - self._partition_info.filesystem_type = filesystem - - elif filesystem == 'ext4': - options = ['-F'] + options - - if (handle := SysCommand(f"/usr/bin/mkfs.ext4 {' '.join(options)} {path}")).exit_code != 0: - raise DiskError(f"Could not format {path} with {filesystem} because: {handle.decode('UTF-8')}") - self._partition_info.filesystem_type = filesystem - - elif filesystem == 'ext2': - options = ['-F'] + options - - if (handle := SysCommand(f"/usr/bin/mkfs.ext2 {' '.join(options)} {path}")).exit_code != 0: - raise DiskError(f"Could not format {path} with {filesystem} because: {handle.decode('UTF-8')}") - self._partition_info.filesystem_type = 'ext2' - elif filesystem == 'xfs': - options = ['-f'] + options - - if (handle := SysCommand(f"/usr/bin/mkfs.xfs {' '.join(options)} {path}")).exit_code != 0: - raise DiskError(f"Could not format {path} with {filesystem} because: {handle.decode('UTF-8')}") - self._partition_info.filesystem_type = filesystem - - elif filesystem == 'f2fs': - options = ['-f'] + options - - if (handle := SysCommand(f"/usr/bin/mkfs.f2fs {' '.join(options)} {path}")).exit_code != 0: - raise DiskError(f"Could not format {path} with {filesystem} because: {handle.decode('UTF-8')}") - self._partition_info.filesystem_type = filesystem - - elif filesystem == 'ntfs3': - options = ['-f'] + options - - if (handle := SysCommand(f"/usr/bin/mkfs.ntfs -Q {' '.join(options)} {path}")).exit_code != 0: - raise DiskError(f"Could not format {path} with {filesystem} because: {handle.decode('UTF-8')}") - self._partition_info.filesystem_type = filesystem - - elif filesystem == 'crypto_LUKS': - # from ..luks import luks2 - # encrypted_partition = luks2(self, None, None) - # encrypted_partition.format(path) - self._partition_info.filesystem_type = filesystem - - else: - raise UnknownFilesystemFormat(f"Fileformat '{filesystem}' is not yet implemented.") - except SysCallError as error: - log(f"Formatting ran in to an error: {error}", level=logging.WARNING, fg="orange") - if retry is True: - log(f"Retrying in {storage.get('DISK_TIMEOUTS', 1)} seconds.", level=logging.WARNING, fg="orange") - time.sleep(storage.get('DISK_TIMEOUTS', 1)) - - return self.format(filesystem, path, log_formatting, options, retry=False) - - if get_filesystem_type(path) == 'crypto_LUKS' or get_filesystem_type(self.real_device) == 'crypto_LUKS': - self._encrypted = True - else: - self._encrypted = False - - return True - - def find_parent_of(self, data :Dict[str, Any], name :str, parent :Optional[str] = None) -> Optional[str]: - if data['name'] == name: - return parent - elif 'children' in data: - for child in data['children']: - if parent := self.find_parent_of(child, name, parent=data['name']): - return parent - - return None - - def mount(self, target :str, fs :Optional[str] = None, options :str = '') -> bool: - if not self._partition_info.get_first_mountpoint(): - log(f'Mounting {self} to {target}', level=logging.INFO) - - if not fs: - fs = self._partition_info.filesystem_type - - fs_type = get_mount_fs_type(fs) - - Path(target).mkdir(parents=True, exist_ok=True) - - if self.bind_name: - device_path = self.device_path - # TODO options should be better be a list than a string - if options: - options = f"{options},subvol={self.bind_name}" - else: - options = f"subvol={self.bind_name}" - else: - device_path = self._path - try: - if options: - mnt_handle = SysCommand(f"/usr/bin/mount -t {fs_type} -o {options} {device_path} {target}") - else: - mnt_handle = SysCommand(f"/usr/bin/mount -t {fs_type} {device_path} {target}") - - # TODO: Should be redundant to check for exit_code - if mnt_handle.exit_code != 0: - raise DiskError(f"Could not mount {self._path} to {target} using options {options}") - except SysCallError as err: - raise err - - # Update the partition info since the mount info has changed after this call. - self._partition_info = self._fetch_information() - return True - - return False - - def unmount(self) -> bool: - SysCommand(f"/usr/bin/umount {self._path}") - - # Update the partition info since the mount info has changed after this call. - self._partition_info = self._fetch_information() - return True - - def filesystem_supported(self) -> bool: - """ - The support for a filesystem (this partition) is tested by calling - partition.format() with a path set to '/dev/null' which returns two exceptions: - 1. SysCallError saying that /dev/null is not formattable - but the filesystem is supported - 2. UnknownFilesystemFormat that indicates that we don't support the given filesystem type - """ - try: - self.format(self._partition_info.filesystem_type, '/dev/null', log_formatting=False) - except (SysCallError, DiskError): - pass # We supported it, but /dev/null is not formattable as expected so the mkfs call exited with an error code - except UnknownFilesystemFormat as err: - raise err - return True - - -def get_mount_fs_type(fs :str) -> str: - if fs == 'ntfs': - return 'ntfs3' # Needed to use the Paragon R/W NTFS driver - elif fs == 'fat32': - return 'vfat' # This is the actual type used for fat32 mounting - return fs diff --git a/archinstall/lib/disk/partitioning_menu.py b/archinstall/lib/disk/partitioning_menu.py new file mode 100644 index 00000000..fb1eb74b --- /dev/null +++ b/archinstall/lib/disk/partitioning_menu.py @@ -0,0 +1,429 @@ +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any, TYPE_CHECKING, List, Optional, Tuple +from dataclasses import dataclass + +from .device_model import ( + PartitionModification, FilesystemType, BDevice, + Size, Unit, PartitionType, PartitionFlag, + ModificationStatus, DeviceGeometry, SectorSize, BtrfsMountOption +) +from ..hardware import SysInfo +from ..menu import Menu, ListManager, MenuSelection, TextInput +from ..output import FormattedOutput, warn +from .subvolume_menu import SubvolumeMenu + +if TYPE_CHECKING: + _: Any + + +@dataclass +class DefaultFreeSector: + start: Size + end: Size + + +class PartitioningList(ListManager): + """ + subclass of ListManager for the managing of user accounts + """ + def __init__(self, prompt: str, device: BDevice, device_partitions: List[PartitionModification]): + self._device = device + self._actions = { + 'create_new_partition': str(_('Create a new partition')), + 'suggest_partition_layout': str(_('Suggest partition layout')), + 'remove_added_partitions': str(_('Remove all newly added partitions')), + 'assign_mountpoint': str(_('Assign mountpoint')), + 'mark_formatting': str(_('Mark/Unmark to be formatted (wipes data)')), + 'mark_bootable': str(_('Mark/Unmark as bootable')), + 'set_filesystem': str(_('Change filesystem')), + 'btrfs_mark_compressed': str(_('Mark/Unmark as compressed')), # btrfs only + 'btrfs_mark_nodatacow': str(_('Mark/Unmark as nodatacow')), # btrfs only + 'btrfs_set_subvolumes': str(_('Set subvolumes')), # btrfs only + 'delete_partition': str(_('Delete partition')) + } + + display_actions = list(self._actions.values()) + super().__init__(prompt, device_partitions, display_actions[:2], display_actions[3:]) + + def selected_action_display(self, partition: PartitionModification) -> str: + return str(_('Partition')) + + def filter_options(self, selection: PartitionModification, options: List[str]) -> List[str]: + not_filter = [] + + # only display formatting if the partition exists already + if not selection.exists(): + not_filter += [self._actions['mark_formatting']] + else: + # only allow options if the existing partition + # was marked as formatting, otherwise we run into issues where + # 1. select a new fs -> potentially mark as wipe now + # 2. Switch back to old filesystem -> should unmark wipe now, but + # how do we know it was the original one? + not_filter += [ + self._actions['set_filesystem'], + self._actions['mark_bootable'], + self._actions['btrfs_mark_compressed'], + self._actions['btrfs_mark_nodatacow'], + self._actions['btrfs_set_subvolumes'] + ] + + # non btrfs partitions shouldn't get btrfs options + if selection.fs_type != FilesystemType.Btrfs: + not_filter += [ + self._actions['btrfs_mark_compressed'], + self._actions['btrfs_mark_nodatacow'], + self._actions['btrfs_set_subvolumes'] + ] + else: + not_filter += [self._actions['assign_mountpoint']] + + return [o for o in options if o not in not_filter] + + def handle_action( + self, + action: str, + entry: Optional[PartitionModification], + data: List[PartitionModification] + ) -> List[PartitionModification]: + action_key = [k for k, v in self._actions.items() if v == action][0] + + match action_key: + case 'create_new_partition': + new_partition = self._create_new_partition() + data += [new_partition] + case 'suggest_partition_layout': + new_partitions = self._suggest_partition_layout(data) + if len(new_partitions) > 0: + data = new_partitions + case 'remove_added_partitions': + choice = self._reset_confirmation() + if choice.value == Menu.yes(): + data = [part for part in data if part.is_exists_or_modify()] + case 'assign_mountpoint' if entry: + entry.mountpoint = self._prompt_mountpoint() + if entry.mountpoint == Path('/boot'): + entry.set_flag(PartitionFlag.Boot) + if SysInfo.has_uefi(): + entry.set_flag(PartitionFlag.ESP) + case 'mark_formatting' if entry: + self._prompt_formatting(entry) + case 'mark_bootable' if entry: + entry.invert_flag(PartitionFlag.Boot) + if SysInfo.has_uefi(): + entry.invert_flag(PartitionFlag.ESP) + case 'set_filesystem' if entry: + fs_type = self._prompt_partition_fs_type() + if fs_type: + entry.fs_type = fs_type + # btrfs subvolumes will define mountpoints + if fs_type == FilesystemType.Btrfs: + entry.mountpoint = None + case 'btrfs_mark_compressed' if entry: + self._toggle_mount_option(entry, BtrfsMountOption.compress) + case 'btrfs_mark_nodatacow' if entry: + self._toggle_mount_option(entry, BtrfsMountOption.nodatacow) + case 'btrfs_set_subvolumes' if entry: + self._set_btrfs_subvolumes(entry) + case 'delete_partition' if entry: + data = self._delete_partition(entry, data) + + return data + + def _delete_partition( + self, + entry: PartitionModification, + data: List[PartitionModification] + ) -> List[PartitionModification]: + if entry.is_exists_or_modify(): + entry.status = ModificationStatus.Delete + return data + else: + return [d for d in data if d != entry] + + def _toggle_mount_option( + self, + partition: PartitionModification, + option: BtrfsMountOption + ): + if option.value not in partition.mount_options: + if option == BtrfsMountOption.compress: + partition.mount_options = [ + o for o in partition.mount_options + if o != BtrfsMountOption.nodatacow.value + ] + + partition.mount_options = [ + o for o in partition.mount_options + if not o.startswith(BtrfsMountOption.compress.name) + ] + + partition.mount_options.append(option.value) + else: + partition.mount_options = [ + o for o in partition.mount_options if o != option.value + ] + + def _set_btrfs_subvolumes(self, partition: PartitionModification): + partition.btrfs_subvols = SubvolumeMenu( + _("Manage btrfs subvolumes for current partition"), + partition.btrfs_subvols + ).run() + + def _prompt_formatting(self, partition: PartitionModification): + # an existing partition can toggle between Exist or Modify + if partition.is_modify(): + partition.status = ModificationStatus.Exist + return + elif partition.exists(): + partition.status = ModificationStatus.Modify + + # If we mark a partition for formatting, but the format is CRYPTO LUKS, there's no point in formatting it really + # without asking the user which inner-filesystem they want to use. Since the flag 'encrypted' = True is already set, + # it's safe to change the filesystem for this partition. + if partition.fs_type == FilesystemType.Crypto_luks: + prompt = str(_('This partition is currently encrypted, to format it a filesystem has to be specified')) + fs_type = self._prompt_partition_fs_type(prompt) + partition.fs_type = fs_type + + if fs_type == FilesystemType.Btrfs: + partition.mountpoint = None + + def _prompt_mountpoint(self) -> Path: + header = str(_('Partition mount-points are relative to inside the installation, the boot would be /boot as an example.')) + '\n' + header += str(_('If mountpoint /boot is set, then the partition will also be marked as bootable.')) + '\n' + prompt = str(_('Mountpoint: ')) + + print(header) + + while True: + value = TextInput(prompt).run().strip() + + if value: + mountpoint = Path(value) + break + + return mountpoint + + def _prompt_partition_fs_type(self, prompt: str = '') -> FilesystemType: + options = {fs.value: fs for fs in FilesystemType if fs != FilesystemType.Crypto_luks} + + prompt = prompt + '\n' + str(_('Enter a desired filesystem type for the partition')) + choice = Menu(prompt, options, sort=False, skip=False).run() + return options[choice.single_value] + + def _validate_value( + self, + sector_size: SectorSize, + total_size: Size, + text: str, + start: Optional[Size] + ) -> Optional[Size]: + match = re.match(r'([0-9]+)([a-zA-Z|%]*)', text, re.I) + + if match: + str_value, unit = match.groups() + + if unit == '%' and start: + available = total_size - start + value = int(available.value * (int(str_value) / 100)) + unit = available.unit.name + else: + value = int(str_value) + + if unit and unit not in Unit.get_all_units(): + return None + + unit = Unit[unit] if unit else Unit.sectors + return Size(value, unit, sector_size) + + return None + + def _enter_size( + self, + sector_size: SectorSize, + total_size: Size, + prompt: str, + default: Size, + start: Optional[Size], + ) -> Size: + while True: + value = TextInput(prompt).run().strip() + size: Optional[Size] = None + if not value: + size = default + else: + size = self._validate_value(sector_size, total_size, value, start) + + if size: + return size + + warn(f'Invalid value: {value}') + + def _prompt_size(self) -> Tuple[Size, Size]: + device_info = self._device.device_info + + text = str(_('Current free sectors on device {}:')).format(device_info.path) + '\n\n' + free_space_table = FormattedOutput.as_table(device_info.free_space_regions) + prompt = text + free_space_table + '\n' + + total_sectors = device_info.total_size.format_size(Unit.sectors, device_info.sector_size) + total_bytes = device_info.total_size.format_size(Unit.B) + + prompt += str(_('Total: {} / {}')).format(total_sectors, total_bytes) + '\n\n' + prompt += str(_('All entered values can be suffixed with a unit: %, B, KB, KiB, MB, MiB...')) + '\n' + prompt += str(_('If no unit is provided, the value is interpreted as sectors')) + '\n' + print(prompt) + + default_free_sector = self._find_default_free_space() + + if not default_free_sector: + default_free_sector = DefaultFreeSector( + Size(0, Unit.sectors, self._device.device_info.sector_size), + Size(0, Unit.sectors, self._device.device_info.sector_size) + ) + + # prompt until a valid start sector was entered + start_prompt = str(_('Enter start (default: sector {}): ')).format(default_free_sector.start.value) + + start_size = self._enter_size( + device_info.sector_size, + device_info.total_size, + start_prompt, + default_free_sector.start, + None + ) + + if start_size.value == default_free_sector.start.value and default_free_sector.end.value != 0: + end_size = default_free_sector.end + else: + end_size = device_info.total_size + + # prompt until valid end sector was entered + end_prompt = str(_('Enter end (default: {}): ')).format(end_size.as_text()) + end_size = self._enter_size( + device_info.sector_size, + device_info.total_size, + end_prompt, + end_size, + start_size + ) + + return start_size, end_size + + def _find_default_free_space(self) -> Optional[DefaultFreeSector]: + device_info = self._device.device_info + + largest_free_area: Optional[DeviceGeometry] = None + largest_deleted_area: Optional[PartitionModification] = None + + if len(device_info.free_space_regions) > 0: + largest_free_area = max(device_info.free_space_regions, key=lambda r: r.get_length()) + + deleted_partitions = list(filter(lambda x: x.status == ModificationStatus.Delete, self._data)) + if len(deleted_partitions) > 0: + largest_deleted_area = max(deleted_partitions, key=lambda p: p.length) + + def _free_space(space: DeviceGeometry) -> DefaultFreeSector: + start = Size(space.start, Unit.sectors, device_info.sector_size) + end = Size(space.end, Unit.sectors, device_info.sector_size) + return DefaultFreeSector(start, end) + + def _free_deleted(space: PartitionModification) -> DefaultFreeSector: + start = space.start.convert(Unit.sectors, self._device.device_info.sector_size) + end = space.end.convert(Unit.sectors, self._device.device_info.sector_size) + return DefaultFreeSector(start, end) + + if not largest_deleted_area and largest_free_area: + return _free_space(largest_free_area) + elif not largest_free_area and largest_deleted_area: + return _free_deleted(largest_deleted_area) + elif not largest_deleted_area and not largest_free_area: + return None + elif largest_free_area and largest_deleted_area: + free_space = _free_space(largest_free_area) + if free_space.start > largest_deleted_area.start: + return free_space + else: + return _free_deleted(largest_deleted_area) + + return None + + def _create_new_partition(self) -> PartitionModification: + fs_type = self._prompt_partition_fs_type() + + start_size, end_size = self._prompt_size() + length = end_size - start_size + + # new line for the next prompt + print() + + mountpoint = None + if fs_type != FilesystemType.Btrfs: + mountpoint = self._prompt_mountpoint() + + partition = PartitionModification( + status=ModificationStatus.Create, + type=PartitionType.Primary, + start=start_size, + length=length, + fs_type=fs_type, + mountpoint=mountpoint + ) + + if partition.mountpoint == Path('/boot'): + partition.set_flag(PartitionFlag.Boot) + if SysInfo.has_uefi(): + partition.set_flag(PartitionFlag.ESP) + + return partition + + def _reset_confirmation(self) -> MenuSelection: + prompt = str(_('This will remove all newly added partitions, continue?')) + choice = Menu(prompt, Menu.yes_no(), default_option=Menu.no(), skip=False).run() + return choice + + def _suggest_partition_layout(self, data: List[PartitionModification]) -> List[PartitionModification]: + # if modifications have been done already, inform the user + # that this operation will erase those modifications + if any([not entry.exists() for entry in data]): + choice = self._reset_confirmation() + if choice.value == Menu.no(): + return [] + + from ..interactions.disk_conf import suggest_single_disk_layout + + device_modification = suggest_single_disk_layout(self._device) + return device_modification.partitions + + +def manual_partitioning( + device: BDevice, + prompt: str = '', + preset: List[PartitionModification] = [] +) -> List[PartitionModification]: + if not prompt: + prompt = str(_('Partition management: {}')).format(device.device_info.path) + '\n' + prompt += str(_('Total length: {}')).format(device.device_info.total_size.format_size(Unit.MiB)) + + manual_preset = [] + + if not preset: + # we'll display the existing partitions of the device + for partition in device.partition_infos: + manual_preset.append( + PartitionModification.from_existing_partition(partition) + ) + else: + manual_preset = preset + + menu_list = PartitioningList(prompt, device, manual_preset) + partitions: List[PartitionModification] = menu_list.run() + + if menu_list.is_last_choice_cancel(): + return preset + + return partitions diff --git a/archinstall/lib/disk/subvolume_menu.py b/archinstall/lib/disk/subvolume_menu.py new file mode 100644 index 00000000..ea77149d --- /dev/null +++ b/archinstall/lib/disk/subvolume_menu.py @@ -0,0 +1,61 @@ +from pathlib import Path +from typing import List, Optional, Any, TYPE_CHECKING + +from .device_model import SubvolumeModification +from ..menu import TextInput, ListManager + +if TYPE_CHECKING: + _: Any + + +class SubvolumeMenu(ListManager): + def __init__(self, prompt: str, btrfs_subvols: List[SubvolumeModification]): + self._actions = [ + str(_('Add subvolume')), + str(_('Edit subvolume')), + str(_('Delete subvolume')) + ] + super().__init__(prompt, btrfs_subvols, [self._actions[0]], self._actions[1:]) + + def selected_action_display(self, subvolume: SubvolumeModification) -> str: + return str(subvolume.name) + + def _add_subvolume(self, editing: Optional[SubvolumeModification] = None) -> Optional[SubvolumeModification]: + name = TextInput(f'\n\n{_("Subvolume name")}: ', editing.name if editing else '').run() + + if not name: + return None + + mountpoint = TextInput(f'{_("Subvolume mountpoint")}: ', str(editing.mountpoint) if editing else '').run() + + if not mountpoint: + return None + + return SubvolumeModification(Path(name), Path(mountpoint)) + + def handle_action( + self, + action: str, + entry: Optional[SubvolumeModification], + data: List[SubvolumeModification] + ) -> List[SubvolumeModification]: + if action == self._actions[0]: # add + new_subvolume = self._add_subvolume() + + if new_subvolume is not None: + # in case a user with the same username as an existing user + # was created we'll replace the existing one + data = [d for d in data if d.name != new_subvolume.name] + data += [new_subvolume] + elif entry is not None: + if action == self._actions[1]: # edit subvolume + new_subvolume = self._add_subvolume(entry) + + if new_subvolume is not None: + # we'll remove the original subvolume and add the modified version + data = [d for d in data if d.name != entry.name and d.name != new_subvolume.name] + data += [new_subvolume] + elif action == self._actions[2]: # delete + data = [d for d in data if d != entry] + + return data diff --git a/archinstall/lib/disk/user_guides.py b/archinstall/lib/disk/user_guides.py deleted file mode 100644 index 5809c073..00000000 --- a/archinstall/lib/disk/user_guides.py +++ /dev/null @@ -1,240 +0,0 @@ -from __future__ import annotations -import logging -from typing import Optional, Dict, Any, List, TYPE_CHECKING - -# https://stackoverflow.com/a/39757388/929999 -from ..models.subvolume import Subvolume - -if TYPE_CHECKING: - from .blockdevice import BlockDevice - _: Any - -from .helpers import sort_block_devices_based_on_performance, select_largest_device, select_disk_larger_than_or_close_to -from ..hardware import has_uefi -from ..output import log -from ..menu import Menu - - -def suggest_single_disk_layout(block_device :BlockDevice, - default_filesystem :Optional[str] = None, - advanced_options :bool = False) -> Dict[str, Any]: - - if not default_filesystem: - from ..user_interaction import ask_for_main_filesystem_format - default_filesystem = ask_for_main_filesystem_format(advanced_options) - - MIN_SIZE_TO_ALLOW_HOME_PART = 40 # GiB - using_subvolumes = False - using_home_partition = False - compression = False - - if default_filesystem == 'btrfs': - prompt = str(_('Would you like to use BTRFS subvolumes with a default structure?')) - choice = Menu(prompt, Menu.yes_no(), skip=False, default_option=Menu.yes()).run() - using_subvolumes = choice.value == Menu.yes() - - prompt = str(_('Would you like to use BTRFS compression?')) - choice = Menu(prompt, Menu.yes_no(), skip=False, default_option=Menu.yes()).run() - compression = choice.value == Menu.yes() - - layout = { - block_device.path : { - "wipe" : True, - "partitions" : [] - } - } - - # Used for reference: https://wiki.archlinux.org/title/partitioning - - # 2 MiB is unallocated for GRUB on BIOS. Potentially unneeded for - # other bootloaders? - - # TODO: On BIOS, /boot partition is only needed if the drive will - # be encrypted, otherwise it is not recommended. We should probably - # add a check for whether the drive will be encrypted or not. - layout[block_device.path]['partitions'].append({ - # Boot - "type" : "primary", - "start" : "3MiB", - "size" : "203MiB", - "boot" : True, - "encrypted" : False, - "wipe" : True, - "mountpoint" : "/boot", - "filesystem" : { - "format" : "fat32" - } - }) - - # Increase the UEFI partition if UEFI is detected. - # Also re-align the start to 1MiB since we don't need the first sectors - # like we do in MBR layouts where the boot loader is installed traditionally. - if has_uefi(): - layout[block_device.path]['partitions'][-1]['start'] = '1MiB' - layout[block_device.path]['partitions'][-1]['size'] = '512MiB' - - layout[block_device.path]['partitions'].append({ - # Root - "type" : "primary", - "start" : "206MiB", - "encrypted" : False, - "wipe" : True, - "mountpoint" : "/" if not using_subvolumes else None, - "filesystem" : { - "format" : default_filesystem, - "mount_options" : ["compress=zstd"] if compression else [] - } - }) - - if has_uefi(): - layout[block_device.path]['partitions'][-1]['start'] = '513MiB' - - if not using_subvolumes and block_device.size >= MIN_SIZE_TO_ALLOW_HOME_PART: - prompt = str(_('Would you like to create a separate partition for /home?')) - choice = Menu(prompt, Menu.yes_no(), skip=False, default_option=Menu.yes()).run() - using_home_partition = choice.value == Menu.yes() - - # Set a size for / (/root) - if using_subvolumes or block_device.size < MIN_SIZE_TO_ALLOW_HOME_PART or not using_home_partition: - # We'll use subvolumes - # Or the disk size is too small to allow for a separate /home - # Or the user doesn't want to create a separate partition for /home - layout[block_device.path]['partitions'][-1]['size'] = '100%' - else: - layout[block_device.path]['partitions'][-1]['size'] = f"{min(block_device.size, 20)}GiB" - - if default_filesystem == 'btrfs' and using_subvolumes: - # if input('Do you want to use a recommended structure? (Y/n): ').strip().lower() in ('', 'y', 'yes'): - # https://btrfs.wiki.kernel.org/index.php/FAQ - # https://unix.stackexchange.com/questions/246976/btrfs-subvolume-uuid-clash - # https://github.com/classy-giraffe/easy-arch/blob/main/easy-arch.sh - layout[block_device.path]['partitions'][1]['btrfs'] = { - 'subvolumes': [ - Subvolume('@', '/'), - Subvolume('@home', '/home'), - Subvolume('@log', '/var/log'), - Subvolume('@pkg', '/var/cache/pacman/pkg'), - Subvolume('@.snapshots', '/.snapshots') - ] - } - elif using_home_partition: - # If we don't want to use subvolumes, - # But we want to be able to re-use data between re-installs.. - # A second partition for /home would be nice if we have the space for it - layout[block_device.path]['partitions'].append({ - # Home - "type" : "primary", - "start" : f"{min(block_device.size, 20)}GiB", - "size" : "100%", - "encrypted" : False, - "wipe" : True, - "mountpoint" : "/home", - "filesystem" : { - "format" : default_filesystem, - "mount_options" : ["compress=zstd"] if compression else [] - } - }) - - return layout - - -def suggest_multi_disk_layout(block_devices :List[BlockDevice], default_filesystem :Optional[str] = None, advanced_options :bool = False): - - if not default_filesystem: - from ..user_interaction import ask_for_main_filesystem_format - default_filesystem = ask_for_main_filesystem_format(advanced_options) - - # Not really a rock solid foundation of information to stand on, but it's a start: - # https://www.reddit.com/r/btrfs/comments/m287gp/partition_strategy_for_two_physical_disks/ - # https://www.reddit.com/r/btrfs/comments/9us4hr/what_is_your_btrfs_partitionsubvolumes_scheme/ - - MIN_SIZE_TO_ALLOW_HOME_PART = 40 # GiB - ARCH_LINUX_INSTALLED_SIZE = 20 # GiB, rough estimate taking in to account user desktops etc. TODO: Catch user packages to detect size? - - block_devices = sort_block_devices_based_on_performance(block_devices).keys() - - home_device = select_largest_device(block_devices, gigabytes=MIN_SIZE_TO_ALLOW_HOME_PART) - root_device = select_disk_larger_than_or_close_to(block_devices, gigabytes=ARCH_LINUX_INSTALLED_SIZE, filter_out=[home_device]) - - if home_device is None or root_device is None: - text = _('The selected drives do not have the minimum capacity required for an automatic suggestion\n') - text += _('Minimum capacity for /home partition: {}GB\n').format(MIN_SIZE_TO_ALLOW_HOME_PART) - text += _('Minimum capacity for Arch Linux partition: {}GB').format(ARCH_LINUX_INSTALLED_SIZE) - Menu(str(text), [str(_('Continue'))], skip=False).run() - return None - - compression = False - - if default_filesystem == 'btrfs': - # prompt = 'Would you like to use BTRFS subvolumes with a default structure?' - # choice = Menu(prompt, ['yes', 'no'], skip=False, default_option='yes').run() - # using_subvolumes = choice == 'yes' - - prompt = str(_('Would you like to use BTRFS compression?')) - choice = Menu(prompt, Menu.yes_no(), skip=False, default_option=Menu.yes()).run() - compression = choice.value == Menu.yes() - - log(f"Suggesting multi-disk-layout using {len(block_devices)} disks, where {root_device} will be /root and {home_device} will be /home", level=logging.DEBUG) - - layout = { - root_device.path : { - "wipe" : True, - "partitions" : [] - }, - home_device.path : { - "wipe" : True, - "partitions" : [] - }, - } - - # TODO: Same deal as with the single disk layout, we should - # probably check if the drive will be encrypted. - layout[root_device.path]['partitions'].append({ - # Boot - "type" : "primary", - "start" : "3MiB", - "size" : "203MiB", - "boot" : True, - "encrypted" : False, - "wipe" : True, - "mountpoint" : "/boot", - "filesystem" : { - "format" : "fat32" - } - }) - - if has_uefi(): - layout[root_device.path]['partitions'][-1]['start'] = '1MiB' - layout[root_device.path]['partitions'][-1]['size'] = '512MiB' - - layout[root_device.path]['partitions'].append({ - # Root - "type" : "primary", - "start" : "206MiB", - "size" : "100%", - "encrypted" : False, - "wipe" : True, - "mountpoint" : "/", - "filesystem" : { - "format" : default_filesystem, - "mount_options" : ["compress=zstd"] if compression else [] - } - }) - if has_uefi(): - layout[root_device.path]['partitions'][-1]['start'] = '513MiB' - - layout[home_device.path]['partitions'].append({ - # Home - "type" : "primary", - "start" : "1MiB", - "size" : "100%", - "encrypted" : False, - "wipe" : True, - "mountpoint" : "/home", - "filesystem" : { - "format" : default_filesystem, - "mount_options" : ["compress=zstd"] if compression else [] - } - }) - - return layout diff --git a/archinstall/lib/disk/validators.py b/archinstall/lib/disk/validators.py deleted file mode 100644 index 076a8ba2..00000000 --- a/archinstall/lib/disk/validators.py +++ /dev/null @@ -1,48 +0,0 @@ -from typing import List - -def valid_parted_position(pos :str) -> bool: - if not len(pos): - return False - - if pos.isdigit(): - return True - - pos_lower = pos.lower() - - if (pos_lower.endswith('b') or pos_lower.endswith('s')) and pos[:-1].isdigit(): - return True - - if any(pos_lower.endswith(size) and pos[:-len(size)].replace(".", "", 1).isdigit() - for size in ['%', 'kb', 'mb', 'gb', 'tb', 'kib', 'mib', 'gib', 'tib']): - return True - - return False - - -def fs_types() -> List[str]: - # https://www.gnu.org/software/parted/manual/html_node/mkpart.html - # Above link doesn't agree with `man parted` /mkpart documentation: - """ - fs-type can - be one of "btrfs", "ext2", - "ext3", "ext4", "fat16", - "fat32", "hfs", "hfs+", - "linux-swap", "ntfs", "reis‐ - erfs", "udf", or "xfs". - """ - return [ - "btrfs", - "ext2", - "ext3", "ext4", # `man parted` allows these - "fat16", "fat32", - "hfs", "hfs+", # "hfsx", not included in `man parted` - "linux-swap", - "ntfs", - "reiserfs", - "udf", # "ufs", not included in `man parted` - "xfs", # `man parted` allows this - ] - - -def valid_fs_type(fstype :str) -> bool: - return fstype.lower() in fs_types() diff --git a/archinstall/lib/exceptions.py b/archinstall/lib/exceptions.py index a66e4e04..80926e0b 100644 --- a/archinstall/lib/exceptions.py +++ b/archinstall/lib/exceptions.py @@ -3,23 +3,20 @@ from typing import Optional, TYPE_CHECKING if TYPE_CHECKING: from .general import SysCommandWorker -class RequirementError(BaseException): - pass - -class DiskError(BaseException): +class RequirementError(Exception): pass -class UnknownFilesystemFormat(BaseException): +class DiskError(Exception): pass -class ProfileError(BaseException): +class UnknownFilesystemFormat(Exception): pass -class SysCallError(BaseException): +class SysCallError(Exception): def __init__(self, message :str, exit_code :Optional[int] = None, worker :Optional['SysCommandWorker'] = None) -> None: super(SysCallError, self).__init__(message) self.message = message @@ -27,33 +24,17 @@ class SysCallError(BaseException): self.worker = worker -class PermissionError(BaseException): +class HardwareIncompatibilityError(Exception): pass -class ProfileNotFound(BaseException): +class ServiceException(Exception): pass -class HardwareIncompatibilityError(BaseException): +class PackageError(Exception): pass -class UserError(BaseException): +class Deprecated(Exception): pass - - -class ServiceException(BaseException): - pass - - -class PackageError(BaseException): - pass - - -class TranslationError(BaseException): - pass - - -class Deprecated(BaseException): - pass
\ No newline at end of file diff --git a/archinstall/lib/general.py b/archinstall/lib/general.py index 79ab024b..8dbf23ff 100644 --- a/archinstall/lib/general.py +++ b/archinstall/lib/general.py @@ -1,7 +1,6 @@ from __future__ import annotations -import hashlib + import json -import logging import os import secrets import shlex @@ -12,212 +11,117 @@ import sys import time import re import urllib.parse -import urllib.request +from urllib.request import Request, urlopen import urllib.error import pathlib from datetime import datetime, date +from enum import Enum from typing import Callable, Optional, Dict, Any, List, Union, Iterator, TYPE_CHECKING -# https://stackoverflow.com/a/39757388/929999 -if TYPE_CHECKING: - from .installer import Installer - -if sys.platform == 'linux': - from select import epoll, EPOLLIN, EPOLLHUP -else: - import select - EPOLLIN = 0 - EPOLLHUP = 0 - - class epoll(): - """ #!if windows - Create a epoll() implementation that simulates the epoll() behavior. - This so that the rest of the code doesn't need to worry weither we're using select() or epoll(). - """ - def __init__(self) -> None: - self.sockets: Dict[str, Any] = {} - self.monitoring: Dict[int, Any] = {} - - def unregister(self, fileno :int, *args :List[Any], **kwargs :Dict[str, Any]) -> None: - try: - del(self.monitoring[fileno]) # noqa: E275 - except: - pass - - def register(self, fileno :int, *args :int, **kwargs :Dict[str, Any]) -> None: - self.monitoring[fileno] = True - - def poll(self, timeout: float = 0.05, *args :str, **kwargs :Dict[str, Any]) -> List[Any]: - try: - return [[fileno, 1] for fileno in select.select(list(self.monitoring.keys()), [], [], timeout)[0]] - except OSError: - return [] +from select import epoll, EPOLLIN, EPOLLHUP +from shutil import which from .exceptions import RequirementError, SysCallError -from .output import log +from .output import debug, error, info from .storage import storage -def gen_uid(entropy_length :int = 256) -> str: - return hashlib.sha512(os.urandom(entropy_length)).hexdigest() + +if TYPE_CHECKING: + from .installer import Installer + def generate_password(length :int = 64) -> str: - haystack = string.printable # digits, ascii_letters, punctiation (!"#$[] etc) and whitespace + haystack = string.printable # digits, ascii_letters, punctuation (!"#$[] etc) and whitespace return ''.join(secrets.choice(haystack) for i in range(length)) -def multisplit(s :str, splitters :List[str]) -> str: - s = [s, ] - for key in splitters: - ns = [] - for obj in s: - x = obj.split(key) - for index, part in enumerate(x): - if len(part): - ns.append(part) - if index < len(x) - 1: - ns.append(key) - s = ns - return s def locate_binary(name :str) -> str: - for PATH in os.environ['PATH'].split(':'): - for root, folders, files in os.walk(PATH): - for file in files: - if file == name: - return os.path.join(root, file) - break # Don't recurse - + if path := which(name): + return path raise RequirementError(f"Binary {name} does not exist.") -def clear_vt100_escape_codes(data :Union[bytes, str]): - # https://stackoverflow.com/a/43627833/929999 - if type(data) == bytes: - vt100_escape_regex = bytes(r'\x1B\[[?0-9;]*[a-zA-Z]', 'UTF-8') - else: - vt100_escape_regex = r'\x1B\[[?0-9;]*[a-zA-Z]' - - for match in re.findall(vt100_escape_regex, data, re.IGNORECASE): - data = data.replace(match, '' if type(data) == str else b'') - return data - -def json_dumps(*args :str, **kwargs :str) -> str: - return json.dumps(*args, **{**kwargs, 'cls': JSON}) +def clear_vt100_escape_codes(data :Union[bytes, str]) -> Union[bytes, str]: + # https://stackoverflow.com/a/43627833/929999 + vt100_escape_regex = r'\x1B\[[?0-9;]*[a-zA-Z]' + if isinstance(data, bytes): + return re.sub(vt100_escape_regex.encode(), b'', data) + return re.sub(vt100_escape_regex, '', data) -class JsonEncoder: - @staticmethod - def _encode(obj :Any) -> Any: - """ - This JSON encoder function will try it's best to convert - any archinstall data structures, instances or variables into - something that's understandable by the json.parse()/json.loads() lib. - _encode() will skip any dictionary key starting with an exclamation mark (!) - """ - if isinstance(obj, dict): - # We'll need to iterate not just the value that default() usually gets passed - # But also iterate manually over each key: value pair in order to trap the keys. - - copy = {} - for key, val in list(obj.items()): - if isinstance(val, dict): - # This, is a EXTREMELY ugly hack.. but it's the only quick way I can think of to trigger a encoding of sub-dictionaries. - val = json.loads(json.dumps(val, cls=JSON)) - else: - val = JsonEncoder._encode(val) - - if type(key) == str and key[0] == '!': - pass - else: - copy[JsonEncoder._encode(key)] = val - return copy - elif hasattr(obj, 'json'): - # json() is a friendly name for json-helper, it should return - # a dictionary representation of the object so that it can be - # processed by the json library. - return json.loads(json.dumps(obj.json(), cls=JSON)) - elif hasattr(obj, '__dump__'): - return obj.__dump__() - elif isinstance(obj, (datetime, date)): - return obj.isoformat() - elif isinstance(obj, (list, set, tuple)): - return [json.loads(json.dumps(item, cls=JSON)) for item in obj] - elif isinstance(obj, (pathlib.Path)): - return str(obj) - else: - return obj +def jsonify(obj: Any, safe: bool = True) -> Any: + """ + Converts objects into json.dumps() compatible nested dictionaries. + Setting safe to True skips dictionary keys starting with a bang (!) + """ - @staticmethod - def _unsafe_encode(obj :Any) -> Any: - """ - Same as _encode() but it keeps dictionary keys starting with ! - """ - if isinstance(obj, dict): - copy = {} - for key, val in list(obj.items()): - if isinstance(val, dict): - # This, is a EXTREMELY ugly hack.. but it's the only quick way I can think of to trigger a encoding of sub-dictionaries. - val = json.loads(json.dumps(val, cls=UNSAFE_JSON)) - else: - val = JsonEncoder._unsafe_encode(val) - - copy[JsonEncoder._unsafe_encode(key)] = val - return copy - else: - return JsonEncoder._encode(obj) + compatible_types = str, int, float, bool + if isinstance(obj, dict): + return { + key: jsonify(value, safe) + for key, value in obj.items() + if isinstance(key, compatible_types) + and not (isinstance(key, str) and key.startswith("!") and safe) + } + if isinstance(obj, Enum): + return obj.value + if hasattr(obj, 'json'): + # json() is a friendly name for json-helper, it should return + # a dictionary representation of the object so that it can be + # processed by the json library. + return jsonify(obj.json(), safe) + if isinstance(obj, (datetime, date)): + return obj.isoformat() + if isinstance(obj, (list, set, tuple)): + return [jsonify(item, safe) for item in obj] + if isinstance(obj, pathlib.Path): + return str(obj) + if hasattr(obj, "__dict__"): + return vars(obj) + + return obj class JSON(json.JSONEncoder, json.JSONDecoder): """ A safe JSON encoder that will omit private information in dicts (starting with !) """ - def _encode(self, obj :Any) -> Any: - return JsonEncoder._encode(obj) - def encode(self, obj :Any) -> Any: - return super(JSON, self).encode(self._encode(obj)) + def encode(self, obj: Any) -> str: + return super().encode(jsonify(obj)) + class UNSAFE_JSON(json.JSONEncoder, json.JSONDecoder): """ UNSAFE_JSON will call/encode and keep private information in dicts (starting with !) """ - def _encode(self, obj :Any) -> Any: - return JsonEncoder._unsafe_encode(obj) - def encode(self, obj :Any) -> Any: - return super(UNSAFE_JSON, self).encode(self._encode(obj)) + def encode(self, obj: Any) -> str: + return super().encode(jsonify(obj, safe=False)) + class SysCommandWorker: - def __init__(self, + def __init__( + self, cmd :Union[str, List[str]], callbacks :Optional[Dict[str, Any]] = None, peek_output :Optional[bool] = False, - peak_output :Optional[bool] = False, environment_vars :Optional[Dict[str, Any]] = None, logfile :Optional[None] = None, working_directory :Optional[str] = './', - remove_vt100_escape_codes_from_lines :bool = True): - - if peak_output: - log("SysCommandWorker()'s peak_output is deprecated, use peek_output instead.", level=logging.WARNING, fg='red') - - if not callbacks: - callbacks = {} - if not environment_vars: - environment_vars = {} + remove_vt100_escape_codes_from_lines :bool = True + ): + callbacks = callbacks or {} + environment_vars = environment_vars or {} - if type(cmd) is str: + if isinstance(cmd, str): cmd = shlex.split(cmd) - cmd = list(cmd) # This is to please mypy - if cmd[0][0] != '/' and cmd[0][:2] != './': - # "which" doesn't work as it's a builtin to bash. - # It used to work, but for whatever reason it doesn't anymore. - # We there for fall back on manual lookup in os.PATH - cmd[0] = locate_binary(cmd[0]) + if cmd: + if cmd[0][0] != '/' and cmd[0][:2] != './': # pathlib.Path does not work well + cmd[0] = locate_binary(cmd[0]) self.cmd = cmd self.callbacks = callbacks self.peek_output = peek_output - if not self.peek_output and peak_output: - self.peek_output = peak_output # define the standard locale for command outputs. For now the C ascii one. Can be overridden self.environment_vars = {**storage.get('CMD_LOCALE',{}),**environment_vars} self.logfile = logfile @@ -237,27 +141,36 @@ class SysCommandWorker: Contains will also move the current buffert position forward. This is to avoid re-checking the same data when looking for output. """ - assert type(key) == bytes + assert isinstance(key, bytes) - if (contains := key in self._trace_log[self._trace_log_pos:]): - self._trace_log_pos += self._trace_log[self._trace_log_pos:].find(key) + len(key) + index = self._trace_log.find(key, self._trace_log_pos) + if index >= 0: + self._trace_log_pos += index + len(key) + return True - return contains + return False def __iter__(self, *args :str, **kwargs :Dict[str, Any]) -> Iterator[bytes]: - for line in self._trace_log[self._trace_log_pos:self._trace_log.rfind(b'\n')].split(b'\n'): - if line: - if self.remove_vt100_escape_codes_from_lines: - line = clear_vt100_escape_codes(line) + last_line = self._trace_log.rfind(b'\n') + lines = filter(None, self._trace_log[self._trace_log_pos:last_line].splitlines()) + for line in lines: + if self.remove_vt100_escape_codes_from_lines: + line = clear_vt100_escape_codes(line) # type: ignore - yield line + b'\n' + yield line + b'\n' - self._trace_log_pos = self._trace_log.rfind(b'\n') + self._trace_log_pos = last_line def __repr__(self) -> str: self.make_sure_we_are_executing() return str(self._trace_log) + def __str__(self) -> str: + try: + return self._trace_log.decode('utf-8') + except UnicodeDecodeError: + return str(self._trace_log) + def __enter__(self) -> 'SysCommandWorker': return self @@ -278,10 +191,14 @@ class SysCommandWorker: sys.stdout.flush() if len(args) >= 2 and args[1]: - log(args[1], level=logging.DEBUG, fg='red') + debug(args[1]) if self.exit_code != 0: - raise SysCallError(f"{self.cmd} exited with abnormal exit code [{self.exit_code}]: {self._trace_log[-500:]}", self.exit_code, worker=self) + raise SysCallError( + f"{self.cmd} exited with abnormal exit code [{self.exit_code}]: {str(self)[-500:]}", + self.exit_code, + worker=self + ) def is_alive(self) -> bool: self.poll() @@ -292,12 +209,13 @@ class SysCommandWorker: return False def write(self, data: bytes, line_ending :bool = True) -> int: - assert type(data) == bytes # TODO: Maybe we can support str as well and encode it + assert isinstance(data, bytes) # TODO: Maybe we can support str as well and encode it self.make_sure_we_are_executing() if self.child_fd: return os.write(self.child_fd, data + (b'\n' if line_ending else b'')) + os.fsync(self.child_fd) return 0 @@ -317,7 +235,7 @@ class SysCommandWorker: def peak(self, output: Union[str, bytes]) -> bool: if self.peek_output: - if type(output) == bytes: + if isinstance(output, bytes): try: output = output.decode('UTF-8') except UnicodeDecodeError: @@ -330,7 +248,7 @@ class SysCommandWorker: change_perm = True with peak_logfile.open("a") as peek_output_log: - peek_output_log.write(output) + peek_output_log.write(str(output)) if change_perm: os.chmod(str(peak_logfile), stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP) @@ -355,7 +273,7 @@ class SysCommandWorker: self.ended = time.time() break - if self.ended or (got_output is False and pid_exists(self.pid) is False): + if self.ended or (not got_output and not _pid_exists(self.pid)): self.ended = time.time() try: wait_status = os.waitpid(self.pid, 0)[1] @@ -394,22 +312,20 @@ class SysCommandWorker: if change_perm: os.chmod(str(history_logfile), stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP) - except PermissionError: - pass - # If history_logfile does not exist, ignore the error - except FileNotFoundError: + except (PermissionError, FileNotFoundError): + # If history_logfile does not exist, ignore the error pass except Exception as e: exception_type = type(e).__name__ - log(f"Unexpected {exception_type} occurred in {self.cmd}: {e}", level=logging.ERROR) + error(f"Unexpected {exception_type} occurred in {self.cmd}: {e}") raise e os.execve(self.cmd[0], list(self.cmd), {**os.environ, **self.environment_vars}) if storage['arguments'].get('debug'): - log(f"Executing: {self.cmd}", level=logging.DEBUG) + debug(f"Executing: {self.cmd}") except FileNotFoundError: - log(f"{self.cmd[0]} does not exist.", level=logging.ERROR, fg="red") + error(f"{self.cmd[0]} does not exist.") self.exit_code = 1 return False else: @@ -428,29 +344,19 @@ class SysCommandWorker: class SysCommand: def __init__(self, cmd :Union[str, List[str]], - callbacks :Optional[Dict[str, Callable[[Any], Any]]] = None, + callbacks :Dict[str, Callable[[Any], Any]] = {}, start_callback :Optional[Callable[[Any], Any]] = None, peek_output :Optional[bool] = False, - peak_output :Optional[bool] = False, environment_vars :Optional[Dict[str, Any]] = None, working_directory :Optional[str] = './', remove_vt100_escape_codes_from_lines :bool = True): - if peak_output: - log("SysCommandWorker()'s peak_output is deprecated, use peek_output instead.", level=logging.WARNING, fg='red') - - _callbacks = {} - if callbacks: - for hook, func in callbacks.items(): - _callbacks[hook] = func + self._callbacks = callbacks.copy() if start_callback: - _callbacks['on_start'] = start_callback + self._callbacks['on_start'] = start_callback self.cmd = cmd - self._callbacks = _callbacks self.peek_output = peek_output - if not self.peek_output and peak_output: - self.peek_output = peak_output self.environment_vars = environment_vars self.working_directory = working_directory self.remove_vt100_escape_codes_from_lines = remove_vt100_escape_codes_from_lines @@ -466,7 +372,7 @@ class SysCommand: # TODO: https://stackoverflow.com/questions/28157929/how-to-safely-handle-an-exception-inside-a-context-manager if len(args) >= 2 and args[1]: - log(args[1], level=logging.ERROR, fg='red') + error(args[1]) def __iter__(self, *args :List[Any], **kwargs :Dict[str, Any]) -> Iterator[bytes]: if self.session: @@ -477,17 +383,15 @@ class SysCommand: if not self.session: raise KeyError(f"SysCommand() does not have an active session.") elif type(key) is slice: - start = key.start if key.start else 0 - end = key.stop if key.stop else len(self.session._trace_log) + start = key.start or 0 + end = key.stop or len(self.session._trace_log) return self.session._trace_log[start:end] else: raise ValueError("SysCommand() doesn't have key & value pairs, only slices, SysCommand('ls')[:10] as an example.") def __repr__(self, *args :List[Any], **kwargs :Dict[str, Any]) -> str: - if self.session: - return self.session._trace_log.decode('UTF-8', errors='backslashreplace') - return '' + return self.decode('UTF-8', errors='backslashreplace') or '' def __json__(self) -> Dict[str, Union[str, bool, List[str], Dict[str, Any], Optional[bool], Optional[Dict[str, Any]]]]: return { @@ -495,7 +399,7 @@ class SysCommand: 'callbacks': self._callbacks, 'peak': self.peek_output, 'environment_vars': self.environment_vars, - 'session': True if self.session else False + 'session': self.session is not None } def create_session(self) -> bool: @@ -505,7 +409,7 @@ class SysCommand: clears any printed output if ``.peek_output=True``. """ if self.session: - return self.session + return True with SysCommandWorker( self.cmd, @@ -515,10 +419,9 @@ class SysCommand: remove_vt100_escape_codes_from_lines=self.remove_vt100_escape_codes_from_lines, working_directory=self.working_directory) as session: - if not self.session: - self.session = session + self.session = session - while self.session.ended is None: + while not self.session.ended: self.session.poll() if self.peek_output: @@ -527,10 +430,21 @@ class SysCommand: return True - def decode(self, fmt :str = 'UTF-8') -> Optional[str]: - if self.session: - return self.session._trace_log.decode(fmt) - return None + def decode(self, encoding: str = 'utf-8', errors='backslashreplace', strip: bool = True) -> str: + if not self.session: + raise ValueError('No session available to decode') + + val = self.session._trace_log.decode(encoding, errors=errors) + + if strip: + return val.strip() + return val + + def output(self) -> bytes: + if not self.session: + raise ValueError('No session available') + + return self.session._trace_log.replace(b'\r\n', b'\n') @property def exit_code(self) -> Optional[int]: @@ -546,22 +460,7 @@ class SysCommand: return None -def prerequisite_check() -> bool: - """ - This function is used as a safety check before - continuing with an installation. - - Could be anything from checking that /boot is big enough - to check if nvidia hardware exists when nvidia driver was chosen. - """ - - return True - -def reboot(): - SysCommand("/usr/bin/reboot") - - -def pid_exists(pid: int) -> bool: +def _pid_exists(pid: int) -> bool: try: return any(subprocess.check_output(['/usr/bin/ps', '--no-headers', '-o', 'pid', '-p', str(pid)]).strip()) except subprocess.CalledProcessError: @@ -570,56 +469,57 @@ def pid_exists(pid: int) -> bool: def run_custom_user_commands(commands :List[str], installation :Installer) -> None: for index, command in enumerate(commands): - log(f'Executing custom command "{command}" ...', level=logging.INFO) + script_path = f"/var/tmp/user-command.{index}.sh" + chroot_path = f"{installation.target}/{script_path}" - with open(f"{installation.target}/var/tmp/user-command.{index}.sh", "w") as temp_script: - temp_script.write(command) + info(f'Executing custom command "{command}" ...') + with open(chroot_path, "w") as user_script: + user_script.write(command) - execution_output = SysCommand(f"arch-chroot {installation.target} bash /var/tmp/user-command.{index}.sh") + SysCommand(f"arch-chroot {installation.target} bash {script_path}") + + os.unlink(chroot_path) - log(execution_output) - os.unlink(f"{installation.target}/var/tmp/user-command.{index}.sh") def json_stream_to_structure(configuration_identifier : str, stream :str, target :dict) -> bool : """ - Function to load a stream (file (as name) or valid JSON string into an existing dictionary - Returns true if it could be done - Return false if operation could not be executed + Load a JSON encoded dictionary from a stream and merge it into an existing dictionary. + A stream can be a filepath, a URL or a raw JSON string. + Returns True if the operation succeeded, False otherwise. +configuration_identifier is just a parameter to get meaningful, but not so long messages """ - parsed_url = urllib.parse.urlparse(stream) + raw: Optional[str] = None + # Try using the stream as a URL that should be grabbed + if urllib.parse.urlparse(stream).scheme: + try: + with urlopen(Request(stream, headers={'User-Agent': 'ArchInstall'})) as response: + raw = response.read() + except urllib.error.HTTPError as err: + error(f"Could not fetch JSON from {stream} as {configuration_identifier}: {err}") + return False - if parsed_url.scheme: # The stream is in fact a URL that should be grabbed + # Try using the stream as a filepath that should be read + if raw is None and (path := pathlib.Path(stream)).exists(): try: - with urllib.request.urlopen(urllib.request.Request(stream, headers={'User-Agent': 'ArchInstall'})) as response: - target.update(json.loads(response.read())) - except urllib.error.HTTPError as error: - log(f"Could not load {configuration_identifier} via {parsed_url} due to: {error}", level=logging.ERROR, fg="red") + raw = path.read_text() + except Exception as err: + error(f"Could not read file {stream} as {configuration_identifier}: {err}") return False - else: - if pathlib.Path(stream).exists(): - try: - with pathlib.Path(stream).open() as fh: - target.update(json.load(fh)) - except Exception as error: - log(f"{configuration_identifier} = {stream} does not contain a valid JSON format: {error}", level=logging.ERROR, fg="red") - return False - else: - # NOTE: This is a rudimentary check if what we're trying parse is a dict structure. - # Which is the only structure we tolerate anyway. - if stream.strip().startswith('{') and stream.strip().endswith('}'): - try: - target.update(json.loads(stream)) - except Exception as e: - log(f" {configuration_identifier} Contains an invalid JSON format : {e}",level=logging.ERROR, fg="red") - return False - else: - log(f" {configuration_identifier} is neither a file nor is a JSON string:",level=logging.ERROR, fg="red") - return False + try: + # We use `or` to try the stream as raw JSON to be parsed + structure = json.loads(raw or stream) + except Exception as err: + error(f"{configuration_identifier} contains an invalid JSON format: {err}") + return False + if not isinstance(structure, dict): + error(f"{stream} passed as {configuration_identifier} is not a JSON encoded dictionary") + return False + target.update(structure) return True + def secret(x :str): """ return * with len equal to to the input string """ return '*' * len(x) diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py new file mode 100644 index 00000000..1b5e779b --- /dev/null +++ b/archinstall/lib/global_menu.py @@ -0,0 +1,472 @@ +from __future__ import annotations + +from typing import Any, List, Optional, Dict, TYPE_CHECKING + +from . import disk +from .general import secret +from .hardware import SysInfo +from .locale.locale_menu import LocaleConfiguration, LocaleMenu +from .menu import Selector, AbstractMenu +from .mirrors import MirrorConfiguration, MirrorMenu +from .models import NetworkConfiguration, NicType +from .models.bootloader import Bootloader +from .models.audio_configuration import Audio, AudioConfiguration +from .models.users import User +from .output import FormattedOutput +from .profile.profile_menu import ProfileConfiguration +from .configuration import save_config +from .interactions import add_number_of_parallel_downloads +from .interactions import ask_additional_packages_to_install +from .interactions import ask_for_additional_users +from .interactions import ask_for_audio_selection +from .interactions import ask_for_bootloader +from .interactions import ask_for_uki +from .interactions import ask_for_swap +from .interactions import ask_hostname +from .interactions import ask_to_configure_network +from .interactions import get_password, ask_for_a_timezone +from .interactions import select_additional_repositories +from .interactions import select_kernel +from .utils.util import format_cols +from .interactions import ask_ntp + +if TYPE_CHECKING: + _: Any + + +class GlobalMenu(AbstractMenu): + def __init__(self, data_store: Dict[str, Any]): + super().__init__(data_store=data_store, auto_cursor=True, preview_size=0.3) + + def setup_selection_menu_options(self): + # archinstall.Language will not use preset values + self._menu_options['archinstall-language'] = \ + Selector( + _('Archinstall language'), + lambda x: self._select_archinstall_language(x), + display_func=lambda x: x.display_name, + default=self.translation_handler.get_language_by_abbr('en')) + self._menu_options['locale_config'] = \ + Selector( + _('Locales'), + lambda preset: self._locale_selection(preset), + preview_func=self._prev_locale, + display_func=lambda x: self.defined_text if x else '') + self._menu_options['mirror_config'] = \ + Selector( + _('Mirrors'), + lambda preset: self._mirror_configuration(preset), + display_func=lambda x: self.defined_text if x else '', + preview_func=self._prev_mirror_config + ) + self._menu_options['disk_config'] = \ + Selector( + _('Disk configuration'), + lambda preset: self._select_disk_config(preset), + preview_func=self._prev_disk_config, + display_func=lambda x: self.defined_text if x else '', + ) + self._menu_options['disk_encryption'] = \ + Selector( + _('Disk encryption'), + lambda preset: self._disk_encryption(preset), + preview_func=self._prev_disk_encryption, + display_func=lambda x: self._display_disk_encryption(x), + dependencies=['disk_config'] + ) + self._menu_options['swap'] = \ + Selector( + _('Swap'), + lambda preset: ask_for_swap(preset), + default=True) + self._menu_options['bootloader'] = \ + Selector( + _('Bootloader'), + lambda preset: ask_for_bootloader(preset), + display_func=lambda x: x.value, + default=Bootloader.get_default()) + self._menu_options['uki'] = \ + Selector( + _('Unified kernel images'), + lambda preset: ask_for_uki(preset), + default=False) + self._menu_options['hostname'] = \ + Selector( + _('Hostname'), + lambda preset: ask_hostname(preset), + default='archlinux') + # root password won't have preset value + self._menu_options['!root-password'] = \ + Selector( + _('Root password'), + lambda preset:self._set_root_password(), + display_func=lambda x: secret(x) if x else '') + self._menu_options['!users'] = \ + Selector( + _('User account'), + lambda x: self._create_user_account(x), + default=[], + display_func=lambda x: f'{len(x)} {_("User(s)")}' if len(x) > 0 else '', + preview_func=self._prev_users) + self._menu_options['profile_config'] = \ + Selector( + _('Profile'), + lambda preset: self._select_profile(preset), + display_func=lambda x: x.profile.name if x else '', + preview_func=self._prev_profile + ) + self._menu_options['audio_config'] = \ + Selector( + _('Audio'), + lambda preset: self._select_audio(preset), + display_func=lambda x: self._display_audio(x) + ) + self._menu_options['parallel downloads'] = \ + Selector( + _('Parallel Downloads'), + lambda preset: add_number_of_parallel_downloads(preset), + display_func=lambda x: x if x else '0', + default=0 + ) + self._menu_options['kernels'] = \ + Selector( + _('Kernels'), + lambda preset: select_kernel(preset), + display_func=lambda x: ', '.join(x) if x else None, + default=['linux']) + self._menu_options['packages'] = \ + Selector( + _('Additional packages'), + lambda preset: ask_additional_packages_to_install(preset), + display_func=lambda x: self.defined_text if x else '', + preview_func=self._prev_additional_pkgs, + default=[]) + self._menu_options['additional-repositories'] = \ + Selector( + _('Optional repositories'), + lambda preset: select_additional_repositories(preset), + display_func=lambda x: ', '.join(x) if x else None, + default=[]) + self._menu_options['network_config'] = \ + Selector( + _('Network configuration'), + lambda preset: ask_to_configure_network(preset), + display_func=lambda x: self._display_network_conf(x), + preview_func=self._prev_network_config, + default={}) + self._menu_options['timezone'] = \ + Selector( + _('Timezone'), + lambda preset: ask_for_a_timezone(preset), + default='UTC') + self._menu_options['ntp'] = \ + Selector( + _('Automatic time sync (NTP)'), + lambda preset: ask_ntp(preset), + default=True) + self._menu_options['__separator__'] = \ + Selector('') + self._menu_options['save_config'] = \ + Selector( + _('Save configuration'), + lambda preset: save_config(self._data_store), + no_store=True) + self._menu_options['install'] = \ + Selector( + self._install_text(), + exec_func=lambda n, v: self._is_config_valid(), + preview_func=self._prev_install_invalid_config, + no_store=True) + + self._menu_options['abort'] = Selector(_('Abort'), exec_func=lambda n,v:exit(1)) + + def _missing_configs(self) -> List[str]: + def check(s) -> bool: + obj = self._menu_options.get(s) + if obj and obj.has_selection(): + return True + return False + + def has_superuser() -> bool: + sel = self._menu_options['!users'] + if sel.current_selection: + return any([u.sudo for u in sel.current_selection]) + return False + + mandatory_fields = dict(filter(lambda x: x[1].is_mandatory(), self._menu_options.items())) + missing = set() + + for key, selector in mandatory_fields.items(): + if key in ['!root-password', '!users']: + if not check('!root-password') and not has_superuser(): + missing.add( + str(_('Either root-password or at least 1 user with sudo privileges must be specified')) + ) + elif key == 'disk_config': + if not check('disk_config'): + missing.add(self._menu_options['disk_config'].description) + + return list(missing) + + def _is_config_valid(self) -> bool: + """ + Checks the validity of the current configuration. + """ + if len(self._missing_configs()) != 0: + return False + return self._validate_bootloader() is None + + def _update_uki_display(self, name: Optional[str] = None): + if bootloader := self._menu_options['bootloader'].current_selection: + if not SysInfo.has_uefi() or not bootloader.has_uki_support(): + self._menu_options['uki'].set_current_selection(False) + self._menu_options['uki'].set_enabled(False) + elif name and name == 'bootloader': + self._menu_options['uki'].set_enabled(True) + + def _update_install_text(self, name: Optional[str] = None, value: Any = None): + text = self._install_text() + self._menu_options['install'].update_description(text) + + def post_callback(self, name: Optional[str] = None, value: Any = None): + self._update_uki_display(name) + self._update_install_text(name, value) + + def _install_text(self): + missing = len(self._missing_configs()) + if missing > 0: + return _('Install ({} config(s) missing)').format(missing) + return _('Install') + + def _display_network_conf(self, config: Optional[NetworkConfiguration]) -> str: + if not config: + return str(_('Not configured, unavailable unless setup manually')) + + return config.type.display_msg() + + def _disk_encryption(self, preset: Optional[disk.DiskEncryption]) -> Optional[disk.DiskEncryption]: + disk_config: Optional[disk.DiskLayoutConfiguration] = self._menu_options['disk_config'].current_selection + + if not disk_config: + # this should not happen as the encryption menu has the disk_config as dependency + raise ValueError('No disk layout specified') + + if not disk.DiskEncryption.validate_enc(disk_config): + return None + + data_store: Dict[str, Any] = {} + disk_encryption = disk.DiskEncryptionMenu(disk_config, data_store, preset=preset).run() + return disk_encryption + + def _locale_selection(self, preset: LocaleConfiguration) -> LocaleConfiguration: + data_store: Dict[str, Any] = {} + locale_config = LocaleMenu(data_store, preset).run() + return locale_config + + def _prev_locale(self) -> Optional[str]: + selector = self._menu_options['locale_config'] + if selector.has_selection(): + config: LocaleConfiguration = selector.current_selection # type: ignore + output = '{}: {}\n'.format(str(_('Keyboard layout')), config.kb_layout) + output += '{}: {}\n'.format(str(_('Locale language')), config.sys_lang) + output += '{}: {}'.format(str(_('Locale encoding')), config.sys_enc) + return output + return None + + def _prev_network_config(self) -> Optional[str]: + selector: Optional[NetworkConfiguration] = self._menu_options['network_config'].current_selection + if selector: + if selector.type == NicType.MANUAL: + output = FormattedOutput.as_table(selector.nics) + return output + return None + + def _prev_additional_pkgs(self): + selector = self._menu_options['packages'] + if selector.current_selection: + packages: List[str] = selector.current_selection + return format_cols(packages, None) + return None + + def _prev_disk_config(self) -> Optional[str]: + selector = self._menu_options['disk_config'] + disk_layout_conf: Optional[disk.DiskLayoutConfiguration] = selector.current_selection + + output = '' + if disk_layout_conf: + output += str(_('Configuration type: {}')).format(disk_layout_conf.config_type.display_msg()) + + if disk_layout_conf.lvm_config: + output += '\n{}: {}'.format(str(_('LVM configuration type')), disk_layout_conf.lvm_config.config_type.display_msg()) + + if output: + return output + + return None + + def _display_disk_config(self, current_value: Optional[disk.DiskLayoutConfiguration] = None) -> str: + if current_value: + return current_value.config_type.display_msg() + return '' + + def _prev_disk_encryption(self) -> Optional[str]: + disk_config: Optional[disk.DiskLayoutConfiguration] = self._menu_options['disk_config'].current_selection + + if disk_config and not disk.DiskEncryption.validate_enc(disk_config): + return str(_('LVM disk encryption with more than 2 partitions is currently not supported')) + + encryption: Optional[disk.DiskEncryption] = self._menu_options['disk_encryption'].current_selection + + if encryption: + enc_type = disk.EncryptionType.type_to_text(encryption.encryption_type) + output = str(_('Encryption type')) + f': {enc_type}\n' + output += str(_('Password')) + f': {secret(encryption.encryption_password)}\n' + + if encryption.partitions: + output += 'Partitions: {} selected'.format(len(encryption.partitions)) + '\n' + elif encryption.lvm_volumes: + output += 'LVM volumes: {} selected'.format(len(encryption.lvm_volumes)) + '\n' + + if encryption.hsm_device: + output += f'HSM: {encryption.hsm_device.manufacturer}' + + return output + + return None + + def _display_disk_encryption(self, current_value: Optional[disk.DiskEncryption]) -> str: + if current_value: + return disk.EncryptionType.type_to_text(current_value.encryption_type) + return '' + + def _validate_bootloader(self) -> Optional[str]: + """ + Checks the selected bootloader is valid for the selected filesystem + type of the boot partition. + + Returns [`None`] if the bootloader is valid, otherwise returns a + string with the error message. + + XXX: The caller is responsible for wrapping the string with the translation + shim if necessary. + """ + bootloader = self._menu_options['bootloader'].current_selection + boot_partition: Optional[disk.PartitionModification] = None + + if disk_config := self._menu_options['disk_config'].current_selection: + for layout in disk_config.device_modifications: + if boot_partition := layout.get_boot_partition(): + break + else: + return "No disk layout selected" + + if boot_partition is None: + return "Boot partition not found" + + if bootloader == Bootloader.Limine: + if boot_partition.fs_type != disk.FilesystemType.Fat32: + return "Limine does not support booting from filesystems other than FAT32" + + return None + + def _prev_install_invalid_config(self) -> Optional[str]: + if missing := self._missing_configs(): + text = str(_('Missing configurations:\n')) + for m in missing: + text += f'- {m}\n' + return text[:-1] # remove last new line + + if error := self._validate_bootloader(): + return str(_(f"Invalid configuration: {error}")) + + return None + + def _prev_users(self) -> Optional[str]: + selector = self._menu_options['!users'] + users: Optional[List[User]] = selector.current_selection + + if users: + return FormattedOutput.as_table(users) + return None + + def _prev_profile(self) -> Optional[str]: + selector = self._menu_options['profile_config'] + profile_config: Optional[ProfileConfiguration] = selector.current_selection + + if profile_config and profile_config.profile: + output = str(_('Profiles')) + ': ' + if profile_names := profile_config.profile.current_selection_names(): + output += ', '.join(profile_names) + '\n' + else: + output += profile_config.profile.name + '\n' + + if profile_config.gfx_driver: + output += str(_('Graphics driver')) + ': ' + profile_config.gfx_driver.value + '\n' + + if profile_config.greeter: + output += str(_('Greeter')) + ': ' + profile_config.greeter.value + '\n' + + return output + + return None + + def _set_root_password(self) -> Optional[str]: + prompt = str(_('Enter root password (leave blank to disable root): ')) + password = get_password(prompt=prompt) + return password + + def _select_disk_config( + self, + preset: Optional[disk.DiskLayoutConfiguration] = None + ) -> Optional[disk.DiskLayoutConfiguration]: + data_store: Dict[str, Any] = {} + disk_config = disk.DiskLayoutConfigurationMenu(preset, data_store).run() + + if disk_config != preset: + self._menu_options['disk_encryption'].set_current_selection(None) + + return disk_config + + def _select_profile(self, current_profile: Optional[ProfileConfiguration]): + from .profile.profile_menu import ProfileMenu + store: Dict[str, Any] = {} + profile_config = ProfileMenu(store, preset=current_profile).run() + return profile_config + + def _select_audio( + self, + current: Optional[AudioConfiguration] = None + ) -> Optional[AudioConfiguration]: + selection = ask_for_audio_selection(current) + return selection + + def _display_audio(self, current: Optional[AudioConfiguration]) -> str: + if not current: + return Audio.no_audio_text() + else: + return current.audio.name + + def _create_user_account(self, defined_users: List[User]) -> List[User]: + users = ask_for_additional_users(defined_users=defined_users) + return users + + def _mirror_configuration(self, preset: Optional[MirrorConfiguration] = None) -> Optional[MirrorConfiguration]: + data_store: Dict[str, Any] = {} + mirror_configuration = MirrorMenu(data_store, preset=preset).run() + return mirror_configuration + + def _prev_mirror_config(self) -> Optional[str]: + selector = self._menu_options['mirror_config'] + + if selector.has_selection(): + mirror_config: MirrorConfiguration = selector.current_selection # type: ignore + output = '' + if mirror_config.regions: + output += '{}: {}\n\n'.format(str(_('Mirror regions')), mirror_config.regions) + if mirror_config.custom_mirrors: + table = FormattedOutput.as_table(mirror_config.custom_mirrors) + output += '{}\n{}'.format(str(_('Custom mirrors')), table) + + return output.strip() + + return None diff --git a/archinstall/lib/hardware.py b/archinstall/lib/hardware.py index 8400d338..c8001c19 100644 --- a/archinstall/lib/hardware.py +++ b/archinstall/lib/hardware.py @@ -1,192 +1,318 @@ import os -import logging -from functools import partial +from enum import Enum +from functools import cached_property from pathlib import Path -from typing import Iterator, Optional, Union +from typing import Optional, Dict, List, TYPE_CHECKING, Any +from .exceptions import SysCallError from .general import SysCommand from .networking import list_interfaces, enrich_iface_types -from .exceptions import SysCallError -from .output import log - -__packages__ = [ - "mesa", - "xf86-video-amdgpu", - "xf86-video-ati", - "xf86-video-nouveau", - "xf86-video-vmware", - "xf86-video-intel", - "xf86-video-qxl", - "libva-mesa-driver", - "libva-intel-driver", - "vulkan-radeon", - "vulkan-intel", -] - -AVAILABLE_GFX_DRIVERS = { - # Sub-dicts are layer-2 options to be selected - # and lists are a list of packages to be installed - "All open-source (default)": [ - "mesa", - "xf86-video-amdgpu", - "xf86-video-ati", - "xf86-video-nouveau", - "xf86-video-vmware", - "xf86-video-intel", - "xf86-video-qxl", - "libva-mesa-driver", - "libva-intel-driver", - "vulkan-radeon", - "vulkan-intel", - ], - "AMD / ATI (open-source)": [ - "mesa", - "xf86-video-amdgpu", - "xf86-video-ati", - "libva-mesa-driver", - "vulkan-radeon", - ], - "Intel (open-source, modern)": [ - "mesa", - "libva-intel-driver", - "vulkan-intel", - ], - "Intel (open-source, old)": [ - "mesa", - "xf86-video-intel" - ], - "Nvidia (open-source nouveau driver)": [ - "mesa", - "xf86-video-nouveau", - "libva-mesa-driver" - ], - "VMware / VirtualBox / QXL (open-source)": ["mesa", "xf86-video-vmware", "xf86-video-qxl"], -} - -CPUINFO = Path("/proc/cpuinfo") -MEMINFO = Path("/proc/meminfo") - - -def cpuinfo() -> Iterator[dict[str, str]]: - """Yields information about the CPUs of the system.""" - cpu = {} - - with CPUINFO.open() as file: - for line in file: - if not (line := line.strip()): - yield cpu - cpu = {} - continue - - key, value = line.split(":", maxsplit=1) - cpu[key.strip()] = value.strip() - - -def meminfo(key: Optional[str] = None) -> Union[dict[str, int], Optional[int]]: - """Returns a dict with memory info if called with no args - or the value of the given key of said dict. - """ - with MEMINFO.open() as file: - mem_info = { - (columns := line.strip().split())[0].rstrip(':'): int(columns[1]) - for line in file - } - - if key is None: - return mem_info - - return mem_info.get(key) - - -def has_wifi() -> bool: - return 'WIRELESS' in enrich_iface_types(list_interfaces().values()).values() - - -def has_cpu_vendor(vendor_id: str) -> bool: - return any(cpu.get("vendor_id") == vendor_id for cpu in cpuinfo()) - - -has_amd_cpu = partial(has_cpu_vendor, "AuthenticAMD") - - -has_intel_cpu = partial(has_cpu_vendor, "GenuineIntel") - - -def has_uefi() -> bool: - return os.path.isdir('/sys/firmware/efi') - - -def graphics_devices() -> dict: - cards = {} - for line in SysCommand("lspci"): - if b' VGA ' in line or b' 3D ' in line: - _, identifier = line.split(b': ', 1) - cards[identifier.strip().decode('UTF-8')] = line - return cards - - -def has_nvidia_graphics() -> bool: - return any('nvidia' in x.lower() for x in graphics_devices()) - - -def has_amd_graphics() -> bool: - return any('amd' in x.lower() for x in graphics_devices()) - - -def has_intel_graphics() -> bool: - return any('intel' in x.lower() for x in graphics_devices()) +from .output import debug +from .utils.util import format_cols + +if TYPE_CHECKING: + _: Any + + +class CpuVendor(Enum): + AuthenticAMD = 'amd' + GenuineIntel = 'intel' + _Unknown = 'unknown' + + @classmethod + def get_vendor(cls, name: str) -> 'CpuVendor': + if vendor := getattr(cls, name, None): + return vendor + else: + debug(f"Unknown CPU vendor '{name}' detected.") + return cls._Unknown + + def _has_microcode(self) -> bool: + match self: + case CpuVendor.AuthenticAMD | CpuVendor.GenuineIntel: + return True + case _: + return False + + def get_ucode(self) -> Optional[Path]: + if self._has_microcode(): + return Path(self.value + '-ucode.img') + return None + + +class GfxPackage(Enum): + Dkms = 'dkms' + IntelMediaDriver = 'intel-media-driver' + LibvaIntelDriver = 'libva-intel-driver' + LibvaMesaDriver = 'libva-mesa-driver' + Mesa = "mesa" + NvidiaDkms = 'nvidia-dkms' + NvidiaOpen = 'nvidia-open' + NvidiaOpenDkms = 'nvidia-open-dkms' + VulkanIntel = 'vulkan-intel' + VulkanRadeon = 'vulkan-radeon' + Xf86VideoAmdgpu = "xf86-video-amdgpu" + Xf86VideoAti = "xf86-video-ati" + Xf86VideoNouveau = 'xf86-video-nouveau' + Xf86VideoVmware = 'xf86-video-vmware' + XorgServer = 'xorg-server' + XorgXinit = 'xorg-xinit' + + +class GfxDriver(Enum): + AllOpenSource = 'All open-source' + AmdOpenSource = 'AMD / ATI (open-source)' + IntelOpenSource = 'Intel (open-source)' + NvidiaOpenKernel = 'Nvidia (open kernel module for newer GPUs, Turing+)' + NvidiaOpenSource = 'Nvidia (open-source nouveau driver)' + NvidiaProprietary = 'Nvidia (proprietary)' + VMOpenSource = 'VMware / VirtualBox (open-source)' + + def is_nvidia(self) -> bool: + match self: + case GfxDriver.NvidiaProprietary | \ + GfxDriver.NvidiaOpenSource | \ + GfxDriver.NvidiaOpenKernel: + return True + case _: + return False + + def packages_text(self) -> str: + text = str(_('Installed packages')) + ':\n' + pkg_names = [p.value for p in self.gfx_packages()] + text += format_cols(sorted(pkg_names)) + return text + + def gfx_packages(self) -> List[GfxPackage]: + packages = [GfxPackage.XorgServer, GfxPackage.XorgXinit] + + match self: + case GfxDriver.AllOpenSource: + packages += [ + GfxPackage.Mesa, + GfxPackage.Xf86VideoAmdgpu, + GfxPackage.Xf86VideoAti, + GfxPackage.Xf86VideoNouveau, + GfxPackage.Xf86VideoVmware, + GfxPackage.LibvaMesaDriver, + GfxPackage.LibvaIntelDriver, + GfxPackage.IntelMediaDriver, + GfxPackage.VulkanRadeon, + GfxPackage.VulkanIntel + ] + case GfxDriver.AmdOpenSource: + packages += [ + GfxPackage.Mesa, + GfxPackage.Xf86VideoAmdgpu, + GfxPackage.Xf86VideoAti, + GfxPackage.LibvaMesaDriver, + GfxPackage.VulkanRadeon + ] + case GfxDriver.IntelOpenSource: + packages += [ + GfxPackage.Mesa, + GfxPackage.LibvaIntelDriver, + GfxPackage.IntelMediaDriver, + GfxPackage.VulkanIntel + ] + case GfxDriver.NvidiaOpenKernel: + packages += [ + GfxPackage.NvidiaOpen, + GfxPackage.Dkms, + GfxPackage.NvidiaOpenDkms + ] + case GfxDriver.NvidiaOpenSource: + packages += [ + GfxPackage.Mesa, + GfxPackage.Xf86VideoNouveau, + GfxPackage.LibvaMesaDriver + ] + case GfxDriver.NvidiaProprietary: + packages += [ + GfxPackage.NvidiaDkms, + GfxPackage.Dkms, + ] + case GfxDriver.VMOpenSource: + packages += [ + GfxPackage.Mesa, + GfxPackage.Xf86VideoVmware + ] + + return packages + +class _SysInfo: + def __init__(self): + pass + + @cached_property + def cpu_info(self) -> Dict[str, str]: + """ + Returns system cpu information + """ + cpu_info_path = Path("/proc/cpuinfo") + cpu: Dict[str, str] = {} + + with cpu_info_path.open() as file: + for line in file: + if line := line.strip(): + key, value = line.split(":", maxsplit=1) + cpu[key.strip()] = value.strip() + + return cpu + + @cached_property + def mem_info(self) -> Dict[str, int]: + """ + Returns system memory information + """ + mem_info_path = Path("/proc/meminfo") + mem_info: Dict[str, int] = {} + + with mem_info_path.open() as file: + for line in file: + key, value = line.strip().split(':') + num = value.split()[0] + mem_info[key] = int(num) + return mem_info -def cpu_vendor() -> Optional[str]: - for cpu in cpuinfo(): - return cpu.get("vendor_id") - - return None - - -def cpu_model() -> Optional[str]: - for cpu in cpuinfo(): - return cpu.get("model name") - - return None - - -def sys_vendor() -> Optional[str]: - with open(f"/sys/devices/virtual/dmi/id/sys_vendor") as vendor: - return vendor.read().strip() - - -def product_name() -> Optional[str]: - with open(f"/sys/devices/virtual/dmi/id/product_name") as product: - return product.read().strip() - - -def mem_available() -> Optional[int]: - return meminfo('MemAvailable') - - -def mem_free() -> Optional[int]: - return meminfo('MemFree') - - -def mem_total() -> Optional[int]: - return meminfo('MemTotal') - - -def virtualization() -> Optional[str]: - try: - return str(SysCommand("systemd-detect-virt")).strip('\r\n') - except SysCallError as error: - log(f"Could not detect virtual system: {error}", level=logging.DEBUG) - - return None - - -def is_vm() -> bool: - try: - return b"none" not in b"".join(SysCommand("systemd-detect-virt")).lower() - except SysCallError as error: - log(f"System is not running in a VM: {error}", level=logging.DEBUG) - return None - -# TODO: Add more identifiers + def mem_info_by_key(self, key: str) -> int: + return self.mem_info[key] + + @cached_property + def loaded_modules(self) -> List[str]: + """ + Returns loaded kernel modules + """ + modules_path = Path('/proc/modules') + modules: List[str] = [] + + with modules_path.open() as file: + for line in file: + module = line.split(maxsplit=1)[0] + modules.append(module) + + return modules + + +_sys_info = _SysInfo() + + +class SysInfo: + @staticmethod + def has_wifi() -> bool: + ifaces = list(list_interfaces().values()) + return 'WIRELESS' in enrich_iface_types(ifaces).values() + + @staticmethod + def has_uefi() -> bool: + return os.path.isdir('/sys/firmware/efi') + + @staticmethod + def _graphics_devices() -> Dict[str, str]: + cards: Dict[str, str] = {} + for line in SysCommand("lspci"): + if b' VGA ' in line or b' 3D ' in line: + _, identifier = line.split(b': ', 1) + cards[identifier.strip().decode('UTF-8')] = str(line) + return cards + + @staticmethod + def has_nvidia_graphics() -> bool: + return any('nvidia' in x.lower() for x in SysInfo._graphics_devices()) + + @staticmethod + def has_amd_graphics() -> bool: + return any('amd' in x.lower() for x in SysInfo._graphics_devices()) + + @staticmethod + def has_intel_graphics() -> bool: + return any('intel' in x.lower() for x in SysInfo._graphics_devices()) + + @staticmethod + def cpu_vendor() -> Optional[CpuVendor]: + if vendor := _sys_info.cpu_info.get('vendor_id'): + return CpuVendor.get_vendor(vendor) + return None + + @staticmethod + def cpu_model() -> Optional[str]: + return _sys_info.cpu_info.get('model name', None) + + @staticmethod + def sys_vendor() -> str: + with open(f"/sys/devices/virtual/dmi/id/sys_vendor") as vendor: + return vendor.read().strip() + + @staticmethod + def product_name() -> str: + with open(f"/sys/devices/virtual/dmi/id/product_name") as product: + return product.read().strip() + + @staticmethod + def mem_available() -> int: + return _sys_info.mem_info_by_key('MemAvailable') + + @staticmethod + def mem_free() -> int: + return _sys_info.mem_info_by_key('MemFree') + + @staticmethod + def mem_total() -> int: + return _sys_info.mem_info_by_key('MemTotal') + + @staticmethod + def virtualization() -> Optional[str]: + try: + return str(SysCommand("systemd-detect-virt")).strip('\r\n') + except SysCallError as err: + debug(f"Could not detect virtual system: {err}") + + return None + + @staticmethod + def is_vm() -> bool: + try: + result = SysCommand("systemd-detect-virt") + return b"none" not in b"".join(result).lower() + except SysCallError as err: + debug(f"System is not running in a VM: {err}") + + return False + + @staticmethod + def requires_sof_fw() -> bool: + return 'snd_sof' in _sys_info.loaded_modules + + @staticmethod + def requires_alsa_fw() -> bool: + modules = ( + 'snd_asihpi', + 'snd_cs46xx', + 'snd_darla20', + 'snd_darla24', + 'snd_echo3g', + 'snd_emu10k1', + 'snd_gina20', + 'snd_gina24', + 'snd_hda_codec_ca0132', + 'snd_hdsp', + 'snd_indigo', + 'snd_indigodj', + 'snd_indigodjx', + 'snd_indigoio', + 'snd_indigoiox', + 'snd_layla20', + 'snd_layla24', + 'snd_mia', + 'snd_mixart', + 'snd_mona', + 'snd_pcxhr', + 'snd_vx_lib' + ) + + for loaded_module in _sys_info.loaded_modules: + if loaded_module in modules: + return True + + return False diff --git a/archinstall/lib/hsm/__init__.py b/archinstall/lib/hsm/__init__.py deleted file mode 100644 index a3f64019..00000000 --- a/archinstall/lib/hsm/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .fido import Fido2 diff --git a/archinstall/lib/installer.py b/archinstall/lib/installer.py index f1c7b3db..8292a3be 100644 --- a/archinstall/lib/installer.py +++ b/archinstall/lib/installer.py @@ -1,323 +1,427 @@ -import time -import logging +import glob import os import re -import shutil import shlex -import pathlib +import shutil import subprocess -import glob -from types import ModuleType -from typing import Union, Dict, Any, List, Optional, Iterator, Mapping, TYPE_CHECKING -from .disk import get_partitions_in_use, Partition -from .general import SysCommand, generate_password -from .hardware import has_uefi, is_vm, cpu_vendor -from .locale_helpers import verify_keyboard_layout, verify_x11_keyboard_layout -from .disk.helpers import findmnt -from .mirrors import use_mirrors -from .models.disk_encryption import DiskEncryption -from .plugins import plugins -from .storage import storage -from .output import log -from .profiles import Profile -from .disk.partition import get_mount_fs_type +import time +from pathlib import Path +from typing import Any, List, Optional, TYPE_CHECKING, Union, Dict, Callable + +from . import disk from .exceptions import DiskError, ServiceException, RequirementError, HardwareIncompatibilityError, SysCallError +from .general import SysCommand +from .hardware import SysInfo +from .locale import LocaleConfiguration +from .locale import verify_keyboard_layout, verify_x11_keyboard_layout +from .luks import Luks2 +from .mirrors import MirrorConfiguration +from .models.bootloader import Bootloader +from .models.network_configuration import Nic from .models.users import User -from .models.subvolume import Subvolume -from .hsm import Fido2 +from .output import log, error, info, warn, debug +from . import pacman +from .pacman import Pacman +from .plugins import plugins +from .storage import storage if TYPE_CHECKING: _: Any - # Any package that the Installer() is responsible for (optional and the default ones) __packages__ = ["base", "base-devel", "linux-firmware", "linux", "linux-lts", "linux-zen", "linux-hardened"] # Additional packages that are installed if the user is running the Live ISO with accessibility tools enabled __accessibility_packages__ = ["brltty", "espeakup", "alsa-utils"] -from .pacman import run_pacman -from .models.network_configuration import NetworkConfiguration - - -class InstallationFile: - def __init__(self, installation :'Installer', filename :str, owner :str, mode :str = "w"): - self.installation = installation - self.filename = filename - self.owner = owner - self.mode = mode - self.fh = None - - def __enter__(self) -> 'InstallationFile': - self.fh = open(self.filename, self.mode) - return self - - def __exit__(self, *args :str) -> None: - self.fh.close() - self.installation.chown(self.owner, self.filename) - - def write(self, data: Union[str, bytes]) -> int: - return self.fh.write(data) - - def read(self, *args) -> Union[str, bytes]: - return self.fh.read(*args) - -# def poll(self, *args) -> bool: -# return self.fh.poll(*args) - def accessibility_tools_in_use() -> bool: return os.system('systemctl is-active --quiet espeakup.service') == 0 class Installer: - """ - `Installer()` is the wrapper for most basic installation steps. - It also wraps :py:func:`~archinstall.Installer.pacstrap` among other things. - - :param partition: Requires a partition as the first argument, this is - so that the installer can mount to `mountpoint` and strap packages there. - :type partition: class:`archinstall.Partition` - - :param boot_partition: There's two reasons for needing a boot partition argument, - The first being so that `mkinitcpio` can place the `vmlinuz` kernel at the right place - during the `pacstrap` or `linux` and the base packages for a minimal installation. - The second being when :py:func:`~archinstall.Installer.add_bootloader` is called, - A `boot_partition` must be known to the installer before this is called. - :type boot_partition: class:`archinstall.Partition` - - :param profile: A profile to install, this is optional and can be called later manually. - This just simplifies the process by not having to call :py:func:`~archinstall.Installer.install_profile` later on. - :type profile: str, optional - - :param hostname: The given /etc/hostname for the machine. - :type hostname: str, optional - - """ - - def __init__(self, target :str, *, base_packages :Optional[List[str]] = None, kernels :Optional[List[str]] = None): - if base_packages is None: - base_packages = __packages__[:3] - if kernels is None: - self.kernels = ['linux'] - else: - self.kernels = kernels - self.target = target + def __init__( + self, + target: Path, + disk_config: disk.DiskLayoutConfiguration, + disk_encryption: Optional[disk.DiskEncryption] = None, + base_packages: List[str] = [], + kernels: Optional[List[str]] = None + ): + """ + `Installer()` is the wrapper for most basic installation steps. + It also wraps :py:func:`~archinstall.Installer.pacstrap` among other things. + """ + self._base_packages = base_packages or __packages__[:3] + self.kernels = kernels or ['linux'] + self._disk_config = disk_config + + self._disk_encryption = disk_encryption or disk.DiskEncryption(disk.EncryptionType.NoEncryption) + self.target: Path = target + self.init_time = time.strftime('%Y-%m-%d_%H-%M-%S') self.milliseconds = int(str(time.time()).split('.')[1]) + self.helper_flags: Dict[str, Any] = {'base': False, 'bootloader': None} - self.helper_flags = { - 'base': False, - 'bootloader': False - } - - self.base_packages = base_packages.split(' ') if type(base_packages) is str else base_packages for kernel in self.kernels: - self.base_packages.append(kernel) + self._base_packages.append(kernel) # If using accessibility tools in the live environment, append those to the packages list if accessibility_tools_in_use(): - self.base_packages.extend(__accessibility_packages__) + self._base_packages.extend(__accessibility_packages__) - self.post_base_install = [] + self.post_base_install: List[Callable] = [] # TODO: Figure out which one of these two we'll use.. But currently we're mixing them.. storage['session'] = self storage['installation_session'] = self - self.MODULES = [] - self.BINARIES = [] - self.FILES = [] + self._modules: List[str] = [] + self._binaries: List[str] = [] + self._files: List[str] = [] + # systemd, sd-vconsole and sd-encrypt will be replaced by udev, keymap and encrypt # if HSM is not used to encrypt the root volume. Check mkinitcpio() function for that override. - self.HOOKS = ["base", "systemd", "autodetect", "keyboard", "sd-vconsole", "modconf", "block", "filesystems", "fsck"] - self.KERNEL_PARAMS = [] - self.FSTAB_ENTRIES = [] + self._hooks: List[str] = [ + "base", "systemd", "autodetect", "microcode", "keyboard", + "sd-vconsole", "modconf", "block", "filesystems", "fsck" + ] + self._kernel_params: List[str] = [] + self._fstab_entries: List[str] = [] self._zram_enabled = False + self._disable_fstrim = False - self._disk_encryption: DiskEncryption = storage['arguments'].get('disk_encryption') - - def log(self, *args :str, level :int = logging.DEBUG, **kwargs :str): - """ - installer.log() wraps output.log() mainly to set a default log-level for this install session. - Any manual override can be done per log() call. - """ - log(*args, level=level, **kwargs) + self.pacman = Pacman(self.target, storage['arguments'].get('silent', False)) - def __enter__(self, *args :str, **kwargs :str) -> 'Installer': + def __enter__(self) -> 'Installer': return self - def __exit__(self, *args :str, **kwargs :str) -> None: - # TODO: https://stackoverflow.com/questions/28157929/how-to-safely-handle-an-exception-inside-a-context-manager - - if len(args) >= 2 and args[1]: - self.log(args[1], level=logging.ERROR, fg='red') + def __exit__(self, exc_type, exc_val, exc_tb): + if exc_type is not None: + error(exc_val) self.sync_log_to_install_medium() # We avoid printing /mnt/<log path> because that might confuse people if they note it down # and then reboot, and a identical log file will be found in the ISO medium anyway. - print(_("[!] A log file has been created here: {}").format(os.path.join(storage['LOG_PATH'], storage['LOG_FILE']))) + print(_("[!] A log file has been created here: {}").format( + os.path.join(storage['LOG_PATH'], storage['LOG_FILE']))) print(_(" Please submit this issue (and file) to https://github.com/archlinux/archinstall/issues")) - raise args[1] + raise exc_val if not (missing_steps := self.post_install_check()): - self.log('Installation completed without any errors. You may now reboot.', fg='green', level=logging.INFO) + log('Installation completed without any errors. You may now reboot.', fg='green') self.sync_log_to_install_medium() - return True else: - self.log('Some required steps were not successfully installed/configured before leaving the installer:', fg='red', level=logging.WARNING) + warn('Some required steps were not successfully installed/configured before leaving the installer:') + for step in missing_steps: - self.log(f' - {step}', fg='red', level=logging.WARNING) + warn(f' - {step}') - self.log(f"Detailed error logs can be found at: {storage['LOG_PATH']}", level=logging.WARNING) - self.log("Submit this zip file as an issue to https://github.com/archlinux/archinstall/issues", level=logging.WARNING) + warn(f"Detailed error logs can be found at: {storage['LOG_PATH']}") + warn("Submit this zip file as an issue to https://github.com/archlinux/archinstall/issues") self.sync_log_to_install_medium() return False - @property - def partitions(self) -> List[Partition]: - return get_partitions_in_use(self.target).values() + def remove_mod(self, mod: str): + if mod in self._modules: + self._modules.remove(mod) - def sync_log_to_install_medium(self) -> bool: - # Copy over the install log (if there is one) to the install medium if - # at least the base has been strapped in, otherwise we won't have a filesystem/structure to copy to. - if self.helper_flags.get('base-strapped', False) is True: - if filename := storage.get('LOG_FILE', None): - absolute_logfile = os.path.join(storage.get('LOG_PATH', './'), filename) + def append_mod(self, mod: str): + if mod not in self._modules: + self._modules.append(mod) - if not os.path.isdir(f"{self.target}/{os.path.dirname(absolute_logfile)}"): - os.makedirs(f"{self.target}/{os.path.dirname(absolute_logfile)}") + def _verify_service_stop(self): + """ + Certain services might be running that affects the system during installation. + One such service is "reflector.service" which updates /etc/pacman.d/mirrorlist + We need to wait for it before we continue since we opted in to use a custom mirror/region. + """ - shutil.copy2(absolute_logfile, f"{self.target}/{absolute_logfile}") + if not storage['arguments'].get('skip_ntp', False): + info(_('Waiting for time sync (timedatectl show) to complete.')) - return True + _started_wait = time.time() + _notified = False + while True: + if not _notified and time.time() - _started_wait > 5: + _notified = True + warn( + _("Time synchronization not completing, while you wait - check the docs for workarounds: https://archinstall.readthedocs.io/")) - def _create_keyfile(self,luks_handle , partition :dict, password :str): - """ roiutine to create keyfiles, so it can be moved elsewhere - """ - if self._disk_encryption and self._disk_encryption.generate_encryption_file(partition): - if not (cryptkey_dir := pathlib.Path(f"{self.target}/etc/cryptsetup-keys.d")).exists(): - cryptkey_dir.mkdir(parents=True) - # Once we store the key as ../xyzloop.key systemd-cryptsetup can automatically load this key - # if we name the device to "xyzloop". - if partition.get('mountpoint',None): - encryption_key_path = f"/etc/cryptsetup-keys.d/{pathlib.Path(partition['mountpoint']).name}loop.key" - else: - encryption_key_path = f"/etc/cryptsetup-keys.d/{pathlib.Path(partition['device_instance'].path).name}.key" - with open(f"{self.target}{encryption_key_path}", "w") as keyfile: - keyfile.write(generate_password(length=512)) + time_val = SysCommand('timedatectl show --property=NTPSynchronized --value').decode() + if time_val and time_val.strip() == 'yes': + break + time.sleep(1) + else: + info( + _('Skipping waiting for automatic time sync (this can cause issues if time is out of sync during installation)')) + + info('Waiting for automatic mirror selection (reflector) to complete.') + while self._service_state('reflector') not in ('dead', 'failed', 'exited'): + time.sleep(1) + + # info('Waiting for pacman-init.service to complete.') + # while self._service_state('pacman-init') not in ('dead', 'failed', 'exited'): + # time.sleep(1) - os.chmod(f"{self.target}{encryption_key_path}", 0o400) + info(_('Waiting for Arch Linux keyring sync (archlinux-keyring-wkd-sync) to complete.')) + # Wait for the timer to kick in + while self._service_started('archlinux-keyring-wkd-sync.timer') is None: + time.sleep(1) - luks_handle.add_key(pathlib.Path(f"{self.target}{encryption_key_path}"), password=password) - luks_handle.crypttab(self, encryption_key_path, options=["luks", "key-slot=1"]) + # Wait for the service to enter a finished state + while self._service_state('archlinux-keyring-wkd-sync.service') not in ('dead', 'failed', 'exited'): + time.sleep(1) - def _has_root(self, partition :dict) -> bool: + def _verify_boot_part(self): """ - Determine if an encrypted partition contains root in it + Check that mounted /boot device has at minimum size for installation + The reason this check is here is to catch pre-mounted device configuration and potentially + configured one that has not gone through any previous checks (e.g. --silence mode) + + NOTE: this function should be run AFTER running the mount_ordered_layout function """ - if partition.get("mountpoint") is None: - if (sub_list := partition.get("btrfs",{}).get('subvolumes',{})): - for mountpoint in [sub_list[subvolume].get("mountpoint") if isinstance(subvolume, dict) else subvolume.mountpoint for subvolume in sub_list]: - if mountpoint == '/': - return True - return False - else: - return False - elif partition.get("mountpoint") == '/': - return True - else: - return False + boot_mount = self.target / 'boot' + lsblk_info = disk.get_lsblk_by_mountpoint(boot_mount) + + if len(lsblk_info) > 0: + if lsblk_info[0].size < disk.Size(200, disk.Unit.MiB, disk.SectorSize.default()): + raise DiskError( + f'The boot partition mounted at {boot_mount} is not large enough to install a boot loader. ' + f'Please resize it to at least 200MiB and re-run the installation.' + ) - def mount_ordered_layout(self, layouts: Dict[str, Any]) -> None: - from .luks import luks2 - from .disk.btrfs import setup_subvolumes, mount_subvolume - - # set the partitions as a list not part of a tree (which we don't need anymore (i think) - list_part = [] - list_luks_handles = [] - for blockdevice in layouts: - list_part.extend(layouts[blockdevice]['partitions']) - - # TODO: Implement a proper mount-queue system that does not depend on return values. - mount_queue = {} - - # we manage the encrypted partititons - if self._disk_encryption: - for partition in self._disk_encryption.all_partitions: - # open the luks device and all associate stuff - loopdev = f"{storage.get('ENC_IDENTIFIER', 'ai')}{pathlib.Path(partition['device_instance'].path).name}" - - # note that we DON'T auto_unmount (i.e. close the encrypted device so it can be used - with (luks_handle := luks2(partition['device_instance'], loopdev, self._disk_encryption.encryption_password, auto_unmount=False)) as unlocked_device: - if self._disk_encryption.generate_encryption_file(partition) and not self._has_root(partition): - list_luks_handles.append([luks_handle, partition, self._disk_encryption.encryption_password]) - # this way all the requesrs will be to the dm_crypt device and not to the physical partition - partition['device_instance'] = unlocked_device - - if self._has_root(partition) and self._disk_encryption.generate_encryption_file(partition) is False: - if self._disk_encryption.hsm_device: - Fido2.fido2_enroll(self._disk_encryption.hsm_device, partition['device_instance'], self._disk_encryption.encryption_password) - - btrfs_subvolumes = [entry for entry in list_part if entry.get('btrfs', {}).get('subvolumes', [])] - - for partition in btrfs_subvolumes: - device_instance = partition['device_instance'] - mount_options = partition.get('filesystem', {}).get('mount_options', []) - self.mount(device_instance, "/", options=','.join(mount_options)) - setup_subvolumes(installation=self, partition_dict=partition) - device_instance.unmount() - - # We then handle any special cases, such as btrfs - for partition in btrfs_subvolumes: - subvolumes: List[Subvolume] = partition['btrfs']['subvolumes'] - for subvolume in sorted(subvolumes, key=lambda item: item.mountpoint): - # We cache the mount call for later - mount_queue[subvolume.mountpoint] = lambda sub_vol=subvolume, device=partition['device_instance']: mount_subvolume( - installation=self, - device=device, - subvolume=sub_vol - ) + def sanity_check(self): + # self._verify_boot_part() + self._verify_service_stop() + + def mount_ordered_layout(self): + debug('Mounting ordered layout') + + luks_handlers: Dict[Any, Luks2] = {} + + match self._disk_encryption.encryption_type: + case disk.EncryptionType.NoEncryption: + self._mount_lvm_layout() + case disk.EncryptionType.Luks: + luks_handlers = self._prepare_luks_partitions(self._disk_encryption.partitions) + case disk.EncryptionType.LvmOnLuks: + luks_handlers = self._prepare_luks_partitions(self._disk_encryption.partitions) + self._import_lvm() + self._mount_lvm_layout(luks_handlers) + case disk.EncryptionType.LuksOnLvm: + self._import_lvm() + luks_handlers = self._prepare_luks_lvm(self._disk_encryption.lvm_volumes) + self._mount_lvm_layout(luks_handlers) + + # mount all regular partitions + self._mount_partition_layout(luks_handlers) + + def _mount_partition_layout(self, luks_handlers: Dict[Any, Luks2]): + debug('Mounting partition layout') + + # do not mount any PVs part of the LVM configuration + pvs = [] + if self._disk_config.lvm_config: + pvs = self._disk_config.lvm_config.get_all_pvs() + + for mod in self._disk_config.device_modifications: + not_pv_part_mods = list(filter(lambda x: x not in pvs, mod.partitions)) + + # partitions have to mounted in the right order on btrfs the mountpoint will + # be empty as the actual subvolumes are getting mounted instead so we'll use + # '/' just for sorting + sorted_part_mods = sorted(not_pv_part_mods, key=lambda x: x.mountpoint or Path('/')) + + for part_mod in sorted_part_mods: + if luks_handler := luks_handlers.get(part_mod): + self._mount_luks_partition(part_mod, luks_handler) + else: + self._mount_partition(part_mod) - # We mount ordinary partitions, and we sort them by the mountpoint - for partition in sorted([entry for entry in list_part if entry.get('mountpoint', False)], key=lambda part: part['mountpoint']): - mountpoint = partition['mountpoint'] - log(f"Mounting {mountpoint} to {self.target}{mountpoint} using {partition['device_instance']}", level=logging.INFO) + def _mount_lvm_layout(self, luks_handlers: Dict[Any, Luks2] = {}): + lvm_config = self._disk_config.lvm_config - if partition.get('filesystem',{}).get('mount_options',[]): - mount_options = ','.join(partition['filesystem']['mount_options']) - mount_queue[mountpoint] = lambda instance=partition['device_instance'], target=f"{self.target}{mountpoint}", options=mount_options: instance.mount(target, options=options) - else: - mount_queue[mountpoint] = lambda instance=partition['device_instance'], target=f"{self.target}{mountpoint}": instance.mount(target) + if not lvm_config: + debug('No lvm config defined to be mounted') + return - log(f"Using mount order: {list(sorted(mount_queue.items(), key=lambda item: item[0]))}", level=logging.DEBUG, fg="white") + debug('Mounting LVM layout') - # We mount everything by sorting on the mountpoint itself. - for mountpoint, frozen_func in sorted(mount_queue.items(), key=lambda item: item[0]): - frozen_func() + for vg in lvm_config.vol_groups: + sorted_vol = sorted(vg.volumes, key=lambda x: x.mountpoint or Path('/')) - time.sleep(1) + for vol in sorted_vol: + if luks_handler := luks_handlers.get(vol): + self._mount_luks_volume(vol, luks_handler) + else: + self._mount_lvm_vol(vol) + + def _prepare_luks_partitions( + self, + partitions: List[disk.PartitionModification] + ) -> Dict[disk.PartitionModification, Luks2]: + return { + part_mod: disk.device_handler.unlock_luks2_dev( + part_mod.dev_path, + part_mod.mapper_name, + self._disk_encryption.encryption_password + ) + for part_mod in partitions + if part_mod.mapper_name and part_mod.dev_path + } - try: - findmnt(pathlib.Path(f"{self.target}{mountpoint}"), traverse=False) - except DiskError: - raise DiskError(f"Target {self.target}{mountpoint} never got mounted properly (unable to get mount information using findmnt).") + def _import_lvm(self): + lvm_config = self._disk_config.lvm_config + + if not lvm_config: + debug('No lvm config defined to be imported') + return + + for vg in lvm_config.vol_groups: + disk.device_handler.lvm_import_vg(vg) + + for vol in vg.volumes: + disk.device_handler.lvm_vol_change(vol, True) + + def _prepare_luks_lvm( + self, + lvm_volumes: List[disk.LvmVolume] + ) -> Dict[disk.LvmVolume, Luks2]: + return { + vol: disk.device_handler.unlock_luks2_dev( + vol.dev_path, + vol.mapper_name, + self._disk_encryption.encryption_password + ) + for vol in lvm_volumes + if vol.mapper_name and vol.dev_path + } + + def _mount_partition(self, part_mod: disk.PartitionModification): + # it would be none if it's btrfs as the subvolumes will have the mountpoints defined + if part_mod.mountpoint and part_mod.dev_path: + target = self.target / part_mod.relative_mountpoint + disk.device_handler.mount(part_mod.dev_path, target, options=part_mod.mount_options) + + if part_mod.fs_type == disk.FilesystemType.Btrfs and part_mod.dev_path: + self._mount_btrfs_subvol( + part_mod.dev_path, + part_mod.btrfs_subvols, + part_mod.mount_options + ) + + def _mount_lvm_vol(self, volume: disk.LvmVolume): + if volume.fs_type != disk.FilesystemType.Btrfs: + if volume.mountpoint and volume.dev_path: + target = self.target / volume.relative_mountpoint + disk.device_handler.mount(volume.dev_path, target, options=volume.mount_options) + + if volume.fs_type == disk.FilesystemType.Btrfs and volume.dev_path: + self._mount_btrfs_subvol(volume.dev_path, volume.btrfs_subvols, volume.mount_options) + + def _mount_luks_partition(self, part_mod: disk.PartitionModification, luks_handler: Luks2): + if part_mod.fs_type != disk.FilesystemType.Btrfs: + if part_mod.mountpoint and luks_handler.mapper_dev: + target = self.target / part_mod.relative_mountpoint + disk.device_handler.mount(luks_handler.mapper_dev, target, options=part_mod.mount_options) + + if part_mod.fs_type == disk.FilesystemType.Btrfs and luks_handler.mapper_dev: + self._mount_btrfs_subvol(luks_handler.mapper_dev, part_mod.btrfs_subvols, part_mod.mount_options) + + def _mount_luks_volume(self, volume: disk.LvmVolume, luks_handler: Luks2): + if volume.fs_type != disk.FilesystemType.Btrfs: + if volume.mountpoint and luks_handler.mapper_dev: + target = self.target / volume.relative_mountpoint + disk.device_handler.mount(luks_handler.mapper_dev, target, options=volume.mount_options) + + if volume.fs_type == disk.FilesystemType.Btrfs and luks_handler.mapper_dev: + self._mount_btrfs_subvol(luks_handler.mapper_dev, volume.btrfs_subvols, volume.mount_options) + + def _mount_btrfs_subvol( + self, + dev_path: Path, + subvolumes: List[disk.SubvolumeModification], + mount_options: List[str] = [] + ): + for subvol in subvolumes: + mountpoint = self.target / subvol.relative_mountpoint + mount_options = mount_options + [f'subvol={subvol.name}'] + disk.device_handler.mount(dev_path, mountpoint, options=mount_options) + + def generate_key_files(self): + match self._disk_encryption.encryption_type: + case disk.EncryptionType.Luks: + self._generate_key_files_partitions() + case disk.EncryptionType.LuksOnLvm: + self._generate_key_file_lvm_volumes() + case disk.EncryptionType.LvmOnLuks: + # currently LvmOnLuks only supports a single + # partitioning layout (boot + partition) + # so we won't need any keyfile generation atm + pass + + def _generate_key_files_partitions(self): + for part_mod in self._disk_encryption.partitions: + gen_enc_file = self._disk_encryption.should_generate_encryption_file(part_mod) + + luks_handler = Luks2( + part_mod.safe_dev_path, + mapper_name=part_mod.mapper_name, + password=self._disk_encryption.encryption_password + ) + + if gen_enc_file and not part_mod.is_root(): + debug(f'Creating key-file: {part_mod.dev_path}') + luks_handler.create_keyfile(self.target) + + if part_mod.is_root() and not gen_enc_file: + if self._disk_encryption.hsm_device: + disk.Fido2.fido2_enroll( + self._disk_encryption.hsm_device, + part_mod.safe_dev_path, + self._disk_encryption.encryption_password + ) + + def _generate_key_file_lvm_volumes(self): + for vol in self._disk_encryption.lvm_volumes: + gen_enc_file = self._disk_encryption.should_generate_encryption_file(vol) + + luks_handler = Luks2( + vol.safe_dev_path, + mapper_name=vol.mapper_name, + password=self._disk_encryption.encryption_password + ) + + if gen_enc_file and not vol.is_root(): + info(f'Creating key-file: {vol.dev_path}') + luks_handler.create_keyfile(self.target) - # once everything is mounted, we generate the key files in the correct place - for handle in list_luks_handles: - ppath = handle[1]['device_instance'].path - log(f"creating key-file for {ppath}",level=logging.INFO) - self._create_keyfile(handle[0],handle[1],handle[2]) + if vol.is_root() and not gen_enc_file: + if self._disk_encryption.hsm_device: + disk.Fido2.fido2_enroll( + self._disk_encryption.hsm_device, + vol.safe_dev_path, + self._disk_encryption.encryption_password + ) + + def sync_log_to_install_medium(self) -> bool: + # Copy over the install log (if there is one) to the install medium if + # at least the base has been strapped in, otherwise we won't have a filesystem/structure to copy to. + if self.helper_flags.get('base-strapped', False) is True: + if filename := storage.get('LOG_FILE', None): + absolute_logfile = os.path.join(storage.get('LOG_PATH', './'), filename) + + if not os.path.isdir(f"{self.target}/{os.path.dirname(absolute_logfile)}"): + os.makedirs(f"{self.target}/{os.path.dirname(absolute_logfile)}") - def mount(self, partition :Partition, mountpoint :str, create_mountpoint :bool = True, options='') -> None: - if create_mountpoint and not os.path.isdir(f'{self.target}{mountpoint}'): - os.makedirs(f'{self.target}{mountpoint}') + shutil.copy2(absolute_logfile, f"{self.target}/{absolute_logfile}") - partition.mount(f'{self.target}{mountpoint}', options=options) + return True def add_swapfile(self, size='4G', enable_resume=True, file='/swapfile'): if file[:1] != '/': @@ -329,176 +433,137 @@ class Installer: SysCommand(f'chmod 0600 {self.target}{file}') SysCommand(f'mkswap {self.target}{file}') - self.FSTAB_ENTRIES.append(f'{file} none swap defaults 0 0') + self._fstab_entries.append(f'{file} none swap defaults 0 0') if enable_resume: - resume_uuid = SysCommand(f'findmnt -no UUID -T {self.target}{file}').decode('UTF-8').strip() - resume_offset = SysCommand(f'/usr/bin/filefrag -v {self.target}{file}').decode('UTF-8').split('0:', 1)[1].split(":", 1)[1].split("..", 1)[0].strip() + resume_uuid = SysCommand(f'findmnt -no UUID -T {self.target}{file}').decode() + resume_offset = SysCommand( + f'/usr/bin/filefrag -v {self.target}{file}' + ).decode().split('0:', 1)[1].split(":", 1)[1].split("..", 1)[0].strip() - self.HOOKS.append('resume') - self.KERNEL_PARAMS.append(f'resume=UUID={resume_uuid}') - self.KERNEL_PARAMS.append(f'resume_offset={resume_offset}') + self._hooks.append('resume') + self._kernel_params.append(f'resume=UUID={resume_uuid}') + self._kernel_params.append(f'resume_offset={resume_offset}') - def post_install_check(self, *args :str, **kwargs :str) -> List[str]: + def post_install_check(self, *args: str, **kwargs: str) -> List[str]: return [step for step, flag in self.helper_flags.items() if flag is False] - def enable_multilib_repository(self): - # Set up a regular expression pattern of a commented line containing 'multilib' within [] - pattern = re.compile(r"^#\s*\[multilib\]$") - - # This is used to track if the previous line is a match, so we end up uncommenting the line after the block. - matched = False - - # Read in the lines from the original file - with open("/etc/pacman.conf", "r") as pacman_conf: - lines = pacman_conf.readlines() - - # Open the file again in write mode, to replace the contents - with open("/etc/pacman.conf", "w") as pacman_conf: - for line in lines: - if pattern.match(line): - # If this is the [] block containing 'multilib', uncomment it and set the matched tracking boolean. - pacman_conf.write(line.lstrip('#')) - matched = True - elif matched: - # The previous line was a match for [.*multilib.*]. - # This means we're on a line that looks like '#Include = /etc/pacman.d/mirrorlist' - pacman_conf.write(line.lstrip('#')) - matched = False # Reset the state of matched to False. - else: - pacman_conf.write(line) - - def enable_testing_repositories(self, enable_multilib_testing=False): - # Set up a regular expression pattern of a commented line containing 'testing' within [] - pattern = re.compile("^#\\[.*testing.*\\]$") - - # This is used to track if the previous line is a match, so we end up uncommenting the line after the block. - matched = False - - # Read in the lines from the original file - with open("/etc/pacman.conf", "r") as pacman_conf: - lines = pacman_conf.readlines() - - # Open the file again in write mode, to replace the contents - with open("/etc/pacman.conf", "w") as pacman_conf: - for line in lines: - if pattern.match(line) and (enable_multilib_testing or 'multilib' not in line): - # If this is the [] block containing 'testing', uncomment it and set the matched tracking boolean. - pacman_conf.write(line.lstrip('#')) - matched = True - elif matched: - # The previous line was a match for [.*testing.*]. - # This means we're on a line that looks like '#Include = /etc/pacman.d/mirrorlist' - pacman_conf.write(line.lstrip('#')) - matched = False # Reset the state of matched to False. - else: - pacman_conf.write(line) - - def pacstrap(self, *packages :str, **kwargs :str) -> bool: - if type(packages[0]) in (list, tuple): - packages = packages[0] - - for plugin in plugins.values(): - if hasattr(plugin, 'on_pacstrap'): - if (result := plugin.on_pacstrap(packages)): - packages = result + def set_mirrors(self, mirror_config: MirrorConfiguration, on_target: bool = False): + """ + Set the mirror configuration for the installation. - self.log(f'Installing packages: {packages}', level=logging.INFO) + :param mirror_config: The mirror configuration to use. + :type mirror_config: MirrorConfiguration - # TODO: We technically only need to run the -Syy once. - try: - run_pacman('-Syy', default_cmd='/usr/bin/pacman') - except SysCallError as error: - self.log(f'Could not sync a new package database: {error}', level=logging.ERROR, fg="red") + :on_target: Whether to set the mirrors on the target system or the live system. + :param on_target: bool + """ + debug('Setting mirrors') - if storage['arguments'].get('silent', False) is False: - if input('Would you like to re-try this download? (Y/n): ').lower().strip() in ('', 'y'): - return self.pacstrap(*packages, **kwargs) + for plugin in plugins.values(): + if hasattr(plugin, 'on_mirrors'): + if result := plugin.on_mirrors(mirror_config): + mirror_config = result - raise RequirementError(f'Could not sync mirrors: {error}', level=logging.ERROR, fg="red") + if on_target: + local_pacman_conf = Path(f'{self.target}/etc/pacman.conf') + local_mirrorlist_conf = Path(f'{self.target}/etc/pacman.d/mirrorlist') + else: + local_pacman_conf = Path('/etc/pacman.conf') + local_mirrorlist_conf = Path('/etc/pacman.d/mirrorlist') - try: - SysCommand(f'/usr/bin/pacstrap -C /etc/pacman.conf -K {self.target} {" ".join(packages)} --noconfirm', peek_output=True) - return True - except SysCallError as error: - self.log(f'Could not strap in packages: {error}', level=logging.ERROR, fg="red") + mirrorlist_config = mirror_config.mirrorlist_config() + pacman_config = mirror_config.pacman_config() - if storage['arguments'].get('silent', False) is False: - if input('Would you like to re-try this download? (Y/n): ').lower().strip() in ('', 'y'): - return self.pacstrap(*packages, **kwargs) + if pacman_config: + debug(f'Pacman config: {pacman_config}') - raise RequirementError("Pacstrap failed. See /var/log/archinstall/install.log or above message for error details.") + with local_pacman_conf.open('a') as fp: + fp.write(pacman_config) - def set_mirrors(self, mirrors :Mapping[str, Iterator[str]]) -> None: - for plugin in plugins.values(): - if hasattr(plugin, 'on_mirrors'): - if result := plugin.on_mirrors(mirrors): - mirrors = result + if mirrorlist_config: + debug(f'Mirrorlist: {mirrorlist_config}') - return use_mirrors(mirrors, destination=f'{self.target}/etc/pacman.d/mirrorlist') + with local_mirrorlist_conf.open('a') as fp: + fp.write(mirrorlist_config) - def genfstab(self, flags :str = '-pU') -> bool: - self.log(f"Updating {self.target}/etc/fstab", level=logging.INFO) + def genfstab(self, flags: str = '-pU'): + fstab_path = self.target / "etc" / "fstab" + info(f"Updating {fstab_path}") try: - fstab = SysCommand(f'/usr/bin/genfstab {flags} {self.target}') - except SysCallError as error: - raise RequirementError(f'Could not generate fstab, strapping in packages most likely failed (disk out of space?)\n Error: {error}') + gen_fstab = SysCommand(f'/usr/bin/genfstab {flags} {self.target}').output() + except SysCallError as err: + raise RequirementError( + f'Could not generate fstab, strapping in packages most likely failed (disk out of space?)\n Error: {err}') - with open(f"{self.target}/etc/fstab", 'a') as fstab_fh: - fstab_fh.write(fstab.decode()) + with open(fstab_path, 'ab') as fp: + fp.write(gen_fstab) - if not os.path.isfile(f'{self.target}/etc/fstab'): - raise RequirementError(f'Could not generate fstab, strapping in packages most likely failed (disk out of space?)\n Error: {fstab}') + if not fstab_path.is_file(): + raise RequirementError(f'Could not create fstab file') for plugin in plugins.values(): if hasattr(plugin, 'on_genfstab'): if plugin.on_genfstab(self) is True: break - with open(f"{self.target}/etc/fstab", 'a') as fstab_fh: - for entry in self.FSTAB_ENTRIES: - fstab_fh.write(f'{entry}\n') + with open(fstab_path, 'a') as fp: + for entry in self._fstab_entries: + fp.write(f'{entry}\n') - return True - - def set_hostname(self, hostname: str, *args :str, **kwargs :str) -> None: + def set_hostname(self, hostname: str): with open(f'{self.target}/etc/hostname', 'w') as fh: fh.write(hostname + '\n') - def set_locale(self, locale :str, encoding :str = 'UTF-8', *args :str, **kwargs :str) -> bool: - if not len(locale): - return True - + def set_locale(self, locale_config: LocaleConfiguration) -> bool: modifier = '' + lang = locale_config.sys_lang + encoding = locale_config.sys_enc # This is a temporary patch to fix #1200 - if '.' in locale: - locale, potential_encoding = locale.split('.', 1) + if '.' in locale_config.sys_lang: + lang, potential_encoding = locale_config.sys_lang.split('.', 1) # Override encoding if encoding is set to the default parameter # and the "found" encoding differs. - if encoding == 'UTF-8' and encoding != potential_encoding: + if locale_config.sys_enc == 'UTF-8' and locale_config.sys_enc != potential_encoding: encoding = potential_encoding # Make sure we extract the modifier, that way we can put it in if needed. - if '@' in locale: - locale, modifier = locale.split('@', 1) + if '@' in locale_config.sys_lang: + lang, modifier = locale_config.sys_lang.split('@', 1) modifier = f"@{modifier}" # - End patch - with open(f'{self.target}/etc/locale.gen', 'a') as fh: - fh.write(f'{locale}.{encoding}{modifier} {encoding}\n') - with open(f'{self.target}/etc/locale.conf', 'w') as fh: - fh.write(f'LANG={locale}.{encoding}{modifier}\n') + locale_gen = self.target / 'etc/locale.gen' + locale_gen_lines = locale_gen.read_text().splitlines(True) + + # A locale entry in /etc/locale.gen may or may not contain the encoding + # in the first column of the entry; check for both cases. + entry_re = re.compile(rf'#{lang}(\.{encoding})?{modifier} {encoding}') + + for index, line in enumerate(locale_gen_lines): + if entry_re.match(line): + uncommented_line = line.removeprefix('#') + locale_gen_lines[index] = uncommented_line + locale_gen.write_text(''.join(locale_gen_lines)) + lang_value = uncommented_line.split()[0] + break + else: + error(f"Invalid locale: language '{locale_config.sys_lang}', encoding '{locale_config.sys_enc}'") + return False try: SysCommand(f'/usr/bin/arch-chroot {self.target} locale-gen') - return True - except SysCallError: + except SysCallError as e: + error(f'Failed to run locale-gen on target: {e}') return False - def set_timezone(self, zone :str, *args :str, **kwargs :str) -> bool: + (self.target / 'etc/locale.conf').write_text(f'LANG={lang_value}\n') + return True + + def set_timezone(self, zone: str) -> bool: if not zone: return True if not len(zone): @@ -509,62 +574,49 @@ class Installer: if result := plugin.on_timezone(zone): zone = result - if (pathlib.Path("/usr") / "share" / "zoneinfo" / zone).exists(): - (pathlib.Path(self.target) / "etc" / "localtime").unlink(missing_ok=True) + if (Path("/usr") / "share" / "zoneinfo" / zone).exists(): + (Path(self.target) / "etc" / "localtime").unlink(missing_ok=True) SysCommand(f'/usr/bin/arch-chroot {self.target} ln -s /usr/share/zoneinfo/{zone} /etc/localtime') return True else: - self.log( - f"Time zone {zone} does not exist, continuing with system default.", - level=logging.WARNING, - fg='red' - ) + warn(f'Time zone {zone} does not exist, continuing with system default') return False - def activate_ntp(self) -> None: - log(f"activate_ntp() is deprecated, use activate_time_syncronization()", fg="yellow", level=logging.INFO) - self.activate_time_syncronization() - - def activate_time_syncronization(self) -> None: - self.log('Activating systemd-timesyncd for time synchronization using Arch Linux and ntp.org NTP servers.', level=logging.INFO) + def activate_time_synchronization(self) -> None: + info('Activating systemd-timesyncd for time synchronization using Arch Linux and ntp.org NTP servers') self.enable_service('systemd-timesyncd') - with open(f"{self.target}/etc/systemd/timesyncd.conf", "w") as fh: - fh.write("[Time]\n") - fh.write("NTP=0.arch.pool.ntp.org 1.arch.pool.ntp.org 2.arch.pool.ntp.org 3.arch.pool.ntp.org\n") - fh.write("FallbackNTP=0.pool.ntp.org 1.pool.ntp.org 0.fr.pool.ntp.org\n") - - from .systemd import Boot - with Boot(self) as session: - session.SysCommand(["timedatectl", "set-ntp", 'true']) - def enable_espeakup(self) -> None: - self.log('Enabling espeakup.service for speech synthesis (accessibility).', level=logging.INFO) + info('Enabling espeakup.service for speech synthesis (accessibility)') self.enable_service('espeakup') def enable_periodic_trim(self) -> None: - self.log("Enabling periodic TRIM") + info("Enabling periodic TRIM") # fstrim is owned by util-linux, a dependency of both base and systemd. self.enable_service("fstrim.timer") - def enable_service(self, *services :str) -> None: + def enable_service(self, services: Union[str, List[str]]) -> None: + if isinstance(services, str): + services = [services] + for service in services: - self.log(f'Enabling service {service}', level=logging.INFO) + info(f'Enabling service {service}') + try: self.arch_chroot(f'systemctl enable {service}') - except SysCallError as error: - raise ServiceException(f"Unable to start service {service}: {error}") + except SysCallError as err: + raise ServiceException(f"Unable to start service {service}: {err}") for plugin in plugins.values(): if hasattr(plugin, 'on_service'): plugin.on_service(service) - def run_command(self, cmd :str, *args :str, **kwargs :str) -> None: + def run_command(self, cmd: str, *args: str, **kwargs: str) -> SysCommand: return SysCommand(f'/usr/bin/arch-chroot {self.target} {cmd}') - def arch_chroot(self, cmd :str, run_as :Optional[str] = None): + def arch_chroot(self, cmd: str, run_as: Optional[str] = None) -> SysCommand: if run_as: cmd = f"su - {run_as} -c {shlex.quote(cmd)}" @@ -573,38 +625,23 @@ class Installer: def drop_to_shell(self) -> None: subprocess.check_call(f"/usr/bin/arch-chroot {self.target}", shell=True) - def configure_nic(self, network_config: NetworkConfiguration) -> None: - from .systemd import Networkd - - if network_config.dhcp: - conf = Networkd(Match={"Name": network_config.iface}, Network={"DHCP": "yes"}) - else: - network = {"Address": network_config.ip} - if network_config.gateway: - network["Gateway"] = network_config.gateway - if network_config.dns: - dns = network_config.dns - network["DNS"] = dns if isinstance(dns, list) else [dns] - - conf = Networkd(Match={"Name": network_config.iface}, Network=network) + def configure_nic(self, nic: Nic): + conf = nic.as_systemd_config() for plugin in plugins.values(): if hasattr(plugin, 'on_configure_nic'): - new_conf = plugin.on_configure_nic( - network_config.iface, - network_config.dhcp, - network_config.ip, - network_config.gateway, - network_config.dns - ) - - if new_conf: - conf = new_conf - - with open(f"{self.target}/etc/systemd/network/10-{network_config.iface}.network", "a") as netconf: + conf = plugin.on_configure_nic( + nic.iface, + nic.dhcp, + nic.ip, + nic.gateway, + nic.dns + ) or conf + + with open(f"{self.target}/etc/systemd/network/10-{nic.iface}.network", "a") as netconf: netconf.write(str(conf)) - def copy_iso_network_config(self, enable_services :bool = False) -> bool: + def copy_iso_network_config(self, enable_services: bool = False) -> bool: # Copy (if any) iwd password and config files if os.path.isdir('/var/lib/iwd/'): if psk_files := glob.glob('/var/lib/iwd/*.psk'): @@ -614,19 +651,19 @@ class Installer: if enable_services: # If we haven't installed the base yet (function called pre-maturely) if self.helper_flags.get('base', False) is False: - self.base_packages.append('iwd') + self._base_packages.append('iwd') # This function will be called after minimal_installation() # as a hook for post-installs. This hook is only needed if # base is not installed yet. - def post_install_enable_iwd_service(*args :str, **kwargs :str): + def post_install_enable_iwd_service(*args: str, **kwargs: str): self.enable_service('iwd') self.post_base_install.append(post_install_enable_iwd_service) # Otherwise, we can go ahead and add the required package # and enable it's service: else: - self.pacstrap('iwd') + self.pacman.strap('iwd') self.enable_service('iwd') for psk in psk_files: @@ -644,179 +681,208 @@ class Installer: # If we haven't installed the base yet (function called pre-maturely) if self.helper_flags.get('base', False) is False: - def post_install_enable_networkd_resolved(*args :str, **kwargs :str): - self.enable_service('systemd-networkd', 'systemd-resolved') + def post_install_enable_networkd_resolved(*args: str, **kwargs: str): + self.enable_service(['systemd-networkd', 'systemd-resolved']) self.post_base_install.append(post_install_enable_networkd_resolved) # Otherwise, we can go ahead and enable the services else: - self.enable_service('systemd-networkd', 'systemd-resolved') + self.enable_service(['systemd-networkd', 'systemd-resolved']) return True - def detect_encryption(self, partition :Partition) -> bool: - from .disk.mapperdev import MapperDev - from .disk.dmcryptdev import DMCryptDev - from .disk.helpers import get_filesystem_type - - if type(partition) is MapperDev: - # Returns MapperDev.partition - return partition.partition - elif type(partition) is DMCryptDev: - return partition.MapperDev.partition - elif get_filesystem_type(partition.path) == 'crypto_LUKS': - return partition - - return False - - def mkinitcpio(self, *flags :str) -> bool: + def mkinitcpio(self, flags: List[str]) -> bool: for plugin in plugins.values(): if hasattr(plugin, 'on_mkinitcpio'): # Allow plugins to override the usage of mkinitcpio altogether. if plugin.on_mkinitcpio(self): return True - # mkinitcpio will error out if there's no vconsole. - if (vconsole := pathlib.Path(f"{self.target}/etc/vconsole.conf")).exists() is False: - with vconsole.open('w') as fh: - fh.write(f"KEYMAP={storage['arguments']['keyboard-layout']}\n") - with open(f'{self.target}/etc/mkinitcpio.conf', 'w') as mkinit: - mkinit.write(f"MODULES=({' '.join(self.MODULES)})\n") - mkinit.write(f"BINARIES=({' '.join(self.BINARIES)})\n") - mkinit.write(f"FILES=({' '.join(self.FILES)})\n") + mkinit.write(f"MODULES=({' '.join(self._modules)})\n") + mkinit.write(f"BINARIES=({' '.join(self._binaries)})\n") + mkinit.write(f"FILES=({' '.join(self._files)})\n") - if self._disk_encryption and not self._disk_encryption.hsm_device: + if not self._disk_encryption.hsm_device: # For now, if we don't use HSM we revert to the old # way of setting up encryption hooks for mkinitcpio. # This is purely for stability reasons, we're going away from this. # * systemd -> udev # * sd-vconsole -> keymap - self.HOOKS = [hook.replace('systemd', 'udev').replace('sd-vconsole', 'keymap') for hook in self.HOOKS] + self._hooks = [hook.replace('systemd', 'udev').replace('sd-vconsole', 'keymap') for hook in self._hooks] - mkinit.write(f"HOOKS=({' '.join(self.HOOKS)})\n") + mkinit.write(f"HOOKS=({' '.join(self._hooks)})\n") try: - SysCommand(f'/usr/bin/arch-chroot {self.target} mkinitcpio {" ".join(flags)}') + SysCommand(f'/usr/bin/arch-chroot {self.target} mkinitcpio {" ".join(flags)}', peek_output=True) return True - except SysCallError: + except SysCallError as error: + if error.worker: + log(error.worker._trace_log.decode()) return False - def minimal_installation( - self, testing: bool = False, multilib: bool = False, - hostname: str = 'archinstall', locales: List[str] = ['en_US.UTF-8 UTF-8']) -> bool: - # Add necessary packages if encrypting the drive - # (encrypted partitions default to btrfs for now, so we need btrfs-progs) - # TODO: Perhaps this should be living in the function which dictates - # the partitioning. Leaving here for now. - - for partition in self.partitions: - if partition.filesystem == 'btrfs': - # if partition.encrypted: - if 'btrfs-progs' not in self.base_packages: - self.base_packages.append('btrfs-progs') - if partition.filesystem == 'xfs': - if 'xfs' not in self.base_packages: - self.base_packages.append('xfsprogs') - if partition.filesystem == 'f2fs': - if 'f2fs' not in self.base_packages: - self.base_packages.append('f2fs-tools') - - # Configure mkinitcpio to handle some specific use cases. - if partition.filesystem == 'btrfs': - if 'btrfs' not in self.MODULES: - self.MODULES.append('btrfs') - if '/usr/bin/btrfs' not in self.BINARIES: - self.BINARIES.append('/usr/bin/btrfs') - # There is not yet an fsck tool for NTFS. If it's being used for the root filesystem, the hook should be removed. - if partition.filesystem == 'ntfs3' and partition.mountpoint == self.target: - if 'fsck' in self.HOOKS: - self.HOOKS.remove('fsck') - - if self.detect_encryption(partition): - if self._disk_encryption and self._disk_encryption.hsm_device: - # Required bby mkinitcpio to add support for fido2-device options - self.pacstrap('libfido2') - - if 'sd-encrypt' not in self.HOOKS: - self.HOOKS.insert(self.HOOKS.index('filesystems'), 'sd-encrypt') - else: - if 'encrypt' not in self.HOOKS: - self.HOOKS.insert(self.HOOKS.index('filesystems'), 'encrypt') - - if not has_uefi(): - self.base_packages.append('grub') - - if not is_vm(): - vendor = cpu_vendor() - if vendor == "AuthenticAMD": - self.base_packages.append("amd-ucode") - if (ucode := pathlib.Path(f"{self.target}/boot/amd-ucode.img")).exists(): - ucode.unlink() - elif vendor == "GenuineIntel": - self.base_packages.append("intel-ucode") - if (ucode := pathlib.Path(f"{self.target}/boot/intel-ucode.img")).exists(): - ucode.unlink() + def _get_microcode(self) -> Optional[Path]: + if not SysInfo.is_vm(): + if vendor := SysInfo.cpu_vendor(): + return vendor.get_ucode() + return None + + def _handle_partition_installation(self): + pvs = [] + if self._disk_config.lvm_config: + pvs = self._disk_config.lvm_config.get_all_pvs() + + for mod in self._disk_config.device_modifications: + for part in mod.partitions: + if part in pvs or part.fs_type is None: + continue + + if (pkg := part.fs_type.installation_pkg) is not None: + self._base_packages.append(pkg) + if (module := part.fs_type.installation_module) is not None: + self._modules.append(module) + if (binary := part.fs_type.installation_binary) is not None: + self._binaries.append(binary) + + # https://github.com/archlinux/archinstall/issues/1837 + if part.fs_type.fs_type_mount == 'btrfs': + self._disable_fstrim = True + + # There is not yet an fsck tool for NTFS. If it's being used for the root filesystem, the hook should be removed. + if part.fs_type.fs_type_mount == 'ntfs3' and part.mountpoint == self.target: + if 'fsck' in self._hooks: + self._hooks.remove('fsck') + + if part in self._disk_encryption.partitions: + if self._disk_encryption.hsm_device: + # Required by mkinitcpio to add support for fido2-device options + self.pacman.strap('libfido2') + + if 'sd-encrypt' not in self._hooks: + self._hooks.insert(self._hooks.index('filesystems'), 'sd-encrypt') + else: + if 'encrypt' not in self._hooks: + self._hooks.insert(self._hooks.index('filesystems'), 'encrypt') + + def _handle_lvm_installation(self): + if not self._disk_config.lvm_config: + return + + self.add_additional_packages('lvm2') + self._hooks.insert(self._hooks.index('filesystems') - 1, 'lvm2') + + for vg in self._disk_config.lvm_config.vol_groups: + for vol in vg.volumes: + if vol.fs_type is not None: + if (pkg := vol.fs_type.installation_pkg) is not None: + self._base_packages.append(pkg) + if (module := vol.fs_type.installation_module) is not None: + self._modules.append(module) + if (binary := vol.fs_type.installation_binary) is not None: + self._binaries.append(binary) + + if vol.fs_type.fs_type_mount == 'btrfs': + self._disable_fstrim = True + + # There is not yet an fsck tool for NTFS. If it's being used for the root filesystem, the hook should be removed. + if vol.fs_type.fs_type_mount == 'ntfs3' and vol.mountpoint == self.target: + if 'fsck' in self._hooks: + self._hooks.remove('fsck') + + if self._disk_encryption.encryption_type in [disk.EncryptionType.LvmOnLuks, disk.EncryptionType.LuksOnLvm]: + if self._disk_encryption.hsm_device: + # Required by mkinitcpio to add support for fido2-device options + self.pacman.strap('libfido2') + + if 'sd-encrypt' not in self._hooks: + self._hooks.insert(self._hooks.index('lvm2') - 1, 'sd-encrypt') else: - self.log(f"Unknown CPU vendor '{vendor}' detected. Archinstall won't install any ucode.", level=logging.DEBUG) + if 'encrypt' not in self._hooks: + self._hooks.insert(self._hooks.index('lvm2') - 1, 'encrypt') + + def minimal_installation( + self, + testing: bool = False, + multilib: bool = False, + mkinitcpio: bool = True, + hostname: str = 'archinstall', + locale_config: LocaleConfiguration = LocaleConfiguration.default() + ): + if self._disk_config.lvm_config: + self._handle_lvm_installation() + else: + self._handle_partition_installation() + + if not SysInfo.has_uefi(): + self._base_packages.append('grub') + + if ucode := self._get_microcode(): + (self.target / 'boot' / ucode).unlink(missing_ok=True) + self._base_packages.append(ucode.stem) + else: + debug('Archinstall will not install any ucode.') # Determine whether to enable multilib/testing repositories before running pacstrap if testing flag is set. # This action takes place on the host system as pacstrap copies over package repository lists. + pacman_conf = pacman.Config(self.target) if multilib: - self.log("The multilib flag is set. This system will be installed with the multilib repository enabled.") - self.enable_multilib_repository() + info("The multilib flag is set. This system will be installed with the multilib repository enabled.") + pacman_conf.enable(pacman.Repo.Multilib) else: - self.log("The multilib flag is not set. This system will be installed without multilib repositories enabled.") + info("The multilib flag is not set. This system will be installed without multilib repositories enabled.") if testing: - self.log("The testing flag is set. This system will be installed with testing repositories enabled.") - self.enable_testing_repositories(multilib) + info("The testing flag is set. This system will be installed with testing repositories enabled.") + pacman_conf.enable(pacman.Repo.Testing) else: - self.log("The testing flag is not set. This system will be installed without testing repositories enabled.") + info("The testing flag is not set. This system will be installed without testing repositories enabled.") + + pacman_conf.apply() - self.pacstrap(self.base_packages) + self.pacman.strap(self._base_packages) self.helper_flags['base-strapped'] = True - # This handles making sure that the repositories we enabled persist on the installed system - if multilib or testing: - shutil.copy2("/etc/pacman.conf", f"{self.target}/etc/pacman.conf") + pacman_conf.persist() # Periodic TRIM may improve the performance and longevity of SSDs whilst # having no adverse effect on other devices. Most distributions enable # periodic TRIM by default. # # https://github.com/archlinux/archinstall/issues/880 - self.enable_periodic_trim() + # https://github.com/archlinux/archinstall/issues/1837 + # https://github.com/archlinux/archinstall/issues/1841 + if not self._disable_fstrim: + self.enable_periodic_trim() # TODO: Support locale and timezone # os.remove(f'{self.target}/etc/localtime') # sys_command(f'/usr/bin/arch-chroot {self.target} ln -s /usr/share/zoneinfo/{localtime} /etc/localtime') # sys_command('/usr/bin/arch-chroot /mnt hwclock --hctosys --localtime') self.set_hostname(hostname) - self.set_locale(*locales[0].split()) + self.set_locale(locale_config) + self.set_keyboard_language(locale_config.kb_layout) # TODO: Use python functions for this SysCommand(f'/usr/bin/arch-chroot {self.target} chmod 700 /root') - self.mkinitcpio('-P') + if mkinitcpio and not self.mkinitcpio(['-P']): + error('Error generating initramfs (continuing anyway)') self.helper_flags['base'] = True # Run registered post-install hooks for function in self.post_base_install: - self.log(f"Running post-installation hook: {function}", level=logging.INFO) + info(f"Running post-installation hook: {function}") function(self) for plugin in plugins.values(): if hasattr(plugin, 'on_install'): plugin.on_install(self) - return True - - def setup_swap(self, kind :str = 'zram') -> bool: + def setup_swap(self, kind: str = 'zram'): if kind == 'zram': - self.log(f"Setting up swap on zram") - self.pacstrap('zram-generator') + info(f"Setting up swap on zram") + self.pacman.strap('zram-generator') # We could use the default example below, but maybe not the best idea: https://github.com/archlinux/archinstall/pull/678#issuecomment-962124813 # zram_example_location = '/usr/share/doc/zram-generator/zram-generator.conf.example' @@ -827,224 +893,532 @@ class Installer: self.enable_service('systemd-zram-setup@zram0.service') self._zram_enabled = True - - return True else: raise ValueError(f"Archinstall currently only supports setting up swap on zram") - def add_systemd_bootloader(self, boot_partition :Partition, root_partition :Partition) -> bool: - self.pacstrap('efibootmgr') + def _get_efi_partition(self) -> Optional[disk.PartitionModification]: + for layout in self._disk_config.device_modifications: + if partition := layout.get_efi_partition(): + return partition + return None + + def _get_boot_partition(self) -> Optional[disk.PartitionModification]: + for layout in self._disk_config.device_modifications: + if boot := layout.get_boot_partition(): + return boot + return None + + def _get_root(self) -> Optional[disk.PartitionModification | disk.LvmVolume]: + if self._disk_config.lvm_config: + return self._disk_config.lvm_config.get_root_volume() + else: + for mod in self._disk_config.device_modifications: + if root := mod.get_root_partition(): + return root + return None + + def _get_luks_uuid_from_mapper_dev(self, mapper_dev_path: Path) -> str: + lsblk_info = disk.get_lsblk_info(mapper_dev_path, reverse=True, full_dev_path=True) + + if not lsblk_info.children or not lsblk_info.children[0].uuid: + raise ValueError('Unable to determine UUID of luks superblock') + + return lsblk_info.children[0].uuid + + def _get_kernel_params_partition( + self, + root_partition: disk.PartitionModification, + id_root: bool = True, + partuuid: bool = True + ) -> List[str]: + kernel_parameters = [] + + if root_partition in self._disk_encryption.partitions: + # TODO: We need to detect if the encrypted device is a whole disk encryption, + # or simply a partition encryption. Right now we assume it's a partition (and we always have) + + if self._disk_encryption and self._disk_encryption.hsm_device: + debug(f'Root partition is an encrypted device, identifying by UUID: {root_partition.uuid}') + # Note: UUID must be used, not PARTUUID for sd-encrypt to work + kernel_parameters.append(f'rd.luks.name={root_partition.uuid}=root') + # Note: tpm2-device and fido2-device don't play along very well: + # https://github.com/archlinux/archinstall/pull/1196#issuecomment-1129715645 + kernel_parameters.append('rd.luks.options=fido2-device=auto,password-echo=no') + elif partuuid: + debug(f'Root partition is an encrypted device, identifying by PARTUUID: {root_partition.partuuid}') + kernel_parameters.append(f'cryptdevice=PARTUUID={root_partition.partuuid}:root') + else: + debug(f'Root partition is an encrypted device, identifying by UUID: {root_partition.uuid}') + kernel_parameters.append(f'cryptdevice=UUID={root_partition.uuid}:root') + + if id_root: + kernel_parameters.append('root=/dev/mapper/root') + elif id_root: + if partuuid: + debug(f'Identifying root partition by PARTUUID: {root_partition.partuuid}') + kernel_parameters.append(f'root=PARTUUID={root_partition.partuuid}') + else: + debug(f'Identifying root partition by UUID: {root_partition.uuid}') + kernel_parameters.append(f'root=UUID={root_partition.uuid}') + + return kernel_parameters + + def _get_kernel_params_lvm( + self, + lvm: disk.LvmVolume + ) -> List[str]: + kernel_parameters = [] + + match self._disk_encryption.encryption_type: + case disk.EncryptionType.LvmOnLuks: + if not lvm.vg_name: + raise ValueError(f'Unable to determine VG name for {lvm.name}') + + pv_seg_info = disk.device_handler.lvm_pvseg_info(lvm.vg_name, lvm.name) - if not has_uefi(): + if not pv_seg_info: + raise ValueError(f'Unable to determine PV segment info for {lvm.vg_name}/{lvm.name}') + + uuid = self._get_luks_uuid_from_mapper_dev(pv_seg_info.pv_name) + + if self._disk_encryption.hsm_device: + debug(f'LvmOnLuks, encrypted root partition, HSM, identifying by UUID: {uuid}') + kernel_parameters.append(f'rd.luks.name={uuid}=cryptlvm root={lvm.safe_dev_path}') + else: + debug(f'LvmOnLuks, encrypted root partition, identifying by UUID: {uuid}') + kernel_parameters.append(f'cryptdevice=UUID={uuid}:cryptlvm root={lvm.safe_dev_path}') + case disk.EncryptionType.LuksOnLvm: + uuid = self._get_luks_uuid_from_mapper_dev(lvm.mapper_path) + + if self._disk_encryption.hsm_device: + debug(f'LuksOnLvm, encrypted root partition, HSM, identifying by UUID: {uuid}') + kernel_parameters.append(f'rd.luks.name={uuid}=root root=/dev/mapper/root') + else: + debug(f'LuksOnLvm, encrypted root partition, identifying by UUID: {uuid}') + kernel_parameters.append(f'cryptdevice=UUID={uuid}:root root=/dev/mapper/root') + case disk.EncryptionType.NoEncryption: + debug(f'Identifying root lvm by mapper device: {lvm.dev_path}') + kernel_parameters.append(f'root={lvm.safe_dev_path}') + + return kernel_parameters + + def _get_kernel_params( + self, + root: disk.PartitionModification | disk.LvmVolume, + id_root: bool = True, + partuuid: bool = True + ) -> List[str]: + kernel_parameters = [] + + if isinstance(root, disk.LvmVolume): + kernel_parameters = self._get_kernel_params_lvm(root) + else: + kernel_parameters = self._get_kernel_params_partition(root, id_root, partuuid) + + # Zswap should be disabled when using zram. + # https://github.com/archlinux/archinstall/issues/881 + if self._zram_enabled: + kernel_parameters.append('zswap.enabled=0') + + if id_root: + for sub_vol in root.btrfs_subvols: + if sub_vol.is_root(): + kernel_parameters.append(f'rootflags=subvol={sub_vol.name}') + break + + kernel_parameters.append('rw') + + kernel_parameters.append(f'rootfstype={root.safe_fs_type.fs_type_mount}') + kernel_parameters.extend(self._kernel_params) + + debug(f'kernel parameters: {" ".join(kernel_parameters)}') + + return kernel_parameters + + def _add_systemd_bootloader( + self, + boot_partition: disk.PartitionModification, + root: disk.PartitionModification | disk.LvmVolume, + efi_partition: Optional[disk.PartitionModification], + uki_enabled: bool = False + ): + debug('Installing systemd bootloader') + + self.pacman.strap('efibootmgr') + + if not SysInfo.has_uefi(): raise HardwareIncompatibilityError + # TODO: Ideally we would want to check if another config # points towards the same disk and/or partition. # And in which case we should do some clean up. + bootctl_options = [] + + if efi_partition and boot_partition != efi_partition: + bootctl_options.append(f'--esp-path={efi_partition.mountpoint}') + bootctl_options.append(f'--boot-path={boot_partition.mountpoint}') # Install the boot loader try: - SysCommand(f'/usr/bin/arch-chroot {self.target} bootctl --path=/boot install') + SysCommand(f"/usr/bin/arch-chroot {self.target} bootctl {' '.join(bootctl_options)} install") except SysCallError: # Fallback, try creating the boot loader without touching the EFI variables - SysCommand(f'/usr/bin/arch-chroot {self.target} bootctl --no-variables --path=/boot install') + SysCommand(f"/usr/bin/arch-chroot {self.target} bootctl --no-variables {' '.join(bootctl_options)} install") - # Ensure that the /boot/loader directory exists before we try to create files in it - if not os.path.exists(f'{self.target}/boot/loader'): - os.makedirs(f'{self.target}/boot/loader') + # Ensure that the $BOOT/loader/ directory exists before we try to create files in it. + # + # As mentioned in https://github.com/archlinux/archinstall/pull/1859 - we store the + # loader entries in $BOOT/loader/ rather than $ESP/loader/ + # The current reasoning being that $BOOT works in both use cases as well + # as being tied to the current installation. This may change. + loader_dir = self.target / 'boot/loader' + loader_dir.mkdir(parents=True, exist_ok=True) + + default_kernel = self.kernels[0] + if uki_enabled: + default_entry = f'arch-{default_kernel}.efi' + else: + entry_name = self.init_time + '_{kernel}{variant}.conf' + default_entry = entry_name.format(kernel=default_kernel, variant='') + + default = f'default {default_entry}' # Modify or create a loader.conf - if os.path.isfile(f'{self.target}/boot/loader/loader.conf'): - with open(f'{self.target}/boot/loader/loader.conf', 'r') as loader: - loader_data = loader.read().split('\n') - else: + loader_conf = loader_dir / 'loader.conf' + + try: + loader_data = loader_conf.read_text().splitlines() + except FileNotFoundError: loader_data = [ - f"default {self.init_time}", - "timeout 15" + default, + 'timeout 15' ] - - with open(f'{self.target}/boot/loader/loader.conf', 'w') as loader: - for line in loader_data: - if line[:8] == 'default ': - loader.write(f'default {self.init_time}_{self.kernels[0]}\n') - elif line[:8] == '#timeout' and 'timeout 15' not in loader_data: + else: + for index, line in enumerate(loader_data): + if line.startswith('default'): + loader_data[index] = default + elif line.startswith('#timeout'): # We add in the default timeout to support dual-boot - loader.write(f"{line[1:]}\n") - else: - loader.write(f"{line}\n") + loader_data[index] = line.removeprefix('#') + + loader_conf.write_text('\n'.join(loader_data) + '\n') - # Ensure that the /boot/loader/entries directory exists before we try to create files in it - if not os.path.exists(f'{self.target}/boot/loader/entries'): - os.makedirs(f'{self.target}/boot/loader/entries') + if uki_enabled: + return + + # Ensure that the $BOOT/loader/entries/ directory exists before we try to create files in it + entries_dir = loader_dir / 'entries' + entries_dir.mkdir(parents=True, exist_ok=True) + + comments = ( + '# Created by: archinstall', + f'# Created on: {self.init_time}' + ) + + options = 'options ' + ' '.join(self._get_kernel_params(root)) for kernel in self.kernels: for variant in ("", "-fallback"): # Setup the loader entry - with open(f'{self.target}/boot/loader/entries/{self.init_time}_{kernel}{variant}.conf', 'w') as entry: - entry.write('# Created by: archinstall\n') - entry.write(f'# Created on: {self.init_time}\n') - entry.write(f'title Arch Linux ({kernel}{variant})\n') - entry.write(f"linux /vmlinuz-{kernel}\n") - if not is_vm(): - vendor = cpu_vendor() - if vendor == "AuthenticAMD": - entry.write("initrd /amd-ucode.img\n") - elif vendor == "GenuineIntel": - entry.write("initrd /intel-ucode.img\n") - else: - self.log(f"Unknown CPU vendor '{vendor}' detected. Archinstall won't add any ucode to systemd-boot config.", level=logging.DEBUG) - entry.write(f"initrd /initramfs-{kernel}{variant}.img\n") - # blkid doesn't trigger on loopback devices really well, - # so we'll use the old manual method until we get that sorted out. - root_fs_type = get_mount_fs_type(root_partition.filesystem) - - if root_fs_type is not None: - options_entry = f'rw rootfstype={root_fs_type} {" ".join(self.KERNEL_PARAMS)}\n' - else: - options_entry = f'rw {" ".join(self.KERNEL_PARAMS)}\n' - - for subvolume in root_partition.subvolumes: - if subvolume.root is True and subvolume.name != '<FS_TREE>': - options_entry = f"rootflags=subvol={subvolume.name} " + options_entry - - # Zswap should be disabled when using zram. - # - # https://github.com/archlinux/archinstall/issues/881 - if self._zram_enabled: - options_entry = "zswap.enabled=0 " + options_entry - - if real_device := self.detect_encryption(root_partition): - # TODO: We need to detect if the encrypted device is a whole disk encryption, - # or simply a partition encryption. Right now we assume it's a partition (and we always have) - log(f"Identifying root partition by PART-UUID on {real_device}: '{real_device.uuid}/{real_device.part_uuid}'.", level=logging.DEBUG) - - kernel_options = f"options" - - if self._disk_encryption.hsm_device: - # Note: lsblk UUID must be used, not PARTUUID for sd-encrypt to work - kernel_options += f" rd.luks.name={real_device.uuid}=luksdev" - # Note: tpm2-device and fido2-device don't play along very well: - # https://github.com/archlinux/archinstall/pull/1196#issuecomment-1129715645 - kernel_options += f" rd.luks.options=fido2-device=auto,password-echo=no" - else: - kernel_options += f" cryptdevice=PARTUUID={real_device.part_uuid}:luksdev" - - entry.write(f'{kernel_options} root=/dev/mapper/luksdev {options_entry}') - - if self._disk_encryption and self._disk_encryption.hsm_device: - # Note: lsblk UUID must be used, not PARTUUID for sd-encrypt to work - kernel_options += f" rd.luks.name={real_device.uuid}=luksdev" - # Note: tpm2-device and fido2-device don't play along very well: - # https://github.com/archlinux/archinstall/pull/1196#issuecomment-1129715645 - kernel_options += f" rd.luks.options=fido2-device=auto,password-echo=no" - else: - log(f"Identifying root partition by PARTUUID on {root_partition}, looking for '{root_partition.part_uuid}'.", level=logging.DEBUG) - entry.write(f'options root=PARTUUID={root_partition.part_uuid} {options_entry}') + entry = [ + *comments, + f'title Arch Linux ({kernel}{variant})', + f'linux /vmlinuz-{kernel}', + f'initrd /initramfs-{kernel}{variant}.img', + options, + ] - self.helper_flags['bootloader'] = "systemd" + name = entry_name.format(kernel=kernel, variant=variant) + entry_conf = entries_dir / name + entry_conf.write_text('\n'.join(entry) + '\n') - return True + self.helper_flags['bootloader'] = 'systemd' - def add_grub_bootloader(self, boot_partition :Partition, root_partition :Partition) -> bool: - self.pacstrap('grub') # no need? + def _add_grub_bootloader( + self, + boot_partition: disk.PartitionModification, + root: disk.PartitionModification | disk.LvmVolume, + efi_partition: Optional[disk.PartitionModification] + ): + debug('Installing grub bootloader') - root_fs_type = get_mount_fs_type(root_partition.filesystem) + self.pacman.strap('grub') # no need? - if real_device := self.detect_encryption(root_partition): - root_uuid = SysCommand(f"blkid -s UUID -o value {real_device.path}").decode().rstrip() - _file = "/etc/default/grub" - add_to_CMDLINE_LINUX = f"sed -i 's/GRUB_CMDLINE_LINUX=\"\"/GRUB_CMDLINE_LINUX=\"cryptdevice=UUID={root_uuid}:cryptlvm rootfstype={root_fs_type}\"/'" - enable_CRYPTODISK = "sed -i 's/#GRUB_ENABLE_CRYPTODISK=y/GRUB_ENABLE_CRYPTODISK=y/'" + grub_default = self.target / 'etc/default/grub' + config = grub_default.read_text() - log(f"Using UUID {root_uuid} of {real_device} as encrypted root identifier.", level=logging.INFO) - SysCommand(f"/usr/bin/arch-chroot {self.target} {add_to_CMDLINE_LINUX} {_file}") - SysCommand(f"/usr/bin/arch-chroot {self.target} {enable_CRYPTODISK} {_file}") - else: - _file = "/etc/default/grub" - add_to_CMDLINE_LINUX = f"sed -i 's/GRUB_CMDLINE_LINUX=\"\"/GRUB_CMDLINE_LINUX=\"rootfstype={root_fs_type}\"/'" - SysCommand(f"/usr/bin/arch-chroot {self.target} {add_to_CMDLINE_LINUX} {_file}") + kernel_parameters = ' '.join(self._get_kernel_params(root, False, False)) + config = re.sub(r'(GRUB_CMDLINE_LINUX=")("\n)', rf'\1{kernel_parameters}\2', config, 1) + + grub_default.write_text(config) + + info(f"GRUB boot partition: {boot_partition.dev_path}") + + boot_dir = Path('/boot') + + command = [ + '/usr/bin/arch-chroot', + str(self.target), + 'grub-install', + '--debug' + ] + + if SysInfo.has_uefi(): + if not efi_partition: + raise ValueError('Could not detect efi partition') + + info(f"GRUB EFI partition: {efi_partition.dev_path}") + + self.pacman.strap('efibootmgr') # TODO: Do we need? Yes, but remove from minimal_installation() instead? + + boot_dir_arg = [] + if boot_partition.mountpoint and boot_partition.mountpoint != boot_dir: + boot_dir_arg.append(f'--boot-directory={boot_partition.mountpoint}') + boot_dir = boot_partition.mountpoint + + add_options = [ + '--target=x86_64-efi', + f'--efi-directory={efi_partition.mountpoint}', + *boot_dir_arg, + '--bootloader-id=GRUB', + '--removable' + ] + + command.extend(add_options) - log(f"GRUB uses {boot_partition.path} as the boot partition.", level=logging.INFO) - if has_uefi(): - self.pacstrap('efibootmgr') # TODO: Do we need? Yes, but remove from minimal_installation() instead? try: - SysCommand(f'/usr/bin/arch-chroot {self.target} grub-install --debug --target=x86_64-efi --efi-directory=/boot --bootloader-id=GRUB --removable', peek_output=True) + SysCommand(command, peek_output=True) except SysCallError: try: - SysCommand(f'/usr/bin/arch-chroot {self.target} grub-install --debug --target=x86_64-efi --efi-directory=/boot --bootloader-id=GRUB --removable', peek_output=True) - except SysCallError as error: - raise DiskError(f"Could not install GRUB to {self.target}/boot: {error}") + SysCommand(command, peek_output=True) + except SysCallError as err: + raise DiskError(f"Could not install GRUB to {self.target}{efi_partition.mountpoint}: {err}") else: + info(f"GRUB boot partition: {boot_partition.dev_path}") + + parent_dev_path = disk.device_handler.get_parent_device_path(boot_partition.safe_dev_path) + + add_options = [ + '--target=i386-pc', + '--recheck', + str(parent_dev_path) + ] + try: - SysCommand(f'/usr/bin/arch-chroot {self.target} grub-install --debug --target=i386-pc --recheck {boot_partition.parent}', peek_output=True) - except SysCallError as error: - raise DiskError(f"Could not install GRUB to {boot_partition.path}: {error}") + SysCommand(command + add_options, peek_output=True) + except SysCallError as err: + raise DiskError(f"Failed to install GRUB boot on {boot_partition.dev_path}: {err}") try: - SysCommand(f'/usr/bin/arch-chroot {self.target} grub-mkconfig -o /boot/grub/grub.cfg') - except SysCallError as error: - raise DiskError(f"Could not configure GRUB: {error}") + SysCommand( + f'/usr/bin/arch-chroot {self.target} ' + f'grub-mkconfig -o {boot_dir}/grub/grub.cfg' + ) + except SysCallError as err: + raise DiskError(f"Could not configure GRUB: {err}") self.helper_flags['bootloader'] = "grub" - return True + def _add_limine_bootloader( + self, + boot_partition: disk.PartitionModification, + efi_partition: Optional[disk.PartitionModification], + root: disk.PartitionModification | disk.LvmVolume + ): + debug('Installing limine bootloader') + + self.pacman.strap('limine') + + info(f"Limine boot partition: {boot_partition.dev_path}") + + limine_path = self.target / 'usr' / 'share' / 'limine' + hook_command = None + + if SysInfo.has_uefi(): + if not efi_partition: + raise ValueError('Could not detect efi partition') + elif not efi_partition.mountpoint: + raise ValueError('EFI partition is not mounted') + + info(f"Limine EFI partition: {efi_partition.dev_path}") + + try: + efi_dir_path = self.target / efi_partition.mountpoint.relative_to('/') / 'EFI' / 'BOOT' + efi_dir_path.mkdir(parents=True, exist_ok=True) + + for file in ('BOOTIA32.EFI', 'BOOTX64.EFI'): + shutil.copy(limine_path / file, efi_dir_path) + except Exception as err: + raise DiskError(f'Failed to install Limine in {self.target}{efi_partition.mountpoint}: {err}') + + hook_command = f'/usr/bin/cp /usr/share/limine/BOOTIA32.EFI {efi_partition.mountpoint}/EFI/BOOT/' \ + f' && /usr/bin/cp /usr/share/limine/BOOTX64.EFI {efi_partition.mountpoint}/EFI/BOOT/' + else: + parent_dev_path = disk.device_handler.get_parent_device_path(boot_partition.safe_dev_path) + + if unique_path := disk.device_handler.get_unique_path_for_device(parent_dev_path): + parent_dev_path = unique_path + + try: + # The `limine-bios.sys` file contains stage 3 code. + shutil.copy(limine_path / 'limine-bios.sys', self.target / 'boot') + + # `limine bios-install` deploys the stage 1 and 2 to the disk. + SysCommand(f'/usr/bin/arch-chroot {self.target} limine bios-install {parent_dev_path}', peek_output=True) + except Exception as err: + raise DiskError(f'Failed to install Limine on {parent_dev_path}: {err}') + + hook_command = f'/usr/bin/limine bios-install {parent_dev_path}' \ + f' && /usr/bin/cp /usr/share/limine/limine-bios.sys /boot/' + + hook_contents = f'''[Trigger] +Operation = Install +Operation = Upgrade +Type = Package +Target = limine + +[Action] +Description = Deploying Limine after upgrade... +When = PostTransaction +Exec = /bin/sh -c "{hook_command}" +''' + + hooks_dir = self.target / 'etc' / 'pacman.d' / 'hooks' + hooks_dir.mkdir(parents=True, exist_ok=True) + + hook_path = hooks_dir / '99-limine.hook' + hook_path.write_text(hook_contents) + + kernel_params = ' '.join(self._get_kernel_params(root)) + config_contents = 'TIMEOUT=5\n' + + for kernel in self.kernels: + for variant in ('', '-fallback'): + entry = [ + f'PROTOCOL=linux', + f'KERNEL_PATH=boot:///vmlinuz-{kernel}', + f'MODULE_PATH=boot:///initramfs-{kernel}{variant}.img', + f'CMDLINE={kernel_params}', + ] + + config_contents += f'\n:Arch Linux ({kernel}{variant})\n' + config_contents += '\n'.join([f' {it}' for it in entry]) + '\n' - def add_efistub_bootloader(self, boot_partition :Partition, root_partition :Partition) -> bool: - self.pacstrap('efibootmgr') + config_path = self.target / 'boot' / 'limine.cfg' + config_path.write_text(config_contents) - if not has_uefi(): + self.helper_flags['bootloader'] = "limine" + + def _add_efistub_bootloader( + self, + boot_partition: disk.PartitionModification, + root: disk.PartitionModification | disk.LvmVolume, + uki_enabled: bool = False + ): + debug('Installing efistub bootloader') + + self.pacman.strap('efibootmgr') + + if not SysInfo.has_uefi(): raise HardwareIncompatibilityError + # TODO: Ideally we would want to check if another config # points towards the same disk and/or partition. # And in which case we should do some clean up. - root_fs_type = get_mount_fs_type(root_partition.filesystem) + if not uki_enabled: + loader = '/vmlinuz-{kernel}' + + entries = ( + 'initrd=/initramfs-{kernel}.img', + *self._get_kernel_params(root) + ) + + cmdline = [' '.join(entries)] + else: + loader = '/EFI/Linux/arch-{kernel}.efi' + cmdline = [] + + parent_dev_path = disk.device_handler.get_parent_device_path(boot_partition.safe_dev_path) + + cmd_template = ( + 'efibootmgr', + '--create', + '--disk', str(parent_dev_path), + '--part', str(boot_partition.partn), + '--label', 'Arch Linux ({kernel})', + '--loader', loader, + '--unicode', *cmdline, + '--verbose' + ) for kernel in self.kernels: # Setup the firmware entry + cmd = [arg.format(kernel=kernel) for arg in cmd_template] + SysCommand(cmd) + + self.helper_flags['bootloader'] = "efistub" - label = f'Arch Linux ({kernel})' - loader = f"/vmlinuz-{kernel}" + def _config_uki( + self, + root: disk.PartitionModification | disk.LvmVolume, + efi_partition: Optional[disk.PartitionModification] + ): + if not efi_partition or not efi_partition.mountpoint: + raise ValueError(f'Could not detect ESP at mountpoint {self.target}') - kernel_parameters = [] + # Set up kernel command line + with open(self.target / 'etc/kernel/cmdline', 'w') as cmdline: + kernel_parameters = self._get_kernel_params(root) + cmdline.write(' '.join(kernel_parameters) + '\n') - if not is_vm(): - vendor = cpu_vendor() - if vendor == "AuthenticAMD": - kernel_parameters.append("initrd=\\amd-ucode.img") - elif vendor == "GenuineIntel": - kernel_parameters.append("initrd=\\intel-ucode.img") - else: - self.log(f"Unknown CPU vendor '{vendor}' detected. Archinstall won't add any ucode to firmware boot entry.", level=logging.DEBUG) + diff_mountpoint = None - kernel_parameters.append(f"initrd=\\initramfs-{kernel}.img") + if efi_partition.mountpoint != Path('/efi'): + diff_mountpoint = str(efi_partition.mountpoint) - # blkid doesn't trigger on loopback devices really well, - # so we'll use the old manual method until we get that sorted out. - if real_device := self.detect_encryption(root_partition): - # TODO: We need to detect if the encrypted device is a whole disk encryption, - # or simply a partition encryption. Right now we assume it's a partition (and we always have) - log(f"Identifying root partition by PART-UUID on {real_device}: '{real_device.part_uuid}'.", level=logging.DEBUG) - kernel_parameters.append(f'cryptdevice=PARTUUID={real_device.part_uuid}:luksdev root=/dev/mapper/luksdev rw rootfstype={root_fs_type} {" ".join(self.KERNEL_PARAMS)}') - else: - log(f"Identifying root partition by PART-UUID on {root_partition}, looking for '{root_partition.part_uuid}'.", level=logging.DEBUG) - kernel_parameters.append(f'root=PARTUUID={root_partition.part_uuid} rw rootfstype={root_fs_type} {" ".join(self.KERNEL_PARAMS)}') + image_re = re.compile('(.+_image="/([^"]+).+\n)') + uki_re = re.compile('#((.+_uki=")/[^/]+(.+\n))') - SysCommand(f'efibootmgr --disk {boot_partition.path[:-1]} --part {boot_partition.path[-1]} --create --label "{label}" --loader {loader} --unicode \'{" ".join(kernel_parameters)}\' --verbose') + # Modify .preset files + for kernel in self.kernels: + preset = self.target / 'etc/mkinitcpio.d' / (kernel + '.preset') + config = preset.read_text().splitlines(True) + + for index, line in enumerate(config): + # Avoid storing redundant image file + if m := image_re.match(line): + image = self.target / m.group(2) + image.unlink(missing_ok=True) + config[index] = '#' + m.group(1) + elif m := uki_re.match(line): + if diff_mountpoint: + config[index] = m.group(2) + diff_mountpoint + m.group(3) + else: + config[index] = m.group(1) + elif line.startswith('#default_options='): + config[index] = line.removeprefix('#') - self.helper_flags['bootloader'] = "efistub" + preset.write_text(''.join(config)) - return True + # Directory for the UKIs + uki_dir = self.target / efi_partition.relative_mountpoint / 'EFI/Linux' + uki_dir.mkdir(parents=True, exist_ok=True) - def add_bootloader(self, bootloader :str = 'systemd-bootctl') -> bool: + # Build the UKIs + if not self.mkinitcpio(['-P']): + error('Error generating initramfs (continuing anyway)') + + def add_bootloader(self, bootloader: Bootloader, uki_enabled: bool = False): """ Adds a bootloader to the installation instance. Archinstall supports one of three types: * systemd-bootctl * grub + * limine (beta) * efistub (beta) - :param bootloader: Can be one of the three strings - 'systemd-bootctl', 'grub' or 'efistub' (beta) + :param bootloader: Type of bootloader to be added """ for plugin in plugins.values(): @@ -1054,61 +1428,41 @@ class Installer: if plugin.on_add_bootloader(self): return True - if type(self.target) == str: - self.target = pathlib.Path(self.target) + efi_partition = self._get_efi_partition() + boot_partition = self._get_boot_partition() + root = self._get_root() - boot_partition = None - root_partition = None - for partition in self.partitions: - if self.target / 'boot' in partition.mountpoints: - boot_partition = partition - elif self.target in partition.mountpoints: - root_partition = partition + if boot_partition is None: + raise ValueError(f'Could not detect boot at mountpoint {self.target}') - if boot_partition is None or root_partition is None: - raise ValueError(f"Could not detect root ({root_partition}) or boot ({boot_partition}) in {self.target} based on: {self.partitions}") + if root is None: + raise ValueError(f'Could not detect root at mountpoint {self.target}') - self.log(f'Adding bootloader {bootloader} to {boot_partition if boot_partition else root_partition}', level=logging.INFO) + info(f'Adding bootloader {bootloader.value} to {boot_partition.dev_path}') - if bootloader == 'systemd-bootctl': - self.add_systemd_bootloader(boot_partition, root_partition) - elif bootloader == "grub-install": - self.add_grub_bootloader(boot_partition, root_partition) - elif bootloader == 'efistub': - self.add_efistub_bootloader(boot_partition, root_partition) - else: - raise RequirementError(f"Unknown (or not yet implemented) bootloader requested: {bootloader}") + if uki_enabled: + self._config_uki(root, efi_partition) - return True - - def add_additional_packages(self, *packages :str) -> bool: - return self.pacstrap(*packages) + match bootloader: + case Bootloader.Systemd: + self._add_systemd_bootloader(boot_partition, root, efi_partition, uki_enabled) + case Bootloader.Grub: + self._add_grub_bootloader(boot_partition, root, efi_partition) + case Bootloader.Efistub: + self._add_efistub_bootloader(boot_partition, root, uki_enabled) + case Bootloader.Limine: + self._add_limine_bootloader(boot_partition, efi_partition, root) - def install_profile(self, profile :str) -> ModuleType: - """ - Installs a archinstall profile script (.py file). - This profile can be either local, remote or part of the library. + def add_additional_packages(self, packages: Union[str, List[str]]) -> bool: + return self.pacman.strap(packages) - :param profile: Can be a local path or a remote path (URL) - :return: Returns the imported script as a module, this way - you can access any remaining functions exposed by the profile. - :rtype: module - """ - storage['installation_session'] = self - - if type(profile) == str: - profile = Profile(self, profile) - - self.log(f'Installing archinstall profile {profile}', level=logging.INFO) - return profile.install() - - def enable_sudo(self, entity: str, group :bool = False): - self.log(f'Enabling sudo permissions for {entity}.', level=logging.INFO) + def enable_sudo(self, entity: str, group: bool = False): + info(f'Enabling sudo permissions for {entity}') sudoers_dir = f"{self.target}/etc/sudoers.d" # Creates directory if not exists - if not (sudoers_path := pathlib.Path(sudoers_dir)).exists(): + if not (sudoers_path := Path(sudoers_dir)).exists(): sudoers_path.mkdir(parents=True) # Guarantees sudoer confs directory recommended perms os.chmod(sudoers_dir, 0o440) @@ -1118,7 +1472,7 @@ class Installer: # We count how many files are there already so we know which number to prefix the file with num_of_rules_already = len(os.listdir(sudoers_dir)) - file_num_str = "{:02d}".format(num_of_rules_already) # We want 00_user1, 01_user2, etc + file_num_str = "{:02d}".format(num_of_rules_already) # We want 00_user1, 01_user2, etc # Guarantees that entity str does not contain invalid characters for a linux file name: # \ / : * ? " < > | @@ -1130,7 +1484,7 @@ class Installer: sudoers.write(f'{"%" if group else ""}{entity} ALL=(ALL) ALL\n') # Guarantees sudoer conf file recommended perms - os.chmod(pathlib.Path(rule_file_name), 0o440) + os.chmod(Path(rule_file_name), 0o440) def create_users(self, users: Union[User, List[User]]): if not isinstance(users, list): @@ -1139,7 +1493,8 @@ class Installer: for user in users: self.user_create(user.username, user.password, user.groups, user.sudo) - def user_create(self, user :str, password :Optional[str] = None, groups :Optional[List[str]] = None, sudo :bool = False) -> None: + def user_create(self, user: str, password: Optional[str] = None, groups: Optional[List[str]] = None, + sudo: bool = False) -> None: if groups is None: groups = [] @@ -1152,11 +1507,11 @@ class Installer: handled_by_plugin = result if not handled_by_plugin: - self.log(f'Creating user {user}', level=logging.INFO) + info(f'Creating user {user}') try: SysCommand(f'/usr/bin/arch-chroot {self.target} useradd -m -G wheel {user}') - except SysCallError as error: - raise SystemError(f"Could not create user inside installation: {error}") + except SysCallError as err: + raise SystemError(f"Could not create user inside installation: {err}") for plugin in plugins.values(): if hasattr(plugin, 'on_user_created'): @@ -1173,8 +1528,8 @@ class Installer: if sudo and self.enable_sudo(user): self.helper_flags['user'] = True - def user_set_pw(self, user :str, password :str) -> bool: - self.log(f'Setting password for {user}', level=logging.INFO) + def user_set_pw(self, user: str, password: str) -> bool: + info(f'Setting password for {user}') if user == 'root': # This means the root account isn't locked/disabled with * in /etc/passwd @@ -1190,8 +1545,8 @@ class Installer: except SysCallError: return False - def user_set_shell(self, user :str, shell :str) -> bool: - self.log(f'Setting shell for {user} to {shell}', level=logging.INFO) + def user_set_shell(self, user: str, shell: str) -> bool: + info(f'Setting shell for {user} to {shell}') try: SysCommand(f"/usr/bin/arch-chroot {self.target} sh -c \"chsh -s {shell} {user}\"") @@ -1199,7 +1554,7 @@ class Installer: except SysCallError: return False - def chown(self, owner :str, path :str, options :List[str] = []) -> bool: + def chown(self, owner: str, path: str, options: List[str] = []) -> bool: cleaned_path = path.replace('\'', '\\\'') try: SysCommand(f"/usr/bin/arch-chroot {self.target} sh -c 'chown {' '.join(options)} {owner} {cleaned_path}'") @@ -1207,53 +1562,75 @@ class Installer: except SysCallError: return False - def create_file(self, filename :str, owner :Optional[str] = None) -> InstallationFile: - return InstallationFile(self, filename, owner) - def set_keyboard_language(self, language: str) -> bool: - log(f"Setting keyboard language to {language}", level=logging.INFO) + info(f"Setting keyboard language to {language}") + if len(language.strip()): if not verify_keyboard_layout(language): - self.log(f"Invalid keyboard language specified: {language}", fg="red", level=logging.ERROR) + error(f"Invalid keyboard language specified: {language}") return False # In accordance with https://github.com/archlinux/archinstall/issues/107#issuecomment-841701968 # Setting an empty keymap first, allows the subsequent call to set layout for both console and x11. - from .systemd import Boot + from .boot import Boot with Boot(self) as session: os.system('/usr/bin/systemd-run --machine=archinstall --pty localectl set-keymap ""') try: session.SysCommand(["localectl", "set-keymap", language]) - except SysCallError as error: - raise ServiceException(f"Unable to set locale '{language}' for console: {error}") + except SysCallError as err: + raise ServiceException(f"Unable to set locale '{language}' for console: {err}") - self.log(f"Keyboard language for this installation is now set to: {language}") + info(f"Keyboard language for this installation is now set to: {language}") else: - self.log('Keyboard language was not changed from default (no language specified).', fg="yellow", level=logging.INFO) + info('Keyboard language was not changed from default (no language specified)') return True def set_x11_keyboard_language(self, language: str) -> bool: - log(f"Setting x11 keyboard language to {language}", level=logging.INFO) """ A fallback function to set x11 layout specifically and separately from console layout. This isn't strictly necessary since .set_keyboard_language() does this as well. """ + info(f"Setting x11 keyboard language to {language}") + if len(language.strip()): if not verify_x11_keyboard_layout(language): - self.log(f"Invalid x11-keyboard language specified: {language}", fg="red", level=logging.ERROR) + error(f"Invalid x11-keyboard language specified: {language}") return False - from .systemd import Boot + from .boot import Boot with Boot(self) as session: session.SysCommand(["localectl", "set-x11-keymap", '""']) try: session.SysCommand(["localectl", "set-x11-keymap", language]) - except SysCallError as error: - raise ServiceException(f"Unable to set locale '{language}' for X11: {error}") + except SysCallError as err: + raise ServiceException(f"Unable to set locale '{language}' for X11: {err}") else: - self.log(f'X11-Keyboard language was not changed from default (no language specified).', fg="yellow", level=logging.INFO) + info(f'X11-Keyboard language was not changed from default (no language specified)') return True + + def _service_started(self, service_name: str) -> Optional[str]: + if os.path.splitext(service_name)[1] not in ('.service', '.target', '.timer'): + service_name += '.service' # Just to be safe + + last_execution_time = SysCommand( + f"systemctl show --property=ActiveEnterTimestamp --no-pager {service_name}", + environment_vars={'SYSTEMD_COLORS': '0'} + ).decode().lstrip('ActiveEnterTimestamp=') + + if not last_execution_time: + return None + + return last_execution_time + + def _service_state(self, service_name: str) -> str: + if os.path.splitext(service_name)[1] not in ('.service', '.target', '.timer'): + service_name += '.service' # Just to be safe + + return SysCommand( + f'systemctl show --no-pager -p SubState --value {service_name}', + environment_vars={'SYSTEMD_COLORS': '0'} + ).decode() diff --git a/archinstall/lib/interactions/__init__.py b/archinstall/lib/interactions/__init__.py new file mode 100644 index 00000000..4b696a78 --- /dev/null +++ b/archinstall/lib/interactions/__init__.py @@ -0,0 +1,19 @@ +from .manage_users_conf import UserList, ask_for_additional_users +from .network_menu import ManualNetworkConfig, ask_to_configure_network +from .utils import get_password + +from .disk_conf import ( + select_devices, select_disk_config, get_default_partition_layout, + select_main_filesystem_format, suggest_single_disk_layout, + suggest_multi_disk_layout +) + +from .general_conf import ( + ask_ntp, ask_hostname, ask_for_a_timezone, ask_for_audio_selection, + select_archinstall_language, ask_additional_packages_to_install, + add_number_of_parallel_downloads, select_additional_repositories +) + +from .system_conf import ( + select_kernel, ask_for_bootloader, ask_for_uki, select_driver, ask_for_swap +) diff --git a/archinstall/lib/interactions/disk_conf.py b/archinstall/lib/interactions/disk_conf.py new file mode 100644 index 00000000..4fce4fe5 --- /dev/null +++ b/archinstall/lib/interactions/disk_conf.py @@ -0,0 +1,572 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any, TYPE_CHECKING +from typing import Optional, List, Tuple + +from .. import disk +from ..disk.device_model import BtrfsMountOption +from ..hardware import SysInfo +from ..menu import Menu +from ..menu import TableMenu +from ..menu.menu import MenuSelectionType +from ..output import FormattedOutput, debug +from ..utils.util import prompt_dir +from ..storage import storage + +if TYPE_CHECKING: + _: Any + + +def select_devices(preset: List[disk.BDevice] = []) -> List[disk.BDevice]: + """ + Asks the user to select one or multiple devices + + :return: List of selected devices + :rtype: list + """ + + def _preview_device_selection(selection: disk._DeviceInfo) -> Optional[str]: + dev = disk.device_handler.get_device(selection.path) + if dev and dev.partition_infos: + return FormattedOutput.as_table(dev.partition_infos) + return None + + if preset is None: + preset = [] + + title = str(_('Select one or more devices to use and configure')) + warning = str(_('If you reset the device selection this will also reset the current disk layout. Are you sure?')) + + devices = disk.device_handler.devices + options = [d.device_info for d in devices] + preset_value = [p.device_info for p in preset] + + choice = TableMenu( + title, + data=options, + multi=True, + preset=preset_value, + preview_command=_preview_device_selection, + preview_title=str(_('Existing Partitions')), + preview_size=0.2, + allow_reset=True, + allow_reset_warning_msg=warning + ).run() + + match choice.type_: + case MenuSelectionType.Reset: return [] + case MenuSelectionType.Skip: return preset + case MenuSelectionType.Selection: + selected_device_info: List[disk._DeviceInfo] = choice.single_value + selected_devices = [] + + for device in devices: + if device.device_info in selected_device_info: + selected_devices.append(device) + + return selected_devices + + +def get_default_partition_layout( + devices: List[disk.BDevice], + filesystem_type: Optional[disk.FilesystemType] = None, + advanced_option: bool = False +) -> List[disk.DeviceModification]: + if len(devices) == 1: + device_modification = suggest_single_disk_layout( + devices[0], + filesystem_type=filesystem_type, + advanced_options=advanced_option + ) + return [device_modification] + else: + return suggest_multi_disk_layout( + devices, + filesystem_type=filesystem_type, + advanced_options=advanced_option + ) + + +def _manual_partitioning( + preset: List[disk.DeviceModification], + devices: List[disk.BDevice] +) -> List[disk.DeviceModification]: + modifications = [] + for device in devices: + mod = next(filter(lambda x: x.device == device, preset), None) + if not mod: + mod = disk.DeviceModification(device, wipe=False) + + if partitions := disk.manual_partitioning(device, preset=mod.partitions): + mod.partitions = partitions + modifications.append(mod) + + return modifications + + +def select_disk_config( + preset: Optional[disk.DiskLayoutConfiguration] = None, + advanced_option: bool = False +) -> Optional[disk.DiskLayoutConfiguration]: + default_layout = disk.DiskLayoutType.Default.display_msg() + manual_mode = disk.DiskLayoutType.Manual.display_msg() + pre_mount_mode = disk.DiskLayoutType.Pre_mount.display_msg() + + options = [default_layout, manual_mode, pre_mount_mode] + preset_value = preset.config_type.display_msg() if preset else None + warning = str(_('Are you sure you want to reset this setting?')) + + choice = Menu( + _('Select a partitioning option'), + options, + allow_reset=True, + allow_reset_warning_msg=warning, + sort=False, + preview_size=0.2, + preset_values=preset_value + ).run() + + match choice.type_: + case MenuSelectionType.Skip: return preset + case MenuSelectionType.Reset: return None + case MenuSelectionType.Selection: + if choice.single_value == pre_mount_mode: + output = 'You will use whatever drive-setup is mounted at the specified directory\n' + output += "WARNING: Archinstall won't check the suitability of this setup\n" + + try: + path = prompt_dir(str(_('Enter the root directory of the mounted devices: ')), output) + except (KeyboardInterrupt, EOFError): + return preset + mods = disk.device_handler.detect_pre_mounted_mods(path) + + storage['MOUNT_POINT'] = Path(path) + + return disk.DiskLayoutConfiguration( + config_type=disk.DiskLayoutType.Pre_mount, + device_modifications=mods, + mountpoint=path + ) + + preset_devices = [mod.device for mod in preset.device_modifications] if preset else [] + devices = select_devices(preset_devices) + + if not devices: + return None + + if choice.value == default_layout: + modifications = get_default_partition_layout(devices, advanced_option=advanced_option) + if modifications: + return disk.DiskLayoutConfiguration( + config_type=disk.DiskLayoutType.Default, + device_modifications=modifications + ) + elif choice.value == manual_mode: + preset_mods = preset.device_modifications if preset else [] + modifications = _manual_partitioning(preset_mods, devices) + + if modifications: + return disk.DiskLayoutConfiguration( + config_type=disk.DiskLayoutType.Manual, + device_modifications=modifications + ) + + return None + + +def select_lvm_config( + disk_config: disk.DiskLayoutConfiguration, + preset: Optional[disk.LvmConfiguration] = None, +) -> Optional[disk.LvmConfiguration]: + default_mode = disk.LvmLayoutType.Default.display_msg() + + options = [default_mode] + + preset_value = preset.config_type.display_msg() if preset else None + warning = str(_('Are you sure you want to reset this setting?')) + + choice = Menu( + _('Select a LVM option'), + options, + allow_reset=True, + allow_reset_warning_msg=warning, + sort=False, + preview_size=0.2, + preset_values=preset_value + ).run() + + match choice.type_: + case MenuSelectionType.Skip: return preset + case MenuSelectionType.Reset: return None + case MenuSelectionType.Selection: + if choice.single_value == default_mode: + return suggest_lvm_layout(disk_config) + return preset + + +def _boot_partition(sector_size: disk.SectorSize, using_gpt: bool) -> disk.PartitionModification: + flags = [disk.PartitionFlag.Boot] + if using_gpt: + start = disk.Size(1, disk.Unit.MiB, sector_size) + size = disk.Size(1, disk.Unit.GiB, sector_size) + flags.append(disk.PartitionFlag.ESP) + else: + start = disk.Size(3, disk.Unit.MiB, sector_size) + size = disk.Size(203, disk.Unit.MiB, sector_size) + + # boot partition + return disk.PartitionModification( + status=disk.ModificationStatus.Create, + type=disk.PartitionType.Primary, + start=start, + length=size, + mountpoint=Path('/boot'), + fs_type=disk.FilesystemType.Fat32, + flags=flags + ) + + +def select_main_filesystem_format(advanced_options: bool = False) -> disk.FilesystemType: + options = { + 'btrfs': disk.FilesystemType.Btrfs, + 'ext4': disk.FilesystemType.Ext4, + 'xfs': disk.FilesystemType.Xfs, + 'f2fs': disk.FilesystemType.F2fs + } + + if advanced_options: + options.update({'ntfs': disk.FilesystemType.Ntfs}) + + prompt = _('Select which filesystem your main partition should use') + choice = Menu(prompt, options, skip=False, sort=False).run() + return options[choice.single_value] + + +def select_mount_options() -> List[str]: + prompt = str(_('Would you like to use compression or disable CoW?')) + options = [str(_('Use compression')), str(_('Disable Copy-on-Write'))] + choice = Menu(prompt, options, sort=False).run() + + if choice.type_ == MenuSelectionType.Selection: + if choice.single_value == options[0]: + return [BtrfsMountOption.compress.value] + else: + return [BtrfsMountOption.nodatacow.value] + + return [] + + +def process_root_partition_size(available_space: disk.Size, sector_size: disk.SectorSize) -> disk.Size: + # root partition size processing + total_device_size = available_space.convert(disk.Unit.GiB) + if total_device_size.value > 500: + # maximum size + return disk.Size(value=50, unit=disk.Unit.GiB, sector_size=sector_size) + elif total_device_size.value < 200: + # minimum size + return disk.Size(value=20, unit=disk.Unit.GiB, sector_size=sector_size) + else: + # 10% of total size + length = total_device_size.value // 10 + return disk.Size(value=length, unit=disk.Unit.GiB, sector_size=sector_size) + + +def suggest_single_disk_layout( + device: disk.BDevice, + filesystem_type: Optional[disk.FilesystemType] = None, + advanced_options: bool = False, + separate_home: Optional[bool] = None +) -> disk.DeviceModification: + if not filesystem_type: + filesystem_type = select_main_filesystem_format(advanced_options) + + sector_size = device.device_info.sector_size + total_size = device.device_info.total_size + min_size_to_allow_home_part = disk.Size(40, disk.Unit.GiB, sector_size) + root_partition_size = process_root_partition_size(available_space=total_size, sector_size=sector_size) + using_subvolumes = False + using_home_partition = False + mount_options = [] + device_size_gib = device.device_info.total_size + + if filesystem_type == disk.FilesystemType.Btrfs: + prompt = str(_('Would you like to use BTRFS subvolumes with a default structure?')) + choice = Menu(prompt, Menu.yes_no(), skip=False, default_option=Menu.yes()).run() + using_subvolumes = choice.value == Menu.yes() + mount_options = select_mount_options() + + device_modification = disk.DeviceModification(device, wipe=True) + + using_gpt = SysInfo.has_uefi() + + # Used for reference: https://wiki.archlinux.org/title/partitioning + # 2 MiB is unallocated for GRUB on BIOS. Potentially unneeded for other bootloaders? + + # TODO: On BIOS, /boot partition is only needed if the drive will + # be encrypted, otherwise it is not recommended. We should probably + # add a check for whether the drive will be encrypted or not. + + # Increase the UEFI partition if UEFI is detected. + # Also re-align the start to 1MiB since we don't need the first sectors + # like we do in MBR layouts where the boot loader is installed traditionally. + + boot_partition = _boot_partition(sector_size, using_gpt) + device_modification.add_partition(boot_partition) + + if not using_subvolumes: + if device_size_gib >= min_size_to_allow_home_part: + if separate_home is None: + prompt = str(_('Would you like to create a separate partition for /home?')) + choice = Menu(prompt, Menu.yes_no(), skip=False, default_option=Menu.yes()).run() + using_home_partition = choice.value == Menu.yes() + elif separate_home is True: + using_home_partition = True + else: + using_home_partition = False + + align_buffer = disk.Size(1, disk.Unit.MiB, sector_size) + + # root partition + root_start = boot_partition.start + boot_partition.length + + # Set a size for / (/root) + if using_subvolumes or device_size_gib < min_size_to_allow_home_part or not using_home_partition: + root_length = total_size - root_start + else: + root_length = min(total_size, root_partition_size) + + if using_gpt and not using_home_partition: + root_length -= align_buffer + + root_partition = disk.PartitionModification( + status=disk.ModificationStatus.Create, + type=disk.PartitionType.Primary, + start=root_start, + length=root_length, + mountpoint=Path('/') if not using_subvolumes else None, + fs_type=filesystem_type, + mount_options=mount_options + ) + + device_modification.add_partition(root_partition) + + if using_subvolumes: + # https://btrfs.wiki.kernel.org/index.php/FAQ + # https://unix.stackexchange.com/questions/246976/btrfs-subvolume-uuid-clash + # https://github.com/classy-giraffe/easy-arch/blob/main/easy-arch.sh + subvolumes = [ + disk.SubvolumeModification(Path('@'), Path('/')), + disk.SubvolumeModification(Path('@home'), Path('/home')), + disk.SubvolumeModification(Path('@log'), Path('/var/log')), + disk.SubvolumeModification(Path('@pkg'), Path('/var/cache/pacman/pkg')), + disk.SubvolumeModification(Path('@.snapshots'), Path('/.snapshots')) + ] + root_partition.btrfs_subvols = subvolumes + elif using_home_partition: + # If we don't want to use subvolumes, + # But we want to be able to reuse data between re-installs.. + # A second partition for /home would be nice if we have the space for it + home_start = root_partition.start + root_partition.length + home_length = total_size - home_start + + if using_gpt: + home_length -= align_buffer + + home_partition = disk.PartitionModification( + status=disk.ModificationStatus.Create, + type=disk.PartitionType.Primary, + start=home_start, + length=home_length, + mountpoint=Path('/home'), + fs_type=filesystem_type, + mount_options=mount_options + ) + device_modification.add_partition(home_partition) + + return device_modification + + +def suggest_multi_disk_layout( + devices: List[disk.BDevice], + filesystem_type: Optional[disk.FilesystemType] = None, + advanced_options: bool = False +) -> List[disk.DeviceModification]: + if not devices: + return [] + + # Not really a rock solid foundation of information to stand on, but it's a start: + # https://www.reddit.com/r/btrfs/comments/m287gp/partition_strategy_for_two_physical_disks/ + # https://www.reddit.com/r/btrfs/comments/9us4hr/what_is_your_btrfs_partitionsubvolumes_scheme/ + min_home_partition_size = disk.Size(40, disk.Unit.GiB, disk.SectorSize.default()) + # rough estimate taking in to account user desktops etc. TODO: Catch user packages to detect size? + desired_root_partition_size = disk.Size(20, disk.Unit.GiB, disk.SectorSize.default()) + mount_options = [] + + if not filesystem_type: + filesystem_type = select_main_filesystem_format(advanced_options) + + # find proper disk for /home + possible_devices = list(filter(lambda x: x.device_info.total_size >= min_home_partition_size, devices)) + home_device = max(possible_devices, key=lambda d: d.device_info.total_size) if possible_devices else None + + # find proper device for /root + devices_delta = {} + for device in devices: + if device is not home_device: + delta = device.device_info.total_size - desired_root_partition_size + devices_delta[device] = delta + + sorted_delta: List[Tuple[disk.BDevice, Any]] = sorted(devices_delta.items(), key=lambda x: x[1]) + root_device: Optional[disk.BDevice] = sorted_delta[0][0] + + if home_device is None or root_device is None: + text = _('The selected drives do not have the minimum capacity required for an automatic suggestion\n') + text += _('Minimum capacity for /home partition: {}GiB\n').format(min_home_partition_size.format_size(disk.Unit.GiB)) + text += _('Minimum capacity for Arch Linux partition: {}GiB').format(desired_root_partition_size.format_size(disk.Unit.GiB)) + Menu(str(text), [str(_('Continue'))], skip=False).run() + return [] + + if filesystem_type == disk.FilesystemType.Btrfs: + mount_options = select_mount_options() + + device_paths = ', '.join([str(d.device_info.path) for d in devices]) + + debug(f'Suggesting multi-disk-layout for devices: {device_paths}') + debug(f'/root: {root_device.device_info.path}') + debug(f'/home: {home_device.device_info.path}') + + root_device_modification = disk.DeviceModification(root_device, wipe=True) + home_device_modification = disk.DeviceModification(home_device, wipe=True) + + root_device_sector_size = root_device_modification.device.device_info.sector_size + home_device_sector_size = home_device_modification.device.device_info.sector_size + + root_align_buffer = disk.Size(1, disk.Unit.MiB, root_device_sector_size) + home_align_buffer = disk.Size(1, disk.Unit.MiB, home_device_sector_size) + + using_gpt = SysInfo.has_uefi() + + # add boot partition to the root device + boot_partition = _boot_partition(root_device_sector_size, using_gpt) + root_device_modification.add_partition(boot_partition) + + root_start = boot_partition.start + boot_partition.length + root_length = root_device.device_info.total_size - root_start + + if using_gpt: + root_length -= root_align_buffer + + # add root partition to the root device + root_partition = disk.PartitionModification( + status=disk.ModificationStatus.Create, + type=disk.PartitionType.Primary, + start=root_start, + length=root_length, + mountpoint=Path('/'), + mount_options=mount_options, + fs_type=filesystem_type + ) + root_device_modification.add_partition(root_partition) + + home_start = home_align_buffer + home_length = home_device.device_info.total_size - home_start + + if using_gpt: + home_length -= home_align_buffer + + # add home partition to home device + home_partition = disk.PartitionModification( + status=disk.ModificationStatus.Create, + type=disk.PartitionType.Primary, + start=home_start, + length=home_length, + mountpoint=Path('/home'), + mount_options=mount_options, + fs_type=filesystem_type, + ) + home_device_modification.add_partition(home_partition) + + return [root_device_modification, home_device_modification] + + +def suggest_lvm_layout( + disk_config: disk.DiskLayoutConfiguration, + filesystem_type: Optional[disk.FilesystemType] = None, + vg_grp_name: str = 'ArchinstallVg', +) -> disk.LvmConfiguration: + if disk_config.config_type != disk.DiskLayoutType.Default: + raise ValueError('LVM suggested volumes are only available for default partitioning') + + using_subvolumes = False + btrfs_subvols = [] + home_volume = True + mount_options = [] + + if not filesystem_type: + filesystem_type = select_main_filesystem_format() + + if filesystem_type == disk.FilesystemType.Btrfs: + prompt = str(_('Would you like to use BTRFS subvolumes with a default structure?')) + choice = Menu(prompt, Menu.yes_no(), skip=False, default_option=Menu.yes()).run() + using_subvolumes = choice.value == Menu.yes() + + mount_options = select_mount_options() + + if using_subvolumes: + btrfs_subvols = [ + disk.SubvolumeModification(Path('@'), Path('/')), + disk.SubvolumeModification(Path('@home'), Path('/home')), + disk.SubvolumeModification(Path('@log'), Path('/var/log')), + disk.SubvolumeModification(Path('@pkg'), Path('/var/cache/pacman/pkg')), + disk.SubvolumeModification(Path('@.snapshots'), Path('/.snapshots')), + ] + + home_volume = False + + boot_part: Optional[disk.PartitionModification] = None + other_part: List[disk.PartitionModification] = [] + + for mod in disk_config.device_modifications: + for part in mod.partitions: + if part.is_boot(): + boot_part = part + else: + other_part.append(part) + + if not boot_part: + raise ValueError('Unable to find boot partition in partition modifications') + + total_vol_available = sum( + [p.length for p in other_part], + disk.Size(0, disk.Unit.B, disk.SectorSize.default()), + ) + root_vol_size = disk.Size(20, disk.Unit.GiB, disk.SectorSize.default()) + home_vol_size = total_vol_available - root_vol_size + + lvm_vol_group = disk.LvmVolumeGroup(vg_grp_name, pvs=other_part, ) + + root_vol = disk.LvmVolume( + status=disk.LvmVolumeStatus.Create, + name='root', + fs_type=filesystem_type, + length=root_vol_size, + mountpoint=Path('/'), + btrfs_subvols=btrfs_subvols, + mount_options=mount_options + ) + + lvm_vol_group.volumes.append(root_vol) + + if home_volume: + home_vol = disk.LvmVolume( + status=disk.LvmVolumeStatus.Create, + name='home', + fs_type=filesystem_type, + length=home_vol_size, + mountpoint=Path('/home'), + ) + + lvm_vol_group.volumes.append(home_vol) + + return disk.LvmConfiguration(disk.LvmLayoutType.Default, [lvm_vol_group]) diff --git a/archinstall/lib/interactions/general_conf.py b/archinstall/lib/interactions/general_conf.py new file mode 100644 index 00000000..a879552e --- /dev/null +++ b/archinstall/lib/interactions/general_conf.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +import pathlib +from typing import List, Any, Optional, TYPE_CHECKING + +from ..locale import list_timezones +from ..menu import MenuSelectionType, Menu, TextInput +from ..models.audio_configuration import Audio, AudioConfiguration +from ..output import warn +from ..packages.packages import validate_package_list +from ..storage import storage +from ..translationhandler import Language + +if TYPE_CHECKING: + _: Any + + +def ask_ntp(preset: bool = True) -> bool: + prompt = str(_('Would you like to use automatic time synchronization (NTP) with the default time servers?\n')) + prompt += str(_('Hardware time and other post-configuration steps might be required in order for NTP to work.\nFor more information, please check the Arch wiki')) + if preset: + preset_val = Menu.yes() + else: + preset_val = Menu.no() + choice = Menu(prompt, Menu.yes_no(), skip=False, preset_values=preset_val, default_option=Menu.yes()).run() + + return False if choice.value == Menu.no() else True + + +def ask_hostname(preset: str = '') -> str: + hostname = TextInput( + str(_('Desired hostname for the installation: ')), + preset + ).run().strip() + + if not hostname: + return preset + + return hostname + + +def ask_for_a_timezone(preset: Optional[str] = None) -> Optional[str]: + timezones = list_timezones() + default = 'UTC' + + choice = Menu( + _('Select a timezone'), + timezones, + preset_values=preset, + default_option=default + ).run() + + match choice.type_: + case MenuSelectionType.Skip: return preset + case MenuSelectionType.Selection: return choice.single_value + + return None + + +def ask_for_audio_selection( + current: Optional[AudioConfiguration] = None +) -> Optional[AudioConfiguration]: + choices = [ + Audio.Pipewire.name, + Audio.Pulseaudio.name, + Audio.no_audio_text() + ] + + preset = current.audio.name if current else None + + choice = Menu( + _('Choose an audio server'), + choices, + preset_values=preset + ).run() + + match choice.type_: + case MenuSelectionType.Skip: return current + case MenuSelectionType.Selection: + value = choice.single_value + if value == Audio.no_audio_text(): + return None + else: + return AudioConfiguration(Audio[value]) + + return None + + +def select_language(preset: Optional[str] = None) -> Optional[str]: + from ..locale.locale_menu import select_kb_layout + + # We'll raise an exception in an upcoming version. + # from ..exceptions import Deprecated + # raise Deprecated("select_language() has been deprecated, use select_kb_layout() instead.") + + # No need to translate this i feel, as it's a short lived message. + warn("select_language() is deprecated, use select_kb_layout() instead. select_language() will be removed in a future version") + + return select_kb_layout(preset) + + +def select_archinstall_language(languages: List[Language], preset: Language) -> Language: + # these are the displayed language names which can either be + # the english name of a language or, if present, the + # name of the language in its own language + options = {lang.display_name: lang for lang in languages} + + title = 'NOTE: If a language can not displayed properly, a proper font must be set manually in the console.\n' + title += 'All available fonts can be found in "/usr/share/kbd/consolefonts"\n' + title += 'e.g. setfont LatGrkCyr-8x16 (to display latin/greek/cyrillic characters)\n' + + choice = Menu( + title, + list(options.keys()), + default_option=preset.display_name, + preview_size=0.5 + ).run() + + match choice.type_: + case MenuSelectionType.Skip: return preset + case MenuSelectionType.Selection: return options[choice.single_value] + + raise ValueError('Language selection not handled') + + +def ask_additional_packages_to_install(preset: List[str] = []) -> List[str]: + # Additional packages (with some light weight error handling for invalid package names) + print(_('Only packages such as base, base-devel, linux, linux-firmware, efibootmgr and optional profile packages are installed.')) + print(_('If you desire a web browser, such as firefox or chromium, you may specify it in the following prompt.')) + + def read_packages(p: List = []) -> list: + display = ' '.join(p) + input_packages = TextInput(_('Write additional packages to install (space separated, leave blank to skip): '), display).run().strip() + return input_packages.split() if input_packages else [] + + preset = preset if preset else [] + packages = read_packages(preset) + + if not storage['arguments']['offline'] and not storage['arguments']['no_pkg_lookups']: + while True: + if len(packages): + # Verify packages that were given + print(_("Verifying that additional packages exist (this might take a few seconds)")) + valid, invalid = validate_package_list(packages) + + if invalid: + warn(f"Some packages could not be found in the repository: {invalid}") + packages = read_packages(valid) + continue + break + + return packages + + +def add_number_of_parallel_downloads(input_number :Optional[int] = None) -> Optional[int]: + max_recommended = 5 + print(_(f"This option enables the number of parallel downloads that can occur during package downloads")) + print(_("Enter the number of parallel downloads to be enabled.\n\nNote:\n")) + print(str(_(" - Maximum recommended value : {} ( Allows {} parallel downloads at a time )")).format(max_recommended, max_recommended)) + print(_(" - Disable/Default : 0 ( Disables parallel downloading, allows only 1 download at a time )\n")) + + while True: + try: + input_number = int(TextInput(_("[Default value: 0] > ")).run().strip() or 0) + if input_number <= 0: + input_number = 0 + break + except: + print(str(_("Invalid input! Try again with a valid input [or 0 to disable]")).format(max_recommended)) + + pacman_conf_path = pathlib.Path("/etc/pacman.conf") + with pacman_conf_path.open() as f: + pacman_conf = f.read().split("\n") + + with pacman_conf_path.open("w") as fwrite: + for line in pacman_conf: + if "ParallelDownloads" in line: + fwrite.write(f"ParallelDownloads = {input_number}\n") if not input_number == 0 else fwrite.write("#ParallelDownloads = 0\n") + else: + fwrite.write(f"{line}\n") + + return input_number + + +def select_additional_repositories(preset: List[str]) -> List[str]: + """ + Allows the user to select additional repositories (multilib, and testing) if desired. + + :return: The string as a selected repository + :rtype: string + """ + + repositories = ["multilib", "testing"] + + choice = Menu( + _('Choose which optional additional repositories to enable'), + repositories, + sort=False, + multi=True, + preset_values=preset, + allow_reset=True + ).run() + + match choice.type_: + case MenuSelectionType.Skip: return preset + case MenuSelectionType.Reset: return [] + case MenuSelectionType.Selection: return choice.single_value + + return []
\ No newline at end of file diff --git a/archinstall/lib/user_interaction/manage_users_conf.py b/archinstall/lib/interactions/manage_users_conf.py index 84ce3556..886f85b6 100644 --- a/archinstall/lib/user_interaction/manage_users_conf.py +++ b/archinstall/lib/interactions/manage_users_conf.py @@ -1,13 +1,11 @@ from __future__ import annotations import re -from typing import Any, Dict, TYPE_CHECKING, List, Optional +from typing import Any, TYPE_CHECKING, List, Optional from .utils import get_password -from ..menu import Menu -from ..menu.list_manager import ListManager +from ..menu import Menu, ListManager from ..models.users import User -from ..output import FormattedOutput if TYPE_CHECKING: _: Any @@ -27,21 +25,6 @@ class UserList(ListManager): ] super().__init__(prompt, lusers, [self._actions[0]], self._actions[1:]) - def reformat(self, data: List[User]) -> Dict[str, User]: - table = FormattedOutput.as_table(data) - rows = table.split('\n') - - # these are the header rows of the table and do not map to any User obviously - # we're adding 2 spaces as prefix because the menu selector '> ' will be put before - # the selectable rows so the header has to be aligned - display_data = {f' {rows[0]}': None, f' {rows[1]}': None} - - for row, user in zip(rows[2:], data): - row = row.replace('|', '\\|') - display_data[row] = user - - return display_data - def selected_action_display(self, user: User) -> str: return user.username @@ -53,16 +36,16 @@ class UserList(ListManager): # was created we'll replace the existing one data = [d for d in data if d.username != new_user.username] data += [new_user] - elif action == self._actions[1]: # change password + elif action == self._actions[1] and entry: # change password prompt = str(_('Password for user "{}": ').format(entry.username)) new_password = get_password(prompt=prompt) if new_password: user = next(filter(lambda x: x == entry, data)) user.password = new_password - elif action == self._actions[2]: # promote/demote + elif action == self._actions[2] and entry: # promote/demote user = next(filter(lambda x: x == entry, data)) user.sudo = False if user.sudo else True - elif action == self._actions[3]: # delete + elif action == self._actions[3] and entry: # delete data = [d for d in data if d != entry] return data @@ -76,20 +59,28 @@ class UserList(ListManager): prompt = '\n\n' + str(_('Enter username (leave blank to skip): ')) while True: - username = input(prompt).strip(' ') + try: + username = input(prompt).strip(' ') + except (KeyboardInterrupt, EOFError): + return None + if not username: return None if not self._check_for_correct_username(username): - prompt = str(_("The username you entered is invalid. Try again")) + '\n' + prompt + error_prompt = str(_("The username you entered is invalid. Try again")) + print(error_prompt) else: break password = get_password(prompt=str(_('Password for user "{}": ').format(username))) + if not password: + return None + choice = Menu( str(_('Should "{}" be a superuser (sudo)?')).format(username), Menu.yes_no(), skip=False, - default_option=Menu.no(), + default_option=Menu.yes(), clear_screen=False, show_search_hint=False ).run() diff --git a/archinstall/lib/user_interaction/network_conf.py b/archinstall/lib/interactions/network_menu.py index 5e637f23..14fc5785 100644 --- a/archinstall/lib/user_interaction/network_conf.py +++ b/archinstall/lib/interactions/network_menu.py @@ -1,17 +1,14 @@ from __future__ import annotations import ipaddress -import logging -from typing import Any, Optional, TYPE_CHECKING, List, Union, Dict +from typing import Any, Optional, TYPE_CHECKING, List, Dict -from ..menu.menu import MenuSelectionType -from ..menu.text_input import TextInput -from ..models.network_configuration import NetworkConfiguration, NicType +from ..menu import MenuSelectionType, TextInput +from ..models.network_configuration import NetworkConfiguration, NicType, Nic from ..networking import list_interfaces -from ..menu import Menu -from ..output import log, FormattedOutput -from ..menu.list_manager import ListManager +from ..output import FormattedOutput, warn +from ..menu import ListManager, Menu if TYPE_CHECKING: _: Any @@ -22,23 +19,22 @@ class ManualNetworkConfig(ListManager): subclass of ListManager for the managing of network configurations """ - def __init__(self, prompt: str, ifaces: List[NetworkConfiguration]): + def __init__(self, prompt: str, preset: List[Nic]): self._actions = [ str(_('Add interface')), str(_('Edit interface')), str(_('Delete interface')) ] + super().__init__(prompt, preset, [self._actions[0]], self._actions[1:]) - super().__init__(prompt, ifaces, [self._actions[0]], self._actions[1:]) - - def reformat(self, data: List[NetworkConfiguration]) -> Dict[str, Optional[NetworkConfiguration]]: + def reformat(self, data: List[Nic]) -> Dict[str, Optional[Nic]]: table = FormattedOutput.as_table(data) rows = table.split('\n') # these are the header rows of the table and do not map to any User obviously # we're adding 2 spaces as prefix because the menu selector '> ' will be put before # the selectable rows so the header has to be aligned - display_data: Dict[str, Optional[NetworkConfiguration]] = {f' {rows[0]}': None, f' {rows[1]}': None} + display_data: Dict[str, Optional[Nic]] = {f' {rows[0]}': None, f' {rows[1]}': None} for row, iface in zip(rows[2:], data): row = row.replace('|', '\\|') @@ -46,16 +42,16 @@ class ManualNetworkConfig(ListManager): return display_data - def selected_action_display(self, iface: NetworkConfiguration) -> str: - return iface.iface if iface.iface else '' + def selected_action_display(self, nic: Nic) -> str: + return nic.iface if nic.iface else '' - def handle_action(self, action: str, entry: Optional[NetworkConfiguration], data: List[NetworkConfiguration]): + def handle_action(self, action: str, entry: Optional[Nic], data: List[Nic]): if action == self._actions[0]: # add - iface_name = self._select_iface(data) - if iface_name: - iface = NetworkConfiguration(NicType.MANUAL, iface=iface_name) - iface = self._edit_iface(iface) - data += [iface] + iface = self._select_iface(data) + if iface: + nic = Nic(iface=iface) + nic = self._edit_iface(nic) + data += [nic] elif entry: if action == self._actions[1]: # edit interface data = [d for d in data if d.iface != entry.iface] @@ -65,7 +61,7 @@ class ManualNetworkConfig(ListManager): return data - def _select_iface(self, data: List[NetworkConfiguration]) -> Optional[Any]: + def _select_iface(self, data: List[Nic]) -> Optional[str]: all_ifaces = list_interfaces().values() existing_ifaces = [d.iface for d in data] available = set(all_ifaces) - set(existing_ifaces) @@ -74,10 +70,10 @@ class ManualNetworkConfig(ListManager): if choice.type_ == MenuSelectionType.Skip: return None - return choice.value + return choice.single_value - def _edit_iface(self, edit_iface: NetworkConfiguration): - iface_name = edit_iface.iface + def _edit_iface(self, edit_nic: Nic) -> Nic: + iface_name = edit_nic.iface modes = ['DHCP (auto detect)', 'IP (static)'] default_mode = 'DHCP (auto detect)' @@ -87,13 +83,13 @@ class ManualNetworkConfig(ListManager): if mode.value == 'IP (static)': while 1: prompt = _('Enter the IP and subnet for {} (example: 192.168.0.5/24): ').format(iface_name) - ip = TextInput(prompt, edit_iface.ip).run().strip() + ip = TextInput(prompt, edit_nic.ip).run().strip() # Implemented new check for correct IP/subnet input try: ipaddress.ip_interface(ip) break except ValueError: - log("You need to enter a valid IP in IP-config mode.", level=logging.WARNING, fg='red') + warn("You need to enter a valid IP in IP-config mode") # Implemented new check for correct gateway IP address gateway = None @@ -101,17 +97,17 @@ class ManualNetworkConfig(ListManager): while 1: gateway = TextInput( _('Enter your gateway (router) IP address or leave blank for none: '), - edit_iface.gateway + edit_nic.gateway ).run().strip() try: if len(gateway) > 0: ipaddress.ip_address(gateway) break except ValueError: - log("You need to enter a valid gateway (router) IP address.", level=logging.WARNING, fg='red') + warn("You need to enter a valid gateway (router) IP address") - if edit_iface.dns: - display_dns = ' '.join(edit_iface.dns) + if edit_nic.dns: + display_dns = ' '.join(edit_nic.dns) else: display_dns = None dns_input = TextInput(_('Enter your DNS servers (space separated, blank for none): '), display_dns).run().strip() @@ -120,39 +116,24 @@ class ManualNetworkConfig(ListManager): if len(dns_input): dns = dns_input.split(' ') - return NetworkConfiguration(NicType.MANUAL, iface=iface_name, ip=ip, gateway=gateway, dns=dns, dhcp=False) + return Nic(iface=iface_name, ip=ip, gateway=gateway, dns=dns, dhcp=False) else: # this will contain network iface names - return NetworkConfiguration(NicType.MANUAL, iface=iface_name) + return Nic(iface=iface_name) -def ask_to_configure_network( - preset: Union[NetworkConfiguration, List[NetworkConfiguration]] -) -> Optional[NetworkConfiguration | List[NetworkConfiguration]]: +def ask_to_configure_network(preset: Optional[NetworkConfiguration]) -> Optional[NetworkConfiguration]: """ Configure the network on the newly installed system """ - network_options = { - 'none': str(_('No network configuration')), - 'iso_config': str(_('Copy ISO network configuration to installation')), - 'network_manager': str(_('Use NetworkManager (necessary to configure internet graphically in GNOME and KDE)')), - 'manual': str(_('Manual configuration')) - } - # for this routine it's easier to set the cursor position rather than a preset value - cursor_idx = None - - if preset and not isinstance(preset, list): - if preset.type == 'iso_config': - cursor_idx = 0 - elif preset.type == 'network_manager': - cursor_idx = 1 - + options = {n.display_msg(): n for n in NicType} + preset_val = preset.type.display_msg() if preset else None warning = str(_('Are you sure you want to reset this setting?')) choice = Menu( _('Select one network interface to configure'), - list(network_options.values()), - cursor_index=cursor_idx, + list(options.keys()), + preset_values=preset_val, sort=False, allow_reset=True, allow_reset_warning_msg=warning @@ -161,15 +142,18 @@ def ask_to_configure_network( match choice.type_: case MenuSelectionType.Skip: return preset case MenuSelectionType.Reset: return None - - if choice.value == network_options['none']: - return None - elif choice.value == network_options['iso_config']: - return NetworkConfiguration(NicType.ISO) - elif choice.value == network_options['network_manager']: - return NetworkConfiguration(NicType.NM) - elif choice.value == network_options['manual']: - preset_ifaces = preset if isinstance(preset, list) else [] - return ManualNetworkConfig('Configure interfaces', preset_ifaces).run() + case MenuSelectionType.Selection: + nic_type = options[choice.single_value] + + match nic_type: + case NicType.ISO: + return NetworkConfiguration(NicType.ISO) + case NicType.NM: + return NetworkConfiguration(NicType.NM) + case NicType.MANUAL: + preset_nics = preset.nics if preset else [] + nics = ManualNetworkConfig('Configure interfaces', preset_nics).run() + if nics: + return NetworkConfiguration(NicType.MANUAL, nics) return preset diff --git a/archinstall/lib/interactions/system_conf.py b/archinstall/lib/interactions/system_conf.py new file mode 100644 index 00000000..35ba5a8b --- /dev/null +++ b/archinstall/lib/interactions/system_conf.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from typing import List, Any, TYPE_CHECKING, Optional + +from ..hardware import SysInfo, GfxDriver +from ..menu import MenuSelectionType, Menu +from ..models.bootloader import Bootloader + +if TYPE_CHECKING: + _: Any + + +def select_kernel(preset: List[str] = []) -> List[str]: + """ + Asks the user to select a kernel for system. + + :return: The string as a selected kernel + :rtype: string + """ + + kernels = ["linux", "linux-lts", "linux-zen", "linux-hardened"] + default_kernel = "linux" + + warning = str(_('Are you sure you want to reset this setting?')) + + choice = Menu( + _('Choose which kernels to use or leave blank for default "{}"').format(default_kernel), + kernels, + sort=True, + multi=True, + preset_values=preset, + allow_reset_warning_msg=warning + ).run() + + match choice.type_: + case MenuSelectionType.Skip: return preset + case MenuSelectionType.Selection: return choice.single_value + + return [] + + +def ask_for_bootloader(preset: Bootloader) -> Bootloader: + # Systemd is UEFI only + if not SysInfo.has_uefi(): + options = [Bootloader.Grub.value, Bootloader.Limine.value] + default = Bootloader.Grub.value + else: + options = Bootloader.values() + default = Bootloader.Systemd.value + + preset_value = preset.value if preset else None + + choice = Menu( + _('Choose a bootloader'), + options, + preset_values=preset_value, + sort=False, + default_option=default + ).run() + + match choice.type_: + case MenuSelectionType.Skip: return preset + case MenuSelectionType.Selection: return Bootloader(choice.value) + + return preset + + +def ask_for_uki(preset: bool = True) -> bool: + if preset: + preset_val = Menu.yes() + else: + preset_val = Menu.no() + + prompt = _('Would you like to use unified kernel images?') + choice = Menu(prompt, Menu.yes_no(), default_option=Menu.no(), preset_values=preset_val).run() + + match choice.type_: + case MenuSelectionType.Skip: return preset + case MenuSelectionType.Selection: return False if choice.value == Menu.no() else True + + return preset + + +def select_driver(options: List[GfxDriver] = [], current_value: Optional[GfxDriver] = None) -> Optional[GfxDriver]: + """ + Some what convoluted function, whose job is simple. + Select a graphics driver from a pre-defined set of popular options. + + (The template xorg is for beginner users, not advanced, and should + there for appeal to the general public first and edge cases later) + """ + if not options: + options = [driver for driver in GfxDriver] + + drivers = sorted([o.value for o in options]) + + if drivers: + title = '' + if SysInfo.has_amd_graphics(): + title += str(_('For the best compatibility with your AMD hardware, you may want to use either the all open-source or AMD / ATI options.')) + '\n' + if SysInfo.has_intel_graphics(): + title += str(_('For the best compatibility with your Intel hardware, you may want to use either the all open-source or Intel options.\n')) + if SysInfo.has_nvidia_graphics(): + title += str(_('For the best compatibility with your Nvidia hardware, you may want to use the Nvidia proprietary driver.\n')) + + preset = current_value.value if current_value else None + + choice = Menu( + title, + drivers, + preset_values=preset, + default_option=GfxDriver.AllOpenSource.value, + preview_command=lambda x: GfxDriver(x).packages_text(), + preview_size=0.3 + ).run() + + if choice.type_ != MenuSelectionType.Selection: + return current_value + + return GfxDriver(choice.single_value) + + return current_value + + +def ask_for_swap(preset: bool = True) -> bool: + if preset: + preset_val = Menu.yes() + else: + preset_val = Menu.no() + + prompt = _('Would you like to use swap on zram?') + choice = Menu(prompt, Menu.yes_no(), default_option=Menu.yes(), preset_values=preset_val).run() + + match choice.type_: + case MenuSelectionType.Skip: return preset + case MenuSelectionType.Selection: return False if choice.value == Menu.no() else True + + return preset diff --git a/archinstall/lib/interactions/utils.py b/archinstall/lib/interactions/utils.py new file mode 100644 index 00000000..fdbb4625 --- /dev/null +++ b/archinstall/lib/interactions/utils.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import getpass +from typing import Any, Optional, TYPE_CHECKING + +from ..models import PasswordStrength +from ..output import log, error + +if TYPE_CHECKING: + _: Any + +# used for signal handler +SIG_TRIGGER = None + + +def get_password(prompt: str = '') -> Optional[str]: + if not prompt: + prompt = _("Enter a password: ") + + while True: + try: + password = getpass.getpass(prompt) + except (KeyboardInterrupt, EOFError): + break + + if len(password.strip()) <= 0: + break + + strength = PasswordStrength.strength(password) + log(f'Password strength: {strength.value}', fg=strength.color()) + + passwd_verification = getpass.getpass(prompt=_('And one more time for verification: ')) + if password != passwd_verification: + error(' * Passwords did not match * ') + continue + + return password + + return None diff --git a/archinstall/lib/locale/__init__.py b/archinstall/lib/locale/__init__.py new file mode 100644 index 00000000..90f1aecc --- /dev/null +++ b/archinstall/lib/locale/__init__.py @@ -0,0 +1,10 @@ +from .locale_menu import LocaleConfiguration +from .utils import ( + list_keyboard_languages, + list_locales, + list_x11_keyboard_languages, + verify_keyboard_layout, + verify_x11_keyboard_layout, + list_timezones, + set_kb_layout +) diff --git a/archinstall/lib/locale/locale_menu.py b/archinstall/lib/locale/locale_menu.py new file mode 100644 index 00000000..db119f20 --- /dev/null +++ b/archinstall/lib/locale/locale_menu.py @@ -0,0 +1,158 @@ +from dataclasses import dataclass +from typing import Dict, Any, TYPE_CHECKING, Optional + +from .utils import list_keyboard_languages, list_locales, set_kb_layout +from ..menu import Selector, AbstractSubMenu, MenuSelectionType, Menu + +if TYPE_CHECKING: + _: Any + + +@dataclass +class LocaleConfiguration: + kb_layout: str + sys_lang: str + sys_enc: str + + @staticmethod + def default() -> 'LocaleConfiguration': + return LocaleConfiguration('us', 'en_US', 'UTF-8') + + def json(self) -> Dict[str, str]: + return { + 'kb_layout': self.kb_layout, + 'sys_lang': self.sys_lang, + 'sys_enc': self.sys_enc + } + + @classmethod + def _load_config(cls, config: 'LocaleConfiguration', args: Dict[str, Any]) -> 'LocaleConfiguration': + if 'sys_lang' in args: + config.sys_lang = args['sys_lang'] + if 'sys_enc' in args: + config.sys_enc = args['sys_enc'] + if 'kb_layout' in args: + config.kb_layout = args['kb_layout'] + + return config + + @classmethod + def parse_arg(cls, args: Dict[str, Any]) -> 'LocaleConfiguration': + default = cls.default() + + if 'locale_config' in args: + default = cls._load_config(default, args['locale_config']) + else: + default = cls._load_config(default, args) + + return default + + +class LocaleMenu(AbstractSubMenu): + def __init__( + self, + data_store: Dict[str, Any], + locale_conf: LocaleConfiguration + ): + self._preset = locale_conf + super().__init__(data_store=data_store) + + def setup_selection_menu_options(self): + self._menu_options['keyboard-layout'] = \ + Selector( + _('Keyboard layout'), + lambda preset: self._select_kb_layout(preset), + default=self._preset.kb_layout, + enabled=True) + self._menu_options['sys-language'] = \ + Selector( + _('Locale language'), + lambda preset: select_locale_lang(preset), + default=self._preset.sys_lang, + enabled=True) + self._menu_options['sys-encoding'] = \ + Selector( + _('Locale encoding'), + lambda preset: select_locale_enc(preset), + default=self._preset.sys_enc, + enabled=True) + + def run(self, allow_reset: bool = True) -> LocaleConfiguration: + super().run(allow_reset=allow_reset) + + if not self._data_store: + return LocaleConfiguration.default() + + return LocaleConfiguration( + self._data_store['keyboard-layout'], + self._data_store['sys-language'], + self._data_store['sys-encoding'] + ) + + def _select_kb_layout(self, preset: Optional[str]) -> Optional[str]: + kb_lang = select_kb_layout(preset) + if kb_lang: + set_kb_layout(kb_lang) + return kb_lang + + +def select_locale_lang(preset: Optional[str] = None) -> Optional[str]: + locales = list_locales() + locale_lang = set([locale.split()[0] for locale in locales]) + + choice = Menu( + _('Choose which locale language to use'), + list(locale_lang), + sort=True, + preset_values=preset + ).run() + + match choice.type_: + case MenuSelectionType.Selection: return choice.single_value + case MenuSelectionType.Skip: return preset + + return None + + +def select_locale_enc(preset: Optional[str] = None) -> Optional[str]: + locales = list_locales() + locale_enc = set([locale.split()[1] for locale in locales]) + + choice = Menu( + _('Choose which locale encoding to use'), + list(locale_enc), + sort=True, + preset_values=preset + ).run() + + match choice.type_: + case MenuSelectionType.Selection: return choice.single_value + case MenuSelectionType.Skip: return preset + + return None + + +def select_kb_layout(preset: Optional[str] = None) -> Optional[str]: + """ + Asks the user to select a language + Usually this is combined with :ref:`archinstall.list_keyboard_languages`. + + :return: The language/dictionary key of the selected language + :rtype: str + """ + kb_lang = list_keyboard_languages() + # sort alphabetically and then by length + sorted_kb_lang = sorted(kb_lang, key=lambda x: (len(x), x)) + + choice = Menu( + _('Select keyboard layout'), + sorted_kb_lang, + preset_values=preset, + sort=False + ).run() + + match choice.type_: + case MenuSelectionType.Skip: return preset + case MenuSelectionType.Selection: return choice.single_value + + return None diff --git a/archinstall/lib/locale/utils.py b/archinstall/lib/locale/utils.py new file mode 100644 index 00000000..d7641d50 --- /dev/null +++ b/archinstall/lib/locale/utils.py @@ -0,0 +1,67 @@ +from typing import List + +from ..exceptions import ServiceException, SysCallError +from ..general import SysCommand +from ..output import error + + +def list_keyboard_languages() -> List[str]: + return SysCommand( + "localectl --no-pager list-keymaps", + environment_vars={'SYSTEMD_COLORS': '0'} + ).decode().splitlines() + + +def list_locales() -> List[str]: + locales = [] + + with open('/usr/share/i18n/SUPPORTED') as file: + for line in file: + if line != 'C.UTF-8 UTF-8\n': + locales.append(line.rstrip()) + + return locales + + +def list_x11_keyboard_languages() -> List[str]: + return SysCommand( + "localectl --no-pager list-x11-keymap-layouts", + environment_vars={'SYSTEMD_COLORS': '0'} + ).decode().splitlines() + + +def verify_keyboard_layout(layout :str) -> bool: + for language in list_keyboard_languages(): + if layout.lower() == language.lower(): + return True + return False + + +def verify_x11_keyboard_layout(layout :str) -> bool: + for language in list_x11_keyboard_languages(): + if layout.lower() == language.lower(): + return True + return False + + +def set_kb_layout(locale :str) -> bool: + if len(locale.strip()): + if not verify_keyboard_layout(locale): + error(f"Invalid keyboard locale specified: {locale}") + return False + + try: + SysCommand(f'localectl set-keymap {locale}') + except SysCallError as err: + raise ServiceException(f"Unable to set locale '{locale}' for console: {err}") + + return True + + return False + + +def list_timezones() -> List[str]: + return SysCommand( + "timedatectl --no-pager list-timezones", + environment_vars={'SYSTEMD_COLORS': '0'} + ).decode().splitlines() diff --git a/archinstall/lib/locale_helpers.py b/archinstall/lib/locale_helpers.py deleted file mode 100644 index 5580fa91..00000000 --- a/archinstall/lib/locale_helpers.py +++ /dev/null @@ -1,168 +0,0 @@ -import logging -from typing import Iterator, List, Callable - -from .exceptions import ServiceException -from .general import SysCommand -from .output import log -from .storage import storage - -def list_keyboard_languages() -> Iterator[str]: - for line in SysCommand("localectl --no-pager list-keymaps", environment_vars={'SYSTEMD_COLORS': '0'}): - yield line.decode('UTF-8').strip() - - -def list_locales() -> List[str]: - with open('/etc/locale.gen', 'r') as fp: - locales = [] - # before the list of locales begins there's an empty line with a '#' in front - # so we'll collect the localels from bottom up and halt when we're donw - entries = fp.readlines() - entries.reverse() - - for entry in entries: - text = entry.replace('#', '').strip() - if text == '': - break - locales.append(text) - - locales.reverse() - return locales - -def get_locale_mode_text(mode): - if mode == 'LC_ALL': - mode_text = "general (LC_ALL)" - elif mode == "LC_CTYPE": - mode_text = "Character set" - elif mode == "LC_NUMERIC": - mode_text = "Numeric values" - elif mode == "LC_TIME": - mode_text = "Time Values" - elif mode == "LC_COLLATE": - mode_text = "sort order" - elif mode == "LC_MESSAGES": - mode_text = "text messages" - else: - mode_text = "Unassigned" - return mode_text - -def reset_cmd_locale(): - """ sets the cmd_locale to its saved default """ - storage['CMD_LOCALE'] = storage.get('CMD_LOCALE_DEFAULT',{}) - -def unset_cmd_locale(): - """ archinstall will use the execution environment default """ - storage['CMD_LOCALE'] = {} - -def set_cmd_locale(general :str = None, - charset :str = 'C', - numbers :str = 'C', - time :str = 'C', - collate :str = 'C', - messages :str = 'C'): - """ - Set the cmd locale. - If the parameter general is specified, it takes precedence over the rest (might as well not exist) - The rest define some specific settings above the installed default language. If anyone of this parameters is none means the installation default - """ - installed_locales = list_installed_locales() - result = {} - if general: - if general in installed_locales: - storage['CMD_LOCALE'] = {'LC_ALL':general} - else: - log(f"{get_locale_mode_text('LC_ALL')} {general} is not installed. Defaulting to C",fg="yellow",level=logging.WARNING) - return - - if numbers: - if numbers in installed_locales: - result["LC_NUMERIC"] = numbers - else: - log(f"{get_locale_mode_text('LC_NUMERIC')} {numbers} is not installed. Defaulting to installation language",fg="yellow",level=logging.WARNING) - if charset: - if charset in installed_locales: - result["LC_CTYPE"] = charset - else: - log(f"{get_locale_mode_text('LC_CTYPE')} {charset} is not installed. Defaulting to installation language",fg="yellow",level=logging.WARNING) - if time: - if time in installed_locales: - result["LC_TIME"] = time - else: - log(f"{get_locale_mode_text('LC_TIME')} {time} is not installed. Defaulting to installation language",fg="yellow",level=logging.WARNING) - if collate: - if collate in installed_locales: - result["LC_COLLATE"] = collate - else: - log(f"{get_locale_mode_text('LC_COLLATE')} {collate} is not installed. Defaulting to installation language",fg="yellow",level=logging.WARNING) - if messages: - if messages in installed_locales: - result["LC_MESSAGES"] = messages - else: - log(f"{get_locale_mode_text('LC_MESSAGES')} {messages} is not installed. Defaulting to installation language",fg="yellow",level=logging.WARNING) - storage['CMD_LOCALE'] = result - -def host_locale_environ(func :Callable): - """ decorator when we want a function executing in the host's locale environment """ - def wrapper(*args, **kwargs): - unset_cmd_locale() - result = func(*args,**kwargs) - reset_cmd_locale() - return result - return wrapper - -def c_locale_environ(func :Callable): - """ decorator when we want a function executing in the C locale environment """ - def wrapper(*args, **kwargs): - set_cmd_locale(general='C') - result = func(*args,**kwargs) - reset_cmd_locale() - return result - return wrapper - -def list_installed_locales() -> List[str]: - lista = [] - for line in SysCommand('locale -a'): - lista.append(line.decode('UTF-8').strip()) - return lista - -def list_x11_keyboard_languages() -> Iterator[str]: - for line in SysCommand("localectl --no-pager list-x11-keymap-layouts", environment_vars={'SYSTEMD_COLORS': '0'}): - yield line.decode('UTF-8').strip() - - -def verify_keyboard_layout(layout :str) -> bool: - for language in list_keyboard_languages(): - if layout.lower() == language.lower(): - return True - return False - - -def verify_x11_keyboard_layout(layout :str) -> bool: - for language in list_x11_keyboard_languages(): - if layout.lower() == language.lower(): - return True - return False - - -def search_keyboard_layout(layout :str) -> Iterator[str]: - for language in list_keyboard_languages(): - if layout.lower() in language.lower(): - yield language - - -def set_keyboard_language(locale :str) -> bool: - if len(locale.strip()): - if not verify_keyboard_layout(locale): - log(f"Invalid keyboard locale specified: {locale}", fg="red", level=logging.ERROR) - return False - - if (output := SysCommand(f'localectl set-keymap {locale}')).exit_code != 0: - raise ServiceException(f"Unable to set locale '{locale}' for console: {output}") - - return True - - return False - - -def list_timezones() -> Iterator[str]: - for line in SysCommand("timedatectl --no-pager list-timezones", environment_vars={'SYSTEMD_COLORS': '0'}): - yield line.decode('UTF-8').strip() diff --git a/archinstall/lib/luks.py b/archinstall/lib/luks.py index ad6bf093..50e15cee 100644 --- a/archinstall/lib/luks.py +++ b/archinstall/lib/luks.py @@ -1,92 +1,77 @@ from __future__ import annotations -import json -import logging -import os -import pathlib + import shlex import time -from typing import Optional, List,TYPE_CHECKING -# https://stackoverflow.com/a/39757388/929999 -if TYPE_CHECKING: - from .installer import Installer - -from .disk import Partition, convert_device_to_uuid -from .general import SysCommand, SysCommandWorker -from .output import log +from dataclasses import dataclass +from pathlib import Path +from typing import Optional, List + +from . import disk +from .general import SysCommand, generate_password, SysCommandWorker +from .output import info, debug from .exceptions import SysCallError, DiskError from .storage import storage -from .disk.helpers import get_filesystem_type -from .disk.mapperdev import MapperDev -from .disk.btrfs import BTRFSPartition - - -class luks2: - def __init__(self, - partition: Partition, - mountpoint: Optional[str], - password: Optional[str], - key_file :Optional[str] = None, - auto_unmount :bool = False, - *args :str, - **kwargs :str): - - self.password = password - self.partition = partition - self.mountpoint = mountpoint - self.args = args - self.kwargs = kwargs - self.key_file = key_file - self.auto_unmount = auto_unmount - self.filesystem = 'crypto_LUKS' - self.mapdev = None - def __enter__(self) -> Partition: - if not self.key_file: - self.key_file = f"/tmp/{os.path.basename(self.partition.path)}.disk_pw" # TODO: Make disk-pw-file randomly unique? - if type(self.password) != bytes: - self.password = bytes(self.password, 'UTF-8') +@dataclass +class Luks2: + luks_dev_path: Path + mapper_name: Optional[str] = None + password: Optional[str] = None + key_file: Optional[Path] = None + auto_unmount: bool = False - with open(self.key_file, 'wb') as fh: - fh.write(self.password) + # will be set internally after unlocking the device + _mapper_dev: Optional[Path] = None - return self.unlock(self.partition, self.mountpoint, self.key_file) + @property + def mapper_dev(self) -> Optional[Path]: + if self.mapper_name: + return Path(f'/dev/mapper/{self.mapper_name}') + return None - def __exit__(self, *args :str, **kwargs :str) -> bool: - # TODO: https://stackoverflow.com/questions/28157929/how-to-safely-handle-an-exception-inside-a-context-manager + def __post_init__(self): + if self.luks_dev_path is None: + raise ValueError('Partition must have a path set') + + def __enter__(self): + self.unlock(self.key_file) + + def __exit__(self, *args: str, **kwargs: str): if self.auto_unmount: - self.close() + self.lock() + + def _default_key_file(self) -> Path: + return Path(f'/tmp/{self.luks_dev_path.name}.disk_pw') - if len(args) >= 2 and args[1]: - raise args[1] + def _password_bytes(self) -> bytes: + if not self.password: + raise ValueError('Password for luks2 device was not specified') - return True + if isinstance(self.password, bytes): + return self.password + else: + return bytes(self.password, 'UTF-8') - def encrypt(self, partition :Partition, - password :Optional[str] = None, - key_size :int = 512, - hash_type :str = 'sha512', - iter_time :int = 10000, - key_file :Optional[str] = None) -> str: + def encrypt( + self, + key_size: int = 512, + hash_type: str = 'sha512', + iter_time: int = 10000, + key_file: Optional[Path] = None + ) -> Path: + debug(f'Luks2 encrypting: {self.luks_dev_path}') - log(f'Encrypting {partition} (This might take a while)', level=logging.INFO) + byte_password = self._password_bytes() if not key_file: if self.key_file: key_file = self.key_file else: - key_file = f"/tmp/{os.path.basename(self.partition.path)}.disk_pw" # TODO: Make disk-pw-file randomly unique? + key_file = self._default_key_file() - if not password: - password = self.password - - if type(password) != bytes: - password = bytes(password, 'UTF-8') - - with open(key_file, 'wb') as fh: - fh.write(password) - - partition.partprobe() + with open(key_file, 'wb') as fh: + fh.write(byte_password) cryptsetup_args = shlex.join([ '/usr/bin/cryptsetup', @@ -97,120 +82,170 @@ class luks2: '--hash', hash_type, '--key-size', str(key_size), '--iter-time', str(iter_time), - '--key-file', os.path.abspath(key_file), + '--key-file', str(key_file), '--use-urandom', - 'luksFormat', partition.path, + 'luksFormat', str(self.luks_dev_path), ]) - try: - # Retry formatting the volume because archinstall can some times be too quick - # which generates a "Device /dev/sdX does not exist or access denied." between - # setting up partitions and us trying to encrypt it. - for i in range(storage['DISK_RETRY_ATTEMPTS']): - if (cmd_handle := SysCommand(cryptsetup_args)).exit_code != 0: - time.sleep(storage['DISK_TIMEOUTS']) + debug(f'cryptsetup format: {cryptsetup_args}') + + # Retry formatting the volume because archinstall can some times be too quick + # which generates a "Device /dev/sdX does not exist or access denied." between + # setting up partitions and us trying to encrypt it. + for retry_attempt in range(storage['DISK_RETRY_ATTEMPTS'] + 1): + try: + result = SysCommand(cryptsetup_args).decode() + debug(f'cryptsetup luksFormat output: {result}') + break + except SysCallError as err: + time.sleep(storage['DISK_TIMEOUTS']) + + if retry_attempt != storage['DISK_RETRY_ATTEMPTS']: + continue + + if err.exit_code == 1: + info(f'luks2 partition currently in use: {self.luks_dev_path}') + info('Attempting to unmount, crypt-close and trying encryption again') + + self.lock() + # Then try again to set up the crypt-device + result = SysCommand(cryptsetup_args).decode() + debug(f'cryptsetup luksFormat output: {result}') else: - break + raise DiskError(f'Could not encrypt volume "{self.luks_dev_path}": {err}') - if cmd_handle.exit_code != 0: - raise DiskError(f'Could not encrypt volume "{partition.path}": {b"".join(cmd_handle)}') - except SysCallError as err: - if err.exit_code == 1: - log(f'{partition} is being used, trying to unmount and crypt-close the device and running one more attempt at encrypting the device.', level=logging.DEBUG) - # Partition was in use, unmount it and try again - partition.unmount() - - # Get crypt-information about the device by doing a reverse lookup starting with the partition path - # For instance: /dev/sda - SysCommand(f'bash -c "partprobe"') - devinfo = json.loads(b''.join(SysCommand(f"lsblk --fs -J {partition.path}")).decode('UTF-8'))['blockdevices'][0] - - # For each child (sub-partition/sub-device) - if len(children := devinfo.get('children', [])): - for child in children: - # Unmount the child location - if child_mountpoint := child.get('mountpoint', None): - log(f'Unmounting {child_mountpoint}', level=logging.DEBUG) - SysCommand(f"umount -R {child_mountpoint}") - - # And close it if possible. - log(f"Closing crypt device {child['name']}", level=logging.DEBUG) - SysCommand(f"cryptsetup close {child['name']}") - - # Then try again to set up the crypt-device - cmd_handle = SysCommand(cryptsetup_args) - else: - raise err + self.key_file = key_file return key_file - def unlock(self, partition :Partition, mountpoint :str, key_file :str) -> Partition: + def _get_luks_uuid(self) -> str: + command = f'/usr/bin/cryptsetup luksUUID {self.luks_dev_path}' + + try: + return SysCommand(command).decode() + except SysCallError as err: + info(f'Unable to get UUID for Luks device: {self.luks_dev_path}') + raise err + + def is_unlocked(self) -> bool: + return self.mapper_name is not None and Path(f'/dev/mapper/{self.mapper_name}').exists() + + def unlock(self, key_file: Optional[Path] = None): """ - Mounts a luks2 compatible partition to a certain mountpoint. - Keyfile must be specified as there's no way to interact with the pw-prompt atm. + Unlocks the luks device, an optional key file location for unlocking can be specified, + otherwise a default location for the key file will be used. - :param mountpoint: The name without absolute path, for instance "luksdev" will point to /dev/mapper/luksdev - :type mountpoint: str + :param key_file: An alternative key file + :type key_file: Path """ + debug(f'Unlocking luks2 device: {self.luks_dev_path}') - if '/' in mountpoint: - os.path.basename(mountpoint) # TODO: Raise exception instead? + if not self.mapper_name: + raise ValueError('mapper name missing') + + byte_password = self._password_bytes() + + if not key_file: + if self.key_file: + key_file = self.key_file + else: + key_file = self._default_key_file() + + with open(key_file, 'wb') as fh: + fh.write(byte_password) wait_timer = time.time() - while pathlib.Path(partition.path).exists() is False and time.time() - wait_timer < 10: + while Path(self.luks_dev_path).exists() is False and time.time() - wait_timer < 10: time.sleep(0.025) - SysCommand(f'/usr/bin/cryptsetup open {partition.path} {mountpoint} --key-file {os.path.abspath(key_file)} --type luks2') - if os.path.islink(f'/dev/mapper/{mountpoint}'): - self.mapdev = f'/dev/mapper/{mountpoint}' - - if (filesystem_type := get_filesystem_type(pathlib.Path(self.mapdev))) == 'btrfs': - return BTRFSPartition( - self.mapdev, - block_device=MapperDev(mountpoint).partition.block_device, - encrypted=True, - filesystem=filesystem_type, - autodetect_filesystem=False - ) - - return Partition( - self.mapdev, - block_device=MapperDev(mountpoint).partition.block_device, - encrypted=True, - filesystem=get_filesystem_type(self.mapdev), - autodetect_filesystem=False - ) - - def close(self, mountpoint :Optional[str] = None) -> bool: - if not mountpoint: - mountpoint = self.mapdev - - SysCommand(f'/usr/bin/cryptsetup close {self.mapdev}') - return os.path.islink(self.mapdev) is False - - def format(self, path :str) -> None: - if (handle := SysCommand(f"/usr/bin/cryptsetup -q -v luksErase {path}")).exit_code != 0: - raise DiskError(f'Could not format {path} with {self.filesystem} because: {b"".join(handle)}') - - def add_key(self, path :pathlib.Path, password :str) -> bool: - if not path.exists(): - raise OSError(2, f"Could not import {path} as a disk encryption key, file is missing.", str(path)) - - log(f'Adding additional key-file {path} for {self.partition}', level=logging.INFO) - worker = SysCommandWorker(f"/usr/bin/cryptsetup -q -v luksAddKey {self.partition.path} {path}", - environment_vars={'LC_ALL':'C'}) + result = SysCommand( + '/usr/bin/cryptsetup open ' + f'{self.luks_dev_path} ' + f'{self.mapper_name} ' + f'--key-file {key_file} ' + f'--type luks2' + ).decode() + + debug(f'cryptsetup open output: {result}') + + if not self.mapper_dev or not self.mapper_dev.is_symlink(): + raise DiskError(f'Failed to open luks2 device: {self.luks_dev_path}') + + def lock(self): + disk.device_handler.umount(self.luks_dev_path) + + # Get crypt-information about the device by doing a reverse lookup starting with the partition path + # For instance: /dev/sda + lsblk_info = disk.get_lsblk_info(self.luks_dev_path) + + # For each child (sub-partition/sub-device) + for child in lsblk_info.children: + # Unmount the child location + for mountpoint in child.mountpoints: + debug(f'Unmounting {mountpoint}') + disk.device_handler.umount(mountpoint, recursive=True) + + # And close it if possible. + debug(f"Closing crypt device {child.name}") + SysCommand(f"cryptsetup close {child.name}") + + self._mapper_dev = None + + def create_keyfile(self, target_path: Path, override: bool = False): + """ + Routine to create keyfiles, so it can be moved elsewhere + """ + if self.mapper_name is None: + raise ValueError('Mapper name must be provided') + + # Once we store the key as ../xyzloop.key systemd-cryptsetup can + # automatically load this key if we name the device to "xyzloop" + kf_path = Path(f'/etc/cryptsetup-keys.d/{self.mapper_name}.key') + key_file = target_path / kf_path.relative_to(kf_path.root) + crypttab_path = target_path / 'etc/crypttab' + + if key_file.exists(): + if not override: + info(f'Key file {key_file} already exists, keeping existing') + return + else: + info(f'Key file {key_file} already exists, overriding') + + key_file.parent.mkdir(parents=True, exist_ok=True) + + pwd = generate_password(length=512) + key_file.write_text(pwd) + + key_file.chmod(0o400) + + self._add_key(key_file) + self._crypttab(crypttab_path, kf_path, options=["luks", "key-slot=1"]) + + def _add_key(self, key_file: Path): + debug(f'Adding additional key-file {key_file}') + + command = f'/usr/bin/cryptsetup -q -v luksAddKey {self.luks_dev_path} {key_file}' + worker = SysCommandWorker(command, environment_vars={'LC_ALL': 'C'}) pw_injected = False + while worker.is_alive(): if b'Enter any existing passphrase' in worker and pw_injected is False: - worker.write(bytes(password, 'UTF-8')) + worker.write(self._password_bytes()) pw_injected = True if worker.exit_code != 0: - raise DiskError(f'Could not add encryption key {path} to {self.partition} because: {worker}') - - return True - - def crypttab(self, installation :Installer, key_path :str, options :List[str] = ["luks", "key-slot=1"]) -> None: - log(f'Adding a crypttab entry for key {key_path} in {installation}', level=logging.INFO) - with open(f"{installation.target}/etc/crypttab", "a") as crypttab: - crypttab.write(f"{self.mountpoint} UUID={convert_device_to_uuid(self.partition.path)} {key_path} {','.join(options)}\n") + raise DiskError(f'Could not add encryption key {key_file} to {self.luks_dev_path}: {worker.decode()}') + + def _crypttab( + self, + crypttab_path: Path, + key_file: Path, + options: List[str] + ) -> None: + debug(f'Adding crypttab entry for key {key_file}') + + with open(crypttab_path, 'a') as crypttab: + opt = ','.join(options) + uuid = self._get_luks_uuid() + row = f"{self.mapper_name} UUID={uuid} {key_file} {opt}\n" + crypttab.write(row) diff --git a/archinstall/lib/menu/__init__.py b/archinstall/lib/menu/__init__.py index 9b0adb8b..9c86faf5 100644 --- a/archinstall/lib/menu/__init__.py +++ b/archinstall/lib/menu/__init__.py @@ -1,2 +1,9 @@ -from .menu import Menu as Menu -from .global_menu import GlobalMenu as GlobalMenu
\ No newline at end of file +from .abstract_menu import Selector, AbstractMenu, AbstractSubMenu +from .list_manager import ListManager +from .menu import ( + MenuSelectionType, + MenuSelection, + Menu, +) +from .table_selection_menu import TableMenu +from .text_input import TextInput diff --git a/archinstall/lib/menu/abstract_menu.py b/archinstall/lib/menu/abstract_menu.py index d659d709..ee55f5c9 100644 --- a/archinstall/lib/menu/abstract_menu.py +++ b/archinstall/lib/menu/abstract_menu.py @@ -1,13 +1,11 @@ from __future__ import annotations -import logging from typing import Callable, Any, List, Iterator, Tuple, Optional, Dict, TYPE_CHECKING from .menu import Menu, MenuSelectionType -from ..locale_helpers import set_keyboard_language -from ..output import log +from ..output import error +from ..output import unicode_ljust from ..translationhandler import TranslationHandler, Language -from ..user_interaction.general_conf import select_archinstall_language if TYPE_CHECKING: _: Any @@ -16,17 +14,17 @@ if TYPE_CHECKING: class Selector: def __init__( self, - description :str, - func :Optional[Callable] = None, - display_func :Optional[Callable] = None, - default :Any = None, - enabled :bool = False, - dependencies :List = [], - dependencies_not :List = [], - exec_func :Optional[Callable] = None, - preview_func :Optional[Callable] = None, - mandatory :bool = False, - no_store :bool = False + description: str, + func: Optional[Callable[[Any], Any]] = None, + display_func: Optional[Callable] = None, + default: Optional[Any] = None, + enabled: bool = False, + dependencies: List = [], + dependencies_not: List = [], + exec_func: Optional[Callable] = None, + preview_func: Optional[Callable] = None, + mandatory: bool = False, + no_store: bool = False ): """ Create a new menu selection entry @@ -71,84 +69,66 @@ class Selector: :param no_store: A boolean which determines that the field should or shouldn't be stored in the data storage :type no_store: bool """ - self._description = description - self.func = func self._display_func = display_func - self._current_selection = default + self._no_store = no_store + + self.description = description + self.func = func + self.current_selection = default self.enabled = enabled - self._dependencies = dependencies - self._dependencies_not = dependencies_not + self.dependencies = dependencies + self.dependencies_not = dependencies_not self.exec_func = exec_func - self._preview_func = preview_func + self.preview_func = preview_func self.mandatory = mandatory - self._no_store = no_store - - @property - def description(self) -> str: - return self._description - - @property - def dependencies(self) -> List: - return self._dependencies - - @property - def dependencies_not(self) -> List: - return self._dependencies_not - - @property - def current_selection(self): - return self._current_selection - - @property - def preview_func(self): - return self._preview_func + self.default = default def do_store(self) -> bool: return self._no_store is False - def set_enabled(self, status :bool = True): + def set_enabled(self, status: bool = True): self.enabled = status - def update_description(self, description :str): - self._description = description + def update_description(self, description: str): + self.description = description def menu_text(self, padding: int = 0) -> str: - if self._description == '': # special menu option for __separator__ + if self.description == '': # special menu option for __separator__ return '' current = '' if self._display_func: - current = self._display_func(self._current_selection) + current = self._display_func(self.current_selection) else: - if self._current_selection is not None: - current = str(self._current_selection) + if self.current_selection is not None: + current = str(self.current_selection) if current: padding += 5 - description = str(self._description).ljust(padding, ' ') - current = str(_('set: {}').format(current)) + description = unicode_ljust(str(self.description), padding, ' ') + current = current else: - description = self._description + description = self.description current = '' return f'{description} {current}' - def set_current_selection(self, current :Optional[str]): - self._current_selection = current + def set_current_selection(self, current: Optional[Any]): + self.current_selection = current def has_selection(self) -> bool: - if not self._current_selection: + if not self.current_selection: return False return True def get_selection(self) -> Any: - return self._current_selection + return self.current_selection def is_empty(self) -> bool: - if self._current_selection is None: + if self.current_selection is None: return True - elif isinstance(self._current_selection, (str, list, dict)) and len(self._current_selection) == 0: + elif isinstance(self.current_selection, (str, list, dict)) and len(self.current_selection) == 0: return True return False @@ -158,14 +138,17 @@ class Selector: def is_mandatory(self) -> bool: return self.mandatory - def set_mandatory(self, status :bool = True): - self.mandatory = status - if status and not self.is_enabled(): - self.set_enabled(True) + def set_mandatory(self, value: bool): + self.mandatory = value class AbstractMenu: - def __init__(self, data_store: Optional[Dict[str, Any]] = None, auto_cursor=False, preview_size :float = 0.2): + def __init__( + self, + data_store: Dict[str, Any] = {}, + auto_cursor: bool = False, + preview_size: float = 0.2 + ): """ Create a new selection menu. @@ -179,29 +162,34 @@ class AbstractMenu: ;type preview_size: float (range 0..1) """ - self._enabled_order :List[str] = [] + self._enabled_order: List[str] = [] self._translation_handler = TranslationHandler() self.is_context_mgr = False - self._data_store = data_store if data_store is not None else {} + self._data_store = data_store self.auto_cursor = auto_cursor self._menu_options: Dict[str, Selector] = {} - self._setup_selection_menu_options() self.preview_size = preview_size self._last_choice = None + self.setup_selection_menu_options() + self._sync_all() + self._populate_default_values() + + self.defined_text = str(_('Defined')) + @property def last_choice(self): return self._last_choice - def __enter__(self, *args :Any, **kwargs :Any) -> AbstractMenu: + def __enter__(self, *args: Any, **kwargs: Any) -> AbstractMenu: self.is_context_mgr = True return self - def __exit__(self, *args :Any, **kwargs :Any) -> None: + def __exit__(self, *args: Any, **kwargs: Any) -> None: # TODO: https://stackoverflow.com/questions/28157929/how-to-safely-handle-an-exception-inside-a-context-manager # TODO: skip processing when it comes from a planified exit if len(args) >= 2 and args[1]: - log(args[1], level=logging.ERROR, fg='red') + error(args[1]) print(" Please submit this issue (and file) to https://github.com/archlinux/archinstall/issues") raise args[1] @@ -216,7 +204,25 @@ class AbstractMenu: def translation_handler(self) -> TranslationHandler: return self._translation_handler - def _setup_selection_menu_options(self): + def _populate_default_values(self): + for config_key, selector in self._menu_options.items(): + if selector.default is not None and config_key not in self._data_store: + self._data_store[config_key] = selector.default + + def _sync_all(self): + for key in self._menu_options.keys(): + self._sync(key) + + def _sync(self, selector_name: str): + value = self._data_store.get(selector_name, None) + selector = self._menu_options.get(selector_name, None) + + if value is not None: + self._menu_options[selector_name].set_current_selection(value) + elif selector is not None and selector.has_selection(): + self._data_store[selector_name] = selector.current_selection + + def setup_selection_menu_options(self): """ Define the menu options. Menu options can be defined here in a subclass or done per program calling self.set_option() """ @@ -234,31 +240,16 @@ class AbstractMenu: """ will be called at the end of the processing of the menu """ return - def synch(self, selector_name :str, omit_if_set :bool = False,omit_if_disabled :bool = False): - """ loads menu options with data_store value """ - arg = self._data_store.get(selector_name, None) - # don't display the menu option if it was defined already - if arg is not None and omit_if_set: - return - - if not self.option(selector_name).is_enabled() and omit_if_disabled: - return - - if arg is not None: - self._menu_options[selector_name].set_current_selection(arg) - def _update_enabled_order(self, selector_name: str): self._enabled_order.append(selector_name) - def enable(self, selector_name :str, omit_if_set :bool = False , mandatory :bool = False): + def enable(self, selector_name: str, mandatory: bool = False): """ activates menu options """ if self._menu_options.get(selector_name, None): self._menu_options[selector_name].set_enabled(True) self._update_enabled_order(selector_name) - - if mandatory: - self._menu_options[selector_name].set_mandatory(True) - self.synch(selector_name,omit_if_set) + self._menu_options[selector_name].set_mandatory(mandatory) + self._sync(selector_name) else: raise ValueError(f'No selector found: {selector_name}') @@ -274,7 +265,11 @@ class AbstractMenu: def _find_selection(self, selection_name: str) -> Tuple[str, Selector]: enabled_menus = self._menus_to_enable() padding = self._get_menu_text_padding(list(enabled_menus.values())) - option = [(k, v) for k, v in self._menu_options.items() if v.menu_text(padding).strip() == selection_name.strip()] + + option = [] + for k, v in self._menu_options.items(): + if v.menu_text(padding).strip() == selection_name.strip(): + option.append((k, v)) if len(option) != 1: raise ValueError(f'Selection not found: {selection_name}') @@ -283,18 +278,11 @@ class AbstractMenu: return config_name, selector def run(self, allow_reset: bool = False): - """ Calls the Menu framework""" - # we synch all the options just in case - for item in self.list_options(): - self.synch(item) - - self.post_callback() # as all the values can vary i have to exec this callback + self._sync_all() + self.post_callback() cursor_pos = None while True: - # Before continuing, set the preferred keyboard layout/language in the current terminal. - # This will just help the user with the next following questions. - self._set_kb_language() enabled_menus = self._menus_to_enable() padding = self._get_menu_text_padding(list(enabled_menus.values())) @@ -336,18 +324,18 @@ class AbstractMenu: value = value.strip() # if this calls returns false, we exit the menu - # we allow for an callback for special processing on realeasing control + # we allow for an callback for special processing on releasing control if not self._process_selection(value): break # we get the last action key - actions = {str(v.description):k for k,v in self._menu_options.items()} + actions = {str(v.description): k for k, v in self._menu_options.items()} self._last_choice = actions[selection.value.strip()] # type: ignore if not self.is_context_mgr: self.__exit__() - def _process_selection(self, selection_name :str) -> bool: + def _process_selection(self, selection_name: str) -> bool: """ determines and executes the selection y Can / Should be extended to handle specific selection issues Returns true if the menu shall continue, False if it has ended @@ -356,7 +344,7 @@ class AbstractMenu: config_name, selector = self._find_selection(selection_name) return self.exec_option(config_name, selector) - def exec_option(self, config_name :str, p_selector :Optional[Selector] = None) -> bool: + def exec_option(self, config_name: str, p_selector: Optional[Selector] = None) -> bool: """ processes the execution of a given menu entry - pre process callback - selection function @@ -372,40 +360,42 @@ class AbstractMenu: self.pre_callback(config_name) result = None + if selector.func is not None: - presel_val = self.option(config_name).get_selection() - result = selector.func(presel_val) + cur_value = self.option(config_name).get_selection() + result = selector.func(cur_value) self._menu_options[config_name].set_current_selection(result) + if selector.do_store(): self._data_store[config_name] = result - exec_ret_val = selector.exec_func(config_name,result) if selector.exec_func is not None else False - self.post_callback(config_name,result) - if exec_ret_val and self._check_mandatory_status(): + exec_ret_val = selector.exec_func(config_name, result) if selector.exec_func else False + + self.post_callback(config_name, result) + + if exec_ret_val: return False - return True - def _set_kb_language(self): - """ general for ArchInstall""" - # Before continuing, set the preferred keyboard layout/language in the current terminal. - # This will just help the user with the next following questions. - if self._data_store.get('keyboard-layout', None) and len(self._data_store['keyboard-layout']): - set_keyboard_language(self._data_store['keyboard-layout']) + return True - def _verify_selection_enabled(self, selection_name :str) -> bool: - """ general """ + def _verify_selection_enabled(self, selection_name: str) -> bool: if selection := self._menu_options.get(selection_name, None): if not selection.enabled: return False if len(selection.dependencies) > 0: - for d in selection.dependencies: - if not self._verify_selection_enabled(d) or self._menu_options[d].is_empty(): - return False + for dep in selection.dependencies: + if isinstance(dep, str): + if not self._verify_selection_enabled(dep) or self._menu_options[dep].is_empty(): + return False + elif callable(dep): # callable dependency eval + return dep() + else: + raise ValueError(f'Unsupported dependency: {selection_name}') if len(selection.dependencies_not) > 0: - for d in selection.dependencies_not: - if not self._menu_options[d].is_empty(): + for dep in selection.dependencies_not: + if not self._menu_options[dep].is_empty(): return False return True @@ -429,16 +419,10 @@ class AbstractMenu: return ordered_menus - def option(self,name :str) -> Selector: + def option(self, name: str) -> Selector: # TODO check inexistent name return self._menu_options[name] - def list_options(self) -> Iterator: - """ Iterator to retrieve the enabled menu option names - """ - for item in self._menu_options: - yield item - def list_enabled_options(self) -> Iterator: """ Iterator to retrieve the enabled menu options at a given time. The results are dynamic (if between calls to the iterator some elements -still not retrieved- are (de)activated @@ -447,44 +431,21 @@ class AbstractMenu: if item in self._menus_to_enable(): yield item - def set_option(self, name :str, selector :Selector): - self._menu_options[name] = selector - self.synch(name) - - def _check_mandatory_status(self) -> bool: - for field in self._menu_options: - option = self._menu_options[field] - if option.is_mandatory() and not option.has_selection(): - return False - return True - - def set_mandatory(self, field :str, status :bool): - self.option(field).set_mandatory(status) - - def mandatory_overview(self) -> Tuple[int, int]: - mandatory_fields = 0 - mandatory_waiting = 0 - for field, option in self._menu_options.items(): - if option.is_mandatory(): - mandatory_fields += 1 - if not option.has_selection(): - mandatory_waiting += 1 - return mandatory_fields, mandatory_waiting - - def _select_archinstall_language(self, preset_value: Language) -> Language: - language = select_archinstall_language(self.translation_handler.translated_languages, preset_value) + def _select_archinstall_language(self, preset: Language) -> Language: + from ..interactions.general_conf import select_archinstall_language + language = select_archinstall_language(self.translation_handler.translated_languages, preset) self._translation_handler.activate(language) return language class AbstractSubMenu(AbstractMenu): - def __init__(self, data_store: Optional[Dict[str, Any]] = None): - super().__init__(data_store=data_store) + def __init__(self, data_store: Dict[str, Any] = {}, preview_size: float = 0.2): + super().__init__(data_store=data_store, preview_size=preview_size) self._menu_options['__separator__'] = Selector('') self._menu_options['back'] = \ Selector( - _('Back'), + Menu.back(), no_store=True, enabled=True, exec_func=lambda n, v: True, diff --git a/archinstall/lib/menu/global_menu.py b/archinstall/lib/menu/global_menu.py deleted file mode 100644 index 7c5b153e..00000000 --- a/archinstall/lib/menu/global_menu.py +++ /dev/null @@ -1,429 +0,0 @@ -from __future__ import annotations - -from typing import Any, List, Optional, Union, Dict, TYPE_CHECKING - -import archinstall -from ..disk.encryption import DiskEncryptionMenu -from ..general import SysCommand, secret -from ..hardware import has_uefi -from ..menu import Menu -from ..menu.abstract_menu import Selector, AbstractMenu -from ..models import NetworkConfiguration -from ..models.disk_encryption import DiskEncryption, EncryptionType -from ..models.users import User -from ..output import FormattedOutput -from ..profiles import is_desktop_profile, Profile -from ..storage import storage -from ..user_interaction import add_number_of_parrallel_downloads -from ..user_interaction import ask_additional_packages_to_install -from ..user_interaction import ask_for_additional_users -from ..user_interaction import ask_for_audio_selection -from ..user_interaction import ask_for_bootloader -from ..user_interaction import ask_for_swap -from ..user_interaction import ask_hostname -from ..user_interaction import ask_ntp -from ..user_interaction import ask_to_configure_network -from ..user_interaction import get_password, ask_for_a_timezone, save_config -from ..user_interaction import select_additional_repositories -from ..user_interaction import select_disk_layout -from ..user_interaction import select_harddrives -from ..user_interaction import select_kernel -from ..user_interaction import select_language -from ..user_interaction import select_locale_enc -from ..user_interaction import select_locale_lang -from ..user_interaction import select_mirror_regions -from ..user_interaction import select_profile -from ..user_interaction.partitioning_conf import current_partition_layout - -if TYPE_CHECKING: - _: Any - - -class GlobalMenu(AbstractMenu): - def __init__(self,data_store): - self._disk_check = True - super().__init__(data_store=data_store, auto_cursor=True, preview_size=0.3) - - def _setup_selection_menu_options(self): - # archinstall.Language will not use preset values - self._menu_options['archinstall-language'] = \ - Selector( - _('Archinstall language'), - lambda x: self._select_archinstall_language(x), - display_func=lambda x: x.display_name, - default=self.translation_handler.get_language_by_abbr('en')) - self._menu_options['keyboard-layout'] = \ - Selector( - _('Keyboard layout'), - lambda preset: select_language(preset), - default='us') - self._menu_options['mirror-region'] = \ - Selector( - _('Mirror region'), - lambda preset: select_mirror_regions(preset), - display_func=lambda x: list(x.keys()) if x else '[]', - default={}) - self._menu_options['sys-language'] = \ - Selector( - _('Locale language'), - lambda preset: select_locale_lang(preset), - default='en_US') - self._menu_options['sys-encoding'] = \ - Selector( - _('Locale encoding'), - lambda preset: select_locale_enc(preset), - default='UTF-8') - self._menu_options['harddrives'] = \ - Selector( - _('Drive(s)'), - lambda preset: self._select_harddrives(preset), - display_func=lambda x: f'{len(x)} ' + str(_('Drive(s)')) if x is not None and len(x) > 0 else '', - preview_func=self._prev_harddrives, - ) - self._menu_options['disk_layouts'] = \ - Selector( - _('Disk layout'), - lambda preset: select_disk_layout( - preset, - storage['arguments'].get('harddrives', []), - storage['arguments'].get('advanced', False) - ), - preview_func=self._prev_disk_layouts, - display_func=lambda x: self._display_disk_layout(x), - dependencies=['harddrives']) - self._menu_options['disk_encryption'] = \ - Selector( - _('Disk encryption'), - lambda preset: self._disk_encryption(preset), - preview_func=self._prev_disk_encryption, - display_func=lambda x: self._display_disk_encryption(x), - dependencies=['disk_layouts']) - self._menu_options['swap'] = \ - Selector( - _('Swap'), - lambda preset: ask_for_swap(preset), - default=True) - self._menu_options['bootloader'] = \ - Selector( - _('Bootloader'), - lambda preset: ask_for_bootloader(storage['arguments'].get('advanced', False),preset), - default="systemd-bootctl" if has_uefi() else "grub-install") - self._menu_options['hostname'] = \ - Selector( - _('Hostname'), - ask_hostname, - default='archlinux') - # root password won't have preset value - self._menu_options['!root-password'] = \ - Selector( - _('Root password'), - lambda preset:self._set_root_password(), - display_func=lambda x: secret(x) if x else 'None') - self._menu_options['!users'] = \ - Selector( - _('User account'), - lambda x: self._create_user_account(x), - default={}, - display_func=lambda x: f'{len(x)} {_("User(s)")}' if len(x) > 0 else None, - preview_func=self._prev_users) - self._menu_options['profile'] = \ - Selector( - _('Profile'), - lambda preset: self._select_profile(preset), - display_func=lambda x: x if x else 'None' - ) - self._menu_options['audio'] = \ - Selector( - _('Audio'), - lambda preset: ask_for_audio_selection(is_desktop_profile(storage['arguments'].get('profile', None)),preset), - display_func=lambda x: x if x else 'None', - default=None - ) - - self._menu_options['parallel downloads'] = \ - Selector( - _('Parallel Downloads'), - add_number_of_parrallel_downloads, - display_func=lambda x: x if x else '0', - default=0 - ) - - self._menu_options['kernels'] = \ - Selector( - _('Kernels'), - lambda preset: select_kernel(preset), - default=['linux']) - self._menu_options['packages'] = \ - Selector( - _('Additional packages'), - # lambda x: ask_additional_packages_to_install(storage['arguments'].get('packages', None)), - ask_additional_packages_to_install, - default=[]) - self._menu_options['additional-repositories'] = \ - Selector( - _('Optional repositories'), - select_additional_repositories, - default=[]) - self._menu_options['nic'] = \ - Selector( - _('Network configuration'), - ask_to_configure_network, - display_func=lambda x: self._display_network_conf(x), - preview_func=self._prev_network_config, - default={}) - self._menu_options['timezone'] = \ - Selector( - _('Timezone'), - lambda preset: ask_for_a_timezone(preset), - default='UTC') - self._menu_options['ntp'] = \ - Selector( - _('Automatic time sync (NTP)'), - lambda preset: self._select_ntp(preset), - default=True) - self._menu_options['__separator__'] = \ - Selector('') - self._menu_options['save_config'] = \ - Selector( - _('Save configuration'), - lambda preset: save_config(self._data_store), - no_store=True) - self._menu_options['install'] = \ - Selector( - self._install_text(), - exec_func=lambda n,v: True if len(self._missing_configs()) == 0 else False, - preview_func=self._prev_install_missing_config, - no_store=True) - - self._menu_options['abort'] = Selector(_('Abort'), exec_func=lambda n,v:exit(1)) - - def _update_install_text(self, name :Optional[str] = None, result :Any = None): - text = self._install_text() - self._menu_options['install'].update_description(text) - - def post_callback(self,name :Optional[str] = None ,result :Any = None): - self._update_install_text(name, result) - - def _install_text(self): - missing = len(self._missing_configs()) - if missing > 0: - return _('Install ({} config(s) missing)').format(missing) - return _('Install') - - def _display_network_conf(self, cur_value: Union[NetworkConfiguration, List[NetworkConfiguration]]) -> str: - if not cur_value: - return _('Not configured, unavailable unless setup manually') - else: - if isinstance(cur_value, list): - return str(_('Configured {} interfaces')).format(len(cur_value)) - else: - return str(cur_value) - - def _disk_encryption(self, preset: Optional[DiskEncryption]) -> Optional[DiskEncryption]: - data_store: Dict[str, Any] = {} - - selector = self._menu_options['disk_layouts'] - - if selector.has_selection(): - layouts: Dict[str, Dict[str, Any]] = selector.current_selection - else: - # this should not happen as the encryption menu has the disk layout as dependency - raise ValueError('No disk layout specified') - - disk_encryption = DiskEncryptionMenu(data_store, preset, layouts).run() - return disk_encryption - - def _prev_network_config(self) -> Optional[str]: - selector = self._menu_options['nic'] - if selector.has_selection(): - ifaces = selector.current_selection - if isinstance(ifaces, list): - return FormattedOutput.as_table(ifaces) - return None - - def _prev_harddrives(self) -> Optional[str]: - selector = self._menu_options['harddrives'] - if selector.has_selection(): - drives = selector.current_selection - return FormattedOutput.as_table(drives) - return None - - def _prev_disk_layouts(self) -> Optional[str]: - selector = self._menu_options['disk_layouts'] - if selector.has_selection(): - layouts: Dict[str, Dict[str, Any]] = selector.current_selection - - output = '' - for device, layout in layouts.items(): - output += f'{_("Device")}: {device}\n\n' - output += current_partition_layout(layout['partitions'], with_title=False) - output += '\n\n' - - return output.rstrip() - - return None - - def _display_disk_layout(self, current_value: Optional[Dict[str, Any]]) -> str: - if current_value: - total_partitions = [entry['partitions'] for entry in current_value.values()] - total_nr = sum([len(p) for p in total_partitions]) - return f'{total_nr} {_("Partitions")}' - return '' - - def _prev_disk_encryption(self) -> Optional[str]: - selector = self._menu_options['disk_encryption'] - if selector.has_selection(): - encryption: DiskEncryption = selector.current_selection - - enc_type = EncryptionType.type_to_text(encryption.encryption_type) - output = str(_('Encryption type')) + f': {enc_type}\n' - output += str(_('Password')) + f': {secret(encryption.encryption_password)}\n' - - if encryption.all_partitions: - output += 'Partitions: {} selected'.format(len(encryption.all_partitions)) + '\n' - - if encryption.hsm_device: - output += f'HSM: {encryption.hsm_device.manufacturer}' - - return output - - return None - - def _display_disk_encryption(self, current_value: Optional[DiskEncryption]) -> str: - if current_value: - return EncryptionType.type_to_text(current_value.encryption_type) - return '' - - def _prev_install_missing_config(self) -> Optional[str]: - if missing := self._missing_configs(): - text = str(_('Missing configurations:\n')) - for m in missing: - text += f'- {m}\n' - return text[:-1] # remove last new line - return None - - def _prev_users(self) -> Optional[str]: - selector = self._menu_options['!users'] - if selector.has_selection(): - users: List[User] = selector.current_selection - return FormattedOutput.as_table(users) - return None - - def _missing_configs(self) -> List[str]: - def check(s): - return self._menu_options.get(s).has_selection() - - def has_superuser() -> bool: - users = self._menu_options['!users'].current_selection - return any([u.sudo for u in users]) - - missing = [] - if not check('bootloader'): - missing += ['Bootloader'] - if not check('hostname'): - missing += ['Hostname'] - if not check('!root-password') and not has_superuser(): - missing += [str(_('Either root-password or at least 1 user with sudo privileges must be specified'))] - if self._disk_check: - if not check('harddrives'): - missing += [str(_('Drive(s)'))] - if check('harddrives'): - if not self._menu_options['harddrives'].is_empty() and not check('disk_layouts'): - missing += [str(_('Disk layout'))] - - return missing - - def _set_root_password(self) -> Optional[str]: - prompt = str(_('Enter root password (leave blank to disable root): ')) - password = get_password(prompt=prompt) - return password - - # def _select_encrypted_password(self) -> Optional[str]: - # if passwd := get_password(prompt=str(_('Enter disk encryption password (leave blank for no encryption): '))): - # return passwd - # return None - - def _select_ntp(self, preset :bool = True) -> bool: - ntp = ask_ntp(preset) - - value = str(ntp).lower() - SysCommand(f'timedatectl set-ntp {value}') - - return ntp - - def _select_harddrives(self, old_harddrives: List[str] = []) -> List: - harddrives = select_harddrives(old_harddrives) - - if harddrives is not None: - if len(harddrives) == 0: - prompt = _( - "You decided to skip harddrive selection\nand will use whatever drive-setup is mounted at {} (experimental)\n" - "WARNING: Archinstall won't check the suitability of this setup\n" - "Do you wish to continue?" - ).format(storage['MOUNT_POINT']) - - choice = Menu(prompt, Menu.yes_no(), default_option=Menu.yes(), skip=False).run() - - if choice.value == Menu.no(): - self._disk_check = True - return self._select_harddrives(old_harddrives) - else: - self._disk_check = False - - # in case the harddrives got changed we have to reset the disk layout as well - if old_harddrives != harddrives: - self._menu_options['disk_layouts'].set_current_selection(None) - storage['arguments']['disk_layouts'] = {} - - return harddrives - - def _select_profile(self, preset) -> Optional[Profile]: - ret: Optional[Profile] = None - profile = select_profile(preset) - - if profile is None: - if any([ - archinstall.storage.get('profile_minimal', False), - archinstall.storage.get('_selected_servers', None), - archinstall.storage.get('_desktop_profile', None), - archinstall.arguments.get('desktop-environment', None), - archinstall.arguments.get('gfx_driver_packages', None) - ]): - return preset - else: # ctrl+c was actioned and all profile settings have been reset - return None - - servers = archinstall.storage.get('_selected_servers', []) - desktop = archinstall.storage.get('_desktop_profile', None) - desktop_env = archinstall.arguments.get('desktop-environment', None) - gfx_driver = archinstall.arguments.get('gfx_driver_packages', None) - - # Check the potentially selected profiles preparations to get early checks if some additional questions are needed. - if profile and profile.has_prep_function(): - namespace = f'{profile.namespace}.py' - with profile.load_instructions(namespace=namespace) as imported: - if imported._prep_function(servers=servers, desktop=desktop, desktop_env=desktop_env, gfx_driver=gfx_driver): - ret = profile - - match ret.name: - case 'minimal': - reset = ['_selected_servers', '_desktop_profile', 'desktop-environment', 'gfx_driver_packages'] - case 'server': - reset = ['_desktop_profile', 'desktop-environment'] - case 'desktop': - reset = ['_selected_servers'] - case 'xorg': - reset = ['_selected_servers', '_desktop_profile', 'desktop-environment'] - - for r in reset: - archinstall.storage[r] = None - else: - return self._select_profile(preset) - elif profile: - ret = profile - - return ret - - def _create_user_account(self, defined_users: List[User]) -> List[User]: - users = ask_for_additional_users(defined_users=defined_users) - return users diff --git a/archinstall/lib/menu/list_manager.py b/archinstall/lib/menu/list_manager.py index 1e09d987..de18791c 100644 --- a/archinstall/lib/menu/list_manager.py +++ b/archinstall/lib/menu/list_manager.py @@ -3,6 +3,7 @@ from os import system from typing import Any, TYPE_CHECKING, Dict, Optional, Tuple, List from .menu import Menu +from ..output import FormattedOutput if TYPE_CHECKING: _: Any @@ -34,7 +35,7 @@ class ListManager: self._data = copy.deepcopy(entries) explainer = str(_('\n Choose an object from the list, and select one of the available actions for it to execute')) - self._prompt = prompt + explainer if prompt else explainer + self._prompt = prompt if prompt else explainer self._separator = '' self._confirm_action = str(_('Confirm and exit')) @@ -44,13 +45,18 @@ class ListManager: self._base_actions = base_actions self._sub_menu_actions = sub_menu_actions - self._last_choice = None + self._last_choice: Optional[str] = None @property - def last_choice(self): + def last_choice(self) -> Optional[str]: return self._last_choice - def run(self): + def is_last_choice_cancel(self) -> bool: + if self._last_choice is not None: + return self._last_choice == self._cancel_action + return False + + def run(self) -> List[Any]: while True: # this will return a dictionary with the key as the menu entry to be displayed # and the value is the original value from the self._data container @@ -75,11 +81,12 @@ class ListManager: self._data = self.handle_action(choice.value, None, self._data) elif choice.value in self._terminate_actions: break - else: # an entry of the existing selection was choosen - selected_entry = data_formatted[choice.value] + else: # an entry of the existing selection was chosen + selected_entry = data_formatted[choice.value] # type: ignore self._run_actions_on_entry(selected_entry) - self._last_choice = choice + self._last_choice = choice.value # type: ignore + if choice.value == self._cancel_action: return self._original_data # return the original list else: @@ -121,22 +128,41 @@ class ListManager: if choice.value and choice.value != self._cancel_action: self._data = self.handle_action(choice.value, entry, self._data) - def selected_action_display(self, selection: Any) -> str: - # this will return the value to be displayed in the - # "Select an action for '{}'" string - raise NotImplementedError('Please implement me in the child class') + def reformat(self, data: List[Any]) -> Dict[str, Optional[Any]]: + """ + Default implementation of the table to be displayed. + Override if any custom formatting is needed + """ + table = FormattedOutput.as_table(data) + rows = table.split('\n') + + # these are the header rows of the table and do not map to any User obviously + # we're adding 2 spaces as prefix because the menu selector '> ' will be put before + # the selectable rows so the header has to be aligned + display_data: Dict[str, Optional[Any]] = {f' {rows[0]}': None, f' {rows[1]}': None} + + for row, entry in zip(rows[2:], data): + row = row.replace('|', '\\|') + display_data[row] = entry - def reformat(self, data: List[Any]) -> Dict[str, Any]: - # this should return a dictionary of display string to actual data entry - # mapping; if the value for a given display string is None it will be used - # in the header value (useful when displaying tables) + return display_data + + def selected_action_display(self, selection: Any) -> str: + """ + this will return the value to be displayed in the + "Select an action for '{}'" string + """ raise NotImplementedError('Please implement me in the child class') def handle_action(self, action: Any, entry: Optional[Any], data: List[Any]) -> List[Any]: - # this function is called when a base action or - # a specific action for an entry is triggered + """ + this function is called when a base action or + a specific action for an entry is triggered + """ raise NotImplementedError('Please implement me in the child class') - def filter_options(self, selection :Any, options :List[str]) -> List[str]: - # filter which actions to show for an specific selection + def filter_options(self, selection: Any, options: List[str]) -> List[str]: + """ + filter which actions to show for an specific selection + """ return options diff --git a/archinstall/lib/menu/menu.py b/archinstall/lib/menu/menu.py index 09685c55..38301d3a 100644 --- a/archinstall/lib/menu/menu.py +++ b/archinstall/lib/menu/menu.py @@ -3,14 +3,11 @@ from enum import Enum, auto from os import system from typing import Dict, List, Union, Any, TYPE_CHECKING, Optional, Callable -from .simple_menu import TerminalMenu +from simple_term_menu import TerminalMenu # type: ignore from ..exceptions import RequirementError -from ..output import log +from ..output import debug -from collections.abc import Iterable -import sys -import logging if TYPE_CHECKING: _: Any @@ -27,42 +24,61 @@ class MenuSelection: type_: MenuSelectionType value: Optional[Union[str, List[str]]] = None + @property + def single_value(self) -> Any: + return self.value # type: ignore + + @property + def multi_value(self) -> List[Any]: + return self.value # type: ignore + class Menu(TerminalMenu): + _menu_is_active: bool = False + + @staticmethod + def is_menu_active() -> bool: + return Menu._menu_is_active + + @classmethod + def back(cls) -> str: + return str(_('← Back')) @classmethod - def yes(cls): + def yes(cls) -> str: return str(_('yes')) @classmethod - def no(cls): + def no(cls) -> str: return str(_('no')) @classmethod - def yes_no(cls): + def yes_no(cls) -> List[str]: return [cls.yes(), cls.no()] def __init__( self, - title :str, - p_options :Union[List[str], Dict[str, Any]], - skip :bool = True, - multi :bool = False, - default_option : Optional[str] = None, - sort :bool = True, - preset_values :Union[str, List[str]] = None, - cursor_index : Optional[int] = None, - preview_command: Optional[Callable] = None, + title: str, + p_options: Union[List[str], Dict[str, Any]], + skip: bool = True, + multi: bool = False, + default_option: Optional[str] = None, + sort: bool = True, + preset_values: Optional[Union[str, List[str]]] = None, + cursor_index: Optional[int] = None, + preview_command: Optional[Callable[[Any], str | None]] = None, preview_size: float = 0.0, preview_title: str = 'Info', - header :Union[List[str],str] = None, - allow_reset :bool = False, - allow_reset_warning_msg :str = '', + header: Union[List[str], str] = [], + allow_reset: bool = False, + allow_reset_warning_msg: Optional[str] = None, clear_screen: bool = True, show_search_hint: bool = True, cycle_cursor: bool = True, clear_menu_on_exit: bool = True, - skip_empty_entries: bool = False + skip_empty_entries: bool = False, + display_back_option: bool = False, + extra_bottom_space: bool = False ): """ Creates a new menu @@ -72,7 +88,7 @@ class Menu(TerminalMenu): :param p_options: Options to be displayed in the menu to chose from; if dict is specified then the keys of such will be used as options - :type options: list, dict + :type p_options: list, dict :param skip: Indicate if the selection is not mandatory and can be skipped :type skip: bool @@ -101,46 +117,27 @@ class Menu(TerminalMenu): :param preview_title: Title of the preview window :type preview_title: str - param header: one or more header lines for the menu - type param: string or list + :param header: one or more header lines for the menu + :type header: string or list - param raise_error_on_interrupt: This will explicitly handle a ctrl+c instead and return that specific state - type param: bool + :param allow_reset: This will explicitly handle a ctrl+c instead and return that specific state + :type allow_reset: bool - param raise_error_warning_msg: If raise_error_on_interrupt is True and this is non-empty, there will be a warning with a user confirmation displayed - type param: str + param allow_reset_warning_msg: If raise_error_on_interrupt is True the warning is set, a user confirmation is displayed + type allow_reset_warning_msg: str - :param kwargs : any SimpleTerminal parameter + :param extra_bottom_space: Add an extra empty line at the end of the menu + :type extra_bottom_space: bool """ - # we guarantee the inmutability of the options outside the class. - # an unknown number of iterables (.keys(),.values(),generator,...) can't be directly copied, in this case - # we recourse to make them lists before, but thru an exceptions - # this is the old code, which is not maintenable with more types - # options = copy(list(p_options) if isinstance(p_options,(type({}.keys()),type({}.values()))) else p_options) - # We check that the options are iterable. If not we abort. Else we copy them to lists - # it options is a dictionary we use the values as entries of the list - # if options is a string object, each character becomes an entry - # if options is a list, we implictily build a copy to maintain immutability - if not isinstance(p_options,Iterable): - log(f"Objects of type {type(p_options)} is not iterable, and are not supported at Menu",fg="red") - log(f"invalid parameter at Menu() call was at <{sys._getframe(1).f_code.co_name}>",level=logging.WARNING) - raise RequirementError("Menu() requires an iterable as option.") - - self._default_str = str(_('(default)')) - - if isinstance(p_options,dict): + if isinstance(p_options, Dict): options = list(p_options.keys()) else: options = list(p_options) if not options: - log(" * Menu didn't find any options to choose from * ", fg='red') - log(f"invalid parameter at Menu() call was at <{sys._getframe(1).f_code.co_name}>",level=logging.WARNING) raise RequirementError('Menu.__init__() requires at least one option to proceed.') if any([o for o in options if not isinstance(o, str)]): - log(" * Menu options must be of type string * ", fg='red') - log(f"invalid parameter at Menu() call was at <{sys._getframe(1).f_code.co_name}>",level=logging.WARNING) raise RequirementError('Menu.__init__() requires the options to be of type string') if sort: @@ -152,7 +149,6 @@ class Menu(TerminalMenu): self._multi = multi self._raise_error_on_interrupt = allow_reset self._raise_error_warning_msg = allow_reset_warning_msg - self._preview_command = preview_command action_info = '' if skip: @@ -179,10 +175,28 @@ class Menu(TerminalMenu): if default_option: # if a default value was specified we move that one # to the top of the list and mark it as default as well - default = f'{default_option} {self._default_str}' - self._menu_options = [default] + [o for o in self._menu_options if default_option != o] + self._menu_options = [self._default_menu_value] + [o for o in self._menu_options if default_option != o] + + if display_back_option and not multi and skip: + skip_empty_entries = True + self._menu_options += ['', self.back()] + + if extra_bottom_space: + skip_empty_entries = True + self._menu_options += [''] + + preset_list: Optional[List[str]] = None + + if preset_values and isinstance(preset_values, str): + preset_list = [preset_values] - self._preselection(preset_values,cursor_index) + calc_cursor_idx = self._determine_cursor_pos(preset_list, cursor_index) + + # when we're not in multi selection mode we don't care about + # passing the pre-selection list to the menu as the position + # of the cursor is the one determining the pre-selection + if not self._multi: + preset_values = None cursor = "> " main_menu_cursor_style = ("fg_cyan", "bold") @@ -194,13 +208,10 @@ class Menu(TerminalMenu): menu_cursor=cursor, menu_cursor_style=main_menu_cursor_style, menu_highlight_style=main_menu_style, - # cycle_cursor=True, - # clear_screen=True, multi_select=multi, - # show_search_hint=True, - preselected_entries=self.preset_values, - cursor_index=self.cursor_index, - preview_command=lambda x: self._preview_wrapper(preview_command, x), + preselected_entries=preset_values, + cursor_index=calc_cursor_idx, + preview_command=lambda x: self._show_preview(preview_command, x), preview_size=preview_size, preview_title=preview_title, raise_error_on_interrupt=self._raise_error_on_interrupt, @@ -212,6 +223,28 @@ class Menu(TerminalMenu): skip_empty_entries=skip_empty_entries ) + @property + def _default_menu_value(self) -> str: + default_str = str(_('(default)')) + return f'{self._default_option} {default_str}' + + def _show_preview( + self, + preview_command: Optional[Callable[[Any], str | None]], + selection: str + ) -> Optional[str]: + if selection == self.back(): + return None + + if preview_command: + if self._default_option is not None and self._default_menu_value == selection: + selection = self._default_option + + if res := preview_command(selection): + return res.rstrip('\n') + + return None + def _show(self) -> MenuSelection: try: idx = self.show() @@ -219,45 +252,47 @@ class Menu(TerminalMenu): return MenuSelection(type_=MenuSelectionType.Reset) def check_default(elem): - if self._default_option is not None and f'{self._default_option} {self._default_str}' in elem: + if self._default_option is not None and self._default_menu_value in elem: return self._default_option else: return elem if idx is not None: - if isinstance(idx, (list, tuple)): + if isinstance(idx, (list, tuple)): # on multi selection results = [] for i in idx: option = check_default(self._menu_options[i]) results.append(option) return MenuSelection(type_=MenuSelectionType.Selection, value=results) - else: + else: # on single selection result = check_default(self._menu_options[idx]) return MenuSelection(type_=MenuSelectionType.Selection, value=result) else: return MenuSelection(type_=MenuSelectionType.Skip) - def _preview_wrapper(self, preview_command: Optional[Callable], current_selection: str) -> Optional[str]: - if preview_command: - if self._default_option is not None and f'{self._default_option} {self._default_str}' == current_selection: - current_selection = self._default_option - return preview_command(current_selection) - return None - def run(self) -> MenuSelection: - ret = self._show() + Menu._menu_is_active = True + + selection = self._show() - if ret.type_ == MenuSelectionType.Reset: - if self._raise_error_on_interrupt and len(self._raise_error_warning_msg) > 0: + if selection.type_ == MenuSelectionType.Reset: + if self._raise_error_on_interrupt and self._raise_error_warning_msg is not None: response = Menu(self._raise_error_warning_msg, Menu.yes_no(), skip=False).run() if response.value == Menu.no(): return self.run() - elif ret.type_ is MenuSelectionType.Skip: + elif selection.type_ is MenuSelectionType.Skip: if not self._skip: system('clear') return self.run() - return ret + if selection.type_ == MenuSelectionType.Selection: + if selection.value == self.back(): + selection.type_ = MenuSelectionType.Skip + selection.value = None + + Menu._menu_is_active = False + + return selection def set_cursor_pos(self,pos :int): if pos and 0 < pos < len(self._menu_entries): @@ -269,31 +304,47 @@ class Menu(TerminalMenu): pos = self._menu_entries.index(value) self.set_cursor_pos(pos) - def _preselection(self,preset_values :Union[str, List[str]] = [], cursor_index : Optional[int] = None): - def from_preset_to_cursor(): - if preset_values: - # if the value is not extant return 0 as cursor index + def _determine_cursor_pos( + self, + preset: Optional[List[str]] = None, + cursor_index: Optional[int] = None + ) -> Optional[int]: + """ + The priority order to determine the cursor position is: + 1. A static cursor position was provided + 2. Preset values have been provided so the cursor will be + positioned on those + 3. A default value for a selection is given so the cursor + will be placed on such + """ + if cursor_index: + return cursor_index + + if preset: + indexes = [] + + for p in preset: try: - if isinstance(preset_values,str): - self.cursor_index = self._menu_options.index(self.preset_values) - else: # should return an error, but this is smoother - self.cursor_index = self._menu_options.index(self.preset_values[0]) - except ValueError: - self.cursor_index = 0 - - self.cursor_index = cursor_index - if not preset_values: - self.preset_values = None - return - - self.preset_values = preset_values + # the options of the table selection menu + # are already escaped so we have to escape + # the preset values as well for the comparison + if '|' in p: + p = p.replace('|', '\\|') + + if p in self._menu_options: + idx = self._menu_options.index(p) + else: + idx = self._menu_options.index(self._default_menu_value) + indexes.append(idx) + except (IndexError, ValueError): + debug(f'Error finding index of {p}: {self._menu_options}') + + if len(indexes) == 0: + indexes.append(0) + + return indexes[0] + if self._default_option: - if isinstance(preset_values,str) and self._default_option == preset_values: - self.preset_values = f"{preset_values} {self._default_str}" - elif isinstance(preset_values,(list,tuple)) and self._default_option in preset_values: - idx = preset_values.index(self._default_option) - self.preset_values[idx] = f"{preset_values[idx]} {self._default_str}" - if cursor_index is None or not self._multi: - from_preset_to_cursor() - if not self._multi: # Not supported by the infraestructure - self.preset_values = None + return self._menu_options.index(self._default_menu_value) + + return None diff --git a/archinstall/lib/menu/simple_menu.py b/archinstall/lib/menu/simple_menu.py deleted file mode 100644 index 1980e2ce..00000000 --- a/archinstall/lib/menu/simple_menu.py +++ /dev/null @@ -1,2002 +0,0 @@ -""" -This file is copied over from the simple-term-menu project -(https://github.com/IngoMeyer441/simple-term-menu) -In order to comply with installation methods of Arch Linux. -We here by copy the MIT license attached to the project at the time of copy: - -Copyright 2021 Forschungszentrum Jülich GmbH - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -""" -import argparse -import copy -import ctypes -import io -import locale -import os -import platform -import re -import shlex -import signal -import string -import subprocess -import sys -from locale import getlocale -from types import FrameType -from typing import ( - Any, - Callable, - Dict, - Iterable, - Iterator, - List, - Match, - Optional, - Pattern, - Sequence, - Set, - TextIO, - Tuple, - Union, - cast, -) - -try: - import termios -except ImportError as e: - raise NotImplementedError('"{}" is currently not supported.'.format(platform.system())) from e - -__author__ = "Ingo Meyer" -__email__ = "i.meyer@fz-juelich.de" -__copyright__ = "Copyright © 2021 Forschungszentrum Jülich GmbH. All rights reserved." -__license__ = "MIT" -__version_info__ = (1, 5, 0) -__version__ = ".".join(map(str, __version_info__)) - - -DEFAULT_ACCEPT_KEYS = ("enter",) -DEFAULT_CLEAR_MENU_ON_EXIT = True -DEFAULT_CLEAR_SCREEN = False -DEFAULT_CYCLE_CURSOR = True -DEFAULT_EXIT_ON_SHORTCUT = True -DEFAULT_MENU_CURSOR = "> " -DEFAULT_MENU_CURSOR_STYLE = ("fg_red", "bold") -DEFAULT_MENU_HIGHLIGHT_STYLE = ("standout",) -DEFAULT_MULTI_SELECT = False -DEFAULT_MULTI_SELECT_CURSOR = "[*] " -DEFAULT_MULTI_SELECT_CURSOR_BRACKETS_STYLE = ("fg_gray",) -DEFAULT_MULTI_SELECT_CURSOR_STYLE = ("fg_yellow", "bold") -DEFAULT_MULTI_SELECT_KEYS = (" ", "tab") -DEFAULT_MULTI_SELECT_SELECT_ON_ACCEPT = True -DEFAULT_PREVIEW_BORDER = True -DEFAULT_PREVIEW_SIZE = 0.25 -DEFAULT_PREVIEW_TITLE = "preview" -DEFAULT_QUIT_KEYS = ("escape", "q") -DEFAULT_SEARCH_CASE_SENSITIVE = False -DEFAULT_SEARCH_HIGHLIGHT_STYLE = ("fg_black", "bg_yellow", "bold") -DEFAULT_SEARCH_KEY = "/" -DEFAULT_SHORTCUT_BRACKETS_HIGHLIGHT_STYLE = ("fg_gray",) -DEFAULT_SHORTCUT_KEY_HIGHLIGHT_STYLE = ("fg_blue",) -DEFAULT_SHOW_MULTI_SELECT_HINT = False -DEFAULT_SHOW_SEARCH_HINT = False -DEFAULT_SHOW_SHORTCUT_HINTS = False -DEFAULT_SHOW_SHORTCUT_HINTS_IN_STATUS_BAR = True -DEFAULT_STATUS_BAR_BELOW_PREVIEW = False -DEFAULT_STATUS_BAR_STYLE = ("fg_yellow", "bg_black") -MIN_VISIBLE_MENU_ENTRIES_COUNT = 3 - - -class InvalidParameterCombinationError(Exception): - pass - - -class InvalidStyleError(Exception): - pass - - -class NoMenuEntriesError(Exception): - pass - - -class PreviewCommandFailedError(Exception): - pass - - -class UnknownMenuEntryError(Exception): - pass - - -def get_locale() -> str: - user_locale = locale.getlocale()[1] - if user_locale is None: - return "ascii" - else: - return user_locale.lower() - - -def wcswidth(text: str) -> int: - if not hasattr(wcswidth, "libc"): - if platform.system() == "Darwin": - wcswidth.libc = ctypes.cdll.LoadLibrary("libSystem.dylib") # type: ignore - else: - wcswidth.libc = ctypes.cdll.LoadLibrary("libc.so.6") # type: ignore - user_locale = get_locale() - # First replace any null characters with the unicode replacement character (U+FFFD) since they cannot be passed - # in a `c_wchar_p` - encoded_text = text.replace("\0", "\uFFFD").encode(encoding=user_locale, errors="replace") - return wcswidth.libc.wcswidth( # type: ignore - ctypes.c_wchar_p(encoded_text.decode(encoding=user_locale)), len(encoded_text) - ) - - -def static_variables(**variables: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]: - def decorator(f: Callable[..., Any]) -> Callable[..., Any]: - for key, value in variables.items(): - setattr(f, key, value) - return f - - return decorator - - -class BoxDrawingCharacters: - if getlocale()[1] == "UTF-8": - # Unicode box characters - horizontal = "─" - vertical = "│" - upper_left = "┌" - upper_right = "┐" - lower_left = "└" - lower_right = "┘" - else: - # ASCII box characters - horizontal = "-" - vertical = "|" - upper_left = "+" - upper_right = "+" - lower_left = "+" - lower_right = "+" - - -class TerminalMenu: - class Search: - def __init__( - self, - menu_entries: Iterable[str], - search_text: Optional[str] = None, - case_senitive: bool = False, - show_search_hint: bool = False, - ): - self._menu_entries = menu_entries - self._case_sensitive = case_senitive - self._show_search_hint = show_search_hint - self._matches = [] # type: List[Tuple[int, Match[str]]] - self._search_regex = None # type: Optional[Pattern[str]] - self._change_callback = None # type: Optional[Callable[[], None]] - # Use the property setter since it has some more logic - self.search_text = search_text - - def _update_matches(self) -> None: - if self._search_regex is None: - self._matches = [] - else: - matches = [] - for i, menu_entry in enumerate(self._menu_entries): - match_obj = self._search_regex.search(menu_entry) - if match_obj: - matches.append((i, match_obj)) - self._matches = matches - - @property - def matches(self) -> List[Tuple[int, Match[str]]]: - return list(self._matches) - - @property - def search_regex(self) -> Optional[Pattern[str]]: - return self._search_regex - - @property - def search_text(self) -> Optional[str]: - return self._search_text - - @search_text.setter - def search_text(self, text: Optional[str]) -> None: - self._search_text = text - search_text = self._search_text - self._search_regex = None - while search_text and self._search_regex is None: - try: - self._search_regex = re.compile(search_text, flags=re.IGNORECASE if not self._case_sensitive else 0) - except re.error: - search_text = search_text[:-1] - self._update_matches() - if self._change_callback: - self._change_callback() - - @property - def change_callback(self) -> Optional[Callable[[], None]]: - return self._change_callback - - @change_callback.setter - def change_callback(self, callback: Optional[Callable[[], None]]) -> None: - self._change_callback = callback - - @property - def occupied_lines_count(self) -> int: - if not self and not self._show_search_hint: - return 0 - else: - return 1 - - def __bool__(self) -> bool: - return self._search_text is not None - - def __contains__(self, menu_index: int) -> bool: - return any(i == menu_index for i, _ in self._matches) - - def __len__(self) -> int: - return wcswidth(self._search_text) if self._search_text is not None else 0 - - class Selection: - def __init__(self, num_menu_entries: int, preselected_indices: Optional[Iterable[int]] = None): - self._num_menu_entries = num_menu_entries - self._selected_menu_indices = set(preselected_indices) if preselected_indices is not None else set() - - def clear(self) -> None: - self._selected_menu_indices.clear() - - def add(self, menu_index: int) -> None: - self[menu_index] = True - - def remove(self, menu_index: int) -> None: - self[menu_index] = False - - def toggle(self, menu_index: int) -> bool: - self[menu_index] = menu_index not in self._selected_menu_indices - return self[menu_index] - - def __bool__(self) -> bool: - return bool(self._selected_menu_indices) - - def __contains__(self, menu_index: int) -> bool: - return menu_index in self._selected_menu_indices - - def __getitem__(self, menu_index: int) -> bool: - return menu_index in self._selected_menu_indices - - def __setitem__(self, menu_index: int, is_selected: bool) -> None: - if is_selected: - self._selected_menu_indices.add(menu_index) - else: - self._selected_menu_indices.remove(menu_index) - - def __iter__(self) -> Iterator[int]: - return iter(self._selected_menu_indices) - - @property - def selected_menu_indices(self) -> Tuple[int, ...]: - return tuple(sorted(self._selected_menu_indices)) - - class View: - def __init__( - self, - menu_entries: Iterable[str], - search: "TerminalMenu.Search", - selection: "TerminalMenu.Selection", - viewport: "TerminalMenu.Viewport", - cycle_cursor: bool = True, - skip_indices: List[int] = [], - ): - self._menu_entries = list(menu_entries) - self._search = search - self._selection = selection - self._viewport = viewport - self._cycle_cursor = cycle_cursor - self._active_displayed_index = None # type: Optional[int] - self._skip_indices = skip_indices - self.update_view() - - def update_view(self) -> None: - if self._search and self._search.search_text != "": - self._displayed_index_to_menu_index = tuple(i for i, match_obj in self._search.matches) - else: - self._displayed_index_to_menu_index = tuple(range(len(self._menu_entries))) - self._menu_index_to_displayed_index = { - menu_index: displayed_index - for displayed_index, menu_index in enumerate(self._displayed_index_to_menu_index) - } - self._active_displayed_index = 0 if self._displayed_index_to_menu_index else None - self._viewport.search_lines_count = self._search.occupied_lines_count - self._viewport.keep_visible(self._active_displayed_index) - - def increment_active_index(self) -> None: - if self._active_displayed_index is not None: - if self._active_displayed_index + 1 < len(self._displayed_index_to_menu_index): - self._active_displayed_index += 1 - elif self._cycle_cursor: - self._active_displayed_index = 0 - self._viewport.keep_visible(self._active_displayed_index) - - if self._active_displayed_index in self._skip_indices: - self.increment_active_index() - - def decrement_active_index(self) -> None: - if self._active_displayed_index is not None: - if self._active_displayed_index > 0: - self._active_displayed_index -= 1 - elif self._cycle_cursor: - self._active_displayed_index = len(self._displayed_index_to_menu_index) - 1 - self._viewport.keep_visible(self._active_displayed_index) - - if self._active_displayed_index in self._skip_indices: - self.decrement_active_index() - - def is_visible(self, menu_index: int) -> bool: - return menu_index in self._menu_index_to_displayed_index and ( - self._viewport.lower_index - <= self._menu_index_to_displayed_index[menu_index] - <= self._viewport.upper_index - ) - - def convert_menu_index_to_displayed_index(self, menu_index: int) -> Optional[int]: - if menu_index in self._menu_index_to_displayed_index: - return self._menu_index_to_displayed_index[menu_index] - else: - return None - - def convert_displayed_index_to_menu_index(self, displayed_index: int) -> int: - return self._displayed_index_to_menu_index[displayed_index] - - @property - def active_menu_index(self) -> Optional[int]: - if self._active_displayed_index is not None: - return self._displayed_index_to_menu_index[self._active_displayed_index] - else: - return None - - @active_menu_index.setter - def active_menu_index(self, value: int) -> None: - self._selected_index = value - self._active_displayed_index = [ - displayed_index - for displayed_index, menu_index in enumerate(self._displayed_index_to_menu_index) - if menu_index == value - ][0] - self._viewport.keep_visible(self._active_displayed_index) - - @property - def active_displayed_index(self) -> Optional[int]: - return self._active_displayed_index - - @property - def displayed_selected_indices(self) -> List[int]: - return [ - self._menu_index_to_displayed_index[selected_index] - for selected_index in self._selection - if selected_index in self._menu_index_to_displayed_index - ] - - def __bool__(self) -> bool: - return self._active_displayed_index is not None - - def __iter__(self) -> Iterator[Tuple[int, int, str]]: - for displayed_index, menu_index in enumerate(self._displayed_index_to_menu_index): - if self._viewport.lower_index <= displayed_index <= self._viewport.upper_index: - yield (displayed_index, menu_index, self._menu_entries[menu_index]) - - class Viewport: - def __init__( - self, - num_menu_entries: int, - title_lines_count: int, - status_bar_lines_count: int, - preview_lines_count: int, - search_lines_count: int, - ): - self._num_menu_entries = num_menu_entries - self._title_lines_count = title_lines_count - self._status_bar_lines_count = status_bar_lines_count - # Use the property setter since it has some more logic - self.preview_lines_count = preview_lines_count - self.search_lines_count = search_lines_count - self._num_lines = self._calculate_num_lines() - self._viewport = (0, min(self._num_menu_entries, self._num_lines) - 1) - self.keep_visible(cursor_position=None, refresh_terminal_size=False) - - def _calculate_num_lines(self) -> int: - return ( - TerminalMenu._num_lines() - - self._title_lines_count - - self._status_bar_lines_count - - self._preview_lines_count - - self._search_lines_count - ) - - def keep_visible(self, cursor_position: Optional[int], refresh_terminal_size: bool = True) -> None: - # Treat `cursor_position=None` like `cursor_position=0` - if cursor_position is None: - cursor_position = 0 - if refresh_terminal_size: - self.update_terminal_size() - if self._viewport[0] <= cursor_position <= self._viewport[1]: - # Cursor is already visible - return - if cursor_position < self._viewport[0]: - scroll_num = cursor_position - self._viewport[0] - else: - scroll_num = cursor_position - self._viewport[1] - self._viewport = (self._viewport[0] + scroll_num, self._viewport[1] + scroll_num) - - def update_terminal_size(self) -> None: - num_lines = self._calculate_num_lines() - if num_lines != self._num_lines: - # First let the upper index grow or shrink - upper_index = min(num_lines, self._num_menu_entries) - 1 - # Then, use as much space as possible for the `lower_index` - lower_index = max(0, upper_index - num_lines) - self._viewport = (lower_index, upper_index) - self._num_lines = num_lines - - @property - def lower_index(self) -> int: - return self._viewport[0] - - @property - def upper_index(self) -> int: - return self._viewport[1] - - @property - def viewport(self) -> Tuple[int, int]: - return self._viewport - - @property - def size(self) -> int: - return self._viewport[1] - self._viewport[0] + 1 - - @property - def num_menu_entries(self) -> int: - return self._num_menu_entries - - @property - def title_lines_count(self) -> int: - return self._title_lines_count - - @property - def status_bar_lines_count(self) -> int: - return self._status_bar_lines_count - - @status_bar_lines_count.setter - def status_bar_lines_count(self, value: int) -> None: - self._status_bar_lines_count = value - - @property - def preview_lines_count(self) -> int: - return self._preview_lines_count - - @preview_lines_count.setter - def preview_lines_count(self, value: int) -> None: - self._preview_lines_count = min( - value if value >= 3 else 0, - TerminalMenu._num_lines() - - self._title_lines_count - - self._status_bar_lines_count - - MIN_VISIBLE_MENU_ENTRIES_COUNT, - ) - - @property - def search_lines_count(self) -> int: - return self._search_lines_count - - @search_lines_count.setter - def search_lines_count(self, value: int) -> None: - self._search_lines_count = value - - @property - def must_scroll(self) -> bool: - return self._num_menu_entries > self._num_lines - - _codename_to_capname = { - "bg_black": "setab 0", - "bg_blue": "setab 4", - "bg_cyan": "setab 6", - "bg_gray": "setab 7", - "bg_green": "setab 2", - "bg_purple": "setab 5", - "bg_red": "setab 1", - "bg_yellow": "setab 3", - "bold": "bold", - "clear": "clear", - "colors": "colors", - "cursor_down": "cud1", - "cursor_invisible": "civis", - "cursor_left": "cub1", - "cursor_right": "cuf1", - "cursor_up": "cuu1", - "cursor_visible": "cnorm", - "delete_line": "dl1", - "down": "kcud1", - "enter_application_mode": "smkx", - "exit_application_mode": "rmkx", - "fg_black": "setaf 0", - "fg_blue": "setaf 4", - "fg_cyan": "setaf 6", - "fg_gray": "setaf 7", - "fg_green": "setaf 2", - "fg_purple": "setaf 5", - "fg_red": "setaf 1", - "fg_yellow": "setaf 3", - "italics": "sitm", - "reset_attributes": "sgr0", - "standout": "smso", - "underline": "smul", - "up": "kcuu1", - } - _name_to_control_character = { - "backspace": "", # Is assigned later in `self._init_backspace_control_character` - "ctrl-j": "\012", - "ctrl-k": "\013", - "enter": "\015", - "escape": "\033", - "tab": "\t", - } - _codenames = tuple(_codename_to_capname.keys()) - _codename_to_terminal_code = None # type: Optional[Dict[str, str]] - _terminal_code_to_codename = None # type: Optional[Dict[str, str]] - - def __init__( - self, - menu_entries: Iterable[str], - *, - accept_keys: Iterable[str] = DEFAULT_ACCEPT_KEYS, - clear_menu_on_exit: bool = DEFAULT_CLEAR_MENU_ON_EXIT, - clear_screen: bool = DEFAULT_CLEAR_SCREEN, - cursor_index: Optional[int] = None, - cycle_cursor: bool = DEFAULT_CYCLE_CURSOR, - exit_on_shortcut: bool = DEFAULT_EXIT_ON_SHORTCUT, - menu_cursor: Optional[str] = DEFAULT_MENU_CURSOR, - menu_cursor_style: Optional[Iterable[str]] = DEFAULT_MENU_CURSOR_STYLE, - menu_highlight_style: Optional[Iterable[str]] = DEFAULT_MENU_HIGHLIGHT_STYLE, - multi_select: bool = DEFAULT_MULTI_SELECT, - multi_select_cursor: str = DEFAULT_MULTI_SELECT_CURSOR, - multi_select_cursor_brackets_style: Optional[Iterable[str]] = DEFAULT_MULTI_SELECT_CURSOR_BRACKETS_STYLE, - multi_select_cursor_style: Optional[Iterable[str]] = DEFAULT_MULTI_SELECT_CURSOR_STYLE, - multi_select_empty_ok: bool = False, - multi_select_keys: Optional[Iterable[str]] = DEFAULT_MULTI_SELECT_KEYS, - multi_select_select_on_accept: bool = DEFAULT_MULTI_SELECT_SELECT_ON_ACCEPT, - preselected_entries: Optional[Iterable[Union[str, int]]] = None, - preview_border: bool = DEFAULT_PREVIEW_BORDER, - preview_command: Optional[Union[str, Callable[[str], str]]] = None, - preview_size: float = DEFAULT_PREVIEW_SIZE, - preview_title: str = DEFAULT_PREVIEW_TITLE, - quit_keys: Iterable[str] = DEFAULT_QUIT_KEYS, - raise_error_on_interrupt: bool = False, - search_case_sensitive: bool = DEFAULT_SEARCH_CASE_SENSITIVE, - search_highlight_style: Optional[Iterable[str]] = DEFAULT_SEARCH_HIGHLIGHT_STYLE, - search_key: Optional[str] = DEFAULT_SEARCH_KEY, - shortcut_brackets_highlight_style: Optional[Iterable[str]] = DEFAULT_SHORTCUT_BRACKETS_HIGHLIGHT_STYLE, - shortcut_key_highlight_style: Optional[Iterable[str]] = DEFAULT_SHORTCUT_KEY_HIGHLIGHT_STYLE, - show_multi_select_hint: bool = DEFAULT_SHOW_MULTI_SELECT_HINT, - show_multi_select_hint_text: Optional[str] = None, - show_search_hint: bool = DEFAULT_SHOW_SEARCH_HINT, - show_search_hint_text: Optional[str] = None, - show_shortcut_hints: bool = DEFAULT_SHOW_SHORTCUT_HINTS, - show_shortcut_hints_in_status_bar: bool = DEFAULT_SHOW_SHORTCUT_HINTS_IN_STATUS_BAR, - skip_empty_entries: bool = False, - status_bar: Optional[Union[str, Iterable[str], Callable[[str], str]]] = None, - status_bar_below_preview: bool = DEFAULT_STATUS_BAR_BELOW_PREVIEW, - status_bar_style: Optional[Iterable[str]] = DEFAULT_STATUS_BAR_STYLE, - title: Optional[Union[str, Iterable[str]]] = None - ): - def extract_shortcuts_menu_entries_and_preview_arguments( - entries: Iterable[str], - ) -> Tuple[List[str], List[Optional[str]], List[Optional[str]], List[int]]: - separator_pattern = re.compile(r"([^\\])\|") - escaped_separator_pattern = re.compile(r"\\\|") - menu_entry_pattern = re.compile(r"^(?:\[(\S)\]\s*)?([^\x1F]+)(?:\x1F([^\x1F]*))?") - shortcut_keys = [] # type: List[Optional[str]] - menu_entries = [] # type: List[str] - preview_arguments = [] # type: List[Optional[str]] - skip_indices = [] # type: List[int] - - for idx, entry in enumerate(entries): - if entry is None or (entry == "" and skip_empty_entries): - shortcut_keys.append(None) - menu_entries.append("") - preview_arguments.append(None) - skip_indices.append(idx) - else: - unit_separated_entry = escaped_separator_pattern.sub("|", separator_pattern.sub("\\1\x1F", entry)) - match_obj = menu_entry_pattern.match(unit_separated_entry) - # this is none in case the entry was an emtpy string which - # will be interpreted as a separator - assert match_obj is not None - shortcut_key = match_obj.group(1) - display_text = match_obj.group(2) - preview_argument = match_obj.group(3) - shortcut_keys.append(shortcut_key) - menu_entries.append(display_text) - preview_arguments.append(preview_argument) - - return menu_entries, shortcut_keys, preview_arguments, skip_indices - - def convert_preselected_entries_to_indices( - preselected_indices_or_entries: Iterable[Union[str, int]] - ) -> Set[int]: - menu_entry_to_indices = {} # type: Dict[str, Set[int]] - for menu_index, menu_entry in enumerate(self._menu_entries): - menu_entry_to_indices.setdefault(menu_entry, set()) - menu_entry_to_indices[menu_entry].add(menu_index) - preselected_indices = set() - for item in preselected_indices_or_entries: - if isinstance(item, int): - if 0 <= item < len(self._menu_entries): - preselected_indices.add(item) - else: - raise IndexError( - "Error: {} is outside the allowable range of 0..{}.".format( - item, len(self._menu_entries) - 1 - ) - ) - elif isinstance(item, str): - try: - preselected_indices.update(menu_entry_to_indices[item]) - except KeyError as e: - raise UnknownMenuEntryError('Pre-selection "{}" is not a valid menu entry.'.format(item)) from e - else: - raise ValueError('"preselected_entries" must either contain integers or strings.') - return preselected_indices - - def setup_title_or_status_bar_lines( - title_or_status_bar: Optional[Union[str, Iterable[str]]], - show_shortcut_hints: bool, - menu_entries: Iterable[str], - shortcut_keys: Iterable[Optional[str]], - shortcut_hints_in_parentheses: bool, - ) -> Tuple[str, ...]: - if title_or_status_bar is None: - lines = [] # type: List[str] - elif isinstance(title_or_status_bar, str): - lines = title_or_status_bar.split("\n") - else: - lines = list(title_or_status_bar) - if show_shortcut_hints: - shortcut_hints_line = self._get_shortcut_hints_line( - menu_entries, shortcut_keys, shortcut_hints_in_parentheses - ) - if shortcut_hints_line is not None: - lines.append(shortcut_hints_line) - return tuple(lines) - - ( - self._menu_entries, - self._shortcut_keys, - self._preview_arguments, - self._skip_indices, - ) = extract_shortcuts_menu_entries_and_preview_arguments(menu_entries) - self._shortcuts_defined = any(key is not None for key in self._shortcut_keys) - self._accept_keys = tuple(accept_keys) - self._clear_menu_on_exit = clear_menu_on_exit - self._clear_screen = clear_screen - self._cycle_cursor = cycle_cursor - self._multi_select_empty_ok = multi_select_empty_ok - self._exit_on_shortcut = exit_on_shortcut - self._menu_cursor = menu_cursor if menu_cursor is not None else "" - self._menu_cursor_style = tuple(menu_cursor_style) if menu_cursor_style is not None else () - self._menu_highlight_style = tuple(menu_highlight_style) if menu_highlight_style is not None else () - self._multi_select = multi_select - self._multi_select_cursor = multi_select_cursor - self._multi_select_cursor_brackets_style = ( - tuple(multi_select_cursor_brackets_style) if multi_select_cursor_brackets_style is not None else () - ) - self._multi_select_cursor_style = ( - tuple(multi_select_cursor_style) if multi_select_cursor_style is not None else () - ) - self._multi_select_keys = tuple(multi_select_keys) if multi_select_keys is not None else () - self._multi_select_select_on_accept = multi_select_select_on_accept - if preselected_entries and not self._multi_select: - raise InvalidParameterCombinationError( - "Multi-select mode must be enabled when preselected entries are given." - ) - self._preselected_indices = ( - convert_preselected_entries_to_indices(preselected_entries) if preselected_entries is not None else None - ) - self._preview_border = preview_border - self._preview_command = preview_command - self._preview_size = preview_size - self._preview_title = preview_title - self._quit_keys = tuple(quit_keys) - self._raise_error_on_interrupt = raise_error_on_interrupt - self._search_case_sensitive = search_case_sensitive - self._search_highlight_style = tuple(search_highlight_style) if search_highlight_style is not None else () - self._search_key = search_key - self._shortcut_brackets_highlight_style = ( - tuple(shortcut_brackets_highlight_style) if shortcut_brackets_highlight_style is not None else () - ) - self._shortcut_key_highlight_style = ( - tuple(shortcut_key_highlight_style) if shortcut_key_highlight_style is not None else () - ) - self._show_search_hint = show_search_hint - self._show_search_hint_text = show_search_hint_text - self._show_shortcut_hints = show_shortcut_hints - self._show_shortcut_hints_in_status_bar = show_shortcut_hints_in_status_bar - self._status_bar_func = None # type: Optional[Callable[[str], str]] - self._status_bar_lines = None # type: Optional[Tuple[str, ...]] - if callable(status_bar): - self._status_bar_func = status_bar - else: - self._status_bar_lines = setup_title_or_status_bar_lines( - status_bar, - show_shortcut_hints and show_shortcut_hints_in_status_bar, - self._menu_entries, - self._shortcut_keys, - False, - ) - self._status_bar_below_preview = status_bar_below_preview - self._status_bar_style = tuple(status_bar_style) if status_bar_style is not None else () - self._title_lines = setup_title_or_status_bar_lines( - title, - show_shortcut_hints and not show_shortcut_hints_in_status_bar, - self._menu_entries, - self._shortcut_keys, - True, - ) - self._show_multi_select_hint = show_multi_select_hint - self._show_multi_select_hint_text = show_multi_select_hint_text - self._chosen_accept_key = None # type: Optional[str] - self._chosen_menu_index = None # type: Optional[int] - self._chosen_menu_indices = None # type: Optional[Tuple[int, ...]] - self._paint_before_next_read = False - self._previous_displayed_menu_height = None # type: Optional[int] - self._reading_next_key = False - self._search = self.Search( - self._menu_entries, - case_senitive=self._search_case_sensitive, - show_search_hint=self._show_search_hint, - ) - self._selection = self.Selection(len(self._menu_entries), self._preselected_indices) - self._viewport = self.Viewport( - len(self._menu_entries), - len(self._title_lines), - len(self._status_bar_lines) if self._status_bar_lines is not None else 0, - 0, - 0, - ) - self._view = self.View( - self._menu_entries, self._search, self._selection, self._viewport, self._cycle_cursor, self._skip_indices - ) - if cursor_index and 0 < cursor_index < len(self._menu_entries): - self._view.active_menu_index = cursor_index - self._search.change_callback = self._view.update_view - self._old_term = None # type: Optional[List[Union[int, List[bytes]]]] - self._new_term = None # type: Optional[List[Union[int, List[bytes]]]] - self._tty_in = None # type: Optional[TextIO] - self._tty_out = None # type: Optional[TextIO] - self._user_locale = get_locale() - self._check_for_valid_styles() - # backspace can be queried from the terminal database but is unreliable, query the terminal directly instead - self._init_backspace_control_character() - self._add_missing_control_characters_for_keys(self._accept_keys) - self._add_missing_control_characters_for_keys(self._quit_keys) - self._init_terminal_codes() - - @staticmethod - def _get_shortcut_hints_line( - menu_entries: Iterable[str], - shortcut_keys: Iterable[Optional[str]], - shortcut_hints_in_parentheses: bool, - ) -> Optional[str]: - shortcut_hints_line = ", ".join( - "[{}]: {}".format(shortcut_key, menu_entry) - for shortcut_key, menu_entry in zip(shortcut_keys, menu_entries) - if shortcut_key is not None - ) - if shortcut_hints_line != "": - if shortcut_hints_in_parentheses: - return "(" + shortcut_hints_line + ")" - else: - return shortcut_hints_line - return None - - @staticmethod - def _get_keycode_for_key(key: str) -> str: - if len(key) == 1: - # One letter keys represent themselves - return key - alt_modified_regex = re.compile(r"[Aa]lt-(\S)") - ctrl_modified_regex = re.compile(r"[Cc]trl-(\S)") - match_obj = alt_modified_regex.match(key) - if match_obj: - return "\033" + match_obj.group(1) - match_obj = ctrl_modified_regex.match(key) - if match_obj: - # Ctrl + key is interpreted by terminals as the ascii code of that key minus 64 - ctrl_code_ascii = ord(match_obj.group(1).upper()) - 64 - if ctrl_code_ascii < 0: - # Interpret negative ascii codes as unsigned 7-Bit integers - ctrl_code_ascii = ctrl_code_ascii & 0x80 - 1 - return chr(ctrl_code_ascii) - raise ValueError('Cannot interpret the given key "{}".'.format(key)) - - @classmethod - def _init_backspace_control_character(self) -> None: - try: - with open("/dev/tty", "r") as tty: - stty_output = subprocess.check_output(["stty", "-a"], universal_newlines=True, stdin=tty) - name_to_keycode_regex = re.compile(r"^\s*(\S+)\s*=\s*\^(\S+)\s*$") - for field in stty_output.split(";"): - match_obj = name_to_keycode_regex.match(field) - if not match_obj: - continue - name, ctrl_code = match_obj.group(1), match_obj.group(2) - if name != "erase": - continue - self._name_to_control_character["backspace"] = self._get_keycode_for_key("ctrl-" + ctrl_code) - return - except subprocess.CalledProcessError: - pass - # Backspace control character could not be queried, assume `<Ctrl-?>` (is most often used) - self._name_to_control_character["backspace"] = "\177" - - @classmethod - def _add_missing_control_characters_for_keys(cls, keys: Iterable[str]) -> None: - for key in keys: - if key not in cls._name_to_control_character and key not in string.ascii_letters: - cls._name_to_control_character[key] = cls._get_keycode_for_key(key) - - @classmethod - def _init_terminal_codes(cls) -> None: - if cls._codename_to_terminal_code is not None: - return - supported_colors = int(cls._query_terminfo_database("colors")) - cls._codename_to_terminal_code = { - codename: cls._query_terminfo_database(codename) - if not (codename.startswith("bg_") or codename.startswith("fg_")) or supported_colors >= 8 - else "" - for codename in cls._codenames - } - cls._codename_to_terminal_code.update(cls._name_to_control_character) - cls._terminal_code_to_codename = { - terminal_code: codename for codename, terminal_code in cls._codename_to_terminal_code.items() - } - - @classmethod - def _query_terminfo_database(cls, codename: str) -> str: - if codename in cls._codename_to_capname: - capname = cls._codename_to_capname[codename] - else: - capname = codename - try: - return subprocess.check_output(["tput"] + capname.split(), universal_newlines=True) - except subprocess.CalledProcessError as e: - # The return code 1 indicates a missing terminal capability - if e.returncode == 1: - return "" - raise e - - @classmethod - def _num_lines(self) -> int: - return int(self._query_terminfo_database("lines")) - - @classmethod - def _num_cols(self) -> int: - return int(self._query_terminfo_database("cols")) - - def _check_for_valid_styles(self) -> None: - invalid_styles = [] - for style_tuple in ( - self._menu_cursor_style, - self._menu_highlight_style, - self._search_highlight_style, - self._shortcut_key_highlight_style, - self._shortcut_brackets_highlight_style, - self._status_bar_style, - self._multi_select_cursor_brackets_style, - self._multi_select_cursor_style, - ): - for style in style_tuple: - if style not in self._codename_to_capname: - invalid_styles.append(style) - if invalid_styles: - if len(invalid_styles) == 1: - raise InvalidStyleError('The style "{}" does not exist.'.format(invalid_styles[0])) - else: - raise InvalidStyleError('The styles ("{}") do not exist.'.format('", "'.join(invalid_styles))) - - def _init_term(self) -> None: - # pylint: disable=unsubscriptable-object - assert self._codename_to_terminal_code is not None - self._tty_in = open("/dev/tty", "r", encoding=self._user_locale) - self._tty_out = open("/dev/tty", "w", encoding=self._user_locale, errors="replace") - self._old_term = termios.tcgetattr(self._tty_in.fileno()) - self._new_term = termios.tcgetattr(self._tty_in.fileno()) - # set the terminal to: unbuffered, no echo and no <CR> to <NL> translation (so <enter> sends <CR> instead of - # <NL, this is necessary to distinguish between <enter> and <Ctrl-j> since <Ctrl-j> generates <NL>) - self._new_term[3] = cast(int, self._new_term[3]) & ~termios.ICANON & ~termios.ECHO & ~termios.ICRNL - self._new_term[0] = cast(int, self._new_term[0]) & ~termios.ICRNL - termios.tcsetattr( - self._tty_in.fileno(), termios.TCSAFLUSH, cast(List[Union[int, List[Union[bytes, int]]]], self._new_term) - ) - # Enter terminal application mode to get expected escape codes for arrow keys - self._tty_out.write(self._codename_to_terminal_code["enter_application_mode"]) - self._tty_out.write(self._codename_to_terminal_code["cursor_invisible"]) - if self._clear_screen: - self._tty_out.write(self._codename_to_terminal_code["clear"]) - - def _reset_term(self) -> None: - # pylint: disable=unsubscriptable-object - assert self._codename_to_terminal_code is not None - assert self._tty_in is not None - assert self._tty_out is not None - assert self._old_term is not None - termios.tcsetattr( - self._tty_out.fileno(), termios.TCSAFLUSH, cast(List[Union[int, List[Union[bytes, int]]]], self._old_term) - ) - self._tty_out.write(self._codename_to_terminal_code["cursor_visible"]) - self._tty_out.write(self._codename_to_terminal_code["exit_application_mode"]) - if self._clear_screen: - self._tty_out.write(self._codename_to_terminal_code["clear"]) - self._tty_in.close() - self._tty_out.close() - - def _paint_menu(self) -> None: - def get_status_bar_lines() -> Tuple[str, ...]: - def get_multi_select_hint() -> str: - def get_string_from_keys(keys: Sequence[str]) -> str: - string_to_key = { - " ": "space", - } - keys_string = ", ".join( - "<" + string_to_key.get(accept_key, accept_key) + ">" for accept_key in keys - ) - return keys_string - - accept_keys_string = get_string_from_keys(self._accept_keys) - multi_select_keys_string = get_string_from_keys(self._multi_select_keys) - if self._show_multi_select_hint_text is not None: - return self._show_multi_select_hint_text.format( - multi_select_keys=multi_select_keys_string, accept_keys=accept_keys_string - ) - else: - return "Press {} for multi-selection and {} to {}accept".format( - multi_select_keys_string, - accept_keys_string, - "select and " if self._multi_select_select_on_accept else "", - ) - - if self._status_bar_func is not None and self._view.active_menu_index is not None: - status_bar_lines = tuple( - self._status_bar_func(self._menu_entries[self._view.active_menu_index]).strip().split("\n") - ) - if self._show_shortcut_hints and self._show_shortcut_hints_in_status_bar: - shortcut_hints_line = self._get_shortcut_hints_line(self._menu_entries, self._shortcut_keys, False) - if shortcut_hints_line is not None: - status_bar_lines += (shortcut_hints_line,) - elif self._status_bar_lines is not None: - status_bar_lines = self._status_bar_lines - else: - status_bar_lines = tuple() - if self._multi_select and self._show_multi_select_hint: - status_bar_lines += (get_multi_select_hint(),) - return status_bar_lines - - def apply_style( - style_iterable: Optional[Iterable[str]] = None, reset: bool = True, file: Optional[TextIO] = None - ) -> None: - # pylint: disable=unsubscriptable-object - assert self._codename_to_terminal_code is not None - assert self._tty_out is not None - if file is None: - file = self._tty_out - if reset or style_iterable is None: - file.write(self._codename_to_terminal_code["reset_attributes"]) - if style_iterable is not None: - for style in style_iterable: - file.write(self._codename_to_terminal_code[style]) - - def print_menu_entries() -> int: - # pylint: disable=unsubscriptable-object - assert self._codename_to_terminal_code is not None - assert self._tty_out is not None - all_cursors_width = wcswidth(self._menu_cursor) + ( - wcswidth(self._multi_select_cursor) if self._multi_select else 0 - ) - current_menu_block_displayed_height = 0 # sum all written lines - num_cols = self._num_cols() - if self._title_lines: - self._tty_out.write( - len(self._title_lines) * self._codename_to_terminal_code["cursor_up"] - + "\r" - + "\n".join( - (title_line[:num_cols] + (num_cols - wcswidth(title_line)) * " ") - for title_line in self._title_lines - ) - + "\n" - ) - shortcut_string_len = 4 if self._shortcuts_defined else 0 - displayed_index = -1 - for displayed_index, menu_index, menu_entry in self._view: - current_shortcut_key = self._shortcut_keys[menu_index] - self._tty_out.write(all_cursors_width * self._codename_to_terminal_code["cursor_right"]) - if self._shortcuts_defined: - if current_shortcut_key is not None: - apply_style(self._shortcut_brackets_highlight_style) - self._tty_out.write("[") - apply_style(self._shortcut_key_highlight_style) - self._tty_out.write(current_shortcut_key) - apply_style(self._shortcut_brackets_highlight_style) - self._tty_out.write("]") - apply_style() - else: - self._tty_out.write(3 * " ") - self._tty_out.write(" ") - if menu_index == self._view.active_menu_index: - apply_style(self._menu_highlight_style) - if self._search and self._search.search_text != "": - match_obj = self._search.matches[displayed_index][1] - self._tty_out.write( - menu_entry[: min(match_obj.start(), num_cols - all_cursors_width - shortcut_string_len)] - ) - apply_style(self._search_highlight_style) - self._tty_out.write( - menu_entry[ - match_obj.start() : min(match_obj.end(), num_cols - all_cursors_width - shortcut_string_len) - ] - ) - apply_style() - if menu_index == self._view.active_menu_index: - apply_style(self._menu_highlight_style) - self._tty_out.write( - menu_entry[match_obj.end() : num_cols - all_cursors_width - shortcut_string_len] - ) - else: - self._tty_out.write(menu_entry[: num_cols - all_cursors_width - shortcut_string_len]) - if menu_index == self._view.active_menu_index: - apply_style() - self._tty_out.write((num_cols - wcswidth(menu_entry) - all_cursors_width - shortcut_string_len) * " ") - if displayed_index < self._viewport.upper_index: - self._tty_out.write("\n") - empty_menu_lines = self._viewport.upper_index - displayed_index - self._tty_out.write( - max(0, empty_menu_lines - 1) * (num_cols * " " + "\n") + min(1, empty_menu_lines) * (num_cols * " ") - ) - self._tty_out.write("\r" + (self._viewport.size - 1) * self._codename_to_terminal_code["cursor_up"]) - current_menu_block_displayed_height += self._viewport.size - 1 # sum all written lines - return current_menu_block_displayed_height - - def print_search_line(current_menu_height: int) -> int: - # pylint: disable=unsubscriptable-object - assert self._codename_to_terminal_code is not None - assert self._tty_out is not None - current_menu_block_displayed_height = 0 - num_cols = self._num_cols() - if self._search or self._show_search_hint: - self._tty_out.write((current_menu_height + 1) * self._codename_to_terminal_code["cursor_down"]) - if self._search: - assert self._search.search_text is not None - self._tty_out.write( - ( - (self._search_key if self._search_key is not None else DEFAULT_SEARCH_KEY) - + self._search.search_text - )[:num_cols] - ) - self._tty_out.write((num_cols - len(self._search) - 1) * " ") - elif self._show_search_hint: - if self._show_search_hint_text is not None: - search_hint = self._show_search_hint_text.format(key=self._search_key)[:num_cols] - elif self._search_key is not None: - search_hint = '(Press "{key}" to search)'.format(key=self._search_key)[:num_cols] - else: - search_hint = "(Press any letter key to search)"[:num_cols] - self._tty_out.write(search_hint) - self._tty_out.write((num_cols - wcswidth(search_hint)) * " ") - if self._search or self._show_search_hint: - self._tty_out.write("\r" + (current_menu_height + 1) * self._codename_to_terminal_code["cursor_up"]) - current_menu_block_displayed_height = 1 - return current_menu_block_displayed_height - - def print_status_bar(current_menu_height: int, status_bar_lines: Tuple[str, ...]) -> int: - # pylint: disable=unsubscriptable-object - assert self._codename_to_terminal_code is not None - assert self._tty_out is not None - current_menu_block_displayed_height = 0 # sum all written lines - num_cols = self._num_cols() - if status_bar_lines: - self._tty_out.write((current_menu_height + 1) * self._codename_to_terminal_code["cursor_down"]) - apply_style(self._status_bar_style) - self._tty_out.write( - "\r" - + "\n".join( - (status_bar_line[:num_cols] + (num_cols - wcswidth(status_bar_line)) * " ") - for status_bar_line in status_bar_lines - ) - + "\r" - ) - apply_style() - self._tty_out.write( - (current_menu_height + len(status_bar_lines)) * self._codename_to_terminal_code["cursor_up"] - ) - current_menu_block_displayed_height += len(status_bar_lines) - return current_menu_block_displayed_height - - def print_preview(current_menu_height: int, preview_max_num_lines: int) -> int: - # pylint: disable=unsubscriptable-object - assert self._codename_to_terminal_code is not None - assert self._tty_out is not None - if self._preview_command is None or preview_max_num_lines < 3: - return 0 - - def get_preview_string() -> Optional[str]: - assert self._preview_command is not None - if self._view.active_menu_index is None: - return None - preview_argument = ( - self._preview_arguments[self._view.active_menu_index] - if self._preview_arguments[self._view.active_menu_index] is not None - else self._menu_entries[self._view.active_menu_index] - ) - if preview_argument == "": - return None - if isinstance(self._preview_command, str): - try: - preview_process = subprocess.Popen( - [cmd_part.format(preview_argument) for cmd_part in shlex.split(self._preview_command)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - assert preview_process.stdout is not None - preview_string = ( - io.TextIOWrapper(preview_process.stdout, encoding=self._user_locale, errors="replace") - .read() - .strip() - ) - except subprocess.CalledProcessError as e: - raise PreviewCommandFailedError( - e.stderr.decode(encoding=self._user_locale, errors="replace").strip() - ) from e - else: - preview_string = self._preview_command(preview_argument) if preview_argument is not None else "" - return preview_string - - @static_variables( - # Regex taken from https://stackoverflow.com/a/14693789/5958465 - ansi_escape_regex=re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])"), - # Modified version of https://stackoverflow.com/a/2188410/5958465 - ansi_sgr_regex=re.compile(r"\x1B\[[;\d]*m"), - ) - def strip_ansi_codes_except_styling(string: str) -> str: - stripped_string = strip_ansi_codes_except_styling.ansi_escape_regex.sub( # type: ignore - lambda match_obj: match_obj.group(0) - if strip_ansi_codes_except_styling.ansi_sgr_regex.match(match_obj.group(0)) # type: ignore - else "", - string, - ) - return cast(str, stripped_string) - - @static_variables( - regular_text_regex=re.compile(r"([^\x1B]+)(.*)"), - ansi_escape_regex=re.compile(r"(\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]))(.*)"), - ) - def limit_string_with_escape_codes(string: str, max_len: int) -> Tuple[str, int]: - if max_len <= 0: - return "", 0 - string_parts = [] - string_len = 0 - while string: - regular_text_match = limit_string_with_escape_codes.regular_text_regex.match(string) # type: ignore - if regular_text_match is not None: - regular_text = regular_text_match.group(1) - regular_text_len = wcswidth(regular_text) - if string_len + regular_text_len > max_len: - string_parts.append(regular_text[: max_len - string_len]) - string_len = max_len - break - string_parts.append(regular_text) - string_len += regular_text_len - string = regular_text_match.group(2) - else: - ansi_escape_match = limit_string_with_escape_codes.ansi_escape_regex.match( # type: ignore - string - ) - if ansi_escape_match is not None: - # Adopt the ansi escape code but do not count its length - ansi_escape_code_text = ansi_escape_match.group(1) - string_parts.append(ansi_escape_code_text) - string = ansi_escape_match.group(2) - else: - # It looks like an escape code (starts with escape), but it is something else - # -> skip the escape character and continue the loop - string_parts.append("\x1B") - string = string[1:] - return "".join(string_parts), string_len - - num_cols = self._num_cols() - try: - preview_string = get_preview_string() - if preview_string is not None: - preview_string = strip_ansi_codes_except_styling(preview_string) - except PreviewCommandFailedError as e: - preview_string = "The preview command failed with error message:\n\n" + str(e) - self._tty_out.write(current_menu_height * self._codename_to_terminal_code["cursor_down"]) - if preview_string is not None: - self._tty_out.write(self._codename_to_terminal_code["cursor_down"] + "\r") - if self._preview_border: - self._tty_out.write( - ( - BoxDrawingCharacters.upper_left - + (2 * BoxDrawingCharacters.horizontal + " " + self._preview_title)[: num_cols - 3] - + " " - + (num_cols - len(self._preview_title) - 6) * BoxDrawingCharacters.horizontal - + BoxDrawingCharacters.upper_right - )[:num_cols] - + "\n" - ) - # `finditer` can be used as a generator version of `str.join` - for i, line in enumerate( - match.group(0) for match in re.finditer(r"^.*$", preview_string, re.MULTILINE) - ): - if i >= preview_max_num_lines - (2 if self._preview_border else 0): - preview_num_lines = preview_max_num_lines - break - limited_line, limited_line_len = limit_string_with_escape_codes( - line, num_cols - (3 if self._preview_border else 0) - ) - self._tty_out.write( - ( - ((BoxDrawingCharacters.vertical + " ") if self._preview_border else "") - + limited_line - + self._codename_to_terminal_code["reset_attributes"] - + max(num_cols - limited_line_len - (3 if self._preview_border else 0), 0) * " " - + (BoxDrawingCharacters.vertical if self._preview_border else "") - ) - ) - else: - preview_num_lines = i + (3 if self._preview_border else 1) - if self._preview_border: - self._tty_out.write( - "\n" - + ( - BoxDrawingCharacters.lower_left - + (num_cols - 2) * BoxDrawingCharacters.horizontal - + BoxDrawingCharacters.lower_right - )[:num_cols] - ) - self._tty_out.write("\r") - else: - preview_num_lines = 0 - self._tty_out.write( - (current_menu_height + preview_num_lines) * self._codename_to_terminal_code["cursor_up"] - ) - return preview_num_lines - - def delete_old_menu_lines(displayed_menu_height: int) -> None: - # pylint: disable=unsubscriptable-object - assert self._codename_to_terminal_code is not None - assert self._tty_out is not None - if ( - self._previous_displayed_menu_height is not None - and self._previous_displayed_menu_height > displayed_menu_height - ): - self._tty_out.write((displayed_menu_height + 1) * self._codename_to_terminal_code["cursor_down"]) - self._tty_out.write( - (self._previous_displayed_menu_height - displayed_menu_height) - * self._codename_to_terminal_code["delete_line"] - ) - self._tty_out.write((displayed_menu_height + 1) * self._codename_to_terminal_code["cursor_up"]) - - def position_cursor() -> None: - # pylint: disable=unsubscriptable-object - assert self._codename_to_terminal_code is not None - assert self._tty_out is not None - if self._view.active_displayed_index is None: - return - - cursor_width = wcswidth(self._menu_cursor) - for displayed_index in range(self._viewport.lower_index, self._viewport.upper_index + 1): - if displayed_index == self._view.active_displayed_index: - apply_style(self._menu_cursor_style) - self._tty_out.write(self._menu_cursor) - apply_style() - else: - self._tty_out.write(cursor_width * " ") - self._tty_out.write("\r") - if displayed_index < self._viewport.upper_index: - self._tty_out.write(self._codename_to_terminal_code["cursor_down"]) - self._tty_out.write((self._viewport.size - 1) * self._codename_to_terminal_code["cursor_up"]) - - def print_multi_select_column() -> None: - # pylint: disable=unsubscriptable-object - assert self._codename_to_terminal_code is not None - assert self._tty_out is not None - if not self._multi_select: - return - - def prepare_multi_select_cursors() -> Tuple[str, str]: - bracket_characters = "([{<)]}>" - bracket_style_escape_codes_io = io.StringIO() - multi_select_cursor_style_escape_codes_io = io.StringIO() - reset_codes_io = io.StringIO() - apply_style(self._multi_select_cursor_brackets_style, file=bracket_style_escape_codes_io) - apply_style(self._multi_select_cursor_style, file=multi_select_cursor_style_escape_codes_io) - apply_style(file=reset_codes_io) - bracket_style_escape_codes = bracket_style_escape_codes_io.getvalue() - multi_select_cursor_style_escape_codes = multi_select_cursor_style_escape_codes_io.getvalue() - reset_codes = reset_codes_io.getvalue() - - cursor_with_brackets_only = re.sub( - r"[^{}]".format(re.escape(bracket_characters)), " ", self._multi_select_cursor - ) - cursor_with_brackets_only_styled = re.sub( - r"[{}]+".format(re.escape(bracket_characters)), - lambda match_obj: bracket_style_escape_codes + match_obj.group(0) + reset_codes, - cursor_with_brackets_only, - ) - cursor_styled = re.sub( - r"[{brackets}]+|[^{brackets}\s]+".format(brackets=re.escape(bracket_characters)), - lambda match_obj: ( - bracket_style_escape_codes - if match_obj.group(0)[0] in bracket_characters - else multi_select_cursor_style_escape_codes - ) - + match_obj.group(0) - + reset_codes, - self._multi_select_cursor, - ) - return cursor_styled, cursor_with_brackets_only_styled - - if not self._view: - return - checked_multi_select_cursor, unchecked_multi_select_cursor = prepare_multi_select_cursors() - cursor_width = wcswidth(self._menu_cursor) - displayed_selected_indices = self._view.displayed_selected_indices - displayed_index = 0 - for displayed_index, _, _ in self._view: - self._tty_out.write("\r" + cursor_width * self._codename_to_terminal_code["cursor_right"]) - if displayed_index in self._skip_indices: - self._tty_out.write("") - elif displayed_index in displayed_selected_indices: - self._tty_out.write(checked_multi_select_cursor) - else: - self._tty_out.write(unchecked_multi_select_cursor) - if displayed_index < self._viewport.upper_index: - self._tty_out.write(self._codename_to_terminal_code["cursor_down"]) - self._tty_out.write("\r") - self._tty_out.write( - (displayed_index + (1 if displayed_index < self._viewport.upper_index else 0)) - * self._codename_to_terminal_code["cursor_up"] - ) - - # pylint: disable=unsubscriptable-object - assert self._codename_to_terminal_code is not None - assert self._tty_out is not None - displayed_menu_height = 0 # sum all written lines - status_bar_lines = get_status_bar_lines() - self._viewport.status_bar_lines_count = len(status_bar_lines) - if self._preview_command is not None: - self._viewport.preview_lines_count = int(self._preview_size * self._num_lines()) - preview_max_num_lines = self._viewport.preview_lines_count - self._viewport.keep_visible(self._view.active_displayed_index) - displayed_menu_height += print_menu_entries() - displayed_menu_height += print_search_line(displayed_menu_height) - if not self._status_bar_below_preview: - displayed_menu_height += print_status_bar(displayed_menu_height, status_bar_lines) - if self._preview_command is not None: - displayed_menu_height += print_preview(displayed_menu_height, preview_max_num_lines) - if self._status_bar_below_preview: - displayed_menu_height += print_status_bar(displayed_menu_height, status_bar_lines) - delete_old_menu_lines(displayed_menu_height) - position_cursor() - if self._multi_select: - print_multi_select_column() - self._previous_displayed_menu_height = displayed_menu_height - self._tty_out.flush() - - def _clear_menu(self) -> None: - # pylint: disable=unsubscriptable-object - assert self._codename_to_terminal_code is not None - assert self._previous_displayed_menu_height is not None - assert self._tty_out is not None - if self._clear_menu_on_exit: - if self._title_lines: - self._tty_out.write(len(self._title_lines) * self._codename_to_terminal_code["cursor_up"]) - self._tty_out.write(len(self._title_lines) * self._codename_to_terminal_code["delete_line"]) - self._tty_out.write( - (self._previous_displayed_menu_height + 1) * self._codename_to_terminal_code["delete_line"] - ) - else: - self._tty_out.write( - (self._previous_displayed_menu_height + 1) * self._codename_to_terminal_code["cursor_down"] - ) - self._tty_out.flush() - - def _read_next_key(self, ignore_case: bool = True) -> str: - # pylint: disable=unsubscriptable-object,unsupported-membership-test - assert self._terminal_code_to_codename is not None - assert self._tty_in is not None - # Needed for asynchronous handling of terminal resize events - self._reading_next_key = True - if self._paint_before_next_read: - self._paint_menu() - self._paint_before_next_read = False - # blocks until any amount of bytes is available - code = os.read(self._tty_in.fileno(), 80).decode("ascii", errors="ignore") - self._reading_next_key = False - if code in self._terminal_code_to_codename: - return self._terminal_code_to_codename[code] - elif ignore_case: - return code.lower() - else: - return code - - def show(self) -> Optional[Union[int, Tuple[int, ...]]]: - def init_signal_handling() -> None: - # `SIGWINCH` is send on terminal resizes - def handle_sigwinch(signum: signal.Signals, frame: FrameType) -> None: - # pylint: disable=unused-argument - if self._reading_next_key: - self._paint_menu() - else: - self._paint_before_next_read = True - - signal.signal(signal.SIGWINCH, handle_sigwinch) - - def reset_signal_handling() -> None: - signal.signal(signal.SIGWINCH, signal.SIG_DFL) - - def remove_letter_keys(menu_action_to_keys: Dict[str, Set[Optional[str]]]) -> None: - letter_keys = frozenset(string.ascii_lowercase) | frozenset(" ") - for keys in menu_action_to_keys.values(): - keys -= letter_keys - - # pylint: disable=unsubscriptable-object - assert self._codename_to_terminal_code is not None - self._init_term() - if self._preselected_indices is None: - self._selection.clear() - self._chosen_accept_key = None - self._chosen_menu_indices = None - self._chosen_menu_index = None - assert self._tty_out is not None - if self._title_lines: - # `print_menu` expects the cursor on the first menu item -> reserve one line for the title - self._tty_out.write(len(self._title_lines) * self._codename_to_terminal_code["cursor_down"]) - menu_was_interrupted = False - try: - init_signal_handling() - menu_action_to_keys = { - "menu_up": set(("up", "ctrl-k", "k")), - "menu_down": set(("down", "ctrl-j", "j")), - "accept": set(self._accept_keys), - "multi_select": set(self._multi_select_keys), - "quit": set(self._quit_keys), - "search_start": set((self._search_key,)), - "backspace": set(("backspace",)), - } # type: Dict[str, Set[Optional[str]]] - while True: - self._paint_menu() - current_menu_action_to_keys = copy.deepcopy(menu_action_to_keys) - next_key = self._read_next_key(ignore_case=False) - if self._search or self._search_key is None: - remove_letter_keys(current_menu_action_to_keys) - else: - next_key = next_key.lower() - if self._search_key is not None and not self._search and next_key in self._shortcut_keys: - shortcut_menu_index = self._shortcut_keys.index(next_key) - if self._exit_on_shortcut: - self._selection.add(shortcut_menu_index) - break - else: - if self._multi_select: - self._selection.toggle(shortcut_menu_index) - else: - self._view.active_menu_index = shortcut_menu_index - elif next_key in current_menu_action_to_keys["menu_up"]: - self._view.decrement_active_index() - elif next_key in current_menu_action_to_keys["menu_down"]: - self._view.increment_active_index() - elif self._multi_select and next_key in current_menu_action_to_keys["multi_select"]: - if self._view.active_menu_index is not None: - self._selection.toggle(self._view.active_menu_index) - elif next_key in current_menu_action_to_keys["accept"]: - if self._view.active_menu_index is not None: - if self._multi_select_select_on_accept or ( - not self._selection and self._multi_select_empty_ok is False - ): - self._selection.add(self._view.active_menu_index) - self._chosen_accept_key = next_key - break - elif next_key in current_menu_action_to_keys["quit"]: - if not self._search: - menu_was_interrupted = True - break - else: - self._search.search_text = None - elif not self._search: - if next_key in current_menu_action_to_keys["search_start"] or ( - self._search_key is None and next_key == DEFAULT_SEARCH_KEY - ): - self._search.search_text = "" - elif self._search_key is None: - self._search.search_text = next_key - else: - assert self._search.search_text is not None - if next_key in ("backspace",): - if self._search.search_text != "": - self._search.search_text = self._search.search_text[:-1] - else: - self._search.search_text = None - elif wcswidth(next_key) >= 0 and not ( - next_key in current_menu_action_to_keys["search_start"] and self._search.search_text == "" - ): - # Only append `next_key` if it is a printable character and the first character is not the - # `search_start` key - self._search.search_text += next_key - except KeyboardInterrupt as e: - if self._raise_error_on_interrupt: - raise e - menu_was_interrupted = True - finally: - reset_signal_handling() - self._clear_menu() - self._reset_term() - if not menu_was_interrupted: - chosen_menu_indices = self._selection.selected_menu_indices - if chosen_menu_indices: - if self._multi_select: - self._chosen_menu_indices = chosen_menu_indices - else: - self._chosen_menu_index = chosen_menu_indices[0] - return self._chosen_menu_indices if self._multi_select else self._chosen_menu_index - - @property - def chosen_accept_key(self) -> Optional[str]: - return self._chosen_accept_key - - @property - def chosen_menu_entry(self) -> Optional[str]: - return self._menu_entries[self._chosen_menu_index] if self._chosen_menu_index is not None else None - - @property - def chosen_menu_entries(self) -> Optional[Tuple[str, ...]]: - return ( - tuple(self._menu_entries[menu_index] for menu_index in self._chosen_menu_indices) - if self._chosen_menu_indices is not None - else None - ) - - @property - def chosen_menu_index(self) -> Optional[int]: - return self._chosen_menu_index - - @property - def chosen_menu_indices(self) -> Optional[Tuple[int, ...]]: - return self._chosen_menu_indices - - -class AttributeDict(dict): # type: ignore - def __getattr__(self, attr: str) -> Any: - return self[attr] - - def __setattr__(self, attr: str, value: Any) -> None: - self[attr] = value - - -def get_argumentparser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - formatter_class=argparse.RawDescriptionHelpFormatter, - description=""" -%(prog)s creates simple interactive menus in the terminal and returns the selected entry as exit code. -""", - ) - parser.add_argument( - "-s", "--case-sensitive", action="store_true", dest="case_sensitive", help="searches are case sensitive" - ) - parser.add_argument( - "-X", - "--no-clear-menu-on-exit", - action="store_false", - dest="clear_menu_on_exit", - help="do not clear the menu on exit", - ) - parser.add_argument( - "-l", - "--clear-screen", - action="store_true", - dest="clear_screen", - help="clear the screen before the menu is shown", - ) - parser.add_argument( - "--cursor", - action="store", - dest="cursor", - default=DEFAULT_MENU_CURSOR, - help='menu cursor (default: "%(default)s")', - ) - parser.add_argument( - "-i", - "--cursor-index", - action="store", - dest="cursor_index", - type=int, - default=0, - help="initially selected item index", - ) - parser.add_argument( - "--cursor-style", - action="store", - dest="cursor_style", - default=",".join(DEFAULT_MENU_CURSOR_STYLE), - help='style for the menu cursor as comma separated list (default: "%(default)s")', - ) - parser.add_argument("-C", "--no-cycle", action="store_false", dest="cycle", help="do not cycle the menu selection") - parser.add_argument( - "-E", - "--no-exit-on-shortcut", - action="store_false", - dest="exit_on_shortcut", - help="do not exit on shortcut keys", - ) - parser.add_argument( - "--highlight-style", - action="store", - dest="highlight_style", - default=",".join(DEFAULT_MENU_HIGHLIGHT_STYLE), - help='style for the selected menu entry as comma separated list (default: "%(default)s")', - ) - parser.add_argument( - "-m", - "--multi-select", - action="store_true", - dest="multi_select", - help="Allow the selection of multiple entries (implies `--stdout`)", - ) - parser.add_argument( - "--multi-select-cursor", - action="store", - dest="multi_select_cursor", - default=DEFAULT_MULTI_SELECT_CURSOR, - help='multi-select menu cursor (default: "%(default)s")', - ) - parser.add_argument( - "--multi-select-cursor-brackets-style", - action="store", - dest="multi_select_cursor_brackets_style", - default=",".join(DEFAULT_MULTI_SELECT_CURSOR_BRACKETS_STYLE), - help='style for brackets of the multi-select menu cursor as comma separated list (default: "%(default)s")', - ) - parser.add_argument( - "--multi-select-cursor-style", - action="store", - dest="multi_select_cursor_style", - default=",".join(DEFAULT_MULTI_SELECT_CURSOR_STYLE), - help='style for the multi-select menu cursor as comma separated list (default: "%(default)s")', - ) - parser.add_argument( - "--multi-select-keys", - action="store", - dest="multi_select_keys", - default=",".join(DEFAULT_MULTI_SELECT_KEYS), - help=('key for toggling a selected item in a multi-selection (default: "%(default)s", '), - ) - parser.add_argument( - "--multi-select-no-select-on-accept", - action="store_false", - dest="multi_select_select_on_accept", - help=( - "do not select the currently highlighted menu item when the accept key is pressed " - "(it is still selected if no other item was selected before)" - ), - ) - parser.add_argument( - "--multi-select-empty-ok", - action="store_true", - dest="multi_select_empty_ok", - help=("when used together with --multi-select-no-select-on-accept allows returning no selection at all"), - ) - parser.add_argument( - "-p", - "--preview", - action="store", - dest="preview_command", - help=( - "Command to generate a preview for the selected menu entry. " - '"{}" can be used as placeholder for the menu text. ' - 'If the menu entry has a data component (separated by "|"), this is used instead.' - ), - ) - parser.add_argument( - "--no-preview-border", - action="store_false", - dest="preview_border", - help="do not draw a border around the preview window", - ) - parser.add_argument( - "--preview-size", - action="store", - dest="preview_size", - type=float, - default=DEFAULT_PREVIEW_SIZE, - help='maximum height of the preview window in fractions of the terminal height (default: "%(default)s")', - ) - parser.add_argument( - "--preview-title", - action="store", - dest="preview_title", - default=DEFAULT_PREVIEW_TITLE, - help='title of the preview window (default: "%(default)s")', - ) - parser.add_argument( - "--search-highlight-style", - action="store", - dest="search_highlight_style", - default=",".join(DEFAULT_SEARCH_HIGHLIGHT_STYLE), - help='style of matched search patterns (default: "%(default)s")', - ) - parser.add_argument( - "--search-key", - action="store", - dest="search_key", - default=DEFAULT_SEARCH_KEY, - help=( - 'key to start a search (default: "%(default)s", ' - '"none" is treated a special value which activates the search on any letter key)' - ), - ) - parser.add_argument( - "--shortcut-brackets-highlight-style", - action="store", - dest="shortcut_brackets_highlight_style", - default=",".join(DEFAULT_SHORTCUT_BRACKETS_HIGHLIGHT_STYLE), - help='style of brackets enclosing shortcut keys (default: "%(default)s")', - ) - parser.add_argument( - "--shortcut-key-highlight-style", - action="store", - dest="shortcut_key_highlight_style", - default=",".join(DEFAULT_SHORTCUT_KEY_HIGHLIGHT_STYLE), - help='style of shortcut keys (default: "%(default)s")', - ) - parser.add_argument( - "--show-multi-select-hint", - action="store_true", - dest="show_multi_select_hint", - help="show a multi-select hint in the status bar", - ) - parser.add_argument( - "--show-multi-select-hint-text", - action="store", - dest="show_multi_select_hint_text", - help=( - "Custom text which will be shown as multi-select hint. Use the placeholders {multi_select_keys} and " - "{accept_keys} if appropriately." - ), - ) - parser.add_argument( - "--show-search-hint", - action="store_true", - dest="show_search_hint", - help="show a search hint in the search line", - ) - parser.add_argument( - "--show-search-hint-text", - action="store", - dest="show_search_hint_text", - help=( - "Custom text which will be shown as search hint. Use the placeholders {key} for the search key " - "if appropriately." - ), - ) - parser.add_argument( - "--show-shortcut-hints", - action="store_true", - dest="show_shortcut_hints", - help="show shortcut hints in the status bar", - ) - parser.add_argument( - "--show-shortcut-hints-in-title", - action="store_false", - dest="show_shortcut_hints_in_status_bar", - default=True, - help="show shortcut hints in the menu title", - ) - parser.add_argument( - "--skip-empty-entries", - action="store_true", - dest="skip_empty_entries", - help="Interpret an empty string in menu entries as an empty menu entry", - ) - parser.add_argument( - "-b", - "--status-bar", - action="store", - dest="status_bar", - help="status bar text", - ) - parser.add_argument( - "-d", - "--status-bar-below-preview", - action="store_true", - dest="status_bar_below_preview", - help="show the status bar below the preview window if any", - ) - parser.add_argument( - "--status-bar-style", - action="store", - dest="status_bar_style", - default=",".join(DEFAULT_STATUS_BAR_STYLE), - help='style of the status bar lines (default: "%(default)s")', - ) - parser.add_argument( - "--stdout", - action="store_true", - dest="stdout", - help=( - "Print the selected menu index or indices to stdout (in addition to the exit status). " - 'Multiple indices are separated by ";".' - ), - ) - parser.add_argument("-t", "--title", action="store", dest="title", help="menu title") - parser.add_argument( - "-V", "--version", action="store_true", dest="print_version", help="print the version number and exit" - ) - parser.add_argument("entries", action="store", nargs="*", help="the menu entries to show") - group = parser.add_mutually_exclusive_group() - group.add_argument( - "-r", - "--preselected_entries", - action="store", - dest="preselected_entries", - help="Comma separated list of strings matching menu items to start pre-selected in a multi-select menu.", - ) - group.add_argument( - "-R", - "--preselected_indices", - action="store", - dest="preselected_indices", - help="Comma separated list of numeric indexes of menu items to start pre-selected in a multi-select menu.", - ) - return parser - - -def parse_arguments() -> AttributeDict: - parser = get_argumentparser() - args = AttributeDict({key: value for key, value in vars(parser.parse_args()).items()}) - if not args.print_version and not args.entries: - raise NoMenuEntriesError("No menu entries given!") - if args.skip_empty_entries: - args.entries = [entry if entry != "None" else None for entry in args.entries] - if args.cursor_style != "": - args.cursor_style = tuple(args.cursor_style.split(",")) - else: - args.cursor_style = None - if args.highlight_style != "": - args.highlight_style = tuple(args.highlight_style.split(",")) - else: - args.highlight_style = None - if args.search_highlight_style != "": - args.search_highlight_style = tuple(args.search_highlight_style.split(",")) - else: - args.search_highlight_style = None - if args.shortcut_key_highlight_style != "": - args.shortcut_key_highlight_style = tuple(args.shortcut_key_highlight_style.split(",")) - else: - args.shortcut_key_highlight_style = None - if args.shortcut_brackets_highlight_style != "": - args.shortcut_brackets_highlight_style = tuple(args.shortcut_brackets_highlight_style.split(",")) - else: - args.shortcut_brackets_highlight_style = None - if args.status_bar_style != "": - args.status_bar_style = tuple(args.status_bar_style.split(",")) - else: - args.status_bar_style = None - if args.multi_select_cursor_brackets_style != "": - args.multi_select_cursor_brackets_style = tuple(args.multi_select_cursor_brackets_style.split(",")) - else: - args.multi_select_cursor_brackets_style = None - if args.multi_select_cursor_style != "": - args.multi_select_cursor_style = tuple(args.multi_select_cursor_style.split(",")) - else: - args.multi_select_cursor_style = None - if args.multi_select_keys != "": - args.multi_select_keys = tuple(args.multi_select_keys.split(",")) - else: - args.multi_select_keys = None - if args.search_key.lower() == "none": - args.search_key = None - if args.show_shortcut_hints_in_status_bar: - args.show_shortcut_hints = True - if args.multi_select: - args.stdout = True - if args.preselected_entries is not None: - args.preselected = list(args.preselected_entries.split(",")) - elif args.preselected_indices is not None: - args.preselected = list(map(int, args.preselected_indices.split(","))) - else: - args.preselected = None - return args - - -def main() -> None: - try: - args = parse_arguments() - except SystemExit: - sys.exit(0) # Error code 0 is the error case in this program - except NoMenuEntriesError as e: - print(str(e), file=sys.stderr) - sys.exit(0) - if args.print_version: - print("{}, version {}".format(os.path.basename(sys.argv[0]), __version__)) - sys.exit(0) - try: - terminal_menu = TerminalMenu( - menu_entries=args.entries, - clear_menu_on_exit=args.clear_menu_on_exit, - clear_screen=args.clear_screen, - cursor_index=args.cursor_index, - cycle_cursor=args.cycle, - exit_on_shortcut=args.exit_on_shortcut, - menu_cursor=args.cursor, - menu_cursor_style=args.cursor_style, - menu_highlight_style=args.highlight_style, - multi_select=args.multi_select, - multi_select_cursor=args.multi_select_cursor, - multi_select_cursor_brackets_style=args.multi_select_cursor_brackets_style, - multi_select_cursor_style=args.multi_select_cursor_style, - multi_select_empty_ok=args.multi_select_empty_ok, - multi_select_keys=args.multi_select_keys, - multi_select_select_on_accept=args.multi_select_select_on_accept, - preselected_entries=args.preselected, - preview_border=args.preview_border, - preview_command=args.preview_command, - preview_size=args.preview_size, - preview_title=args.preview_title, - search_case_sensitive=args.case_sensitive, - search_highlight_style=args.search_highlight_style, - search_key=args.search_key, - shortcut_brackets_highlight_style=args.shortcut_brackets_highlight_style, - shortcut_key_highlight_style=args.shortcut_key_highlight_style, - show_multi_select_hint=args.show_multi_select_hint, - show_multi_select_hint_text=args.show_multi_select_hint_text, - show_search_hint=args.show_search_hint, - show_search_hint_text=args.show_search_hint_text, - show_shortcut_hints=args.show_shortcut_hints, - show_shortcut_hints_in_status_bar=args.show_shortcut_hints_in_status_bar, - skip_empty_entries=args.skip_empty_entries, - status_bar=args.status_bar, - status_bar_below_preview=args.status_bar_below_preview, - status_bar_style=args.status_bar_style, - title=args.title, - ) - except (InvalidParameterCombinationError, InvalidStyleError, UnknownMenuEntryError) as e: - print(str(e), file=sys.stderr) - sys.exit(0) - chosen_entries = terminal_menu.show() - if chosen_entries is None: - sys.exit(0) - else: - if isinstance(chosen_entries, Iterable): - if args.stdout: - print(",".join(str(entry + 1) for entry in chosen_entries)) - sys.exit(chosen_entries[0] + 1) - else: - chosen_entry = chosen_entries - if args.stdout: - print(chosen_entry + 1) - sys.exit(chosen_entry + 1) - - -if __name__ == "__main__": - main() diff --git a/archinstall/lib/menu/table_selection_menu.py b/archinstall/lib/menu/table_selection_menu.py index 09cd6ee2..fec6ae59 100644 --- a/archinstall/lib/menu/table_selection_menu.py +++ b/archinstall/lib/menu/table_selection_menu.py @@ -1,19 +1,25 @@ -from typing import Any, Tuple, List, Dict, Optional +from typing import Any, Tuple, List, Dict, Optional, Callable -from .menu import MenuSelectionType, MenuSelection +from .menu import MenuSelectionType, MenuSelection, Menu from ..output import FormattedOutput -from ..menu import Menu class TableMenu(Menu): def __init__( self, title: str, - data: List[Any] = [], + data: Optional[List[Any]] = None, table_data: Optional[Tuple[List[Any], str]] = None, + preset: List[Any] = [], custom_menu_options: List[str] = [], default: Any = None, - multi: bool = False + multi: bool = False, + preview_command: Optional[Callable] = None, + preview_title: str = 'Info', + preview_size: float = 0.0, + allow_reset: bool = True, + allow_reset_warning_msg: Optional[str] = None, + skip: bool = True ): """ param title: Text that will be displayed above the menu @@ -29,10 +35,10 @@ class TableMenu(Menu): param custom_options: List of custom options that will be displayed under the table :type custom_menu_options: List - """ - if not data and not table_data: - raise ValueError('Either "data" or "table_data" must be provided') + :param preview_command: A function that should return a string that will be displayed in a preview window when a menu selection item is in focus + :type preview_command: Callable + """ self._custom_options = custom_menu_options self._multi = multi @@ -41,7 +47,7 @@ class TableMenu(Menu): else: header_padding = 2 - if len(data): + if data is not None: table_text = FormattedOutput.as_table(data) rows = table_text.split('\n') table = self._create_table(data, rows, header_padding=header_padding) @@ -53,20 +59,54 @@ class TableMenu(Menu): data = table_data[0] rows = table_data[1].split('\n') table = self._create_table(data, rows, header_padding=header_padding) + else: + raise ValueError('Either "data" or "table_data" must be provided') self._options, header = self._prepare_selection(table) + preset_values = self._preset_values(preset) + + extra_bottom_space = True if preview_command else False + super().__init__( title, self._options, + preset_values=preset_values, header=header, skip_empty_entries=True, show_search_hint=False, - allow_reset=True, multi=multi, - default_option=default + default_option=default, + preview_command=lambda x: self._table_show_preview(preview_command, x), + preview_size=preview_size, + preview_title=preview_title, + extra_bottom_space=extra_bottom_space, + allow_reset=allow_reset, + allow_reset_warning_msg=allow_reset_warning_msg, + skip=skip ) + def _preset_values(self, preset: List[Any]) -> List[str]: + # when we create the table of just the preset values it will + # be formatted a bit different due to spacing, so to determine + # correct rows lets remove all the spaces and compare apples with apples + preset_table = FormattedOutput.as_table(preset).strip() + data_rows = preset_table.split('\n')[2:] # get all data rows + pure_data_rows = [self._escape_row(row.replace(' ', '')) for row in data_rows] + + # the actual preset value has to be in non-escaped form + pure_option_rows = {o.replace(' ', ''): self._unescape_row(o) for o in self._options.keys()} + preset_rows = [row for pure, row in pure_option_rows.items() if pure in pure_data_rows] + + return preset_rows + + def _table_show_preview(self, preview_command: Optional[Callable], selection: Any) -> Optional[str]: + if preview_command: + row = self._escape_row(selection) + obj = self._options[row] + return preview_command(obj) + return None + def run(self) -> MenuSelection: choice = super().run() @@ -79,6 +119,12 @@ class TableMenu(Menu): return choice + def _escape_row(self, row: str) -> str: + return row.replace('|', '\\|') + + def _unescape_row(self, row: str) -> str: + return row.replace('\\|', '|') + def _create_table(self, data: List[Any], rows: List[str], header_padding: int = 2) -> Dict[str, Any]: # these are the header rows of the table and do not map to any data obviously # we're adding 2 spaces as prefix because the menu selector '> ' will be put before @@ -87,7 +133,7 @@ class TableMenu(Menu): display_data = {f'{padding}{rows[0]}': None, f'{padding}{rows[1]}': None} for row, entry in zip(rows[2:], data): - row = row.replace('|', '\\|') + row = self._escape_row(row) display_data[row] = entry return display_data diff --git a/archinstall/lib/menu/text_input.py b/archinstall/lib/menu/text_input.py index 05ca0f22..971df5fd 100644 --- a/archinstall/lib/menu/text_input.py +++ b/archinstall/lib/menu/text_input.py @@ -1,4 +1,5 @@ import readline +import sys class TextInput: @@ -12,6 +13,14 @@ class TextInput: def run(self) -> str: readline.set_pre_input_hook(self._hook) - result = input(self._prompt) + try: + result = input(self._prompt) + except (KeyboardInterrupt, EOFError): + # To make sure any output that may follow + # will be on the line after the prompt + sys.stdout.write('\n') + sys.stdout.flush() + + result = '' readline.set_pre_input_hook() return result diff --git a/archinstall/lib/mirrors.py b/archinstall/lib/mirrors.py index f78a8b18..18ffffcd 100644 --- a/archinstall/lib/mirrors.py +++ b/archinstall/lib/mirrors.py @@ -1,187 +1,318 @@ -import logging import pathlib -import urllib.error -import urllib.request -from typing import Union, Mapping, Iterable, Dict, Any, List +from dataclasses import dataclass, field +from enum import Enum +from typing import Dict, Any, List, Optional, TYPE_CHECKING -from .general import SysCommand -from .output import log +from .menu import AbstractSubMenu, Selector, MenuSelectionType, Menu, ListManager, TextInput +from .networking import fetch_data_from_url +from .output import warn, FormattedOutput from .storage import storage -def sort_mirrorlist(raw_data :bytes, sort_order=["https", "http"]) -> bytes: - """ - This function can sort /etc/pacman.d/mirrorlist according to the - mirror's URL prefix. By default places HTTPS before HTTP but it also - preserves the country/rank-order. - - This assumes /etc/pacman.d/mirrorlist looks like the following: - - ## Comment - Server = url - - or - - ## Comment - #Server = url - - But the Comments need to start with double-hashmarks to be distringuished - from server url definitions (commented or uncommented). - """ - comments_and_whitespaces = b"" - - categories = {key: [] for key in sort_order + ["Unknown"]} - for line in raw_data.split(b"\n"): - if line[0:2] in (b'##', b''): - comments_and_whitespaces += line + b'\n' - elif line[:6].lower() == b'server' or line[:7].lower() == b'#server': - opening, url = line.split(b'=', 1) - opening, url = opening.strip(), url.strip() - if (category := url.split(b'://',1)[0].decode('UTF-8')) in categories: - categories[category].append(comments_and_whitespaces) - categories[category].append(opening + b' = ' + url + b'\n') - else: - categories["Unknown"].append(comments_and_whitespaces) - categories["Unknown"].append(opening + b' = ' + url + b'\n') - - comments_and_whitespaces = b"" - - new_raw_data = b'' - for category in sort_order + ["Unknown"]: - for line in categories[category]: - new_raw_data += line - - return new_raw_data - - -def filter_mirrors_by_region(regions :str, - destination :str = '/etc/pacman.d/mirrorlist', - sort_order :List[str] = ["https", "http"], - *args :str, - **kwargs :str -) -> Union[bool, bytes]: +if TYPE_CHECKING: + _: Any + + +class SignCheck(Enum): + Never = 'Never' + Optional = 'Optional' + Required = 'Required' + + +class SignOption(Enum): + TrustedOnly = 'TrustedOnly' + TrustAll = 'TrustAll' + + +@dataclass +class CustomMirror: + name: str + url: str + sign_check: SignCheck + sign_option: SignOption + + def table_data(self) -> Dict[str, str]: + return { + 'Name': self.name, + 'Url': self.url, + 'Sign check': self.sign_check.value, + 'Sign options': self.sign_option.value + } + + def json(self) -> Dict[str, str]: + return { + 'name': self.name, + 'url': self.url, + 'sign_check': self.sign_check.value, + 'sign_option': self.sign_option.value + } + + @classmethod + def parse_args(cls, args: List[Dict[str, str]]) -> List['CustomMirror']: + configs = [] + for arg in args: + configs.append( + CustomMirror( + arg['name'], + arg['url'], + SignCheck(arg['sign_check']), + SignOption(arg['sign_option']) + ) + ) + + return configs + + +@dataclass +class MirrorConfiguration: + mirror_regions: Dict[str, List[str]] = field(default_factory=dict) + custom_mirrors: List[CustomMirror] = field(default_factory=list) + + @property + def regions(self) -> str: + return ', '.join(self.mirror_regions.keys()) + + def json(self) -> Dict[str, Any]: + return { + 'mirror_regions': self.mirror_regions, + 'custom_mirrors': [c.json() for c in self.custom_mirrors] + } + + def mirrorlist_config(self) -> str: + config = '' + + for region, mirrors in self.mirror_regions.items(): + for mirror in mirrors: + config += f'\n\n## {region}\nServer = {mirror}\n' + + for cm in self.custom_mirrors: + config += f'\n\n## {cm.name}\nServer = {cm.url}\n' + + return config + + def pacman_config(self) -> str: + config = '' + + for mirror in self.custom_mirrors: + config += f'\n\n[{mirror.name}]\n' + config += f'SigLevel = {mirror.sign_check.value} {mirror.sign_option.value}\n' + config += f'Server = {mirror.url}\n' + + return config + + @classmethod + def parse_args(cls, args: Dict[str, Any]) -> 'MirrorConfiguration': + config = MirrorConfiguration() + + if 'mirror_regions' in args: + config.mirror_regions = args['mirror_regions'] + + if 'custom_mirrors' in args: + config.custom_mirrors = CustomMirror.parse_args(args['custom_mirrors']) + + return config + + +class CustomMirrorList(ListManager): + def __init__(self, prompt: str, custom_mirrors: List[CustomMirror]): + self._actions = [ + str(_('Add a custom mirror')), + str(_('Change custom mirror')), + str(_('Delete custom mirror')) + ] + super().__init__(prompt, custom_mirrors, [self._actions[0]], self._actions[1:]) + + def selected_action_display(self, mirror: CustomMirror) -> str: + return mirror.name + + def handle_action( + self, + action: str, + entry: Optional[CustomMirror], + data: List[CustomMirror] + ) -> List[CustomMirror]: + if action == self._actions[0]: # add + new_mirror = self._add_custom_mirror() + if new_mirror is not None: + data = [d for d in data if d.name != new_mirror.name] + data += [new_mirror] + elif action == self._actions[1] and entry: # modify mirror + new_mirror = self._add_custom_mirror(entry) + if new_mirror is not None: + data = [d for d in data if d.name != entry.name] + data += [new_mirror] + elif action == self._actions[2] and entry: # delete + data = [d for d in data if d != entry] + + return data + + def _add_custom_mirror(self, mirror: Optional[CustomMirror] = None) -> Optional[CustomMirror]: + prompt = '\n\n' + str(_('Enter name (leave blank to skip): ')) + existing_name = mirror.name if mirror else '' + + while True: + name = TextInput(prompt, existing_name).run() + if not name: + return mirror + break + + prompt = '\n' + str(_('Enter url (leave blank to skip): ')) + existing_url = mirror.url if mirror else '' + + while True: + url = TextInput(prompt, existing_url).run() + if not url: + return mirror + break + + sign_check_choice = Menu( + str(_('Select signature check option')), + [s.value for s in SignCheck], + skip=False, + clear_screen=False, + preset_values=mirror.sign_check.value if mirror else None + ).run() + + sign_option_choice = Menu( + str(_('Select signature option')), + [s.value for s in SignOption], + skip=False, + clear_screen=False, + preset_values=mirror.sign_option.value if mirror else None + ).run() + + return CustomMirror( + name, + url, + SignCheck(sign_check_choice.single_value), + SignOption(sign_option_choice.single_value) + ) + + +class MirrorMenu(AbstractSubMenu): + def __init__( + self, + data_store: Dict[str, Any], + preset: Optional[MirrorConfiguration] = None + ): + if preset: + self._preset = preset + else: + self._preset = MirrorConfiguration() + + super().__init__(data_store=data_store) + + def setup_selection_menu_options(self): + self._menu_options['mirror_regions'] = \ + Selector( + _('Mirror region'), + lambda preset: select_mirror_regions(preset), + display_func=lambda x: ', '.join(x.keys()) if x else '', + default=self._preset.mirror_regions, + enabled=True) + self._menu_options['custom_mirrors'] = \ + Selector( + _('Custom mirrors'), + lambda preset: select_custom_mirror(preset=preset), + display_func=lambda x: str(_('Defined')) if x else '', + preview_func=self._prev_custom_mirror, + default=self._preset.custom_mirrors, + enabled=True + ) + + def _prev_custom_mirror(self) -> Optional[str]: + selector = self._menu_options['custom_mirrors'] + + if selector.has_selection(): + custom_mirrors: List[CustomMirror] = selector.current_selection # type: ignore + output = FormattedOutput.as_table(custom_mirrors) + return output.strip() + + return None + + def run(self, allow_reset: bool = True) -> Optional[MirrorConfiguration]: + super().run(allow_reset=allow_reset) + + if self._data_store.get('mirror_regions', None) or self._data_store.get('custom_mirrors', None): + return MirrorConfiguration( + mirror_regions=self._data_store['mirror_regions'], + custom_mirrors=self._data_store['custom_mirrors'], + ) + + return None + + +def select_mirror_regions(preset_values: Dict[str, List[str]] = {}) -> Dict[str, List[str]]: """ - This function will change the active mirrors on the live medium by - filtering which regions are active based on `regions`. + Asks the user to select a mirror or region + Usually this is combined with :ref:`archinstall.list_mirrors`. - :param regions: A series of country codes separated by `,`. For instance `SE,US` for sweden and United States. - :type regions: str + :return: The dictionary information about a mirror/region. + :rtype: dict """ - region_list = [f'country={region}' for region in regions.split(',')] - response = urllib.request.urlopen(urllib.request.Request(f"https://archlinux32.org/mirrorlist/?{'&'.join(region_list)}&protocol=https&protocol=http&ip_version=4&ip_version=6&use_mirror_status=on'", headers={'User-Agent': 'ArchInstall'})) - new_list = response.read().replace(b"#Server", b"Server") - - if sort_order: - new_list = sort_mirrorlist(new_list, sort_order=sort_order) + if preset_values is None: + preselected = None + else: + preselected = list(preset_values.keys()) - if destination: - with open(destination, "wb") as mirrorlist: - mirrorlist.write(new_list) + mirrors = list_mirrors() - return True - else: - return new_list.decode('UTF-8') + choice = Menu( + _('Select one of the regions to download packages from'), + list(mirrors.keys()), + preset_values=preselected, + multi=True, + allow_reset=True + ).run() + match choice.type_: + case MenuSelectionType.Reset: + return {} + case MenuSelectionType.Skip: + return preset_values + case MenuSelectionType.Selection: + return {selected: mirrors[selected] for selected in choice.multi_value} -def add_custom_mirrors(mirrors: List[str], *args :str, **kwargs :str) -> bool: - """ - This will append custom mirror definitions in pacman.conf + return {} - :param mirrors: A list of mirror data according to: `{'url': 'http://url.com', 'signcheck': 'Optional', 'signoptions': 'TrustAll', 'name': 'testmirror'}` - :type mirrors: dict - """ - with open('/etc/pacman.conf', 'a') as pacman: - for mirror in mirrors: - pacman.write(f"[{mirror['name']}]\n") - pacman.write(f"SigLevel = {mirror['signcheck']} {mirror['signoptions']}\n") - pacman.write(f"Server = {mirror['url']}\n") - return True +def select_custom_mirror(prompt: str = '', preset: List[CustomMirror] = []): + custom_mirrors = CustomMirrorList(prompt, preset).run() + return custom_mirrors -def insert_mirrors(mirrors :Dict[str, Any], *args :str, **kwargs :str) -> bool: - """ - This function will insert a given mirror-list at the top of `/etc/pacman.d/mirrorlist`. - It will not flush any other mirrors, just insert new ones. +def _parse_mirror_list(mirrorlist: str) -> Dict[str, List[str]]: + file_content = mirrorlist.split('\n') + file_content = list(filter(lambda x: x, file_content)) # filter out empty lines + first_srv_idx = [idx for idx, line in enumerate(file_content) if 'server' in line.lower()][0] + mirrors = file_content[first_srv_idx - 1:] - :param mirrors: A dictionary of `{'url' : 'country', 'url2' : 'country'}` - :type mirrors: dict - """ - original_mirrorlist = '' - with open('/etc/pacman.d/mirrorlist', 'r') as original: - original_mirrorlist = original.read() - - with open('/etc/pacman.d/mirrorlist', 'w') as new_mirrorlist: - for mirror, country in mirrors.items(): - new_mirrorlist.write(f'## {country}\n') - new_mirrorlist.write(f'Server = {mirror}\n') - new_mirrorlist.write('\n') - new_mirrorlist.write(original_mirrorlist) - - return True - - -def use_mirrors( - regions: Mapping[str, Iterable[str]], - destination: str = '/etc/pacman.d/mirrorlist' -) -> None: - log(f'A new package mirror-list has been created: {destination}', level=logging.INFO) - with open(destination, 'w') as mirrorlist: - for region, mirrors in regions.items(): - for mirror in mirrors: - mirrorlist.write(f'## {region}\n') - mirrorlist.write(f'Server = {mirror}\n') + mirror_list: Dict[str, List[str]] = {} + for idx in range(0, len(mirrors), 2): + region = mirrors[idx].removeprefix('## ') + url = mirrors[idx + 1].removeprefix('#').removeprefix('Server = ') + mirror_list.setdefault(region, []).append(url) -def re_rank_mirrors( - top: int = 10, - src: str = '/etc/pacman.d/mirrorlist', - dst: str = '/etc/pacman.d/mirrorlist', -) -> bool: - cmd = SysCommand(f"/usr/bin/rankmirrors -n {top} {src}") - if cmd.exit_code != 0: - return False - with open(dst, 'w') as f: - f.write(str(cmd)) - return True + return mirror_list -def list_mirrors(sort_order :List[str] = ["https", "http"]) -> Dict[str, Any]: - regions = {} +def list_mirrors() -> Dict[str, List[str]]: + regions: Dict[str, List[str]] = {} if storage['arguments']['offline']: - with pathlib.Path('/etc/pacman.d/mirrorlist').open('rb') as fh: - mirrorlist = fh.read() + with pathlib.Path('/etc/pacman.d/mirrorlist').open('r') as fp: + mirrorlist = fp.read() else: url = "https://archlinux32.org/mirrorlist/?protocol=https&protocol=http&ip_version=4&ip_version=6&use_mirror_status=on" - try: - response = urllib.request.urlopen(url) - except urllib.error.URLError as err: - log(f'Could not fetch an active mirror-list: {err}', level=logging.WARNING, fg="orange") + mirrorlist = fetch_data_from_url(url) + except ValueError as err: + warn(f'Could not fetch an active mirror-list: {err}') return regions - mirrorlist = response.read() - - if sort_order: - mirrorlist = sort_mirrorlist(mirrorlist, sort_order=sort_order) - - region = 'Unknown region' - for line in mirrorlist.split(b'\n'): - if len(line.strip()) == 0: - continue - - line = line.decode('UTF-8').strip('\n').strip('\r') - if line[:3] == '## ': - region = line[3:] - elif line[:10] == '#Server = ': - regions.setdefault(region, {}) - - url = line.lstrip('#Server = ') - regions[region][url] = True - elif line.startswith('Server = '): - regions.setdefault(region, {}) - - url = line.lstrip('Server = ') - regions[region][url] = True + regions = _parse_mirror_list(mirrorlist) + sorted_regions = {} + for region, urls in regions.items(): + sorted_regions[region] = sorted(urls, reverse=True) - return regions + return sorted_regions diff --git a/archinstall/lib/models/__init__.py b/archinstall/lib/models/__init__.py index 4a018b2c..a1c90e48 100644 --- a/archinstall/lib/models/__init__.py +++ b/archinstall/lib/models/__init__.py @@ -1 +1,9 @@ -from .network_configuration import NetworkConfiguration as NetworkConfiguration
\ No newline at end of file +from .network_configuration import ( + NetworkConfiguration, + NicType, + Nic +) +from .bootloader import Bootloader +from .gen import VersionDef, PackageSearchResult, PackageSearch, LocalPackage +from .users import PasswordStrength, User +from .audio_configuration import Audio, AudioConfiguration diff --git a/archinstall/lib/models/audio_configuration.py b/archinstall/lib/models/audio_configuration.py new file mode 100644 index 00000000..88cd5d8e --- /dev/null +++ b/archinstall/lib/models/audio_configuration.py @@ -0,0 +1,54 @@ +from dataclasses import dataclass +from enum import Enum +from typing import Any, TYPE_CHECKING, Dict + +from ..hardware import SysInfo +from ..output import info +from ...default_profiles.applications.pipewire import PipewireProfile + +if TYPE_CHECKING: + _: Any + + +@dataclass +class Audio(Enum): + Pipewire = 'pipewire' + Pulseaudio = 'pulseaudio' + + @staticmethod + def no_audio_text() -> str: + return str(_('No audio server')) + + +@dataclass +class AudioConfiguration: + audio: Audio + + def json(self) -> Dict[str, Any]: + return { + 'audio': self.audio.value + } + + @staticmethod + def parse_arg(arg: Dict[str, Any]) -> 'AudioConfiguration': + return AudioConfiguration( + Audio(arg['audio']) + ) + + def install_audio_config( + self, + installation: Any + ): + info(f'Installing audio server: {self.audio.name}') + + match self.audio: + case Audio.Pipewire: + PipewireProfile().install(installation) + case Audio.Pulseaudio: + installation.add_additional_packages("pulseaudio") + + if SysInfo.requires_sof_fw(): + installation.add_additional_packages('sof-firmware') + + if SysInfo.requires_alsa_fw(): + installation.add_additional_packages('alsa-firmware') diff --git a/archinstall/lib/models/bootloader.py b/archinstall/lib/models/bootloader.py new file mode 100644 index 00000000..aa1a8e27 --- /dev/null +++ b/archinstall/lib/models/bootloader.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import sys +from enum import Enum +from typing import List + +from ..hardware import SysInfo +from ..output import warn + + +class Bootloader(Enum): + Systemd = 'Systemd-boot' + Grub = 'Grub' + Efistub = 'Efistub' + Limine = 'Limine' + + def has_uki_support(self) -> bool: + match self: + case Bootloader.Efistub | Bootloader.Systemd: + return True + case _: + return False + + def json(self) -> str: + return self.value + + @staticmethod + def values() -> List[str]: + return [e.value for e in Bootloader] + + @classmethod + def get_default(cls) -> Bootloader: + if SysInfo.has_uefi(): + return Bootloader.Systemd + else: + return Bootloader.Grub + + @classmethod + def from_arg(cls, bootloader: str) -> Bootloader: + # to support old configuration files + bootloader = bootloader.capitalize() + + if bootloader not in cls.values(): + values = ', '.join(cls.values()) + warn(f'Invalid bootloader value "{bootloader}". Allowed values: {values}') + sys.exit(1) + return Bootloader(bootloader) diff --git a/archinstall/lib/models/disk_encryption.py b/archinstall/lib/models/disk_encryption.py deleted file mode 100644 index a4a501d9..00000000 --- a/archinstall/lib/models/disk_encryption.py +++ /dev/null @@ -1,90 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from enum import Enum -from typing import Optional, List, Dict, TYPE_CHECKING, Any - -from ..hsm.fido import Fido2Device - -if TYPE_CHECKING: - _: Any - - -class EncryptionType(Enum): - Partition = 'partition' - - @classmethod - def _encryption_type_mapper(cls) -> Dict[str, 'EncryptionType']: - return { - # str(_('Full disk encryption')): EncryptionType.FullDiskEncryption, - str(_('Partition encryption')): EncryptionType.Partition - } - - @classmethod - def text_to_type(cls, text: str) -> 'EncryptionType': - mapping = cls._encryption_type_mapper() - return mapping[text] - - @classmethod - def type_to_text(cls, type_: 'EncryptionType') -> str: - mapping = cls._encryption_type_mapper() - type_to_text = {type_: text for text, type_ in mapping.items()} - return type_to_text[type_] - - -@dataclass -class DiskEncryption: - encryption_type: EncryptionType = EncryptionType.Partition - encryption_password: str = '' - partitions: Dict[str, List[Dict[str, Any]]] = field(default_factory=dict) - hsm_device: Optional[Fido2Device] = None - - @property - def all_partitions(self) -> List[Dict[str, Any]]: - _all: List[Dict[str, Any]] = [] - for parts in self.partitions.values(): - _all += parts - return _all - - def generate_encryption_file(self, partition) -> bool: - return partition in self.all_partitions and partition['mountpoint'] != '/' - - def json(self) -> Dict[str, Any]: - obj = { - 'encryption_type': self.encryption_type.value, - 'partitions': self.partitions - } - - if self.hsm_device: - obj['hsm_device'] = self.hsm_device.json() - - return obj - - @classmethod - def parse_arg( - cls, - disk_layout: Dict[str, Any], - arg: Dict[str, Any], - password: str = '' - ) -> 'DiskEncryption': - # we have to map the enc partition config to the disk layout objects - # they both need to point to the same object as it will get modified - # during the installation process - enc_partitions: Dict[str, List[Dict[str, Any]]] = {} - - for path, partitions in disk_layout.items(): - conf_partitions = arg['partitions'].get(path, []) - for part in partitions['partitions']: - if part in conf_partitions: - enc_partitions.setdefault(path, []).append(part) - - enc = DiskEncryption( - EncryptionType(arg['encryption_type']), - password, - enc_partitions - ) - - if hsm := arg.get('hsm_device', None): - enc.hsm_device = Fido2Device.parse_arg(hsm) - - return enc diff --git a/archinstall/lib/models/dataclasses.py b/archinstall/lib/models/gen.py index 99221fe3..fb7e5751 100644 --- a/archinstall/lib/models/dataclasses.py +++ b/archinstall/lib/models/gen.py @@ -1,16 +1,17 @@ from dataclasses import dataclass -from typing import Optional, List +from typing import Optional, List, Dict, Any + @dataclass class VersionDef: version_string: str @classmethod - def parse_version(self) -> List[str]: - if '.' in self.version_string: - versions = self.version_string.split('.') + def parse_version(cls) -> List[str]: + if '.' in cls.version_string: + versions = cls.version_string.split('.') else: - versions = [self.version_string] + versions = [cls.version_string] return versions @@ -19,37 +20,44 @@ class VersionDef: return self.parse_version()[0] @classmethod - def minor(self) -> str: - versions = self.parse_version() + def minor(cls) -> Optional[str]: + versions = cls.parse_version() if len(versions) >= 2: return versions[1] + return None + @classmethod - def patch(self) -> str: - versions = self.parse_version() + def patch(cls) -> Optional[str]: + versions = cls.parse_version() if '-' in versions[-1]: _, patch_version = versions[-1].split('-', 1) return patch_version - def __eq__(self, other :'VersionDef') -> bool: + return None + + def __eq__(self, other) -> bool: if other.major == self.major and \ other.minor == self.minor and \ other.patch == self.patch: return True return False - - def __lt__(self, other :'VersionDef') -> bool: - if self.major > other.major: + + def __lt__(self, other) -> bool: + if self.major() > other.major(): return False - elif self.minor and other.minor and self.minor > other.minor: + elif self.minor() and other.minor() and self.minor() > other.minor(): return False - elif self.patch and other.patch and self.patch > other.patch: + elif self.patch() and other.patch() and self.patch() > other.patch(): return False + return True + def __str__(self) -> str: return self.version_string + @dataclass class PackageSearchResult: pkgname: str @@ -79,16 +87,21 @@ class PackageSearchResult: makedepends: List[str] checkdepends: List[str] + @staticmethod + def from_json(data: Dict[str, Any]) -> 'PackageSearchResult': + return PackageSearchResult(**data) + @property def pkg_version(self) -> str: return self.pkgver - def __eq__(self, other :'VersionDef') -> bool: + def __eq__(self, other) -> bool: return self.pkg_version == other.pkg_version - def __lt__(self, other :'VersionDef') -> bool: + def __lt__(self, other) -> bool: return self.pkg_version < other.pkg_version + @dataclass class PackageSearch: version: int @@ -98,8 +111,19 @@ class PackageSearch: page: int results: List[PackageSearchResult] - def __post_init__(self): - self.results = [PackageSearchResult(**x) for x in self.results] + @staticmethod + def from_json(data: Dict[str, Any]) -> 'PackageSearch': + results = [PackageSearchResult.from_json(r) for r in data['results']] + + return PackageSearch( + version=data['version'], + limit=data['limit'], + valid=data['valid'], + num_pages=data['num_pages'], + page=data['page'], + results=results + ) + @dataclass class LocalPackage: @@ -129,8 +153,8 @@ class LocalPackage: def pkg_version(self) -> str: return self.version - def __eq__(self, other :'VersionDef') -> bool: + def __eq__(self, other) -> bool: return self.pkg_version == other.pkg_version - def __lt__(self, other :'VersionDef') -> bool: - return self.pkg_version < other.pkg_version
\ No newline at end of file + def __lt__(self, other) -> bool: + return self.pkg_version < other.pkg_version diff --git a/archinstall/lib/models/network_configuration.py b/archinstall/lib/models/network_configuration.py index e026e97b..dfd8b8cb 100644 --- a/archinstall/lib/models/network_configuration.py +++ b/archinstall/lib/models/network_configuration.py @@ -1,183 +1,144 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import Enum -from typing import List, Optional, Dict, Union, Any, TYPE_CHECKING +from typing import List, Optional, Dict, Any, TYPE_CHECKING, Tuple -from ..output import log -from ..storage import storage +from ..profile import ProfileConfiguration if TYPE_CHECKING: _: Any -class NicType(str, Enum): +class NicType(Enum): ISO = "iso" NM = "nm" MANUAL = "manual" + def display_msg(self) -> str: + match self: + case NicType.ISO: + return str(_('Copy ISO network configuration to installation')) + case NicType.NM: + return str(_('Use NetworkManager (necessary to configure internet graphically in GNOME and KDE Plasma)')) + case NicType.MANUAL: + return str(_('Manual configuration')) + @dataclass -class NetworkConfiguration: - type: NicType +class Nic: iface: Optional[str] = None ip: Optional[str] = None dhcp: bool = True gateway: Optional[str] = None - dns: Union[None, List[str]] = None - - def __str__(self): - if self.is_iso(): - return "Copy ISO configuration" - elif self.is_network_manager(): - return "Use NetworkManager" - elif self.is_manual(): - if self.dhcp: - return f'iface={self.iface}, dhcp=auto' - else: - return f'iface={self.iface}, ip={self.ip}, dhcp=staticIp, gateway={self.gateway}, dns={self.dns}' + dns: List[str] = field(default_factory=list) + + def table_data(self) -> Dict[str, Any]: + return { + 'iface': self.iface if self.iface else '', + 'ip': self.ip if self.ip else '', + 'dhcp': self.dhcp, + 'gateway': self.gateway if self.gateway else '', + 'dns': self.dns + } + + def json(self) -> Dict[str, Any]: + return { + 'iface': self.iface, + 'ip': self.ip, + 'dhcp': self.dhcp, + 'gateway': self.gateway, + 'dns': self.dns + } + + @staticmethod + def parse_arg(arg: Dict[str, Any]) -> Nic: + return Nic( + iface=arg.get('iface', None), + ip=arg.get('ip', None), + dhcp=arg.get('dhcp', True), + gateway=arg.get('gateway', None), + dns=arg.get('dns', []), + ) + + def as_systemd_config(self) -> str: + match: List[Tuple[str, str]] = [] + network: List[Tuple[str, str]] = [] + + if self.iface: + match.append(('Name', self.iface)) + + if self.dhcp: + network.append(('DHCP', 'yes')) else: - return 'Unknown type' - - def as_json(self) -> Dict: - exclude_fields = ['type'] - data = {} - for k, v in self.__dict__.items(): - if k not in exclude_fields: - if isinstance(v, list) and len(v) == 0: - v = '' - elif v is None: - v = '' - - data[k] = v - - return data + if self.ip: + network.append(('Address', self.ip)) + if self.gateway: + network.append(('Gateway', self.gateway)) + for dns in self.dns: + network.append(('DNS', dns)) - def json(self) -> Dict: - # for json serialization when calling json.dumps(...) on this class - return self.__dict__ + config = {'Match': match, 'Network': network} - def is_iso(self) -> bool: - return self.type == NicType.ISO + config_str = '' + for top, entries in config.items(): + config_str += f'[{top}]\n' + config_str += '\n'.join([f'{k}={v}' for k, v in entries]) + config_str += '\n\n' - def is_network_manager(self) -> bool: - return self.type == NicType.NM + return config_str - def is_manual(self) -> bool: - return self.type == NicType.MANUAL +@dataclass +class NetworkConfiguration: + type: NicType + nics: List[Nic] = field(default_factory=list) -class NetworkConfigurationHandler: - def __init__(self, config: Union[None, NetworkConfiguration, List[NetworkConfiguration]] = None): - self._configuration = config - - @property - def configuration(self): - return self._configuration + def json(self) -> Dict[str, Any]: + config: Dict[str, Any] = {'type': self.type.value} + if self.nics: + config['nics'] = [n.json() for n in self.nics] - def config_installer(self, installation: Any): - if self._configuration is None: - return + return config - if isinstance(self._configuration, list): - for config in self._configuration: - installation.configure_nic(config) + @staticmethod + def parse_arg(config: Dict[str, Any]) -> Optional[NetworkConfiguration]: + nic_type = config.get('type', None) + if not nic_type: + return None - installation.enable_service('systemd-networkd') - installation.enable_service('systemd-resolved') - else: - # If user selected to copy the current ISO network configuration - # Perform a copy of the config - if self._configuration.is_iso(): + match NicType(nic_type): + case NicType.ISO: + return NetworkConfiguration(NicType.ISO) + case NicType.NM: + return NetworkConfiguration(NicType.NM) + case NicType.MANUAL: + nics_arg = config.get('nics', []) + if nics_arg: + nics = [Nic.parse_arg(n) for n in nics_arg] + return NetworkConfiguration(NicType.MANUAL, nics) + + return None + + def install_network_config( + self, + installation: Any, + profile_config: Optional[ProfileConfiguration] = None + ): + match self.type: + case NicType.ISO: installation.copy_iso_network_config( - enable_services=True) # Sources the ISO network configuration to the install medium. - elif self._configuration.is_network_manager(): + enable_services=True # Sources the ISO network configuration to the install medium. + ) + case NicType.NM: installation.add_additional_packages(["networkmanager"]) - if (profile := storage['arguments'].get('profile')) and profile.is_desktop_profile: - installation.add_additional_packages(["network-manager-applet"]) + if profile_config and profile_config.profile: + if profile_config.profile.is_desktop_profile(): + installation.add_additional_packages(["network-manager-applet"]) installation.enable_service('NetworkManager.service') + case NicType.MANUAL: + for nic in self.nics: + installation.configure_nic(nic) - def _backwards_compability_config(self, config: Union[str,Dict[str, str]]) -> Union[List[NetworkConfiguration], NetworkConfiguration, None]: - def get(config: Dict[str, str], key: str) -> List[str]: - if (value := config.get(key, None)) is not None: - return [value] - return [] - - if isinstance(config, str): # is a ISO network - return NetworkConfiguration(NicType.ISO) - elif config.get('NetworkManager'): # is a network manager configuration - return NetworkConfiguration(NicType.NM) - elif 'ip' in config: - return [NetworkConfiguration( - NicType.MANUAL, - iface=config.get('nic', ''), - ip=config.get('ip'), - gateway=config.get('gateway', ''), - dns=get(config, 'dns'), - dhcp=False - )] - elif 'nic' in config: - return [NetworkConfiguration( - NicType.MANUAL, - iface=config.get('nic', ''), - dhcp=True - )] - else: # not recognized - return None - - def _parse_manual_config(self, configs: List[Dict[str, Any]]) -> Optional[List[NetworkConfiguration]]: - configurations = [] - - for manual_config in configs: - iface = manual_config.get('iface', None) - - if iface is None: - log(_('No iface specified for manual configuration')) - exit(1) - - if manual_config.get('dhcp', False) or not any([manual_config.get(v, '') for v in ['ip', 'gateway', 'dns']]): - configurations.append( - NetworkConfiguration(NicType.MANUAL, iface=iface) - ) - else: - ip = manual_config.get('ip', '') - if not ip: - log(_('Manual nic configuration with no auto DHCP requires an IP address'), fg='red') - exit(1) - - configurations.append( - NetworkConfiguration( - NicType.MANUAL, - iface=iface, - ip=ip, - gateway=manual_config.get('gateway', ''), - dns=manual_config.get('dns', []), - dhcp=False - ) - ) - - return configurations - - def _parse_nic_type(self, nic_type: str) -> NicType: - try: - return NicType(nic_type) - except ValueError: - options = [e.value for e in NicType] - log(_('Unknown nic type: {}. Possible values are {}').format(nic_type, options), fg='red') - exit(1) - - def parse_arguments(self, config: Any): - if isinstance(config, list): # new data format - self._configuration = self._parse_manual_config(config) - elif nic_type := config.get('type', None): # new data format - type_ = self._parse_nic_type(nic_type) - - if type_ != NicType.MANUAL: - self._configuration = NetworkConfiguration(type_) - else: # manual configuration settings - self._configuration = self._parse_manual_config([config]) - else: # old style definitions - network_config = self._backwards_compability_config(config) - if network_config: - return network_config - return None + installation.enable_service('systemd-networkd') + installation.enable_service('systemd-resolved') diff --git a/archinstall/lib/models/password_strength.py b/archinstall/lib/models/password_strength.py deleted file mode 100644 index 61986bf0..00000000 --- a/archinstall/lib/models/password_strength.py +++ /dev/null @@ -1,85 +0,0 @@ -from enum import Enum - - -class PasswordStrength(Enum): - VERY_WEAK = 'very weak' - WEAK = 'weak' - MODERATE = 'moderate' - STRONG = 'strong' - - @property - def value(self): - match self: - case PasswordStrength.VERY_WEAK: return str(_('very weak')) - case PasswordStrength.WEAK: return str(_('weak')) - case PasswordStrength.MODERATE: return str(_('moderate')) - case PasswordStrength.STRONG: return str(_('strong')) - - def color(self): - match self: - case PasswordStrength.VERY_WEAK: return 'red' - case PasswordStrength.WEAK: return 'red' - case PasswordStrength.MODERATE: return 'yellow' - case PasswordStrength.STRONG: return 'green' - - @classmethod - def strength(cls, password: str) -> 'PasswordStrength': - digit = any(character.isdigit() for character in password) - upper = any(character.isupper() for character in password) - lower = any(character.islower() for character in password) - symbol = any(not character.isalnum() for character in password) - return cls._check_password_strength(digit, upper, lower, symbol, len(password)) - - @classmethod - def _check_password_strength( - cls, - digit: bool, - upper: bool, - lower: bool, - symbol: bool, - length: int - ) -> 'PasswordStrength': - # suggested evaluation - # https://github.com/archlinux/archinstall/issues/1304#issuecomment-1146768163 - if digit and upper and lower and symbol: - match length: - case num if 13 <= num: - return PasswordStrength.STRONG - case num if 11 <= num <= 12: - return PasswordStrength.MODERATE - case num if 7 <= num <= 10: - return PasswordStrength.WEAK - case num if num <= 6: - return PasswordStrength.VERY_WEAK - elif digit and upper and lower: - match length: - case num if 14 <= num: - return PasswordStrength.STRONG - case num if 11 <= num <= 13: - return PasswordStrength.MODERATE - case num if 7 <= num <= 10: - return PasswordStrength.WEAK - case num if num <= 6: - return PasswordStrength.VERY_WEAK - elif upper and lower: - match length: - case num if 15 <= num: - return PasswordStrength.STRONG - case num if 12 <= num <= 14: - return PasswordStrength.MODERATE - case num if 7 <= num <= 11: - return PasswordStrength.WEAK - case num if num <= 6: - return PasswordStrength.VERY_WEAK - elif lower or upper: - match length: - case num if 18 <= num: - return PasswordStrength.STRONG - case num if 14 <= num <= 17: - return PasswordStrength.MODERATE - case num if 9 <= num <= 13: - return PasswordStrength.WEAK - case num if num <= 8: - return PasswordStrength.VERY_WEAK - - return PasswordStrength.VERY_WEAK diff --git a/archinstall/lib/models/pydantic.py b/archinstall/lib/models/pydantic.py deleted file mode 100644 index 799e92af..00000000 --- a/archinstall/lib/models/pydantic.py +++ /dev/null @@ -1,134 +0,0 @@ -from typing import Optional, List -from pydantic import BaseModel - -""" -This python file is not in use. -Pydantic is not a builtin, and we use the dataclasses.py instead! -""" - -class VersionDef(BaseModel): - version_string: str - - @classmethod - def parse_version(self) -> List[str]: - if '.' in self.version_string: - versions = self.version_string.split('.') - else: - versions = [self.version_string] - - return versions - - @classmethod - def major(self) -> str: - return self.parse_version()[0] - - @classmethod - def minor(self) -> str: - versions = self.parse_version() - if len(versions) >= 2: - return versions[1] - - @classmethod - def patch(self) -> str: - versions = self.parse_version() - if '-' in versions[-1]: - _, patch_version = versions[-1].split('-', 1) - return patch_version - - def __eq__(self, other :'VersionDef') -> bool: - if other.major == self.major and \ - other.minor == self.minor and \ - other.patch == self.patch: - - return True - return False - - def __lt__(self, other :'VersionDef') -> bool: - if self.major > other.major: - return False - elif self.minor and other.minor and self.minor > other.minor: - return False - elif self.patch and other.patch and self.patch > other.patch: - return False - - def __str__(self) -> str: - return self.version_string - - -class PackageSearchResult(BaseModel): - pkgname: str - pkgbase: str - repo: str - arch: str - pkgver: str - pkgrel: str - epoch: int - pkgdesc: str - url: str - filename: str - compressed_size: int - installed_size: int - build_date: str - last_update: str - flag_date: Optional[str] - maintainers: List[str] - packager: str - groups: List[str] - licenses: List[str] - conflicts: List[str] - provides: List[str] - replaces: List[str] - depends: List[str] - optdepends: List[str] - makedepends: List[str] - checkdepends: List[str] - - @property - def pkg_version(self) -> str: - return self.pkgver - - def __eq__(self, other :'VersionDef') -> bool: - return self.pkg_version == other.pkg_version - - def __lt__(self, other :'VersionDef') -> bool: - return self.pkg_version < other.pkg_version - - -class PackageSearch(BaseModel): - version: int - limit: int - valid: bool - results: List[PackageSearchResult] - - -class LocalPackage(BaseModel): - name: str - version: str - description:str - architecture: str - url: str - licenses: str - groups: str - depends_on: str - optional_deps: str - required_by: str - optional_for: str - conflicts_with: str - replaces: str - installed_size: str - packager: str - build_date: str - install_date: str - install_reason: str - install_script: str - validated_by: str - - @property - def pkg_version(self) -> str: - return self.version - - def __eq__(self, other :'VersionDef') -> bool: - return self.pkg_version == other.pkg_version - - def __lt__(self, other :'VersionDef') -> bool: - return self.pkg_version < other.pkg_version
\ No newline at end of file diff --git a/archinstall/lib/models/subvolume.py b/archinstall/lib/models/subvolume.py deleted file mode 100644 index 34a09227..00000000 --- a/archinstall/lib/models/subvolume.py +++ /dev/null @@ -1,68 +0,0 @@ -from dataclasses import dataclass -from typing import List, Any, Dict - - -@dataclass -class Subvolume: - name: str - mountpoint: str - compress: bool = False - nodatacow: bool = False - - def display(self) -> str: - options_str = ','.join(self.options) - return f'{_("Subvolume")}: {self.name:15} {_("Mountpoint")}: {self.mountpoint:20} {_("Options")}: {options_str}' - - @property - def options(self) -> List[str]: - options = [ - 'compress' if self.compress else '', - 'nodatacow' if self.nodatacow else '' - ] - return [o for o in options if len(o)] - - def json(self) -> Dict[str, Any]: - return { - 'name': self.name, - 'mountpoint': self.mountpoint, - 'compress': self.compress, - 'nodatacow': self.nodatacow - } - - @classmethod - def _parse(cls, config_subvolumes: List[Dict[str, Any]]) -> List['Subvolume']: - subvolumes = [] - for entry in config_subvolumes: - if not entry.get('name', None) or not entry.get('mountpoint', None): - continue - - subvolumes.append( - Subvolume( - entry['name'], - entry['mountpoint'], - entry.get('compress', False), - entry.get('nodatacow', False) - ) - ) - - return subvolumes - - @classmethod - def _parse_backwards_compatible(cls, config_subvolumes) -> List['Subvolume']: - subvolumes = [] - for name, mountpoint in config_subvolumes.items(): - if not name or not mountpoint: - continue - - subvolumes.append(Subvolume(name, mountpoint)) - - return subvolumes - - @classmethod - def parse_arguments(cls, config_subvolumes: Any) -> List['Subvolume']: - if isinstance(config_subvolumes, list): - return cls._parse(config_subvolumes) - elif isinstance(config_subvolumes, dict): - return cls._parse_backwards_compatible(config_subvolumes) - - raise ValueError('Unknown disk layout btrfs subvolume format') diff --git a/archinstall/lib/models/users.py b/archinstall/lib/models/users.py index a8feb9ef..9ed70eef 100644 --- a/archinstall/lib/models/users.py +++ b/archinstall/lib/models/users.py @@ -1,12 +1,95 @@ from dataclasses import dataclass from typing import Dict, List, Union, Any, TYPE_CHECKING - -from .password_strength import PasswordStrength +from enum import Enum if TYPE_CHECKING: _: Any +class PasswordStrength(Enum): + VERY_WEAK = 'very weak' + WEAK = 'weak' + MODERATE = 'moderate' + STRONG = 'strong' + + @property + def value(self): + match self: + case PasswordStrength.VERY_WEAK: return str(_('very weak')) + case PasswordStrength.WEAK: return str(_('weak')) + case PasswordStrength.MODERATE: return str(_('moderate')) + case PasswordStrength.STRONG: return str(_('strong')) + + def color(self): + match self: + case PasswordStrength.VERY_WEAK: return 'red' + case PasswordStrength.WEAK: return 'red' + case PasswordStrength.MODERATE: return 'yellow' + case PasswordStrength.STRONG: return 'green' + + @classmethod + def strength(cls, password: str) -> 'PasswordStrength': + digit = any(character.isdigit() for character in password) + upper = any(character.isupper() for character in password) + lower = any(character.islower() for character in password) + symbol = any(not character.isalnum() for character in password) + return cls._check_password_strength(digit, upper, lower, symbol, len(password)) + + @classmethod + def _check_password_strength( + cls, + digit: bool, + upper: bool, + lower: bool, + symbol: bool, + length: int + ) -> 'PasswordStrength': + # suggested evaluation + # https://github.com/archlinux/archinstall/issues/1304#issuecomment-1146768163 + if digit and upper and lower and symbol: + match length: + case num if 13 <= num: + return PasswordStrength.STRONG + case num if 11 <= num <= 12: + return PasswordStrength.MODERATE + case num if 7 <= num <= 10: + return PasswordStrength.WEAK + case num if num <= 6: + return PasswordStrength.VERY_WEAK + elif digit and upper and lower: + match length: + case num if 14 <= num: + return PasswordStrength.STRONG + case num if 11 <= num <= 13: + return PasswordStrength.MODERATE + case num if 7 <= num <= 10: + return PasswordStrength.WEAK + case num if num <= 6: + return PasswordStrength.VERY_WEAK + elif upper and lower: + match length: + case num if 15 <= num: + return PasswordStrength.STRONG + case num if 12 <= num <= 14: + return PasswordStrength.MODERATE + case num if 7 <= num <= 11: + return PasswordStrength.WEAK + case num if num <= 6: + return PasswordStrength.VERY_WEAK + elif lower or upper: + match length: + case num if 18 <= num: + return PasswordStrength.STRONG + case num if 14 <= num <= 17: + return PasswordStrength.MODERATE + case num if 9 <= num <= 13: + return PasswordStrength.WEAK + case num if num <= 8: + return PasswordStrength.VERY_WEAK + + return PasswordStrength.VERY_WEAK + + @dataclass class User: username: str @@ -26,13 +109,6 @@ class User: 'sudo': self.sudo } - def display(self) -> str: - password = '*' * (len(self.password) if self.password else 0) - if password: - strength = PasswordStrength.strength(self.password) - password += f' ({strength.value})' - return f'{_("Username")}: {self.username:16} {_("Password")}: {password:20} sudo: {str(self.sudo)}' - @classmethod def _parse(cls, config_users: List[Dict[str, Any]]) -> List['User']: users = [] diff --git a/archinstall/lib/networking.py b/archinstall/lib/networking.py index 5a60886f..bfc4b7d5 100644 --- a/archinstall/lib/networking.py +++ b/archinstall/lib/networking.py @@ -1,21 +1,22 @@ -import logging import os import socket +import ssl import struct -from typing import Union, Dict, Any, List +from typing import Union, Dict, Any, List, Optional +from urllib.error import URLError +from urllib.parse import urlencode +from urllib.request import urlopen -from .exceptions import HardwareIncompatibilityError, SysCallError -from .general import SysCommand -from .output import log -from .pacman import run_pacman -from .storage import storage +from .exceptions import SysCallError +from .output import error, info +from .pacman import Pacman def get_hw_addr(ifname :str) -> str: import fcntl s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - info = fcntl.ioctl(s.fileno(), 0x8927, struct.pack('256s', bytes(ifname, 'utf-8')[:15])) - return ':'.join('%02x' % b for b in info[18:24]) + ret = fcntl.ioctl(s.fileno(), 0x8927, struct.pack('256s', bytes(ifname, 'utf-8')[:15])) + return ':'.join('%02x' % b for b in ret[18:24]) def list_interfaces(skip_loopback :bool = True) -> Dict[str, str]: @@ -31,26 +32,14 @@ def list_interfaces(skip_loopback :bool = True) -> Dict[str, str]: return interfaces -def check_mirror_reachable() -> bool: - log("Testing connectivity to the Arch Linux mirrors ...", level=logging.INFO) - try: - if run_pacman("-Sy").exit_code == 0: - return True - elif os.geteuid() != 0: - log("check_mirror_reachable() uses 'pacman -Sy' which requires root.", level=logging.ERROR, fg="red") - except SysCallError as err: - log(err, level=logging.DEBUG) - - return False - - def update_keyring() -> bool: - log("Updating archlinux-keyring ...", level=logging.INFO) - if run_pacman("-Sy --noconfirm archlinux-keyring").exit_code == 0: + info("Updating archlinux-keyring ...") + try: + Pacman.run("-Sy --noconfirm archlinux-keyring") return True - - elif os.geteuid() != 0: - log("update_keyring() uses 'pacman -Sy archlinux-keyring' which requires root.", level=logging.ERROR, fg="red") + except SysCallError: + if os.geteuid() != 0: + error("update_keyring() uses 'pacman -Sy archlinux-keyring' which requires root.") return False @@ -86,35 +75,20 @@ def enrich_iface_types(interfaces: Union[Dict[str, Any], List[str]]) -> Dict[str return result -def get_interface_from_mac(mac :str) -> str: - return list_interfaces().get(mac.lower(), None) +def fetch_data_from_url(url: str, params: Optional[Dict] = None) -> str: + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + if params is not None: + encoded = urlencode(params) + full_url = f'{url}?{encoded}' + else: + full_url = url -def wireless_scan(interface :str) -> None: - interfaces = enrich_iface_types(list_interfaces().values()) - if interfaces[interface] != 'WIRELESS': - raise HardwareIncompatibilityError(f"Interface {interface} is not a wireless interface: {interfaces}") - - if not (output := SysCommand(f"iwctl station {interface} scan")).exit_code == 0: - raise SystemError(f"Could not scan for wireless networks: {output}") - - if '_WIFI' not in storage: - storage['_WIFI'] = {} - if interface not in storage['_WIFI']: - storage['_WIFI'][interface] = {} - - storage['_WIFI'][interface]['scanning'] = True - - -# TODO: Full WiFi experience might get evolved in the future, pausing for now 2021-01-25 -def get_wireless_networks(interface :str) -> None: - # TODO: Make this oneliner pritter to check if the interface is scanning or not. - # TODO: Rename this to list_wireless_networks() as it doesn't return anything - if '_WIFI' not in storage or interface not in storage['_WIFI'] or storage['_WIFI'][interface].get('scanning', False) is False: - import time - - wireless_scan(interface) - time.sleep(5) - - for line in SysCommand(f"iwctl station {interface} get-networks"): - print(line) + try: + response = urlopen(full_url, context=ssl_context) + data = response.read().decode('UTF-8') + return data + except URLError: + raise ValueError(f'Unable to fetch data from url: {url}') diff --git a/archinstall/lib/output.py b/archinstall/lib/output.py index 709a7382..62a1ba27 100644 --- a/archinstall/lib/output.py +++ b/archinstall/lib/output.py @@ -1,19 +1,28 @@ import logging import os import sys +import unicodedata +from enum import Enum + from pathlib import Path -from typing import Dict, Union, List, Any, Callable +from typing import Dict, Union, List, Any, Callable, Optional +from dataclasses import asdict, is_dataclass from .storage import storage -from dataclasses import asdict, is_dataclass class FormattedOutput: @classmethod - def values(cls, o: Any, class_formatter: str = None, filter_list: List[str] = None) -> Dict[str, Any]: - """ the original values returned a dataclass as dict thru the call to some specific methods - this version allows thru the parameter class_formatter to call a dynamicly selected formatting method. + def _get_values( + cls, + o: Any, + class_formatter: Optional[Union[str, Callable]] = None, + filter_list: List[str] = [] + ) -> Dict[str, Any]: + """ + the original values returned a dataclass as dict thru the call to some specific methods + this version allows thru the parameter class_formatter to call a dynamically selected formatting method. Can transmit a filter list to the class_formatter, """ if class_formatter: @@ -25,9 +34,10 @@ class FormattedOutput: elif hasattr(o, class_formatter) and callable(getattr(o, class_formatter)): func = getattr(o, class_formatter) return func(filter_list) - # kept as to make it backward compatible - elif hasattr(o, 'as_json'): - return o.as_json() + + raise ValueError('Unsupported formatting call') + elif hasattr(o, 'table_data'): + return o.table_data() elif hasattr(o, 'json'): return o.json() elif is_dataclass(o): @@ -36,7 +46,13 @@ class FormattedOutput: return o.__dict__ @classmethod - def as_table(cls, obj: List[Any], class_formatter: Union[str, Callable] = None, filter_list: List[str] = None) -> str: + def as_table( + cls, + obj: List[Any], + class_formatter: Optional[Union[str, Callable]] = None, + filter_list: List[str] = [], + capitalize: bool = False + ) -> str: """ variant of as_table (subtly different code) which has two additional parameters filter which is a list of fields which will be shon class_formatter a special method to format the outgoing data @@ -45,7 +61,8 @@ class FormattedOutput: is for compatibility with a print statement As_table_filter can be a drop in replacement for as_table """ - raw_data = [cls.values(o, class_formatter, filter_list) for o in obj] + raw_data = [cls._get_values(o, class_formatter, filter_list) for o in obj] + # determine the maximum column size column_width: Dict[str, int] = {} for o in raw_data: @@ -55,14 +72,20 @@ class FormattedOutput: column_width[k] = max([column_width[k], len(str(v)), len(k)]) if not filter_list: - filter_list = (column_width.keys()) + filter_list = list(column_width.keys()) + # create the header lines output = '' key_list = [] for key in filter_list: width = column_width[key] - key = key.replace('!', '') - key_list.append(key.ljust(width)) + key = key.replace('!', '').replace('_', ' ') + + if capitalize: + key = key.capitalize() + + key_list.append(unicode_ljust(key, width)) + output += ' | '.join(key_list) + '\n' output += '-' * len(output) + '\n' @@ -72,20 +95,40 @@ class FormattedOutput: for key in filter_list: width = column_width.get(key, len(key)) value = record.get(key, '') + if '!' in key: value = '*' * width - if isinstance(value,(int, float)) or (isinstance(value, str) and value.isnumeric()): - obj_data.append(str(value).rjust(width)) + + if isinstance(value, (int, float)) or (isinstance(value, str) and value.isnumeric()): + obj_data.append(unicode_rjust(str(value), width)) else: - obj_data.append(str(value).ljust(width)) + obj_data.append(unicode_ljust(str(value), width)) + output += ' | '.join(obj_data) + '\n' return output + @classmethod + def as_columns(cls, entries: List[str], cols: int) -> str: + """ + Will format a list into a given number of columns + """ + chunks = [] + output = '' + + for i in range(0, len(entries), cols): + chunks.append(entries[i:i + cols]) + + for row in chunks: + out_fmt = '{: <30} ' * len(row) + output += out_fmt.format(*row) + '\n' + + return output + class Journald: @staticmethod - def log(message :str, level :int = logging.DEBUG) -> None: + def log(message: str, level: int = logging.DEBUG) -> None: try: import systemd.journal # type: ignore except ModuleNotFoundError: @@ -101,16 +144,39 @@ class Journald: log_adapter.log(level, message) -# TODO: Replace log() for session based logging. -class SessionLogging: - def __init__(self): - pass +def _check_log_permissions(): + filename = storage.get('LOG_FILE', None) + log_dir = storage.get('LOG_PATH', Path('./')) + + if not filename: + raise ValueError('No log file name defined') + + log_file = log_dir / filename + + try: + log_dir.mkdir(exist_ok=True, parents=True) + log_file.touch(exist_ok=True) + with log_file.open('a') as fp: + fp.write('') + except PermissionError: + # Fallback to creating the log file in the current folder + fallback_dir = Path('./').absolute() + fallback_log_file = fallback_dir / filename -# Found first reference here: https://stackoverflow.com/questions/7445658/how-to-detect-if-the-console-does-support-ansi-escape-codes-in-python -# And re-used this: https://github.com/django/django/blob/master/django/core/management/color.py#L12 -def supports_color() -> bool: + fallback_log_file.touch(exist_ok=True) + + storage['LOG_PATH'] = fallback_dir + warn(f'Not enough permission to place log file at {log_file}, creating it in {fallback_log_file} instead') + + +def _supports_color() -> bool: """ + Found first reference here: + https://stackoverflow.com/questions/7445658/how-to-detect-if-the-console-does-support-ansi-escape-codes-in-python + And re-used this: + https://github.com/django/django/blob/master/django/core/management/color.py#L12 + Return True if the running system's terminal supports color, and False otherwise. """ @@ -121,13 +187,30 @@ def supports_color() -> bool: return supported_platform and is_a_tty -# Heavily influenced by: https://github.com/django/django/blob/ae8338daf34fd746771e0678081999b656177bae/django/utils/termcolors.py#L13 -# Color options here: https://askubuntu.com/questions/528928/how-to-do-underline-bold-italic-strikethrough-color-background-and-size-i -def stylize_output(text: str, *opts :str, **kwargs) -> str: +class Font(Enum): + bold = '1' + italic = '3' + underscore = '4' + blink = '5' + reverse = '7' + conceal = '8' + + +def _stylize_output( + text: str, + fg: str, + bg: Optional[str], + reset: bool, + font: List[Font] = [], +) -> str: """ + Heavily influenced by: + https://github.com/django/django/blob/ae8338daf34fd746771e0678081999b656177bae/django/utils/termcolors.py#L13 + Color options here: + https://askubuntu.com/questions/528928/how-to-do-underline-bold-italic-strikethrough-color-background-and-size-i + Adds styling to a text given a set of color arguments. """ - opt_dict = {'bold': '1', 'italic': '3', 'underscore': '4', 'blink': '5', 'reverse': '7', 'conceal': '8'} colors = { 'black' : '0', 'red' : '1', @@ -145,65 +228,132 @@ def stylize_output(text: str, *opts :str, **kwargs) -> str: 'darkgray' : '8;5;240', 'lightgray' : '8;5;256' } + foreground = {key: f'3{colors[key]}' for key in colors} background = {key: f'4{colors[key]}' for key in colors} - reset = '0' - code_list = [] - if text == '' and len(opts) == 1 and opts[0] == 'reset': - return '\x1b[%sm' % reset - for k, v in kwargs.items(): - if k == 'fg': - code_list.append(foreground[str(v)]) - elif k == 'bg': - code_list.append(background[str(v)]) + if text == '' and reset: + return '\x1b[%sm' % '0' - for o in opts: - if o in opt_dict: - code_list.append(opt_dict[o]) + code_list.append(foreground[str(fg)]) - if 'noreset' not in opts: - text = '%s\x1b[%sm' % (text or '', reset) + if bg: + code_list.append(background[str(bg)]) - return '%s%s' % (('\x1b[%sm' % ';'.join(code_list)), text or '') + for o in font: + code_list.append(o.value) + ansi = ';'.join(code_list) -def log(*args :str, **kwargs :Union[str, int, Dict[str, Union[str, int]]]) -> None: - string = orig_string = ' '.join([str(x) for x in args]) + return f'\033[{ansi}m{text}\033[0m' - # Attempt to colorize the output if supported - # Insert default colors and override with **kwargs - if supports_color(): - kwargs = {'fg': 'white', **kwargs} - string = stylize_output(string, **kwargs) - # If a logfile is defined in storage, - # we use that one to output everything - if filename := storage.get('LOG_FILE', None): - absolute_logfile = os.path.join(storage.get('LOG_PATH', './'), filename) +def info( + *msgs: str, + level: int = logging.INFO, + fg: str = 'white', + bg: Optional[str] = None, + reset: bool = False, + font: List[Font] = [] +): + log(*msgs, level=level, fg=fg, bg=bg, reset=reset, font=font) - try: - Path(absolute_logfile).parents[0].mkdir(exist_ok=True, parents=True) - with open(absolute_logfile, 'a') as log_file: - log_file.write("") - except PermissionError: - # Fallback to creating the log file in the current folder - err_string = f"Not enough permission to place log file at {absolute_logfile}, creating it in {Path('./').absolute() / filename} instead." - absolute_logfile = Path('./').absolute() / filename - absolute_logfile.parents[0].mkdir(exist_ok=True) - absolute_logfile = str(absolute_logfile) - storage['LOG_PATH'] = './' - log(err_string, fg="red") - - with open(absolute_logfile, 'a') as log_file: - log_file.write(f"{orig_string}\n") - - Journald.log(string, level=int(str(kwargs.get('level', logging.INFO)))) - - # Finally, print the log unless we skipped it based on level. - # We use sys.stdout.write()+flush() instead of print() to try and - # fix issue #94 - if kwargs.get('level', logging.INFO) != logging.DEBUG or storage['arguments'].get('verbose', False): - sys.stdout.write(f"{string}\n") - sys.stdout.flush() + +def debug( + *msgs: str, + level: int = logging.DEBUG, + fg: str = 'white', + bg: Optional[str] = None, + reset: bool = False, + font: List[Font] = [] +): + log(*msgs, level=level, fg=fg, bg=bg, reset=reset, font=font) + + +def error( + *msgs: str, + level: int = logging.ERROR, + fg: str = 'red', + bg: Optional[str] = None, + reset: bool = False, + font: List[Font] = [] +): + log(*msgs, level=level, fg=fg, bg=bg, reset=reset, font=font) + + +def warn( + *msgs: str, + level: int = logging.WARN, + fg: str = 'yellow', + bg: Optional[str] = None, + reset: bool = False, + font: List[Font] = [] +): + log(*msgs, level=level, fg=fg, bg=bg, reset=reset, font=font) + + +def log( + *msgs: str, + level: int = logging.INFO, + fg: str = 'white', + bg: Optional[str] = None, + reset: bool = False, + font: List[Font] = [] +): + # leave this check here as we need to setup the logging + # right from the beginning when the modules are loaded + _check_log_permissions() + + text = orig_string = ' '.join([str(x) for x in msgs]) + + # Attempt to colorize the output if supported + # Insert default colors and override with **kwargs + if _supports_color(): + text = _stylize_output(text, fg, bg, reset, font) + + log_file: Path = storage['LOG_PATH'] / storage['LOG_FILE'] + + with log_file.open('a') as fp: + fp.write(f"{orig_string}\n") + + Journald.log(text, level=level) + + from .menu import Menu + if not Menu.is_menu_active(): + # Finally, print the log unless we skipped it based on level. + # We use sys.stdout.write()+flush() instead of print() to try and + # fix issue #94 + if level != logging.DEBUG or storage.get('arguments', {}).get('verbose', False): + sys.stdout.write(f"{text}\n") + sys.stdout.flush() + +def _count_wchars(string: str) -> int: + "Count the total number of wide characters contained in a string" + return sum(unicodedata.east_asian_width(c) in 'FW' for c in string) + +def unicode_ljust(string: str, width: int, fillbyte: str = ' ') -> str: + """Return a left-justified unicode string of length width. + >>> unicode_ljust('Hello', 15, '*') + 'Hello**********' + >>> unicode_ljust('你好', 15, '*') + '你好***********' + >>> unicode_ljust('안녕하세요', 15, '*') + '안녕하세요*****' + >>> unicode_ljust('こんにちは', 15, '*') + 'こんにちは*****' + """ + return string.ljust(width - _count_wchars(string), fillbyte) + +def unicode_rjust(string: str, width: int, fillbyte: str = ' ') -> str: + """Return a right-justified unicode string of length width. + >>> unicode_rjust('Hello', 15, '*') + '**********Hello' + >>> unicode_rjust('你好', 15, '*') + '***********你好' + >>> unicode_rjust('안녕하세요', 15, '*') + '*****안녕하세요' + >>> unicode_rjust('こんにちは', 15, '*') + '*****こんにちは' + """ + return string.rjust(width - _count_wchars(string), fillbyte) diff --git a/archinstall/lib/packages/__init__.py b/archinstall/lib/packages/__init__.py index e69de29b..e2aab577 100644 --- a/archinstall/lib/packages/__init__.py +++ b/archinstall/lib/packages/__init__.py @@ -0,0 +1,4 @@ +from .packages import ( + group_search, package_search, find_package, + find_packages, validate_package_list, installed_package +) diff --git a/archinstall/lib/packages/packages.py b/archinstall/lib/packages/packages.py index 0743e83b..e495b03f 100644 --- a/archinstall/lib/packages/packages.py +++ b/archinstall/lib/packages/packages.py @@ -7,8 +7,8 @@ from urllib.parse import urlencode from urllib.request import urlopen from ..exceptions import PackageError, SysCallError -from ..models.dataclasses import PackageSearch, PackageSearchResult, LocalPackage -from ..pacman import run_pacman +from ..models.gen import PackageSearch, PackageSearchResult, LocalPackage +from ..pacman import Pacman BASE_URL_PKG_SEARCH = 'https://archlinux.org/packages/search/json/' # BASE_URL_PKG_CONTENT = 'https://archlinux.org/packages/search/json/' @@ -37,7 +37,7 @@ def group_search(name :str) -> List[PackageSearchResult]: raise err # Just to be sure some code didn't slip through the exception - data = response.read().decode('UTF-8') + data = response.read().decode('utf-8') return [PackageSearchResult(**package) for package in json.loads(data)['results']] @@ -55,8 +55,8 @@ def package_search(package :str) -> PackageSearch: raise PackageError(f"Could not locate package: [{response.code}] {response}") data = response.read().decode('UTF-8') - - return PackageSearch(**json.loads(data)) + json_data = json.loads(data) + return PackageSearch.from_json(json_data) def find_package(package :str) -> List[PackageSearchResult]: @@ -106,11 +106,11 @@ def validate_package_list(packages :list) -> Tuple[list, list]: def installed_package(package :str) -> LocalPackage: package_info = {} try: - for line in run_pacman(f"-Q --info {package}"): + for line in Pacman.run(f"-Q --info {package}"): if b':' in line: key, value = line.decode().split(':', 1) package_info[key.strip().lower().replace(' ', '_')] = value.strip() except SysCallError: pass - return LocalPackage({field.name: package_info.get(field.name) for field in dataclasses.fields(LocalPackage)}) + return LocalPackage({field.name: package_info.get(field.name) for field in dataclasses.fields(LocalPackage)}) # type: ignore diff --git a/archinstall/lib/pacman.py b/archinstall/lib/pacman.py deleted file mode 100644 index 9c427aff..00000000 --- a/archinstall/lib/pacman.py +++ /dev/null @@ -1,28 +0,0 @@ -import logging -import pathlib -import time - -from .general import SysCommand -from .output import log - - -def run_pacman(args :str, default_cmd :str = 'pacman') -> SysCommand: - """ - A centralized function to call `pacman` from. - It also protects us from colliding with other running pacman sessions (if used locally). - The grace period is set to 10 minutes before exiting hard if another pacman instance is running. - """ - pacman_db_lock = pathlib.Path('/var/lib/pacman/db.lck') - - if pacman_db_lock.exists(): - log(_('Pacman is already running, waiting maximum 10 minutes for it to terminate.'), level=logging.WARNING, fg="red") - - started = time.time() - while pacman_db_lock.exists(): - time.sleep(0.25) - - if time.time() - started > (60 * 10): - log(_('Pre-existing pacman lock never exited. Please clean up any existing pacman sessions before using archinstall.'), level=logging.WARNING, fg="red") - exit(1) - - return SysCommand(f'{default_cmd} {args}') diff --git a/archinstall/lib/pacman/__init__.py b/archinstall/lib/pacman/__init__.py new file mode 100644 index 00000000..6478f0cc --- /dev/null +++ b/archinstall/lib/pacman/__init__.py @@ -0,0 +1,88 @@ +from pathlib import Path +import time +import re +from typing import TYPE_CHECKING, Any, List, Callable, Union +from shutil import copy2 + +from ..general import SysCommand +from ..output import warn, error, info +from .repo import Repo +from .config import Config +from ..exceptions import RequirementError +from ..plugins import plugins + +if TYPE_CHECKING: + _: Any + + +class Pacman: + + def __init__(self, target: Path, silent: bool = False): + self.synced = False + self.silent = silent + self.target = target + + @staticmethod + def run(args :str, default_cmd :str = 'pacman') -> SysCommand: + """ + A centralized function to call `pacman` from. + It also protects us from colliding with other running pacman sessions (if used locally). + The grace period is set to 10 minutes before exiting hard if another pacman instance is running. + """ + pacman_db_lock = Path('/var/lib/pacman/db.lck') + + if pacman_db_lock.exists(): + warn(_('Pacman is already running, waiting maximum 10 minutes for it to terminate.')) + + started = time.time() + while pacman_db_lock.exists(): + time.sleep(0.25) + + if time.time() - started > (60 * 10): + error(_('Pre-existing pacman lock never exited. Please clean up any existing pacman sessions before using archinstall.')) + exit(1) + + return SysCommand(f'{default_cmd} {args}') + + def ask(self, error_message: str, bail_message: str, func: Callable, *args, **kwargs): + while True: + try: + func(*args, **kwargs) + break + except Exception as err: + error(f'{error_message}: {err}') + if not self.silent and input('Would you like to re-try this download? (Y/n): ').lower().strip() in 'y': + continue + raise RequirementError(f'{bail_message}: {err}') + + def sync(self): + if self.synced: + return + self.ask( + 'Could not sync a new package database', + 'Could not sync mirrors', + self.run, + '-Syy', + default_cmd='/usr/bin/pacman' + ) + self.synced = True + + def strap(self, packages: Union[str, List[str]]): + self.sync() + if isinstance(packages, str): + packages = [packages] + + for plugin in plugins.values(): + if hasattr(plugin, 'on_pacstrap'): + if (result := plugin.on_pacstrap(packages)): + packages = result + + info(f'Installing packages: {packages}') + + self.ask( + 'Could not strap in packages', + 'Pacstrap failed. See /var/log/archinstall/install.log or above message for error details', + SysCommand, + f'/usr/bin/pacstrap -C /etc/pacman.conf -K {self.target} {" ".join(packages)} --noconfirm', + peek_output=True + ) diff --git a/archinstall/lib/pacman/config.py b/archinstall/lib/pacman/config.py new file mode 100644 index 00000000..6686f4a9 --- /dev/null +++ b/archinstall/lib/pacman/config.py @@ -0,0 +1,44 @@ +import re +from pathlib import Path +from shutil import copy2 +from typing import List + +from .repo import Repo + + +class Config: + def __init__(self, target: Path): + self.path = Path("/etc") / "pacman.conf" + self.chroot_path = target / "etc" / "pacman.conf" + self.repos: List[Repo] = [] + + def enable(self, repo: Repo): + self.repos.append(repo) + + def apply(self): + if not self.repos: + return + + if Repo.Testing in self.repos: + if Repo.Multilib in self.repos: + repos_pattern = f'({Repo.Multilib.value}|.+-{Repo.Testing.value})' + else: + repos_pattern = f'(?!{Repo.Multilib.value}).+-{Repo.Testing.value}' + else: + repos_pattern = Repo.Multilib.value + + pattern = re.compile(rf"^#\s*\[{repos_pattern}\]$") + + lines = iter(self.path.read_text().splitlines(keepends=True)) + with open(self.path, 'w') as f: + for line in lines: + if pattern.match(line): + # Uncomment this line and the next. + f.write(line.lstrip('#')) + f.write(next(lines).lstrip('#')) + else: + f.write(line) + + def persist(self): + if self.repos: + copy2(self.path, self.chroot_path) diff --git a/archinstall/lib/pacman/repo.py b/archinstall/lib/pacman/repo.py new file mode 100644 index 00000000..7a461431 --- /dev/null +++ b/archinstall/lib/pacman/repo.py @@ -0,0 +1,5 @@ +from enum import Enum + +class Repo(Enum): + Multilib = "multilib" + Testing = "testing" diff --git a/archinstall/lib/plugins.py b/archinstall/lib/plugins.py index 0ff63610..4ccb0666 100644 --- a/archinstall/lib/plugins.py +++ b/archinstall/lib/plugins.py @@ -1,105 +1,120 @@ import hashlib import importlib -import logging import os import sys -import pathlib import urllib.parse import urllib.request from importlib import metadata +from pathlib import Path from typing import Optional, List -from types import ModuleType -from .output import log +from .output import error, info, warn from .storage import storage plugins = {} + # 1: List archinstall.plugin definitions # 2: Load the plugin entrypoint # 3: Initiate the plugin and store it as .name in plugins for plugin_definition in metadata.entry_points().select(group='archinstall.plugin'): plugin_entrypoint = plugin_definition.load() + try: plugins[plugin_definition.name] = plugin_entrypoint() except Exception as err: - log(err, level=logging.ERROR) - log(f"The above error was detected when loading the plugin: {plugin_definition}", fg="red", level=logging.ERROR) + error( + f'Error: {err}', + f"The above error was detected when loading the plugin: {plugin_definition}" + ) + +def _localize_path(path: Path) -> Path: + """ + Support structures for load_plugin() + """ + url = urllib.parse.urlparse(str(path)) -# The following functions and core are support structures for load_plugin() -def localize_path(profile_path :str) -> str: - if (url := urllib.parse.urlparse(profile_path)).scheme and url.scheme in ('https', 'http'): - converted_path = f"/tmp/{os.path.basename(profile_path).replace('.py', '')}_{hashlib.md5(os.urandom(12)).hexdigest()}.py" + if url.scheme and url.scheme in ('https', 'http'): + converted_path = Path(f'/tmp/{path.stem}_{hashlib.md5(os.urandom(12)).hexdigest()}.py') with open(converted_path, "w") as temp_file: temp_file.write(urllib.request.urlopen(url.geturl()).read().decode('utf-8')) return converted_path else: - return profile_path + return path -def import_via_path(path :str, namespace :Optional[str] = None) -> ModuleType: +def _import_via_path(path: Path, namespace: Optional[str] = None) -> Optional[str]: if not namespace: namespace = os.path.basename(path) if namespace == '__init__.py': - path = pathlib.PurePath(path) namespace = path.parent.name try: spec = importlib.util.spec_from_file_location(namespace, path) - imported = importlib.util.module_from_spec(spec) - sys.modules[namespace] = imported - spec.loader.exec_module(sys.modules[namespace]) + if spec and spec.loader: + imported = importlib.util.module_from_spec(spec) + sys.modules[namespace] = imported + spec.loader.exec_module(sys.modules[namespace]) return namespace except Exception as err: - log(err, level=logging.ERROR) - log(f"The above error was detected when loading the plugin: {path}", fg="red", level=logging.ERROR) + error( + f'Error: {err}', + f"The above error was detected when loading the plugin: {path}" + ) try: - del(sys.modules[namespace]) # noqa: E275 - except: + del sys.modules[namespace] + except Exception: pass -def find_nth(haystack :List[str], needle :str, n :int) -> int: - start = haystack.find(needle) - while start >= 0 and n > 1: - start = haystack.find(needle, start + len(needle)) - n -= 1 - return start + return namespace + + +def _find_nth(haystack: List[str], needle: str, n: int) -> Optional[int]: + indices = [idx for idx, elem in enumerate(haystack) if elem == needle] + if n <= len(indices): + return indices[n - 1] + return None + -def load_plugin(path :str) -> ModuleType: - parsed_url = urllib.parse.urlparse(path) - log(f"Loading plugin {parsed_url}.", fg="gray", level=logging.INFO) +def load_plugin(path: Path): + namespace: Optional[str] = None + parsed_url = urllib.parse.urlparse(str(path)) + info(f"Loading plugin from url {parsed_url}") # The Profile was not a direct match on a remote URL if not parsed_url.scheme: # Path was not found in any known examples, check if it's an absolute path if os.path.isfile(path): - namespace = import_via_path(path) + namespace = _import_via_path(path) elif parsed_url.scheme in ('https', 'http'): - namespace = import_via_path(localize_path(path)) + localized = _localize_path(path) + namespace = _import_via_path(localized) - if namespace in sys.modules: + if namespace and namespace in sys.modules: # Version dependency via __archinstall__version__ variable (if present) in the plugin # Any errors in version inconsistency will be handled through normal error handling if not defined. if hasattr(sys.modules[namespace], '__archinstall__version__'): - archinstall_major_and_minor_version = float(storage['__version__'][:find_nth(storage['__version__'], '.', 2)]) + archinstall_major_and_minor_version = float(storage['__version__'][:_find_nth(storage['__version__'], '.', 2)]) if sys.modules[namespace].__archinstall__version__ < archinstall_major_and_minor_version: - log(f"Plugin {sys.modules[namespace]} does not support the current Archinstall version.", fg="red", level=logging.ERROR) + error(f"Plugin {sys.modules[namespace]} does not support the current Archinstall version.") # Locate the plugin entry-point called Plugin() # This in accordance with the entry_points() from setup.cfg above if hasattr(sys.modules[namespace], 'Plugin'): try: plugins[namespace] = sys.modules[namespace].Plugin() - log(f"Plugin {plugins[namespace]} has been loaded.", fg="gray", level=logging.INFO) + info(f"Plugin {plugins[namespace]} has been loaded.") except Exception as err: - log(err, level=logging.ERROR) - log(f"The above error was detected when initiating the plugin: {path}", fg="red", level=logging.ERROR) + error( + f'Error: {err}', + f"The above error was detected when initiating the plugin: {path}" + ) else: - log(f"Plugin '{path}' is missing a valid entry-point or is corrupt.", fg="yellow", level=logging.WARNING) + warn(f"Plugin '{path}' is missing a valid entry-point or is corrupt.") diff --git a/archinstall/lib/profile/__init__.py b/archinstall/lib/profile/__init__.py new file mode 100644 index 00000000..6e74b0d8 --- /dev/null +++ b/archinstall/lib/profile/__init__.py @@ -0,0 +1,3 @@ +from .profile_menu import ProfileMenu, select_greeter, select_profile +from .profiles_handler import profile_handler +from .profile_model import ProfileConfiguration diff --git a/archinstall/lib/profile/profile_menu.py b/archinstall/lib/profile/profile_menu.py new file mode 100644 index 00000000..aba75a88 --- /dev/null +++ b/archinstall/lib/profile/profile_menu.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Optional, Dict + +from archinstall.default_profiles.profile import Profile, GreeterType +from .profile_model import ProfileConfiguration +from ..menu import Menu, MenuSelectionType, AbstractSubMenu, Selector +from ..interactions.system_conf import select_driver +from ..hardware import GfxDriver + +if TYPE_CHECKING: + _: Any + + +class ProfileMenu(AbstractSubMenu): + def __init__( + self, + data_store: Dict[str, Any], + preset: Optional[ProfileConfiguration] = None + ): + if preset: + self._preset = preset + else: + self._preset = ProfileConfiguration() + + super().__init__(data_store=data_store) + + def setup_selection_menu_options(self): + self._menu_options['profile'] = Selector( + _('Type'), + lambda x: self._select_profile(x), + display_func=lambda x: x.name if x else None, + preview_func=self._preview_profile, + default=self._preset.profile, + enabled=True + ) + + self._menu_options['gfx_driver'] = Selector( + _('Graphics driver'), + lambda preset: self._select_gfx_driver(preset), + display_func=lambda x: x.value if x else None, + dependencies=['profile'], + preview_func=self._preview_gfx, + default=self._preset.gfx_driver if self._preset.profile and self._preset.profile.is_graphic_driver_supported() else None, + enabled=self._preset.profile.is_graphic_driver_supported() if self._preset.profile else False + ) + + self._menu_options['greeter'] = Selector( + _('Greeter'), + lambda preset: select_greeter(self._menu_options['profile'].current_selection, preset), + display_func=lambda x: x.value if x else None, + dependencies=['profile'], + default=self._preset.greeter if self._preset.profile and self._preset.profile.is_greeter_supported() else None, + enabled=self._preset.profile.is_greeter_supported() if self._preset.profile else False + ) + + def run(self, allow_reset: bool = True) -> Optional[ProfileConfiguration]: + super().run(allow_reset=allow_reset) + + if self._data_store.get('profile', None): + return ProfileConfiguration( + self._menu_options['profile'].current_selection, + self._menu_options['gfx_driver'].current_selection, + self._menu_options['greeter'].current_selection + ) + + return None + + def _select_profile(self, preset: Optional[Profile]) -> Optional[Profile]: + profile = select_profile(preset) + + if profile is not None: + if not profile.is_graphic_driver_supported(): + self._menu_options['gfx_driver'].set_enabled(False) + self._menu_options['gfx_driver'].set_current_selection(None) + else: + self._menu_options['gfx_driver'].set_enabled(True) + self._menu_options['gfx_driver'].set_current_selection(GfxDriver.AllOpenSource) + + if not profile.is_greeter_supported(): + self._menu_options['greeter'].set_enabled(False) + self._menu_options['greeter'].set_current_selection(None) + else: + self._menu_options['greeter'].set_enabled(True) + self._menu_options['greeter'].set_current_selection(profile.default_greeter_type) + else: + self._menu_options['gfx_driver'].set_current_selection(None) + self._menu_options['greeter'].set_current_selection(None) + + return profile + + def _select_gfx_driver(self, preset: Optional[GfxDriver] = None) -> Optional[GfxDriver]: + driver = preset + profile: Optional[Profile] = self._menu_options['profile'].current_selection + + if profile: + if profile.is_graphic_driver_supported(): + driver = select_driver(current_value=preset) + + if driver and 'Sway' in profile.current_selection_names(): + if driver.is_nvidia(): + prompt = str(_('The proprietary Nvidia driver is not supported by Sway. It is likely that you will run into issues, are you okay with that?')) + choice = Menu(prompt, Menu.yes_no(), default_option=Menu.no(), skip=False).run() + + if choice.value == Menu.no(): + return None + + return driver + + def _preview_gfx(self) -> Optional[str]: + driver: Optional[GfxDriver] = self._menu_options['gfx_driver'].current_selection + + if driver: + return driver.packages_text() + + return None + + def _preview_profile(self) -> Optional[str]: + profile: Optional[Profile] = self._menu_options['profile'].current_selection + text = '' + + if profile: + if (sub_profiles := profile.current_selection) is not None: + text += str(_('Selected profiles: ')) + text += ', '.join([p.name for p in sub_profiles]) + '\n' + + if packages := profile.packages_text(include_sub_packages=True): + text += f'{packages}' + + if text: + return text + + return None + + +def select_greeter( + profile: Optional[Profile] = None, + preset: Optional[GreeterType] = None +) -> Optional[GreeterType]: + if not profile or profile.is_greeter_supported(): + title = str(_('Please chose which greeter to install')) + greeter_options = [greeter.value for greeter in GreeterType] + + default: Optional[GreeterType] = None + + if preset is not None: + default = preset + elif profile is not None: + default_greeter = profile.default_greeter_type + default = default_greeter if default_greeter else None + + choice = Menu( + title, + greeter_options, + skip=True, + default_option=default.value if default else None + ).run() + + match choice.type_: + case MenuSelectionType.Skip: + return default + + return GreeterType(choice.single_value) + + return None + + +def select_profile( + current_profile: Optional[Profile] = None, + title: Optional[str] = None, + allow_reset: bool = True, + multi: bool = False +) -> Optional[Profile]: + from archinstall.lib.profile.profiles_handler import profile_handler + top_level_profiles = profile_handler.get_top_level_profiles() + + display_title = title + if not display_title: + display_title = str(_('This is a list of pre-programmed default_profiles')) + + choice = profile_handler.select_profile( + top_level_profiles, + current_profile=current_profile, + title=display_title, + allow_reset=allow_reset, + multi=multi + ) + + match choice.type_: + case MenuSelectionType.Selection: + profile_selection: Profile = choice.single_value + select_result = profile_selection.do_on_select() + + if not select_result: + return select_profile( + current_profile=current_profile, + title=title, + allow_reset=allow_reset, + multi=multi + ) + + # we're going to reset the currently selected profile(s) to avoid + # any stale data laying around + match select_result: + case select_result.NewSelection: + profile_handler.reset_top_level_profiles(exclude=[profile_selection]) + current_profile = profile_selection + case select_result.ResetCurrent: + profile_handler.reset_top_level_profiles() + current_profile = None + case select_result.SameSelection: + pass + + return current_profile + case MenuSelectionType.Reset: + return None + case MenuSelectionType.Skip: + return current_profile diff --git a/archinstall/lib/profile/profile_model.py b/archinstall/lib/profile/profile_model.py new file mode 100644 index 00000000..8c955733 --- /dev/null +++ b/archinstall/lib/profile/profile_model.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Optional, Dict + +from ..hardware import GfxDriver +from archinstall.default_profiles.profile import Profile, GreeterType + +if TYPE_CHECKING: + _: Any + + +@dataclass +class ProfileConfiguration: + profile: Optional[Profile] = None + gfx_driver: Optional[GfxDriver] = None + greeter: Optional[GreeterType] = None + + def json(self) -> Dict[str, Any]: + from .profiles_handler import profile_handler + return { + 'profile': profile_handler.to_json(self.profile), + 'gfx_driver': self.gfx_driver.value if self.gfx_driver else None, + 'greeter': self.greeter.value if self.greeter else None + } + + @classmethod + def parse_arg(cls, arg: Dict[str, Any]) -> 'ProfileConfiguration': + from .profiles_handler import profile_handler + + profile = profile_handler.parse_profile_config(arg['profile']) + greeter = arg.get('greeter', None) + gfx_driver = arg.get('gfx_driver', None) + + return ProfileConfiguration( + profile, + GfxDriver(gfx_driver) if gfx_driver else None, + GreeterType(greeter) if greeter else None + ) diff --git a/archinstall/lib/profile/profiles_handler.py b/archinstall/lib/profile/profiles_handler.py new file mode 100644 index 00000000..b9acb4fe --- /dev/null +++ b/archinstall/lib/profile/profiles_handler.py @@ -0,0 +1,413 @@ +from __future__ import annotations + +import importlib.util +import sys +import inspect +from collections import Counter +from functools import cached_property +from pathlib import Path +from tempfile import NamedTemporaryFile +from types import ModuleType +from typing import List, TYPE_CHECKING, Any, Optional, Dict, Union + +from archinstall.default_profiles.profile import Profile, TProfile, GreeterType +from .profile_model import ProfileConfiguration +from ..hardware import GfxDriver +from ..menu import MenuSelectionType, Menu, MenuSelection +from ..networking import list_interfaces, fetch_data_from_url +from ..output import error, debug, info +from ..storage import storage + +if TYPE_CHECKING: + from ..installer import Installer + _: Any + + +class ProfileHandler: + def __init__(self): + self._profiles_path: Path = storage['PROFILE'] + self._profiles = None + + # special variable to keep track of a profile url configuration + # it is merely used to be able to export the path again when a user + # wants to save the configuration + self._url_path = None + + def to_json(self, profile: Optional[Profile]) -> Dict[str, Any]: + """ + Serialize the selected profile setting to JSON + """ + data: Dict[str, Any] = {} + + if profile is not None: + data = { + 'main': profile.name, + 'details': [profile.name for profile in profile.current_selection], + 'custom_settings': {profile.name: profile.custom_settings for profile in profile.current_selection} + } + + if self._url_path is not None: + data['path'] = self._url_path + + return data + + def parse_profile_config(self, profile_config: Dict[str, Any]) -> Optional[Profile]: + """ + Deserialize JSON configuration for profile + """ + profile: Optional[Profile] = None + + # the order of these is important, we want to + # load all the default_profiles from url and custom + # so that we can then apply whatever was specified + # in the main/detail sections + if url_path := profile_config.get('path', None): + self._url_path = url_path + local_path = Path(url_path) + + if local_path.is_file(): + profiles = self._process_profile_file(local_path) + self.remove_custom_profiles(profiles) + self.add_custom_profiles(profiles) + else: + self._import_profile_from_url(url_path) + + # if custom := profile_config.get('custom', None): + # from archinstall.default_profiles.custom import CustomTypeProfile + # custom_types = [] + # + # for entry in custom: + # custom_types.append( + # CustomTypeProfile( + # entry['name'], + # entry['enabled'], + # entry.get('packages', []), + # entry.get('services', []) + # ) + # ) + # + # self.remove_custom_profiles(custom_types) + # self.add_custom_profiles(custom_types) + # + # # this doesn't mean it's actual going to be set as a selection + # # but we are simply populating the custom profile with all + # # possible custom definitions + # if custom_profile := self.get_profile_by_name('Custom'): + # custom_profile.set_current_selection(custom_types) + + if main := profile_config.get('main', None): + profile = self.get_profile_by_name(main) if main else None + + if not profile: + return None + + valid_sub_profiles: List[Profile] = [] + invalid_sub_profiles: List[str] = [] + details: List[str] = profile_config.get('details', []) + + if details: + for detail in filter(None, details): + # [2024-04-19] TODO: Backwards compatibility after naming change: https://github.com/archlinux/archinstall/pull/2421 + # 'Kde' is deprecated, remove this block in a future version + if detail == 'Kde': + detail = 'KDE Plasma' + + if sub_profile := self.get_profile_by_name(detail): + valid_sub_profiles.append(sub_profile) + else: + invalid_sub_profiles.append(detail) + + if invalid_sub_profiles: + info('No profile definition found: {}'.format(', '.join(invalid_sub_profiles))) + + custom_settings = profile_config.get('custom_settings', {}) + profile.set_custom_settings(custom_settings) + profile.set_current_selection(valid_sub_profiles) + + return profile + + @property + def profiles(self) -> List[Profile]: + """ + List of all available default_profiles + """ + self._profiles = self._profiles or self._find_available_profiles() + return self._profiles + + @cached_property + def _local_mac_addresses(self) -> List[str]: + return list(list_interfaces()) + + def add_custom_profiles(self, profiles: Union[TProfile, List[TProfile]]): + if not isinstance(profiles, list): + profiles = [profiles] + + for profile in profiles: + self.profiles.append(profile) + + self._verify_unique_profile_names(self.profiles) + + def remove_custom_profiles(self, profiles: Union[TProfile, List[TProfile]]): + if not isinstance(profiles, list): + profiles = [profiles] + + remove_names = [p.name for p in profiles] + self._profiles = [p for p in self.profiles if p.name not in remove_names] + + def get_profile_by_name(self, name: str) -> Optional[Profile]: + return next(filter(lambda x: x.name == name, self.profiles), None) # type: ignore + + def get_top_level_profiles(self) -> List[Profile]: + return list(filter(lambda x: x.is_top_level_profile(), self.profiles)) + + def get_server_profiles(self) -> List[Profile]: + return list(filter(lambda x: x.is_server_type_profile(), self.profiles)) + + def get_desktop_profiles(self) -> List[Profile]: + return list(filter(lambda x: x.is_desktop_type_profile(), self.profiles)) + + def get_custom_profiles(self) -> List[Profile]: + return list(filter(lambda x: x.is_custom_type_profile(), self.profiles)) + + def get_mac_addr_profiles(self) -> List[Profile]: + tailored = list(filter(lambda x: x.is_tailored(), self.profiles)) + match_mac_addr_profiles = list(filter(lambda x: x.name in self._local_mac_addresses, tailored)) + return match_mac_addr_profiles + + def install_greeter(self, install_session: 'Installer', greeter: GreeterType): + packages = [] + service = None + + match greeter: + case GreeterType.LightdmSlick: + packages = ['lightdm', 'lightdm-slick-greeter'] + service = ['lightdm'] + case GreeterType.Lightdm: + packages = ['lightdm', 'lightdm-gtk-greeter'] + service = ['lightdm'] + case GreeterType.Sddm: + packages = ['sddm'] + service = ['sddm'] + case GreeterType.Gdm: + packages = ['gdm'] + service = ['gdm'] + case GreeterType.Ly: + packages = ['ly'] + service = ['ly'] + + if packages: + install_session.add_additional_packages(packages) + if service: + install_session.enable_service(service) + + # slick-greeter requires a config change + if greeter == GreeterType.LightdmSlick: + path = install_session.target.joinpath('etc/lightdm/lightdm.conf') + with open(path, 'r') as file: + filedata = file.read() + + filedata = filedata.replace('#greeter-session=example-gtk-gnome', 'greeter-session=lightdm-slick-greeter') + + with open(path, 'w') as file: + file.write(filedata) + + def install_gfx_driver(self, install_session: 'Installer', driver: GfxDriver): + debug(f'Installing GFX driver: {driver.value}') + + if driver in [GfxDriver.NvidiaOpenKernel, GfxDriver.NvidiaProprietary]: + headers = [f'{kernel}-headers' for kernel in install_session.kernels] + # Fixes https://github.com/archlinux/archinstall/issues/585 + install_session.add_additional_packages(headers) + elif driver in [GfxDriver.AllOpenSource, GfxDriver.AmdOpenSource]: + # The order of these two are important if amdgpu is installed #808 + install_session.remove_mod('amdgpu') + install_session.remove_mod('radeon') + + install_session.append_mod('amdgpu') + install_session.append_mod('radeon') + + driver_pkgs = driver.gfx_packages() + pkg_names = [p.value for p in driver_pkgs] + install_session.add_additional_packages(pkg_names) + + def install_profile_config(self, install_session: 'Installer', profile_config: ProfileConfiguration): + profile = profile_config.profile + + if not profile: + return + + profile.install(install_session) + + if profile_config.gfx_driver and (profile.is_xorg_type_profile() or profile.is_desktop_profile()): + self.install_gfx_driver(install_session, profile_config.gfx_driver) + + if profile_config.greeter: + self.install_greeter(install_session, profile_config.greeter) + + def _import_profile_from_url(self, url: str): + """ + Import default_profiles from a url path + """ + try: + data = fetch_data_from_url(url) + b_data = bytes(data, 'utf-8') + + with NamedTemporaryFile(delete=False, suffix='.py') as fp: + fp.write(b_data) + filepath = Path(fp.name) + + profiles = self._process_profile_file(filepath) + self.remove_custom_profiles(profiles) + self.add_custom_profiles(profiles) + except ValueError: + err = str(_('Unable to fetch profile from specified url: {}')).format(url) + error(err) + + def _load_profile_class(self, module: ModuleType) -> List[Profile]: + """ + Load all default_profiles defined in a module + """ + profiles = [] + for k, v in module.__dict__.items(): + if isinstance(v, type) and v.__module__ == module.__name__: + bases = inspect.getmro(v) + + if Profile in bases: + try: + cls_ = v() + if isinstance(cls_, Profile): + profiles.append(cls_) + except Exception: + debug(f'Cannot import {module}, it does not appear to be a Profile class') + + return profiles + + def _verify_unique_profile_names(self, profiles: List[Profile]): + """ + All profile names have to be unique, this function will verify + that the provided list contains only default_profiles with unique names + """ + counter = Counter([p.name for p in profiles]) + duplicates = list(filter(lambda x: x[1] != 1, counter.items())) + + if len(duplicates) > 0: + err = str(_('Profiles must have unique name, but profile definitions with duplicate name found: {}')).format(duplicates[0][0]) + error(err) + sys.exit(1) + + def _is_legacy(self, file: Path) -> bool: + """ + Check if the provided profile file contains a + legacy profile definition + """ + with open(file, 'r') as fp: + for line in fp.readlines(): + if '__packages__' in line: + return True + return False + + def _process_profile_file(self, file: Path) -> List[Profile]: + """ + Process a file for profile definitions + """ + if self._is_legacy(file): + info(f'Cannot import {file} because it is no longer supported, please use the new profile format') + return [] + + if not file.is_file(): + info(f'Cannot find profile file {file}') + return [] + + name = file.name.removesuffix(file.suffix) + debug(f'Importing profile: {file}') + + try: + if spec := importlib.util.spec_from_file_location(name, file): + imported = importlib.util.module_from_spec(spec) + if spec.loader is not None: + spec.loader.exec_module(imported) + return self._load_profile_class(imported) + except Exception as e: + error(f'Unable to parse file {file}: {e}') + + return [] + + def _find_available_profiles(self) -> List[Profile]: + """ + Search the profile path for profile definitions + """ + profiles = [] + for file in self._profiles_path.glob('**/*.py'): + # ignore the abstract default_profiles class + if 'profile.py' in file.name: + continue + profiles += self._process_profile_file(file) + + self._verify_unique_profile_names(profiles) + return profiles + + def reset_top_level_profiles(self, exclude: List[Profile] = []): + """ + Reset all top level profile configurations, this is usually necessary + when a new top level profile is selected + """ + excluded_profiles = [p.name for p in exclude] + for profile in self.get_top_level_profiles(): + if profile.name not in excluded_profiles: + profile.reset() + + def select_profile( + self, + selectable_profiles: List[Profile], + current_profile: Optional[Union[TProfile, List[TProfile]]] = None, + title: str = '', + allow_reset: bool = True, + multi: bool = False, + ) -> MenuSelection: + """ + Helper function to perform a profile selection + """ + options = {p.name: p for p in selectable_profiles} + options = dict((k, v) for k, v in sorted(options.items(), key=lambda x: x[0].upper())) + + warning = str(_('Are you sure you want to reset this setting?')) + + preset_value: Optional[Union[str, List[str]]] = None + if current_profile is not None: + if isinstance(current_profile, list): + preset_value = [p.name for p in current_profile] + else: + preset_value = current_profile.name + + choice = Menu( + title=title, + preset_values=preset_value, + p_options=options, + allow_reset=allow_reset, + allow_reset_warning_msg=warning, + multi=multi, + sort=False, + preview_command=self.preview_text, + preview_size=0.5 + ).run() + + if choice.type_ == MenuSelectionType.Selection: + value = choice.value + if multi: + # this is quite dirty and should eb switched to a + # dedicated return type instead + choice.value = [options[val] for val in value] # type: ignore + else: + choice.value = options[value] # type: ignore + + return choice + + def preview_text(self, selection: str) -> Optional[str]: + """ + Callback for preview display on profile selection + """ + profile = self.get_profile_by_name(selection) + return profile.preview_text() if profile is not None else None + + +profile_handler = ProfileHandler() diff --git a/archinstall/lib/profiles.py b/archinstall/lib/profiles.py deleted file mode 100644 index a4fbe490..00000000 --- a/archinstall/lib/profiles.py +++ /dev/null @@ -1,340 +0,0 @@ -from __future__ import annotations -import hashlib -import importlib.util -import json -import os -import re -import ssl -import sys -import urllib.error -import urllib.parse -import urllib.request -from typing import Optional, Dict, Union, TYPE_CHECKING, Any -from types import ModuleType -# https://stackoverflow.com/a/39757388/929999 -if TYPE_CHECKING: - from .installer import Installer - _: Any - -from .general import multisplit -from .networking import list_interfaces -from .storage import storage -from .exceptions import ProfileNotFound - - -def grab_url_data(path :str) -> str: - safe_path = path[: path.find(':') + 1] + ''.join([item if item in ('/', '?', '=', '&') else urllib.parse.quote(item) for item in multisplit(path[path.find(':') + 1:], ('/', '?', '=', '&'))]) - ssl_context = ssl.create_default_context() - ssl_context.check_hostname = False - ssl_context.verify_mode = ssl.CERT_NONE - response = urllib.request.urlopen(safe_path, context=ssl_context) - return response.read() # bytes? - - -def is_desktop_profile(profile :str) -> bool: - if str(profile) == 'Profile(desktop)': - return True - - desktop_profile = Profile(None, "desktop") - with open(desktop_profile.path, 'r') as source: - source_data = source.read() - - if '__name__' in source_data and '__supported__' in source_data: - with desktop_profile.load_instructions(namespace=f"{desktop_profile.namespace}.py") as imported: - if hasattr(imported, '__supported__'): - desktop_profiles = imported.__supported__ - return str(profile) in [f"Profile({s})" for s in desktop_profiles] - - return False - - -def list_profiles( - filter_irrelevant_macs :bool = True, - subpath :str = '', - filter_top_level_profiles :bool = False -) -> Dict[str, Dict[str, Union[str, bool]]]: - # TODO: Grab from github page as well, not just local static files - - if filter_irrelevant_macs: - local_macs = list_interfaces() - - cache = {} - # Grab all local profiles found in PROFILE_PATH - for PATH_ITEM in storage['PROFILE_PATH']: - for root, folders, files in os.walk(os.path.abspath(os.path.expanduser(PATH_ITEM + subpath))): - for file in files: - if file == '__init__.py': - continue - if os.path.splitext(file)[1] == '.py': - tailored = False - if len(mac := re.findall('(([a-zA-z0-9]{2}[-:]){5}([a-zA-z0-9]{2}))', file)): - if filter_irrelevant_macs and mac[0][0].lower() not in local_macs: - continue - tailored = True - - description = '' - with open(os.path.join(root, file), 'r') as fh: - first_line = fh.readline() - if len(first_line) and first_line[0] == '#': - description = first_line[1:].strip() - - cache[file[:-3]] = {'path': os.path.join(root, file), 'description': description, 'tailored': tailored} - break - - # Grab profiles from upstream URL - if storage['PROFILE_DB']: - profiles_url = os.path.join(storage["UPSTREAM_URL"] + subpath, storage['PROFILE_DB']) - try: - profile_list = json.loads(grab_url_data(profiles_url)) - except urllib.error.HTTPError as err: - print(_('Error: Listing profiles on URL "{}" resulted in:').format(profiles_url), err) - return cache - except json.decoder.JSONDecodeError as err: - print(_('Error: Could not decode "{}" result as JSON:').format(profiles_url), err) - return cache - - for profile in profile_list: - if os.path.splitext(profile)[1] == '.py': - tailored = False - if len(mac := re.findall('(([a-zA-z0-9]{2}[-:]){5}([a-zA-z0-9]{2}))', profile)): - if filter_irrelevant_macs and mac[0][0].lower() not in local_macs: - continue - tailored = True - - cache[profile[:-3]] = {'path': os.path.join(storage["UPSTREAM_URL"] + subpath, profile), 'description': profile_list[profile], 'tailored': tailored} - - if filter_top_level_profiles: - for profile in list(cache.keys()): - if Profile(None, profile).is_top_level_profile() is False: - del cache[profile] - - return cache - - -class Script: - def __init__(self, profile :str, installer :Optional[Installer] = None): - """ - :param profile: A string representing either a boundled profile, a local python file - or a remote path (URL) to a python script-profile. Three examples: - * profile: https://archlinux.org/some_profile.py - * profile: desktop - * profile: /path/to/profile.py - """ - self.profile = profile - self.installer = installer # TODO: Appears not to be used anymore? - self.converted_path = None - self.spec = None - self.examples = {} - self.namespace = os.path.splitext(os.path.basename(self.path))[0] - self.original_namespace = self.namespace - - def __enter__(self, *args :str, **kwargs :str) -> ModuleType: - self.execute() - return sys.modules[self.namespace] - - def __exit__(self, *args :str, **kwargs :str) -> None: - # TODO: https://stackoverflow.com/questions/28157929/how-to-safely-handle-an-exception-inside-a-context-manager - if len(args) >= 2 and args[1]: - raise args[1] - - if self.original_namespace: - self.namespace = self.original_namespace - - def localize_path(self, profile_path :str) -> str: - if (url := urllib.parse.urlparse(profile_path)).scheme and url.scheme in ('https', 'http'): - if not self.converted_path: - self.converted_path = f"/tmp/{os.path.basename(self.profile).replace('.py', '')}_{hashlib.md5(os.urandom(12)).hexdigest()}.py" - - with open(self.converted_path, "w") as temp_file: - temp_file.write(urllib.request.urlopen(url.geturl()).read().decode('utf-8')) - - return self.converted_path - else: - return profile_path - - @property - def path(self) -> str: - parsed_url = urllib.parse.urlparse(self.profile) - - # The Profile was not a direct match on a remote URL - if not parsed_url.scheme: - # Try to locate all local or known URL's - if not self.examples: - self.examples = list_profiles() - - if f"{self.profile}" in self.examples: - return self.localize_path(self.examples[self.profile]['path']) - # TODO: Redundant, the below block shouldn't be needed as profiles are stripped of their .py, but just in case for now: - elif f"{self.profile}.py" in self.examples: - return self.localize_path(self.examples[f"{self.profile}.py"]['path']) - - # Path was not found in any known examples, check if it's an absolute path - if os.path.isfile(self.profile): - return self.profile - - raise ProfileNotFound(f"File {self.profile} does not exist in {storage['PROFILE_PATH']}") - elif parsed_url.scheme in ('https', 'http'): - return self.localize_path(self.profile) - else: - raise ProfileNotFound(f"Cannot handle scheme {parsed_url.scheme}") - - def load_instructions(self, namespace :Optional[str] = None) -> 'Script': - if namespace: - self.namespace = namespace - - self.spec = importlib.util.spec_from_file_location(self.namespace, self.path) - imported = importlib.util.module_from_spec(self.spec) - sys.modules[self.namespace] = imported - - return self - - def execute(self) -> ModuleType: - if self.namespace not in sys.modules or self.spec is None: - self.load_instructions() - - self.spec.loader.exec_module(sys.modules[self.namespace]) - - return sys.modules[self.namespace] - - -class Profile(Script): - def __init__(self, installer :Optional[Installer], path :str): - super(Profile, self).__init__(path, installer) - - def __dump__(self, *args :str, **kwargs :str) -> Dict[str, str]: - return {'path': self.path} - - def __repr__(self, *args :str, **kwargs :str) -> str: - return f'Profile({os.path.basename(self.profile)})' - - @property - def name(self) -> str: - return os.path.basename(self.profile) - - @property - def is_desktop_profile(self) -> bool: - return is_desktop_profile(repr(self)) - - def install(self) -> ModuleType: - # Before installing, revert any temporary changes to the namespace. - # This ensures that the namespace during installation is the original initiation namespace. - # (For instance awesome instead of aweosme.py or app-awesome.py) - self.namespace = self.original_namespace - return self.execute() - - def has_prep_function(self) -> bool: - with open(self.path, 'r') as source: - source_data = source.read() - - # Some crude safety checks, make sure the imported profile has - # a __name__ check and if so, check if it's got a _prep_function() - # we can call to ask for more user input. - # - # If the requirements are met, import with .py in the namespace to not - # trigger a traditional: - # if __name__ == 'moduleName' - if '__name__' in source_data and '_prep_function' in source_data: - with self.load_instructions(namespace=f"{self.namespace}.py") as imported: - if hasattr(imported, '_prep_function'): - return True - return False - - def has_post_install(self) -> bool: - with open(self.path, 'r') as source: - source_data = source.read() - - # Some crude safety checks, make sure the imported profile has - # a __name__ check and if so, check if it's got a _prep_function() - # we can call to ask for more user input. - # - # If the requirements are met, import with .py in the namespace to not - # trigger a traditional: - # if __name__ == 'moduleName' - if '__name__' in source_data and '_post_install' in source_data: - with self.load_instructions(namespace=f"{self.namespace}.py") as imported: - if hasattr(imported, '_post_install'): - return True - - def is_top_level_profile(self) -> bool: - with open(self.path, 'r') as source: - source_data = source.read() - - if '__name__' in source_data and 'is_top_level_profile' in source_data: - with self.load_instructions(namespace=f"{self.namespace}.py") as imported: - if hasattr(imported, 'is_top_level_profile'): - return imported.is_top_level_profile - - # Default to True if nothing is specified, - # since developers like less code - omitting it should assume they want to present it. - return True - - def get_profile_description(self) -> str: - with open(self.path, 'r') as source: - source_data = source.read() - - if '__description__' in source_data: - with self.load_instructions(namespace=f"{self.namespace}.py") as imported: - if hasattr(imported, '__description__'): - return imported.__description__ - - # Default to this string if the profile does not have a description. - return "This profile does not have the __description__ attribute set." - - @property - def packages(self) -> Optional[list]: - """ - Returns a list of packages baked into the profile definition. - If no package definition has been done, .packages() will return None. - """ - with open(self.path, 'r') as source: - source_data = source.read() - - # Some crude safety checks, make sure the imported profile has - # a __name__ check before importing. - # - # If the requirements are met, import with .py in the namespace to not - # trigger a traditional: - # if __name__ == 'moduleName' - if '__name__' in source_data and '__packages__' in source_data: - with self.load_instructions(namespace=f"{self.namespace}.py") as imported: - if hasattr(imported, '__packages__'): - return imported.__packages__ - return None - - -class Application(Profile): - def __repr__(self, *args :str, **kwargs :str): - return f'Application({os.path.basename(self.profile)})' - - @property - def path(self) -> str: - parsed_url = urllib.parse.urlparse(self.profile) - - # The Profile was not a direct match on a remote URL - if not parsed_url.scheme: - # Try to locate all local or known URL's - if not self.examples: - self.examples = list_profiles(subpath='/applications') - - if f"{self.profile}" in self.examples: - return self.localize_path(self.examples[self.profile]['path']) - # TODO: Redundant, the below block shouldn't be needed as profiles are stripped of their .py, but just in case for now: - elif f"{self.profile}.py" in self.examples: - return self.localize_path(self.examples[f"{self.profile}.py"]['path']) - - # Path was not found in any known examples, check if it's an absolute path - if os.path.isfile(self.profile): - return os.path.basename(self.profile) - - raise ProfileNotFound(f"Application file {self.profile} does not exist in {storage['PROFILE_PATH']}") - elif parsed_url.scheme in ('https', 'http'): - return self.localize_path(self.profile) - else: - raise ProfileNotFound(f"Application cannot handle scheme {parsed_url.scheme}") - - def install(self) -> ModuleType: - # Before installing, revert any temporary changes to the namespace. - # This ensures that the namespace during installation is the original initiation namespace. - # (For instance awesome instead of aweosme.py or app-awesome.py) - self.namespace = self.original_namespace - return self.execute() diff --git a/archinstall/lib/services.py b/archinstall/lib/services.py deleted file mode 100644 index b177052b..00000000 --- a/archinstall/lib/services.py +++ /dev/null @@ -1,11 +0,0 @@ -import os -from .general import SysCommand - - -def service_state(service_name: str) -> str: - if os.path.splitext(service_name)[1] != '.service': - service_name += '.service' # Just to be safe - - state = b''.join(SysCommand(f'systemctl show --no-pager -p SubState --value {service_name}', environment_vars={'SYSTEMD_COLORS': '0'})) - - return state.strip().decode('UTF-8') diff --git a/archinstall/lib/storage.py b/archinstall/lib/storage.py index 8c358161..2f256e5d 100644 --- a/archinstall/lib/storage.py +++ b/archinstall/lib/storage.py @@ -1,26 +1,19 @@ -import os - # There's a few scenarios of execution: -# 1. In the git repository, where ./profiles/ exist +# 1. In the git repository, where ./profiles_bck/ exist # 2. When executing from a remote directory, but targeted a script that starts from the git repository -# 3. When executing as a python -m archinstall module where profiles exist one step back for library reasons. +# 3. When executing as a python -m archinstall module where profiles_bck exist one step back for library reasons. # (4. Added the ~/.config directory as an additional option for future reasons) # # And Keeping this in dict ensures that variables are shared across imports. from typing import Any, Dict +from pathlib import Path + storage: Dict[str, Any] = { - 'PROFILE_PATH': [ - './profiles', - '~/.config/archinstall/profiles', - os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'profiles'), - # os.path.abspath(f'{os.path.dirname(__file__)}/../examples') - ], - 'UPSTREAM_URL': 'https://raw.githubusercontent.com/archlinux/archinstall/master/profiles', - 'PROFILE_DB': None, # Used in cases when listing profiles is desired, not mandatory for direct profile grabbing. - 'LOG_PATH': '/var/log/archinstall', - 'LOG_FILE': 'install.log', - 'MOUNT_POINT': '/mnt/archinstall', + 'PROFILE': Path(__file__).parent.parent.joinpath('default_profiles'), + 'LOG_PATH': Path('/var/log/archinstall'), + 'LOG_FILE': Path('install.log'), + 'MOUNT_POINT': Path('/mnt/archinstall'), 'ENC_IDENTIFIER': 'ainst', 'DISK_TIMEOUTS' : 1, # seconds 'DISK_RETRY_ATTEMPTS' : 5, # RETRY_ATTEMPTS * DISK_TIMEOUTS is used in disk operations diff --git a/archinstall/lib/translationhandler.py b/archinstall/lib/translationhandler.py index 0d74f974..3ea4c70e 100644 --- a/archinstall/lib/translationhandler.py +++ b/archinstall/lib/translationhandler.py @@ -1,14 +1,14 @@ from __future__ import annotations import json -import logging import os import gettext from dataclasses import dataclass from pathlib import Path from typing import List, Dict, Any, TYPE_CHECKING, Optional -from .exceptions import TranslationError + +from .output import error, debug if TYPE_CHECKING: _: Any @@ -80,8 +80,8 @@ class TranslationHandler: language = Language(abbr, lang, translation, percent, translated_lang) languages.append(language) - except FileNotFoundError as error: - raise TranslationError(f"Could not locate language file for '{lang}': {error}") + except FileNotFoundError as err: + raise FileNotFoundError(f"Could not locate language file for '{lang}': {err}") return languages @@ -89,12 +89,12 @@ class TranslationHandler: """ Set the provided font as the new terminal font """ - from .general import SysCommand, log + from .general import SysCommand try: - log(f'Setting font: {font}', level=logging.DEBUG) + debug(f'Setting font: {font}') SysCommand(f'setfont {font}') except Exception: - log(f'Unable to set font {font}', level=logging.ERROR) + error(f'Unable to set font {font}') def _load_language_mappings(self) -> List[Dict[str, Any]]: """ @@ -138,7 +138,7 @@ class TranslationHandler: def get_language_by_abbr(self, abbr: str) -> Language: """ - Get a language object by its abbrevation, e.g. en + Get a language object by its abbreviation, e.g. en """ try: return next(filter(lambda x: x.abbr == abbr, self._translated_languages)) @@ -168,7 +168,7 @@ class TranslationHandler: translation_files = [] for filename in filenames: - if len(filename) == 2 or filename == 'pt_BR': + if len(filename) == 2 or filename in ['pt_BR', 'zh-CN', 'zh-TW']: translation_files.append(filename) return translation_files @@ -206,4 +206,4 @@ class DeferredTranslation: @classmethod def install(cls): import builtins - builtins._ = cls + builtins._ = cls # type: ignore diff --git a/archinstall/lib/udev/__init__.py b/archinstall/lib/udev/__init__.py deleted file mode 100644 index 86c8cc29..00000000 --- a/archinstall/lib/udev/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .udevadm import udevadm_info
\ No newline at end of file diff --git a/archinstall/lib/udev/udevadm.py b/archinstall/lib/udev/udevadm.py deleted file mode 100644 index 84ec9cfd..00000000 --- a/archinstall/lib/udev/udevadm.py +++ /dev/null @@ -1,17 +0,0 @@ -import typing -import pathlib -from ..general import SysCommand - -def udevadm_info(path :pathlib.Path) -> typing.Dict[str, str]: - if path.resolve().exists() is False: - return {} - - result = SysCommand(f"udevadm info {path.resolve()}") - data = {} - for line in result: - if b': ' in line and b'=' in line: - _, obj = line.split(b': ', 1) - key, value = obj.split(b'=', 1) - data[key.decode('UTF-8').lower()] = value.decode('UTF-8').strip() - - return data
\ No newline at end of file diff --git a/archinstall/lib/user_interaction/__init__.py b/archinstall/lib/user_interaction/__init__.py deleted file mode 100644 index 2bc46759..00000000 --- a/archinstall/lib/user_interaction/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -from .save_conf import save_config -from .manage_users_conf import ask_for_additional_users -from .backwards_compatible_conf import generic_select, generic_multi_select -from .locale_conf import select_locale_lang, select_locale_enc -from .system_conf import select_kernel, select_harddrives, select_driver, ask_for_bootloader, ask_for_swap -from .network_conf import ask_to_configure_network -from .partitioning_conf import select_partition -from .general_conf import (ask_ntp, ask_for_a_timezone, ask_for_audio_selection, select_language, select_mirror_regions, - select_profile, select_archinstall_language, ask_additional_packages_to_install, - select_additional_repositories, ask_hostname, add_number_of_parrallel_downloads) -from .disk_conf import ask_for_main_filesystem_format, select_individual_blockdevice_usage, select_disk_layout, select_disk -from .utils import get_password, do_countdown diff --git a/archinstall/lib/user_interaction/backwards_compatible_conf.py b/archinstall/lib/user_interaction/backwards_compatible_conf.py deleted file mode 100644 index 296572d2..00000000 --- a/archinstall/lib/user_interaction/backwards_compatible_conf.py +++ /dev/null @@ -1,95 +0,0 @@ -from __future__ import annotations - -import logging -import sys -from collections.abc import Iterable -from typing import Any, Union, TYPE_CHECKING - -from ..exceptions import RequirementError -from ..menu import Menu -from ..output import log - -if TYPE_CHECKING: - _: Any - - -def generic_select( - p_options: Union[list, dict], - input_text: str = '', - allow_empty_input: bool = True, - options_output: bool = True, # function not available - sort: bool = False, - multi: bool = False, - default: Any = None) -> Any: - """ - A generic select function that does not output anything - other than the options and their indexes. As an example: - - generic_select(["first", "second", "third option"]) - > first - second - third option - When the user has entered the option correctly, - this function returns an item from list, a string, or None - - Options can be any iterable. - Duplicate entries are not checked, but the results with them are unreliable. Which element to choose from the duplicates depends on the return of the index() - Default value if not on the list of options will be added as the first element - sort will be handled by Menu() - """ - # We check that the options are iterable. If not we abort. Else we copy them to lists - # it options is a dictionary we use the values as entries of the list - # if options is a string object, each character becomes an entry - # if options is a list, we implictily build a copy to maintain immutability - if not isinstance(p_options, Iterable): - log(f"Objects of type {type(p_options)} is not iterable, and are not supported at generic_select", fg="red") - log(f"invalid parameter at Menu() call was at <{sys._getframe(1).f_code.co_name}>", level=logging.WARNING) - raise RequirementError("generic_select() requires an iterable as option.") - - input_text = input_text if input_text else _('Select one of the values shown below: ') - - if isinstance(p_options, dict): - options = list(p_options.values()) - else: - options = list(p_options) - # check that the default value is in the list. If not it will become the first entry - if default and default not in options: - options.insert(0, default) - - # one of the drawbacks of the new interface is that in only allows string like options, so we do a conversion - # also for the default value if it exists - soptions = list(map(str, options)) - default_value = options[options.index(default)] if default else None - - selected_option = Menu(input_text, - soptions, - skip=allow_empty_input, - multi=multi, - default_option=default_value, - sort=sort).run() - # we return the original objects, not the strings. - # options is the list with the original objects and soptions the list with the string values - # thru the map, we get from the value selected in soptions it index, and thu it the original object - if not selected_option: - return selected_option - elif isinstance(selected_option, list): # for multi True - selected_option = list(map(lambda x: options[soptions.index(x)], selected_option)) - else: # for multi False - selected_option = options[soptions.index(selected_option)] - return selected_option - - -def generic_multi_select(p_options: Union[list, dict], - text: str = '', - sort: bool = False, - default: Any = None, - allow_empty: bool = False) -> Any: - - text = text if text else _("Select one or more of the options below: ") - - return generic_select(p_options, - input_text=text, - allow_empty_input=allow_empty, - sort=sort, - multi=True, - default=default) diff --git a/archinstall/lib/user_interaction/disk_conf.py b/archinstall/lib/user_interaction/disk_conf.py deleted file mode 100644 index 554d13ef..00000000 --- a/archinstall/lib/user_interaction/disk_conf.py +++ /dev/null @@ -1,86 +0,0 @@ -from __future__ import annotations - -from typing import Any, Dict, TYPE_CHECKING, Optional - -from .partitioning_conf import manage_new_and_existing_partitions, get_default_partition_layout -from ..disk import BlockDevice -from ..exceptions import DiskError -from ..menu import Menu -from ..menu.menu import MenuSelectionType - -if TYPE_CHECKING: - _: Any - - -def ask_for_main_filesystem_format(advanced_options=False) -> str: - options = {'btrfs': 'btrfs', 'ext4': 'ext4', 'xfs': 'xfs', 'f2fs': 'f2fs'} - - advanced = {'ntfs': 'ntfs'} - - if advanced_options: - options.update(advanced) - - prompt = _('Select which filesystem your main partition should use') - choice = Menu(prompt, options, skip=False).run() - return choice.value - - -def select_individual_blockdevice_usage(block_devices: list) -> Dict[str, Any]: - result = {} - - for device in block_devices: - layout = manage_new_and_existing_partitions(device) - result[device.path] = layout - - return result - - -def select_disk_layout(preset: Optional[Dict[str, Any]], block_devices: list, advanced_options=False) -> Optional[Dict[str, Any]]: - wipe_mode = str(_('Wipe all selected drives and use a best-effort default partition layout')) - custome_mode = str(_('Select what to do with each individual drive (followed by partition usage)')) - modes = [wipe_mode, custome_mode] - - warning = str(_('Are you sure you want to reset this setting?')) - - choice = Menu( - _('Select what you wish to do with the selected block devices'), - modes, - allow_reset=True, - allow_reset_warning_msg=warning - ).run() - - match choice.type_: - case MenuSelectionType.Skip: return preset - case MenuSelectionType.Reset: return None - case MenuSelectionType.Selection: - if choice.value == wipe_mode: - return get_default_partition_layout(block_devices, advanced_options) - else: - return select_individual_blockdevice_usage(block_devices) - - -def select_disk(dict_o_disks: Dict[str, BlockDevice]) -> Optional[BlockDevice]: - """ - Asks the user to select a harddrive from the `dict_o_disks` selection. - Usually this is combined with :ref:`archinstall.list_drives`. - - :param dict_o_disks: A `dict` where keys are the drive-name, value should be a dict containing drive information. - :type dict_o_disks: dict - - :return: The name/path (the dictionary key) of the selected drive - :rtype: str - """ - drives = sorted(list(dict_o_disks.keys())) - if len(drives) >= 1: - title = str(_('You can skip selecting a drive and partitioning and use whatever drive-setup is mounted at /mnt (experimental)')) + '\n' - title += str(_('Select one of the disks or skip and use /mnt as default')) - - choice = Menu(title, drives).run() - - if choice.type_ == MenuSelectionType.Skip: - return None - - drive = dict_o_disks[choice.value] - return drive - - raise DiskError('select_disk() requires a non-empty dictionary of disks to select from.') diff --git a/archinstall/lib/user_interaction/general_conf.py b/archinstall/lib/user_interaction/general_conf.py deleted file mode 100644 index fc7ded45..00000000 --- a/archinstall/lib/user_interaction/general_conf.py +++ /dev/null @@ -1,271 +0,0 @@ -from __future__ import annotations - -import logging -import pathlib -from typing import List, Any, Optional, Dict, TYPE_CHECKING - -from ..locale_helpers import list_keyboard_languages, list_timezones -from ..menu import Menu -from ..menu.menu import MenuSelectionType -from ..menu.text_input import TextInput -from ..mirrors import list_mirrors -from ..output import log -from ..packages.packages import validate_package_list -from ..profiles import Profile, list_profiles -from ..storage import storage -from ..translationhandler import Language - -if TYPE_CHECKING: - _: Any - - -def ask_ntp(preset: bool = True) -> bool: - prompt = str(_('Would you like to use automatic time synchronization (NTP) with the default time servers?\n')) - prompt += str(_('Hardware time and other post-configuration steps might be required in order for NTP to work.\nFor more information, please check the Arch wiki')) - if preset: - preset_val = Menu.yes() - else: - preset_val = Menu.no() - choice = Menu(prompt, Menu.yes_no(), skip=False, preset_values=preset_val, default_option=Menu.yes()).run() - - return False if choice.value == Menu.no() else True - - -def ask_hostname(preset: str = None) -> str: - hostname = TextInput(_('Desired hostname for the installation: '), preset).run().strip(' ') - return hostname - - -def ask_for_a_timezone(preset: str = None) -> str: - timezones = list_timezones() - default = 'UTC' - - choice = Menu( - _('Select a timezone'), - list(timezones), - preset_values=preset, - default_option=default - ).run() - - match choice.type_: - case MenuSelectionType.Skip: return preset - case MenuSelectionType.Selection: return choice.value - - -def ask_for_audio_selection(desktop: bool = True, preset: str = None) -> str: - no_audio = str(_('No audio server')) - choices = ['pipewire', 'pulseaudio'] if desktop else ['pipewire', 'pulseaudio', no_audio] - default = 'pipewire' if desktop else no_audio - - choice = Menu(_('Choose an audio server'), choices, preset_values=preset, default_option=default).run() - - match choice.type_: - case MenuSelectionType.Skip: return preset - case MenuSelectionType.Selection: return choice.value - - -def select_language(preset_value: str = None) -> str: - """ - Asks the user to select a language - Usually this is combined with :ref:`archinstall.list_keyboard_languages`. - - :return: The language/dictionary key of the selected language - :rtype: str - """ - kb_lang = list_keyboard_languages() - # sort alphabetically and then by length - sorted_kb_lang = sorted(sorted(list(kb_lang)), key=len) - - selected_lang = Menu( - _('Select keyboard layout'), - sorted_kb_lang, - preset_values=preset_value, - sort=False - ).run() - - if selected_lang.value is None: - return preset_value - - return selected_lang.value - - -def select_mirror_regions(preset_values: Dict[str, Any] = {}) -> Dict[str, Any]: - """ - Asks the user to select a mirror or region - Usually this is combined with :ref:`archinstall.list_mirrors`. - - :return: The dictionary information about a mirror/region. - :rtype: dict - """ - if preset_values is None: - preselected = None - else: - preselected = list(preset_values.keys()) - mirrors = list_mirrors() - selected_mirror = Menu( - _('Select one of the regions to download packages from'), - list(mirrors.keys()), - preset_values=preselected, - multi=True, - allow_reset=True - ).run() - - match selected_mirror.type_: - case MenuSelectionType.Reset: return {} - case MenuSelectionType.Skip: return preset_values - case _: return {selected: mirrors[selected] for selected in selected_mirror.value} - - -def select_archinstall_language(languages: List[Language], preset_value: Language) -> Language: - # these are the displayed language names which can either be - # the english name of a language or, if present, the - # name of the language in its own language - options = {lang.display_name: lang for lang in languages} - - title = 'NOTE: If a language can not displayed properly, a proper font must be set manually in the console.\n' - title += 'All available fonts can be found in "/usr/share/kbd/consolefonts"\n' - title += 'e.g. setfont LatGrkCyr-8x16 (to display latin/greek/cyrillic characters)\n' - - choice = Menu( - title, - list(options.keys()), - default_option=preset_value.display_name, - preview_size=0.5 - ).run() - - match choice.type_: - case MenuSelectionType.Skip: - return preset_value - case MenuSelectionType.Selection: - return options[choice.value] - - -def select_profile(preset) -> Optional[Profile]: - """ - # Asks the user to select a profile from the available profiles. - # - # :return: The name/dictionary key of the selected profile - # :rtype: str - # """ - top_level_profiles = sorted(list(list_profiles(filter_top_level_profiles=True))) - options = {} - - for profile in top_level_profiles: - profile = Profile(None, profile) - description = profile.get_profile_description() - - option = f'{profile.profile}: {description}' - options[option] = profile - - title = _('This is a list of pre-programmed profiles, they might make it easier to install things like desktop environments') - warning = str(_('Are you sure you want to reset this setting?')) - - selection = Menu( - title=title, - p_options=list(options.keys()), - allow_reset=True, - allow_reset_warning_msg=warning - ).run() - - match selection.type_: - case MenuSelectionType.Selection: - return options[selection.value] if selection.value is not None else None - case MenuSelectionType.Reset: - storage['profile_minimal'] = False - storage['_selected_servers'] = [] - storage['_desktop_profile'] = None - storage['sway_sys_priv_ctrl'] = None - storage['arguments']['sway_sys_priv_ctrl'] = None - storage['arguments']['desktop-environment'] = None - storage['arguments']['gfx_driver'] = None - storage['arguments']['gfx_driver_packages'] = None - return None - case MenuSelectionType.Skip: - return None - - -def ask_additional_packages_to_install(pre_set_packages: List[str] = []) -> List[str]: - # Additional packages (with some light weight error handling for invalid package names) - print(_('Only packages such as base, base-devel, linux, linux-firmware, efibootmgr and optional profile packages are installed.')) - print(_('If you desire a web browser, such as firefox or chromium, you may specify it in the following prompt.')) - - def read_packages(already_defined: list = []) -> list: - display = ' '.join(already_defined) - input_packages = TextInput(_('Write additional packages to install (space separated, leave blank to skip): '), display).run().strip() - return input_packages.split() if input_packages else [] - - pre_set_packages = pre_set_packages if pre_set_packages else [] - packages = read_packages(pre_set_packages) - - if not storage['arguments']['offline'] and not storage['arguments']['no_pkg_lookups']: - while True: - if len(packages): - # Verify packages that were given - print(_("Verifying that additional packages exist (this might take a few seconds)")) - valid, invalid = validate_package_list(packages) - - if invalid: - log(f"Some packages could not be found in the repository: {invalid}", level=logging.WARNING, fg='red') - packages = read_packages(valid) - continue - break - - return packages - - -def add_number_of_parrallel_downloads(input_number :Optional[int] = None) -> Optional[int]: - max_downloads = 5 - print(_(f"This option enables the number of parallel downloads that can occur during installation")) - print(_(f"Enter the number of parallel downloads to be enabled.\n (Enter a value between 1 to {max_downloads})\nNote:")) - print(_(f" - Maximum value : {max_downloads} ( Allows {max_downloads} parallel downloads, allows {max_downloads+1} downloads at a time )")) - print(_(f" - Minimum value : 1 ( Allows 1 parallel download, allows 2 downloads at a time )")) - print(_(f" - Disable/Default : 0 ( Disables parallel downloading, allows only 1 download at a time )")) - - while True: - try: - input_number = int(TextInput(_("[Default value: 0] > ")).run().strip() or 0) - if input_number <= 0: - input_number = 0 - elif input_number > max_downloads: - input_number = max_downloads - break - except: - print(_(f"Invalid input! Try again with a valid input [1 to {max_downloads}, or 0 to disable]")) - - pacman_conf_path = pathlib.Path("/etc/pacman.conf") - with pacman_conf_path.open() as f: - pacman_conf = f.read().split("\n") - - with pacman_conf_path.open("w") as fwrite: - for line in pacman_conf: - if "ParallelDownloads" in line: - fwrite.write(f"ParallelDownloads = {input_number+1}\n") if not input_number == 0 else fwrite.write("#ParallelDownloads = 0\n") - else: - fwrite.write(f"{line}\n") - - return input_number - - -def select_additional_repositories(preset: List[str]) -> List[str]: - """ - Allows the user to select additional repositories (multilib, and testing) if desired. - - :return: The string as a selected repository - :rtype: string - """ - - repositories = ["multilib", "testing"] - - choice = Menu( - _('Choose which optional additional repositories to enable'), - repositories, - sort=False, - multi=True, - preset_values=preset, - allow_reset=True - ).run() - - match choice.type_: - case MenuSelectionType.Skip: return preset - case MenuSelectionType.Reset: return [] - case MenuSelectionType.Selection: return choice.value diff --git a/archinstall/lib/user_interaction/locale_conf.py b/archinstall/lib/user_interaction/locale_conf.py deleted file mode 100644 index bbbe070b..00000000 --- a/archinstall/lib/user_interaction/locale_conf.py +++ /dev/null @@ -1,42 +0,0 @@ -from __future__ import annotations - -from typing import Any, TYPE_CHECKING - -from ..locale_helpers import list_locales -from ..menu import Menu -from ..menu.menu import MenuSelectionType - -if TYPE_CHECKING: - _: Any - - -def select_locale_lang(preset: str = None) -> str: - locales = list_locales() - locale_lang = set([locale.split()[0] for locale in locales]) - - selected_locale = Menu( - _('Choose which locale language to use'), - list(locale_lang), - sort=True, - preset_values=preset - ).run() - - match selected_locale.type_: - case MenuSelectionType.Selection: return selected_locale.value - case MenuSelectionType.Skip: return preset - - -def select_locale_enc(preset: str = None) -> str: - locales = list_locales() - locale_enc = set([locale.split()[1] for locale in locales]) - - selected_locale = Menu( - _('Choose which locale encoding to use'), - list(locale_enc), - sort=True, - preset_values=preset - ).run() - - match selected_locale.type_: - case MenuSelectionType.Selection: return selected_locale.value - case MenuSelectionType.Skip: return preset diff --git a/archinstall/lib/user_interaction/partitioning_conf.py b/archinstall/lib/user_interaction/partitioning_conf.py deleted file mode 100644 index 0a5ede51..00000000 --- a/archinstall/lib/user_interaction/partitioning_conf.py +++ /dev/null @@ -1,362 +0,0 @@ -from __future__ import annotations - -import copy -from typing import List, Any, Dict, Union, TYPE_CHECKING, Callable, Optional - -from ..menu import Menu -from ..menu.menu import MenuSelectionType -from ..output import log, FormattedOutput - -from ..disk.validators import fs_types - -if TYPE_CHECKING: - from ..disk import BlockDevice - from ..disk.partition import Partition - _: Any - - -def partition_overlap(partitions: list, start: str, end: str) -> bool: - # TODO: Implement sanity check - return False - - -def current_partition_layout(partitions: List[Dict[str, Any]], with_idx: bool = False, with_title: bool = True) -> str: - - def do_padding(name: str, max_len: int): - spaces = abs(len(str(name)) - max_len) + 2 - pad_left = int(spaces / 2) - pad_right = spaces - pad_left - return f'{pad_right * " "}{name}{pad_left * " "}|' - - def flatten_data(data: Dict[str, Any]) -> Dict[str, Any]: - flattened = {} - for k, v in data.items(): - if k == 'filesystem': - flat = flatten_data(v) - flattened.update(flat) - elif k == 'btrfs': - # we're going to create a separate table for the btrfs subvolumes - pass - else: - flattened[k] = v - return flattened - - display_data: List[Dict[str, Any]] = [flatten_data(entry) for entry in partitions] - - column_names = {} - - # this will add an initial index to the table for each partition - if with_idx: - column_names['index'] = max([len(str(len(display_data))), len('index')]) - - # determine all attribute names and the max length - # of the value among all display_data to know the width - # of the table cells - for p in display_data: - for attribute, value in p.items(): - if attribute in column_names.keys(): - column_names[attribute] = max([column_names[attribute], len(str(value)), len(attribute)]) - else: - column_names[attribute] = max([len(str(value)), len(attribute)]) - - current_layout = '' - for name, max_len in column_names.items(): - current_layout += do_padding(name, max_len) - - current_layout = f'{current_layout[:-1]}\n{"-" * len(current_layout)}\n' - - for idx, p in enumerate(display_data): - row = '' - for name, max_len in column_names.items(): - if name == 'index': - row += do_padding(str(idx), max_len) - elif name in p: - row += do_padding(p[name], max_len) - else: - row += ' ' * (max_len + 2) + '|' - - current_layout += f'{row[:-1]}\n' - - # we'll create a separate table for the btrfs subvolumes - btrfs_subvolumes = [partition['btrfs']['subvolumes'] for partition in partitions if partition.get('btrfs', None)] - if len(btrfs_subvolumes) > 0: - for subvolumes in btrfs_subvolumes: - output = FormattedOutput.as_table(subvolumes) - current_layout += f'\n{output}' - - if with_title: - title = str(_('Current partition layout')) - return f'\n\n{title}:\n\n{current_layout}' - - return current_layout - - -def _get_partitions(partitions :List[Partition], filter_ :Callable = None) -> List[str]: - """ - filter allows to filter out the indexes once they are set. Should return True if element is to be included - """ - partition_indexes = [] - for i in range(len(partitions)): - if filter_: - if filter_(partitions[i]): - partition_indexes.append(str(i)) - else: - partition_indexes.append(str(i)) - - return partition_indexes - - -def select_partition( - title :str, - partitions :List[Partition], - multiple :bool = False, - filter_ :Callable = None -) -> Optional[int, List[int]]: - partition_indexes = _get_partitions(partitions, filter_) - - if len(partition_indexes) == 0: - return None - - choice = Menu(title, partition_indexes, multi=multiple).run() - - if choice.type_ == MenuSelectionType.Skip: - return None - - if isinstance(choice.value, list): - return [int(p) for p in choice.value] - else: - return int(choice.value) - - -def get_default_partition_layout( - block_devices: Union['BlockDevice', List['BlockDevice']], - advanced_options: bool = False -) -> Optional[Dict[str, Any]]: - from ..disk import suggest_single_disk_layout, suggest_multi_disk_layout - - if len(block_devices) == 1: - return suggest_single_disk_layout(block_devices[0], advanced_options=advanced_options) - else: - return suggest_multi_disk_layout(block_devices, advanced_options=advanced_options) - - -def manage_new_and_existing_partitions(block_device: 'BlockDevice') -> Dict[str, Any]: # noqa: max-complexity: 50 - block_device_struct = {"partitions": [partition.__dump__() for partition in block_device.partitions.values()]} - original_layout = copy.deepcopy(block_device_struct) - - new_partition = str(_('Create a new partition')) - suggest_partition_layout = str(_('Suggest partition layout')) - delete_partition = str(_('Delete a partition')) - delete_all_partitions = str(_('Clear/Delete all partitions')) - assign_mount_point = str(_('Assign mount-point for a partition')) - mark_formatted = str(_('Mark/Unmark a partition to be formatted (wipes data)')) - mark_compressed = str(_('Mark/Unmark a partition as compressed (btrfs only)')) - mark_bootable = str(_('Mark/Unmark a partition as bootable (automatic for /boot)')) - set_filesystem_partition = str(_('Set desired filesystem for a partition')) - set_btrfs_subvolumes = str(_('Set desired subvolumes on a btrfs partition')) - save_and_exit = str(_('Save and exit')) - cancel = str(_('Cancel')) - - while True: - modes = [new_partition, suggest_partition_layout] - - if len(block_device_struct['partitions']) > 0: - modes += [ - delete_partition, - delete_all_partitions, - assign_mount_point, - mark_formatted, - mark_bootable, - mark_compressed, - set_filesystem_partition, - ] - - indexes = _get_partitions( - block_device_struct["partitions"], - filter_=lambda x: True if x.get('filesystem', {}).get('format') == 'btrfs' else False - ) - - if len(indexes) > 0: - modes += [set_btrfs_subvolumes] - - title = _('Select what to do with\n{}').format(block_device) - - # show current partition layout: - if len(block_device_struct["partitions"]): - title += current_partition_layout(block_device_struct['partitions']) + '\n' - - modes += [save_and_exit, cancel] - - task = Menu(title, modes, sort=False, skip=False).run() - task = task.value - - if task == cancel: - return original_layout - elif task == save_and_exit: - break - - if task == new_partition: - from ..disk import valid_parted_position - - # if partition_type == 'gpt': - # # https://www.gnu.org/software/parted/manual/html_node/mkpart.html - # # https://www.gnu.org/software/parted/manual/html_node/mklabel.html - # name = input("Enter a desired name for the partition: ").strip() - - fs_choice = Menu(_('Enter a desired filesystem type for the partition'), fs_types()).run() - - if fs_choice.type_ == MenuSelectionType.Skip: - continue - - prompt = str(_('Enter the start location (in parted units: s, GB, %, etc. ; default: {}): ')).format( - block_device.first_free_sector - ) - start = input(prompt).strip() - - if not start.strip(): - start = block_device.first_free_sector - end_suggested = block_device.first_end_sector - else: - end_suggested = '100%' - - prompt = str(_('Enter the end location (in parted units: s, GB, %, etc. ; ex: {}): ')).format( - end_suggested - ) - end = input(prompt).strip() - - if not end.strip(): - end = end_suggested - - if valid_parted_position(start) and valid_parted_position(end): - if partition_overlap(block_device_struct["partitions"], start, end): - log(f"This partition overlaps with other partitions on the drive! Ignoring this partition creation.", - fg="red") - continue - - block_device_struct["partitions"].append({ - "type": "primary", # Strictly only allowed under MS-DOS, but GPT accepts it so it's "safe" to inject - "start": start, - "size": end, - "mountpoint": None, - "wipe": True, - "filesystem": { - "format": fs_choice.value - } - }) - else: - log(f"Invalid start ({valid_parted_position(start)}) or end ({valid_parted_position(end)}) for this partition. Ignoring this partition creation.", - fg="red") - continue - elif task == suggest_partition_layout: - from ..disk import suggest_single_disk_layout - - if len(block_device_struct["partitions"]): - prompt = _('{}\ncontains queued partitions, this will remove those, are you sure?').format(block_device) - choice = Menu(prompt, Menu.yes_no(), default_option=Menu.no(), skip=False).run() - - if choice.value == Menu.no(): - continue - - block_device_struct.update(suggest_single_disk_layout(block_device)[block_device.path]) - else: - current_layout = current_partition_layout(block_device_struct['partitions'], with_idx=True) - - if task == delete_partition: - title = _('{}\n\nSelect by index which partitions to delete').format(current_layout) - to_delete = select_partition(title, block_device_struct["partitions"], multiple=True) - - if to_delete: - block_device_struct['partitions'] = [ - p for idx, p in enumerate(block_device_struct['partitions']) if idx not in to_delete - ] - elif task == mark_compressed: - title = _('{}\n\nSelect which partition to mark as bootable').format(current_layout) - partition = select_partition(title, block_device_struct["partitions"]) - - if partition is not None: - if "filesystem" not in block_device_struct["partitions"][partition]: - block_device_struct["partitions"][partition]["filesystem"] = {} - if "mount_options" not in block_device_struct["partitions"][partition]["filesystem"]: - block_device_struct["partitions"][partition]["filesystem"]["mount_options"] = [] - - if "compress=zstd" not in block_device_struct["partitions"][partition]["filesystem"]["mount_options"]: - block_device_struct["partitions"][partition]["filesystem"]["mount_options"].append("compress=zstd") - elif task == delete_all_partitions: - block_device_struct["partitions"] = [] - block_device_struct["wipe"] = True - elif task == assign_mount_point: - title = _('{}\n\nSelect by index which partition to mount where').format(current_layout) - partition = select_partition(title, block_device_struct["partitions"]) - - if partition is not None: - print(_(' * Partition mount-points are relative to inside the installation, the boot would be /boot as an example.')) - mountpoint = input(_('Select where to mount partition (leave blank to remove mountpoint): ')).strip() - - if len(mountpoint): - block_device_struct["partitions"][partition]['mountpoint'] = mountpoint - if mountpoint == '/boot': - log(f"Marked partition as bootable because mountpoint was set to /boot.", fg="yellow") - block_device_struct["partitions"][partition]['boot'] = True - else: - del (block_device_struct["partitions"][partition]['mountpoint']) - - elif task == mark_formatted: - title = _('{}\n\nSelect which partition to mask for formatting').format(current_layout) - partition = select_partition(title, block_device_struct["partitions"]) - - if partition is not None: - # If we mark a partition for formatting, but the format is CRYPTO LUKS, there's no point in formatting it really - # without asking the user which inner-filesystem they want to use. Since the flag 'encrypted' = True is already set, - # it's safe to change the filesystem for this partition. - if block_device_struct["partitions"][partition].get('filesystem',{}).get('format', 'crypto_LUKS') == 'crypto_LUKS': - if not block_device_struct["partitions"][partition].get('filesystem', None): - block_device_struct["partitions"][partition]['filesystem'] = {} - - fs_choice = Menu(_('Enter a desired filesystem type for the partition'), fs_types()).run() - - if fs_choice.type_ == MenuSelectionType.Selection: - block_device_struct["partitions"][partition]['filesystem']['format'] = fs_choice.value - - # Negate the current wipe marking - block_device_struct["partitions"][partition]['wipe'] = not block_device_struct["partitions"][partition].get('wipe', False) - - elif task == mark_bootable: - title = _('{}\n\nSelect which partition to mark as bootable').format(current_layout) - partition = select_partition(title, block_device_struct["partitions"]) - - if partition is not None: - block_device_struct["partitions"][partition]['boot'] = \ - not block_device_struct["partitions"][partition].get('boot', False) - - elif task == set_filesystem_partition: - title = _('{}\n\nSelect which partition to set a filesystem on').format(current_layout) - partition = select_partition(title, block_device_struct["partitions"]) - - if partition is not None: - if not block_device_struct["partitions"][partition].get('filesystem', None): - block_device_struct["partitions"][partition]['filesystem'] = {} - - fstype_title = _('Enter a desired filesystem type for the partition: ') - fs_choice = Menu(fstype_title, fs_types()).run() - - if fs_choice.type_ == MenuSelectionType.Selection: - block_device_struct["partitions"][partition]['filesystem']['format'] = fs_choice.value - - elif task == set_btrfs_subvolumes: - from .subvolume_config import SubvolumeList - - # TODO get preexisting partitions - title = _('{}\n\nSelect which partition to set subvolumes on').format(current_layout) - partition = select_partition(title, block_device_struct["partitions"],filter_=lambda x:True if x.get('filesystem',{}).get('format') == 'btrfs' else False) - - if partition is not None: - if not block_device_struct["partitions"][partition].get('btrfs', {}): - block_device_struct["partitions"][partition]['btrfs'] = {} - if not block_device_struct["partitions"][partition]['btrfs'].get('subvolumes', []): - block_device_struct["partitions"][partition]['btrfs']['subvolumes'] = [] - - prev = block_device_struct["partitions"][partition]['btrfs']['subvolumes'] - result = SubvolumeList(_("Manage btrfs subvolumes for current partition"), prev).run() - block_device_struct["partitions"][partition]['btrfs']['subvolumes'] = result - - return block_device_struct diff --git a/archinstall/lib/user_interaction/save_conf.py b/archinstall/lib/user_interaction/save_conf.py deleted file mode 100644 index 5b4ae2b3..00000000 --- a/archinstall/lib/user_interaction/save_conf.py +++ /dev/null @@ -1,135 +0,0 @@ -from __future__ import annotations - -import logging - -from pathlib import Path -from typing import Any, Dict, TYPE_CHECKING - -from ..configuration import ConfigurationOutput -from ..general import SysCommand -from ..menu import Menu -from ..menu.menu import MenuSelectionType -from ..output import log - -if TYPE_CHECKING: - _: Any - - -def save_config(config: Dict): - - def preview(selection: str): - if options['user_config'] == selection: - json_config = config_output.user_config_to_json() - return f'{config_output.user_configuration_file}\n{json_config}' - elif options['user_creds'] == selection: - if json_config := config_output.user_credentials_to_json(): - return f'{config_output.user_credentials_file}\n{json_config}' - else: - return str(_('No configuration')) - elif options['disk_layout'] == selection: - if json_config := config_output.disk_layout_to_json(): - return f'{config_output.disk_layout_file}\n{json_config}' - else: - return str(_('No configuration')) - elif options['all'] == selection: - output = f'{config_output.user_configuration_file}\n' - if json_config := config_output.user_credentials_to_json(): - output += f'{config_output.user_credentials_file}\n' - if json_config := config_output.disk_layout_to_json(): - output += f'{config_output.disk_layout_file}\n' - return output[:-1] - return None - - config_output = ConfigurationOutput(config) - - options = { - 'user_config': str(_('Save user configuration')), - 'user_creds': str(_('Save user credentials')), - 'disk_layout': str(_('Save disk layout')), - 'all': str(_('Save all')) - } - - choice = Menu( - _('Choose which configuration to save'), - list(options.values()), - sort=False, - skip=True, - preview_size=0.75, - preview_command=preview - ).run() - - if choice.type_ == MenuSelectionType.Skip: - return - - dirs_to_exclude = [ - '/bin', - '/dev', - '/lib', - '/lib64', - '/lost+found', - '/opt', - '/proc', - '/run', - '/sbin', - '/srv', - '/sys', - '/usr', - '/var', - ] - log( - _('When picking a directory to save configuration files to,' - ' by default we will ignore the following folders: ') + ','.join(dirs_to_exclude), - level=logging.DEBUG - ) - - log(_('Finding possible directories to save configuration files ...'), level=logging.INFO) - - find_exclude = '-path ' + ' -prune -o -path '.join(dirs_to_exclude) + ' -prune ' - file_picker_command = f'find / {find_exclude} -o -type d -print0' - possible_save_dirs = list( - filter(None, SysCommand(file_picker_command).decode().split('\x00')) - ) - - selection = Menu( - _('Select directory (or directories) for saving configuration files'), - possible_save_dirs, - multi=True, - skip=True, - allow_reset=False, - ).run() - - match selection.type_: - case MenuSelectionType.Skip: - return - case _: - save_dirs = selection.value - - prompt = _('Do you want to save {} configuration file(s) in the following locations?\n\n{}').format( - list(options.keys())[list(options.values()).index(choice.value)], - save_dirs - ) - save_confirmation = Menu(prompt, Menu.yes_no(), default_option=Menu.yes()).run() - if save_confirmation == Menu.no(): - return - - log( - _('Saving {} configuration files to {}').format( - list(options.keys())[list(options.values()).index(choice.value)], - save_dirs - ), - level=logging.DEBUG - ) - - if save_dirs is not None: - for save_dir_str in save_dirs: - save_dir = Path(save_dir_str) - if options['user_config'] == choice.value: - config_output.save_user_config(save_dir) - elif options['user_creds'] == choice.value: - config_output.save_user_creds(save_dir) - elif options['disk_layout'] == choice.value: - config_output.save_disk_layout(save_dir) - elif options['all'] == choice.value: - config_output.save_user_config(save_dir) - config_output.save_user_creds(save_dir) - config_output.save_disk_layout(save_dir) diff --git a/archinstall/lib/user_interaction/subvolume_config.py b/archinstall/lib/user_interaction/subvolume_config.py deleted file mode 100644 index 94150dee..00000000 --- a/archinstall/lib/user_interaction/subvolume_config.py +++ /dev/null @@ -1,98 +0,0 @@ -from typing import Dict, List, Optional, Any, TYPE_CHECKING - -from ..menu.list_manager import ListManager -from ..menu.menu import MenuSelectionType -from ..menu.text_input import TextInput -from ..menu import Menu -from ..models.subvolume import Subvolume -from ... import FormattedOutput - -if TYPE_CHECKING: - _: Any - - -class SubvolumeList(ListManager): - def __init__(self, prompt: str, subvolumes: List[Subvolume]): - self._actions = [ - str(_('Add subvolume')), - str(_('Edit subvolume')), - str(_('Delete subvolume')) - ] - super().__init__(prompt, subvolumes, [self._actions[0]], self._actions[1:]) - - def reformat(self, data: List[Subvolume]) -> Dict[str, Optional[Subvolume]]: - table = FormattedOutput.as_table(data) - rows = table.split('\n') - - # these are the header rows of the table and do not map to any User obviously - # we're adding 2 spaces as prefix because the menu selector '> ' will be put before - # the selectable rows so the header has to be aligned - display_data: Dict[str, Optional[Subvolume]] = {f' {rows[0]}': None, f' {rows[1]}': None} - - for row, subvol in zip(rows[2:], data): - row = row.replace('|', '\\|') - display_data[row] = subvol - - return display_data - - def selected_action_display(self, subvolume: Subvolume) -> str: - return subvolume.name - - def _prompt_options(self, editing: Optional[Subvolume] = None) -> List[str]: - preset_options = [] - if editing: - preset_options = editing.options - - choice = Menu( - str(_("Select the desired subvolume options ")), - ['nodatacow','compress'], - skip=True, - preset_values=preset_options, - multi=True - ).run() - - if choice.type_ == MenuSelectionType.Selection: - return choice.value # type: ignore - - return [] - - def _add_subvolume(self, editing: Optional[Subvolume] = None) -> Optional[Subvolume]: - name = TextInput(f'\n\n{_("Subvolume name")}: ', editing.name if editing else '').run() - - if not name: - return None - - mountpoint = TextInput(f'\n{_("Subvolume mountpoint")}: ', editing.mountpoint if editing else '').run() - - if not mountpoint: - return None - - options = self._prompt_options(editing) - - subvolume = Subvolume(name, mountpoint) - subvolume.compress = 'compress' in options - subvolume.nodatacow = 'nodatacow' in options - - return subvolume - - def handle_action(self, action: str, entry: Optional[Subvolume], data: List[Subvolume]) -> List[Subvolume]: - if action == self._actions[0]: # add - new_subvolume = self._add_subvolume() - - if new_subvolume is not None: - # in case a user with the same username as an existing user - # was created we'll replace the existing one - data = [d for d in data if d.name != new_subvolume.name] - data += [new_subvolume] - elif entry is not None: - if action == self._actions[1]: # edit subvolume - new_subvolume = self._add_subvolume(entry) - - if new_subvolume is not None: - # we'll remove the original subvolume and add the modified version - data = [d for d in data if d.name != entry.name and d.name != new_subvolume.name] - data += [new_subvolume] - elif action == self._actions[2]: # delete - data = [d for d in data if d != entry] - - return data diff --git a/archinstall/lib/user_interaction/system_conf.py b/archinstall/lib/user_interaction/system_conf.py deleted file mode 100644 index 68a1a7d2..00000000 --- a/archinstall/lib/user_interaction/system_conf.py +++ /dev/null @@ -1,168 +0,0 @@ -from __future__ import annotations - -from typing import List, Any, Dict, TYPE_CHECKING - -from ..disk import all_blockdevices -from ..exceptions import RequirementError -from ..hardware import AVAILABLE_GFX_DRIVERS, has_uefi, has_amd_graphics, has_intel_graphics, has_nvidia_graphics -from ..menu import Menu -from ..menu.menu import MenuSelectionType -from ..storage import storage - -if TYPE_CHECKING: - _: Any - - -def select_kernel(preset: List[str] = None) -> List[str]: - """ - Asks the user to select a kernel for system. - - :return: The string as a selected kernel - :rtype: string - """ - - kernels = ["linux", "linux-lts", "linux-zen", "linux-pae"] - default_kernel = "linux" - - warning = str(_('Are you sure you want to reset this setting?')) - - choice = Menu( - _('Choose which kernels to use or leave blank for default "{}"').format(default_kernel), - kernels, - sort=True, - multi=True, - preset_values=preset, - allow_reset=True, - allow_reset_warning_msg=warning - ).run() - - match choice.type_: - case MenuSelectionType.Skip: return preset - case MenuSelectionType.Reset: return [] - case MenuSelectionType.Selection: return choice.value - - -def select_harddrives(preset: List[str] = []) -> List[str]: - """ - Asks the user to select one or multiple hard drives - - :return: List of selected hard drives - :rtype: list - """ - hard_drives = all_blockdevices(partitions=False).values() - options = {f'{option}': option for option in hard_drives} - - title = str(_('Select one or more hard drives to use and configure\n')) - title += str(_('Any modifications to the existing setting will reset the disk layout!')) - - warning = str(_('If you reset the harddrive selection this will also reset the current disk layout. Are you sure?')) - - selected_harddrive = Menu( - title, - list(options.keys()), - multi=True, - allow_reset=True, - allow_reset_warning_msg=warning - ).run() - - match selected_harddrive.type_: - case MenuSelectionType.Reset: return [] - case MenuSelectionType.Skip: return preset - case MenuSelectionType.Selection: return [options[i] for i in selected_harddrive.value] - - -def select_driver(options: Dict[str, Any] = AVAILABLE_GFX_DRIVERS) -> str: - """ - Some what convoluted function, whose job is simple. - Select a graphics driver from a pre-defined set of popular options. - - (The template xorg is for beginner users, not advanced, and should - there for appeal to the general public first and edge cases later) - """ - - drivers = sorted(list(options)) - - if drivers: - arguments = storage.get('arguments', {}) - title = '' - - if has_amd_graphics(): - title += str(_( - 'For the best compatibility with your AMD hardware, you may want to use either the all open-source or AMD / ATI options.' - )) + '\n' - if has_intel_graphics(): - title += str(_( - 'For the best compatibility with your Intel hardware, you may want to use either the all open-source or Intel options.\n' - )) - if has_nvidia_graphics(): - title += str(_( - 'For the best compatibility with your Nvidia hardware, you may want to use the Nvidia proprietary driver.\n' - )) - - title += str(_('\n\nSelect a graphics driver or leave blank to install all open-source drivers')) - choice = Menu(title, drivers).run() - - if choice.type_ != MenuSelectionType.Selection: - return arguments.get('gfx_driver') - - arguments['gfx_driver'] = choice.value - return options.get(choice.value) - - raise RequirementError("Selecting drivers require a least one profile to be given as an option.") - - -def ask_for_bootloader(advanced_options: bool = False, preset: str = None) -> str: - if preset == 'systemd-bootctl': - preset_val = 'systemd-boot' if advanced_options else Menu.no() - elif preset == 'grub-install': - preset_val = 'grub' if advanced_options else Menu.yes() - else: - preset_val = preset - - bootloader = "systemd-bootctl" if has_uefi() else "grub-install" - - if has_uefi(): - if not advanced_options: - selection = Menu( - _('Would you like to use GRUB as a bootloader instead of systemd-boot?'), - Menu.yes_no(), - preset_values=preset_val, - default_option=Menu.no() - ).run() - - match selection.type_: - case MenuSelectionType.Skip: return preset - case MenuSelectionType.Selection: bootloader = 'grub-install' if selection.value == Menu.yes() else bootloader - else: - # We use the common names for the bootloader as the selection, and map it back to the expected values. - choices = ['systemd-boot', 'grub', 'efistub'] - selection = Menu(_('Choose a bootloader'), choices, preset_values=preset_val).run() - - value = '' - match selection.type_: - case MenuSelectionType.Skip: value = preset_val - case MenuSelectionType.Selection: value = selection.value - - if value != "": - if value == 'systemd-boot': - bootloader = 'systemd-bootctl' - elif value == 'grub': - bootloader = 'grub-install' - else: - bootloader = value - - return bootloader - - -def ask_for_swap(preset: bool = True) -> bool: - if preset: - preset_val = Menu.yes() - else: - preset_val = Menu.no() - - prompt = _('Would you like to use swap on zram?') - choice = Menu(prompt, Menu.yes_no(), default_option=Menu.yes(), preset_values=preset_val).run() - - match choice.type_: - case MenuSelectionType.Skip: return preset - case MenuSelectionType.Selection: return False if choice.value == Menu.no() else True diff --git a/archinstall/lib/user_interaction/utils.py b/archinstall/lib/user_interaction/utils.py deleted file mode 100644 index 7ee6fc07..00000000 --- a/archinstall/lib/user_interaction/utils.py +++ /dev/null @@ -1,79 +0,0 @@ -from __future__ import annotations - -import getpass -import signal -import sys -import time -from typing import Any, Optional, TYPE_CHECKING - -from ..menu import Menu -from ..models.password_strength import PasswordStrength -from ..output import log - -if TYPE_CHECKING: - _: Any - -# used for signal handler -SIG_TRIGGER = None - - -def get_password(prompt: str = '') -> Optional[str]: - if not prompt: - prompt = _("Enter a password: ") - - while password := getpass.getpass(prompt): - if len(password.strip()) <= 0: - break - - strength = PasswordStrength.strength(password) - log(f'Password strength: {strength.value}', fg=strength.color()) - - passwd_verification = getpass.getpass(prompt=_('And one more time for verification: ')) - if password != passwd_verification: - log(' * Passwords did not match * ', fg='red') - continue - - return password - - return None - - -def do_countdown() -> bool: - SIG_TRIGGER = False - - def kill_handler(sig: int, frame: Any) -> None: - print() - exit(0) - - def sig_handler(sig: int, frame: Any) -> None: - global SIG_TRIGGER - SIG_TRIGGER = True - signal.signal(signal.SIGINT, kill_handler) - - original_sigint_handler = signal.getsignal(signal.SIGINT) - signal.signal(signal.SIGINT, sig_handler) - - for i in range(5, 0, -1): - print(f"{i}", end='') - - for x in range(4): - sys.stdout.flush() - time.sleep(0.25) - print(".", end='') - - if SIG_TRIGGER: - prompt = _('Do you really want to abort?') - choice = Menu(prompt, Menu.yes_no(), skip=False).run() - if choice.value == Menu.yes(): - exit(0) - - if SIG_TRIGGER is False: - sys.stdin.read() - - SIG_TRIGGER = False - signal.signal(signal.SIGINT, sig_handler) - - print() - signal.signal(signal.SIGINT, original_sigint_handler) - - return True diff --git a/archinstall/lib/utils/__init__.py b/archinstall/lib/utils/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/archinstall/lib/utils/__init__.py diff --git a/archinstall/lib/utils/singleton.py b/archinstall/lib/utils/singleton.py new file mode 100644 index 00000000..55be70eb --- /dev/null +++ b/archinstall/lib/utils/singleton.py @@ -0,0 +1,15 @@ +from typing import Dict, Any + + +class _Singleton(type): + """ A metaclass that creates a Singleton base class when called. """ + _instances: Dict[Any, Any] = {} + + def __call__(cls, *args, **kwargs): + if cls not in cls._instances: + cls._instances[cls] = super().__call__(*args, **kwargs) + return cls._instances[cls] + + +class Singleton(_Singleton('SingletonMeta', (object,), {})): # type: ignore + pass diff --git a/archinstall/lib/utils/util.py b/archinstall/lib/utils/util.py new file mode 100644 index 00000000..2e42b3cf --- /dev/null +++ b/archinstall/lib/utils/util.py @@ -0,0 +1,51 @@ +from pathlib import Path +from typing import Any, TYPE_CHECKING, Optional, List + +from ..output import FormattedOutput +from ..output import info + +if TYPE_CHECKING: + _: Any + + +def prompt_dir(text: str, header: Optional[str] = None) -> Path: + if header: + print(header) + + while True: + path = input(text).strip(' ') + dest_path = Path(path) + if dest_path.exists() and dest_path.is_dir(): + return dest_path + info(_('Not a valid directory: {}').format(dest_path)) + + +def is_subpath(first: Path, second: Path): + """ + Check if _first_ a subpath of _second_ + """ + try: + first.relative_to(second) + return True + except ValueError: + return False + + +def format_cols(items: List[str], header: Optional[str] = None) -> str: + if header: + text = f'{header}:\n' + else: + text = '' + + nr_items = len(items) + if nr_items <= 4: + col = 1 + elif nr_items <= 8: + col = 2 + elif nr_items <= 12: + col = 3 + else: + col = 4 + + text += FormattedOutput.as_columns(items, col) + return text |