Browse Source

[neutron-db-manage] support separate migration branches

New migration rule scheme is introduced. Now, changes are classified
into the following branches:

- expand (additive changes only)
- contract (contraction changes, including data migration)

Make 'neutron-db-manage revision' generate two separate migration
scripts, one per branch.

Now that we support multiple heads, renamed HEAD file in HEADS. We still
don't allow more branching, so keep validation for the number of
branches.

For backwards compatibility, calling to 'upgrade head' applies both
branches in proper order.

Note that advanced services will be moved to the new migration scheme in
separate patches for respective repos.

This patch does not introduce autogenerate support for multiple branches
for 'revision' command (that depends on a new alembic version yet
unreleased; but alembic/master already has everything to support us).

The patch does not implement 'expand' or 'contract' commands that are
anticipated by the spec proposal. Those will come in consequent patches.

Upgrade impact is backwards compatible: those who are interested in
reducing downtime while applying some migration rules can opt in it by
modifying their upgrade practices, while everything should still work
the old way for those who don't care.

blueprint online-schema-migrations

DocImpact
UpgradeImpact

Change-Id: I3823900bc5aaf7757c37edb804027cf4d9c757ab
tags/7.0.0.0b2
Ihar Hrachyshka 4 years ago
parent
commit
c7acfbabdc

+ 41
- 0
doc/source/devref/db_layer.rst View File

@@ -23,6 +23,47 @@ should also be added in model. If default value in database is not needed,
23 23
 business logic.
24 24
 
25 25
 
26
+How we manage database migration rules
27
+--------------------------------------
28
+
29
+Since Liberty, Neutron maintains two parallel alembic migration branches.
30
+
31
+The first one, called 'expand', is used to store expansion-only migration
32
+rules. Those rules are strictly additive and can be applied while
33
+neutron-server is running. Examples of additive database schema changes are:
34
+creating a new table, adding a new table column, adding a new index, etc.
35
+
36
+The second branch, called 'contract', is used to store those migration rules
37
+that are not safe to apply while neutron-server is running. Those include:
38
+column or table removal, moving data from one part of the database into another
39
+(renaming a column, transforming single table into multiple, etc.), introducing
40
+or modifying constraints, etc.
41
+
42
+The intent of the split is to allow invoking those safe migrations from
43
+'expand' branch while neutron-server is running, reducing downtime needed to
44
+upgrade the service.
45
+
46
+To apply just expansion rules, execute:
47
+
48
+- neutron-db-manage upgrade liberty_expand@head
49
+
50
+After the first step is done, you can stop neutron-server, apply remaining
51
+non-expansive migration rules, if any:
52
+
53
+- neutron-db-manage upgrade liberty_contract@head
54
+
55
+and finally, start your neutron-server again.
56
+
57
+If you are not interested in applying safe migration rules while the service is
58
+running, you can still upgrade database the old way, by stopping the service,
59
+and then applying all available rules:
60
+
61
+- neutron-db-manage upgrade head[s]
62
+
63
+It will apply all the rules from both the expand and the contract branches, in
64
+proper order.
65
+
66
+
26 67
 Tests to verify that database migrations and models are in sync
27 68
 ---------------------------------------------------------------
28 69
 

+ 0
- 1
neutron/db/migration/alembic_migrations/versions/HEAD View File

@@ -1 +0,0 @@
1
-52c5312f6baf

+ 3
- 0
neutron/db/migration/alembic_migrations/versions/HEADS View File

@@ -0,0 +1,3 @@
1
+30018084ec99
2
+52c5312f6baf
3
+kilo

+ 30
- 0
neutron/db/migration/alembic_migrations/versions/liberty/contract/30018084ec99_initial.py View File

@@ -0,0 +1,30 @@
1
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
2
+#    not use this file except in compliance with the License. You may obtain
3
+#    a copy of the License at
4
+#
5
+#         http://www.apache.org/licenses/LICENSE-2.0
6
+#
7
+#    Unless required by applicable law or agreed to in writing, software
8
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
9
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
10
+#    License for the specific language governing permissions and limitations
11
+#    under the License.
12
+#
13
+
14
+"""Initial no-op Liberty contract rule.
15
+
16
+Revision ID: 30018084ec99
17
+Revises: None
18
+Create Date: 2015-06-22 00:00:00.000000
19
+
20
+"""
21
+
22
+# revision identifiers, used by Alembic.
23
+revision = '30018084ec99'
24
+down_revision = None
25
+depends_on = ('kilo',)
26
+branch_labels = ('liberty_contract',)
27
+
28
+
29
+def upgrade():
30
+    pass

