mirror of
https://github.com/YunoHost/yunohost.git
synced 2024-09-03 20:06:10 +02:00
563 lines
17 KiB
Python
563 lines
17 KiB
Python
# -*- coding: utf-8 -*-
|
|
|
|
""" License
|
|
|
|
Copyright (C) 2013 YunoHost
|
|
|
|
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
|
|
|
|
"""
|
|
|
|
""" yunohost_hook.py
|
|
|
|
Manage hooks
|
|
"""
|
|
import os
|
|
import re
|
|
import sys
|
|
import tempfile
|
|
import mimetypes
|
|
from glob import iglob
|
|
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_yaml, cp
|
|
|
|
HOOK_FOLDER = "/usr/share/yunohost/hooks/"
|
|
CUSTOM_HOOK_FOLDER = "/etc/yunohost/hooks.d/"
|
|
|
|
logger = log.getActionLogger("yunohost.hook")
|
|
|
|
|
|
def hook_add(app, file):
|
|
"""
|
|
Store hook script to filsystem
|
|
|
|
Keyword argument:
|
|
app -- App to link with
|
|
file -- Script to add (/path/priority-file)
|
|
|
|
"""
|
|
path, filename = os.path.split(file)
|
|
priority, action = _extract_filename_parts(filename)
|
|
|
|
try:
|
|
os.listdir(CUSTOM_HOOK_FOLDER + action)
|
|
except OSError:
|
|
os.makedirs(CUSTOM_HOOK_FOLDER + action)
|
|
|
|
finalpath = CUSTOM_HOOK_FOLDER + action + "/" + priority + "-" + app
|
|
cp(file, finalpath)
|
|
|
|
return {"hook": finalpath}
|
|
|
|
|
|
def hook_remove(app):
|
|
"""
|
|
Remove hooks linked to a specific app
|
|
|
|
Keyword argument:
|
|
app -- Scripts related to app will be removed
|
|
|
|
"""
|
|
try:
|
|
for action in os.listdir(CUSTOM_HOOK_FOLDER):
|
|
for script in os.listdir(CUSTOM_HOOK_FOLDER + action):
|
|
if script.endswith(app):
|
|
os.remove(CUSTOM_HOOK_FOLDER + action + "/" + script)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def hook_info(action, name):
|
|
"""
|
|
Get information about a given hook
|
|
|
|
Keyword argument:
|
|
action -- Action name
|
|
name -- Hook name
|
|
|
|
"""
|
|
hooks = []
|
|
priorities = set()
|
|
|
|
# Search in custom folder first
|
|
for h in iglob("{:s}{:s}/*-{:s}".format(CUSTOM_HOOK_FOLDER, action, name)):
|
|
priority, _ = _extract_filename_parts(os.path.basename(h))
|
|
priorities.add(priority)
|
|
hooks.append(
|
|
{
|
|
"priority": priority,
|
|
"path": h,
|
|
}
|
|
)
|
|
# Append non-overwritten system hooks
|
|
for h in iglob("{:s}{:s}/*-{:s}".format(HOOK_FOLDER, action, name)):
|
|
priority, _ = _extract_filename_parts(os.path.basename(h))
|
|
if priority not in priorities:
|
|
hooks.append(
|
|
{
|
|
"priority": priority,
|
|
"path": h,
|
|
}
|
|
)
|
|
|
|
if not hooks:
|
|
raise YunohostValidationError("hook_name_unknown", name=name)
|
|
return {
|
|
"action": action,
|
|
"name": name,
|
|
"hooks": hooks,
|
|
}
|
|
|
|
|
|
def hook_list(action, list_by="name", show_info=False):
|
|
"""
|
|
List available hooks for an action
|
|
|
|
Keyword argument:
|
|
action -- Action name
|
|
list_by -- Property to list hook by
|
|
show_info -- Show hook information
|
|
|
|
"""
|
|
result = {}
|
|
|
|
# Process the property to list hook by
|
|
if list_by == "priority":
|
|
if show_info:
|
|
|
|
def _append_hook(d, priority, name, path):
|
|
# Use the priority as key and a dict of hooks names
|
|
# with their info as value
|
|
value = {"path": path}
|
|
try:
|
|
d[priority][name] = value
|
|
except KeyError:
|
|
d[priority] = {name: value}
|
|
|
|
else:
|
|
|
|
def _append_hook(d, priority, name, path):
|
|
# Use the priority as key and the name as value
|
|
try:
|
|
d[priority].add(name)
|
|
except KeyError:
|
|
d[priority] = set([name])
|
|
|
|
elif list_by == "name" or list_by == "folder":
|
|
if show_info:
|
|
|
|
def _append_hook(d, priority, name, path):
|
|
# Use the name as key and a list of hooks info - the
|
|
# executed ones with this name - as value
|
|
name_list = d.get(name, list())
|
|
for h in name_list:
|
|
# Only one priority for the hook is accepted
|
|
if h["priority"] == priority:
|
|
# Custom hooks overwrite system ones and they
|
|
# are appended at the end - so overwite it
|
|
if h["path"] != path:
|
|
h["path"] = path
|
|
return
|
|
name_list.append({"priority": priority, "path": path})
|
|
d[name] = name_list
|
|
|
|
else:
|
|
if list_by == "name":
|
|
result = set()
|
|
|
|
def _append_hook(d, priority, name, path):
|
|
# Add only the name
|
|
d.add(name)
|
|
|
|
else:
|
|
raise YunohostValidationError("hook_list_by_invalid")
|
|
|
|
def _append_folder(d, folder):
|
|
# Iterate over and add hook from a folder
|
|
for f in os.listdir(folder + action):
|
|
if (
|
|
f[0] == "."
|
|
or f[-1] == "~"
|
|
or f.endswith(".pyc")
|
|
or (f.startswith("__") and f.endswith("__"))
|
|
):
|
|
continue
|
|
path = "%s%s/%s" % (folder, action, f)
|
|
priority, name = _extract_filename_parts(f)
|
|
_append_hook(d, priority, name, path)
|
|
|
|
try:
|
|
# Append system hooks first
|
|
if list_by == "folder":
|
|
result["system"] = dict() if show_info else set()
|
|
_append_folder(result["system"], HOOK_FOLDER)
|
|
else:
|
|
_append_folder(result, HOOK_FOLDER)
|
|
except OSError:
|
|
pass
|
|
|
|
try:
|
|
# Append custom hooks
|
|
if list_by == "folder":
|
|
result["custom"] = dict() if show_info else set()
|
|
_append_folder(result["custom"], CUSTOM_HOOK_FOLDER)
|
|
else:
|
|
_append_folder(result, CUSTOM_HOOK_FOLDER)
|
|
except OSError:
|
|
pass
|
|
|
|
return {"hooks": result}
|
|
|
|
|
|
def hook_callback(
|
|
action,
|
|
hooks=[],
|
|
args=None,
|
|
chdir=None,
|
|
env=None,
|
|
pre_callback=None,
|
|
post_callback=None,
|
|
):
|
|
"""
|
|
Execute all scripts binded to an action
|
|
|
|
Keyword argument:
|
|
action -- Action name
|
|
hooks -- List of hooks names to execute
|
|
args -- Ordered list of arguments to pass to the scripts
|
|
chdir -- The directory from where the scripts will be executed
|
|
env -- Dictionnary of environment variables to export
|
|
pre_callback -- An object to call before each script execution with
|
|
(name, priority, path, args) as arguments and which must return
|
|
the arguments to pass to the script
|
|
post_callback -- An object to call after each script execution with
|
|
(name, priority, path, succeed) as arguments
|
|
|
|
"""
|
|
result = {}
|
|
hooks_dict = {}
|
|
|
|
# Retrieve hooks
|
|
if not hooks:
|
|
hooks_dict = hook_list(action, list_by="priority", show_info=True)["hooks"]
|
|
else:
|
|
hooks_names = hook_list(action, list_by="name", show_info=True)["hooks"]
|
|
|
|
# Add similar hooks to the list
|
|
# For example: Having a 16-postfix hook in the list will execute a
|
|
# xx-postfix_dkim as well
|
|
all_hooks = []
|
|
for n in hooks:
|
|
for key in hooks_names.keys():
|
|
if key == n or key.startswith("%s_" % n) and key not in all_hooks:
|
|
all_hooks.append(key)
|
|
|
|
# Iterate over given hooks names list
|
|
for n in all_hooks:
|
|
try:
|
|
hl = hooks_names[n]
|
|
except KeyError:
|
|
raise YunohostValidationError("hook_name_unknown", n)
|
|
# Iterate over hooks with this name
|
|
for h in hl:
|
|
# Update hooks dict
|
|
d = hooks_dict.get(h["priority"], dict())
|
|
d.update({n: {"path": h["path"]}})
|
|
hooks_dict[h["priority"]] = d
|
|
if not hooks_dict:
|
|
return result
|
|
|
|
# Validate callbacks
|
|
if not callable(pre_callback):
|
|
|
|
def pre_callback(name, priority, path, args):
|
|
return args
|
|
|
|
if not callable(post_callback):
|
|
|
|
def post_callback(name, priority, path, succeed):
|
|
return None
|
|
|
|
# Iterate over hooks and execute them
|
|
for priority in sorted(hooks_dict):
|
|
for name, info in iter(hooks_dict[priority].items()):
|
|
state = "succeed"
|
|
path = info["path"]
|
|
try:
|
|
hook_args = pre_callback(
|
|
name=name, priority=priority, path=path, args=args
|
|
)
|
|
hook_return = hook_exec(
|
|
path, args=hook_args, chdir=chdir, env=env, raise_on_error=True
|
|
)[1]
|
|
except YunohostError as e:
|
|
state = "failed"
|
|
hook_return = {}
|
|
logger.error(e.strerror, exc_info=1)
|
|
post_callback(name=name, priority=priority, path=path, succeed=False)
|
|
else:
|
|
post_callback(name=name, priority=priority, path=path, succeed=True)
|
|
if name not in result:
|
|
result[name] = {}
|
|
result[name][path] = {"state": state, "stdreturn": hook_return}
|
|
return result
|
|
|
|
|
|
def hook_exec(
|
|
path,
|
|
args=None,
|
|
raise_on_error=False,
|
|
chdir=None,
|
|
env=None,
|
|
user="root",
|
|
return_format="yaml",
|
|
):
|
|
"""
|
|
Execute hook from a file with arguments
|
|
|
|
Keyword argument:
|
|
path -- Path of the script to execute
|
|
args -- Ordered list of arguments to pass to the script
|
|
raise_on_error -- Raise if the script returns a non-zero exit code
|
|
chdir -- The directory from where the script will be executed
|
|
env -- Dictionnary of environment variables to export
|
|
user -- User with which to run the command
|
|
"""
|
|
|
|
# Validate hook path
|
|
if path[0] != "/":
|
|
path = os.path.realpath(path)
|
|
if not os.path.isfile(path):
|
|
raise YunohostError("file_does_not_exist", path=path)
|
|
|
|
def is_relevant_warning(msg):
|
|
|
|
# Ignore empty warning messages...
|
|
if not msg:
|
|
return False
|
|
|
|
# Some of these are shit sent from apt and we don't give a shit about
|
|
# them because they ain't actual warnings >_>
|
|
irrelevant_warnings = [
|
|
r"invalid value for trace file descriptor",
|
|
r"Creating config file .* with new version",
|
|
r"Created symlink /etc/systemd",
|
|
r"dpkg: warning: while removing .* not empty so not removed",
|
|
r"apt-key output should not be parsed",
|
|
r"update-rc.d: ",
|
|
]
|
|
return all(not re.search(w, msg) for w in irrelevant_warnings)
|
|
|
|
# Define output loggers and call command
|
|
loggers = (
|
|
lambda l: logger.debug(l.rstrip() + "\r"),
|
|
lambda l: logger.warning(l.rstrip())
|
|
if is_relevant_warning(l.rstrip())
|
|
else logger.debug(l.rstrip()),
|
|
lambda l: logger.info(l.rstrip()),
|
|
)
|
|
|
|
# Check the type of the hook (bash by default)
|
|
# For now we support only python and bash hooks.
|
|
hook_type = mimetypes.MimeTypes().guess_type(path)[0]
|
|
if hook_type == "text/x-python":
|
|
returncode, returndata = _hook_exec_python(path, args, env, loggers)
|
|
else:
|
|
returncode, returndata = _hook_exec_bash(
|
|
path, args, chdir, env, user, return_format, loggers
|
|
)
|
|
|
|
# Check and return process' return code
|
|
if returncode is None:
|
|
if raise_on_error:
|
|
raise YunohostError("hook_exec_not_terminated", path=path)
|
|
else:
|
|
logger.error(m18n.n("hook_exec_not_terminated", path=path))
|
|
return 1, {}
|
|
elif raise_on_error and returncode != 0:
|
|
raise YunohostError("hook_exec_failed", path=path)
|
|
|
|
return returncode, returndata
|
|
|
|
|
|
def _hook_exec_bash(path, args, chdir, env, user, return_format, loggers):
|
|
|
|
from moulinette.utils.process import call_async_output
|
|
|
|
# Construct command variables
|
|
cmd_args = ""
|
|
if args and isinstance(args, list):
|
|
# Concatenate escaped arguments
|
|
cmd_args = " ".join(shell_quote(s) for s in args)
|
|
if not chdir:
|
|
# use the script directory as current one
|
|
chdir, cmd_script = os.path.split(path)
|
|
cmd_script = "./{0}".format(cmd_script)
|
|
else:
|
|
cmd_script = path
|
|
|
|
# Add Execution dir to environment var
|
|
if env is None:
|
|
env = {}
|
|
env["YNH_CWD"] = chdir
|
|
|
|
env["YNH_INTERFACE"] = Moulinette.interface.type
|
|
|
|
stdreturn = os.path.join(tempfile.mkdtemp(), "stdreturn")
|
|
with open(stdreturn, "w") as f:
|
|
f.write("")
|
|
env["YNH_STDRETURN"] = stdreturn
|
|
|
|
# Construct command to execute
|
|
if user == "root":
|
|
command = ["sh", "-c"]
|
|
else:
|
|
command = ["sudo", "-n", "-u", user, "-H", "sh", "-c"]
|
|
|
|
# use xtrace on fd 7 which is redirected to stdout
|
|
env["BASH_XTRACEFD"] = "7"
|
|
cmd = '/bin/bash -x "{script}" {args} 7>&1'
|
|
command.append(cmd.format(script=cmd_script, args=cmd_args))
|
|
|
|
logger.debug("Executing command '%s'" % command)
|
|
|
|
_env = os.environ.copy()
|
|
_env.update(env)
|
|
|
|
returncode = call_async_output(command, loggers, shell=False, cwd=chdir, env=_env)
|
|
|
|
raw_content = None
|
|
try:
|
|
with open(stdreturn, "r") as f:
|
|
raw_content = f.read()
|
|
returncontent = {}
|
|
|
|
if return_format == "yaml":
|
|
if raw_content != "":
|
|
try:
|
|
returncontent = read_yaml(stdreturn)
|
|
except Exception as e:
|
|
raise YunohostError(
|
|
"hook_json_return_error",
|
|
path=path,
|
|
msg=str(e),
|
|
raw_content=raw_content,
|
|
)
|
|
|
|
elif return_format == "plain_dict":
|
|
for line in raw_content.split("\n"):
|
|
if "=" in line:
|
|
key, value = line.strip().split("=", 1)
|
|
returncontent[key] = value
|
|
|
|
else:
|
|
raise YunohostError(
|
|
"Expected value for return_format is either 'json' or 'plain_dict', got '%s'"
|
|
% return_format
|
|
)
|
|
finally:
|
|
stdreturndir = os.path.split(stdreturn)[0]
|
|
os.remove(stdreturn)
|
|
os.rmdir(stdreturndir)
|
|
|
|
return returncode, returncontent
|
|
|
|
|
|
def _hook_exec_python(path, args, env, loggers):
|
|
|
|
dir_ = os.path.dirname(path)
|
|
name = os.path.splitext(os.path.basename(path))[0]
|
|
|
|
if dir_ not in sys.path:
|
|
sys.path = [dir_] + sys.path
|
|
module = import_module(name)
|
|
|
|
ret = module.main(args, env, loggers)
|
|
# # Assert that the return is a (int, dict) tuple
|
|
assert (
|
|
isinstance(ret, tuple)
|
|
and len(ret) == 2
|
|
and isinstance(ret[0], int)
|
|
and isinstance(ret[1], dict)
|
|
), ("Module %s did not return a (int, dict) tuple !" % module)
|
|
return ret
|
|
|
|
|
|
def hook_exec_with_script_debug_if_failure(*args, **kwargs):
|
|
|
|
operation_logger = kwargs.pop("operation_logger")
|
|
error_message_if_failed = kwargs.pop("error_message_if_failed")
|
|
error_message_if_script_failed = kwargs.pop("error_message_if_script_failed")
|
|
|
|
failed = True
|
|
failure_message_with_debug_instructions = None
|
|
try:
|
|
retcode, retpayload = hook_exec(*args, **kwargs)
|
|
failed = True if retcode != 0 else False
|
|
if failed:
|
|
error = error_message_if_script_failed
|
|
logger.error(error_message_if_failed(error))
|
|
failure_message_with_debug_instructions = operation_logger.error(error)
|
|
if Moulinette.interface.type != "api":
|
|
operation_logger.dump_script_log_extract_for_debugging()
|
|
# Script got manually interrupted ...
|
|
# N.B. : KeyboardInterrupt does not inherit from Exception
|
|
except (KeyboardInterrupt, EOFError):
|
|
error = m18n.n("operation_interrupted")
|
|
logger.error(error_message_if_failed(error))
|
|
failure_message_with_debug_instructions = operation_logger.error(error)
|
|
# 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(error_message_if_failed(error))
|
|
failure_message_with_debug_instructions = operation_logger.error(error)
|
|
|
|
return failed, failure_message_with_debug_instructions
|
|
|
|
|
|
def _extract_filename_parts(filename):
|
|
"""Extract hook parts from filename"""
|
|
if "-" in filename:
|
|
priority, action = filename.split("-", 1)
|
|
else:
|
|
priority = "50"
|
|
action = filename
|
|
|
|
# Remove extension if there's one
|
|
action = os.path.splitext(action)[0]
|
|
return priority, action
|
|
|
|
|
|
# Taken from Python 3 shlex module --------------------------------------------
|
|
|
|
_find_unsafe = re.compile(r"[^\w@%+=:,./-]", re.UNICODE).search
|
|
|
|
|
|
def shell_quote(s):
|
|
"""Return a shell-escaped version of the string *s*."""
|
|
s = str(s)
|
|
if not s:
|
|
return "''"
|
|
if _find_unsafe(s) is None:
|
|
return s
|
|
|
|
# use single quotes, and put single quotes into double quotes
|
|
# the string $'b is then quoted as '$'"'"'b'
|
|
return "'" + s.replace("'", "'\"'\"'") + "'"
|