diff --git a/data/actionsmap/yunohost.yml b/data/actionsmap/yunohost.yml index 7db57b0e6..d276a6cef 100644 --- a/data/actionsmap/yunohost.yml +++ b/data/actionsmap/yunohost.yml @@ -771,6 +771,31 @@ app: apps: nargs: "+" + subcategories: + + config: + subcategory_help: Applications configuration panel + actions: + + ### app_config_show_panel() + show-panel: + action_help: show config panel for the application + api: GET /apps//config-panel + arguments: + app_id: + help: App ID + + ### app_config_apply() + apply: + action_help: apply the new configuration + api: POST /apps//config + arguments: + app_id: + help: App ID + -a: + full: --args + help: Serialized arguments for new configuration (i.e. "domain=domain.tld&path=/path") + ############################# # Backup # ############################# diff --git a/locales/en.json b/locales/en.json index dc7a5203a..19213c372 100644 --- a/locales/en.json +++ b/locales/en.json @@ -179,6 +179,7 @@ "executing_command": "Executing command '{command:s}'...", "executing_script": "Executing script '{script:s}'...", "extracting": "Extracting...", + "experimental_feature": "Warning: this feature is experimental and not consider stable, you shouldn't be using it except if you know what you are doing.", "field_invalid": "Invalid field '{:s}'", "firewall_reload_failed": "Unable to reload the firewall", "firewall_reloaded": "The firewall has been reloaded", diff --git a/src/yunohost/app.py b/src/yunohost/app.py index 72a53da1f..5f52a8628 100644 --- a/src/yunohost/app.py +++ b/src/yunohost/app.py @@ -40,6 +40,7 @@ from collections import OrderedDict from moulinette import msignals, m18n, msettings from moulinette.core import MoulinetteError from moulinette.utils.log import getActionLogger +from moulinette.utils.filesystem import read_json from yunohost.service import service_log, _run_service_command from yunohost.utils import packages @@ -640,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, "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)) @@ -757,6 +761,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, "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)) @@ -1360,6 +1367,143 @@ def app_change_label(auth, app, new_label): app_ssowatconf(auth) +# Config panel todo list: +# * docstrings +# * merge translations on the json once the workflow is in place +def app_config_show_panel(app_id): + logger.warning(m18n.n('experimental_feature')) + + from yunohost.hook import hook_exec + + # this will take care of checking if the app is installed + app_info_dict = app_info(app_id) + + config_panel = os.path.join(APPS_SETTING_PATH, app_id, 'config_panel.json') + config_script = os.path.join(APPS_SETTING_PATH, app_id, 'scripts', 'config') + + if not os.path.exists(config_panel) or not os.path.exists(config_script): + return { + "config_panel": [], + } + + config_panel = read_json(config_panel) + + env = {"YNH_APP_ID": app_id} + parsed_values = {} + + # I need to parse stdout to communicate between scripts because I can't + # read the child environment :( (that would simplify things so much) + # after hours of research this is apparently quite a standard way, another + # option would be to add an explicite pipe or a named pipe for that + # a third option would be to write in a temporary file but I don't like + # that because that could expose sensitive data + def parse_stdout(line): + line = line.rstrip() + logger.info(line) + + if line.strip().startswith("YNH_CONFIG_") and "=" in line: + # XXX error handling? + # XXX this might not work for multilines stuff :( (but echo without + # formatting should do it no?) + key, value = line.strip().split("=", 1) + logger.debug("config script declared: %s -> %s", key, value) + parsed_values[key] = value + + return_code = hook_exec(config_script, + args=["show"], + env=env, + user="root", + stdout_callback=parse_stdout, + ) + + if return_code != 0: + raise Exception("script/config show return value code: %s (considered as an error)", return_code) + + logger.debug("Generating global variables:") + for tab in config_panel.get("panel", []): + tab_id = tab["id"] # this makes things easier to debug on crash + for section in tab.get("sections", []): + section_id = section["id"] + for option in section.get("options", []): + option_id = option["id"] + generated_id = ("YNH_CONFIG_%s_%s_%s" % (tab_id, section_id, option_id)).upper() + option["id"] = generated_id + logger.debug(" * '%s'.'%s'.'%s' -> %s", tab.get("name"), section.get("name"), option.get("name"), generated_id) + + if generated_id in parsed_values: + # XXX we should probably uses the one of install here but it's at a POC state right now + option_type = option["type"] + if option_type == "bool": + assert parsed_values[generated_id].lower() in ("true", "false") + option["value"] = True if parsed_values[generated_id].lower() == "true" else False + elif option_type == "integer": + option["value"] = int(parsed_values[generated_id]) + elif option_type == "text": + option["value"] = parsed_values[generated_id] + else: + logger.debug("Variable '%s' is not declared by config script, using default", generated_id) + option["value"] = option["default"] + + return { + "app_id": app_id, + "app_name": app_info_dict["name"], + "config_panel": config_panel, + } + + +def app_config_apply(app_id, args): + logger.warning(m18n.n('experimental_feature')) + + from yunohost.hook import hook_exec + + installed = _is_installed(app_id) + if not installed: + raise MoulinetteError(errno.ENOPKG, + m18n.n('app_not_installed', app=app_id)) + + config_panel = os.path.join(APPS_SETTING_PATH, app_id, 'config_panel.json') + config_script = os.path.join(APPS_SETTING_PATH, app_id, 'scripts', 'config') + + if not os.path.exists(config_panel) or not os.path.exists(config_script): + # XXX real exception + raise Exception("Not config-panel.json nor scripts/config") + + config_panel = read_json(config_panel) + + env = {"YNH_APP_ID": app_id} + args = dict(urlparse.parse_qsl(args, keep_blank_values=True)) if args else {} + + for tab in config_panel.get("panel", []): + tab_id = tab["id"] # this makes things easier to debug on crash + for section in tab.get("sections", []): + section_id = section["id"] + for option in section.get("options", []): + option_id = option["id"] + generated_id = ("YNH_CONFIG_%s_%s_%s" % (tab_id, section_id, option_id)).upper() + + if generated_id in args: + logger.debug("include into env %s=%s", generated_id, args[generated_id]) + env[generated_id] = args[generated_id] + else: + logger.debug("no value for key id %s", generated_id) + + # for debug purpose + for key in args: + if key not in env: + logger.warning("Ignore key '%s' from arguments because it is not in the config", key) + + return_code = hook_exec(config_script, + args=["apply"], + env=env, + user="root", + ) + + if return_code != 0: + raise Exception("'script/config apply' return value code: %s (considered as an error)", return_code) + + logger.success("Config updated as expected") + + def _get_app_settings(app_id): """ Get settings of an installed app diff --git a/src/yunohost/hook.py b/src/yunohost/hook.py index 32570ab57..4688b25b3 100644 --- a/src/yunohost/hook.py +++ b/src/yunohost/hook.py @@ -297,7 +297,8 @@ def hook_callback(action, hooks=[], args=None, no_trace=False, chdir=None, def hook_exec(path, args=None, raise_on_error=False, no_trace=False, - chdir=None, env=None, user="admin"): + chdir=None, env=None, user="admin", stdout_callback=None, + stderr_callback=None): """ Execute hook from a file with arguments @@ -361,8 +362,8 @@ def hook_exec(path, args=None, raise_on_error=False, no_trace=False, # Define output callbacks and call command callbacks = ( - lambda l: logger.debug(l.rstrip()), - lambda l: logger.warning(l.rstrip()), + stdout_callback if stdout_callback else lambda l: logger.debug(l.rstrip()), + stderr_callback if stderr_callback else lambda l: logger.warning(l.rstrip()), ) returncode = call_async_output( command, callbacks, shell=False, cwd=chdir