neutron/db/migration/alembic_migrations/versions/354db87e3225_nsxv_vdr_metadata.py → neutron/db/migration/alembic_migrations/versions/liberty/expand/354db87e3225_nsxv_vdr_metadata.py View File

@@ -23,7 +23,10 @@ Create Date: 2015-04-19 14:59:15.102609
23 23
 
24 24
 # revision identifiers, used by Alembic.
25 25
 revision = '354db87e3225'
26
-down_revision = 'kilo'
26
+down_revision = None
27
+branch_labels = ('liberty_expand',)
28
+depends_on = ('kilo',)
29
+
27 30
 from alembic import op
28 31
 import sqlalchemy as sa
29 32
 

neutron/db/migration/alembic_migrations/versions/52c5312f6baf_address_scopes.py → neutron/db/migration/alembic_migrations/versions/liberty/expand/52c5312f6baf_address_scopes.py View File


neutron/db/migration/alembic_migrations/versions/599c6a226151_neutrodb_ipam.py → neutron/db/migration/alembic_migrations/versions/liberty/expand/599c6a226151_neutrodb_ipam.py View File


+ 116
- 31
neutron/db/migration/cli.py View File

@@ -25,7 +25,12 @@ from oslo_utils import importutils
25 25
 
26 26
 from neutron.common import repos
27 27
 
28
-HEAD_FILENAME = 'HEAD'
28
+
29
+# TODO(ihrachyshka): maintain separate HEAD files per branch
30
+HEADS_FILENAME = 'HEADS'
31
+CURRENT_RELEASE = "liberty"
32
+MIGRATION_BRANCHES = ('expand', 'contract')
33
+
29 34
 
30 35
 mods = repos.NeutronModules()
31 36
 VALID_SERVICES = map(mods.alembic_name, mods.installed_list())
@@ -76,7 +81,7 @@ def do_alembic_command(config, cmd, *args, **kwargs):
76 81
 
77 82
 def do_check_migration(config, cmd):
78 83
     do_alembic_command(config, 'branches')
79
-    validate_head_file(config)
84
+    validate_heads_file(config)
80 85
 
81 86
 
82 87
 def add_alembic_subparser(sub, cmd):
@@ -101,6 +106,10 @@ def do_upgrade(config, cmd):
101 106
             raise SystemExit(_('Negative delta (downgrade) not supported'))
102 107
         revision = '%s+%d' % (revision, delta)
103 108
 
109
+    # leave branchless 'head' revision request backward compatible by applying
110
+    # all heads in all available branches.
111
+    if revision == 'head':
112
+        revision = 'heads'
104 113
     if not CONF.command.sql:
105 114
         run_sanity_checks(config, revision)
106 115
     do_alembic_command(config, cmd, revision, sql=CONF.command.sql)
@@ -116,35 +125,62 @@ def do_stamp(config, cmd):
116 125
                        sql=CONF.command.sql)
117 126
 
118 127
 
119
-def do_revision(config, cmd):
120
-    do_alembic_command(config, cmd,
121
-                       message=CONF.command.message,
122
-                       autogenerate=CONF.command.autogenerate,
123
-                       sql=CONF.command.sql)
124
-    update_head_file(config)
125
-
128
+def _get_branch_head(branch):
129
+    '''Get the latest @head specification for a branch.'''
130
+    return '%s_%s@head' % (CURRENT_RELEASE, branch)
126 131
 
127
-def validate_head_file(config):
128
-    script = alembic_script.ScriptDirectory.from_config(config)
129
-    if len(script.get_heads()) > 1:
130
-        alembic_util.err(_('Timeline branches unable to generate timeline'))
131 132
 
132
-    head_path = os.path.join(script.versions, HEAD_FILENAME)
133
-    if (os.path.isfile(head_path) and
134
-        open(head_path).read().strip() == script.get_current_head()):
135
-        return
133
+def do_revision(config, cmd):
134
+    '''Generate new revision files, one per branch.'''
135
+    if _separate_migration_branches_supported(CONF):
136
+        for branch in MIGRATION_BRANCHES:
137
+            version_path = _get_version_branch_path(CONF, branch)
138
+            head = _get_branch_head(branch)
139
+            do_alembic_command(config, cmd,
140
+                               message=CONF.command.message,
141
+                               autogenerate=CONF.command.autogenerate,
142
+                               sql=CONF.command.sql,
143
+                               version_path=version_path,
144
+                               head=head)
136 145
     else:
