archinstall/archinstall/lib/installer.py

1342 lines
47 KiB
Python

import glob
import os
import re
import shlex
import shutil
import subprocess
import time
from pathlib import Path
from typing import Any, List, Optional, TYPE_CHECKING, Union, Dict, Callable
from . import disk
from .exceptions import DiskError, ServiceException, RequirementError, HardwareIncompatibilityError, SysCallError
from .general import SysCommand
from .hardware import SysInfo
from .locale import LocaleConfiguration
from .locale import verify_keyboard_layout, verify_x11_keyboard_layout
from .luks import Luks2
from .mirrors import use_mirrors, MirrorConfiguration, add_custom_mirrors
from .models.bootloader import Bootloader
from .models.network_configuration import Nic
from .models.users import User
from .output import log, error, info, warn, debug
from . import pacman
from .pacman import Pacman
from .plugins import plugins
from .storage import storage
if TYPE_CHECKING:
_: Any
# Any package that the Installer() is responsible for (optional and the default ones)
__packages__ = ["base", "base-devel", "linux-firmware", "linux", "linux-lts", "linux-zen", "linux-hardened"]
# Additional packages that are installed if the user is running the Live ISO with accessibility tools enabled
__accessibility_packages__ = ["brltty", "espeakup", "alsa-utils"]
def accessibility_tools_in_use() -> bool:
return os.system('systemctl is-active --quiet espeakup.service') == 0
class Installer:
def __init__(
self,
target: Path,
disk_config: disk.DiskLayoutConfiguration,
disk_encryption: Optional[disk.DiskEncryption] = None,
base_packages: List[str] = [],
kernels: Optional[List[str]] = None
):
"""
`Installer()` is the wrapper for most basic installation steps.
It also wraps :py:func:`~archinstall.Installer.pacstrap` among other things.
"""
self.base_packages = base_packages or __packages__[:3]
self.kernels = kernels or ['linux']
self._disk_config = disk_config
self._disk_encryption = disk_encryption or disk.DiskEncryption(disk.EncryptionType.NoEncryption)
self.target: Path = target
self.init_time = time.strftime('%Y-%m-%d_%H-%M-%S')
self.milliseconds = int(str(time.time()).split('.')[1])
self.helper_flags: Dict[str, Any] = {'base': False, 'bootloader': None}
for kernel in self.kernels:
self.base_packages.append(kernel)
# If using accessibility tools in the live environment, append those to the packages list
if accessibility_tools_in_use():
self.base_packages.extend(__accessibility_packages__)
self.post_base_install: List[Callable] = []
# TODO: Figure out which one of these two we'll use.. But currently we're mixing them..
storage['session'] = self
storage['installation_session'] = self
self.modules: List[str] = []
self._binaries: List[str] = []
self._files: List[str] = []
# systemd, sd-vconsole and sd-encrypt will be replaced by udev, keymap and encrypt
# if HSM is not used to encrypt the root volume. Check mkinitcpio() function for that override.
self._hooks: List[str] = [
"base", "systemd", "autodetect", "keyboard",
"sd-vconsole", "modconf", "block", "filesystems", "fsck"
]
self._kernel_params: List[str] = []
self._fstab_entries: List[str] = []
self._zram_enabled = False
self.pacman = Pacman(self.target, storage['arguments'].get('silent', False))
def __enter__(self) -> 'Installer':
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is not None:
error(exc_val)
self.sync_log_to_install_medium()
# We avoid printing /mnt/<log path> because that might confuse people if they note it down
# and then reboot, and a identical log file will be found in the ISO medium anyway.
print(_("[!] A log file has been created here: {}").format(os.path.join(storage['LOG_PATH'], storage['LOG_FILE'])))
print(_(" Please submit this issue (and file) to https://github.com/archlinux/archinstall/issues"))
raise exc_val
if not (missing_steps := self.post_install_check()):
log('Installation completed without any errors. You may now reboot.', fg='green')
self.sync_log_to_install_medium()
return True
else:
warn('Some required steps were not successfully installed/configured before leaving the installer:')
for step in missing_steps:
warn(f' - {step}')
warn(f"Detailed error logs can be found at: {storage['LOG_PATH']}")
warn("Submit this zip file as an issue to https://github.com/archlinux/archinstall/issues")
self.sync_log_to_install_medium()
return False
def _verify_service_stop(self):
"""
Certain services might be running that affects the system during installation.
One such service is "reflector.service" which updates /etc/pacman.d/mirrorlist
We need to wait for it before we continue since we opted in to use a custom mirror/region.
"""
info('Waiting for time sync (systemd-timesyncd.service) to complete.')
while True:
time_val = SysCommand('timedatectl show --property=NTPSynchronized --value').decode()
if time_val and time_val.strip() == 'yes':
break
time.sleep(1)
info('Waiting for automatic mirror selection (reflector) to complete.')
while self._service_state('reflector') not in ('dead', 'failed', 'exited'):
time.sleep(1)
# info('Waiting for pacman-init.service to complete.')
# while self._service_state('pacman-init') not in ('dead', 'failed', 'exited'):
# time.sleep(1)
info('Waiting for Arch Linux keyring sync (archlinux-keyring-wkd-sync) to complete.')
# Wait for the timer to kick in
while self._service_started('archlinux-keyring-wkd-sync.timer') is None:
time.sleep(1)
# Wait for the service to enter a finished state
while self._service_state('archlinux-keyring-wkd-sync.service') not in ('dead', 'failed', 'exited'):
time.sleep(1)
def _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)
NOTE: this function should be run AFTER running the mount_ordered_layout function
"""
boot_mount = self.target / 'boot'
lsblk_info = disk.get_lsblk_by_mountpoint(boot_mount)
if len(lsblk_info) > 0:
if lsblk_info[0].size < disk.Size(200, disk.Unit.MiB, disk.SectorSize.default()):
raise DiskError(
f'The boot partition mounted at {boot_mount} is not large enough to install a boot loader. '
f'Please resize it to at least 200MiB and re-run the installation.'
)
def sanity_check(self):
# self._verify_boot_part()
self._verify_service_stop()
def mount_ordered_layout(self):
info('Mounting partitions in order')
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 or Path('/'))
enc_partitions = []
if self._disk_encryption.encryption_type is not disk.EncryptionType.NoEncryption:
enc_partitions = list(set(sorted_part_mods) & set(self._disk_encryption.partitions))
# attempt to decrypt all luks partitions
luks_handlers = self._prepare_luks_partitions(enc_partitions)
for part_mod in sorted_part_mods:
if luks_handler := luks_handlers.get(part_mod):
# mount encrypted partition
self._mount_luks_partition(part_mod, luks_handler)
else:
# partition is not encrypted
self._mount_partition(part_mod)
def _prepare_luks_partitions(self, partitions: List[disk.PartitionModification]) -> Dict[disk.PartitionModification, Luks2]:
return {
part_mod: disk.device_handler.unlock_luks2_dev(
part_mod.dev_path,
part_mod.mapper_name,
self._disk_encryption.encryption_password
)
for part_mod in partitions
if part_mod.mapper_name and part_mod.dev_path
}
def _mount_partition(self, part_mod: disk.PartitionModification):
# it would be none if it's btrfs as the subvolumes will have the mountpoints defined
if part_mod.mountpoint and part_mod.dev_path:
target = self.target / part_mod.relative_mountpoint
disk.device_handler.mount(part_mod.dev_path, target, options=part_mod.mount_options)
if part_mod.fs_type == disk.FilesystemType.Btrfs and part_mod.dev_path:
self._mount_btrfs_subvol(part_mod.dev_path, part_mod.btrfs_subvols)
def _mount_luks_partition(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 and luks_handler.mapper_dev:
target = self.target / part_mod.relative_mountpoint
disk.device_handler.mount(luks_handler.mapper_dev, target, options=part_mod.mount_options)
if part_mod.fs_type == disk.FilesystemType.Btrfs and luks_handler.mapper_dev:
self._mount_btrfs_subvol(luks_handler.mapper_dev, part_mod.btrfs_subvols)
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.safe_dev_path,
mapper_name=part_mod.mapper_name,
password=self._disk_encryption.encryption_password
)
if gen_enc_file and not part_mod.is_root():
info(f'Creating key-file: {part_mod.dev_path}')
luks_handler.create_keyfile(self.target)
if part_mod.is_root() and not gen_enc_file:
if self._disk_encryption.hsm_device:
disk.Fido2.fido2_enroll(
self._disk_encryption.hsm_device,
part_mod,
self._disk_encryption.encryption_password
)
def sync_log_to_install_medium(self) -> bool:
# Copy over the install log (if there is one) to the install medium if
# at least the base has been strapped in, otherwise we won't have a filesystem/structure to copy to.
if self.helper_flags.get('base-strapped', False) is True:
if filename := storage.get('LOG_FILE', None):
absolute_logfile = os.path.join(storage.get('LOG_PATH', './'), filename)
if not os.path.isdir(f"{self.target}/{os.path.dirname(absolute_logfile)}"):
os.makedirs(f"{self.target}/{os.path.dirname(absolute_logfile)}")
shutil.copy2(absolute_logfile, f"{self.target}/{absolute_logfile}")
return True
def add_swapfile(self, size='4G', enable_resume=True, file='/swapfile'):
if file[:1] != '/':
file = f"/{file}"
if len(file.strip()) <= 0 or file == '/':
raise ValueError(f"The filename for the swap file has to be a valid path, not: {self.target}{file}")
SysCommand(f'dd if=/dev/zero of={self.target}{file} bs={size} count=1')
SysCommand(f'chmod 0600 {self.target}{file}')
SysCommand(f'mkswap {self.target}{file}')
self._fstab_entries.append(f'{file} none swap defaults 0 0')
if enable_resume:
resume_uuid = SysCommand(f'findmnt -no UUID -T {self.target}{file}').decode()
resume_offset = SysCommand(
f'/usr/bin/filefrag -v {self.target}{file}'
).decode().split('0:', 1)[1].split(":", 1)[1].split("..", 1)[0].strip()
self._hooks.append('resume')
self._kernel_params.append(f'resume=UUID={resume_uuid}')
self._kernel_params.append(f'resume_offset={resume_offset}')
def post_install_check(self, *args :str, **kwargs :str) -> List[str]:
return [step for step, flag in self.helper_flags.items() if flag is False]
def set_mirrors(self, mirror_config: MirrorConfiguration):
for plugin in plugins.values():
if hasattr(plugin, 'on_mirrors'):
if result := plugin.on_mirrors(mirror_config):
mirror_config = result
destination = f'{self.target}/etc/pacman.d/mirrorlist'
if mirror_config.mirror_regions:
use_mirrors(mirror_config.mirror_regions, destination)
if mirror_config.custom_mirrors:
add_custom_mirrors(mirror_config.custom_mirrors)
def genfstab(self, flags :str = '-pU'):
fstab_path = self.target / "etc" / "fstab"
info(f"Updating {fstab_path}")
try:
gen_fstab = SysCommand(f'/usr/bin/genfstab {flags} {self.target}').decode()
except SysCallError as err:
raise RequirementError(f'Could not generate fstab, strapping in packages most likely failed (disk out of space?)\n Error: {err}')
with open(fstab_path, 'a') as fp:
fp.write(gen_fstab)
if not fstab_path.is_file():
raise RequirementError(f'Could not create fstab file')
for plugin in plugins.values():
if hasattr(plugin, 'on_genfstab'):
if plugin.on_genfstab(self) is True:
break
with open(fstab_path, 'a') as fp:
for entry in self._fstab_entries:
fp.write(f'{entry}\n')
for mod in self._disk_config.device_modifications:
for part_mod in mod.partitions:
if part_mod.fs_type != disk.FilesystemType.Btrfs:
continue
with fstab_path.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_path.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:
fh.write(hostname + '\n')
def set_locale(self, locale_config: LocaleConfiguration) -> bool:
modifier = ''
lang = locale_config.sys_lang
encoding = locale_config.sys_enc
# This is a temporary patch to fix #1200
if '.' in locale_config.sys_lang:
lang, potential_encoding = locale_config.sys_lang.split('.', 1)
# Override encoding if encoding is set to the default parameter
# and the "found" encoding differs.
if locale_config.sys_enc == 'UTF-8' and locale_config.sys_enc != potential_encoding:
encoding = potential_encoding
# Make sure we extract the modifier, that way we can put it in if needed.
if '@' in locale_config.sys_lang:
lang, modifier = locale_config.sys_lang.split('@', 1)
modifier = f"@{modifier}"
# - End patch
locale_gen = self.target / 'etc/locale.gen'
locale_gen_lines = locale_gen.read_text().splitlines(True)
# A locale entry in /etc/locale.gen may or may not contain the encoding
# in the first column of the entry; check for both cases.
entry_re = re.compile(rf'#{lang}(\.{encoding})?{modifier} {encoding}')
for index, line in enumerate(locale_gen_lines):
if entry_re.match(line):
uncommented_line = line.removeprefix('#')
locale_gen_lines[index] = uncommented_line
locale_gen.write_text(''.join(locale_gen_lines))
lang_value = uncommented_line.split()[0]
break
else:
error(f"Invalid locale: language '{locale_config.sys_lang}', encoding '{locale_config.sys_enc}'")
return False
try:
SysCommand(f'/usr/bin/arch-chroot {self.target} locale-gen')
except SysCallError as e:
error(f'Failed to run locale-gen on target: {e}')
return False
(self.target / 'etc/locale.conf').write_text(f'LANG={lang_value}\n')
return True
def set_timezone(self, zone :str, *args :str, **kwargs :str) -> bool:
if not zone:
return True
if not len(zone):
return True # Redundant
for plugin in plugins.values():
if hasattr(plugin, 'on_timezone'):
if result := plugin.on_timezone(zone):
zone = result
if (Path("/usr") / "share" / "zoneinfo" / zone).exists():
(Path(self.target) / "etc" / "localtime").unlink(missing_ok=True)
SysCommand(f'/usr/bin/arch-chroot {self.target} ln -s /usr/share/zoneinfo/{zone} /etc/localtime')
return True
else:
warn(f'Time zone {zone} does not exist, continuing with system default')
return False
def activate_time_synchronization(self) -> None:
info('Activating systemd-timesyncd for time synchronization using Arch Linux and ntp.org NTP servers')
self.enable_service('systemd-timesyncd')
def enable_espeakup(self) -> None:
info('Enabling espeakup.service for speech synthesis (accessibility)')
self.enable_service('espeakup')
def enable_periodic_trim(self) -> None:
info("Enabling periodic TRIM")
# fstrim is owned by util-linux, a dependency of both base and systemd.
self.enable_service("fstrim.timer")
def enable_service(self, services: Union[str, List[str]]) -> None:
if isinstance(services, str):
services = [services]
for service in services:
info(f'Enabling service {service}')
try:
self.arch_chroot(f'systemctl enable {service}')
except SysCallError as err:
raise ServiceException(f"Unable to start service {service}: {err}")
for plugin in plugins.values():
if hasattr(plugin, 'on_service'):
plugin.on_service(service)
def run_command(self, cmd :str, *args :str, **kwargs :str) -> SysCommand:
return SysCommand(f'/usr/bin/arch-chroot {self.target} {cmd}')
def arch_chroot(self, cmd :str, run_as :Optional[str] = None) -> SysCommand:
if run_as:
cmd = f"su - {run_as} -c {shlex.quote(cmd)}"
return self.run_command(cmd)
def drop_to_shell(self) -> None:
subprocess.check_call(f"/usr/bin/arch-chroot {self.target}", shell=True)
def configure_nic(self, nic: Nic):
conf = nic.as_systemd_config()
for plugin in plugins.values():
if hasattr(plugin, 'on_configure_nic'):
conf = plugin.on_configure_nic(
nic.iface,
nic.dhcp,
nic.ip,
nic.gateway,
nic.dns
) or conf
with open(f"{self.target}/etc/systemd/network/10-{nic.iface}.network", "a") as netconf:
netconf.write(str(conf))
def copy_iso_network_config(self, enable_services :bool = False) -> bool:
# Copy (if any) iwd password and config files
if os.path.isdir('/var/lib/iwd/'):
if psk_files := glob.glob('/var/lib/iwd/*.psk'):
if not os.path.isdir(f"{self.target}/var/lib/iwd"):
os.makedirs(f"{self.target}/var/lib/iwd")
if enable_services:
# If we haven't installed the base yet (function called pre-maturely)
if self.helper_flags.get('base', False) is False:
self.base_packages.append('iwd')
# This function will be called after minimal_installation()
# as a hook for post-installs. This hook is only needed if
# base is not installed yet.
def post_install_enable_iwd_service(*args :str, **kwargs :str):
self.enable_service('iwd')
self.post_base_install.append(post_install_enable_iwd_service)
# Otherwise, we can go ahead and add the required package
# and enable it's service:
else:
self.pacman.strap('iwd')
self.enable_service('iwd')
for psk in psk_files:
shutil.copy2(psk, f"{self.target}/var/lib/iwd/{os.path.basename(psk)}")
# Copy (if any) systemd-networkd config files
if netconfigurations := glob.glob('/etc/systemd/network/*'):
if not os.path.isdir(f"{self.target}/etc/systemd/network/"):
os.makedirs(f"{self.target}/etc/systemd/network/")
for netconf_file in netconfigurations:
shutil.copy2(netconf_file, f"{self.target}/etc/systemd/network/{os.path.basename(netconf_file)}")
if enable_services:
# If we haven't installed the base yet (function called pre-maturely)
if self.helper_flags.get('base', False) is False:
def post_install_enable_networkd_resolved(*args :str, **kwargs :str):
self.enable_service(['systemd-networkd', 'systemd-resolved'])
self.post_base_install.append(post_install_enable_networkd_resolved)
# Otherwise, we can go ahead and enable the services
else:
self.enable_service(['systemd-networkd', 'systemd-resolved'])
return True
def mkinitcpio(self, flags: List[str], locale_config: LocaleConfiguration) -> bool:
for plugin in plugins.values():
if hasattr(plugin, 'on_mkinitcpio'):
# Allow plugins to override the usage of mkinitcpio altogether.
if plugin.on_mkinitcpio(self):
return True
# mkinitcpio will error out if there's no vconsole.
if (vconsole := Path(f"{self.target}/etc/vconsole.conf")).exists() is False:
with vconsole.open('w') as fh:
fh.write(f"KEYMAP={locale_config.kb_layout}\n")
with open(f'{self.target}/etc/mkinitcpio.conf', 'w') as mkinit:
mkinit.write(f"MODULES=({' '.join(self.modules)})\n")
mkinit.write(f"BINARIES=({' '.join(self._binaries)})\n")
mkinit.write(f"FILES=({' '.join(self._files)})\n")
if not self._disk_encryption.hsm_device:
# For now, if we don't use HSM we revert to the old
# way of setting up encryption hooks for mkinitcpio.
# This is purely for stability reasons, we're going away from this.
# * systemd -> udev
# * sd-vconsole -> keymap
self._hooks = [hook.replace('systemd', 'udev').replace('sd-vconsole', 'keymap') for hook in self._hooks]
mkinit.write(f"HOOKS=({' '.join(self._hooks)})\n")
try:
SysCommand(f'/usr/bin/arch-chroot {self.target} mkinitcpio {" ".join(flags)}', peek_output=True)
return True
except SysCallError as error:
if error.worker:
log(error.worker._trace_log.decode())
return False
def _get_microcode(self) -> Optional[Path]:
if not SysInfo.is_vm():
if vendor := SysInfo.cpu_vendor():
return vendor.get_ucode()
return None
def minimal_installation(
self,
testing: bool = False,
multilib: bool = False,
hostname: str = 'archinstall',
locale_config: LocaleConfiguration = LocaleConfiguration.default()
):
_disable_fstrim = False
for mod in self._disk_config.device_modifications:
for part in mod.partitions:
if part.fs_type is not None:
if (pkg := part.fs_type.installation_pkg) is not None:
self.base_packages.append(pkg)
if (module := part.fs_type.installation_module) is not None:
self.modules.append(module)
if (binary := part.fs_type.installation_binary) is not None:
self._binaries.append(binary)
# https://github.com/archlinux/archinstall/issues/1837
if part.fs_type.fs_type_mount == 'btrfs':
_disable_fstrim = True
# There is not yet an fsck tool for NTFS. If it's being used for the root filesystem, the hook should be removed.
if part.fs_type.fs_type_mount == 'ntfs3' and part.mountpoint == self.target:
if 'fsck' in self._hooks:
self._hooks.remove('fsck')
if part in self._disk_encryption.partitions:
if self._disk_encryption.hsm_device:
# Required by mkinitcpio to add support for fido2-device options
self.pacman.strap('libfido2')
if 'sd-encrypt' not in self._hooks:
self._hooks.insert(self._hooks.index('filesystems'), 'sd-encrypt')
else:
if 'encrypt' not in self._hooks:
self._hooks.insert(self._hooks.index('filesystems'), 'encrypt')
if not SysInfo.has_uefi():
self.base_packages.append('grub')
if ucode := self._get_microcode():
(self.target / 'boot' / ucode).unlink(missing_ok=True)
self.base_packages.append(ucode.stem)
else:
debug('Archinstall will not install any ucode.')
# Determine whether to enable multilib/testing repositories before running pacstrap if testing flag is set.
# This action takes place on the host system as pacstrap copies over package repository lists.
pacman_conf = pacman.Config(self.target)
if multilib:
info("The multilib flag is set. This system will be installed with the multilib repository enabled.")
pacman_conf.enable(pacman.Repo.Multilib)
else:
info("The multilib flag is not set. This system will be installed without multilib repositories enabled.")
if testing:
info("The testing flag is set. This system will be installed with testing repositories enabled.")
pacman_conf.enable(pacman.Repo.Testing)
if multilib:
pacman_conf.enable(pacman.Repo.MultilibTesting)
else:
info("The testing flag is not set. This system will be installed without testing repositories enabled.")
pacman_conf.apply()
self.pacman.strap(self.base_packages)
self.helper_flags['base-strapped'] = True
pacman_conf.persist()
# Periodic TRIM may improve the performance and longevity of SSDs whilst
# having no adverse effect on other devices. Most distributions enable
# periodic TRIM by default.
#
# https://github.com/archlinux/archinstall/issues/880
# https://github.com/archlinux/archinstall/issues/1837
# https://github.com/archlinux/archinstall/issues/1841
if not _disable_fstrim:
self.enable_periodic_trim()
# TODO: Support locale and timezone
# os.remove(f'{self.target}/etc/localtime')
# sys_command(f'/usr/bin/arch-chroot {self.target} ln -s /usr/share/zoneinfo/{localtime} /etc/localtime')
# sys_command('/usr/bin/arch-chroot /mnt hwclock --hctosys --localtime')
self.set_hostname(hostname)
self.set_locale(locale_config)
# TODO: Use python functions for this
SysCommand(f'/usr/bin/arch-chroot {self.target} chmod 700 /root')
if not self.mkinitcpio(['-P'], locale_config):
error(f"Error generating initramfs (continuing anyway)")
self.helper_flags['base'] = True
# Run registered post-install hooks
for function in self.post_base_install:
info(f"Running post-installation hook: {function}")
function(self)
for plugin in plugins.values():
if hasattr(plugin, 'on_install'):
plugin.on_install(self)
def setup_swap(self, kind :str = 'zram'):
if kind == 'zram':
info(f"Setting up swap on zram")
self.pacman.strap('zram-generator')
# We could use the default example below, but maybe not the best idea: https://github.com/archlinux/archinstall/pull/678#issuecomment-962124813
# zram_example_location = '/usr/share/doc/zram-generator/zram-generator.conf.example'
# shutil.copy2(f"{self.target}{zram_example_location}", f"{self.target}/usr/lib/systemd/zram-generator.conf")
with open(f"{self.target}/etc/systemd/zram-generator.conf", "w") as zram_conf:
zram_conf.write("[zram0]\n")
self.enable_service('systemd-zram-setup@zram0.service')
self._zram_enabled = True
else:
raise ValueError(f"Archinstall currently only supports setting up swap on zram")
def _get_efi_partition(self) -> Optional[disk.PartitionModification]:
for layout in self._disk_config.device_modifications:
if partition := layout.get_efi_partition():
return partition
return None
def _get_boot_partition(self) -> Optional[disk.PartitionModification]:
for layout in self._disk_config.device_modifications:
if boot := layout.get_boot_partition():
return boot
return None
def _get_root_partition(self) -> Optional[disk.PartitionModification]:
for mod in self._disk_config.device_modifications:
if root := mod.get_root_partition():
return root
return None
def _get_kernel_params(
self,
root_partition: disk.PartitionModification,
id_root: bool = True,
partuuid: bool = True
) -> List[str]:
kernel_parameters = []
if root_partition in self._disk_encryption.partitions:
# TODO: We need to detect if the encrypted device is a whole disk encryption,
# or simply a partition encryption. Right now we assume it's a partition (and we always have)
if self._disk_encryption and self._disk_encryption.hsm_device:
debug(f'Root partition is an encrypted device, identifying by UUID: {root_partition.uuid}')
# Note: UUID must be used, not PARTUUID for sd-encrypt to work
kernel_parameters.append(f'rd.luks.name={root_partition.uuid}=root')
# Note: tpm2-device and fido2-device don't play along very well:
# https://github.com/archlinux/archinstall/pull/1196#issuecomment-1129715645
kernel_parameters.append('rd.luks.options=fido2-device=auto,password-echo=no')
elif partuuid:
debug(f'Root partition is an encrypted device, identifying by PARTUUID: {root_partition.partuuid}')
kernel_parameters.append(f'cryptdevice=PARTUUID={root_partition.partuuid}:root')
else:
debug(f'Root partition is an encrypted device, identifying by UUID: {root_partition.uuid}')
kernel_parameters.append(f'cryptdevice=UUID={root_partition.uuid}:root')
if id_root:
kernel_parameters.append('root=/dev/mapper/root')
elif id_root:
if partuuid:
debug(f'Identifying root partition by PARTUUID: {root_partition.partuuid}')
kernel_parameters.append(f'root=PARTUUID={root_partition.partuuid}')
else:
debug(f'Identifying root partition by UUID: {root_partition.uuid}')
kernel_parameters.append(f'root=UUID={root_partition.uuid}')
# Zswap should be disabled when using zram.
# https://github.com/archlinux/archinstall/issues/881
if self._zram_enabled:
kernel_parameters.append('zswap.enabled=0')
if id_root:
for sub_vol in root_partition.btrfs_subvols:
if sub_vol.is_root():
kernel_parameters.append(f'rootflags=subvol={sub_vol.name}')
break
kernel_parameters.append('rw')
kernel_parameters.append(f'rootfstype={root_partition.safe_fs_type.fs_type_mount}')
kernel_parameters.extend(self._kernel_params)
debug(f'kernel parameters: {" ".join(kernel_parameters)}')
return kernel_parameters
def _add_systemd_bootloader(
self,
boot_partition: disk.PartitionModification,
root_partition: disk.PartitionModification,
efi_partition: Optional[disk.PartitionModification]
):
self.pacman.strap('efibootmgr')
if not SysInfo.has_uefi():
raise HardwareIncompatibilityError
# TODO: Ideally we would want to check if another config
# points towards the same disk and/or partition.
# And in which case we should do some clean up.
bootctl_options = []
if efi_partition and boot_partition != efi_partition:
bootctl_options.append(f'--esp-path={efi_partition.mountpoint}')
bootctl_options.append(f'--boot-path={boot_partition.mountpoint}')
# Install the boot loader
try:
SysCommand(f"/usr/bin/arch-chroot {self.target} bootctl {' '.join(bootctl_options)} install")
except SysCallError:
# Fallback, try creating the boot loader without touching the EFI variables
SysCommand(f"/usr/bin/arch-chroot {self.target} bootctl --no-variables {' '.join(bootctl_options)} install")
# Ensure that the $BOOT/loader/ directory exists before we try to create files in it.
#
# As mentioned in https://github.com/archlinux/archinstall/pull/1859 - we store the
# loader entries in $BOOT/loader/ rather than $ESP/loader/
# The current reasoning being that $BOOT works in both use cases as well
# as being tied to the current installation. This may change.
loader_dir = self.target / 'boot/loader'
loader_dir.mkdir(parents=True, exist_ok=True)
# Modify or create a loader.conf
loader_conf = loader_dir / 'loader.conf'
default = f'default {self.init_time}_{self.kernels[0]}.conf\n'
try:
with loader_conf.open() as loader:
loader_data = loader.readlines()
except FileNotFoundError:
loader_data = [
default,
'timeout 15\n'
]
else:
for index, line in enumerate(loader_data):
if line.startswith('default'):
loader_data[index] = default
elif line.startswith('#timeout'):
# We add in the default timeout to support dual-boot
loader_data[index] = line.removeprefix('#')
with loader_conf.open('w') as loader:
loader.writelines(loader_data)
# Ensure that the $BOOT/loader/entries/ directory exists before we try to create files in it
entries_dir = loader_dir / 'entries'
entries_dir.mkdir(parents=True, exist_ok=True)
comments = (
'# Created by: archinstall\n',
f'# Created on: {self.init_time}\n'
)
microcode = []
if ucode := self._get_microcode():
microcode.append(f'initrd /{ucode}\n')
else:
debug('Archinstall will not add any ucode to systemd-boot config.')
options = 'options ' + ' '.join(self._get_kernel_params(root_partition)) + '\n'
for kernel in self.kernels:
for variant in ("", "-fallback"):
# Setup the loader entry
entry = [
*comments,
f'title Arch Linux ({kernel}{variant})\n',
f'linux /vmlinuz-{kernel}\n',
*microcode,
f'initrd /initramfs-{kernel}{variant}.img\n',
options,
]
entry_conf = entries_dir / f'{self.init_time}_{kernel}{variant}.conf'
entry_conf.write_text(''.join(entry))
self.helper_flags['bootloader'] = 'systemd'
def _add_grub_bootloader(
self,
boot_partition: disk.PartitionModification,
root_partition: disk.PartitionModification,
efi_partition: Optional[disk.PartitionModification]
):
self.pacman.strap('grub') # no need?
grub_default = self.target / 'etc/default/grub'
config = grub_default.read_text()
kernel_parameters = ' '.join(self._get_kernel_params(root_partition, False, False))
config = re.sub(r'(GRUB_CMDLINE_LINUX=")("\n)', rf'\1{kernel_parameters}\2', config, 1)
grub_default.write_text(config)
info(f"GRUB boot partition: {boot_partition.dev_path}")
if boot_partition == root_partition and root_partition.mountpoint:
boot_dir = root_partition.mountpoint / 'boot'
elif boot_partition.mountpoint:
boot_dir = boot_partition.mountpoint
else:
raise ValueError('Could not detect boot directory')
command = [
'/usr/bin/arch-chroot',
str(self.target),
'grub-install',
'--debug'
]
if SysInfo.has_uefi():
if not efi_partition:
raise ValueError('Could not detect efi partition')
info(f"GRUB EFI partition: {efi_partition.dev_path}")
self.pacman.strap('efibootmgr') # TODO: Do we need? Yes, but remove from minimal_installation() instead?
boot_dir_arg = []
if boot_partition != efi_partition:
boot_dir_arg.append(f'--boot-directory={boot_dir}')
add_options = [
'--target=x86_64-efi',
f'--efi-directory={efi_partition.mountpoint}',
*boot_dir_arg,
'--bootloader-id=GRUB',
'--removable'
]
command.extend(add_options)
try:
SysCommand(command, peek_output=True)
except SysCallError:
try:
SysCommand(command, peek_output=True)
except SysCallError as err:
raise DiskError(f"Could not install GRUB to {self.target}{efi_partition.mountpoint}: {err}")
else:
info(f"GRUB boot partition: {boot_partition.dev_path}")
parent_dev_path = disk.device_handler.get_parent_device_path(boot_partition.safe_dev_path)
add_options = [
'--target=i386-pc',
'--recheck',
str(parent_dev_path)
]
try:
SysCommand(command + add_options, peek_output=True)
except SysCallError as err:
raise DiskError(f"Failed to install GRUB boot on {boot_partition.dev_path}: {err}")
try:
SysCommand(
f'/usr/bin/arch-chroot {self.target} '
f'grub-mkconfig -o {boot_dir}/grub/grub.cfg'
)
except SysCallError as err:
raise DiskError(f"Could not configure GRUB: {err}")
self.helper_flags['bootloader'] = "grub"
def _add_limine_bootloader(
self,
boot_partition: disk.PartitionModification,
root_partition: disk.PartitionModification
):
self.pacman.strap('limine')
info(f"Limine boot partition: {boot_partition.dev_path}")
root_uuid = root_partition.uuid
def create_pacman_hook(contents: str):
HOOK_DIR = "/etc/pacman.d/hooks"
SysCommand(f"/usr/bin/arch-chroot {self.target} mkdir -p {HOOK_DIR}")
SysCommand(f"/usr/bin/arch-chroot {self.target} sh -c \"echo '{contents}' > {HOOK_DIR}/liminedeploy.hook\"")
if SysInfo.has_uefi():
try:
# The `limine.sys` file, contains stage 3 code.
cmd = f'/usr/bin/arch-chroot' \
f' {self.target}' \
f' cp' \
f' /usr/share/limine/BOOTX64.EFI' \
f' /boot/EFI/BOOT/'
SysCommand(cmd, peek_output=True)
except SysCallError as err:
raise DiskError(f"Failed to install Limine BOOTX64.EFI on {boot_partition.dev_path}: {err}")
# Create the EFI limine pacman hook.
create_pacman_hook("""
[Trigger]
Operation = Install
Operation = Upgrade
Type = Package
Target = limine
[Action]
Description = Deploying Limine after upgrade...
When = PostTransaction
Exec = /usr/bin/cp /usr/share/limine/BOOTX64.EFI /boot/EFI/BOOT/
""")
else:
parent_dev_path = disk.device_handler.get_parent_device_path(boot_partition.safe_dev_path)
try:
# The `limine.sys` file, contains stage 3 code.
cmd = f'/usr/bin/arch-chroot' \
f' {self.target}' \
f' cp' \
f' /usr/share/limine/limine-bios.sys' \
f' /boot/limine-bios.sys'
SysCommand(cmd, peek_output=True)
# `limine bios-install` deploys the stage 1 and 2 to the disk.
cmd = f'/usr/bin/arch-chroot' \
f' {self.target}' \
f' limine' \
f' bios-install' \
f' {parent_dev_path}'
SysCommand(cmd, peek_output=True)
except SysCallError as err:
raise DiskError(f"Failed to install Limine on {boot_partition.dev_path}: {err}")
create_pacman_hook(f"""
[Trigger]
Operation = Install
Operation = Upgrade
Type = Package
Target = limine
[Action]
Description = Deploying Limine after upgrade...
When = PostTransaction
# XXX: Kernel name descriptors cannot be used since they are not persistent and
# can change after each boot.
Exec = /bin/sh -c \\"/usr/bin/limine bios-install /dev/disk/by-uuid/{root_uuid} && /usr/bin/cp /usr/share/limine/limine-bios.sys /boot/\\"
""")
# Limine does not ship with a default configuration file. We are going to
# create a basic one that is similar to the one GRUB generates.
try:
config = f"""
TIMEOUT=5
:Arch Linux
PROTOCOL=linux
KERNEL_PATH=boot:///vmlinuz-linux
CMDLINE=root=UUID={root_uuid} rw rootfstype={root_partition.safe_fs_type.value} loglevel=3
MODULE_PATH=boot:///initramfs-linux.img
:Arch Linux (fallback)
PROTOCOL=linux
KERNEL_PATH=boot:///vmlinuz-linux
CMDLINE=root=UUID={root_uuid} rw rootfstype={root_partition.safe_fs_type.value} loglevel=3
MODULE_PATH=boot:///initramfs-linux-fallback.img
"""
SysCommand(f"/usr/bin/arch-chroot {self.target} sh -c \"echo '{config}' > /boot/limine.cfg\"")
except SysCallError as err:
raise DiskError(f"Could not configure Limine: {err}")
self.helper_flags['bootloader'] = "limine"
def _add_efistub_bootloader(
self,
boot_partition: disk.PartitionModification,
root_partition: disk.PartitionModification
):
self.pacman.strap('efibootmgr')
if not SysInfo.has_uefi():
raise HardwareIncompatibilityError
# TODO: Ideally we would want to check if another config
# points towards the same disk and/or partition.
# And in which case we should do some clean up.
microcode = []
if ucode := self._get_microcode():
microcode.append(f'initrd=\\{ucode}')
else:
debug('Archinstall will not add any ucode to firmware boot entry.')
kernel_parameters = self._get_kernel_params(root_partition)
parent_dev_path = disk.device_handler.get_parent_device_path(boot_partition.safe_dev_path)
for kernel in self.kernels:
# Setup the firmware entry
cmdline = [
*microcode,
f"initrd=\\initramfs-{kernel}.img",
*kernel_parameters,
]
cmd = [
'efibootmgr',
'--disk', str(parent_dev_path),
'--part', str(boot_partition.partn),
'--create',
'--label', f'Arch Linux ({kernel})',
'--loader', f"/vmlinuz-{kernel}",
'--unicode', ' '.join(cmdline),
'--verbose'
]
SysCommand(cmd)
self.helper_flags['bootloader'] = "efistub"
def add_bootloader(self, bootloader: Bootloader):
"""
Adds a bootloader to the installation instance.
Archinstall supports one of three types:
* systemd-bootctl
* grub
* limine (beta)
* efistub (beta)
:param bootloader: Type of bootloader to be added
"""
for plugin in plugins.values():
if hasattr(plugin, 'on_add_bootloader'):
# Allow plugins to override the boot-loader handling.
# This allows for bot configuring and installing bootloaders.
if plugin.on_add_bootloader(self):
return True
efi_partition = self._get_efi_partition()
boot_partition = self._get_boot_partition()
root_partition = self._get_root_partition()
if boot_partition is None:
raise ValueError(f'Could not detect boot at mountpoint {self.target}')
if root_partition is None:
raise ValueError(f'Could not detect root at mountpoint {self.target}')
info(f'Adding bootloader {bootloader.value} to {boot_partition.dev_path}')
match bootloader:
case Bootloader.Systemd:
self._add_systemd_bootloader(boot_partition, root_partition, efi_partition)
case Bootloader.Grub:
self._add_grub_bootloader(boot_partition, root_partition, efi_partition)
case Bootloader.Efistub:
self._add_efistub_bootloader(boot_partition, root_partition)
case Bootloader.Limine:
self._add_limine_bootloader(boot_partition, root_partition)
def add_additional_packages(self, packages: Union[str, List[str]]) -> bool:
return self.pacman.strap(packages)
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):
info(f'Enabling sudo permissions for {entity}')
sudoers_dir = f"{self.target}/etc/sudoers.d"
# Creates directory if not 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)
# Appends a reference to the sudoers file, because if we are here sudoers.d did not exist yet
with open(f'{self.target}/etc/sudoers', 'a') as sudoers:
sudoers.write('@includedir /etc/sudoers.d\n')
# We count how many files are there already so we know which number to prefix the file with
num_of_rules_already = len(os.listdir(sudoers_dir))
file_num_str = "{:02d}".format(num_of_rules_already) # We want 00_user1, 01_user2, etc
# Guarantees that entity str does not contain invalid characters for a linux file name:
# \ / : * ? " < > |
safe_entity_file_name = re.sub(r'(\\|\/|:|\*|\?|"|<|>|\|)', '', entity)
rule_file_name = f"{sudoers_dir}/{file_num_str}_{safe_entity_file_name}"
with open(rule_file_name, 'a') as sudoers:
sudoers.write(f'{"%" if group else ""}{entity} ALL=(ALL) ALL\n')
# Guarantees sudoer conf file recommended perms
os.chmod(Path(rule_file_name), 0o440)
def create_users(self, users: Union[User, List[User]]):
if not isinstance(users, list):
users = [users]
for user in users:
self.user_create(user.username, user.password, user.groups, user.sudo)
def user_create(self, user :str, password :Optional[str] = None, groups :Optional[List[str]] = None, sudo :bool = False) -> None:
if groups is None:
groups = []
# This plugin hook allows for the plugin to handle the creation of the user.
# Password and Group management is still handled by user_create()
handled_by_plugin = False
for plugin in plugins.values():
if hasattr(plugin, 'on_user_create'):
if result := plugin.on_user_create(self, user):
handled_by_plugin = result
if not handled_by_plugin:
info(f'Creating user {user}')
try:
SysCommand(f'/usr/bin/arch-chroot {self.target} useradd -m -G wheel {user}')
except SysCallError as err:
raise SystemError(f"Could not create user inside installation: {err}")
for plugin in plugins.values():
if hasattr(plugin, 'on_user_created'):
if result := plugin.on_user_created(self, user):
handled_by_plugin = result
if password:
self.user_set_pw(user, password)
if groups:
for group in groups:
SysCommand(f'/usr/bin/arch-chroot {self.target} gpasswd -a {user} {group}')
if sudo and self.enable_sudo(user):
self.helper_flags['user'] = True
def user_set_pw(self, user :str, password :str) -> bool:
info(f'Setting password for {user}')
if user == 'root':
# This means the root account isn't locked/disabled with * in /etc/passwd
self.helper_flags['user'] = True
combo = f'{user}:{password}'
echo = shlex.join(['echo', combo])
sh = shlex.join(['sh', '-c', echo])
try:
SysCommand(f"/usr/bin/arch-chroot {self.target} " + sh[:-1] + " | chpasswd'")
return True
except SysCallError:
return False
def user_set_shell(self, user :str, shell :str) -> bool:
info(f'Setting shell for {user} to {shell}')
try:
SysCommand(f"/usr/bin/arch-chroot {self.target} sh -c \"chsh -s {shell} {user}\"")
return True
except SysCallError:
return False
def chown(self, owner :str, path :str, options :List[str] = []) -> bool:
cleaned_path = path.replace('\'', '\\\'')
try:
SysCommand(f"/usr/bin/arch-chroot {self.target} sh -c 'chown {' '.join(options)} {owner} {cleaned_path}'")
return True
except SysCallError:
return False
def set_keyboard_language(self, language: str) -> bool:
info(f"Setting keyboard language to {language}")
if len(language.strip()):
if not verify_keyboard_layout(language):
error(f"Invalid keyboard language specified: {language}")
return False
# In accordance with https://github.com/archlinux/archinstall/issues/107#issuecomment-841701968
# Setting an empty keymap first, allows the subsequent call to set layout for both console and x11.
from .boot import Boot
with Boot(self) as session:
os.system('/usr/bin/systemd-run --machine=archinstall --pty localectl set-keymap ""')
try:
session.SysCommand(["localectl", "set-keymap", language])
except SysCallError as err:
raise ServiceException(f"Unable to set locale '{language}' for console: {err}")
info(f"Keyboard language for this installation is now set to: {language}")
else:
info('Keyboard language was not changed from default (no language specified)')
return True
def set_x11_keyboard_language(self, language: str) -> bool:
"""
A fallback function to set x11 layout specifically and separately from console layout.
This isn't strictly necessary since .set_keyboard_language() does this as well.
"""
info(f"Setting x11 keyboard language to {language}")
if len(language.strip()):
if not verify_x11_keyboard_layout(language):
error(f"Invalid x11-keyboard language specified: {language}")
return False
from .boot import Boot
with Boot(self) as session:
session.SysCommand(["localectl", "set-x11-keymap", '""'])
try:
session.SysCommand(["localectl", "set-x11-keymap", language])
except SysCallError as err:
raise ServiceException(f"Unable to set locale '{language}' for X11: {err}")
else:
info(f'X11-Keyboard language was not changed from default (no language specified)')
return True
def _service_started(self, service_name: str) -> Optional[str]:
if os.path.splitext(service_name)[1] not in ('.service', '.target', '.timer'):
service_name += '.service' # Just to be safe
last_execution_time = SysCommand(
f"systemctl show --property=ActiveEnterTimestamp --no-pager {service_name}",
environment_vars={'SYSTEMD_COLORS': '0'}
).decode().lstrip('ActiveEnterTimestamp=')
if not last_execution_time:
return None
return last_execution_time
def _service_state(self, service_name: str) -> str:
if os.path.splitext(service_name)[1] not in ('.service', '.target', '.timer'):
service_name += '.service' # Just to be safe
return SysCommand(
f'systemctl show --no-pager -p SubState --value {service_name}',
environment_vars={'SYSTEMD_COLORS': '0'}
).decode()