mirror of
https://github.com/YunoHost/yunohost.git
synced 2024-09-03 20:06:10 +02:00
Merge branch 'enh-config-panel-file' into enh-dns-autoconf
This commit is contained in:
commit
0da2f137ca
10 changed files with 1371 additions and 648 deletions
|
@ -896,24 +896,45 @@ app:
|
|||
subcategory_help: Applications configuration panel
|
||||
actions:
|
||||
|
||||
### app_config_show_panel()
|
||||
show-panel:
|
||||
action_help: show config panel for the application
|
||||
### app_config_get()
|
||||
get:
|
||||
action_help: Display an app configuration
|
||||
api: GET /apps/<app>/config-panel
|
||||
arguments:
|
||||
app:
|
||||
help: App name
|
||||
app:
|
||||
help: App name
|
||||
key:
|
||||
help: A specific panel, section or a question identifier
|
||||
nargs: '?'
|
||||
-m:
|
||||
full: --mode
|
||||
help: Display mode to use
|
||||
choices:
|
||||
- classic
|
||||
- full
|
||||
- export
|
||||
default: classic
|
||||
|
||||
### app_config_apply()
|
||||
apply:
|
||||
action_help: apply the new configuration
|
||||
### app_config_set()
|
||||
set:
|
||||
action_help: Apply a new configuration
|
||||
api: PUT /apps/<app>/config
|
||||
arguments:
|
||||
app:
|
||||
help: App name
|
||||
-a:
|
||||
full: --args
|
||||
help: Serialized arguments for new configuration (i.e. "domain=domain.tld&path=/path")
|
||||
app:
|
||||
help: App name
|
||||
key:
|
||||
help: The question or panel key
|
||||
nargs: '?'
|
||||
-v:
|
||||
full: --value
|
||||
help: new value
|
||||
-a:
|
||||
full: --args
|
||||
help: Serialized arguments for new configuration (i.e. "domain=domain.tld&path=/path")
|
||||
-f:
|
||||
full: --args-file
|
||||
help: YAML or JSON file with key/value couples
|
||||
type: open
|
||||
|
||||
#############################
|
||||
# Backup #
|
||||
|
|
|
@ -326,12 +326,25 @@ ynh_bind_or_cp() {
|
|||
ynh_store_file_checksum () {
|
||||
# Declare an array to define the options of this helper.
|
||||
local legacy_args=f
|
||||
local -A args_array=( [f]=file= )
|
||||
local -A args_array=( [f]=file= [u]=update_only )
|
||||
local file
|
||||
local update_only
|
||||
update_only="${update_only:-0}"
|
||||
|
||||
# Manage arguments with getopts
|
||||
ynh_handle_getopts_args "$@"
|
||||
|
||||
local checksum_setting_name=checksum_${file//[\/ ]/_} # Replace all '/' and ' ' by '_'
|
||||
|
||||
# If update only, we don't save the new checksum if no old checksum exist
|
||||
if [ $update_only -eq 1 ] ; then
|
||||
local checksum_value=$(ynh_app_setting_get --app=$app --key=$checksum_setting_name)
|
||||
if [ -z "${checksum_value}" ] ; then
|
||||
unset backup_file_checksum
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
ynh_app_setting_set --app=$app --key=$checksum_setting_name --value=$(md5sum "$file" | cut --delimiter=' ' --fields=1)
|
||||
|
||||
# If backup_file_checksum isn't empty, ynh_backup_if_checksum_is_different has made a backup
|
||||
|
|
273
data/helpers.d/config
Normal file
273
data/helpers.d/config
Normal file
|
@ -0,0 +1,273 @@
|
|||
#!/bin/bash
|
||||
|
||||
|
||||
_ynh_app_config_get() {
|
||||
# From settings
|
||||
local lines
|
||||
lines=`python3 << EOL
|
||||
import toml
|
||||
from collections import OrderedDict
|
||||
with open("../config_panel.toml", "r") as f:
|
||||
file_content = f.read()
|
||||
loaded_toml = toml.loads(file_content, _dict=OrderedDict)
|
||||
|
||||
for panel_name, panel in loaded_toml.items():
|
||||
if not isinstance(panel, dict): continue
|
||||
for section_name, section in panel.items():
|
||||
if not isinstance(section, dict): continue
|
||||
for name, param in section.items():
|
||||
if not isinstance(param, dict):
|
||||
continue
|
||||
print(';'.join([
|
||||
name,
|
||||
param.get('type', 'string'),
|
||||
param.get('source', 'settings' if param.get('type', 'string') != 'file' else '')
|
||||
]))
|
||||
EOL
|
||||
`
|
||||
for line in $lines
|
||||
do
|
||||
IFS=';' read short_setting type source <<< "$line"
|
||||
local getter="get__${short_setting}"
|
||||
sources[${short_setting}]="$source"
|
||||
types[${short_setting}]="$type"
|
||||
file_hash[${short_setting}]=""
|
||||
formats[${short_setting}]=""
|
||||
# Get value from getter if exists
|
||||
if type -t $getter 2>/dev/null | grep -q '^function$' 2>/dev/null; then
|
||||
old[$short_setting]="$($getter)"
|
||||
formats[${short_setting}]="yaml"
|
||||
|
||||
elif [[ "$source" == "" ]] ; then
|
||||
old[$short_setting]="YNH_NULL"
|
||||
|
||||
# Get value from app settings or from another file
|
||||
elif [[ "$type" == "file" ]] ; then
|
||||
if [[ "$source" == "settings" ]] ; then
|
||||
ynh_die "File '${short_setting}' can't be stored in settings"
|
||||
fi
|
||||
old[$short_setting]="$(ls $(echo $source | sed s@__FINALPATH__@$final_path@ | sed s/__APP__/$app/) 2> /dev/null || echo YNH_NULL)"
|
||||
file_hash[$short_setting]="true"
|
||||
|
||||
# Get multiline text from settings or from a full file
|
||||
elif [[ "$type" == "text" ]] ; then
|
||||
if [[ "$source" == "settings" ]] ; then
|
||||
old[$short_setting]="$(ynh_app_setting_get $app $short_setting)"
|
||||
elif [[ "$source" == *":"* ]] ; then
|
||||
ynh_die "For technical reasons, multiline text '${short_setting}' can't be stored automatically in a variable file, you have to create custom getter/setter"
|
||||
else
|
||||
old[$short_setting]="$(cat $(echo $source | sed s@__FINALPATH__@$final_path@ | sed s/__APP__/$app/) 2> /dev/null || echo YNH_NULL)"
|
||||
fi
|
||||
|
||||
# Get value from a kind of key/value file
|
||||
else
|
||||
if [[ "$source" == "settings" ]] ; then
|
||||
source=":/etc/yunohost/apps/$app/settings.yml"
|
||||
fi
|
||||
local source_key="$(echo "$source" | cut -d: -f1)"
|
||||
source_key=${source_key:-$short_setting}
|
||||
local source_file="$(echo "$source" | cut -d: -f2 | sed s@__FINALPATH__@$final_path@ | sed s/__APP__/$app/)"
|
||||
old[$short_setting]="$(ynh_get_var --file="${source_file}" --key="${source_key}")"
|
||||
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
}
|
||||
|
||||
_ynh_app_config_apply() {
|
||||
for short_setting in "${!old[@]}"
|
||||
do
|
||||
local setter="set__${short_setting}"
|
||||
local source="${sources[$short_setting]}"
|
||||
local type="${types[$short_setting]}"
|
||||
if [ "${changed[$short_setting]}" == "true" ] ; then
|
||||
# Apply setter if exists
|
||||
if type -t $setter 2>/dev/null | grep -q '^function$' 2>/dev/null; then
|
||||
$setter
|
||||
|
||||
elif [[ "$source" == "" ]] ; then
|
||||
continue
|
||||
|
||||
# Save in a file
|
||||
elif [[ "$type" == "file" ]] ; then
|
||||
if [[ "$source" == "settings" ]] ; then
|
||||
ynh_die "File '${short_setting}' can't be stored in settings"
|
||||
fi
|
||||
local source_file="$(echo "$source" | sed s@__FINALPATH__@$final_path@ | sed s/__APP__/$app/)"
|
||||
if [[ "${!short_setting}" == "" ]] ; then
|
||||
ynh_backup_if_checksum_is_different --file="$source_file"
|
||||
rm -f "$source_file"
|
||||
ynh_delete_file_checksum --file="$source_file" --update_only
|
||||
ynh_print_info "File '$source_file' removed"
|
||||
else
|
||||
ynh_backup_if_checksum_is_different --file="$source_file"
|
||||
cp "${!short_setting}" "$source_file"
|
||||
ynh_store_file_checksum --file="$source_file" --update_only
|
||||
ynh_print_info "File '$source_file' overwrited with ${!short_setting}"
|
||||
fi
|
||||
|
||||
# Save value in app settings
|
||||
elif [[ "$source" == "settings" ]] ; then
|
||||
ynh_app_setting_set $app $short_setting "${!short_setting}"
|
||||
ynh_print_info "Configuration key '$short_setting' edited in app settings"
|
||||
|
||||
# Save multiline text in a file
|
||||
elif [[ "$type" == "text" ]] ; then
|
||||
if [[ "$source" == *":"* ]] ; then
|
||||
ynh_die "For technical reasons, multiline text '${short_setting}' can't be stored automatically in a variable file, you have to create custom getter/setter"
|
||||
fi
|
||||
local source_file="$(echo "$source" | sed s@__FINALPATH__@$final_path@ | sed s/__APP__/$app/)"
|
||||
ynh_backup_if_checksum_is_different --file="$source_file"
|
||||
echo "${!short_setting}" > "$source_file"
|
||||
ynh_store_file_checksum --file="$source_file" --update_only
|
||||
ynh_print_info "File '$source_file' overwrited with the content you provieded in '${short_setting}' question"
|
||||
|
||||
# Set value into a kind of key/value file
|
||||
else
|
||||
local source_key="$(echo "$source" | cut -d: -f1)"
|
||||
source_key=${source_key:-$short_setting}
|
||||
local source_file="$(echo "$source" | cut -d: -f2 | sed s@__FINALPATH__@$final_path@ | sed s/__APP__/$app/)"
|
||||
|
||||
ynh_backup_if_checksum_is_different --file="$source_file"
|
||||
ynh_set_var --file="${source_file}" --key="${source_key}" --value="${!short_setting}"
|
||||
ynh_store_file_checksum --file="$source_file" --update_only
|
||||
|
||||
# We stored the info in settings in order to be able to upgrade the app
|
||||
ynh_app_setting_set $app $short_setting "${!short_setting}"
|
||||
ynh_print_info "Configuration key '$source_key' edited into $source_file"
|
||||
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
_ynh_app_config_show() {
|
||||
for short_setting in "${!old[@]}"
|
||||
do
|
||||
if [[ "${old[$short_setting]}" != YNH_NULL ]] ; then
|
||||
if [[ "${formats[$short_setting]}" == "yaml" ]] ; then
|
||||
ynh_return "${short_setting}:"
|
||||
ynh_return "$(echo "${old[$short_setting]}" | sed 's/^/ /g')"
|
||||
else
|
||||
ynh_return "${short_setting}: "'"'"$(echo "${old[$short_setting]}" | sed 's/"/\\"/g' | sed ':a;N;$!ba;s/\n/\n\n/g')"'"'
|
||||
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
_ynh_app_config_validate() {
|
||||
# Change detection
|
||||
ynh_script_progression --message="Checking what changed in the new configuration..." --weight=1
|
||||
local is_error=true
|
||||
#for changed_status in "${!changed[@]}"
|
||||
for short_setting in "${!old[@]}"
|
||||
do
|
||||
changed[$short_setting]=false
|
||||
if [ -z ${!short_setting+x} ]; then
|
||||
# Assign the var with the old value in order to allows multiple
|
||||
# args validation
|
||||
declare "$short_setting"="${old[$short_setting]}"
|
||||
continue
|
||||
fi
|
||||
if [ ! -z "${file_hash[${short_setting}]}" ] ; then
|
||||
file_hash[old__$short_setting]=""
|
||||
file_hash[new__$short_setting]=""
|
||||
if [ -f "${old[$short_setting]}" ] ; then
|
||||
file_hash[old__$short_setting]=$(sha256sum "${old[$short_setting]}" | cut -d' ' -f1)
|
||||
if [ -z "${!short_setting}" ] ; then
|
||||
changed[$short_setting]=true
|
||||
is_error=false
|
||||
fi
|
||||
fi
|
||||
if [ -f "${!short_setting}" ] ; then
|
||||
file_hash[new__$short_setting]=$(sha256sum "${!short_setting}" | cut -d' ' -f1)
|
||||
if [[ "${file_hash[old__$short_setting]}" != "${file_hash[new__$short_setting]}" ]]
|
||||
then
|
||||
changed[$short_setting]=true
|
||||
is_error=false
|
||||
fi
|
||||
fi
|
||||
else
|
||||
if [[ "${!short_setting}" != "${old[$short_setting]}" ]]
|
||||
then
|
||||
changed[$short_setting]=true
|
||||
is_error=false
|
||||
fi
|
||||
fi
|
||||
done
|
||||
if [[ "$is_error" == "true" ]]
|
||||
then
|
||||
ynh_print_info "Nothing has changed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Run validation if something is changed
|
||||
ynh_script_progression --message="Validating the new configuration..." --weight=1
|
||||
|
||||
for short_setting in "${!old[@]}"
|
||||
do
|
||||
[[ "${changed[$short_setting]}" == "false" ]] && continue
|
||||
local result=""
|
||||
if type -t validate__$short_setting | grep -q '^function$' 2>/dev/null; then
|
||||
result="$(validate__$short_setting)"
|
||||
fi
|
||||
if [ -n "$result" ]
|
||||
then
|
||||
local key="YNH_ERROR_${short_setting}"
|
||||
ynh_return "$key: \"$result\""
|
||||
is_error=true
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$is_error" == "true" ]]
|
||||
then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
}
|
||||
|
||||
ynh_app_config_get() {
|
||||
_ynh_app_config_get
|
||||
}
|
||||
|
||||
ynh_app_config_show() {
|
||||
_ynh_app_config_show
|
||||
}
|
||||
|
||||
ynh_app_config_validate() {
|
||||
_ynh_app_config_validate
|
||||
}
|
||||
|
||||
ynh_app_config_apply() {
|
||||
_ynh_app_config_apply
|
||||
}
|
||||
|
||||
ynh_app_config_run() {
|
||||
declare -Ag old=()
|
||||
declare -Ag changed=()
|
||||
declare -Ag file_hash=()
|
||||
declare -Ag sources=()
|
||||
declare -Ag types=()
|
||||
declare -Ag formats=()
|
||||
|
||||
case $1 in
|
||||
show)
|
||||
ynh_app_config_get
|
||||
ynh_app_config_show
|
||||
;;
|
||||
apply)
|
||||
max_progression=4
|
||||
ynh_script_progression --message="Reading config panel description and current configuration..."
|
||||
ynh_app_config_get
|
||||
|
||||
ynh_app_config_validate
|
||||
|
||||
ynh_script_progression --message="Applying the new configuration..."
|
||||
ynh_app_config_apply
|
||||
ynh_script_progression --message="Configuration of $app completed" --last
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
|
@ -473,6 +473,105 @@ ynh_replace_vars () {
|
|||
done
|
||||
}
|
||||
|
||||
# Get a value from heterogeneous file (yaml, json, php, python...)
|
||||
#
|
||||
# usage: ynh_get_var --file=PATH --key=KEY
|
||||
# | arg: -f, --file= - the path to the file
|
||||
# | arg: -k, --key= - the key to get
|
||||
#
|
||||
# This helpers match several var affectation use case in several languages
|
||||
# We don't use jq or equivalent to keep comments and blank space in files
|
||||
# This helpers work line by line, it is not able to work correctly
|
||||
# if you have several identical keys in your files
|
||||
#
|
||||
# Example of line this helpers can managed correctly
|
||||
# .yml
|
||||
# title: YunoHost documentation
|
||||
# email: 'yunohost@yunohost.org'
|
||||
# .json
|
||||
# "theme": "colib'ris",
|
||||
# "port": 8102
|
||||
# "some_boolean": false,
|
||||
# "user": null
|
||||
# .ini
|
||||
# some_boolean = On
|
||||
# action = "Clear"
|
||||
# port = 20
|
||||
# .php
|
||||
# $user=
|
||||
# user => 20
|
||||
# .py
|
||||
# USER = 8102
|
||||
# user = 'https://donate.local'
|
||||
# CUSTOM['user'] = 'YunoHost'
|
||||
# Requires YunoHost version 4.3 or higher.
|
||||
ynh_get_var() {
|
||||
# Declare an array to define the options of this helper.
|
||||
local legacy_args=fk
|
||||
local -A args_array=( [f]=file= [k]=key= )
|
||||
local file
|
||||
local key
|
||||
# Manage arguments with getopts
|
||||
ynh_handle_getopts_args "$@"
|
||||
|
||||
local var_part='^[ \t]*\$?(\w*\[)?[ \t]*["'"']?${key}['"'"]?[ \t]*\]?[ \t]*[:=]>?[ \t]*'
|
||||
|
||||
local crazy_value="$((grep -i -o -P '^[ \t]*\$?(\w*\[)?[ \t]*["'"']?${key}['"'"]?[ \t]*\]?[ \t]*[:=]>?[ \t]*\K.*(?=[ \t,\n;]*$)' ${file} || echo YNH_NULL) | head -n1)"
|
||||
#"
|
||||
|
||||
local first_char="${crazy_value:0:1}"
|
||||
if [[ "$first_char" == '"' ]] ; then
|
||||
echo "$crazy_value" | grep -m1 -o -P '"\K([^"](\\")?)*[^\\](?=")' | head -n1 | sed 's/\\"/"/g'
|
||||
elif [[ "$first_char" == "'" ]] ; then
|
||||
echo "$crazy_value" | grep -m1 -o -P "'\K([^'](\\\\')?)*[^\\\\](?=')" | head -n1 | sed "s/\\\\'/'/g"
|
||||
else
|
||||
echo "$crazy_value"
|
||||
fi
|
||||
}
|
||||
|
||||
# Set a value into heterogeneous file (yaml, json, php, python...)
|
||||
#
|
||||
# usage: ynh_set_var --file=PATH --key=KEY --value=VALUE
|
||||
# | arg: -f, --file= - the path to the file
|
||||
# | arg: -k, --key= - the key to set
|
||||
# | arg: -v, --value= - the value to set
|
||||
#
|
||||
# Requires YunoHost version 4.3 or higher.
|
||||
ynh_set_var() {
|
||||
# Declare an array to define the options of this helper.
|
||||
local legacy_args=fkv
|
||||
local -A args_array=( [f]=file= [k]=key= [v]=value=)
|
||||
local file
|
||||
local key
|
||||
local value
|
||||
# Manage arguments with getopts
|
||||
ynh_handle_getopts_args "$@"
|
||||
local var_part='[ \t]*\$?(\w*\[)?[ \t]*["'"']?${key}['"'"]?[ \t]*\]?[ \t]*[:=]>?[ \t]*'
|
||||
|
||||
local crazy_value="$(grep -i -o -P '^[ \t]*\$?(\w*\[)?[ \t]*["'"']?${key}['"'"]?[ \t]*\]?[ \t]*[:=]>?[ \t]*\K.*(?=[ \t,\n;]*$)' ${file} | head -n1)"
|
||||
# local crazy_value="$(grep -i -o -P "^${var_part}\K.*(?=[ \t,\n;]*\$)" ${file} | head -n1)"
|
||||
local first_char="${crazy_value:0:1}"
|
||||
delimiter=$'\001'
|
||||
if [[ "$first_char" == '"' ]] ; then
|
||||
# \ and sed is quite complex you need 2 \\ to get one in a sed
|
||||
# So we need \\\\ to go through 2 sed
|
||||
value="$(echo "$value" | sed 's/"/\\\\"/g')"
|
||||
sed -ri s$delimiter'^('"${var_part}"'")([^"]|\\")*("[ \t;,]*)$'$delimiter'\1'"${value}"'\4'$delimiter'i' ${file}
|
||||
elif [[ "$first_char" == "'" ]] ; then
|
||||
# \ and sed is quite complex you need 2 \\ to get one in a sed
|
||||
# However double quotes implies to double \\ to
|
||||
# So we need \\\\\\\\ to go through 2 sed and 1 double quotes str
|
||||
value="$(echo "$value" | sed "s/'/\\\\\\\\'/g")"
|
||||
sed -ri "s$delimiter^(${var_part}')([^']|\\')*('"'[ \t,;]*)$'$delimiter'\1'"${value}"'\4'$delimiter'i' ${file}
|
||||
else
|
||||
if [[ "$value" == *"'"* ]] || [[ "$value" == *'"'* ]] ; then
|
||||
value='\"'"$(echo "$value" | sed 's/"/\\\\"/g')"'\"'
|
||||
fi
|
||||
sed -ri "s$delimiter^(${var_part}).*"'$'$delimiter'\1'"${value}"$delimiter'i' ${file}
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
# Render templates with Jinja2
|
||||
#
|
||||
# [internal]
|
||||
|
|
|
@ -13,8 +13,8 @@
|
|||
"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_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_invalid": "Pick a valid value for the argument '{name}': {error}",
|
||||
"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 '{field}': {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_required": "Argument '{name}' is required",
|
||||
"app_change_url_failed_nginx_reload": "Could not reload NGINX. Here is the output of 'nginx -t':\n{nginx_errors}",
|
||||
|
@ -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_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}",
|
||||
"danger": "Danger:",
|
||||
"diagnosis_basesystem_hardware": "Server hardware architecture is {virt} {arch}",
|
||||
"diagnosis_basesystem_hardware_model": "Server model is {model}",
|
||||
"diagnosis_basesystem_host": "Server is running Debian {debian_version}",
|
||||
|
@ -397,8 +398,9 @@
|
|||
"log_app_upgrade": "Upgrade the '{}' app",
|
||||
"log_app_makedefault": "Make '{}' the default 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_apply": "Apply config to the '{}' app",
|
||||
"log_app_config_show": "Show the config panel of 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_backup_create": "Create a backup archive",
|
||||
"log_backup_restore_system": "Restore system from a backup archive",
|
||||
|
|
|
@ -36,6 +36,7 @@ import urllib.parse
|
|||
import tempfile
|
||||
from collections import OrderedDict
|
||||
|
||||
from moulinette.interfaces.cli import colorize
|
||||
from moulinette import Moulinette, m18n
|
||||
from moulinette.core import MoulinetteError
|
||||
from moulinette.utils.log import getActionLogger
|
||||
|
@ -53,7 +54,9 @@ from moulinette.utils.filesystem import (
|
|||
)
|
||||
|
||||
from yunohost.service import service_status, _run_service_command
|
||||
from yunohost.utils import packages
|
||||
from yunohost.utils import packages, config
|
||||
from yunohost.utils.config import ConfigPanel, parse_args_in_yunohost_format, YunoHostArgumentFormatParser
|
||||
from yunohost.utils.i18n import _value_for_locale
|
||||
from yunohost.utils.error import YunohostError, YunohostValidationError
|
||||
from yunohost.utils.filesystem import free_space_in_directory
|
||||
from yunohost.log import is_unit_operation, OperationLogger
|
||||
|
@ -189,10 +192,7 @@ def app_info(app, full=False):
|
|||
"""
|
||||
from yunohost.permission import user_permission_list
|
||||
|
||||
if not _is_installed(app):
|
||||
raise YunohostValidationError(
|
||||
"app_not_installed", app=app, all_apps=_get_all_installed_apps_id()
|
||||
)
|
||||
_assert_is_installed(app)
|
||||
|
||||
setting_path = os.path.join(APPS_SETTING_PATH, app)
|
||||
local_manifest = _get_manifest_of_app(setting_path)
|
||||
|
@ -536,10 +536,8 @@ def app_upgrade(app=[], url=None, file=None, force=False, no_safety_backup=False
|
|||
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..
|
||||
for app in [app_ for app_ in apps if not _is_installed(app_)]:
|
||||
raise YunohostValidationError(
|
||||
"app_not_installed", app=app, all_apps=_get_all_installed_apps_id()
|
||||
)
|
||||
for app_ in apps:
|
||||
_assert_is_installed(app_)
|
||||
|
||||
if len(apps) == 0:
|
||||
raise YunohostValidationError("apps_already_up_to_date")
|
||||
|
@ -753,7 +751,6 @@ def app_upgrade(app=[], url=None, file=None, force=False, no_safety_backup=False
|
|||
for file_to_copy in [
|
||||
"actions.json",
|
||||
"actions.toml",
|
||||
"config_panel.json",
|
||||
"config_panel.toml",
|
||||
"conf",
|
||||
]:
|
||||
|
@ -973,7 +970,6 @@ def app_install(
|
|||
for file_to_copy in [
|
||||
"actions.json",
|
||||
"actions.toml",
|
||||
"config_panel.json",
|
||||
"config_panel.toml",
|
||||
"conf",
|
||||
]:
|
||||
|
@ -1761,172 +1757,79 @@ def app_action_run(operation_logger, app, action, args=None):
|
|||
return logger.success("Action successed!")
|
||||
|
||||
|
||||
# Config panel todo list:
|
||||
# * docstrings
|
||||
# * merge translations on the json once the workflow is in place
|
||||
def app_config_get(app, key='', mode='classic'):
|
||||
"""
|
||||
Display an app configuration in classic, full or export mode
|
||||
"""
|
||||
config = AppConfigPanel(app)
|
||||
return config.get(key, mode)
|
||||
|
||||
|
||||
@is_unit_operation()
|
||||
def app_config_show_panel(operation_logger, app):
|
||||
logger.warning(m18n.n("experimental_feature"))
|
||||
def app_config_set(operation_logger, app, key=None, value=None, args=None, args_file=None):
|
||||
"""
|
||||
Apply a new app configuration
|
||||
"""
|
||||
|
||||
from yunohost.hook import hook_exec
|
||||
|
||||
# this will take care of checking if the app is installed
|
||||
app_info_dict = app_info(app)
|
||||
config = AppConfigPanel(app)
|
||||
|
||||
YunoHostArgumentFormatParser.operation_logger = operation_logger
|
||||
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)
|
||||
result = config.set(key, value, args, args_file)
|
||||
if "errors" not in result:
|
||||
operation_logger.success()
|
||||
return result
|
||||
|
||||
if not config_panel or not os.path.exists(config_script):
|
||||
return {
|
||||
class AppConfigPanel(ConfigPanel):
|
||||
def __init__(self, app):
|
||||
|
||||
# Check app is installed
|
||||
_assert_is_installed(app)
|
||||
|
||||
self.app = app
|
||||
config_path = os.path.join(APPS_SETTING_PATH, app, "config_panel.toml")
|
||||
super().__init__(config_path=config_path)
|
||||
|
||||
def _load_current_values(self):
|
||||
self.values = self._call_config_script('show')
|
||||
|
||||
def _apply(self):
|
||||
self.errors = self._call_config_script('apply', self.new_values)
|
||||
|
||||
def _call_config_script(self, action, env={}):
|
||||
from yunohost.hook import hook_exec
|
||||
|
||||
# Add default config script if needed
|
||||
config_script = os.path.join(APPS_SETTING_PATH, self.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_app_config_run $1
|
||||
"""
|
||||
write_to_file(config_script, default_script)
|
||||
|
||||
# Call config script to extract current values
|
||||
logger.debug(f"Calling '{action}' action from config script")
|
||||
app_id, app_instance_nb = _parse_app_instance_name(self.app)
|
||||
env.update({
|
||||
"app_id": app_id,
|
||||
"app": app,
|
||||
"app_name": app_info_dict["name"],
|
||||
"config_panel": [],
|
||||
}
|
||||
"app": self.app,
|
||||
"app_instance_nb": str(app_instance_nb),
|
||||
})
|
||||
|
||||
env = {
|
||||
"YNH_APP_ID": app_id,
|
||||
"YNH_APP_INSTANCE_NAME": app,
|
||||
"YNH_APP_INSTANCE_NUMBER": str(app_instance_nb),
|
||||
}
|
||||
|
||||
# FIXME: this should probably be ran in a tmp workdir...
|
||||
return_code, parsed_values = hook_exec(
|
||||
config_script, args=["show"], env=env, return_format="plain_dict"
|
||||
)
|
||||
|
||||
if return_code != 0:
|
||||
raise Exception(
|
||||
"script/config show return value code: %s (considered as an error)",
|
||||
return_code,
|
||||
ret, values = hook_exec(
|
||||
config_script, args=[action], env=env
|
||||
)
|
||||
|
||||
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_name = option["name"]
|
||||
generated_name = (
|
||||
"YNH_CONFIG_%s_%s_%s" % (tab_id, section_id, option_name)
|
||||
).upper()
|
||||
option["name"] = generated_name
|
||||
logger.debug(
|
||||
" * '%s'.'%s'.'%s' -> %s",
|
||||
tab.get("name"),
|
||||
section.get("name"),
|
||||
option.get("name"),
|
||||
generated_name,
|
||||
)
|
||||
|
||||
if generated_name in parsed_values:
|
||||
# code is not adapted for that so we have to mock expected format :/
|
||||
if option.get("type") == "boolean":
|
||||
if parsed_values[generated_name].lower() in ("true", "1", "y"):
|
||||
option["default"] = parsed_values[generated_name]
|
||||
else:
|
||||
del option["default"]
|
||||
else:
|
||||
option["default"] = parsed_values[generated_name]
|
||||
|
||||
args_dict = _parse_args_in_yunohost_format(
|
||||
{option["name"]: parsed_values[generated_name]}, [option]
|
||||
)
|
||||
option["default"] = args_dict[option["name"]][0]
|
||||
else:
|
||||
logger.debug(
|
||||
"Variable '%s' is not declared by config script, using default",
|
||||
generated_name,
|
||||
)
|
||||
# do nothing, we'll use the default if present
|
||||
|
||||
return {
|
||||
"app_id": app_id,
|
||||
"app": app,
|
||||
"app_name": app_info_dict["name"],
|
||||
"config_panel": config_panel,
|
||||
"logs": operation_logger.success(),
|
||||
}
|
||||
|
||||
|
||||
@is_unit_operation()
|
||||
def app_config_apply(operation_logger, app, args):
|
||||
logger.warning(m18n.n("experimental_feature"))
|
||||
|
||||
from yunohost.hook import hook_exec
|
||||
|
||||
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()
|
||||
app_id, app_instance_nb = _parse_app_instance_name(app)
|
||||
env = {
|
||||
"YNH_APP_ID": app_id,
|
||||
"YNH_APP_INSTANCE_NAME": app,
|
||||
"YNH_APP_INSTANCE_NUMBER": str(app_instance_nb),
|
||||
}
|
||||
args = dict(urllib.parse.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_name = option["name"]
|
||||
generated_name = (
|
||||
"YNH_CONFIG_%s_%s_%s" % (tab_id, section_id, option_name)
|
||||
).upper()
|
||||
|
||||
if generated_name in args:
|
||||
logger.debug(
|
||||
"include into env %s=%s", generated_name, args[generated_name]
|
||||
)
|
||||
env[generated_name] = args[generated_name]
|
||||
else:
|
||||
logger.debug("no value for key id %s", generated_name)
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
# FIXME: this should probably be ran in a tmp workdir...
|
||||
return_code = hook_exec(
|
||||
config_script,
|
||||
args=["apply"],
|
||||
env=env,
|
||||
)[0]
|
||||
|
||||
if return_code != 0:
|
||||
msg = (
|
||||
"'script/config apply' return value code: %s (considered as an error)"
|
||||
% return_code
|
||||
)
|
||||
operation_logger.error(msg)
|
||||
raise Exception(msg)
|
||||
|
||||
logger.success("Config updated as expected")
|
||||
return {
|
||||
"app": app,
|
||||
"logs": operation_logger.success(),
|
||||
}
|
||||
|
||||
if ret != 0:
|
||||
if action == 'show':
|
||||
raise YunohostError("app_config_unable_to_read_values")
|
||||
else:
|
||||
raise YunohostError("app_config_unable_to_apply_values_correctly")
|
||||
return values
|
||||
|
||||
def _get_all_installed_apps_id():
|
||||
"""
|
||||
|
@ -2029,145 +1932,6 @@ def _get_app_actions(app_id):
|
|||
return None
|
||||
|
||||
|
||||
def _get_app_config_panel(app_id):
|
||||
"Get app config panel stored in json or in toml"
|
||||
config_panel_toml_path = os.path.join(
|
||||
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
|
||||
# this toml extract:
|
||||
#
|
||||
# version = "0.1"
|
||||
# name = "Unattended-upgrades configuration panel"
|
||||
#
|
||||
# [main]
|
||||
# name = "Unattended-upgrades configuration"
|
||||
#
|
||||
# [main.unattended_configuration]
|
||||
# name = "50unattended-upgrades configuration file"
|
||||
#
|
||||
# [main.unattended_configuration.upgrade_level]
|
||||
# name = "Choose the sources of packages to automatically upgrade."
|
||||
# default = "Security only"
|
||||
# type = "text"
|
||||
# help = "We can't use a choices field for now. In the meantime please choose between one of this values:<br>Security only, Security and updates."
|
||||
# # choices = ["Security only", "Security and updates"]
|
||||
|
||||
# [main.unattended_configuration.ynh_update]
|
||||
# name = "Would you like to update YunoHost packages automatically ?"
|
||||
# type = "bool"
|
||||
# default = true
|
||||
#
|
||||
# will be parsed into this:
|
||||
#
|
||||
# OrderedDict([(u'version', u'0.1'),
|
||||
# (u'name', u'Unattended-upgrades configuration panel'),
|
||||
# (u'main',
|
||||
# OrderedDict([(u'name', u'Unattended-upgrades configuration'),
|
||||
# (u'unattended_configuration',
|
||||
# OrderedDict([(u'name',
|
||||
# u'50unattended-upgrades configuration file'),
|
||||
# (u'upgrade_level',
|
||||
# OrderedDict([(u'name',
|
||||
# u'Choose the sources of packages to automatically upgrade.'),
|
||||
# (u'default',
|
||||
# u'Security only'),
|
||||
# (u'type', u'text'),
|
||||
# (u'help',
|
||||
# u"We can't use a choices field for now. In the meantime please choose between one of this values:<br>Security only, Security and updates.")])),
|
||||
# (u'ynh_update',
|
||||
# OrderedDict([(u'name',
|
||||
# u'Would you like to update YunoHost packages automatically ?'),
|
||||
# (u'type', u'bool'),
|
||||
# (u'default', True)])),
|
||||
#
|
||||
# and needs to be converted into this:
|
||||
#
|
||||
# {u'name': u'Unattended-upgrades configuration panel',
|
||||
# u'panel': [{u'id': u'main',
|
||||
# u'name': u'Unattended-upgrades configuration',
|
||||
# u'sections': [{u'id': u'unattended_configuration',
|
||||
# u'name': u'50unattended-upgrades configuration file',
|
||||
# u'options': [{u'//': u'"choices" : ["Security only", "Security and updates"]',
|
||||
# u'default': u'Security only',
|
||||
# u'help': u"We can't use a choices field for now. In the meantime please choose between one of this values:<br>Security only, Security and updates.",
|
||||
# u'id': u'upgrade_level',
|
||||
# u'name': u'Choose the sources of packages to automatically upgrade.',
|
||||
# u'type': u'text'},
|
||||
# {u'default': True,
|
||||
# u'id': u'ynh_update',
|
||||
# u'name': u'Would you like to update YunoHost packages automatically ?',
|
||||
# u'type': u'bool'},
|
||||
|
||||
if os.path.exists(config_panel_toml_path):
|
||||
toml_config_panel = toml.load(
|
||||
open(config_panel_toml_path, "r"), _dict=OrderedDict
|
||||
)
|
||||
|
||||
# transform toml format into json format
|
||||
config_panel = {
|
||||
"name": toml_config_panel["name"],
|
||||
"version": toml_config_panel["version"],
|
||||
"panel": [],
|
||||
}
|
||||
|
||||
panels = [
|
||||
key_value
|
||||
for key_value in toml_config_panel.items()
|
||||
if key_value[0] not in ("name", "version")
|
||||
and isinstance(key_value[1], OrderedDict)
|
||||
]
|
||||
|
||||
for key, value in panels:
|
||||
panel = {
|
||||
"id": key,
|
||||
"name": value["name"],
|
||||
"sections": [],
|
||||
}
|
||||
|
||||
sections = [
|
||||
k_v1
|
||||
for k_v1 in value.items()
|
||||
if k_v1[0] not in ("name",) and isinstance(k_v1[1], OrderedDict)
|
||||
]
|
||||
|
||||
for section_key, section_value in sections:
|
||||
section = {
|
||||
"id": section_key,
|
||||
"name": section_value["name"],
|
||||
"options": [],
|
||||
}
|
||||
|
||||
options = [
|
||||
k_v
|
||||
for k_v in section_value.items()
|
||||
if k_v[0] not in ("name",) and isinstance(k_v[1], OrderedDict)
|
||||
]
|
||||
|
||||
for option_key, option_value in options:
|
||||
option = dict(option_value)
|
||||
option["name"] = option_key
|
||||
option["ask"] = {"en": option["ask"]}
|
||||
if "help" in option:
|
||||
option["help"] = {"en": option["help"]}
|
||||
section["options"].append(option)
|
||||
|
||||
panel["sections"].append(section)
|
||||
|
||||
config_panel["panel"].append(panel)
|
||||
|
||||
return config_panel
|
||||
|
||||
elif os.path.exists(config_panel_json_path):
|
||||
return json.load(open(config_panel_json_path))
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _get_app_settings(app_id):
|
||||
"""
|
||||
Get settings of an installed app
|
||||
|
@ -2608,34 +2372,17 @@ def _is_installed(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():
|
||||
return os.listdir(APPS_SETTING_PATH)
|
||||
|
||||
|
||||
def _value_for_locale(values):
|
||||
"""
|
||||
Return proper value for current locale
|
||||
|
||||
Keyword arguments:
|
||||
values -- A dict of values associated to their locale
|
||||
|
||||
Returns:
|
||||
An utf-8 encoded string
|
||||
|
||||
"""
|
||||
if not isinstance(values, dict):
|
||||
return values
|
||||
|
||||
for lang in [m18n.locale, m18n.default_locale]:
|
||||
try:
|
||||
return values[lang]
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
# Fallback to first value
|
||||
return list(values.values())[0]
|
||||
|
||||
|
||||
def _check_manifest_requirements(manifest, app_instance_name):
|
||||
"""Check if required packages are met from the manifest"""
|
||||
|
||||
|
@ -2682,7 +2429,7 @@ def _parse_args_from_manifest(manifest, action, args={}):
|
|||
return OrderedDict()
|
||||
|
||||
action_args = manifest["arguments"][action]
|
||||
return _parse_args_in_yunohost_format(args, action_args)
|
||||
return parse_args_in_yunohost_format(args, action_args)
|
||||
|
||||
|
||||
def _parse_args_for_action(action, args={}):
|
||||
|
@ -2706,299 +2453,7 @@ def _parse_args_for_action(action, args={}):
|
|||
|
||||
action_args = action["arguments"]
|
||||
|
||||
return _parse_args_in_yunohost_format(args, action_args)
|
||||
|
||||
|
||||
class Question:
|
||||
"empty class to store questions information"
|
||||
|
||||
|
||||
class YunoHostArgumentFormatParser(object):
|
||||
hide_user_input_in_prompt = False
|
||||
|
||||
def parse_question(self, question, user_answers):
|
||||
parsed_question = Question()
|
||||
|
||||
parsed_question.name = question["name"]
|
||||
parsed_question.default = question.get("default", None)
|
||||
parsed_question.choices = question.get("choices", [])
|
||||
parsed_question.optional = question.get("optional", False)
|
||||
parsed_question.ask = question.get("ask")
|
||||
parsed_question.value = user_answers.get(parsed_question.name)
|
||||
|
||||
if parsed_question.ask is None:
|
||||
parsed_question.ask = "Enter value for '%s':" % parsed_question.name
|
||||
|
||||
# Empty value is parsed as empty string
|
||||
if parsed_question.default == "":
|
||||
parsed_question.default = None
|
||||
|
||||
return parsed_question
|
||||
|
||||
def parse(self, question, user_answers):
|
||||
question = self.parse_question(question, user_answers)
|
||||
|
||||
if question.value is None:
|
||||
text_for_user_input_in_cli = self._format_text_for_user_input_in_cli(
|
||||
question
|
||||
)
|
||||
|
||||
try:
|
||||
question.value = Moulinette.prompt(
|
||||
text_for_user_input_in_cli, self.hide_user_input_in_prompt
|
||||
)
|
||||
except NotImplementedError:
|
||||
question.value = None
|
||||
|
||||
# we don't have an answer, check optional and default_value
|
||||
if question.value is None or question.value == "":
|
||||
if not question.optional and question.default is None:
|
||||
raise YunohostValidationError(
|
||||
"app_argument_required", name=question.name
|
||||
)
|
||||
else:
|
||||
question.value = (
|
||||
getattr(self, "default_value", None)
|
||||
if question.default is None
|
||||
else question.default
|
||||
)
|
||||
|
||||
# we have an answer, do some post checks
|
||||
if question.value is not None:
|
||||
if question.choices and question.value not in question.choices:
|
||||
self._raise_invalid_answer(question)
|
||||
|
||||
# this is done to enforce a certain formating like for boolean
|
||||
# by default it doesn't do anything
|
||||
question.value = self._post_parse_value(question)
|
||||
|
||||
return (question.value, self.argument_type)
|
||||
|
||||
def _raise_invalid_answer(self, question):
|
||||
raise YunohostValidationError(
|
||||
"app_argument_choice_invalid",
|
||||
name=question.name,
|
||||
choices=", ".join(question.choices),
|
||||
)
|
||||
|
||||
def _format_text_for_user_input_in_cli(self, question):
|
||||
text_for_user_input_in_cli = _value_for_locale(question.ask)
|
||||
|
||||
if question.choices:
|
||||
text_for_user_input_in_cli += " [{0}]".format(" | ".join(question.choices))
|
||||
|
||||
if question.default is not None:
|
||||
text_for_user_input_in_cli += " (default: {0})".format(question.default)
|
||||
|
||||
return text_for_user_input_in_cli
|
||||
|
||||
def _post_parse_value(self, question):
|
||||
return question.value
|
||||
|
||||
|
||||
class StringArgumentParser(YunoHostArgumentFormatParser):
|
||||
argument_type = "string"
|
||||
default_value = ""
|
||||
|
||||
|
||||
class PasswordArgumentParser(YunoHostArgumentFormatParser):
|
||||
hide_user_input_in_prompt = True
|
||||
argument_type = "password"
|
||||
default_value = ""
|
||||
forbidden_chars = "{}"
|
||||
|
||||
def parse_question(self, question, user_answers):
|
||||
question = super(PasswordArgumentParser, self).parse_question(
|
||||
question, user_answers
|
||||
)
|
||||
|
||||
if question.default is not None:
|
||||
raise YunohostValidationError(
|
||||
"app_argument_password_no_default", name=question.name
|
||||
)
|
||||
|
||||
return question
|
||||
|
||||
def _post_parse_value(self, question):
|
||||
if any(char in question.value for char in self.forbidden_chars):
|
||||
raise YunohostValidationError(
|
||||
"pattern_password_app", forbidden_chars=self.forbidden_chars
|
||||
)
|
||||
|
||||
# If it's an optional argument the value should be empty or strong enough
|
||||
if not question.optional or question.value:
|
||||
from yunohost.utils.password import assert_password_is_strong_enough
|
||||
|
||||
assert_password_is_strong_enough("user", question.value)
|
||||
|
||||
return super(PasswordArgumentParser, self)._post_parse_value(question)
|
||||
|
||||
|
||||
class PathArgumentParser(YunoHostArgumentFormatParser):
|
||||
argument_type = "path"
|
||||
default_value = ""
|
||||
|
||||
|
||||
class BooleanArgumentParser(YunoHostArgumentFormatParser):
|
||||
argument_type = "boolean"
|
||||
default_value = False
|
||||
|
||||
def parse_question(self, question, user_answers):
|
||||
question = super(BooleanArgumentParser, self).parse_question(
|
||||
question, user_answers
|
||||
)
|
||||
|
||||
if question.default is None:
|
||||
question.default = False
|
||||
|
||||
return question
|
||||
|
||||
def _format_text_for_user_input_in_cli(self, question):
|
||||
text_for_user_input_in_cli = _value_for_locale(question.ask)
|
||||
|
||||
text_for_user_input_in_cli += " [yes | no]"
|
||||
|
||||
if question.default is not None:
|
||||
formatted_default = "yes" if question.default else "no"
|
||||
text_for_user_input_in_cli += " (default: {0})".format(formatted_default)
|
||||
|
||||
return text_for_user_input_in_cli
|
||||
|
||||
def _post_parse_value(self, question):
|
||||
if isinstance(question.value, bool):
|
||||
return 1 if question.value else 0
|
||||
|
||||
if str(question.value).lower() in ["1", "yes", "y", "true"]:
|
||||
return 1
|
||||
|
||||
if str(question.value).lower() in ["0", "no", "n", "false"]:
|
||||
return 0
|
||||
|
||||
raise YunohostValidationError(
|
||||
"app_argument_choice_invalid",
|
||||
name=question.name,
|
||||
choices="yes, no, y, n, 1, 0",
|
||||
)
|
||||
|
||||
|
||||
class DomainArgumentParser(YunoHostArgumentFormatParser):
|
||||
argument_type = "domain"
|
||||
|
||||
def parse_question(self, question, user_answers):
|
||||
from yunohost.domain import domain_list, _get_maindomain
|
||||
|
||||
question = super(DomainArgumentParser, self).parse_question(
|
||||
question, user_answers
|
||||
)
|
||||
|
||||
if question.default is None:
|
||||
question.default = _get_maindomain()
|
||||
|
||||
question.choices = domain_list()["domains"]
|
||||
|
||||
return question
|
||||
|
||||
def _raise_invalid_answer(self, question):
|
||||
raise YunohostValidationError(
|
||||
"app_argument_invalid", name=question.name, error=m18n.n("domain_unknown")
|
||||
)
|
||||
|
||||
|
||||
class UserArgumentParser(YunoHostArgumentFormatParser):
|
||||
argument_type = "user"
|
||||
|
||||
def parse_question(self, question, user_answers):
|
||||
from yunohost.user import user_list, user_info
|
||||
from yunohost.domain import _get_maindomain
|
||||
|
||||
question = super(UserArgumentParser, self).parse_question(
|
||||
question, user_answers
|
||||
)
|
||||
question.choices = user_list()["users"]
|
||||
if question.default is None:
|
||||
root_mail = "root@%s" % _get_maindomain()
|
||||
for user in question.choices.keys():
|
||||
if root_mail in user_info(user).get("mail-aliases", []):
|
||||
question.default = user
|
||||
break
|
||||
|
||||
return question
|
||||
|
||||
def _raise_invalid_answer(self, question):
|
||||
raise YunohostValidationError(
|
||||
"app_argument_invalid",
|
||||
name=question.name,
|
||||
error=m18n.n("user_unknown", user=question.value),
|
||||
)
|
||||
|
||||
|
||||
class NumberArgumentParser(YunoHostArgumentFormatParser):
|
||||
argument_type = "number"
|
||||
default_value = ""
|
||||
|
||||
def parse_question(self, question, user_answers):
|
||||
question = super(NumberArgumentParser, self).parse_question(
|
||||
question, user_answers
|
||||
)
|
||||
|
||||
if question.default is None:
|
||||
question.default = 0
|
||||
|
||||
return question
|
||||
|
||||
def _post_parse_value(self, question):
|
||||
if isinstance(question.value, int):
|
||||
return super(NumberArgumentParser, self)._post_parse_value(question)
|
||||
|
||||
if isinstance(question.value, str) and question.value.isdigit():
|
||||
return int(question.value)
|
||||
|
||||
raise YunohostValidationError(
|
||||
"app_argument_invalid", name=question.name, error=m18n.n("invalid_number")
|
||||
)
|
||||
|
||||
|
||||
class DisplayTextArgumentParser(YunoHostArgumentFormatParser):
|
||||
argument_type = "display_text"
|
||||
|
||||
def parse(self, question, user_answers):
|
||||
print(question["ask"])
|
||||
|
||||
|
||||
ARGUMENTS_TYPE_PARSERS = {
|
||||
"string": StringArgumentParser,
|
||||
"password": PasswordArgumentParser,
|
||||
"path": PathArgumentParser,
|
||||
"boolean": BooleanArgumentParser,
|
||||
"domain": DomainArgumentParser,
|
||||
"user": UserArgumentParser,
|
||||
"number": NumberArgumentParser,
|
||||
"display_text": DisplayTextArgumentParser,
|
||||
}
|
||||
|
||||
|
||||
|
||||
def _parse_args_in_yunohost_format(user_answers, argument_questions):
|
||||
"""Parse arguments store in either manifest.json or actions.json or from a
|
||||
config panel against the user answers when they are present.
|
||||
|
||||
Keyword arguments:
|
||||
user_answers -- a dictionnary of arguments from the user (generally
|
||||
empty in CLI, filed from the admin interface)
|
||||
argument_questions -- the arguments description store in yunohost
|
||||
format from actions.json/toml, manifest.json/toml
|
||||
or config_panel.json/toml
|
||||
"""
|
||||
parsed_answers_dict = OrderedDict()
|
||||
|
||||
for question in argument_questions:
|
||||
parser = ARGUMENTS_TYPE_PARSERS[question.get("type", "string")]()
|
||||
|
||||
answer = parser.parse(question=question, user_answers=user_answers)
|
||||
if answer is not None:
|
||||
parsed_answers_dict[question["name"]] = answer
|
||||
|
||||
return parsed_answers_dict
|
||||
return parse_args_in_yunohost_format(args, action_args)
|
||||
|
||||
|
||||
def _validate_and_normalize_webpath(args_dict, app_folder):
|
||||
|
|
|
@ -34,7 +34,7 @@ from importlib import import_module
|
|||
from moulinette import m18n, Moulinette
|
||||
from yunohost.utils.error import YunohostError, YunohostValidationError
|
||||
from moulinette.utils import log
|
||||
from moulinette.utils.filesystem import read_json
|
||||
from moulinette.utils.filesystem import read_yaml
|
||||
|
||||
HOOK_FOLDER = "/usr/share/yunohost/hooks/"
|
||||
CUSTOM_HOOK_FOLDER = "/etc/yunohost/hooks.d/"
|
||||
|
@ -326,7 +326,7 @@ def hook_exec(
|
|||
chdir=None,
|
||||
env=None,
|
||||
user="root",
|
||||
return_format="json",
|
||||
return_format="yaml",
|
||||
):
|
||||
"""
|
||||
Execute hook from a file with arguments
|
||||
|
@ -447,10 +447,10 @@ def _hook_exec_bash(path, args, chdir, env, user, return_format, loggers):
|
|||
raw_content = f.read()
|
||||
returncontent = {}
|
||||
|
||||
if return_format == "json":
|
||||
if return_format == "yaml":
|
||||
if raw_content != "":
|
||||
try:
|
||||
returncontent = read_json(stdreturn)
|
||||
returncontent = read_yaml(stdreturn)
|
||||
except Exception as e:
|
||||
raise YunohostError(
|
||||
"hook_json_return_error",
|
||||
|
|
814
src/yunohost/utils/config.py
Normal file
814
src/yunohost/utils/config.py
Normal file
|
@ -0,0 +1,814 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
""" License
|
||||
|
||||
Copyright (C) 2018 YUNOHOST.ORG
|
||||
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program; if not, see http://www.gnu.org/licenses
|
||||
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import toml
|
||||
import urllib.parse
|
||||
import tempfile
|
||||
from collections import OrderedDict
|
||||
|
||||
from moulinette.interfaces.cli import colorize
|
||||
from moulinette import Moulinette, m18n
|
||||
from moulinette.utils.log import getActionLogger
|
||||
from moulinette.utils.process import check_output
|
||||
from moulinette.utils.filesystem import (
|
||||
read_toml,
|
||||
read_yaml,
|
||||
write_to_yaml,
|
||||
mkdir,
|
||||
)
|
||||
|
||||
from yunohost.service import _get_services
|
||||
from yunohost.service import _run_service_command, _get_services
|
||||
from yunohost.utils.i18n import _value_for_locale
|
||||
from yunohost.utils.error import YunohostError, YunohostValidationError
|
||||
|
||||
logger = getActionLogger("yunohost.config")
|
||||
CONFIG_PANEL_VERSION_SUPPORTED = 1.0
|
||||
|
||||
class ConfigPanel:
|
||||
|
||||
def __init__(self, config_path, save_path=None):
|
||||
self.config_path = config_path
|
||||
self.save_path = save_path
|
||||
self.config = {}
|
||||
self.values = {}
|
||||
self.new_values = {}
|
||||
|
||||
def get(self, key='', mode='classic'):
|
||||
self.filter_key = key or ''
|
||||
|
||||
# Read config panel toml
|
||||
self._get_config_panel()
|
||||
|
||||
if not self.config:
|
||||
raise YunohostError("config_no_panel")
|
||||
|
||||
# Read or get values and hydrate the config
|
||||
self._load_current_values()
|
||||
self._hydrate()
|
||||
|
||||
# Format result in full mode
|
||||
if mode == 'full':
|
||||
return self.config
|
||||
|
||||
# In 'classic' mode, we display the current value if key refer to an option
|
||||
if self.filter_key.count('.') == 2 and mode == 'classic':
|
||||
option = self.filter_key.split('.')[-1]
|
||||
return self.values.get(option, None)
|
||||
|
||||
# Format result in 'classic' or 'export' mode
|
||||
logger.debug(f"Formating result in '{mode}' mode")
|
||||
result = {}
|
||||
for panel, section, option in self._iterate():
|
||||
key = f"{panel['id']}.{section['id']}.{option['id']}"
|
||||
if mode == 'export':
|
||||
result[option['id']] = option.get('current_value')
|
||||
else:
|
||||
result[key] = { 'ask': _value_for_locale(option['ask']) }
|
||||
if 'current_value' in option:
|
||||
result[key]['value'] = option['current_value']
|
||||
|
||||
return result
|
||||
|
||||
def set(self, key=None, value=None, args=None, args_file=None):
|
||||
self.filter_key = key or ''
|
||||
|
||||
# Read config panel toml
|
||||
self._get_config_panel()
|
||||
|
||||
if not self.config:
|
||||
raise YunohostError("config_no_panel")
|
||||
|
||||
if (args is not None or args_file is not None) and value is not None:
|
||||
raise YunohostError("config_args_value")
|
||||
|
||||
if self.filter_key.count('.') != 2 and not value is None:
|
||||
raise YunohostError("config_set_value_on_section")
|
||||
|
||||
# Import and parse pre-answered options
|
||||
logger.debug("Import and parse pre-answered options")
|
||||
args = urllib.parse.parse_qs(args or '', keep_blank_values=True)
|
||||
self.args = { key: ','.join(value_) for key, value_ in args.items() }
|
||||
|
||||
if args_file:
|
||||
# Import YAML / JSON file but keep --args values
|
||||
self.args = { **read_yaml(args_file), **self.args }
|
||||
|
||||
if value is not None:
|
||||
self.args = {self.filter_key.split('.')[-1]: value}
|
||||
|
||||
# Read or get values and hydrate the config
|
||||
self._load_current_values()
|
||||
self._hydrate()
|
||||
|
||||
try:
|
||||
self._ask()
|
||||
self._apply()
|
||||
|
||||
# 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_failed", 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_failed", error=error))
|
||||
raise
|
||||
finally:
|
||||
# Delete files uploaded from API
|
||||
FileArgumentParser.clean_upload_dirs()
|
||||
|
||||
if self.errors:
|
||||
return {
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
self._reload_services()
|
||||
|
||||
logger.success("Config updated as expected")
|
||||
return {}
|
||||
|
||||
def _get_toml(self):
|
||||
return read_toml(self.config_path)
|
||||
|
||||
|
||||
def _get_config_panel(self):
|
||||
# Split filter_key
|
||||
filter_key = dict(enumerate(self.filter_key.split('.')))
|
||||
if len(filter_key) > 3:
|
||||
raise YunohostError("config_too_much_sub_keys")
|
||||
|
||||
if not os.path.exists(self.config_path):
|
||||
return None
|
||||
toml_config_panel = self._get_toml()
|
||||
|
||||
# Check TOML config panel is in a supported version
|
||||
if float(toml_config_panel["version"]) < CONFIG_PANEL_VERSION_SUPPORTED:
|
||||
raise YunohostError(
|
||||
"config_too_old_version", version=toml_config_panel["version"]
|
||||
)
|
||||
|
||||
# Transform toml format into internal format
|
||||
defaults = {
|
||||
'toml': {
|
||||
'version': 1.0
|
||||
},
|
||||
'panels': {
|
||||
'name': '',
|
||||
'services': [],
|
||||
'actions': {'apply': {'en': 'Apply'}}
|
||||
}, # help
|
||||
'sections': {
|
||||
'name': '',
|
||||
'services': [],
|
||||
'optional': True
|
||||
}, # visibleIf help
|
||||
'options': {}
|
||||
# ask type source help helpLink example style icon placeholder visibleIf
|
||||
# optional choices pattern limit min max step accept redact
|
||||
}
|
||||
|
||||
def convert(toml_node, node_type):
|
||||
"""Convert TOML in internal format ('full' mode used by webadmin)
|
||||
Here are some properties of 1.0 config panel in toml:
|
||||
- node properties and node children are mixed,
|
||||
- text are in english only
|
||||
- some properties have default values
|
||||
This function detects all children nodes and put them in a list
|
||||
"""
|
||||
# Prefill the node default keys if needed
|
||||
default = defaults[node_type]
|
||||
node = {key: toml_node.get(key, value) for key, value in default.items()}
|
||||
|
||||
# Define the filter_key part to use and the children type
|
||||
i = list(defaults).index(node_type)
|
||||
search_key = filter_key.get(i)
|
||||
subnode_type = list(defaults)[i+1] if node_type != 'options' else None
|
||||
|
||||
for key, value in toml_node.items():
|
||||
# Key/value are a child node
|
||||
if isinstance(value, OrderedDict) and key not in default and subnode_type:
|
||||
# We exclude all nodes not referenced by the filter_key
|
||||
if search_key and key != search_key:
|
||||
continue
|
||||
subnode = convert(value, subnode_type)
|
||||
subnode['id'] = key
|
||||
if node_type == 'sections':
|
||||
subnode['name'] = key # legacy
|
||||
subnode.setdefault('optional', toml_node.get('optional', True))
|
||||
node.setdefault(subnode_type, []).append(subnode)
|
||||
# Key/value are a property
|
||||
else:
|
||||
# Todo search all i18n keys
|
||||
node[key] = value if key not in ['ask', 'help', 'name'] else { 'en': value }
|
||||
return node
|
||||
|
||||
self.config = convert(toml_config_panel, 'toml')
|
||||
|
||||
try:
|
||||
self.config['panels'][0]['sections'][0]['options'][0]
|
||||
except (KeyError, IndexError):
|
||||
raise YunohostError(
|
||||
"config_empty_or_bad_filter_key", filter_key=self.filter_key
|
||||
)
|
||||
|
||||
return self.config
|
||||
|
||||
def _hydrate(self):
|
||||
# Hydrating config panel with current value
|
||||
logger.debug("Hydrating config with current values")
|
||||
for _, _, option in self._iterate():
|
||||
if option['name'] not in self.values:
|
||||
continue
|
||||
value = self.values[option['name']]
|
||||
# In general, the value is just a simple value.
|
||||
# Sometimes it could be a dict used to overwrite the option itself
|
||||
value = value if isinstance(value, dict) else {'current_value': value }
|
||||
option.update(value)
|
||||
|
||||
return self.values
|
||||
|
||||
def _ask(self):
|
||||
logger.debug("Ask unanswered question and prevalidate data")
|
||||
def display_header(message):
|
||||
""" CLI panel/section header display
|
||||
"""
|
||||
if Moulinette.interface.type == 'cli' and self.filter_key.count('.') < 2:
|
||||
Moulinette.display(colorize(message, 'purple'))
|
||||
for panel, section, obj in self._iterate(['panel', 'section']):
|
||||
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'])
|
||||
display_header(f"\n# {name}")
|
||||
|
||||
# Check and ask unanswered questions
|
||||
self.new_values.update(parse_args_in_yunohost_format(
|
||||
self.args, section['options']
|
||||
))
|
||||
self.new_values = {key: str(value[0]) for key, value in self.new_values.items() if not value[0] is None}
|
||||
|
||||
def _apply(self):
|
||||
logger.info("Running config script...")
|
||||
dir_path = os.path.dirname(os.path.realpath(self.save_path))
|
||||
if not os.path.exists(dir_path):
|
||||
mkdir(dir_path, mode=0o700)
|
||||
# Save the settings to the .yaml file
|
||||
write_to_yaml(self.save_path, self.new_values)
|
||||
|
||||
|
||||
def _reload_services(self):
|
||||
logger.info("Reloading services...")
|
||||
services_to_reload = set()
|
||||
for panel, section, obj in self._iterate(['panel', 'section', 'option']):
|
||||
services_to_reload |= set(obj.get('services', []))
|
||||
|
||||
services_to_reload = list(services_to_reload)
|
||||
services_to_reload.sort(key = 'nginx'.__eq__)
|
||||
for service in services_to_reload:
|
||||
if '__APP__':
|
||||
service = service.replace('__APP__', self.app)
|
||||
logger.debug(f"Reloading {service}")
|
||||
if not _run_service_command('reload-or-restart', service):
|
||||
services = _get_services()
|
||||
test_conf = services[service].get('test_conf', 'true')
|
||||
errors = check_output(f"{test_conf}; exit 0") if test_conf else ''
|
||||
raise YunohostError(
|
||||
"config_failed_service_reload",
|
||||
service=service, errors=errors
|
||||
)
|
||||
|
||||
def _iterate(self, trigger=['option']):
|
||||
for panel in self.config.get("panels", []):
|
||||
if 'panel' in trigger:
|
||||
yield (panel, None, panel)
|
||||
for section in panel.get("sections", []):
|
||||
if 'section' in trigger:
|
||||
yield (panel, section, section)
|
||||
if 'option' in trigger:
|
||||
for option in section.get("options", []):
|
||||
yield (panel, section, option)
|
||||
|
||||
|
||||
class Question:
|
||||
"empty class to store questions information"
|
||||
|
||||
|
||||
class YunoHostArgumentFormatParser(object):
|
||||
hide_user_input_in_prompt = False
|
||||
operation_logger = None
|
||||
|
||||
def parse_question(self, question, user_answers):
|
||||
parsed_question = Question()
|
||||
|
||||
parsed_question.name = question["name"]
|
||||
parsed_question.type = question.get("type", 'string')
|
||||
parsed_question.default = question.get("default", None)
|
||||
parsed_question.current_value = question.get("current_value")
|
||||
parsed_question.optional = question.get("optional", False)
|
||||
parsed_question.choices = question.get("choices", [])
|
||||
parsed_question.pattern = question.get("pattern")
|
||||
parsed_question.ask = question.get("ask", {'en': f"{parsed_question.name}"})
|
||||
parsed_question.help = question.get("help")
|
||||
parsed_question.helpLink = question.get("helpLink")
|
||||
parsed_question.value = user_answers.get(parsed_question.name)
|
||||
parsed_question.redact = question.get('redact', False)
|
||||
|
||||
# Empty value is parsed as empty string
|
||||
if parsed_question.default == "":
|
||||
parsed_question.default = None
|
||||
|
||||
return parsed_question
|
||||
|
||||
def parse(self, question, user_answers):
|
||||
question = self.parse_question(question, user_answers)
|
||||
|
||||
while True:
|
||||
# Display question if no value filled or if it's a readonly message
|
||||
if Moulinette.interface.type== 'cli':
|
||||
text_for_user_input_in_cli = self._format_text_for_user_input_in_cli(
|
||||
question
|
||||
)
|
||||
if getattr(self, "readonly", False):
|
||||
Moulinette.display(text_for_user_input_in_cli)
|
||||
|
||||
elif question.value is None:
|
||||
prefill = ""
|
||||
if question.current_value is not None:
|
||||
prefill = question.current_value
|
||||
elif question.default is not None:
|
||||
prefill = question.default
|
||||
question.value = Moulinette.prompt(
|
||||
message=text_for_user_input_in_cli,
|
||||
is_password=self.hide_user_input_in_prompt,
|
||||
confirm=self.hide_user_input_in_prompt,
|
||||
prefill=prefill,
|
||||
is_multiline=(question.type == "text")
|
||||
)
|
||||
|
||||
|
||||
# Apply default value
|
||||
if question.value in [None, ""] and question.default is not None:
|
||||
question.value = (
|
||||
getattr(self, "default_value", None)
|
||||
if question.default is None
|
||||
else question.default
|
||||
)
|
||||
|
||||
# Prevalidation
|
||||
try:
|
||||
self._prevalidate(question)
|
||||
except YunohostValidationError as e:
|
||||
if Moulinette.interface.type== 'api':
|
||||
raise
|
||||
Moulinette.display(str(e), 'error')
|
||||
question.value = None
|
||||
continue
|
||||
break
|
||||
# this is done to enforce a certain formating like for boolean
|
||||
# by default it doesn't do anything
|
||||
question.value = self._post_parse_value(question)
|
||||
|
||||
return (question.value, self.argument_type)
|
||||
|
||||
def _prevalidate(self, question):
|
||||
if question.value in [None, ""] and not question.optional:
|
||||
raise YunohostValidationError(
|
||||
"app_argument_required", name=question.name
|
||||
)
|
||||
|
||||
# we have an answer, do some post checks
|
||||
if question.value is not None:
|
||||
if question.choices and question.value not in question.choices:
|
||||
self._raise_invalid_answer(question)
|
||||
if question.pattern and not re.match(question.pattern['regexp'], str(question.value)):
|
||||
raise YunohostValidationError(
|
||||
question.pattern['error'],
|
||||
name=question.name,
|
||||
value=question.value,
|
||||
)
|
||||
|
||||
def _raise_invalid_answer(self, question):
|
||||
raise YunohostValidationError(
|
||||
"app_argument_choice_invalid",
|
||||
name=question.name,
|
||||
value=question.value,
|
||||
choices=", ".join(question.choices),
|
||||
)
|
||||
|
||||
def _format_text_for_user_input_in_cli(self, question):
|
||||
text_for_user_input_in_cli = _value_for_locale(question.ask)
|
||||
|
||||
if question.choices:
|
||||
text_for_user_input_in_cli += " [{0}]".format(" | ".join(question.choices))
|
||||
|
||||
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 += _value_for_locale(question.help)
|
||||
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
|
||||
|
||||
def _post_parse_value(self, question):
|
||||
if not question.redact:
|
||||
return question.value
|
||||
|
||||
# Tell the operation_logger to redact all password-type / secret args
|
||||
# Also redact the % escaped version of the password that might appear in
|
||||
# the 'args' section of metadata (relevant for password with non-alphanumeric char)
|
||||
data_to_redact = []
|
||||
if question.value and isinstance(question.value, str):
|
||||
data_to_redact.append(question.value)
|
||||
if question.current_value and isinstance(question.current_value, str):
|
||||
data_to_redact.append(question.current_value)
|
||||
data_to_redact += [
|
||||
urllib.parse.quote(data)
|
||||
for data in data_to_redact
|
||||
if urllib.parse.quote(data) != data
|
||||
]
|
||||
if self.operation_logger:
|
||||
self.operation_logger.data_to_redact.extend(data_to_redact)
|
||||
elif data_to_redact:
|
||||
raise YunohostError("app_argument_cant_redact", arg=question.name)
|
||||
|
||||
return question.value
|
||||
|
||||
|
||||
class StringArgumentParser(YunoHostArgumentFormatParser):
|
||||
argument_type = "string"
|
||||
default_value = ""
|
||||
|
||||
class TagsArgumentParser(YunoHostArgumentFormatParser):
|
||||
argument_type = "tags"
|
||||
|
||||
def _prevalidate(self, question):
|
||||
values = question.value
|
||||
for value in values.split(','):
|
||||
question.value = value
|
||||
super()._prevalidate(question)
|
||||
question.value = values
|
||||
|
||||
|
||||
|
||||
class PasswordArgumentParser(YunoHostArgumentFormatParser):
|
||||
hide_user_input_in_prompt = True
|
||||
argument_type = "password"
|
||||
default_value = ""
|
||||
forbidden_chars = "{}"
|
||||
|
||||
def parse_question(self, question, user_answers):
|
||||
question = super(PasswordArgumentParser, self).parse_question(
|
||||
question, user_answers
|
||||
)
|
||||
question.redact = True
|
||||
if question.default is not None:
|
||||
raise YunohostValidationError(
|
||||
"app_argument_password_no_default", name=question.name
|
||||
)
|
||||
|
||||
return question
|
||||
|
||||
def _prevalidate(self, question):
|
||||
super()._prevalidate(question)
|
||||
|
||||
if question.value is not None:
|
||||
if any(char in question.value for char in self.forbidden_chars):
|
||||
raise YunohostValidationError(
|
||||
"pattern_password_app", forbidden_chars=self.forbidden_chars
|
||||
)
|
||||
|
||||
# If it's an optional argument the value should be empty or strong enough
|
||||
from yunohost.utils.password import assert_password_is_strong_enough
|
||||
|
||||
assert_password_is_strong_enough("user", question.value)
|
||||
|
||||
|
||||
class PathArgumentParser(YunoHostArgumentFormatParser):
|
||||
argument_type = "path"
|
||||
default_value = ""
|
||||
|
||||
|
||||
class BooleanArgumentParser(YunoHostArgumentFormatParser):
|
||||
argument_type = "boolean"
|
||||
default_value = False
|
||||
|
||||
def parse_question(self, question, user_answers):
|
||||
question = super().parse_question(
|
||||
question, user_answers
|
||||
)
|
||||
|
||||
if question.default is None:
|
||||
question.default = False
|
||||
|
||||
return question
|
||||
|
||||
def _format_text_for_user_input_in_cli(self, question):
|
||||
text_for_user_input_in_cli = _value_for_locale(question.ask)
|
||||
|
||||
text_for_user_input_in_cli += " [yes | no]"
|
||||
|
||||
if question.default is not None:
|
||||
formatted_default = "yes" if question.default else "no"
|
||||
text_for_user_input_in_cli += " (default: {0})".format(formatted_default)
|
||||
|
||||
return text_for_user_input_in_cli
|
||||
|
||||
def _post_parse_value(self, question):
|
||||
if isinstance(question.value, bool):
|
||||
return 1 if question.value else 0
|
||||
|
||||
if str(question.value).lower() in ["1", "yes", "y", "true"]:
|
||||
return 1
|
||||
|
||||
if str(question.value).lower() in ["0", "no", "n", "false"]:
|
||||
return 0
|
||||
|
||||
raise YunohostValidationError(
|
||||
"app_argument_choice_invalid",
|
||||
name=question.name,
|
||||
value=question.value,
|
||||
choices="yes, no, y, n, 1, 0",
|
||||
)
|
||||
|
||||
|
||||
class DomainArgumentParser(YunoHostArgumentFormatParser):
|
||||
argument_type = "domain"
|
||||
|
||||
def parse_question(self, question, user_answers):
|
||||
from yunohost.domain import domain_list, _get_maindomain
|
||||
|
||||
question = super(DomainArgumentParser, self).parse_question(
|
||||
question, user_answers
|
||||
)
|
||||
|
||||
if question.default is None:
|
||||
question.default = _get_maindomain()
|
||||
|
||||
question.choices = domain_list()["domains"]
|
||||
|
||||
return question
|
||||
|
||||
def _raise_invalid_answer(self, question):
|
||||
raise YunohostValidationError(
|
||||
"app_argument_invalid", field=question.name, error=m18n.n("domain_unknown")
|
||||
)
|
||||
|
||||
|
||||
class UserArgumentParser(YunoHostArgumentFormatParser):
|
||||
argument_type = "user"
|
||||
|
||||
def parse_question(self, question, user_answers):
|
||||
from yunohost.user import user_list, user_info
|
||||
from yunohost.domain import _get_maindomain
|
||||
|
||||
question = super(UserArgumentParser, self).parse_question(
|
||||
question, user_answers
|
||||
)
|
||||
question.choices = user_list()["users"]
|
||||
if question.default is None:
|
||||
root_mail = "root@%s" % _get_maindomain()
|
||||
for user in question.choices.keys():
|
||||
if root_mail in user_info(user).get("mail-aliases", []):
|
||||
question.default = user
|
||||
break
|
||||
|
||||
return question
|
||||
|
||||
def _raise_invalid_answer(self, question):
|
||||
raise YunohostValidationError(
|
||||
"app_argument_invalid",
|
||||
field=question.name,
|
||||
error=m18n.n("user_unknown", user=question.value),
|
||||
)
|
||||
|
||||
|
||||
class NumberArgumentParser(YunoHostArgumentFormatParser):
|
||||
argument_type = "number"
|
||||
default_value = ""
|
||||
|
||||
def parse_question(self, question, user_answers):
|
||||
question_parsed = super().parse_question(
|
||||
question, user_answers
|
||||
)
|
||||
question_parsed.min = question.get('min', None)
|
||||
question_parsed.max = question.get('max', None)
|
||||
if question_parsed.default is None:
|
||||
question_parsed.default = 0
|
||||
|
||||
return question_parsed
|
||||
|
||||
def _prevalidate(self, question):
|
||||
super()._prevalidate(question)
|
||||
if not isinstance(question.value, int) and not (isinstance(question.value, str) and question.value.isdigit()):
|
||||
raise YunohostValidationError(
|
||||
"app_argument_invalid", field=question.name, error=m18n.n("invalid_number")
|
||||
)
|
||||
|
||||
if question.min is not None and int(question.value) < question.min:
|
||||
raise YunohostValidationError(
|
||||
"app_argument_invalid", field=question.name, error=m18n.n("invalid_number")
|
||||
)
|
||||
|
||||
if question.max is not None and int(question.value) > question.max:
|
||||
raise YunohostValidationError(
|
||||
"app_argument_invalid", field=question.name, error=m18n.n("invalid_number")
|
||||
)
|
||||
|
||||
def _post_parse_value(self, question):
|
||||
if isinstance(question.value, int):
|
||||
return super()._post_parse_value(question)
|
||||
|
||||
if isinstance(question.value, str) and question.value.isdigit():
|
||||
return int(question.value)
|
||||
|
||||
raise YunohostValidationError(
|
||||
"app_argument_invalid", field=question.name, error=m18n.n("invalid_number")
|
||||
)
|
||||
|
||||
|
||||
class DisplayTextArgumentParser(YunoHostArgumentFormatParser):
|
||||
argument_type = "display_text"
|
||||
readonly = True
|
||||
|
||||
def parse_question(self, question, user_answers):
|
||||
question_parsed = super().parse_question(
|
||||
question, user_answers
|
||||
)
|
||||
|
||||
question_parsed.optional = True
|
||||
question_parsed.style = question.get('style', 'info')
|
||||
|
||||
return question_parsed
|
||||
|
||||
def _format_text_for_user_input_in_cli(self, question):
|
||||
text = question.ask['en']
|
||||
|
||||
if question.style in ['success', 'info', 'warning', 'danger']:
|
||||
color = {
|
||||
'success': 'green',
|
||||
'info': 'cyan',
|
||||
'warning': 'yellow',
|
||||
'danger': 'red'
|
||||
}
|
||||
return colorize(m18n.g(question.style), color[question.style]) + f" {text}"
|
||||
else:
|
||||
return text
|
||||
|
||||
class FileArgumentParser(YunoHostArgumentFormatParser):
|
||||
argument_type = "file"
|
||||
upload_dirs = []
|
||||
|
||||
@classmethod
|
||||
def clean_upload_dirs(cls):
|
||||
# Delete files uploaded from API
|
||||
if Moulinette.interface.type== 'api':
|
||||
for upload_dir in cls.upload_dirs:
|
||||
if os.path.exists(upload_dir):
|
||||
shutil.rmtree(upload_dir)
|
||||
|
||||
def parse_question(self, question, user_answers):
|
||||
question_parsed = super().parse_question(
|
||||
question, user_answers
|
||||
)
|
||||
if question.get('accept'):
|
||||
question_parsed.accept = question.get('accept').replace(' ', '').split(',')
|
||||
else:
|
||||
question_parsed.accept = []
|
||||
if Moulinette.interface.type== 'api':
|
||||
if user_answers.get(f"{question_parsed.name}[name]"):
|
||||
question_parsed.value = {
|
||||
'content': question_parsed.value,
|
||||
'filename': user_answers.get(f"{question_parsed.name}[name]", question_parsed.name),
|
||||
}
|
||||
# If path file are the same
|
||||
if question_parsed.value and str(question_parsed.value) == question_parsed.current_value:
|
||||
question_parsed.value = None
|
||||
|
||||
return question_parsed
|
||||
|
||||
def _prevalidate(self, question):
|
||||
super()._prevalidate(question)
|
||||
if isinstance(question.value, str) and question.value and not os.path.exists(question.value):
|
||||
raise YunohostValidationError(
|
||||
"app_argument_invalid", field=question.name, error=m18n.n("invalid_number1")
|
||||
)
|
||||
if question.value in [None, ''] or not question.accept:
|
||||
return
|
||||
|
||||
filename = question.value if isinstance(question.value, str) else question.value['filename']
|
||||
if '.' not in filename or '.' + filename.split('.')[-1] not in question.accept:
|
||||
raise YunohostValidationError(
|
||||
"app_argument_invalid", field=question.name, error=m18n.n("invalid_number2")
|
||||
)
|
||||
|
||||
|
||||
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 Moulinette.interface.type== 'api':
|
||||
|
||||
upload_dir = tempfile.mkdtemp(prefix='tmp_configpanel_')
|
||||
FileArgumentParser.upload_dirs += [upload_dir]
|
||||
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)
|
||||
if not file_path.startswith(upload_dir + "/"):
|
||||
raise YunohostError("relative_parent_path_in_filename_forbidden")
|
||||
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 = {
|
||||
"string": StringArgumentParser,
|
||||
"text": StringArgumentParser,
|
||||
"select": StringArgumentParser,
|
||||
"tags": TagsArgumentParser,
|
||||
"email": StringArgumentParser,
|
||||
"url": StringArgumentParser,
|
||||
"date": StringArgumentParser,
|
||||
"time": StringArgumentParser,
|
||||
"color": StringArgumentParser,
|
||||
"password": PasswordArgumentParser,
|
||||
"path": PathArgumentParser,
|
||||
"boolean": BooleanArgumentParser,
|
||||
"domain": DomainArgumentParser,
|
||||
"user": UserArgumentParser,
|
||||
"number": NumberArgumentParser,
|
||||
"range": NumberArgumentParser,
|
||||
"display_text": DisplayTextArgumentParser,
|
||||
"alert": DisplayTextArgumentParser,
|
||||
"markdown": DisplayTextArgumentParser,
|
||||
"file": FileArgumentParser,
|
||||
}
|
||||
|
||||
def parse_args_in_yunohost_format(user_answers, argument_questions):
|
||||
"""Parse arguments store in either manifest.json or actions.json or from a
|
||||
config panel against the user answers when they are present.
|
||||
|
||||
Keyword arguments:
|
||||
user_answers -- a dictionnary of arguments from the user (generally
|
||||
empty in CLI, filed from the admin interface)
|
||||
argument_questions -- the arguments description store in yunohost
|
||||
format from actions.json/toml, manifest.json/toml
|
||||
or config_panel.json/toml
|
||||
"""
|
||||
parsed_answers_dict = OrderedDict()
|
||||
|
||||
for question in argument_questions:
|
||||
parser = ARGUMENTS_TYPE_PARSERS[question.get("type", "string")]()
|
||||
|
||||
answer = parser.parse(question=question, user_answers=user_answers)
|
||||
if answer is not None:
|
||||
parsed_answers_dict[question["name"]] = answer
|
||||
|
||||
return parsed_answers_dict
|
||||
|
|
@ -59,4 +59,4 @@ class YunohostValidationError(YunohostError):
|
|||
|
||||
def content(self):
|
||||
|
||||
return {"error": self.strerror, "error_key": self.key}
|
||||
return {"error": self.strerror, "error_key": self.key, **self.kwargs}
|
||||
|
|
46
src/yunohost/utils/i18n.py
Normal file
46
src/yunohost/utils/i18n.py
Normal file
|
@ -0,0 +1,46 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
""" License
|
||||
|
||||
Copyright (C) 2018 YUNOHOST.ORG
|
||||
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program; if not, see http://www.gnu.org/licenses
|
||||
|
||||
"""
|
||||
from moulinette import Moulinette, m18n
|
||||
|
||||
def _value_for_locale(values):
|
||||
"""
|
||||
Return proper value for current locale
|
||||
|
||||
Keyword arguments:
|
||||
values -- A dict of values associated to their locale
|
||||
|
||||
Returns:
|
||||
An utf-8 encoded string
|
||||
|
||||
"""
|
||||
if not isinstance(values, dict):
|
||||
return values
|
||||
|
||||
for lang in [m18n.locale, m18n.default_locale]:
|
||||
try:
|
||||
return values[lang]
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
# Fallback to first value
|
||||
return list(values.values())[0]
|
||||
|
||||
|
Loading…
Add table
Reference in a new issue