From 062d9e0200d5d3ebe18b577bb34cd3c7116d6dd2 Mon Sep 17 00:00:00 2001 From: Michal Wojcik Date: Thu, 26 Mar 2026 14:07:47 +0100 Subject: [PATCH] TPT-4278 python-sdk: Implement support for Reserved IP for IPv4 --- conftest.py | 6 + linode_api4/groups/linode.py | 5 + linode_api4/groups/networking.py | 115 ++++++- linode_api4/groups/nodebalancer.py | 7 +- linode_api4/groups/tag.py | 5 + linode_api4/objects/linode.py | 19 +- linode_api4/objects/networking.py | 66 ++++ linode_api4/objects/tag.py | 5 +- pytest.ini | 6 + test/fixtures/networking_reserved_ips.json | 35 +++ .../networking_reserved_ips_types.json | 27 ++ test/unit/groups/networking_test.py | 186 +++++++++++- test/unit/objects/networking_test.py | 286 +++++++++++++++++- test/unit/objects/tag_test.py | 59 +++- 14 files changed, 806 insertions(+), 21 deletions(-) create mode 100644 conftest.py create mode 100644 pytest.ini create mode 100644 test/fixtures/networking_reserved_ips.json create mode 100644 test/fixtures/networking_reserved_ips_types.json diff --git a/conftest.py b/conftest.py new file mode 100644 index 000000000..62c0ec3a6 --- /dev/null +++ b/conftest.py @@ -0,0 +1,6 @@ +import sys +import os + +# Ensure the repo root is on sys.path so that `from test.unit.base import ...` +# works regardless of which directory pytest is invoked from. +sys.path.insert(0, os.path.dirname(__file__)) diff --git a/linode_api4/groups/linode.py b/linode_api4/groups/linode.py index 2bd51fa97..bf4c3debb 100644 --- a/linode_api4/groups/linode.py +++ b/linode_api4/groups/linode.py @@ -162,6 +162,7 @@ def instance_create( interface_generation: Optional[Union[InterfaceGeneration, str]] = None, network_helper: Optional[bool] = None, maintenance_policy: Optional[str] = None, + ipv4: Optional[List[str]] = None, **kwargs, ): """ @@ -336,6 +337,9 @@ def instance_create( :param maintenance_policy: The slug of the maintenance policy to apply during maintenance. If not provided, the default policy (linode/migrate) will be applied. :type maintenance_policy: str + :param ipv4: A list of reserved IPv4 addresses to assign to this Instance. + NOTE: Reserved IP feature may not currently be available to all users. + :type ipv4: list[str] :returns: A new Instance object, or a tuple containing the new Instance and the generated password. @@ -373,6 +377,7 @@ def instance_create( "interfaces": interfaces, "interface_generation": interface_generation, "network_helper": network_helper, + "ipv4": ipv4, } params.update(kwargs) diff --git a/linode_api4/groups/networking.py b/linode_api4/groups/networking.py index b16d12d9a..bd0f8dd07 100644 --- a/linode_api4/groups/networking.py +++ b/linode_api4/groups/networking.py @@ -17,6 +17,7 @@ Region, ) from linode_api4.objects.base import _flatten_request_body_recursive +from linode_api4.objects.networking import ReservedIPAddress, ReservedIPType from linode_api4.util import drop_null_keys @@ -328,10 +329,19 @@ def ips_assign(self, region, *assignments): }, ) - def ip_allocate(self, linode, public=True): + def ip_allocate( + self, linode=None, public=True, reserved=False, region=None + ): """ - Allocates an IP to a Instance you own. Additional IPs must be requested - by opening a support ticket first. + Allocates an IP to a Instance you own, or reserves a new IP address. + + When ``reserved`` is False (default), ``linode`` is required and an + ephemeral IP is allocated and assigned to that Instance. + + When ``reserved`` is True, either ``region`` or ``linode`` must be + provided. Passing only ``region`` creates an unassigned reserved IP. + Passing ``linode`` (with or without ``region``) creates a reserved IP + in the Instance's region and assigns it to that Instance. API Documentation: https://techdocs.akamai.com/linode-api/reference/post-allocate-ip @@ -339,18 +349,33 @@ def ip_allocate(self, linode, public=True): :type linode: Instance or int :param public: If True, allocate a public IP address. Defaults to True. :type public: bool + :param reserved: If True, reserve the new IP address. + NOTE: Reserved IP feature may not currently be available to all users. + :type reserved: bool + :param region: The region for the reserved IP (required when reserved=True and linode is not set). + NOTE: Reserved IP feature may not currently be available to all users. + :type region: str or Region :returns: The new IPAddress. :rtype: IPAddress """ - result = self.client.post( - "/networking/ips/", - data={ - "linode_id": linode.id if isinstance(linode, Base) else linode, - "type": "ipv4", - "public": public, - }, - ) + data = { + "type": "ipv4", + "public": public, + } + + if linode is not None: + data["linode_id"] = ( + linode.id if isinstance(linode, Base) else linode + ) + + if reserved: + data["reserved"] = True + + if region is not None: + data["region"] = region.id if isinstance(region, Base) else region + + result = self.client.post("/networking/ips/", data=data) if not "address" in result: raise UnexpectedResponseError( @@ -510,3 +535,71 @@ def delete_vlan(self, vlan, region): return False return True + + def reserved_ips(self, *filters): + """ + Returns a list of reserved IPv4 addresses on your account. + + NOTE: Reserved IP feature may not currently be available to all users. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-reserved-ips + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A list of reserved IP addresses on the account. + :rtype: PaginatedList of ReservedIPAddress + """ + return self.client._get_and_filter(ReservedIPAddress, *filters) + + def reserved_ip_create(self, region, tags=None, **kwargs): + """ + Reserves a new IPv4 address in the given region. + + NOTE: Reserved IP feature may not currently be available to all users. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-reserve-ip + + :param region: The region in which to reserve the IP. + :type region: str or Region + :param tags: Tags to apply to the reserved IP. + :type tags: list of str + + :returns: The new reserved IP address. + :rtype: ReservedIPAddress + """ + params = { + "region": region.id if isinstance(region, Region) else region, + } + if tags is not None: + params["tags"] = tags + params.update(kwargs) + + result = self.client.post("/networking/reserved/ips", data=params) + + if "address" not in result: + raise UnexpectedResponseError( + "Unexpected response when reserving IP address!", json=result + ) + + return ReservedIPAddress(self.client, result["address"], result) + + def reserved_ip_types(self, *filters): + """ + Returns a list of reserved IP types with pricing information. + + NOTE: Reserved IP feature may not currently be available to all users. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-reserved-iptypes + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A list of reserved IP types. + :rtype: PaginatedList of ReservedIPType + """ + return self.client._get_and_filter( + ReservedIPType, *filters, endpoint="/networking/reserved/ips/types" + ) diff --git a/linode_api4/groups/nodebalancer.py b/linode_api4/groups/nodebalancer.py index 57830c8c4..cef2a1b25 100644 --- a/linode_api4/groups/nodebalancer.py +++ b/linode_api4/groups/nodebalancer.py @@ -24,7 +24,7 @@ def __call__(self, *filters): """ return self.client._get_and_filter(NodeBalancer, *filters) - def create(self, region, **kwargs): + def create(self, region, ipv4=None, **kwargs): """ Creates a new NodeBalancer in the given Region. @@ -32,6 +32,9 @@ def create(self, region, **kwargs): :param region: The Region in which to create the NodeBalancer. :type region: Region or str + :param ipv4: A reserved IPv4 address to assign to this NodeBalancer. + NOTE: Reserved IP feature may not currently be available to all users. + :type ipv4: str :returns: The new NodeBalancer :rtype: NodeBalancer @@ -39,6 +42,8 @@ def create(self, region, **kwargs): params = { "region": region.id if isinstance(region, Base) else region, } + if ipv4 is not None: + params["ipv4"] = ipv4 params.update(kwargs) result = self.client.post("/nodebalancers", data=params) diff --git a/linode_api4/groups/tag.py b/linode_api4/groups/tag.py index 5948b513b..1cf7819b2 100644 --- a/linode_api4/groups/tag.py +++ b/linode_api4/groups/tag.py @@ -32,6 +32,7 @@ def create( domains=None, nodebalancers=None, volumes=None, + reserved_ipv4_addresses=None, entities=[], ): """ @@ -61,6 +62,9 @@ def create( :param volumes: A list of Volumes to apply this Tag to upon creation :type volumes: list of Volumes or list of int + :param reserved_ipv4_addresses: A list of reserved IPv4 addresses to apply + this Tag to upon creation. + :type reserved_ipv4_addresses: list of str :returns: The new Tag :rtype: Tag @@ -103,6 +107,7 @@ def create( "nodebalancers": nodebalancer_ids or None, "domains": domain_ids or None, "volumes": volume_ids or None, + "reserved_ipv4_addresses": reserved_ipv4_addresses or None, } result = self.client.post("/tags", data=params) diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 3ffe4b232..4ae930913 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -1539,7 +1539,7 @@ def snapshot(self, label=None): b = Backup(self._client, result["id"], self.id, result) return b - def ip_allocate(self, public=False): + def ip_allocate(self, public=False, address=None): """ Allocates a new :any:`IPAddress` for this Instance. Additional public IPs require justification, and you may need to open a :any:`SupportTicket` @@ -1551,17 +1551,26 @@ def ip_allocate(self, public=False): :param public: If the new IP should be public or private. Defaults to private. :type public: bool + :param address: A reserved IPv4 address to assign to this Instance instead + of allocating a new ephemeral IP. The address must be an + unassigned reserved IP owned by this account. + NOTE: Reserved IP feature may not currently be available to all users. + :type address: str :returns: The new IPAddress :rtype: IPAddress """ + data = { + "type": "ipv4", + "public": public, + } + if address is not None: + data["address"] = address + result = self._client.post( "{}/ips".format(Instance.api_endpoint), model=self, - data={ - "type": "ipv4", - "public": public, - }, + data=data, ) if not "address" in result: diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index ed975ab71..2247de890 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -57,6 +57,20 @@ class InstanceIPNAT1To1(JSONObject): vpc_id: int = 0 +@dataclass +class ReservedIPAssignedEntity(JSONObject): + """ + Represents the entity that a reserved IP is assigned to. + + NOTE: Reserved IP feature may not currently be available to all users. + """ + + id: int = 0 + label: str = "" + type: str = "" + url: str = "" + + class IPAddress(Base): """ note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. @@ -90,6 +104,9 @@ class IPAddress(Base): "interface_id": Property(), "region": Property(slug_relationship=Region), "vpc_nat_1_1": Property(json_object=InstanceIPNAT1To1), + "reserved": Property(mutable=True), + "tags": Property(mutable=True, unordered=True), + "assigned_entity": Property(json_object=ReservedIPAssignedEntity), } @property @@ -156,6 +173,38 @@ def delete(self): return True +class ReservedIPAddress(Base): + """ + .. note:: This endpoint is in beta. This will only function if base_url is set to ``https://api.linode.com/v4beta``. + + Represents a Linode Reserved IPv4 Address. + + Update tags on a reserved IP by mutating the ``tags`` attribute and calling ``save()``. + + NOTE: Reserved IP feature may not currently be available to all users. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-reserved-ip + """ + + api_endpoint = "/networking/reserved/ips/{address}" + id_attribute = "address" + + properties = { + "address": Property(identifier=True), + "gateway": Property(), + "linode_id": Property(), + "prefix": Property(), + "public": Property(), + "rdns": Property(), + "region": Property(slug_relationship=Region), + "reserved": Property(), + "subnet_mask": Property(), + "tags": Property(mutable=True, unordered=True), + "type": Property(), + "assigned_entity": Property(json_object=ReservedIPAssignedEntity), + } + + @dataclass class VPCIPAddressIPv6(JSONObject): slaac_address: str = "" @@ -424,3 +473,20 @@ class NetworkTransferPrice(Base): "region_prices": Property(json_object=RegionPrice), "transfer": Property(), } + + +class ReservedIPType(Base): + """ + Represents a reserved IP type with pricing information. + + NOTE: Reserved IP feature may not currently be available to all users. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-reserved-iptype + """ + + properties = { + "id": Property(identifier=True), + "label": Property(), + "price": Property(json_object=Price), + "region_prices": Property(json_object=RegionPrice), + } diff --git a/linode_api4/objects/tag.py b/linode_api4/objects/tag.py index 4f2e7b1cb..5f698ba0b 100644 --- a/linode_api4/objects/tag.py +++ b/linode_api4/objects/tag.py @@ -6,6 +6,7 @@ Property, Volume, ) +from linode_api4.objects.networking import ReservedIPAddress from linode_api4.paginated_list import PaginatedList CLASS_MAP = { @@ -13,6 +14,7 @@ "domain": Domain, "nodebalancer": NodeBalancer, "volume": Volume, + "reserved_ipv4_address": ReservedIPAddress, } @@ -124,7 +126,8 @@ def make_instance(cls, id, client, parent_id=None, json=None): # discard the envelope real_json = json["data"] - real_id = real_json["id"] + id_attr = getattr(make_cls, "id_attribute", "id") + real_id = real_json[id_attr] # make the real object type return Base.make( diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 000000000..554758f09 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +testpaths = test +markers = + smoke: mark a test as a smoke test + flaky: mark a test as a flaky test for rerun +python_files = *_test.py test_*.py diff --git a/test/fixtures/networking_reserved_ips.json b/test/fixtures/networking_reserved_ips.json new file mode 100644 index 000000000..05eb145eb --- /dev/null +++ b/test/fixtures/networking_reserved_ips.json @@ -0,0 +1,35 @@ +{ + "page": 1, + "pages": 1, + "results": 2, + "data": [ + { + "address": "66.175.209.100", + "gateway": "66.175.209.1", + "linode_id": null, + "prefix": 24, + "public": true, + "rdns": "66-175-209-100.ip.linodeusercontent.com", + "region": "us-east", + "reserved": true, + "subnet_mask": "255.255.255.0", + "tags": ["lb"], + "type": "ipv4", + "assigned_entity": null + }, + { + "address": "66.175.209.101", + "gateway": "66.175.209.1", + "linode_id": null, + "prefix": 24, + "public": true, + "rdns": "66-175-209-101.ip.linodeusercontent.com", + "region": "us-east", + "reserved": true, + "subnet_mask": "255.255.255.0", + "tags": [], + "type": "ipv4", + "assigned_entity": null + } + ] +} diff --git a/test/fixtures/networking_reserved_ips_types.json b/test/fixtures/networking_reserved_ips_types.json new file mode 100644 index 000000000..e233adb4e --- /dev/null +++ b/test/fixtures/networking_reserved_ips_types.json @@ -0,0 +1,27 @@ +{ + "page": 1, + "pages": 1, + "results": 1, + "data": [ + { + "id": "ipv4", + "label": "IPv4 Address", + "price": { + "hourly": 0.005, + "monthly": 2.0 + }, + "region_prices": [ + { + "id": "us-east", + "hourly": 0.005, + "monthly": 2.0 + }, + { + "id": "br-gru", + "hourly": 0.006, + "monthly": 3.0 + } + ] + } + ] +} diff --git a/test/unit/groups/networking_test.py b/test/unit/groups/networking_test.py index 72cc95cda..916e9ee2d 100644 --- a/test/unit/groups/networking_test.py +++ b/test/unit/groups/networking_test.py @@ -1,6 +1,8 @@ -from test.unit.base import ClientBaseCase +from test.unit.base import ClientBaseCase, MethodMock from test.unit.objects.firewall_test import FirewallTemplatesTest +from linode_api4.objects.networking import ReservedIPAddress + class NetworkingGroupTest(ClientBaseCase): """ @@ -15,3 +17,185 @@ def test_get_templates(self): assert templates[1].slug == "vpc" FirewallTemplatesTest.assert_rules(templates[1].rules) + + def test_reserved_ips_list(self): + """ + Tests that reserved IPs are listed correctly. + """ + reserved = self.client.networking.reserved_ips() + + assert len(reserved) == 2 + assert reserved[0].address == "66.175.209.100" + assert reserved[0].region.id == "us-east" + assert reserved[0].reserved is True + assert reserved[0].tags == ["lb"] + assert reserved[1].address == "66.175.209.101" + assert reserved[1].tags == [] + + def test_reserved_ip_create(self): + """ + Tests that reserved_ip_create sends the correct request body and returns a + ReservedIPAddress. + """ + with MethodMock( + "post", + { + "address": "66.175.209.200", + "gateway": "66.175.209.1", + "linode_id": None, + "prefix": 24, + "public": True, + "rdns": "66-175-209-200.ip.linodeusercontent.com", + "region": "us-east", + "reserved": True, + "subnet_mask": "255.255.255.0", + "tags": ["lb"], + "type": "ipv4", + "assigned_entity": None, + }, + ) as m: + result = self.client.networking.reserved_ip_create( + "us-east", tags=["lb"] + ) + + assert m.call_url == "/networking/reserved/ips" + body = m.call_data + assert body["region"] == "us-east" + assert body["tags"] == ["lb"] + + assert isinstance(result, ReservedIPAddress) + assert result.address == "66.175.209.200" + assert result.reserved is True + assert result.tags == ["lb"] + assert result.assigned_entity is None + + def test_reserved_ip_create_no_tags(self): + """ + Tests that reserved_ip_create omits tags from the request when not provided. + """ + with MethodMock( + "post", + { + "address": "66.175.209.201", + "gateway": "66.175.209.1", + "linode_id": None, + "prefix": 24, + "public": True, + "rdns": "66-175-209-201.ip.linodeusercontent.com", + "region": "us-east", + "reserved": True, + "subnet_mask": "255.255.255.0", + "tags": [], + "type": "ipv4", + }, + ) as m: + self.client.networking.reserved_ip_create("us-east") + + body = m.call_data + assert "tags" not in body + + def test_reserved_ip_types(self): + """ + Tests that reserved IP types are listed with pricing data. + """ + types = self.client.networking.reserved_ip_types() + + assert len(types) == 1 + assert types[0].id == "ipv4" + assert types[0].label == "IPv4 Address" + assert types[0].price.hourly == 0.005 + assert types[0].price.monthly == 2.0 + assert len(types[0].region_prices) == 2 + assert types[0].region_prices[0].id == "us-east" + + def test_ip_allocate_reserved_with_region(self): + """ + Tests that ip_allocate with reserved=True and a region creates an unassigned reserved IP. + """ + with MethodMock( + "post", + { + "address": "66.175.209.200", + "gateway": "66.175.209.1", + "linode_id": None, + "prefix": 24, + "public": True, + "rdns": "", + "region": "us-east", + "subnet_mask": "255.255.255.0", + "type": "ipv4", + "reserved": True, + "tags": [], + }, + ) as m: + ip = self.client.networking.ip_allocate( + reserved=True, region="us-east" + ) + + assert m.call_url == "/networking/ips/" + body = m.call_data + assert body["type"] == "ipv4" + assert body["public"] is True + assert body["reserved"] is True + assert body["region"] == "us-east" + assert "linode_id" not in body + assert ip.address == "66.175.209.200" + assert ip.reserved is True + + def test_ip_allocate_reserved_with_linode(self): + """ + Tests that ip_allocate with reserved=True and a linode assigns a reserved IP to that Instance. + """ + with MethodMock( + "post", + { + "address": "66.175.209.201", + "gateway": "66.175.209.1", + "linode_id": 123, + "prefix": 24, + "public": True, + "rdns": "", + "region": "us-east", + "subnet_mask": "255.255.255.0", + "type": "ipv4", + "reserved": True, + "tags": [], + }, + ) as m: + ip = self.client.networking.ip_allocate(linode=123, reserved=True) + + body = m.call_data + assert body["linode_id"] == 123 + assert body["reserved"] is True + assert "region" not in body + assert ip.linode_id == 123 + assert ip.reserved is True + + def test_ip_allocate_ephemeral(self): + """ + Tests that ip_allocate without reserved= sends the classic ephemeral request. + """ + with MethodMock( + "post", + { + "address": "198.51.100.1", + "gateway": "198.51.100.254", + "linode_id": 456, + "prefix": 24, + "public": True, + "rdns": "", + "region": "us-east", + "subnet_mask": "255.255.255.0", + "type": "ipv4", + "reserved": False, + "tags": [], + }, + ) as m: + ip = self.client.networking.ip_allocate(linode=456) + + body = m.call_data + assert body["linode_id"] == 456 + assert body["type"] == "ipv4" + assert "reserved" not in body + assert ip.linode_id == 456 + assert ip.reserved is False diff --git a/test/unit/objects/networking_test.py b/test/unit/objects/networking_test.py index cd2e1b15e..245767214 100644 --- a/test/unit/objects/networking_test.py +++ b/test/unit/objects/networking_test.py @@ -1,7 +1,11 @@ -from test.unit.base import ClientBaseCase +from test.unit.base import ClientBaseCase, MethodMock from linode_api4 import VLAN, ExplicitNullValue, Instance, Region from linode_api4.objects import Firewall, IPAddress, IPv6Range +from linode_api4.objects.networking import ( + ReservedIPAddress, + ReservedIPAssignedEntity, +) class NetworkingTest(ClientBaseCase): @@ -171,3 +175,283 @@ def test_delete_vlan(self): self.assertEqual( m.call_url, "/networking/vlans/us-southeast/vlan-test" ) + + def test_ip_address_reserved_and_tags(self): + """ + Tests that IPAddress exposes the reserved and tags fields. + """ + with self.mock_get( + { + "address": "127.0.0.1", + "gateway": "127.0.0.1", + "linode_id": 123, + "interface_id": 456, + "prefix": 24, + "public": True, + "rdns": "test.example.org", + "region": "us-east", + "subnet_mask": "255.255.255.0", + "type": "ipv4", + "vpc_nat_1_1": None, + "reserved": True, + "tags": ["lb"], + } + ): + ip = IPAddress(self.client, "127.0.0.1") + assert ip.reserved is True + assert ip.tags == ["lb"] + + def test_reserved_ip_address_save_tags(self): + """ + Tests that saving a ReservedIPAddress sends tags in the PUT body. + """ + reserved_ip = ReservedIPAddress( + self.client, + "66.175.209.100", + { + "address": "66.175.209.100", + "gateway": "66.175.209.1", + "linode_id": None, + "prefix": 24, + "public": True, + "rdns": "66-175-209-100.ip.linodeusercontent.com", + "region": "us-east", + "reserved": True, + "subnet_mask": "255.255.255.0", + "tags": ["lb"], + "type": "ipv4", + }, + ) + + with MethodMock( + "put", + { + "address": "66.175.209.100", + "gateway": "66.175.209.1", + "linode_id": None, + "prefix": 24, + "public": True, + "rdns": "66-175-209-100.ip.linodeusercontent.com", + "region": "us-east", + "reserved": True, + "subnet_mask": "255.255.255.0", + "tags": ["lb", "team:infra"], + "type": "ipv4", + "assigned_entity": None, + }, + ) as m: + reserved_ip.tags = ["lb", "team:infra"] + reserved_ip.save() + + assert m.call_url == "/networking/reserved/ips/66.175.209.100" + body = m.call_data + assert body["tags"] == ["lb", "team:infra"] + assert reserved_ip.assigned_entity is None + + def test_reserved_ip_address_delete(self): + """ + Tests that deleting a ReservedIPAddress calls the correct endpoint. + """ + with self.mock_delete() as m: + reserved_ip = ReservedIPAddress(self.client, "66.175.209.100") + reserved_ip.delete() + + self.assertEqual( + m.call_url, "/networking/reserved/ips/66.175.209.100" + ) + + def test_ip_address_assigned_entity(self): + """ + Tests that IPAddress deserializes the assigned_entity field. + """ + with self.mock_get( + { + "address": "66.175.209.100", + "gateway": "66.175.209.1", + "linode_id": 123, + "interface_id": None, + "prefix": 24, + "public": True, + "rdns": "", + "region": "us-east", + "subnet_mask": "255.255.255.0", + "type": "ipv4", + "vpc_nat_1_1": None, + "reserved": True, + "tags": ["lb"], + "assigned_entity": { + "id": 123, + "label": "my-linode", + "type": "linode", + "url": "/v4/linode/instances/123", + }, + } + ): + ip = IPAddress(self.client, "66.175.209.100") + assert ip.assigned_entity is not None + assert isinstance(ip.assigned_entity, ReservedIPAssignedEntity) + assert ip.assigned_entity.id == 123 + assert ip.assigned_entity.label == "my-linode" + assert ip.assigned_entity.type == "linode" + assert ip.assigned_entity.url == "/v4/linode/instances/123" + + def test_ip_address_assigned_entity_null(self): + """ + Tests that IPAddress handles a null assigned_entity field. + """ + with self.mock_get( + { + "address": "66.175.209.101", + "gateway": "66.175.209.1", + "linode_id": None, + "interface_id": None, + "prefix": 24, + "public": True, + "rdns": "", + "region": "us-east", + "subnet_mask": "255.255.255.0", + "type": "ipv4", + "vpc_nat_1_1": None, + "reserved": True, + "tags": [], + "assigned_entity": None, + } + ): + ip = IPAddress(self.client, "66.175.209.101") + assert ip.assigned_entity is None + + def test_ip_address_reserved_mutable(self): + """ + Tests that IPAddress.reserved can be set and saved (convert ephemeral <-> reserved). + """ + with self.mock_get( + { + "address": "66.175.209.100", + "gateway": "66.175.209.1", + "linode_id": 123, + "interface_id": None, + "prefix": 24, + "public": True, + "rdns": "", + "region": "us-east", + "subnet_mask": "255.255.255.0", + "type": "ipv4", + "vpc_nat_1_1": None, + "reserved": False, + "tags": [], + "assigned_entity": None, + } + ): + ip = IPAddress(self.client, "66.175.209.100") + assert ip.reserved is False + + with MethodMock( + "put", + { + "address": "66.175.209.100", + "gateway": "66.175.209.1", + "linode_id": 123, + "prefix": 24, + "public": True, + "rdns": "", + "region": "us-east", + "subnet_mask": "255.255.255.0", + "type": "ipv4", + "reserved": True, + "tags": [], + }, + ) as m: + ip.reserved = True + ip.save() + + assert m.call_url == "/networking/ips/66.175.209.100" + assert m.call_data["reserved"] is True + + def test_reserved_ip_address_assigned_entity(self): + """ + Tests that ReservedIPAddress deserializes the assigned_entity field. + """ + reserved_ip = ReservedIPAddress( + self.client, + "66.175.209.100", + { + "address": "66.175.209.100", + "gateway": "66.175.209.1", + "linode_id": 5678, + "prefix": 24, + "public": True, + "rdns": "", + "region": "us-east", + "reserved": True, + "subnet_mask": "255.255.255.0", + "tags": ["lb"], + "type": "ipv4", + "assigned_entity": { + "id": 5678, + "label": "my-nodebalancer", + "type": "nodebalancer", + "url": "/v4/nodebalancers/5678", + }, + }, + ) + assert reserved_ip.assigned_entity is not None + assert isinstance(reserved_ip.assigned_entity, ReservedIPAssignedEntity) + assert reserved_ip.assigned_entity.id == 5678 + assert reserved_ip.assigned_entity.label == "my-nodebalancer" + assert reserved_ip.assigned_entity.type == "nodebalancer" + assert reserved_ip.assigned_entity.url == "/v4/nodebalancers/5678" + + def test_instance_ip_allocate_with_address(self): + """ + Tests that Instance.ip_allocate sends the address field when provided. + """ + with MethodMock( + "post", + { + "address": "66.175.209.100", + "gateway": "66.175.209.1", + "linode_id": 123, + "prefix": 24, + "public": True, + "rdns": "", + "region": "us-east", + "subnet_mask": "255.255.255.0", + "type": "ipv4", + "reserved": True, + "tags": [], + }, + ) as m: + instance = Instance(self.client, 123) + ip = instance.ip_allocate(public=True, address="66.175.209.100") + + assert m.call_url == "/linode/instances/123/ips" + assert m.call_data["address"] == "66.175.209.100" + assert m.call_data["type"] == "ipv4" + assert m.call_data["public"] is True + assert ip.address == "66.175.209.100" + + def test_instance_ip_allocate_without_address(self): + """ + Tests that Instance.ip_allocate omits address when not provided. + """ + with MethodMock( + "post", + { + "address": "198.51.100.5", + "gateway": "198.51.100.1", + "linode_id": 123, + "prefix": 24, + "public": True, + "rdns": "", + "region": "us-east", + "subnet_mask": "255.255.255.0", + "type": "ipv4", + "reserved": False, + "tags": [], + }, + ) as m: + instance = Instance(self.client, 123) + instance.ip_allocate(public=True) + + assert m.call_url == "/linode/instances/123/ips" + assert "address" not in m.call_data diff --git a/test/unit/objects/tag_test.py b/test/unit/objects/tag_test.py index 137d11deb..53fc53b63 100644 --- a/test/unit/objects/tag_test.py +++ b/test/unit/objects/tag_test.py @@ -1,6 +1,7 @@ -from test.unit.base import ClientBaseCase +from test.unit.base import ClientBaseCase, MethodMock from linode_api4.objects import Tag +from linode_api4.objects.networking import ReservedIPAddress class TagTest(ClientBaseCase): @@ -44,3 +45,59 @@ def test_delete_tag(self): self.assertEqual(result, True) self.assertEqual(m.call_url, "/tags/nothing") + + def test_tagged_reserved_ipv4_address(self): + """ + Tests that a tagged reserved_ipv4_address object is correctly resolved + to a ReservedIPAddress instance. + """ + with self.mock_get( + { + "page": 1, + "pages": 1, + "results": 1, + "data": [ + { + "type": "reserved_ipv4_address", + "data": { + "address": "66.175.209.100", + "gateway": "66.175.209.1", + "linode_id": None, + "prefix": 24, + "public": True, + "rdns": "66-175-209-100.ip.linodeusercontent.com", + "region": "us-east", + "reserved": True, + "subnet_mask": "255.255.255.0", + "tags": ["lb"], + "type": "ipv4", + }, + } + ], + } + ): + tag = self.client.load(Tag, "lb") + objects = tag.objects + + self.assertEqual(len(objects), 1) + self.assertIsInstance(objects[0], ReservedIPAddress) + self.assertEqual(objects[0].address, "66.175.209.100") + self.assertEqual(objects[0].region.id, "us-east") + self.assertTrue(objects[0].reserved) + self.assertEqual(objects[0].tags, ["lb"]) + + def test_create_tag_with_reserved_ipv4_addresses(self): + """ + Tests that creating a tag with reserved_ipv4_addresses sends them in + the request body. + """ + with MethodMock("post", {"label": "lb"}) as m: + self.client.tags.create( + "lb", reserved_ipv4_addresses=["66.175.209.100"] + ) + + body = m.call_data + self.assertEqual(body["label"], "lb") + self.assertEqual( + body["reserved_ipv4_addresses"], ["66.175.209.100"] + )