Browse Source

Warn and tranform on non-ssh://-style repositories (#557).

Dan Helfman 2 years ago
parent
commit
28d847b8b1

+ 2 - 2
README.md

@@ -24,8 +24,8 @@ location:
 
 
     # Paths of local or remote repositories to backup to.
     # Paths of local or remote repositories to backup to.
     repositories:
     repositories:
-        - ssh://1234@usw-s001.rsync.net:backups.borg
-        - ssh://k8pDxu32@k8pDxu32.repo.borgbase.com:repo
+        - ssh://1234@usw-s001.rsync.net/backups.borg
+        - ssh://k8pDxu32@k8pDxu32.repo.borgbase.com/repo
         - /var/lib/backups/local.borg
         - /var/lib/backups/local.borg
 
 
 retention:
 retention:

+ 2 - 1
borgmatic/commands/borgmatic.py

@@ -743,9 +743,10 @@ def load_configurations(config_filenames, overrides=None, resolve_env=True):
     # Parse and load each configuration file.
     # Parse and load each configuration file.
     for config_filename in config_filenames:
     for config_filename in config_filenames:
         try:
         try:
-            configs[config_filename] = validate.parse_configuration(
+            configs[config_filename], parse_logs = validate.parse_configuration(
                 config_filename, validate.schema_filename(), overrides, resolve_env
                 config_filename, validate.schema_filename(), overrides, resolve_env
             )
             )
