فهرست منبع

Upgrade your borgmatic configuration to get new options and comments via "generate-borgmatic-config --source" (#239).

Dan Helfman 5 سال پیش
والد
کامیت
2115eeb6a2

+ 5 - 0
NEWS

@@ -1,3 +1,8 @@
+1.4.7
+ * #239: Upgrade your borgmatic configuration to get new options and comments via
+   "generate-borgmatic-config --source". See the documentation for more information:
+   https://torsion.org/borgmatic/docs/how-to/upgrade/#upgrading-your-configuration
+
 1.4.6
 1.4.6
  * Verbosity level "-1" for even quieter output: Errors only (#236).
  * Verbosity level "-1" for even quieter output: Errors only (#236).
 
 

+ 18 - 2
borgmatic/commands/generate_config.py

@@ -12,12 +12,18 @@ def parse_arguments(*arguments):
     them as an ArgumentParser instance.
     them as an ArgumentParser instance.
     '''
     '''
     parser = ArgumentParser(description='Generate a sample borgmatic YAML configuration file.')
     parser = ArgumentParser(description='Generate a sample borgmatic YAML configuration file.')
+    parser.add_argument(
+        '-s',
+        '--source',
+        dest='source_filename',
+        help='Optional YAML configuration file to merge into the generated configuration, useful for upgrading your configuration',
+    )
     parser.add_argument(
     parser.add_argument(
         '-d',
         '-d',
         '--destination',
         '--destination',
         dest='destination_filename',
         dest='destination_filename',
         default=DEFAULT_DESTINATION_CONFIG_FILENAME,
         default=DEFAULT_DESTINATION_CONFIG_FILENAME,
-        help='Destination YAML configuration filename. Default: {}'.format(
+        help='Destination YAML configuration file. Default: {}'.format(
             DEFAULT_DESTINATION_CONFIG_FILENAME
             DEFAULT_DESTINATION_CONFIG_FILENAME
         ),
         ),
     )
     )
@@ -30,11 +36,21 @@ def main():  # pragma: no cover
         args = parse_arguments(*sys.argv[1:])
         args = parse_arguments(*sys.argv[1:])
 
 
         generate.generate_sample_configuration(
         generate.generate_sample_configuration(
-            args.destination_filename, validate.schema_filename()
+            args.source_filename, args.destination_filename, validate.schema_filename()
         )
         )
 
 
         print('Generated a sample configuration file at {}.'.format(args.destination_filename))
         print('Generated a sample configuration file at {}.'.format(args.destination_filename))
         print()
         print()
+        if args.source_filename:
+            print(
+                'Merged in the contents of configuration file at {}.'.format(args.source_filename)
+            )
+            print('To review the changes made, run:')
+            print()
+            print(
+                '    diff --unified {} {}'.format(args.source_filename, args.destination_filename)
+            )
+            print()
         print('Please edit the file to suit your needs. The values are representative.')
         print('Please edit the file to suit your needs. The values are representative.')
         print('All fields are optional except where indicated.')
         print('All fields are optional except where indicated.')
         print()
         print()

+ 115 - 30
borgmatic/config/generate.py

@@ -1,9 +1,12 @@
+import collections
 import io
 import io
 import os
 import os
 import re
 import re
 
 
 from ruamel import yaml
 from ruamel import yaml
 
 
+from borgmatic.config import load
+
 INDENT = 4
 INDENT = 4
 SEQUENCE_INDENT = 2
 SEQUENCE_INDENT = 2
 
 
@@ -40,8 +43,8 @@ def _schema_to_sample_configuration(schema, level=0, parent_is_sequence=False):
     elif 'map' in schema:
     elif 'map' in schema:
         config = yaml.comments.CommentedMap(
         config = yaml.comments.CommentedMap(
             [
             [
-                (section_name, _schema_to_sample_configuration(section_schema, level + 1))
-                for section_name, section_schema in schema['map'].items()
+                (field_name, _schema_to_sample_configuration(sub_schema, level + 1))
+                for field_name, sub_schema in schema['map'].items()
             ]
             ]
         )
         )
         indent = (level * INDENT) + (SEQUENCE_INDENT if parent_is_sequence else 0)
         indent = (level * INDENT) + (SEQUENCE_INDENT if parent_is_sequence else 0)
@@ -68,37 +71,32 @@ def _comment_out_line(line):
     return '# '.join((indent_spaces, line[count_indent_spaces:]))
     return '# '.join((indent_spaces, line[count_indent_spaces:]))
 
 
 
 
-REQUIRED_KEYS = {'source_directories', 'repositories', 'keep_daily'}
-REQUIRED_SECTION_NAMES = {'location', 'retention'}
-
-
 def _comment_out_optional_configuration(rendered_config):
 def _comment_out_optional_configuration(rendered_config):
     '''
     '''
-    Post-process a rendered configuration string to comment out optional key/values. The idea is
-    that this prevents the user from having to comment out a bunch of configuration they don't care
-    about to get to a minimal viable configuration file.
+    Post-process a rendered configuration string to comment out optional key/values, as determined
+    by a sentinel in the comment before each key.
 
 
-    Ideally ruamel.yaml would support this during configuration generation, but it's not terribly
-    easy to accomplish that way.
+    The idea is that the pre-commented configuration prevents the user from having to comment out a
+    bunch of configuration they don't care about to get to a minimal viable configuration file.
+
+    Ideally ruamel.yaml would support commenting out keys during configuration generation, but it's
+    not terribly easy to accomplish that way.
     '''
     '''
     lines = []
     lines = []
-    required = False
+    optional = False
 
 
     for line in rendered_config.split('\n'):
     for line in rendered_config.split('\n'):
-        key = line.strip().split(':')[0]
-
-        if key in REQUIRED_SECTION_NAMES:
-            lines.append(line)
+        # Upon encountering an optional configuration option, commenting out lines until the next
+        # blank line.
+        if line.strip().startswith('# {}'.format(COMMENTED_OUT_SENTINEL)):
+            optional = True
             continue
             continue
 
 
-        # Upon encountering a required configuration option, skip commenting out lines until the
-        # next blank line.
-        if key in REQUIRED_KEYS:
-            required = True
-        elif not key:
-            required = False
+        # Hit a blank line, so reset commenting.
+        if not line.strip():
+            optional = False
 
 
-        lines.append(_comment_out_line(line) if not required else line)
+        lines.append(_comment_out_line(line) if optional else line)
 
 
     return '\n'.join(lines)
     return '\n'.join(lines)
 
 
@@ -168,6 +166,11 @@ def add_comments_to_configuration_sequence(config, schema, indent=0):
         return
         return
 
 
 
 
+REQUIRED_SECTION_NAMES = {'location', 'retention'}
+REQUIRED_KEYS = {'source_directories', 'repositories', 'keep_daily'}
+COMMENTED_OUT_SENTINEL = 'COMMENT_OUT'
+
+
 def add_comments_to_configuration_map(config, schema, indent=0, skip_first=False):
 def add_comments_to_configuration_map(config, schema, indent=0, skip_first=False):
     '''
     '''
     Using descriptions from a schema as a source, add those descriptions as comments to the given
     Using descriptions from a schema as a source, add those descriptions as comments to the given
@@ -178,10 +181,20 @@ def add_comments_to_configuration_map(config, schema, indent=0, skip_first=False
             continue
             continue
 
 
         field_schema = schema['map'].get(field_name, {})
         field_schema = schema['map'].get(field_name, {})
-        description = field_schema.get('desc')
+        description = field_schema.get('desc', '').strip()
+
+        # If this is an optional key, add an indicator to the comment flagging it to be commented
+        # out from the sample configuration. This sentinel is consumed by downstream processing that
+        # does the actual commenting out.
+        if field_name not in REQUIRED_SECTION_NAMES and field_name not in REQUIRED_KEYS:
+            description = (
+                '\n'.join((description, COMMENTED_OUT_SENTINEL))
+                if description
+                else COMMENTED_OUT_SENTINEL
+            )
 
 
         # No description to use? Skip it.
         # No description to use? Skip it.
-        if not field_schema or not description:
+        if not field_schema or not description:  # pragma: no cover
             continue
             continue
 
 
         config.yaml_set_comment_before_after_key(key=field_name, before=description, indent=indent)
         config.yaml_set_comment_before_after_key(key=field_name, before=description, indent=indent)
@@ -190,14 +203,86 @@ def add_comments_to_configuration_map(config, schema, indent=0, skip_first=False
             _insert_newline_before_comment(config, field_name)
             _insert_newline_before_comment(config, field_name)
 
 
 
 
-def generate_sample_configuration(config_filename, schema_filename):
+RUAMEL_YAML_COMMENTS_INDEX = 1
+
+
+def remove_commented_out_sentinel(config, field_name):
     '''
     '''
-    Given a target config filename and the path to a schema filename in pykwalify YAML schema
-    format, write out a sample configuration file based on that schema.
+    Given a configuration CommentedMap and a top-level field name in it, remove any "commented out"
+    sentinel found at the end of its YAML comments. This prevents the given field name from getting
+    commented out by downstream processing that consumes the sentinel.
+    '''
+    try:
+        last_comment_value = config.ca.items[field_name][RUAMEL_YAML_COMMENTS_INDEX][-1].value
+    except KeyError:
+        return
+
+    if last_comment_value == '# {}\n'.format(COMMENTED_OUT_SENTINEL):
+        config.ca.items[field_name][RUAMEL_YAML_COMMENTS_INDEX].pop()
+
+
+def merge_source_configuration_into_destination(destination_config, source_config):
+    '''
+    Deep merge the given source configuration dict into the destination configuration CommentedMap,
+    favoring values from the source when there are collisions.
+
+    The purpose of this is to upgrade configuration files from old versions of borgmatic by adding
+    new
+    configuration keys and comments.
+    '''
+    if not destination_config or not isinstance(source_config, collections.abc.Mapping):
+        return source_config
+
+    for field_name, source_value in source_config.items():
+        # Since this key/value is from the source configuration, leave it uncommented and remove any
+        # sentinel that would cause it to get commented out.
+        remove_commented_out_sentinel(destination_config, field_name)
+
+        # This is a mapping. Recurse for this key/value.
+        if isinstance(source_value, collections.abc.Mapping):
+            destination_config[field_name] = merge_source_configuration_into_destination(
+                destination_config[field_name], source_value
+            )
+            continue
+
+        # This is a sequence. Recurse for each item in it.
+        if isinstance(source_value, collections.abc.Sequence) and not isinstance(source_value, str):
+            destination_value = destination_config[field_name]
+            destination_config[field_name] = yaml.comments.CommentedSeq(
+                [
+                    merge_source_configuration_into_destination(
+                        destination_value[index] if index < len(destination_value) else None,
+                        source_item,
+                    )
+                    for index, source_item in enumerate(source_value)
+                ]
+            )
+            continue
+
+        # This is some sort of scalar. Simply set it into the destination.
+        destination_config[field_name] = source_config[field_name]
+
+    return destination_config
+
+
+def generate_sample_configuration(source_filename, destination_filename, schema_filename):
+    '''
+    Given an optional source configuration filename, and a required destination configuration
+    filename, and the path to a schema filename in pykwalify YAML schema format, write out a
+    sample configuration file based on that schema. If a source filename is provided, merge the
+    parsed contents of that configuration into the generated configuration.
     '''
     '''
     schema = yaml.round_trip_load(open(schema_filename))
     schema = yaml.round_trip_load(open(schema_filename))
-    config = _schema_to_sample_configuration(schema)
+    source_config = None
+
+    if source_filename:
+        source_config = load.load_configuration(source_filename)
+
+    destination_config = merge_source_configuration_into_destination(
+        _schema_to_sample_configuration(schema), source_config
+    )
 
 
     write_configuration(
     write_configuration(
-        config_filename, _comment_out_optional_configuration(_render_configuration(config))
+        destination_filename,
+        _comment_out_optional_configuration(_render_configuration(destination_config)),
     )
     )

+ 7 - 3
docs/how-to/set-up-backups.md

@@ -77,9 +77,12 @@ else borgmatic won't recognize the option. Also be sure to use spaces rather
 than tabs for indentation; YAML does not allow tabs.
 than tabs for indentation; YAML does not allow tabs.
 
 
 You can also get the same sample configuration file from the [configuration
 You can also get the same sample configuration file from the [configuration
-reference](https://torsion.org/borgmatic/docs/reference/configuration/), the authoritative set of
-all configuration options. This is handy if borgmatic has added new options
-since you originally created your configuration file.
+reference](https://torsion.org/borgmatic/docs/reference/configuration/), the
+authoritative set of all configuration options. This is handy if borgmatic has
+added new options
+since you originally created your configuration file. Also check out how to
+[upgrade your
+configuration](https://torsion.org/borgmatic/docs/how-to/upgrade/#upgrading-your-configuration).
 
 
 
 
 ### Encryption
 ### Encryption
@@ -248,5 +251,6 @@ it.
  * [Deal with very large backups](https://torsion.org/borgmatic/docs/how-to/deal-with-very-large-backups/)
  * [Deal with very large backups](https://torsion.org/borgmatic/docs/how-to/deal-with-very-large-backups/)
  * [Inspect your backups](https://torsion.org/borgmatic/docs/how-to/inspect-your-backups/)
  * [Inspect your backups](https://torsion.org/borgmatic/docs/how-to/inspect-your-backups/)
  * [Monitor your backups](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/)
  * [Monitor your backups](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/)
+ * [Upgrade borgmatic](https://torsion.org/borgmatic/docs/how-to/upgrade/)
  * [borgmatic configuration reference](https://torsion.org/borgmatic/docs/reference/configuration/)
  * [borgmatic configuration reference](https://torsion.org/borgmatic/docs/reference/configuration/)
  * [borgmatic command-line reference](https://torsion.org/borgmatic/docs/reference/command-line/)
  * [borgmatic command-line reference](https://torsion.org/borgmatic/docs/reference/command-line/)

+ 40 - 1
docs/how-to/upgrade.md

@@ -10,7 +10,46 @@ following:
 sudo pip3 install --user --upgrade borgmatic
 sudo pip3 install --user --upgrade borgmatic
 ```
 ```
 
 
-See below about special cases.
+See below about special cases with old versions of borgmatic. Additionally, if
+you installed borgmatic [without using `pip3 install
+--user`](https://torsion.org/borgmatic/docs/how-to/set-up-backups/#other-ways-to-install),
+then your upgrade process may be different.
+
+
+### Upgrading your configuration
+
+The borgmatic configuration file format is almost always backwards-compatible
+from release to release without any changes, but you may still want to update
+your configuration file when you upgrade to take advantage of new
+configuration options. This is completely optional. If you prefer, you can add
+new configuration options manually.
+
+If you do want to upgrade your configuration file to include new options, use
+the `generate-borgmatic-config` script with its optional `--source` flag that
+takes the path to your original configuration file. If provided with this
+path, `generate-borgmatic-config` merges your original configuration into the
+generated configuration file, so you get all the newest options and comments.
+
+Here's an example:
+
+```bash
+generate-borgmatic-config --source config.yaml --destination config-new.yaml
+```
+
+New options start as commented out, so you can edit the file and decide
+whether you want to use each one.
+
+There are a few caveats to this process, however. First, when generating the
+new configuration file, `generate-borgmatic-config` replaces any comments
+you've written in your original configuration file with the newest generated
+comments. Second, the script adds back any options you had originally deleted,
+although it does so with the options commented out. And finally, any YAML
+includes you've used in the source configuration get flattened out into a
+single generated file.
+
+As a safety measure, `generate-borgmatic-config` refuses to modify
+configuration files in-place. So it's up to you to review the generated file
+and, if desired, replace your original configuration file with it.
 
 
 
 
 ### Upgrading from borgmatic 1.0.x
 ### Upgrading from borgmatic 1.0.x

+ 1 - 1
setup.py

@@ -1,6 +1,6 @@
 from setuptools import find_packages, setup
 from setuptools import find_packages, setup
 
 
-VERSION = '1.4.6'
+VERSION = '1.4.7'
 
 
 
 
 setup(
 setup(

+ 65 - 3
tests/integration/config/test_generate.py

@@ -47,9 +47,13 @@ def test_comment_out_line_comments_twice_indented_option():
 
 
 
 
 def test_comment_out_optional_configuration_comments_optional_config_only():
 def test_comment_out_optional_configuration_comments_optional_config_only():
+    # The "# COMMENT_OUT" comment is a sentinel used to express that the following key is optional.
+    # It's stripped out of the final output.
     flexmock(module)._comment_out_line = lambda line: '# ' + line
     flexmock(module)._comment_out_line = lambda line: '# ' + line
     config = '''
     config = '''
+# COMMENT_OUT
 foo:
 foo:
+    # COMMENT_OUT
     bar:
     bar:
         - baz
         - baz
         - quux
         - quux
@@ -59,6 +63,8 @@ location:
         - one
         - one
         - two
         - two
 
 
+    # This comment should be kept.
+    # COMMENT_OUT
     other: thing
     other: thing
     '''
     '''
 
 
@@ -68,12 +74,13 @@ location:
 #     bar:
 #     bar:
 #         - baz
 #         - baz
 #         - quux
 #         - quux
-# 
+
 location:
 location:
     repositories:
     repositories:
         - one
         - one
         - two
         - two
-# 
+
+    # This comment should be kept.
 #     other: thing
 #     other: thing
     '''
     '''
 
 
@@ -142,12 +149,67 @@ def test_add_comments_to_configuration_map_does_not_raise():
     module.add_comments_to_configuration_map(config, schema)
     module.add_comments_to_configuration_map(config, schema)
 
 
 
 
+def test_add_comments_to_configuration_map_with_skip_first_does_not_raise():
+    config = module.yaml.comments.CommentedMap([('foo', 33)])
+    schema = {'map': {'foo': {'desc': 'Foo'}}}
+
+    module.add_comments_to_configuration_map(config, schema, skip_first=True)
+
+
+def test_remove_commented_out_sentinel_keeps_other_comments():
+    field_name = 'foo'
+    config = module.yaml.comments.CommentedMap([(field_name, 33)])
+    config.yaml_set_comment_before_after_key(key=field_name, before='Actual comment.\nCOMMENT_OUT')
+
+    module.remove_commented_out_sentinel(config, field_name)
+
+    comments = config.ca.items[field_name][module.RUAMEL_YAML_COMMENTS_INDEX]
+    assert len(comments) == 1
+    assert comments[0].value == '# Actual comment.\n'
+
+
+def test_remove_commented_out_sentinel_without_sentinel_keeps_other_comments():
+    field_name = 'foo'
+    config = module.yaml.comments.CommentedMap([(field_name, 33)])
+    config.yaml_set_comment_before_after_key(key=field_name, before='Actual comment.')
+
+    module.remove_commented_out_sentinel(config, field_name)
+
+    comments = config.ca.items[field_name][module.RUAMEL_YAML_COMMENTS_INDEX]
+    assert len(comments) == 1
+    assert comments[0].value == '# Actual comment.\n'
+
+
+def test_remove_commented_out_sentinel_on_unknown_field_does_not_raise():
+    field_name = 'foo'
+    config = module.yaml.comments.CommentedMap([(field_name, 33)])
+    config.yaml_set_comment_before_after_key(key=field_name, before='Actual comment.')
+
+    module.remove_commented_out_sentinel(config, 'unknown')
+
+
 def test_generate_sample_configuration_does_not_raise():
 def test_generate_sample_configuration_does_not_raise():
     builtins = flexmock(sys.modules['builtins'])
     builtins = flexmock(sys.modules['builtins'])
     builtins.should_receive('open').with_args('schema.yaml').and_return('')
     builtins.should_receive('open').with_args('schema.yaml').and_return('')
+    flexmock(module.yaml).should_receive('round_trip_load')
+    flexmock(module).should_receive('_schema_to_sample_configuration')
+    flexmock(module).should_receive('merge_source_configuration_into_destination')
+    flexmock(module).should_receive('_render_configuration')
+    flexmock(module).should_receive('_comment_out_optional_configuration')
+    flexmock(module).should_receive('write_configuration')
+
+    module.generate_sample_configuration(None, 'dest.yaml', 'schema.yaml')
+
+
+def test_generate_sample_configuration_with_source_filename_does_not_raise():
+    builtins = flexmock(sys.modules['builtins'])
+    builtins.should_receive('open').with_args('schema.yaml').and_return('')
+    flexmock(module.yaml).should_receive('round_trip_load')
+    flexmock(module.load).should_receive('load_configuration')
     flexmock(module).should_receive('_schema_to_sample_configuration')
     flexmock(module).should_receive('_schema_to_sample_configuration')
+    flexmock(module).should_receive('merge_source_configuration_into_destination')
     flexmock(module).should_receive('_render_configuration')
     flexmock(module).should_receive('_render_configuration')
     flexmock(module).should_receive('_comment_out_optional_configuration')
     flexmock(module).should_receive('_comment_out_optional_configuration')
     flexmock(module).should_receive('write_configuration')
     flexmock(module).should_receive('write_configuration')
 
 
-    module.generate_sample_configuration('config.yaml', 'schema.yaml')
+    module.generate_sample_configuration('source.yaml', 'dest.yaml', 'schema.yaml')

+ 2 - 0
tests/unit/config/test_convert.py

@@ -21,6 +21,7 @@ def test_convert_section_generates_integer_value_for_integer_type_in_schema():
 
 
 def test_convert_legacy_parsed_config_transforms_source_config_to_mapping():
 def test_convert_legacy_parsed_config_transforms_source_config_to_mapping():
     flexmock(module.yaml.comments).should_receive('CommentedMap').replace_with(OrderedDict)
     flexmock(module.yaml.comments).should_receive('CommentedMap').replace_with(OrderedDict)
+    flexmock(module.generate).should_receive('add_comments_to_configuration_map')
     source_config = Parsed_config(
     source_config = Parsed_config(
         location=OrderedDict([('source_directories', '/home'), ('repository', 'hostname.borg')]),
         location=OrderedDict([('source_directories', '/home'), ('repository', 'hostname.borg')]),
         storage=OrderedDict([('encryption_passphrase', 'supersecret')]),
         storage=OrderedDict([('encryption_passphrase', 'supersecret')]),
@@ -53,6 +54,7 @@ def test_convert_legacy_parsed_config_transforms_source_config_to_mapping():
 
 
 def test_convert_legacy_parsed_config_splits_space_separated_values():
 def test_convert_legacy_parsed_config_splits_space_separated_values():
     flexmock(module.yaml.comments).should_receive('CommentedMap').replace_with(OrderedDict)
     flexmock(module.yaml.comments).should_receive('CommentedMap').replace_with(OrderedDict)
+    flexmock(module.generate).should_receive('add_comments_to_configuration_map')
     source_config = Parsed_config(
     source_config = Parsed_config(
         location=OrderedDict(
         location=OrderedDict(
             [('source_directories', '/home /etc'), ('repository', 'hostname.borg')]
             [('source_directories', '/home /etc'), ('repository', 'hostname.borg')]

+ 52 - 0
tests/unit/config/test_generate.py

@@ -51,6 +51,7 @@ def test_schema_to_sample_configuration_generates_config_sequence_of_strings_wit
 def test_schema_to_sample_configuration_generates_config_sequence_of_maps_with_examples():
 def test_schema_to_sample_configuration_generates_config_sequence_of_maps_with_examples():
     flexmock(module.yaml.comments).should_receive('CommentedSeq').replace_with(list)
     flexmock(module.yaml.comments).should_receive('CommentedSeq').replace_with(list)
     flexmock(module).should_receive('add_comments_to_configuration_sequence')
     flexmock(module).should_receive('add_comments_to_configuration_sequence')
+    flexmock(module).should_receive('add_comments_to_configuration_map')
     schema = {
     schema = {
         'seq': [
         'seq': [
             {
             {
@@ -71,3 +72,54 @@ def test_schema_to_sample_configuration_with_unsupported_schema_raises():
 
 
     with pytest.raises(ValueError):
     with pytest.raises(ValueError):
         module._schema_to_sample_configuration(schema)
         module._schema_to_sample_configuration(schema)
+
+
+def test_merge_source_configuration_into_destination_inserts_map_fields():
+    destination_config = {'foo': 'dest1', 'bar': 'dest2'}
+    source_config = {'foo': 'source1', 'baz': 'source2'}
+    flexmock(module).should_receive('remove_commented_out_sentinel')
+    flexmock(module).should_receive('yaml.comments.CommentedSeq').replace_with(list)
+
+    module.merge_source_configuration_into_destination(destination_config, source_config)
+
+    assert destination_config == {'foo': 'source1', 'bar': 'dest2', 'baz': 'source2'}
+
+
+def test_merge_source_configuration_into_destination_inserts_nested_map_fields():
+    destination_config = {'foo': {'first': 'dest1', 'second': 'dest2'}, 'bar': 'dest3'}
+    source_config = {'foo': {'first': 'source1'}}
+    flexmock(module).should_receive('remove_commented_out_sentinel')
+    flexmock(module).should_receive('yaml.comments.CommentedSeq').replace_with(list)
+
+    module.merge_source_configuration_into_destination(destination_config, source_config)
+
+    assert destination_config == {'foo': {'first': 'source1', 'second': 'dest2'}, 'bar': 'dest3'}
+
+
+def test_merge_source_configuration_into_destination_inserts_sequence_fields():
+    destination_config = {'foo': ['dest1', 'dest2'], 'bar': ['dest3'], 'baz': ['dest4']}
+    source_config = {'foo': ['source1'], 'bar': ['source2', 'source3']}
+    flexmock(module).should_receive('remove_commented_out_sentinel')
+    flexmock(module).should_receive('yaml.comments.CommentedSeq').replace_with(list)
+
+    module.merge_source_configuration_into_destination(destination_config, source_config)
+
+    assert destination_config == {
+        'foo': ['source1'],
+        'bar': ['source2', 'source3'],
+        'baz': ['dest4'],
+    }
+
+
+def test_merge_source_configuration_into_destination_inserts_sequence_of_maps():
+    destination_config = {'foo': [{'first': 'dest1', 'second': 'dest2'}], 'bar': 'dest3'}
+    source_config = {'foo': [{'first': 'source1'}, {'other': 'source2'}]}
+    flexmock(module).should_receive('remove_commented_out_sentinel')
+    flexmock(module).should_receive('yaml.comments.CommentedSeq').replace_with(list)
+
+    module.merge_source_configuration_into_destination(destination_config, source_config)
+
+    assert destination_config == {
+        'foo': [{'first': 'source1', 'second': 'dest2'}, {'other': 'source2'}],
+        'bar': 'dest3',
+    }