Mercurial > libervia-backend
annotate sat/plugins/plugin_misc_app_manager.py @ 3908:d43b197735d1
tests (unit/AP gateway): add tests for events:
rel 372
author | Goffi <goffi@goffi.org> |
---|---|
date | Thu, 22 Sep 2022 00:01:48 +0200 |
parents | d66a8453b02b |
children | 402d31527af4 |
rev | line source |
---|---|
3372 | 1 #!/usr/bin/env python3 |
2 | |
3 # SàT plugin to manage external applications | |
3479 | 4 # Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) |
3372 | 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 pathlib import Path | |
20 from typing import Optional, List | |
21 from functools import partial, reduce | |
22 import tempfile | |
23 import secrets | |
24 import string | |
25 import shortuuid | |
26 from twisted.internet import defer | |
27 from twisted.python.procutils import which | |
28 from sat.core.i18n import _ | |
29 from sat.core import exceptions | |
30 from sat.core.constants import Const as C | |
31 from sat.core.log import getLogger | |
32 from sat.tools.common import data_format | |
33 from sat.tools.common import async_process | |
34 | |
35 log = getLogger(__name__) | |
36 | |
37 try: | |
38 import yaml | |
39 except ImportError: | |
40 raise exceptions.MissingModule( | |
41 'Missing module PyYAML, please download/install it. You can use ' | |
42 '"pip install pyyaml"' | |
43 ) | |
44 | |
45 try: | |
46 from yaml import CLoader as Loader, CDumper as Dumper | |
47 except ImportError: | |
48 log.warning( | |
49 "Can't use LibYAML binding (is libyaml installed?), pure Python version will be " | |
50 "used, but it is slower" | |
51 ) | |
52 from yaml import Loader, Dumper | |
53 | |
54 from yaml.constructor import ConstructorError | |
55 | |
56 | |
57 PLUGIN_INFO = { | |
58 C.PI_NAME: "Applications Manager", | |
59 C.PI_IMPORT_NAME: "APP_MANAGER", | |
60 C.PI_TYPE: C.PLUG_TYPE_MISC, | |
61 C.PI_MODES: C.PLUG_MODE_BOTH, | |
62 C.PI_MAIN: "AppManager", | |
63 C.PI_HANDLER: "no", | |
64 C.PI_DESCRIPTION: _( | |
65 """Applications Manager | |
66 | |
67 Manage external applications using packagers, OS virtualization/containers or other | |
68 software management tools. | |
69 """), | |
70 } | |
71 | |
72 APP_FILE_PREFIX = "sat_app_" | |
73 | |
74 | |
75 class AppManager: | |
76 load = partial(yaml.load, Loader=Loader) | |
77 dump = partial(yaml.dump, Dumper=Dumper) | |
78 | |
79 def __init__(self, host): | |
80 log.info(_("plugin Applications Manager initialization")) | |
81 self.host = host | |
82 self._managers = {} | |
83 self._apps = {} | |
84 self._started = {} | |
85 # instance id to app data map | |
86 self._instances = {} | |
87 host.bridge.addMethod( | |
88 "applicationsList", | |
89 ".plugin", | |
90 in_sign="as", | |
91 out_sign="as", | |
92 method=self.list_applications, | |
93 ) | |
94 host.bridge.addMethod( | |
95 "applicationStart", | |
96 ".plugin", | |
97 in_sign="ss", | |
98 out_sign="", | |
99 method=self._start, | |
100 async_=True, | |
101 ) | |
102 host.bridge.addMethod( | |
103 "applicationStop", | |
104 ".plugin", | |
105 in_sign="sss", | |
106 out_sign="", | |
107 method=self._stop, | |
108 async_=True, | |
109 ) | |
110 host.bridge.addMethod( | |
111 "applicationExposedGet", | |
112 ".plugin", | |
113 in_sign="sss", | |
114 out_sign="s", | |
115 method=self._getExposed, | |
116 async_=True, | |
117 ) | |
118 yaml.add_constructor( | |
119 "!sat_conf", self._sat_conf_constr, Loader=Loader) | |
120 yaml.add_constructor( | |
121 "!sat_generate_pwd", self._sat_generate_pwd_constr, Loader=Loader) | |
122 yaml.add_constructor( | |
123 "!sat_param", self._sat_param_constr, Loader=Loader) | |
124 | |
125 def unload(self): | |
126 log.debug("unloading applications manager") | |
127 for instances in self._started.values(): | |
128 for instance in instances: | |
129 data = instance['data'] | |
130 if not data['single_instance']: | |
131 log.debug( | |
132 f"cleaning temporary directory at {data['_instance_dir_path']}") | |
133 data['_instance_dir_obj'].cleanup() | |
134 | |
135 def _sat_conf_constr(self, loader, node): | |
136 """Get a value from SàT configuration | |
137 | |
138 A list is expected with either "name" of a config parameter, a one or more of | |
139 those parameters: | |
140 - section | |
141 - name | |
142 - default value | |
143 - filter | |
144 filter can be: | |
145 - "first": get the first item of the value | |
146 """ | |
147 config_data = loader.construct_sequence(node) | |
148 if len(config_data) == 1: | |
149 section, name, default, filter_ = "", config_data[0], None, None | |
150 if len(config_data) == 2: | |
151 (section, name), default, filter_ = config_data, None, None | |
152 elif len(config_data) == 3: | |
153 (section, name, default), filter_ = config_data, None | |
154 elif len(config_data) == 4: | |
155 section, name, default, filter_ = config_data | |
156 else: | |
157 raise ValueError( | |
158 f"invalid !sat_conf value ({config_data!r}), a list of 1 to 4 items is " | |
159 "expected" | |
160 ) | |
161 | |
162 value = self.host.memory.getConfig(section, name, default) | |
3564
2c9e95796371
plugin app manager: "public_url" must NOT have a schema
Goffi <goffi@goffi.org>
parents:
3479
diff
changeset
|
163 # FIXME: "public_url" is used only here and doesn't take multi-sites into account |
2c9e95796371
plugin app manager: "public_url" must NOT have a schema
Goffi <goffi@goffi.org>
parents:
3479
diff
changeset
|
164 if name == "public_url" and (not value or value.startswith('http')): |
3372 | 165 if not value: |
166 log.warning(_( | |
3564
2c9e95796371
plugin app manager: "public_url" must NOT have a schema
Goffi <goffi@goffi.org>
parents:
3479
diff
changeset
|
167 'No value found for "public_url", using "example.org" for ' |
2c9e95796371
plugin app manager: "public_url" must NOT have a schema
Goffi <goffi@goffi.org>
parents:
3479
diff
changeset
|
168 'now, please set the proper value in libervia.conf')) |
3372 | 169 else: |
170 log.warning(_( | |
3564
2c9e95796371
plugin app manager: "public_url" must NOT have a schema
Goffi <goffi@goffi.org>
parents:
3479
diff
changeset
|
171 'invalid value for "public_url" ({value}), it musts not start with ' |
2c9e95796371
plugin app manager: "public_url" must NOT have a schema
Goffi <goffi@goffi.org>
parents:
3479
diff
changeset
|
172 'schema ("http"), ignoring it and using "example.org" ' |
2c9e95796371
plugin app manager: "public_url" must NOT have a schema
Goffi <goffi@goffi.org>
parents:
3479
diff
changeset
|
173 'instead') |
3372 | 174 .format(value=value)) |
3564
2c9e95796371
plugin app manager: "public_url" must NOT have a schema
Goffi <goffi@goffi.org>
parents:
3479
diff
changeset
|
175 value = "example.org" |
3372 | 176 |
177 if filter_ is None: | |
178 pass | |
179 elif filter_ == 'first': | |
180 value = value[0] | |
181 else: | |
182 raise ValueError(f"unmanaged filter: {filter_}") | |
183 | |
184 return value | |
185 | |
186 def _sat_generate_pwd_constr(self, loader, node): | |
187 alphabet = string.ascii_letters + string.digits | |
188 return ''.join(secrets.choice(alphabet) for i in range(30)) | |
189 | |
190 def _sat_param_constr(self, loader, node): | |
191 """Get a parameter specified when starting the application | |
192 | |
193 The value can be either the name of the parameter to get, or a list as | |
194 [name, default_value] | |
195 """ | |
196 try: | |
197 name, default = loader.construct_sequence(node) | |
198 except ConstructorError: | |
199 name, default = loader.construct_scalar(node), None | |
200 return self._params.get(name, default) | |
201 | |
202 def register(self, manager): | |
203 name = manager.name | |
204 if name in self._managers: | |
205 raise exceptions.ConflictError( | |
206 f"There is already a manager with the name {name}") | |
207 self._managers[manager.name] = manager | |
208 if hasattr(manager, "discover_path"): | |
209 self.discover(manager.discover_path, manager) | |
210 | |
211 def getManager(self, app_data: dict) -> object: | |
212 """Get manager instance needed for this app | |
213 | |
214 @raise exceptions.DataError: something is wrong with the type | |
215 @raise exceptions.NotFound: manager is not registered | |
216 """ | |
217 try: | |
218 app_type = app_data["type"] | |
219 except KeyError: | |
220 raise exceptions.DataError( | |
221 "app file doesn't have the mandatory \"type\" key" | |
222 ) | |
223 if not isinstance(app_type, str): | |
224 raise exceptions.DataError( | |
225 f"invalid app data type: {app_type!r}" | |
226 ) | |
227 app_type = app_type.strip() | |
228 try: | |
229 return self._managers[app_type] | |
230 except KeyError: | |
231 raise exceptions.NotFound( | |
232 f"No manager found to manage app of type {app_type!r}") | |
233 | |
234 def getAppData( | |
235 self, | |
236 id_type: Optional[str], | |
237 identifier: str | |
238 ) -> dict: | |
239 """Retrieve instance's app_data from identifier | |
240 | |
241 @param id_type: type of the identifier, can be: | |
242 - "name": identifier is a canonical application name | |
243 the first found instance of this application is returned | |
244 - "instance": identifier is an instance id | |
245 @param identifier: identifier according to id_type | |
246 @return: instance application data | |
247 @raise exceptions.NotFound: no instance with this id can be found | |
248 @raise ValueError: id_type is invalid | |
249 """ | |
250 if not id_type: | |
251 id_type = 'name' | |
252 if id_type == 'name': | |
253 identifier = identifier.lower().strip() | |
254 try: | |
255 return next(iter(self._started[identifier])) | |
256 except (KeyError, StopIteration): | |
257 raise exceptions.NotFound( | |
258 f"No instance of {identifier!r} is currently running" | |
259 ) | |
260 elif id_type == 'instance': | |
261 instance_id = identifier | |
262 try: | |
263 return self._instances[instance_id] | |
264 except KeyError: | |
265 raise exceptions.NotFound( | |
266 f"There is no application instance running with id {instance_id!r}" | |
267 ) | |
268 else: | |
269 raise ValueError(f"invalid id_type: {id_type!r}") | |
270 | |
271 def discover( | |
272 self, | |
273 dir_path: Path, | |
274 manager: Optional = None | |
275 ) -> None: | |
276 for file_path in dir_path.glob(f"{APP_FILE_PREFIX}*.yaml"): | |
277 if manager is None: | |
278 try: | |
279 app_data = self.parse(file_path) | |
280 manager = self.getManager(app_data) | |
281 except (exceptions.DataError, exceptions.NotFound) as e: | |
282 log.warning( | |
283 f"Can't parse {file_path}, skipping: {e}") | |
284 app_name = file_path.stem[len(APP_FILE_PREFIX):].strip().lower() | |
285 if not app_name: | |
286 log.warning( | |
287 f"invalid app file name at {file_path}") | |
288 continue | |
289 app_dict = self._apps.setdefault(app_name, {}) | |
290 manager_set = app_dict.setdefault(manager, set()) | |
291 manager_set.add(file_path) | |
292 log.debug( | |
293 f"{app_name!r} {manager.name} application found" | |
294 ) | |
295 | |
296 def parse(self, file_path: Path, params: Optional[dict] = None) -> dict: | |
297 """Parse SàT application file | |
298 | |
299 @param params: parameters for running this instance | |
300 @raise exceptions.DataError: something is wrong in the file | |
301 """ | |
302 if params is None: | |
303 params = {} | |
304 with file_path.open() as f: | |
305 # we set parameters to be used only with this instance | |
306 # no async method must used between this assignation and `load` | |
307 self._params = params | |
308 app_data = self.load(f) | |
309 self._params = None | |
310 if "name" not in app_data: | |
311 # note that we don't use lower() here as we want human readable name and | |
312 # uppercase may be set on purpose | |
313 app_data['name'] = file_path.stem[len(APP_FILE_PREFIX):].strip() | |
314 single_instance = app_data.setdefault("single_instance", True) | |
315 if not isinstance(single_instance, bool): | |
316 raise ValueError( | |
317 f'"single_instance" must be a boolean, but it is {type(single_instance)}' | |
318 ) | |
319 return app_data | |
320 | |
321 def list_applications(self, filters: Optional[List[str]]) -> List[str]: | |
322 """List available application | |
323 | |
324 @param filters: only show applications matching those filters. | |
325 using None will list all known applications | |
326 a filter can be: | |
327 - available: applications available locally | |
328 - running: only show launched applications | |
329 """ | |
330 if not filters: | |
331 return list(self.apps) | |
332 found = set() | |
333 for filter_ in filters: | |
334 if filter_ == "available": | |
335 found.update(self._apps) | |
336 elif filter_ == "running": | |
337 found.update(self._started) | |
338 else: | |
339 raise ValueError(f"Unknown filter: {filter_}") | |
340 return list(found) | |
341 | |
342 def _start(self, app_name, extra): | |
343 extra = data_format.deserialise(extra) | |
344 return defer.ensureDeferred(self.start(str(app_name), extra)) | |
345 | |
346 async def start( | |
347 self, | |
348 app_name: str, | |
349 extra: Optional[dict] = None, | |
350 ) -> None: | |
351 # FIXME: for now we use the first app manager available for the requested app_name | |
352 # TODO: implement running multiple instance of the same app if some metadata | |
353 # to be defined in app_data allows explicitly it. | |
354 app_name = app_name.lower().strip() | |
355 try: | |
356 app_file_path = next(iter(next(iter(self._apps[app_name].values())))) | |
357 except KeyError: | |
358 raise exceptions.NotFound( | |
359 f"No application found with the name {app_name!r}" | |
360 ) | |
361 started_data = self._started.setdefault(app_name, []) | |
362 app_data = self.parse(app_file_path, extra) | |
363 app_data['_file_path'] = app_file_path | |
364 app_data['_name_canonical'] = app_name | |
365 single_instance = app_data['single_instance'] | |
366 if single_instance: | |
367 if started_data: | |
368 log.info(f"{app_name!r} is already started") | |
369 return | |
370 else: | |
371 cache_path = self.host.memory.getCachePath( | |
372 PLUGIN_INFO[C.PI_IMPORT_NAME], app_name | |
373 ) | |
374 cache_path.mkdir(0o700, parents=True, exist_ok=True) | |
375 app_data['_instance_dir_path'] = cache_path | |
376 else: | |
377 dest_dir_obj = tempfile.TemporaryDirectory(prefix="sat_app_") | |
378 app_data['_instance_dir_obj'] = dest_dir_obj | |
379 app_data['_instance_dir_path'] = Path(dest_dir_obj.name) | |
380 instance_id = app_data['_instance_id'] = shortuuid.uuid() | |
381 manager = self.getManager(app_data) | |
382 app_data['_manager'] = manager | |
383 started_data.append(app_data) | |
384 self._instances[instance_id] = app_data | |
385 | |
386 try: | |
387 start = manager.start | |
388 except AttributeError: | |
389 raise exceptions.InternalError( | |
390 f"{manager.name} doesn't have the mandatory \"start\" method" | |
391 ) | |
392 else: | |
393 await start(app_data) | |
394 log.info(f"{app_name!r} started") | |
395 | |
396 def _stop(self, identifier, id_type, extra): | |
397 extra = data_format.deserialise(extra) | |
398 return defer.ensureDeferred( | |
399 self.stop(str(identifier), str(id_type) or None, extra)) | |
400 | |
401 async def stop( | |
402 self, | |
403 identifier: str, | |
404 id_type: Optional[str] = None, | |
405 extra: Optional[dict] = None, | |
406 ) -> None: | |
407 if extra is None: | |
408 extra = {} | |
409 | |
410 app_data = self.getAppData(id_type, identifier) | |
411 | |
412 log.info(f"stopping {app_data['name']!r}") | |
413 | |
414 app_name = app_data['_name_canonical'] | |
415 instance_id = app_data['_instance_id'] | |
416 manager = app_data['_manager'] | |
417 | |
418 try: | |
419 stop = manager.stop | |
420 except AttributeError: | |
421 raise exceptions.InternalError( | |
422 f"{manager.name} doesn't have the mandatory \"stop\" method" | |
423 ) | |
424 else: | |
425 try: | |
426 await stop(app_data) | |
427 except Exception as e: | |
428 log.warning( | |
429 f"Instance {instance_id} of application {app_name} can't be stopped " | |
430 f"properly: {e}" | |
431 ) | |
432 return | |
433 | |
434 try: | |
435 del self._instances[instance_id] | |
436 except KeyError: | |
437 log.error( | |
438 f"INTERNAL ERROR: {instance_id!r} is not present in self._instances") | |
439 | |
440 try: | |
441 self._started[app_name].remove(app_data) | |
442 except ValueError: | |
443 log.error( | |
444 "INTERNAL ERROR: there is no app data in self._started with id " | |
445 f"{instance_id!r}" | |
446 ) | |
447 | |
448 log.info(f"{app_name!r} stopped") | |
449 | |
450 def _getExposed(self, identifier, id_type, extra): | |
451 extra = data_format.deserialise(extra) | |
452 d = defer.ensureDeferred(self.getExposed(identifier, id_type, extra)) | |
453 d.addCallback(lambda d: data_format.serialise(d)) | |
454 return d | |
455 | |
456 def getValueFromPath(self, app_data: dict, path: List[str]) -> any: | |
457 """Retrieve a value set in the data from it path | |
458 | |
459 @param path: list of key to use in app_data to retrieve the value | |
460 @return: found value | |
461 @raise NotFound: the value can't be found | |
462 """ | |
463 | |
464 async def getExposed( | |
465 self, | |
466 identifier: str, | |
467 id_type: Optional[str] = None, | |
468 extra: Optional[dict] = None, | |
469 ) -> dict: | |
470 """Get data exposed by the application | |
471 | |
472 The manager's "computeExpose" method will be called if it exists. It can be used | |
473 to handle manager specific conventions. | |
474 """ | |
475 app_data = self.getAppData(id_type, identifier) | |
476 if app_data.get('_exposed_computed', False): | |
477 return app_data['expose'] | |
478 if extra is None: | |
479 extra = {} | |
480 expose = app_data.setdefault("expose", {}) | |
481 if "passwords" in expose: | |
482 passwords = expose['passwords'] | |
483 for name, value in list(passwords.items()): | |
484 if isinstance(value, list): | |
485 # if we have a list, is the sequence of keys leading to the value | |
486 # to expose. We use "reduce" to retrieve the desired value | |
487 try: | |
488 passwords[name] = reduce(lambda l, k: l[k], value, app_data) | |
489 except Exception as e: | |
490 log.warning( | |
491 f"Can't retrieve exposed value for password {name!r}: {e}") | |
492 del passwords[name] | |
493 | |
494 url_prefix = expose.get("url_prefix") | |
495 if isinstance(url_prefix, list): | |
496 try: | |
497 expose["url_prefix"] = reduce(lambda l, k: l[k], url_prefix, app_data) | |
498 except Exception as e: | |
499 log.warning( | |
500 f"Can't retrieve exposed value for url_prefix: {e}") | |
501 del expose["url_prefix"] | |
502 | |
503 try: | |
504 computeExpose = app_data['_manager'].computeExpose | |
505 except AttributeError: | |
506 pass | |
507 else: | |
508 await computeExpose(app_data) | |
509 | |
510 app_data['_exposed_computed'] = True | |
511 return expose | |
512 | |
513 async def _doPrepare( | |
514 self, | |
515 app_data: dict, | |
516 ) -> None: | |
517 name = app_data['name'] | |
518 dest_path = app_data['_instance_dir_path'] | |
519 if next(dest_path.iterdir(), None) != None: | |
520 log.debug(f"There is already a prepared dir at {dest_path}, nothing to do") | |
521 return | |
522 try: | |
523 prepare = app_data['prepare'].copy() | |
524 except KeyError: | |
525 prepare = {} | |
526 | |
527 if not prepare: | |
528 log.debug("Nothing to prepare for {name!r}") | |
529 return | |
530 | |
531 for action, value in list(prepare.items()): | |
532 log.debug(f"[{name}] [prepare] running {action!r} action") | |
533 if action == "git": | |
534 try: | |
535 git_path = which('git')[0] | |
536 except IndexError: | |
537 raise exceptions.NotFound( | |
538 "Can't find \"git\" executable, {name} can't be started without it" | |
539 ) | |
540 await async_process.run(git_path, "clone", value, str(dest_path)) | |
541 log.debug(f"{value!r} git repository cloned at {dest_path}") | |
542 else: | |
543 raise NotImplementedError( | |
544 f"{action!r} is not managed, can't start {name}" | |
545 ) | |
546 del prepare[action] | |
547 | |
548 if prepare: | |
549 raise exceptions.InternalError('"prepare" should be empty') | |
550 | |
3565
d66a8453b02b
plugin app manager: add a way to create files:
Goffi <goffi@goffi.org>
parents:
3564
diff
changeset
|
551 async def _doCreateFiles( |
d66a8453b02b
plugin app manager: add a way to create files:
Goffi <goffi@goffi.org>
parents:
3564
diff
changeset
|
552 self, |
d66a8453b02b
plugin app manager: add a way to create files:
Goffi <goffi@goffi.org>
parents:
3564
diff
changeset
|
553 app_data: dict, |
d66a8453b02b
plugin app manager: add a way to create files:
Goffi <goffi@goffi.org>
parents:
3564
diff
changeset
|
554 ) -> None: |
d66a8453b02b
plugin app manager: add a way to create files:
Goffi <goffi@goffi.org>
parents:
3564
diff
changeset
|
555 dest_path = app_data['_instance_dir_path'] |
d66a8453b02b
plugin app manager: add a way to create files:
Goffi <goffi@goffi.org>
parents:
3564
diff
changeset
|
556 files = app_data.get('files') |
d66a8453b02b
plugin app manager: add a way to create files:
Goffi <goffi@goffi.org>
parents:
3564
diff
changeset
|
557 if not files: |
d66a8453b02b
plugin app manager: add a way to create files:
Goffi <goffi@goffi.org>
parents:
3564
diff
changeset
|
558 return |
d66a8453b02b
plugin app manager: add a way to create files:
Goffi <goffi@goffi.org>
parents:
3564
diff
changeset
|
559 if not isinstance(files, dict): |
d66a8453b02b
plugin app manager: add a way to create files:
Goffi <goffi@goffi.org>
parents:
3564
diff
changeset
|
560 raise ValueError('"files" must be a dictionary') |
d66a8453b02b
plugin app manager: add a way to create files:
Goffi <goffi@goffi.org>
parents:
3564
diff
changeset
|
561 for filename, data in files.items(): |
d66a8453b02b
plugin app manager: add a way to create files:
Goffi <goffi@goffi.org>
parents:
3564
diff
changeset
|
562 path = dest_path / filename |
d66a8453b02b
plugin app manager: add a way to create files:
Goffi <goffi@goffi.org>
parents:
3564
diff
changeset
|
563 if path.is_file(): |
d66a8453b02b
plugin app manager: add a way to create files:
Goffi <goffi@goffi.org>
parents:
3564
diff
changeset
|
564 log.info(f"{path} already exists, skipping") |
d66a8453b02b
plugin app manager: add a way to create files:
Goffi <goffi@goffi.org>
parents:
3564
diff
changeset
|
565 with path.open("w") as f: |
d66a8453b02b
plugin app manager: add a way to create files:
Goffi <goffi@goffi.org>
parents:
3564
diff
changeset
|
566 f.write(data.get("content", "")) |
d66a8453b02b
plugin app manager: add a way to create files:
Goffi <goffi@goffi.org>
parents:
3564
diff
changeset
|
567 log.debug(f"{path} created") |
d66a8453b02b
plugin app manager: add a way to create files:
Goffi <goffi@goffi.org>
parents:
3564
diff
changeset
|
568 |
3372 | 569 async def startCommon(self, app_data: dict) -> None: |
570 """Method running common action when starting a manager | |
571 | |
572 It should be called by managers in "start" method. | |
573 """ | |
574 log.info(f"starting {app_data['name']!r}") | |
575 await self._doPrepare(app_data) | |
3565
d66a8453b02b
plugin app manager: add a way to create files:
Goffi <goffi@goffi.org>
parents:
3564
diff
changeset
|
576 await self._doCreateFiles(app_data) |