diff --git a/libervia/backend/plugins/plugin_app_manager_docker/__init__.py b/libervia/backend/plugins/plugin_app_manager_docker/__init__.py index 3e83415b..e2d1b179 100644 --- a/libervia/backend/plugins/plugin_app_manager_docker/__init__.py +++ b/libervia/backend/plugins/plugin_app_manager_docker/__init__.py @@ -17,12 +17,21 @@ # along with this program. If not, see . from pathlib import Path +from typing import Literal + +from pydantic import Field from twisted.python.procutils import which -from libervia.backend.core.i18n import _ -from libervia.backend.core.constants import Const as C + +from libervia.backend import G from libervia.backend.core import exceptions +from libervia.backend.core.constants import Const as C +from libervia.backend.core.i18n import _ from libervia.backend.core.log import getLogger -from libervia.backend.plugins.plugin_misc_app_manager.models import AppManagerBackend +from libervia.backend.plugins.plugin_misc_app_manager.models import ( + AppManagerBackend, + AppUpdateResult, + AppUpdateServiceInfo, +) from libervia.backend.tools.common import async_process from libervia.backend.tools.config import PluginSettings @@ -108,6 +117,63 @@ class AppManagerDocker(AppManagerBackend): path=str(working_dir), ) + async def update(self, app_data: dict) -> AppUpdateResult | None: + working_dir = app_data["_instance_dir_path"] + old_versions = await self._get_images(working_dir) + await async_process.run( + self.runner_path, + "compose", + "pull", + path=str(working_dir), + ) + new_versions = await self._get_images(working_dir) + updated = [ + AppUpdateServiceInfo( + service=name, old_version=old_id, new_version=new_versions[name] + ) + for name, old_id in old_versions.items() + if name in new_versions and old_id != new_versions[name] + ] + if not updated: + return None + if app_data.get("_started"): + # Running: recreate containers with new images + await async_process.run( + self.runner_path, + "compose", + "up", + "--detach", + path=str(working_dir), + ) + elif old_versions: + # Stopped: recreate containers but don't start them + await async_process.run( + self.runner_path, + "compose", + "up", + "--detach", + "--no-start", + path=str(working_dir), + ) + return AppUpdateResult(updated=updated) + + async def _get_images(self, working_dir: Path) -> dict[str, str]: + output = await async_process.run( + self.runner_path, + "compose", + "images", + "--format", + "{{.Name}}:{{.Image}}", + path=str(working_dir), + ) + images: dict[str, str] = {} + for line in output.decode().strip().splitlines(): + if ":" not in line: + continue + name, image = line.split(":", 1) + images[name] = image + return images + async def compute_expose(self, app_data: dict) -> None: working_dir = app_data["_instance_dir_path"] expose = app_data["expose"] @@ -132,7 +198,7 @@ class AppManagerDocker(AppManagerBackend): ) exposed_port = exposed_port.decode().strip() try: - addr, port = exposed_port.split(":") + __, port = exposed_port.split(":") int(port) except ValueError: log.warning( diff --git a/libervia/backend/plugins/plugin_misc_app_manager/__init__.py b/libervia/backend/plugins/plugin_misc_app_manager/__init__.py index 73a113e5..3d0ea16a 100644 --- a/libervia/backend/plugins/plugin_misc_app_manager/__init__.py +++ b/libervia/backend/plugins/plugin_misc_app_manager/__init__.py @@ -36,7 +36,10 @@ from libervia.backend.core.constants import Const as C from libervia.backend.core.i18n import _ from libervia.backend.core.log import getLogger from libervia.backend.memory import persistent -from libervia.backend.plugins.plugin_misc_app_manager.models import AppManagerBackend +from libervia.backend.plugins.plugin_misc_app_manager.models import ( + AppManagerBackend, + AppUpdateResult, +) from libervia.backend.tools.common import data_format from libervia.backend.tools.common import async_process @@ -78,12 +81,12 @@ APP_FILE_PREFIX = "libervia_app_" class AppManager: - load = partial(yaml.load, Loader=Loader) - dump = partial(yaml.dump, Dumper=Dumper) def __init__(self, host): log.info(_("plugin Applications Manager initialization")) self.host = host + self.load = partial(yaml.load, Loader=Loader) + self.dump = partial(yaml.dump, Dumper=Dumper) self._managers = {} self._apps = {} self._started = {} @@ -123,6 +126,14 @@ class AppManager: method=self._get_exposed, async_=True, ) + host.bridge.add_method( + "application_update", + ".plugin", + in_sign="s", + out_sign="s", + method=self._update, + async_=True, + ) # application has been started succeesfully, # args: name, instance_id, extra host.bridge.add_signal("application_started", ".plugin", signature="sss") @@ -558,6 +569,39 @@ class AppManager: log.info(f"{app_name!r} stopped") + def _update(self, app_name): + d = defer.ensureDeferred(self.update(str(app_name))) + d.addCallback(lambda result: "" if result is None else result.model_dump_json()) + return d + + async def update(self, app_name: str) -> AppUpdateResult | None: + app_name = app_name.lower().strip() + app_data = self._started.get(app_name) + if app_data: + app_data = app_data[0] + else: + try: + app_file_path = next(iter(next(iter(self._apps[app_name].values())))) + except KeyError: + raise exceptions.NotFound( + f"No application found with the name {app_name!r}" + ) + self._app_persistent_data = await self.persistent_data.get(app_name) or {} + app_data = self.parse(app_file_path) + app_data["_started"] = False + app_data["_file_path"] = app_file_path + app_data["_name_canonical"] = app_name + cache_path = self.host.memory.get_cache_path( + PLUGIN_INFO[C.PI_IMPORT_NAME], app_name + ) + app_data["_instance_dir_path"] = cache_path + manager = self.get_manager(app_data) + log.info(f"updating {app_name!r}") + try: + return await manager.update(app_data) + except Exception as e: + raise exceptions.InternalError(f"Can't update {app_name!r}: {e}") + def _get_exposed(self, identifier, id_type, extra): extra = data_format.deserialise(extra) d = defer.ensureDeferred(self.get_exposed(identifier, id_type, extra)) diff --git a/libervia/backend/plugins/plugin_misc_app_manager/models.py b/libervia/backend/plugins/plugin_misc_app_manager/models.py index c832cec5..ef81afb0 100644 --- a/libervia/backend/plugins/plugin_misc_app_manager/models.py +++ b/libervia/backend/plugins/plugin_misc_app_manager/models.py @@ -20,9 +20,27 @@ import abc from pathlib import Path +from pydantic import BaseModel, Field + from libervia.backend.core.i18n import _ +class AppUpdateServiceInfo(BaseModel): + """Info about a service that was updated.""" + + service: str = Field(description="Service name") + old_version: str = Field(description="Previous version identifier") + new_version: str = Field(description="New version identifier") + + +class AppUpdateResult(BaseModel): + """Result of an app update.""" + + updated: list[AppUpdateServiceInfo] = Field( + description="List of services that were updated" + ) + + class AppManagerBackend(abc.ABC): """Abstract class for App Manager.""" @@ -54,6 +72,15 @@ class AppManagerBackend(abc.ABC): """ pass + @abc.abstractmethod + async def update(self, app_data: dict) -> AppUpdateResult | None: + """Update the app. + + @param app_data: A dictionary containing app data. + @return: Update result with changed services, or None if no update. + """ + pass + @abc.abstractmethod async def compute_expose(self, app_data: dict) -> None: """Compute exposed data for the app.