From 1c524f1f3306ddbd5d30f6e5752fed0b9e618c51 Mon Sep 17 00:00:00 2001 From: ezilber-akamai Date: Tue, 8 Sep 2026 11:19:45 -0400 Subject: [PATCH 1/4] Added support for NAT Gateway --- linode_api4/groups/networking.py | 125 ++++++++- linode_api4/objects/linode_interfaces.py | 29 ++ linode_api4/objects/networking.py | 258 ++++++++++++++++++ linode_api4/objects/vpc.py | 57 +++- .../linode_instances_124_interfaces.json | 16 +- .../linode_instances_124_interfaces_456.json | 16 +- ...node_instances_124_upgrade-interfaces.json | 16 +- test/fixtures/networking_natgateways.json | 30 ++ .../networking_natgateways_42_addresses.json | 16 ++ ...natgateways_42_addresses_203.0.113.42.json | 8 + ..._42_addresses_203.0.113.42_interfaces.json | 23 ++ .../networking_natgateways_42_interfaces.json | 40 +++ .../networking_natgateways_settings.json | 6 + .../networking_natgateways_types.json | 16 ++ test/fixtures/vpcs_123456_ips.json | 14 +- test/fixtures/vpcs_123456_subnets.json | 18 ++ test/fixtures/vpcs_123456_subnets_789.json | 18 ++ test/fixtures/vpcs_ips.json | 14 +- test/unit/groups/networking_test.py | 89 +++++- test/unit/groups/vpc_test.py | 53 +++- test/unit/objects/linode_interface_test.py | 11 + test/unit/objects/networking_test.py | 178 ++++++++++++ test/unit/objects/vpc_test.py | 85 +++++- 23 files changed, 1125 insertions(+), 11 deletions(-) create mode 100644 test/fixtures/networking_natgateways.json create mode 100644 test/fixtures/networking_natgateways_42_addresses.json create mode 100644 test/fixtures/networking_natgateways_42_addresses_203.0.113.42.json create mode 100644 test/fixtures/networking_natgateways_42_addresses_203.0.113.42_interfaces.json create mode 100644 test/fixtures/networking_natgateways_42_interfaces.json create mode 100644 test/fixtures/networking_natgateways_settings.json create mode 100644 test/fixtures/networking_natgateways_types.json diff --git a/linode_api4/groups/networking.py b/linode_api4/groups/networking.py index bdcb5b7cb..7726b58e5 100644 --- a/linode_api4/groups/networking.py +++ b/linode_api4/groups/networking.py @@ -15,9 +15,16 @@ IPv6Range, NetworkTransferPrice, Region, + NATGateway, + NATGatewayAddress, ) from linode_api4.objects.base import _flatten_request_body_recursive -from linode_api4.objects.networking import ReservedIPAddress, ReservedIPType +from linode_api4.objects.networking import ( + NATGatewaySettings, + NATGatewayType, + ReservedIPAddress, + ReservedIPType, +) from linode_api4.paginated_list import PaginatedList from linode_api4.util import drop_null_keys @@ -622,3 +629,119 @@ def reserved_ip_types(self, *filters) -> PaginatedList: return self.client._get_and_filter( ReservedIPType, *filters, endpoint="/networking/reserved/ips/types" ) + + + def natgateways(self, *filters): + """ + Retrieves the NAT Gateways your user has access to. + + API Documentation: TODO + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A list of NAT Gateways the acting user can access. + :rtype: PaginatedList of NATGateway + """ + return self.client._get_and_filter(NATGateway, *filters) + + + def natgateway_create( + self, + region: Union[Region, str], + label: str, + addresses: Optional[ + List[Union[NATGatewayAddress, Dict[str, Any]]] + ] = None, + default_ports_per_interface: Optional[int] = None, + use_autoscaling: Optional[bool] = None, + vpc_subnet_id: Optional[int] = None, + **kwargs, + ) -> NATGateway: + """ + Create a NAT Gateway in the given region. + + NOTE: NAT Gateways may not currently be available to all users. + + API Documentation: TODO + + :param region: The region in which to create the NAT Gateway. + :type region: str or Region + :param label: The label of the NAT Gateway. + :type label: str + :param addresses: The reserved IP addresses to assign to this NAT Gateway. + :type addresses: list of NATGatewayAddress or list of dict + :param default_ports_per_interface: The default ports per interface to use. + Defaults to 4096 on the API side. + :type default_ports_per_interface: int + :param use_autoscaling: Whether the NAT Gateway should autoscale its address pool. + :type use_autoscaling: bool + :param vpc_subnet_id: The ID of the VPC subnet this NAT Gateway should attach to. + :type vpc_subnet_id: int + + :returns: The new NAT Gateway. + :rtype: NATGateway + """ + params = { + "region": region.id if isinstance(region, Region) else region, + "label": label, + "addresses": addresses, + "default_ports_per_interface": default_ports_per_interface, + "use_autoscaling": use_autoscaling, + "vpc_subnet_id": vpc_subnet_id, + } + params.update(kwargs) + + result = self.client.post( + "/networking/natgateways", + data=drop_null_keys(_flatten_request_body_recursive(params)), + ) + + if "id" not in result: + raise UnexpectedResponseError( + "Unexpected response when creating NAT Gateway!", json=result + ) + + return NATGateway(self.client, result["id"], result) + + def natgateway_types(self, *filters) -> PaginatedList: + """ + Returns a list of NAT Gateway types with pricing information. + + NOTE: NAT Gateways may not currently be available to all users. + + API Documentation: TODO + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A list of NAT Gateway types. + :rtype: PaginatedList of NATGatewayType + """ + return self.client._get_and_filter( + NATGatewayType, *filters, endpoint="/networking/natgateways/types" + ) + + def natgateway_settings(self) -> NATGatewaySettings: + """ + Returns the account-wide NAT Gateway settings and limits for the current user. + + NOTE: NAT Gateways may not currently be available to all users. + + API Documentation: TODO + + :returns: The NAT Gateway settings for the current user. + :rtype: NATGatewaySettings + """ + result = self.client.get("/networking/natgateways/settings") + + if "allowed_ports_per_interface" not in result: + raise UnexpectedResponseError( + "Unexpected response when getting NAT Gateway settings!", + json=result, + ) + + return NATGatewaySettings.from_json(result) + diff --git a/linode_api4/objects/linode_interfaces.py b/linode_api4/objects/linode_interfaces.py index 69cebca23..e7576cc0c 100644 --- a/linode_api4/objects/linode_interfaces.py +++ b/linode_api4/objects/linode_interfaces.py @@ -245,6 +245,34 @@ class LinodeInterfaceVPCIPv4Range(JSONObject): range: str = "" +@dataclass +class LinodeInterfaceVPCIPv4NATGatewayPortsetPort(JSONObject): + start: int = 0 + end: int = 0 + + +@dataclass +class LinodeInterfaceVPCIPv4NATGatewayPortset(JSONObject): + address: str = "" + ports: List[LinodeInterfaceVPCIPv4NATGatewayPortsetPort] = field(default_factory=list) + + +@dataclass +class LinodeInterfaceVPCIPv4NATGateway(JSONObject): + """ + A NAT gateway under the IPv4 configuration of a VPC Linode Interface. + """ + + id: int = 0 + label: str = "" + type: str = "" + url: str = "" + addresses: List[str] = field(default_factory=list) + portset_assignments: int = 0 + portset_capacity: int = 0 + portsets: List[LinodeInterfaceVPCIPv4NATGatewayPortset] = field(default_factory=list) # NOTE: This field may not be available to all users. + + @dataclass class LinodeInterfaceVPCIPv4(JSONObject): """ @@ -255,6 +283,7 @@ class LinodeInterfaceVPCIPv4(JSONObject): addresses: List[LinodeInterfaceVPCIPv4Address] = field(default_factory=list) ranges: List[LinodeInterfaceVPCIPv4Range] = field(default_factory=list) + natgateway: Optional[LinodeInterfaceVPCIPv4NATGateway] = None @dataclass diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index 7693953d5..dfb0eaa93 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -7,6 +7,7 @@ from linode_api4.objects.dbase import DerivedBase from linode_api4.objects.region import Region from linode_api4.objects.serializable import JSONObject +from linode_api4.paginated_list import PaginatedList class IPv6Pool(Base): @@ -210,6 +211,31 @@ class VPCIPAddressIPv6(JSONObject): slaac_address: str = "" +@dataclass +class VPCIPAddressNATGatewayPortsetPort(JSONObject): + start: int = 0 + end: int = 0 + + +@dataclass +class VPCIPAddressNATGatewayPortset(JSONObject): + address: str = "" + ports: List[VPCIPAddressNATGatewayPortsetPort] = field(default_factory=list) + + +@dataclass +class VPCIPAddressNATGateway(JSONObject): + """ + A NAT gateway under a VPC IP Address. + """ + + id: int = 0 + addresses: List[str] = field(default_factory=list) + portset_assignments: int = 0 + portset_capacity: int = 0 + portsets: List[VPCIPAddressNATGatewayPortset] = field(default_factory=list) # NOTE: This field may not be available to all users. + + @dataclass class VPCIPAddress(JSONObject): """ @@ -238,6 +264,7 @@ class VPCIPAddress(JSONObject): ipv6_range: Optional[str] = None ipv6_is_public: Optional[bool] = None ipv6_addresses: Optional[List[VPCIPAddressIPv6]] = None + natgateway: Optional[VPCIPAddressNATGateway] = None class VLAN(Base): @@ -490,3 +517,234 @@ class ReservedIPType(Base): "price": Property(json_object=Price), "region_prices": Property(json_object=RegionPrice), } + + +@dataclass +class NATGatewayAddress(JSONObject): + address: str = "" + + +@dataclass +class NATGatewayVPCSubnet(JSONObject): + id: int = 0 + type: str = "" + label: str = "" + url: str = "" + vpc_id: int = 0 + vpc_label: str = "" + + +@dataclass +class NATGatewayAddressAssignment(JSONObject): + address: str = "" + in_use: bool = False + interface_count: int = 0 + interface_url: str = "" + portset_assignments: int = 0 + portset_capacity: int = 0 + + +@dataclass +class NATGatewayInterfaceLinode(JSONObject): + id: int = 0 + label: str = "" + type: str = "" + url: str = "" + + + +@dataclass +class NATGatewayInterfacePortsetPort(JSONObject): + start: int = 0 + end: int = 0 + + +@dataclass +class NATGatewayInterfacePortset(JSONObject): + address: str = "" + ports: List[NATGatewayInterfacePortsetPort] = field(default_factory=list) + + +@dataclass +class NATGatewayInterface(JSONObject): + id: int = 0 + linode: NATGatewayInterfaceLinode = None + addresses: List[str] = field(default_factory=list) + portsets: List[NATGatewayInterfacePortset] = field(default_factory=list) # NOTE: This field may not be available to all users. + + +@dataclass +class NATGatewayType(JSONObject): + id: int = 0 + label: str = "" + price: Price = None + + +@dataclass +class NATGatewaySettings(JSONObject): + allowed_ports_per_interface: List[int] = field(default_factory=list) + maximum_autoscaling_addresses_per_natgateway: int = 0 + maximum_reserved_addresses_per_natgateway: int = 0 + + +class NATGateway(Base): + """ + Represents a single Linode NAT Gateway. + + API documentation: TODO + + NOTE: This feature may not currently be available to all users. + """ + + api_endpoint = "/networking/natgateways/{id}" + + id_attribute = "id" + + properties = { + "id": Property(identifier=True), + "region": Property(), + "addresses": Property(json_object=NATGatewayAddress), + "address_autoscale_max": Property(), + "default_ports_per_interface": Property(), + "label": Property(mutable=True), + "portset_assignments": Property(), + "portset_capacity": Property(), + "vpc_subnet": Property(json_object=NATGatewayVPCSubnet), + "created": Property(is_datetime=True), + "updated": Property(is_datetime=True), + } + + def address_assignments(self, *filters) -> PaginatedList: + """ + Retrieves the reserved IP address assignments for this NAT Gateway. + + API Documentation: TODO + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A paginated list of address assignments for this NAT Gateway. + :rtype: PaginatedList of NATGatewayAddressAssignment + """ + return self._client._get_and_filter( + NATGatewayAddressAssignment, + *filters, + endpoint="{}/addresses".format(NATGateway.api_endpoint).format( + id=self.id + ), + ) + + def address_assignment_view( + self, address: str + ) -> NATGatewayAddressAssignment: + """ + Retrieves a single reserved IP address assignment for this NAT Gateway. + + API Documentation: TODO + + :param address: The reserved IPv4 address to look up. + :type address: str + + :returns: The requested address assignment. + :rtype: NATGatewayAddressAssignment + """ + result = self._client.get( + "{}/addresses/{}".format(NATGateway.api_endpoint, address), + model=self, + ) + return NATGatewayAddressAssignment.from_json(result) + + def address_assignment_create( + self, address: str + ) -> NATGatewayAddressAssignment: + """ + Assigns a reserved IP address to this NAT Gateway. + + API Documentation: TODO + + :param address: The reserved IPv4 address to assign to this NAT Gateway. + :type address: str + + :returns: The new address assignment. + :rtype: NATGatewayAddressAssignment + """ + result = self._client.post( + "{}/addresses".format(NATGateway.api_endpoint), + model=self, + data={"address": address}, + ) + + if "address" not in result: + raise UnexpectedResponseError( + "Unexpected response when assigning address to NAT Gateway!", + json=result, + ) + + return NATGatewayAddressAssignment.from_json(result) + + def address_assignment_delete(self, address: str) -> bool: + """ + Removes a reserved IP address assignment from this NAT Gateway. + + API Documentation: TODO + + :param address: The reserved IPv4 address to remove from this NAT Gateway. + :type address: str + + :returns: True if the delete request succeeded. + :rtype: bool + """ + resp = self._client.delete( + "{}/addresses/{}".format(NATGateway.api_endpoint, address), + model=self, + ) + + if "error" in resp: + return False + return True + + def interfaces(self, *filters) -> PaginatedList: + """ + Retrieves the Linode Interfaces attached to this NAT Gateway. + + API Documentation: TODO + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A paginated list of interfaces attached to this NAT Gateway. + :rtype: PaginatedList of NATGatewayInterface + """ + return self._client._get_and_filter( + NATGatewayInterface, + *filters, + endpoint="{}/interfaces".format(NATGateway.api_endpoint).format( + id=self.id + ), + ) + + def address_interfaces(self, address: str, *filters) -> PaginatedList: + """ + Retrieves the Linode Interfaces using the given reserved IP address on this NAT Gateway. + + API Documentation: TODO + + :param address: The reserved IPv4 address to look up interfaces for. + :type address: str + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A paginated list of interfaces using the given address. + :rtype: PaginatedList of NATGatewayInterface + """ + return self._client._get_and_filter( + NATGatewayInterface, + *filters, + endpoint="{}/addresses/{}/interfaces".format( + NATGateway.api_endpoint, address + ).format(id=self.id), + ) + diff --git a/linode_api4/objects/vpc.py b/linode_api4/objects/vpc.py index ae3f067fd..e2590154f 100644 --- a/linode_api4/objects/vpc.py +++ b/linode_api4/objects/vpc.py @@ -101,6 +101,57 @@ class VPCSubnetDatabase(JSONObject): ipv6_ranges: Optional[List[str]] = None +@dataclass +class VPCSubnetNATGatewayOptions(JSONObject): + """ + VPCSubnetNATGatewayOptions is used to specify a NAT Gateway when creating or updating a VPC subnet. + + To attach or change the NAT Gateway on a subnet, set ``id`` to the ID of the target NAT Gateway:: + + subnet.natgateway = VPCSubnetNATGatewayOptions(id=42) + subnet.save() + + To disconnect the NAT Gateway from a subnet, set ``id`` to ``None`` (or :any:`ExplicitNullValue`):: + + subnet.natgateway = VPCSubnetNATGatewayOptions(id=None) + subnet.save() + + To update other fields without touching the NAT Gateway, simply do not modify the ``natgateway`` + attribute before calling ``save()``. + """ + + # Ensure ``id`` is always included in the serialized payload so that + # ``VPCSubnetNATGatewayOptions(id=None)`` produces ``{"id": null}`` which + # signals the API to disconnect the NAT Gateway from the subnet. + always_include = {"id"} + + id: Optional[int] = None + + +@dataclass +class VPCSubnetNATGatewayPortsetPort(JSONObject): + start: int = 0 + end: int = 0 + + +@dataclass +class VPCSubnetNATGatewayPortset(JSONObject): + address: str = "" + ports: List[VPCSubnetNATGatewayPortsetPort] = None + + +@dataclass +class VPCSubnetNATGateway(JSONObject): + put_class = VPCSubnetNATGatewayOptions + + id: int = 0 + label: str = "" + addresses: List[str] = None + portset_assignments: int = 0 + portset_capacity: int = 0 + portsets: List[VPCSubnetNATGatewayPortset] = None # NOTE: This field may not be available to all users. + + class VPCSubnet(DerivedBase): """ An instance of a VPC subnet. @@ -119,6 +170,7 @@ class VPCSubnet(DerivedBase): "ipv6": Property(json_object=VPCSubnetIPv6Range, unordered=True), "linodes": Property(json_object=VPCSubnetLinode, unordered=True), "databases": Property(json_object=VPCSubnetDatabase, unordered=True), + "natgateway": Property(json_object=VPCSubnetNATGateway, mutable=True), "created": Property(is_datetime=True), "updated": Property(is_datetime=True), } @@ -155,6 +207,7 @@ def subnet_create( ipv6: Optional[ List[Union[VPCSubnetIPv6RangeOptions, Dict[str, Any]]] ] = None, + natgateway: Optional[VPCSubnetNATGatewayOptions] = None, **kwargs, ) -> VPCSubnet: """ @@ -168,8 +221,10 @@ def subnet_create( :type ipv4: str :param ipv6: The IPv6 range of this subnet in CIDR format. :type ipv6: List[Union[VPCSubnetIPv6RangeOptions, Dict[str, Any]]] + :param natgateway: The NAT gateway options for this subnet. NOTE: May not be available for all users. + :type natgateway: VPCSubnetNATGatewayOptions """ - params = {"label": label, "ipv4": ipv4, "ipv6": ipv6} + params = {"label": label, "ipv4": ipv4, "ipv6": ipv6, "natgateway": natgateway} params.update(kwargs) diff --git a/test/fixtures/linode_instances_124_interfaces.json b/test/fixtures/linode_instances_124_interfaces.json index dbb6f79fb..93adae0bb 100644 --- a/test/fixtures/linode_instances_124_interfaces.json +++ b/test/fixtures/linode_instances_124_interfaces.json @@ -79,7 +79,21 @@ { "range": "192.168.22.32/28" } - ] + ], + "natgateway": { + "id": 42, + "label": "the-natgateway", + "type": "natgateway", + "url": "/v4/networking/natgateways/1", + "addresses": [ "203.0.113.42" ], + "portset_capacity": 30, + "portsets": [ + { + "address": "203.0.113.42", + "ports": [{"start": 2048, "end": 3071}] + } + ] + } }, "ipv6": { "is_public": true, diff --git a/test/fixtures/linode_instances_124_interfaces_456.json b/test/fixtures/linode_instances_124_interfaces_456.json index 8ec4abd3d..c3b313e57 100644 --- a/test/fixtures/linode_instances_124_interfaces_456.json +++ b/test/fixtures/linode_instances_124_interfaces_456.json @@ -20,7 +20,21 @@ "ranges": [ { "range": "192.168.22.16/28"}, { "range": "192.168.22.32/28"} - ] + ], + "natgateway": { + "id": 42, + "label": "the-natgateway", + "type": "natgateway", + "url": "/v4/networking/natgateways/1", + "addresses": [ "203.0.113.42" ], + "portset_capacity": 30, + "portsets": [ + { + "address": "203.0.113.42", + "ports": [{"start": 2048, "end": 3071}] + } + ] + } }, "ipv6": { "is_public": true, diff --git a/test/fixtures/linode_instances_124_upgrade-interfaces.json b/test/fixtures/linode_instances_124_upgrade-interfaces.json index fa1015029..08586c2af 100644 --- a/test/fixtures/linode_instances_124_upgrade-interfaces.json +++ b/test/fixtures/linode_instances_124_upgrade-interfaces.json @@ -81,7 +81,21 @@ { "range": "192.168.22.32/28" } - ] + ], + "natgateway": { + "id": 42, + "label": "the-natgateway", + "type": "natgateway", + "url": "/v4/networking/natgateways/1", + "addresses": [ "203.0.113.42" ], + "portset_capacity": 30, + "portsets": [ + { + "address": "203.0.113.42", + "ports": [{"start": 2048, "end": 3071}] + } + ] + } }, "ipv6": { "is_public": true, diff --git a/test/fixtures/networking_natgateways.json b/test/fixtures/networking_natgateways.json new file mode 100644 index 000000000..b4b74891f --- /dev/null +++ b/test/fixtures/networking_natgateways.json @@ -0,0 +1,30 @@ +{ + "data": [ + { + "id": 42, + "label": "the-natgateway", + "region": "us-east", + "addresses": [ + {"address": "203.0.113.42"} + ], + "address_autoscale_max": 4, + "default_ports_per_interface": 4096, + "portset_assignments": 15, + "portset_capacity": 30, + "vpc_subnet": { + "id": 789, + "type": "subnet", + "label": "my-subnet", + "url": "/v4/vpcs/123456/subnets/789", + "vpc_id": 123456, + "vpc_label": "my-vpc" + }, + "created": "2018-01-01T00:01:01", + "updated": "2018-01-01T00:01:01" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} + diff --git a/test/fixtures/networking_natgateways_42_addresses.json b/test/fixtures/networking_natgateways_42_addresses.json new file mode 100644 index 000000000..25a0f9451 --- /dev/null +++ b/test/fixtures/networking_natgateways_42_addresses.json @@ -0,0 +1,16 @@ +{ + "data": [ + { + "address": "203.0.113.42", + "in_use": true, + "interface_count": 2, + "interface_url": "/v4/linode/instances/123/interfaces/456", + "portset_assignments": 15, + "portset_capacity": 30 + } + ], + "page": 1, + "pages": 1, + "results": 1 +} + diff --git a/test/fixtures/networking_natgateways_42_addresses_203.0.113.42.json b/test/fixtures/networking_natgateways_42_addresses_203.0.113.42.json new file mode 100644 index 000000000..b573a8eae --- /dev/null +++ b/test/fixtures/networking_natgateways_42_addresses_203.0.113.42.json @@ -0,0 +1,8 @@ +{ + "address": "203.0.113.42", + "in_use": true, + "interface_count": 2, + "interface_url": "/v4/linode/instances/123/interfaces/456", + "portset_assignments": 15, + "portset_capacity": 30 +} diff --git a/test/fixtures/networking_natgateways_42_addresses_203.0.113.42_interfaces.json b/test/fixtures/networking_natgateways_42_addresses_203.0.113.42_interfaces.json new file mode 100644 index 000000000..15b94a8b1 --- /dev/null +++ b/test/fixtures/networking_natgateways_42_addresses_203.0.113.42_interfaces.json @@ -0,0 +1,23 @@ +{ + "data": [ + { + "id": 142, + "linode": { + "id": 1001, + "label": "linode1001", + "type": "linode", + "url": "/v4/linode/instances/1001" + }, + "addresses": ["203.0.113.42"], + "portsets": [ + { + "address": "203.0.113.42", + "ports": [{"start": 2048, "end": 3071}] + } + ] + } + ], + "page": 1, + "pages": 1, + "results": 1 +} diff --git a/test/fixtures/networking_natgateways_42_interfaces.json b/test/fixtures/networking_natgateways_42_interfaces.json new file mode 100644 index 000000000..ddda7a575 --- /dev/null +++ b/test/fixtures/networking_natgateways_42_interfaces.json @@ -0,0 +1,40 @@ +{ + "data": [ + { + "id": 142, + "linode": { + "id": 1001, + "label": "linode1001", + "type": "linode", + "url": "/v4/linode/instances/1001" + }, + "addresses": ["172.24.213.144"], + "portsets": [ + { + "address": "172.24.213.144", + "ports": [{"start": 2048, "end": 3071}] + } + ] + }, + { + "id": 143, + "linode": { + "id": 1002, + "label": "linode1002", + "type": "linode", + "url": "/v4/linode/instances/1002" + }, + "addresses": ["172.24.213.144"], + "portsets": [ + { + "address": "172.24.213.144", + "ports": [{"start": 3072, "end": 4095}] + } + ] + } + ], + "page": 1, + "pages": 1, + "results": 2 +} + diff --git a/test/fixtures/networking_natgateways_settings.json b/test/fixtures/networking_natgateways_settings.json new file mode 100644 index 000000000..6faa25ee0 --- /dev/null +++ b/test/fixtures/networking_natgateways_settings.json @@ -0,0 +1,6 @@ +{ + "allowed_ports_per_interface": [4096, 8192, 16384], + "maximum_autoscaling_addresses_per_natgateway": 100, + "maximum_reserved_addresses_per_natgateway": 100 +} + diff --git a/test/fixtures/networking_natgateways_types.json b/test/fixtures/networking_natgateways_types.json new file mode 100644 index 000000000..ae5b4fda9 --- /dev/null +++ b/test/fixtures/networking_natgateways_types.json @@ -0,0 +1,16 @@ +{ + "data": [ + { + "id": "g1-natgateway", + "label": "NAT Gateway", + "price": { + "hourly": 0.035, + "monthly": 25 + } + } + ], + "page": 1, + "pages": 1, + "results": 1 +} + diff --git a/test/fixtures/vpcs_123456_ips.json b/test/fixtures/vpcs_123456_ips.json index 10cb94f3c..08b47f5fa 100644 --- a/test/fixtures/vpcs_123456_ips.json +++ b/test/fixtures/vpcs_123456_ips.json @@ -13,7 +13,19 @@ "nat_1_1": null, "gateway": "10.0.0.1", "prefix": 8, - "subnet_mask": "255.0.0.0" + "subnet_mask": "255.0.0.0", + "natgateway": { + "id": 42, + "addresses": ["203.0.113.42"], + "portset_assignments": 15, + "portset_capacity": 30, + "portsets": [ + { + "address": "203.0.113.42", + "ports": [{"start": 2048, "end": 3071}] + } + ] + } }, { "address": "10.0.0.3", diff --git a/test/fixtures/vpcs_123456_subnets.json b/test/fixtures/vpcs_123456_subnets.json index 8239daec2..41b80a169 100644 --- a/test/fixtures/vpcs_123456_subnets.json +++ b/test/fixtures/vpcs_123456_subnets.json @@ -35,6 +35,24 @@ ] } ], + "natgateway": { + "id": 42, + "label": "my-nat-gateway", + "addresses": [ "203.0.113.42" ], + "portset_assignments": 15, + "portset_capacity": 30, + "portsets": [ + { + "address": "203.0.113.42", + "ports": [ + { + "start": 2048, + "end": 3071 + } + ] + } + ] + }, "created": "2018-01-01T00:01:01", "updated": "2018-01-01T00:01:01" } diff --git a/test/fixtures/vpcs_123456_subnets_789.json b/test/fixtures/vpcs_123456_subnets_789.json index 199156130..f839be0e3 100644 --- a/test/fixtures/vpcs_123456_subnets_789.json +++ b/test/fixtures/vpcs_123456_subnets_789.json @@ -33,6 +33,24 @@ ] } ], + "natgateway": { + "id": 42, + "label": "my-nat-gateway", + "addresses": [ "203.0.113.42" ], + "portset_assignments": 15, + "portset_capacity": 30, + "portsets": [ + { + "address": "203.0.113.42", + "ports": [ + { + "start": 2048, + "end": 3071 + } + ] + } + ] + }, "created": "2018-01-01T00:01:01", "updated": "2018-01-01T00:01:01" } \ No newline at end of file diff --git a/test/fixtures/vpcs_ips.json b/test/fixtures/vpcs_ips.json index 7849f5d76..ea76aa65a 100644 --- a/test/fixtures/vpcs_ips.json +++ b/test/fixtures/vpcs_ips.json @@ -13,7 +13,19 @@ "nat_1_1": "172.233.179.133", "gateway": "10.0.0.1", "prefix": 24, - "subnet_mask": "255.255.255.0" + "subnet_mask": "255.255.255.0", + "natgateway": { + "id": 42, + "addresses": ["203.0.113.42"], + "portset_assignments": 15, + "portset_capacity": 30, + "portsets": [ + { + "address": "203.0.113.42", + "ports": [{"start": 2048, "end": 3071}] + } + ] + } }, { "ipv6_range": "fd71:1140:a9d0::/52", diff --git a/test/unit/groups/networking_test.py b/test/unit/groups/networking_test.py index 6503b426f..230c5e753 100644 --- a/test/unit/groups/networking_test.py +++ b/test/unit/groups/networking_test.py @@ -1,7 +1,13 @@ from test.unit.base import ClientBaseCase, MethodMock from test.unit.objects.firewall_test import FirewallTemplatesTest +from test.unit.objects.networking_test import NATGatewayTest -from linode_api4.objects.networking import ReservedIPAddress +from linode_api4.objects import NATGateway, NATGatewayAddress +from linode_api4.objects.networking import ( + NATGatewaySettings, + NATGatewayType, + ReservedIPAddress, +) class NetworkingGroupTest(ClientBaseCase): @@ -238,3 +244,84 @@ def test_ip_allocate_rejects_region_when_not_reserved(self): "region is only valid when reserved is True." ) assert m.called is False + + def test_list_natgateways(self): + """ + Tests that NAT Gateways can be listed via GET /networking/natgateways. + """ + natgateways = self.client.networking.natgateways() + + assert len(natgateways) == 1 + NATGatewayTest.assert_natgateway_42(natgateways[0]) + + def test_natgateway_create(self): + """ + Tests that natgateway_create sends the correct POST body and returns a NATGateway. + """ + with self.mock_post("/networking/natgateways/42") as m: + result = self.client.networking.natgateway_create( + region="us-east", + label="the-natgateway", + addresses=[NATGatewayAddress(address="203.0.113.42")], + default_ports_per_interface=4096, + use_autoscaling=True, + vpc_subnet_id=789, + ) + + assert m.call_url == "/networking/natgateways" + assert m.call_data == { + "region": "us-east", + "label": "the-natgateway", + "addresses": [{"address": "203.0.113.42"}], + "default_ports_per_interface": 4096, + "use_autoscaling": True, + "vpc_subnet_id": 789, + } + + assert isinstance(result, NATGateway) + NATGatewayTest.assert_natgateway_42(result) + + def test_natgateway_create_minimal(self): + """ + Tests that natgateway_create with only required fields omits optional fields + and accepts raw dict form for addresses. + """ + with self.mock_post("/networking/natgateways/42") as m: + self.client.networking.natgateway_create( + region="us-east", + label="the-natgateway", + addresses=[{"address": "203.0.113.42"}], + ) + + assert m.call_data == { + "region": "us-east", + "label": "the-natgateway", + "addresses": [{"address": "203.0.113.42"}], + } + + def test_natgateway_types(self): + """ + Tests GET /networking/natgateways/types. + """ + types = self.client.networking.natgateway_types() + + assert len(types) == 1 + + t = types[0] + assert isinstance(t, NATGatewayType) + assert t.id == "g1-natgateway" + assert t.label == "NAT Gateway" + assert t.price.hourly == 0.035 + assert t.price.monthly == 25 + + def test_natgateway_settings(self): + """ + Tests GET /networking/natgateways/settings. + """ + settings = self.client.networking.natgateway_settings() + + assert isinstance(settings, NATGatewaySettings) + assert settings.allowed_ports_per_interface == [4096, 8192, 16384] + assert settings.maximum_autoscaling_addresses_per_natgateway == 100 + assert settings.maximum_reserved_addresses_per_natgateway == 100 + diff --git a/test/unit/groups/vpc_test.py b/test/unit/groups/vpc_test.py index fbeda5f3a..147e711f0 100644 --- a/test/unit/groups/vpc_test.py +++ b/test/unit/groups/vpc_test.py @@ -1,7 +1,13 @@ import datetime from test.unit.base import ClientBaseCase -from linode_api4 import DATE_FORMAT, VPC, VPCIPv4DefaultRange, VPCSubnet +from linode_api4 import ( + DATE_FORMAT, + VPC, + VPCIPv4DefaultRange, + VPCSubnet, + VPCSubnetNATGatewayOptions, +) class VPCTest(ClientBaseCase): @@ -58,6 +64,44 @@ def test_create_vpc_with_subnet(self): self.assertEqual(vpc._populated, True) self.validate_vpc_123456(vpc) + def test_create_vpc_with_subnet_natgateway(self): + """ + Tests that a subnet's natgateway is correctly serialized. + """ + + with self.mock_post("/vpcs/123456") as m: + vpc = self.client.vpcs.create( + "test-vpc", + "us-southeast", + subnets=[ + { + "label": "test-subnet", + "ipv4": "10.0.0.0/24", + "natgateway": VPCSubnetNATGatewayOptions(id=42), + }, + ], + ) + + self.assertEqual(m.call_url, "/vpcs") + + self.assertEqual( + m.call_data, + { + "label": "test-vpc", + "region": "us-southeast", + "subnets": [ + { + "label": "test-subnet", + "ipv4": "10.0.0.0/24", + "natgateway": {"id": 42}, + }, + ], + }, + ) + + self.assertEqual(vpc._populated, True) + self.validate_vpc_123456(vpc) + def test_list_ips(self): """ Validates that all VPC IPs can be listed. @@ -83,6 +127,13 @@ def test_list_ips(self): assert ip.gateway == "10.0.0.1" assert ip.prefix == 24 assert ip.subnet_mask == "255.255.255.0" + assert ip.natgateway.id == 42 + assert ip.natgateway.addresses == ["203.0.113.42"] + assert ip.natgateway.portset_assignments == 15 + assert ip.natgateway.portset_capacity == 30 + assert ip.natgateway.portsets[0].address == "203.0.113.42" + assert ip.natgateway.portsets[0].ports[0].start == 2048 + assert ip.natgateway.portsets[0].ports[0].end == 3071 def validate_vpc_123456(self, vpc: VPC): expected_dt = datetime.datetime.strptime( diff --git a/test/unit/objects/linode_interface_test.py b/test/unit/objects/linode_interface_test.py index c021334e1..1ad136d85 100644 --- a/test/unit/objects/linode_interface_test.py +++ b/test/unit/objects/linode_interface_test.py @@ -150,6 +150,17 @@ def assert_linode_124_interface_456(iface: LinodeInterface): assert iface.vpc.ipv4.ranges[0].range == "192.168.22.16/28" assert iface.vpc.ipv4.ranges[1].range == "192.168.22.32/28" + # natgateway assertions + assert iface.vpc.ipv4.natgateway.id == 42 + assert iface.vpc.ipv4.natgateway.label == "the-natgateway" + assert iface.vpc.ipv4.natgateway.type == "natgateway" + assert iface.vpc.ipv4.natgateway.url == "/v4/networking/natgateways/1" + assert iface.vpc.ipv4.natgateway.addresses == ["203.0.113.42"] + assert iface.vpc.ipv4.natgateway.portset_capacity == 30 + assert iface.vpc.ipv4.natgateway.portsets[0].address == "203.0.113.42" + assert iface.vpc.ipv4.natgateway.portsets[0].ports[0].start == 2048 + assert iface.vpc.ipv4.natgateway.portsets[0].ports[0].end == 3071 + assert iface.vpc.ipv6.is_public assert iface.vpc.ipv6.slaac[0].range == "1234::/64" diff --git a/test/unit/objects/networking_test.py b/test/unit/objects/networking_test.py index 245767214..1b158d662 100644 --- a/test/unit/objects/networking_test.py +++ b/test/unit/objects/networking_test.py @@ -3,6 +3,9 @@ from linode_api4 import VLAN, ExplicitNullValue, Instance, Region from linode_api4.objects import Firewall, IPAddress, IPv6Range from linode_api4.objects.networking import ( + NATGateway, + NATGatewayAddressAssignment, + NATGatewayInterface, ReservedIPAddress, ReservedIPAssignedEntity, ) @@ -455,3 +458,178 @@ def test_instance_ip_allocate_without_address(self): assert m.call_url == "/linode/instances/123/ips" assert "address" not in m.call_data + + +class NATGatewayTest(ClientBaseCase): + """ + Tests methods of the NATGateway class. + """ + + @staticmethod + def assert_natgateway_42(natgateway: NATGateway): + assert natgateway.id == 42 + assert natgateway.label == "the-natgateway" + assert natgateway.region == "us-east" + assert natgateway.address_autoscale_max == 4 + assert natgateway.default_ports_per_interface == 4096 + assert natgateway.portset_assignments == 15 + assert natgateway.portset_capacity == 30 + assert natgateway.addresses[0].address == "203.0.113.42" + assert natgateway.vpc_subnet.id == 789 + assert natgateway.vpc_subnet.type == "subnet" + assert natgateway.vpc_subnet.label == "my-subnet" + assert natgateway.vpc_subnet.url == "/v4/vpcs/123456/subnets/789" + assert natgateway.vpc_subnet.vpc_id == 123456 + assert natgateway.vpc_subnet.vpc_label == "my-vpc" + + def test_get_natgateway(self): + """ + Tests that a NAT Gateway is loaded correctly via GET /networking/natgateways/{id}. + """ + natgateway = NATGateway(self.client, 42) + self.assertEqual(natgateway._populated, False) + + self.assert_natgateway_42(natgateway) + self.assertEqual(natgateway._populated, True) + + def test_update_natgateway(self): + """ + Tests that only the mutable ``label`` field is sent via PUT /networking/natgateways/{id}. + """ + with self.mock_put("/networking/natgateways/42") as m: + natgateway = NATGateway(self.client, 42) + # Force a lazy load so the object is fully populated. + _ = natgateway.label + + natgateway.label = "renamed-natgateway" + natgateway.save() + + self.assertEqual(m.call_url, "/networking/natgateways/42") + self.assertEqual(m.call_data, {"label": "renamed-natgateway"}) + + def test_delete_natgateway(self): + """ + Tests that DELETE /networking/natgateways/{id} is issued. + """ + with self.mock_delete() as m: + natgateway = NATGateway(self.client, 42) + natgateway.delete() + + self.assertEqual(m.call_url, "/networking/natgateways/42") + + @staticmethod + def assert_address_assignment(assignment: NATGatewayAddressAssignment): + assert assignment.address == "203.0.113.42" + assert assignment.in_use is True + assert assignment.interface_count == 2 + assert ( + assignment.interface_url + == "/v4/linode/instances/123/interfaces/456" + ) + assert assignment.portset_assignments == 15 + assert assignment.portset_capacity == 30 + + def test_list_address_assignments(self): + """ + Tests GET /networking/natgateways/{id}/addresses. + """ + natgateway = NATGateway(self.client, 42) + assignments = natgateway.address_assignments() + + assert len(assignments) == 1 + NATGatewayTest.assert_address_assignment(assignments[0]) + + def test_view_address_assignment(self): + """ + Tests GET /networking/natgateways/{id}/addresses/{address}. + """ + natgateway = NATGateway(self.client, 42) + assignment = natgateway.address_assignment_view("203.0.113.42") + + assert isinstance(assignment, NATGatewayAddressAssignment) + NATGatewayTest.assert_address_assignment(assignment) + + def test_create_address_assignment(self): + """ + Tests POST /networking/natgateways/{id}/addresses. + """ + with self.mock_post( + "/networking/natgateways/42/addresses/203.0.113.42" + ) as m: + natgateway = NATGateway(self.client, 42) + assignment = natgateway.address_assignment_create("203.0.113.42") + + self.assertEqual( + m.call_url, "/networking/natgateways/42/addresses" + ) + self.assertEqual(m.call_data, {"address": "203.0.113.42"}) + + assert isinstance(assignment, NATGatewayAddressAssignment) + NATGatewayTest.assert_address_assignment(assignment) + + def test_delete_address_assignment(self): + """ + Tests DELETE /networking/natgateways/{id}/addresses/{address}. + """ + with self.mock_delete() as m: + natgateway = NATGateway(self.client, 42) + result = natgateway.address_assignment_delete("203.0.113.42") + + self.assertEqual( + m.call_url, + "/networking/natgateways/42/addresses/203.0.113.42", + ) + assert result is True + + @staticmethod + def assert_interfaces(interfaces): + assert len(interfaces) == 2 + + first = interfaces[0] + assert isinstance(first, NATGatewayInterface) + assert first.id == 142 + assert first.linode.id == 1001 + assert first.linode.label == "linode1001" + assert first.linode.type == "linode" + assert first.linode.url == "/v4/linode/instances/1001" + assert first.addresses == ["172.24.213.144"] + assert first.portsets[0].address == "172.24.213.144" + assert first.portsets[0].ports[0].start == 2048 + assert first.portsets[0].ports[0].end == 3071 + + second = interfaces[1] + assert second.id == 143 + assert second.linode.id == 1002 + assert second.linode.label == "linode1002" + + def test_list_interfaces(self): + """ + Tests GET /networking/natgateways/{id}/interfaces. + """ + natgateway = NATGateway(self.client, 42) + interfaces = natgateway.interfaces() + NATGatewayTest.assert_interfaces(interfaces) + + def test_list_address_interfaces(self): + """ + Tests GET /networking/natgateways/{id}/addresses/{address}/interfaces. + + The per-address endpoint only returns interfaces actually using that + address, so the response is a subset of the gateway-wide list. + """ + natgateway = NATGateway(self.client, 42) + interfaces = natgateway.address_interfaces("203.0.113.42") + + assert len(interfaces) == 1 + iface = interfaces[0] + assert isinstance(iface, NATGatewayInterface) + assert iface.id == 142 + assert iface.linode.id == 1001 + assert iface.linode.label == "linode1001" + assert iface.linode.type == "linode" + assert iface.linode.url == "/v4/linode/instances/1001" + assert iface.addresses == ["203.0.113.42"] + assert iface.portsets[0].address == "203.0.113.42" + assert iface.portsets[0].ports[0].start == 2048 + assert iface.portsets[0].ports[0].end == 3071 + diff --git a/test/unit/objects/vpc_test.py b/test/unit/objects/vpc_test.py index b3a79b5b2..a4c4a8fd1 100644 --- a/test/unit/objects/vpc_test.py +++ b/test/unit/objects/vpc_test.py @@ -1,7 +1,7 @@ import datetime from test.unit.base import ClientBaseCase -from linode_api4 import DATE_FORMAT, VPC, VPCSubnet +from linode_api4 import DATE_FORMAT, VPC, VPCSubnet, VPCSubnetNATGatewayOptions class VPCTest(ClientBaseCase): @@ -62,7 +62,11 @@ def test_create_subnet(self): with self.mock_post("/vpcs/123456/subnets/789") as m: vpc = VPC(self.client, 123456) - subnet = vpc.subnet_create("test-subnet", "10.0.0.0/24") + subnet = vpc.subnet_create( + "test-subnet", + "10.0.0.0/24", + natgateway=VPCSubnetNATGatewayOptions(id=42), + ) self.assertEqual(m.call_url, "/vpcs/123456/subnets") @@ -71,11 +75,71 @@ def test_create_subnet(self): { "label": "test-subnet", "ipv4": "10.0.0.0/24", + "natgateway": {"id": 42}, }, ) self.validate_vpc_subnet_789(subnet) + def test_update_subnet_attach_natgateway(self): + """ + Tests that saving a subnet with a new NAT Gateway sends + {"label": ..., "natgateway": {"id": 42}}. + """ + + with self.mock_put("/vpcs/123456/subnets/789") as m: + subnet = VPCSubnet(self.client, 789, 123456) + # Force a lazy load so the object is fully populated. + _ = subnet.label + + subnet.label = "cool-vpc-subnet" + subnet.natgateway = VPCSubnetNATGatewayOptions(id=42) + subnet.save() + + self.assertEqual(m.call_url, "/vpcs/123456/subnets/789") + + self.assertEqual(m.call_data.get("label"), "cool-vpc-subnet") + self.assertEqual(m.call_data.get("natgateway"), {"id": 42}) + + def test_update_subnet_label_only(self): + """ + Tests that saving a subnet without touching natgateway either + omits the field or serializes the current attached NAT Gateway + as a no-op {"id": }. + """ + + with self.mock_put("/vpcs/123456/subnets/789") as m: + subnet = VPCSubnet(self.client, 789, 123456) + # Force a lazy load so the object is fully populated. + _ = subnet.label + + subnet.label = "cool-vpc-subnet" + subnet.save() + + self.assertEqual(m.call_url, "/vpcs/123456/subnets/789") + self.assertEqual(m.call_data.get("label"), "cool-vpc-subnet") + + # The fixture has a NAT Gateway already attached,so the + # unchanged put_class serialization is a no-op. + self.assertEqual(m.call_data.get("natgateway"), {"id": 42}) + + def test_update_subnet_disconnect_natgateway(self): + """ + Tests that saving a subnet after setting natgateway to + VPCSubnetNATGatewayOptions(id=None) sends {"natgateway": {"id": null}}. + """ + + with self.mock_put("/vpcs/123456/subnets/789") as m: + subnet = VPCSubnet(self.client, 789, 123456) + # Force a lazy load so the object is fully populated. + _ = subnet.label + + subnet.natgateway = VPCSubnetNATGatewayOptions(id=None) + subnet.save() + + self.assertEqual(m.call_url, "/vpcs/123456/subnets/789") + self.assertEqual(m.call_data.get("natgateway"), {"id": None}) + def test_list_ips(self): """ Validates that all VPC IPs can be listed. @@ -141,6 +205,15 @@ def validate_vpc_subnet_789(self, subnet: VPCSubnet): self.assertEqual(subnet.ipv6[0].range, "fd71:1140:a9d0::/52") + assert subnet.natgateway.id == 42 + assert subnet.natgateway.label == "my-nat-gateway" + assert subnet.natgateway.addresses == ["203.0.113.42"] + assert subnet.natgateway.portset_assignments == 15 + assert subnet.natgateway.portset_capacity == 30 + assert subnet.natgateway.portsets[0].address == "203.0.113.42" + assert subnet.natgateway.portsets[0].ports[0].start == 2048 + assert subnet.natgateway.portsets[0].ports[0].end == 3071 + def test_list_vpc_ips(self): """ Test that the ips under a specific VPC can be listed. @@ -166,6 +239,14 @@ def test_list_vpc_ips(self): self.assertEqual(vpc_ip.prefix, 8) self.assertEqual(vpc_ip.subnet_mask, "255.0.0.0") + self.assertEqual(vpc_ip.natgateway.id, 42) + self.assertEqual(vpc_ip.natgateway.addresses, ["203.0.113.42"]) + self.assertEqual(vpc_ip.natgateway.portset_assignments, 15) + self.assertEqual(vpc_ip.natgateway.portset_capacity, 30) + self.assertEqual(vpc_ip.natgateway.portsets[0].address, "203.0.113.42") + self.assertEqual(vpc_ip.natgateway.portsets[0].ports[0].start, 2048) + self.assertEqual(vpc_ip.natgateway.portsets[0].ports[0].end, 3071) + vpc_ip_2 = vpc_ips[2] self.assertEqual(vpc_ip_2.ipv6_range, "fd71:1140:a9d0::/52") From 0a7a5cfc0e16993a674647cd5fbdf2f071a6651a Mon Sep 17 00:00:00 2001 From: ezilber-akamai Date: Tue, 8 Sep 2026 11:28:46 -0400 Subject: [PATCH 2/4] Fix lint --- linode_api4/groups/networking.py | 7 ++----- linode_api4/objects/linode_interfaces.py | 8 ++++++-- linode_api4/objects/networking.py | 10 ++++++---- linode_api4/objects/vpc.py | 11 +++++++++-- test/unit/groups/networking_test.py | 1 - test/unit/objects/networking_test.py | 5 +---- 6 files changed, 24 insertions(+), 18 deletions(-) diff --git a/linode_api4/groups/networking.py b/linode_api4/groups/networking.py index 7726b58e5..a0cf007e4 100644 --- a/linode_api4/groups/networking.py +++ b/linode_api4/groups/networking.py @@ -13,10 +13,10 @@ IPAddress, IPv6Pool, IPv6Range, - NetworkTransferPrice, - Region, NATGateway, NATGatewayAddress, + NetworkTransferPrice, + Region, ) from linode_api4.objects.base import _flatten_request_body_recursive from linode_api4.objects.networking import ( @@ -630,7 +630,6 @@ def reserved_ip_types(self, *filters) -> PaginatedList: ReservedIPType, *filters, endpoint="/networking/reserved/ips/types" ) - def natgateways(self, *filters): """ Retrieves the NAT Gateways your user has access to. @@ -646,7 +645,6 @@ def natgateways(self, *filters): """ return self.client._get_and_filter(NATGateway, *filters) - def natgateway_create( self, region: Union[Region, str], @@ -744,4 +742,3 @@ def natgateway_settings(self) -> NATGatewaySettings: ) return NATGatewaySettings.from_json(result) - diff --git a/linode_api4/objects/linode_interfaces.py b/linode_api4/objects/linode_interfaces.py index e7576cc0c..0b00168b9 100644 --- a/linode_api4/objects/linode_interfaces.py +++ b/linode_api4/objects/linode_interfaces.py @@ -254,7 +254,9 @@ class LinodeInterfaceVPCIPv4NATGatewayPortsetPort(JSONObject): @dataclass class LinodeInterfaceVPCIPv4NATGatewayPortset(JSONObject): address: str = "" - ports: List[LinodeInterfaceVPCIPv4NATGatewayPortsetPort] = field(default_factory=list) + ports: List[LinodeInterfaceVPCIPv4NATGatewayPortsetPort] = field( + default_factory=list + ) @dataclass @@ -270,7 +272,9 @@ class LinodeInterfaceVPCIPv4NATGateway(JSONObject): addresses: List[str] = field(default_factory=list) portset_assignments: int = 0 portset_capacity: int = 0 - portsets: List[LinodeInterfaceVPCIPv4NATGatewayPortset] = field(default_factory=list) # NOTE: This field may not be available to all users. + portsets: List[LinodeInterfaceVPCIPv4NATGatewayPortset] = field( + default_factory=list + ) # NOTE: This field may not be available to all users. @dataclass diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index dfb0eaa93..8b8971348 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -233,7 +233,9 @@ class VPCIPAddressNATGateway(JSONObject): addresses: List[str] = field(default_factory=list) portset_assignments: int = 0 portset_capacity: int = 0 - portsets: List[VPCIPAddressNATGatewayPortset] = field(default_factory=list) # NOTE: This field may not be available to all users. + portsets: List[VPCIPAddressNATGatewayPortset] = field( + default_factory=list + ) # NOTE: This field may not be available to all users. @dataclass @@ -552,7 +554,6 @@ class NATGatewayInterfaceLinode(JSONObject): url: str = "" - @dataclass class NATGatewayInterfacePortsetPort(JSONObject): start: int = 0 @@ -570,7 +571,9 @@ class NATGatewayInterface(JSONObject): id: int = 0 linode: NATGatewayInterfaceLinode = None addresses: List[str] = field(default_factory=list) - portsets: List[NATGatewayInterfacePortset] = field(default_factory=list) # NOTE: This field may not be available to all users. + portsets: List[NATGatewayInterfacePortset] = field( + default_factory=list + ) # NOTE: This field may not be available to all users. @dataclass @@ -747,4 +750,3 @@ def address_interfaces(self, address: str, *filters) -> PaginatedList: NATGateway.api_endpoint, address ).format(id=self.id), ) - diff --git a/linode_api4/objects/vpc.py b/linode_api4/objects/vpc.py index e2590154f..07bbb3912 100644 --- a/linode_api4/objects/vpc.py +++ b/linode_api4/objects/vpc.py @@ -149,7 +149,9 @@ class VPCSubnetNATGateway(JSONObject): addresses: List[str] = None portset_assignments: int = 0 portset_capacity: int = 0 - portsets: List[VPCSubnetNATGatewayPortset] = None # NOTE: This field may not be available to all users. + portsets: List[VPCSubnetNATGatewayPortset] = ( + None # NOTE: This field may not be available to all users. + ) class VPCSubnet(DerivedBase): @@ -224,7 +226,12 @@ def subnet_create( :param natgateway: The NAT gateway options for this subnet. NOTE: May not be available for all users. :type natgateway: VPCSubnetNATGatewayOptions """ - params = {"label": label, "ipv4": ipv4, "ipv6": ipv6, "natgateway": natgateway} + params = { + "label": label, + "ipv4": ipv4, + "ipv6": ipv6, + "natgateway": natgateway, + } params.update(kwargs) diff --git a/test/unit/groups/networking_test.py b/test/unit/groups/networking_test.py index 230c5e753..00093621b 100644 --- a/test/unit/groups/networking_test.py +++ b/test/unit/groups/networking_test.py @@ -324,4 +324,3 @@ def test_natgateway_settings(self): assert settings.allowed_ports_per_interface == [4096, 8192, 16384] assert settings.maximum_autoscaling_addresses_per_natgateway == 100 assert settings.maximum_reserved_addresses_per_natgateway == 100 - diff --git a/test/unit/objects/networking_test.py b/test/unit/objects/networking_test.py index 1b158d662..7e0520202 100644 --- a/test/unit/objects/networking_test.py +++ b/test/unit/objects/networking_test.py @@ -559,9 +559,7 @@ def test_create_address_assignment(self): natgateway = NATGateway(self.client, 42) assignment = natgateway.address_assignment_create("203.0.113.42") - self.assertEqual( - m.call_url, "/networking/natgateways/42/addresses" - ) + self.assertEqual(m.call_url, "/networking/natgateways/42/addresses") self.assertEqual(m.call_data, {"address": "203.0.113.42"}) assert isinstance(assignment, NATGatewayAddressAssignment) @@ -632,4 +630,3 @@ def test_list_address_interfaces(self): assert iface.portsets[0].address == "203.0.113.42" assert iface.portsets[0].ports[0].start == 2048 assert iface.portsets[0].ports[0].end == 3071 - From a78c84d7d10d887853d0676f70b08e0378eb1a72 Mon Sep 17 00:00:00 2001 From: ezilber-akamai Date: Tue, 8 Sep 2026 14:48:16 -0400 Subject: [PATCH 3/4] Address CoPilot suggestions --- linode_api4/objects/networking.py | 6 +++--- linode_api4/objects/vpc.py | 11 +++++------ test/fixtures/linode_instances_124_interfaces.json | 2 +- .../fixtures/linode_instances_124_interfaces_456.json | 2 +- .../linode_instances_124_upgrade-interfaces.json | 2 +- test/unit/objects/linode_interface_test.py | 2 +- test/unit/objects/vpc_test.py | 2 +- 7 files changed, 13 insertions(+), 14 deletions(-) diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index 8b8971348..ce503316e 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -569,7 +569,7 @@ class NATGatewayInterfacePortset(JSONObject): @dataclass class NATGatewayInterface(JSONObject): id: int = 0 - linode: NATGatewayInterfaceLinode = None + linode: Optional[NATGatewayInterfaceLinode] = None addresses: List[str] = field(default_factory=list) portsets: List[NATGatewayInterfacePortset] = field( default_factory=list @@ -578,9 +578,9 @@ class NATGatewayInterface(JSONObject): @dataclass class NATGatewayType(JSONObject): - id: int = 0 + id: str = "" label: str = "" - price: Price = None + price: Optional[Price] = None @dataclass diff --git a/linode_api4/objects/vpc.py b/linode_api4/objects/vpc.py index 07bbb3912..1724b68f5 100644 --- a/linode_api4/objects/vpc.py +++ b/linode_api4/objects/vpc.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any, Dict, List, Optional, Union from linode_api4.errors import UnexpectedResponseError @@ -137,7 +137,7 @@ class VPCSubnetNATGatewayPortsetPort(JSONObject): @dataclass class VPCSubnetNATGatewayPortset(JSONObject): address: str = "" - ports: List[VPCSubnetNATGatewayPortsetPort] = None + ports: List[VPCSubnetNATGatewayPortsetPort] = field(default_factory=list) @dataclass @@ -146,12 +146,11 @@ class VPCSubnetNATGateway(JSONObject): id: int = 0 label: str = "" - addresses: List[str] = None + addresses: List[str] = field(default_factory=list) portset_assignments: int = 0 portset_capacity: int = 0 - portsets: List[VPCSubnetNATGatewayPortset] = ( - None # NOTE: This field may not be available to all users. - ) + # NOTE: This field may not be available to all users. + portsets: List[VPCSubnetNATGatewayPortset] = field(default_factory=list) class VPCSubnet(DerivedBase): diff --git a/test/fixtures/linode_instances_124_interfaces.json b/test/fixtures/linode_instances_124_interfaces.json index 93adae0bb..5f74fc53a 100644 --- a/test/fixtures/linode_instances_124_interfaces.json +++ b/test/fixtures/linode_instances_124_interfaces.json @@ -84,7 +84,7 @@ "id": 42, "label": "the-natgateway", "type": "natgateway", - "url": "/v4/networking/natgateways/1", + "url": "/v4/networking/natgateways/42", "addresses": [ "203.0.113.42" ], "portset_capacity": 30, "portsets": [ diff --git a/test/fixtures/linode_instances_124_interfaces_456.json b/test/fixtures/linode_instances_124_interfaces_456.json index c3b313e57..524a49c96 100644 --- a/test/fixtures/linode_instances_124_interfaces_456.json +++ b/test/fixtures/linode_instances_124_interfaces_456.json @@ -25,7 +25,7 @@ "id": 42, "label": "the-natgateway", "type": "natgateway", - "url": "/v4/networking/natgateways/1", + "url": "/v4/networking/natgateways/42", "addresses": [ "203.0.113.42" ], "portset_capacity": 30, "portsets": [ diff --git a/test/fixtures/linode_instances_124_upgrade-interfaces.json b/test/fixtures/linode_instances_124_upgrade-interfaces.json index 08586c2af..a82045ab9 100644 --- a/test/fixtures/linode_instances_124_upgrade-interfaces.json +++ b/test/fixtures/linode_instances_124_upgrade-interfaces.json @@ -86,7 +86,7 @@ "id": 42, "label": "the-natgateway", "type": "natgateway", - "url": "/v4/networking/natgateways/1", + "url": "/v4/networking/natgateways/42", "addresses": [ "203.0.113.42" ], "portset_capacity": 30, "portsets": [ diff --git a/test/unit/objects/linode_interface_test.py b/test/unit/objects/linode_interface_test.py index 1ad136d85..6b6ef05a9 100644 --- a/test/unit/objects/linode_interface_test.py +++ b/test/unit/objects/linode_interface_test.py @@ -154,7 +154,7 @@ def assert_linode_124_interface_456(iface: LinodeInterface): assert iface.vpc.ipv4.natgateway.id == 42 assert iface.vpc.ipv4.natgateway.label == "the-natgateway" assert iface.vpc.ipv4.natgateway.type == "natgateway" - assert iface.vpc.ipv4.natgateway.url == "/v4/networking/natgateways/1" + assert iface.vpc.ipv4.natgateway.url == "/v4/networking/natgateways/42" assert iface.vpc.ipv4.natgateway.addresses == ["203.0.113.42"] assert iface.vpc.ipv4.natgateway.portset_capacity == 30 assert iface.vpc.ipv4.natgateway.portsets[0].address == "203.0.113.42" diff --git a/test/unit/objects/vpc_test.py b/test/unit/objects/vpc_test.py index a4c4a8fd1..80e2becf1 100644 --- a/test/unit/objects/vpc_test.py +++ b/test/unit/objects/vpc_test.py @@ -119,7 +119,7 @@ def test_update_subnet_label_only(self): self.assertEqual(m.call_url, "/vpcs/123456/subnets/789") self.assertEqual(m.call_data.get("label"), "cool-vpc-subnet") - # The fixture has a NAT Gateway already attached,so the + # The fixture has a NAT Gateway already attached, so the # unchanged put_class serialization is a no-op. self.assertEqual(m.call_data.get("natgateway"), {"id": 42}) From f4252a24c9627c5381d7bd774173e6346b504053 Mon Sep 17 00:00:00 2001 From: ezilber-akamai Date: Tue, 8 Sep 2026 15:53:16 -0400 Subject: [PATCH 4/4] Added doc links --- linode_api4/groups/networking.py | 8 ++++---- linode_api4/objects/networking.py | 14 +++++++------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/linode_api4/groups/networking.py b/linode_api4/groups/networking.py index a0cf007e4..721580da2 100644 --- a/linode_api4/groups/networking.py +++ b/linode_api4/groups/networking.py @@ -634,7 +634,7 @@ def natgateways(self, *filters): """ Retrieves the NAT Gateways your user has access to. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-natgateways :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -662,7 +662,7 @@ def natgateway_create( NOTE: NAT Gateways may not currently be available to all users. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-natgateways :param region: The region in which to create the NAT Gateway. :type region: str or Region @@ -709,7 +709,7 @@ def natgateway_types(self, *filters) -> PaginatedList: NOTE: NAT Gateways may not currently be available to all users. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-natgateway-types :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -728,7 +728,7 @@ def natgateway_settings(self) -> NATGatewaySettings: NOTE: NAT Gateways may not currently be available to all users. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-natgateway-settings :returns: The NAT Gateway settings for the current user. :rtype: NATGatewaySettings diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index ce503316e..4444a885a 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -594,7 +594,7 @@ class NATGateway(Base): """ Represents a single Linode NAT Gateway. - API documentation: TODO + API documentation: https://techdocs.akamai.com/linode-api/reference/get-natgateway NOTE: This feature may not currently be available to all users. """ @@ -621,7 +621,7 @@ def address_assignments(self, *filters) -> PaginatedList: """ Retrieves the reserved IP address assignments for this NAT Gateway. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-natgateway-addresses :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -644,7 +644,7 @@ def address_assignment_view( """ Retrieves a single reserved IP address assignment for this NAT Gateway. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-natgateway-address :param address: The reserved IPv4 address to look up. :type address: str @@ -664,7 +664,7 @@ def address_assignment_create( """ Assigns a reserved IP address to this NAT Gateway. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-natgateway-address :param address: The reserved IPv4 address to assign to this NAT Gateway. :type address: str @@ -690,7 +690,7 @@ def address_assignment_delete(self, address: str) -> bool: """ Removes a reserved IP address assignment from this NAT Gateway. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/delete-natgateway-address :param address: The reserved IPv4 address to remove from this NAT Gateway. :type address: str @@ -711,7 +711,7 @@ def interfaces(self, *filters) -> PaginatedList: """ Retrieves the Linode Interfaces attached to this NAT Gateway. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-natgateway-interfaces :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -732,7 +732,7 @@ def address_interfaces(self, address: str, *filters) -> PaginatedList: """ Retrieves the Linode Interfaces using the given reserved IP address on this NAT Gateway. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-natgateway-address-interfaces :param address: The reserved IPv4 address to look up interfaces for. :type address: str