[enh] Rewrite config show, get, set actions

This commit is contained in:
ljf 2021-08-20 16:35:51 +02:00
parent a89dd4827c
commit 98ca514f8f
4 changed files with 327 additions and 199 deletions

View file

@ -831,21 +831,44 @@ app:
subcategory_help: Applications configuration panel subcategory_help: Applications configuration panel
actions: actions:
### app_config_show_panel() ### app_config_show()
show-panel: show:
action_help: show config panel for the application action_help: show config panel for the application
api: GET /apps/<app>/config-panel api: GET /apps/<app>/config-panel
arguments: arguments:
app: app:
help: App name help: App name
panel:
help: Select a specific panel
nargs: '?'
-f:
full: --full
help: Display all info known about the config-panel.
action: store_true
### app_config_apply() ### app_config_get()
apply: get:
action_help: show config panel for the application
api: GET /apps/<app>/config-panel/<key>
arguments:
app:
help: App name
key:
help: The question identifier
### app_config_set()
set:
action_help: apply the new configuration action_help: apply the new configuration
api: PUT /apps/<app>/config api: PUT /apps/<app>/config
arguments: arguments:
app: app:
help: App name help: App name
key:
help: The question or panel key
nargs: '?'
-v:
full: --value
help: new value
-a: -a:
full: --args full: --args
help: Serialized arguments for new configuration (i.e. "domain=domain.tld&path=/path") help: Serialized arguments for new configuration (i.e. "domain=domain.tld&path=/path")

View file

