comparison libervia/backend/plugins/plugin_misc_app_manager/__init__.py @ 4247:4aa62767f501

plugin app manager: various improvements: - Generated password must now be named and are stored, so they are re-used on following restarts. Password size can now be specified. - New `not` filter for `!libervia_param` to inverse a boolean value. - Former `front_url` field has been renamed to `web_url_path` as it is the URL path used for web frontend. All Web frontend related field are prefixed with `web_`. - `front_url` is now used to specify a whole front URL (notably useful if an app uses its own domain). A list can be used to retrieve a key, like for `url_prefix`, and `https` scheme is added if no scheme is specified. - An abstract class is now used for App Managers. - Last application start time is stored in persistent data.
author Goffi <goffi@goffi.org>
date Fri, 31 May 2024 11:08:14 +0200
parents libervia/backend/plugins/plugin_misc_app_manager.py@c93b02000ae4
children 0d7bb4df2343
comparison
equal deleted inserted replaced
4246:5eb13251fd75 4247:4aa62767f501
1 #!/usr/bin/env python3
2
3 # Libervia plugin to manage external applications
4 # Copyright (C) 2009-2024 Jérôme Poisson (goffi@goffi.org)
5
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU Affero General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
10
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU Affero General Public License for more details.
15
16 # You should have received a copy of the GNU Affero General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
18
19 from functools import partial, reduce
20 from pathlib import Path
21 import secrets
22 import string
23 import tempfile
24 import time
25 from typing import Any, Callable, List, Optional
26 from urllib.parse import urlparse, urlunparse
27
28 import shortuuid
29 from twisted.internet import defer
30 from twisted.python.procutils import which
31 from yaml.constructor import ConstructorError
32
33 from libervia.backend.core import exceptions
34 from libervia.backend.core.constants import Const as C
35 from libervia.backend.core.i18n import _
36 from libervia.backend.core.log import getLogger
37 from libervia.backend.memory import persistent
38 from libervia.backend.plugins.plugin_misc_app_manager.models import AppManagerBackend
39 from libervia.backend.tools.common import data_format
40 from libervia.backend.tools.common import async_process
41
42 log = getLogger(__name__)
43
44 try:
45 import yaml
46 except ImportError:
47 raise exceptions.MissingModule(
48 'Missing module PyYAML, please download/install it. You can use '
49 '"pip install pyyaml"'
50 )
51
52 try:
53 from yaml import CLoader as Loader, CDumper as Dumper
54 except ImportError:
55 log.warning(
56 "Can't use LibYAML binding (is libyaml installed?), pure Python version will be "
57 "used, but it is slower"
58 )
59 from yaml import Loader, Dumper
60
61
62
63
64 PLUGIN_INFO = {
65 C.PI_NAME: "Applications Manager",
66 C.PI_IMPORT_NAME: "APP_MANAGER",
67 C.PI_TYPE: C.PLUG_TYPE_MISC,
68 C.PI_MODES: C.PLUG_MODE_BOTH,
69 C.PI_MAIN: "AppManager",
70 C.PI_HANDLER: "no",
71 C.PI_DESCRIPTION: _(
72 """Applications Manager
73
74 Manage external applications using packagers, OS virtualization/containers or other
75 software management tools.
76 """),
77 }
78
79 APP_FILE_PREFIX = "libervia_app_"
80
81
82 class AppManager:
83 load = partial(yaml.load, Loader=Loader)
84 dump = partial(yaml.dump, Dumper=Dumper)
85
86 def __init__(self, host):
87 log.info(_("plugin Applications Manager initialization"))
88 self.host = host
89 self._managers = {}
90 self._apps = {}
91 self._started = {}
92 # instance id to app data map
93 self._instances = {}
94
95
96 self.persistent_data = persistent.LazyPersistentBinaryDict("app_manager")
97
98 host.bridge.add_method(
99 "applications_list",
100 ".plugin",
101 in_sign="as",
102 out_sign="as",
103 method=self.list_applications,
104 )
105 host.bridge.add_method(
106 "application_start",
107 ".plugin",
108 in_sign="ss",
109 out_sign="s",
110 method=self._start,
111 async_=True,
112 )
113 host.bridge.add_method(
114 "application_stop",
115 ".plugin",
116 in_sign="sss",
117 out_sign="",
118 method=self._stop,
119 async_=True,
120 )
121 host.bridge.add_method(
122 "application_exposed_get",
123 ".plugin",
124 in_sign="sss",
125 out_sign="s",
126 method=self._get_exposed,
127 async_=True,
128 )
129 # application has been started succeesfully,
130 # args: name, instance_id, extra
131 host.bridge.add_signal(
132 "application_started", ".plugin", signature="sss"
133 )
134 # application went wrong with the application
135 # args: name, instance_id, extra
136 host.bridge.add_signal(
137 "application_error", ".plugin", signature="sss"
138 )
139 yaml.add_constructor(
140 "!libervia_conf", self._libervia_conf_constr, Loader=Loader)
141 yaml.add_constructor(
142 "!libervia_generate_pwd", self._libervia_generate_pwd_constr, Loader=Loader)
143 yaml.add_constructor(
144 "!libervia_param", self._libervia_param_constr, Loader=Loader)
145
146 def unload(self):
147 log.debug("unloading applications manager")
148 for instances in self._started.values():
149 for instance in instances:
150 data = instance['data']
151 if not data['single_instance']:
152 log.debug(
153 f"cleaning temporary directory at {data['_instance_dir_path']}")
154 data['_instance_dir_obj'].cleanup()
155
156 def _libervia_conf_constr(self, loader, node) -> str:
157 """Get a value from Libervia configuration
158
159 A list is expected with either "name" of a config parameter, a one or more of
160 those parameters:
161 - section
162 - name
163 - default value
164 - filter
165 filter can be:
166 - "first": get the first item of the value
167 - "not": get the opposite value (to be used with booleans)
168 """
169 config_data = loader.construct_sequence(node)
170 if len(config_data) == 1:
171 section, name, default, filter_ = "", config_data[0], None, None
172 if len(config_data) == 2:
173 (section, name), default, filter_ = config_data, None, None
174 elif len(config_data) == 3:
175 (section, name, default), filter_ = config_data, None
176 elif len(config_data) == 4:
177 section, name, default, filter_ = config_data
178 else:
179 raise ValueError(
180 f"invalid !libervia_conf value ({config_data!r}), a list of 1 to 4 items "
181 "is expected"
182 )
183
184 value = self.host.memory.config_get(section, name, default)
185 # FIXME: "public_url" is used only here and doesn't take multi-sites into account
186 if name == "public_url" and (not value or value.startswith('http')):
187 if not value:
188 log.warning(_(
189 'No value found for "public_url", using "example.org" for '
190 'now, please set the proper value in libervia.conf'))
191 else:
192 log.warning(_(
193 'invalid value for "public_url" ({value}), it musts not start with '
194 'schema ("http"), ignoring it and using "example.org" '
195 'instead')
196 .format(value=value))
197 value = "example.org"
198
199 if filter_ is None:
200 pass
201 elif filter_ == 'first':
202 value = value[0]
203 elif filter_ == "not":
204 value = C.bool(value)
205 value = C.bool_const(not value).lower()
206 else:
207 raise ValueError(f"unmanaged filter: {filter_}")
208
209 return value
210
211 def _libervia_generate_pwd_constr(self, loader, node) -> str:
212 """Generate a password and store it in persistent data.
213
214 If the password has already been generated previously, it is reused.
215 Mapping arguments are:
216 ``name`` (str) **required**
217 Name of the password. Will be used to store it.
218 ``size`` (int)
219 Size of the password to generate. Default to 30
220 """
221 try:
222 kwargs = loader.construct_mapping(node)
223 name = kwargs["name"]
224 except (ConstructorError, KeyError):
225 raise ValueError(
226 '!libervia_generate_pwd map arguments is missing. At least "name" '
227 "must be specified."
228 )
229 pwd_data_key = f"pwd_{name}"
230 try:
231 key = self._app_persistent_data[pwd_data_key]
232 except KeyError:
233 alphabet = string.ascii_letters + string.digits
234 key_size = int(kwargs.get("size", 30))
235 key = ''.join(secrets.choice(alphabet) for __ in range(key_size))
236 self._app_persistent_data[pwd_data_key] = key
237 else:
238 log.debug(f"Re-using existing key for {name!r} password.")
239 return key
240
241 def _libervia_param_constr(self, loader, node) -> str:
242 """Get a parameter specified when starting the application
243
244 The value can be either the name of the parameter to get, or a list as
245 [name, default_value]
246 """
247 try:
248 name, default = loader.construct_sequence(node)
249 except ConstructorError:
250 name, default = loader.construct_scalar(node), None
251 assert self._params is not None
252 return self._params.get(name, default)
253
254 def register(self, manager):
255 name = manager.name
256 if name in self._managers:
257 raise exceptions.ConflictError(
258 f"There is already a manager with the name {name}")
259 self._managers[manager.name] = manager
260 if manager.discover_path is not None:
261 self.discover(manager.discover_path, manager)
262
263 def get_manager(self, app_data: dict) -> AppManagerBackend:
264 """Get manager instance needed for this app
265
266 @raise exceptions.DataError: something is wrong with the type
267 @raise exceptions.NotFound: manager is not registered
268 """
269 try:
270 app_type = app_data["type"]
271 except KeyError:
272 raise exceptions.DataError(
273 "app file doesn't have the mandatory \"type\" key"
274 )
275 if not isinstance(app_type, str):
276 raise exceptions.DataError(
277 f"invalid app data type: {app_type!r}"
278 )
279 app_type = app_type.strip()
280 try:
281 return self._managers[app_type]
282 except KeyError:
283 raise exceptions.NotFound(
284 f"No manager found to manage app of type {app_type!r}")
285
286 def get_app_data(
287 self,
288 id_type: Optional[str],
289 identifier: str
290 ) -> dict:
291 """Retrieve instance's app_data from identifier
292
293 @param id_type: type of the identifier, can be:
294 - "name": identifier is a canonical application name
295 the first found instance of this application is returned
296 - "instance": identifier is an instance id
297 @param identifier: identifier according to id_type
298 @return: instance application data
299 @raise exceptions.NotFound: no instance with this id can be found
300 @raise ValueError: id_type is invalid
301 """
302 if not id_type:
303 id_type = 'name'
304 if id_type == 'name':
305 identifier = identifier.lower().strip()
306 try:
307 return next(iter(self._started[identifier]))
308 except (KeyError, StopIteration):
309 raise exceptions.NotFound(
310 f"No instance of {identifier!r} is currently running"
311 )
312 elif id_type == 'instance':
313 instance_id = identifier
314 try:
315 return self._instances[instance_id]
316 except KeyError:
317 raise exceptions.NotFound(
318 f"There is no application instance running with id {instance_id!r}"
319 )
320 else:
321 raise ValueError(f"invalid id_type: {id_type!r}")
322
323 def discover(
324 self,
325 dir_path: Path,
326 manager: AppManagerBackend|None = None
327 ) -> None:
328 """Search for app configuration file.
329
330 App configuration files must start with [APP_FILE_PREFIX] and have a ``.yaml``
331 extension.
332 """
333 for file_path in dir_path.glob(f"{APP_FILE_PREFIX}*.yaml"):
334 if manager is None:
335 try:
336 app_data = self.parse(file_path)
337 manager = self.get_manager(app_data)
338 except (exceptions.DataError, exceptions.NotFound) as e:
339 log.warning(
340 f"Can't parse {file_path}, skipping: {e}")
341 continue
342 app_name = file_path.stem[len(APP_FILE_PREFIX):].strip().lower()
343 if not app_name:
344 log.warning(f"invalid app file name at {file_path}")
345 continue
346 app_dict = self._apps.setdefault(app_name, {})
347 manager_set = app_dict.setdefault(manager, set())
348 manager_set.add(file_path)
349 log.debug(
350 f"{app_name!r} {manager.name} application found"
351 )
352
353 def parse(self, file_path: Path, params: Optional[dict] = None) -> dict:
354 """Parse Libervia application file
355
356 @param params: parameters for running this instance
357 @raise exceptions.DataError: something is wrong in the file
358 """
359 if params is None:
360 params = {}
361 with file_path.open() as f:
362 # we set parameters to be used only with this instance
363 # no async method must used between this assignation and `load`
364 self._params = params
365 app_data = self.load(f)
366 self._params = None
367 if "name" not in app_data:
368 # note that we don't use lower() here as we want human readable name and
369 # uppercase may be set on purpose
370 app_data['name'] = file_path.stem[len(APP_FILE_PREFIX):].strip()
371 single_instance = app_data.setdefault("single_instance", True)
372 if not isinstance(single_instance, bool):
373 raise ValueError(
374 f'"single_instance" must be a boolean, but it is {type(single_instance)}'
375 )
376 return app_data
377
378 def list_applications(self, filters: Optional[List[str]]) -> List[str]:
379 """List available application
380
381 @param filters: only show applications matching those filters.
382 using None will list all known applications
383 a filter can be:
384 - available: applications available locally
385 - running: only show launched applications
386 """
387 if not filters:
388 return list(self.apps)
389 found = set()
390 for filter_ in filters:
391 if filter_ == "available":
392 found.update(self._apps)
393 elif filter_ == "running":
394 found.update(self._started)
395 else:
396 raise ValueError(f"Unknown filter: {filter_}")
397 return list(found)
398
399 def _start(self, app_name, extra):
400 extra = data_format.deserialise(extra)
401 d = defer.ensureDeferred(self.start(str(app_name), extra))
402 d.addCallback(data_format.serialise)
403 return d
404
405 async def start(
406 self,
407 app_name: str,
408 extra: Optional[dict] = None,
409 ) -> dict:
410 """Start an application
411
412 @param app_name: name of the application to start
413 @param extra: extra parameters
414 @return: data with following keys:
415 - name (str): canonical application name
416 - instance (str): instance ID
417 - started (bool): True if the application is already started
418 if False, the "application_started" signal should be used to get notificed
419 when the application is actually started
420 - expose (dict): exposed data as given by [self.get_exposed]
421 exposed data which need to be computed are NOT returned, they will
422 available when the app will be fully started, throught the
423 [self.get_exposed] method.
424 """
425 # FIXME: for now we use the first app manager available for the requested app_name
426 # TODO: implement running multiple instance of the same app if some metadata
427 # to be defined in app_data allows explicitly it.
428 app_name = app_name.lower().strip()
429 try:
430 app_file_path = next(iter(next(iter(self._apps[app_name].values()))))
431 except KeyError:
432 raise exceptions.NotFound(
433 f"No application found with the name {app_name!r}"
434 )
435 log.info(f"starting {app_name!r}")
436 self._app_persistent_data = await self.persistent_data.get(app_name) or {}
437 self._app_persistent_data["last_started"] = time.time()
438 started_data = self._started.setdefault(app_name, [])
439 app_data = self.parse(app_file_path, extra)
440 await self.persistent_data.aset(app_name, self._app_persistent_data)
441 app_data["_started"] = False
442 app_data['_file_path'] = app_file_path
443 app_data['_name_canonical'] = app_name
444 single_instance = app_data['single_instance']
445 ret_data = {
446 "name": app_name,
447 "started": False
448 }
449 if single_instance:
450 if started_data:
451 instance_data = started_data[0]
452 instance_id = instance_data["_instance_id"]
453 ret_data["instance"] = instance_id
454 ret_data["started"] = instance_data["_started"]
455 ret_data["expose"] = await self.get_exposed(
456 instance_id, "instance", {"skip_compute": True}
457 )
458 log.info(f"{app_name!r} is already started or being started")
459 return ret_data
460 else:
461 cache_path = self.host.memory.get_cache_path(
462 PLUGIN_INFO[C.PI_IMPORT_NAME], app_name
463 )
464 cache_path.mkdir(0o700, parents=True, exist_ok=True)
465 app_data['_instance_dir_path'] = cache_path
466 else:
467 dest_dir_obj = tempfile.TemporaryDirectory(prefix="libervia_app_")
468 app_data['_instance_dir_obj'] = dest_dir_obj
469 app_data['_instance_dir_path'] = Path(dest_dir_obj.name)
470 instance_id = ret_data["instance"] = app_data['_instance_id'] = shortuuid.uuid()
471 manager = self.get_manager(app_data)
472 app_data['_manager'] = manager
473 started_data.append(app_data)
474 self._instances[instance_id] = app_data
475 # we retrieve exposed data such as url_prefix which can be useful computed exposed
476 # data must wait for the app to be started, so we skip them for now
477 ret_data["expose"] = await self.get_exposed(
478 instance_id, "instance", {"skip_compute": True}
479 )
480
481 try:
482 start = manager.start
483 except AttributeError:
484 raise exceptions.InternalError(
485 f"{manager.name} doesn't have the mandatory \"start\" method"
486 )
487 else:
488 defer.ensureDeferred(self.start_app(start, app_data))
489 return ret_data
490
491 async def start_app(self, start_cb: Callable, app_data: dict) -> None:
492 app_name = app_data["_name_canonical"]
493 instance_id = app_data["_instance_id"]
494 try:
495 await start_cb(app_data)
496 except Exception as e:
497 log.exception(f"Can't start libervia app {app_name!r}")
498 self.host.bridge.application_error(
499 app_name,
500 instance_id,
501 data_format.serialise(
502 {
503 "class": str(type(e)),
504 "msg": str(e)
505 }
506 ))
507 else:
508 app_data["_started"] = True
509 self.host.bridge.application_started(app_name, instance_id, "")
510 log.info(f"{app_name!r} started")
511
512 def _stop(self, identifier, id_type, extra):
513 extra = data_format.deserialise(extra)
514 return defer.ensureDeferred(
515 self.stop(str(identifier), str(id_type) or None, extra))
516
517 async def stop(
518 self,
519 identifier: str,
520 id_type: Optional[str] = None,
521 extra: Optional[dict] = None,
522 ) -> None:
523 if extra is None:
524 extra = {}
525
526 app_data = self.get_app_data(id_type, identifier)
527
528 log.info(f"stopping {app_data['name']!r}")
529
530 app_name = app_data['_name_canonical']
531 instance_id = app_data['_instance_id']
532 manager = app_data['_manager']
533
534 try:
535 stop = manager.stop
536 except AttributeError:
537 raise exceptions.InternalError(
538 f"{manager.name} doesn't have the mandatory \"stop\" method"
539 )
540 else:
541 try:
542 await stop(app_data)
543 except Exception as e:
544 log.warning(
545 f"Instance {instance_id} of application {app_name} can't be stopped "
546 f"properly: {e}"
547 )
548 return
549
550 try:
551 del self._instances[instance_id]
552 except KeyError:
553 log.error(
554 f"INTERNAL ERROR: {instance_id!r} is not present in self._instances")
555
556 try:
557 self._started[app_name].remove(app_data)
558 except ValueError:
559 log.error(
560 "INTERNAL ERROR: there is no app data in self._started with id "
561 f"{instance_id!r}"
562 )
563
564 log.info(f"{app_name!r} stopped")
565
566 def _get_exposed(self, identifier, id_type, extra):
567 extra = data_format.deserialise(extra)
568 d = defer.ensureDeferred(self.get_exposed(identifier, id_type, extra))
569 d.addCallback(lambda d: data_format.serialise(d))
570 return d
571
572 def get_app_data_value(self, path: list[str], data: dict[str, Any]) -> Any:
573 """Return a value from app data from its path.
574
575 @param path: sequence of keys to get to retrieve the value
576 @return: requested value
577 @raise KeyError: Can't find the requested value.
578 """
579 return reduce(lambda l, k: l[k], path, data)
580
581 async def get_exposed(
582 self,
583 identifier: str,
584 id_type: Optional[str] = None,
585 extra: Optional[dict] = None,
586 ) -> dict:
587 """Get data exposed by the application
588
589 The manager's "compute_expose" method will be called if it exists. It can be used
590 to handle manager specific conventions.
591 """
592 app_data = self.get_app_data(id_type, identifier)
593 if app_data.get('_exposed_computed', False):
594 return app_data['expose']
595 if extra is None:
596 extra = {}
597 expose = app_data.setdefault("expose", {})
598 if "passwords" in expose:
599 passwords = expose['passwords']
600 for name, value in list(passwords.items()):
601 if isinstance(value, list):
602 # if we have a list, is the sequence of keys leading to the value
603 # to expose.
604 try:
605 passwords[name] = self.get_app_data_value(value, app_data)
606 except KeyError:
607 log.warning(
608 f"Can't retrieve exposed value for password {name!r}: {e}")
609 del passwords[name]
610
611 for key in ("url_prefix", "front_url"):
612 value = expose.get(key)
613 if isinstance(value, list):
614 try:
615 expose[key] = self.get_app_data_value(value, app_data)
616 except KeyError:
617 log.warning(
618 f"Can't retrieve exposed value for {key!r} at {value}"
619 )
620 del expose[key]
621
622 front_url = expose.get("front_url")
623 if front_url:
624 # we want to be sure that a scheme is defined, defaulting to ``https``
625 parsed_url = urlparse(front_url)
626 if not parsed_url.scheme:
627 if not parsed_url.netloc:
628 path_elt = parsed_url.path.split("/", 1)
629 parsed_url = parsed_url._replace(
630 netloc=path_elt[0],
631 path=f"/{path_elt[1]}" if len(path_elt) > 1 else ""
632 )
633 parsed_url = parsed_url._replace(scheme='https')
634 expose["front_url"] = urlunparse(parsed_url)
635
636 if extra.get("skip_compute", False):
637 return expose
638
639 try:
640 compute_expose = app_data['_manager'].compute_expose
641 except AttributeError:
642 pass
643 else:
644 await compute_expose(app_data)
645
646 app_data['_exposed_computed'] = True
647 return expose
648
649 async def _do_prepare(
650 self,
651 app_data: dict,
652 ) -> None:
653 name = app_data['name']
654 dest_path = app_data['_instance_dir_path']
655 if next(dest_path.iterdir(), None) != None:
656 log.debug(f"There is already a prepared dir at {dest_path}, nothing to do")
657 return
658 try:
659 prepare = app_data['prepare'].copy()
660 except KeyError:
661 prepare = {}
662
663 if not prepare:
664 log.debug(f"Nothing to prepare for {name!r}")
665 return
666
667 for action, value in list(prepare.items()):
668 log.debug(f"[{name}] [prepare] running {action!r} action")
669 if action == "git":
670 try:
671 git_path = which('git')[0]
672 except IndexError:
673 raise exceptions.NotFound(
674 "Can't find \"git\" executable, {name} can't be started without it"
675 )
676 await async_process.run(git_path, "clone", value, str(dest_path))
677 log.debug(f"{value!r} git repository cloned at {dest_path}")
678 else:
679 raise NotImplementedError(
680 f"{action!r} is not managed, can't start {name}"
681 )
682 del prepare[action]
683
684 if prepare:
685 raise exceptions.InternalError('"prepare" should be empty')
686
687 async def _do_create_files(
688 self,
689 app_data: dict,
690 ) -> None:
691 dest_path = app_data['_instance_dir_path']
692 files = app_data.get('files')
693 if not files:
694 return
695 if not isinstance(files, dict):
696 raise ValueError('"files" must be a dictionary')
697 for filename, data in files.items():
698 path = dest_path / filename
699 if path.is_file():
700 log.info(f"{path} already exists, skipping")
701 with path.open("w") as f:
702 f.write(data.get("content", ""))
703 log.debug(f"{path} created")
704
705 async def start_common(self, app_data: dict) -> None:
706 """Method running common action when starting a manager
707
708 It should be called by managers in "start" method.
709 """
710 await self._do_prepare(app_data)
711 await self._do_create_files(app_data)