diff --git a/cloudcafe/blockstorage/v1/volumes_api/behaviors.py b/cloudcafe/blockstorage/v1/volumes_api/behaviors.py index f697c0c8..acdd97a0 100644 --- a/cloudcafe/blockstorage/v1/volumes_api/behaviors.py +++ b/cloudcafe/blockstorage/v1/volumes_api/behaviors.py @@ -51,14 +51,14 @@ class VolumesAPI_Behaviors(BaseBehavior): @behavior(VolumesClient) def wait_for_volume_status( - self, volume_id, expected_status, timeout, wait_period=None): + self, volume_id, expected_status, timeout, poll_rate=5): """ Waits for a specific status and returns None when that status is observed. Note: Unreliable for transient statuses like 'deleting'. """ - wait_period = float( - wait_period or self.config.volume_status_poll_frequency) + poll_rate = int( + poll_rate or self.config.volume_status_poll_frequency) end_time = time() + int(timeout) while time() < end_time: @@ -68,7 +68,7 @@ class VolumesAPI_Behaviors(BaseBehavior): msg = ( "wait_for_volume_status() failure: " "get_volume_info() call failed with status_code {0} while " - "waiting for volume to reach the {1} status".format( + "waiting for volume to reach the '{1}' status".format( resp.status_code, expected_status)) self._log.error(msg) raise VolumesAPIBehaviorException(msg) @@ -86,8 +86,7 @@ class VolumesAPI_Behaviors(BaseBehavior): 'Expected Volume status "{0}" observed as expected'.format( expected_status)) break - - sleep(wait_period) + sleep(poll_rate) else: msg = ( diff --git a/cloudcafe/blockstorage/v1/volumes_api/client.py b/cloudcafe/blockstorage/v1/volumes_api/client.py index 7dcfb4c4..cee309be 100644 --- a/cloudcafe/blockstorage/v1/volumes_api/client.py +++ b/cloudcafe/blockstorage/v1/volumes_api/client.py @@ -16,8 +16,7 @@ limitations under the License. from cafe.engine.clients.rest import AutoMarshallingRestClient from cloudcafe.blockstorage.v1.volumes_api.models.requests import ( - VolumeRequest, - VolumeSnapshotRequest) + VolumeRequest, VolumeSnapshotRequest) from cloudcafe.blockstorage.v1.volumes_api.models.responses import( VolumeResponse, VolumeSnapshotResponse, VolumeTypeResponse, diff --git a/cloudcafe/compute/volume_attachments_api/behaviors.py b/cloudcafe/compute/volume_attachments_api/behaviors.py new file mode 100644 index 00000000..63f3d629 --- /dev/null +++ b/cloudcafe/compute/volume_attachments_api/behaviors.py @@ -0,0 +1,96 @@ +from time import sleep, time +from cafe.engine.behaviors import BaseBehavior, behavior +from cloudcafe.compute.volume_attachments_api.client import \ + VolumeAttachmentsAPIClient +from cloudcafe.compute.volume_attachments_api.config import \ + VolumeAttachmentsAPIConfig +from cloudcafe.blockstorage.v1.volumes_api.client import VolumesClient +from cloudcafe.blockstorage.v1.volumes_api.config import VolumesAPIConfig +from cloudcafe.blockstorage.v1.volumes_api.behaviors import \ + VolumesAPI_Behaviors + + +class VolumeAttachmentBehaviorError(Exception): + pass + + +class VolumeAttachmentsAPI_Behaviors(BaseBehavior): + + def __init__( + self, volume_attachments_client=None, volumes_client=None, + volume_attachments_config=None, volumes_config=None): + + self.client = volume_attachments_client + self.config = volume_attachments_config or VolumeAttachmentsAPIConfig() + + self.volumes_client = volumes_client + self.volumes_behaviors = VolumesAPI_Behaviors(volumes_client) + self.volumes_config = volumes_config or VolumesAPIConfig() + + @behavior(VolumeAttachmentsAPIClient) + def wait_for_attachment_to_propagate( + self, attachment_id, server_id, timeout=None, poll_rate=5): + + timeout = timeout or self.config.attachment_propagation_timeout + poll_rate = poll_rate or self.config.api_poll_rate + endtime = time() + int(timeout) + while time() < endtime: + resp = \ + self.client.get_volume_attachment_details( + attachment_id, server_id) + if resp.ok: + return True + sleep(poll_rate) + else: + return False + + @behavior(VolumeAttachmentsAPIClient, VolumesClient) + def attach_volume_to_server( + self, server_id, volume_id, device=None, + expected_volume_status='in-use', volume_status_timeout=120, + attachment_propagation_timeout=60): + """Returns a VolumeAttachment object""" + + attachment_propagation_timeout = ( + attachment_propagation_timeout + or self.config.attachment_propagation_timeout) + + resp = self.client.attach_volume(server_id, volume_id, device=device) + + if not resp.ok: + raise VolumeAttachmentBehaviorError( + "Volume attachment failed in auto_attach_volume_to_server" + " with a {0}. Could not attach volume {1} to server {2}" + .format(resp.status_code, volume_id, server_id)) + + if resp.entity is None: + raise VolumeAttachmentBehaviorError( + "Volume attachment failed in auto_attach_volume_to_server." + " Could not deserialize volume attachment response body. Could" + " not attach volume {1} to server {2}".format( + volume_id, server_id)) + + attachment = resp.entity + + #Confirm volume attachment propagation + propagated = self.wait_for_attachment_to_propagate( + attachment.id_, server_id, timeout=attachment_propagation_timeout) + + if not propagated: + raise VolumeAttachmentBehaviorError( + "Volume attachment {0} belonging to server {1} failed to" + "propagate to the relevant cell within {2} seconds".format( + attachment.id_, server_id, attachment_propagation_timeout)) + + # Confirm volume status + try: + self.volumes_behaviors.wait_for_volume_status( + volume_id, expected_volume_status, volume_status_timeout) + + except: + raise VolumeAttachmentBehaviorError( + "Volume did not attain the '{0}' status within {1}" + "seconds after being attached to a server".format( + expected_volume_status, volume_status_timeout)) + + return attachment diff --git a/cloudcafe/compute/volume_attachments_api/volume_attachments_client.py b/cloudcafe/compute/volume_attachments_api/client.py similarity index 51% rename from cloudcafe/compute/volume_attachments_api/volume_attachments_client.py rename to cloudcafe/compute/volume_attachments_api/client.py index a19cae57..c65753a1 100644 --- a/cloudcafe/compute/volume_attachments_api/volume_attachments_client.py +++ b/cloudcafe/compute/volume_attachments_api/client.py @@ -15,84 +15,80 @@ limitations under the License. """ from cafe.engine.clients.rest import AutoMarshallingRestClient -from cloudcafe.compute.volume_attachments_api.models.requests. \ - volume_attachments import VolumeAttachmentRequest -from cloudcafe.compute.volume_attachments_api.models.responses. \ - volume_attachments import VolumeAttachmentListResponse +from cloudcafe.compute.volume_attachments_api.models.requests import \ + VolumeAttachmentRequest +from cloudcafe.compute.volume_attachments_api.models.responses import \ + VolumeAttachmentResponse, VolumeAttachmentListResponse class VolumeAttachmentsAPIClient(AutoMarshallingRestClient): - def __init__(self, url, auth_token, tenant_id, serialize_format=None, - deserialize_format=None): + def __init__( + self, url, auth_token, serialize_format=None, + deserialize_format=None): super(VolumeAttachmentsAPIClient, self).__init__( serialize_format, deserialize_format) - self.url = url + url = url.rstrip('/') self.auth_token = auth_token - self.tenant_id = tenant_id self.default_headers['X-Auth-Token'] = auth_token self.default_headers['Content-Type'] = 'application/{0}'.format( self.serialize_format) self.default_headers['Accept'] = 'application/{0}'.format( self.deserialize_format) + self.url = "{0}/servers/{1}/os-volume_attachments".format( + url, "{server_id}") + def attach_volume(self, server_id, volume_id, device=None, requestslib_kwargs=None): """ POST - v2/{tenant_id}/servers/{server_id}/os-volume_attachments + servers/{server_id}/os-volume_attachments """ - url = '{0}/servers/{1}/os-volume_attachments'.format( - self.url, server_id) - va = VolumeAttachmentRequest(volume_id, device) + url = self.url.format(server_id=server_id) + req_ent = VolumeAttachmentRequest(volume_id, device) return self.request( - 'POST', url, response_entity_type=VolumeAttachmentListResponse, - request_entity=va, requestslib_kwargs=requestslib_kwargs) + 'POST', url, response_entity_type=VolumeAttachmentResponse, + request_entity=req_ent, requestslib_kwargs=requestslib_kwargs) def delete_volume_attachment(self, attachment_id, server_id, requestslib_kwargs=None): """ DELETE - v2/servers/{server_id}/os-volume_attachments/{attachment_id} + servers/{server_id}/os-volume_attachments/{attachment_id} """ - url = '{0}/servers/{1}/os-volume_attachments/{2}'.format( - self.url, server_id, attachment_id) - - params = { - 'tenant_id': self.tenant_id, 'server_id': server_id, - 'attachment_id': attachment_id} + url = "{0}/{1}".format( + self.url.format(server_id=server_id), attachment_id) return self.request( - 'DELETE', url, params=params, - requestslib_kwargs=requestslib_kwargs) + 'DELETE', url, requestslib_kwargs=requestslib_kwargs) def get_server_volume_attachments(self, server_id, requestslib_kwargs=None): """ GET - v2/servers/{server_id}/os-volume_attachments/ + servers/{server_id}/os-volume_attachments """ - url = '{0}/servers/{1}/os-volume_attachments'.format( - self.url, server_id) - - params = {'tenant_id': self.tenant_id, 'server_id': server_id} - + url = self.url.format(server_id=server_id) return self.request( - 'GET', url, params=params, requestslib_kwargs=requestslib_kwargs) + 'GET', url, response_entity_type=VolumeAttachmentListResponse, + requestslib_kwargs=requestslib_kwargs) def get_volume_attachment_details(self, attachment_id, server_id, requestslib_kwargs=None): - url = '{0}/servers/{1}/os-volume_attachments/{2}'.format( - self.url, server_id, attachment_id) + """ + GET + servers/{server_id}/os-volume_attachments/{attachment_id} + """ - params = {'tenant_id': self.tenant_id, - 'server_id': server_id, - 'attachment_id': attachment_id} + url = "{0}/{1}".format( + self.url.format(server_id=server_id), attachment_id) return self.request( - 'GET', url, params=params, requestslib_kwargs=requestslib_kwargs) + 'GET', url, response_entity_type=VolumeAttachmentResponse, + requestslib_kwargs=requestslib_kwargs) diff --git a/cloudcafe/compute/volume_attachments_api/config.py b/cloudcafe/compute/volume_attachments_api/config.py new file mode 100644 index 00000000..841002d4 --- /dev/null +++ b/cloudcafe/compute/volume_attachments_api/config.py @@ -0,0 +1,37 @@ +""" +Copyright 2013 Rackspace + +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. +""" + +from cloudcafe.common.models.configuration import ConfigSectionInterface + + +class VolumeAttachmentsAPIConfig(ConfigSectionInterface): + + SECTION_NAME = 'volume_attachments' + + @property + def attachment_propagation_timeout(self): + """ + Seconds it should take for a new volume attachment instance to + propagate. + """ + return self.get("attachment_propagation_timeout", 60) + + @property + def api_poll_rate(self): + """ + Seconds to wait between polling the os-volume_attachments API in loops. + """ + return self.get("api_poll_rate", 5) diff --git a/cloudcafe/compute/volume_attachments_api/models/requests/volume_attachments.py b/cloudcafe/compute/volume_attachments_api/models/requests.py similarity index 67% rename from cloudcafe/compute/volume_attachments_api/models/requests/volume_attachments.py rename to cloudcafe/compute/volume_attachments_api/models/requests.py index 27004597..7c08c124 100644 --- a/cloudcafe/compute/volume_attachments_api/models/requests/volume_attachments.py +++ b/cloudcafe/compute/volume_attachments_api/models/requests.py @@ -15,6 +15,7 @@ limitations under the License. """ import json +from xml.etree import ElementTree from cafe.engine.models.base import AutoMarshallingModel @@ -23,18 +24,17 @@ class VolumeAttachmentRequest(AutoMarshallingModel): def __init__(self, volume_id=None, device=None): super(VolumeAttachmentRequest, self).__init__() - self.id = None - self.server_id = None self.volume_id = volume_id self.device = device def _obj_to_json(self): - return self._obj_to_json_ele() + data = {"volumeAttachment": self._obj_to_dict()} + return json.dumps(data) - def _obj_to_json_ele(self): - sub_body = {"volumeId": self.volume_id} - sub_body["device"] = self.device - sub_body = self._remove_empty_values(sub_body) - body = {"volumeAttachment": sub_body} - body = self._remove_empty_values(body) - return json.dumps(body) + def _obj_to_xml(self): + element = ElementTree.Element('volumeAttachment') + element = self._set_xml_etree_element(element, self._obj_to_dict()) + return ElementTree.tostring(element) + + def _obj_to_dict(self): + return {"volumeId": self.volume_id, "device": self.device} diff --git a/cloudcafe/compute/volume_attachments_api/models/requests/__init__.py b/cloudcafe/compute/volume_attachments_api/models/requests/__init__.py deleted file mode 100644 index 59ab77fa..00000000 --- a/cloudcafe/compute/volume_attachments_api/models/requests/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -Copyright 2013 Rackspace - -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. -""" diff --git a/cloudcafe/compute/volume_attachments_api/models/responses.py b/cloudcafe/compute/volume_attachments_api/models/responses.py new file mode 100644 index 00000000..ff4c1a26 --- /dev/null +++ b/cloudcafe/compute/volume_attachments_api/models/responses.py @@ -0,0 +1,68 @@ +""" +Copyright 2013 Rackspace + +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 json +from xml.etree import ElementTree + +from cafe.engine.models.base import \ + AutoMarshallingModel, AutoMarshallingListModel + + +class VolumeAttachmentResponse(AutoMarshallingModel): + + def __init__(self, id_=None, volume_id=None, server_id=None, device=None): + super(VolumeAttachmentResponse, self).__init__() + self.id_ = id_ + self.volume_id = volume_id + self.server_id = server_id + self.device = device + + @classmethod + def _json_to_obj(cls, serialized_str): + data = json.loads(serialized_str) + data = data.get("volumeAttachment", dict()) + return cls._dict_to_obj(data) + + @classmethod + def _xml_to_obj(cls, serialized_str): + return cls._dict_to_obj(ElementTree.fromstring(serialized_str)) + + @classmethod + def _dict_to_obj(cls, obj_dict): + return VolumeAttachmentResponse( + id_=obj_dict.get('id', None), + volume_id=obj_dict.get('volumeId', None), + server_id=obj_dict.get('serverId', None), + device=obj_dict.get('device', None)) + + +class VolumeAttachmentListResponse(AutoMarshallingListModel): + """Represents a list of VolumeAttachmentResponse objects""" + + @classmethod + def _xml_to_obj(cls, serialized_str): + return cls._xml_ele_to_obj(ElementTree.fromstring(serialized_str)) + + @classmethod + def _json_to_obj(cls, serialized_str): + data = json.loads(serialized_str) + data = data.get("volumeAttachments") + return cls._list_to_obj(data) + + @classmethod + def _list_to_obj(cls, obj_list): + return VolumeAttachmentListResponse( + [VolumeAttachmentResponse._dict_to_obj(obj) for obj in obj_list]) diff --git a/cloudcafe/compute/volume_attachments_api/models/responses/__init__.py b/cloudcafe/compute/volume_attachments_api/models/responses/__init__.py deleted file mode 100644 index 59ab77fa..00000000 --- a/cloudcafe/compute/volume_attachments_api/models/responses/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -Copyright 2013 Rackspace - -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. -""" diff --git a/cloudcafe/compute/volume_attachments_api/models/responses/volume_attachments.py b/cloudcafe/compute/volume_attachments_api/models/responses/volume_attachments.py deleted file mode 100644 index 4ac41a00..00000000 --- a/cloudcafe/compute/volume_attachments_api/models/responses/volume_attachments.py +++ /dev/null @@ -1,76 +0,0 @@ -""" -Copyright 2013 Rackspace - -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 json - -from cafe.engine.models.base import \ - AutoMarshallingModel, AutoMarshallingListModel - - -class VolumeAttachment(AutoMarshallingModel): - - def __init__(self, id_=None, volume_id=None, server_id=None, device=None): - super(VolumeAttachment, self).__init__() - self.id_ = None - self.server_id = None - self.volume_id = volume_id - self.device = device - - @classmethod - def _json_to_obj(cls, serialized_str): - json_dict = json.loads(serialized_str) - return VolumeAttachment( - id_=json_dict.get('id'), - volume_id=json_dict.get('volumeId'), - server_id=json_dict.get('serverId'), - device=json_dict.get('device')) - - -class VolumeAttachmentListResponse(AutoMarshallingListModel): - - @classmethod - def _json_to_obj(cls, serialized_str): - ''' - Handles both the single and list version of the Volume - call, obviating the need for separate domain objects for "Volumes" - and "Lists of Volumes" responses. - Returns a list-like VolumeAttachmentListResponse - of VolumeAttachment objects, even if there is only one volume - attachment present. - ''' - json_dict = json.loads(serialized_str) - - is_list = True if json_dict.get('volumeAttachments', None) else False - - va_list = VolumeAttachmentListResponse() - if is_list: - for volume_attachment in json_dict.get('volumeAttachments'): - va = VolumeAttachment( - id_=volume_attachment.get('id'), - volume_id=volume_attachment.get('volumeId'), - server_id=volume_attachment.get('serverId'), - device=volume_attachment.get('device')) - va_list.append(va) - else: - volume_attachment = json_dict.get('volumeAttachment') - va_list.append( - VolumeAttachment( - id_=volume_attachment.get('id'), - volume_id=volume_attachment.get('volumeId'), - server_id=volume_attachment.get('serverId'), - device=volume_attachment.get('device'))) - va_list.append(va) - return va_list