libervia-backend/libervia/backend/tools/config.py
Goffi 6a7b7bc36a core: Replace legacy INI config system with Pydantic-based TOML configuration.
This is a huge configuration refactoring.

The configuration system now uses Pydantic settings, with better validation, better
support for environment variables and other goodies.

The main config files now use TOML instead of INI, this allow for better handling of
complex types, notably dict.

Plugins, components and frontends can declare their config settings in a class which will
be imported dynamically.
2025-11-30 12:27:35 +01:00

503 lines
18 KiB
Python

#!/usr/bin/env python3
# Libervia XMPP Client
# Copyright (C) 2009-2025 Jérôme Poisson (goffi@goffi.org)
# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""Configuration related useful methods"""
from collections import defaultdict
from pathlib import Path
from socket import getfqdn
from typing import Annotated, Literal, TypeVar, cast
from pydantic import (
AfterValidator,
BaseModel,
ConfigDict,
Field,
ValidationError,
create_model,
field_validator,
)
from pydantic.types import PathType
from pydantic_settings import (
BaseSettings,
PydanticBaseSettingsSource,
SettingsConfigDict,
TomlConfigSettingsSource,
)
from libervia.backend.core import exceptions
from libervia.backend.core.constants import Const as C, xdg_data_home
from libervia.backend.core.i18n import _
from libervia.backend.core.log import getLogger
from libervia.backend.tools.common.files_utils import get_downloads_dir
log = getLogger(__name__)
expanduser_validator = AfterValidator(lambda p: p.expanduser().resolve())
cached_config: "Settings|None" = None
DynamicSettingsRegister = dict[str, "type[DynamicSettingsBase]"]
DynamicSettings: dict[str, DynamicSettingsRegister | type["DynamicSettingsBase"]] = (
defaultdict(DynamicSettingsRegister)
)
def autocreatedir(p: Path) -> Path:
"""If directory doesn't exists, create it.
Will also create parents, permission are set for user-only access.
"""
p.mkdir(mode=0o700, parents=True, exist_ok=True)
return p
autocreatedir_validator = AfterValidator(autocreatedir)
def _validate_executable_file(path: Path) -> Path:
"""Validate that a file is executable."""
stat_result = path.stat()
if not (stat_result.st_mode & 0o100):
raise ValueError(f"File {path} is not executable.")
return path
FilePath = Annotated[Path, expanduser_validator, PathType("file")]
ExecutableFilePath = Annotated[
Path,
expanduser_validator,
PathType("file"),
AfterValidator(_validate_executable_file),
]
DirectoryPathAuto = Annotated[
Path, expanduser_validator, autocreatedir_validator, PathType("dir")
]
DirectoryPath = Annotated[Path, expanduser_validator, PathType("dir")]
class ConfigOptions(BaseModel):
"""Configuration options."""
# We need to validate default to be sure that autocreatedir_validator is run.
model_config = ConfigDict(extra="forbid", validate_default=True)
class DynamicSettingsBase(ConfigOptions):
"""Settings which are loaded at runtime."""
_key: str = ""
_sub_key: str | None = None
@classmethod
def _init_subclass(
cls, /, key: str, plugin_info: dict | None = None
) -> None:
"""Initialize the sublass, and register it in DynamicSettings.
@param key: name of the field which will be used to store those settings.
@param info: ``PLUGIN_INFO`` data of the plugin adding settings.
Use None when the data is from a frontend and not a plugin.
"""
cls._key = key
if plugin_info is not None:
sub_key = plugin_info.get(C.PI_CONFIG_KEY) or plugin_info[C.PI_IMPORT_NAME]
assert sub_key
# FIXME: We transform key here, but in the future plugins key name must be
# normalized on plugin import.
sub_key = sub_key.replace("-", "_").lower()
cls._sub_key = sub_key
register = DynamicSettings[key]
assert isinstance(register, dict)
if sub_key in register:
raise exceptions.ConflictError(
f"There is already a plugin registered for {key!r}.{sub_key!r}."
)
register[sub_key] = cls
else:
cls._sub_key = None
DynamicSettings[key] = cls
class PluginSettings(DynamicSettingsBase):
"""Configuration options for plugins."""
def __init_subclass__(cls, /, plugin_info: dict, **kwargs) -> None:
super().__init_subclass__(**kwargs)
super()._init_subclass(key="plugins", plugin_info=plugin_info)
class ComponentSettings(DynamicSettingsBase):
"""Configuration options for components."""
def __init_subclass__(cls, /, plugin_info: dict, **kwargs) -> None:
super().__init_subclass__(**kwargs)
super()._init_subclass(key="components", plugin_info=plugin_info)
class FrontendSettings(DynamicSettingsBase):
def __init_subclass__(cls, /, key: str, **kwargs) -> None:
super().__init_subclass__(**kwargs)
super()._init_subclass(key=key)
D = TypeVar("D", bound=DynamicSettingsBase)
class PbBridgePbSettings(ConfigOptions):
type: Literal["pb"] = "pb"
connection_type: Literal["unix_socket", "socket"] = Field(
default="unix_socket", description="type of connection to use."
)
port: int = Field(
default=8789, description='TCP port to use when ``connection_type`` is "socket"'
)
socket_dir: DirectoryPathAuto = Field(
default=C.TEMP_DIR,
description=(
"Directory where the UNIX socket file will be created when using unix_socket"
" connection type."
),
)
host: str = Field(
default="localhost", description="Host to connect to when using socket connection"
)
class DBusBridgeSettings(ConfigOptions):
type: Literal["dbus"] = "dbus"
int_prefix: str = Field(
default="org.libervia.Libervia", description="Interface prefix"
)
BridgeSettings = Annotated[
DBusBridgeSettings | PbBridgePbSettings, Field(discriminator="type")
]
B = TypeVar("B", bound=BridgeSettings)
class WebSettings(ConfigOptions):
# FIXME: to be moved to web frontend.
model_config = ConfigDict(extra="allow")
sites_path_public: dict[str, str] = Field(
default_factory=dict, description="Dictionary of public site paths"
)
sites_path_private_dict: dict[str, str] = Field(
default_factory=dict, description="Dictionary of private site paths"
)
class EmailSettings(ConfigOptions):
server: str = Field(default="localhost", description="Email server address")
port: int = Field(default=25, description="Email server port")
username: str | None = Field(default=None, description="Email server username")
password: str | None = Field(default=None, description="Email server password")
starttls: bool = Field(default=False, description="Use STARTTLS for email connection")
auth: bool = Field(
default=False, description="Enable authentication for email connection"
)
from_: str | None = Field(
default="NOREPLY@example.net",
description="Sender address for emails",
alias="from",
)
sender_domain: str | None = Field(default=None, description="Email sender domain")
class TLSSettings(ConfigOptions):
certificate: FilePath | None = Field(
default=None, description="Path to TLS certificate file"
)
private_key: FilePath | None = Field(
default=None, description="Path to TLS private key file"
)
chain: FilePath | None = Field(
default=None,
description="Path to TLS certificate chain file (intermediate certificates)",
)
class CommonSettings(ConfigOptions):
# "media_dir": "/usr/share/" + Const.APP_NAME_FILE + "/media",
media_dir: DirectoryPath | None = Field(
default=None, description="Directory for media files"
)
local_dir: DirectoryPathAuto = Field(
default=xdg_data_home / C.APP_NAME_FILE,
description="Local directory for Libervia data",
)
downloads_dir: DirectoryPathAuto = Field(
default_factory=get_downloads_dir,
description="Directory where files can be downloaded by default.",
)
log_dir: DirectoryPath = Field(
default_factory=lambda data: data["local_dir"],
description="Directory where log files will be written. Default to local_dir.",
)
log_level: str | None = Field(
default=None, description="Logging level (debug, info, warning, error)"
)
log_fmt: str | None = Field(default=None, description="Log message format")
bridges: list[BridgeSettings] = Field(
default_factory=list,
description=(
"Bridge is the name of the IPC used to communicate between backend and"
" frontends. Leave empty to let Libervia determine the best one. If you"
" indicate several bridges, they will run in parallel."
),
)
pid_dir: DirectoryPathAuto = Field(
default=C.TEMP_DIR,
description=(
"Directory where PID files will be stored. Default is a subdirectory of the"
" system's temporary directory."
),
)
xmpp_domain: str = Field(
default=getfqdn(),
description=(
"XMPP domain of the main server. Local hostname will be used by default."
),
)
new_account_domain: str | None = Field(
default_factory=lambda data: data["xmpp_domain"],
description=(
"Domain to use when creating new account. Default to ``xmpp_domain``."
),
)
public_url: str | None = Field(default=None, description="Public URL for the server")
# FIXME: we don't use JIDType here because config is also parsed by frontend, and
# JIDType uses Twisted's JID class.
pubsub_service: str | None = Field(
default=None,
description=(
"Main pubsub service to use. If not set, it will be discovered automatically."
),
)
admins_list: list[str] = Field(
default_factory=list, description="List of profile names of admin users"
)
email_admins_list: list[str] = Field(
default_factory=list, description="List of admin email addresses"
)
mutli_users: bool = Field(default=False, description="Enable multi-user support")
pubsub_cache_strategy: Literal["no_cache"] | None = Field(
default=None, description="PubSub cache strategy"
)
hosts_dict: dict[str, dict] = Field(
default_factory=dict, description="Dictionary of host configurations"
)
ack_timeout: int = Field(
default=35,
description=(
"Acknowledgement timeout in seconds. If the server doesn't acknowledge a"
" stanza within this time, the connection will be aborted."
),
)
force_autodisconnect: bool = Field(
default=False, description="Force automatic disconnection"
)
# FIXME: Seems not used anymore, delete?
use_local_shared_tmp: bool = Field(
default=False,
description=(
"If set, a temporary dir in cache will be used to share files between backend"
" and frontends, useful when they are separated (e.g. when using containers)."
" If unset, a temporary dir will be automatically created in os-relevant"
" location."
),
)
allow_external_search: bool = Field(
default=False, description="Allow external search functionality"
)
image_max: tuple[int, int] = Field(
default=(1200, 720), description="Maximum dimensions for images (width, height)"
)
init_wait_for_service: str | None = Field(
default=None,
description=(
"Wait for a network service to become available before starting the backend."
" Format is 'host:port[:timeout][:service_name]' where host can be an"
" IPv4/IPv6 address or hostname, port is the service port number, timeout is"
" optional (default 60 seconds) and service_name is optional (defaults to"
" 'service'). Example: 'localhost:5222:30:XmppServer'"
),
)
init_script: FilePath | None = Field(
default=None,
description="Path to an executable script to be run during backend startup.",
)
group_chat_search_default_jid: str = Field(
default="api@search.jabber.network",
description="JID of the entity used for group chat searches.",
)
@field_validator("bridges", mode="after")
@classmethod
def set_bridges_auto(cls, bridges: list[BridgeSettings]) -> list[BridgeSettings]:
if not bridges:
# FIXME: We default to DBus for now.
bridges = [DBusBridgeSettings()]
return bridges
def get_bridge_conf(self, bridge_type: type[B]) -> B:
"""Get bridge configuration by type.
@param type: The type of bridge to find (e.g. ``pb``, ``dbus``)
@return: The configuration for the specified bridge.
@raise exceptions.InternalError: If no bridge configuration is found for the given
type.
"""
for bridge_conf in self.bridges:
if isinstance(bridge_conf, bridge_type):
return bridge_conf
# We raise internal error because there should not be any request for conf if the
# bridge is not in "bridges" and thus instanciating.
raise exceptions.InternalError(
f"No bridge configuration found for type {type!r}."
)
class Settings(BaseSettings):
model_config = SettingsConfigDict(
extra="allow",
env_prefix="LIBERVIA_",
env_nested_delimiter="__",
toml_file=C.CONFIG_FILES,
)
common: CommonSettings = Field(default_factory=CommonSettings)
email: EmailSettings = Field(default_factory=EmailSettings)
tls: TLSSettings = Field(default_factory=TLSSettings)
web: WebSettings = Field(default_factory=WebSettings)
plugins: dict = Field(default_factory=dict, description="Plugin specific settings.")
components: dict = Field(
default_factory=dict, description="Component specific settings."
)
def get_settings(self, dyn_settings_cls: type[D]) -> D:
"""Get dynamic settings instance.
@param dyn_settings_cls: The dynamtic settings class to retrieve.
@return: The instance of the specified settings.
@raise exceptions.InternalError: If no settings are found. This should not happen
as the dynamtic classes are automatically registered.
"""
try:
settings = getattr(self, dyn_settings_cls._key)
if dyn_settings_cls._sub_key is None:
return settings
else:
return getattr(settings, dyn_settings_cls._sub_key)
except AttributeError:
raise exceptions.InternalError(
f"No settings found for {dyn_settings_cls._sub_key!r}, they should"
" be imported dynamically."
)
@classmethod
def settings_customise_sources(
cls,
settings_cls: type[BaseSettings],
init_settings: PydanticBaseSettingsSource,
env_settings: PydanticBaseSettingsSource,
dotenv_settings: PydanticBaseSettingsSource,
file_secret_settings: PydanticBaseSettingsSource,
) -> tuple[PydanticBaseSettingsSource, ...]:
return (
init_settings,
env_settings,
dotenv_settings,
file_secret_settings,
TomlConfigSettingsSource(
settings_cls,
# FIXME: deep_merge is not yet available, will be for pydantic-settings
# 2.13
# deep_merge=True
),
)
def parse_main_conf(log_filenames: bool = False, ignore_cache=False) -> Settings:
"""Look for main .toml configuration files, and parse them.
@param log_filenames: if True, log filenames of read config files.
@param ignore_cache: if True, settings will be reparsed even if they are already in
cache. Notable useful when dynamic setting (e.g.; from plugins) have been added.
"""
global cached_config
if log_filenames:
existing_conf_file = []
for path_str in C.CONFIG_FILES:
path = Path(path_str)
if path.is_file() and path.exists():
existing_conf_file.append(str(path))
if existing_conf_file:
log.info(
_("Configuration will be read from: {filenames}.").format(
filenames=", ".join(existing_conf_file)
)
)
else:
log.info(_("No configuration file found."))
if ignore_cache or cached_config is None:
dynamic_settings_kw = {}
for key, settings in DynamicSettings.items():
if isinstance(settings, dict):
NewSettingsModel = create_model(
f"{key.title()}Settings",
**cast(
dict,
{
sub_key: (settings_type, Field(default_factory=settings_type))
for sub_key, settings_type in settings.items()
},
),
__config__ = ConfigDict(extra="forbid")
)
dynamic_settings_kw[key] = (
NewSettingsModel,
Field(default_factory=NewSettingsModel),
)
else:
dynamic_settings_kw[key] = (
settings,
Field(default_factory=settings),
)
LiberviaSettings = create_model(
"LiberviaSettings", __base__=Settings, **dynamic_settings_kw
)
try:
cached_config = LiberviaSettings()
except ValidationError as e:
log.error(f"Settings are invalid, please fix your configuration: {e}")
raise exceptions.ConfigError
except:
log.exception("Can't parse settings.")
raise exceptions.InternalError
return cached_config