diff --git a/linode_api4/groups/networking.py b/linode_api4/groups/networking.py index bdcb5b7cb..721580da2 100644 --- a/linode_api4/groups/networking.py +++ b/linode_api4/groups/networking.py @@ -13,11 +13,18 @@ IPAddress, IPv6Pool, IPv6Range, + NATGateway, + NATGatewayAddress, NetworkTransferPrice, Region, ) 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,116 @@ 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: https://techdocs.akamai.com/linode-api/reference/get-natgateways + + :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: 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 + :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: 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` + 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: https://techdocs.akamai.com/linode-api/reference/get-natgateway-settings + + :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..0b00168b9 100644 --- a/linode_api4/objects/linode_interfaces.py +++ b/linode_api4/objects/linode_interfaces.py @@ -245,6 +245,38 @@ 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 +287,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..4444a885a 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,33 @@ 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 +266,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 +519,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: Optional[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: str = "" + label: str = "" + price: Optional[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: https://techdocs.akamai.com/linode-api/reference/get-natgateway + + 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: 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` + 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: https://techdocs.akamai.com/linode-api/reference/get-natgateway-address + + :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: 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 + + :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: 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 + + :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: 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` + 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: 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 + :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..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 @@ -101,6 +101,58 @@ 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] = field(default_factory=list) + + +@dataclass +class VPCSubnetNATGateway(JSONObject): + put_class = VPCSubnetNATGatewayOptions + + id: int = 0 + label: str = "" + addresses: List[str] = field(default_factory=list) + portset_assignments: int = 0 + portset_capacity: int = 0 + # NOTE: This field may not be available to all users. + portsets: List[VPCSubnetNATGatewayPortset] = field(default_factory=list) + + class VPCSubnet(DerivedBase): """ An instance of a VPC subnet. @@ -119,6 +171,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 +208,7 @@ def subnet_create( ipv6: Optional[ List[Union[VPCSubnetIPv6RangeOptions, Dict[str, Any]]] ] = None, + natgateway: Optional[VPCSubnetNATGatewayOptions] = None, **kwargs, ) -> VPCSubnet: """ @@ -168,8 +222,15 @@ 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..5f74fc53a 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/42", + "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..524a49c96 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/42", + "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..a82045ab9 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/42", + "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..00093621b 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,83 @@ 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..6b6ef05a9 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/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" + 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..7e0520202 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,175 @@ 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..80e2becf1 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")