From a9fa2eeed4dc437e0a84026ea2a3e44d9b755f65 Mon Sep 17 00:00:00 2001 From: Goffi Date: Tue, 24 Mar 2026 11:57:16 +0100 Subject: [PATCH] core (main, config): Component auto-configuration: New `jid`, `secret` and `autoconnect` field are used for component settings. When used, backend will automatically create the profile and set its relevant parameters, or check that existing profile is in sync (and update it if necessary). This make component installation easier to Libervia, as it is not necessary anymore to use the CLI frontend to install a component. rel 476 --- libervia/backend/core/main.py | 106 ++++++++++++++++++++++++++++++ libervia/backend/memory/memory.py | 33 +++++----- libervia/backend/tools/config.py | 36 +++++++++- 3 files changed, 159 insertions(+), 16 deletions(-) diff --git a/libervia/backend/core/main.py b/libervia/backend/core/main.py index 5e6b6487..ce49fca9 100644 --- a/libervia/backend/core/main.py +++ b/libervia/backend/core/main.py @@ -31,6 +31,7 @@ from libervia.backend.core import patches from libervia.backend.models.tor import TorData from libervia.backend.tools.config import ( BridgeSettings, + ComponentSettings, DBusBridgeSettings, PbBridgeSettings, ) @@ -296,6 +297,8 @@ class LiberviaBackend(service.Service): self.stopService() sys.exit(C.EXIT_ERROR) + await self._sync_components_profiles() + self.initialised.callback(None) log.info(_("Backend is ready")) @@ -535,6 +538,109 @@ class LiberviaBackend(service.Service): defers_list.append(utils.as_deferred(unload)) return defers_list + async def _sync_components_profiles(self) -> None: + """Synchronize components profiles data with settings. + + ``jid`` and ``secret`` are used in settings as helpers to easily create + autoconnecting components. This method syncs those settings with actual profiles. + """ + log.debug("Synchronizing component settings.") + for component_field_name in G.config.component.__class__.model_fields: + component_settings = cast( + ComponentSettings, getattr(G.config.component, component_field_name) + ) + + if component_settings.jid is not None: + # Automatic setting fields are used + assert component_settings.secret is not None + entry_point = component_settings._import_name + assert entry_point is not None + profile = entry_point + if not G.storage.has_profile(entry_point): + log.info(f"Creating profile {profile!r}") + await G.host.memory.create_profile(profile, "", entry_point) + # FIXME: why do we need to start session to set parameters? To check + # after parameters refactoring. + await G.host.memory.start_session("", profile) + await G.host.memory.param_set( + "JabberID", + str(component_settings.jid), + "Connection", + profile_key=profile, + ) + await G.host.memory.param_set( + "Password", + component_settings.secret, + "Connection", + profile_key=profile, + ) + await G.host.memory.param_set( + "autoconnect_backend", + component_settings.autoconnect, + "Connection", + profile_key=profile, + ) + log.info(f"Component profile {profile!r} set for auto-connection.") + else: + # Profile exists, check if values correspond to settings + try: + # FIXME: As for new profile condition above, we need to check if + # we can skip session starting to set profile parameters. + await G.host.memory.start_session("", profile) + except exceptions.PasswordError: + # Component passwords should be empty to allow for autoconnection. + log.warning( + f"Can't set automatically component profile for {profile}: " + "the password is not empty as expected." + ) + continue + current_jid = await G.host.memory.param_get_a_async( + "JabberID", "Connection", profile_key=profile + ) + if current_jid != str(component_settings.jid): + log.warning( + f"Component profile {profile!r} JID mismatch: " + f"current={current_jid}, settings={component_settings.jid}, " + f"updating from settings" + ) + await G.host.memory.param_set( + "JabberID", + str(component_settings.jid), + "Connection", + profile_key=profile, + ) + + current_password = await G.host.memory.param_get_a_async( + "Password", "Connection", profile_key=profile + ) + if current_password != component_settings.secret: + log.warning( + f"Component profile {profile!r} secret mismatch, " + f"updating from settings" + ) + await G.host.memory.param_set( + "Password", + component_settings.secret, + "Connection", + profile_key=profile, + ) + + current_autoconnect = await G.host.memory.param_get_a_async( + "autoconnect_backend", "Connection", profile_key=profile + ) + expected_autoconnect = component_settings.autoconnect + if current_autoconnect != expected_autoconnect: + log.warning( + f"Component profile {profile!r} autoconnect setting" + f" mismatch, updating from settings" + ) + await G.host.memory.param_set( + "autoconnect_backend", + expected_autoconnect, + "Connection", + profile_key=profile, + ) + def _connect(self, profile_key, password="", options=None): profile = self.memory.get_profile_name(profile_key) return defer.ensureDeferred(self.connect(profile, password, options)) diff --git a/libervia/backend/memory/memory.py b/libervia/backend/memory/memory.py index d9d564ab..42228642 100644 --- a/libervia/backend/memory/memory.py +++ b/libervia/backend/memory/memory.py @@ -30,7 +30,6 @@ from uuid import uuid4 import shortuuid from twisted.internet import defer, error, reactor from twisted.internet.base import DelayedCall -from twisted.python import failure from twisted.words.protocols.jabber import jid from libervia.backend.core import exceptions @@ -44,7 +43,6 @@ from libervia.backend.memory.disco import Discovery from libervia.backend.memory.params import Params from libervia.backend.memory.persistent import PersistentDict from libervia.backend.memory.sqla import ( - Notification, NotificationPriority, NotificationStatus, NotificationType, @@ -518,15 +516,19 @@ class Memory: self.memory_data["Profile_default"] = profile - def create_profile(self, name, password, component=None): + def create_profile( + self, name: str, password: str, component: str | None = None + ) -> defer.Deferred[None]: """Create a new profile - @param name(unicode): profile name - @param password(unicode): profile password - Can be empty to disable password - @param component(None, unicode): set to entry point if this is a component - @return: Deferred - @raise exceptions.NotFound: component is not a known plugin import name + @param name: profile name + @param password: profile password (can be empty to disable password) + @param component: entry point if this is a component + @return: Deferred that fires when the profile is created + @raise ValueError: if profile name is empty, starts with '@', or contains line + feed + @raise exceptions.ConflictError: if a session for this profile exists + @raise exceptions.NotFound: if component is not a known plugin import name """ if not name: raise ValueError("Empty profile name") @@ -547,7 +549,8 @@ class Memory: ) ) ) - # FIXME: PLUGIN_INFO is not currently accessible after import, but type shoul be tested here + # FIXME: PLUGIN_INFO is not currently accessible after import, but type should + # be tested here #  if self.host.plugins[component].PLUGIN_INFO[u"type"] != C.PLUG_TYPE_ENTRY_POINT: #   raise ValueError(_(u"Plugin {component} is not an entry point !".format( #   component = component))) @@ -612,12 +615,12 @@ class Memory: """ return self.storage.profile_is_component(profile_name) - def get_entry_point(self, profile_name): - """Get a component entry point + def get_entry_point(self, profile_name: str) -> str: + """Get a component entry point for a given profile. - @param profile_name(unicode): name of the profile - @return (bool): True if profile is a component - @raise exceptions.NotFound: profile doesn't exist + @param profile_name: name of the profile + @return: the component entry point (import name) for the profile + @raise exceptions.NotFound: if the profile doesn't exist or is not a component """ return self.storage.get_entry_point(profile_name) diff --git a/libervia/backend/tools/config.py b/libervia/backend/tools/config.py index ba4d5548..797e73e8 100644 --- a/libervia/backend/tools/config.py +++ b/libervia/backend/tools/config.py @@ -22,7 +22,7 @@ from collections import defaultdict from pathlib import Path from socket import getfqdn -from typing import Annotated, Any, Literal, TypeVar, cast +from typing import Annotated, Any, Literal, Self, TypeVar, cast from pydantic import ( AfterValidator, @@ -32,6 +32,7 @@ from pydantic import ( ValidationError, create_model, field_validator, + model_validator, ) from pydantic.types import PathType from pydantic_settings import ( @@ -45,6 +46,7 @@ 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.models.types import JIDType from libervia.backend.tools.common.files_utils import get_downloads_dir log = getLogger(__name__) @@ -103,6 +105,7 @@ class DynamicSettingsBase(ConfigOptions): _key: str = "" _sub_key: str | None = None + _import_name: str | None = None @classmethod def _init_subclass(cls, /, key: str, plugin_info: dict | None = None) -> None: @@ -127,6 +130,7 @@ class DynamicSettingsBase(ConfigOptions): f"There is already a plugin registered for {key!r}.{sub_key!r}." ) register[sub_key] = cls + cls._import_name = plugin_info.get(C.PI_IMPORT_NAME) else: cls._sub_key = None DynamicSettings[key] = cls @@ -143,10 +147,40 @@ class PluginSettings(DynamicSettingsBase): class ComponentSettings(DynamicSettingsBase): """Configuration options for components.""" + jid: JIDType | None = Field( + default=None, + description=( + "JID of the component. If used, the component profile will be created" + " automatically (with the component entry point used as name), and" + " auto-connect on backend startup." + ), + ) + secret: str | None = Field( + default=None, + description=( + "Shared secret between the component and the XMPP server. To be used with" + " ``jid`` field for automatic configuration and connetion." + ), + ) + autoconnect: bool = Field( + default=True, + description=( + "Whether to automatically connect this component on backend startup. Used" + " when ``jid`` and ``secret`` are set." + ), + ) + def __init_subclass__(cls, /, plugin_info: dict, **kwargs) -> None: super().__init_subclass__(**kwargs) super()._init_subclass(key="component", plugin_info=plugin_info) + @model_validator(mode="after") + def jid_and_secret_together(self) -> Self: + """Check that "jid" and "secret" are (un)set at the same time""" + if (self.jid is None) ^ (self.secret is None): + raise ValueError('You must set "jid" and "secret" together.') + return self + class FrontendSettings(DynamicSettingsBase):