137
-        alembic_util.err(_('HEAD file does not match migration timeline head'))
146
+        do_alembic_command(config, cmd,
147
+                           message=CONF.command.message,
148
+                           autogenerate=CONF.command.autogenerate,
149
+                           sql=CONF.command.sql)
150
+    update_heads_file(config)
138 151
 
139 152
 
140
-def update_head_file(config):
153
+def _get_sorted_heads(script):
154
+    '''Get the list of heads for all branches, sorted.'''
155
+    heads = script.get_heads()
156
+    # +1 stands for the core 'kilo' branch, the one that didn't have branches
157
+    if len(heads) > len(MIGRATION_BRANCHES) + 1:
158
+        alembic_util.err(_('No new branches are allowed except: %s') %
159
+                         ' '.join(MIGRATION_BRANCHES))
160
+    return sorted(heads)
161
+
162
+
163
+def validate_heads_file(config):
164
+    '''Check that HEADS file contains the latest heads for each branch.'''
141 165
     script = alembic_script.ScriptDirectory.from_config(config)
142
-    if len(script.get_heads()) > 1:
143
-        alembic_util.err(_('Timeline branches unable to generate timeline'))
166
+    heads = _get_sorted_heads(script)
167
+    heads_path = _get_heads_file_path(CONF)
168
+    try:
169
+        with open(heads_path) as file_:
170
+            if file_.read().split() == heads:
171
+                return
172
+    except IOError:
173
+        pass
174
+    alembic_util.err(_('HEADS file does not match migration timeline heads'))
175
+
144 176
 
145
-    head_path = os.path.join(script.versions, HEAD_FILENAME)
146
-    with open(head_path, 'w+') as f:
147
-        f.write(script.get_current_head())
177
+def update_heads_file(config):
178
+    '''Update HEADS file with the latest branch heads.'''
179
+    script = alembic_script.ScriptDirectory.from_config(config)
180
+    heads = _get_sorted_heads(script)
181
+    heads_path = _get_heads_file_path(CONF)
182
+    with open(heads_path, 'w+') as f:
183
+        f.write('\n'.join(heads))
148 184
 
149 185
 
150 186
 def add_command_parsers(subparsers):
