1import logging
2import os
3
4from borgmatic import execute
5
6logger = logging.getLogger(__name__)
7
8
9SOFT_FAIL_EXIT_CODE = 75
10
11
12def interpolate_context(command, context):
13    '''
14    Given a single hook command and a dict of context names/values, interpolate the values by
15    "{name}" into the command and return the result.
16    '''
17    for name, value in context.items():
18        command = command.replace('{%s}' % name, str(value))
19
20    return command
21
22
23def execute_hook(commands, umask, config_filename, description, dry_run, **context):
24    '''
25    Given a list of hook commands to execute, a umask to execute with (or None), a config filename,
26    a hook description, and whether this is a dry run, run the given commands. Or, don't run them
27    if this is a dry run.
28
29    The context contains optional values interpolated by name into the hook commands. Currently,
30    this only applies to the on_error hook.
31
32    Raise ValueError if the umask cannot be parsed.
33    Raise subprocesses.CalledProcessError if an error occurs in a hook.
34    '''
35    if not commands:
36        logger.debug('{}: No commands to run for {} hook'.format(config_filename, description))
37        return
38
39    dry_run_label = ' (dry run; not actually running hooks)' if dry_run else ''
40
41    context['configuration_filename'] = config_filename
42    commands = [interpolate_context(command, context) for command in commands]
43
44    if len(commands) == 1:
45        logger.info(
46            '{}: Running command for {} hook{}'.format(config_filename, description, dry_run_label)
47        )
48    else:
49        logger.info(
50            '{}: Running {} commands for {} hook{}'.format(
51                config_filename, len(commands), description, dry_run_label
52            )
53        )
54
55    if umask:
56        parsed_umask = int(str(umask), 8)
57        logger.debug('{}: Set hook umask to {}'.format(config_filename, oct(parsed_umask)))
58        original_umask = os.umask(parsed_umask)
59    else:
60        original_umask = None
61
62    try:
63        for command in commands:
64            if not dry_run:
65                execute.execute_command(
66                    [command],
67                    output_log_level=logging.ERROR
68                    if description == 'on-error'
69                    else logging.WARNING,
70                    shell=True,
71                )
72    finally:
73        if original_umask:
74            os.umask(original_umask)
75
76
77def considered_soft_failure(config_filename, error):
78    '''
79    Given a configuration filename and an exception object, return whether the exception object
80    represents a subprocess.CalledProcessError with a return code of SOFT_FAIL_EXIT_CODE. If so,
81    that indicates that the error is a "soft failure", and should not result in an error.
82    '''
83    exit_code = getattr(error, 'returncode', None)
84    if exit_code is None:
85        return False
86
87    if exit_code == SOFT_FAIL_EXIT_CODE:
88        logger.info(
89            '{}: Command hook exited with soft failure exit code ({}); skipping remaining actions'.format(
90                config_filename, SOFT_FAIL_EXIT_CODE
91            )
92        )
93        return True
94
95    return False
96