# HG changeset patch # User Goffi # Date 1677951047 -3600 # Node ID 402d31527af4c24f09b4957c408670fac7cbd372 # Parent 1b7c6ee080b9506ddf9aadec293e2cbf3eb86bfb plugin app manager: `start` doesn't wait anymore for actual app start: Application may be long to start (e.g. a Docker app may have to download images first, and even without the downloading, the starting could be long), which may lead to UI blocking or bridge time out. To prevent that, `start` is now returning immediately, and 2 new signals are used to indicate when the application is started, of if something wrong happened. `start` now returns initial app data, including exposed data without the computed exposed data. The computed data must be retrieved after the app has been started. diff -r 1b7c6ee080b9 -r 402d31527af4 sat/plugins/plugin_app_manager_docker/__init__.py --- a/sat/plugins/plugin_app_manager_docker/__init__.py Sat Mar 04 18:23:33 2023 +0100 +++ b/sat/plugins/plugin_app_manager_docker/__init__.py Sat Mar 04 18:30:47 2023 +0100 @@ -57,7 +57,7 @@ self._am.register(self) async def start(self, app_data: dict) -> None: - await self._am.startCommon(app_data) + await self._am.start_common(app_data) working_dir = app_data['_instance_dir_path'] try: override = app_data['override'] @@ -83,7 +83,7 @@ path=str(working_dir), ) - async def computeExpose(self, app_data: dict) -> dict: + async def compute_expose(self, app_data: dict) -> dict: working_dir = app_data['_instance_dir_path'] expose = app_data['expose'] ports = expose.get('ports', {}) diff -r 1b7c6ee080b9 -r 402d31527af4 sat/plugins/plugin_misc_app_manager.py --- a/sat/plugins/plugin_misc_app_manager.py Sat Mar 04 18:23:33 2023 +0100 +++ b/sat/plugins/plugin_misc_app_manager.py Sat Mar 04 18:30:47 2023 +0100 @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -# SàT plugin to manage external applications +# Libervia plugin to manage external applications # Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,7 @@ # along with this program. If not, see . from pathlib import Path -from typing import Optional, List +from typing import Optional, List, Callable from functools import partial, reduce import tempfile import secrets @@ -95,7 +95,7 @@ "applicationStart", ".plugin", in_sign="ss", - out_sign="", + out_sign="s", method=self._start, async_=True, ) @@ -112,9 +112,19 @@ ".plugin", in_sign="sss", out_sign="s", - method=self._getExposed, + method=self._get_exposed, async_=True, ) + # application has been started succeesfully, + # args: name, instance_id, extra + host.bridge.addSignal( + "application_started", ".plugin", signature="sss" + ) + # application went wrong with the application + # args: name, instance_id, extra + host.bridge.addSignal( + "application_error", ".plugin", signature="sss" + ) yaml.add_constructor( "!sat_conf", self._sat_conf_constr, Loader=Loader) yaml.add_constructor( @@ -133,7 +143,7 @@ data['_instance_dir_obj'].cleanup() def _sat_conf_constr(self, loader, node): - """Get a value from SàT configuration + """Get a value from Libervia configuration A list is expected with either "name" of a config parameter, a one or more of those parameters: @@ -208,7 +218,7 @@ if hasattr(manager, "discover_path"): self.discover(manager.discover_path, manager) - def getManager(self, app_data: dict) -> object: + def get_manager(self, app_data: dict) -> object: """Get manager instance needed for this app @raise exceptions.DataError: something is wrong with the type @@ -231,7 +241,7 @@ raise exceptions.NotFound( f"No manager found to manage app of type {app_type!r}") - def getAppData( + def get_app_data( self, id_type: Optional[str], identifier: str @@ -277,7 +287,7 @@ if manager is None: try: app_data = self.parse(file_path) - manager = self.getManager(app_data) + manager = self.get_manager(app_data) except (exceptions.DataError, exceptions.NotFound) as e: log.warning( f"Can't parse {file_path}, skipping: {e}") @@ -294,7 +304,7 @@ ) def parse(self, file_path: Path, params: Optional[dict] = None) -> dict: - """Parse SàT application file + """Parse Libervia application file @param params: parameters for running this instance @raise exceptions.DataError: something is wrong in the file @@ -341,13 +351,30 @@ def _start(self, app_name, extra): extra = data_format.deserialise(extra) - return defer.ensureDeferred(self.start(str(app_name), extra)) + d = defer.ensureDeferred(self.start(str(app_name), extra)) + d.addCallback(data_format.serialise) + return d async def start( self, app_name: str, extra: Optional[dict] = None, - ) -> None: + ) -> dict: + """Start an application + + @param app_name: name of the application to start + @param extra: extra parameters + @return: data with following keys: + - name (str): canonical application name + - instance (str): instance ID + - started (bool): True if the application is already started + if False, the "application_started" signal should be used to get notificed + when the application is actually started + - expose (dict): exposed data as given by [self.get_exposed] + exposed data which need to be computed are NOT returned, they will + available when the app will be fully started, throught the + [self.get_exposed] method. + """ # FIXME: for now we use the first app manager available for the requested app_name # TODO: implement running multiple instance of the same app if some metadata # to be defined in app_data allows explicitly it. @@ -358,15 +385,28 @@ raise exceptions.NotFound( f"No application found with the name {app_name!r}" ) + log.info(f"starting {app_name!r}") started_data = self._started.setdefault(app_name, []) app_data = self.parse(app_file_path, extra) + app_data["_started"] = False app_data['_file_path'] = app_file_path app_data['_name_canonical'] = app_name single_instance = app_data['single_instance'] + ret_data = { + "name": app_name, + "started": False + } if single_instance: if started_data: - log.info(f"{app_name!r} is already started") - return + instance_data = started_data[0] + instance_id = instance_data["_instance_id"] + ret_data["instance"] = instance_id + ret_data["started"] = instance_data["_started"] + ret_data["expose"] = await self.get_exposed( + instance_id, "instance", {"skip_compute": True} + ) + log.info(f"{app_name!r} is already started or being started") + return ret_data else: cache_path = self.host.memory.getCachePath( PLUGIN_INFO[C.PI_IMPORT_NAME], app_name @@ -377,11 +417,16 @@ dest_dir_obj = tempfile.TemporaryDirectory(prefix="sat_app_") app_data['_instance_dir_obj'] = dest_dir_obj app_data['_instance_dir_path'] = Path(dest_dir_obj.name) - instance_id = app_data['_instance_id'] = shortuuid.uuid() - manager = self.getManager(app_data) + instance_id = ret_data["instance"] = app_data['_instance_id'] = shortuuid.uuid() + manager = self.get_manager(app_data) app_data['_manager'] = manager started_data.append(app_data) self._instances[instance_id] = app_data + # we retrieve exposed data such as url_prefix which can be useful computed exposed + # data must wait for the app to be started, so we skip them for now + ret_data["expose"] = await self.get_exposed( + instance_id, "instance", {"skip_compute": True} + ) try: start = manager.start @@ -390,8 +435,29 @@ f"{manager.name} doesn't have the mandatory \"start\" method" ) else: - await start(app_data) - log.info(f"{app_name!r} started") + defer.ensureDeferred(self.start_app(start, app_data)) + return ret_data + + async def start_app(self, start_cb: Callable, app_data: dict) -> None: + app_name = app_data["_name_canonical"] + instance_id = app_data["_instance_id"] + try: + await start_cb(app_data) + except Exception as e: + log.exception(f"Can't start libervia app {app_name!r}") + self.host.bridge.application_error( + app_name, + instance_id, + data_format.serialise( + { + "class": str(type(e)), + "msg": str(e) + } + )) + else: + app_data["_started"] = True + self.host.bridge.application_started(app_name, instance_id, "") + log.info(f"{app_name!r} started") def _stop(self, identifier, id_type, extra): extra = data_format.deserialise(extra) @@ -407,7 +473,7 @@ if extra is None: extra = {} - app_data = self.getAppData(id_type, identifier) + app_data = self.get_app_data(id_type, identifier) log.info(f"stopping {app_data['name']!r}") @@ -447,21 +513,13 @@ log.info(f"{app_name!r} stopped") - def _getExposed(self, identifier, id_type, extra): + def _get_exposed(self, identifier, id_type, extra): extra = data_format.deserialise(extra) - d = defer.ensureDeferred(self.getExposed(identifier, id_type, extra)) + d = defer.ensureDeferred(self.get_exposed(identifier, id_type, extra)) d.addCallback(lambda d: data_format.serialise(d)) return d - def getValueFromPath(self, app_data: dict, path: List[str]) -> any: - """Retrieve a value set in the data from it path - - @param path: list of key to use in app_data to retrieve the value - @return: found value - @raise NotFound: the value can't be found - """ - - async def getExposed( + async def get_exposed( self, identifier: str, id_type: Optional[str] = None, @@ -469,10 +527,10 @@ ) -> dict: """Get data exposed by the application - The manager's "computeExpose" method will be called if it exists. It can be used + The manager's "compute_expose" method will be called if it exists. It can be used to handle manager specific conventions. """ - app_data = self.getAppData(id_type, identifier) + app_data = self.get_app_data(id_type, identifier) if app_data.get('_exposed_computed', False): return app_data['expose'] if extra is None: @@ -500,17 +558,20 @@ f"Can't retrieve exposed value for url_prefix: {e}") del expose["url_prefix"] + if extra.get("skip_compute", False): + return expose + try: - computeExpose = app_data['_manager'].computeExpose + compute_expose = app_data['_manager'].compute_expose except AttributeError: pass else: - await computeExpose(app_data) + await compute_expose(app_data) app_data['_exposed_computed'] = True return expose - async def _doPrepare( + async def _do_prepare( self, app_data: dict, ) -> None: @@ -548,7 +609,7 @@ if prepare: raise exceptions.InternalError('"prepare" should be empty') - async def _doCreateFiles( + async def _do_create_files( self, app_data: dict, ) -> None: @@ -566,11 +627,10 @@ f.write(data.get("content", "")) log.debug(f"{path} created") - async def startCommon(self, app_data: dict) -> None: + async def start_common(self, app_data: dict) -> None: """Method running common action when starting a manager It should be called by managers in "start" method. """ - log.info(f"starting {app_data['name']!r}") - await self._doPrepare(app_data) - await self._doCreateFiles(app_data) + await self._do_prepare(app_data) + await self._do_create_files(app_data)