diff --git a/archinstall/applications/aur_helper.py b/archinstall/applications/aur_helper.py new file mode 100644 index 0000000000..90aee9a632 --- /dev/null +++ b/archinstall/applications/aur_helper.py @@ -0,0 +1,69 @@ +import shlex +from typing import TYPE_CHECKING + +from archinstall.lib.exceptions import PackageError, SysCallError +from archinstall.lib.models.aur import AURHelperConfiguration +from archinstall.lib.models.users import User +from archinstall.lib.output import debug, info +from archinstall.lib.translationhandler import tr + +if TYPE_CHECKING: + from archinstall.lib.installer import Installer + + +class AURHelperApp: + """Bootstraps an AUR helper (paru / yay) inside the chroot. + + The helper package name comes from a controlled ``AURHelper`` enum value, + and the build username is validated upstream by archinstall's user-creation + path, so shell interpolation of these values is safe. Dynamic file system + paths are still passed through ``shlex.quote``. + """ + + _SUDOERS_FILE = 'etc/sudoers.d/99_aur_build' + + def install( + self, + install_session: Installer, + helper_config: AURHelperConfiguration, + build_user: User, + ) -> None: + install_session.add_additional_packages(['base-devel', 'git']) + + sudoers_path = install_session.target / self._SUDOERS_FILE + sudoers_path.write_text(f'{build_user.username} ALL=(ALL) NOPASSWD: /usr/bin/pacman\n') + sudoers_path.chmod(0o440) + + helper_pkg = helper_config.helper.value + # Build inside the user's $HOME rather than /tmp: arch-chroot -S spawns a + # transient systemd-run unit and the chroot's /tmp is the on-disk + # directory (mode 0755, root-owned) since no boot-time tmpfs mount has + # happened, so non-root writes to /tmp fail. Build tools like Go also + # default TMPDIR to /tmp, so we redirect TMPDIR for the same reason. + build_subdir = f'.cache/aur-build/{helper_pkg}' + quoted_build = shlex.quote(build_subdir) + tmp_env = 'TMPDIR="$HOME/.cache/aur-build/tmp"' + + try: + info(tr('Installing AUR helper {}').format(helper_config.helper.value)) + install_session.arch_chroot( + f'rm -rf -- {quoted_build} && mkdir -p -- "$HOME/.cache/aur-build/tmp" "$(dirname -- {quoted_build})"', + run_as=build_user.username, + ) + install_session.arch_chroot( + f'git clone https://aur.archlinux.org/{helper_pkg}.git {quoted_build}', + run_as=build_user.username, + ) + install_session.arch_chroot( + f'cd {quoted_build} && {tmp_env} makepkg -si --noconfirm', + run_as=build_user.username, + ) + install_session.arch_chroot( + f'rm -rf -- {quoted_build} "$HOME/.cache/aur-build/tmp"', + run_as=build_user.username, + ) + except SysCallError as e: + debug(f'AUR helper install failed: {e}') + raise PackageError(tr('Failed to install AUR helper: {}').format(e)) from e + finally: + sudoers_path.unlink(missing_ok=True) diff --git a/archinstall/lib/args.py b/archinstall/lib/args.py index 14aff6c69c..fafa4cdb86 100644 --- a/archinstall/lib/args.py +++ b/archinstall/lib/args.py @@ -16,6 +16,7 @@ from archinstall.lib.crypt import decrypt from archinstall.lib.menu.util import get_password from archinstall.lib.models.application import ApplicationConfiguration, ZramConfiguration +from archinstall.lib.models.aur import AURConfiguration from archinstall.lib.models.authentication import AuthenticationConfiguration from archinstall.lib.models.bootloader import Bootloader, BootloaderConfiguration from archinstall.lib.models.config import SubConfig @@ -76,6 +77,7 @@ class ArchConfigType(StrEnum): NETWORK_CONFIG = 'network_config' BOOTLOADER_CONFIG = 'bootloader_config' APP_CONFIG = 'app_config' + AUR_CONFIG = 'aur_config' AUTH_CONFIG = 'auth_config' SWAP = 'swap' USERS = 'users' @@ -112,6 +114,8 @@ def text(self) -> str: return tr('Bootloader') case ArchConfigType.APP_CONFIG: return tr('Application') + case ArchConfigType.AUR_CONFIG: + return tr('Enable AUR') case ArchConfigType.AUTH_CONFIG: return tr('Authentication') case ArchConfigType.SWAP: @@ -152,6 +156,7 @@ class ArchConfig: network_config: NetworkConfiguration | None = None bootloader_config: BootloaderConfiguration | None = None app_config: ApplicationConfiguration | None = None + aur_config: AURConfiguration | None = None auth_config: AuthenticationConfiguration | None = None swap: ZramConfiguration | None = None hostname: str = 'archlinux' @@ -240,6 +245,9 @@ def sub_cfg(self) -> dict[ArchConfigType, SubConfig]: if self.app_config: cfg[ArchConfigType.APP_CONFIG] = self.app_config + if self.aur_config: + cfg[ArchConfigType.AUR_CONFIG] = self.aur_config + return cfg @classmethod @@ -307,6 +315,9 @@ def from_config(cls, args_config: dict[str, Any], args: Arguments) -> Self: if audio_config_args is not None or app_config_args is not None: arch_config.app_config = ApplicationConfiguration.parse_arg(app_config_args, audio_config_args) + if aur_config_args := args_config.get('aur_config', None): + arch_config.aur_config = AURConfiguration.parse_arg(aur_config_args) + if auth_config_args := args_config.get('auth_config', None): arch_config.auth_config = AuthenticationConfiguration.parse_arg(auth_config_args) diff --git a/archinstall/lib/aur/__init__.py b/archinstall/lib/aur/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/archinstall/lib/aur/aur_handler.py b/archinstall/lib/aur/aur_handler.py new file mode 100644 index 0000000000..21bef4d5a5 --- /dev/null +++ b/archinstall/lib/aur/aur_handler.py @@ -0,0 +1,40 @@ +from typing import TYPE_CHECKING + +from archinstall.applications.aur_helper import AURHelperApp +from archinstall.lib.exceptions import RequirementError +from archinstall.lib.models.aur import AURConfiguration +from archinstall.lib.models.users import User +from archinstall.lib.output import debug +from archinstall.lib.translationhandler import tr + +if TYPE_CHECKING: + from archinstall.lib.installer import Installer + + +class AURHandler: + """Coordinates AUR helper installation as part of a guided install. + + The build user is resolved deterministically: first user with ``sudo=True``, + falling back to the first user in ``users``. If no users are configured the + handler raises ``RequirementError`` so ``--silent`` runs surface the missing + dependency immediately. + """ + + def install_aur( + self, + install_session: Installer, + aur_config: AURConfiguration | None, + users: list[User], + ) -> None: + if aur_config is None or aur_config.helper_config is None: + debug('AUR: no helper configured, skipping') + return + + build_user = next((u for u in users if u.sudo), None) or (users[0] if users else None) + + if build_user is None: + raise RequirementError( + tr('AUR helper requires a non-root user account. Configure one under Authentication.'), + ) + + AURHelperApp().install(install_session, aur_config.helper_config, build_user) diff --git a/archinstall/lib/aur/aur_menu.py b/archinstall/lib/aur/aur_menu.py new file mode 100644 index 0000000000..f12fb854b2 --- /dev/null +++ b/archinstall/lib/aur/aur_menu.py @@ -0,0 +1,77 @@ +from typing import override + +from archinstall.lib.menu.abstract_menu import AbstractSubMenu +from archinstall.lib.menu.helpers import Selection +from archinstall.lib.models.aur import ( + AURConfiguration, + AURHelper, + AURHelperConfiguration, +) +from archinstall.lib.translationhandler import tr +from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.result import ResultType + + +class AURMenu(AbstractSubMenu[AURConfiguration]): + def __init__( + self, + preset: AURConfiguration | None = None, + ): + if preset: + self._aur_config = preset + else: + self._aur_config = AURConfiguration() + + menu_options = self._define_menu_options() + self._item_group = MenuItemGroup(menu_options, checkmarks=True) + + super().__init__( + self._item_group, + config=self._aur_config, + allow_reset=True, + ) + + @override + async def show(self) -> AURConfiguration | None: + _ = await super().show() + return self._aur_config + + def _define_menu_options(self) -> list[MenuItem]: + return [ + MenuItem( + text=tr('Enable AUR Helper'), + action=select_aur_helper, + value=self._aur_config.helper_config, + preview_action=self._prev_helper, + key='helper_config', + ), + ] + + def _prev_helper(self, item: MenuItem) -> str | None: + if item.value is not None: + config: AURHelperConfiguration = item.value + return f'{tr("AUR helper")}: {config.helper.value}' + return None + + +async def select_aur_helper(preset: AURHelperConfiguration | None = None) -> AURHelperConfiguration | None: + items = [MenuItem(h.value, value=h) for h in AURHelper] + group = MenuItemGroup(items) + + if preset: + group.set_focus_by_value(preset.helper) + + result = await Selection[AURHelper]( + group, + header=tr('Enable AUR Helper'), + allow_skip=True, + allow_reset=True, + ).show() + + match result.type_: + case ResultType.Skip: + return preset + case ResultType.Selection: + return AURHelperConfiguration(helper=result.get_value()) + case ResultType.Reset: + return None diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py index eac936bdd0..17362549c7 100644 --- a/archinstall/lib/global_menu.py +++ b/archinstall/lib/global_menu.py @@ -3,6 +3,7 @@ from archinstall.default_profiles.profile import GreeterType from archinstall.lib.applications.application_menu import ApplicationMenu from archinstall.lib.args import ArchConfig +from archinstall.lib.aur.aur_menu import AURMenu from archinstall.lib.authentication.authentication_menu import AuthenticationMenu from archinstall.lib.bootloader.bootloader_menu import BootloaderMenu from archinstall.lib.bootloader.utils import validate_bootloader_layout @@ -16,6 +17,7 @@ from archinstall.lib.mirror.mirror_handler import MirrorListHandler from archinstall.lib.mirror.mirror_menu import MirrorMenu from archinstall.lib.models.application import ApplicationConfiguration, ZramConfiguration +from archinstall.lib.models.aur import AURConfiguration from archinstall.lib.models.authentication import AuthenticationConfiguration from archinstall.lib.models.bootloader import Bootloader, BootloaderConfiguration from archinstall.lib.models.device import DiskLayoutConfiguration, DiskLayoutType, PartitionModification @@ -136,6 +138,12 @@ def _get_menu_options(self) -> list[MenuItem]: preview_action=self._prev_applications, key='app_config', ), + MenuItem( + text=tr('Enable AUR'), + action=self._select_aur, + preview_action=self._prev_aur, + key='aur_config', + ), MenuItem( text=tr('Network configuration'), action=select_network, @@ -235,6 +243,13 @@ def check(s: str) -> bool: if not check(item.key): missing.add(item.text) + aur_item: MenuItem = self._item_group.find_by_key('aur_config') + aur_config: AURConfiguration | None = aur_item.value + if aur_config and aur_config.helper_config and not (auth_config and auth_config.has_regular_user()): + missing.add( + tr('AUR helper requires a non-root user account. Configure one under Authentication.'), + ) + return list(missing) @override @@ -267,6 +282,17 @@ async def _select_applications(self, preset: ApplicationConfiguration | None) -> app_config = await ApplicationMenu(preset).show() return app_config + async def _select_aur(self, preset: AURConfiguration | None) -> AURConfiguration | None: + aur_config = await AURMenu(preset).show() + return aur_config + + def _prev_aur(self, item: MenuItem) -> str | None: + if item.value is not None: + aur_config: AURConfiguration = item.value + if aur_config.helper_config: + return f'{tr("AUR helper")}: {aur_config.helper_config.helper.value}' + return None + async def _select_authentication(self, preset: AuthenticationConfiguration | None) -> AuthenticationConfiguration | None: auth_config = await AuthenticationMenu(preset).show() return auth_config diff --git a/archinstall/lib/models/aur.py b/archinstall/lib/models/aur.py new file mode 100644 index 0000000000..29c8eb2d83 --- /dev/null +++ b/archinstall/lib/models/aur.py @@ -0,0 +1,57 @@ +from dataclasses import dataclass +from enum import StrEnum +from typing import Any, NotRequired, Self, TypedDict, override + +from archinstall.lib.models.config import SubConfig +from archinstall.lib.translationhandler import tr + + +class AURHelper(StrEnum): + PARU = 'paru' + YAY = 'yay' + + +class AURHelperConfigSerialization(TypedDict): + helper: str + + +class AURConfigSerialization(TypedDict, total=False): + helper_config: NotRequired[AURHelperConfigSerialization] + + +@dataclass +class AURHelperConfiguration: + helper: AURHelper + + def json(self) -> AURHelperConfigSerialization: + return {'helper': self.helper.value} + + @classmethod + def parse_arg(cls, arg: dict[str, Any]) -> Self: + return cls(helper=AURHelper(arg['helper'])) + + +@dataclass +class AURConfiguration(SubConfig): + helper_config: AURHelperConfiguration | None = None + + @classmethod + def parse_arg(cls, args: dict[str, Any] | None = None) -> Self: + cfg = cls() + if args and (helper_config := args.get('helper_config')) is not None: + cfg.helper_config = AURHelperConfiguration.parse_arg(helper_config) + return cfg + + @override + def json(self) -> AURConfigSerialization: + config: AURConfigSerialization = {} + if self.helper_config: + config['helper_config'] = self.helper_config.json() + return config + + @override + def summary(self) -> list[str]: + out: list[str] = [] + if self.helper_config: + out.append(tr('AUR helper "{}"').format(self.helper_config.helper.value)) + return out diff --git a/archinstall/locales/base.pot b/archinstall/locales/base.pot index e16815264a..383a166d71 100644 --- a/archinstall/locales/base.pot +++ b/archinstall/locales/base.pot @@ -2463,3 +2463,27 @@ msgstr "" msgid "Enter a repository name" msgstr "" + +msgid "Enable AUR" +msgstr "" + +msgid "Enable AUR Helper" +msgstr "" + +msgid "AUR helper" +msgstr "" + +msgid "AUR helper requires a non-root user account. Configure one under Authentication." +msgstr "" + +#, python-brace-format +msgid "Installing AUR helper {}" +msgstr "" + +#, python-brace-format +msgid "Failed to install AUR helper: {}" +msgstr "" + +#, python-brace-format +msgid "AUR helper \"{}\"" +msgstr "" diff --git a/archinstall/scripts/guided.py b/archinstall/scripts/guided.py index aab02cfd4d..e373e3361f 100644 --- a/archinstall/scripts/guided.py +++ b/archinstall/scripts/guided.py @@ -4,6 +4,7 @@ from archinstall.lib.applications.application_handler import ApplicationHandler from archinstall.lib.args import ArchConfig, ArchConfigHandler +from archinstall.lib.aur.aur_handler import AURHandler from archinstall.lib.authentication.authentication_handler import AuthenticationHandler from archinstall.lib.bootloader.utils import validate_bootloader_layout from archinstall.lib.configuration import ConfigurationOutput @@ -138,6 +139,8 @@ def perform_installation( if app_config := config.app_config: application_handler.install_applications(installation, app_config) + AURHandler().install_aur(installation, config.aur_config, users or []) + if profile_config := config.profile_config: profile_handler.install_profile_config(installation, profile_config) diff --git a/tests/conftest.py b/tests/conftest.py index 819c839716..d3efe8af7b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,6 +8,11 @@ def config_fixture() -> Path: return Path(__file__).parent / 'data' / 'test_config.json' +@pytest.fixture(scope='session') +def aur_config_fixture() -> Path: + return Path(__file__).parent / 'data' / 'test_config_aur.json' + + @pytest.fixture(scope='session') def btrfs_config_fixture() -> Path: return Path(__file__).parent / 'data' / 'test_config_btrfs.json' diff --git a/tests/data/test_config_aur.json b/tests/data/test_config_aur.json new file mode 100644 index 0000000000..15d37b6815 --- /dev/null +++ b/tests/data/test_config_aur.json @@ -0,0 +1,214 @@ +{ + "archinstall-language": "English", + "script": "test_script", + "app_config": { + "bluetooth_config": { + "enabled": true + }, + "audio_config": { + "audio": "pipewire" + }, + "print_service_config": { + "enabled": true + } + }, + "auth_config": { + "u2f_config": { + "passwordless_sudo": true, + "u2f_login_method": "passwordless" + } + }, + "audio_config": { + "audio": "pipewire" + }, + "bootloader_config": { + "bootloader": "Systemd-boot", + "uki": false, + "removable": false + }, + "services": [ + "service_1", + "service_2" + ], + "disk_config": { + "config_type": "default_layout", + "btrfs_options": { + "snapshot_config": { + "type": "Snapper" + } + }, + "device_modifications": [ + { + "device": "/dev/sda", + "partitions": [ + { + "btrfs": [], + "dev_path": null, + "flags": [ + "boot" + ], + "fs_type": "fat32", + "size": { + "sector_size": { + "unit": "B", + "value": 512 + }, + "unit": "MiB", + "value": 512 + }, + "mount_options": [], + "mountpoint": "/boot", + "obj_id": "2c3fa2d5-2c79-4fab-86ec-22d0ea1543c0", + "start": { + "sector_size": { + "unit": "B", + "value": 512 + }, + "unit": "MiB", + "value": 1 + }, + "status": "create", + "type": "primary" + }, + { + "btrfs": [], + "dev_path": null, + "flags": [], + "fs_type": "ext4", + "size": { + "sector_size": { + "unit": "B", + "value": 512 + }, + "unit": "GiB", + "value": 32 + }, + "mount_options": [], + "mountpoint": "/", + "obj_id": "3e7018a0-363b-4d05-ab83-8e82d13db208", + "start": { + "sector_size": { + "unit": "B", + "value": 512 + }, + "unit": "MiB", + "value": 513 + }, + "status": "create", + "type": "primary" + }, + { + "btrfs": [], + "dev_path": null, + "flags": [], + "fs_type": "ext4", + "size": { + "sector_size": { + "unit": "B", + "value": 512 + }, + "unit": "GiB", + "value": 32 + }, + "mount_options": [], + "mountpoint": "/home", + "obj_id": "ce58b139-f041-4a06-94da-1f8bad775d3f", + "start": { + "sector_size": { + "unit": "B", + "value": 512 + }, + "unit": "MiB", + "value": 33281 + }, + "status": "create", + "type": "primary" + } + ], + "wipe": true + } + ] + }, + "hostname": "archy", + "kernels": [ + "linux-zen" + ], + "locale_config": { + "kb_layout": "us", + "sys_enc": "UTF-8", + "sys_lang": "en_US" + }, + "mirror_config": { + "custom_servers": [ + { + "url": "https://mymirror.com/$repo/os/$arch" + } + ], + "mirror_regions": { + "Australia": [ + "http://archlinux.mirror.digitalpacific.com.au/$repo/os/$arch" + ] + }, + "optional_repositories": [ + "testing" + ], + "custom_repositories": [ + { + "name": "myrepo", + "url": "https://myrepo.com/$repo/os/$arch", + "sign_check": "Required", + "sign_option": "TrustAll" + } + ] + }, + "network_config": { + "type": "manual", + "nics": [ + { + "iface": "eno1", + "ip": "192.168.1.15/24", + "dhcp": true, + "gateway": "192.168.1.1", + "dns": [ + "192.168.1.1", + "9.9.9.9" + ] + } + ] + }, + "ntp": true, + "packages": [ + "firefox" + ], + "parallel_downloads": 66, + "profile_config": { + "gfx_driver": "All open-source", + "greeter": "lightdm-gtk-greeter", + "profile": { + "custom_settings": { + "Hyprland": { + "seat_access": "polkit" + }, + "Sway": { + "seat_access": "seatd" + } + }, + "details": [ + "Sway", + "Hyprland" + ], + "main": "Desktop" + } + }, + "custom_commands": [ + "echo 'Hello, World!'" + ], + "swap": false, + "timezone": "UTC", + "version": "3.0.2", + "aur_config": { + "helper_config": { + "helper": "paru" + } + } +} \ No newline at end of file diff --git a/tests/test_configuration_output.py b/tests/test_configuration_output.py index 7d9f1f97cf..8454b8f46d 100644 --- a/tests/test_configuration_output.py +++ b/tests/test_configuration_output.py @@ -66,3 +66,28 @@ def test_creds_roundtrip( expected = json.loads(creds_fixture.read_text()) assert sorted(result.items()) == sorted(expected.items()) + + +def test_aur_config_roundtrip( + monkeypatch: MonkeyPatch, + aur_config_fixture: Path, +) -> None: + monkeypatch.setattr('sys.argv', ['archinstall', '--config', str(aur_config_fixture)]) + + handler = ArchConfigHandler() + arch_config = handler.config + arch_config.version = '3.0.2' + + assert arch_config.aur_config is not None + assert arch_config.aur_config.helper_config is not None + assert arch_config.aur_config.helper_config.helper.value == 'paru' + + config_output = ConfigurationOutput(arch_config) + test_out_dir = Path('/tmp/') + test_out_file = test_out_dir / config_output.user_configuration_file + config_output.save(test_out_dir) + + result = json.loads(test_out_file.read_text()) + expected = json.loads(aur_config_fixture.read_text()) + + assert result['aur_config'] == expected['aur_config']