index : archinstall32 | |
Archlinux32 installer | gitolite user |
summaryrefslogtreecommitdiff |
author | Daniel Girtler <blackrabbit256@gmail.com> | 2023-04-19 20:55:42 +1000 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-04-19 12:55:42 +0200 |
commit | 00b0ae7ba439a5a420095175b3bedd52c569db51 (patch) | |
tree | f02d081e361d5e65603f74dea3873dcc6606cf7c /archinstall/lib | |
parent | 5253e57e9f26cf3e59cb2460544af13f56e485bb (diff) |
diff --git a/archinstall/lib/configuration.py b/archinstall/lib/configuration.py index c036783f..77fed755 100644 --- a/archinstall/lib/configuration.py +++ b/archinstall/lib/configuration.py @@ -2,27 +2,15 @@ import os import json import stat import logging -import pathlib -from typing import Optional, Dict +from pathlib import Path +from typing import Optional, Dict, Any, TYPE_CHECKING -from .hsm.fido import Fido2 -from .models.disk_encryption import DiskEncryption 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'." - ) + +if TYPE_CHECKING: + _: Any class ConfigurationOutput: @@ -35,13 +23,11 @@ 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 = Path(storage.get('LOG_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._ignore = ['abort', 'install', 'config', 'creds', 'dry_run'] @@ -56,23 +42,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: if key in self._sensitive: self._user_credentials[key] = self._config[key] - elif key == 'disk_layouts': - self._disk_layout = self._config[key] elif key in self._ignore: pass else: self._user_config[key] = self._config[key] - 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 self._config[key] is not None: + self._user_credentials['encryption_password'] = self._config[key].encryption_password def user_config_to_json(self) -> str: return json.dumps({ @@ -81,11 +62,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) @@ -96,15 +72,11 @@ class ConfigurationOutput: 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) - print() - def _is_valid_path(self, dest_path :pathlib.Path) -> bool: + def _is_valid_path(self, dest_path: 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()), @@ -113,7 +85,7 @@ class ConfigurationOutput: return False return True - 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 @@ -122,7 +94,7 @@ class ConfigurationOutput: 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 @@ -132,21 +104,10 @@ class ConfigurationOutput: os.chmod(str(target), stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP) - 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) - - os.chmod(str(target), stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP) - - def save(self, dest_path :pathlib.Path = None): + def save(self, dest_path: Optional[Path] = None): if not dest_path: dest_path = self._default_save_path 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) diff --git a/archinstall/lib/disk/__init__.py b/archinstall/lib/disk/__init__.py index 352d04b9..cdc96373 100644 --- a/archinstall/lib/disk/__init__.py +++ b/archinstall/lib/disk/__init__.py @@ -1,7 +1,40 @@ -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, + PartitionTable, + Unit, + Size, + 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, +) 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..12cf18ea --- /dev/null +++ b/archinstall/lib/disk/device_handler.py @@ -0,0 +1,599 @@ +from __future__ import annotations + +import json +import logging +import os +import time +from pathlib import Path +from typing import List, Dict, Any, Optional, TYPE_CHECKING + +from parted import ( # type: ignore + Disk, Geometry, FileSystem, + PartitionException, DiskLabelException, + getAllDevices, freshDisk, Partition, +) + +from .device_model import ( + DeviceModification, PartitionModification, + BDevice, _DeviceInfo, _PartitionInfo, + FilesystemType, Unit, PartitionTable, + ModificationStatus, get_lsblk_info, LsblkInfo, + _BtrfsSubvolumeInfo, get_all_lsblk_info, DiskEncryption +) + +from ..exceptions import DiskError, UnknownFilesystemFormat +from ..general import SysCommand, SysCallError, JSON +from ..luks import Luks2 +from ..output import 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 = {} + + for device in getAllDevices(): + try: + disk = Disk(device) + except DiskLabelException as error: + if 'unrecognised disk label' in getattr(error, 'message', str(error)): + disk = freshDisk(device, PartitionTable.GPT.value) + else: + log(f'Unable to get disk from device: {device}', level=logging.DEBUG) + 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.partuuid, + 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: + log(f'Could not determine the filesystem: {partition.fileSystem}', level=logging.DEBUG) + + 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: + return partition.disk.device + 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_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}') + except SysCallError as err: + log(f'Failed to read btrfs subvolume information: {err}', level=logging.DEBUG) + return subvol_infos + + if result.exit_code == 0: + try: + if decoded := result.decode('utf-8'): + # ID 256 gen 16 top level 5 path @ + for line in decoded.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: + log(f"Could not decode lsblk JSON: {result}", fg="red", level=logging.ERROR) + raise err + + if not lsblk_info.mountpoint: + self.umount(dev_path) + + return subvol_infos + + def _perform_formatting( + 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) + + log(f'Formatting filesystem: /usr/bin/{command} {options_str} {path}') + + try: + if (handle := SysCommand(f"/usr/bin/{command} {options_str} {path}")).exit_code != 0: + mkfs_error = handle.decode() + raise DiskError(f'Could not format {path} with {fs_type.value}: {mkfs_error}') + except SysCallError as error: + msg = f'Could not format {path} with {fs_type.value}: {error.message}' + log(msg, fg='red') + raise DiskError(msg) from error + + def _perform_enc_formatting( + 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() + + log(f'Unlocking luks2 device: {dev_path}', level=logging.DEBUG) + luks_handler.unlock(key_file=key_file) + + if not luks_handler.mapper_dev: + raise DiskError('Failed to unlock luks device') + + log(f'luks2 formatting mapper dev: {luks_handler.mapper_dev}', level=logging.INFO) + self._perform_formatting(fs_type, luks_handler.mapper_dev) + + log(f'luks2 locking device: {dev_path}', level=logging.INFO) + luks_handler.lock() + + def format( + self, + modification: DeviceModification, + enc_conf: Optional['DiskEncryption'] = None + ): + """ + 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. + """ + + # verify that all partitions have a path set (which implies that they have been created) + missing_path = next(filter(lambda x: x.dev_path is None, modification.partitions), None) + if missing_path is not None: + raise ValueError('When formatting, all partitions must have a path set') + + # crypto luks is not known to parted and can therefore not + # be used as a filesystem type in that sense; + invalid_fs_type = next(filter(lambda x: x.fs_type is FilesystemType.Crypto_luks, modification.partitions), None) + if invalid_fs_type is not None: + raise ValueError('Crypto luks cannot be set as a filesystem type') + + # make sure all devices are unmounted + self._umount_all_existing(modification) + + for part_mod in modification.partitions: + # partition will be encrypted + if enc_conf is not None and part_mod in enc_conf.partitions: + self._perform_enc_formatting( + part_mod.real_dev_path, + part_mod.mapper_name, + part_mod.fs_type, + enc_conf + ) + else: + self._perform_formatting(part_mod.fs_type, part_mod.real_dev_path) + + def _perform_partitioning( + 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]: + log(f'Delete existing partition: {part_mod.real_dev_path}', level=logging.INFO) + part_info = self.find_partition(part_mod.real_dev_path) + + if not part_info: + raise DiskError(f'No partition for dev path found: {part_mod.real_dev_path}') + + disk.deletePartition(part_info.partition) + disk.commit() + + 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.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) + + log(f'\tType: {part_mod.type.value}', level=logging.DEBUG) + log(f'\tFilesystem: {part_mod.fs_type.value}', level=logging.DEBUG) + log(f'\tGeometry: {start_sector.value} start sector, {length_sector.value} length', level=logging.DEBUG) + + try: + disk.addPartition(partition=partition, constraint=disk.device.optimalAlignedConstraint) + disk.commit() + + # the creation will take a bit of time + time.sleep(3) + + # the partition has a real path now as it was created + part_mod.dev_path = Path(partition.path) + + info = get_lsblk_info(part_mod.dev_path) + + if not info.partuuid: + raise DiskError(f'Unable to determine new partition uuid: {part_mod.dev_path}') + + part_mod.partuuid = info.partuuid + part_mod.uuid = info.uuid + except PartitionException as ex: + raise DiskError(f'Unable to add partition, most likely due to overlapping sectors: {ex}') from ex + + def create_btrfs_volumes( + self, + part_mod: PartitionModification, + enc_conf: Optional['DiskEncryption'] = None + ): + log(f'Creating subvolumes: {part_mod.real_dev_path}', level=logging.INFO) + + 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.real_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) + else: + self.mount(part_mod.real_dev_path, self._TMP_BTRFS_MOUNT, create_target_mountpoint=True) + + for sub_vol in part_mod.btrfs_subvols: + log(f'Creating subvolume: {sub_vol.name}', level=logging.DEBUG) + + 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 sub_vol.nodatacow: + if (result := SysCommand(f'chattr +C {subvol_path}')).exit_code != 0: + raise DiskError(f'Could not set nodatacow attribute at {subvol_path}: {result.decode()}') + + if sub_vol.compress: + if (result := SysCommand(f'chattr +c {subvol_path}')).exit_code != 0: + raise DiskError(f'Could not set compress attribute at {subvol_path}: {result}') + + 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.real_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, modification: DeviceModification): + log(f'Unmounting all partitions: {modification.device_path}', level=logging.INFO) + + existing_partitions = self._devices[modification.device_path].partition_infos + + for partition in existing_partitions: + log(f'Unmounting: {partition.path}', level=logging.DEBUG) + + # 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) + + # 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: + log(f'Use existing device: {modification.device_path}') + disk = modification.device.disk + + log(f'Creating partitions: {modification.device_path}') + + # TODO sort by delete first + + for part_mod in modification.partitions: + # don't touch existing partitions + if part_mod.exists(): + continue + + # 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._perform_partitioning(part_mod, modification.device, disk, requires_delete=requires_delete) + + self.partprobe(modification.device.device_info.path) + + 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: + log(f'Device already mounted at {target_mountpoint}') + return + + str_options = ','.join(options) + str_options = f'-o {str_options}' if str_options else '' + + mount_fs = f'-t {mount_fs}' if mount_fs else '' + + command = f'mount {mount_fs} {str_options} {dev_path} {target_mountpoint}' + + log(f'Mounting {dev_path}: command', level=logging.DEBUG) + + try: + result = SysCommand(command) + if result.exit_code != 0: + raise DiskError(f'Could not mount {dev_path}: {command}\n{result.decode()}') + 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: + log(f'Partition {mountpoint} is currently mounted at: {[str(m) for m in lsblk_info.mountpoints]}', level=logging.DEBUG) + + for mountpoint in lsblk_info.mountpoints: + log(f'Unmounting mountpoint: {mountpoint}', level=logging.DEBUG) + + 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_mods[path].append(PartitionModification.from_existing_partition(part_info)) + 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: + result = SysCommand(command) + if result.exit_code != 0: + log(f'Error calling partprobe: {result.decode()}', level=logging.DEBUG) + raise DiskError(f'Could not perform partprobe on {path}: {result.decode()}') + except SysCallError as error: + log(f"partprobe experienced an error with {path}: {error}", level=logging.DEBUG) + + 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. + """ + log(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: + log(f"Could not return disk layouts: {err}", level=logging.WARNING, fg="yellow") + return '' + except json.decoder.JSONDecodeError as err: + log(f"Could not return disk layouts: {err}", level=logging.WARNING, fg="yellow") + return '' diff --git a/archinstall/lib/disk/device_model.py b/archinstall/lib/disk/device_model.py new file mode 100644 index 00000000..0270a4dd --- /dev/null +++ b/archinstall/lib/disk/device_model.py @@ -0,0 +1,1033 @@ +from __future__ import annotations + +import dataclasses +import json +import logging +import math +import time +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 +from parted import Disk, Geometry, Partition + +from ..exceptions import DiskError, SysCallError +from ..general import SysCommand +from ..output import log +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) + # used for pre-mounted config + relative_mountpoint: Optional[Path] = None + + def __post_init__(self): + if self.config_type == DiskLayoutType.Pre_mount and self.relative_mountpoint is None: + raise ValueError('Must set a relative mountpoint when layout type is pre-mount"') + + def __dump__(self) -> Dict[str, Any]: + return { + 'config_type': self.config_type.value, + 'device_modifications': [mod.__dump__() for mod in self.device_modifications] + } + + @classmethod + def parse_arg(cls, disk_config: Dict[str, List[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 + ) + + 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']), + start=Size.parse_args(partition['start']), + length=Size.parse_args(partition['length']), + mount_options=partition['mount_options'], + mountpoint=Path(partition['mountpoint']) if partition['mountpoint'] 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) + + 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 + + Percent = '%' # size in percentile + + +@dataclass +class Size: + value: int + unit: Unit + sector_size: Optional[Size] = None # only required when unit is sector + total_size: Optional[Size] = None # required when operating on percentages + + def __post_init__(self): + if self.unit == Unit.sectors and self.sector_size is None: + raise ValueError('Sector size is required when unit is sectors') + elif self.unit == Unit.Percent: + if self.value < 0 or self.value > 100: + raise ValueError('Percentage must be between 0 and 100') + elif self.total_size is None: + raise ValueError('Total size is required when unit is percentage') + + @property + def _total_size(self) -> Size: + """ + Save method to get the total size, mainly to satisfy mypy + This shouldn't happen as the Size object fails instantiation on missing total size + """ + if self.unit == Unit.Percent and self.total_size is None: + raise ValueError('Percent unit size must specify a total size') + return self.total_size # type: ignore + + def __dump__(self) -> Dict[str, Any]: + return { + 'value': self.value, + 'unit': self.unit.name, + 'sector_size': self.sector_size.__dump__() if self.sector_size else None, + 'total_size': self._total_size.__dump__() if self._total_size else None + } + + @classmethod + def parse_args(cls, size_arg: Dict[str, Any]) -> Size: + sector_size = size_arg['sector_size'] + total_size = size_arg['total_size'] + + return Size( + size_arg['value'], + Unit[size_arg['unit']], + Size.parse_args(sector_size) if sector_size else None, + Size.parse_args(total_size) if total_size else None + ) + + def convert( + self, + target_unit: Unit, + sector_size: Optional[Size] = None, + total_size: Optional[Size] = 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') + + # not sure why we would ever wanna convert to percentages + if target_unit == Unit.Percent and total_size is None: + raise ValueError('Missing paramter total size to be able to convert to percentage') + + if self.unit == target_unit: + return self + elif self.unit == Unit.Percent: + amount = int(self._total_size._normalize() * (self.value / 100)) + return Size(amount, Unit.B) + elif self.unit == Unit.sectors: + norm = self._normalize() + return Size(norm, Unit.B).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) + + def format_size( + self, + target_unit: Unit, + sector_size: Optional[Size] = None + ) -> str: + if self.unit == Unit.Percent: + return f'{self.value}%' + else: + target_size = self.convert(target_unit, sector_size) + return f'{target_size.value} {target_unit.name}' + + def _normalize(self) -> int: + """ + will normalize the value of the unit to Byte + """ + if self.unit == Unit.Percent: + return self.convert(Unit.B).value + elif 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) + + 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() + + +@dataclass +class _BtrfsSubvolumeInfo: + name: Path + mountpoint: Optional[Path] + + +@dataclass +class _PartitionInfo: + partition: Partition + name: str + type: PartitionType + fs_type: FilesystemType + path: Path + start: Size + length: Size + flags: List[PartitionFlag] + partuuid: str + disk: Disk + mountpoints: List[Path] + btrfs_subvol_infos: List[_BtrfsSubvolumeInfo] = field(default_factory=list) + + def as_json(self) -> Dict[str, Any]: + 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.MiB), + 'Length': self.length.format_size(Unit.MiB), + 'Flags': ', '.join([f.name for f in self.flags]) + } + + if self.btrfs_subvol_infos: + info['Btrfs vol.'] = f'{len(self.btrfs_subvol_infos)} subvolumes' + + return info + + @classmethod + def from_partition( + cls, + partition: Partition, + fs_type: FilesystemType, + partuuid: 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, + Size(partition.disk.device.sectorSize, Unit.B) + ) + + length = Size(int(partition.getLength(unit='B')), 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, + partuuid=partuuid, + 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: Size + read_only: bool + dirty: bool + + def as_json(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_size(Unit.MiB), + '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 + device_type = parted.devices[device.type] + + sector_size = Size(device.sectorSize, Unit.B) + free_space = [DeviceGeometry(g, sector_size) for g in disk.getFreeSpaceRegions()] + + 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), + free_space_regions=free_space, + read_only=device.readOnly, + dirty=device.dirty + ) + + +@dataclass +class SubvolumeModification: + name: Path + mountpoint: Optional[Path] = None + compress: bool = False + nodatacow: bool = False + + @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): + log(f'Subvolume arg is missing name: {entry}', level=logging.DEBUG) + continue + + mountpoint = Path(entry['mountpoint']) if entry['mountpoint'] else None + + mods.append( + SubvolumeModification( + entry['name'], + mountpoint, + entry.get('compress', False), + entry.get('nodatacow', False) + ) + ) + + return mods + + @property + def mount_options(self) -> List[str]: + options = [] + options += ['compress'] if self.compress else [] + options += ['nodatacow'] if self.nodatacow else [] + return options + + @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, relative_mountpoint: Optional[Path] = None) -> bool: + if self.mountpoint: + if relative_mountpoint is not None: + return self.mountpoint.relative_to(relative_mountpoint) == Path('.') + return self.mountpoint == Path('/') + return False + + def __dump__(self) -> Dict[str, Any]: + return { + 'name': str(self.name), + 'mountpoint': str(self.mountpoint), + 'compress': self.compress, + 'nodatacow': self.nodatacow + } + + def as_json(self) -> Dict[str, Any]: + return { + 'name': str(self.name), + 'mountpoint': str(self.mountpoint), + 'compress': self.compress, + 'nodatacow': self.nodatacow + } + + +class DeviceGeometry: + def __init__(self, geometry: Geometry, sector_size: Size): + 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 as_json(self) -> Dict[str, Any]: + return { + 'Sector size': self._sector_size.value, + 'Start sector': self._geometry.start, + 'End sector': self._geometry.end, + 'Length': self._geometry.getLength() + } + + +@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' + + @classmethod + def get_type_from_code(cls, code: int) -> PartitionType: + if code == parted.PARTITION_NORMAL: + return PartitionType.Primary + + raise DiskError(f'Partition code not supported: {code}') + + 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): + Boot = 1 + + +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: FilesystemType + 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 + partuuid: Optional[str] = None + uuid: Optional[str] = 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() + + if self.is_exists_or_modify() and not self.dev_path: + raise ValueError('If partition marked as existing a path must be set') + + 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 real_dev_path(self) -> Path: + if self.dev_path is None: + raise ValueError('Device path was not set') + return self.dev_path + + @classmethod + def from_existing_partition(cls, partition_info: _PartitionInfo) -> PartitionModification: + if partition_info.btrfs_subvol_infos: + mountpoint = None + subvol_mods = [] + for info in partition_info.btrfs_subvol_infos: + subvol_mods.append( + SubvolumeModification.from_existing_subvol_info(info) + ) + 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, + 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_boot(self) -> bool: + return PartitionFlag.Boot in self.flags + + def is_root(self, relative_mountpoint: Optional[Path] = None) -> bool: + if relative_mountpoint is not None and self.mountpoint is not None: + return self.mountpoint.relative_to(relative_mountpoint) == Path('.') + elif self.mountpoint is not None: + return Path('/') == self.mountpoint + else: + for subvol in self.btrfs_subvols: + if subvol.is_root(relative_mountpoint): + 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] + + @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.__dump__(), + 'length': self.length.__dump__(), + 'fs_type': self.fs_type.value, + 'mountpoint': str(self.mountpoint) if self.mountpoint else None, + 'mount_options': self.mount_options, + 'flags': [f.name for f in self.flags], + 'btrfs': [vol.__dump__() for vol in self.btrfs_subvols] + } + + def as_json(self) -> Dict[str, Any]: + """ + Called for displaying data in table format + """ + info = { + 'Status': self.status.value, + 'Device': str(self.dev_path) if self.dev_path else '', + 'Type': self.type.value, + 'Start': self.start.format_size(Unit.MiB), + 'Length': self.length.format_size(Unit.MiB), + 'FS type': self.fs_type.value, + '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: + info['Btrfs vol.'] = f'{len(self.btrfs_subvols)} subvolumes' + + return info + + +@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_boot_partition(self) -> Optional[PartitionModification]: + liltered = filter(lambda x: x.is_boot(), self.partitions) + return next(liltered, None) + + def get_root_partition(self, relative_path: Optional[Path]) -> Optional[PartitionModification]: + filtered = filter(lambda x: x.is_root(relative_path), self.partitions) + return next(filtered, None) + + def __dump__(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" + 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: List[PartitionModification] = field(default_factory=list) + hsm_device: Optional[Fido2Device] = None + + def should_generate_encryption_file(self, part_mod: PartitionModification) -> bool: + return part_mod in self.partitions and part_mod.mountpoint != Path('/') + + 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] + } + + if self.hsm_device: + obj['hsm_device'] = self.hsm_device.json() + + return obj + + @classmethod + def parse_arg( + cls, + disk_config: DiskLayoutConfiguration, + arg: Dict[str, Any], + password: str = '' + ) -> 'DiskEncryption': + 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) + + 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 + + +@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'] + ) + + +@dataclass +class LsblkInfo: + name: str = '' + path: Path = Path() + pkname: str = '' + size: Size = Size(0, Unit.B) + log_sec: int = 0 + pttype: str = '' + ptuuid: str = '' + rota: bool = False + tran: Optional[str] = None + partuuid: 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, + 'partuuid': self.partuuid, + '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: + 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(info, data_field), Path): + val = Path(blockdevice[lsblk_field]) + elif isinstance(getattr(info, data_field), Size): + val = Size(blockdevice[lsblk_field], Unit.B) + else: + val = blockdevice[lsblk_field] + + setattr(info, data_field, val) + + info.children = [LsblkInfo.from_json(child) for child in blockdevice.get('children', [])] + + # sometimes lsblk returns 'mountpoints': [null] + info.mountpoints = [Path(mnt) for mnt in info.mountpoints if mnt] + + fs_roots = [] + for r in info.fsroots: + if r: + path = Path(r) + # store the fsroot entries without the leading / + fs_roots.append(path.relative_to(path.anchor)) + info.fsroots = fs_roots + + return 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, retry: int = 3) -> List[LsblkInfo]: + fields = [_clean_field(f, CleanType.Lsblk) for f in LsblkInfo.fields()] + lsblk_fields = ','.join(fields) + + if not dev_path: + dev_path = '' + + if retry == 0: + retry = 1 + + result = None + + for i in range(retry): + try: + result = SysCommand(f'lsblk --json -b -o+{lsblk_fields} {dev_path}') + except SysCallError as error: + # Get the output minus the message/info from lsblk if it returns a non-zero exit code. + if error.worker: + err = error.worker.decode('UTF-8') + log(f'Error calling lsblk: {err}', level=logging.DEBUG) + time.sleep(1) + else: + raise error + + if result and result.exit_code == 0: + try: + if decoded := result.decode('utf-8'): + block_devices = json.loads(decoded) + blockdevices = block_devices['blockdevices'] + return [LsblkInfo.from_json(device) for device in blockdevices] + except json.decoder.JSONDecodeError as err: + log(f"Could not decode lsblk JSON: {result}", fg="red", level=logging.ERROR) + raise err + + raise DiskError(f'Failed to read disk "{dev_path}" with lsblk') + + +def get_lsblk_info(dev_path: Union[Path, str]) -> LsblkInfo: + if infos := _fetch_lsblk_info(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/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_menu.py index c7496bfa..285270fb 100644 --- a/archinstall/lib/disk/encryption.py +++ b/archinstall/lib/disk/encryption_menu.py @@ -1,30 +1,44 @@ +from pathlib import Path 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 ..disk import ( + DeviceModification, + PartitionModification, + DiskEncryption, + EncryptionType +) +from ..menu import ( + Selector, + AbstractSubMenu, + MenuSelectionType, + TableMenu +) from ..user_interaction.utils import get_password from ..menu import Menu from ..general import secret -from ..hsm.fido import Fido2Device, Fido2 +from .fido import Fido2Device, Fido2 +from ..output import FormattedOutput if TYPE_CHECKING: _: Any class DiskEncryptionMenu(AbstractSubMenu): - def __init__(self, data_store: Dict[str, Any], preset: Optional[DiskEncryption], disk_layouts: Dict[str, Any]): + def __init__( + self, + mods: List[DeviceModification], + data_store: Dict[str, Any], + preset: Optional[DiskEncryption] = None + ): if preset: self._preset = preset else: self._preset = DiskEncryption() - self._disk_layouts = disk_layouts + self._modifications = mods super().__init__(data_store=data_store) - def _setup_selection_menu_options(self): + def setup_selection_menu_options(self): self._menu_options['encryption_password'] = \ Selector( _('Encryption password'), @@ -45,8 +59,8 @@ class DiskEncryptionMenu(AbstractSubMenu): 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, + func=lambda preset: select_partitions_to_encrypt(self._modifications.device_modifications, preset), + display_func=lambda x: f'{len(x)} {_("Partitions")}' if x else None, dependencies=['encryption_password'], default=self._preset.partitions, preview_func=self._prev_disk_layouts, @@ -84,24 +98,18 @@ class DiskEncryptionMenu(AbstractSubMenu): 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 - + partitions: Optional[List[PartitionModification]] = self._menu_options['partitions'].current_selection + if partitions: output = str(_('Partitions to be encrypted')) + '\n' - output += current_partition_layout(all_partitions, with_title=False) + output += FormattedOutput.as_table(partitions) 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) ] @@ -137,38 +145,35 @@ def select_hsm(preset: Optional[Fido2Device] = None) -> Optional[Fido2Device]: 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 +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)) - if all_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 = current_partition_layout(all_partitions, with_title=False).strip() + partition_table = FormattedOutput.as_table(avail_partitions) choice = TableMenu( title, - table_data=(all_partitions, partition_table), + table_data=(avail_partitions, partition_table), + preset=preset, multi=True ).run() match choice.type_: case MenuSelectionType.Reset: - return {} + 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 {} + return choice.multi_value + return [] diff --git a/archinstall/lib/hsm/fido.py b/archinstall/lib/disk/fido.py index 1c226322..436be4d4 100644 --- a/archinstall/lib/hsm/fido.py +++ b/archinstall/lib/disk/fido.py @@ -2,36 +2,11 @@ from __future__ import annotations import getpass import logging +from typing import List -from dataclasses import dataclass -from pathlib import Path -from typing import List, Dict - +from .device_model import PartitionModification, 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 log class Fido2: @@ -92,18 +67,28 @@ 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, + part_mod: PartitionModification, + password: str + ): + worker = SysCommandWorker(f"systemd-cryptenroll --fido2-device={hsm_device.path} {part_mod.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 {part_mod.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 + + 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" + ) diff --git a/archinstall/lib/disk/filesystem.py b/archinstall/lib/disk/filesystem.py index 1083df53..6ea99340 100644 --- a/archinstall/lib/disk/filesystem.py +++ b/archinstall/lib/disk/filesystem.py @@ -1,301 +1,98 @@ from __future__ import annotations -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 -if TYPE_CHECKING: - from .blockdevice import BlockDevice - _: Any +import logging +import signal +import sys +import time +from typing import Any, Optional, TYPE_CHECKING -from .partition import Partition -from .validators import valid_fs_type -from ..exceptions import DiskError, SysCallError -from ..general import SysCommand +from .device_model import DiskLayoutConfiguration, DiskLayoutType, PartitionTable, FilesystemType, DiskEncryption +from .device_handler import device_handler +from ..hardware import has_uefi from ..output import log -from ..storage import storage - -GPT = 0b00000001 -MBR = 0b00000010 - -# 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' - -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 __enter__(self, *args :str, **kwargs :str) -> 'Filesystem': - return self - - def __repr__(self) -> str: - return f"Filesystem(blockdevice={self.blockdevice}, mode={self.mode})" +from ..menu import Menu - 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] - - SysCommand('sync') - return True - - 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')) +if TYPE_CHECKING: + _: Any - 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}") +class FilesystemHandler: + def __init__( + self, + disk_config: DiskLayoutConfiguration, + enc_conf: Optional[DiskEncryption] = None + ): + self._disk_config = disk_config + self._enc_config = enc_conf - def load_layout(self, layout :Dict[str, Any]) -> None: - from ..luks import luks2 - from .btrfs import BTRFSPartition + def perform_filesystem_operations(self, show_countdown: bool = True): + if self._disk_config.config_type == DiskLayoutType.Pre_mount: + log('Disk layout configuration is set to pre-mount, not performing any operations', level=logging.DEBUG) + return - # 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}") + device_mods = list(filter(lambda x: len(x.partitions) > 0, self._disk_config.device_modifications)) - self.blockdevice.flush_cache() - time.sleep(3) + if not device_mods: + log('No modifications required', level=logging.DEBUG) + return - 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) + device_paths = ', '.join([str(mod.device.device_info.path) for mod in device_mods]) - 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) + # 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)) - log(_("Re-using partition instance: {}").format(partition['device_instance']), level=logging.DEBUG, fg="gray") - 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 show_countdown: + self._do_countdown() - 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') + # Setup the blockdevice, filesystem (and optionally encryption). + # Once that's done, we'll hand over to perform_installation() + partition_table = PartitionTable.GPT + if has_uefi() is False: + partition_table = PartitionTable.MBR - 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!") + for mod in device_mods: + device_handler.partition(mod, partition_table=partition_table) + device_handler.format(mod, enc_conf=self._enc_config) - 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}" + 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) - 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'] = {} + def _do_countdown(self) -> bool: + SIG_TRIGGER = False - 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 + def kill_handler(sig: int, frame: Any) -> None: + print() + exit(0) - unlocked_device.format(partition['filesystem']['format'], options=format_options) + def sig_handler(sig: int, frame: Any) -> None: + signal.signal(signal.SIGINT, kill_handler) - 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!") + original_sigint_handler = signal.getsignal(signal.SIGINT) + signal.signal(signal.SIGINT, sig_handler) - partition['device_instance'].format(partition['filesystem']['format'], options=format_options) + for i in range(5, 0, -1): + print(f"{i}", end='') - 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 - ) + for x in range(4): + sys.stdout.flush() + time.sleep(0.25) + print(".", end='') - 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') + 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) - prev_partition = partition + if SIG_TRIGGER is False: + sys.stdin.read() - def find_partition(self, mountpoint :str) -> Partition: - for partition in self.blockdevice: - if partition.target_mountpoint == mountpoint or partition.mountpoint == mountpoint: - return partition + SIG_TRIGGER = False + signal.signal(signal.SIGINT, sig_handler) - 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}") + print() + signal.signal(signal.SIGINT, original_sigint_handler) return True - - 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 - - def parted(self, string: str) -> bool: - """ - Performs a parted execution of the given string - - :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}") - - 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.") - - def add_partition( - 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") - - total_partitions = set([partition.part_uuid for partition in self.blockdevice.partitions.values()]) - total_partitions.update(previous_partuuids) - - # 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") - - raise DiskError(f"Could not add partition using: {parted_string}") - - def set_name(self, partition: int, name: str) -> bool: - return self.parted(f'{self.blockdevice.device} name {partition + 1} "{name}"') == 0 - - 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 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 - - self.partprobe() - worked = self.raw_parted(f'{device} mklabel {disk_label}').exit_code == 0 - self.partprobe() - - return worked 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..686e8c29 --- /dev/null +++ b/archinstall/lib/disk/partitioning_menu.py @@ -0,0 +1,335 @@ +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any, Dict, TYPE_CHECKING, List, Optional, Tuple + +from .device_model import PartitionModification, FilesystemType, BDevice, Size, Unit, PartitionType, PartitionFlag, \ + ModificationStatus +from ..menu import Menu, ListManager, MenuSelection, TextInput +from ..output import FormattedOutput, log +from .subvolume_menu import SubvolumeMenu + +if TYPE_CHECKING: + _: Any + + +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_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 reformat(self, data: List[PartitionModification]) -> Dict[str, Optional[PartitionModification]]: + 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[PartitionModification]] = {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, 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 these 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['assign_mountpoint'], + self._actions['mark_bootable'], + self._actions['btrfs_mark_compressed'], + 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_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) + case 'mark_formatting' if entry: + self._prompt_formatting(entry) + case 'mark_bootable' if entry: + entry.invert_flag(PartitionFlag.Boot) + 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._set_compressed(entry) + 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 _set_compressed(self, partition: PartitionModification): + compression = 'compress=zstd' + + if compression in partition.mount_options: + partition.mount_options = [o for o in partition.mount_options if o != compression] + else: + partition.mount_options.append(compression) + + 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_sector(self, start_sector: str, end_sector: Optional[str] = None) -> bool: + if not start_sector.isdigit(): + return False + + if end_sector: + if end_sector.endswith('%'): + if not end_sector[:-1].isdigit(): + return False + elif not end_sector.isdigit(): + return False + elif int(start_sector) > int(end_sector): + return False + + return True + + def _prompt_sectors(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) + prompt += str(_('Total sectors: {}')).format(total_sectors) + '\n' + print(prompt) + + largest_free_area = max(device_info.free_space_regions, key=lambda r: r.get_length()) + + # prompt until a valid start sector was entered + while True: + start_prompt = str(_('Enter the start sector (default: {}): ')).format(largest_free_area.start) + start_sector = TextInput(start_prompt).run().strip() + + if not start_sector or self._validate_sector(start_sector): + break + + log(f'Invalid start sector entered: {start_sector}', fg='red', level=logging.INFO) + + if not start_sector: + start_sector = str(largest_free_area.start) + end_sector = str(largest_free_area.end) + else: + end_sector = '100%' + + # prompt until valid end sector was entered + while True: + end_prompt = str(_('Enter the end sector of the partition (percentage or block number, default: {}): ')).format(end_sector) + end_value = TextInput(end_prompt).run().strip() + + if not end_value or self._validate_sector(start_sector, end_value): + break + + log(f'Invalid end sector entered: {start_sector}', fg='red', level=logging.INFO) + + # override the default value with the user value + if end_value: + end_sector = end_value + + start_size = Size(int(start_sector), Unit.sectors, device_info.sector_size) + + if end_sector.endswith('%'): + end_size = Size(int(end_sector[:-1]), Unit.Percent, device_info.sector_size, device_info.total_size) + else: + end_size = Size(int(end_sector), Unit.sectors, device_info.sector_size) + + return start_size, end_size + + def _create_new_partition(self) -> PartitionModification: + fs_type = self._prompt_partition_fs_type() + + start_size, end_size = self._prompt_sectors() + 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) + + 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 ..user_interaction.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 = menu_list.run() + + if menu_list.is_last_choice_cancel(): + return preset + + return partitions diff --git a/archinstall/lib/user_interaction/subvolume_config.py b/archinstall/lib/disk/subvolume_menu.py index 94150dee..32a0e616 100644 --- a/archinstall/lib/user_interaction/subvolume_config.py +++ b/archinstall/lib/disk/subvolume_menu.py @@ -1,33 +1,31 @@ +from pathlib import Path 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 +from .device_model import SubvolumeModification +from ..menu import Menu, TextInput, MenuSelectionType, ListManager +from ..output import FormattedOutput if TYPE_CHECKING: _: Any -class SubvolumeList(ListManager): - def __init__(self, prompt: str, subvolumes: List[Subvolume]): +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, subvolumes, [self._actions[0]], self._actions[1:]) + super().__init__(prompt, btrfs_subvols, [self._actions[0]], self._actions[1:]) - def reformat(self, data: List[Subvolume]) -> Dict[str, Optional[Subvolume]]: + def reformat(self, data: List[SubvolumeModification]) -> Dict[str, Optional[SubvolumeModification]]: 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} + display_data: Dict[str, Optional[SubvolumeModification]] = {f' {rows[0]}': None, f' {rows[1]}': None} for row, subvol in zip(rows[2:], data): row = row.replace('|', '\\|') @@ -35,17 +33,17 @@ class SubvolumeList(ListManager): return display_data - def selected_action_display(self, subvolume: Subvolume) -> str: - return subvolume.name + def selected_action_display(self, subvolume: SubvolumeModification) -> str: + return str(subvolume.name) - def _prompt_options(self, editing: Optional[Subvolume] = None) -> List[str]: + def _prompt_options(self, editing: Optional[SubvolumeModification] = None) -> List[str]: preset_options = [] if editing: - preset_options = editing.options + preset_options = editing.mount_options choice = Menu( str(_("Select the desired subvolume options ")), - ['nodatacow','compress'], + ['nodatacow', 'compress'], skip=True, preset_values=preset_options, multi=True @@ -56,26 +54,31 @@ class SubvolumeList(ListManager): return [] - def _add_subvolume(self, editing: Optional[Subvolume] = None) -> Optional[Subvolume]: + 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'\n{_("Subvolume mountpoint")}: ', editing.mountpoint if editing else '').run() + mountpoint = TextInput(f'{_("Subvolume mountpoint")}: ', str(editing.mountpoint) if editing else '').run() if not mountpoint: return None options = self._prompt_options(editing) - subvolume = Subvolume(name, mountpoint) + subvolume = SubvolumeModification(Path(name), Path(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]: + 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() 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/general.py b/archinstall/lib/general.py index 79ab024b..57f13288 100644 --- a/archinstall/lib/general.py +++ b/archinstall/lib/general.py @@ -1,4 +1,5 @@ from __future__ import annotations + import hashlib import json import logging @@ -17,7 +18,7 @@ import urllib.error import pathlib from datetime import datetime, date 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 @@ -140,7 +141,7 @@ class JsonEncoder: 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)): + elif isinstance(obj, pathlib.Path): return str(obj) else: return obj @@ -184,22 +185,21 @@ class UNSAFE_JSON(json.JSONEncoder, json.JSONDecoder): def encode(self, obj :Any) -> Any: return super(UNSAFE_JSON, self).encode(self._encode(obj)) + 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') - + remove_vt100_escape_codes_from_lines :bool = True + ): if not callbacks: callbacks = {} + if not environment_vars: environment_vars = {} @@ -216,8 +216,6 @@ class SysCommandWorker: 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 @@ -396,7 +394,7 @@ class SysCommandWorker: 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 + # If history_logfile does not exist, ignore the error except FileNotFoundError: pass except Exception as e: @@ -431,14 +429,10 @@ class SysCommand: callbacks :Optional[Dict[str, Callable[[Any], Any]]] = None, 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(): @@ -449,8 +443,6 @@ class SysCommand: 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 @@ -575,9 +567,8 @@ def run_custom_user_commands(commands :List[str], installation :Installer) -> No with open(f"{installation.target}/var/tmp/user-command.{index}.sh", "w") as temp_script: temp_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 /var/tmp/user-command.{index}.sh") - 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 : diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py new file mode 100644 index 00000000..bc9164ee --- /dev/null +++ b/archinstall/lib/global_menu.py @@ -0,0 +1,364 @@ +from __future__ import annotations + +from typing import Any, List, Optional, Union, Dict, TYPE_CHECKING + +from . import disk +from .general import SysCommand, secret +from .menu import Selector, AbstractMenu +from .models import NetworkConfiguration +from .models.bootloader import Bootloader +from .models.users import User +from .output import FormattedOutput +from .profile.profile_menu import ProfileConfiguration +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 +from .user_interaction import select_additional_repositories +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.disk_conf import select_disk_config +from .user_interaction.save_conf import save_config + +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['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['disk_config'] = \ + Selector( + _('Disk configuration'), + lambda preset: self._select_disk_config(preset), + preview_func=self._prev_disk_layouts, + display_func=lambda x: self._display_disk_layout(x), + ) + 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['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 '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_config'] = \ + Selector( + _('Profile'), + lambda preset: self._select_profile(preset), + display_func=lambda x: x.profile.name if x else 'None', + preview_func=self._prev_profile + ) + self._menu_options['audio'] = \ + Selector( + _('Audio'), + lambda preset: self._select_audio(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), + display_func=lambda x: ', '.join(x) if x else None, + 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, + display_func=lambda x: ', '.join(x) if x else None, + 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: str, value: str): + text = self._install_text() + self._menu_options['install'].update_description(text) + + def post_callback(self, name: str, value: str): + 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, 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[disk.DiskEncryption]) -> Optional[disk.DiskEncryption]: + mods: Optional[List[disk.DeviceModification]] = self._menu_options['disk_config'].current_selection + + if not mods: + # this should not happen as the encryption menu has the disk_config as dependency + raise ValueError('No disk layout specified') + + data_store: Dict[str, Any] = {} + disk_encryption = disk.DiskEncryptionMenu(mods, data_store, preset=preset).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_disk_layouts(self) -> Optional[str]: + selector = self._menu_options['disk_config'] + disk_layout_conf: Optional[disk.DiskLayoutConfiguration] = selector.current_selection + + if disk_layout_conf: + device_mods: List[disk.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 _display_disk_layout(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]: + 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' + + 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 _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'] + 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 + '\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_ntp(self, preset :bool = True) -> bool: + ntp = ask_ntp(preset) + + value = str(ntp).lower() + SysCommand(f'timedatectl set-ntp {value}') + + return ntp + + def _select_disk_config( + self, + preset: Optional[disk.DiskLayoutConfiguration] = None + ) -> Optional[disk.DiskLayoutConfiguration]: + disk_config = select_disk_config( + preset, + storage['arguments'].get('advanced', False) + ) + + 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: Union[str, None]) -> Optional[str]: + profile_config: Optional[ProfileConfiguration] = self._menu_options['profile_config'].current_selection + if profile_config and profile_config.profile: + is_desktop = profile_config.profile.is_desktop_profile() if profile_config else False + selection = ask_for_audio_selection(is_desktop, current) + return selection + return None + + 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/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 b4d253b3..ddbcc2f2 100644 --- a/archinstall/lib/installer.py +++ b/archinstall/lib/installer.py @@ -1,30 +1,29 @@ -import time +import glob import logging 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 +import time +from pathlib import Path +from typing import Any, Iterator, List, Mapping, Optional, TYPE_CHECKING, Union, Dict + +from . import disk +from .exceptions import DiskError, ServiceException, RequirementError, HardwareIncompatibilityError, SysCallError +from .general import SysCommand 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 .luks import Luks2 from .mirrors import use_mirrors -from .models.disk_encryption import DiskEncryption +from .models.bootloader import Bootloader +from .models.network_configuration import NetworkConfiguration +from .models.users import User +from .output import log +from .pacman import run_pacman from .plugins import plugins +from .services import service_state from .storage import storage -from .output import log -from .profiles import Profile -from .disk.partition import get_mount_fs_type -from .exceptions import DiskError, ServiceException, RequirementError, HardwareIncompatibilityError, SysCallError -from .models.users import User -from .models.subvolume import Subvolume -from .hsm import Fido2 if TYPE_CHECKING: _: Any @@ -36,9 +35,6 @@ __packages__ = ["base", "base-devel", "linux-firmware", "linux", "linux-lts", "l # 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"): @@ -92,26 +88,35 @@ class Installer: :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: + def __init__( + self, + target: Path, + disk_config: disk.DiskLayoutConfiguration, + disk_encryption: Optional[disk.DiskEncryption] = None, + base_packages: List[str] = [], + kernels: Optional[List[str]] = None + ): + if not base_packages: base_packages = __packages__[:3] + if kernels is None: self.kernels = ['linux'] else: self.kernels = kernels + + self._disk_config = disk_config + self._disk_encryption = disk_encryption + + if self._disk_encryption is None: + self._disk_encryption = disk.DiskEncryption(disk.EncryptionType.NoEncryption) + self.target = target self.init_time = time.strftime('%Y-%m-%d_%H-%M-%S') self.milliseconds = int(str(time.time()).split('.')[1]) + self.helper_flags = {'base': False, 'bootloader': False} + self.base_packages = base_packages - 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) @@ -136,19 +141,10 @@ class Installer: self._zram_enabled = 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) - - def __enter__(self, *args :str, **kwargs :str) -> 'Installer': + def __enter__(self, *args: str, **kwargs: str) -> 'Installer': return self - def __exit__(self, *args :str, **kwargs :str) -> 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 if len(args) >= 2 and args[1]: @@ -165,7 +161,6 @@ class Installer: if not (missing_steps := self.post_install_check()): self.log('Installation completed without any errors. You may now reboot.', fg='green', level=logging.INFO) 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) @@ -178,146 +173,168 @@ class Installer: self.sync_log_to_install_medium() return False - @property - def partitions(self) -> List[Partition]: - return get_partitions_in_use(self.target).values() + 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) - 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 _verify_service_stop(self): + """ + Certain services might be running that affects the system during installation. + Currently, only 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. + """ + log('Waiting for automatic mirror selection (reflector) to complete...', level=logging.INFO) + while service_state('reflector') not in ('dead', 'failed', 'exited'): + time.sleep(1) - if not os.path.isdir(f"{self.target}/{os.path.dirname(absolute_logfile)}"): - os.makedirs(f"{self.target}/{os.path.dirname(absolute_logfile)}") + log('Waiting pacman-init.service to complete.', level=logging.INFO) + while service_state('pacman-init') not in ('dead', 'failed', 'exited'): + time.sleep(1) - shutil.copy2(absolute_logfile, f"{self.target}/{absolute_logfile}") + log('Waiting Arch Linux keyring sync (archlinux-keyring-wkd-sync) to complete.', level=logging.INFO) + while service_state('archlinux-keyring-wkd-sync') not in ('dead', 'failed', 'exited'): + time.sleep(1) - return True + def _verify_boot_part(self): + """ + 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) - def _create_keyfile(self,luks_handle , partition :dict, password :str): - """ roiutine to create keyfiles, so it can be moved elsewhere + NOTE: this function should be run AFTER running the mount_ordered_layout function """ - 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)) + 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): + 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.' + ) - os.chmod(f"{self.target}{encryption_key_path}", 0o400) + def sanity_check(self): + self._verify_boot_part() + self._verify_service_stop() - 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"]) + def mount_ordered_layout(self): + log('Mounting partitions in order', level=logging.INFO) - def _has_root(self, partition :dict) -> bool: - """ - Determine if an encrypted partition contains root in it - """ - 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 + for mod in self._disk_config.device_modifications: + # 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(mod.partitions, key=lambda x: x.mountpoint if x.mountpoint else Path('/')) + + if self._disk_encryption.encryption_type is not disk.EncryptionType.NoEncryption: + enc_partitions = list(filter(lambda x: x in self._disk_encryption.partitions, sorted_part_mods)) else: - return False - elif partition.get("mountpoint") == '/': - return True - else: - return False + enc_partitions = [] - 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 - ) + # attempt to decrypt all luks partitions + luks_handlers = self._prepare_luks_partitions(enc_partitions) - # 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) + for part_mod in sorted_part_mods: + if part_mod not in luks_handlers: # partition is not encrypted + self._mount_partition(part_mod) + else: # mount encrypted partition + self._mount_luks_partiton(part_mod, luks_handlers[part_mod]) - 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) + def _prepare_luks_partitions(self, partitions: List[disk.PartitionModification]) -> Dict[disk.PartitionModification, Luks2]: + luks_handlers = {} - log(f"Using mount order: {list(sorted(mount_queue.items(), key=lambda item: item[0]))}", level=logging.DEBUG, fg="white") + for part_mod in partitions: + luks_handler = disk.device_handler.unlock_luks2_dev( + part_mod.dev_path, + part_mod.mapper_name, + self._disk_encryption.encryption_password + ) + luks_handlers[part_mod] = luks_handler + + return luks_handlers + + 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 is not None: + 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: + self._mount_btrfs_subvol(part_mod.dev_path, part_mod.btrfs_subvols) + + def _mount_luks_partiton(self, part_mod: disk.PartitionModification, luks_handler: Luks2): + # it would be none if it's btrfs as the subvolumes will have the mountpoints defined + if part_mod.mountpoint is not None: + 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: + self._mount_btrfs_subvol(luks_handler.mapper_dev, part_mod.btrfs_subvols) + + def _mount_btrfs_subvol(self, dev_path: Path, subvolumes: List[disk.SubvolumeModification]): + for subvol in subvolumes: + mountpoint = self.target / subvol.relative_mountpoint + mount_options = subvol.mount_options + [f'subvol={subvol.name}'] + disk.device_handler.mount(dev_path, mountpoint, options=mount_options) + + def generate_key_files(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.dev_path, + mapper_name=part_mod.mapper_name, + password=self._disk_encryption.encryption_password + ) - # 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() + if gen_enc_file and not part_mod.is_root(): + log(f'Creating key-file: {part_mod.dev_path}', level=logging.INFO) + 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, + self._disk_encryption.encryption_password + ) + + def activate_ntp(self): + """ + If NTP is activated, confirm activiation in the ISO and at least one time-sync finishes + """ + SysCommand('timedatectl set-ntp true') + + logged = False + while service_state('dbus-org.freedesktop.timesync1.service') not in ['running']: + if not logged: + log(f"Waiting for dbus-org.freedesktop.timesync1.service to enter running state", level=logging.INFO) + logged = True time.sleep(1) - 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).") + logged = False + while 'Server: n/a' in SysCommand('timedatectl timesync-status --no-pager --property=Server --value'): + if not logged: + log(f"Waiting for timedatectl timesync-status to report a timesync against a server", level=logging.INFO) + logged = True + time.sleep(1) - # 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]) + 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] != '/': @@ -394,7 +411,7 @@ class Installer: else: pacman_conf.write(line) - def pacstrap(self, *packages :str, **kwargs :str) -> bool: + def pacstrap(self, *packages: Union[str, List[str]], **kwargs :str) -> bool: if type(packages[0]) in (list, tuple): packages = packages[0] @@ -437,7 +454,7 @@ class Installer: return use_mirrors(mirrors, destination=f'{self.target}/etc/pacman.d/mirrorlist') - def genfstab(self, flags :str = '-pU') -> bool: + def genfstab(self, flags :str = '-pU'): self.log(f"Updating {self.target}/etc/fstab", level=logging.INFO) try: @@ -460,7 +477,37 @@ class Installer: for entry in self.FSTAB_ENTRIES: fstab_fh.write(f'{entry}\n') - return True + for mod in self._disk_config.device_modifications: + for part_mod in mod.partitions: + if part_mod.fs_type != disk.FilesystemType.Btrfs: + continue + + fstab_file = Path(f'{self.target}/etc/fstab') + + with fstab_file.open('r') as fp: + fstab = fp.readlines() + + # Replace the {installation}/etc/fstab with entries + # using the compress=zstd where the mountpoint has compression set. + for index, line in enumerate(fstab): + # 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 + subvoldef = re.findall(',.*?subvol=.*?[\t ]', line) + mountpoint = re.findall('[\t ]/.*?[\t ]', line) + + if not subvoldef or not mountpoint: + continue + + for sub_vol in part_mod.btrfs_subvols: + # We then locate the correct subvolume and check if it's compressed, + # and skip entries where compression is already defined + # We then sneak in the compress=zstd option if it doesn't already exist: + if sub_vol.compress and str(sub_vol.mountpoint) == Path(mountpoint[0].strip()) and ',compress=zstd,' not in line: + fstab[index] = line.replace(subvoldef[0], f',compress=zstd{subvoldef[0]}') + break + + with fstab_file.open('w') as fp: + fp.writelines(fstab) def set_hostname(self, hostname: str, *args :str, **kwargs :str) -> None: with open(f'{self.target}/etc/hostname', 'w') as fh: @@ -509,8 +556,8 @@ 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 @@ -523,10 +570,6 @@ class Installer: 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) self.enable_service('systemd-timesyncd') @@ -540,7 +583,10 @@ class Installer: # 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 type(services[0]) in (list, tuple): + services = services[0] + for service in services: self.log(f'Enabling service {service}', level=logging.INFO) try: @@ -552,10 +598,10 @@ class Installer: 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)}" @@ -645,21 +691,6 @@ class Installer: 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: for plugin in plugins.values(): if hasattr(plugin, 'on_mkinitcpio'): @@ -668,7 +699,7 @@ class Installer: return True # mkinitcpio will error out if there's no vconsole. - if (vconsole := pathlib.Path(f"{self.target}/etc/vconsole.conf")).exists() is False: + if (vconsole := 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") @@ -677,7 +708,7 @@ class Installer: 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. @@ -694,46 +725,36 @@ class Installer: 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') + self, + testing: bool = False, + multilib: bool = False, + hostname: str = 'archinstall', + locales: List[str] = ['en_US.UTF-8 UTF-8'] + ): + for mod in self._disk_config.device_modifications: + for part in mod.partitions: + 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) + + # 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 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') @@ -742,11 +763,11 @@ class Installer: vendor = cpu_vendor() if vendor == "AuthenticAMD": self.base_packages.append("amd-ucode") - if (ucode := pathlib.Path(f"{self.target}/boot/amd-ucode.img")).exists(): + if (ucode := 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(): + if (ucode := Path(f"{self.target}/boot/intel-ucode.img")).exists(): ucode.unlink() else: self.log(f"Unknown CPU vendor '{vendor}' detected. Archinstall won't install any ucode.", level=logging.DEBUG) @@ -802,9 +823,7 @@ class Installer: 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') @@ -818,16 +837,27 @@ 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: + 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_partition(self) -> Optional[disk.PartitionModification]: + for mod in self._disk_config.device_modifications: + if root := mod.get_root_partition(self._disk_config.relative_mountpoint): + return root + return None + + def _add_systemd_bootloader(self, root_partition: disk.PartitionModification): self.pacstrap('efibootmgr') if not 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. @@ -882,74 +912,73 @@ class Installer: 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) + 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' + options_entry = f'rw rootfstype={root_partition.fs_type.fs_type_mount} {" ".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 + for sub_vol in root_partition.btrfs_subvols: + if sub_vol.is_root(): + options_entry = f"rootflags=subvol={sub_vol.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): + if root_partition.fs_type.is_crypto(): # 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) + log('Root partition is an encrypted device, identifying by PARTUUID: {root_partition.partuuid}', level=logging.DEBUG) kernel_options = f"options" 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" + kernel_options += f' rd.luks.name={root_partition.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" + kernel_options += f' rd.luks.options=fido2-device=auto,password-echo=no' else: - kernel_options += f" cryptdevice=PARTUUID={real_device.part_uuid}:luksdev" + kernel_options += f' cryptdevice=PARTUUID={root_partition.partuuid}:luksdev' entry.write(f'{kernel_options} root=/dev/mapper/luksdev {options_entry}') 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}') + log(f'Identifying root partition by PARTUUID: {root_partition.partuuid}', level=logging.DEBUG) + entry.write(f'options root=PARTUUID={root_partition.partuuid} {options_entry}') - self.helper_flags['bootloader'] = "systemd" - - return True + self.helper_flags['bootloader'] = 'systemd' - def add_grub_bootloader(self, boot_partition :Partition, root_partition :Partition) -> bool: + def _add_grub_bootloader( + self, + boot_partition: disk.PartitionModification, + root_partition: disk.PartitionModification + ): self.pacstrap('grub') # no need? - root_fs_type = get_mount_fs_type(root_partition.filesystem) + _file = "/etc/default/grub" - 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/'" + if root_partition.fs_type.is_crypto(): + log(f"Using UUID {root_partition.uuid} as encrypted root identifier", level=logging.DEBUG) - 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}") + cmd_line_linux = f"sed -i 's/GRUB_CMDLINE_LINUX=\"\"/GRUB_CMDLINE_LINUX=\"cryptdevice=UUID={root_partition.uuid}:cryptlvm rootfstype={root_partition.fs_type.value}\"/'" + enable_cryptdisk = "sed -i 's/#GRUB_ENABLE_CRYPTODISK=y/GRUB_ENABLE_CRYPTODISK=y/'" + + SysCommand(f"/usr/bin/arch-chroot {self.target} {enable_cryptdisk} {_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}") + cmd_line_linux = f"sed -i 's/GRUB_CMDLINE_LINUX=\"\"/GRUB_CMDLINE_LINUX=\"rootfstype={root_partition.fs_type.value}\"/'" + + SysCommand(f"/usr/bin/arch-chroot {self.target} {cmd_line_linux} {_file}") + + log(f"GRUB boot partition: {boot_partition.dev_path}", level=logging.INFO) - 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) except SysCallError: @@ -961,7 +990,7 @@ class Installer: 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}") + raise DiskError(f"Failed to install GRUB boot on {boot_partition.dev_path}: {error}") try: SysCommand(f'/usr/bin/arch-chroot {self.target} grub-mkconfig -o /boot/grub/grub.cfg') @@ -970,22 +999,22 @@ class Installer: self.helper_flags['bootloader'] = "grub" - return True - - def add_efistub_bootloader(self, boot_partition :Partition, root_partition :Partition) -> bool: + def _add_efistub_bootloader( + self, + boot_partition: disk.PartitionModification, + root_partition: disk.PartitionModification + ): self.pacstrap('efibootmgr') if not 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) - for kernel in self.kernels: # Setup the firmware entry - label = f'Arch Linux ({kernel})' loader = f"/vmlinuz-{kernel}" @@ -1004,22 +1033,22 @@ class Installer: # 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): + + if root_partition.fs_type.is_crypto(): # 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)}') + log(f'Identifying root partition by PARTUUID: {root_partition.partuuid}', level=logging.DEBUG) + kernel_parameters.append(f'cryptdevice=PARTUUID={root_partition.partuuid}:luksdev root=/dev/mapper/luksdev rw rootfstype={root_partition.fs_type.value} {" ".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)}') + log(f'Root partition is an encrypted device identifying by PARTUUID: {root_partition.partuuid}', level=logging.DEBUG) + kernel_parameters.append(f'root=PARTUUID={root_partition.partuuid} rw rootfstype={root_partition.fs_type.value} {" ".join(self.KERNEL_PARAMS)}') - SysCommand(f'efibootmgr --disk {boot_partition.path[:-1]} --part {boot_partition.path[-1]} --create --label "{label}" --loader {loader} --unicode \'{" ".join(kernel_parameters)}\' --verbose') + device = disk.device_handler.get_device_by_partition_path(boot_partition.dev_path) + SysCommand(f'efibootmgr --disk {device.path} --part {device.path} --create --label "{label}" --loader {loader} --unicode \'{" ".join(kernel_parameters)}\' --verbose') self.helper_flags['bootloader'] = "efistub" - return True - - def add_bootloader(self, bootloader :str = 'systemd-bootctl') -> bool: + def add_bootloader(self, bootloader: Bootloader) -> bool: """ Adds a bootloader to the installation instance. Archinstall supports one of three types: @@ -1039,52 +1068,33 @@ class Installer: return True if type(self.target) == str: - self.target = pathlib.Path(self.target) - - 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 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}") - - self.log(f'Adding bootloader {bootloader} to {boot_partition if boot_partition else root_partition}', level=logging.INFO) - - 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}") + self.target = Path(self.target) - return True + boot_partition = self._get_boot_partition() + root_partition = self._get_root_partition() - def add_additional_packages(self, *packages :str) -> bool: - return self.pacstrap(*packages) + if boot_partition is None: + raise ValueError(f'Could not detect boot at mountpoint {self.target}') - 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. + if root_partition is None: + raise ValueError(f'Could not detect root at mountpoint {self.target}') - :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 + self.log(f'Adding bootloader {bootloader.value} to {boot_partition.dev_path}', level=logging.INFO) + + match bootloader: + case Bootloader.Systemd: + self._add_systemd_bootloader(root_partition) + case Bootloader.Grub: + self._add_grub_bootloader(boot_partition, root_partition) + case Bootloader.Efistub: + self._add_efistub_bootloader(boot_partition, root_partition) - if type(profile) == str: - profile = Profile(self, profile) + def add_additional_packages(self, *packages: Union[str, List[str]]) -> bool: + return self.pacstrap(*packages) - self.log(f'Installing archinstall profile {profile}', level=logging.INFO) - return profile.install() + def _enable_users(self, service: str, users: List[User]): + for user in users: + self.arch_chroot(f'systemctl enable --user {service}', run_as=user.username) def enable_sudo(self, entity: str, group :bool = False): self.log(f'Enabling sudo permissions for {entity}.', level=logging.INFO) @@ -1092,7 +1102,7 @@ class Installer: 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) @@ -1114,7 +1124,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): diff --git a/archinstall/lib/locale_helpers.py b/archinstall/lib/locale_helpers.py index 5580fa91..d1fb4562 100644 --- a/archinstall/lib/locale_helpers.py +++ b/archinstall/lib/locale_helpers.py @@ -1,11 +1,12 @@ import logging -from typing import Iterator, List, Callable +from typing import Iterator, List, Callable, Optional 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() @@ -45,20 +46,25 @@ def get_locale_mode_text(mode): 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'): + +def set_cmd_locale( + general: Optional[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) diff --git a/archinstall/lib/luks.py b/archinstall/lib/luks.py index ad6bf093..fc531a06 100644 --- a/archinstall/lib/luks.py +++ b/archinstall/lib/luks.py @@ -1,92 +1,78 @@ 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 dataclasses import dataclass +from pathlib import Path +from typing import Optional, List -from .disk import Partition, convert_device_to_uuid -from .general import SysCommand, SysCommandWorker +from . import disk +from .general import SysCommand, generate_password, SysCommandWorker from .output import log 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') - - with open(self.key_file, 'wb') as fh: - fh.write(self.password) - - return self.unlock(self.partition, self.mountpoint, self.key_file) - - def __exit__(self, *args :str, **kwargs :str) -> bool: - # TODO: https://stackoverflow.com/questions/28157929/how-to-safely-handle-an-exception-inside-a-context-manager + + +@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 + + # will be set internally after unlocking the device + _mapper_dev: Optional[Path] = None + + @property + def mapper_dev(self) -> Optional[Path]: + if self.mapper_name: + return Path(f'/dev/mapper/{self.mapper_name}') + return None + + 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: + log(f'Luks2 encrypting: {self.luks_dev_path}', level=logging.INFO) - 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? - - if not password: - password = self.password - - if type(password) != bytes: - password = bytes(password, 'UTF-8') + key_file = self._default_key_file() - 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 +83,163 @@ 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. + cmd_handle = None for i in range(storage['DISK_RETRY_ATTEMPTS']): if (cmd_handle := SysCommand(cryptsetup_args)).exit_code != 0: time.sleep(storage['DISK_TIMEOUTS']) else: break - if cmd_handle.exit_code != 0: - raise DiskError(f'Could not encrypt volume "{partition.path}": {b"".join(cmd_handle)}') + if cmd_handle is not None and cmd_handle.exit_code != 0: + output = str(b''.join(cmd_handle)) + raise DiskError(f'Could not encrypt volume "{self.luks_dev_path}": {output}') 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']}") + log(f'luks2 partition currently in use: {self.luks_dev_path}') + log('Attempting to unmount, crypt-close and trying encryption again') + self.lock() # Then try again to set up the crypt-device - cmd_handle = SysCommand(cryptsetup_args) + SysCommand(cryptsetup_args) else: raise err 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: + result = SysCommand(command) + if result.exit_code != 0: + raise DiskError(f'Unable to get UUID for Luks device: {result.decode()}') + + return result.decode() # type: ignore + except SysCallError as err: + log(f'Unable to get UUID for Luks device: {self.luks_dev_path}', level=logging.INFO) + 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 """ + log(f'Unlocking luks2 device: {self.luks_dev_path}', level=logging.DEBUG) + + 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() - if '/' in mountpoint: - os.path.basename(mountpoint) # TODO: Raise exception instead? + 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'}) + SysCommand(f'/usr/bin/cryptsetup open {self.luks_dev_path} {self.mapper_name} --key-file {key_file} --type luks2') + + 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 + disk.device_handler.partprobe(self.luks_dev_path) + 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: + log(f'Unmounting {mountpoint}', level=logging.DEBUG) + disk.device_handler.umount(mountpoint, recursive=True) + + # And close it if possible. + log(f"Closing crypt device {child.name}", level=logging.DEBUG) + 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" + key_file_path = target_path / 'etc/cryptsetup-keys.d/' / self.mapper_name + key_file = key_file_path / '.key' + crypttab_path = target_path / 'etc/crypttab' + + if key_file.exists(): + if not override: + log(f'Key file {key_file} already exists, keeping existing') + return + else: + log(f'Key file {key_file} already exists, overriding') + + key_file_path.mkdir(parents=True, exist_ok=True) + + with open(key_file, "w") as keyfile: + keyfile.write(generate_password(length=512)) + + key_file_path.chmod(0o400) + + self._add_key(key_file) + self._crypttab(crypttab_path, key_file, options=["luks", "key-slot=1"]) + + def _add_key(self, key_file: Path): + log(f'Adding additional key-file {key_file}', level=logging.INFO) + + 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: + log(f'Adding crypttab entry for key {key_file}', level=logging.INFO) + + 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..53816655 100644 --- a/archinstall/lib/menu/abstract_menu.py +++ b/archinstall/lib/menu/abstract_menu.py @@ -7,7 +7,6 @@ from .menu import Menu, MenuSelectionType from ..locale_helpers import set_keyboard_language from ..output import log from ..translationhandler import TranslationHandler, Language -from ..user_interaction.general_conf import select_archinstall_language if TYPE_CHECKING: _: Any @@ -16,17 +15,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[[str], 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 @@ -82,6 +81,11 @@ class Selector: self._preview_func = preview_func self.mandatory = mandatory self._no_store = no_store + self._default = default + + @property + def default(self) -> Any: + return self._default @property def description(self) -> str: @@ -96,7 +100,7 @@ class Selector: return self._dependencies_not @property - def current_selection(self): + def current_selection(self) -> Optional[Any]: return self._current_selection @property @@ -106,14 +110,14 @@ class Selector: 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): + 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 = '' @@ -134,7 +138,7 @@ class Selector: return f'{description} {current}' - def set_current_selection(self, current :Optional[str]): + def set_current_selection(self, current: Optional[Any]): self._current_selection = current def has_selection(self) -> bool: @@ -158,14 +162,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,25 +186,28 @@ 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() + @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]: @@ -216,7 +226,50 @@ 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 _missing_configs(self) -> List[str]: + def check(s): + return self._menu_options.get(s).has_selection() + + 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 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() """ @@ -226,7 +279,7 @@ class AbstractMenu: """ will be called before each action in the menu """ return - def post_callback(self, selection_name: Optional[str] = None, value: Any = None): + def post_callback(self, selection_name: str, value: Any): """ will be called after each action in the menu """ return True @@ -234,31 +287,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 +312,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,12 +325,7 @@ 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() cursor_pos = None while True: @@ -341,13 +378,13 @@ class AbstractMenu: 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 +393,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,17 +409,21 @@ 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) 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): @@ -392,7 +433,7 @@ class AbstractMenu: if self._data_store.get('keyboard-layout', None) and len(self._data_store['keyboard-layout']): set_keyboard_language(self._data_store['keyboard-layout']) - def _verify_selection_enabled(self, selection_name :str) -> bool: + def _verify_selection_enabled(self, selection_name: str) -> bool: """ general """ if selection := self._menu_options.get(selection_name, None): if not selection.enabled: @@ -429,16 +470,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 +482,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: + from ..user_interaction.general_conf import select_archinstall_language language = select_archinstall_language(self.translation_handler.translated_languages, preset_value) self._translation_handler.activate(language) return language class AbstractSubMenu(AbstractMenu): - def __init__(self, data_store: Optional[Dict[str, Any]] = None): + def __init__(self, data_store: Dict[str, Any] = {}): super().__init__(data_store=data_store) 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..be31fdf0 100644 --- a/archinstall/lib/menu/list_manager.py +++ b/archinstall/lib/menu/list_manager.py @@ -34,7 +34,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 +44,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 @@ -76,10 +81,11 @@ class ListManager: elif choice.value in self._terminate_actions: break else: # an entry of the existing selection was choosen - selected_entry = data_formatted[choice.value] + 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: @@ -122,21 +128,29 @@ class ListManager: 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 + """ + 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, 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) + def reformat(self, data: List[Any]) -> Dict[str, Optional[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) + """ 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..44ac33a6 100644 --- a/archinstall/lib/menu/menu.py +++ b/archinstall/lib/menu/menu.py @@ -3,7 +3,7 @@ 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 from ..exceptions import RequirementError from ..output import log @@ -27,42 +27,56 @@ class MenuSelection: type_: MenuSelectionType value: Optional[Union[str, List[str]]] = None + @property + def single_value(self) -> Any: + return self.value + + @property + def multi_value(self) -> List[Any]: + return self.value + class Menu(TerminalMenu): @classmethod - def yes(cls): + def back(cls) -> str: + return str(_('← Back')) + + @classmethod + 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, + 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] = 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] = None, + 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 +86,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,16 +115,17 @@ 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 warnign 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 @@ -152,7 +167,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: @@ -182,6 +196,14 @@ class Menu(TerminalMenu): default = f'{default_option} {self._default_str}' self._menu_options = [default] + [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 += [''] + self._preselection(preset_values,cursor_index) cursor = "> " @@ -194,13 +216,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), + 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 +231,17 @@ class Menu(TerminalMenu): skip_empty_entries=skip_empty_entries ) + def _show_preview(self, preview_command: Optional[Callable], selection: str) -> Optional[str]: + if selection == self.back(): + return None + + if preview_command: + if self._default_option is not None and f'{self._default_option} {self._default_str}' == selection: + selection = self._default_option + return preview_command(selection) + + return None + def _show(self) -> MenuSelection: try: idx = self.show() @@ -225,39 +255,37 @@ class Menu(TerminalMenu): 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() + 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 + + return selection def set_cursor_pos(self,pos :int): if pos and 0 < pos < len(self._menu_entries): 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..4cff7216 100644 --- a/archinstall/lib/menu/table_selection_menu.py +++ b/archinstall/lib/menu/table_selection_menu.py @@ -1,19 +1,24 @@ -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, ): """ param title: Text that will be displayed above the menu @@ -29,10 +34,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 +46,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 +58,53 @@ 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 ) + 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 +117,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 +131,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/mirrors.py b/archinstall/lib/mirrors.py index d76e0473..4bae6d8b 100644 --- a/archinstall/lib/mirrors.py +++ b/archinstall/lib/mirrors.py @@ -2,7 +2,7 @@ import logging import pathlib import urllib.error import urllib.request -from typing import Union, Mapping, Iterable, Dict, Any, List +from typing import Union, Iterable, Dict, Any, List from .general import SysCommand from .output import log @@ -121,7 +121,7 @@ def insert_mirrors(mirrors :Dict[str, Any], *args :str, **kwargs :str) -> bool: def use_mirrors( - regions: Mapping[str, Iterable[str]], + regions: Dict[str, Iterable[str]], destination: str = '/etc/pacman.d/mirrorlist' ) -> None: log(f'A new package mirror-list has been created: {destination}', level=logging.INFO) diff --git a/archinstall/lib/models/__init__.py b/archinstall/lib/models/__init__.py index 4a018b2c..8cc49ea0 100644 --- a/archinstall/lib/models/__init__.py +++ b/archinstall/lib/models/__init__.py @@ -1 +1,4 @@ -from .network_configuration import NetworkConfiguration as NetworkConfiguration
\ No newline at end of file +from .network_configuration import NetworkConfiguration, NicType, NetworkConfigurationHandler +from .bootloader import Bootloader +from .gen import VersionDef, PackageSearchResult, PackageSearch, LocalPackage +from .users import PasswordStrength, User diff --git a/archinstall/lib/models/bootloader.py b/archinstall/lib/models/bootloader.py new file mode 100644 index 00000000..38254c99 --- /dev/null +++ b/archinstall/lib/models/bootloader.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import logging +import sys +from enum import Enum +from typing import List + +from ..hardware import has_uefi +from ..output import log + + +class Bootloader(Enum): + Systemd = 'Systemd-boot' + Grub = 'Grub' + Efistub = 'Efistub' + + def json(self): + return self.value + + @classmethod + def values(cls) -> List[str]: + return [e.value for e in cls] + + @classmethod + def get_default(cls) -> Bootloader: + if 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()) + log(f'Invalid bootloader value "{bootloader}". Allowed values: {values}', level=logging.WARN) + 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..cc8d7605 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 + @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 @@ -83,12 +91,13 @@ class PackageSearchResult: 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 @@ -101,6 +110,7 @@ class PackageSearch: def __post_init__(self): self.results = [PackageSearchResult(**x) for x in self.results] + @dataclass class LocalPackage: name: str @@ -129,8 +139,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..b7ab690d 100644 --- a/archinstall/lib/models/network_configuration.py +++ b/archinstall/lib/models/network_configuration.py @@ -93,7 +93,7 @@ class NetworkConfigurationHandler: enable_services=True) # Sources the ISO network configuration to the install medium. elif self._configuration.is_network_manager(): installation.add_additional_packages(["networkmanager"]) - if (profile := storage['arguments'].get('profile')) and profile.is_desktop_profile: + if (profile := storage['arguments'].get('profile_config')) and profile.is_desktop_type_profile: installation.add_additional_packages(["network-manager-applet"]) installation.enable_service('NetworkManager.service') 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 96e8f3a1..3516aac4 100644 --- a/archinstall/lib/networking.py +++ b/archinstall/lib/networking.py @@ -1,8 +1,12 @@ 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 @@ -39,7 +43,7 @@ def check_mirror_reachable() -> bool: 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) + log(f'exit_code: {err.exit_code}, Error: {err.message}', level=logging.DEBUG) return False @@ -75,12 +79,8 @@ 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 wireless_scan(interface :str) -> None: - interfaces = enrich_iface_types(list_interfaces().values()) + interfaces = enrich_iface_types(list(list_interfaces().values())) if interfaces[interface] != 'WIRELESS': raise HardwareIncompatibilityError(f"Interface {interface} is not a wireless interface: {interfaces}") @@ -107,3 +107,22 @@ def get_wireless_networks(interface :str) -> None: for line in SysCommand(f"iwctl station {interface} get-networks"): print(line) + + +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 + + 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..d65f835f 100644 --- a/archinstall/lib/output.py +++ b/archinstall/lib/output.py @@ -2,7 +2,7 @@ import logging import os import sys from pathlib import Path -from typing import Dict, Union, List, Any, Callable +from typing import Dict, Union, List, Any, Callable, Optional from .storage import storage from dataclasses import asdict, is_dataclass @@ -11,7 +11,12 @@ 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]: + def 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 dynamicly selected formatting method. Can transmit a filter list to the class_formatter, @@ -25,7 +30,8 @@ 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 + + raise ValueError('Unsupported formatting call') elif hasattr(o, 'as_json'): return o.as_json() elif hasattr(o, 'json'): @@ -36,7 +42,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 @@ -46,6 +58,7 @@ class FormattedOutput: 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] + # determine the maximum column size column_width: Dict[str, int] = {} for o in raw_data: @@ -55,14 +68,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 = key.replace('!', '').replace('_', ' ') + + if capitalize: + key = key.capitalize() + key_list.append(key.ljust(width)) + output += ' | '.join(key_list) + '\n' output += '-' * len(output) + '\n' @@ -82,6 +101,20 @@ class FormattedOutput: return output + @classmethod + def as_columns(cls, entries: List[str], cols: int) -> str: + 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 @@ -204,6 +237,6 @@ def log(*args :str, **kwargs :Union[str, int, Dict[str, Union[str, int]]]) -> No # 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): + if kwargs.get('level', logging.INFO) != logging.DEBUG or storage.get('arguments', {}).get('verbose', False): sys.stdout.write(f"{string}\n") sys.stdout.flush() 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..71818ca5 100644 --- a/archinstall/lib/packages/packages.py +++ b/archinstall/lib/packages/packages.py @@ -7,7 +7,7 @@ from urllib.parse import urlencode from urllib.request import urlopen from ..exceptions import PackageError, SysCallError -from ..models.dataclasses import PackageSearch, PackageSearchResult, LocalPackage +from ..models.gen import PackageSearch, PackageSearchResult, LocalPackage from ..pacman import run_pacman BASE_URL_PKG_SEARCH = 'https://archlinux.org/packages/search/json/' @@ -113,4 +113,4 @@ def installed_package(package :str) -> LocalPackage: 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 index 9c427aff..0dfd5afa 100644 --- a/archinstall/lib/pacman.py +++ b/archinstall/lib/pacman.py @@ -1,10 +1,14 @@ import logging import pathlib import time +from typing import TYPE_CHECKING, Any from .general import SysCommand from .output import log +if TYPE_CHECKING: + _: Any + def run_pacman(args :str, default_cmd :str = 'pacman') -> SysCommand: """ diff --git a/archinstall/lib/profile/__init__.py b/archinstall/lib/profile/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/archinstall/lib/profile/__init__.py diff --git a/archinstall/lib/profile/profile_menu.py b/archinstall/lib/profile/profile_menu.py new file mode 100644 index 00000000..6462685a --- /dev/null +++ b/archinstall/lib/profile/profile_menu.py @@ -0,0 +1,203 @@ +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 ..hardware import AVAILABLE_GFX_DRIVERS +from ..menu import Menu, MenuSelectionType, AbstractSubMenu, Selector +from ..user_interaction.system_conf import select_driver + +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( + _('Profile'), + 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 if x else None, + dependencies=['profile'], + 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('All open-source (default)') + + 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[str] = None) -> Optional[str]: + 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(): + packages = AVAILABLE_GFX_DRIVERS[driver] + + if packages and "nvidia" in packages: + 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_profile(self) -> Optional[str]: + profile: Optional[Profile] = self._menu_options['profile'].current_selection + + if profile: + names = profile.current_selection_names() + return '\n'.join(names) + + 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..ad3015ae --- /dev/null +++ b/archinstall/lib/profile/profile_model.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Optional, Dict + +from archinstall.default_profiles.profile import Profile, GreeterType + +if TYPE_CHECKING: + _: Any + + +@dataclass +class ProfileConfiguration: + profile: Optional[Profile] = None + gfx_driver: Optional[str] = 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, + '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 + greeter = arg.get('greeter', None) + + return ProfileConfiguration( + profile_handler.parse_profile_config(arg['profile']), + arg.get('gfx_driver', 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..063b12ea --- /dev/null +++ b/archinstall/lib/profile/profiles_handler.py @@ -0,0 +1,391 @@ +from __future__ import annotations + +import importlib.util +import logging +import sys +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 AVAILABLE_GFX_DRIVERS +from ..menu import MenuSelectionType, Menu, MenuSelection +from ..networking import list_interfaces, fetch_data_from_url +from ..output import log +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], + } + + 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 + """ + 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 + + valid: List[Profile] = [] + if details := profile_config.get('details', []): + resolved = {detail: self.get_profile_by_name(detail) for detail in details if detail} + valid = [p for p in resolved.values() if p is not None] + invalid = ', '.join([k for k, v in resolved.items() if v is None]) + + if invalid: + log(f'No profile definition found: {invalid}') + + if profile is not None: + profile.set_current_selection(valid) + + return profile + + @property + def profiles(self) -> List[Profile]: + """ + List of all available default_profiles + """ + if self._profiles is None: + self._profiles = self._find_available_profiles() + return self._profiles + + @cached_property + def _local_mac_addresses(self) -> List[str]: + ifaces = list_interfaces() + return list(ifaces.keys()) + + 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.Lightdm: + packages = ['lightdm', 'lightdm-gtk-greeter'] + service = ['lightdm'] + case GreeterType.Sddm: + packages = ['sddm'] + service = ['sddm'] + case GreeterType.Gdm: + packages = ['gdm'] + service = ['gdm'] + + if packages: + install_session.add_additional_packages(packages) + if service: + install_session.enable_service(service) + + def install_gfx_driver(self, install_session: 'Installer', driver: str): + try: + driver_pkgs = AVAILABLE_GFX_DRIVERS[driver] if driver else [] + additional_pkg = ' '.join(['xorg-server', 'xorg-xinit'] + driver_pkgs) + + if driver is not None: + if 'nvidia' in driver: + if "linux-zen" in install_session.base_packages or "linux-lts" in install_session.base_packages: + for kernel in install_session.kernels: + # Fixes https://github.com/archlinux/archinstall/issues/585 + install_session.add_additional_packages(f"{kernel}-headers") + + # I've had kernel regen fail if it wasn't installed before nvidia-dkms + install_session.add_additional_packages("dkms xorg-server xorg-xinit nvidia-dkms") + return + elif 'amdgpu' in driver_pkgs: + # The order of these two are important if amdgpu is installed #808 + if 'amdgpu' in install_session.MODULES: + install_session.MODULES.remove('amdgpu') + install_session.MODULES.append('amdgpu') + + if 'radeon' in install_session.MODULES: + install_session.MODULES.remove('radeon') + install_session.MODULES.append('radeon') + + install_session.add_additional_packages(additional_pkg) + except Exception as err: + log(f"Could not handle nvidia and linuz-zen specific situations during xorg installation: {err}", level=logging.WARNING, fg="yellow") + # Prep didn't run, so there's no driver to install + install_session.add_additional_packages("xorg-server xorg-xinit") + + def install_profile_config(self, install_session: 'Installer', profile_config: ProfileConfiguration): + profile = profile_config.profile + + if profile: + profile.install(install_session) + + if profile and profile_config.gfx_driver: + if profile.is_xorg_type_profile() or profile.is_desktop_type_profile(): + self.install_gfx_driver(install_session, profile_config.gfx_driver) + + if profile and 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) + log(err, level=logging.ERROR, fg="red") + + 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__: + try: + cls_ = v() + if isinstance(cls_, Profile): + profiles.append(cls_) + except Exception: + log(f'Cannot import {module}, it does not appear to be a Profile class', level=logging.DEBUG) + + 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]) + log(err, level=logging.ERROR, fg="red") + 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): + log(f'Cannot import {file} because it is no longer supported, please use the new profile format') + return [] + + if not file.is_file(): + log(f'Cannot find profile file {file}') + return [] + + name = file.name.removesuffix(file.suffix) + log(f'Importing profile: {file}', level=logging.DEBUG) + + try: + spec = importlib.util.spec_from_file_location(name, file) + if spec is not None: + 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: + log(f'Unable to parse file {file}: {e}', level=logging.ERROR) + + 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} + + 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=True, + 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/storage.py b/archinstall/lib/storage.py index 8c358161..5a54d816 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. + 'PROFILE': Path(__file__).parent.parent.joinpath('default_profiles'), 'LOG_PATH': '/var/log/archinstall', 'LOG_FILE': 'install.log', - 'MOUNT_POINT': '/mnt/archinstall', + '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/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 index 2bc46759..5ee89de0 100644 --- a/archinstall/lib/user_interaction/__init__.py +++ b/archinstall/lib/user_interaction/__init__.py @@ -1,12 +1,10 @@ -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 .system_conf import select_kernel, 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 +from .general_conf import ( + ask_ntp, ask_for_a_timezone, ask_for_audio_selection, select_language, select_mirror_regions, + select_archinstall_language, ask_additional_packages_to_install, + select_additional_repositories, ask_hostname, add_number_of_parrallel_downloads +) +from .utils import get_password 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 index 554d13ef..a77e950a 100644 --- a/archinstall/lib/user_interaction/disk_conf.py +++ b/archinstall/lib/user_interaction/disk_conf.py @@ -1,86 +1,391 @@ from __future__ import annotations -from typing import Any, Dict, TYPE_CHECKING, Optional +import logging +from pathlib import Path +from typing import Any, TYPE_CHECKING, Optional, List, Tuple -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 +from .. import disk +from ..hardware import has_uefi +from ..menu import Menu, MenuSelectionType, TableMenu +from ..output import FormattedOutput +from ..output import log +from ..utils.util import prompt_dir if TYPE_CHECKING: _: Any -def ask_for_main_filesystem_format(advanced_options=False) -> str: - options = {'btrfs': 'btrfs', 'ext4': 'ext4', 'xfs': 'xfs', 'f2fs': 'f2fs'} +def select_devices(preset: List[disk.BDevice] = []) -> List[disk.BDevice]: + """ + Asks the user to select one or multiple devices - advanced = {'ntfs': 'ntfs'} + :return: List of selected devices + :rtype: list + """ - if advanced_options: - options.update(advanced) + 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 - prompt = _('Select which filesystem your main partition should use') - choice = Menu(prompt, options, skip=False).run() - return choice.value + 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.value # type: ignore + selected_devices = [] + + for device in devices: + if device.device_info in selected_device_info: + selected_devices.append(device) + + return selected_devices -def select_individual_blockdevice_usage(block_devices: list) -> Dict[str, Any]: - result = {} +def get_default_partition_layout( + devices: List[disk.BDevice], + filesystem_type: Optional[disk.FilesystemType] = None, + advanced_option: bool = False +) -> List[disk.DeviceModification]: - for device in block_devices: - layout = manage_new_and_existing_partitions(device) - result[device.path] = layout + 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 + ) - return result +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) -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] + 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 what you wish to do with the selected block devices'), - modes, + _('Select a partitioning option'), + options, allow_reset=True, - allow_reset_warning_msg=warning + 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.value == wipe_mode: - return get_default_partition_layout(block_devices, advanced_options) + 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" + + path = prompt_dir(str(_('Enter the root directory of the mounted devices: ')), output) + mods = disk.device_handler.detect_pre_mounted_mods(path) + + return disk.DiskLayoutConfiguration( + config_type=disk.DiskLayoutType.Pre_mount, + relative_mountpoint=path, + device_modifications=mods + ) + + 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 _boot_partition() -> disk.PartitionModification: + if has_uefi(): + start = disk.Size(1, disk.Unit.MiB) + size = disk.Size(512, disk.Unit.MiB) + else: + start = disk.Size(3, disk.Unit.MiB) + size = disk.Size(203, disk.Unit.MiB) + + # 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=[disk.PartitionFlag.Boot] + ) + + +def ask_for_main_filesystem_format(advanced_options=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 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 = ask_for_main_filesystem_format(advanced_options) + + min_size_to_allow_home_part = disk.Size(40, disk.Unit.GiB) + root_partition_size = disk.Size(20, disk.Unit.GiB) + using_subvolumes = False + using_home_partition = False + compression = False + 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() + + 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() + + device_modification = disk.DeviceModification(device, wipe=True) + + # 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() + 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: - return select_individual_blockdevice_usage(block_devices) + using_home_partition = False + # root partition + start = disk.Size(513, disk.Unit.MiB) if has_uefi() else disk.Size(206, disk.Unit.MiB) -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`. + # Set a size for / (/root) + if using_subvolumes or device_size_gib < min_size_to_allow_home_part or not using_home_partition: + length = disk.Size(100, disk.Unit.Percent, total_size=device.device_info.total_size) + else: + length = min(device.device_info.total_size, root_partition_size) - :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 + root_partition = disk.PartitionModification( + status=disk.ModificationStatus.Create, + type=disk.PartitionType.Primary, + start=start, + length=length, + mountpoint=Path('/') if not using_subvolumes else None, + fs_type=filesystem_type, + mount_options=['compress=zstd'] if compression else [], + ) + device_modification.add_partition(root_partition) - :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')) + 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 re-use data between re-installs.. + # A second partition for /home would be nice if we have the space for it + home_partition = disk.PartitionModification( + status=disk.ModificationStatus.Create, + type=disk.PartitionType.Primary, + start=root_partition.length, + length=disk.Size(100, disk.Unit.Percent, total_size=device.device_info.total_size), + mountpoint=Path('/home'), + fs_type=filesystem_type, + mount_options=['compress=zstd'] if compression else [] + ) + 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) + # 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) + compression = False + + if not filesystem_type: + filesystem_type = ask_for_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: + 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() + + device_paths = ', '.join([str(d.device_info.path) for d in devices]) + log(f"Suggesting multi-disk-layout for devices: {device_paths}", level=logging.DEBUG) + log(f"/root: {root_device.device_info.path}", level=logging.DEBUG) + log(f"/home: {home_device.device_info.path}", level=logging.DEBUG) + + root_device_modification = disk.DeviceModification(root_device, wipe=True) + home_device_modification = disk.DeviceModification(home_device, wipe=True) - choice = Menu(title, drives).run() + # add boot partition to the root device + boot_partition = _boot_partition() + root_device_modification.add_partition(boot_partition) - if choice.type_ == MenuSelectionType.Skip: - return None + # add root partition to the root device + root_partition = disk.PartitionModification( + status=disk.ModificationStatus.Create, + type=disk.PartitionType.Primary, + start=disk.Size(513, disk.Unit.MiB) if has_uefi() else disk.Size(206, disk.Unit.MiB), + length=disk.Size(100, disk.Unit.Percent, total_size=root_device.device_info.total_size), + mountpoint=Path('/'), + mount_options=['compress=zstd'] if compression else [], + fs_type=filesystem_type + ) + root_device_modification.add_partition(root_partition) - drive = dict_o_disks[choice.value] - return drive + # add home partition to home device + home_partition = disk.PartitionModification( + status=disk.ModificationStatus.Create, + type=disk.PartitionType.Primary, + start=disk.Size(1, disk.Unit.MiB), + length=disk.Size(100, disk.Unit.Percent, total_size=home_device.device_info.total_size), + mountpoint=Path('/home'), + mount_options=['compress=zstd'] if compression else [], + fs_type=filesystem_type, + ) + home_device_modification.add_partition(home_partition) - raise DiskError('select_disk() requires a non-empty dictionary of disks to select from.') + return [root_device_modification, home_device_modification] diff --git a/archinstall/lib/user_interaction/general_conf.py b/archinstall/lib/user_interaction/general_conf.py index fc7ded45..7a6bb358 100644 --- a/archinstall/lib/user_interaction/general_conf.py +++ b/archinstall/lib/user_interaction/general_conf.py @@ -3,15 +3,13 @@ from __future__ import annotations import logging import pathlib from typing import List, Any, Optional, Dict, TYPE_CHECKING +from typing import Union 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 ..menu import MenuSelectionType, Menu, 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 @@ -32,9 +30,10 @@ def ask_ntp(preset: bool = True) -> bool: def ask_hostname(preset: str = None) -> str: - hostname = TextInput(_('Desired hostname for the installation: '), preset).run().strip(' ') - return hostname - + while True: + hostname = TextInput(_('Desired hostname for the installation: '), preset).run().strip() + if hostname: + return hostname def ask_for_a_timezone(preset: str = None) -> str: timezones = list_timezones() @@ -52,7 +51,7 @@ def ask_for_a_timezone(preset: str = None) -> str: case MenuSelectionType.Selection: return choice.value -def ask_for_audio_selection(desktop: bool = True, preset: str = None) -> str: +def ask_for_audio_selection(desktop: bool = True, preset: Union[str, None] = None) -> Union[str, None]: no_audio = str(_('No audio server')) choices = ['pipewire', 'pulseaudio'] if desktop else ['pipewire', 'pulseaudio', no_audio] default = 'pipewire' if desktop else no_audio @@ -140,50 +139,6 @@ def select_archinstall_language(languages: List[Language], preset_value: Languag 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.')) diff --git a/archinstall/lib/user_interaction/locale_conf.py b/archinstall/lib/user_interaction/locale_conf.py index bbbe070b..88aec64e 100644 --- a/archinstall/lib/user_interaction/locale_conf.py +++ b/archinstall/lib/user_interaction/locale_conf.py @@ -3,8 +3,7 @@ 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 +from ..menu import Menu, MenuSelectionType if TYPE_CHECKING: _: Any diff --git a/archinstall/lib/user_interaction/manage_users_conf.py b/archinstall/lib/user_interaction/manage_users_conf.py index 84ce3556..879578da 100644 --- a/archinstall/lib/user_interaction/manage_users_conf.py +++ b/archinstall/lib/user_interaction/manage_users_conf.py @@ -4,8 +4,7 @@ import re from typing import Any, Dict, 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 @@ -27,14 +26,14 @@ class UserList(ListManager): ] super().__init__(prompt, lusers, [self._actions[0]], self._actions[1:]) - def reformat(self, data: List[User]) -> Dict[str, User]: + def reformat(self, data: List[User]) -> Dict[str, Any]: 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} + display_data: Dict[str, Optional[User]] = {f' {rows[0]}': None, f' {rows[1]}': None} for row, user in zip(rows[2:], data): row = row.replace('|', '\\|') @@ -53,16 +52,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 @@ -80,16 +79,20 @@ class UserList(ListManager): 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/user_interaction/network_conf.py index 5e637f23..b682c1d2 100644 --- a/archinstall/lib/user_interaction/network_conf.py +++ b/archinstall/lib/user_interaction/network_conf.py @@ -4,14 +4,12 @@ import ipaddress import logging from typing import Any, Optional, TYPE_CHECKING, List, Union, Dict -from ..menu.menu import MenuSelectionType -from ..menu.text_input import TextInput +from ..menu import MenuSelectionType, TextInput from ..models.network_configuration import NetworkConfiguration, NicType from ..networking import list_interfaces -from ..menu import Menu from ..output import log, FormattedOutput -from ..menu.list_manager import ListManager +from ..menu import ListManager, Menu if TYPE_CHECKING: _: Any 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 index 5b4ae2b3..e05b9afe 100644 --- a/archinstall/lib/user_interaction/save_conf.py +++ b/archinstall/lib/user_interaction/save_conf.py @@ -5,38 +5,30 @@ 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 +from ..configuration import ConfigurationOutput 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}' + serialized = config_output.user_config_to_json() + return f'{config_output.user_configuration_file}\n{serialized}' 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}' + if maybe_serial := config_output.user_credentials_to_json(): + return f'{config_output.user_credentials_file}\n{maybe_serial}' 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(): + if 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 @@ -61,6 +53,9 @@ def save_config(config: Dict): if choice.type_ == MenuSelectionType.Skip: return + save_config_value = choice.single_value + saving_key = [k for k, v in options.items() if v == save_config_value][0] + dirs_to_exclude = [ '/bin', '/dev', @@ -76,19 +71,19 @@ def save_config(config: Dict): '/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('Ignore configuration option 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')) - ) + + directories = SysCommand(file_picker_command).decode() + + if directories is None: + raise ValueError('Failed to retrieve possible configuration directories') + + possible_save_dirs = list(filter(None, directories.split('\x00'))) selection = Menu( _('Select directory (or directories) for saving configuration files'), @@ -101,35 +96,18 @@ def save_config(config: Dict): 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 - ) - + + save_dirs = selection.multi_value + + log(f'Saving {saving_key} configuration files to {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: + if options['user_config'] == save_config_value: config_output.save_user_config(save_dir) - elif options['user_creds'] == choice.value: + elif options['user_creds'] == save_config_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: + elif options['all'] == save_config_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/system_conf.py b/archinstall/lib/user_interaction/system_conf.py index e1581677..3f57d0e7 100644 --- a/archinstall/lib/user_interaction/system_conf.py +++ b/archinstall/lib/user_interaction/system_conf.py @@ -1,19 +1,16 @@ from __future__ import annotations -from typing import List, Any, Dict, TYPE_CHECKING +from typing import List, Any, Dict, TYPE_CHECKING, Optional -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 +from ..menu import MenuSelectionType, Menu +from ..models.bootloader import Bootloader if TYPE_CHECKING: _: Any -def select_kernel(preset: List[str] = None) -> List[str]: +def select_kernel(preset: List[str] = []) -> List[str]: """ Asks the user to select a kernel for system. @@ -39,39 +36,36 @@ def select_kernel(preset: List[str] = None) -> List[str]: match choice.type_: case MenuSelectionType.Skip: return preset case MenuSelectionType.Reset: return [] - case MenuSelectionType.Selection: return choice.value + case MenuSelectionType.Selection: return choice.value # type: ignore -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!')) +def ask_for_bootloader(preset: Bootloader) -> Bootloader: + # when the system only supports grub + if not has_uefi(): + options = [Bootloader.Grub.value] + default = Bootloader.Grub.value + else: + options = Bootloader.values() + default = Bootloader.Systemd.value - warning = str(_('If you reset the harddrive selection this will also reset the current disk layout. Are you sure?')) + preset_value = preset.value if preset else None - selected_harddrive = Menu( - title, - list(options.keys()), - multi=True, - allow_reset=True, - allow_reset_warning_msg=warning + choice = Menu( + _('Choose a bootloader'), + options, + preset_values=preset_value, + sort=False, + default_option=default ).run() - match selected_harddrive.type_: - case MenuSelectionType.Reset: return [] + match choice.type_: case MenuSelectionType.Skip: return preset - case MenuSelectionType.Selection: return [options[i] for i in selected_harddrive.value] + case MenuSelectionType.Selection: return Bootloader(choice.value) + + return preset -def select_driver(options: Dict[str, Any] = AVAILABLE_GFX_DRIVERS) -> str: +def select_driver(options: Dict[str, Any] = {}, current_value: Optional[str] = None) -> Optional[str]: """ Some what convoluted function, whose job is simple. Select a graphics driver from a pre-defined set of popular options. @@ -80,78 +74,31 @@ def select_driver(options: Dict[str, Any] = AVAILABLE_GFX_DRIVERS) -> str: there for appeal to the general public first and edge cases later) """ - drivers = sorted(list(options)) + if not options: + options = AVAILABLE_GFX_DRIVERS + + drivers = sorted(list(options.keys())) 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' + 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' - )) + 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(_('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() + title += str(_('\nSelect a graphics driver or leave blank to install all open-source drivers')) - if choice.type_ != MenuSelectionType.Selection: - return arguments.get('gfx_driver') + preset = current_value if current_value else None + choice = Menu(title, drivers, preset_values=preset).run() - 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.") + if choice.type_ != MenuSelectionType.Selection: + return None + return choice.value # type: ignore -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 + return current_value def ask_for_swap(preset: bool = True) -> bool: @@ -166,3 +113,5 @@ def ask_for_swap(preset: bool = True) -> bool: 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/user_interaction/utils.py b/archinstall/lib/user_interaction/utils.py index 7ee6fc07..918945c0 100644 --- a/archinstall/lib/user_interaction/utils.py +++ b/archinstall/lib/user_interaction/utils.py @@ -1,13 +1,9 @@ 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 ..models import PasswordStrength from ..output import log if TYPE_CHECKING: @@ -36,44 +32,3 @@ def get_password(prompt: str = '') -> Optional[str]: 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..ded480ae --- /dev/null +++ b/archinstall/lib/utils/util.py @@ -0,0 +1,30 @@ +from pathlib import Path +from typing import Any, TYPE_CHECKING, Optional + +from ..output import log + +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 + log(_('Not a valid directory: {}').format(dest_path), fg='red') + + +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 |