+            logs.extend(parse_logs)
         except PermissionError:
         except PermissionError:
             logs.extend(
             logs.extend(
                 [
                 [

+ 44 - 3
borgmatic/config/normalize.py

@@ -1,8 +1,14 @@
-def normalize(config):
+import logging
+
+
+def normalize(config_filename, config):
     '''
     '''
-    Given a configuration dict, apply particular hard-coded rules to normalize its contents to
-    adhere to the configuration schema.
+    Given a configuration filename and a configuration dict of its loaded contents, apply particular
+    hard-coded rules to normalize the configuration to adhere to the current schema. Return any log
+    message warnings produced based on the normalization performed.
     '''
     '''
+    logs = []
+
     # Upgrade exclude_if_present from a string to a list.
     # Upgrade exclude_if_present from a string to a list.
     exclude_if_present = config.get('location', {}).get('exclude_if_present')
     exclude_if_present = config.get('location', {}).get('exclude_if_present')
     if isinstance(exclude_if_present, str):
     if isinstance(exclude_if_present, str):
@@ -29,3 +35,38 @@ def normalize(config):
     checks = config.get('consistency', {}).get('checks')
     checks = config.get('consistency', {}).get('checks')
     if isinstance(checks, list) and len(checks) and isinstance(checks[0], str):
     if isinstance(checks, list) and len(checks) and isinstance(checks[0], str):
         config['consistency']['checks'] = [{'name': check_type} for check_type in checks]
         config['consistency']['checks'] = [{'name': check_type} for check_type in checks]
+
+    # Upgrade remote repositories to ssh:// syntax, required in Borg 2.
+    repositories = config.get('location', {}).get('repositories')
+    if repositories:
+        config['location']['repositories'] = []
+        for repository in repositories:
+            # TODO: Instead of logging directly here, return logs and bubble them up to be displayed *after* logging is initialized.
+            if '~' in repository:
+                logs.append(
+                    logging.makeLogRecord(
+                        dict(
+                            levelno=logging.WARNING,
+                            levelname='WARNING',
+                            msg=f'{config_filename}: Repository paths containing "~" are deprecated in borgmatic and no longer work in Borg 2.',
+                        )
+                    )
+                )
+            if ':' in repository and not repository.startswith('ssh://'):
+                rewritten_repository = (
+                    f"ssh://{repository.replace(':~', '/~').replace(':/', '/').replace(':', '/./')}"
+                )
+                logs.append(
+                    logging.makeLogRecord(
+                        dict(
+                            levelno=logging.WARNING,
+                            levelname='WARNING',
+                            msg=f'{config_filename}: Remote repository paths without ssh:// syntax are deprecated. Interpreting "{repository}" as "{rewritten_repository}"',
+                        )
+                    )
+                )
+                config['location']['repositories'].append(rewritten_repository)
+            else:
+                config['location']['repositories'].append(repository)
+
+    return logs

+ 5 - 2
borgmatic/config/validate.py

@@ -89,6 +89,9 @@ def parse_configuration(config_filename, schema_filename, overrides=None, resolv
        {'location': {'source_directories': ['/home', '/etc'], 'repository': 'hostname.borg'},
        {'location': {'source_directories': ['/home', '/etc'], 'repository': 'hostname.borg'},
        'retention': {'keep_daily': 7}, 'consistency': {'checks': ['repository', 'archives']}}
        'retention': {'keep_daily': 7}, 'consistency': {'checks': ['repository', 'archives']}}
 
 
+    Also return a sequence of logging.LogRecord instances containing any warnings about the
+    configuration.
+
     Raise FileNotFoundError if the file does not exist, PermissionError if the user does not
     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.
     have permissions to read the file, or Validation_error if the config does not match the schema.
     '''
     '''
@@ -99,7 +102,7 @@ def parse_configuration(config_filename, schema_filename, overrides=None, resolv
         raise Validation_error(config_filename, (str(error),))
         raise Validation_error(config_filename, (str(error),))
 
 
     override.apply_overrides(config, overrides)
     override.apply_overrides(config, overrides)
-    normalize.normalize(config)
+    logs = normalize.normalize(config_filename, config)
     if resolve_env:
     if resolve_env:
         environment.resolve_env_variables(config)
         environment.resolve_env_variables(config)
 
 
@@ -116,7 +119,7 @@ def parse_configuration(config_filename, schema_filename, overrides=None, resolv
 
 
     apply_logical_validation(config_filename, config)
     apply_logical_validation(config_filename, config)
 
 
-    return config
+    return config, logs
 
 
 
 
 def normalize_repository_path(repository):
 def normalize_repository_path(repository):

+ 1 - 1
docs/how-to/backup-to-a-removable-drive-or-an-intermittent-server.md

@@ -76,7 +76,7 @@ location:
         - /home
         - /home
 
 
     repositories:
     repositories:
-        - ssh://me@buddys-server.org:backup.borg
+        - ssh://me@buddys-server.org/backup.borg
 
 
 hooks:
 hooks:
     before_backup:
     before_backup:

+ 2 - 2
docs/how-to/make-backups-redundant.md

@@ -20,8 +20,8 @@ location:
 
 
     # Paths of local or remote repositories to backup to.
     # Paths of local or remote repositories to backup to.
     repositories:
     repositories:
-        - ssh://1234@usw-s001.rsync.net:backups.borg
-        - ssh://k8pDxu32@k8pDxu32.repo.borgbase.com:repo
+        - ssh://1234@usw-s001.rsync.net/backups.borg
+        - ssh://k8pDxu32@k8pDxu32.repo.borgbase.com/repo
         - /var/lib/backups/local.borg
         - /var/lib/backups/local.borg
 ```
 ```
 
 

+ 24 - 20
tests/integration/config/test_validate.py

@@ -60,39 +60,39 @@ def test_parse_configuration_transforms_file_into_mapping():
         '''
         '''
     )
     )
 
 
-    result = module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml')
+    config, logs = module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml')
 
 
-    assert result == {
+    assert config == {
         'location': {'source_directories': ['/home', '/etc'], 'repositories': ['hostname.borg']},
         'location': {'source_directories': ['/home', '/etc'], 'repositories': ['hostname.borg']},
         'retention': {'keep_daily': 7, 'keep_hourly': 24, 'keep_minutely': 60},
         'retention': {'keep_daily': 7, 'keep_hourly': 24, 'keep_minutely': 60},
         'consistency': {'checks': [{'name': 'repository'}, {'name': 'archives'}]},
         'consistency': {'checks': [{'name': 'repository'}, {'name': 'archives'}]},
     }
     }
+    assert logs == []
 
 
 
 
 def test_parse_configuration_passes_through_quoted_punctuation():
 def test_parse_configuration_passes_through_quoted_punctuation():
     escaped_punctuation = string.punctuation.replace('\\', r'\\').replace('"', r'\"')
     escaped_punctuation = string.punctuation.replace('\\', r'\\').replace('"', r'\"')
 
 
     mock_config_and_schema(
     mock_config_and_schema(
-        '''
+        f'''
         location:
         location:
             source_directories:
             source_directories:
-                - /home
+                - "/home/{escaped_punctuation}"
 
 
             repositories:
             repositories:
-                - "{}.borg"
-        '''.format(
-            escaped_punctuation
-        )
+                - test.borg
+        '''
     )
     )
 
 
-    result = module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml')
+    config, logs = module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml')
 
 
-    assert result == {
+    assert config == {
         'location': {
         'location': {
-            'source_directories': ['/home'],
-            'repositories': ['{}.borg'.format(string.punctuation)],
+            'source_directories': [f'/home/{string.punctuation}'],
+            'repositories': ['test.borg'],
         }
         }
     }
     }
+    assert logs == []
 
 
 
 
 def test_parse_configuration_with_schema_lacking_examples_does_not_raise():
 def test_parse_configuration_with_schema_lacking_examples_does_not_raise():
@@ -148,12 +148,13 @@ def test_parse_configuration_inlines_include():
     include_file.name = 'include.yaml'
     include_file.name = 'include.yaml'
     builtins.should_receive('open').with_args('/tmp/include.yaml').and_return(include_file)
     builtins.should_receive('open').with_args('/tmp/include.yaml').and_return(include_file)
 
 
-    result = module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml')
+    config, logs = module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml')
 
 
-    assert result == {
+    assert config == {
         'location': {'source_directories': ['/home'], 'repositories': ['hostname.borg']},
         'location': {'source_directories': ['/home'], 'repositories': ['hostname.borg']},
         'retention': {'keep_daily': 7, 'keep_hourly': 24},
         'retention': {'keep_daily': 7, 'keep_hourly': 24},
     }
     }
+    assert logs == []
 
 
 
 
 def test_parse_configuration_merges_include():
 def test_parse_configuration_merges_include():
@@ -181,12 +182,13 @@ def test_parse_configuration_merges_include():
     include_file.name = 'include.yaml'
     include_file.name = 'include.yaml'
     builtins.should_receive('open').with_args('/tmp/include.yaml').and_return(include_file)
     builtins.should_receive('open').with_args('/tmp/include.yaml').and_return(include_file)
 
 
-    result = module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml')
+    config, logs = module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml')
 
 
-    assert result == {
+    assert config == {
         'location': {'source_directories': ['/home'], 'repositories': ['hostname.borg']},
         'location': {'source_directories': ['/home'], 'repositories': ['hostname.borg']},
         'retention': {'keep_daily': 1, 'keep_hourly': 24},
         'retention': {'keep_daily': 1, 'keep_hourly': 24},
     }
     }
+    assert logs == []
 
 
 
 
 def test_parse_configuration_raises_for_missing_config_file():
 def test_parse_configuration_raises_for_missing_config_file():
@@ -238,17 +240,18 @@ def test_parse_configuration_applies_overrides():
         '''
         '''
     )
     )
 
 
-    result = module.parse_configuration(
+    config, logs = module.parse_configuration(
         '/tmp/config.yaml', '/tmp/schema.yaml', overrides=['location.local_path=borg2']
         '/tmp/config.yaml', '/tmp/schema.yaml', overrides=['location.local_path=borg2']
     )
     )
 
 
-    assert result == {
+    assert config == {
         'location': {
         'location': {
             'source_directories': ['/home'],
             'source_directories': ['/home'],
             'repositories': ['hostname.borg'],
             'repositories': ['hostname.borg'],
             'local_path': 'borg2',
             'local_path': 'borg2',
         }
         }
     }
     }
+    assert logs == []
 
 
 
 
 def test_parse_configuration_applies_normalization():
 def test_parse_configuration_applies_normalization():
@@ -265,12 +268,13 @@ def test_parse_configuration_applies_normalization():
         '''
         '''
     )
     )
 
 
-    result = module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml')
+    config, logs = module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml')
 
 
-    assert result == {
+    assert config == {
         'location': {
         'location': {
             'source_directories': ['/home'],
             'source_directories': ['/home'],
             'repositories': ['hostname.borg'],
             'repositories': ['hostname.borg'],
             'exclude_if_present': ['.nobackup'],
             'exclude_if_present': ['.nobackup'],
         }
         }
     }
     }
+    assert logs == []

+ 6 - 4
tests/unit/commands/test_borgmatic.py

@@ -699,17 +699,19 @@ def test_run_actions_does_not_raise_for_borg_action():
     )
     )
 
 
 
 
