diff --git a/objectrocket/acls.py b/objectrocket/acls.py new file mode 100644 index 0000000..8c52a10 --- /dev/null +++ b/objectrocket/acls.py @@ -0,0 +1,256 @@ +"""ACL operations and objects.""" +import copy +import json +import logging + +import requests + +from objectrocket import bases +from objectrocket import util + +logger = logging.getLogger(__name__) + + +class Acls(bases.BaseOperationsLayer): + """ACL operations. + + :param objectrocket.client.Client base_client: An instance of objectrocket.client.Client. + """ + + def __init__(self, base_client): + super(Acls, self).__init__(base_client=base_client) + + ##################### + # Public interface. # + ##################### + @util.token_auto_auth + def all(self, instance): + """Get all ACLs associated with the instance specified by name. + + :param str instance: The name of the instance from which to fetch ACLs. + :returns: A list of :py:class:`Acl` objects associated with the specified instance. + :rtype: list + """ + url = self._url.format(instance) + response = requests.get(url, **self._default_request_kwargs) + data = self._get_response_data(response) + return self._concrete_acl_list(data) + + @util.token_auto_auth + def create(self, instance, cidr_mask, description, **kwargs): + """Create an ACL entry for the specified instance. + + :param str instance: The name of the instance to associate the new ACL entry with. + :param str cidr_mask: The IPv4 CIDR mask for the new ACL entry. + :param str description: A short description for the new ACL entry. + :param collector kwargs: (optional) Additional key=value pairs to be supplied to the + creation payload. **Caution:** fields unrecognized by the API will cause this request + to fail with a 400 from the API. + """ + # Build up request data. + url = self._url.format(instance) + request_data = { + 'cidr_mask': cidr_mask, + 'description': description + } + request_data.update(kwargs) + + # Call to create an instance. + response = requests.post( + url, + data=json.dumps(request_data), + **self._default_request_kwargs + ) + + # Log outcome of instance creation request. + if response.status_code == 200: + logger.info('Successfully created a new ACL for instance {} with: {}.' + .format(instance, request_data)) + else: + logger.info('Failed to create a new ACL for instance {} with: {}.' + .format(instance, request_data)) + + data = self._get_response_data(response) + return self._concrete_acl(data) + + @util.token_auto_auth + def get(self, instance, acl): + """Get an ACL by ID belonging to the instance specified by name. + + :param str instance: The name of the instance from which to fetch the ACL. + :param str acl: The ID of the ACL to fetch. + :returns: An :py:class:`Acl` object, or None if ACL does not exist. + :rtype: :py:class:`Acl` + """ + url = self._url.format(instance) + response = requests.get(url, **self._default_request_kwargs) + data = self._get_response_data(response) + return self._concrete_acl(data) + + ###################### + # Private interface. # + ###################### + def _concrete_acl(self, acl_doc): + """Concretize an ACL document. + + :param dict acl_doc: A document describing an ACL entry. Should come from the API. + :returns: An :py:class:`Acl`, or None. + :rtype: :py:class:`bases.BaseInstance` + """ + if not isinstance(acl_doc, dict): + return None + + # Attempt to instantiate an Acl object with the given dict. + try: + return Acl(document=acl_doc, acls=self) + + # If construction fails, log the exception and return None. + except Exception as ex: + logger.exception(ex) + logger.error('Could not instantiate ACL document. You probably need to upgrade to a ' + 'recent version of the client. Document which caused this error: {}' + .format(acl_doc)) + return None + + def _concrete_acl_list(self, acl_docs): + """Concretize a list of ACL documents. + + :param list acl_docs: A list of ACL documents. Should come from the API. + :returns: A list of :py:class:`ACL` objects. + :rtype: list + """ + if not acl_docs: + return [] + + return list(filter(None, [self._concrete_acl(acl_doc=doc) for doc in acl_docs])) + + @property + def _default_request_kwargs(self): + """The default request keyword arguments to be passed to the requests library.""" + defaults = copy.deepcopy(super(Acls, self)._default_request_kwargs) + defaults.setdefault('headers', {}).update({ + 'X-Auth-Token': self._client.auth._token + }) + return defaults + + @property + def _url(self): + """The base URL for ACL operations.""" + base_url = self._client._url.rstrip('/') + return '{}/instances/{{instance}}/acls/'.format(base_url) + + +class Acl(object): + """An Access Control List entry object. + + :param document dict: The dict representing this object. + :param Acls acls: The Acls operations layer instance from which this object came. + """ + + def __init__(self, document, acls): + self.__client = acls._client + self.__acls = acls + self.__document = document + + # Bind required pseudo private attributes from API response document. + self._cidr_mask = document['cidr_mask'] + self._description = document['description'] + self._id = document['id'] + self._instance_name = document['instance'] + self._login = document['login'] + self._port = document['port'] + + # Bind attributes which may be present in API response document. + self._date_created = document.get('date_created', None) + self._instance = document.get('instance_id', None) + self._instance_type = document.get('instance_type', None) + self._metadata = document.get('metadata', {}) + self._service_type = document.get('service_type', None) + + def __repr__(self): + """Represent this object as a string.""" + _id = hex(id(self)) + rep = ( + '<{!s} cidr={!s} port={!s} instance={!s} id={!s} at {!s}>' + .format(self.__class__.__name__, self.cidr_mask, self.port, + self.instance_name, self.id, _id) + ) + return rep + + @property + def cidr_mask(self): + """This ACL entry's CIDR mask.""" + return self._cidr_mask + + @property + def date_created(self): + """The date which this ACL entry was created on.""" + return self._date_created + + @property + def description(self): + """This ACL entry's description.""" + return self._description + + @property + def _document(self): + """This ACL entry's document.""" + return self.__document + + @property + def id(self): + """This ACL entry's ID.""" + return self._id + + @property + def instance(self): + """The ID of the instance to which this ACL entry is associated.""" + return self._instance + + @property + def instance_name(self): + """The name of the instance to which this ACL entry is associated.""" + return self._instance_name + + @property + def instance_type(self): + """The type of the instance to which this ACL entry is associated.""" + return self._instance_type + + @property + def login(self): + """The login of the user to which this ACL entry belongs.""" + return self._login + + @property + def metadata(self): + """This ACL entry's metadata.""" + return self._metadata + + @property + def port(self): + """This ACL entry's port number.""" + return self._port + + @property + def service_type(self): + """The service of the instance to which this ACL entry is associated.""" + return self._service_type + + def to_dict(self): + """Render this object as a dictionary.""" + return self._document + + ###################### + # Private interface. # + ###################### + @property + def _client(self): + """An instance of the objectrocket.client.Client.""" + return self.__client + + @property + def _url(self): + """The URL of this ACL object.""" + base_url = self._client._url.rstrip('/') + return '{}/instances/{}/acls/{}/'.format(base_url, self.instance_name, self.id) diff --git a/objectrocket/bases.py b/objectrocket/bases.py index 7b3ce05..309f03e 100644 --- a/objectrocket/bases.py +++ b/objectrocket/bases.py @@ -1,5 +1,6 @@ """Base classes used throughout the library.""" import abc +import logging import six @@ -8,6 +9,8 @@ from stevedore.extension import ExtensionManager +log = logging.getLogger(__name__) + @six.add_metaclass(abc.ABCMeta) class BaseOperationsLayer(object): @@ -37,6 +40,19 @@ def _default_request_kwargs(self): } return default_kwargs + def _get_response_data(self, response): + """Return the data from a ``requests.Response`` object. + + :param requests.Response response: The ``Response`` object from which to get the data. + """ + try: + _json = response.json() + data = _json.get('data') + return data + except ValueError as ex: + log.exception(ex) + return None + @abc.abstractproperty def _url(self): """The URL this operations layer is to interface with.""" diff --git a/objectrocket/client.py b/objectrocket/client.py index cf90f60..7ae5070 100644 --- a/objectrocket/client.py +++ b/objectrocket/client.py @@ -1,4 +1,5 @@ """ObjectRocket Python client.""" +from objectrocket import acls from objectrocket import auth from objectrocket import bases from objectrocket import constants @@ -16,6 +17,7 @@ def __init__(self, base_url=constants.OR_DEFAULT_API_URL): self.__url = base_url # Public interface attributes. + self.acls = acls.Acls(base_client=self) self.auth = auth.Auth(base_client=self) self.instances = instances.Instances(base_client=self) diff --git a/objectrocket/instances/__init__.py b/objectrocket/instances/__init__.py index 52b82bf..7a1c345 100644 --- a/objectrocket/instances/__init__.py +++ b/objectrocket/instances/__init__.py @@ -102,23 +102,21 @@ def _concrete_instance(self, instance_doc): if not isinstance(instance_doc, dict): return None - service = instance_doc.setdefault('service', 'unknown') - inst = None - - # If service key is a recognized service type, instantiate its respective instance. - if service in self._service_class_map: + # Attempt to instantiate the appropriate class for the given instance document. + try: + service = instance_doc['service'] cls = self._service_class_map[service] - inst = cls(instance_document=instance_doc, instances=self) + return cls(instance_document=instance_doc, instances=self) - # If service is not recognized, log a warning and return None. - else: - logger.warning( - 'Could not determine instance service. You probably need to upgrade to a more ' + # If construction fails, log the exception and return None. + except Exception as ex: + logger.exception(ex) + logger.error( + 'Instance construction failed. You probably need to upgrade to a more ' 'recent version of the client. Instance document which generated this ' 'warning: {}'.format(instance_doc) ) - - return inst + return None def _concrete_instance_list(self, instance_docs): """Concretize a list of instance documents. @@ -130,19 +128,7 @@ def _concrete_instance_list(self, instance_docs): if not instance_docs: return [] - return filter(None, [self._concrete_instance(instance_doc=doc) for doc in instance_docs]) - - def _get_response_data(self, response): - """Return the data from a ``requests.Response`` object. - - :param requests.Response response: The ``Response`` object from which to get the data. - """ - try: - _json = response.json() - data = _json.get('data') - return data - except ValueError: - return None + return list(filter(None, [self._concrete_instance(instance_doc=doc) for doc in instance_docs])) @property def _default_request_kwargs(self): diff --git a/tests/conftest.py b/tests/conftest.py index 0e0b702..e42fb16 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,7 @@ import requests from objectrocket.client import Client +from objectrocket import acls from objectrocket import instances from objectrocket import constants @@ -57,6 +58,35 @@ class Obj(object): return Obj() +########################## +# ACLs related fixtures. # +########################## +@pytest.fixture +def acl_doc(): + now = datetime.datetime.utcnow() + doc = { + 'id': uuid.uuid4().hex, + + 'cidr_mask': '0.0.0.0/1', + 'description': 'testing', + 'instance': 'testinstance', + 'login': 'testuser', + 'port': 27017, + + 'date_created': datetime.datetime.strftime(now, constants.TIME_FORMAT), + 'instance_id': uuid.uuid4().hex, + 'instance_type': 'mongodb_sharded', + 'metadata': {}, + 'service_type': 'mongodb' + } + return doc + + +@pytest.fixture +def acl(acl_doc, client): + return acls.Acl(document=acl_doc, acls=client.acls) + + ############################## # Instance related fixtures. # ############################## @@ -125,6 +155,10 @@ def patched_requests_map(request): """ patches = {} + mocked = mock.patch('objectrocket.acls.requests', autospec=True) + request.addfinalizer(mocked.stop) + patches['acls'] = mocked.start() + mocked = mock.patch('objectrocket.auth.requests', autospec=True) request.addfinalizer(mocked.stop) patches['auth'] = mocked.start() diff --git a/tests/test_acls.py b/tests/test_acls.py new file mode 100644 index 0000000..a572541 --- /dev/null +++ b/tests/test_acls.py @@ -0,0 +1,108 @@ +"""Tests for the objectrocket.acls module.""" +from objectrocket.acls import Acl + + +#################################### +# Tests for Acls public interface. # +#################################### +# def test_authenticate_makes_expected_request(client, mocked_response, patched_requests_map): +# username, password, return_token = 'tester', 'testpass', 'return_token' +# patched_requests_map['auth'].get.return_value = mocked_response +# mocked_response.json.return_value = {'data': {'token': return_token}} + +# output = client.auth.authenticate(username, password) + +# assert output == return_token +# patched_requests_map['auth'].get.assert_called_with( +# client.auth._url, +# auth=(username, password), +# **client.auth._default_request_kwargs +# ) + + +# def test_authenticate_binds_given_credentials(client, mocked_response, patched_requests_map): +# username, password, return_token = 'tester', 'testpass', 'return_token' +# patched_requests_map['auth'].get.return_value = mocked_response +# mocked_response.json.return_value = {'data': {'token': return_token}} +# orig_username, orig_password = client.auth._username, client.auth._password + +# client.auth.authenticate(username, password) + +# assert orig_username is None +# assert orig_password is None +# assert client.auth._username == username +# assert client.auth._password == password + + +# def test_authenticate_binds_auth_token_properly(client, mocked_response, patched_requests_map): +# username, password, return_token = 'tester', 'testpass', 'return_token' +# patched_requests_map['auth'].get.return_value = mocked_response +# mocked_response.json.return_value = {'data': {'token': return_token}} +# orig_token = client.auth._token + +# client.auth.authenticate(username, password) + +# assert orig_token is None +# assert client.auth._token == return_token + + +# def test_authenticate_raises_when_no_data_returned(client, mocked_response, patched_requests_map): +# username, password = 'tester', 'testpass' +# auth = Auth(base_client=client) +# patched_requests_map['auth'].get.return_value = mocked_response +# mocked_response.json.return_value = {} + +# with pytest.raises(errors.AuthFailure) as exinfo: +# auth.authenticate(username, password) + +# assert exinfo.value.args == ("KeyError: 'data'",) + + +# def test_authenticate_raises_when_no_token_returned(client, mocked_response, patched_requests_map): +# username, password = 'tester', 'testpass' +# auth = Auth(base_client=client) +# patched_requests_map['auth'].get.return_value = mocked_response +# mocked_response.json.return_value = {'data': {}} + +# with pytest.raises(errors.AuthFailure) as exinfo: +# auth.authenticate(username, password) + +# assert exinfo.value.args == ("KeyError: 'token'",) + + +##################################### +# Tests for Acls private interface. # +##################################### +def test_concrete_acl_returns_none_if_dict_not_given(client): + output = client.acls._concrete_acl([]) + assert output is None + + +def test_concrete_acl_returns_none_if_doc_is_missing_needed_field(acl_doc, client): + acl_doc.pop('cidr_mask') # Will cause a constructor error. + output = client.acls._concrete_acl(acl_doc) + assert output is None + + +def test_concrete_acl_returns_expected_acl_object(acl_doc, client): + output = client.acls._concrete_acl(acl_doc) + assert isinstance(output, Acl) + assert output._document is acl_doc + + +def test_concrete_acl_list_returns_empty_list_if_empty_list_provided(client): + output = client.acls._concrete_acl_list([]) + assert output == [] + + +def test_concrete_acl_list_returns_empty_list_if_constructor_error_for_doc(acl_doc, client): + acl_doc.pop('cidr_mask') # Will cause a constructor error. + output = client.acls._concrete_acl_list([acl_doc]) + assert output == [] + + +def test_concrete_acl_list_returns_expected_list(acl_doc, client): + output = client.acls._concrete_acl_list([acl_doc]) + assert isinstance(output, list) + assert len(output) == 1 + assert output[0]._document is acl_doc diff --git a/tests/test_instances.py b/tests/test_instances.py new file mode 100644 index 0000000..00021da --- /dev/null +++ b/tests/test_instances.py @@ -0,0 +1,40 @@ +"""Tests for the objectrocket.instances module.""" +from objectrocket.instances.mongodb import MongodbInstance + + +########################################## +# Tests for Instances private interface. # +########################################## +def test_concrete_instance_returns_none_if_dict_not_given(client): + output = client.instances._concrete_instance([]) + assert output is None + + +def test_concrete_instance_returns_none_if_doc_is_missing_needed_field(client, mongodb_sharded_doc): + mongodb_sharded_doc.pop('name') # Will cause a constructor error. + output = client.instances._concrete_instance(mongodb_sharded_doc) + assert output is None + + +def test_concrete_instance_returns_expected_instance_object(client, mongodb_sharded_doc): + output = client.instances._concrete_instance(mongodb_sharded_doc) + assert isinstance(output, MongodbInstance) + assert output._instance_document is mongodb_sharded_doc + + +def test_concrete_instance_list_returns_empty_list_if_empty_list_provided(client): + output = client.instances._concrete_instance_list([]) + assert output == [] + + +def test_concrete_instance_list_returns_empty_list_if_constructor_error_for_doc(client, mongodb_sharded_doc): + mongodb_sharded_doc.pop('name') # Will cause a constructor error. + output = client.instances._concrete_instance_list([mongodb_sharded_doc]) + assert output == [] + + +def test_concrete_instance_list_returns_expected_list(client, mongodb_sharded_doc): + output = client.instances._concrete_instance_list([mongodb_sharded_doc]) + assert isinstance(output, list) + assert len(output) == 1 + assert output[0]._instance_document is mongodb_sharded_doc