From eef1b6d65818b682f44e919450960187a377c288 Mon Sep 17 00:00:00 2001 From: Laurent Peuch Date: Wed, 30 May 2018 10:38:30 +0200 Subject: [PATCH 1/8] [enh] new command to list apps action --- data/actionsmap/yunohost.yml | 12 ++++++++++++ src/yunohost/app.py | 25 +++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/data/actionsmap/yunohost.yml b/data/actionsmap/yunohost.yml index d276a6cef..298332c51 100644 --- a/data/actionsmap/yunohost.yml +++ b/data/actionsmap/yunohost.yml @@ -773,6 +773,18 @@ app: subcategories: + action: + subcategory_help: Handle apps actions + actions: + + ### app_action_list() + list: + action_help: List app actions + api: GET //actions + arguments: + app_id: + help: app id + config: subcategory_help: Applications configuration panel actions: diff --git a/src/yunohost/app.py b/src/yunohost/app.py index 5f52a8628..601bc7ef1 100644 --- a/src/yunohost/app.py +++ b/src/yunohost/app.py @@ -1367,6 +1367,31 @@ def app_change_label(auth, app, new_label): app_ssowatconf(auth) +# ACTIONS todo list +# * save actions.json +# commands: +# yunohost app action list $app +# yunohost app action run $app $action -d parameters +# docstring + +def app_action_list(app_id): + installed = _is_installed(app_id) + if not installed: + raise MoulinetteError(errno.ENOPKG, + m18n.n('app_not_installed', app=app_id)) + + actions = os.path.join( APPS_SETTING_PATH, app_id, 'actions.json') + + if not os.path.exists(actions): + return { + "actions": [], + } + + return { + "actions": read_json(actions), + } + + # Config panel todo list: # * docstrings # * merge translations on the json once the workflow is in place From f35e3ef0551d0f37a26736e31cc1f6123e0f5113 Mon Sep 17 00:00:00 2001 From: Laurent Peuch Date: Thu, 31 May 2018 12:40:13 +0200 Subject: [PATCH 2/8] [enh] can run the action of an app --- data/actionsmap/yunohost.yml | 14 +++ src/yunohost/app.py | 199 ++++++++++++++++++++++++++++++++++- src/yunohost/hook.py | 1 + 3 files changed, 212 insertions(+), 2 deletions(-) diff --git a/data/actionsmap/yunohost.yml b/data/actionsmap/yunohost.yml index 298332c51..147e08b0c 100644 --- a/data/actionsmap/yunohost.yml +++ b/data/actionsmap/yunohost.yml @@ -785,6 +785,20 @@ app: app_id: help: app id + ### app_action_run() + run: + action_help: Run app action + api: PUT //actions/ + arguments: + app_id: + help: app id + action: + help: action id + -a: + full: --args + help: Serialized arguments for app script (i.e. "domain=domain.tld&path=/path") + + config: subcategory_help: Applications configuration panel actions: diff --git a/src/yunohost/app.py b/src/yunohost/app.py index 601bc7ef1..05acc40e3 100644 --- a/src/yunohost/app.py +++ b/src/yunohost/app.py @@ -1392,6 +1392,51 @@ def app_action_list(app_id): } +def app_action_run(app_id, action, args=None): + from yunohost.hook import hook_exec + import tempfile + + # will raise if action doesn't exist + actions = app_action_list(app_id)["actions"] + + if action not in actions: + raise MoulinetteError(errno.EINVAL, "action '%s' not available for app '%s', available actions are: %s" % (action, app_id, ", ".join(actions.keys()))) + + action_declaration = actions[action] + + # Retrieve arguments list for install script + args_dict = {} if not args else \ + dict(urlparse.parse_qsl(args, keep_blank_values=True)) + args_odict = _parse_args_for_action(actions[action], args=args_dict) + args_list = args_odict.values() + + env_dict = _make_environment_dict(args_odict, prefix="ACTION_") + env_dict["YNH_APP_ID"] = app_id + env_dict["YNH_ACTION"] = action + + _, path = tempfile.mkstemp() + + with open(path, "w") as script: + script.write(action_declaration["command"]) + + os.chmod(path, 700) + + retcode = hook_exec( + path, + args=args_list, + env=env_dict, + chdir=action_declaration.get("cwd"), + user=action_declaration.get("user", "root"), + ) + + if retcode not in action_declaration.get("accepted_return_codes", [0]): + raise MoulinetteError(retcode, "Error while executing action '%s' of app '%s': return code %s" % (action, app_id, retcode)) + + os.remove(path) + + return logger.success("Action successed!") + + # Config panel todo list: # * docstrings # * merge translations on the json once the workflow is in place @@ -2105,7 +2150,157 @@ def _parse_args_from_manifest(manifest, action, args={}, auth=None): return args_dict -def _make_environment_dict(args_dict): +def _parse_args_for_action(action, args={}, auth=None): + """Parse arguments needed for an action from the actions list + + Retrieve specified arguments for the action from the manifest, and parse + given args according to that. If some required arguments are not provided, + its values will be asked if interaction is possible. + Parsed arguments will be returned as an OrderedDict + + Keyword arguments: + action -- The action + args -- A dictionnary of arguments to parse + + """ + from yunohost.domain import (domain_list, _get_maindomain, + domain_url_available, _normalize_domain_path) + from yunohost.user import user_info + + args_dict = OrderedDict() + + if 'arguments' not in action: + logger.debug("no arguments found for '%s' in manifest", action) + return args_dict + + action_args = action['arguments'] + + for arg in action_args: + arg_name = arg['name'] + arg_type = arg.get('type', 'string') + arg_default = arg.get('default', None) + arg_choices = arg.get('choices', []) + arg_value = None + + # Transpose default value for boolean type and set it to + # false if not defined. + if arg_type == 'boolean': + arg_default = 1 if arg_default else 0 + + # Attempt to retrieve argument value + if arg_name in args: + arg_value = args[arg_name] + else: + if 'ask' in arg: + # Retrieve proper ask string + ask_string = _value_for_locale(arg['ask']) + + # Append extra strings + if arg_type == 'boolean': + ask_string += ' [0 | 1]' + elif arg_choices: + ask_string += ' [{0}]'.format(' | '.join(arg_choices)) + if arg_default is not None: + ask_string += ' (default: {0})'.format(arg_default) + + # Check for a password argument + is_password = True if arg_type == 'password' else False + + if arg_type == 'domain': + arg_default = _get_maindomain() + ask_string += ' (default: {0})'.format(arg_default) + msignals.display(m18n.n('domains_available')) + for domain in domain_list(auth)['domains']: + msignals.display("- {}".format(domain)) + + try: + input_string = msignals.prompt(ask_string, is_password) + except NotImplementedError: + input_string = None + if (input_string == '' or input_string is None) \ + and arg_default is not None: + arg_value = arg_default + else: + arg_value = input_string + elif arg_default is not None: + arg_value = arg_default + + # Validate argument value + if (arg_value is None or arg_value == '') \ + and not arg.get('optional', False): + raise MoulinetteError(errno.EINVAL, + m18n.n('app_argument_required', name=arg_name)) + elif arg_value is None: + args_dict[arg_name] = '' + continue + + # Validate argument choice + if arg_choices and arg_value not in arg_choices: + raise MoulinetteError(errno.EINVAL, + m18n.n('app_argument_choice_invalid', + name=arg_name, choices=', '.join(arg_choices))) + + # Validate argument type + if arg_type == 'domain': + if arg_value not in domain_list(auth)['domains']: + raise MoulinetteError(errno.EINVAL, + m18n.n('app_argument_invalid', + name=arg_name, error=m18n.n('domain_unknown'))) + elif arg_type == 'user': + try: + user_info(auth, arg_value) + except MoulinetteError as e: + raise MoulinetteError(errno.EINVAL, + m18n.n('app_argument_invalid', + name=arg_name, error=e.strerror)) + elif arg_type == 'app': + if not _is_installed(arg_value): + raise MoulinetteError(errno.EINVAL, + m18n.n('app_argument_invalid', + name=arg_name, error=m18n.n('app_unknown'))) + elif arg_type == 'boolean': + if isinstance(arg_value, bool): + arg_value = 1 if arg_value else 0 + else: + try: + arg_value = int(arg_value) + if arg_value not in [0, 1]: + raise ValueError() + except (TypeError, ValueError): + raise MoulinetteError(errno.EINVAL, + m18n.n('app_argument_choice_invalid', + name=arg_name, choices='0, 1')) + args_dict[arg_name] = arg_value + + # END loop over action_args... + + # If there's only one "domain" and "path", validate that domain/path + # is an available url and normalize the path. + + domain_args = [arg["name"] for arg in action_args + if arg.get("type", "string") == "domain"] + path_args = [arg["name"] for arg in action_args + if arg.get("type", "string") == "path"] + + if len(domain_args) == 1 and len(path_args) == 1: + + domain = args_dict[domain_args[0]] + path = args_dict[path_args[0]] + domain, path = _normalize_domain_path(domain, path) + + # Check the url is available + if not domain_url_available(auth, domain, path): + raise MoulinetteError(errno.EINVAL, + m18n.n('app_location_unavailable')) + + # (We save this normalized path so that the install script have a + # standard path format to deal with no matter what the user inputted) + args_dict[path_args[0]] = path + + return args_dict + + +def _make_environment_dict(args_dict, prefix="APP_ARG_"): """ Convert a dictionnary containing manifest arguments to a dictionnary of env. var. to be passed to scripts @@ -2116,7 +2311,7 @@ def _make_environment_dict(args_dict): """ env_dict = {} for arg_name, arg_value in args_dict.items(): - env_dict["YNH_APP_ARG_%s" % arg_name.upper()] = arg_value + env_dict["YNH_%s%s" % (prefix, arg_name.upper())] = arg_value return env_dict diff --git a/src/yunohost/hook.py b/src/yunohost/hook.py index 4688b25b3..2fb179f83 100644 --- a/src/yunohost/hook.py +++ b/src/yunohost/hook.py @@ -365,6 +365,7 @@ def hook_exec(path, args=None, raise_on_error=False, no_trace=False, stdout_callback if stdout_callback else lambda l: logger.debug(l.rstrip()), stderr_callback if stderr_callback else lambda l: logger.warning(l.rstrip()), ) + logger.debug("About to run the command '%s'" % command) returncode = call_async_output( command, callbacks, shell=False, cwd=chdir ) From 9cefc601610a99477a156c089d6026fe068d7bbd Mon Sep 17 00:00:00 2001 From: Laurent Peuch Date: Thu, 31 May 2018 12:50:03 +0200 Subject: [PATCH 3/8] [enh] store actions.json en install/upgrade --- src/yunohost/app.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/yunohost/app.py b/src/yunohost/app.py index 05acc40e3..e74bb0b78 100644 --- a/src/yunohost/app.py +++ b/src/yunohost/app.py @@ -641,6 +641,9 @@ def app_upgrade(auth, app=[], url=None, file=None): os.system('rm -rf "%s/scripts" "%s/manifest.json %s/conf"' % (app_setting_path, app_setting_path, app_setting_path)) os.system('mv "%s/manifest.json" "%s/scripts" %s' % (extracted_app_folder, extracted_app_folder, app_setting_path)) + if os.path.exists(os.path.join(extracted_app_folder, "actions.json")): + os.system('cp -R %s/actions.json %s' % (extracted_app_folder, app_setting_path)) + if os.path.exists(os.path.join(extracted_app_folder, "config_panel.json")): os.system('cp -R %s/config_panel.json %s' % (extracted_app_folder, app_setting_path)) @@ -761,6 +764,9 @@ def app_install(auth, app, label=None, args=None, no_remove_on_failure=False): os.system('cp %s/manifest.json %s' % (extracted_app_folder, app_setting_path)) os.system('cp -R %s/scripts %s' % (extracted_app_folder, app_setting_path)) + if os.path.exists(os.path.join(extracted_app_folder, "actions.json")): + os.system('cp -R %s/actions.json %s' % (extracted_app_folder, app_setting_path)) + if os.path.exists(os.path.join(extracted_app_folder, "config_panel.json")): os.system('cp -R %s/config_panel.json %s' % (extracted_app_folder, app_setting_path)) From 2d0b5edd2336e674e8d1533b730198d83c24917d Mon Sep 17 00:00:00 2001 From: Laurent Peuch Date: Fri, 22 Jun 2018 04:52:38 +0200 Subject: [PATCH 4/8] [mod] DRY, easier to maintain here --- src/yunohost/app.py | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/src/yunohost/app.py b/src/yunohost/app.py index e74bb0b78..83dd49323 100644 --- a/src/yunohost/app.py +++ b/src/yunohost/app.py @@ -641,14 +641,9 @@ def app_upgrade(auth, app=[], url=None, file=None): os.system('rm -rf "%s/scripts" "%s/manifest.json %s/conf"' % (app_setting_path, app_setting_path, app_setting_path)) os.system('mv "%s/manifest.json" "%s/scripts" %s' % (extracted_app_folder, extracted_app_folder, app_setting_path)) - if os.path.exists(os.path.join(extracted_app_folder, "actions.json")): - os.system('cp -R %s/actions.json %s' % (extracted_app_folder, app_setting_path)) - - if os.path.exists(os.path.join(extracted_app_folder, "config_panel.json")): - os.system('cp -R %s/config_panel.json %s' % (extracted_app_folder, app_setting_path)) - - if os.path.exists(os.path.join(extracted_app_folder, "conf")): - os.system('cp -R %s/conf %s' % (extracted_app_folder, app_setting_path)) + for file_to_copy in ["actions.json", "config_panel.json", "conf"]: + if os.path.exists(os.path.join(extracted_app_folder, file_to_copy)): + os.system('cp -R %s/%s %s' % (extracted_app_folder, file_to_copy, app_setting_path)) # So much win upgraded_apps.append(app_instance_name) @@ -764,14 +759,9 @@ def app_install(auth, app, label=None, args=None, no_remove_on_failure=False): os.system('cp %s/manifest.json %s' % (extracted_app_folder, app_setting_path)) os.system('cp -R %s/scripts %s' % (extracted_app_folder, app_setting_path)) - if os.path.exists(os.path.join(extracted_app_folder, "actions.json")): - os.system('cp -R %s/actions.json %s' % (extracted_app_folder, app_setting_path)) - - if os.path.exists(os.path.join(extracted_app_folder, "config_panel.json")): - os.system('cp -R %s/config_panel.json %s' % (extracted_app_folder, app_setting_path)) - - if os.path.exists(os.path.join(extracted_app_folder, "conf")): - os.system('cp -R %s/conf %s' % (extracted_app_folder, app_setting_path)) + for file_to_copy in ["actions.json", "config_panel.json", "conf"]: + if os.path.exists(os.path.join(extracted_app_folder, file_to_copy)): + os.system('cp -R %s/%s %s' % (extracted_app_folder, file_to_copy, app_setting_path)) # Execute the app install script install_retcode = 1 From 26f2741a7e001b472aeadea3aa117c569ea0b55b Mon Sep 17 00:00:00 2001 From: Laurent Peuch Date: Fri, 22 Jun 2018 04:53:52 +0200 Subject: [PATCH 5/8] [mod] mark actions commands as experimental --- src/yunohost/app.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/yunohost/app.py b/src/yunohost/app.py index 83dd49323..bef227db7 100644 --- a/src/yunohost/app.py +++ b/src/yunohost/app.py @@ -1363,14 +1363,12 @@ def app_change_label(auth, app, new_label): app_ssowatconf(auth) -# ACTIONS todo list -# * save actions.json -# commands: -# yunohost app action list $app -# yunohost app action run $app $action -d parameters -# docstring +# actions todo list: +# * docstring def app_action_list(app_id): + logger.warning(m18n.n('experimental_feature')) + installed = _is_installed(app_id) if not installed: raise MoulinetteError(errno.ENOPKG, @@ -1389,6 +1387,8 @@ def app_action_list(app_id): def app_action_run(app_id, action, args=None): + logger.warning(m18n.n('experimental_feature')) + from yunohost.hook import hook_exec import tempfile From 64fe357995ff5b507be0e3907a28cc6ade14217d Mon Sep 17 00:00:00 2001 From: Laurent Peuch Date: Fri, 22 Jun 2018 04:54:56 +0200 Subject: [PATCH 6/8] [mod] compress code a bit --- src/yunohost/app.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/yunohost/app.py b/src/yunohost/app.py index bef227db7..d310b44de 100644 --- a/src/yunohost/app.py +++ b/src/yunohost/app.py @@ -1374,15 +1374,10 @@ def app_action_list(app_id): raise MoulinetteError(errno.ENOPKG, m18n.n('app_not_installed', app=app_id)) - actions = os.path.join( APPS_SETTING_PATH, app_id, 'actions.json') - - if not os.path.exists(actions): - return { - "actions": [], - } + actions = os.path.join(APPS_SETTING_PATH, app_id, 'actions.json') return { - "actions": read_json(actions), + "actions": read_json(actions) if os.path.exists(actions) else [], } From 3806e2a90833782382cc065165dcc65bf6ef6bb8 Mon Sep 17 00:00:00 2001 From: Laurent Peuch Date: Fri, 22 Jun 2018 05:27:00 +0200 Subject: [PATCH 7/8] [mod] style --- src/yunohost/app.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/yunohost/app.py b/src/yunohost/app.py index d310b44de..a6c8e209e 100644 --- a/src/yunohost/app.py +++ b/src/yunohost/app.py @@ -1396,8 +1396,7 @@ def app_action_run(app_id, action, args=None): action_declaration = actions[action] # Retrieve arguments list for install script - args_dict = {} if not args else \ - dict(urlparse.parse_qsl(args, keep_blank_values=True)) + args_dict = dict(urlparse.parse_qsl(args, keep_blank_values=True)) if args else {} args_odict = _parse_args_for_action(actions[action], args=args_dict) args_list = args_odict.values() From f8d99e52e3daadc153d82173549ec14065488c8e Mon Sep 17 00:00:00 2001 From: Laurent Peuch Date: Fri, 22 Jun 2018 05:27:15 +0200 Subject: [PATCH 8/8] [mod] DRY, remove big copy/pasta --- src/yunohost/app.py | 145 ++++---------------------------------------- 1 file changed, 13 insertions(+), 132 deletions(-) diff --git a/src/yunohost/app.py b/src/yunohost/app.py index a6c8e209e..9c5f9ac67 100644 --- a/src/yunohost/app.py +++ b/src/yunohost/app.py @@ -2005,139 +2005,12 @@ def _parse_args_from_manifest(manifest, action, args={}, auth=None): args -- A dictionnary of arguments to parse """ - from yunohost.domain import (domain_list, _get_maindomain, - domain_url_available, _normalize_domain_path) - from yunohost.user import user_info - - args_dict = OrderedDict() try: action_args = manifest['arguments'][action] except KeyError: logger.debug("no arguments found for '%s' in manifest", action) else: - for arg in action_args: - arg_name = arg['name'] - arg_type = arg.get('type', 'string') - arg_default = arg.get('default', None) - arg_choices = arg.get('choices', []) - arg_value = None - - # Transpose default value for boolean type and set it to - # false if not defined. - if arg_type == 'boolean': - arg_default = 1 if arg_default else 0 - - # Attempt to retrieve argument value - if arg_name in args: - arg_value = args[arg_name] - else: - if 'ask' in arg: - # Retrieve proper ask string - ask_string = _value_for_locale(arg['ask']) - - # Append extra strings - if arg_type == 'boolean': - ask_string += ' [0 | 1]' - elif arg_choices: - ask_string += ' [{0}]'.format(' | '.join(arg_choices)) - if arg_default is not None: - ask_string += ' (default: {0})'.format(arg_default) - - # Check for a password argument - is_password = True if arg_type == 'password' else False - - if arg_type == 'domain': - arg_default = _get_maindomain() - ask_string += ' (default: {0})'.format(arg_default) - msignals.display(m18n.n('domains_available')) - for domain in domain_list(auth)['domains']: - msignals.display("- {}".format(domain)) - - try: - input_string = msignals.prompt(ask_string, is_password) - except NotImplementedError: - input_string = None - if (input_string == '' or input_string is None) \ - and arg_default is not None: - arg_value = arg_default - else: - arg_value = input_string - elif arg_default is not None: - arg_value = arg_default - - # Validate argument value - if (arg_value is None or arg_value == '') \ - and not arg.get('optional', False): - raise MoulinetteError(errno.EINVAL, - m18n.n('app_argument_required', name=arg_name)) - elif arg_value is None: - args_dict[arg_name] = '' - continue - - # Validate argument choice - if arg_choices and arg_value not in arg_choices: - raise MoulinetteError(errno.EINVAL, - m18n.n('app_argument_choice_invalid', - name=arg_name, choices=', '.join(arg_choices))) - - # Validate argument type - if arg_type == 'domain': - if arg_value not in domain_list(auth)['domains']: - raise MoulinetteError(errno.EINVAL, - m18n.n('app_argument_invalid', - name=arg_name, error=m18n.n('domain_unknown'))) - elif arg_type == 'user': - try: - user_info(auth, arg_value) - except MoulinetteError as e: - raise MoulinetteError(errno.EINVAL, - m18n.n('app_argument_invalid', - name=arg_name, error=e.strerror)) - elif arg_type == 'app': - if not _is_installed(arg_value): - raise MoulinetteError(errno.EINVAL, - m18n.n('app_argument_invalid', - name=arg_name, error=m18n.n('app_unknown'))) - elif arg_type == 'boolean': - if isinstance(arg_value, bool): - arg_value = 1 if arg_value else 0 - else: - try: - arg_value = int(arg_value) - if arg_value not in [0, 1]: - raise ValueError() - except (TypeError, ValueError): - raise MoulinetteError(errno.EINVAL, - m18n.n('app_argument_choice_invalid', - name=arg_name, choices='0, 1')) - args_dict[arg_name] = arg_value - - # END loop over action_args... - - # If there's only one "domain" and "path", validate that domain/path - # is an available url and normalize the path. - - domain_args = [arg["name"] for arg in action_args - if arg.get("type", "string") == "domain"] - path_args = [arg["name"] for arg in action_args - if arg.get("type", "string") == "path"] - - if len(domain_args) == 1 and len(path_args) == 1: - - domain = args_dict[domain_args[0]] - path = args_dict[path_args[0]] - domain, path = _normalize_domain_path(domain, path) - - # Check the url is available - if not domain_url_available(auth, domain, path): - raise MoulinetteError(errno.EINVAL, - m18n.n('app_location_unavailable')) - - # (We save this normalized path so that the install script have a - # standard path format to deal with no matter what the user inputted) - args_dict[path_args[0]] = path - - return args_dict + return _parse_action_args_in_yunohost_format(args, action_args, auth) def _parse_args_for_action(action, args={}, auth=None): @@ -2153,10 +2026,6 @@ def _parse_args_for_action(action, args={}, auth=None): args -- A dictionnary of arguments to parse """ - from yunohost.domain import (domain_list, _get_maindomain, - domain_url_available, _normalize_domain_path) - from yunohost.user import user_info - args_dict = OrderedDict() if 'arguments' not in action: @@ -2165,6 +2034,18 @@ def _parse_args_for_action(action, args={}, auth=None): action_args = action['arguments'] + return _parse_action_args_in_yunohost_format(args, action_args, auth) + + +def _parse_action_args_in_yunohost_format(args, action_args, auth=None): + """Parse arguments store in either manifest.json or actions.json + """ + from yunohost.domain import (domain_list, _get_maindomain, + domain_url_available, _normalize_domain_path) + from yunohost.user import user_info + + args_dict = OrderedDict() + for arg in action_args: arg_name = arg['name'] arg_type = arg.get('type', 'string')