diff --git a/NEWS b/NEWS index d422d2e3..07f32415 100644 --- a/NEWS +++ b/NEWS @@ -18,6 +18,8 @@ segment compaction. * #1334: Add the "CAP_FOWNER" capability to "CapabilityBoundingSet" in the sample systemd service, so that Borg can open source files without changing file access times. + * #1336: Fix for the "bootstrap" action ignoring the "--verbosity" flag when no configuration is + present. * Fix a bug in which the "compact" action does not pass a compact threshold of zero to Borg. 2.1.6 diff --git a/borgmatic/commands/borgmatic.py b/borgmatic/commands/borgmatic.py index 2298b239..5b5fe373 100644 --- a/borgmatic/commands/borgmatic.py +++ b/borgmatic/commands/borgmatic.py @@ -665,6 +665,13 @@ def load_configurations(config_filenames, arguments, overrides=None, resolve_env config_paths = set() logs = [] + # As a special case for the "bootstrap" action, parse configuration for a non-existent + # configuration file with None for a filename. This sets any command-line arguments into an + # empty configuration dict, so for instance a "--verbosity" flag gets used even if there is no + # configuration file yet. + if 'bootstrap' in arguments and not config_filenames: + config_filenames = (None,) + # Parse and load each configuration file. for config_filename in config_filenames: logs.extend( diff --git a/borgmatic/config/validate.py b/borgmatic/config/validate.py index eae2a8d2..90d7a6a3 100644 --- a/borgmatic/config/validate.py +++ b/borgmatic/config/validate.py @@ -108,7 +108,8 @@ def parse_configuration( rendition of JSON Schema format, arguments as dict from action name to argparse.Namespace, a sequence of configuration file override strings in the form of "option.suboption=value", and whether to resolve environment variables, return the parsed configuration as a data structure of - nested dicts and lists corresponding to the schema. Example return value. + nested dicts and lists corresponding to the schema. Apply the given arguments to the config, + modifying it based on the values of those arguments. Example return value: @@ -122,13 +123,21 @@ def parse_configuration( Also return a set of loaded configuration paths and a sequence of logging.LogRecord instances containing any warnings about the configuration. + If the given config filename is None, then create a configuration dict from whole cloth, + applying the given arguments to it. This is useful for the "bootstrap" action, for which + configuration may not yet exist. + Raise FileNotFoundError if the file does not exist, PermissionError if the user does not have permissions to read the file, or Validation_error if the config does not match the schema. ''' config_paths = set() try: - config = load.load_configuration(config_filename, config_paths) + config = ( + load.load_configuration(config_filename, config_paths) + if config_filename + else {'repositories': []} + ) schema = load.load_configuration(schema_filename) except (ruamel.yaml.error.YAMLError, RecursionError) as error: raise Validation_error(config_filename, (str(error),)) diff --git a/tests/integration/config/test_validate.py b/tests/integration/config/test_validate.py index ee59e479..0fbe9eed 100644 --- a/tests/integration/config/test_validate.py +++ b/tests/integration/config/test_validate.py @@ -15,14 +15,17 @@ def test_schema_filename_returns_plausible_path(): assert schema_path.endswith('/schema.yaml') -def mock_config_and_schema(config_yaml, schema_yaml=None): +def mock_config_and_schema(config_yaml=None, schema_yaml=None): ''' - Set up mocks for the given config config YAML string and the schema YAML string, or the default - schema if no schema is provided. The idea is that that the code under test consumes these mocks - when parsing the configuration. + Set up mocks for the given config config YAML string (if provided) and the schema YAML string or + the default schema if no schema is provided. The idea is that that the code under test consumes + these mocks when parsing the configuration. ''' - config_stream = io.StringIO(config_yaml) - config_stream.name = 'config.yaml' + if config_yaml is None: + config_stream = None + else: + config_stream = io.StringIO(config_yaml) + config_stream.name = 'config.yaml' if schema_yaml is None: schema_stream = open(module.schema_filename()) @@ -81,6 +84,24 @@ def test_parse_configuration_transforms_file_into_mapping(): assert logs == [] +def test_parse_configuration_with_none_config_filename_creates_configuration_from_whole_cloth(): + mock_config_and_schema() + + config, config_paths, logs = module.parse_configuration( + None, + '/tmp/schema.yaml', + arguments={'global': flexmock(verbosity=2)}, + ) + + assert config == { + 'bootstrap': {}, + 'repositories': [], + 'verbosity': 2, + } + assert config_paths == set() + assert logs == [] + + def test_parse_configuration_passes_through_quoted_punctuation(): escaped_punctuation = string.punctuation.replace('\\', r'\\').replace('"', r'\"') diff --git a/tests/unit/commands/test_borgmatic.py b/tests/unit/commands/test_borgmatic.py index 5d3f129a..eac85468 100644 --- a/tests/unit/commands/test_borgmatic.py +++ b/tests/unit/commands/test_borgmatic.py @@ -1801,6 +1801,50 @@ def test_load_configurations_logs_critical_for_parse_error(): assert max(log.levelno for log in logs) == logging.CRITICAL +def test_load_configurations_with_bootstrap_action_and_no_configuration_file_creates_configuration_from_whole_cloth(): + configuration = flexmock() + test_expected_logs = [flexmock(), flexmock()] + flexmock(module.validate).should_receive('parse_configuration').and_return( + configuration, + [None], + test_expected_logs, + ) + + configs, config_paths, logs = tuple( + module.load_configurations( + (), + arguments={'bootstrap': flexmock()}, + resolve_env=False, + ), + ) + + assert configs == {None: configuration} + assert config_paths == [None] + assert logs + + +def test_load_configurations_with_bootstrap_action_and_existing_configuration_file_uses_it(): + configuration = flexmock() + test_expected_logs = [flexmock(), flexmock()] + flexmock(module.validate).should_receive('parse_configuration').and_return( + configuration, + ['/tmp/test.yaml'], + test_expected_logs, + ) + + configs, config_paths, logs = tuple( + module.load_configurations( + ('test.yaml',), + arguments={'bootstrap': flexmock()}, + resolve_env=False, + ), + ) + + assert configs == {'test.yaml': configuration} + assert config_paths == ['/tmp/test.yaml'] + assert logs + + def test_log_record_does_not_raise(): module.log_record(levelno=1, foo='bar', baz='quux')