command.py 3.2 KB

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