Refactor configuration file (#4583)
This commit is contained in:
parent
c777ee03f6
commit
c6a5a130a8
|
|
@ -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 pathlib import Path
|
|||
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.profile import ProfileConfiguration
|
|||
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 @@ class ArchConfigType(StrEnum):
|
|||
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 @@ class ArchConfig:
|
|||
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
async def confirm_config(config: ArchConfig) -> bool:
|
||||
header = f'{tr("The specified configuration will be applied")}. '
|
||||
header += tr('Would you like to continue?') + '\n'
|
||||
|
||||
:param config: Archinstall configuration object
|
||||
:type config: ArchConfig
|
||||
"""
|
||||
group = MenuItemGroup.yes_no()
|
||||
group.set_preview_for_all(lambda x: config.user_config_to_json())
|
||||
|
||||
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')
|
||||
result = await Confirmation(
|
||||
group=group,
|
||||
header=header,
|
||||
allow_skip=False,
|
||||
preset=True,
|
||||
preview_location='bottom',
|
||||
preview_header=tr('Configuration preview'),
|
||||
).show()
|
||||
|
||||
@property
|
||||
def user_configuration_file(self) -> Path:
|
||||
return self._user_config_file
|
||||
if not result.get_value():
|
||||
return False
|
||||
|
||||
@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
|
||||
|
||||
for config_type, obj in self._config.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
|
||||
|
||||
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
|
||||
|
||||
return True
|
||||
|
||||
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 @@ async def save_config(config: ArchConfig) -> 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)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from archinstall.lib.args import ArchConfig
|
|||
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 @@ class GlobalMenu(AbstractMenu[None]):
|
|||
|
||||
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 @@ class GlobalMenu(AbstractMenu[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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from archinstall.lib.applications.application_handler import ApplicationHandler
|
|||
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
|
||||
|
|
@ -213,9 +213,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.
|
||||
|
|
@ -231,7 +230,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')
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import sys
|
|||
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')
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@ from pathlib import Path
|
|||
|
||||
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())
|
||||
|
|
|
|||
Loading…
Reference in New Issue