mirror of
https://github.com/YunoHost/yunohost.git
synced 2024-09-03 20:06:10 +02:00
Merge pull request #486 from YunoHost/app_actions
[enh] POC for app actions
This commit is contained in:
commit
434e438f94
3 changed files with 240 additions and 122 deletions
|
@ -773,6 +773,32 @@ app:
|
||||||
|
|
||||||
subcategories:
|
subcategories:
|
||||||
|
|
||||||
|
action:
|
||||||
|
subcategory_help: Handle apps actions
|
||||||
|
actions:
|
||||||
|
|
||||||
|
### app_action_list()
|
||||||
|
list:
|
||||||
|
action_help: List app actions
|
||||||
|
api: GET /<app>/actions
|
||||||
|
arguments:
|
||||||
|
app_id:
|
||||||
|
help: app id
|
||||||
|
|
||||||
|
### app_action_run()
|
||||||
|
run:
|
||||||
|
action_help: Run app action
|
||||||
|
api: PUT /<app>/actions/<action>
|
||||||
|
arguments:
|
||||||
|
app_id:
|
||||||
|
help: app id
|
||||||
|
action:
|
||||||
|
help: action id
|
||||||
|
-a:
|
||||||
|
full: --args
|
||||||
|
help: Serialized arguments for app script (i.e. "domain=domain.tld&path=/path")
|
||||||
|
|
||||||
|
|
||||||
config:
|
config:
|
||||||
subcategory_help: Applications configuration panel
|
subcategory_help: Applications configuration panel
|
||||||
actions:
|
actions:
|
||||||
|
|
|
@ -641,11 +641,9 @@ def app_upgrade(auth, app=[], url=None, file=None):
|
||||||
os.system('rm -rf "%s/scripts" "%s/manifest.json %s/conf"' % (app_setting_path, app_setting_path, app_setting_path))
|
os.system('rm -rf "%s/scripts" "%s/manifest.json %s/conf"' % (app_setting_path, app_setting_path, app_setting_path))
|
||||||
os.system('mv "%s/manifest.json" "%s/scripts" %s' % (extracted_app_folder, extracted_app_folder, app_setting_path))
|
os.system('mv "%s/manifest.json" "%s/scripts" %s' % (extracted_app_folder, extracted_app_folder, app_setting_path))
|
||||||
|
|
||||||
if os.path.exists(os.path.join(extracted_app_folder, "config_panel.json")):
|
for file_to_copy in ["actions.json", "config_panel.json", "conf"]:
|
||||||
os.system('cp -R %s/config_panel.json %s' % (extracted_app_folder, app_setting_path))
|
if os.path.exists(os.path.join(extracted_app_folder, file_to_copy)):
|
||||||
|
os.system('cp -R %s/%s %s' % (extracted_app_folder, file_to_copy, app_setting_path))
|
||||||
if os.path.exists(os.path.join(extracted_app_folder, "conf")):
|
|
||||||
os.system('cp -R %s/conf %s' % (extracted_app_folder, app_setting_path))
|
|
||||||
|
|
||||||
# So much win
|
# So much win
|
||||||
upgraded_apps.append(app_instance_name)
|
upgraded_apps.append(app_instance_name)
|
||||||
|
@ -761,11 +759,9 @@ def app_install(auth, app, label=None, args=None, no_remove_on_failure=False):
|
||||||
os.system('cp %s/manifest.json %s' % (extracted_app_folder, app_setting_path))
|
os.system('cp %s/manifest.json %s' % (extracted_app_folder, app_setting_path))
|
||||||
os.system('cp -R %s/scripts %s' % (extracted_app_folder, app_setting_path))
|
os.system('cp -R %s/scripts %s' % (extracted_app_folder, app_setting_path))
|
||||||
|
|
||||||
if os.path.exists(os.path.join(extracted_app_folder, "config_panel.json")):
|
for file_to_copy in ["actions.json", "config_panel.json", "conf"]:
|
||||||
os.system('cp -R %s/config_panel.json %s' % (extracted_app_folder, app_setting_path))
|
if os.path.exists(os.path.join(extracted_app_folder, file_to_copy)):
|
||||||
|
os.system('cp -R %s/%s %s' % (extracted_app_folder, file_to_copy, app_setting_path))
|
||||||
if os.path.exists(os.path.join(extracted_app_folder, "conf")):
|
|
||||||
os.system('cp -R %s/conf %s' % (extracted_app_folder, app_setting_path))
|
|
||||||
|
|
||||||
# Execute the app install script
|
# Execute the app install script
|
||||||
install_retcode = 1
|
install_retcode = 1
|
||||||
|
@ -1367,6 +1363,70 @@ def app_change_label(auth, app, new_label):
|
||||||
app_ssowatconf(auth)
|
app_ssowatconf(auth)
|
||||||
|
|
||||||
|
|
||||||
|
# actions todo list:
|
||||||
|
# * docstring
|
||||||
|
|
||||||
|
def app_action_list(app_id):
|
||||||
|
logger.warning(m18n.n('experimental_feature'))
|
||||||
|
|
||||||
|
installed = _is_installed(app_id)
|
||||||
|
if not installed:
|
||||||
|
raise MoulinetteError(errno.ENOPKG,
|
||||||
|
m18n.n('app_not_installed', app=app_id))
|
||||||
|
|
||||||
|
actions = os.path.join(APPS_SETTING_PATH, app_id, 'actions.json')
|
||||||
|
|
||||||
|
return {
|
||||||
|
"actions": read_json(actions) if os.path.exists(actions) else [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def app_action_run(app_id, action, args=None):
|
||||||
|
logger.warning(m18n.n('experimental_feature'))
|
||||||
|
|
||||||
|
from yunohost.hook import hook_exec
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
# will raise if action doesn't exist
|
||||||
|
actions = app_action_list(app_id)["actions"]
|
||||||
|
|
||||||
|
if action not in actions:
|
||||||
|
raise MoulinetteError(errno.EINVAL, "action '%s' not available for app '%s', available actions are: %s" % (action, app_id, ", ".join(actions.keys())))
|
||||||
|
|
||||||
|
action_declaration = actions[action]
|
||||||
|
|
||||||
|
# Retrieve arguments list for install script
|
||||||
|
args_dict = dict(urlparse.parse_qsl(args, keep_blank_values=True)) if args else {}
|
||||||
|
args_odict = _parse_args_for_action(actions[action], args=args_dict)
|
||||||
|
args_list = args_odict.values()
|
||||||
|
|
||||||
|
env_dict = _make_environment_dict(args_odict, prefix="ACTION_")
|
||||||
|
env_dict["YNH_APP_ID"] = app_id
|
||||||
|
env_dict["YNH_ACTION"] = action
|
||||||
|
|
||||||
|
_, path = tempfile.mkstemp()
|
||||||
|
|
||||||
|
with open(path, "w") as script:
|
||||||
|
script.write(action_declaration["command"])
|
||||||
|
|
||||||
|
os.chmod(path, 700)
|
||||||
|
|
||||||
|
retcode = hook_exec(
|
||||||
|
path,
|
||||||
|
args=args_list,
|
||||||
|
env=env_dict,
|
||||||
|
chdir=action_declaration.get("cwd"),
|
||||||
|
user=action_declaration.get("user", "root"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if retcode not in action_declaration.get("accepted_return_codes", [0]):
|
||||||
|
raise MoulinetteError(retcode, "Error while executing action '%s' of app '%s': return code %s" % (action, app_id, retcode))
|
||||||
|
|
||||||
|
os.remove(path)
|
||||||
|
|
||||||
|
return logger.success("Action successed!")
|
||||||
|
|
||||||
|
|
||||||
# Config panel todo list:
|
# Config panel todo list:
|
||||||
# * docstrings
|
# * docstrings
|
||||||
# * merge translations on the json once the workflow is in place
|
# * merge translations on the json once the workflow is in place
|
||||||
|
@ -1945,142 +2005,173 @@ def _parse_args_from_manifest(manifest, action, args={}, auth=None):
|
||||||
args -- A dictionnary of arguments to parse
|
args -- A dictionnary of arguments to parse
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from yunohost.domain import (domain_list, _get_maindomain,
|
|
||||||
domain_url_available, _normalize_domain_path)
|
|
||||||
from yunohost.user import user_info
|
|
||||||
|
|
||||||
args_dict = OrderedDict()
|
|
||||||
try:
|
try:
|
||||||
action_args = manifest['arguments'][action]
|
action_args = manifest['arguments'][action]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
logger.debug("no arguments found for '%s' in manifest", action)
|
logger.debug("no arguments found for '%s' in manifest", action)
|
||||||
else:
|
else:
|
||||||
for arg in action_args:
|
return _parse_action_args_in_yunohost_format(args, action_args, auth)
|
||||||
arg_name = arg['name']
|
|
||||||
arg_type = arg.get('type', 'string')
|
|
||||||
arg_default = arg.get('default', None)
|
|
||||||
arg_choices = arg.get('choices', [])
|
|
||||||
arg_value = None
|
|
||||||
|
|
||||||
# Transpose default value for boolean type and set it to
|
|
||||||
# false if not defined.
|
|
||||||
if arg_type == 'boolean':
|
|
||||||
arg_default = 1 if arg_default else 0
|
|
||||||
|
|
||||||
# Attempt to retrieve argument value
|
def _parse_args_for_action(action, args={}, auth=None):
|
||||||
if arg_name in args:
|
"""Parse arguments needed for an action from the actions list
|
||||||
arg_value = args[arg_name]
|
|
||||||
else:
|
|
||||||
if 'ask' in arg:
|
|
||||||
# Retrieve proper ask string
|
|
||||||
ask_string = _value_for_locale(arg['ask'])
|
|
||||||
|
|
||||||
# Append extra strings
|
Retrieve specified arguments for the action from the manifest, and parse
|
||||||
if arg_type == 'boolean':
|
given args according to that. If some required arguments are not provided,
|
||||||
ask_string += ' [0 | 1]'
|
its values will be asked if interaction is possible.
|
||||||
elif arg_choices:
|
Parsed arguments will be returned as an OrderedDict
|
||||||
ask_string += ' [{0}]'.format(' | '.join(arg_choices))
|
|
||||||
if arg_default is not None:
|
|
||||||
ask_string += ' (default: {0})'.format(arg_default)
|
|
||||||
|
|
||||||
# Check for a password argument
|
Keyword arguments:
|
||||||
is_password = True if arg_type == 'password' else False
|
action -- The action
|
||||||
|
args -- A dictionnary of arguments to parse
|
||||||
|
|
||||||
if arg_type == 'domain':
|
"""
|
||||||
arg_default = _get_maindomain()
|
args_dict = OrderedDict()
|
||||||
ask_string += ' (default: {0})'.format(arg_default)
|
|
||||||
msignals.display(m18n.n('domains_available'))
|
|
||||||
for domain in domain_list(auth)['domains']:
|
|
||||||
msignals.display("- {}".format(domain))
|
|
||||||
|
|
||||||
try:
|
if 'arguments' not in action:
|
||||||
input_string = msignals.prompt(ask_string, is_password)
|
logger.debug("no arguments found for '%s' in manifest", action)
|
||||||
except NotImplementedError:
|
return args_dict
|
||||||
input_string = None
|
|
||||||
if (input_string == '' or input_string is None) \
|
|
||||||
and arg_default is not None:
|
|
||||||
arg_value = arg_default
|
|
||||||
else:
|
|
||||||
arg_value = input_string
|
|
||||||
elif arg_default is not None:
|
|
||||||
arg_value = arg_default
|
|
||||||
|
|
||||||
# Validate argument value
|
action_args = action['arguments']
|
||||||
if (arg_value is None or arg_value == '') \
|
|
||||||
and not arg.get('optional', False):
|
|
||||||
raise MoulinetteError(errno.EINVAL,
|
|
||||||
m18n.n('app_argument_required', name=arg_name))
|
|
||||||
elif arg_value is None:
|
|
||||||
args_dict[arg_name] = ''
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Validate argument choice
|
return _parse_action_args_in_yunohost_format(args, action_args, auth)
|
||||||
if arg_choices and arg_value not in arg_choices:
|
|
||||||
raise MoulinetteError(errno.EINVAL,
|
|
||||||
m18n.n('app_argument_choice_invalid',
|
def _parse_action_args_in_yunohost_format(args, action_args, auth=None):
|
||||||
name=arg_name, choices=', '.join(arg_choices)))
|
"""Parse arguments store in either manifest.json or actions.json
|
||||||
|
"""
|
||||||
|
from yunohost.domain import (domain_list, _get_maindomain,
|
||||||
|
domain_url_available, _normalize_domain_path)
|
||||||
|
from yunohost.user import user_info
|
||||||
|
|
||||||
|
args_dict = OrderedDict()
|
||||||
|
|
||||||
|
for arg in action_args:
|
||||||
|
arg_name = arg['name']
|
||||||
|
arg_type = arg.get('type', 'string')
|
||||||
|
arg_default = arg.get('default', None)
|
||||||
|
arg_choices = arg.get('choices', [])
|
||||||
|
arg_value = None
|
||||||
|
|
||||||
|
# Transpose default value for boolean type and set it to
|
||||||
|
# false if not defined.
|
||||||
|
if arg_type == 'boolean':
|
||||||
|
arg_default = 1 if arg_default else 0
|
||||||
|
|
||||||
|
# Attempt to retrieve argument value
|
||||||
|
if arg_name in args:
|
||||||
|
arg_value = args[arg_name]
|
||||||
|
else:
|
||||||
|
if 'ask' in arg:
|
||||||
|
# Retrieve proper ask string
|
||||||
|
ask_string = _value_for_locale(arg['ask'])
|
||||||
|
|
||||||
|
# Append extra strings
|
||||||
|
if arg_type == 'boolean':
|
||||||
|
ask_string += ' [0 | 1]'
|
||||||
|
elif arg_choices:
|
||||||
|
ask_string += ' [{0}]'.format(' | '.join(arg_choices))
|
||||||
|
if arg_default is not None:
|
||||||
|
ask_string += ' (default: {0})'.format(arg_default)
|
||||||
|
|
||||||
|
# Check for a password argument
|
||||||
|
is_password = True if arg_type == 'password' else False
|
||||||
|
|
||||||
|
if arg_type == 'domain':
|
||||||
|
arg_default = _get_maindomain()
|
||||||
|
ask_string += ' (default: {0})'.format(arg_default)
|
||||||
|
msignals.display(m18n.n('domains_available'))
|
||||||
|
for domain in domain_list(auth)['domains']:
|
||||||
|
msignals.display("- {}".format(domain))
|
||||||
|
|
||||||
# Validate argument type
|
|
||||||
if arg_type == 'domain':
|
|
||||||
if arg_value not in domain_list(auth)['domains']:
|
|
||||||
raise MoulinetteError(errno.EINVAL,
|
|
||||||
m18n.n('app_argument_invalid',
|
|
||||||
name=arg_name, error=m18n.n('domain_unknown')))
|
|
||||||
elif arg_type == 'user':
|
|
||||||
try:
|
try:
|
||||||
user_info(auth, arg_value)
|
input_string = msignals.prompt(ask_string, is_password)
|
||||||
except MoulinetteError as e:
|
except NotImplementedError:
|
||||||
raise MoulinetteError(errno.EINVAL,
|
input_string = None
|
||||||
m18n.n('app_argument_invalid',
|
if (input_string == '' or input_string is None) \
|
||||||
name=arg_name, error=e.strerror))
|
and arg_default is not None:
|
||||||
elif arg_type == 'app':
|
arg_value = arg_default
|
||||||
if not _is_installed(arg_value):
|
|
||||||
raise MoulinetteError(errno.EINVAL,
|
|
||||||
m18n.n('app_argument_invalid',
|
|
||||||
name=arg_name, error=m18n.n('app_unknown')))
|
|
||||||
elif arg_type == 'boolean':
|
|
||||||
if isinstance(arg_value, bool):
|
|
||||||
arg_value = 1 if arg_value else 0
|
|
||||||
else:
|
else:
|
||||||
try:
|
arg_value = input_string
|
||||||
arg_value = int(arg_value)
|
elif arg_default is not None:
|
||||||
if arg_value not in [0, 1]:
|
arg_value = arg_default
|
||||||
raise ValueError()
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
raise MoulinetteError(errno.EINVAL,
|
|
||||||
m18n.n('app_argument_choice_invalid',
|
|
||||||
name=arg_name, choices='0, 1'))
|
|
||||||
args_dict[arg_name] = arg_value
|
|
||||||
|
|
||||||
# END loop over action_args...
|
# Validate argument value
|
||||||
|
if (arg_value is None or arg_value == '') \
|
||||||
|
and not arg.get('optional', False):
|
||||||
|
raise MoulinetteError(errno.EINVAL,
|
||||||
|
m18n.n('app_argument_required', name=arg_name))
|
||||||
|
elif arg_value is None:
|
||||||
|
args_dict[arg_name] = ''
|
||||||
|
continue
|
||||||
|
|
||||||
# If there's only one "domain" and "path", validate that domain/path
|
# Validate argument choice
|
||||||
# is an available url and normalize the path.
|
if arg_choices and arg_value not in arg_choices:
|
||||||
|
raise MoulinetteError(errno.EINVAL,
|
||||||
|
m18n.n('app_argument_choice_invalid',
|
||||||
|
name=arg_name, choices=', '.join(arg_choices)))
|
||||||
|
|
||||||
domain_args = [arg["name"] for arg in action_args
|
# Validate argument type
|
||||||
if arg.get("type", "string") == "domain"]
|
if arg_type == 'domain':
|
||||||
path_args = [arg["name"] for arg in action_args
|
if arg_value not in domain_list(auth)['domains']:
|
||||||
if arg.get("type", "string") == "path"]
|
|
||||||
|
|
||||||
if len(domain_args) == 1 and len(path_args) == 1:
|
|
||||||
|
|
||||||
domain = args_dict[domain_args[0]]
|
|
||||||
path = args_dict[path_args[0]]
|
|
||||||
domain, path = _normalize_domain_path(domain, path)
|
|
||||||
|
|
||||||
# Check the url is available
|
|
||||||
if not domain_url_available(auth, domain, path):
|
|
||||||
raise MoulinetteError(errno.EINVAL,
|
raise MoulinetteError(errno.EINVAL,
|
||||||
m18n.n('app_location_unavailable'))
|
m18n.n('app_argument_invalid',
|
||||||
|
name=arg_name, error=m18n.n('domain_unknown')))
|
||||||
|
elif arg_type == 'user':
|
||||||
|
try:
|
||||||
|
user_info(auth, arg_value)
|
||||||
|
except MoulinetteError as e:
|
||||||
|
raise MoulinetteError(errno.EINVAL,
|
||||||
|
m18n.n('app_argument_invalid',
|
||||||
|
name=arg_name, error=e.strerror))
|
||||||
|
elif arg_type == 'app':
|
||||||
|
if not _is_installed(arg_value):
|
||||||
|
raise MoulinetteError(errno.EINVAL,
|
||||||
|
m18n.n('app_argument_invalid',
|
||||||
|
name=arg_name, error=m18n.n('app_unknown')))
|
||||||
|
elif arg_type == 'boolean':
|
||||||
|
if isinstance(arg_value, bool):
|
||||||
|
arg_value = 1 if arg_value else 0
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
arg_value = int(arg_value)
|
||||||
|
if arg_value not in [0, 1]:
|
||||||
|
raise ValueError()
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise MoulinetteError(errno.EINVAL,
|
||||||
|
m18n.n('app_argument_choice_invalid',
|
||||||
|
name=arg_name, choices='0, 1'))
|
||||||
|
args_dict[arg_name] = arg_value
|
||||||
|
|
||||||
# (We save this normalized path so that the install script have a
|
# END loop over action_args...
|
||||||
# standard path format to deal with no matter what the user inputted)
|
|
||||||
args_dict[path_args[0]] = path
|
# If there's only one "domain" and "path", validate that domain/path
|
||||||
|
# is an available url and normalize the path.
|
||||||
|
|
||||||
|
domain_args = [arg["name"] for arg in action_args
|
||||||
|
if arg.get("type", "string") == "domain"]
|
||||||
|
path_args = [arg["name"] for arg in action_args
|
||||||
|
if arg.get("type", "string") == "path"]
|
||||||
|
|
||||||
|
if len(domain_args) == 1 and len(path_args) == 1:
|
||||||
|
|
||||||
|
domain = args_dict[domain_args[0]]
|
||||||
|
path = args_dict[path_args[0]]
|
||||||
|
domain, path = _normalize_domain_path(domain, path)
|
||||||
|
|
||||||
|
# Check the url is available
|
||||||
|
if not domain_url_available(auth, domain, path):
|
||||||
|
raise MoulinetteError(errno.EINVAL,
|
||||||
|
m18n.n('app_location_unavailable'))
|
||||||
|
|
||||||
|
# (We save this normalized path so that the install script have a
|
||||||
|
# standard path format to deal with no matter what the user inputted)
|
||||||
|
args_dict[path_args[0]] = path
|
||||||
|
|
||||||
return args_dict
|
return args_dict
|
||||||
|
|
||||||
|
|
||||||
def _make_environment_dict(args_dict):
|
def _make_environment_dict(args_dict, prefix="APP_ARG_"):
|
||||||
"""
|
"""
|
||||||
Convert a dictionnary containing manifest arguments
|
Convert a dictionnary containing manifest arguments
|
||||||
to a dictionnary of env. var. to be passed to scripts
|
to a dictionnary of env. var. to be passed to scripts
|
||||||
|
@ -2091,7 +2182,7 @@ def _make_environment_dict(args_dict):
|
||||||
"""
|
"""
|
||||||
env_dict = {}
|
env_dict = {}
|
||||||
for arg_name, arg_value in args_dict.items():
|
for arg_name, arg_value in args_dict.items():
|
||||||
env_dict["YNH_APP_ARG_%s" % arg_name.upper()] = arg_value
|
env_dict["YNH_%s%s" % (prefix, arg_name.upper())] = arg_value
|
||||||
return env_dict
|
return env_dict
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -365,6 +365,7 @@ def hook_exec(path, args=None, raise_on_error=False, no_trace=False,
|
||||||
stdout_callback if stdout_callback else lambda l: logger.debug(l.rstrip()),
|
stdout_callback if stdout_callback else lambda l: logger.debug(l.rstrip()),
|
||||||
stderr_callback if stderr_callback else lambda l: logger.warning(l.rstrip()),
|
stderr_callback if stderr_callback else lambda l: logger.warning(l.rstrip()),
|
||||||
)
|
)
|
||||||
|
logger.debug("About to run the command '%s'" % command)
|
||||||
returncode = call_async_output(
|
returncode = call_async_output(
|
||||||
command, callbacks, shell=False, cwd=chdir
|
command, callbacks, shell=False, cwd=chdir
|
||||||
)
|
)
|
||||||
|
|
Loading…
Add table
Reference in a new issue