@ -1,10 +1,5 @@
#!/bin/bash #!/bin/bash
ynh_lowerdot_to_uppersnake() {
local lowerdot
lowerdot=$(echo "$1" | cut -d= -f1 | sed "s/\./_/g")
echo "${lowerdot^^}"
}
# Get a value from heterogeneous file (yaml, json, php, python...) # Get a value from heterogeneous file (yaml, json, php, python...)
# #
@ -99,7 +94,6 @@ ynh_value_set() {
} }
_ynh_panel_get() { _ynh_panel_get() {
set +x
# From settings # From settings
local params_sources local params_sources
params_sources=`python3 << EOL params_sources=`python3 << EOL
@ -114,7 +108,7 @@ for panel_name,panel in loaded_toml.items():
for section_name, section in panel.items(): for section_name, section in panel.items():
if isinstance(section, dict): if isinstance(section, dict):
for name, param in section.items(): for name, param in section.items():
if isinstance(param, dict) and param.get('type', 'string') not in ['info', 'warning', 'error']: if isinstance(param, dict) and param.get('type', 'string') not in ['success', 'info', 'warning', 'danger', 'display_text', 'markdown']:
print("%s=%s" % (name, param.get('source', 'settings'))) print("%s=%s" % (name, param.get('source', 'settings')))
EOL EOL
` `
@ -147,7 +141,6 @@ EOL
file_hash[$short_setting]="true" file_hash[$short_setting]="true"
fi fi
done done
set -x
} }
@ -164,7 +157,7 @@ _ynh_panel_apply() {
# Copy file in right place # Copy file in right place
elif [[ "$source" == "settings" ]] ; then elif [[ "$source" == "settings" ]] ; then
ynh_app_setting_set $app $short_setting "${new[$short_setting]}" ynh_app_setting_set $app $short_setting "${!short_setting}"
# Get value from a kind of key/value file # Get value from a kind of key/value file
elif [[ "$source" == *":"* ]] elif [[ "$source" == *":"* ]]
@ -172,11 +165,11 @@ _ynh_panel_apply() {
local source_key="$(echo "$source" | cut -d: -f1)" local source_key="$(echo "$source" | cut -d: -f1)"
source_key=${source_key:-$short_setting} source_key=${source_key:-$short_setting}
local source_file="$(echo "$source" | cut -d: -f2)" local source_file="$(echo "$source" | cut -d: -f2)"
ynh_value_set --file="${source_file}" --key="${source_key}" --value="${new[$short_setting]}" ynh_value_set --file="${source_file}" --key="${source_key}" --value="${!short_setting}"
# Specific case for files (all content of the file is the source) # Specific case for files (all content of the file is the source)
else else
cp "${new[$short_setting]}" "$source" cp "${!short_setting}" "$source"
fi fi
fi fi
done done
@ -185,25 +178,25 @@ _ynh_panel_apply() {
_ynh_panel_show() { _ynh_panel_show() {
for short_setting in "${!old[@]}" for short_setting in "${!old[@]}"
do do
ynh_return "${short_setting}=${old[$short_setting]}" ynh_return "${short_setting}: \"${old[$short_setting]}\""
done done
} }
_ynh_panel_validate() { _ynh_panel_validate() {
set +x
# Change detection # Change detection
local is_error=true local is_error=true
#for changed_status in "${!changed[@]}" #for changed_status in "${!changed[@]}"
for short_setting in "${!old[@]}" for short_setting in "${!old[@]}"
do do
changed[$short_setting]=false changed[$short_setting]=false
[ -z ${!short_setting+x} ] && continue
if [ ! -z "${file_hash[${short_setting}]}" ] ; then if [ ! -z "${file_hash[${short_setting}]}" ] ; then
file_hash[old__$short_setting]="" file_hash[old__$short_setting]=""
file_hash[new__$short_setting]="" file_hash[new__$short_setting]=""
if [ -f "${old[$short_setting]}" ] ; then if [ -f "${old[$short_setting]}" ] ; then
file_hash[old__$short_setting]=$(sha256sum "${old[$short_setting]}" | cut -d' ' -f1) file_hash[old__$short_setting]=$(sha256sum "${old[$short_setting]}" | cut -d' ' -f1)
fi fi
if [ -f "${new[$short_setting]}" ] ; then if [ -f "${!short_setting}" ] ; then
file_hash[new__$short_setting]=$(sha256sum "${new[$short_setting]}" | cut -d' ' -f1) file_hash[new__$short_setting]=$(sha256sum "${new[$short_setting]}" | cut -d' ' -f1)
if [[ "${file_hash[old__$short_setting]}" != "${file_hash[new__$short_setting]}" ]] if [[ "${file_hash[old__$short_setting]}" != "${file_hash[new__$short_setting]}" ]]
then then
@ -211,7 +204,7 @@ _ynh_panel_validate() {
fi fi
fi fi
else else
if [[ "${new[$short_setting]}" != "${old[$short_setting]}" ]] if [[ "${!short_setting}" != "${old[$short_setting]}" ]]
then then
changed[$short_setting]=true changed[$short_setting]=true
is_error=false is_error=false
@ -242,7 +235,6 @@ _ynh_panel_validate() {
then then
ynh_die "" ynh_die ""
fi fi
set -x
} }
@ -264,15 +256,14 @@ ynh_panel_apply() {
ynh_panel_run() { ynh_panel_run() {
declare -Ag old=() declare -Ag old=()
declare -Ag new=()
declare -Ag changed=() declare -Ag changed=()
declare -Ag file_hash=() declare -Ag file_hash=()
declare -Ag sources=() declare -Ag sources=()
ynh_panel_get ynh_panel_get
case $1 in case $1 in
show) ynh_panel_show;; show) ynh_panel_get && ynh_panel_show;;
apply) ynh_panel_validate && ynh_panel_apply;; apply) ynh_panel_get && ynh_panel_validate && ynh_panel_apply;;
esac esac
} }

View file

@ -13,7 +13,7 @@
"app_already_installed": "{app} is already installed", "app_already_installed": "{app} is already installed",
"app_already_installed_cant_change_url": "This app is already installed. The URL cannot be changed just by this function. Check in `app changeurl` if it's available.", "app_already_installed_cant_change_url": "This app is already installed. The URL cannot be changed just by this function. Check in `app changeurl` if it's available.",
"app_already_up_to_date": "{app} is already up-to-date", "app_already_up_to_date": "{app} is already up-to-date",
"app_argument_choice_invalid": "Use one of these choices '{choices}' for the argument '{name}'", "app_argument_choice_invalid": "Use one of these choices '{choices}' for the argument '{name}' instead of '{value}'",
"app_argument_invalid": "Pick a valid value for the argument '{name}': {error}", "app_argument_invalid": "Pick a valid value for the argument '{name}': {error}",
"app_argument_password_no_default": "Error while parsing password argument '{name}': password argument can't have a default value for security reason", "app_argument_password_no_default": "Error while parsing password argument '{name}': password argument can't have a default value for security reason",
"app_argument_required": "Argument '{name}' is required", "app_argument_required": "Argument '{name}' is required",
@ -143,6 +143,7 @@
"confirm_app_install_danger": "DANGER! This app is known to be still experimental (if not explicitly not working)! You should probably NOT install it unless you know what you are doing. NO SUPPORT will be provided if this app doesn't work or breaks your system… If you are willing to take that risk anyway, type '{answers}'", "confirm_app_install_danger": "DANGER! This app is known to be still experimental (if not explicitly not working)! You should probably NOT install it unless you know what you are doing. NO SUPPORT will be provided if this app doesn't work or breaks your system… If you are willing to take that risk anyway, type '{answers}'",
"confirm_app_install_thirdparty": "DANGER! This app is not part of YunoHost's app catalog. Installing third-party apps may compromise the integrity and security of your system. You should probably NOT install it unless you know what you are doing. NO SUPPORT will be provided if this app doesn't work or breaks your system… If you are willing to take that risk anyway, type '{answers}'", "confirm_app_install_thirdparty": "DANGER! This app is not part of YunoHost's app catalog. Installing third-party apps may compromise the integrity and security of your system. You should probably NOT install it unless you know what you are doing. NO SUPPORT will be provided if this app doesn't work or breaks your system… If you are willing to take that risk anyway, type '{answers}'",
"custom_app_url_required": "You must provide a URL to upgrade your custom app {app}", "custom_app_url_required": "You must provide a URL to upgrade your custom app {app}",
"danger": "Danger:",
"diagnosis_basesystem_hardware": "Server hardware architecture is {virt} {arch}", "diagnosis_basesystem_hardware": "Server hardware architecture is {virt} {arch}",
"diagnosis_basesystem_hardware_model": "Server model is {model}", "diagnosis_basesystem_hardware_model": "Server model is {model}",
"diagnosis_basesystem_host": "Server is running Debian {debian_version}", "diagnosis_basesystem_host": "Server is running Debian {debian_version}",
@ -382,8 +383,9 @@
"log_app_upgrade": "Upgrade the '{}' app", "log_app_upgrade": "Upgrade the '{}' app",
"log_app_makedefault": "Make '{}' the default app", "log_app_makedefault": "Make '{}' the default app",
"log_app_action_run": "Run action of the '{}' app", "log_app_action_run": "Run action of the '{}' app",
"log_app_config_show_panel": "Show the config panel of the '{}' app", "log_app_config_show": "Show the config panel of the '{}' app",
"log_app_config_apply": "Apply config to the '{}' app", "log_app_config_get": "Get a specific setting from config panel of the '{}' app",
"log_app_config_set": "Apply config to the '{}' app",
"log_available_on_yunopaste": "This log is now available via {url}", "log_available_on_yunopaste": "This log is now available via {url}",
"log_backup_create": "Create a backup archive", "log_backup_create": "Create a backup archive",
"log_backup_restore_system": "Restore system from a backup archive", "log_backup_restore_system": "Restore system from a backup archive",

View file

@ -38,6 +38,7 @@ import tempfile
from collections import OrderedDict from collections import OrderedDict
from moulinette import msignals, m18n, msettings from moulinette import msignals, m18n, msettings
from moulinette.interfaces.cli import colorize
from moulinette.core import MoulinetteError from moulinette.core import MoulinetteError
from moulinette.utils.log import getActionLogger from moulinette.utils.log import getActionLogger
from moulinette.utils.network import download_json from moulinette.utils.network import download_json
@ -190,10 +191,7 @@ def app_info(app, full=False):
""" """
from yunohost.permission import user_permission_list from yunohost.permission import user_permission_list
if not _is_installed(app): _assert_is_installed(app)
raise YunohostValidationError(
"app_not_installed", app=app, all_apps=_get_all_installed_apps_id()
)
local_manifest = _get_manifest_of_app(os.path.join(APPS_SETTING_PATH, app)) local_manifest = _get_manifest_of_app(os.path.join(APPS_SETTING_PATH, app))
permissions = user_permission_list(full=True, absolute_urls=True, apps=[app])[ permissions = user_permission_list(full=True, absolute_urls=True, apps=[app])[
@ -534,10 +532,8 @@ def app_upgrade(app=[], url=None, file=None, force=False):
apps = [app_ for i, app_ in enumerate(apps) if app_ not in apps[:i]] apps = [app_ for i, app_ in enumerate(apps) if app_ not in apps[:i]]
# Abort if any of those app is in fact not installed.. # Abort if any of those app is in fact not installed..
for app in [app_ for app_ in apps if not _is_installed(app_)]: for app_ in apps:
raise YunohostValidationError( _assert_is_installed(app_)
"app_not_installed", app=app, all_apps=_get_all_installed_apps_id()
)
if len(apps) == 0: if len(apps) == 0:
raise YunohostValidationError("apps_already_up_to_date") raise YunohostValidationError("apps_already_up_to_date")
@ -750,7 +746,6 @@ def app_upgrade(app=[], url=None, file=None, force=False):
for file_to_copy in [ for file_to_copy in [
"actions.json", "actions.json",
"actions.toml", "actions.toml",
"config_panel.json",
"config_panel.toml", "config_panel.toml",
"conf", "conf",
]: ]:
@ -970,7 +965,6 @@ def app_install(
for file_to_copy in [ for file_to_copy in [
"actions.json", "actions.json",
"actions.toml", "actions.toml",
"config_panel.json",
"config_panel.toml", "config_panel.toml",
"conf", "conf",
]: ]:
@ -1759,165 +1753,143 @@ def app_action_run(operation_logger, app, action, args=None):
# * docstrings # * docstrings
# * merge translations on the json once the workflow is in place # * merge translations on the json once the workflow is in place
@is_unit_operation() @is_unit_operation()
def app_config_show_panel(operation_logger, app): def app_config_show(operation_logger, app, panel='', full=False):
logger.warning(m18n.n("experimental_feature")) # logger.warning(m18n.n("experimental_feature"))
from yunohost.hook import hook_exec # Check app is installed
_assert_is_installed(app)
# this will take care of checking if the app is installed
app_info_dict = app_info(app)
panel = panel if panel else ''
operation_logger.start() operation_logger.start()
config_panel = _get_app_config_panel(app)
config_script = os.path.join(APPS_SETTING_PATH, app, "scripts", "config")
app_id, app_instance_nb = _parse_app_instance_name(app) # Read config panel toml
config_panel = _get_app_config_panel(app, filter_key=panel)
if not config_panel or not os.path.exists(config_script): if not config_panel:
return { return None
"app_id": app_id,
"app": app,
"app_name": app_info_dict["name"],
"config_panel": [],
}
env = { # Call config script to extract current values
"app_id": app_id, parsed_values = _call_config_script(app, 'show')
"app": app,
"app_instance_nb": str(app_instance_nb),
}
try: # # Check and transform values if needed
ret, parsed_values = hook_exec( # options = [option for _, _, option in _get_options_iterator(config_panel)]
config_script, args=["show"], env=env, return_format="plain_dict" # args_dict = _parse_args_in_yunohost_format(
) # parsed_values, options, False
# Here again, calling hook_exec could fail miserably, or get # )
# manually interrupted (by mistake or because script was stuck)
except (KeyboardInterrupt, EOFError, Exception):
raise YunohostError("unexpected_error")
logger.debug("Generating global variables:")
for tab in config_panel.get("panel", []):
for section in tab.get("sections", []):
for option in section.get("options", []):
logger.debug(
" * '%s'.'%s'.'%s'",
tab.get("name"),
section.get("name"),
option.get("name"),
)
# Hydrate
logger.debug("Hydrating config with current value")
for _, _, option in _get_options_iterator(config_panel):
if option['name'] in parsed_values: if option['name'] in parsed_values:
# code is not adapted for that so we have to mock expected format :/ option["value"] = parsed_values[option['name']] #args_dict[option["name"]][0]
if option.get("type") == "boolean":
if parsed_values[option['name']].lower() in ("true", "1", "y"):
option["default"] = parsed_values[option['name']]
else:
del option["default"]
else:
option["default"] = parsed_values[option['name']]
args_dict = _parse_args_in_yunohost_format( # Format result in full or reduce mode
parsed_values, [option] if full:
) operation_logger.success()
option["default"] = args_dict[option["name"]][0] return config_panel
else:
logger.debug(
"Variable '%s' is not declared by config script, using default",
option['name'],
)
# do nothing, we'll use the default if present
return { result = OrderedDict()
"app_id": app_id, for panel, section, option in _get_options_iterator(config_panel):
"app": app, if panel['id'] not in result:
"app_name": app_info_dict["name"], r_panel = result[panel['id']] = OrderedDict()
"config_panel": config_panel, if section['id'] not in r_panel:
"logs": operation_logger.success(), r_section = r_panel[section['id']] = OrderedDict()
r_option = r_section[option['name']] = {
"ask": option['ask']['en']
} }
if not option.get('optional', False):
r_option['ask'] += ' *'
if option.get('value', None) is not None:
r_option['value'] = option['value']
operation_logger.success()
return result
@is_unit_operation() @is_unit_operation()
def app_config_apply(operation_logger, app, args): def app_config_get(operation_logger, app, key):
logger.warning(m18n.n("experimental_feature")) # Check app is installed
_assert_is_installed(app)
from yunohost.hook import hook_exec
from base64 import b64decode
installed = _is_installed(app)
if not installed:
raise YunohostValidationError(
"app_not_installed", app=app, all_apps=_get_all_installed_apps_id()
)
config_panel = _get_app_config_panel(app)
config_script = os.path.join(APPS_SETTING_PATH, app, "scripts", "config")
if not config_panel or not os.path.exists(config_script):
# XXX real exception
raise Exception("Not config-panel.json nor scripts/config")
operation_logger.start() operation_logger.start()
app_id, app_instance_nb = _parse_app_instance_name(app)
env = { # Read config panel toml
"app_id": app_id, config_panel = _get_app_config_panel(app, filter_key=key)
"app": app,
"app_instance_nb": str(app_instance_nb), if not config_panel:
} raise YunohostError("app_config_no_panel")
# Call config script to extract current values
parsed_values = _call_config_script(app, 'show')
logger.debug("Searching value")
short_key = key.split('.')[-1]
if short_key not in parsed_values:
return None
return parsed_values[short_key]
# for panel, section, option in _get_options_iterator(config_panel):
# if option['name'] == short_key:
# # Check and transform values if needed
# args_dict = _parse_args_in_yunohost_format(
# parsed_values, [option], False
# )
# operation_logger.success()
# return args_dict[short_key][0]
# return None
@is_unit_operation()
def app_config_set(operation_logger, app, key=None, value=None, args=None):
# Check app is installed
_assert_is_installed(app)
filter_key = key if key else ''
# Read config panel toml
config_panel = _get_app_config_panel(app, filter_key=filter_key)
if not config_panel:
raise YunohostError("app_config_no_panel")
if args is not None and value is not None:
raise YunohostError("app_config_args_value")
operation_logger.start()
# Prepare pre answered questions
args = {}
if args:
args = dict(urllib.parse.parse_qsl(args, keep_blank_values=True)) if args else {} args = dict(urllib.parse.parse_qsl(args, keep_blank_values=True)) if args else {}
elif value is not None:
args = {key: value}
upload_dir = None upload_dir = None
for tab in config_panel.get("panel", []):
for section in tab.get("sections", []):
for option in section.get("options", []):
if option['name'] in args: for panel in config_panel.get("panel", []):
# Upload files from API
# A file arg contains a string with "FILENAME:BASE64_CONTENT"
if 'type' in option and option["type"] == "file" \
and msettings.get('interface') == 'api':
if upload_dir is None:
upload_dir = tempfile.mkdtemp(prefix='tmp_configpanel_')
filename = args[option['name'] + '[name]']
content = args[option['name']]
logger.debug("Save uploaded file %s from API into %s", filename, upload_dir)
# Filename is given by user of the API. For security reason, we have replaced if msettings.get('interface') == 'cli' and len(filter_key.split('.')) < 3:
# os.path.join to avoid the user to be able to rewrite a file in filesystem msignals.display(colorize("\n" + "=" * 40, 'purple'))
# i.e. os.path.join("/foo", "/etc/passwd") == "/etc/passwd" msignals.display(colorize(f">>>> {panel['name']}", 'purple'))
file_path = os.path.normpath(upload_dir + "/" + filename) msignals.display(colorize("=" * 40, 'purple'))
i = 2 for section in panel.get("sections", []):
while os.path.exists(file_path): if msettings.get('interface') == 'cli' and len(filter_key.split('.')) < 3:
file_path = os.path.normpath(upload_dir + "/" + filename + (".%d" % i)) msignals.display(colorize(f"\n# {section['name']}", 'purple'))
i += 1
try:
with open(file_path, 'wb') as f:
f.write(b64decode(content))
except IOError as e:
raise YunohostError("cannot_write_file", file=file_path, error=str(e))
except Exception as e:
raise YunohostError("error_writing_file", file=file_path, error=str(e))
args[option['name']] = file_path
logger.debug( # Check and ask unanswered questions
"include into env %s=%s", option['name'], args[option['name']] args_dict = _parse_args_in_yunohost_format(
args, section['options']
) )
env[option['name']] = args[option['name']]
else:
logger.debug("no value for key id %s", option['name'])
# for debug purpose # Call config script to extract current values
for key in args: logger.info("Running config script...")
if key not in env: env = {key: value[0] for key, value in args_dict.items()}
logger.debug(
"Ignore key '%s' from arguments because it is not in the config", key
)
try: try:
hook_exec( errors = _call_config_script(app, 'apply', env=env)
config_script,
args=["apply"],
env=env
)
# Here again, calling hook_exec could fail miserably, or get # Here again, calling hook_exec could fail miserably, or get
# manually interrupted (by mistake or because script was stuck) # manually interrupted (by mistake or because script was stuck)
except (KeyboardInterrupt, EOFError, Exception): except (KeyboardInterrupt, EOFError, Exception):
@ -1931,10 +1903,51 @@ def app_config_apply(operation_logger, app, args):
logger.success("Config updated as expected") logger.success("Config updated as expected")
return { return {
"app": app, "app": app,
"errors": errors,
"logs": operation_logger.success(), "logs": operation_logger.success(),
} }
def _get_options_iterator(config_panel):
for panel in config_panel.get("panel", []):
for section in panel.get("sections", []):
for option in section.get("options", []):
yield (panel, section, option)
def _call_config_script(app, action, env={}):
from yunohost.hook import hook_exec
# Add default config script if needed
config_script = os.path.join(APPS_SETTING_PATH, app, "scripts", "config")
if not os.path.exists(config_script):
logger.debug("Adding a default config script")
default_script = """#!/bin/bash
source /usr/share/yunohost/helpers
ynh_abort_if_errors
final_path=$(ynh_app_setting_get $app final_path)
ynh_panel_run $1
"""
write_to_file(config_script, default_script)
# Call config script to extract current values
logger.debug("Calling 'show' action from config script")
app_id, app_instance_nb = _parse_app_instance_name(app)
env.update({
"app_id": app_id,
"app": app,
"app_instance_nb": str(app_instance_nb),
})
try:
_, parsed_values = hook_exec(
config_script, args=[action], env=env
)
except (KeyboardInterrupt, EOFError, Exception):
logger.error('Unable to extract some values')
parsed_values = {}
return parsed_values
def _get_all_installed_apps_id(): def _get_all_installed_apps_id():
""" """
Return something like: Return something like:
@ -2036,14 +2049,11 @@ def _get_app_actions(app_id):
return None return None
def _get_app_config_panel(app_id): def _get_app_config_panel(app_id, filter_key=''):
"Get app config panel stored in json or in toml" "Get app config panel stored in json or in toml"
config_panel_toml_path = os.path.join( config_panel_toml_path = os.path.join(
APPS_SETTING_PATH, app_id, "config_panel.toml" APPS_SETTING_PATH, app_id, "config_panel.toml"
) )
config_panel_json_path = os.path.join(
APPS_SETTING_PATH, app_id, "config_panel.json"
)
# sample data to get an idea of what is going on # sample data to get an idea of what is going on
# this toml extract: # this toml extract:
@ -2121,6 +2131,10 @@ def _get_app_config_panel(app_id):
"version": toml_config_panel["version"], "version": toml_config_panel["version"],
"panel": [], "panel": [],
} }
filter_key = filter_key.split('.')
filter_panel = filter_key.pop(0)
filter_section = filter_key.pop(0) if len(filter_key) > 0 else False
filter_option = filter_key.pop(0) if len(filter_key) > 0 else False
panels = [ panels = [
key_value key_value
@ -2130,6 +2144,9 @@ def _get_app_config_panel(app_id):
] ]
for key, value in panels: for key, value in panels:
if filter_panel and key != filter_panel:
continue
panel = { panel = {
"id": key, "id": key,
"name": value.get("name", ""), "name": value.get("name", ""),
@ -2143,9 +2160,14 @@ def _get_app_config_panel(app_id):
] ]
for section_key, section_value in sections: for section_key, section_value in sections:
if filter_section and section_key != filter_section:
continue
section = { section = {
"id": section_key, "id": section_key,
"name": section_value.get("name", ""), "name": section_value.get("name", ""),
"optional": section_value.get("optional", True),
"options": [], "options": [],
} }
@ -2156,7 +2178,11 @@ def _get_app_config_panel(app_id):
] ]
for option_key, option_value in options: for option_key, option_value in options:
if filter_option and option_key != filter_option:
continue
option = dict(option_value) option = dict(option_value)
option["optional"] = option_value.get("optional", section['optional'])
option["name"] = option_key option["name"] = option_key
option["ask"] = {"en": option["ask"]} option["ask"] = {"en": option["ask"]}
if "help" in option: if "help" in option:
@ -2169,9 +2195,6 @@ def _get_app_config_panel(app_id):
return config_panel return config_panel
elif os.path.exists(config_panel_json_path):
return json.load(open(config_panel_json_path))
return None return None
@ -2615,6 +2638,13 @@ def _is_installed(app):
return os.path.isdir(APPS_SETTING_PATH + app) return os.path.isdir(APPS_SETTING_PATH + app)
def _assert_is_installed(app):
if not _is_installed(app):
raise YunohostValidationError(
"app_not_installed", app=app, all_apps=_get_all_installed_apps_id()
)
def _installed_apps(): def _installed_apps():
return os.listdir(APPS_SETTING_PATH) return os.listdir(APPS_SETTING_PATH)
@ -2727,10 +2757,13 @@ class YunoHostArgumentFormatParser(object):
parsed_question = Question() parsed_question = Question()
parsed_question.name = question["name"] parsed_question.name = question["name"]
parsed_question.type = question.get("type", 'string')
parsed_question.default = question.get("default", None) parsed_question.default = question.get("default", None)
parsed_question.choices = question.get("choices", []) parsed_question.choices = question.get("choices", [])
parsed_question.optional = question.get("optional", False) parsed_question.optional = question.get("optional", False)
parsed_question.ask = question.get("ask") parsed_question.ask = question.get("ask")
parsed_question.help = question.get("help")
parsed_question.helpLink = question.get("helpLink")
parsed_question.value = user_answers.get(parsed_question.name) parsed_question.value = user_answers.get(parsed_question.name)
if parsed_question.ask is None: if parsed_question.ask is None:
@ -2742,24 +2775,28 @@ class YunoHostArgumentFormatParser(object):
return parsed_question return parsed_question
def parse(self, question, user_answers): def parse(self, question, user_answers, check_required=True):
question = self.parse_question(question, user_answers) question = self.parse_question(question, user_answers)
if question.value is None: if question.value is None and not getattr(self, "readonly", False):
text_for_user_input_in_cli = self._format_text_for_user_input_in_cli( text_for_user_input_in_cli = self._format_text_for_user_input_in_cli(
question question
) )
try: try:
question.value = msignals.prompt( question.value = msignals.prompt(
text_for_user_input_in_cli, self.hide_user_input_in_prompt message=text_for_user_input_in_cli,
is_password=self.hide_user_input_in_prompt,
confirm=self.hide_user_input_in_prompt
) )
except NotImplementedError: except NotImplementedError:
question.value = None question.value = None
if getattr(self, "readonly", False):
msignals.display(self._format_text_for_user_input_in_cli(question))
# we don't have an answer, check optional and default_value # we don't have an answer, check optional and default_value
if question.value is None or question.value == "": if question.value is None or question.value == "":
if not question.optional and question.default is None: if not question.optional and question.default is None and check_required:
raise YunohostValidationError( raise YunohostValidationError(
"app_argument_required", name=question.name "app_argument_required", name=question.name
) )
@ -2785,6 +2822,7 @@ class YunoHostArgumentFormatParser(object):
raise YunohostValidationError( raise YunohostValidationError(
"app_argument_choice_invalid", "app_argument_choice_invalid",
name=question.name, name=question.name,
value=question.value,
choices=", ".join(question.choices), choices=", ".join(question.choices),
) )
@ -2796,7 +2834,15 @@ class YunoHostArgumentFormatParser(object):
if question.default is not None: if question.default is not None:
text_for_user_input_in_cli += " (default: {0})".format(question.default) text_for_user_input_in_cli += " (default: {0})".format(question.default)
if question.help or question.helpLink:
text_for_user_input_in_cli += ":\033[m"
if question.help:
text_for_user_input_in_cli += "\n - "
text_for_user_input_in_cli += question.help['en']
if question.helpLink:
if not isinstance(question.helpLink, dict):
question.helpLink = {'href': question.helpLink}
text_for_user_input_in_cli += f"\n - See {question.helpLink['href']}"
return text_for_user_input_in_cli return text_for_user_input_in_cli
def _post_parse_value(self, question): def _post_parse_value(self, question):
@ -2884,6 +2930,7 @@ class BooleanArgumentParser(YunoHostArgumentFormatParser):
raise YunohostValidationError( raise YunohostValidationError(
"app_argument_choice_invalid", "app_argument_choice_invalid",
name=question.name, name=question.name,
value=question.value,
choices="yes, no, y, n, 1, 0", choices="yes, no, y, n, 1, 0",
) )
@ -2967,13 +3014,73 @@ class NumberArgumentParser(YunoHostArgumentFormatParser):
class DisplayTextArgumentParser(YunoHostArgumentFormatParser): class DisplayTextArgumentParser(YunoHostArgumentFormatParser):
argument_type = "display_text" argument_type = "display_text"
readonly = True
def parse(self, question, user_answers): def parse_question(self, question, user_answers):
print(question["ask"]) question = super(DisplayTextArgumentParser, self).parse_question(
question, user_answers
)
question.optional = True
return question
def _format_text_for_user_input_in_cli(self, question):
text = question.ask['en']
if question.type in ['info', 'warning', 'danger']:
color = {
'info': 'cyan',
'warning': 'yellow',
'danger': 'red'
}
return colorize(m18n.g(question.type), color[question.type]) + f" {text}"
else:
return text
class FileArgumentParser(YunoHostArgumentFormatParser): class FileArgumentParser(YunoHostArgumentFormatParser):
argument_type = "file" argument_type = "file"
def parse_question(self, question, user_answers):
question = super(FileArgumentParser, self).parse_question(
question, user_answers
)
if msettings.get('interface') == 'api':
question.value = {
'content': user_answers[question.name],
'filename': user_answers.get(f"{question.name}[name]", question.name),
} if user_answers[question.name] else None
return question
def _post_parse_value(self, question):
from base64 import b64decode
# Upload files from API
# A file arg contains a string with "FILENAME:BASE64_CONTENT"
if not question.value:
return question.value
if msettings.get('interface') == 'api':
upload_dir = tempfile.mkdtemp(prefix='tmp_configpanel_')
filename = question.value['filename']
logger.debug(f"Save uploaded file {question.value['filename']} from API into {upload_dir}")
# Filename is given by user of the API. For security reason, we have replaced
# os.path.join to avoid the user to be able to rewrite a file in filesystem
# i.e. os.path.join("/foo", "/etc/passwd") == "/etc/passwd"
file_path = os.path.normpath(upload_dir + "/" + filename)
i = 2
while os.path.exists(file_path):
file_path = os.path.normpath(upload_dir + "/" + filename + (".%d" % i))
i += 1
content = question.value['content']
try:
with open(file_path, 'wb') as f:
f.write(b64decode(content))
except IOError as e:
raise YunohostError("cannot_write_file", file=file_path, error=str(e))
except Exception as e:
raise YunohostError("error_writing_file", file=file_path, error=str(e))
question.value = file_path
return question.value
ARGUMENTS_TYPE_PARSERS = { ARGUMENTS_TYPE_PARSERS = {
@ -2994,11 +3101,16 @@ ARGUMENTS_TYPE_PARSERS = {
"number": NumberArgumentParser, "number": NumberArgumentParser,
"range": NumberArgumentParser, "range": NumberArgumentParser,
"display_text": DisplayTextArgumentParser, "display_text": DisplayTextArgumentParser,
"success": DisplayTextArgumentParser,
"danger": DisplayTextArgumentParser,
"warning": DisplayTextArgumentParser,
"info": DisplayTextArgumentParser,
"markdown": DisplayTextArgumentParser,
"file": FileArgumentParser, "file": FileArgumentParser,
} }
def _parse_args_in_yunohost_format(user_answers, argument_questions): def _parse_args_in_yunohost_format(user_answers, argument_questions, check_required=True):
"""Parse arguments store in either manifest.json or actions.json or from a """Parse arguments store in either manifest.json or actions.json or from a
config panel against the user answers when they are present. config panel against the user answers when they are present.
@ -3014,7 +3126,7 @@ def _parse_args_in_yunohost_format(user_answers, argument_questions):
for question in argument_questions: for question in argument_questions:
parser = ARGUMENTS_TYPE_PARSERS[question.get("type", "string")]() parser = ARGUMENTS_TYPE_PARSERS[question.get("type", "string")]()
answer = parser.parse(question=question, user_answers=user_answers) answer = parser.parse(question=question, user_answers=user_answers, check_required=check_required)
if answer is not None: if answer is not None:
parsed_answers_dict[question["name"]] = answer parsed_answers_dict[question["name"]] = answer