First crack at MySQL/MariaDB database restore (#228).

This commit is contained in:
Dan Helfman 2019-11-11 21:59:30 -08:00
parent 4ed43ae4dc
commit ded042d8cc
12 changed files with 354 additions and 157 deletions

View file

@ -18,7 +18,7 @@ from borgmatic.borg import list as borg_list
from borgmatic.borg import prune as borg_prune from borgmatic.borg import prune as borg_prune
from borgmatic.commands.arguments import parse_arguments from borgmatic.commands.arguments import parse_arguments
from borgmatic.config import checks, collect, convert, validate from borgmatic.config import checks, collect, convert, validate
from borgmatic.hooks import command, cronhub, cronitor, healthchecks, mysql, postgresql from borgmatic.hooks import command, cronhub, cronitor, dispatch, dump, healthchecks
from borgmatic.logger import configure_logging, should_do_markup from borgmatic.logger import configure_logging, should_do_markup
from borgmatic.signals import configure_signals from borgmatic.signals import configure_signals
from borgmatic.verbosity import verbosity_to_log_level from borgmatic.verbosity import verbosity_to_log_level
@ -69,11 +69,12 @@ def run_configuration(config_filename, config, arguments):
'pre-backup', 'pre-backup',
global_arguments.dry_run, global_arguments.dry_run,
) )
postgresql.dump_databases( dispatch.call_hooks(
hooks.get('postgresql_databases'), config_filename, global_arguments.dry_run 'dump_databases',
) hooks,
mysql.dump_databases( config_filename,
hooks.get('mysql_databases'), config_filename, global_arguments.dry_run dump.DATABASE_HOOK_NAMES,
global_arguments.dry_run,
) )
except (OSError, CalledProcessError) as error: except (OSError, CalledProcessError) as error:
encountered_error = error encountered_error = error
@ -104,11 +105,12 @@ def run_configuration(config_filename, config, arguments):
if 'create' in arguments and not encountered_error: if 'create' in arguments and not encountered_error:
try: try:
postgresql.remove_database_dumps( dispatch.call_hooks(
hooks.get('postgresql_databases'), config_filename, global_arguments.dry_run 'remove_database_dumps',
) hooks,
mysql.remove_database_dumps( config_filename,
hooks.get('mysql_databases'), config_filename, global_arguments.dry_run dump.DATABASE_HOOK_NAMES,
global_arguments.dry_run,
) )
command.execute_hook( command.execute_hook(
hooks.get('after_backup'), hooks.get('after_backup'),
@ -260,12 +262,20 @@ def run_actions(
restore_names = [] restore_names = []
# Extract dumps for the named databases from the archive. # Extract dumps for the named databases from the archive.
dump_patterns = postgresql.make_database_dump_patterns(restore_names) dump_patterns = dispatch.call_hooks(
'make_database_dump_patterns',
hooks,
repository,
dump.DATABASE_HOOK_NAMES,
restore_names,
)
borg_extract.extract_archive( borg_extract.extract_archive(
global_arguments.dry_run, global_arguments.dry_run,
repository, repository,
arguments['restore'].archive, arguments['restore'].archive,
postgresql.convert_glob_patterns_to_borg_patterns(dump_patterns), dump.convert_glob_patterns_to_borg_patterns(
[pattern for patterns in dump_patterns.values() for pattern in patterns]
),
location, location,
storage, storage,
local_path=local_path, local_path=local_path,
@ -274,17 +284,35 @@ def run_actions(
progress=arguments['restore'].progress, progress=arguments['restore'].progress,
) )
# Map the restore names to the corresponding database configurations. # Map the restore names or detected dumps to the corresponding database configurations.
databases = list( # TODO: Need to filter restore_names by database type? Maybe take a database --type argument to disambiguate.
postgresql.get_database_configurations( restore_databases = {
hooks.get('postgresql_databases'), hook_name: list(
restore_names or postgresql.get_database_names_from_dumps(dump_patterns), dump.get_database_configurations(
hooks.get(hook_name),
restore_names
or dump.get_database_names_from_dumps(dump_patterns['hook_name']),
) )
) )
for hook_name in dump.DATABASE_HOOK_NAMES
if hook_name in hooks
}
# Finally, restore the databases and cleanup the dumps. # Finally, restore the databases and cleanup the dumps.
postgresql.restore_database_dumps(databases, repository, global_arguments.dry_run) dispatch.call_hooks(
postgresql.remove_database_dumps(databases, repository, global_arguments.dry_run) 'restore_database_dumps',
restore_databases,
repository,
dump.DATABASE_HOOK_NAMES,
global_arguments.dry_run,
)
dispatch.call_hooks(
'remove_database_dumps',
restore_databases,
repository,
dump.DATABASE_HOOK_NAMES,
global_arguments.dry_run,
)
if 'list' in arguments: if 'list' in arguments:
if arguments['list'].repository is None or repository == arguments['list'].repository: if arguments['list'].repository is None or repository == arguments['list'].repository:
logger.info('{}: Listing archives'.format(repository)) logger.info('{}: Listing archives'.format(repository))

View file

@ -61,6 +61,7 @@ def execute_command(
full_command, full_command,
output_log_level=logging.INFO, output_log_level=logging.INFO,
output_file=None, output_file=None,
input_file=None,
shell=False, shell=False,
extra_environment=None, extra_environment=None,
working_directory=None, working_directory=None,
@ -70,10 +71,11 @@ def execute_command(
Execute the given command (a sequence of command/argument strings) and log its output at the Execute the given command (a sequence of command/argument strings) and log its output at the
given log level. If output log level is None, instead capture and return the output. If an given log level. If output log level is None, instead capture and return the output. If an
open output file object is given, then write stdout to the file and only log stderr (but only open output file object is given, then write stdout to the file and only log stderr (but only
if an output log level is set). If shell is True, execute the command within a shell. If an if an output log level is set). If an open input file object is given, then read stdin from the
extra environment dict is given, then use it to augment the current environment, and pass the file. If shell is True, execute the command within a shell. If an extra environment dict is
result into the command. If a working directory is given, use that as the present working given, then use it to augment the current environment, and pass the result into the command. If
directory when running the command. a working directory is given, use that as the present working directory when running the
command.
Raise subprocesses.CalledProcessError if an error occurs while running the command. Raise subprocesses.CalledProcessError if an error occurs while running the command.
''' '''
@ -88,6 +90,7 @@ def execute_command(
else: else:
process = subprocess.Popen( process = subprocess.Popen(
full_command, full_command,
stdin=input_file,
stdout=output_file or subprocess.PIPE, stdout=output_file or subprocess.PIPE,
stderr=subprocess.PIPE if output_file else subprocess.STDOUT, stderr=subprocess.PIPE if output_file else subprocess.STDOUT,
shell=shell, shell=shell,

View file

@ -0,0 +1,55 @@
import logging
from borgmatic.hooks import mysql, postgresql
logger = logging.getLogger(__name__)
HOOK_NAME_TO_MODULE = {'postgresql_databases': postgresql, 'mysql_databases': mysql}
def call_hook(function_name, hooks, log_prefix, hook_name, *args, **kwargs):
'''
Given the hooks configuration dict and a prefix to use in log entries, call the requested
function of the Python module corresponding to the given hook name. Supply that call with the
configuration for this hook, the log prefix, and any given args and kwargs. Return any return
value.
If the hook name is not present in the hooks configuration, then bail without calling anything.
Raise ValueError if the hook name is unknown.
Raise AttributeError if the function name is not found in the module.
Raise anything else that the called function raises.
'''
config = hooks.get(hook_name)
if not config:
logger.debug('{}: No {} hook configured.'.format(log_prefix, hook_name))
return
try:
module = HOOK_NAME_TO_MODULE[hook_name]
except KeyError:
raise ValueError('Unknown hook name: {}'.format(hook_name))
logger.debug('{}: Calling {} hook function {}'.format(log_prefix, hook_name, function_name))
return getattr(module, function_name)(config, log_prefix, *args, **kwargs)
def call_hooks(function_name, hooks, log_prefix, hook_names, *args, **kwargs):
'''
Given the hooks configuration dict and a prefix to use in log entries, call the requested
function of the Python module corresponding to each given hook name. Supply each call with the
configuration for that hook, the log prefix, and any given args and kwargs. Collect any return
values into a dict from hook name to return value.
If the hook name is not present in the hooks configuration, then don't call the function for it,
and omit it from the return values.
Raise ValueError if the hook name is unknown.
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.
'''
return {
hook_name: call_hook(function_name, hooks, log_prefix, hook_name, *args, **kwargs)
for hook_name in hook_names
if hook_name in hooks
}

View file

@ -1,8 +1,11 @@
import glob
import logging import logging
import os import os
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DATABASE_HOOK_NAMES = ('postgresql_databases', 'mysql_databases')
def make_database_dump_filename(dump_path, name, hostname=None): def make_database_dump_filename(dump_path, name, hostname=None):
''' '''
@ -52,3 +55,48 @@ def remove_database_dumps(dump_path, databases, database_type_name, log_prefix,
if len(os.listdir(dump_path)) == 0: if len(os.listdir(dump_path)) == 0:
os.rmdir(dump_path) os.rmdir(dump_path)
def convert_glob_patterns_to_borg_patterns(patterns):
'''
Convert a sequence of shell glob patterns like "/etc/*" to the corresponding Borg archive
patterns like "sh:etc/*".
'''
return ['sh:{}'.format(pattern.lstrip(os.path.sep)) for pattern in patterns]
def get_database_names_from_dumps(patterns):
'''
Given a sequence of database dump patterns, find the corresponding database dumps on disk and
return the database names from their filenames.
'''
return [os.path.basename(dump_path) for pattern in patterns for dump_path in glob.glob(pattern)]
def get_database_configurations(databases, names):
'''
Given the full database configuration dicts as per the configuration schema, and a sequence of
database names, filter down and yield the configuration for just the named databases.
Additionally, if a database configuration is named "all", project out that configuration for
each named database.
Raise ValueError if one of the database names cannot be matched to a database in borgmatic's
database configuration.
'''
named_databases = {database['name']: database for database in databases}
for name in names:
database = named_databases.get(name)
if database:
yield database
continue
if 'all' in named_databases:
yield {**named_databases['all'], **{'name': name}}
continue
raise ValueError(
'Cannot restore database "{}", as it is not defined in borgmatic\'s configuration'.format(
name
)
)

View file

@ -14,10 +14,6 @@ def dump_databases(databases, log_prefix, dry_run):
dicts, one dict describing each database as per the configuration schema. Use the given log dicts, one dict describing each database as per the configuration schema. Use the given log
prefix in any log entries. If this is a dry run, then don't actually dump anything. prefix in any log entries. If this is a dry run, then don't actually dump anything.
''' '''
if not databases:
logger.debug('{}: No MySQL databases configured'.format(log_prefix))
return
dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else '' dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
logger.info('{}: Dumping MySQL databases{}'.format(log_prefix, dry_run_label)) logger.info('{}: Dumping MySQL databases{}'.format(log_prefix, dry_run_label))
@ -48,10 +44,51 @@ def dump_databases(databases, log_prefix, dry_run):
) )
def remove_database_dumps(databases, log_prefix, dry_run): def remove_database_dumps(databases, log_prefix, dry_run): # pragma: no cover
''' '''
Remove the database dumps for the given databases. The databases are supplied as a sequence of Remove the database dumps for the given databases. The databases are supplied as a sequence of
dicts, one dict describing each database as per the configuration schema. Use the log prefix in dicts, one dict describing each database as per the configuration schema. Use the log prefix in
any log entries. If this is a dry run, then don't actually remove anything. any log entries. If this is a dry run, then don't actually remove anything.
''' '''
dump.remove_database_dumps(DUMP_PATH, databases, 'MySQL', log_prefix, dry_run) dump.remove_database_dumps(DUMP_PATH, databases, 'MySQL', log_prefix, dry_run)
def make_database_dump_patterns(databases, log_prefix, names):
'''
Given a sequence of configurations dicts, a prefix to log with, and a sequence of database
names to match, return the corresponding glob patterns to match the database dumps in an
archive. An empty sequence of names indicates that the patterns should match all dumps.
'''
return [
dump.make_database_dump_filename(DUMP_PATH, name, hostname='*') for name in (names or ['*'])
]
def restore_database_dumps(databases, log_prefix, dry_run):
'''
Restore the given MySQL/MariaDB databases from disk. The databases are supplied as a sequence of
dicts, one dict describing each database as per the configuration schema. Use the given log
prefix in any log entries. If this is a dry run, then don't actually restore anything.
'''
dry_run_label = ' (dry run; not actually restoring anything)' if dry_run else ''
for database in databases:
dump_filename = dump.make_database_dump_filename(
DUMP_PATH, database['name'], database.get('hostname')
)
restore_command = (
('mysql', '--batch')
+ (('--host', database['hostname']) if 'hostname' in database else ())
+ (('--port', str(database['port'])) if 'port' in database else ())
+ (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ())
+ (('--user', database['username']) if 'username' in database else ())
)
extra_environment = {'MYSQL_PWD': database['password']} if 'password' in database else None
logger.debug(
'{}: Restoring MySQL database {}{}'.format(log_prefix, database['name'], dry_run_label)
)
if not dry_run:
execute_command(
restore_command, input_file=open(dump_filename), extra_environment=extra_environment
)

View file

@ -1,4 +1,3 @@
import glob
import logging import logging
import os import os
@ -15,10 +14,6 @@ def dump_databases(databases, log_prefix, dry_run):
one dict describing each database as per the configuration schema. Use the given log prefix in one dict describing each database as per the configuration schema. Use the given log prefix in
any log entries. If this is a dry run, then don't actually dump anything. any log entries. If this is a dry run, then don't actually dump anything.
''' '''
if not databases:
logger.debug('{}: No PostgreSQL databases configured'.format(log_prefix))
return
dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else '' dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
logger.info('{}: Dumping PostgreSQL databases{}'.format(log_prefix, dry_run_label)) logger.info('{}: Dumping PostgreSQL databases{}'.format(log_prefix, dry_run_label))
@ -49,7 +44,7 @@ def dump_databases(databases, log_prefix, dry_run):
execute_command(command, extra_environment=extra_environment) execute_command(command, extra_environment=extra_environment)
def remove_database_dumps(databases, log_prefix, dry_run): def remove_database_dumps(databases, log_prefix, dry_run): # pragma: no cover
''' '''
Remove the database dumps for the given databases. The databases are supplied as a sequence of Remove the database dumps for the given databases. The databases are supplied as a sequence of
dicts, one dict describing each database as per the configuration schema. Use the log prefix in dicts, one dict describing each database as per the configuration schema. Use the log prefix in
@ -58,72 +53,23 @@ def remove_database_dumps(databases, log_prefix, dry_run):
dump.remove_database_dumps(DUMP_PATH, databases, 'PostgreSQL', log_prefix, dry_run) dump.remove_database_dumps(DUMP_PATH, databases, 'PostgreSQL', log_prefix, dry_run)
def make_database_dump_patterns(names): def make_database_dump_patterns(databases, log_prefix, names):
''' '''
Given a sequence of database names, return the corresponding glob patterns to match the database Given a sequence of configurations dicts, a prefix to log with, and a sequence of database
dumps in an archive. An empty sequence of names indicates that the patterns should match all names to match, return the corresponding glob patterns to match the database dumps in an
dumps. archive. An empty sequence of names indicates that the patterns should match all dumps.
''' '''
return [ return [
dump.make_database_dump_filename(DUMP_PATH, name, hostname='*') for name in (names or ['*']) dump.make_database_dump_filename(DUMP_PATH, name, hostname='*') for name in (names or ['*'])
] ]
def convert_glob_patterns_to_borg_patterns(patterns):
'''
Convert a sequence of shell glob patterns like "/etc/*" to the corresponding Borg archive
patterns like "sh:etc/*".
'''
return ['sh:{}'.format(pattern.lstrip(os.path.sep)) for pattern in patterns]
def get_database_names_from_dumps(patterns):
'''
Given a sequence of database dump patterns, find the corresponding database dumps on disk and
return the database names from their filenames.
'''
return [os.path.basename(dump_path) for pattern in patterns for dump_path in glob.glob(pattern)]
def get_database_configurations(databases, names):
'''
Given the full database configuration dicts as per the configuration schema, and a sequence of
database names, filter down and yield the configuration for just the named databases.
Additionally, if a database configuration is named "all", project out that configuration for
each named database.
Raise ValueError if one of the database names cannot be matched to a database in borgmatic's
database configuration.
'''
named_databases = {database['name']: database for database in databases}
for name in names:
database = named_databases.get(name)
if database:
yield database
continue
if 'all' in named_databases:
yield {**named_databases['all'], **{'name': name}}
continue
raise ValueError(
'Cannot restore database "{}", as it is not defined in borgmatic\'s configuration'.format(
name
)
)
def restore_database_dumps(databases, log_prefix, dry_run): def restore_database_dumps(databases, log_prefix, dry_run):
''' '''
Restore the given PostgreSQL databases from disk. The databases are supplied as a sequence of Restore the given PostgreSQL databases from disk. The databases are supplied as a sequence of
dicts, one dict describing each database as per the configuration schema. Use the given log dicts, one dict describing each database as per the configuration schema. Use the given log
prefix in any log entries. If this is a dry run, then don't actually restore anything. prefix in any log entries. If this is a dry run, then don't actually restore anything.
''' '''
if not databases:
logger.debug('{}: No PostgreSQL databases configured'.format(log_prefix))
return
dry_run_label = ' (dry run; not actually restoring anything)' if dry_run else '' dry_run_label = ' (dry run; not actually restoring anything)' if dry_run else ''
for database in databases: for database in databases:

View file

@ -23,13 +23,10 @@ def test_run_configuration_runs_actions_for_each_repository():
def test_run_configuration_executes_hooks_for_create_action(): def test_run_configuration_executes_hooks_for_create_action():
flexmock(module.borg_environment).should_receive('initialize') flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.command).should_receive('execute_hook').twice() flexmock(module.command).should_receive('execute_hook').twice()
flexmock(module.postgresql).should_receive('dump_databases').once() flexmock(module.dispatch).should_receive('call_hooks').twice()
flexmock(module.mysql).should_receive('dump_databases').once()
flexmock(module.healthchecks).should_receive('ping_healthchecks').twice() flexmock(module.healthchecks).should_receive('ping_healthchecks').twice()
flexmock(module.cronitor).should_receive('ping_cronitor').twice() flexmock(module.cronitor).should_receive('ping_cronitor').twice()
flexmock(module.cronhub).should_receive('ping_cronhub').twice() flexmock(module.cronhub).should_receive('ping_cronhub').twice()
flexmock(module.postgresql).should_receive('remove_database_dumps').once()
flexmock(module.mysql).should_receive('remove_database_dumps').once()
flexmock(module).should_receive('run_actions').and_return([]) flexmock(module).should_receive('run_actions').and_return([])
config = {'location': {'repositories': ['foo']}} config = {'location': {'repositories': ['foo']}}
arguments = {'global': flexmock(dry_run=False), 'create': flexmock()} arguments = {'global': flexmock(dry_run=False), 'create': flexmock()}
@ -40,8 +37,7 @@ def test_run_configuration_executes_hooks_for_create_action():
def test_run_configuration_logs_actions_error(): def test_run_configuration_logs_actions_error():
flexmock(module.borg_environment).should_receive('initialize') flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.command).should_receive('execute_hook') flexmock(module.command).should_receive('execute_hook')
flexmock(module.postgresql).should_receive('dump_databases') flexmock(module.dispatch).should_receive('call_hooks')
flexmock(module.mysql).should_receive('dump_databases')
flexmock(module.healthchecks).should_receive('ping_healthchecks') flexmock(module.healthchecks).should_receive('ping_healthchecks')
flexmock(module.cronitor).should_receive('ping_cronitor') flexmock(module.cronitor).should_receive('ping_cronitor')
flexmock(module.cronhub).should_receive('ping_cronhub') flexmock(module.cronhub).should_receive('ping_cronhub')
@ -75,6 +71,10 @@ def test_run_configuration_logs_post_hook_error():
flexmock(module.command).should_receive('execute_hook').and_return(None).and_raise( flexmock(module.command).should_receive('execute_hook').and_return(None).and_raise(
OSError OSError
).and_return(None) ).and_return(None)
flexmock(module.dispatch).should_receive('call_hooks')
flexmock(module.healthchecks).should_receive('ping_healthchecks')
flexmock(module.cronitor).should_receive('ping_cronitor')
flexmock(module.cronhub).should_receive('ping_cronhub')
expected_results = [flexmock()] expected_results = [flexmock()]
flexmock(module).should_receive('make_error_log_records').and_return(expected_results) flexmock(module).should_receive('make_error_log_records').and_return(expected_results)
flexmock(module).should_receive('run_actions').and_return([]) flexmock(module).should_receive('run_actions').and_return([])

View file

@ -0,0 +1,68 @@
import sys
import pytest
from flexmock import flexmock
from borgmatic.hooks import dispatch as module
def hook_function(config, log_prefix, thing, value):
'''
This test function gets mocked out below.
'''
pass
def test_call_hook_invokes_module_function_with_arguments_and_returns_value():
hooks = {'super_hook': flexmock(), 'other_hook': flexmock()}
expected_return_value = flexmock()
test_module = sys.modules[__name__]
flexmock(module).HOOK_NAME_TO_MODULE = {'super_hook': test_module}
flexmock(test_module).should_receive('hook_function').with_args(
hooks['super_hook'], 'prefix', 55, value=66
).and_return(expected_return_value).once()
return_value = module.call_hook('hook_function', hooks, 'prefix', 'super_hook', 55, value=66)
assert return_value == expected_return_value
def test_call_hook_without_hook_config_skips_call():
hooks = {'other_hook': flexmock()}
test_module = sys.modules[__name__]
flexmock(module).HOOK_NAME_TO_MODULE = {'super_hook': test_module}
flexmock(test_module).should_receive('hook_function').never()
module.call_hook('hook_function', hooks, 'prefix', 'super_hook', 55, value=66)
def test_call_hook_without_corresponding_module_raises():
hooks = {'super_hook': flexmock(), 'other_hook': flexmock()}
test_module = sys.modules[__name__]
flexmock(module).HOOK_NAME_TO_MODULE = {'other_hook': test_module}
flexmock(test_module).should_receive('hook_function').never()
with pytest.raises(ValueError):
module.call_hook('hook_function', hooks, 'prefix', 'super_hook', 55, value=66)
def test_call_hooks_calls_each_hook_and_collects_return_values():
hooks = {'super_hook': flexmock(), 'other_hook': flexmock()}
expected_return_values = {'super_hook': flexmock(), 'other_hook': flexmock()}
flexmock(module).should_receive('call_hook').and_return(
expected_return_values['super_hook']
).and_return(expected_return_values['other_hook'])
return_values = module.call_hooks('do_stuff', hooks, 'prefix', ('super_hook', 'other_hook'), 55)
assert return_values == expected_return_values
def test_call_hooks_calls_skips_return_values_for_unconfigured_hooks():
hooks = {'super_hook': flexmock()}
expected_return_values = {'super_hook': flexmock()}
flexmock(module).should_receive('call_hook').and_return(expected_return_values['super_hook'])
return_values = module.call_hooks('do_stuff', hooks, 'prefix', ('super_hook', 'other_hook'), 55)
assert return_values == expected_return_values

View file

@ -52,3 +52,50 @@ def test_remove_database_dumps_with_dry_run_skips_removal():
def test_remove_database_dumps_without_databases_does_not_raise(): def test_remove_database_dumps_without_databases_does_not_raise():
module.remove_database_dumps('databases', [], 'SuperDB', 'test.yaml', dry_run=False) module.remove_database_dumps('databases', [], 'SuperDB', 'test.yaml', dry_run=False)
def test_convert_glob_patterns_to_borg_patterns_removes_leading_slash():
assert module.convert_glob_patterns_to_borg_patterns(('/etc/foo/bar',)) == ['sh:etc/foo/bar']
def test_get_database_names_from_dumps_gets_names_from_filenames_matching_globs():
flexmock(module.glob).should_receive('glob').and_return(
('databases/localhost/foo',)
).and_return(('databases/localhost/bar',)).and_return(())
assert module.get_database_names_from_dumps(
('databases/*/foo', 'databases/*/bar', 'databases/*/baz')
) == ['foo', 'bar']
def test_get_database_configurations_only_produces_named_databases():
databases = [
{'name': 'foo', 'hostname': 'example.org'},
{'name': 'bar', 'hostname': 'example.com'},
{'name': 'baz', 'hostname': 'example.org'},
]
assert list(module.get_database_configurations(databases, ('foo', 'baz'))) == [
{'name': 'foo', 'hostname': 'example.org'},
{'name': 'baz', 'hostname': 'example.org'},
]
def test_get_database_configurations_matches_all_database():
databases = [
{'name': 'foo', 'hostname': 'example.org'},
{'name': 'all', 'hostname': 'example.com'},
]
assert list(module.get_database_configurations(databases, ('foo', 'bar', 'baz'))) == [
{'name': 'foo', 'hostname': 'example.org'},
{'name': 'bar', 'hostname': 'example.com'},
{'name': 'baz', 'hostname': 'example.com'},
]
def test_get_database_configurations_with_unknown_database_name_raises():
databases = [{'name': 'foo', 'hostname': 'example.org'}]
with pytest.raises(ValueError):
list(module.get_database_configurations(databases, ('foo', 'bar')))

View file

@ -35,10 +35,6 @@ def test_dump_databases_with_dry_run_skips_mysqldump():
module.dump_databases(databases, 'test.yaml', dry_run=True) module.dump_databases(databases, 'test.yaml', dry_run=True)
def test_dump_databases_without_databases_does_not_raise():
module.dump_databases([], 'test.yaml', dry_run=False)
def test_dump_databases_runs_mysqldump_with_hostname_and_port(): def test_dump_databases_runs_mysqldump_with_hostname_and_port():
databases = [{'name': 'foo', 'hostname': 'database.example.org', 'port': 5433}] databases = [{'name': 'foo', 'hostname': 'database.example.org', 'port': 5433}]
output_file = flexmock() output_file = flexmock()

View file

@ -1,4 +1,3 @@
import pytest
from flexmock import flexmock from flexmock import flexmock
from borgmatic.hooks import postgresql as module from borgmatic.hooks import postgresql as module
@ -40,10 +39,6 @@ def test_dump_databases_with_dry_run_skips_pg_dump():
module.dump_databases(databases, 'test.yaml', dry_run=True) module.dump_databases(databases, 'test.yaml', dry_run=True)
def test_dump_databases_without_databases_does_not_raise():
module.dump_databases([], 'test.yaml', dry_run=False)
def test_dump_databases_runs_pg_dump_with_hostname_and_port(): def test_dump_databases_runs_pg_dump_with_hostname_and_port():
databases = [{'name': 'foo', 'hostname': 'database.example.org', 'port': 5433}] databases = [{'name': 'foo', 'hostname': 'database.example.org', 'port': 5433}]
flexmock(module.dump).should_receive('make_database_dump_filename').and_return( flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
@ -167,7 +162,7 @@ def test_make_database_dump_patterns_converts_names_to_glob_paths():
'databases/*/foo' 'databases/*/foo'
).and_return('databases/*/bar') ).and_return('databases/*/bar')
assert module.make_database_dump_patterns(('foo', 'bar')) == [ assert module.make_database_dump_patterns(flexmock(), flexmock(), ('foo', 'bar')) == [
'databases/*/foo', 'databases/*/foo',
'databases/*/bar', 'databases/*/bar',
] ]
@ -178,54 +173,7 @@ def test_make_database_dump_patterns_treats_empty_names_as_matching_all_database
module.DUMP_PATH, '*', '*' module.DUMP_PATH, '*', '*'
).and_return('databases/*/*') ).and_return('databases/*/*')
assert module.make_database_dump_patterns(()) == ['databases/*/*'] assert module.make_database_dump_patterns(flexmock(), flexmock(), ()) == ['databases/*/*']
def test_convert_glob_patterns_to_borg_patterns_removes_leading_slash():
assert module.convert_glob_patterns_to_borg_patterns(('/etc/foo/bar',)) == ['sh:etc/foo/bar']
def test_get_database_names_from_dumps_gets_names_from_filenames_matching_globs():
flexmock(module.glob).should_receive('glob').and_return(
('databases/localhost/foo',)
).and_return(('databases/localhost/bar',)).and_return(())
assert module.get_database_names_from_dumps(
('databases/*/foo', 'databases/*/bar', 'databases/*/baz')
) == ['foo', 'bar']
def test_get_database_configurations_only_produces_named_databases():
databases = [
{'name': 'foo', 'hostname': 'example.org'},
{'name': 'bar', 'hostname': 'example.com'},
{'name': 'baz', 'hostname': 'example.org'},
]
assert list(module.get_database_configurations(databases, ('foo', 'baz'))) == [
{'name': 'foo', 'hostname': 'example.org'},
{'name': 'baz', 'hostname': 'example.org'},
]
def test_get_database_configurations_matches_all_database():
databases = [
{'name': 'foo', 'hostname': 'example.org'},
{'name': 'all', 'hostname': 'example.com'},
]
assert list(module.get_database_configurations(databases, ('foo', 'bar', 'baz'))) == [
{'name': 'foo', 'hostname': 'example.org'},
{'name': 'bar', 'hostname': 'example.com'},
{'name': 'baz', 'hostname': 'example.com'},
]
def test_get_database_configurations_with_unknown_database_name_raises():
databases = [{'name': 'foo', 'hostname': 'example.org'}]
with pytest.raises(ValueError):
list(module.get_database_configurations(databases, ('foo', 'bar')))
def test_restore_database_dumps_restores_each_database(): def test_restore_database_dumps_restores_each_database():
@ -256,10 +204,6 @@ def test_restore_database_dumps_restores_each_database():
module.restore_database_dumps(databases, 'test.yaml', dry_run=False) module.restore_database_dumps(databases, 'test.yaml', dry_run=False)
def test_restore_database_dumps_without_databases_does_not_raise():
module.restore_database_dumps({}, 'test.yaml', dry_run=False)
def test_restore_database_dumps_runs_pg_restore_with_hostname_and_port(): def test_restore_database_dumps_runs_pg_restore_with_hostname_and_port():
databases = [{'name': 'foo', 'hostname': 'database.example.org', 'port': 5433}] databases = [{'name': 'foo', 'hostname': 'database.example.org', 'port': 5433}]
flexmock(module.dump).should_receive('make_database_dump_filename').and_return( flexmock(module.dump).should_receive('make_database_dump_filename').and_return(

View file

@ -47,6 +47,7 @@ def test_execute_command_calls_full_command():
flexmock(module.os, environ={'a': 'b'}) flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args( flexmock(module.subprocess).should_receive('Popen').with_args(
full_command, full_command,
stdin=None,
stdout=module.subprocess.PIPE, stdout=module.subprocess.PIPE,
stderr=module.subprocess.STDOUT, stderr=module.subprocess.STDOUT,
shell=False, shell=False,
@ -66,6 +67,7 @@ def test_execute_command_calls_full_command_with_output_file():
flexmock(module.os, environ={'a': 'b'}) flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args( flexmock(module.subprocess).should_receive('Popen').with_args(
full_command, full_command,
stdin=None,
stdout=output_file, stdout=output_file,
stderr=module.subprocess.PIPE, stderr=module.subprocess.PIPE,
shell=False, shell=False,
@ -79,11 +81,32 @@ def test_execute_command_calls_full_command_with_output_file():
assert output is None assert output is None
def test_execute_command_calls_full_command_with_input_file():
full_command = ['foo', 'bar']
input_file = flexmock()
flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args(
full_command,
stdin=input_file,
stdout=module.subprocess.PIPE,
stderr=module.subprocess.STDOUT,
shell=False,
env=None,
cwd=None,
).and_return(flexmock(stdout=None)).once()
flexmock(module).should_receive('log_output')
output = module.execute_command(full_command, input_file=input_file)
assert output is None
def test_execute_command_calls_full_command_with_shell(): def test_execute_command_calls_full_command_with_shell():
full_command = ['foo', 'bar'] full_command = ['foo', 'bar']
flexmock(module.os, environ={'a': 'b'}) flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args( flexmock(module.subprocess).should_receive('Popen').with_args(
full_command, full_command,
stdin=None,
stdout=module.subprocess.PIPE, stdout=module.subprocess.PIPE,
stderr=module.subprocess.STDOUT, stderr=module.subprocess.STDOUT,
shell=True, shell=True,
@ -102,6 +125,7 @@ def test_execute_command_calls_full_command_with_extra_environment():
flexmock(module.os, environ={'a': 'b'}) flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args( flexmock(module.subprocess).should_receive('Popen').with_args(
full_command, full_command,
stdin=None,
stdout=module.subprocess.PIPE, stdout=module.subprocess.PIPE,
stderr=module.subprocess.STDOUT, stderr=module.subprocess.STDOUT,
shell=False, shell=False,
@ -120,6 +144,7 @@ def test_execute_command_calls_full_command_with_working_directory():
flexmock(module.os, environ={'a': 'b'}) flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args( flexmock(module.subprocess).should_receive('Popen').with_args(
full_command, full_command,
stdin=None,
stdout=module.subprocess.PIPE, stdout=module.subprocess.PIPE,
stderr=module.subprocess.STDOUT, stderr=module.subprocess.STDOUT,
shell=False, shell=False,