diff --git a/archinstall/lib/args.py b/archinstall/lib/args.py index e3729504dd..4bc9f31726 100644 --- a/archinstall/lib/args.py +++ b/archinstall/lib/args.py @@ -1,6 +1,7 @@ import argparse import json import os +import stat import sys import urllib.error import urllib.parse @@ -11,9 +12,10 @@ from typing import Any, Self from urllib.request import Request, urlopen +from pydantic import TypeAdapter from pydantic.dataclasses import dataclass as p_dataclass -from archinstall.lib.crypt import decrypt +from archinstall.lib.crypt import decrypt, encrypt from archinstall.lib.log import debug, error, logger, warn from archinstall.lib.menu.util import get_password from archinstall.lib.models.application import ApplicationConfiguration, ZramConfiguration @@ -31,6 +33,8 @@ from archinstall.lib.models.users import Password, User, UserSerialization from archinstall.lib.plugins import load_plugin from archinstall.lib.translationhandler import Language, tr, translation_handler +from archinstall.lib.utils.format import as_key_value_pair +from archinstall.lib.utils.util import is_valid_path from archinstall.lib.version import get_version from archinstall.tui.components import tui @@ -140,6 +144,11 @@ def text(self) -> str: return tr('Disk encryption password') +USER_CONFIG_FILE: Path = Path('user_configuration.json') +USER_CREDS_FILE: Path = Path('user_credentials.json') +DEFAULT_SAVE_PATH = logger.directory + + @dataclass class ArchConfig: version: str | None = None @@ -367,6 +376,94 @@ def from_config(cls, args_config: dict[str, Any], args: Arguments) -> Self: return arch_config + def user_config_to_json(self) -> str: + config = self.safe_config() + + adapter = TypeAdapter(dict[ArchConfigType, Any]) + python_dict = adapter.dump_python(config) + return json.dumps(python_dict, indent=4, sort_keys=True) + + def user_credentials_to_json(self) -> str: + cfg = self.unsafe_config() + + adapter = TypeAdapter(dict[ArchConfigType, Any]) + python_dict = adapter.dump_python(cfg) + return json.dumps(python_dict, indent=4, sort_keys=True) + + def write_debug(self) -> None: + debug(' -- Chosen configuration --') + debug(self.user_config_to_json()) + + def save( + self, + dest_path: Path | None = None, + creds: bool = False, + password: str | None = None, + ) -> None: + save_path = dest_path or DEFAULT_SAVE_PATH + + if not is_valid_path(save_path): + warn( + f'Destination directory {save_path} does not exist or is not a directory\n.', + 'Configuration files can not be saved', + ) + return + + self.save_user_config(save_path) + if creds: + self.save_user_creds(save_path, password=password) + + def save_user_config(self, dest_path: Path) -> None: + if not is_valid_path(dest_path): + error(f'Invalid path {dest_path}. User configuration could not be saved.') + return + + target = dest_path / USER_CONFIG_FILE + data = self.user_config_to_json() + target.write_text(data) + target.chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP) + + def save_user_creds( + self, + dest_path: Path, + password: str | None = None, + ) -> None: + if not is_valid_path(dest_path): + error(f'Invalid path {dest_path}. User credentials could not be saved.') + return + + data = self.user_credentials_to_json() + + if password: + data = encrypt(password, data) + + target = dest_path / USER_CREDS_FILE + target.write_text(data) + target.chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP) + + def as_summary(self) -> str: + """ + Render a concise two-column summary of the current configuration. + + Returns an empty string if nothing meaningful to show. + """ + cfg: dict[str, str | list[str] | bool] = {} + + for key, value in self.plain_cfg().items(): + cfg[key.text()] = value + + for config_type, obj in self.sub_cfg().items(): + if not hasattr(obj, 'summary'): + continue + + summary = obj.summary() + if summary: + cfg[config_type.text()] = summary + + simple_summary = as_key_value_pair(cfg, ignore_empty=True) + + return simple_summary + class ArchConfigHandler: def __init__(self) -> None: diff --git a/archinstall/lib/configuration.py b/archinstall/lib/configuration.py index 5754dfcf12..cb6844c3cd 100644 --- a/archinstall/lib/configuration.py +++ b/archinstall/lib/configuration.py @@ -1,171 +1,53 @@ -import json import readline -import stat -from pathlib import Path -from typing import Any -from pydantic import TypeAdapter - -from archinstall.lib.args import ArchConfig, ArchConfigType -from archinstall.lib.crypt import encrypt -from archinstall.lib.log import debug, logger, warn +from archinstall.lib.args import USER_CONFIG_FILE, USER_CREDS_FILE, ArchConfig +from archinstall.lib.log import debug from archinstall.lib.menu.helpers import Confirmation, Selection from archinstall.lib.menu.util import get_password, prompt_dir from archinstall.lib.translationhandler import tr -from archinstall.lib.utils.format import as_key_value_pair from archinstall.tui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.result import ResultType -class ConfigurationOutput: - def __init__(self, config: ArchConfig): - """ - Configuration output handler to parse the existing - configuration data structure and prepare for output on the - console and for saving it to configuration files - - :param config: Archinstall configuration object - :type config: ArchConfig - """ - - self._config = config - self._default_save_path = logger.directory - self._user_config_file = Path('user_configuration.json') - self._user_creds_file = Path('user_credentials.json') - - @property - def user_configuration_file(self) -> Path: - return self._user_config_file - - @property - def user_credentials_file(self) -> Path: - return self._user_creds_file - - def user_config_to_json(self) -> str: - config = self._config.safe_config() - - adapter = TypeAdapter(dict[ArchConfigType, Any]) - python_dict = adapter.dump_python(config) - return json.dumps(python_dict, indent=4, sort_keys=True) - - def user_credentials_to_json(self) -> str: - cfg = self._config.unsafe_config() - - adapter = TypeAdapter(dict[ArchConfigType, Any]) - python_dict = adapter.dump_python(cfg) - return json.dumps(python_dict, indent=4, sort_keys=True) - - def write_debug(self) -> None: - debug(' -- Chosen configuration --') - debug(self.user_config_to_json()) - - def as_summary(self) -> str: - """ - Render a concise two-column summary of the current configuration. - - Returns an empty string if nothing meaningful to show. - """ - cfg: dict[str, str | list[str] | bool] = {} - - for key, value in self._config.plain_cfg().items(): - cfg[key.text()] = value +async def confirm_config(config: ArchConfig) -> bool: + header = f'{tr("The specified configuration will be applied")}. ' + header += tr('Would you like to continue?') + '\n' - for config_type, obj in self._config.sub_cfg().items(): - if not hasattr(obj, 'summary'): - continue + group = MenuItemGroup.yes_no() + group.set_preview_for_all(lambda x: config.user_config_to_json()) - summary = obj.summary() - if summary: - cfg[config_type.text()] = summary - - simple_summary = as_key_value_pair(cfg, ignore_empty=True) - - return simple_summary - - async def confirm_config(self) -> bool: - header = f'{tr("The specified configuration will be applied")}. ' - header += tr('Would you like to continue?') + '\n' - - group = MenuItemGroup.yes_no() - group.set_preview_for_all(lambda x: self.user_config_to_json()) - - result = await Confirmation( - group=group, - header=header, - allow_skip=False, - preset=True, - preview_location='bottom', - preview_header=tr('Configuration preview'), - ).show() - - if not result.get_value(): - return False + result = await Confirmation( + group=group, + header=header, + allow_skip=False, + preset=True, + preview_location='bottom', + preview_header=tr('Configuration preview'), + ).show() - return True + if not result.get_value(): + return False - def _is_valid_path(self, dest_path: Path) -> bool: - dest_path_ok = dest_path.exists() and dest_path.is_dir() - if not dest_path_ok: - warn( - f'Destination directory {dest_path.resolve()} does not exist or is not a directory\n.', - 'Configuration files can not be saved', - ) - return dest_path_ok - - def save_user_config(self, dest_path: Path) -> None: - if self._is_valid_path(dest_path): - target = dest_path / self._user_config_file - target.write_text(self.user_config_to_json()) - target.chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP) - - def save_user_creds( - self, - dest_path: Path, - password: str | None = None, - ) -> None: - data = self.user_credentials_to_json() - - if password: - data = encrypt(password, data) - - if self._is_valid_path(dest_path): - target = dest_path / self._user_creds_file - target.write_text(data) - target.chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP) - - def save( - self, - dest_path: Path | None = None, - creds: bool = False, - password: str | None = None, - ) -> None: - save_path = dest_path or self._default_save_path - - if self._is_valid_path(save_path): - self.save_user_config(save_path) - if creds: - self.save_user_creds(save_path, password=password) + return True async def save_config(config: ArchConfig) -> None: def preview(item: MenuItem) -> str | None: match item.value: case 'user_config': - serialized = config_output.user_config_to_json() - return f'{config_output.user_configuration_file}\n{serialized}' + serialized = config.user_config_to_json() + return f'{USER_CONFIG_FILE}\n{serialized}' case 'user_creds': - if maybe_serial := config_output.user_credentials_to_json(): - return f'{config_output.user_credentials_file}\n{maybe_serial}' + if maybe_serial := config.user_credentials_to_json(): + return f'{USER_CREDS_FILE}\n{maybe_serial}' return tr('No configuration') case 'all': - output = [str(config_output.user_configuration_file)] - config_output.user_credentials_to_json() - output.append(str(config_output.user_credentials_file)) + output = [str(USER_CONFIG_FILE)] + config.user_credentials_to_json() + output.append(str(USER_CREDS_FILE)) return '\n'.join(output) return None - config_output = ConfigurationOutput(config) - items = [ MenuItem( tr('Save user configuration (including disk layout)'), @@ -248,8 +130,8 @@ def preview(item: MenuItem) -> str | None: match save_option: case 'user_config': - config_output.save_user_config(dest_path) + config.save_user_config(dest_path) case 'user_creds': - config_output.save_user_creds(dest_path, password=enc_password) + config.save_user_creds(dest_path, password=enc_password) case 'all': - config_output.save(dest_path, creds=True, password=enc_password) + config.save(dest_path, creds=True, password=enc_password) diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py index 548ed522de..72c31033af 100644 --- a/archinstall/lib/global_menu.py +++ b/archinstall/lib/global_menu.py @@ -6,7 +6,7 @@ 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 -from archinstall.lib.configuration import ConfigurationOutput, save_config +from archinstall.lib.configuration import save_config from archinstall.lib.disk.disk_menu import DiskLayoutConfigurationMenu from archinstall.lib.general.general_menu import select_hostname, select_ntp, select_timezone from archinstall.lib.general.system_menu import select_kernel, select_swap @@ -504,7 +504,6 @@ def _get_install_warnings(self) -> list[str]: def _prev_install_invalid_config(self, item: MenuItem) -> PreviewResult | None: self.sync_all_to_config() - config_output = ConfigurationOutput(self._arch_config) warnings = self._get_install_warnings() messages: list[tuple[str, MsgLevelType]] = [] @@ -531,7 +530,7 @@ def _prev_install_invalid_config(self, item: MenuItem) -> PreviewResult | None: messages.append((text, MsgLevelType.MsgWarning)) if not errors: - summary = config_output.as_summary() + summary = self._arch_config.as_summary() if summary: messages.append((summary, MsgLevelType.MsgNone)) diff --git a/archinstall/lib/utils/util.py b/archinstall/lib/utils/util.py index f94ca18c86..a0c34f5f9d 100644 --- a/archinstall/lib/utils/util.py +++ b/archinstall/lib/utils/util.py @@ -1,6 +1,7 @@ import secrets import string from datetime import UTC, datetime +from pathlib import Path from archinstall.lib.pathnames import ARCHISO_MOUNTPOINT from archinstall.lib.utils.format import as_columns @@ -46,3 +47,7 @@ def format_cols(items: list[str], header: str | None = None) -> str: # remove whitespaces on each row text = '\n'.join(t.strip() for t in text.split('\n')) return text + + +def is_valid_path(path: Path) -> bool: + return path.exists() and path.is_dir() diff --git a/archinstall/scripts/guided.py b/archinstall/scripts/guided.py index 29573a5f1f..eab05ca0d9 100644 --- a/archinstall/scripts/guided.py +++ b/archinstall/scripts/guided.py @@ -6,7 +6,7 @@ from archinstall.lib.args import ArchConfig, ArchConfigHandler from archinstall.lib.authentication.authentication_handler import AuthenticationHandler from archinstall.lib.bootloader.utils import validate_bootloader_layout -from archinstall.lib.configuration import ConfigurationOutput +from archinstall.lib.configuration import confirm_config from archinstall.lib.disk.filesystem import FilesystemHandler from archinstall.lib.disk.utils import disk_layouts from archinstall.lib.general.general_menu import PostInstallationAction, select_post_installation @@ -212,9 +212,8 @@ def main(arch_config_handler: ArchConfigHandler | None = None) -> None: if not arch_config_handler.args.silent: show_menu(arch_config_handler, mirror_list_handler) - config = ConfigurationOutput(arch_config_handler.config) - config.write_debug() - config.save() + arch_config_handler.config.write_debug() + arch_config_handler.config.save() # Safety net for silent/config-file flow. The TUI menu blocks Install via # GlobalMenu._validate_bootloader() before reaching this point. @@ -230,7 +229,7 @@ def main(arch_config_handler: ArchConfigHandler | None = None) -> None: if not arch_config_handler.args.silent: aborted = False - res: bool = tui.run(config.confirm_config) + res: bool = tui.run(lambda: confirm_config(arch_config_handler.config)) if not res: debug('Installation aborted') diff --git a/archinstall/scripts/minimal.py b/archinstall/scripts/minimal.py index a58963f215..0ab6da83e5 100644 --- a/archinstall/scripts/minimal.py +++ b/archinstall/scripts/minimal.py @@ -1,6 +1,6 @@ from archinstall.default_profiles.minimal import MinimalProfile from archinstall.lib.args import ArchConfigHandler -from archinstall.lib.configuration import ConfigurationOutput +from archinstall.lib.configuration import confirm_config from archinstall.lib.disk.disk_menu import DiskLayoutConfigurationMenu from archinstall.lib.disk.filesystem import FilesystemHandler from archinstall.lib.installer import Installer @@ -68,16 +68,15 @@ async def main(arch_config_handler: ArchConfigHandler | None = None) -> None: disk_config = await DiskLayoutConfigurationMenu(disk_layout_config=None).show() arch_config_handler.config.disk_config = disk_config - config = ConfigurationOutput(arch_config_handler.config) - config.write_debug() - config.save() + arch_config_handler.config.write_debug() + arch_config_handler.config.save() if arch_config_handler.args.dry_run: return if not arch_config_handler.args.silent: aborted = False - res: bool = tui.run(config.confirm_config) + res: bool = tui.run(lambda: confirm_config(arch_config_handler.config)) if not res: debug('Installation aborted') diff --git a/archinstall/scripts/only_hd.py b/archinstall/scripts/only_hd.py index c7349c51b6..6a2374dfe9 100644 --- a/archinstall/scripts/only_hd.py +++ b/archinstall/scripts/only_hd.py @@ -2,7 +2,7 @@ from pathlib import Path from archinstall.lib.args import ArchConfig, ArchConfigHandler -from archinstall.lib.configuration import ConfigurationOutput +from archinstall.lib.configuration import confirm_config from archinstall.lib.disk.filesystem import FilesystemHandler from archinstall.lib.disk.utils import disk_layouts from archinstall.lib.global_menu import GlobalMenu @@ -69,16 +69,15 @@ def main(arch_config_handler: ArchConfigHandler | None = None) -> None: if not arch_config_handler.args.silent: show_menu(arch_config_handler) - config = ConfigurationOutput(arch_config_handler.config) - config.write_debug() - config.save() + arch_config_handler.config.write_debug() + arch_config_handler.config.save() if arch_config_handler.args.dry_run: return if not arch_config_handler.args.silent: aborted = False - res: bool = tui.run(config.confirm_config) + res: bool = tui.run(lambda: confirm_config(arch_config_handler.config)) if not res: debug('Installation aborted') diff --git a/tests/test_configuration_output.py b/tests/test_configuration_output.py index 7d9f1f97cf..cbedc0260c 100644 --- a/tests/test_configuration_output.py +++ b/tests/test_configuration_output.py @@ -3,8 +3,7 @@ from pytest import MonkeyPatch -from archinstall.lib.args import ArchConfigHandler -from archinstall.lib.configuration import ConfigurationOutput +from archinstall.lib.args import USER_CONFIG_FILE, USER_CREDS_FILE, ArchConfigHandler def test_user_config_roundtrip( @@ -20,12 +19,10 @@ def test_user_config_roundtrip( # as there is no version present in the test environment we'll set it manually arch_config.version = '3.0.2' - config_output = ConfigurationOutput(arch_config) - test_out_dir = Path('/tmp/') - test_out_file = test_out_dir / config_output.user_configuration_file + test_out_file = test_out_dir / USER_CONFIG_FILE - config_output.save(test_out_dir) + arch_config.save(test_out_dir) result = json.loads(test_out_file.read_text()) expected = json.loads(config_fixture.read_text()) @@ -55,12 +52,10 @@ def test_creds_roundtrip( handler = ArchConfigHandler() arch_config = handler.config - config_output = ConfigurationOutput(arch_config) - test_out_dir = Path('/tmp/') - test_out_file = test_out_dir / config_output.user_credentials_file + test_out_file = test_out_dir / USER_CREDS_FILE - config_output.save(test_out_dir, creds=True) + arch_config.save(test_out_dir, creds=True) result = json.loads(test_out_file.read_text()) expected = json.loads(creds_fixture.read_text())