@@ -191,6 +227,55 @@ command_opt = cfg.SubCommandOpt('command',
191 227
 CONF.register_cli_opt(command_opt)
192 228
 
193 229
 
230
+def _get_neutron_service_base(neutron_config):
231
+    '''Return base python namespace name for a service.'''
232
+    if neutron_config.service:
233
+        validate_service_installed(neutron_config.service)
234
+        return "neutron_%s" % neutron_config.service
235
+    return "neutron"
236
+
237
+
238
+def _get_root_versions_dir(neutron_config):
239
+    '''Return root directory that contains all migration rules.'''
240
+    service_base = _get_neutron_service_base(neutron_config)
241
+    root_module = importutils.import_module(service_base)
242
+    return os.path.join(
243
+        os.path.dirname(root_module.__file__),
244
+        'db/migration/alembic_migrations/versions')
245
+
246
+
247
+def _get_heads_file_path(neutron_config):
248
+    '''Return the path of the file that contains all latest heads, sorted.'''
249
+    return os.path.join(
250
+        _get_root_versions_dir(neutron_config),
251
+        HEADS_FILENAME)
252
+
253
+
254
+def _get_version_branch_path(neutron_config, branch=None):
255
+    version_path = _get_root_versions_dir(neutron_config)
256
+    if branch:
257
+        return os.path.join(version_path, CURRENT_RELEASE, branch)
258
+    return version_path
259
+
260
+
261
+def _separate_migration_branches_supported(neutron_config):
262
+    '''Detect whether split migration branches are supported.'''
263
+    # Use HEADS file to indicate the new, split migration world
264
+    return os.path.exists(_get_heads_file_path(neutron_config))
265
+
266
+
267
+def _set_version_locations(config):
268
+    '''Make alembic see all revisions in all migration branches.'''
269
+    version_paths = []
270
+
271
+    version_paths.append(_get_version_branch_path(CONF))
272
+    if _separate_migration_branches_supported(CONF):
273
+        for branch in MIGRATION_BRANCHES:
274
+            version_paths.append(_get_version_branch_path(CONF, branch))
275
+
276
+    config.set_main_option('version_locations', ' '.join(version_paths))
277
+
278
+
194 279
 def validate_service_installed(service):
195 280
     if not importutils.try_import('neutron_%s' % service):
196 281
         alembic_util.err(_('Package neutron-%s not installed') % service)
@@ -198,18 +283,14 @@ def validate_service_installed(service):
198 283
 
199 284
 def get_script_location(neutron_config):
200 285
     location = '%s.db.migration:alembic_migrations'
201
-    if neutron_config.service:
202
-        validate_service_installed(neutron_config.service)
203
-        base = "neutron_%s" % neutron_config.service
204
-    else:
205
-        base = "neutron"
206
-    return location % base
286
+    return location % _get_neutron_service_base(neutron_config)
207 287
 
208 288
 
209 289
 def get_alembic_config():
210 290
     config = alembic_config.Config(os.path.join(os.path.dirname(__file__),
211 291
                                                 'alembic.ini'))
212 292
     config.set_main_option('script_location', get_script_location(CONF))
293
+    _set_version_locations(config)
213 294
     return config
214 295
 
215 296
 
@@ -217,7 +298,11 @@ def run_sanity_checks(config, revision):
217 298
     script_dir = alembic_script.ScriptDirectory.from_config(config)
218 299
 
219 300
     def check_sanity(rev, context):
220
-        for script in script_dir.iterate_revisions(revision, rev):
301
+        # TODO(ihrachyshka): here we use internal API for alembic; we may need
302
+        # alembic to expose implicit_base= argument into public
303
+        # iterate_revisions() call
304
+        for script in script_dir.revision_map.iterate_revisions(
305
+                revision, rev, implicit_base=True):
221 306
             if hasattr(script.module, 'check_sanity'):
222 307
                 script.module.check_sanity(context.connection)
223 308
         return []

+ 1
- 1
neutron/tests/functional/db/test_migrations.py View File

@@ -121,7 +121,7 @@ class _TestModelsMigrations(test_migrations.ModelsMigrationsSync):
121 121
 
122 122
     def db_sync(self, engine):
123 123
         cfg.CONF.set_override('connection', engine.url, group='database')
124
-        migration.do_alembic_command(self.alembic_config, 'upgrade', 'head')
124
+        migration.do_alembic_command(self.alembic_config, 'upgrade', 'heads')
125 125
         cfg.CONF.clear_override('connection', group='database')
126 126
 
127 127
     def get_engine(self):

+ 76
- 36
neutron/tests/unit/db/test_migration.py View File

@@ -75,12 +75,13 @@ class TestCli(base.BaseTestCase):
75 75
         self.mock_alembic_err = mock.patch('alembic.util.err').start()
76 76
         self.mock_alembic_err.side_effect = SystemExit
77 77
 
78
-    def _main_test_helper(self, argv, func_name, exp_args=(), exp_kwargs={}):
78
+    def _main_test_helper(self, argv, func_name, exp_args=(), exp_kwargs=[{}]):
79 79
         with mock.patch.object(sys, 'argv', argv), mock.patch.object(
80 80
                 cli, 'run_sanity_checks'):
81 81
             cli.main()
82 82
             self.do_alembic_cmd.assert_has_calls(
83
-                [mock.call(mock.ANY, func_name, *exp_args, **exp_kwargs)]
83
+                [mock.call(mock.ANY, func_name, *exp_args, **kwargs)
84
+                 for kwargs in exp_kwargs]
84 85
             )
85 86
 
86 87
     def test_stamp(self):
@@ -88,14 +89,14 @@ class TestCli(base.BaseTestCase):
88 89
             ['prog', 'stamp', 'foo'],
89 90
             'stamp',
90 91
             ('foo',),
91
-            {'sql': False}
92
+            [{'sql': False}]
92 93
         )
93 94
 
94 95
         self._main_test_helper(
95 96
             ['prog', 'stamp', 'foo', '--sql'],
96 97
             'stamp',
97 98
             ('foo',),
98
-            {'sql': True}
99
+            [{'sql': True}]
99 100
         )
