From 062d9e0200d5d3ebe18b577bb34cd3c7116d6dd2 Mon Sep 17 00:00:00 2001 From: Michal Wojcik Date: Thu, 26 Mar 2026 14:07:47 +0100 Subject: [PATCH 01/27] 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"] + ) From 220daeda8034c1dac4e5f398fe42457f363985c2 Mon Sep 17 00:00:00 2001 From: Michal Wojcik Date: Wed, 15 Apr 2026 14:05:33 +0200 Subject: [PATCH 02/27] TPT-4278: python-sdk: Implement support for Reserved IP for IPv4 --- linode_api4/groups/networking.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/linode_api4/groups/networking.py b/linode_api4/groups/networking.py index bd0f8dd07..08f89f095 100644 --- a/linode_api4/groups/networking.py +++ b/linode_api4/groups/networking.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, Optional, Union +from typing import Any, Dict, List, Optional, Union from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group @@ -18,6 +18,7 @@ ) from linode_api4.objects.base import _flatten_request_body_recursive from linode_api4.objects.networking import ReservedIPAddress, ReservedIPType +from linode_api4.paginated_list import PaginatedList from linode_api4.util import drop_null_keys @@ -330,8 +331,12 @@ def ips_assign(self, region, *assignments): ) def ip_allocate( - self, linode=None, public=True, reserved=False, region=None - ): + self, + linode: Optional[Union[Instance, int]] = None, + public: bool = True, + reserved: bool = False, + region: Optional[Union[Region, str]] = None, + ) -> IPAddress: """ Allocates an IP to a Instance you own, or reserves a new IP address. @@ -536,7 +541,7 @@ def delete_vlan(self, vlan, region): return True - def reserved_ips(self, *filters): + def reserved_ips(self, *filters) -> PaginatedList: """ Returns a list of reserved IPv4 addresses on your account. @@ -553,7 +558,12 @@ def reserved_ips(self, *filters): """ return self.client._get_and_filter(ReservedIPAddress, *filters) - def reserved_ip_create(self, region, tags=None, **kwargs): + def reserved_ip_create( + self, + region: Union[Region, str], + tags: Optional[List[str]] = None, + **kwargs, + ) -> ReservedIPAddress: """ Reserves a new IPv4 address in the given region. @@ -585,7 +595,7 @@ def reserved_ip_create(self, region, tags=None, **kwargs): return ReservedIPAddress(self.client, result["address"], result) - def reserved_ip_types(self, *filters): + def reserved_ip_types(self, *filters) -> PaginatedList: """ Returns a list of reserved IP types with pricing information. From 3535a89e75ce6419fd6e8a2540a7e0d84753a6b5 Mon Sep 17 00:00:00 2001 From: Michal Wojcik Date: Wed, 15 Apr 2026 16:41:37 +0200 Subject: [PATCH 03/27] TPT-4278: python-sdk: Implement support for Reserved IP for IPv4 --- linode_api4/groups/networking.py | 13 ++++++++++ linode_api4/groups/tag.py | 3 ++- test/unit/groups/networking_test.py | 39 +++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/linode_api4/groups/networking.py b/linode_api4/groups/networking.py index 08f89f095..7b526af55 100644 --- a/linode_api4/groups/networking.py +++ b/linode_api4/groups/networking.py @@ -364,6 +364,19 @@ def ip_allocate( :returns: The new IPAddress. :rtype: IPAddress """ + if not reserved and linode is None: + raise ValueError( + "linode is required when reserved is False." + ) + if reserved and linode is None and region is None: + raise ValueError( + "Either linode or region must be provided when reserved is True." + ) + if not reserved and region is not None: + raise ValueError( + "region is only valid when reserved is True." + ) + data = { "type": "ipv4", "public": public, diff --git a/linode_api4/groups/tag.py b/linode_api4/groups/tag.py index 1cf7819b2..9d0ea1f90 100644 --- a/linode_api4/groups/tag.py +++ b/linode_api4/groups/tag.py @@ -33,7 +33,7 @@ def create( nodebalancers=None, volumes=None, reserved_ipv4_addresses=None, - entities=[], + entities=None, ): """ Creates a new Tag and optionally applies it to the given entities. @@ -69,6 +69,7 @@ def create( :returns: The new Tag :rtype: Tag """ + entities = entities or [] linode_ids, nodebalancer_ids, domain_ids, volume_ids = [], [], [], [] # filter input into lists of ids diff --git a/test/unit/groups/networking_test.py b/test/unit/groups/networking_test.py index 916e9ee2d..6503b426f 100644 --- a/test/unit/groups/networking_test.py +++ b/test/unit/groups/networking_test.py @@ -199,3 +199,42 @@ def test_ip_allocate_ephemeral(self): assert "reserved" not in body assert ip.linode_id == 456 assert ip.reserved is False + + def test_ip_allocate_requires_linode_when_not_reserved(self): + """ + Tests that ip_allocate rejects ephemeral allocation without a linode. + """ + with MethodMock("post", {}) as m: + with self.assertRaises(ValueError) as ctx: + self.client.networking.ip_allocate() + + assert str(ctx.exception) == ( + "linode is required when reserved is False." + ) + assert m.called is False + + def test_ip_allocate_requires_linode_or_region_when_reserved(self): + """ + Tests that ip_allocate rejects reserved allocation without a linode or region. + """ + with MethodMock("post", {}) as m: + with self.assertRaises(ValueError) as ctx: + self.client.networking.ip_allocate(reserved=True) + + assert str(ctx.exception) == ( + "Either linode or region must be provided when reserved is True." + ) + assert m.called is False + + def test_ip_allocate_rejects_region_when_not_reserved(self): + """ + Tests that ip_allocate rejects region when reserved is False. + """ + with MethodMock("post", {}) as m: + with self.assertRaises(ValueError) as ctx: + self.client.networking.ip_allocate(region="us-east", linode=456) + + assert str(ctx.exception) == ( + "region is only valid when reserved is True." + ) + assert m.called is False From 4c2a5e100b23a183289934b530d34f986dedd663 Mon Sep 17 00:00:00 2001 From: Michal Wojcik Date: Wed, 15 Apr 2026 17:06:15 +0200 Subject: [PATCH 04/27] TPT-4278: python-sdk: Implement support for Reserved IP for IPv4 --- linode_api4/groups/nodebalancer.py | 5 +++-- linode_api4/groups/tag.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/linode_api4/groups/nodebalancer.py b/linode_api4/groups/nodebalancer.py index cef2a1b25..20252efaa 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, ipv4=None, **kwargs): + def create(self, region, **kwargs): """ Creates a new NodeBalancer in the given Region. @@ -39,6 +39,7 @@ def create(self, region, ipv4=None, **kwargs): :returns: The new NodeBalancer :rtype: NodeBalancer """ + ipv4 = kwargs.pop("ipv4", None) params = { "region": region.id if isinstance(region, Base) else region, } @@ -50,7 +51,7 @@ def create(self, region, ipv4=None, **kwargs): if not "id" in result: raise UnexpectedResponseError( - "Unexpected response when creating Nodebalaner!", json=result + "Unexpected response when creating NodeBalancer!", json=result ) n = NodeBalancer(self.client, result["id"], result) diff --git a/linode_api4/groups/tag.py b/linode_api4/groups/tag.py index 9d0ea1f90..45bbc4b82 100644 --- a/linode_api4/groups/tag.py +++ b/linode_api4/groups/tag.py @@ -32,8 +32,8 @@ def create( domains=None, nodebalancers=None, volumes=None, - reserved_ipv4_addresses=None, entities=None, + reserved_ipv4_addresses=None, ): """ Creates a new Tag and optionally applies it to the given entities. From 97c4a72adab3d20804c1145bef8bb4405502cf7b Mon Sep 17 00:00:00 2001 From: Michal Wojcik Date: Wed, 15 Apr 2026 17:17:38 +0200 Subject: [PATCH 05/27] TPT-4278: python-sdk: Implement support for Reserved IP for IPv4 --- linode_api4/groups/networking.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/linode_api4/groups/networking.py b/linode_api4/groups/networking.py index 7b526af55..9f3eda64b 100644 --- a/linode_api4/groups/networking.py +++ b/linode_api4/groups/networking.py @@ -365,17 +365,13 @@ def ip_allocate( :rtype: IPAddress """ if not reserved and linode is None: - raise ValueError( - "linode is required when reserved is False." - ) + raise ValueError("linode is required when reserved is False.") if reserved and linode is None and region is None: raise ValueError( "Either linode or region must be provided when reserved is True." ) if not reserved and region is not None: - raise ValueError( - "region is only valid when reserved is True." - ) + raise ValueError("region is only valid when reserved is True.") data = { "type": "ipv4", From a99193fc201f34c2eff5e2a1fcb16fbc7346d010 Mon Sep 17 00:00:00 2001 From: Michal Wojcik <32574975+mgwoj@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:18:23 +0200 Subject: [PATCH 06/27] Update linode_api4/groups/networking.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- linode_api4/groups/networking.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linode_api4/groups/networking.py b/linode_api4/groups/networking.py index 9f3eda64b..8c673c488 100644 --- a/linode_api4/groups/networking.py +++ b/linode_api4/groups/networking.py @@ -338,7 +338,7 @@ def ip_allocate( region: Optional[Union[Region, str]] = None, ) -> IPAddress: """ - Allocates an IP to a Instance you own, or reserves a new IP address. + Allocates an IP to an 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. From 3a94fc545ee19f48bc67805d57b1d01261ead7b5 Mon Sep 17 00:00:00 2001 From: Michal Wojcik <32574975+mgwoj@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:18:34 +0200 Subject: [PATCH 07/27] Update conftest.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conftest.py b/conftest.py index 62c0ec3a6..2047317e8 100644 --- a/conftest.py +++ b/conftest.py @@ -1,5 +1,5 @@ -import sys import os +import sys # Ensure the repo root is on sys.path so that `from test.unit.base import ...` # works regardless of which directory pytest is invoked from. From 94fbe6cfcb0972110cf69454d0bf35144dab7224 Mon Sep 17 00:00:00 2001 From: Maciej Wilk Date: Fri, 24 Apr 2026 12:05:48 +0200 Subject: [PATCH 08/27] Create int tests for Reserved IPs networking endpoints --- .../models/networking/test_networking.py | 122 +++++++++++++++++- 1 file changed, 120 insertions(+), 2 deletions(-) diff --git a/test/integration/models/networking/test_networking.py b/test/integration/models/networking/test_networking.py index 27ffbb444..02b333b48 100644 --- a/test/integration/models/networking/test_networking.py +++ b/test/integration/models/networking/test_networking.py @@ -1,3 +1,4 @@ +import ipaddress import time from test.integration.conftest import ( get_api_ca_file, @@ -13,8 +14,8 @@ import pytest -from linode_api4 import Instance, LinodeClient -from linode_api4.objects import Config, ConfigInterfaceIPv4, Firewall, IPAddress +from linode_api4 import Instance, LinodeClient, ApiError +from linode_api4.objects import Config, ConfigInterfaceIPv4, Firewall, IPAddress, ReservedIPAddress from linode_api4.objects.networking import ( FirewallCreateDevicesOptions, NetworkTransferPrice, @@ -351,3 +352,120 @@ def test_ip_info(test_linode_client, create_linode): assert ip_info.subnet_mask is not None assert ip_info.type == "ipv4" assert ip_info.vpc_nat_1_1 is None + + +@pytest.fixture +def create_reserved_ip(test_linode_client): + client = test_linode_client + reserved_ip = client.networking.reserved_ip_create( + region=TEST_REGION, + tags=["test1"] + ) + + yield reserved_ip + + reserved_ip.delete() + + +@pytest.fixture +def create_reserved_ip_assigned(test_linode_client, create_linode): + client = test_linode_client + linode = create_linode + reserved_ip = client.networking.reserved_ip_create( + region=linode.region, + tags=["test", "assigned"], + ) + + client.networking.ip_addresses_assign( + assignments=[{"address": reserved_ip.address, "linode_id": linode.id}], + region=linode.region, + ) + + reserved_ip = test_linode_client.load(ReservedIPAddress, reserved_ip.address) + + yield linode, reserved_ip + + # Delete only if IP exists (some tests delete it earlier) + if reserved_ip.address in [ip.address for ip in client.networking.reserved_ips()]: + reserved_ip.delete() + + +@pytest.mark.smoke +@pytest.mark.parametrize("region, tags", [ + (TEST_REGION, ["test"]), + (TEST_REGION, None), +]) +def test_create_reserved_ip(request, test_linode_client, region, tags): + client = test_linode_client + reserved_ip = client.networking.reserved_ip_create( + region=region, + tags=tags + ) + + request.addfinalizer(reserved_ip.delete) + + assert isinstance(ipaddress.ip_address(reserved_ip.address), ipaddress.IPv4Address) + assert reserved_ip.type == "ipv4" + assert reserved_ip.public == True + assert reserved_ip.linode_id is None + assert reserved_ip.reserved == True + # assert reserved_ip.tags == tags # NOTE: Skipped as tags not available in the API yet + assert reserved_ip.assigned_entity is None + + +def test_create_reserved_ip_wo_region_fail(test_linode_client): + client = test_linode_client + + with pytest.raises(ApiError) as exc_info: + client.networking.reserved_ip_create( + region=None, + tags=["test"] + ) + + error_msg = str(exc_info.value.json) + assert exc_info.value.status == 400 + assert "region is required" in error_msg + + +@pytest.mark.skip # NOTE: Skipped as tags not available in the API yet +def test_update_reserved_ip_tags(create_reserved_ip): + reserved_ip = create_reserved_ip + assert reserved_ip.tags == ["test"] + + reserved_ip.save(tags=["updated"]) + assert reserved_ip.tags == ["updated"] + + +def test_create_reserved_ip_assigned(test_linode_client, create_reserved_ip_assigned): + client = test_linode_client + linode, reserved_ip = create_reserved_ip_assigned + + assert reserved_ip.reserved == True + # assert reserved_ip.tags == tags # NOTE: Skipped as tags not available in the API yet + assert reserved_ip.linode_id == linode.id + assert reserved_ip.assigned_entity.id == linode.id + assert reserved_ip.assigned_entity.type == "linode" + assert reserved_ip.assigned_entity.label == linode.label + assert reserved_ip.assigned_entity.url == f"/v4/linode/instances/{linode.id}" + + ips_list = client.networking.ips() + assert reserved_ip.address in [ip.address for ip in ips_list] + + reserved_ips_list = client.networking.reserved_ips() + assert reserved_ip.address in [ip.address for ip in reserved_ips_list] + + # linode_ips = linode.ips.ipv4.public + # assert len(linode_ips) == 2 + # assert any([ip.reserved for ip in linode_ips]) + + reserved_ip.delete() + reserved_ips_list = client.networking.reserved_ips() + assert reserved_ip.address not in [ip.address for ip in reserved_ips_list] + + reserved_ips_list = client.networking.reserved_ips(ReservedIPAddress.address==reserved_ip.address) + assert len(reserved_ips_list) == 0 + + # delattr(linode, "_ips") + # linode_ips = linode.ips.ipv4.public + # assert len(linode_ips) == 2 + # assert not any([ip.reserved for ip in linode_ips]) From 9e7dbbf35e64cd14c37e83814cb449488215e35f Mon Sep 17 00:00:00 2001 From: Maciej Wilk Date: Fri, 24 Apr 2026 14:07:37 +0200 Subject: [PATCH 09/27] Create int tests for Reserved IPs: types, allocate --- .../models/networking/test_networking.py | 97 ++++++++++++++++--- 1 file changed, 84 insertions(+), 13 deletions(-) diff --git a/test/integration/models/networking/test_networking.py b/test/integration/models/networking/test_networking.py index 02b333b48..11bb61330 100644 --- a/test/integration/models/networking/test_networking.py +++ b/test/integration/models/networking/test_networking.py @@ -390,6 +390,28 @@ def create_reserved_ip_assigned(test_linode_client, create_linode): reserved_ip.delete() +def verify_reserved_ip(reserved_ip): + assert isinstance(ipaddress.ip_address(reserved_ip.address), ipaddress.IPv4Address) + assert reserved_ip.type == "ipv4" + assert reserved_ip.public == True + assert reserved_ip.reserved == True + assert reserved_ip.linode_id is None + assert reserved_ip.assigned_entity is None + + +def verify_reserved_ip_assigned(reserved_ip, resource): + assert isinstance(ipaddress.ip_address(reserved_ip.address), ipaddress.IPv4Address) + assert reserved_ip.type == "ipv4" + assert reserved_ip.public == True + assert reserved_ip.reserved == True + assert reserved_ip.linode_id == resource.id + assert reserved_ip.region.id == resource.region.id + assert reserved_ip.assigned_entity.id == resource.id + assert reserved_ip.assigned_entity.type == "linode" + assert reserved_ip.assigned_entity.label == resource.label + assert reserved_ip.assigned_entity.url == f"/v4/linode/instances/{resource.id}" + + @pytest.mark.smoke @pytest.mark.parametrize("region, tags", [ (TEST_REGION, ["test"]), @@ -401,16 +423,10 @@ def test_create_reserved_ip(request, test_linode_client, region, tags): region=region, tags=tags ) - request.addfinalizer(reserved_ip.delete) - assert isinstance(ipaddress.ip_address(reserved_ip.address), ipaddress.IPv4Address) - assert reserved_ip.type == "ipv4" - assert reserved_ip.public == True - assert reserved_ip.linode_id is None - assert reserved_ip.reserved == True + verify_reserved_ip(reserved_ip) # assert reserved_ip.tags == tags # NOTE: Skipped as tags not available in the API yet - assert reserved_ip.assigned_entity is None def test_create_reserved_ip_wo_region_fail(test_linode_client): @@ -430,9 +446,11 @@ def test_create_reserved_ip_wo_region_fail(test_linode_client): @pytest.mark.skip # NOTE: Skipped as tags not available in the API yet def test_update_reserved_ip_tags(create_reserved_ip): reserved_ip = create_reserved_ip + verify_reserved_ip(reserved_ip) assert reserved_ip.tags == ["test"] reserved_ip.save(tags=["updated"]) + verify_reserved_ip(reserved_ip) assert reserved_ip.tags == ["updated"] @@ -440,13 +458,8 @@ def test_create_reserved_ip_assigned(test_linode_client, create_reserved_ip_assi client = test_linode_client linode, reserved_ip = create_reserved_ip_assigned - assert reserved_ip.reserved == True + verify_reserved_ip_assigned(reserved_ip, linode) # assert reserved_ip.tags == tags # NOTE: Skipped as tags not available in the API yet - assert reserved_ip.linode_id == linode.id - assert reserved_ip.assigned_entity.id == linode.id - assert reserved_ip.assigned_entity.type == "linode" - assert reserved_ip.assigned_entity.label == linode.label - assert reserved_ip.assigned_entity.url == f"/v4/linode/instances/{linode.id}" ips_list = client.networking.ips() assert reserved_ip.address in [ip.address for ip in ips_list] @@ -469,3 +482,61 @@ def test_create_reserved_ip_assigned(test_linode_client, create_reserved_ip_assi # linode_ips = linode.ips.ipv4.public # assert len(linode_ips) == 2 # assert not any([ip.reserved for ip in linode_ips]) + # assert not any([ip.tags for ip in linode_ips]) # Tags should be removed ??? + + +def test_get_reserved_ip_types(test_linode_client, create_reserved_ip): + # TODO: Currently it uses client (token), should not it be publicly accessible (no token required) ??? + client = test_linode_client + types = client.networking.reserved_ip_types() + assert types.only + + pricing = types.first() + assert pricing.id == "reserved-ipv4" + assert pricing.label == "Reserved IPv4" + assert pricing.price.hourly + # assert pricing.price.monthly is None + # assert pricing.region_prices == [] + + +@pytest.mark.smoke +@pytest.mark.parametrize("reserved, region", [ + (True, TEST_REGION), + (True, None), +]) +def test_create_reserved_ip_with_allocate(test_linode_client, create_linode, reserved, region): + client = test_linode_client + linode = create_linode + + if region: + reserved_ip = client.networking.ip_allocate(reserved=reserved, region=TEST_REGION) + verify_reserved_ip(reserved_ip) + else: + reserved_ip = client.networking.ip_allocate(reserved=reserved, linode=linode.id) + verify_reserved_ip_assigned(reserved_ip, linode) + + # assert reserved_ip.tags == tags # NOTE: Skipped as tags not available in the API yet + + +def test_create_reserved_ip_with_allocate_fail(test_linode_client, create_linode): + client = test_linode_client + linode = create_linode + region = TEST_REGION + + while region == linode.region: + region = get_region( + LinodeClient( + token=get_token(), + base_url=get_api_url(), + ca_path=get_api_ca_file(), + ), + {"Linodes", "Cloud Firewall"}, + site_type="core", + ) + + with pytest.raises(ApiError) as exc_info: + client.networking.ip_allocate(reserved=True, region=region, linode=linode.id) + + error_msg = str(exc_info.value.json) + assert exc_info.value.status == 400 + assert "Region passed in must match Linode's region" in error_msg From 9e6ad278f0a28a7e66794b65b04630006d5676a6 Mon Sep 17 00:00:00 2001 From: Maciej Wilk Date: Fri, 24 Apr 2026 15:50:38 +0200 Subject: [PATCH 10/27] Create int tests for Reserved IPs: ephemeral --- .../models/networking/test_networking.py | 48 +++++++++++++++++-- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/test/integration/models/networking/test_networking.py b/test/integration/models/networking/test_networking.py index 11bb61330..62145cdf9 100644 --- a/test/integration/models/networking/test_networking.py +++ b/test/integration/models/networking/test_networking.py @@ -364,7 +364,9 @@ def create_reserved_ip(test_linode_client): yield reserved_ip - reserved_ip.delete() + # Delete only if IP exists (some tests delete it earlier) + if client.networking.reserved_ips(ReservedIPAddress.address==reserved_ip.address): + reserved_ip.delete() @pytest.fixture @@ -386,7 +388,7 @@ def create_reserved_ip_assigned(test_linode_client, create_linode): yield linode, reserved_ip # Delete only if IP exists (some tests delete it earlier) - if reserved_ip.address in [ip.address for ip in client.networking.reserved_ips()]: + if client.networking.reserved_ips(ReservedIPAddress.address==reserved_ip.address): reserved_ip.delete() @@ -444,12 +446,15 @@ def test_create_reserved_ip_wo_region_fail(test_linode_client): @pytest.mark.skip # NOTE: Skipped as tags not available in the API yet -def test_update_reserved_ip_tags(create_reserved_ip): +def test_update_reserved_ip_tags(test_linode_client, create_reserved_ip): + client = test_linode_client reserved_ip = create_reserved_ip verify_reserved_ip(reserved_ip) assert reserved_ip.tags == ["test"] - reserved_ip.save(tags=["updated"]) + reserved_ip.tags = ["updated"] + reserved_ip.save() + reserved_ip = client.networking.reserved_ips(ReservedIPAddress.address==reserved_ip.address)[0] verify_reserved_ip(reserved_ip) assert reserved_ip.tags == ["updated"] @@ -540,3 +545,38 @@ def test_create_reserved_ip_with_allocate_fail(test_linode_client, create_linode error_msg = str(exc_info.value.json) assert exc_info.value.status == 400 assert "Region passed in must match Linode's region" in error_msg + + +def test_reserve_ephemeral_ip(test_linode_client, create_linode): + client = test_linode_client + linode = create_linode + + ip_address = client.load(IPAddress, linode.ipv4[0]) + assert ip_address.linode_id == linode.id + assert ip_address.reserved == False + + ip_address.reserved = True + # ip_address.rdns = "test.example.org" # TODO: Should be enabled ? + ip_address.save() + ip_address = client.load(IPAddress, linode.ipv4[0]) + assert ip_address.linode_id == linode.id + assert ip_address.reserved == True + + ip_address.reserved = False + ip_address.save() + ip_address = client.load(IPAddress, linode.ipv4[0]) + assert ip_address.linode_id == linode.id + assert ip_address.reserved == False + + +def test_convert_unassigned_reserved_ip_to_ephemeral(test_linode_client, create_reserved_ip): + client = test_linode_client + reserved_ip = create_reserved_ip + verify_reserved_ip(reserved_ip) + + ip_address = client.load(IPAddress, reserved_ip.address) + ip_address.reserved = False + ip_address.save() + + reserved_ips_list = client.networking.reserved_ips(ReservedIPAddress.address==reserved_ip.address) + assert len(reserved_ips_list) == 0 From 107ac3fab0e90716bc5462721276913b9efdef0b Mon Sep 17 00:00:00 2001 From: Maciej Wilk Date: Fri, 24 Apr 2026 16:47:35 +0200 Subject: [PATCH 11/27] Create int tests for Reserved IPs: linode instances --- .../models/networking/test_networking.py | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/test/integration/models/networking/test_networking.py b/test/integration/models/networking/test_networking.py index 62145cdf9..c8a896cc9 100644 --- a/test/integration/models/networking/test_networking.py +++ b/test/integration/models/networking/test_networking.py @@ -15,7 +15,7 @@ import pytest from linode_api4 import Instance, LinodeClient, ApiError -from linode_api4.objects import Config, ConfigInterfaceIPv4, Firewall, IPAddress, ReservedIPAddress +from linode_api4.objects import Config, ConfigInterfaceIPv4, Firewall, InterfaceGeneration, IPAddress, ReservedIPAddress from linode_api4.objects.networking import ( FirewallCreateDevicesOptions, NetworkTransferPrice, @@ -580,3 +580,36 @@ def test_convert_unassigned_reserved_ip_to_ephemeral(test_linode_client, create_ reserved_ips_list = client.networking.reserved_ips(ReservedIPAddress.address==reserved_ip.address) assert len(reserved_ips_list) == 0 + + +# TODO: move to linode's tests file +@pytest.mark.parametrize("interface", [ + InterfaceGeneration.LEGACY_CONFIG, + # InterfaceGeneration.LINODE +]) +def test_create_linode_with_reserved_ip_in_legacy_config(test_linode_client, e2e_test_firewall, create_reserved_ip, interface): + client = test_linode_client + reserved_ip = create_reserved_ip + label = get_test_label(length=8) + + # if interface == InterfaceGeneration.LINODE: + # interface = "POST /v4beta/linode/instances: [400] ipv4: Reserved IPs must be assigned directly in interface configurations when using Linode Interfaces" + + linode, _ = client.linode.instance_create( + "g6-nanode-1", + TEST_REGION, + image="linode/debian12", + label=label, + firewall=e2e_test_firewall, + interface_generation=interface, + ipv4=[reserved_ip.address] + ) + + linode_ips = linode.ips.ipv4.public + assert len(linode_ips) == 1 + verify_reserved_ip_assigned(linode_ips[0], linode) + + linode.delete() + reserved_ips_list = client.networking.reserved_ips(ReservedIPAddress.address==reserved_ip.address) + assert len(reserved_ips_list) == 1 + verify_reserved_ip(reserved_ips_list[0]) From bd24819bc7a92e24393d9ea42872a3c3ae5910b9 Mon Sep 17 00:00:00 2001 From: Maciej Wilk Date: Mon, 27 Apr 2026 11:33:42 +0200 Subject: [PATCH 12/27] Move fixtures for reserved IPs into conftest --- test/integration/conftest.py | 40 ++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/test/integration/conftest.py b/test/integration/conftest.py index a5c832f4f..c1c9bcf00 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -26,6 +26,7 @@ PlacementGroupPolicy, PlacementGroupType, PostgreSQLDatabase, + ReservedIPAddress, ) from linode_api4.errors import ApiError from linode_api4.linode_client import LinodeClient, MonitorClient @@ -728,3 +729,42 @@ def test_monitor_client(get_monitor_token_for_db_entities): ) return client, entity_ids + + +@pytest.fixture +def create_reserved_ip(test_linode_client): + client = test_linode_client + region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") + reserved_ip = client.networking.reserved_ip_create( + region=region, + tags=["test1"] + ) + + yield reserved_ip + + # Delete only if IP exists (some tests delete it earlier) + if client.networking.reserved_ips(ReservedIPAddress.address==reserved_ip.address): + reserved_ip.delete() + + +@pytest.fixture +def create_reserved_ip_assigned(test_linode_client, create_linode): + client = test_linode_client + linode = create_linode + reserved_ip = client.networking.reserved_ip_create( + region=linode.region, + tags=["test", "assigned"], + ) + + client.networking.ip_addresses_assign( + assignments=[{"address": reserved_ip.address, "linode_id": linode.id}], + region=linode.region, + ) + + reserved_ip = test_linode_client.load(ReservedIPAddress, reserved_ip.address) + + yield linode, reserved_ip + + # Delete only if IP exists (some tests delete it earlier) + if client.networking.reserved_ips(ReservedIPAddress.address==reserved_ip.address): + reserved_ip.delete() From 3961d4b886bc8d5e3c7fb92c8447a2986aad0207 Mon Sep 17 00:00:00 2001 From: Maciej Wilk Date: Mon, 27 Apr 2026 11:45:58 +0200 Subject: [PATCH 13/27] Create int tests for Reserved IPs: linode interfaces --- .../linode/interfaces/test_interfaces.py | 70 ++++++++++++++++ test/integration/models/linode/test_linode.py | 37 +++++++++ .../models/networking/test_networking.py | 79 ++----------------- 3 files changed, 113 insertions(+), 73 deletions(-) diff --git a/test/integration/models/linode/interfaces/test_interfaces.py b/test/integration/models/linode/interfaces/test_interfaces.py index 650a9cb6c..c80468eca 100644 --- a/test/integration/models/linode/interfaces/test_interfaces.py +++ b/test/integration/models/linode/interfaces/test_interfaces.py @@ -8,6 +8,8 @@ Instance, LinodeInterface, LinodeInterfaceDefaultRouteOptions, + InterfaceGeneration, + LinodeInterfaceOptions, LinodeInterfacePublicIPv4AddressOptions, LinodeInterfacePublicIPv4Options, LinodeInterfacePublicIPv6Options, @@ -18,7 +20,27 @@ LinodeInterfaceVPCIPv4Options, LinodeInterfaceVPCIPv4RangeOptions, LinodeInterfaceVPCOptions, + ReservedIPAddress, ) +from test.integration.helpers import get_test_label + + +def build_interface_public_ipv4(firewall, ip_address): + return LinodeInterfaceOptions( + firewall_id=firewall, + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True, + ), + public=LinodeInterfacePublicOptions( + ipv4=LinodeInterfacePublicIPv4Options( + addresses=[ + LinodeInterfacePublicIPv4AddressOptions( + address=ip_address, primary=True + ) + ], + ), + ), + ) def test_linode_create_with_linode_interfaces( @@ -359,3 +381,51 @@ def test_linode_interface_firewalls(e2e_test_firewall, linode_interface_public): firewall = firewalls[0] assert firewall.id == e2e_test_firewall.id assert firewall.label == e2e_test_firewall.label + + +@pytest.mark.parametrize("iface_type", [ + InterfaceGeneration.LEGACY_CONFIG, + InterfaceGeneration.LINODE +]) +def test_linode_interfaces_with_reserved_ips(test_linode_client, e2e_test_firewall, create_reserved_ip, iface_type): + client = test_linode_client + reserved_ip = create_reserved_ip + label = get_test_label(length=8) + + if iface_type == InterfaceGeneration.LEGACY_CONFIG: + linode, _ = client.linode.instance_create( + "g6-nanode-1", + reserved_ip.region, + image="linode/debian12", + label=label, + firewall=e2e_test_firewall, + interface_generation=iface_type, + ipv4=[reserved_ip.address] + ) + else: + interface = build_interface_public_ipv4(e2e_test_firewall.id, reserved_ip.address) + linode, _ = client.linode.instance_create( + "g6-nanode-1", + reserved_ip.region, + image="linode/debian12", + label=label, + interface_generation=iface_type, + interfaces=[interface], + ) + + linode_ips = linode.ips.ipv4.public + assert len(linode_ips) == 1 + assert linode_ips[0].address == reserved_ip.address + assert linode_ips[0].reserved == True + assert linode_ips[0].linode_id == linode.id + assert linode_ips[0].assigned_entity.id == linode.id + assert linode_ips[0].assigned_entity.type == "linode" + assert linode_ips[0].assigned_entity.label == linode.label + assert linode_ips[0].assigned_entity.url == f"/v4/linode/instances/{linode.id}" + + linode.delete() + reserved_ips_list = client.networking.reserved_ips(ReservedIPAddress.address==reserved_ip.address) + assert len(reserved_ips_list) == 1 + assert reserved_ips_list[0].reserved == True + assert reserved_ips_list[0].linode_id is None + assert reserved_ips_list[0].assigned_entity is None diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index 9f6194fa9..8b7aedd33 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -19,6 +19,7 @@ Instance, InterfaceGeneration, LinodeInterface, + ReservedIPAddress, Type, ) from linode_api4.objects.linode import InstanceDiskEncryptionType, MigrationType @@ -1147,3 +1148,39 @@ def test_update_linode_maintenance_policy(create_linode, test_linode_client): linode.invalidate() assert result assert linode.maintenance_policy_id == non_default_policy.slug + + +def test_update_linode_with_reserved_ip_in_address(test_linode_client, e2e_test_firewall, create_reserved_ip): + label = get_test_label(length=8) + client = test_linode_client + reserved_ip = create_reserved_ip + assert reserved_ip.reserved == True + assert reserved_ip.linode_id is None + assert reserved_ip.assigned_entity is None + + linode, _ = client.linode.instance_create( + "g6-nanode-1", + reserved_ip.region, + image="linode/debian12", + label=label, + firewall=e2e_test_firewall, + ) + + linode_ips = linode.ips.ipv4.public + assert len(linode_ips) == 1 + assert linode_ips[0].address != reserved_ip.address + + linode.ip_allocate(True, reserved_ip.address) + delattr(linode, "_ips") + linode_ips = linode.ips.ipv4.public + assert len(linode_ips) == 2 + assert reserved_ip.address in [ip.address for ip in linode_ips] + + reserved_ip = client.networking.reserved_ips(ReservedIPAddress.address==reserved_ip.address)[0] + assert reserved_ip.linode_id == linode.id + assert reserved_ip.assigned_entity.id == linode.id + assert reserved_ip.assigned_entity.type == "linode" + assert reserved_ip.assigned_entity.label == linode.label + assert reserved_ip.assigned_entity.url == f"/v4/linode/instances/{linode.id}" + + linode.delete() diff --git a/test/integration/models/networking/test_networking.py b/test/integration/models/networking/test_networking.py index c8a896cc9..f16aeb325 100644 --- a/test/integration/models/networking/test_networking.py +++ b/test/integration/models/networking/test_networking.py @@ -14,8 +14,12 @@ import pytest -from linode_api4 import Instance, LinodeClient, ApiError -from linode_api4.objects import Config, ConfigInterfaceIPv4, Firewall, InterfaceGeneration, IPAddress, ReservedIPAddress +from linode_api4 import ( + Instance, + LinodeClient, + ApiError, +) +from linode_api4.objects import Config, ConfigInterfaceIPv4, Firewall, IPAddress, ReservedIPAddress from linode_api4.objects.networking import ( FirewallCreateDevicesOptions, NetworkTransferPrice, @@ -354,44 +358,6 @@ def test_ip_info(test_linode_client, create_linode): assert ip_info.vpc_nat_1_1 is None -@pytest.fixture -def create_reserved_ip(test_linode_client): - client = test_linode_client - reserved_ip = client.networking.reserved_ip_create( - region=TEST_REGION, - tags=["test1"] - ) - - yield reserved_ip - - # Delete only if IP exists (some tests delete it earlier) - if client.networking.reserved_ips(ReservedIPAddress.address==reserved_ip.address): - reserved_ip.delete() - - -@pytest.fixture -def create_reserved_ip_assigned(test_linode_client, create_linode): - client = test_linode_client - linode = create_linode - reserved_ip = client.networking.reserved_ip_create( - region=linode.region, - tags=["test", "assigned"], - ) - - client.networking.ip_addresses_assign( - assignments=[{"address": reserved_ip.address, "linode_id": linode.id}], - region=linode.region, - ) - - reserved_ip = test_linode_client.load(ReservedIPAddress, reserved_ip.address) - - yield linode, reserved_ip - - # Delete only if IP exists (some tests delete it earlier) - if client.networking.reserved_ips(ReservedIPAddress.address==reserved_ip.address): - reserved_ip.delete() - - def verify_reserved_ip(reserved_ip): assert isinstance(ipaddress.ip_address(reserved_ip.address), ipaddress.IPv4Address) assert reserved_ip.type == "ipv4" @@ -580,36 +546,3 @@ def test_convert_unassigned_reserved_ip_to_ephemeral(test_linode_client, create_ reserved_ips_list = client.networking.reserved_ips(ReservedIPAddress.address==reserved_ip.address) assert len(reserved_ips_list) == 0 - - -# TODO: move to linode's tests file -@pytest.mark.parametrize("interface", [ - InterfaceGeneration.LEGACY_CONFIG, - # InterfaceGeneration.LINODE -]) -def test_create_linode_with_reserved_ip_in_legacy_config(test_linode_client, e2e_test_firewall, create_reserved_ip, interface): - client = test_linode_client - reserved_ip = create_reserved_ip - label = get_test_label(length=8) - - # if interface == InterfaceGeneration.LINODE: - # interface = "POST /v4beta/linode/instances: [400] ipv4: Reserved IPs must be assigned directly in interface configurations when using Linode Interfaces" - - linode, _ = client.linode.instance_create( - "g6-nanode-1", - TEST_REGION, - image="linode/debian12", - label=label, - firewall=e2e_test_firewall, - interface_generation=interface, - ipv4=[reserved_ip.address] - ) - - linode_ips = linode.ips.ipv4.public - assert len(linode_ips) == 1 - verify_reserved_ip_assigned(linode_ips[0], linode) - - linode.delete() - reserved_ips_list = client.networking.reserved_ips(ReservedIPAddress.address==reserved_ip.address) - assert len(reserved_ips_list) == 1 - verify_reserved_ip(reserved_ips_list[0]) From 00afc230fd30d69cc7781d3643310579c20d1fb5 Mon Sep 17 00:00:00 2001 From: Maciej Wilk Date: Mon, 27 Apr 2026 12:29:40 +0200 Subject: [PATCH 14/27] Create int tests for Reserved IPs: nodebalancers --- .../models/nodebalancer/test_nodebalancer.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/integration/models/nodebalancer/test_nodebalancer.py b/test/integration/models/nodebalancer/test_nodebalancer.py index 692efb027..7b06cb25b 100644 --- a/test/integration/models/nodebalancer/test_nodebalancer.py +++ b/test/integration/models/nodebalancer/test_nodebalancer.py @@ -15,6 +15,7 @@ NodeBalancerNode, NodeBalancerType, RegionPrice, + ReservedIPAddress, ) TEST_REGION = get_region( @@ -113,6 +114,31 @@ def test_create_nb(test_linode_client, e2e_test_firewall): nb.delete() +pytest.mark.skip() # TODO: Currently if fails to assign ReservedIP via ipv4 param, need to investigate +def test_create_nb_with_reserved_ip(test_linode_client, e2e_test_firewall, create_reserved_ip): + client = test_linode_client + reserved_ip = create_reserved_ip + label = get_test_label(8) + + nb = client.nodebalancer_create( + region=TEST_REGION, + label=label, + firewall=e2e_test_firewall.id, + client_udp_sess_throttle=5, + ipv4=reserved_ip.address, + ) + + assert TEST_REGION, nb.region + assert label == nb.label + assert nb.ipv4.address == reserved_ip.address + assert nb.ipv4.public == True + assert nb.ipv4.reserved == True + + nb.delete() + reserved_ip = client.networking.reserved_ips(ReservedIPAddress.address==reserved_ip.address)[0] + assert reserved_ip.assigned_entity is None + + def test_get_nodebalancer_config(test_linode_client, create_nb_config): config = test_linode_client.load( NodeBalancerConfig, From 0f7e06ca901edbb97da8d22b841debb4dc148048 Mon Sep 17 00:00:00 2001 From: Maciej Wilk Date: Mon, 27 Apr 2026 12:57:40 +0200 Subject: [PATCH 15/27] Create int tests for Reserved IPs: tags --- test/integration/models/tag/test_tag.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/integration/models/tag/test_tag.py b/test/integration/models/tag/test_tag.py index d2edf84c5..90fde77fc 100644 --- a/test/integration/models/tag/test_tag.py +++ b/test/integration/models/tag/test_tag.py @@ -20,3 +20,14 @@ def test_get_tag(test_linode_client, test_tag): tag = test_linode_client.load(Tag, test_tag.id) assert tag.id == test_tag.id + + +@pytest.mark.skip(reason="This test is currently blocked - API does not support tagging reserved IPs yet") +def test_get_tag_with_reserved_ip(test_linode_client, create_reserved_ip): + unique_tag = get_test_label() + "_tag" + reserved_ip = create_reserved_ip + + tag = test_linode_client.tags.create(unique_tag, reserved_ipv4_addresses=[reserved_ip.address]) + tag = test_linode_client.load(Tag, tag.id) + assert tag.type == "reserved_ipv4_address" + # assert tag.data... From 59004bf748796db745b7334303f32bbf9fd093c1 Mon Sep 17 00:00:00 2001 From: Maciej Wilk Date: Mon, 27 Apr 2026 14:01:36 +0200 Subject: [PATCH 16/27] Refactor --- test/integration/models/linode/test_linode.py | 3 -- .../models/networking/test_networking.py | 30 ++++--------------- .../models/nodebalancer/test_nodebalancer.py | 2 +- test/integration/models/tag/test_tag.py | 2 ++ 4 files changed, 8 insertions(+), 29 deletions(-) diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index 8b7aedd33..ca340254b 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -1154,9 +1154,6 @@ def test_update_linode_with_reserved_ip_in_address(test_linode_client, e2e_test_ label = get_test_label(length=8) client = test_linode_client reserved_ip = create_reserved_ip - assert reserved_ip.reserved == True - assert reserved_ip.linode_id is None - assert reserved_ip.assigned_entity is None linode, _ = client.linode.instance_create( "g6-nanode-1", diff --git a/test/integration/models/networking/test_networking.py b/test/integration/models/networking/test_networking.py index f16aeb325..617217be5 100644 --- a/test/integration/models/networking/test_networking.py +++ b/test/integration/models/networking/test_networking.py @@ -486,31 +486,7 @@ def test_create_reserved_ip_with_allocate(test_linode_client, create_linode, res reserved_ip = client.networking.ip_allocate(reserved=reserved, linode=linode.id) verify_reserved_ip_assigned(reserved_ip, linode) - # assert reserved_ip.tags == tags # NOTE: Skipped as tags not available in the API yet - - -def test_create_reserved_ip_with_allocate_fail(test_linode_client, create_linode): - client = test_linode_client - linode = create_linode - region = TEST_REGION - - while region == linode.region: - region = get_region( - LinodeClient( - token=get_token(), - base_url=get_api_url(), - ca_path=get_api_ca_file(), - ), - {"Linodes", "Cloud Firewall"}, - site_type="core", - ) - - with pytest.raises(ApiError) as exc_info: - client.networking.ip_allocate(reserved=True, region=region, linode=linode.id) - - error_msg = str(exc_info.value.json) - assert exc_info.value.status == 400 - assert "Region passed in must match Linode's region" in error_msg + # assert reserved_ip.tags == tags # TODO: Skipped as tags not available in the API yet def test_reserve_ephemeral_ip(test_linode_client, create_linode): @@ -546,3 +522,7 @@ def test_convert_unassigned_reserved_ip_to_ephemeral(test_linode_client, create_ reserved_ips_list = client.networking.reserved_ips(ReservedIPAddress.address==reserved_ip.address) assert len(reserved_ips_list) == 0 + + +# def test_create_unassigned_reserved_ip_with_rdns(): +# pass \ No newline at end of file diff --git a/test/integration/models/nodebalancer/test_nodebalancer.py b/test/integration/models/nodebalancer/test_nodebalancer.py index 7b06cb25b..1efe73b5f 100644 --- a/test/integration/models/nodebalancer/test_nodebalancer.py +++ b/test/integration/models/nodebalancer/test_nodebalancer.py @@ -114,7 +114,7 @@ def test_create_nb(test_linode_client, e2e_test_firewall): nb.delete() -pytest.mark.skip() # TODO: Currently if fails to assign ReservedIP via ipv4 param, need to investigate +@pytest.mark.skip(reason="Currently it fails to assign ReservedIP via ipv4 param") def test_create_nb_with_reserved_ip(test_linode_client, e2e_test_firewall, create_reserved_ip): client = test_linode_client reserved_ip = create_reserved_ip diff --git a/test/integration/models/tag/test_tag.py b/test/integration/models/tag/test_tag.py index 90fde77fc..25e631793 100644 --- a/test/integration/models/tag/test_tag.py +++ b/test/integration/models/tag/test_tag.py @@ -31,3 +31,5 @@ def test_get_tag_with_reserved_ip(test_linode_client, create_reserved_ip): tag = test_linode_client.load(Tag, tag.id) assert tag.type == "reserved_ipv4_address" # assert tag.data... + + tag.delete() From ebe8d96c736230a90e41b90dcf32ff88e97fb74f Mon Sep 17 00:00:00 2001 From: Maciej Wilk Date: Tue, 28 Apr 2026 12:45:38 +0200 Subject: [PATCH 17/27] Update tests after API changes on DevCloud --- test/integration/conftest.py | 2 +- .../linode/interfaces/test_interfaces.py | 2 + .../models/networking/test_networking.py | 52 ++++++++----------- .../models/nodebalancer/test_nodebalancer.py | 1 - 4 files changed, 26 insertions(+), 31 deletions(-) diff --git a/test/integration/conftest.py b/test/integration/conftest.py index c1c9bcf00..9bc972300 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -737,7 +737,7 @@ def create_reserved_ip(test_linode_client): region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") reserved_ip = client.networking.reserved_ip_create( region=region, - tags=["test1"] + tags=["test"] ) yield reserved_ip diff --git a/test/integration/models/linode/interfaces/test_interfaces.py b/test/integration/models/linode/interfaces/test_interfaces.py index c80468eca..31dc7300d 100644 --- a/test/integration/models/linode/interfaces/test_interfaces.py +++ b/test/integration/models/linode/interfaces/test_interfaces.py @@ -417,6 +417,7 @@ def test_linode_interfaces_with_reserved_ips(test_linode_client, e2e_test_firewa assert len(linode_ips) == 1 assert linode_ips[0].address == reserved_ip.address assert linode_ips[0].reserved == True + # assert linode_ips[0].tags == ["test"] # TODO Does not work at the moment - during clarifications with API Team assert linode_ips[0].linode_id == linode.id assert linode_ips[0].assigned_entity.id == linode.id assert linode_ips[0].assigned_entity.type == "linode" @@ -427,5 +428,6 @@ def test_linode_interfaces_with_reserved_ips(test_linode_client, e2e_test_firewa reserved_ips_list = client.networking.reserved_ips(ReservedIPAddress.address==reserved_ip.address) assert len(reserved_ips_list) == 1 assert reserved_ips_list[0].reserved == True + # assert linode_ips[0].tags == ["test"] # TODO Does not work at the moment - during clarifications with API Team assert reserved_ips_list[0].linode_id is None assert reserved_ips_list[0].assigned_entity is None diff --git a/test/integration/models/networking/test_networking.py b/test/integration/models/networking/test_networking.py index 617217be5..101a70d83 100644 --- a/test/integration/models/networking/test_networking.py +++ b/test/integration/models/networking/test_networking.py @@ -1,4 +1,5 @@ import ipaddress +import requests import time from test.integration.conftest import ( get_api_ca_file, @@ -394,7 +395,7 @@ def test_create_reserved_ip(request, test_linode_client, region, tags): request.addfinalizer(reserved_ip.delete) verify_reserved_ip(reserved_ip) - # assert reserved_ip.tags == tags # NOTE: Skipped as tags not available in the API yet + assert reserved_ip.tags == tags if tags else reserved_ip.tags == [] def test_create_reserved_ip_wo_region_fail(test_linode_client): @@ -411,7 +412,6 @@ def test_create_reserved_ip_wo_region_fail(test_linode_client): assert "region is required" in error_msg -@pytest.mark.skip # NOTE: Skipped as tags not available in the API yet def test_update_reserved_ip_tags(test_linode_client, create_reserved_ip): client = test_linode_client reserved_ip = create_reserved_ip @@ -428,19 +428,18 @@ def test_update_reserved_ip_tags(test_linode_client, create_reserved_ip): def test_create_reserved_ip_assigned(test_linode_client, create_reserved_ip_assigned): client = test_linode_client linode, reserved_ip = create_reserved_ip_assigned - verify_reserved_ip_assigned(reserved_ip, linode) - # assert reserved_ip.tags == tags # NOTE: Skipped as tags not available in the API yet + assert sorted(reserved_ip.tags) == ["assigned", "test"] - ips_list = client.networking.ips() - assert reserved_ip.address in [ip.address for ip in ips_list] + # ips_list = client.networking.ips() + # assert reserved_ip.address in [ip.address for ip in ips_list] reserved_ips_list = client.networking.reserved_ips() assert reserved_ip.address in [ip.address for ip in reserved_ips_list] - # linode_ips = linode.ips.ipv4.public - # assert len(linode_ips) == 2 - # assert any([ip.reserved for ip in linode_ips]) + linode_ips = linode.ips.ipv4.public + assert len(linode_ips) == 2 + assert any([ip.reserved for ip in linode_ips]) reserved_ip.delete() reserved_ips_list = client.networking.reserved_ips() @@ -449,25 +448,25 @@ def test_create_reserved_ip_assigned(test_linode_client, create_reserved_ip_assi reserved_ips_list = client.networking.reserved_ips(ReservedIPAddress.address==reserved_ip.address) assert len(reserved_ips_list) == 0 - # delattr(linode, "_ips") - # linode_ips = linode.ips.ipv4.public - # assert len(linode_ips) == 2 - # assert not any([ip.reserved for ip in linode_ips]) - # assert not any([ip.tags for ip in linode_ips]) # Tags should be removed ??? + delattr(linode, "_ips") + linode_ips = linode.ips.ipv4.public + assert len(linode_ips) == 2 + assert not any([ip.reserved for ip in linode_ips]) + assert not any([ip.tags for ip in linode_ips]) # Tags should be removed def test_get_reserved_ip_types(test_linode_client, create_reserved_ip): - # TODO: Currently it uses client (token), should not it be publicly accessible (no token required) ??? client = test_linode_client - types = client.networking.reserved_ip_types() - assert types.only + endpoint = client.base_url + "/networking/reserved/ips/types" + types = requests.get(endpoint).json()["data"] # Pricing should be publicly available - pricing = types.first() - assert pricing.id == "reserved-ipv4" - assert pricing.label == "Reserved IPv4" - assert pricing.price.hourly - # assert pricing.price.monthly is None - # assert pricing.region_prices == [] + assert isinstance(types, list) + assert types[0]["id"] == "reserved-ipv4" + assert types[0]["label"] == "Reserved IPv4" + assert "hourly" in types[0]["price"] + assert "monthly" in types[0]["price"] + assert any(price != 0 for price in list(types[0]["price"].values())) + assert isinstance(types[0]["region_prices"], list) @pytest.mark.smoke @@ -486,7 +485,7 @@ def test_create_reserved_ip_with_allocate(test_linode_client, create_linode, res reserved_ip = client.networking.ip_allocate(reserved=reserved, linode=linode.id) verify_reserved_ip_assigned(reserved_ip, linode) - # assert reserved_ip.tags == tags # TODO: Skipped as tags not available in the API yet + assert reserved_ip.tags == [] def test_reserve_ephemeral_ip(test_linode_client, create_linode): @@ -498,7 +497,6 @@ def test_reserve_ephemeral_ip(test_linode_client, create_linode): assert ip_address.reserved == False ip_address.reserved = True - # ip_address.rdns = "test.example.org" # TODO: Should be enabled ? ip_address.save() ip_address = client.load(IPAddress, linode.ipv4[0]) assert ip_address.linode_id == linode.id @@ -522,7 +520,3 @@ def test_convert_unassigned_reserved_ip_to_ephemeral(test_linode_client, create_ reserved_ips_list = client.networking.reserved_ips(ReservedIPAddress.address==reserved_ip.address) assert len(reserved_ips_list) == 0 - - -# def test_create_unassigned_reserved_ip_with_rdns(): -# pass \ No newline at end of file diff --git a/test/integration/models/nodebalancer/test_nodebalancer.py b/test/integration/models/nodebalancer/test_nodebalancer.py index 1efe73b5f..4cf84a019 100644 --- a/test/integration/models/nodebalancer/test_nodebalancer.py +++ b/test/integration/models/nodebalancer/test_nodebalancer.py @@ -114,7 +114,6 @@ def test_create_nb(test_linode_client, e2e_test_firewall): nb.delete() -@pytest.mark.skip(reason="Currently it fails to assign ReservedIP via ipv4 param") def test_create_nb_with_reserved_ip(test_linode_client, e2e_test_firewall, create_reserved_ip): client = test_linode_client reserved_ip = create_reserved_ip From 760ad623ed8333768e49ea8fc4a24ab7e4e32a12 Mon Sep 17 00:00:00 2001 From: Maciej Wilk Date: Tue, 28 Apr 2026 14:12:37 +0200 Subject: [PATCH 18/27] Create int tests for Reserved IPs: tags #2 --- test/integration/models/tag/test_tag.py | 32 ++++++++++++++++++------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/test/integration/models/tag/test_tag.py b/test/integration/models/tag/test_tag.py index 25e631793..990ea1853 100644 --- a/test/integration/models/tag/test_tag.py +++ b/test/integration/models/tag/test_tag.py @@ -2,7 +2,7 @@ import pytest -from linode_api4.objects import Tag +from linode_api4.objects import ReservedIPAddress, Tag @pytest.fixture @@ -15,6 +15,19 @@ def test_tag(test_linode_client): tag.delete() +@pytest.fixture +def create_tag_with_reserved_ip(test_linode_client, create_reserved_ip): + unique_tag = get_test_label() + "_tag" + reserved_ip = create_reserved_ip + + tag = test_linode_client.tags.create(unique_tag, reserved_ipv4_addresses=[reserved_ip.address]) + reserved_ip = test_linode_client.networking.reserved_ips(ReservedIPAddress.address==reserved_ip.address)[0] + + yield tag, reserved_ip + + tag.delete() + + @pytest.mark.smoke def test_get_tag(test_linode_client, test_tag): tag = test_linode_client.load(Tag, test_tag.id) @@ -22,14 +35,15 @@ def test_get_tag(test_linode_client, test_tag): assert tag.id == test_tag.id -@pytest.mark.skip(reason="This test is currently blocked - API does not support tagging reserved IPs yet") -def test_get_tag_with_reserved_ip(test_linode_client, create_reserved_ip): - unique_tag = get_test_label() + "_tag" - reserved_ip = create_reserved_ip +def test_get_tag_with_reserved_ip(test_linode_client, create_tag_with_reserved_ip): + tag, reserved_ip = create_tag_with_reserved_ip + tag = test_linode_client.load(Tag, tag.id).objects[0] - tag = test_linode_client.tags.create(unique_tag, reserved_ipv4_addresses=[reserved_ip.address]) - tag = test_linode_client.load(Tag, tag.id) - assert tag.type == "reserved_ipv4_address" - # assert tag.data... + assert vars(tag).keys() == vars(reserved_ip).keys() + assert tag.address == reserved_ip.address + assert tag.reserved == reserved_ip.reserved + assert tag.tags == reserved_ip.tags tag.delete() + reserved_ip = test_linode_client.networking.reserved_ips(ReservedIPAddress.address==reserved_ip.address) + assert len(reserved_ip) == 0 From 7a7d7080f110ca19caf14905e3a12b6b8199cf10 Mon Sep 17 00:00:00 2001 From: Maciej Wilk Date: Tue, 28 Apr 2026 14:16:20 +0200 Subject: [PATCH 19/27] Linter fix --- .../models/linode/interfaces/test_interfaces.py | 4 ++-- .../integration/models/networking/test_networking.py | 12 +++++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/test/integration/models/linode/interfaces/test_interfaces.py b/test/integration/models/linode/interfaces/test_interfaces.py index 31dc7300d..f567668e6 100644 --- a/test/integration/models/linode/interfaces/test_interfaces.py +++ b/test/integration/models/linode/interfaces/test_interfaces.py @@ -1,14 +1,15 @@ import copy import ipaddress +from test.integration.helpers import get_test_label import pytest from linode_api4 import ( ApiError, Instance, + InterfaceGeneration, LinodeInterface, LinodeInterfaceDefaultRouteOptions, - InterfaceGeneration, LinodeInterfaceOptions, LinodeInterfacePublicIPv4AddressOptions, LinodeInterfacePublicIPv4Options, @@ -22,7 +23,6 @@ LinodeInterfaceVPCOptions, ReservedIPAddress, ) -from test.integration.helpers import get_test_label def build_interface_public_ipv4(firewall, ip_address): diff --git a/test/integration/models/networking/test_networking.py b/test/integration/models/networking/test_networking.py index 101a70d83..d657832fb 100644 --- a/test/integration/models/networking/test_networking.py +++ b/test/integration/models/networking/test_networking.py @@ -1,5 +1,4 @@ import ipaddress -import requests import time from test.integration.conftest import ( get_api_ca_file, @@ -14,13 +13,20 @@ ) import pytest +import requests from linode_api4 import ( + ApiError, Instance, LinodeClient, - ApiError, ) -from linode_api4.objects import Config, ConfigInterfaceIPv4, Firewall, IPAddress, ReservedIPAddress +from linode_api4.objects import ( + Config, + ConfigInterfaceIPv4, + Firewall, + IPAddress, + ReservedIPAddress, +) from linode_api4.objects.networking import ( FirewallCreateDevicesOptions, NetworkTransferPrice, From 4effa3e1b8c6264346158a5727f9f46f19397131 Mon Sep 17 00:00:00 2001 From: Maciej Wilk Date: Tue, 28 Apr 2026 14:21:38 +0200 Subject: [PATCH 20/27] Linter fix --- test/integration/conftest.py | 15 ++-- .../linode/interfaces/test_interfaces.py | 26 +++--- test/integration/models/linode/test_linode.py | 12 ++- .../models/networking/test_networking.py | 80 ++++++++++++------- .../models/nodebalancer/test_nodebalancer.py | 8 +- test/integration/models/tag/test_tag.py | 16 +++- 6 files changed, 106 insertions(+), 51 deletions(-) diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 9bc972300..8583e5b9c 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -736,14 +736,15 @@ def create_reserved_ip(test_linode_client): client = test_linode_client region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") reserved_ip = client.networking.reserved_ip_create( - region=region, - tags=["test"] + region=region, tags=["test"] ) yield reserved_ip # Delete only if IP exists (some tests delete it earlier) - if client.networking.reserved_ips(ReservedIPAddress.address==reserved_ip.address): + if client.networking.reserved_ips( + ReservedIPAddress.address == reserved_ip.address + ): reserved_ip.delete() @@ -761,10 +762,14 @@ def create_reserved_ip_assigned(test_linode_client, create_linode): region=linode.region, ) - reserved_ip = test_linode_client.load(ReservedIPAddress, reserved_ip.address) + reserved_ip = test_linode_client.load( + ReservedIPAddress, reserved_ip.address + ) yield linode, reserved_ip # Delete only if IP exists (some tests delete it earlier) - if client.networking.reserved_ips(ReservedIPAddress.address==reserved_ip.address): + if client.networking.reserved_ips( + ReservedIPAddress.address == reserved_ip.address + ): reserved_ip.delete() diff --git a/test/integration/models/linode/interfaces/test_interfaces.py b/test/integration/models/linode/interfaces/test_interfaces.py index f567668e6..b207c2ba4 100644 --- a/test/integration/models/linode/interfaces/test_interfaces.py +++ b/test/integration/models/linode/interfaces/test_interfaces.py @@ -383,11 +383,13 @@ def test_linode_interface_firewalls(e2e_test_firewall, linode_interface_public): assert firewall.label == e2e_test_firewall.label -@pytest.mark.parametrize("iface_type", [ - InterfaceGeneration.LEGACY_CONFIG, - InterfaceGeneration.LINODE -]) -def test_linode_interfaces_with_reserved_ips(test_linode_client, e2e_test_firewall, create_reserved_ip, iface_type): +@pytest.mark.parametrize( + "iface_type", + [InterfaceGeneration.LEGACY_CONFIG, InterfaceGeneration.LINODE], +) +def test_linode_interfaces_with_reserved_ips( + test_linode_client, e2e_test_firewall, create_reserved_ip, iface_type +): client = test_linode_client reserved_ip = create_reserved_ip label = get_test_label(length=8) @@ -400,10 +402,12 @@ def test_linode_interfaces_with_reserved_ips(test_linode_client, e2e_test_firewa label=label, firewall=e2e_test_firewall, interface_generation=iface_type, - ipv4=[reserved_ip.address] + ipv4=[reserved_ip.address], ) else: - interface = build_interface_public_ipv4(e2e_test_firewall.id, reserved_ip.address) + interface = build_interface_public_ipv4( + e2e_test_firewall.id, reserved_ip.address + ) linode, _ = client.linode.instance_create( "g6-nanode-1", reserved_ip.region, @@ -422,10 +426,14 @@ def test_linode_interfaces_with_reserved_ips(test_linode_client, e2e_test_firewa assert linode_ips[0].assigned_entity.id == linode.id assert linode_ips[0].assigned_entity.type == "linode" assert linode_ips[0].assigned_entity.label == linode.label - assert linode_ips[0].assigned_entity.url == f"/v4/linode/instances/{linode.id}" + assert ( + linode_ips[0].assigned_entity.url == f"/v4/linode/instances/{linode.id}" + ) linode.delete() - reserved_ips_list = client.networking.reserved_ips(ReservedIPAddress.address==reserved_ip.address) + reserved_ips_list = client.networking.reserved_ips( + ReservedIPAddress.address == reserved_ip.address + ) assert len(reserved_ips_list) == 1 assert reserved_ips_list[0].reserved == True # assert linode_ips[0].tags == ["test"] # TODO Does not work at the moment - during clarifications with API Team diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index ca340254b..fadedeced 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -1150,7 +1150,9 @@ def test_update_linode_maintenance_policy(create_linode, test_linode_client): assert linode.maintenance_policy_id == non_default_policy.slug -def test_update_linode_with_reserved_ip_in_address(test_linode_client, e2e_test_firewall, create_reserved_ip): +def test_update_linode_with_reserved_ip_in_address( + test_linode_client, e2e_test_firewall, create_reserved_ip +): label = get_test_label(length=8) client = test_linode_client reserved_ip = create_reserved_ip @@ -1173,11 +1175,15 @@ def test_update_linode_with_reserved_ip_in_address(test_linode_client, e2e_test_ assert len(linode_ips) == 2 assert reserved_ip.address in [ip.address for ip in linode_ips] - reserved_ip = client.networking.reserved_ips(ReservedIPAddress.address==reserved_ip.address)[0] + reserved_ip = client.networking.reserved_ips( + ReservedIPAddress.address == reserved_ip.address + )[0] assert reserved_ip.linode_id == linode.id assert reserved_ip.assigned_entity.id == linode.id assert reserved_ip.assigned_entity.type == "linode" assert reserved_ip.assigned_entity.label == linode.label - assert reserved_ip.assigned_entity.url == f"/v4/linode/instances/{linode.id}" + assert ( + reserved_ip.assigned_entity.url == f"/v4/linode/instances/{linode.id}" + ) linode.delete() diff --git a/test/integration/models/networking/test_networking.py b/test/integration/models/networking/test_networking.py index d657832fb..d088d7dea 100644 --- a/test/integration/models/networking/test_networking.py +++ b/test/integration/models/networking/test_networking.py @@ -366,7 +366,9 @@ def test_ip_info(test_linode_client, create_linode): def verify_reserved_ip(reserved_ip): - assert isinstance(ipaddress.ip_address(reserved_ip.address), ipaddress.IPv4Address) + assert isinstance( + ipaddress.ip_address(reserved_ip.address), ipaddress.IPv4Address + ) assert reserved_ip.type == "ipv4" assert reserved_ip.public == True assert reserved_ip.reserved == True @@ -375,7 +377,9 @@ def verify_reserved_ip(reserved_ip): def verify_reserved_ip_assigned(reserved_ip, resource): - assert isinstance(ipaddress.ip_address(reserved_ip.address), ipaddress.IPv4Address) + assert isinstance( + ipaddress.ip_address(reserved_ip.address), ipaddress.IPv4Address + ) assert reserved_ip.type == "ipv4" assert reserved_ip.public == True assert reserved_ip.reserved == True @@ -384,20 +388,22 @@ def verify_reserved_ip_assigned(reserved_ip, resource): assert reserved_ip.assigned_entity.id == resource.id assert reserved_ip.assigned_entity.type == "linode" assert reserved_ip.assigned_entity.label == resource.label - assert reserved_ip.assigned_entity.url == f"/v4/linode/instances/{resource.id}" + assert ( + reserved_ip.assigned_entity.url == f"/v4/linode/instances/{resource.id}" + ) @pytest.mark.smoke -@pytest.mark.parametrize("region, tags", [ - (TEST_REGION, ["test"]), - (TEST_REGION, None), -]) +@pytest.mark.parametrize( + "region, tags", + [ + (TEST_REGION, ["test"]), + (TEST_REGION, None), + ], +) def test_create_reserved_ip(request, test_linode_client, region, tags): client = test_linode_client - reserved_ip = client.networking.reserved_ip_create( - region=region, - tags=tags - ) + reserved_ip = client.networking.reserved_ip_create(region=region, tags=tags) request.addfinalizer(reserved_ip.delete) verify_reserved_ip(reserved_ip) @@ -408,10 +414,7 @@ def test_create_reserved_ip_wo_region_fail(test_linode_client): client = test_linode_client with pytest.raises(ApiError) as exc_info: - client.networking.reserved_ip_create( - region=None, - tags=["test"] - ) + client.networking.reserved_ip_create(region=None, tags=["test"]) error_msg = str(exc_info.value.json) assert exc_info.value.status == 400 @@ -426,12 +429,16 @@ def test_update_reserved_ip_tags(test_linode_client, create_reserved_ip): reserved_ip.tags = ["updated"] reserved_ip.save() - reserved_ip = client.networking.reserved_ips(ReservedIPAddress.address==reserved_ip.address)[0] + reserved_ip = client.networking.reserved_ips( + ReservedIPAddress.address == reserved_ip.address + )[0] verify_reserved_ip(reserved_ip) assert reserved_ip.tags == ["updated"] -def test_create_reserved_ip_assigned(test_linode_client, create_reserved_ip_assigned): +def test_create_reserved_ip_assigned( + test_linode_client, create_reserved_ip_assigned +): client = test_linode_client linode, reserved_ip = create_reserved_ip_assigned verify_reserved_ip_assigned(reserved_ip, linode) @@ -451,7 +458,9 @@ def test_create_reserved_ip_assigned(test_linode_client, create_reserved_ip_assi reserved_ips_list = client.networking.reserved_ips() assert reserved_ip.address not in [ip.address for ip in reserved_ips_list] - reserved_ips_list = client.networking.reserved_ips(ReservedIPAddress.address==reserved_ip.address) + reserved_ips_list = client.networking.reserved_ips( + ReservedIPAddress.address == reserved_ip.address + ) assert len(reserved_ips_list) == 0 delattr(linode, "_ips") @@ -464,7 +473,9 @@ def test_create_reserved_ip_assigned(test_linode_client, create_reserved_ip_assi def test_get_reserved_ip_types(test_linode_client, create_reserved_ip): client = test_linode_client endpoint = client.base_url + "/networking/reserved/ips/types" - types = requests.get(endpoint).json()["data"] # Pricing should be publicly available + types = requests.get(endpoint).json()[ + "data" + ] # Pricing should be publicly available assert isinstance(types, list) assert types[0]["id"] == "reserved-ipv4" @@ -476,19 +487,28 @@ def test_get_reserved_ip_types(test_linode_client, create_reserved_ip): @pytest.mark.smoke -@pytest.mark.parametrize("reserved, region", [ - (True, TEST_REGION), - (True, None), -]) -def test_create_reserved_ip_with_allocate(test_linode_client, create_linode, reserved, region): +@pytest.mark.parametrize( + "reserved, region", + [ + (True, TEST_REGION), + (True, None), + ], +) +def test_create_reserved_ip_with_allocate( + test_linode_client, create_linode, reserved, region +): client = test_linode_client linode = create_linode if region: - reserved_ip = client.networking.ip_allocate(reserved=reserved, region=TEST_REGION) + reserved_ip = client.networking.ip_allocate( + reserved=reserved, region=TEST_REGION + ) verify_reserved_ip(reserved_ip) else: - reserved_ip = client.networking.ip_allocate(reserved=reserved, linode=linode.id) + reserved_ip = client.networking.ip_allocate( + reserved=reserved, linode=linode.id + ) verify_reserved_ip_assigned(reserved_ip, linode) assert reserved_ip.tags == [] @@ -515,7 +535,9 @@ def test_reserve_ephemeral_ip(test_linode_client, create_linode): assert ip_address.reserved == False -def test_convert_unassigned_reserved_ip_to_ephemeral(test_linode_client, create_reserved_ip): +def test_convert_unassigned_reserved_ip_to_ephemeral( + test_linode_client, create_reserved_ip +): client = test_linode_client reserved_ip = create_reserved_ip verify_reserved_ip(reserved_ip) @@ -524,5 +546,7 @@ def test_convert_unassigned_reserved_ip_to_ephemeral(test_linode_client, create_ ip_address.reserved = False ip_address.save() - reserved_ips_list = client.networking.reserved_ips(ReservedIPAddress.address==reserved_ip.address) + reserved_ips_list = client.networking.reserved_ips( + ReservedIPAddress.address == reserved_ip.address + ) assert len(reserved_ips_list) == 0 diff --git a/test/integration/models/nodebalancer/test_nodebalancer.py b/test/integration/models/nodebalancer/test_nodebalancer.py index 4cf84a019..522dad3d2 100644 --- a/test/integration/models/nodebalancer/test_nodebalancer.py +++ b/test/integration/models/nodebalancer/test_nodebalancer.py @@ -114,7 +114,9 @@ def test_create_nb(test_linode_client, e2e_test_firewall): nb.delete() -def test_create_nb_with_reserved_ip(test_linode_client, e2e_test_firewall, create_reserved_ip): +def test_create_nb_with_reserved_ip( + test_linode_client, e2e_test_firewall, create_reserved_ip +): client = test_linode_client reserved_ip = create_reserved_ip label = get_test_label(8) @@ -134,7 +136,9 @@ def test_create_nb_with_reserved_ip(test_linode_client, e2e_test_firewall, creat assert nb.ipv4.reserved == True nb.delete() - reserved_ip = client.networking.reserved_ips(ReservedIPAddress.address==reserved_ip.address)[0] + reserved_ip = client.networking.reserved_ips( + ReservedIPAddress.address == reserved_ip.address + )[0] assert reserved_ip.assigned_entity is None diff --git a/test/integration/models/tag/test_tag.py b/test/integration/models/tag/test_tag.py index 990ea1853..51ba8a1b7 100644 --- a/test/integration/models/tag/test_tag.py +++ b/test/integration/models/tag/test_tag.py @@ -20,8 +20,12 @@ def create_tag_with_reserved_ip(test_linode_client, create_reserved_ip): unique_tag = get_test_label() + "_tag" reserved_ip = create_reserved_ip - tag = test_linode_client.tags.create(unique_tag, reserved_ipv4_addresses=[reserved_ip.address]) - reserved_ip = test_linode_client.networking.reserved_ips(ReservedIPAddress.address==reserved_ip.address)[0] + tag = test_linode_client.tags.create( + unique_tag, reserved_ipv4_addresses=[reserved_ip.address] + ) + reserved_ip = test_linode_client.networking.reserved_ips( + ReservedIPAddress.address == reserved_ip.address + )[0] yield tag, reserved_ip @@ -35,7 +39,9 @@ def test_get_tag(test_linode_client, test_tag): assert tag.id == test_tag.id -def test_get_tag_with_reserved_ip(test_linode_client, create_tag_with_reserved_ip): +def test_get_tag_with_reserved_ip( + test_linode_client, create_tag_with_reserved_ip +): tag, reserved_ip = create_tag_with_reserved_ip tag = test_linode_client.load(Tag, tag.id).objects[0] @@ -45,5 +51,7 @@ def test_get_tag_with_reserved_ip(test_linode_client, create_tag_with_reserved_i assert tag.tags == reserved_ip.tags tag.delete() - reserved_ip = test_linode_client.networking.reserved_ips(ReservedIPAddress.address==reserved_ip.address) + reserved_ip = test_linode_client.networking.reserved_ips( + ReservedIPAddress.address == reserved_ip.address + ) assert len(reserved_ip) == 0 From 3e8c558c7dbeda984736bea70c417bd5cb5cd282 Mon Sep 17 00:00:00 2001 From: Maciej Wilk Date: Tue, 28 Apr 2026 14:25:02 +0200 Subject: [PATCH 21/27] Remove pytest.ini --- pytest.ini | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 pytest.ini diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 554758f09..000000000 --- a/pytest.ini +++ /dev/null @@ -1,6 +0,0 @@ -[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 From 2401b024cddfcd2c419615bcb454451cf62ed866 Mon Sep 17 00:00:00 2001 From: Maciej Wilk Date: Tue, 28 Apr 2026 14:44:52 +0200 Subject: [PATCH 22/27] Remove unused assertions after API Team clarifications --- test/integration/models/linode/interfaces/test_interfaces.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/integration/models/linode/interfaces/test_interfaces.py b/test/integration/models/linode/interfaces/test_interfaces.py index b207c2ba4..cc55dfb54 100644 --- a/test/integration/models/linode/interfaces/test_interfaces.py +++ b/test/integration/models/linode/interfaces/test_interfaces.py @@ -421,7 +421,6 @@ def test_linode_interfaces_with_reserved_ips( assert len(linode_ips) == 1 assert linode_ips[0].address == reserved_ip.address assert linode_ips[0].reserved == True - # assert linode_ips[0].tags == ["test"] # TODO Does not work at the moment - during clarifications with API Team assert linode_ips[0].linode_id == linode.id assert linode_ips[0].assigned_entity.id == linode.id assert linode_ips[0].assigned_entity.type == "linode" @@ -436,6 +435,5 @@ def test_linode_interfaces_with_reserved_ips( ) assert len(reserved_ips_list) == 1 assert reserved_ips_list[0].reserved == True - # assert linode_ips[0].tags == ["test"] # TODO Does not work at the moment - during clarifications with API Team assert reserved_ips_list[0].linode_id is None assert reserved_ips_list[0].assigned_entity is None From 19f25bce5505a6072c449f78d2b6640225370907 Mon Sep 17 00:00:00 2001 From: Maciej Wilk Date: Thu, 30 Apr 2026 12:36:29 +0200 Subject: [PATCH 23/27] Use reservedIP's region for NB --- test/integration/models/nodebalancer/test_nodebalancer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/models/nodebalancer/test_nodebalancer.py b/test/integration/models/nodebalancer/test_nodebalancer.py index 522dad3d2..59cbba777 100644 --- a/test/integration/models/nodebalancer/test_nodebalancer.py +++ b/test/integration/models/nodebalancer/test_nodebalancer.py @@ -122,7 +122,7 @@ def test_create_nb_with_reserved_ip( label = get_test_label(8) nb = client.nodebalancer_create( - region=TEST_REGION, + region=reserved_ip.region, label=label, firewall=e2e_test_firewall.id, client_udp_sess_throttle=5, From 9ca8959effbce2b40ab0dd4bdc45bbb6b8d46b40 Mon Sep 17 00:00:00 2001 From: Maciej Wilk Date: Thu, 30 Apr 2026 14:32:36 +0200 Subject: [PATCH 24/27] Refactor --- test/integration/models/tag/test_tag.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/test/integration/models/tag/test_tag.py b/test/integration/models/tag/test_tag.py index 51ba8a1b7..ef9d03818 100644 --- a/test/integration/models/tag/test_tag.py +++ b/test/integration/models/tag/test_tag.py @@ -49,9 +49,3 @@ def test_get_tag_with_reserved_ip( assert tag.address == reserved_ip.address assert tag.reserved == reserved_ip.reserved assert tag.tags == reserved_ip.tags - - tag.delete() - reserved_ip = test_linode_client.networking.reserved_ips( - ReservedIPAddress.address == reserved_ip.address - ) - assert len(reserved_ip) == 0 From 7aed7472664773531a0a621d2740604a722d0ffa Mon Sep 17 00:00:00 2001 From: Maciej Wilk Date: Mon, 4 May 2026 10:36:46 +0200 Subject: [PATCH 25/27] Address Copilot remarks --- test/integration/conftest.py | 6 +++++- test/integration/models/networking/test_networking.py | 9 +++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 8583e5b9c..7586d301b 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -734,7 +734,11 @@ def test_monitor_client(get_monitor_token_for_db_entities): @pytest.fixture def create_reserved_ip(test_linode_client): client = test_linode_client - region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") + region = get_region( + client, + {"Linodes", "Cloud Firewall", "Nodebalancers"}, + site_type="core" + ) reserved_ip = client.networking.reserved_ip_create( region=region, tags=["test"] ) diff --git a/test/integration/models/networking/test_networking.py b/test/integration/models/networking/test_networking.py index d088d7dea..b375539c7 100644 --- a/test/integration/models/networking/test_networking.py +++ b/test/integration/models/networking/test_networking.py @@ -444,9 +444,6 @@ def test_create_reserved_ip_assigned( verify_reserved_ip_assigned(reserved_ip, linode) assert sorted(reserved_ip.tags) == ["assigned", "test"] - # ips_list = client.networking.ips() - # assert reserved_ip.address in [ip.address for ip in ips_list] - reserved_ips_list = client.networking.reserved_ips() assert reserved_ip.address in [ip.address for ip in reserved_ips_list] @@ -470,7 +467,7 @@ def test_create_reserved_ip_assigned( assert not any([ip.tags for ip in linode_ips]) # Tags should be removed -def test_get_reserved_ip_types(test_linode_client, create_reserved_ip): +def test_get_reserved_ip_types(test_linode_client): client = test_linode_client endpoint = client.base_url + "/networking/reserved/ips/types" types = requests.get(endpoint).json()[ @@ -513,6 +510,10 @@ def test_create_reserved_ip_with_allocate( assert reserved_ip.tags == [] + # clean-up + reserved_ip = client.load(ReservedIPAddress, reserved_ip.address) + reserved_ip.delete() + def test_reserve_ephemeral_ip(test_linode_client, create_linode): client = test_linode_client From 6110522592d9551c3470111b469f887254eaf208 Mon Sep 17 00:00:00 2001 From: Maciej Wilk Date: Mon, 4 May 2026 10:46:19 +0200 Subject: [PATCH 26/27] Linter fix --- test/integration/conftest.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 7586d301b..6a20f5120 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -735,9 +735,7 @@ def test_monitor_client(get_monitor_token_for_db_entities): def create_reserved_ip(test_linode_client): client = test_linode_client region = get_region( - client, - {"Linodes", "Cloud Firewall", "Nodebalancers"}, - site_type="core" + client, {"Linodes", "Cloud Firewall", "Nodebalancers"}, site_type="core" ) reserved_ip = client.networking.reserved_ip_create( region=region, tags=["test"] From a9693c5783e5437d79ce5f6b486872070d50fcdb Mon Sep 17 00:00:00 2001 From: Maciej Wilk Date: Mon, 4 May 2026 16:27:41 +0200 Subject: [PATCH 27/27] Revert capabilities' changes in get_regions --- test/integration/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 6a20f5120..748ad00f5 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -735,7 +735,7 @@ def test_monitor_client(get_monitor_token_for_db_entities): def create_reserved_ip(test_linode_client): client = test_linode_client region = get_region( - client, {"Linodes", "Cloud Firewall", "Nodebalancers"}, site_type="core" + client, {"Linodes", "Cloud Firewall"}, site_type="core" ) reserved_ip = client.networking.reserved_ip_create( region=region, tags=["test"]