3372
|
1 #!/usr/bin/env python3 |
|
2 |
|
3 # SàT plugin to manage external applications |
|
4 # Copyright (C) 2009-2020 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 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) |
|
163 if name == "public_url" and not value or not value.startswith('http'): |
|
164 if not value: |
|
165 log.warning(_( |
|
166 'No value found for "public_url", using "https://example.org" for ' |
|
167 'now, please set the proper value in sat.conf')) |
|
168 else: |
|
169 log.warning(_( |
|
170 'invalid value for "public_url" ({value}), it must start with ' |
|
171 '"http", ignoring it and using "https://example.org" instead') |
|
172 .format(value=value)) |
|
173 value = "https://example.org" |
|
174 |
|
175 if filter_ is None: |
|
176 pass |
|
177 elif filter_ == 'first': |
|
178 value = value[0] |
|
179 else: |
|
180 raise ValueError(f"unmanaged filter: {filter_}") |
|
181 |
|
182 return value |
|
183 |
|
184 def _sat_generate_pwd_constr(self, loader, node): |
|
185 alphabet = string.ascii_letters + string.digits |
|
186 return ''.join(secrets.choice(alphabet) for i in range(30)) |
|
187 |
|
188 def _sat_param_constr(self, loader, node): |
|
189 """Get a parameter specified when starting the application |
|
190 |
|
191 The value can be either the name of the parameter to get, or a list as |
|
192 [name, default_value] |
|
193 """ |
|
194 try: |
|
195 name, default = loader.construct_sequence(node) |
|
196 except ConstructorError: |
|
197 name, default = loader.construct_scalar(node), None |
|
198 return self._params.get(name, default) |
|
199 |
|
200 def register(self, manager): |
|
201 name = manager.name |
|
202 if name in self._managers: |
|
203 raise exceptions.ConflictError( |
|
204 f"There is already a manager with the name {name}") |
|
205 self._managers[manager.name] = manager |
|
206 if hasattr(manager, "discover_path"): |
|
207 self.discover(manager.discover_path, manager) |
|
208 |
|
209 def getManager(self, app_data: dict) -> object: |
|
210 """Get manager instance needed for this app |
|
211 |
|
212 @raise exceptions.DataError: something is wrong with the type |
|
213 @raise exceptions.NotFound: manager is not registered |
|
214 """ |
|
215 try: |
|
216 app_type = app_data["type"] |
|
217 except KeyError: |
|
218 raise exceptions.DataError( |
|
219 "app file doesn't have the mandatory \"type\" key" |
|
220 ) |
|
221 if not isinstance(app_type, str): |
|
222 raise exceptions.DataError( |
|
223 f"invalid app data type: {app_type!r}" |
|
224 ) |
|
225 app_type = app_type.strip() |
|
226 try: |
|
227 return self._managers[app_type] |
|
228 except KeyError: |
|
229 raise exceptions.NotFound( |
|
230 f"No manager found to manage app of type {app_type!r}") |
|
231 |
|
232 def getAppData( |
|
233 self, |
|
234 id_type: Optional[str], |
|
235 identifier: str |
|
236 ) -> dict: |
|
237 """Retrieve instance's app_data from identifier |
|
238 |
|
239 @param id_type: type of the identifier, can be: |
|
240 - "name": identifier is a canonical application name |
|
241 the first found instance of this application is returned |
|
242 - "instance": identifier is an instance id |
|
243 @param identifier: identifier according to id_type |
|
244 @return: instance application data |
|
245 @raise exceptions.NotFound: no instance with this id can be found |
|
246 @raise ValueError: id_type is invalid |
|
247 """ |
|
248 if not id_type: |
|
249 id_type = 'name' |
|
250 if id_type == 'name': |
|
251 identifier = identifier.lower().strip() |
|
252 try: |
|
253 return next(iter(self._started[identifier])) |
|
254 except (KeyError, StopIteration): |
|
255 raise exceptions.NotFound( |
|
256 f"No instance of {identifier!r} is currently running" |
|
257 ) |
|
258 elif id_type == 'instance': |
|
259 instance_id = identifier |
|
260 try: |
|
261 return self._instances[instance_id] |
|
262 except KeyError: |
|
263 raise exceptions.NotFound( |
|
264 f"There is no application instance running with id {instance_id!r}" |
|
265 ) |
|
266 else: |
|
267 raise ValueError(f"invalid id_type: {id_type!r}") |
|
268 |
|
269 def discover( |
|
270 self, |
|
271 dir_path: Path, |
|
272 manager: Optional = None |
|
273 ) -> None: |
|
274 for file_path in dir_path.glob(f"{APP_FILE_PREFIX}*.yaml"): |
|
275 if manager is None: |
|
276 try: |
|
277 app_data = self.parse(file_path) |
|
278 manager = self.getManager(app_data) |
|
279 except (exceptions.DataError, exceptions.NotFound) as e: |
|
280 log.warning( |
|
281 f"Can't parse {file_path}, skipping: {e}") |
|
282 app_name = file_path.stem[len(APP_FILE_PREFIX):].strip().lower() |
|
283 if not app_name: |
|
284 log.warning( |
|
285 f"invalid app file name at {file_path}") |
|
286 continue |
|
287 app_dict = self._apps.setdefault(app_name, {}) |
|
288 manager_set = app_dict.setdefault(manager, set()) |
|
289 manager_set.add(file_path) |
|
290 log.debug( |
|
291 f"{app_name!r} {manager.name} application found" |
|
292 ) |
|
293 |
|
294 def parse(self, file_path: Path, params: Optional[dict] = None) -> dict: |
|
295 """Parse SàT application file |
|
296 |
|
297 @param params: parameters for running this instance |
|
298 @raise exceptions.DataError: something is wrong in the file |
|
299 """ |
|
300 if params is None: |
|
301 params = {} |
|
302 with file_path.open() as f: |
|
303 # we set parameters to be used only with this instance |
|
304 # no async method must used between this assignation and `load` |
|
305 self._params = params |
|
306 app_data = self.load(f) |
|
307 self._params = None |
|
308 if "name" not in app_data: |
|
309 # note that we don't use lower() here as we want human readable name and |
|
310 # uppercase may be set on purpose |
|
311 app_data['name'] = file_path.stem[len(APP_FILE_PREFIX):].strip() |
|
312 single_instance = app_data.setdefault("single_instance", True) |
|
313 if not isinstance(single_instance, bool): |
|
314 raise ValueError( |
|
315 f'"single_instance" must be a boolean, but it is {type(single_instance)}' |
|
316 ) |
|
317 return app_data |
|
318 |
|
319 def list_applications(self, filters: Optional[List[str]]) -> List[str]: |
|
320 """List available application |
|
321 |
|
322 @param filters: only show applications matching those filters. |
|
323 using None will list all known applications |
|
324 a filter can be: |
|
325 - available: applications available locally |
|
326 - running: only show launched applications |
|
327 """ |
|
328 if not filters: |
|
329 return list(self.apps) |
|
330 found = set() |
|
331 for filter_ in filters: |
|
332 if filter_ == "available": |
|
333 found.update(self._apps) |
|
334 elif filter_ == "running": |
|
335 found.update(self._started) |
|
336 else: |
|
337 raise ValueError(f"Unknown filter: {filter_}") |
|
338 return list(found) |
|
339 |
|
340 def _start(self, app_name, extra): |
|
341 extra = data_format.deserialise(extra) |
|
342 return defer.ensureDeferred(self.start(str(app_name), extra)) |
|
343 |
|
344 async def start( |
|
345 self, |
|
346 app_name: str, |
|
347 extra: Optional[dict] = None, |
|
348 ) -> None: |
|
349 # FIXME: for now we use the first app manager available for the requested app_name |
|
350 # TODO: implement running multiple instance of the same app if some metadata |
|
351 # to be defined in app_data allows explicitly it. |
|
352 app_name = app_name.lower().strip() |
|
353 try: |
|
354 app_file_path = next(iter(next(iter(self._apps[app_name].values())))) |
|
355 except KeyError: |
|
356 raise exceptions.NotFound( |
|
357 f"No application found with the name {app_name!r}" |
|
358 ) |
|
359 started_data = self._started.setdefault(app_name, []) |
|
360 app_data = self.parse(app_file_path, extra) |
|
361 app_data['_file_path'] = app_file_path |
|
362 app_data['_name_canonical'] = app_name |
|
363 single_instance = app_data['single_instance'] |
|
364 if single_instance: |
|
365 if started_data: |
|
366 log.info(f"{app_name!r} is already started") |
|
367 return |
|
368 else: |
|
369 cache_path = self.host.memory.getCachePath( |
|
370 PLUGIN_INFO[C.PI_IMPORT_NAME], app_name |
|
371 ) |
|
372 cache_path.mkdir(0o700, parents=True, exist_ok=True) |
|
373 app_data['_instance_dir_path'] = cache_path |
|
374 else: |
|
375 dest_dir_obj = tempfile.TemporaryDirectory(prefix="sat_app_") |
|
376 app_data['_instance_dir_obj'] = dest_dir_obj |
|
377 app_data['_instance_dir_path'] = Path(dest_dir_obj.name) |
|
378 instance_id = app_data['_instance_id'] = shortuuid.uuid() |
|
379 manager = self.getManager(app_data) |
|
380 app_data['_manager'] = manager |
|
381 started_data.append(app_data) |
|
382 self._instances[instance_id] = app_data |
|
383 |
|
384 try: |
|
385 start = manager.start |
|
386 except AttributeError: |
|
387 raise exceptions.InternalError( |
|
388 f"{manager.name} doesn't have the mandatory \"start\" method" |
|
389 ) |
|
390 else: |
|
391 await start(app_data) |
|
392 log.info(f"{app_name!r} started") |
|
393 |
|
394 def _stop(self, identifier, id_type, extra): |
|
395 extra = data_format.deserialise(extra) |
|
396 return defer.ensureDeferred( |
|
397 self.stop(str(identifier), str(id_type) or None, extra)) |
|
398 |
|
399 async def stop( |
|
400 self, |
|
401 identifier: str, |
|
402 id_type: Optional[str] = None, |
|
403 extra: Optional[dict] = None, |
|
404 ) -> None: |
|
405 if extra is None: |
|
406 extra = {} |
|
407 |
|
408 app_data = self.getAppData(id_type, identifier) |
|
409 |
|
410 log.info(f"stopping {app_data['name']!r}") |
|
411 |
|
412 app_name = app_data['_name_canonical'] |
|
413 instance_id = app_data['_instance_id'] |
|
414 manager = app_data['_manager'] |
|
415 |
|
416 try: |
|
417 stop = manager.stop |
|
418 except AttributeError: |
|
419 raise exceptions.InternalError( |
|
420 f"{manager.name} doesn't have the mandatory \"stop\" method" |
|
421 ) |
|
422 else: |
|
423 try: |
|
424 await stop(app_data) |
|
425 except Exception as e: |
|
426 log.warning( |
|
427 f"Instance {instance_id} of application {app_name} can't be stopped " |
|
428 f"properly: {e}" |
|
429 ) |
|
430 return |
|
431 |
|
432 try: |
|
433 del self._instances[instance_id] |
|
434 except KeyError: |
|
435 log.error( |
|
436 f"INTERNAL ERROR: {instance_id!r} is not present in self._instances") |
|
437 |
|
438 try: |
|
439 self._started[app_name].remove(app_data) |
|
440 except ValueError: |
|
441 log.error( |
|
442 "INTERNAL ERROR: there is no app data in self._started with id " |
|
443 f"{instance_id!r}" |
|
444 ) |
|
445 |
|
446 log.info(f"{app_name!r} stopped") |
|
447 |
|
448 def _getExposed(self, identifier, id_type, extra): |
|
449 extra = data_format.deserialise(extra) |
|
450 d = defer.ensureDeferred(self.getExposed(identifier, id_type, extra)) |
|
451 d.addCallback(lambda d: data_format.serialise(d)) |
|
452 return d |
|
453 |
|
454 def getValueFromPath(self, app_data: dict, path: List[str]) -> any: |
|
455 """Retrieve a value set in the data from it path |
|
456 |
|
457 @param path: list of key to use in app_data to retrieve the value |
|
458 @return: found value |
|
459 @raise NotFound: the value can't be found |
|
460 """ |
|
461 |
|
462 async def getExposed( |
|
463 self, |
|
464 identifier: str, |
|
465 id_type: Optional[str] = None, |
|
466 extra: Optional[dict] = None, |
|
467 ) -> dict: |
|
468 """Get data exposed by the application |
|
469 |
|
470 The manager's "computeExpose" method will be called if it exists. It can be used |
|
471 to handle manager specific conventions. |
|
472 """ |
|
473 app_data = self.getAppData(id_type, identifier) |
|
474 if app_data.get('_exposed_computed', False): |
|
475 return app_data['expose'] |
|
476 if extra is None: |
|
477 extra = {} |
|
478 expose = app_data.setdefault("expose", {}) |
|
479 if "passwords" in expose: |
|
480 passwords = expose['passwords'] |
|
481 for name, value in list(passwords.items()): |
|
482 if isinstance(value, list): |
|
483 # if we have a list, is the sequence of keys leading to the value |
|
484 # to expose. We use "reduce" to retrieve the desired value |
|
485 try: |
|
486 passwords[name] = reduce(lambda l, k: l[k], value, app_data) |
|
487 except Exception as e: |
|
488 log.warning( |
|
489 f"Can't retrieve exposed value for password {name!r}: {e}") |
|
490 del passwords[name] |
|
491 |
|
492 url_prefix = expose.get("url_prefix") |
|
493 if isinstance(url_prefix, list): |
|
494 try: |
|
495 expose["url_prefix"] = reduce(lambda l, k: l[k], url_prefix, app_data) |
|
496 except Exception as e: |
|
497 log.warning( |
|
498 f"Can't retrieve exposed value for url_prefix: {e}") |
|
499 del expose["url_prefix"] |
|
500 |
|
501 try: |
|
502 computeExpose = app_data['_manager'].computeExpose |
|
503 except AttributeError: |
|
504 pass |
|
505 else: |
|
506 await computeExpose(app_data) |
|
507 |
|
508 app_data['_exposed_computed'] = True |
|
509 return expose |
|
510 |
|
511 async def _doPrepare( |
|
512 self, |
|
513 app_data: dict, |
|
514 ) -> None: |
|
515 name = app_data['name'] |
|
516 dest_path = app_data['_instance_dir_path'] |
|
517 if next(dest_path.iterdir(), None) != None: |
|
518 log.debug(f"There is already a prepared dir at {dest_path}, nothing to do") |
|
519 return |
|
520 try: |
|
521 prepare = app_data['prepare'].copy() |
|
522 except KeyError: |
|
523 prepare = {} |
|
524 |
|
525 if not prepare: |
|
526 log.debug("Nothing to prepare for {name!r}") |
|
527 return |
|
528 |
|
529 for action, value in list(prepare.items()): |
|
530 log.debug(f"[{name}] [prepare] running {action!r} action") |
|
531 if action == "git": |
|
532 try: |
|
533 git_path = which('git')[0] |
|
534 except IndexError: |
|
535 raise exceptions.NotFound( |
|
536 "Can't find \"git\" executable, {name} can't be started without it" |
|
537 ) |
|
538 await async_process.run(git_path, "clone", value, str(dest_path)) |
|
539 log.debug(f"{value!r} git repository cloned at {dest_path}") |
|
540 else: |
|
541 raise NotImplementedError( |
|
542 f"{action!r} is not managed, can't start {name}" |
|
543 ) |
|
544 del prepare[action] |
|
545 |
|
546 if prepare: |
|
547 raise exceptions.InternalError('"prepare" should be empty') |
|
548 |
|
549 async def startCommon(self, app_data: dict) -> None: |
|
550 """Method running common action when starting a manager |
|
551 |
|
552 It should be called by managers in "start" method. |
|
553 """ |
|
554 log.info(f"starting {app_data['name']!r}") |
|
555 await self._doPrepare(app_data) |