Issue LIBCLOUD-470: Add VPC Elastic IP support. This not only adds full EIP support for EC2-Classic/VPC, but also adds a new ElasticIP abstraction that will make future promotion into the base API much easier. New tests were added to validate VPC association/disassociation/release calls work as expected.
Closes #220. Signed-off-by: Tomaz Muraus <[email protected]> Project: http://git-wip-us.apache.org/repos/asf/libcloud/repo Commit: http://git-wip-us.apache.org/repos/asf/libcloud/commit/f44c3648 Tree: http://git-wip-us.apache.org/repos/asf/libcloud/tree/f44c3648 Diff: http://git-wip-us.apache.org/repos/asf/libcloud/diff/f44c3648 Branch: refs/heads/trunk Commit: f44c3648119f8a1e9e3f190bca1bc8e52a2012a3 Parents: 7c1e4fd Author: Chris DeRamus <[email protected]> Authored: Sat Jan 11 15:55:15 2014 -0500 Committer: Tomaz Muraus <[email protected]> Committed: Sat Jan 11 22:13:31 2014 +0100 ---------------------------------------------------------------------- libcloud/compute/drivers/ec2.py | 275 ++++++++++++++----- .../fixtures/ec2/allocate_vpc_address.xml | 6 + .../fixtures/ec2/associate_vpc_address.xml | 5 + .../compute/fixtures/ec2/describe_addresses.xml | 8 +- .../fixtures/ec2/describe_addresses_all.xml | 50 ++-- libcloud/test/compute/test_ec2.py | 79 +++++- 6 files changed, 318 insertions(+), 105 deletions(-) ---------------------------------------------------------------------- http://git-wip-us.apache.org/repos/asf/libcloud/blob/f44c3648/libcloud/compute/drivers/ec2.py ---------------------------------------------------------------------- diff --git a/libcloud/compute/drivers/ec2.py b/libcloud/compute/drivers/ec2.py index a40bc24..e22180b 100644 --- a/libcloud/compute/drivers/ec2.py +++ b/libcloud/compute/drivers/ec2.py @@ -495,6 +495,28 @@ RESOURCE_EXTRA_ATTRIBUTES_MAP = { 'transform_func': int } }, + 'elastic_ip': { + 'allocation_id': { + 'xpath': 'allocationId', + 'transform_func': str, + }, + 'association_id': { + 'xpath': 'associationId', + 'transform_func': str, + }, + 'interface_id': { + 'xpath': 'networkInterfaceId', + 'transform_func': str, + }, + 'owner_id': { + 'xpath': 'networkInterfaceOwnerId', + 'transform_func': str, + }, + 'private_ip': { + 'xpath': 'privateIp', + 'transform_func': str, + } + }, 'image': { 'state': { 'xpath': 'imageState', @@ -924,6 +946,38 @@ class EC2NetworkInterface(object): % (self.id, self.name)) +class ElasticIP(object): + """ + Represents information about an elastic IP adddress + + :param ip: The elastic IP address + :type ip: ``str`` + + :param domain: The domain that the IP resides in (EC2-Classic/VPC). + EC2 classic is represented with standard and VPC + is represented with vpc. + :type domain: ``str`` + + :param instance_id: The identifier of the instance which currently + has the IP associated. + :type instance_id: ``str`` + + Note: This class is used to support both EC2 and VPC IPs. + For VPC specific attributes are stored in the extra + dict to make promotion to the base API easier. + """ + + def __init__(self, ip, domain, instance_id, extra=None): + self.ip = ip + self.domain = domain + self.instance_id = instance_id + self.extra = extra or {} + + def __repr__(self): + return (('<ElasticIP: ip=%s, domain=%s, instance_id=%s>') + % (self.ip, self.domain, self.instance_id)) + + class BaseEC2NodeDriver(NodeDriver): """ Base Amazon EC2 node driver. @@ -1236,6 +1290,48 @@ class BaseEC2NodeDriver(NodeDriver): return EC2Network(vpc_id, name, cidr_block, extra=extra) + def _to_addresses(self, response, only_associated): + """ + Builds a list of dictionaries containing elastic IP properties. + + :param only_associated: If true, return only those addresses + that are associated with an instance. + If false, return all addresses. + :type only_associated: ``bool`` + + :rtype: ``list`` of :class:`ElasticIP` + """ + addresses = [] + for el in response.findall(fixxpath(xpath='addressesSet/item', + namespace=NAMESPACE)): + addr = self._to_address(el, only_associated) + if addr is not None: + addresses.append(addr) + + return addresses + + def _to_address(self, element, only_associated): + instance_id = findtext(element=element, xpath='instanceId', + namespace=NAMESPACE) + + public_ip = findtext(element=element, + xpath='publicIp', + namespace=NAMESPACE) + + domain = findtext(element=element, + xpath='domain', + namespace=NAMESPACE) + + # Build our extra dict + extra = self._get_extra_dict( + element, RESOURCE_EXTRA_ATTRIBUTES_MAP['elastic_ip']) + + # Return NoneType if only associated IPs are requested + if only_associated and not instance_id: + return None + + return ElasticIP(public_ip, domain, instance_id, extra=extra) + def _to_subnets(self, response): return [self._to_subnet(el) for el in response.findall( fixxpath(xpath='subnetSet/item', namespace=NAMESPACE)) @@ -2582,113 +2678,142 @@ class BaseEC2NodeDriver(NodeDriver): 'Filter.0.Value.0': node.id }) - def ex_allocate_address(self): + def ex_allocate_address(self, domain='standard'): """ - Allocate a new Elastic IP address + Allocate a new Elastic IP address for EC2 classic or VPC - :return: String representation of allocated IP address - :rtype: ``str`` + :param domain: The domain to allocate the new address in + (standard/vpc) + :type domain: ``str`` + + :return: Instance of ElasticIP + :rtype: :class:`ElasticIP` """ params = {'Action': 'AllocateAddress'} + if domain == 'vpc': + params['Domain'] = domain + response = self.connection.request(self.path, params=params).object - public_ip = findtext(element=response, xpath='publicIp', - namespace=NAMESPACE) - return public_ip - def ex_release_address(self, elastic_ip_address): + return self._to_address(response, only_associated=False) + + def ex_release_address(self, elastic_ip, domain=None): """ - Release an Elastic IP address + Release an Elastic IP address using the IP (EC2-Classic) or + using the allocation ID (VPC) - :param elastic_ip_address: Elastic IP address which should be used - :type elastic_ip_address: ``str`` + :param elastic_ip: Elastic IP instance + :type elastic_ip: :class:`ElasticIP` - :return: True on success, False otherwise. - :rtype: ``bool`` + :param domain: The domain where the IP resides (vpc only) + :type domain: ``str`` + + :return: True on success, False otherwise. + :rtype: ``bool`` """ params = {'Action': 'ReleaseAddress'} - params.update({'PublicIp': elastic_ip_address}) + if domain is not None and domain != 'vpc': + raise AttributeError('Domain can only be set to vpc') + + if domain is None: + params['PublicIp'] = elastic_ip.ip + else: + params['AllocationId'] = elastic_ip.extra['allocation_id'] + response = self.connection.request(self.path, params=params).object return self._get_boolean(response) - def ex_describe_all_addresses(self, only_allocated=False): + def ex_describe_all_addresses(self, only_associated=False): """ Return all the Elastic IP addresses for this account - optionally, return only the allocated addresses + optionally, return only addresses associated with nodes - :param only_allocated: If true, return only those addresses - that are associated with an instance - :type only_allocated: ``str`` + :param only_associated: If true, return only those addresses + that are associated with an instance. + :type only_associated: ``bool`` - :return: list list of elastic ips for this particular account. - :rtype: ``list`` of ``str`` + :return: List of ElasticIP instances. + :rtype: ``list`` of :class:`ElasticIP` """ params = {'Action': 'DescribeAddresses'} - result = self.connection.request(self.path, - params=params.copy()).object - - # the list which we return - elastic_ip_addresses = [] - for element in findall(element=result, xpath='addressesSet/item', - namespace=NAMESPACE): - instance_id = findtext(element=element, xpath='instanceId', - namespace=NAMESPACE) - - # if only allocated addresses are requested - if only_allocated and not instance_id: - continue - - ip_address = findtext(element=element, xpath='publicIp', - namespace=NAMESPACE) - - elastic_ip_addresses.append(ip_address) + response = self.connection.request(self.path, params=params).object - return elastic_ip_addresses + # We will send our only_associated boolean over to + # shape how the return data is sent back + return self._to_addresses(response, only_associated) - def ex_associate_address_with_node(self, node, elastic_ip_address): + def ex_associate_address_with_node(self, node, elastic_ip, domain=None): """ Associate an Elastic IP address with a particular node. :param node: Node instance :type node: :class:`Node` - :param elastic_ip_address: IP address which should be used - :type elastic_ip_address: ``str`` + :param elastic_ip: Elastic IP instance + :type elastic_ip: :class:`ElasticIP` - :return: True on success, False otherwise. - :rtype: ``bool`` + :param domain: The domain where the IP resides (vpc only) + :type domain: ``str`` + + :return: A string representation of the association ID which is + required for VPC disassociation. EC2/standard + addresses return None + :rtype: ``None`` or ``str`` """ - params = {'Action': 'AssociateAddress'} + params = {'Action': 'AssociateAddress', 'InstanceId': node.id} - params.update({'InstanceId': node.id}) - params.update({'PublicIp': elastic_ip_address}) - res = self.connection.request(self.path, params=params).object - return self._get_boolean(res) + if domain is not None and domain != 'vpc': + raise AttributeError('Domain can only be set to vpc') - def ex_associate_addresses(self, node, elastic_ip_address): + if domain is None: + params.update({'PublicIp': elastic_ip.ip}) + else: + params.update({'AllocationId': elastic_ip.extra['allocation_id']}) + + response = self.connection.request(self.path, params=params).object + association_id = findtext(element=response, + xpath='associationId', + namespace=NAMESPACE) + return association_id + + def ex_associate_addresses(self, node, elastic_ip, domain=None): """ Note: This method has been deprecated in favor of the ex_associate_address_with_node method. """ + return self.ex_associate_address_with_node(node=node, - elastic_ip_address= - elastic_ip_address) + elastic_ip=elastic_ip, + domain=domain) - def ex_disassociate_address(self, elastic_ip_address): + def ex_disassociate_address(self, elastic_ip, domain=None): """ - Disassociate an Elastic IP address + Disassociate an Elastic IP address using the IP (EC2-Classic) + or the association ID (VPC) - :param elastic_ip_address: Elastic IP address which should be used - :type elastic_ip_address: ``str`` + :param elastic_ip: ElasticIP instance + :type elastic_ip: :class:`ElasticIP` - :return: True on success, False otherwise. - :rtype: ``bool`` + :param domain: The domain where the IP resides (vpc only) + :type domain: ``str`` + + :return: True on success, False otherwise. + :rtype: ``bool`` """ params = {'Action': 'DisassociateAddress'} - params.update({'PublicIp': elastic_ip_address}) + if domain is not None and domain != 'vpc': + raise AttributeError('Domain can only be set to vpc') + + if domain is None: + params['PublicIp'] = elastic_ip.ip + + else: + params['AssociationId'] = elastic_ip.extra['association_id'] + res = self.connection.request(self.path, params=params).object return self._get_boolean(res) @@ -2699,9 +2824,10 @@ class BaseEC2NodeDriver(NodeDriver): :param nodes: List of :class:`Node` instances :type nodes: ``list`` of :class:`Node` - :return: Dictionary where a key is a node ID and the value is a - list with the Elastic IP addresses associated with this node. - :rtype: ``dict`` + :return: Dictionary where a key is a node ID and the value is a + list with the Elastic IP addresses associated with + this node. + :rtype: ``dict`` """ if not nodes: return {} @@ -2711,25 +2837,26 @@ class BaseEC2NodeDriver(NodeDriver): if len(nodes) == 1: self._add_instance_filter(params, nodes[0]) - result = self.connection.request(self.path, - params=params.copy()).object + result = self.connection.request(self.path, params=params).object node_instance_ids = [node.id for node in nodes] nodes_elastic_ip_mappings = {} + # We will set only_associated to True so that we only get back + # IPs which are associated with instances + only_associated = True + for node_id in node_instance_ids: nodes_elastic_ip_mappings.setdefault(node_id, []) - for element in findall(element=result, xpath='addressesSet/item', - namespace=NAMESPACE): - instance_id = findtext(element=element, xpath='instanceId', - namespace=NAMESPACE) - ip_address = findtext(element=element, xpath='publicIp', - namespace=NAMESPACE) + for addr in self._to_addresses(result, + only_associated): - if instance_id not in node_instance_ids: - continue + instance_id = addr.instance_id + + if node_id == instance_id: + nodes_elastic_ip_mappings[instance_id].append( + addr.ip) - nodes_elastic_ip_mappings[instance_id].append(ip_address) return nodes_elastic_ip_mappings def ex_describe_addresses_for_node(self, node): http://git-wip-us.apache.org/repos/asf/libcloud/blob/f44c3648/libcloud/test/compute/fixtures/ec2/allocate_vpc_address.xml ---------------------------------------------------------------------- diff --git a/libcloud/test/compute/fixtures/ec2/allocate_vpc_address.xml b/libcloud/test/compute/fixtures/ec2/allocate_vpc_address.xml new file mode 100644 index 0000000..9029d67 --- /dev/null +++ b/libcloud/test/compute/fixtures/ec2/allocate_vpc_address.xml @@ -0,0 +1,6 @@ +<AllocateAddressResponse xmlns="http://ec2.amazonaws.com/doc/2013-10-15/"> + <requestId>eb920193-37a9-401a-8fff-089783b2c153</requestId> + <publicIp>192.0.2.2</publicIp> + <domain>vpc</domain> + <allocationId>eipalloc-666d7f04</allocationId> +</AllocateAddressResponse> \ No newline at end of file http://git-wip-us.apache.org/repos/asf/libcloud/blob/f44c3648/libcloud/test/compute/fixtures/ec2/associate_vpc_address.xml ---------------------------------------------------------------------- diff --git a/libcloud/test/compute/fixtures/ec2/associate_vpc_address.xml b/libcloud/test/compute/fixtures/ec2/associate_vpc_address.xml new file mode 100644 index 0000000..46433e2 --- /dev/null +++ b/libcloud/test/compute/fixtures/ec2/associate_vpc_address.xml @@ -0,0 +1,5 @@ +<AssociateAddressResponse xmlns="http://ec2.amazonaws.com/doc/2013-10-15/"> + <requestId>1b3bf0d9-6c51-4443-a218-f25ecdc98c2a</requestId> + <return>true</return> + <associationId>eipassoc-167a8073</associationId> +</AssociateAddressResponse> \ No newline at end of file http://git-wip-us.apache.org/repos/asf/libcloud/blob/f44c3648/libcloud/test/compute/fixtures/ec2/describe_addresses.xml ---------------------------------------------------------------------- diff --git a/libcloud/test/compute/fixtures/ec2/describe_addresses.xml b/libcloud/test/compute/fixtures/ec2/describe_addresses.xml index 47f40c5..e6147c5 100644 --- a/libcloud/test/compute/fixtures/ec2/describe_addresses.xml +++ b/libcloud/test/compute/fixtures/ec2/describe_addresses.xml @@ -3,7 +3,13 @@ <addressesSet> <item> <publicIp>1.2.3.4</publicIp> + <allocationId>eipalloc-602b5d01</allocationId> + <domain>vpc</domain> <instanceId>i-4382922a</instanceId> + <associationId>eipassoc-cea049ab</associationId> + <networkInterfaceId>eni-83e3c5c5</networkInterfaceId> + <networkInterfaceOwnerId>123456789098</networkInterfaceOwnerId> + <privateIpAddress>192.168.1.5</privateIpAddress> </item> </addressesSet> -</DescribeAddressesResponse> +</DescribeAddressesResponse> \ No newline at end of file http://git-wip-us.apache.org/repos/asf/libcloud/blob/f44c3648/libcloud/test/compute/fixtures/ec2/describe_addresses_all.xml ---------------------------------------------------------------------- diff --git a/libcloud/test/compute/fixtures/ec2/describe_addresses_all.xml b/libcloud/test/compute/fixtures/ec2/describe_addresses_all.xml index 5809f11..2803dce 100644 --- a/libcloud/test/compute/fixtures/ec2/describe_addresses_all.xml +++ b/libcloud/test/compute/fixtures/ec2/describe_addresses_all.xml @@ -1,17 +1,35 @@ <DescribeAddressesResponse xmlns="http://ec2.amazonaws.com/doc/2013-10-15/"> - <requestId>59dbff89-35bd-4eac-99ed-be587EXAMPLE</requestId> - <addressesSet> - <item> - <publicIp>1.2.3.4</publicIp> - <instanceId>i-4382922a</instanceId> - </item> - <item> - <publicIp>1.2.3.6</publicIp> - <instanceId>i-4382922b</instanceId> - </item> - <item> - <publicIp>1.2.3.5</publicIp> - <instanceId /> - </item> -</addressesSet> -</DescribeAddressesResponse> + <requestId>59dbff89-35bd-4eac-99ed-be587EXAMPLE</requestId> + <addressesSet> + <item> + <publicIp>1.2.3.4</publicIp> + <allocationId>eipalloc-602b5d01</allocationId> + <domain>vpc</domain> + <instanceId>i-4382922a</instanceId> + <associationId>eipassoc-cea049ab</associationId> + <networkInterfaceId>eni-83e3c5c5</networkInterfaceId> + <networkInterfaceOwnerId>123456789098</networkInterfaceOwnerId> + <privateIpAddress>192.168.1.5</privateIpAddress> + </item> + <item> + <publicIp>1.2.3.5</publicIp> + <allocationId>eipalloc-998195fb</allocationId> + <domain>vpc</domain> + <instanceId>i-4382922b</instanceId> + <associationId>eipassoc-cea049ac</associationId> + <networkInterfaceId>eni-83e3c5c6</networkInterfaceId> + <networkInterfaceOwnerId>123456789098</networkInterfaceOwnerId> + <privateIpAddress>192.168.1.6</privateIpAddress> + </item> + <item> + <publicIp>1.2.3.6</publicIp> + <allocationId>eipalloc-922a5cf3</allocationId> + <domain>standard</domain> + </item> + <item> + <publicIp>1.2.3.7</publicIp> + <allocationId>eipalloc-992a5cf8</allocationId> + <domain>vpc</domain> + </item> + </addressesSet> +</DescribeAddressesResponse> \ No newline at end of file http://git-wip-us.apache.org/repos/asf/libcloud/blob/f44c3648/libcloud/test/compute/test_ec2.py ---------------------------------------------------------------------- diff --git a/libcloud/test/compute/test_ec2.py b/libcloud/test/compute/test_ec2.py index e8e4a14..9a16e05 100644 --- a/libcloud/test/compute/test_ec2.py +++ b/libcloud/test/compute/test_ec2.py @@ -606,33 +606,76 @@ class EC2Tests(LibcloudTestCase, TestCaseMixin): EC2MockHttp.type = 'all_addresses' elastic_ips1 = self.driver.ex_describe_all_addresses() elastic_ips2 = self.driver.ex_describe_all_addresses( - only_allocated=True) - - self.assertEqual(len(elastic_ips1), 3) - self.assertTrue('1.2.3.5' in elastic_ips1) + only_associated=True) + self.assertEqual('1.2.3.7', elastic_ips1[3].ip) + self.assertEqual('vpc', elastic_ips1[3].domain) + self.assertEqual('eipalloc-992a5cf8', elastic_ips1[3].extra['allocation_id']) self.assertEqual(len(elastic_ips2), 2) - self.assertTrue('1.2.3.5' not in elastic_ips2) + self.assertEqual('1.2.3.5', elastic_ips2[1].ip) + self.assertEqual('vpc', elastic_ips2[1].domain) def test_ex_allocate_address(self): - ret = self.driver.ex_allocate_address() - self.assertTrue(ret) + elastic_ip = self.driver.ex_allocate_address() + self.assertEqual('192.0.2.1', elastic_ip.ip) + self.assertEqual('standard', elastic_ip.domain) + EC2MockHttp.type = 'vpc' + elastic_ip = self.driver.ex_allocate_address(domain='vpc') + self.assertEqual('192.0.2.2', elastic_ip.ip) + self.assertEqual('vpc', elastic_ip.domain) + self.assertEqual('eipalloc-666d7f04', elastic_ip.extra['allocation_id']) def test_ex_release_address(self): - ret = self.driver.ex_release_address('1.2.3.4') + EC2MockHttp.type = 'all_addresses' + elastic_ips = self.driver.ex_describe_all_addresses() + EC2MockHttp.type = '' + ret = self.driver.ex_release_address(elastic_ips[2]) self.assertTrue(ret) + ret = self.driver.ex_release_address(elastic_ips[0], domain='vpc') + self.assertTrue(ret) + self.assertRaises(AttributeError, + self.driver.ex_release_address, + elastic_ips[0], + domain='bogus') def test_ex_associate_address_with_node(self): node = Node('i-4382922a', None, None, None, None, self.driver) - - ret1 = self.driver.ex_associate_address_with_node(node, '1.2.3.4') - ret2 = self.driver.ex_associate_addresses(node, '1.2.3.4') - self.assertTrue(ret1) - self.assertTrue(ret2) + EC2MockHttp.type = 'all_addresses' + elastic_ips = self.driver.ex_describe_all_addresses() + EC2MockHttp.type = '' + ret1 = self.driver.ex_associate_address_with_node( + node, elastic_ips[2]) + ret2 = self.driver.ex_associate_addresses( + node, elastic_ips[2]) + self.assertEqual(None, ret1) + self.assertEqual(None, ret2) + EC2MockHttp.type = 'vpc' + ret3 = self.driver.ex_associate_address_with_node( + node, elastic_ips[3], domain='vpc') + ret4 = self.driver.ex_associate_addresses( + node, elastic_ips[3], domain='vpc') + self.assertEqual('eipassoc-167a8073', ret3) + self.assertEqual('eipassoc-167a8073', ret4) + self.assertRaises(AttributeError, + self.driver.ex_associate_address_with_node, + node, + elastic_ips[1], + domain='bogus') def test_ex_disassociate_address(self): - ret = self.driver.ex_disassociate_address('1.2.3.4') + EC2MockHttp.type = 'all_addresses' + elastic_ips = self.driver.ex_describe_all_addresses() + EC2MockHttp.type = '' + ret = self.driver.ex_disassociate_address(elastic_ips[2]) + self.assertTrue(ret) + # Test a VPC disassociation + ret = self.driver.ex_disassociate_address(elastic_ips[1], + domain='vpc') self.assertTrue(ret) + self.assertRaises(AttributeError, + self.driver.ex_disassociate_address, + elastic_ips[1], + domain='bogus') def test_ex_change_node_size_same_size(self): size = NodeSize('m1.small', 'Small Instance', @@ -1115,10 +1158,18 @@ class EC2MockHttp(MockHttpTestCase): body = self.fixtures.load('allocate_address.xml') return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + def _vpc_AllocateAddress(self, method, url, body, headers): + body = self.fixtures.load('allocate_vpc_address.xml') + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + def _AssociateAddress(self, method, url, body, headers): body = self.fixtures.load('associate_address.xml') return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + def _vpc_AssociateAddress(self, method, url, body, headers): + body = self.fixtures.load('associate_vpc_address.xml') + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + def _DisassociateAddress(self, method, url, body, headers): body = self.fixtures.load('disassociate_address.xml') return (httplib.OK, body, {}, httplib.responses[httplib.OK])
