Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 98 additions & 1 deletion archinstall/lib/args.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import argparse
import json
import os
import stat
import sys
import urllib.error
import urllib.parse
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
174 changes: 28 additions & 146 deletions archinstall/lib/configuration.py
Original file line number Diff line number Diff line change
@@ -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)'),
Expand Down Expand Up @@ -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)
5 changes: 2 additions & 3 deletions archinstall/lib/global_menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]] = []
Expand All @@ -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))

Expand Down
5 changes: 5 additions & 0 deletions archinstall/lib/utils/util.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
Loading