-def test_load_configurations_collects_parsed_configurations():
+def test_load_configurations_collects_parsed_configurations_and_logs():
     configuration = flexmock()
     configuration = flexmock()
     other_configuration = flexmock()
     other_configuration = flexmock()
+    test_expected_logs = [flexmock(), flexmock()]
+    other_expected_logs = [flexmock(), flexmock()]
     flexmock(module.validate).should_receive('parse_configuration').and_return(
     flexmock(module.validate).should_receive('parse_configuration').and_return(
-        configuration
-    ).and_return(other_configuration)
+        configuration, test_expected_logs
+    ).and_return(other_configuration, other_expected_logs)
 
 
     configs, logs = tuple(module.load_configurations(('test.yaml', 'other.yaml')))
     configs, logs = tuple(module.load_configurations(('test.yaml', 'other.yaml')))
 
 
     assert configs == {'test.yaml': configuration, 'other.yaml': other_configuration}
     assert configs == {'test.yaml': configuration, 'other.yaml': other_configuration}
-    assert logs == []
+    assert logs == test_expected_logs + other_expected_logs
 
 
 
 
 def test_load_configurations_logs_warning_for_permission_error():
 def test_load_configurations_logs_warning_for_permission_error():

+ 43 - 4
tests/unit/config/test_normalize.py

