From 90e3f3235cc8ac63b18571d230c294e089b9305f Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Mon, 7 Feb 2022 18:58:10 +0100 Subject: [PATCH] configpanels: Quick and dirty POC for config panel actions --- helpers/config | 14 +++++ src/app.py | 144 ++++---------------------------------------- src/utils/config.py | 131 ++++++++++++++++++++++++++++++++++++---- 3 files changed, 146 insertions(+), 143 deletions(-) diff --git a/helpers/config b/helpers/config index c1f8bca32..fce215a30 100644 --- a/helpers/config +++ b/helpers/config @@ -285,6 +285,18 @@ ynh_app_config_apply() { _ynh_app_config_apply } +ynh_app_action_run() { + local runner="run__$1" + # Get value from getter if exists + if type -t "$runner" 2>/dev/null | grep -q '^function$' 2>/dev/null; then + $runner + #ynh_return "result:" + #ynh_return "$(echo "${result}" | sed 's/^/ /g')" + else + ynh_die "No handler defined in app's script for action $1. If you are the maintainer of this app, you should define '$runner'" + fi +} + ynh_app_config_run() { declare -Ag old=() declare -Ag changed=() @@ -309,5 +321,7 @@ ynh_app_config_run() { ynh_app_config_apply ynh_script_progression --message="Configuration of $app completed" --last ;; + *) + ynh_app_action_run $1 esac } diff --git a/src/app.py b/src/app.py index fd70e883e..81557978b 100644 --- a/src/app.py +++ b/src/app.py @@ -1429,90 +1429,16 @@ def app_change_label(app, new_label): def app_action_list(app): - logger.warning(m18n.n("experimental_feature")) - # this will take care of checking if the app is installed - app_info_dict = app_info(app) - - return { - "app": app, - "app_name": app_info_dict["name"], - "actions": _get_app_actions(app), - } + return AppConfigPanel(app).list_actions() @is_unit_operation() -def app_action_run(operation_logger, app, action, args=None): - logger.warning(m18n.n("experimental_feature")) +def app_action_run( + operation_logger, app, action, args=None, args_file=None +): - from yunohost.hook import hook_exec - - # will raise if action doesn't exist - actions = app_action_list(app)["actions"] - actions = {x["id"]: x for x in actions} - - if action not in actions: - available_actions = (", ".join(actions.keys()),) - raise YunohostValidationError( - f"action '{action}' not available for app '{app}', available actions are: {available_actions}", - raw_msg=True, - ) - - operation_logger.start() - - action_declaration = actions[action] - - # Retrieve arguments list for install script - raw_questions = actions[action].get("arguments", {}) - questions = ask_questions_and_parse_answers(raw_questions, prefilled_answers=args) - args = { - question.name: question.value - for question in questions - if question.value is not None - } - - tmp_workdir_for_app = _make_tmp_workdir_for_app(app=app) - - env_dict = _make_environment_for_app_script( - app, args=args, args_prefix="ACTION_", workdir=tmp_workdir_for_app - ) - env_dict["YNH_ACTION"] = action - - _, action_script = tempfile.mkstemp(dir=tmp_workdir_for_app) - - with open(action_script, "w") as script: - script.write(action_declaration["command"]) - - if action_declaration.get("cwd"): - cwd = action_declaration["cwd"].replace("$app", app) - else: - cwd = tmp_workdir_for_app - - try: - retcode = hook_exec( - action_script, - env=env_dict, - chdir=cwd, - user=action_declaration.get("user", "root"), - )[0] - # Calling hook_exec could fail miserably, or get - # manually interrupted (by mistake or because script was stuck) - # In that case we still want to delete the tmp work dir - except (KeyboardInterrupt, EOFError, Exception): - retcode = -1 - import traceback - - logger.error(m18n.n("unexpected_error", error="\n" + traceback.format_exc())) - finally: - shutil.rmtree(tmp_workdir_for_app) - - if retcode not in action_declaration.get("accepted_return_codes", [0]): - msg = f"Error while executing action '{action}' of app '{app}': return code {retcode}" - operation_logger.error(msg) - raise YunohostError(msg, raw_msg=True) - - operation_logger.success() - return logger.success("Action successed!") + return AppConfigPanel(app).run_action(action, args=args, args_file=args_file, operation_logger=operation_logger) def app_config_get(app, key="", full=False, export=False): @@ -1556,6 +1482,10 @@ class AppConfigPanel(ConfigPanel): def _load_current_values(self): self.values = self._call_config_script("show") + def _run_action(self, action): + env = {key: str(value) for key, value in self.new_values.items()} + self._call_config_script(action, env=env) + def _apply(self): env = {key: str(value) for key, value in self.new_values.items()} return_content = self._call_config_script("apply", env=env) @@ -1609,8 +1539,10 @@ ynh_app_config_run $1 if ret != 0: if action == "show": raise YunohostError("app_config_unable_to_read") - else: + elif action == "show": raise YunohostError("app_config_unable_to_apply") + else: + raise YunohostError("app_action_failed", action=action) return values @@ -1619,58 +1551,6 @@ def _get_app_actions(app_id): actions_toml_path = os.path.join(APPS_SETTING_PATH, app_id, "actions.toml") actions_json_path = os.path.join(APPS_SETTING_PATH, app_id, "actions.json") - # sample data to get an idea of what is going on - # this toml extract: - # - - # [restart_service] - # name = "Restart service" - # command = "echo pouet $YNH_ACTION_SERVICE" - # user = "root" # optional - # cwd = "/" # optional - # accepted_return_codes = [0, 1, 2, 3] # optional - # description.en = "a dummy stupid exemple or restarting a service" - # - # [restart_service.arguments.service] - # type = "string", - # ask.en = "service to restart" - # example = "nginx" - # - # will be parsed into this: - # - # OrderedDict([(u'restart_service', - # OrderedDict([(u'name', u'Restart service'), - # (u'command', u'echo pouet $YNH_ACTION_SERVICE'), - # (u'user', u'root'), - # (u'cwd', u'/'), - # (u'accepted_return_codes', [0, 1, 2, 3]), - # (u'description', - # OrderedDict([(u'en', - # u'a dummy stupid exemple or restarting a service')])), - # (u'arguments', - # OrderedDict([(u'service', - # OrderedDict([(u'type', u'string'), - # (u'ask', - # OrderedDict([(u'en', - # u'service to restart')])), - # (u'example', - # u'nginx')]))]))])), - # - # - # and needs to be converted into this: - # - # [{u'accepted_return_codes': [0, 1, 2, 3], - # u'arguments': [{u'ask': {u'en': u'service to restart'}, - # u'example': u'nginx', - # u'name': u'service', - # u'type': u'string'}], - # u'command': u'echo pouet $YNH_ACTION_SERVICE', - # u'cwd': u'/', - # u'description': {u'en': u'a dummy stupid exemple or restarting a service'}, - # u'id': u'restart_service', - # u'name': u'Restart service', - # u'user': u'root'}] - if os.path.exists(actions_toml_path): toml_actions = toml.load(open(actions_toml_path, "r"), _dict=OrderedDict) diff --git a/src/utils/config.py b/src/utils/config.py index ec7faa719..ac317d83c 100644 --- a/src/utils/config.py +++ b/src/utils/config.py @@ -273,6 +273,10 @@ class ConfigPanel: logger.debug(f"Formating result in '{mode}' mode") result = {} for panel, section, option in self._iterate(): + + if section["is_action_section"] and mode != "full": + continue + key = f"{panel['id']}.{section['id']}.{option['id']}" if mode == "export": result[option["id"]] = option.get("current_value") @@ -311,6 +315,82 @@ class ConfigPanel: else: return result + def list_actions(self): + + actions = {} + + # FIXME : meh, loading the entire config panel is again going to cause + # stupid issues for domain (e.g loading registrar stuff when willing to just list available actions ...) + self.filter_key = "" + self._get_config_panel() + for panel, section, option in self._iterate(): + if option["type"] == "button": + key = f"{panel['id']}.{section['id']}.{option['id']}" + actions[key] = _value_for_locale(option["ask"]) + + return actions + + def run_action( + self, action=None, args=None, args_file=None, operation_logger=None + ): + # + # FIXME : this stuff looks a lot like set() ... + # + + self.filter_key = ".".join(action.split(".")[:2]) + action_id = action.split(".")[2] + + # Read config panel toml + self._get_config_panel() + + # FIXME: should also check that there's indeed a key called action + if not self.config: + raise YunohostValidationError("config_no_such_action", action=action) + + # Import and parse pre-answered options + logger.debug("Import and parse pre-answered options") + self._parse_pre_answered(args, None, args_file) + + # Read or get values and hydrate the config + self._load_current_values() + self._hydrate() + Question.operation_logger = operation_logger + self._ask(for_action=True) + + # FIXME: here, we could want to check constrains on + # the action's visibility / requirements wrt to the answer to questions ... + + if operation_logger: + operation_logger.start() + + try: + self._run_action(action_id) + except YunohostError: + raise + # Script got manually interrupted ... + # N.B. : KeyboardInterrupt does not inherit from Exception + except (KeyboardInterrupt, EOFError): + error = m18n.n("operation_interrupted") + logger.error(m18n.n("config_action_failed", action=action, error=error)) + raise + # Something wrong happened in Yunohost's code (most probably hook_exec) + except Exception: + import traceback + + error = m18n.n("unexpected_error", error="\n" + traceback.format_exc()) + logger.error(m18n.n("config_action_failed", action=action, error=error)) + raise + finally: + # Delete files uploaded from API + # FIXME : this is currently done in the context of config panels, + # but could also happen in the context of app install ... (or anywhere else + # where we may parse args etc...) + FileQuestion.clean_upload_dirs() + + # FIXME: i18n + logger.success(f"Action {action_id} successful") + operation_logger.success() + def set( self, key=None, value=None, args=None, args_file=None, operation_logger=None ): @@ -417,6 +497,7 @@ class ConfigPanel: "name": "", "services": [], "optional": True, + "is_action_section": False, }, }, "options": { @@ -485,6 +566,9 @@ class ConfigPanel: elif level == "sections": subnode["name"] = key # legacy subnode.setdefault("optional", raw_infos.get("optional", True)) + # If this section contains at least one button, it becomes an "action" section + if subnode["type"] == "button": + out["is_action_section"] = True out.setdefault(sublevel, []).append(subnode) # Key/value are a property else: @@ -533,10 +617,14 @@ class ConfigPanel: def _hydrate(self): # Hydrating config panel with current value - for _, _, option in self._iterate(): + for _, section, option in self._iterate(): if option["id"] not in self.values: - allowed_empty_types = ["alert", "display_text", "markdown", "file"] - if ( + + allowed_empty_types = ["alert", "display_text", "markdown", "file", "button"] + + if section["is_action_section"] and option.get("default") is not None: + self.values[option["id"]] = option["default"] + elif ( option["type"] in allowed_empty_types or option.get("bind") == "null" ): @@ -554,7 +642,7 @@ class ConfigPanel: return self.values - def _ask(self): + def _ask(self, for_action=False): logger.debug("Ask unanswered question and prevalidate data") if "i18n" in self.config: @@ -568,13 +656,22 @@ class ConfigPanel: Moulinette.display(colorize(message, "purple")) for panel, section, obj in self._iterate(["panel", "section"]): + + # Ugly hack to skip action section ... except when when explicitly running actions + if not for_action: + if section and section["is_action_section"]: + continue + + if panel == obj: + name = _value_for_locale(panel["name"]) + display_header(f"\n{'='*40}\n>>>> {name}\n{'='*40}") + else: + name = _value_for_locale(section["name"]) + if name: + display_header(f"\n# {name}") + if panel == obj: - name = _value_for_locale(panel["name"]) - display_header(f"\n{'='*40}\n>>>> {name}\n{'='*40}") continue - name = _value_for_locale(section["name"]) - if name: - display_header(f"\n# {name}") # Check and ask unanswered questions prefilled_answers = self.args.copy() @@ -594,8 +691,6 @@ class ConfigPanel: } ) - self.errors = None - def _get_default_values(self): return { option["id"]: option["default"] @@ -1334,6 +1429,17 @@ class FileQuestion(Question): return self.value +class ButtonQuestion(Question): + argument_type = "button" + + #def __init__( + # self, question, context: Mapping[str, Any] = {}, hooks: Dict[str, Callable] = {} + #): + # super().__init__(question, context, hooks) + + + + ARGUMENTS_TYPE_PARSERS = { "string": StringQuestion, "text": StringQuestion, @@ -1356,6 +1462,7 @@ ARGUMENTS_TYPE_PARSERS = { "markdown": DisplayTextQuestion, "file": FileQuestion, "app": AppQuestion, + "button": ButtonQuestion, } @@ -1395,6 +1502,8 @@ def ask_questions_and_parse_answers( for raw_question in raw_questions: question_class = ARGUMENTS_TYPE_PARSERS[raw_question.get("type", "string")] + if question_class.argument_type == "button": + continue raw_question["value"] = answers.get(raw_question["name"]) question = question_class(raw_question, context=context, hooks=hooks) new_values = question.ask_if_needed()