diff --git a/src/actions.yaml b/src/actions.yaml new file mode 100644 index 0000000..8325746 --- /dev/null +++ b/src/actions.yaml @@ -0,0 +1,18 @@ +demote: + description: | + Demote all primary images within all pools to non-primary. + params: + force: + type: boolean +promote: + description: | + Promote all non-primary images within all pools to primary. + params: + force: + type: boolean +refresh-pools: + description: | + \ + Refresh list of pools from local and remote Ceph endpoint. + As a side effect, mirroring will be configured for any manually created + pools that the charm currently does not know about. diff --git a/src/actions/actions.py b/src/actions/actions.py new file mode 100755 index 0000000..163d358 --- /dev/null +++ b/src/actions/actions.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +# Copyright 2019 Canonical Ltd +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import subprocess +import sys + +# Load basic layer module from $CHARM_DIR/lib +sys.path.append('lib') +from charms.layer import basic + +# setup module loading from charm venv +basic.bootstrap_charm_deps() + +import charms.reactive as reactive +import charmhelpers.core as ch_core +import charms_openstack.bus +import charms_openstack.charm + +# load reactive interfaces +reactive.bus.discover() +# load Endpoint based interface data +ch_core.hookenv._run_atstart() + +# load charm class +charms_openstack.bus.discover() + + +def rbd_mirror_action(args): + """Perform RBD command on pools in local Ceph endpoint.""" + action_name = os.path.basename(args[0]) + with charms_openstack.charm.provide_charm_instance() as charm: + ceph_local = reactive.endpoint_from_name('ceph-local') + pools = (pool for pool, attrs in ceph_local.pools.items() + if 'rbd' in attrs['applications']) + result = [] + cmd = ['rbd', '--id', charm.ceph_id, 'mirror', 'pool', action_name] + if ch_core.hookenv.action_get('force'): + cmd += ['--force'] + for pool in pools: + output = subprocess.check_output(cmd + [pool], + stderr=subprocess.STDOUT, + universal_newlines=True) + result.append('{}: {}'.format(pool, output.rstrip())) + ch_core.hookenv.action_set({'output': '\n'.join(result)}) + + +def refresh_pools(args): + """Refresh list of pools from Ceph. + + This is done by updating data on relations to ceph-mons which lead to them + updating the relation data they have with us as a response. + + Due to how the reactive framework handles publishing of relation data we + must do this by setting a flag and runnnig the reactive handlers, emulating + a full hook execution. + """ + if not reactive.is_flag_set('leadership.is_leader'): + ch_core.hookenv.action_fail('run action on the leader unit') + return + + # set and flush flag to disk + reactive.set_flag('refresh.pools') + ch_core.unitdata._KV.flush() + + # run reactive handlers to deal with flag + return reactive.main() + + +ACTIONS = { + 'demote': rbd_mirror_action, + 'promote': rbd_mirror_action, + 'refresh-pools': refresh_pools, +} + + +def main(args): + action_name = os.path.basename(args[0]) + try: + action = ACTIONS[action_name] + except KeyError: + return 'Action {} is undefined'.format(action_name) + + try: + action(args) + except Exception as e: + ch_core.hookenv.action_fail(str(e)) + + +if __name__ == '__main__': + sys.exit(main(sys.argv)) diff --git a/src/actions/demote b/src/actions/demote new file mode 120000 index 0000000..405a394 --- /dev/null +++ b/src/actions/demote @@ -0,0 +1 @@ +actions.py \ No newline at end of file diff --git a/src/actions/promote b/src/actions/promote new file mode 120000 index 0000000..405a394 --- /dev/null +++ b/src/actions/promote @@ -0,0 +1 @@ +actions.py \ No newline at end of file diff --git a/src/actions/refresh-pools b/src/actions/refresh-pools new file mode 120000 index 0000000..405a394 --- /dev/null +++ b/src/actions/refresh-pools @@ -0,0 +1 @@ +actions.py \ No newline at end of file diff --git a/src/reactive/ceph_rbd_mirror_handlers.py b/src/reactive/ceph_rbd_mirror_handlers.py index 6ba018c..a31dd1c 100644 --- a/src/reactive/ceph_rbd_mirror_handlers.py +++ b/src/reactive/ceph_rbd_mirror_handlers.py @@ -86,6 +86,17 @@ def render_stuff(*args): charm_instance.assess_status() +@reactive.when('leadership.is_leader') +@reactive.when('refresh.pools') +@reactive.when('ceph-local.available') +@reactive.when('ceph-remote.available') +def refresh_pools(): + for endpoint in 'ceph-local', 'ceph-remote': + endpoint = reactive.endpoint_from_name(endpoint) + endpoint.refresh_pools() + reactive.clear_flag('refresh.pools') + + @reactive.when('leadership.is_leader') @reactive.when('config.rendered') @reactive.when('ceph-local.available') diff --git a/unit_tests/test_actions.py b/unit_tests/test_actions.py new file mode 100644 index 0000000..7fb3517 --- /dev/null +++ b/unit_tests/test_actions.py @@ -0,0 +1,115 @@ +# Copyright 2019 Canonical Ltd +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import collections +import mock +import sys + +sys.modules['charms.layer'] = mock.MagicMock() +import actions.actions as actions +import charm.openstack.ceph_rbd_mirror as crm + +import charms_openstack.test_utils as test_utils + + +class TestCephRBDMirrorActions(test_utils.PatchHelper): + + def setUp(self): + super().setUp() + self.patch_release(crm.CephRBDMirrorCharm.release) + self.crm_charm = mock.MagicMock() + self.patch_object(actions.charms_openstack.charm, + 'provide_charm_instance', + new=mock.MagicMock()) + self.provide_charm_instance().__enter__.return_value = \ + self.crm_charm + self.provide_charm_instance().__exit__.return_value = None + + def test_rbd_mirror_action(self): + self.patch_object(actions.reactive, 'endpoint_from_name') + self.patch_object(actions.ch_core.hookenv, 'action_get') + self.patch_object(actions.subprocess, 'check_output') + self.patch_object(actions.ch_core.hookenv, 'action_set') + endpoint = mock.MagicMock() + endpoint.pools = collections.OrderedDict( + {'apool': {'applications': {'rbd': {}}}, + 'bpool': {'applications': {'rbd': {}}}}) + self.endpoint_from_name.return_value = endpoint + self.crm_charm.ceph_id = 'acephid' + self.action_get.return_value = False + self.check_output.return_value = 'Promoted 0 mirrored images\n' + actions.rbd_mirror_action(['promote']) + self.endpoint_from_name.assert_called_once_with('ceph-local') + self.action_get.assert_called_once_with('force') + self.check_output.assert_has_calls([ + mock.call(['rbd', '--id', 'acephid', 'mirror', 'pool', 'promote', + 'apool'], + stderr=actions.subprocess.STDOUT, + universal_newlines=True), + mock.call(['rbd', '--id', 'acephid', 'mirror', 'pool', 'promote', + 'bpool'], + stderr=actions.subprocess.STDOUT, + universal_newlines=True), + ], any_order=True) + # the order the pools has in the output string is undefined + self.action_set.assert_called_once_with( + {'output': mock.ANY}) + for entry in self.action_set.call_args[0][0]['output'].split('\n'): + assert (entry == 'apool: Promoted 0 mirrored images' or + entry == 'bpool: Promoted 0 mirrored images') + self.action_get.return_value = True + self.check_output.reset_mock() + actions.rbd_mirror_action(['promote']) + self.check_output.assert_has_calls([ + mock.call(['rbd', '--id', 'acephid', 'mirror', 'pool', 'promote', + '--force', 'apool'], + stderr=actions.subprocess.STDOUT, + universal_newlines=True), + mock.call(['rbd', '--id', 'acephid', 'mirror', 'pool', 'promote', + '--force', 'bpool'], + stderr=actions.subprocess.STDOUT, + universal_newlines=True), + ], any_order=True) + + def test_refresh_pools(self): + self.patch_object(actions.reactive, 'is_flag_set') + self.patch_object(actions.ch_core.hookenv, 'action_fail') + self.is_flag_set.return_value = False + actions.refresh_pools([]) + self.is_flag_set.assert_called_once_with('leadership.is_leader') + self.action_fail.assert_called_once_with( + 'run action on the leader unit') + self.is_flag_set.return_value = True + self.patch_object(actions.reactive, 'set_flag') + self.patch_object(actions.ch_core.unitdata, '_KV') + self.patch_object(actions.reactive, 'main') + actions.refresh_pools([]) + self.set_flag.assert_called_once_with('refresh.pools') + self._KV.flush.assert_called_once_with() + self.main.assert_called_once_with() + + def test_main(self): + self.patch_object(actions, 'ACTIONS') + self.patch_object(actions.ch_core.hookenv, 'action_fail') + args = ['/non-existent/path/to/charm/binary/promote'] + function = mock.MagicMock() + self.ACTIONS.__getitem__.return_value = function + actions.main(args) + function.assert_called_once_with(args) + self.ACTIONS.__getitem__.side_effect = KeyError + self.assertEqual(actions.main(args), 'Action promote is undefined') + self.ACTIONS.__getitem__.side_effect = None + function.side_effect = Exception('random exception') + actions.main(args) + self.action_fail.assert_called_once_with('random exception') diff --git a/unit_tests/test_ceph_rbd_mirror_handlers.py b/unit_tests/test_ceph_rbd_mirror_handlers.py index 0b5a5f7..3d798c1 100644 --- a/unit_tests/test_ceph_rbd_mirror_handlers.py +++ b/unit_tests/test_ceph_rbd_mirror_handlers.py @@ -45,6 +45,12 @@ class TestRegisteredHooks(test_utils.TestRegisteredHooks): 'ceph-local.available', 'ceph-remote.available', ), + 'refresh_pools': ( + 'leadership.is_leader', + 'refresh.pools', + 'ceph-local.available', + 'ceph-remote.available', + ), }, 'when_all': { 'request_keys': ( @@ -140,6 +146,21 @@ class TestCephRBDMirrorHandlers(test_utils.PatchHelper): ]) self.crm_charm.assess_status.assert_called_once_with() + def test_refresh_pools(self): + self.patch_object(handlers.reactive, 'endpoint_from_name') + self.patch_object(handlers.reactive, 'clear_flag') + endpoint_local = mock.MagicMock() + endpoint_remote = mock.MagicMock() + self.endpoint_from_name.side_effect = [endpoint_local, endpoint_remote] + handlers.refresh_pools() + self.endpoint_from_name.assert_has_calls([ + mock.call('ceph-local'), + mock.call('ceph-remote'), + ]) + endpoint_local.refresh_pools.assert_called_once_with() + endpoint_remote.refresh_pools.assert_called_once_with() + self.clear_flag.assert_called_once_with('refresh.pools') + def test_configure_pools(self): self.patch_object(handlers.reactive, 'endpoint_from_flag') endpoint_local = mock.MagicMock()