diff options
-rw-r--r-- | test-requirements.txt | 1 | ||||
-rw-r--r-- | troveclient/openstack/common/apiclient/exceptions.py | 7 | ||||
-rw-r--r-- | troveclient/openstack/common/apiclient/fake_client.py | 10 | ||||
-rw-r--r-- | troveclient/tests/fakes.py | 85 | ||||
-rw-r--r-- | troveclient/tests/test_instances.py | 4 | ||||
-rw-r--r-- | troveclient/tests/test_modules.py | 44 | ||||
-rw-r--r-- | troveclient/tests/test_utils.py | 52 | ||||
-rw-r--r-- | troveclient/tests/test_v1_shell.py | 187 | ||||
-rw-r--r-- | troveclient/tests/utils.py | 16 | ||||
-rw-r--r-- | troveclient/utils.py | 23 | ||||
-rw-r--r-- | troveclient/v1/instances.py | 87 | ||||
-rw-r--r-- | troveclient/v1/modules.py | 33 | ||||
-rw-r--r-- | troveclient/v1/shell.py | 378 |
13 files changed, 793 insertions, 134 deletions
diff --git a/test-requirements.txt b/test-requirements.txt index c3213ff..87e7980 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -13,3 +13,4 @@ testscenarios>=0.4 # Apache-2.0/BSD testtools>=1.4.0 # MIT mock>=1.2 # BSD httplib2>=0.7.5 # MIT +pycrypto>=2.6 # Public Domain diff --git a/troveclient/openstack/common/apiclient/exceptions.py b/troveclient/openstack/common/apiclient/exceptions.py index faff990..c48df3a 100644 --- a/troveclient/openstack/common/apiclient/exceptions.py +++ b/troveclient/openstack/common/apiclient/exceptions.py @@ -34,10 +34,11 @@ class ClientException(Exception): class MissingArgs(ClientException): """Supplied arguments are not sufficient for calling a function.""" - def __init__(self, missing): + def __init__(self, missing, message=None): self.missing = missing - msg = "Missing argument(s): %s" % ", ".join(missing) - super(MissingArgs, self).__init__(msg) + self.message = message or "Missing argument(s): %s" + self.message %= ", ".join(missing) + super(MissingArgs, self).__init__(self.message) class ValidationError(ClientException): diff --git a/troveclient/openstack/common/apiclient/fake_client.py b/troveclient/openstack/common/apiclient/fake_client.py index b4e6e19..2cee578 100644 --- a/troveclient/openstack/common/apiclient/fake_client.py +++ b/troveclient/openstack/common/apiclient/fake_client.py @@ -31,6 +31,7 @@ import six from six.moves.urllib import parse from troveclient.openstack.common.apiclient import client +from troveclient.tests import utils def assert_has_keys(dct, required=[], optional=[]): @@ -86,8 +87,9 @@ class FakeHTTPClient(client.HTTPClient): def assert_called(self, method, url, body=None, pos=-1): """Assert than an API method was just called. """ - expected = (method, url) - called = self.callstack[pos][0:2] + expected = (method, utils.order_url(url)) + called = (self.callstack[pos][0], + utils.order_url(self.callstack[pos][1])) assert self.callstack, \ "Expected %s %s but no calls were made." % expected @@ -102,7 +104,7 @@ class FakeHTTPClient(client.HTTPClient): def assert_called_anytime(self, method, url, body=None): """Assert than an API method was called anytime in the test. """ - expected = (method, url) + expected = (method, utils.order_url(url)) assert self.callstack, \ "Expected %s %s but no calls were made." % expected @@ -110,7 +112,7 @@ class FakeHTTPClient(client.HTTPClient): found = False entry = None for entry in self.callstack: - if expected == entry[0:2]: + if expected == (entry[0], utils.order_url(entry[1])): found = True break diff --git a/troveclient/tests/fakes.py b/troveclient/tests/fakes.py index 820a433..6ef23ff 100644 --- a/troveclient/tests/fakes.py +++ b/troveclient/tests/fakes.py @@ -35,16 +35,32 @@ def assert_has_keys(dict, required=[], optional=[]): class FakeClient(client.Client): + URL_QUERY_SEPARATOR = '&' + URL_SEPARATOR = '?' + def __init__(self, *args, **kwargs): client.Client.__init__(self, 'username', 'password', 'project_id', 'auth_url', extensions=kwargs.get('extensions')) self.client = FakeHTTPClient(**kwargs) + def _order_url_query_str(self, url): + """Returns the url with the query strings ordered, if they exist and + there's more than one. Otherwise the url is returned unaltered. + """ + if self.URL_QUERY_SEPARATOR in url: + parts = url.split(self.URL_SEPARATOR) + if len(parts) == 2: + queries = sorted(parts[1].split(self.URL_QUERY_SEPARATOR)) + url = self.URL_SEPARATOR.join( + [parts[0], self.URL_QUERY_SEPARATOR.join(queries)]) + return url + def assert_called(self, method, url, body=None, pos=-1): """Assert than an API method was just called.""" - expected = (method, url) - called = self.client.callstack[pos][0:2] + expected = (method, utils.order_url(url)) + called = (self.client.callstack[pos][0], + utils.order_url(self.client.callstack[pos][1])) assert self.client.callstack, \ "Expected %s %s but no calls were made." % expected @@ -59,14 +75,14 @@ class FakeClient(client.Client): def assert_called_anytime(self, method, url, body=None): """Assert than an API method was called anytime in the test.""" - expected = (method, url) + expected = (method, utils.order_url(url)) assert self.client.callstack, \ "Expected %s %s but no calls were made." % expected found = False for entry in self.client.callstack: - if expected == entry[0:2]: + if expected == (entry[0], utils.order_url(entry[1])): found = True break @@ -389,6 +405,67 @@ class FakeHTTPClient(base_client.HTTPClient): def get_instances_1234_metadata_key123(self, **kw): return (200, {}, {"metadata": {}}) + def get_modules(self, **kw): + return (200, {}, {"modules": [ + { + "id": "4321", + "name": "mod1", + "type": "ping", + "datastore": 'all', + "datastore_version": 'all', + "tenant": 'all', + "auto_apply": 0, + "visible": 1}, + { + "id": "8765", + "name": "mod2", + "type": "ping", + "datastore": 'all', + "datastore_version": 'all', + "tenant": 'all', + "auto_apply": 0, + "visible": 1}]}) + + def get_modules_4321(self, **kw): + r = {'module': self.get_modules()[2]['modules'][0]} + return (200, {}, r) + + def get_modules_8765(self, **kw): + r = {'module': self.get_modules()[2]['modules'][1]} + return (200, {}, r) + + def post_modules(self, **kw): + r = {'module': self.get_modules()[2]['modules'][0]} + return (200, {}, r) + + def put_modules_4321(self, **kw): + return (200, {}, {"module": {'name': 'mod3'}}) + + def delete_modules_4321(self, **kw): + return (200, {}, None) + + def get_instances_1234_modules(self, **kw): + return (200, {}, {"modules": [{"module": {}}]}) + + def get_modules_4321_instances(self, **kw): + return self.get_instances() + + def get_instances_modules(self, **kw): + return (200, {}, None) + + def get_instances_member_1_modules(self, **kw): + return self.get_modules() + + def get_instances_member_2_modules(self, **kw): + return self.get_modules() + + def post_instances_1234_modules(self, **kw): + r = {'modules': [self.get_modules()[2]['modules'][0]]} + return (200, {}, r) + + def delete_instances_1234_modules_4321(self, **kw): + return (200, {}, None) + def get_limits(self, **kw): return (200, {}, {"limits": [ { diff --git a/troveclient/tests/test_instances.py b/troveclient/tests/test_instances.py index 6d96e0e..1e8e77d 100644 --- a/troveclient/tests/test_instances.py +++ b/troveclient/tests/test_instances.py @@ -99,7 +99,8 @@ class InstancesTest(testtools.TestCase): ['db1', 'db2'], ['u1', 'u2'], datastore="datastore", datastore_version="datastore-version", - nics=nics, slave_of='test') + nics=nics, slave_of='test', + modules=['mod_id']) self.assertEqual("/instances", p) self.assertEqual("instance", i) self.assertEqual(['db1', 'db2'], b["instance"]["databases"]) @@ -116,6 +117,7 @@ class InstancesTest(testtools.TestCase): self.assertEqual('test', b['instance']['replica_of']) self.assertNotIn('slave_of', b['instance']) self.assertTrue(mock_warn.called) + self.assertEqual([{'id': 'mod_id'}], b["instance"]["modules"]) def test_list(self): page_mock = mock.Mock() diff --git a/troveclient/tests/test_modules.py b/troveclient/tests/test_modules.py index 01ee548..5616df7 100644 --- a/troveclient/tests/test_modules.py +++ b/troveclient/tests/test_modules.py @@ -14,8 +14,10 @@ # under the License. # +import Crypto.Random import mock import testtools + from troveclient.v1 import modules @@ -52,25 +54,29 @@ class TestModules(testtools.TestCase): def side_effect_func(path, body, mod): return path, body, mod - self.modules._create = mock.Mock(side_effect=side_effect_func) - path, body, mod = self.modules.create( - self.module_name, "test", "my_contents", - description="my desc", - all_tenants=False, - datastore="ds", - datastore_version="ds-version", - auto_apply=True, - visible=True, - live_update=False) - self.assertEqual("/modules", path) - self.assertEqual("module", mod) - self.assertEqual(self.module_name, body["module"]["name"]) - self.assertEqual("ds", body["module"]["datastore"]["type"]) - self.assertEqual("ds-version", body["module"]["datastore"]["version"]) - self.assertFalse(body["module"]["all_tenants"]) - self.assertTrue(body["module"]["auto_apply"]) - self.assertTrue(body["module"]["visible"]) - self.assertFalse(body["module"]["live_update"]) + text_contents = "my_contents" + binary_contents = Crypto.Random.new().read(20) + for contents in [text_contents, binary_contents]: + self.modules._create = mock.Mock(side_effect=side_effect_func) + path, body, mod = self.modules.create( + self.module_name, "test", contents, + description="my desc", + all_tenants=False, + datastore="ds", + datastore_version="ds-version", + auto_apply=True, + visible=True, + live_update=False) + self.assertEqual("/modules", path) + self.assertEqual("module", mod) + self.assertEqual(self.module_name, body["module"]["name"]) + self.assertEqual("ds", body["module"]["datastore"]["type"]) + self.assertEqual("ds-version", + body["module"]["datastore"]["version"]) + self.assertFalse(body["module"]["all_tenants"]) + self.assertTrue(body["module"]["auto_apply"]) + self.assertTrue(body["module"]["visible"]) + self.assertFalse(body["module"]["live_update"]) def test_update(self): resp = mock.Mock() diff --git a/troveclient/tests/test_utils.py b/troveclient/tests/test_utils.py index 28f3929..b7ebb08 100644 --- a/troveclient/tests/test_utils.py +++ b/troveclient/tests/test_utils.py @@ -15,9 +15,12 @@ # License for the specific language governing permissions and limitations # under the License. +import Crypto.Random import os import six +import tempfile import testtools + from troveclient import utils @@ -53,3 +56,52 @@ class UtilsTest(testtools.TestCase): self.assertEqual('not_unicode', utils.slugify('not_unicode')) self.assertEqual('unicode', utils.slugify(six.u('unicode'))) self.assertEqual('slugify-test', utils.slugify('SLUGIFY% test!')) + + def test_encode_decode_data(self): + text_data_str = 'This is a text string' + try: + text_data_bytes = bytes('This is a byte stream', 'utf-8') + except TypeError: + text_data_bytes = bytes('This is a byte stream') + random_data_str = Crypto.Random.new().read(12) + random_data_bytes = bytearray(Crypto.Random.new().read(12)) + special_char_str = '\x00\xFF\x00\xFF\xFF\x00' + special_char_bytes = bytearray( + [ord(item) for item in special_char_str]) + data = [text_data_str, + text_data_bytes, + random_data_str, + random_data_bytes, + special_char_str, + special_char_bytes] + + for datum in data: + # the deserialized data is always a bytearray + try: + expected_deserialized = bytearray( + [ord(item) for item in datum]) + except TypeError: + expected_deserialized = bytearray( + [item for item in datum]) + serialized_data = utils.encode_data(datum) + self.assertIsNotNone(serialized_data, "'%s' serialized is None" % + datum) + deserialized_data = utils.decode_data(serialized_data) + self.assertIsNotNone(deserialized_data, "'%s' deserialized is None" + % datum) + self.assertEqual(expected_deserialized, deserialized_data, + "Serialize/Deserialize failed") + # Now we write the data to a file and read it back in + # to make sure the round-trip doesn't change anything. + with tempfile.NamedTemporaryFile() as temp_file: + with open(temp_file.name, 'wb') as fh_w: + fh_w.write( + bytearray([ord(item) for item in serialized_data])) + with open(temp_file.name, 'rb') as fh_r: + new_serialized_data = fh_r.read() + new_deserialized_data = utils.decode_data( + new_serialized_data) + self.assertIsNotNone(new_deserialized_data, + "'%s' deserialized is None" % datum) + self.assertEqual(expected_deserialized, new_deserialized_data, + "Serialize/Deserialize with files failed") diff --git a/troveclient/tests/test_v1_shell.py b/troveclient/tests/test_v1_shell.py index 4de0e78..11767da 100644 --- a/troveclient/tests/test_v1_shell.py +++ b/troveclient/tests/test_v1_shell.py @@ -13,15 +13,26 @@ # License for the specific language governing permissions and limitations # under the License. -import six - +try: + # handle py34 + import builtins +except ImportError: + # and py27 + import __builtin__ as builtins + +import base64 import fixtures import mock +import re +import six +import testtools + import troveclient.client from troveclient import exceptions import troveclient.shell from troveclient.tests import fakes from troveclient.tests import utils +import troveclient.v1.modules import troveclient.v1.shell @@ -86,6 +97,76 @@ class ShellTest(utils.TestCase): def assert_called_anytime(self, method, url, body=None): return self.shell.cs.assert_called_anytime(method, url, body) + def test__strip_option(self): + # Format is: opt_name, opt_string, _strip_options_kwargs, + # expected_value, expected_opt_string, exception_msg + data = [ + ["volume", "volume=10", + {}, "10", "", None], + ["volume", ",volume=10,,type=mine,", + {}, "10", "type=mine", None], + ["volume", "type=mine", + {}, "", "type=mine", "Missing option 'volume'.*"], + ["volume", "type=mine", + {'is_required': False}, None, "type=mine", None], + ["volume", "volume=1, volume=2", + {}, "", "", "Option 'volume' found more than once.*"], + ["volume", "volume=1, volume=2", + {'allow_multiple': True}, ['1', '2'], "", None], + ["volume", "volume=1, volume=2,, volume=4, volume=6", + {'allow_multiple': True}, ['1', '2', '4', '6'], "", None], + ["module", ",flavor=10,,nic='net-id=net',module=test, module=test", + {'allow_multiple': True}, ['test'], + "flavor=10,,nic='net-id=net'", None], + ["nic", ",flavor=10,,nic=net-id=net, module=test", + {'quotes_required': True}, "", "", + "Invalid 'nic' option. The value must be quoted.*"], + ["nic", ",flavor=10,,nic='net-id=net', module=test", + {'quotes_required': True}, "net-id=net", + "flavor=10,, module=test", None], + ["nic", + ",nic='port-id=port',flavor=10,,nic='net-id=net', module=test", + {'quotes_required': True, 'allow_multiple': True}, + ["net-id=net", "port-id=port"], + "flavor=10,, module=test", None], + ] + + count = 0 + for datum in data: + count += 1 + opt_name = datum[0] + opts_str = datum[1] + kwargs = datum[2] + expected_value = datum[3] + expected_opt_string = datum[4] + exception_msg = datum[5] + msg = "Error (test data line %s): " % count + try: + value, opt_string = troveclient.v1.shell._strip_option( + opts_str, opt_name, **kwargs) + if exception_msg: + self.assertEqual(True, False, + "%sException not thrown, expecting %s" % + (msg, exception_msg)) + if isinstance(expected_value, list): + self.assertEqual( + set(value), set(expected_value), + "%sValue not correct" % msg) + else: + self.assertEqual(value, expected_value, + "%sValue not correct" % msg) + self.assertEqual(opt_string, expected_opt_string, + "%sOption string not correct" % msg) + except Exception as ex: + if exception_msg: + msg = ex.message if hasattr(ex, 'message') else str(ex) + self.assertThat(msg, + testtools.matchers.MatchesRegex( + exception_msg, re.DOTALL), + exception_msg, "%sWrong ex" % msg) + else: + raise + def test_instance_list(self): self.run_command('list') self.assert_called('GET', '/instances') @@ -184,6 +265,19 @@ class ShellTest(utils.TestCase): 'replica_count': 1 }}) + def test_boot_with_modules(self): + self.run_command('create test-member-1 1 --size 1 --volume_type lvm ' + '--module 4321 --module 8765') + self.assert_called_anytime( + 'POST', '/instances', + {'instance': { + 'volume': {'size': 1, 'type': 'lvm'}, + 'flavorRef': 1, + 'name': 'test-member-1', + 'replica_count': 1, + 'modules': [{'id': '4321'}, {'id': '8765'}] + }}) + def test_boot_by_flavor_name(self): self.run_command( 'create test-member-1 m1.tiny --size 1 --volume_type lvm') @@ -253,7 +347,7 @@ class ShellTest(utils.TestCase): cmd = ('cluster-create test-clstr vertica 7.1 --instance volume=2 ' '--instance flavor=2,volume=1') self.assertRaisesRegexp( - exceptions.MissingArgs, 'Missing argument\(s\): flavor', + exceptions.MissingArgs, "Missing option 'flavor'", self.run_command, cmd) def test_cluster_grow(self): @@ -301,7 +395,7 @@ class ShellTest(utils.TestCase): '--instance flavor=2,volume=1,nic=net-id=some-id,' 'port-id=some-port-id,availability_zone=2') self.assertRaisesRegexp( - exceptions.ValidationError, "Invalid 'nic' parameter. " + exceptions.ValidationError, "Invalid 'nic' option. " "The value must be quoted.", self.run_command, cmd) @@ -427,6 +521,91 @@ class ShellTest(utils.TestCase): self.run_command('metadata-show 1234 key123') self.assert_called('GET', '/instances/1234/metadata/key123') + def test_module_list(self): + self.run_command('module-list') + self.assert_called('GET', '/modules') + + def test_module_list_datastore(self): + self.run_command('module-list --datastore all') + self.assert_called('GET', '/modules?datastore=all') + + def test_module_show(self): + self.run_command('module-show 4321') + self.assert_called('GET', '/modules/4321') + + def test_module_create(self): + with mock.patch.object(builtins, 'open'): + return_value = b'mycontents' + expected_contents = str(return_value.decode('utf-8')) + mock_encode = mock.Mock(return_value=return_value) + with mock.patch.object(base64, 'b64encode', mock_encode): + self.run_command('module-create mod1 type filename') + self.assert_called_anytime( + 'POST', '/modules', + {'module': {'contents': expected_contents, + 'all_tenants': 0, + 'module_type': 'type', 'visible': 1, + 'auto_apply': 0, 'live_update': 0, + 'name': 'mod1'}}) + + def test_module_update(self): + with mock.patch.object(troveclient.v1.modules.Module, '__repr__', + mock.Mock(return_value='4321')): + self.run_command('module-update 4321 --name mod3') + self.assert_called_anytime( + 'PUT', '/modules/4321', + {'module': {'name': 'mod3'}}) + + def test_module_delete(self): + with mock.patch.object(troveclient.v1.modules.Module, '__repr__', + mock.Mock(return_value='4321')): + self.run_command('module-delete 4321') + self.assert_called_anytime('DELETE', '/modules/4321') + + def test_module_list_instance(self): + self.run_command('module-list-instance 1234') + self.assert_called_anytime('GET', '/instances/1234/modules') + + def test_module_instances(self): + with mock.patch.object(troveclient.v1.modules.Module, '__repr__', + mock.Mock(return_value='4321')): + self.run_command('module-instances 4321') + self.assert_called_anytime('GET', '/modules/4321/instances') + + def test_module_instances_clustered(self): + with mock.patch.object(troveclient.v1.modules.Module, '__repr__', + mock.Mock(return_value='4321')): + self.run_command('module-instances 4321 --include_clustered') + self.assert_called_anytime( + 'GET', '/modules/4321/instances?include_clustered=True') + + def test_cluster_modules(self): + self.run_command('cluster-modules cls-1234') + self.assert_called_anytime('GET', '/clusters/cls-1234') + + def test_module_apply(self): + self.run_command('module-apply 1234 4321 8765') + self.assert_called_anytime('POST', '/instances/1234/modules', + {'modules': + [{'id': '4321'}, {'id': '8765'}]}) + + def test_module_remove(self): + self.run_command('module-remove 1234 4321') + self.assert_called_anytime('DELETE', '/instances/1234/modules/4321') + + def test_module_query(self): + self.run_command('module-query 1234') + self.assert_called('GET', '/instances/1234/modules?from_guest=True') + + def test_module_retrieve(self): + with mock.patch.object(troveclient.v1.modules.Module, '__getattr__', + mock.Mock(return_value='4321')): + self.run_command('module-retrieve 1234') + self.assert_called( + 'GET', + '/instances/1234/modules?' + 'include_contents=True&from_guest=True') + def test_limit_list(self): self.run_command('limit-list') self.assert_called('GET', '/limits') diff --git a/troveclient/tests/utils.py b/troveclient/tests/utils.py index c3e81ac..a912649 100644 --- a/troveclient/tests/utils.py +++ b/troveclient/tests/utils.py @@ -23,6 +23,22 @@ AUTH_URL = "http://localhost:5002/auth_url" AUTH_URL_V1 = "http://localhost:5002/auth_url/v1.0" AUTH_URL_V2 = "http://localhost:5002/auth_url/v2.0" +URL_QUERY_SEPARATOR = '&' +URL_SEPARATOR = '?' + + +def order_url(url): + """Returns the url with the query strings ordered, if they exist and + there's more than one. Otherwise the url is returned unaltered. + """ + if URL_QUERY_SEPARATOR in url: + parts = url.split(URL_SEPARATOR) + if len(parts) == 2: + queries = sorted(parts[1].split(URL_QUERY_SEPARATOR)) + url = URL_SEPARATOR.join( + [parts[0], URL_QUERY_SEPARATOR.join(queries)]) + return url + def _patch_mock_to_raise_for_invalid_assert_calls(): def raise_for_invalid_assert_calls(wrapped): diff --git a/troveclient/utils.py b/troveclient/utils.py index c685ea2..8541753 100644 --- a/troveclient/utils.py +++ b/troveclient/utils.py @@ -16,6 +16,7 @@ from __future__ import print_function +import base64 import os import simplejson as json import sys @@ -301,3 +302,25 @@ def is_uuid_like(val): return str(uuid.UUID(val)) == val except (TypeError, ValueError, AttributeError): return False + + +def encode_data(data): + """Encode the data using the base64 codec.""" + + try: + # py27str - if we've got text data, this should encode it + # py27aa/py34aa - if we've got a bytearray, this should work too + encoded = str(base64.b64encode(data).decode('utf-8')) + except TypeError: + # py34str - convert to bytes first, then we can encode + data_bytes = bytes([ord(item) for item in data]) + encoded = base64.b64encode(data_bytes).decode('utf-8') + + return encoded + + +def decode_data(data): + """Encode the data using the base64 codec.""" + + # py27 & py34 seem to understand bytearray the same + return bytearray([item for item in base64.b64decode(data)]) diff --git a/troveclient/v1/instances.py b/troveclient/v1/instances.py index a9c5014..7d461c0 100644 --- a/troveclient/v1/instances.py +++ b/troveclient/v1/instances.py @@ -15,12 +15,15 @@ # License for the specific language governing permissions and limitations # under the License. +import os import warnings from troveclient import base from troveclient import common from troveclient import exceptions from troveclient.i18n import _LW +from troveclient import utils +from troveclient.v1 import modules as core_modules from swiftclient import client as swift_client @@ -85,7 +88,8 @@ class Instances(base.ManagerWithFind): def create(self, name, flavor_id, volume=None, databases=None, users=None, restorePoint=None, availability_zone=None, datastore=None, datastore_version=None, nics=None, configuration=None, - replica_of=None, slave_of=None, replica_count=None): + replica_of=None, slave_of=None, replica_count=None, + modules=None): """Create (boot) a new instance.""" body = {"instance": { @@ -123,6 +127,8 @@ class Instances(base.ManagerWithFind): body["instance"]["replica_of"] = base.getid(replica_of) or slave_of if replica_count: body["instance"]["replica_count"] = replica_count + if modules: + body["instance"]["modules"] = self._get_module_list(modules) return self._create("/instances", body, "instance") @@ -248,6 +254,85 @@ class Instances(base.ManagerWithFind): body = {'eject_replica_source': {}} self._action(instance, body) + def modules(self, instance): + """Get the list of modules for a specific instance.""" + return self._modules_get(instance) + + def module_query(self, instance): + """Query an instance about installed modules.""" + return self._modules_get(instance, from_guest=True) + + def module_retrieve(self, instance, directory=None, prefix=None): + """Retrieve the module data file from an instance. This includes + the contents of the module data file. + """ + if directory: + try: + os.makedirs(directory, exist_ok=True) + except TypeError: + # py27 + try: + os.makedirs(directory) + except OSError: + if not os.path.isdir(directory): + raise + else: + directory = '.' + prefix = prefix or '' + if prefix and not prefix.endswith('_'): + prefix += '_' + module_list = self._modules_get( + instance, from_guest=True, include_contents=True) + saved_modules = {} + for module in module_list: + filename = '%s%s_%s_%s.dat' % (prefix, module.name, + module.datastore, + module.datastore_version) + full_filename = os.path.expanduser( + os.path.join(directory, filename)) + with open(full_filename, 'wb') as fh: + fh.write(utils.decode_data(module.contents)) + saved_modules[module.name] = full_filename + return saved_modules + + def _modules_get(self, instance, from_guest=None, include_contents=None): + url = "/instances/%s/modules" % base.getid(instance) + query_strings = {} + if from_guest is not None: + query_strings["from_guest"] = from_guest + if include_contents is not None: + query_strings["include_contents"] = include_contents + url = common.append_query_strings(url, **query_strings) + resp, body = self.api.client.get(url) + common.check_for_exceptions(resp, body, url) + return [core_modules.Module(self, module, loaded=True) + for module in body['modules']] + + def module_apply(self, instance, modules): + """Apply modules to an instance.""" + url = "/instances/%s/modules" % base.getid(instance) + body = {"modules": self._get_module_list(modules)} + resp, body = self.api.client.post(url, body=body) + common.check_for_exceptions(resp, body, url) + return [core_modules.Module(self, module, loaded=True) + for module in body['modules']] + + def _get_module_list(self, modules): + """Build a list of module ids.""" + module_list = [] + for module in modules: + module_info = {'id': base.getid(module)} + module_list.append(module_info) + return module_list + + def module_remove(self, instance, module): + """Remove a module from an instance. + """ + url = "/instances/%s/modules/%s" % (base.getid(instance), + base.getid(module)) + resp, body = self.api.client.delete(url) + common.check_for_exceptions(resp, body, url) + def log_list(self, instance): """Get a list of all guest logs. diff --git a/troveclient/v1/modules.py b/troveclient/v1/modules.py index ec66b20..15d90c9 100644 --- a/troveclient/v1/modules.py +++ b/troveclient/v1/modules.py @@ -14,35 +14,40 @@ # under the License. # -import base64 - from troveclient import base from troveclient import common +from troveclient import utils class Module(base.Resource): NO_CHANGE_TO_ARG = 'no_change_to_argument' + ALL_KEYWORD = 'all' def __repr__(self): return "<Module: %s>" % self.name + def __hash__(self): + return hash(repr(self)) + + def __eq__(self, other): + if isinstance(other, self.__class__): + return self.__dict__ == other.__dict__ + else: + return False + class Modules(base.ManagerWithFind): """Manage :class:`Module` resources.""" resource_class = Module - def _encode_string(self, data_str): - byte_array = bytearray(data_str, 'utf-8') - return base64.b64encode(byte_array) - def create(self, name, module_type, contents, description=None, all_tenants=None, datastore=None, datastore_version=None, auto_apply=None, visible=None, live_update=None): """Create a new module.""" - contents = self._encode_string(contents) + contents = utils.encode_data(contents) body = {"module": { "name": name, "module_type": module_type, @@ -86,7 +91,7 @@ class Modules(base.ManagerWithFind): if module_type is not None: body["module"]["type"] = module_type if contents is not None: - contents = self._encode_string(contents) + contents = utils.encode_data(contents) body["module"]["contents"] = contents if description is not None: body["module"]["description"] = description @@ -116,7 +121,7 @@ class Modules(base.ManagerWithFind): """Get a list of all modules.""" query_strings = None if datastore: - query_strings = {"datastore": datastore} + query_strings = {"datastore": base.getid(datastore)} return self._paginated( "/modules", "modules", limit, marker, query_strings=query_strings) @@ -130,3 +135,13 @@ class Modules(base.ManagerWithFind): url = "/modules/%s" % base.getid(module) resp, body = self.api.client.delete(url) common.check_for_exceptions(resp, body, url) + + def instances(self, module, limit=None, marker=None, + include_clustered=False): + """Get a list of all instances this module has been applied to.""" + url = "/modules/%s/instances" % base.getid(module) + query_strings = {} + if include_clustered: + query_strings['include_clustered'] = include_clustered + return self._paginated(url, "instances", limit, marker, + query_strings=query_strings) diff --git a/troveclient/v1/shell.py b/troveclient/v1/shell.py index 17bf110..1ff5019 100644 --- a/troveclient/v1/shell.py +++ b/troveclient/v1/shell.py @@ -20,8 +20,9 @@ import argparse import sys import time +INSTANCE_METAVAR = '"opt=<value>[,opt=<value> ...] "' INSTANCE_ERROR = ("Instance argument(s) must be of the form --instance " - "<opt=value[,opt=value]> - see help for details.") + + INSTANCE_METAVAR + " - see help for details.") NIC_ERROR = ("Invalid NIC argument: %s. Must specify either net-id or port-id " "but not both. Please refer to help.") NO_LOG_FOUND_ERROR = "ERROR: No published '%s' log was found for %s." @@ -33,6 +34,7 @@ except ImportError: from troveclient import exceptions from troveclient import utils +from troveclient.v1.modules import Module def _poll_for_status(poll_fn, obj_id, action, final_ok_states, @@ -199,16 +201,22 @@ def do_flavor_show(cs, args): help='Begin displaying the results for IDs greater than the ' 'specified marker. When used with --limit, set this to ' 'the last ID displayed in the previous run.') -@utils.arg('--include-clustered', dest='include_clustered', +@utils.arg('--include_clustered', '--include-clustered', + dest='include_clustered', action="store_true", default=False, help="Include instances that are part of a cluster " - "(default false).") + "(default %(default)s). --include-clustered may be " + "deprecated in the future, retaining just " + "--include_clustered.") @utils.service_type('database') def do_list(cs, args): """Lists all the instances.""" instances = cs.instances.list(limit=args.limit, marker=args.marker, include_clustered=args.include_clustered) + _print_instances(instances) + +def _print_instances(instances): for instance in instances: setattr(instance, 'flavor_id', instance.flavor['id']) if hasattr(instance, 'volume'): @@ -287,22 +295,21 @@ def do_cluster_instances(cs, args): obj_is_dict=True) -@utils.arg('--instance', - metavar="<name=name,flavor=flavor_name_or_id,volume=volume>", - action='append', - dest='instances', - default=[], - help="Add an instance to the cluster. Specify " - "multiple times to create multiple instances.") +@utils.arg('--instance', metavar=INSTANCE_METAVAR, + action='append', dest='instances', default=[], + help="Add an instance to the cluster. Specify multiple " + "times to create multiple instances. Valid options are: " + "name=<name>, flavor=<flavor_name_or_id>, volume=<volume>, " + "module=<module_name_or_id>.") @utils.arg('cluster', metavar='<cluster>', help='ID or name of the cluster.') @utils.service_type('database') def do_cluster_grow(cs, args): """Adds more instances to a cluster.""" cluster = _find_cluster(cs, args.cluster) instances = [] - for instance_str in args.instances: + for instance_opts in args.instances: instance_info = {} - for z in instance_str.split(","): + for z in instance_opts.split(","): for (k, v) in [z.split("=", 1)[:2]]: if k == "name": instance_info[k] = v @@ -324,10 +331,7 @@ def do_cluster_grow(cs, args): @utils.arg('cluster', metavar='<cluster>', help='ID or name of the cluster.') -@utils.arg('instances', - nargs='+', - metavar='<instance>', - default=[], +@utils.arg('instances', metavar='<instance>', nargs='+', default=[], help="Drop instance(s) from the cluster. Specify " "multiple ids to drop multiple instances.") @utils.service_type('database') @@ -370,11 +374,13 @@ def do_cluster_delete(cs, args): type=str, default=None, help='ID of the configuration reference to attach.') -@utils.arg('--detach-replica-source', +@utils.arg('--detach_replica_source', '--detach-replica-source', dest='detach_replica_source', action="store_true", default=False, - help='Detach the replica instance from its replication source.') + help='Detach the replica instance from its replication source. ' + '--detach-replica-source may be deprecated in the future ' + 'in favor of just --detach_replica_source') @utils.arg('--remove_configuration', dest='remove_configuration', action="store_true", @@ -406,11 +412,11 @@ def do_update(cs, args): @utils.arg('flavor', metavar='<flavor>', help='Flavor ID or name of the instance.') -@utils.arg('--databases', metavar='<databases>', +@utils.arg('--databases', metavar='<database>', help='Optional list of databases.', nargs="+", default=[]) -@utils.arg('--users', metavar='<users>', - help='Optional list of users in the form user:password.', +@utils.arg('--users', metavar='<user:password>', + help='Optional list of users.', nargs="+", default=[]) @utils.arg('--backup', metavar='<backup>', @@ -419,7 +425,7 @@ def do_update(cs, args): @utils.arg('--availability_zone', metavar='<availability_zone>', default=None, - help='The Zone hint to give to nova.') + help='The Zone hint to give to Nova.') @utils.arg('--datastore', metavar='<datastore>', default=None, @@ -429,7 +435,8 @@ def do_update(cs, args): default=None, help='A datastore version name or ID.') @utils.arg('--nic', - metavar="<net-id=net-uuid,v4-fixed-ip=ip-addr,port-id=port-uuid>", + metavar="<net-id=<net-uuid>,v4-fixed-ip=<ip-addr>," + "port-id=<port-uuid>>", action='append', dest='nics', default=[], @@ -452,7 +459,11 @@ def do_update(cs, args): metavar='<count>', type=int, default=1, - help='Number of replicas to create (defaults to 1).') + help='Number of replicas to create (defaults to %(default)s).') +@utils.arg('--module', metavar='<module>', + type=str, dest='modules', action='append', default=[], + help='ID or name of the module to apply. Specify multiple ' + 'times to apply multiple modules.') @utils.service_type('database') def do_create(cs, args): """Creates a new instance.""" @@ -476,6 +487,9 @@ def do_create(cs, args): nic_str.split(",")]]) _validate_nic_info(nic_info, nic_str) nics.append(nic_info) + modules = [] + for module in args.modules: + modules.append(_find_module(cs, module).id) instance = cs.instances.create(args.name, flavor_id, @@ -489,7 +503,8 @@ def do_create(cs, args): nics=nics, configuration=args.configuration, replica_of=replica_of_instance, - replica_count=args.replica_count) + replica_count=args.replica_count, + modules=modules) _print_instance(instance) @@ -499,22 +514,26 @@ def _validate_nic_info(nic_info, nic_str): raise exceptions.ValidationError(NIC_ERROR % ("nic='%s'" % nic_str)) -def _get_flavors(cs, instance_str): - flavor_name = _get_instance_property(instance_str, 'flavor', True) +def _get_flavor(cs, opts_str): + flavor_name, opts_str = _strip_option(opts_str, 'flavor', True) flavor_id = _find_flavor(cs, flavor_name).id - return str(flavor_id) - - -def _get_networks(instance_str): - nic_args = _dequote(_get_instance_property(instance_str, 'nic', - is_required=False, quoted=True)) - - nic_info = {} - if nic_args: - net_id = _get_instance_property(nic_args, 'net-id', False) - port_id = _get_instance_property(nic_args, 'port-id', False) - fixed_ipv4 = _get_instance_property(nic_args, 'v4-fixed-ip', False) - + return str(flavor_id), opts_str + + +def _get_networks(opts_str): + nic_args_list, opts_str = _strip_option(opts_str, 'nic', is_required=False, + quotes_required=True, + allow_multiple=True) + nic_info_list = [] + for nic_args in nic_args_list: + orig_nic_args = nic_args = _unquote(nic_args) + nic_info = {} + net_id, nic_args = _strip_option(nic_args, 'net-id', False) + port_id, nic_args = _strip_option(nic_args, 'port-id', False) + fixed_ipv4, nic_args = _strip_option(nic_args, 'v4-fixed-ip', False) + if nic_args: + raise exceptions.ValidationError( + "Unknown args '%s' in 'nic' option" % nic_args) if net_id: nic_info.update({'net-id': net_id}) if port_id: @@ -522,13 +541,13 @@ def _get_networks(instance_str): if fixed_ipv4: nic_info.update({'v4-fixed-ip': fixed_ipv4}) - _validate_nic_info(nic_info, nic_args) - return [nic_info] + _validate_nic_info(nic_info, orig_nic_args) + nic_info_list.append(nic_info) - return None + return nic_info_list, opts_str -def _dequote(value): +def _unquote(value): def _strip_quotes(value, quote_char): if value: return value.strip(quote_char) @@ -537,49 +556,86 @@ def _dequote(value): return _strip_quotes(_strip_quotes(value, "'"), '"') -def _get_volumes(instance_str): - volume_size = _get_instance_property(instance_str, 'volume', True) - volume_type = _get_instance_property(instance_str, 'volume_type', False) +def _get_volume(opts_str): + volume_size, opts_str = _strip_option(opts_str, 'volume', is_required=True) + volume_type, opts_str = _strip_option(opts_str, 'volume_type', + is_required=False) volume_info = {"size": volume_size} if volume_type: volume_info.update({"type": volume_type}) - return volume_info + return volume_info, opts_str -def _get_availability_zones(instance_str): - return _get_instance_property(instance_str, 'availability_zone', False) +def _get_availability_zone(opts_str): + return _strip_option(opts_str, 'availability_zone', is_required=False) -def _get_instance_property(instance_str, property_name, is_required=True, - quoted=False): - if property_name in instance_str: +def _get_modules(cs, opts_str): + modules, opts_str = _strip_option( + opts_str, 'module', is_required=False, allow_multiple=True) + module_list = [] + for module in modules: + module_info = {'id': _find_module(cs, module).id} + module_list.append(module_info) + return module_list, opts_str + + +def _strip_option(opts_str, opt_name, is_required=True, + quotes_required=False, allow_multiple=False): + opt_value = [] if allow_multiple else None + opts_str = opts_str.strip().strip(",") + if opt_name in opts_str: try: - left = instance_str.split('%s=' % property_name)[1] + split_str = '%s=' % opt_name + parts = opts_str.split(split_str) + before = parts[0] + after = parts[1] + if len(parts) > 2: + if allow_multiple: + after = split_str.join(parts[1:]) + value, after = _strip_option( + after, opt_name, is_required=is_required, + quotes_required=quotes_required, + allow_multiple=allow_multiple) + opt_value.extend(value) + else: + raise exceptions.ValidationError(( + "Option '%s' found more than once in argument " + "--instance " % opt_name) + INSTANCE_METAVAR) # Handle complex (quoted) properties. Strip the quotes. - quote = left[0] + quote = after[0] if quote in ["'", '"']: - left = left[1:] + after = after[1:] else: - if quoted: - # Fail if quotes are required. + if quotes_required: raise exceptions.ValidationError( - "Invalid '%s' parameter. The value must be quoted." - % property_name) + "Invalid '%s' option. The value must be quoted. " + "(Or perhaps you're missing quotes around the entire " + "argument string)" + % opt_name) quote = '' - property_value = left.split('%s,' % quote)[0] - return str(property_value).strip() + split_str = '%s,' % quote + parts = after.split(split_str) + value = str(parts[0]).strip() + if allow_multiple: + opt_value.append(value) + opt_value = list(set(opt_value)) + else: + opt_value = value + opts_str = before + split_str.join(parts[1:]) except IndexError: raise exceptions.ValidationError("Invalid '%s' parameter. %s." - % (property_name, INSTANCE_ERROR)) + % (opt_name, INSTANCE_ERROR)) - if is_required: - raise exceptions.MissingArgs([property_name]) + if is_required and not opt_value: + msg = "Missing option '%s' for argument --instance " + INSTANCE_METAVAR + raise exceptions.MissingArgs([opt_name], message=msg) - return None + return opt_value, opts_str.strip().strip(",") @utils.arg('name', @@ -592,35 +648,46 @@ def _get_instance_property(instance_str, property_name, is_required=True, @utils.arg('datastore_version', metavar='<datastore_version>', help='A datastore version name or ID.') -@utils.arg('--instance', - metavar='"<opt=value,opt=value,...>"', +@utils.arg('--instance', metavar=INSTANCE_METAVAR, + action='append', dest='instances', default=[], help="Create an instance for the cluster. Specify multiple " "times to create multiple instances. " - "Valid options are: flavor=flavor_name_or_id, " - "volume=disk_size_in_GB, volume_type=type, " - "nic='net-id=net-uuid,v4-fixed-ip=ip-addr,port-id=port-uuid' " + "Valid options are: flavor=<flavor_name_or_id>, " + "volume=<disk_size_in_GB>, volume_type=<type>, " + "nic='<net-id=<net-uuid>, v4-fixed-ip=<ip-addr>, " + "port-id=<port-uuid>>' " "(where net-id=network_id, v4-fixed-ip=IPv4r_fixed_address, " - "port-id=port_id), availability_zone=AZ_hint_for_Nova.", - action='append', - dest='instances', - default=[]) + "port-id=port_id), availability_zone=<AZ_hint_for_Nova>, " + "module=<module_name_or_id>.") @utils.service_type('database') def do_cluster_create(cs, args): """Creates a new cluster.""" instances = [] - for instance_str in args.instances: + for instance_opts in args.instances: instance_info = {} - instance_info["flavorRef"] = _get_flavors(cs, instance_str) - instance_info["volume"] = _get_volumes(instance_str) + flavor, instance_opts = _get_flavor(cs, instance_opts) + instance_info["flavorRef"] = flavor + volume, instance_opts = _get_volume(instance_opts) + instance_info["volume"] = volume - nics = _get_networks(instance_str) + nics, instance_opts = _get_networks(instance_opts) if nics: instance_info["nics"] = nics - availability_zones = _get_availability_zones(instance_str) - if availability_zones: - instance_info["availability_zone"] = availability_zones + availability_zone, instance_opts = _get_availability_zone( + instance_opts) + if availability_zone: + instance_info["availability_zone"] = availability_zone + + modules, instance_opts = _get_modules(cs, instance_opts) + if modules: + instance_info["modules"] = modules + + if instance_opts: + raise exceptions.ValidationError( + "Unknown option(s) '%s' specified for instance" % + instance_opts) instances.append(instance_info) @@ -1412,18 +1479,30 @@ def do_metadata_delete(cs, args): @utils.arg('--datastore', metavar='<datastore>', - help='Name or ID of datastore to list modules for.') + help="Name or ID of datastore to list modules for. Use '%s' " + "to list modules that apply to all datastores." % + Module.ALL_KEYWORD) @utils.service_type('database') def do_module_list(cs, args): """Lists the modules available.""" datastore = None if args.datastore: - datastore = _find_datastore(cs, args.datastore) + if args.datastore.lower() == Module.ALL_KEYWORD: + datastore = args.datastore.lower() + else: + datastore = _find_datastore(cs, args.datastore) module_list = cs.modules.list(datastore=datastore) + field_list = ['id', 'name', 'type', 'datastore', + 'datastore_version', 'auto_apply', 'tenant', 'visible'] + is_admin = False + if hasattr(cs.client, 'auth'): + roles = cs.client.auth.auth_ref['user']['roles'] + role_names = [role['name'] for role in roles] + is_admin = 'admin' in role_names + if not is_admin: + field_list = field_list[:-2] utils.print_list( - module_list, - ['id', 'tenant', 'name', 'type', 'datastore', - 'datastore_version', 'auto_apply', 'visible'], + module_list, field_list, labels={'datastore_version': 'Version'}) @@ -1441,7 +1520,8 @@ def do_module_show(cs, args): help='Type of the module. The type must be supported by a ' 'corresponding module plugin on the datastore it is ' 'applied to.') -@utils.arg('file', metavar='<filename>', type=argparse.FileType('rb', 0), +@utils.arg('file', metavar='<filename>', + type=argparse.FileType(mode='rb', bufsize=0), help='File containing data contents for the module.') @utils.arg('--description', metavar='<description>', type=str, help='Description of the module.') @@ -1534,7 +1614,7 @@ def do_module_create(cs, args): 'already applied to a current instance or cluster.') @utils.service_type('database') def do_module_update(cs, args): - """Create a module.""" + """Update a module.""" module = _find_module(cs, args.module) contents = args.file.read() if args.file else None visible = not args.hidden if args.hidden is not None else None @@ -1560,6 +1640,126 @@ def do_module_delete(cs, args): cs.modules.delete(module) +@utils.arg('instance', metavar='<instance>', type=str, + help='ID or name of the instance.') +@utils.service_type('database') +def do_module_list_instance(cs, args): + """Lists the modules that have been applied to an instance.""" + instance = _find_instance(cs, args.instance) + module_list = cs.instances.modules(instance) + utils.print_list( + module_list, ['id', 'name', 'type', 'md5', 'created', 'updated']) + + +@utils.arg('module', metavar='<module>', type=str, + help='ID or name of the module.') +@utils.arg('--include_clustered', action="store_true", default=False, + help="Include instances that are part of a cluster " + "(default %(default)s).") +@utils.arg('--limit', metavar='<limit>', default=None, + help='Return up to N number of the most recent results.') +@utils.arg('--marker', metavar='<ID>', type=str, default=None, + help='Begin displaying the results for IDs greater than the ' + 'specified marker. When used with --limit, set this to ' + 'the last ID displayed in the previous run.') +@utils.service_type('database') +def do_module_instances(cs, args): + """Lists the instances that have a particular module applied.""" + module = _find_module(cs, args.module) + wrapper = cs.modules.instances( + module, limit=args.limit, marker=args.marker, + include_clustered=args.include_clustered) + instance_list = wrapper.items + while not args.limit and wrapper.next: + wrapper = cs.modules.instances(module, marker=wrapper.next) + instance_list += wrapper.items + _print_instances(instance_list) + + +@utils.arg('cluster', metavar='<cluster>', help='ID or name of the cluster.') +@utils.service_type('database') +def do_cluster_modules(cs, args): + """Lists all modules for each instance of a cluster.""" + cluster = _find_cluster(cs, args.cluster) + instances = cluster._info['instances'] + module_list = [] + for instance in instances: + new_list = cs.instances.modules(instance['id']) + for item in new_list: + item.instance_id = instance['id'] + item.instance_name = instance['name'] + module_list += new_list + utils.print_list( + module_list, + ['instance_name', 'name', 'type', 'md5', 'created', 'updated'], + labels={'name': 'Module Name', 'type': 'Module Type'}) + + +@utils.arg('instance', metavar='<instance>', type=str, + help='ID or name of the instance.') +@utils.arg('modules', metavar='<module>', type=str, nargs='+', default=[], + help='ID or name of the module.') +@utils.service_type('database') +def do_module_apply(cs, args): + """Apply modules to an instance.""" + instance = _find_instance(cs, args.instance) + modules = [] + for module in args.modules: + modules.append(_find_module(cs, module)) + + result_list = cs.instances.module_apply(instance, modules) + utils.print_list( + result_list, + ['name', 'type', 'datastore', + 'datastore_version', 'status', 'message'], + labels={'datastore_version': 'Version'}) + + +@utils.arg('instance', metavar='<instance>', type=str, + help='ID or name of the instance.') +@utils.arg('module', metavar='<module>', type=str, + help='ID or name of the module.') +@utils.service_type('database') +def do_module_remove(cs, args): + """Remove a module from an instance.""" + instance = _find_instance(cs, args.instance) + module = _find_module(cs, args.module) + cs.instances.module_remove(instance, module) + + +@utils.arg('instance', metavar='<instance>', type=str, + help='ID or name of the instance.') +@utils.service_type('database') +def do_module_query(cs, args): + """Query the status of the modules on an instance.""" + instance = _find_instance(cs, args.instance) + result_list = cs.instances.module_query(instance) + utils.print_list( + result_list, + ['name', 'type', 'datastore', + 'datastore_version', 'status', 'message', 'created', 'updated'], + labels={'datastore_version': 'Version'}) + + +@utils.arg('instance', metavar='<instance>', type=str, + help='ID or name of the instance.') +@utils.arg('--directory', metavar='<directory>', type=str, + help='Directory to write module content files in. It will ' + 'be created if it does not exist. Defaults to the ' + 'current directory.') +@utils.arg('--prefix', metavar='<filename_prefix>', type=str, + help='Prefix to prepend to generated filename for each module.') +@utils.service_type('database') +def do_module_retrieve(cs, args): + """Retrieve module contents from an instance.""" + instance = _find_instance(cs, args.instance) + saved_modules = cs.instances.module_retrieve( + instance, args.directory, args.prefix) + for module_name, filename in saved_modules.items(): + print("Module contents for '%s' written to '%s'" % + (module_name, filename)) + + @utils.arg('instance', metavar='<instance>', help='Id or Name of the instance.') @utils.service_type('database') |