plugin app manager, app manager docker: update implementation:

A new `application_update` bridge method is added to easily update images. Running
container are automatically updated if necessary.
This commit is contained in:
Goffi 2026-07-19 15:06:31 +02:00
commit 6b4f7252e3
3 changed files with 144 additions and 7 deletions

View file

@ -17,12 +17,21 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
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(

View file

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

View file

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