mirror of
https://github.com/borgmatic-collective/borgmatic.git
synced 2026-08-03 23:13:02 +02:00
Add a passcommand hook so borgmatic can collect the encryption passphrase once and pass it to Borg multiple times (#961).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/984
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
1.9.9.dev0
|
||||
* #635: Log the repository path or label on every relevant log message, not just some logs.
|
||||
* #961: Add a passcommand hook so borgmatic can collect the encryption passphrase once and pass it
|
||||
to Borg multiple times.
|
||||
* #981: Fix a "spot" check file count delta error.
|
||||
* #982: Fix for borgmatic "exclude_patterns" and "exclude_from" recursing into excluded
|
||||
subdirectories.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import os
|
||||
|
||||
import borgmatic.hooks.dispatch
|
||||
|
||||
OPTION_TO_ENVIRONMENT_VARIABLE = {
|
||||
'borg_base_directory': 'BORG_BASE_DIR',
|
||||
'borg_config_directory': 'BORG_CONFIG_DIR',
|
||||
@@ -7,7 +9,6 @@ OPTION_TO_ENVIRONMENT_VARIABLE = {
|
||||
'borg_files_cache_ttl': 'BORG_FILES_CACHE_TTL',
|
||||
'borg_security_directory': 'BORG_SECURITY_DIR',
|
||||
'borg_keys_directory': 'BORG_KEYS_DIR',
|
||||
'encryption_passcommand': 'BORG_PASSCOMMAND',
|
||||
'encryption_passphrase': 'BORG_PASSPHRASE',
|
||||
'ssh_command': 'BORG_RSH',
|
||||
'temporary_directory': 'TMPDIR',
|
||||
@@ -36,6 +37,24 @@ def make_environment(config):
|
||||
if value:
|
||||
environment[environment_variable_name] = str(value)
|
||||
|
||||
passphrase = borgmatic.hooks.dispatch.call_hook(
|
||||
function_name='load_credential',
|
||||
config=config,
|
||||
hook_name='passcommand',
|
||||
credential_name='encryption_passphrase',
|
||||
)
|
||||
|
||||
# If the passcommand produced a passphrase, send it to Borg via an anonymous pipe.
|
||||
if passphrase:
|
||||
read_file_descriptor, write_file_descriptor = os.pipe()
|
||||
os.write(write_file_descriptor, passphrase.encode('utf-8'))
|
||||
os.close(write_file_descriptor)
|
||||
|
||||
# This, plus subprocess.Popen(..., close_fds=False) in execute.py, is necessary for the Borg
|
||||
# child process to inherit the file descriptor.
|
||||
os.set_inheritable(read_file_descriptor, True)
|
||||
environment['BORG_PASSPHRASE_FD'] = str(read_file_descriptor)
|
||||
|
||||
for (
|
||||
option_name,
|
||||
environment_variable_name,
|
||||
|
||||
@@ -307,6 +307,8 @@ def execute_command(
|
||||
shell=shell,
|
||||
env=environment,
|
||||
cwd=working_directory,
|
||||
# Necessary for the passcommand credential hook to work.
|
||||
close_fds=not bool((extra_environment or {}).get('BORG_PASSPHRASE_FD')),
|
||||
)
|
||||
if not run_to_completion:
|
||||
return process
|
||||
@@ -354,6 +356,8 @@ def execute_command_and_capture_output(
|
||||
shell=shell,
|
||||
env=environment,
|
||||
cwd=working_directory,
|
||||
# Necessary for the passcommand credential hook to work.
|
||||
close_fds=not bool((extra_environment or {}).get('BORG_PASSPHRASE_FD')),
|
||||
)
|
||||
except subprocess.CalledProcessError as error:
|
||||
if (
|
||||
@@ -414,6 +418,8 @@ def execute_command_with_processes(
|
||||
shell=shell,
|
||||
env=environment,
|
||||
cwd=working_directory,
|
||||
# Necessary for the passcommand credential hook to work.
|
||||
close_fds=not bool((extra_environment or {}).get('BORG_PASSPHRASE_FD')),
|
||||
)
|
||||
except (subprocess.CalledProcessError, OSError):
|
||||
# Something has gone wrong. So vent each process' output buffer to prevent it from hanging.
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import functools
|
||||
import logging
|
||||
import shlex
|
||||
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.execute
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@functools.cache
|
||||
def run_passcommand(passcommand, passphrase_configured, working_directory):
|
||||
'''
|
||||
Run the given passcommand using the given working directory and return the passphrase produced
|
||||
by the command. But bail first if a passphrase is already configured; this mimics Borg's
|
||||
behavior.
|
||||
|
||||
Cache the results so that the passcommand only needs to run—and potentially prompt the user—once
|
||||
per borgmatic invocation.
|
||||
'''
|
||||
if passcommand and passphrase_configured:
|
||||
logger.warning(
|
||||
'Ignoring the "encryption_passcommand" option because "encryption_passphrase" is set'
|
||||
)
|
||||
return None
|
||||
|
||||
return borgmatic.execute.execute_command_and_capture_output(
|
||||
shlex.split(passcommand),
|
||||
working_directory=working_directory,
|
||||
)
|
||||
|
||||
|
||||
def load_credential(hook_config, config, credential_name):
|
||||
'''
|
||||
Given the hook configuration dict, the configuration dict, and a credential name to load, call
|
||||
the configured passcommand to produce and return an encryption passphrase. In effect, we're
|
||||
doing an end-run around Borg by invoking its passcommand ourselves. This allows us to pass the
|
||||
resulting passphrase to multiple different Borg invocations without the user having to be
|
||||
prompted multiple times.
|
||||
|
||||
If no passcommand is configured, then return None.
|
||||
|
||||
The credential name must be "encryption_passphrase"; that's the only supported credential with
|
||||
this particular hook.
|
||||
'''
|
||||
if credential_name != 'encryption_passphrase':
|
||||
raise ValueError(
|
||||
f'Credential name "{credential_name}" is not supported for the passphrase credential hook'
|
||||
)
|
||||
|
||||
passcommand = config.get('encryption_passcommand')
|
||||
|
||||
if not passcommand:
|
||||
return None
|
||||
|
||||
passphrase = config.get('encryption_passphrase')
|
||||
working_directory = borgmatic.config.paths.get_working_directory(config)
|
||||
|
||||
return run_passcommand(passcommand, bool(passphrase), working_directory)
|
||||
@@ -3,6 +3,7 @@ import importlib
|
||||
import logging
|
||||
import pkgutil
|
||||
|
||||
import borgmatic.hooks.credential
|
||||
import borgmatic.hooks.data_source
|
||||
import borgmatic.hooks.monitoring
|
||||
|
||||
@@ -10,6 +11,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Hook_type(enum.Enum):
|
||||
CREDENTIAL = 'credential'
|
||||
DATA_SOURCE = 'data_source'
|
||||
MONITORING = 'monitoring'
|
||||
|
||||
@@ -40,7 +42,11 @@ def call_hook(function_name, config, hook_name, *args, **kwargs):
|
||||
module_name = hook_name.split('_databases')[0]
|
||||
|
||||
# Probe for a data source or monitoring hook module corresponding to the hook name.
|
||||
for parent_module in (borgmatic.hooks.data_source, borgmatic.hooks.monitoring):
|
||||
for parent_module in (
|
||||
borgmatic.hooks.credential,
|
||||
borgmatic.hooks.data_source,
|
||||
borgmatic.hooks.monitoring,
|
||||
):
|
||||
if module_name not in get_submodule_names(parent_module):
|
||||
continue
|
||||
|
||||
@@ -62,8 +68,8 @@ def call_hook(function_name, config, hook_name, *args, **kwargs):
|
||||
def call_hooks(function_name, config, hook_type, *args, **kwargs):
|
||||
'''
|
||||
Given a configuration dict, call the requested function of the Python module corresponding to
|
||||
each hook of the given hook type (either "data_source" or "monitoring"). Supply each call with
|
||||
the configuration for that hook, and any given args and kwargs.
|
||||
each hook of the given hook type ("credential", "data_source", or "monitoring"). Supply each
|
||||
call with the configuration for that hook, and any given args and kwargs.
|
||||
|
||||
Collect any return values into a dict from module name to return value. Note that the module
|
||||
name is the name of the hook module itself, which might be different from the hook configuration
|
||||
@@ -88,9 +94,9 @@ def call_hooks(function_name, config, hook_type, *args, **kwargs):
|
||||
def call_hooks_even_if_unconfigured(function_name, config, hook_type, *args, **kwargs):
|
||||
'''
|
||||
Given a configuration dict, call the requested function of the Python module corresponding to
|
||||
each hook of the given hook type (either "data_source" or "monitoring"). Supply each call with
|
||||
the configuration for that hook and any given args and kwargs. Collect any return values into a
|
||||
dict from hook name to return value.
|
||||
each hook of the given hook type ("credential", "data_source", or "monitoring"). Supply each
|
||||
call with the configuration for that hook and any given args and kwargs. Collect any return
|
||||
values into a dict from hook name to return value.
|
||||
|
||||
Raise AttributeError if the function name is not found in the module.
|
||||
Raise anything else that a called function raises. An error stops calls to subsequent functions.
|
||||
|
||||
@@ -24,11 +24,30 @@ or security reasons, read on.
|
||||
borgmatic supports calling another application such as a password manager to
|
||||
obtain the Borg passphrase to a repository.
|
||||
|
||||
For example, to ask the *Pass* password manager to provide the passphrase:
|
||||
For example, to ask the [Pass](https://www.passwordstore.org/) password manager
|
||||
to provide the passphrase:
|
||||
|
||||
```yaml
|
||||
encryption_passcommand: pass path/to/borg-repokey
|
||||
encryption_passcommand: pass path/to/borg-passphrase
|
||||
```
|
||||
|
||||
Another example for [KeePassXC](https://keepassxc.org/):
|
||||
|
||||
```yaml
|
||||
encryption_passcommand: keepassxc-cli show --show-protected --attributes Password credentials.kdbx borg_passphrase
|
||||
```
|
||||
|
||||
... where `borg_passphrase` is the title of the KeePassXC entry containing your
|
||||
Borg encryption passphrase in its `Password` field.
|
||||
|
||||
<span class="minilink minilink-addedin">New in version 1.9.9</span> Instead of
|
||||
letting Borg run the passcommand—potentially multiple times since borgmatic runs
|
||||
Borg multiple times—borgmatic now runs the passcommand itself and passes the
|
||||
resulting passprhase securely to Borg via an anonymous pipe. This means you
|
||||
should only ever get prompted for your password manager's passphrase at most
|
||||
once per borgmatic run.
|
||||
|
||||
|
||||
### Using systemd service credentials
|
||||
|
||||
Borgmatic supports using encrypted [credentials](https://systemd.io/CREDENTIALS/).
|
||||
|
||||
@@ -18,6 +18,7 @@ you get started. Starting at the top level, we have:
|
||||
* [commands](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/commands): Looking to add a new flag or action? Start here. This contains borgmatic's entry point, argument parsing, and shell completion.
|
||||
* [config](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/config): Code responsible for loading, normalizing, and validating borgmatic's configuration. Interested in adding a new configuration option? Check out `schema.yaml` here.
|
||||
* [hooks](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/hooks): Looking to add a new database, filesystem, or monitoring integration? Start here.
|
||||
* [credential](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/hooks/credential): Credential hooks—for loading passphrases and secrets from external providers.
|
||||
* [data_source](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/hooks/data_source): Database and filesystem hooks—anything that produces data or files to go into a backup archive.
|
||||
* [monitoring](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/hooks/monitoring): Monitoring hooks—integrations with third-party or self-hosted monitoring services.
|
||||
* [docs](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/docs): How-to and reference documentation, including the document you're reading now.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def generate_configuration(config_path, repository_path):
|
||||
'''
|
||||
Generate borgmatic configuration into a file at the config path, and update the defaults so as
|
||||
to work for testing, including updating the source directories, injecting the given repository
|
||||
path, and tacking on an encryption passcommand.
|
||||
'''
|
||||
subprocess.check_call(f'borgmatic config generate --destination {config_path}'.split(' '))
|
||||
config = (
|
||||
open(config_path)
|
||||
.read()
|
||||
.replace('ssh://user@backupserver/./sourcehostname.borg', repository_path)
|
||||
.replace('- path: /mnt/backup', '')
|
||||
.replace('label: local', '')
|
||||
.replace('- /home/user/path with spaces', '')
|
||||
.replace('- /home', f'- {config_path}')
|
||||
.replace('- /etc', '')
|
||||
.replace('- /var/log/syslog*', '')
|
||||
+ '\nencryption_passcommand: "echo test"'
|
||||
)
|
||||
config_file = open(config_path, 'w')
|
||||
config_file.write(config)
|
||||
config_file.close()
|
||||
|
||||
|
||||
def test_borgmatic_command():
|
||||
# Create a Borg repository.
|
||||
temporary_directory = tempfile.mkdtemp()
|
||||
repository_path = os.path.join(temporary_directory, 'test.borg')
|
||||
extract_path = os.path.join(temporary_directory, 'extract')
|
||||
|
||||
original_working_directory = os.getcwd()
|
||||
os.mkdir(extract_path)
|
||||
os.chdir(extract_path)
|
||||
|
||||
try:
|
||||
config_path = os.path.join(temporary_directory, 'test.yaml')
|
||||
generate_configuration(config_path, repository_path)
|
||||
|
||||
subprocess.check_call(
|
||||
f'borgmatic -v 2 --config {config_path} repo-create --encryption repokey'.split(' ')
|
||||
)
|
||||
|
||||
# Run borgmatic to generate a backup archive, and then list it to make sure it exists.
|
||||
subprocess.check_call(f'borgmatic -v 2 --config {config_path}'.split(' '))
|
||||
output = subprocess.check_output(
|
||||
f'borgmatic --config {config_path} list --json'.split(' ')
|
||||
).decode(sys.stdout.encoding)
|
||||
parsed_output = json.loads(output)
|
||||
|
||||
assert len(parsed_output) == 1
|
||||
assert len(parsed_output[0]['archives']) == 1
|
||||
archive_name = parsed_output[0]['archives'][0]['archive']
|
||||
finally:
|
||||
os.chdir(original_working_directory)
|
||||
shutil.rmtree(temporary_directory)
|
||||
@@ -3,13 +3,22 @@ from flexmock import flexmock
|
||||
from borgmatic.borg import environment as module
|
||||
|
||||
|
||||
def test_make_environment_with_passcommand_should_set_environment():
|
||||
def test_make_environment_with_passcommand_should_call_it_and_set_passphrase_file_descriptor_in_environment():
|
||||
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').and_return('passphrase')
|
||||
flexmock(module.os).should_receive('pipe').and_return((3, 4))
|
||||
flexmock(module.os).should_receive('write')
|
||||
flexmock(module.os).should_receive('close')
|
||||
flexmock(module.os).should_receive('set_inheritable')
|
||||
|
||||
environment = module.make_environment({'encryption_passcommand': 'command'})
|
||||
|
||||
assert environment.get('BORG_PASSCOMMAND') == 'command'
|
||||
assert not environment.get('BORG_PASSCOMMAND')
|
||||
assert environment.get('BORG_PASSPHRASE_FD') == '3'
|
||||
|
||||
|
||||
def test_make_environment_with_passphrase_should_set_environment():
|
||||
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').and_return(None)
|
||||
flexmock(module.os).should_receive('pipe').never()
|
||||
flexmock(module.os.environ).should_receive('get').and_return(None)
|
||||
environment = module.make_environment({'encryption_passphrase': 'pass'})
|
||||
|
||||
@@ -17,6 +26,8 @@ def test_make_environment_with_passphrase_should_set_environment():
|
||||
|
||||
|
||||
def test_make_environment_with_ssh_command_should_set_environment():
|
||||
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').and_return(None)
|
||||
flexmock(module.os).should_receive('pipe').never()
|
||||
flexmock(module.os.environ).should_receive('get').and_return(None)
|
||||
environment = module.make_environment({'ssh_command': 'ssh -C'})
|
||||
|
||||
@@ -24,6 +35,8 @@ def test_make_environment_with_ssh_command_should_set_environment():
|
||||
|
||||
|
||||
def test_make_environment_without_configuration_sets_certain_environment_variables():
|
||||
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').and_return(None)
|
||||
flexmock(module.os).should_receive('pipe').never()
|
||||
flexmock(module.os.environ).should_receive('get').and_return(None)
|
||||
environment = module.make_environment({})
|
||||
|
||||
@@ -36,6 +49,8 @@ def test_make_environment_without_configuration_sets_certain_environment_variabl
|
||||
|
||||
|
||||
def test_make_environment_without_configuration_does_not_set_certain_environment_variables_if_already_set():
|
||||
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').and_return(None)
|
||||
flexmock(module.os).should_receive('pipe').never()
|
||||
flexmock(module.os.environ).should_receive('get').with_args(
|
||||
'BORG_RELOCATED_REPO_ACCESS_IS_OK'
|
||||
).and_return('yup')
|
||||
@@ -48,6 +63,8 @@ def test_make_environment_without_configuration_does_not_set_certain_environment
|
||||
|
||||
|
||||
def test_make_environment_with_relocated_repo_access_true_should_set_environment_yes():
|
||||
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').and_return(None)
|
||||
flexmock(module.os).should_receive('pipe').never()
|
||||
flexmock(module.os.environ).should_receive('get').and_return(None)
|
||||
environment = module.make_environment({'relocated_repo_access_is_ok': True})
|
||||
|
||||
@@ -55,6 +72,8 @@ def test_make_environment_with_relocated_repo_access_true_should_set_environment
|
||||
|
||||
|
||||
def test_make_environment_with_relocated_repo_access_false_should_set_environment_no():
|
||||
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').and_return(None)
|
||||
flexmock(module.os).should_receive('pipe').never()
|
||||
flexmock(module.os.environ).should_receive('get').and_return(None)
|
||||
environment = module.make_environment({'relocated_repo_access_is_ok': False})
|
||||
|
||||
@@ -62,6 +81,8 @@ def test_make_environment_with_relocated_repo_access_false_should_set_environmen
|
||||
|
||||
|
||||
def test_make_environment_check_i_know_what_i_am_doing_true_should_set_environment_YES():
|
||||
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').and_return(None)
|
||||
flexmock(module.os).should_receive('pipe').never()
|
||||
flexmock(module.os.environ).should_receive('get').and_return(None)
|
||||
environment = module.make_environment({'check_i_know_what_i_am_doing': True})
|
||||
|
||||
@@ -69,6 +90,8 @@ def test_make_environment_check_i_know_what_i_am_doing_true_should_set_environme
|
||||
|
||||
|
||||
def test_make_environment_check_i_know_what_i_am_doing_false_should_set_environment_NO():
|
||||
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').and_return(None)
|
||||
flexmock(module.os).should_receive('pipe').never()
|
||||
flexmock(module.os.environ).should_receive('get').and_return(None)
|
||||
environment = module.make_environment({'check_i_know_what_i_am_doing': False})
|
||||
|
||||
@@ -76,6 +99,8 @@ def test_make_environment_check_i_know_what_i_am_doing_false_should_set_environm
|
||||
|
||||
|
||||
def test_make_environment_with_integer_variable_value():
|
||||
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').and_return(None)
|
||||
flexmock(module.os).should_receive('pipe').never()
|
||||
flexmock(module.os.environ).should_receive('get').and_return(None)
|
||||
environment = module.make_environment({'borg_files_cache_ttl': 40})
|
||||
assert environment.get('BORG_FILES_CACHE_TTL') == '40'
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import pytest
|
||||
from flexmock import flexmock
|
||||
|
||||
from borgmatic.hooks.credential import passcommand as module
|
||||
|
||||
|
||||
def test_run_passcommand_with_passphrase_configured_bails():
|
||||
flexmock(module.borgmatic.execute).should_receive('execute_command_and_capture_output').never()
|
||||
|
||||
assert (
|
||||
module.run_passcommand('passcommand', passphrase_configured=True, working_directory=None)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_run_passcommand_without_passphrase_configured_executes_passcommand():
|
||||
flexmock(module.borgmatic.execute).should_receive(
|
||||
'execute_command_and_capture_output'
|
||||
).and_return('passphrase').once()
|
||||
|
||||
assert (
|
||||
module.run_passcommand('passcommand', passphrase_configured=False, working_directory=None)
|
||||
== 'passphrase'
|
||||
)
|
||||
|
||||
|
||||
def test_load_credential_with_unknown_credential_name_errors():
|
||||
with pytest.raises(ValueError):
|
||||
module.load_credential(hook_config={}, config={}, credential_name='wtf')
|
||||
|
||||
|
||||
def test_load_credential_with_configured_passcommand_runs_it():
|
||||
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
|
||||
'/working'
|
||||
)
|
||||
flexmock(module).should_receive('run_passcommand').with_args(
|
||||
'command', False, '/working'
|
||||
).and_return('passphrase').once()
|
||||
|
||||
assert (
|
||||
module.load_credential(
|
||||
hook_config={},
|
||||
config={'encryption_passcommand': 'command'},
|
||||
credential_name='encryption_passphrase',
|
||||
)
|
||||
== 'passphrase'
|
||||
)
|
||||
|
||||
|
||||
def test_load_credential_with_configured_passphrase_and_passcommand_detects_passphrase():
|
||||
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
|
||||
'/working'
|
||||
)
|
||||
flexmock(module).should_receive('run_passcommand').with_args(
|
||||
'command', True, '/working'
|
||||
).and_return(None).once()
|
||||
|
||||
assert (
|
||||
module.load_credential(
|
||||
hook_config={},
|
||||
config={'encryption_passphrase': 'passphrase', 'encryption_passcommand': 'command'},
|
||||
credential_name='encryption_passphrase',
|
||||
)
|
||||
is None
|
||||
)
|
||||
@@ -17,6 +17,9 @@ def test_call_hook_invokes_module_function_with_arguments_and_returns_value():
|
||||
config = {'super_hook': flexmock(), 'other_hook': flexmock()}
|
||||
expected_return_value = flexmock()
|
||||
test_module = sys.modules[__name__]
|
||||
flexmock(module).should_receive('get_submodule_names').with_args(
|
||||
module.borgmatic.hooks.credential
|
||||
).and_return(['other_hook'])
|
||||
flexmock(module).should_receive('get_submodule_names').with_args(
|
||||
module.borgmatic.hooks.data_source
|
||||
).and_return(['other_hook'])
|
||||
@@ -39,6 +42,9 @@ def test_call_hook_probes_config_with_databases_suffix():
|
||||
config = {'super_hook_databases': flexmock(), 'other_hook': flexmock()}
|
||||
expected_return_value = flexmock()
|
||||
test_module = sys.modules[__name__]
|
||||
flexmock(module).should_receive('get_submodule_names').with_args(
|
||||
module.borgmatic.hooks.credential
|
||||
).and_return(['other_hook'])
|
||||
flexmock(module).should_receive('get_submodule_names').with_args(
|
||||
module.borgmatic.hooks.data_source
|
||||
).and_return(['other_hook'])
|
||||
@@ -61,6 +67,9 @@ def test_call_hook_strips_databases_suffix_from_hook_name():
|
||||
config = {'super_hook_databases': flexmock(), 'other_hook_databases': flexmock()}
|
||||
expected_return_value = flexmock()
|
||||
test_module = sys.modules[__name__]
|
||||
flexmock(module).should_receive('get_submodule_names').with_args(
|
||||
module.borgmatic.hooks.credential
|
||||
).and_return(['other_hook'])
|
||||
flexmock(module).should_receive('get_submodule_names').with_args(
|
||||
module.borgmatic.hooks.data_source
|
||||
).and_return(['other_hook'])
|
||||
@@ -83,6 +92,9 @@ def test_call_hook_without_hook_config_invokes_module_function_with_arguments_an
|
||||
config = {'other_hook': flexmock()}
|
||||
expected_return_value = flexmock()
|
||||
test_module = sys.modules[__name__]
|
||||
flexmock(module).should_receive('get_submodule_names').with_args(
|
||||
module.borgmatic.hooks.credential
|
||||
).and_return(['other_hook'])
|
||||
flexmock(module).should_receive('get_submodule_names').with_args(
|
||||
module.borgmatic.hooks.data_source
|
||||
).and_return(['other_hook'])
|
||||
@@ -104,6 +116,9 @@ def test_call_hook_without_hook_config_invokes_module_function_with_arguments_an
|
||||
def test_call_hook_without_corresponding_module_raises():
|
||||
config = {'super_hook': flexmock(), 'other_hook': flexmock()}
|
||||
test_module = sys.modules[__name__]
|
||||
flexmock(module).should_receive('get_submodule_names').with_args(
|
||||
module.borgmatic.hooks.credential
|
||||
).and_return(['other_hook'])
|
||||
flexmock(module).should_receive('get_submodule_names').with_args(
|
||||
module.borgmatic.hooks.data_source
|
||||
).and_return(['other_hook'])
|
||||
@@ -121,6 +136,9 @@ def test_call_hook_without_corresponding_module_raises():
|
||||
|
||||
def test_call_hook_skips_non_hook_modules():
|
||||
config = {'not_a_hook': flexmock(), 'other_hook': flexmock()}
|
||||
flexmock(module).should_receive('get_submodule_names').with_args(
|
||||
module.borgmatic.hooks.credential
|
||||
).and_return(['other_hook'])
|
||||
flexmock(module).should_receive('get_submodule_names').with_args(
|
||||
module.borgmatic.hooks.data_source
|
||||
).and_return(['other_hook'])
|
||||
|
||||
+139
-8
@@ -185,6 +185,7 @@ def test_execute_command_calls_full_command():
|
||||
shell=False,
|
||||
env=None,
|
||||
cwd=None,
|
||||
close_fds=True,
|
||||
).and_return(flexmock(stdout=None)).once()
|
||||
flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock())
|
||||
flexmock(module).should_receive('log_outputs')
|
||||
@@ -207,6 +208,7 @@ def test_execute_command_calls_full_command_with_output_file():
|
||||
shell=False,
|
||||
env=None,
|
||||
cwd=None,
|
||||
close_fds=True,
|
||||
).and_return(flexmock(stderr=None)).once()
|
||||
flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock())
|
||||
flexmock(module).should_receive('log_outputs')
|
||||
@@ -221,7 +223,14 @@ def test_execute_command_calls_full_command_without_capturing_output():
|
||||
flexmock(module).should_receive('log_command')
|
||||
flexmock(module.os, environ={'a': 'b'})
|
||||
flexmock(module.subprocess).should_receive('Popen').with_args(
|
||||
full_command, stdin=None, stdout=None, stderr=None, shell=False, env=None, cwd=None
|
||||
full_command,
|
||||
stdin=None,
|
||||
stdout=None,
|
||||
stderr=None,
|
||||
shell=False,
|
||||
env=None,
|
||||
cwd=None,
|
||||
close_fds=True,
|
||||
).and_return(flexmock(wait=lambda: 0)).once()
|
||||
flexmock(module).should_receive('interpret_exit_code').and_return(module.Exit_status.SUCCESS)
|
||||
flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock())
|
||||
@@ -245,6 +254,7 @@ def test_execute_command_calls_full_command_with_input_file():
|
||||
shell=False,
|
||||
env=None,
|
||||
cwd=None,
|
||||
close_fds=True,
|
||||
).and_return(flexmock(stdout=None)).once()
|
||||
flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock())
|
||||
flexmock(module).should_receive('log_outputs')
|
||||
@@ -266,6 +276,7 @@ def test_execute_command_calls_full_command_with_shell():
|
||||
shell=True,
|
||||
env=None,
|
||||
cwd=None,
|
||||
close_fds=True,
|
||||
).and_return(flexmock(stdout=None)).once()
|
||||
flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock())
|
||||
flexmock(module).should_receive('log_outputs')
|
||||
@@ -287,6 +298,7 @@ def test_execute_command_calls_full_command_with_extra_environment():
|
||||
shell=False,
|
||||
env={'a': 'b', 'c': 'd'},
|
||||
cwd=None,
|
||||
close_fds=True,
|
||||
).and_return(flexmock(stdout=None)).once()
|
||||
flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock())
|
||||
flexmock(module).should_receive('log_outputs')
|
||||
@@ -308,6 +320,7 @@ def test_execute_command_calls_full_command_with_working_directory():
|
||||
shell=False,
|
||||
env=None,
|
||||
cwd='/working',
|
||||
close_fds=True,
|
||||
).and_return(flexmock(stdout=None)).once()
|
||||
flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock())
|
||||
flexmock(module).should_receive('log_outputs')
|
||||
@@ -317,6 +330,28 @@ def test_execute_command_calls_full_command_with_working_directory():
|
||||
assert output is None
|
||||
|
||||
|
||||
def test_execute_command_with_BORG_PASSPHRASE_FD_leaves_file_descriptors_open():
|
||||
full_command = ['foo', 'bar']
|
||||
flexmock(module).should_receive('log_command')
|
||||
flexmock(module.os, environ={'a': 'b'})
|
||||
flexmock(module.subprocess).should_receive('Popen').with_args(
|
||||
full_command,
|
||||
stdin=None,
|
||||
stdout=module.subprocess.PIPE,
|
||||
stderr=module.subprocess.STDOUT,
|
||||
shell=False,
|
||||
env={'a': 'b', 'BORG_PASSPHRASE_FD': '4'},
|
||||
cwd=None,
|
||||
close_fds=False,
|
||||
).and_return(flexmock(stdout=None)).once()
|
||||
flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock())
|
||||
flexmock(module).should_receive('log_outputs')
|
||||
|
||||
output = module.execute_command(full_command, extra_environment={'BORG_PASSPHRASE_FD': '4'})
|
||||
|
||||
assert output is None
|
||||
|
||||
|
||||
def test_execute_command_without_run_to_completion_returns_process():
|
||||
full_command = ['foo', 'bar']
|
||||
process = flexmock()
|
||||
@@ -330,6 +365,7 @@ def test_execute_command_without_run_to_completion_returns_process():
|
||||
shell=False,
|
||||
env=None,
|
||||
cwd=None,
|
||||
close_fds=True,
|
||||
).and_return(process).once()
|
||||
flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock())
|
||||
flexmock(module).should_receive('log_outputs')
|
||||
@@ -343,7 +379,12 @@ def test_execute_command_and_capture_output_returns_stdout():
|
||||
flexmock(module).should_receive('log_command')
|
||||
flexmock(module.os, environ={'a': 'b'})
|
||||
flexmock(module.subprocess).should_receive('check_output').with_args(
|
||||
full_command, stderr=None, shell=False, env=None, cwd=None
|
||||
full_command,
|
||||
stderr=None,
|
||||
shell=False,
|
||||
env=None,
|
||||
cwd=None,
|
||||
close_fds=True,
|
||||
).and_return(flexmock(decode=lambda: expected_output)).once()
|
||||
|
||||
output = module.execute_command_and_capture_output(full_command)
|
||||
@@ -357,7 +398,12 @@ def test_execute_command_and_capture_output_with_capture_stderr_returns_stderr()
|
||||
flexmock(module).should_receive('log_command')
|
||||
flexmock(module.os, environ={'a': 'b'})
|
||||
flexmock(module.subprocess).should_receive('check_output').with_args(
|
||||
full_command, stderr=module.subprocess.STDOUT, shell=False, env=None, cwd=None
|
||||
full_command,
|
||||
stderr=module.subprocess.STDOUT,
|
||||
shell=False,
|
||||
env=None,
|
||||
cwd=None,
|
||||
close_fds=True,
|
||||
).and_return(flexmock(decode=lambda: expected_output)).once()
|
||||
|
||||
output = module.execute_command_and_capture_output(full_command, capture_stderr=True)
|
||||
@@ -372,7 +418,12 @@ def test_execute_command_and_capture_output_returns_output_when_process_error_is
|
||||
flexmock(module).should_receive('log_command')
|
||||
flexmock(module.os, environ={'a': 'b'})
|
||||
flexmock(module.subprocess).should_receive('check_output').with_args(
|
||||
full_command, stderr=None, shell=False, env=None, cwd=None
|
||||
full_command,
|
||||
stderr=None,
|
||||
shell=False,
|
||||
env=None,
|
||||
cwd=None,
|
||||
close_fds=True,
|
||||
).and_raise(subprocess.CalledProcessError(1, full_command, err_output)).once()
|
||||
flexmock(module).should_receive('interpret_exit_code').and_return(
|
||||
module.Exit_status.SUCCESS
|
||||
@@ -389,7 +440,12 @@ def test_execute_command_and_capture_output_raises_when_command_errors():
|
||||
flexmock(module).should_receive('log_command')
|
||||
flexmock(module.os, environ={'a': 'b'})
|
||||
flexmock(module.subprocess).should_receive('check_output').with_args(
|
||||
full_command, stderr=None, shell=False, env=None, cwd=None
|
||||
full_command,
|
||||
stderr=None,
|
||||
shell=False,
|
||||
env=None,
|
||||
cwd=None,
|
||||
close_fds=True,
|
||||
).and_raise(subprocess.CalledProcessError(2, full_command, expected_output)).once()
|
||||
flexmock(module).should_receive('interpret_exit_code').and_return(
|
||||
module.Exit_status.ERROR
|
||||
@@ -405,7 +461,12 @@ def test_execute_command_and_capture_output_returns_output_with_shell():
|
||||
flexmock(module).should_receive('log_command')
|
||||
flexmock(module.os, environ={'a': 'b'})
|
||||
flexmock(module.subprocess).should_receive('check_output').with_args(
|
||||
'foo bar', stderr=None, shell=True, env=None, cwd=None
|
||||
'foo bar',
|
||||
stderr=None,
|
||||
shell=True,
|
||||
env=None,
|
||||
cwd=None,
|
||||
close_fds=True,
|
||||
).and_return(flexmock(decode=lambda: expected_output)).once()
|
||||
|
||||
output = module.execute_command_and_capture_output(full_command, shell=True)
|
||||
@@ -424,6 +485,7 @@ def test_execute_command_and_capture_output_returns_output_with_extra_environmen
|
||||
shell=False,
|
||||
env={'a': 'b', 'c': 'd'},
|
||||
cwd=None,
|
||||
close_fds=True,
|
||||
).and_return(flexmock(decode=lambda: expected_output)).once()
|
||||
|
||||
output = module.execute_command_and_capture_output(
|
||||
@@ -439,7 +501,12 @@ def test_execute_command_and_capture_output_returns_output_with_working_director
|
||||
flexmock(module).should_receive('log_command')
|
||||
flexmock(module.os, environ={'a': 'b'})
|
||||
flexmock(module.subprocess).should_receive('check_output').with_args(
|
||||
full_command, stderr=None, shell=False, env=None, cwd='/working'
|
||||
full_command,
|
||||
stderr=None,
|
||||
shell=False,
|
||||
env=None,
|
||||
cwd='/working',
|
||||
close_fds=True,
|
||||
).and_return(flexmock(decode=lambda: expected_output)).once()
|
||||
|
||||
output = module.execute_command_and_capture_output(
|
||||
@@ -449,6 +516,29 @@ def test_execute_command_and_capture_output_returns_output_with_working_director
|
||||
assert output == expected_output
|
||||
|
||||
|
||||
def test_execute_command_and_capture_output_with_BORG_PASSPHRASE_FD_leaves_file_descriptors_open():
|
||||
full_command = ['foo', 'bar']
|
||||
expected_output = '[]'
|
||||
flexmock(module).should_receive('log_command')
|
||||
flexmock(module.os, environ={'a': 'b'})
|
||||
flexmock(module.subprocess).should_receive('check_output').with_args(
|
||||
full_command,
|
||||
stderr=None,
|
||||
shell=False,
|
||||
env={'a': 'b', 'BORG_PASSPHRASE_FD': '4'},
|
||||
cwd=None,
|
||||
close_fds=False,
|
||||
).and_return(flexmock(decode=lambda: expected_output)).once()
|
||||
|
||||
output = module.execute_command_and_capture_output(
|
||||
full_command,
|
||||
shell=False,
|
||||
extra_environment={'BORG_PASSPHRASE_FD': '4'},
|
||||
)
|
||||
|
||||
assert output == expected_output
|
||||
|
||||
|
||||
def test_execute_command_with_processes_calls_full_command():
|
||||
full_command = ['foo', 'bar']
|
||||
processes = (flexmock(),)
|
||||
@@ -462,6 +552,7 @@ def test_execute_command_with_processes_calls_full_command():
|
||||
shell=False,
|
||||
env=None,
|
||||
cwd=None,
|
||||
close_fds=True,
|
||||
).and_return(flexmock(stdout=None)).once()
|
||||
flexmock(module).should_receive('log_outputs')
|
||||
|
||||
@@ -484,6 +575,7 @@ def test_execute_command_with_processes_returns_output_with_output_log_level_non
|
||||
shell=False,
|
||||
env=None,
|
||||
cwd=None,
|
||||
close_fds=True,
|
||||
).and_return(process).once()
|
||||
flexmock(module).should_receive('log_outputs').and_return({process: 'out'})
|
||||
|
||||
@@ -506,6 +598,7 @@ def test_execute_command_with_processes_calls_full_command_with_output_file():
|
||||
shell=False,
|
||||
env=None,
|
||||
cwd=None,
|
||||
close_fds=True,
|
||||
).and_return(flexmock(stderr=None)).once()
|
||||
flexmock(module).should_receive('log_outputs')
|
||||
|
||||
@@ -520,7 +613,14 @@ def test_execute_command_with_processes_calls_full_command_without_capturing_out
|
||||
flexmock(module).should_receive('log_command')
|
||||
flexmock(module.os, environ={'a': 'b'})
|
||||
flexmock(module.subprocess).should_receive('Popen').with_args(
|
||||
full_command, stdin=None, stdout=None, stderr=None, shell=False, env=None, cwd=None
|
||||
full_command,
|
||||
stdin=None,
|
||||
stdout=None,
|
||||
stderr=None,
|
||||
shell=False,
|
||||
env=None,
|
||||
cwd=None,
|
||||
close_fds=True,
|
||||
).and_return(flexmock(wait=lambda: 0)).once()
|
||||
flexmock(module).should_receive('interpret_exit_code').and_return(module.Exit_status.SUCCESS)
|
||||
flexmock(module).should_receive('log_outputs')
|
||||
@@ -546,6 +646,7 @@ def test_execute_command_with_processes_calls_full_command_with_input_file():
|
||||
shell=False,
|
||||
env=None,
|
||||
cwd=None,
|
||||
close_fds=True,
|
||||
).and_return(flexmock(stdout=None)).once()
|
||||
flexmock(module).should_receive('log_outputs')
|
||||
|
||||
@@ -567,6 +668,7 @@ def test_execute_command_with_processes_calls_full_command_with_shell():
|
||||
shell=True,
|
||||
env=None,
|
||||
cwd=None,
|
||||
close_fds=True,
|
||||
).and_return(flexmock(stdout=None)).once()
|
||||
flexmock(module).should_receive('log_outputs')
|
||||
|
||||
@@ -588,6 +690,7 @@ def test_execute_command_with_processes_calls_full_command_with_extra_environmen
|
||||
shell=False,
|
||||
env={'a': 'b', 'c': 'd'},
|
||||
cwd=None,
|
||||
close_fds=True,
|
||||
).and_return(flexmock(stdout=None)).once()
|
||||
flexmock(module).should_receive('log_outputs')
|
||||
|
||||
@@ -611,6 +714,7 @@ def test_execute_command_with_processes_calls_full_command_with_working_director
|
||||
shell=False,
|
||||
env=None,
|
||||
cwd='/working',
|
||||
close_fds=True,
|
||||
).and_return(flexmock(stdout=None)).once()
|
||||
flexmock(module).should_receive('log_outputs')
|
||||
|
||||
@@ -621,6 +725,32 @@ def test_execute_command_with_processes_calls_full_command_with_working_director
|
||||
assert output is None
|
||||
|
||||
|
||||
def test_execute_command_with_processes_with_BORG_PASSPHRASE_FD_leaves_file_descriptors_open():
|
||||
full_command = ['foo', 'bar']
|
||||
processes = (flexmock(),)
|
||||
flexmock(module).should_receive('log_command')
|
||||
flexmock(module.os, environ={'a': 'b'})
|
||||
flexmock(module.subprocess).should_receive('Popen').with_args(
|
||||
full_command,
|
||||
stdin=None,
|
||||
stdout=module.subprocess.PIPE,
|
||||
stderr=module.subprocess.STDOUT,
|
||||
shell=False,
|
||||
env={'a': 'b', 'BORG_PASSPHRASE_FD': '4'},
|
||||
cwd=None,
|
||||
close_fds=False,
|
||||
).and_return(flexmock(stdout=None)).once()
|
||||
flexmock(module).should_receive('log_outputs')
|
||||
|
||||
output = module.execute_command_with_processes(
|
||||
full_command,
|
||||
processes,
|
||||
extra_environment={'BORG_PASSPHRASE_FD': '4'},
|
||||
)
|
||||
|
||||
assert output is None
|
||||
|
||||
|
||||
def test_execute_command_with_processes_kills_processes_on_error():
|
||||
full_command = ['foo', 'bar']
|
||||
flexmock(module).should_receive('log_command')
|
||||
@@ -637,6 +767,7 @@ def test_execute_command_with_processes_kills_processes_on_error():
|
||||
shell=False,
|
||||
env=None,
|
||||
cwd=None,
|
||||
close_fds=True,
|
||||
).and_raise(subprocess.CalledProcessError(1, full_command, 'error')).once()
|
||||
flexmock(module).should_receive('log_outputs').never()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user