100 101
 
101 102
     def test_current(self):
@@ -105,49 +106,75 @@ class TestCli(base.BaseTestCase):
105 106
         self._main_test_helper(['prog', 'history'], 'history')
106 107
 
107 108
     def test_check_migration(self):
108
-        with mock.patch.object(cli, 'validate_head_file') as validate:
109
+        with mock.patch.object(cli, 'validate_heads_file') as validate:
109 110
             self._main_test_helper(['prog', 'check_migration'], 'branches')
110 111
             validate.assert_called_once_with(mock.ANY)
111 112
 
112
-    def test_database_sync_revision(self):
113
-        with mock.patch.object(cli, 'update_head_file') as update:
113
+    def _test_database_sync_revision(self, separate_branches=True):
114
+        with mock.patch.object(cli, 'update_heads_file') as update:
115
+            class FakeConfig(object):
116
+                service = ''
117
+
118
+            fake_config = FakeConfig()
119
+            if separate_branches:
120
+                expected_kwargs = [
121
+                    {'message': 'message', 'sql': False, 'autogenerate': True,
122
+                     'version_path':
123
+                         cli._get_version_branch_path(fake_config, branch),
124
+                     'head': cli._get_branch_head(branch)}
125
+                    for branch in cli.MIGRATION_BRANCHES]
126
+            else:
127
+                expected_kwargs = [{
128
+                    'message': 'message', 'sql': False, 'autogenerate': True,
129
+                }]
114 130
             self._main_test_helper(
115 131
                 ['prog', 'revision', '--autogenerate', '-m', 'message'],
116 132
                 'revision',
117
-                (),
118
-                {'message': 'message', 'sql': False, 'autogenerate': True}
133
+                (), expected_kwargs
119 134
             )
120 135
             update.assert_called_once_with(mock.ANY)
121
-
122 136
             update.reset_mock()
137
+
138
+            for kwarg in expected_kwargs:
139
+                kwarg['autogenerate'] = False
140
+                kwarg['sql'] = True
141
+
123 142
             self._main_test_helper(
124 143
                 ['prog', 'revision', '--sql', '-m', 'message'],
125 144
                 'revision',
126
-                (),
127
-                {'message': 'message', 'sql': True, 'autogenerate': False}
145
+                (), expected_kwargs
128 146
             )
129 147
             update.assert_called_once_with(mock.ANY)
130 148
 
149
+    def test_database_sync_revision(self):
150
+        self._test_database_sync_revision()
151
+
152
+    @mock.patch.object(cli, '_separate_migration_branches_supported',
153
+                       return_value=False)
154
+    def test_database_sync_revision_no_branches(self, *args):
155
+        # Test that old branchless approach is still supported
156
+        self._test_database_sync_revision(separate_branches=False)
157
+
131 158
     def test_upgrade(self):
132 159
         self._main_test_helper(
133 160
             ['prog', 'upgrade', '--sql', 'head'],
134 161
             'upgrade',
135
-            ('head',),
136
-            {'sql': True}
162
+            ('heads',),
163
+            [{'sql': True}]
137 164
         )
138 165
 
139 166
         self._main_test_helper(
140 167
             ['prog', 'upgrade', '--delta', '3'],
141 168
             'upgrade',
142 169
             ('+3',),
143
-            {'sql': False}
170
+            [{'sql': False}]
144 171
         )
145 172
 
146 173
         self._main_test_helper(
147 174
             ['prog', 'upgrade', 'kilo', '--delta', '3'],
148 175
             'upgrade',
149 176
             ('kilo+3',),
150
-            {'sql': False}
177
+            [{'sql': False}]
151 178
         )
152 179
 
153 180
     def assert_command_fails(self, command):
@@ -169,7 +196,7 @@ class TestCli(base.BaseTestCase):
169 196
     def test_upgrade_rejects_delta_with_relative_revision(self):
170 197
         self.assert_command_fails(['prog', 'upgrade', '+2', '--delta', '3'])
171 198
 
172
-    def _test_validate_head_file_helper(self, heads, file_content=None):
199
+    def _test_validate_heads_file_helper(self, heads, file_content=None):
173 200
         with mock.patch('alembic.script.ScriptDirectory.from_config') as fc:
174 201
             fc.return_value.get_heads.return_value = heads
