mirror of
https://github.com/YunoHost/yunohost.git
synced 2024-09-03 20:06:10 +02:00
configpanels: Quick and dirty POC for config panel actions
This commit is contained in:
parent
b7bea608f6
commit
90e3f3235c
3 changed files with 146 additions and 143 deletions
|
@ -285,6 +285,18 @@ ynh_app_config_apply() {
|
||||||
_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() {
|
ynh_app_config_run() {
|
||||||
declare -Ag old=()
|
declare -Ag old=()
|
||||||
declare -Ag changed=()
|
declare -Ag changed=()
|
||||||
|
@ -309,5 +321,7 @@ ynh_app_config_run() {
|
||||||
ynh_app_config_apply
|
ynh_app_config_apply
|
||||||
ynh_script_progression --message="Configuration of $app completed" --last
|
ynh_script_progression --message="Configuration of $app completed" --last
|
||||||
;;
|
;;
|
||||||
|
*)
|
||||||
|
ynh_app_action_run $1
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
144
src/app.py
144
src/app.py
|
@ -1429,90 +1429,16 @@ def app_change_label(app, new_label):
|
||||||
|
|
||||||
|
|
||||||
def app_action_list(app):
|
def app_action_list(app):
|
||||||
logger.warning(m18n.n("experimental_feature"))
|
|
||||||
|
|
||||||
# this will take care of checking if the app is installed
|
return AppConfigPanel(app).list_actions()
|
||||||
app_info_dict = app_info(app)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"app": app,
|
|
||||||
"app_name": app_info_dict["name"],
|
|
||||||
"actions": _get_app_actions(app),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@is_unit_operation()
|
@is_unit_operation()
|
||||||
def app_action_run(operation_logger, app, action, args=None):
|
def app_action_run(
|
||||||
logger.warning(m18n.n("experimental_feature"))
|
operation_logger, app, action, args=None, args_file=None
|
||||||
|
):
|
||||||
|
|
||||||
from yunohost.hook import hook_exec
|
return AppConfigPanel(app).run_action(action, args=args, args_file=args_file, operation_logger=operation_logger)
|
||||||
|
|
||||||
# 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!")
|
|
||||||
|
|
||||||
|
|
||||||
def app_config_get(app, key="", full=False, export=False):
|
def app_config_get(app, key="", full=False, export=False):
|
||||||
|
@ -1556,6 +1482,10 @@ class AppConfigPanel(ConfigPanel):
|
||||||
def _load_current_values(self):
|
def _load_current_values(self):
|
||||||
self.values = self._call_config_script("show")
|
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):
|
def _apply(self):
|
||||||
env = {key: str(value) for key, value in self.new_values.items()}
|
env = {key: str(value) for key, value in self.new_values.items()}
|
||||||
return_content = self._call_config_script("apply", env=env)
|
return_content = self._call_config_script("apply", env=env)
|
||||||
|
@ -1609,8 +1539,10 @@ ynh_app_config_run $1
|
||||||
if ret != 0:
|
if ret != 0:
|
||||||
if action == "show":
|
if action == "show":
|
||||||
raise YunohostError("app_config_unable_to_read")
|
raise YunohostError("app_config_unable_to_read")
|
||||||
else:
|
elif action == "show":
|
||||||
raise YunohostError("app_config_unable_to_apply")
|
raise YunohostError("app_config_unable_to_apply")
|
||||||
|
else:
|
||||||
|
raise YunohostError("app_action_failed", action=action)
|
||||||
return values
|
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_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")
|
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):
|
if os.path.exists(actions_toml_path):
|
||||||
toml_actions = toml.load(open(actions_toml_path, "r"), _dict=OrderedDict)
|
toml_actions = toml.load(open(actions_toml_path, "r"), _dict=OrderedDict)
|
||||||
|
|
||||||
|
|
|
@ -273,6 +273,10 @@ class ConfigPanel:
|
||||||
logger.debug(f"Formating result in '{mode}' mode")
|
logger.debug(f"Formating result in '{mode}' mode")
|
||||||
result = {}
|
result = {}
|
||||||
for panel, section, option in self._iterate():
|
for panel, section, option in self._iterate():
|
||||||
|
|
||||||
|
if section["is_action_section"] and mode != "full":
|
||||||
|
continue
|
||||||
|
|
||||||
key = f"{panel['id']}.{section['id']}.{option['id']}"
|
key = f"{panel['id']}.{section['id']}.{option['id']}"
|
||||||
if mode == "export":
|
if mode == "export":
|
||||||
result[option["id"]] = option.get("current_value")
|
result[option["id"]] = option.get("current_value")
|
||||||
|
@ -311,6 +315,82 @@ class ConfigPanel:
|
||||||
else:
|
else:
|
||||||
return result
|
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(
|
def set(
|
||||||
self, key=None, value=None, args=None, args_file=None, operation_logger=None
|
self, key=None, value=None, args=None, args_file=None, operation_logger=None
|
||||||
):
|
):
|
||||||
|
@ -417,6 +497,7 @@ class ConfigPanel:
|
||||||
"name": "",
|
"name": "",
|
||||||
"services": [],
|
"services": [],
|
||||||
"optional": True,
|
"optional": True,
|
||||||
|
"is_action_section": False,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
|
@ -485,6 +566,9 @@ class ConfigPanel:
|
||||||
elif level == "sections":
|
elif level == "sections":
|
||||||
subnode["name"] = key # legacy
|
subnode["name"] = key # legacy
|
||||||
subnode.setdefault("optional", raw_infos.get("optional", True))
|
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)
|
out.setdefault(sublevel, []).append(subnode)
|
||||||
# Key/value are a property
|
# Key/value are a property
|
||||||
else:
|
else:
|
||||||
|
@ -533,10 +617,14 @@ class ConfigPanel:
|
||||||
|
|
||||||
def _hydrate(self):
|
def _hydrate(self):
|
||||||
# Hydrating config panel with current value
|
# Hydrating config panel with current value
|
||||||
for _, _, option in self._iterate():
|
for _, section, option in self._iterate():
|
||||||
if option["id"] not in self.values:
|
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
|
option["type"] in allowed_empty_types
|
||||||
or option.get("bind") == "null"
|
or option.get("bind") == "null"
|
||||||
):
|
):
|
||||||
|
@ -554,7 +642,7 @@ class ConfigPanel:
|
||||||
|
|
||||||
return self.values
|
return self.values
|
||||||
|
|
||||||
def _ask(self):
|
def _ask(self, for_action=False):
|
||||||
logger.debug("Ask unanswered question and prevalidate data")
|
logger.debug("Ask unanswered question and prevalidate data")
|
||||||
|
|
||||||
if "i18n" in self.config:
|
if "i18n" in self.config:
|
||||||
|
@ -568,13 +656,22 @@ class ConfigPanel:
|
||||||
Moulinette.display(colorize(message, "purple"))
|
Moulinette.display(colorize(message, "purple"))
|
||||||
|
|
||||||
for panel, section, obj in self._iterate(["panel", "section"]):
|
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:
|
if panel == obj:
|
||||||
name = _value_for_locale(panel["name"])
|
|
||||||
display_header(f"\n{'='*40}\n>>>> {name}\n{'='*40}")
|
|
||||||
continue
|
continue
|
||||||
name = _value_for_locale(section["name"])
|
|
||||||
if name:
|
|
||||||
display_header(f"\n# {name}")
|
|
||||||
|
|
||||||
# Check and ask unanswered questions
|
# Check and ask unanswered questions
|
||||||
prefilled_answers = self.args.copy()
|
prefilled_answers = self.args.copy()
|
||||||
|
@ -594,8 +691,6 @@ class ConfigPanel:
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
self.errors = None
|
|
||||||
|
|
||||||
def _get_default_values(self):
|
def _get_default_values(self):
|
||||||
return {
|
return {
|
||||||
option["id"]: option["default"]
|
option["id"]: option["default"]
|
||||||
|
@ -1334,6 +1429,17 @@ class FileQuestion(Question):
|
||||||
return self.value
|
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 = {
|
ARGUMENTS_TYPE_PARSERS = {
|
||||||
"string": StringQuestion,
|
"string": StringQuestion,
|
||||||
"text": StringQuestion,
|
"text": StringQuestion,
|
||||||
|
@ -1356,6 +1462,7 @@ ARGUMENTS_TYPE_PARSERS = {
|
||||||
"markdown": DisplayTextQuestion,
|
"markdown": DisplayTextQuestion,
|
||||||
"file": FileQuestion,
|
"file": FileQuestion,
|
||||||
"app": AppQuestion,
|
"app": AppQuestion,
|
||||||
|
"button": ButtonQuestion,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -1395,6 +1502,8 @@ def ask_questions_and_parse_answers(
|
||||||
|
|
||||||
for raw_question in raw_questions:
|
for raw_question in raw_questions:
|
||||||
question_class = ARGUMENTS_TYPE_PARSERS[raw_question.get("type", "string")]
|
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"])
|
raw_question["value"] = answers.get(raw_question["name"])
|
||||||
question = question_class(raw_question, context=context, hooks=hooks)
|
question = question_class(raw_question, context=context, hooks=hooks)
|
||||||
new_values = question.ask_if_needed()
|
new_values = question.ask_if_needed()
|
||||||
|
|
Loading…
Add table
Reference in a new issue