core: implement isolation mode:
Normally the backend is made to have a singleton instance, and handle all profiles. However, it may be useful to run instances independently (e.g., for development, to run components in independent processes, for testing, etc). This was previously possible by using a specific configuration files at backend's root. This patch introduce a cleaner way by using an isolation mode. Isolation mode is activated by settings the `LIBERVIA_ISOLATION` environment variable to the name of the isolated instance. `LIBERVIA_ISOLATION_MODE` can be used to specify one of the following mode: - normal (default): config and shared files are in XDG in a `isolated/<name>` subdirectory (usually `~/.local/share/libervia/isolated/<name>` and `~/.config/libervia/isolated/<name>`). Runtime files and other instance specific data (such as D-Bus name) are also isolated. Config uses cascade as usual. - full: same as above, but config isn't cascading, it's using only its own isolated config file. - tmp: same as normal but the local directory is put in a temporary directory, and deleted on backend startup. It stays on termination, so data can be checked if necessary (this mode is usually used for development). This feature will notably be useful to test severless mode on a single machine. rel 479
This commit is contained in:
parent
c6f390df3c
commit
f7a42771e8
3 changed files with 96 additions and 6 deletions
|
|
@ -18,6 +18,7 @@
|
|||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from typing import Final
|
||||
|
||||
from libervia import backend
|
||||
|
|
@ -52,17 +53,54 @@ class Const:
|
|||
APP_RELEASE_NAME = "La Ruche"
|
||||
APP_URL = "https://libervia.org"
|
||||
|
||||
## Isolation ##
|
||||
isolation_name = os.environ.get("LIBERVIA_ISOLATION")
|
||||
|
||||
if isolation_name:
|
||||
# Validate isolation name: only allow [A-Za-z0-9_-] to prevent path traversal and
|
||||
# other issues.
|
||||
if not isolation_name.replace("-", "").replace("_", "").isalnum():
|
||||
raise ValueError(
|
||||
f"Invalid LIBERVIA_ISOLATION name {isolation_name!r}, "
|
||||
f"must only contain [A-Za-z0-9_-]"
|
||||
)
|
||||
isolation_mode = os.environ.get("LIBERVIA_ISOLATION_MODE", "normal").lower()
|
||||
match isolation_mode:
|
||||
case "tmp":
|
||||
ISOLATED_LOCAL_DIR = (
|
||||
Path(tempfile.gettempdir()) / f"libervia_isolated_{isolation_name}"
|
||||
)
|
||||
ISOLATED_RUNTIME_DIR = (
|
||||
Path(tempfile.gettempdir())
|
||||
/ f"libervia_isolated_{isolation_name}_run"
|
||||
)
|
||||
ISOLATED_CONFIG_DIR = (
|
||||
xdg_config_home / APP_NAME_FILE / "isolated" / isolation_name
|
||||
)
|
||||
case "normal" | "full":
|
||||
_dir_suffix = Path("isolated") / isolation_name
|
||||
ISOLATED_LOCAL_DIR = xdg_data_home / APP_NAME_FILE / _dir_suffix
|
||||
ISOLATED_RUNTIME_DIR = xdg_runtime_dir / APP_NAME_FILE / _dir_suffix
|
||||
ISOLATED_CONFIG_DIR = xdg_config_home / APP_NAME_FILE / _dir_suffix
|
||||
case _:
|
||||
raise ValueError(
|
||||
f"Invalid LIBERVIA_ISOLATION_MODE {isolation_mode!r}, "
|
||||
f"must be one of: normal, full, tmp"
|
||||
)
|
||||
else:
|
||||
isolation_mode = None
|
||||
ISOLATED_LOCAL_DIR = None
|
||||
ISOLATED_RUNTIME_DIR = None
|
||||
ISOLATED_CONFIG_DIR = None
|
||||
|
||||
## Configuration ##
|
||||
# Config paths are ordered by priority (lowest first).
|
||||
# Later entries override earlier ones.
|
||||
CONFIG_PATHS = [
|
||||
# /etc/_ is used for system-related settings (when a value is set by the
|
||||
# distribution, or in a Docker image)
|
||||
Path("/etc/_"),
|
||||
Path("/etc/"),
|
||||
Path.home(),
|
||||
xdg_config_home / APP_NAME_FILE,
|
||||
# We use backend root as last config path, this is useful for per instance
|
||||
# configurations.
|
||||
Path(backend.__file__).parent.parent,
|
||||
]
|
||||
|
||||
for d in xdg_config_dirs:
|
||||
|
|
@ -75,6 +113,9 @@ class Const:
|
|||
# to add /app
|
||||
CONFIG_PATHS.append(Path("/app/"))
|
||||
|
||||
CONFIG_PATHS.append(xdg_config_home / APP_NAME_FILE)
|
||||
CONFIG_PATHS.append(Path.home())
|
||||
|
||||
CONFIG_FILES: list[Path] = []
|
||||
for p in CONFIG_PATHS:
|
||||
CONFIG_FILES.append(p / f"{APP_NAME_FILE}.toml")
|
||||
|
|
@ -82,7 +123,16 @@ class Const:
|
|||
# In $HOME, the config file can be hidden.
|
||||
CONFIG_FILES.append(Path.home() / f".{APP_NAME_FILE}.toml")
|
||||
|
||||
RUNTIME_DIR = xdg_runtime_dir / APP_NAME_FILE
|
||||
if ISOLATED_CONFIG_DIR is not None:
|
||||
isolated_conf = ISOLATED_CONFIG_DIR / f"{APP_NAME_FILE}.toml"
|
||||
if isolation_mode == "normal":
|
||||
CONFIG_FILES.append(isolated_conf)
|
||||
else:
|
||||
CONFIG_FILES = [isolated_conf]
|
||||
|
||||
RUNTIME_DIR = (
|
||||
ISOLATED_RUNTIME_DIR if ISOLATED_RUNTIME_DIR else xdg_runtime_dir / APP_NAME_FILE
|
||||
)
|
||||
|
||||
## Runtime ##
|
||||
PLUGIN_EXT = "py"
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@
|
|||
import sys
|
||||
import os
|
||||
import argparse
|
||||
import shutil
|
||||
import tempfile
|
||||
from argparse import _SubParsersAction
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -72,6 +74,25 @@ class Launcher:
|
|||
except Exception as e:
|
||||
print(rf"/!\ Can't read main config! {e}")
|
||||
sys.exit(1)
|
||||
if C.isolation_mode == "tmp":
|
||||
self.clean_tmp_dir()
|
||||
if C.isolation_mode is not None:
|
||||
print(
|
||||
f"Isolation mode '{C.isolation_mode}' active "
|
||||
f"(instance: {C.isolation_name}, "
|
||||
f"config: {C.ISOLATED_CONFIG_DIR})"
|
||||
)
|
||||
|
||||
def clean_tmp_dir(self) -> None:
|
||||
"""Remove temporary isolation directory on startup."""
|
||||
tmp_path = C.ISOLATED_LOCAL_DIR
|
||||
if tmp_path is None:
|
||||
return
|
||||
# We want to be sure that we delete a temporary directory, and not accidentally
|
||||
# another local directory.
|
||||
if str(tmp_path).startswith(tempfile.gettempdir()):
|
||||
shutil.rmtree(tmp_path, ignore_errors=True)
|
||||
print(f"Cleaned temp isolation directory: {tmp_path}")
|
||||
|
||||
def cmd_no_subparser(self, args):
|
||||
"""Command launched by default"""
|
||||
|
|
|
|||
|
|
@ -222,6 +222,18 @@ class DBusBridgeSettings(ConfigOptions):
|
|||
default="org.libervia.Libervia", description="Interface prefix"
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def force_isolated_int_prefix(self) -> Self:
|
||||
"""Append isolation name to D-Bus service prefix to avoid conflicts."""
|
||||
if C.isolation_name is not None:
|
||||
# The isolation name is already restricted, so we only need to handle D-Bus
|
||||
# specific rules.
|
||||
sanitized = C.isolation_name.replace("-", "_")
|
||||
if sanitized[0].isdigit():
|
||||
sanitized = f"_{sanitized}"
|
||||
self.int_prefix = f"{self.int_prefix}.isolated.{sanitized}"
|
||||
return self
|
||||
|
||||
|
||||
class AutoBridgeSettings(ConfigOptions):
|
||||
"""Automatic Bridge.
|
||||
|
|
@ -403,6 +415,13 @@ class CommonSettings(ConfigOptions):
|
|||
description="JID of the entity used for group chat searches.",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def force_isolated_local_dir(self) -> Self:
|
||||
# In isolated mode, we force local_dir to avoid issues with parent configs.
|
||||
if C.ISOLATED_LOCAL_DIR is not None:
|
||||
self.local_dir = C.ISOLATED_LOCAL_DIR
|
||||
return self
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
|
|
|
|||
Loading…
Reference in a new issue