175 202
             fc.return_value.get_current_head.return_value = heads[0]
@@ -182,47 +209,60 @@ class TestCli(base.BaseTestCase):
182 209
                     is_file.return_value = file_content is not None
183 210
 
184 211
                     if file_content in heads:
185
-                        cli.validate_head_file(mock.sentinel.config)
212
+                        cli.validate_heads_file(mock.sentinel.config)
186 213
                     else:
187 214
                         self.assertRaises(
188 215
                             SystemExit,
189
-                            cli.validate_head_file,
216
+                            cli.validate_heads_file,
190 217
                             mock.sentinel.config
191 218
                         )
192 219
                         self.mock_alembic_err.assert_called_once_with(mock.ANY)
193 220
             fc.assert_called_once_with(mock.sentinel.config)
194 221
 
195
-    def test_validate_head_file_multiple_heads(self):
196
-        self._test_validate_head_file_helper(['a', 'b'])
222
+    def test_validate_heads_file_multiple_heads(self):
223
+        self._test_validate_heads_file_helper(['a', 'b'])
197 224
 
198
-    def test_validate_head_file_missing_file(self):
199
-        self._test_validate_head_file_helper(['a'])
225
+    def test_validate_heads_file_missing_file(self):
226
+        self._test_validate_heads_file_helper(['a'])
200 227
 
201
-    def test_validate_head_file_wrong_contents(self):
202
-        self._test_validate_head_file_helper(['a'], 'b')
228
+    def test_validate_heads_file_wrong_contents(self):
229
+        self._test_validate_heads_file_helper(['a'], 'b')
203 230
 
204 231
     def test_validate_head_success(self):
205
-        self._test_validate_head_file_helper(['a'], 'a')
232
+        self._test_validate_heads_file_helper(['a'], 'a')
233
+
234
+    def test_update_heads_file_two_heads(self):
235
+        with mock.patch('alembic.script.ScriptDirectory.from_config') as fc:
236
+            heads = ('b', 'a')
237
+            fc.return_value.get_heads.return_value = heads
238
+            with mock.patch('six.moves.builtins.open') as mock_open:
239
+                mock_open.return_value.__enter__ = lambda s: s
240
+                mock_open.return_value.__exit__ = mock.Mock()
241
+
242
+                cli.update_heads_file(mock.sentinel.config)
243
+                mock_open.return_value.write.assert_called_once_with(
244
+                    '\n'.join(sorted(heads)))
206 245
 
207
-    def test_update_head_file_multiple_heads(self):
246
+    def test_update_heads_file_excessive_heads_negative(self):
208 247
         with mock.patch('alembic.script.ScriptDirectory.from_config') as fc:
209
-            fc.return_value.get_heads.return_value = ['a', 'b']
248
+            heads = ('b', 'a', 'c', 'kilo')
249
+            fc.return_value.get_heads.return_value = heads
210 250
             self.assertRaises(
211 251
                 SystemExit,
212
-                cli.update_head_file,
252
+                cli.update_heads_file,
213 253
                 mock.sentinel.config
214 254
             )
215 255
             self.mock_alembic_err.assert_called_once_with(mock.ANY)
216
-            fc.assert_called_once_with(mock.sentinel.config)
217 256
 
218
-    def test_update_head_file_success(self):
257
+    def test_update_heads_file_success(self):
219 258
         with mock.patch('alembic.script.ScriptDirectory.from_config') as fc:
220
-            fc.return_value.get_heads.return_value = ['a']
221
-            fc.return_value.get_current_head.return_value = 'a'
259
+            heads = ('a', 'b')
260
+            fc.return_value.get_heads.return_value = heads
261
+            fc.return_value.get_current_head.return_value = heads
222 262
             with mock.patch('six.moves.builtins.open') as mock_open:
223 263
                 mock_open.return_value.__enter__ = lambda s: s
224 264
                 mock_open.return_value.__exit__ = mock.Mock()
225 265
 
226
-                cli.update_head_file(mock.sentinel.config)
227
-                mock_open.return_value.write.assert_called_once_with('a')
228
-            fc.assert_called_once_with(mock.sentinel.config)
266
+                cli.update_heads_file(mock.sentinel.config)
267
+                mock_open.return_value.write.assert_called_once_with(
268
+                    '\n'.join(heads))

Loading…
Cancel
Save