From 8a12c0e4031f860a099783257a1ac90f61d0ddbe Mon Sep 17 00:00:00 2001 From: Ishu Patel <258577714+ishu7w@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:59:03 +0530 Subject: [PATCH] Allow EC2 Elastic IP association with a network interface Use NetworkInterfaceId instead of InstanceId when an interface is supplied, enabling association on instances with multiple interfaces. Preserve existing instance targeting and forward the option through the deprecated alias. Fixes #1799 --- CHANGES.rst | 6 ++++++ libcloud/compute/drivers/ec2.py | 24 ++++++++++++++++++++---- libcloud/test/compute/test_ec2.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index b5dde2150a..7e31592ecb 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -26,6 +26,12 @@ Common Compute ~~~~~~~ +- [EC2] Allow Elastic IP association with a specific network interface, so + instances with multiple interfaces can select the required target. Existing + calls continue to associate by instance ID. + (GITHUB-1799) + [Ishu Patel - @ishu7w] + - [OpenStack] Select service catalog endpoints by service type without filtering by the default service name. The service name is only used as a filter when explicitly provided via ``ex_force_service_name``. diff --git a/libcloud/compute/drivers/ec2.py b/libcloud/compute/drivers/ec2.py index 8d62c78739..5e1f0a7a4f 100644 --- a/libcloud/compute/drivers/ec2.py +++ b/libcloud/compute/drivers/ec2.py @@ -3290,7 +3290,7 @@ def ex_describe_all_addresses(self, only_associated=False): # 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, domain=None): + def ex_associate_address_with_node(self, node, elastic_ip, domain=None, network_interface=None): """ Associate an Elastic IP address with a particular node. @@ -3303,12 +3303,26 @@ def ex_associate_address_with_node(self, node, elastic_ip, domain=None): :param domain: The domain where the IP resides (vpc only) :type domain: ``str`` + :param network_interface: Optional interface to associate the IP + with instead of the node. Required for nodes with multiple + network interfaces. + :type network_interface: :class:`EC2NetworkInterface` + + For a VPC address on an instance with multiple interfaces, pass + ``domain="vpc"`` and the desired interface from + :meth:`ex_list_network_interfaces`. The request uses its interface ID + instead of the instance ID, as required by EC2. + :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", "InstanceId": node.id} + params = {"Action": "AssociateAddress"} + if network_interface is not None: + params["NetworkInterfaceId"] = network_interface.id + else: + params["InstanceId"] = node.id if domain is not None and domain != "vpc": raise AttributeError("Domain can only be set to vpc") @@ -3322,13 +3336,15 @@ def ex_associate_address_with_node(self, node, elastic_ip, domain=None): association_id = findtext(element=response, xpath="associationId", namespace=NAMESPACE) return association_id - def ex_associate_addresses(self, node, elastic_ip, domain=None): + def ex_associate_addresses(self, node, elastic_ip, domain=None, network_interface=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=elastic_ip, domain=domain) + return self.ex_associate_address_with_node( + node=node, elastic_ip=elastic_ip, domain=domain, network_interface=network_interface + ) def ex_disassociate_address(self, elastic_ip, domain=None): """ diff --git a/libcloud/test/compute/test_ec2.py b/libcloud/test/compute/test_ec2.py index b01ef2e098..16440d5f3a 100644 --- a/libcloud/test/compute/test_ec2.py +++ b/libcloud/test/compute/test_ec2.py @@ -46,6 +46,7 @@ EC2NetworkSubnet, NimbusNodeDriver, EC2PlacementGroup, + EC2NetworkInterface, IdempotentParamError, ExEC2AvailabilityZone, OutscaleSASNodeDriver, @@ -886,6 +887,21 @@ def test_ex_associate_address_with_node(self): domain="bogus", ) + def test_ex_associate_address_with_network_interface(self): + node = Node("i-4382922a", None, None, None, None, self.driver) + interface = EC2NetworkInterface("eni-12345678", "secondary", "attached") + EC2MockHttp.type = "all_addresses" + elastic_ip = self.driver.ex_describe_all_addresses()[3] + EC2MockHttp.type = "interface" + association_id = self.driver.ex_associate_address_with_node( + node, elastic_ip, domain="vpc", network_interface=interface + ) + self.assertEqual("eipassoc-167a8073", association_id) + association_id = self.driver.ex_associate_addresses( + node, elastic_ip, domain="vpc", network_interface=interface + ) + self.assertEqual("eipassoc-167a8073", association_id) + def test_ex_disassociate_address(self): EC2MockHttp.type = "all_addresses" elastic_ips = self.driver.ex_describe_all_addresses() @@ -1681,6 +1697,18 @@ def _AssociateAddress(self, method, url, body, headers): return (httplib.OK, body, {}, httplib.responses[httplib.OK]) def _vpc_AssociateAddress(self, method, url, body, headers): + params = parse_qs(url.split("?", 1)[1]) + self.assertEqual(["i-4382922a"], params["InstanceId"]) + self.assertNotIn("NetworkInterfaceId", params) + body = self.fixtures.load("associate_vpc_address.xml") + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _interface_AssociateAddress(self, method, url, body, headers): + params = parse_qs(url.split("?", 1)[1]) + self.assertEqual(["eni-12345678"], params["NetworkInterfaceId"]) + self.assertNotIn("InstanceId", params) + self.assertIn("AllocationId", params) + self.assertNotIn("PublicIp", params) body = self.fixtures.load("associate_vpc_address.xml") return (httplib.OK, body, {}, httplib.responses[httplib.OK])