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
This commit is contained in:
Goffi 2026-03-24 11:57:16 +01:00
commit a9fa2eeed4
3 changed files with 159 additions and 16 deletions

View file

@ -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))

View file

@ -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)

View file

@ -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):