diff --git a/README.md b/README.md index a92aefc..896ff36 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Overview This interface is used for a charm to send configuration information to the -neutron-api principle charm and request a restart of a service managed by -that charm. +neutron-api principle charm, request a restart of a service managed by +that charm, and request database migration to be performed. # Usage @@ -10,6 +10,10 @@ that charm. The interface provides the `{relation-name}.connected` and `{relation_name}.available` flags and states. +The charm may set the `{relation-name}.db_migration` flag to instruct the +interface code to gate the `{relation_name}.available` flag/state on +completion of any in-flight database migration requests. + ## neutron\_config\_data The neutron\_config\_data property allows the charm author to introspect a @@ -96,6 +100,51 @@ def remote_restart(api_principle): api_principle.request_restart(service_type='neutron') ``` +## request\_db\_migration + +Request principle charm to perform a DB migration. This is useful both at +initial deploy time and at subsequent changes as the lifecycle of the +subordinate may be independent of the principle charm. + +An example of how to request db migration upon initial deployment: + +```python +@reactive.when_none('neutron-plugin-api-subordinate.db_migration', + 'neutron-plugin-api-subordinate.available') +@reactive.when('charm.installed') +def flag_db_migration(): + reactive.set_flag('neutron-plugin-api-subordinate.db_migration') + + +@reactive.when_none('neutron-plugin-api-subordinate.available', + 'run-default-update-status') +@reactive.when('neutron-plugin-api-subordinate.connected') +def request_db_migration(): + neutron = reactive.endpoint_from_flag( + 'neutron-plugin-api-subordinate.connected') + neutron.request_db_migration() +``` + +An example of usage in conjunction with post deployment change: + +```python +@reactive.when('config.changed') +def handle_change(): + ... + if config_change_added_package_which_requires_db_migration: + neutron = reactive.endpoint_from_flag( + 'neutron-plugin-api-subordinate.connected') + neutron.request_db_migration() + + +@reactive.when('neutron-plugin-api-subordinate.available') +def do_something(): + ... + # After requesting the DB migration above, you will not get here until it + # is done. + use_new_feature() +``` + # Metadata To consume this interface in your charm or layer, add the following to diff --git a/provides.py b/provides.py index c7dc840..d05e498 100644 --- a/provides.py +++ b/provides.py @@ -1,6 +1,8 @@ import uuid import json +import charms.reactive as reactive + from charms.reactive import hook from charms.reactive import RelationBase from charms.reactive import scopes @@ -12,9 +14,9 @@ class NeutronPluginAPISubordinate(RelationBase): @hook( '{provides:neutron-plugin-api-subordinate}-relation-{joined,changed}') def changed(self): - """Set connected state""" + """Set connected state and assess available state""" self.set_state('{relation_name}.connected') - if self.neutron_api_ready(): + if self.neutron_api_ready() and not self.db_migration_pending(): self.set_state('{relation_name}.available') @hook( @@ -26,9 +28,11 @@ class NeutronPluginAPISubordinate(RelationBase): @property def neutron_config_data(self): + """Retrive and decode ``neutron_config_data`` from relation""" return json.loads(self.get_remote('neutron_config_data', "{}")) def neutron_api_ready(self): + """Assess remote readiness""" if self.get_remote('neutron-api-ready') == 'yes': return True return False @@ -88,7 +92,6 @@ class NeutronPluginAPISubordinate(RelationBase): """ if subordinate_configuration is None: subordinate_configuration = {} - conversation = self.conversation() relation_info = { 'neutron-plugin': neutron_plugin, 'core-plugin': core_plugin, @@ -100,7 +103,7 @@ class NeutronPluginAPISubordinate(RelationBase): 'neutron-security-groups': neutron_security_groups, 'subordinate_configuration': json.dumps(subordinate_configuration), } - conversation.set_remote(**relation_info) + self.set_remote(**relation_info) def request_restart(self, service_type=None): """Request a restart of a set of remote services @@ -117,3 +120,52 @@ class NeutronPluginAPISubordinate(RelationBase): key: str(uuid.uuid4()), } self.set_remote(**relation_info) + + def request_db_migration(self): + """Request principal to perform a DB migration""" + if not self.neutron_api_ready(): + # Ignore the charm request until we are in a relation-changed hook + # where the prinicpal charm has declared itself ready. + return + nonce = str(uuid.uuid4()) + relation_info = { + 'migrate-database-nonce': nonce, + } + self.set_remote(**relation_info) + # NOTE: we use flags instead of RelationBase state here both because of + # easier interaction with charm code, and because of how states + # interact with RelationBase conversations leading to crashes + # when used prior to relation being fully established. + reactive.set_flag('{relation_name}.db_migration' + .format(relation_name=self.relation_name)) + reactive.set_flag('{relation_name}.db_migration.' + .format(relation_name=self.relation_name)+nonce) + reactive.clear_flag('{relation_name}.available' + .format(relation_name=self.relation_name)) + + def db_migration_pending(self): + """Assess presence and state of optional DB migration request""" + # NOTE: we use flags instead of RelationBase state here both because of + # easier interaction with charm code, and because of how states + # interact with RelationBase conversations leading to crashes + # when used prior to relation being fully established. + flag_prefix = ('{relation_name}.db_migration' + .format(relation_name=self.relation_name)) + if not reactive.is_flag_set(flag_prefix): + return False + flag_nonce = '.'.join( + (flag_prefix, + self.get_remote('migrate-database-nonce', ''))) + if reactive.is_flag_set(flag_nonce): + # hooks fire in a nondeterministic order, and there will be + # occations where a different hook run between the + # ``migrate-database-nonce`` being set and it being returned to us + # a subsequent relation-changed hook. + # + # to avoid buildup of unreaped db_migration nonce flags we remove + # all of them each time we have a match for one. + for flag in reactive.get_flags(): + if flag.startswith(flag_prefix): + reactive.clear_flag(flag) + return False + return True diff --git a/unit_tests/test_provides.py b/unit_tests/test_provides.py index 25eac5f..340f6f1 100644 --- a/unit_tests/test_provides.py +++ b/unit_tests/test_provides.py @@ -144,8 +144,7 @@ class TestNeutronPluginApiSubordinateProvides(test_utils.PatchHelper): self.assertEquals(self.target.neutron_config_data, {'k': 'v'}) def test_configure_plugin(self): - conversation = mock.MagicMock() - self.patch_target('conversation', conversation) + self.patch_target('set_remote') self.target.configure_plugin('aPlugin', 'aCorePlugin', 'aNeutronPluginConfig', @@ -156,7 +155,7 @@ class TestNeutronPluginApiSubordinateProvides(test_utils.PatchHelper): 'typeDriver1,typeDriver2', 'toggleSecurityGroups', ) - conversation.set_remote.assert_called_once_with( + self.set_remote.assert_called_once_with( **{ 'core-plugin': 'aCorePlugin', 'neutron-plugin': 'aPlugin', @@ -170,16 +169,63 @@ class TestNeutronPluginApiSubordinateProvides(test_utils.PatchHelper): ) def test_request_restart(self): - conversation = mock.MagicMock() - self.patch_target('conversation', conversation) self.patch_object(provides.uuid, 'uuid4') self.uuid4.return_value = 'fake-uuid' + self.patch_target('set_remote') self.target.request_restart() - conversation.set_remote.assert_called_once_with( - None, None, None, **{'restart-trigger': 'fake-uuid'}, + self.set_remote.assert_called_once_with( + **{'restart-trigger': 'fake-uuid'}, ) - conversation.set_remote.reset_mock() + self.set_remote.reset_mock() self.target.request_restart('aServiceType') - conversation.set_remote.assert_called_once_with( - None, None, None, **{'restart-trigger-aServiceType': 'fake-uuid'}, + self.set_remote.assert_called_once_with( + **{'restart-trigger-aServiceType': 'fake-uuid'}, ) + + def test_request_db_migration(self): + self.patch_target('neutron_api_ready') + self.patch_object(provides.uuid, 'uuid4') + self.neutron_api_ready.return_value = False + self.target.request_db_migration() + self.assertFalse(self.uuid4.called) + self.patch_target('set_remote') + self.patch_object(provides.reactive, 'set_flag') + self.patch_object(provides.reactive, 'clear_flag') + self.neutron_api_ready.return_value = True + self.uuid4.return_value = 'fake-uuid' + self.target.request_db_migration() + self.set_remote.assert_called_once_with( + **{'migrate-database-nonce': 'fake-uuid'}) + self.set_flag.assert_has_calls([ + mock.call('some-relation.db_migration'), + mock.call('some-relation.db_migration.fake-uuid'), + ]) + self.clear_flag.assert_called_once_with('some-relation.available') + + def test_db_migration_pending(self): + self.patch_object(provides.reactive, 'is_flag_set') + self.patch_target('get_remote') + self.is_flag_set.return_value = False + self.target.db_migration_pending() + self.is_flag_set.assert_called_once_with('some-relation.db_migration') + self.assertFalse(self.get_remote.called) + self.is_flag_set.side_effect = [True, False] + self.get_remote.return_value = 'fake-uuid' + self.assertTrue(self.target.db_migration_pending()) + self.get_remote.assert_called_once_with('migrate-database-nonce', '') + self.is_flag_set.assert_has_calls([ + mock.call('some-relation.db_migration'), + mock.call('some-relation.db_migration.fake-uuid'), + ]) + self.is_flag_set.side_effect = [True, True] + self.patch_object(provides.reactive, 'clear_flag') + self.patch_object(provides.reactive, 'get_flags') + self.get_flags.return_value = [ + 'some-relation.db_migration.fake-uuid', + 'some-relation.db_migration', + ] + self.assertFalse(self.target.db_migration_pending()) + self.clear_flag.assert_has_calls([ + mock.call('some-relation.db_migration.fake-uuid'), + mock.call('some-relation.db_migration'), + ])