@@ -4,44 +4,83 @@ from borgmatic.config import normalize as module
 
 
 
 
 @pytest.mark.parametrize(
 @pytest.mark.parametrize(
-    'config,expected_config',
+    'config,expected_config,produces_logs',
     (
     (
         (
         (
             {'location': {'exclude_if_present': '.nobackup'}},
             {'location': {'exclude_if_present': '.nobackup'}},
             {'location': {'exclude_if_present': ['.nobackup']}},
             {'location': {'exclude_if_present': ['.nobackup']}},
+            False,
         ),
         ),
         (
         (
             {'location': {'exclude_if_present': ['.nobackup']}},
             {'location': {'exclude_if_present': ['.nobackup']}},
             {'location': {'exclude_if_present': ['.nobackup']}},
             {'location': {'exclude_if_present': ['.nobackup']}},
+            False,
         ),
         ),
         (
         (
             {'location': {'source_directories': ['foo', 'bar']}},
             {'location': {'source_directories': ['foo', 'bar']}},
             {'location': {'source_directories': ['foo', 'bar']}},
             {'location': {'source_directories': ['foo', 'bar']}},
+            False,
+        ),
+        (
+            {'storage': {'compression': 'yes_please'}},
+            {'storage': {'compression': 'yes_please'}},
+            False,
         ),
         ),
-        ({'storage': {'compression': 'yes_please'}}, {'storage': {'compression': 'yes_please'}}),
         (
         (
             {'hooks': {'healthchecks': 'https://example.com'}},
             {'hooks': {'healthchecks': 'https://example.com'}},
             {'hooks': {'healthchecks': {'ping_url': 'https://example.com'}}},
             {'hooks': {'healthchecks': {'ping_url': 'https://example.com'}}},
+            False,
         ),
         ),
         (
         (
             {'hooks': {'cronitor': 'https://example.com'}},
             {'hooks': {'cronitor': 'https://example.com'}},
             {'hooks': {'cronitor': {'ping_url': 'https://example.com'}}},
             {'hooks': {'cronitor': {'ping_url': 'https://example.com'}}},
+            False,
         ),
         ),
         (
         (
             {'hooks': {'pagerduty': 'https://example.com'}},
             {'hooks': {'pagerduty': 'https://example.com'}},
             {'hooks': {'pagerduty': {'integration_key': 'https://example.com'}}},
             {'hooks': {'pagerduty': {'integration_key': 'https://example.com'}}},
+            False,
         ),
         ),
         (
         (
             {'hooks': {'cronhub': 'https://example.com'}},
             {'hooks': {'cronhub': 'https://example.com'}},
             {'hooks': {'cronhub': {'ping_url': 'https://example.com'}}},
             {'hooks': {'cronhub': {'ping_url': 'https://example.com'}}},
+            False,
         ),
         ),
         (
         (
             {'consistency': {'checks': ['archives']}},
             {'consistency': {'checks': ['archives']}},
             {'consistency': {'checks': [{'name': 'archives'}]}},
             {'consistency': {'checks': [{'name': 'archives'}]}},
+            False,
+        ),
+        (
+            {'location': {'repositories': ['foo@bar:/repo']}},
+            {'location': {'repositories': ['ssh://foo@bar/repo']}},
+            True,
+        ),
+        (
+            {'location': {'repositories': ['foo@bar:repo']}},
+            {'location': {'repositories': ['ssh://foo@bar/./repo']}},
+            True,
+        ),
+        (
+            {'location': {'repositories': ['foo@bar:~/repo']}},
+            {'location': {'repositories': ['ssh://foo@bar/~/repo']}},
+            True,
+        ),
+        (
+            {'location': {'repositories': ['ssh://foo@bar/repo']}},
+            {'location': {'repositories': ['ssh://foo@bar/repo']}},
+            False,
         ),
         ),
     ),
     ),
 )
 )
-def test_normalize_applies_hard_coded_normalization_to_config(config, expected_config):
-    module.normalize(config)
+def test_normalize_applies_hard_coded_normalization_to_config(
+    config, expected_config, produces_logs
+):
+    logs = module.normalize('test.yaml', config)
 
 
     assert config == expected_config
     assert config == expected_config
+
+    if produces_logs:
+        assert logs
+    else:
+        assert logs == []