diff --git a/linode_api4/groups/vpc.py b/linode_api4/groups/vpc.py index 2dd412a2a..1cda0c946 100644 --- a/linode_api4/groups/vpc.py +++ b/linode_api4/groups/vpc.py @@ -11,6 +11,7 @@ VPCIPv6RangeOptions, ) from linode_api4.objects.base import _flatten_request_body_recursive +from linode_api4.objects.vpc import VPCType from linode_api4.paginated_list import PaginatedList from linode_api4.util import drop_null_keys @@ -43,6 +44,7 @@ def create( description: Optional[str] = None, subnets: Optional[List[Dict[str, Any]]] = None, ipv6: Optional[List[Union[VPCIPv6RangeOptions, Dict[str, Any]]]] = None, + vpc_type: Optional[Union[VPCType, str]] = None, ipv4: Optional[List[Union[VPCIPv4RangeOptions, Dict[str, Any]]]] = None, **kwargs, ) -> VPC: @@ -61,6 +63,11 @@ def create( :type subnets: List[Dict[str, Any]] :param ipv6: The IPv6 address ranges for this VPC. :type ipv6: List[Union[VPCIPv6RangeOptions, Dict[str, Any]]] + :param vpc_type: The type of VPC to create. Defaults to ``regular`` on + the API side. Set to ``rdma`` to create a GPUDirect + RDMA VPC (requires the ``GPUDirect RDMA`` account + capability). + :type vpc_type: Optional[Union[VPCType, str]] :param ipv4: The IPv4 address ranges for this VPC. Note that IPv4 VPCs may not currently be available to all users. :type ipv4: List[Union[VPCIPv4RangeOptions, Dict[str, Any]]] @@ -74,6 +81,7 @@ def create( "ipv4": ipv4, "ipv6": ipv6, "subnets": subnets, + "vpc_type": vpc_type, } if subnets is not None and len(subnets) > 0: diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 31678f4e8..44e946b7c 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -2115,10 +2115,22 @@ def interface_create( :param vpc: The VPC-specific configuration of the new interface. If set, the new instance will be a VPC interface. + .. note:: + RDMA VPC interfaces (``rdma_vpc``) cannot be added via this + endpoint. They may only be specified at instance creation time via + :func:`linode_api4.LinodeGroup.instance_create`. + :returns: The newly created Linode Interface. :rtype: LinodeInterface """ + if kwargs.get("rdma_vpc") is not None: + raise ValueError( + "RDMA VPC interfaces (rdma_vpc) cannot be added via " + "interface_create(). They may only be specified at instance " + "creation time via LinodeGroup.instance_create()." + ) + params = { "firewall_id": firewall, "default_route": default_route, diff --git a/linode_api4/objects/linode_interfaces.py b/linode_api4/objects/linode_interfaces.py index 69cebca23..43c4192c4 100644 --- a/linode_api4/objects/linode_interfaces.py +++ b/linode_api4/objects/linode_interfaces.py @@ -187,6 +187,47 @@ class LinodeInterfaceVLANOptions(JSONObject): ipam_address: Optional[str] = None +@dataclass +class LinodeInterfaceRDMAVPCIPv4AddressOptions(JSONObject): + """ + Options accepted for a single address when creating or updating the IPv4 + configuration of an RDMA VPC Linode Interface. + + Only one address is supported per RDMA VPC interface, and it must be + marked as primary. + """ + + address: Optional[str] = None + primary: Optional[bool] = None + + +@dataclass +class LinodeInterfaceRDMAVPCIPv4Options(JSONObject): + """ + Options accepted when creating or updating the IPv4 configuration of an + RDMA VPC Linode Interface. + + The ``addresses`` list MUST contain exactly one element. If omitted, the + API defaults to a single primary ``auto`` address. + """ + + addresses: Optional[List[LinodeInterfaceRDMAVPCIPv4AddressOptions]] = None + + +@dataclass +class LinodeInterfaceRDMAVPCOptions(JSONObject): + """ + RDMA-VPC-exclusive options accepted when creating or updating a Linode + Interface. + + Used for GPUDirect RDMA interfaces. Default routes and NAT 1:1 addresses + are not supported on RDMA VPC interfaces. + """ + + subnet_id: int = 0 + ipv4: Optional[LinodeInterfaceRDMAVPCIPv4Options] = None + + @dataclass class LinodeInterfaceOptions(JSONObject): """ @@ -204,6 +245,7 @@ class LinodeInterfaceOptions(JSONObject): vpc: Optional[LinodeInterfaceVPCOptions] = None public: Optional[LinodeInterfacePublicOptions] = None vlan: Optional[LinodeInterfaceVLANOptions] = None + rdma_vpc: Optional[LinodeInterfaceRDMAVPCOptions] = None # Interface GET Response @@ -409,6 +451,45 @@ class LinodeInterfaceVLAN(JSONObject): ipam_address: Optional[str] = None +@dataclass +class LinodeInterfaceRDMAVPCIPv4Address(JSONObject): + """ + A single address under the IPv4 configuration of an RDMA VPC Linode Interface. + """ + + put_class = LinodeInterfaceRDMAVPCIPv4AddressOptions + + address: str = "" + primary: bool = False + + +@dataclass +class LinodeInterfaceRDMAVPCIPv4(JSONObject): + """ + The IPv4 configuration of an RDMA VPC Linode Interface. + """ + + put_class = LinodeInterfaceRDMAVPCIPv4Options + + addresses: List[LinodeInterfaceRDMAVPCIPv4Address] = field( + default_factory=list + ) + + +@dataclass +class LinodeInterfaceRDMAVPC(JSONObject): + """ + RDMA VPC-specific configuration field for a Linode Interface. + """ + + put_class = LinodeInterfaceRDMAVPCOptions + + vpc_id: int = 0 + subnet_id: int = 0 + + ipv4: Optional[LinodeInterfaceRDMAVPCIPv4] = None + + class LinodeInterface(DerivedBase): """ A Linode's network interface. @@ -449,6 +530,7 @@ class LinodeInterface(DerivedBase): "public": Property(mutable=True, json_object=LinodeInterfacePublic), "vlan": Property(mutable=True, json_object=LinodeInterfaceVLAN), "vpc": Property(mutable=True, json_object=LinodeInterfaceVPC), + "rdma_vpc": Property(mutable=True, json_object=LinodeInterfaceRDMAVPC), } def firewalls(self, *filters) -> List[Firewall]: diff --git a/linode_api4/objects/region.py b/linode_api4/objects/region.py index ce4ea3894..ebf64985a 100644 --- a/linode_api4/objects/region.py +++ b/linode_api4/objects/region.py @@ -67,6 +67,7 @@ class Capability(StrEnum): ruleset = "Cloud Firewall Rule Set" prefixlists = "Cloud Firewall Prefix Lists" current_prefixlists = "Cloud Firewall Prefix List Current References" + gpudirect_rdma = "GPUDirect RDMA" @dataclass diff --git a/linode_api4/objects/vpc.py b/linode_api4/objects/vpc.py index ae3f067fd..4a4df9e35 100644 --- a/linode_api4/objects/vpc.py +++ b/linode_api4/objects/vpc.py @@ -5,11 +5,20 @@ from linode_api4.objects import Base, DerivedBase, Property, Region from linode_api4.objects.base import _flatten_request_body_recursive from linode_api4.objects.networking import VPCIPAddress -from linode_api4.objects.serializable import JSONObject +from linode_api4.objects.serializable import JSONObject, StrEnum from linode_api4.paginated_list import PaginatedList from linode_api4.util import drop_null_keys +class VPCType(StrEnum): + """ + VPCType represents the supported VPC types. + """ + + regular = "regular" + rdma = "rdma" + + @dataclass class VPCIPv4DefaultRange(JSONObject): """ @@ -119,6 +128,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), + "vpc_type": Property(), "created": Property(is_datetime=True), "updated": Property(is_datetime=True), } @@ -144,6 +154,7 @@ class VPC(Base): ), "ipv6": Property(json_object=VPCIPv6Range, unordered=True), "subnets": Property(derived_class=VPCSubnet), + "vpc_type": Property(), "created": Property(is_datetime=True), "updated": Property(is_datetime=True), } diff --git a/test/fixtures/linode_instances_124_interfaces_999.json b/test/fixtures/linode_instances_124_interfaces_999.json new file mode 100644 index 000000000..0c78fe04d --- /dev/null +++ b/test/fixtures/linode_instances_124_interfaces_999.json @@ -0,0 +1,27 @@ +{ + "id": 999, + "mac_address": "22:00:f2:9e:d3:48", + "created": "2026-03-12T09:54:34", + "updated": "2026-03-12T09:54:35", + "default_route": { + "ipv4": false, + "ipv6": false + }, + "version": 1, + "public": null, + "vpc": null, + "vlan": null, + "rdma_vpc": { + "vpc_id": 7, + "subnet_id": 8, + "ipv4": { + "addresses": [ + { + "address": "10.0.0.2", + "primary": true + } + ] + } + } +} + diff --git a/test/fixtures/vpcs.json b/test/fixtures/vpcs.json index 22f1fe362..6457b742d 100644 --- a/test/fixtures/vpcs.json +++ b/test/fixtures/vpcs.json @@ -5,6 +5,7 @@ "id": 123456, "description": "A very real VPC.", "region": "us-southeast", + "vpc_type": "regular", "ipv4": [ { "range": "10.0.0.0/8" diff --git a/test/fixtures/vpcs_123456.json b/test/fixtures/vpcs_123456.json index 71a250e50..9950a2b59 100644 --- a/test/fixtures/vpcs_123456.json +++ b/test/fixtures/vpcs_123456.json @@ -3,6 +3,7 @@ "id": 123456, "description": "A very real VPC.", "region": "us-southeast", + "vpc_type": "regular", "ipv4": [ { "range": "10.0.0.0/8" diff --git a/test/fixtures/vpcs_123456_subnets.json b/test/fixtures/vpcs_123456_subnets.json index 8239daec2..e387e0215 100644 --- a/test/fixtures/vpcs_123456_subnets.json +++ b/test/fixtures/vpcs_123456_subnets.json @@ -35,6 +35,7 @@ ] } ], + "vpc_type": "regular", "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..b58c382db 100644 --- a/test/fixtures/vpcs_123456_subnets_789.json +++ b/test/fixtures/vpcs_123456_subnets_789.json @@ -7,6 +7,7 @@ "range": "fd71:1140:a9d0::/52" } ], + "vpc_type": "regular", "linodes": [ { "id": 12345, diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 998c9769a..466910ef1 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -17,6 +17,7 @@ from requests.exceptions import ConnectionError, RequestException from linode_api4 import ( + Capability, Instance, InterfaceGeneration, IPAddress, @@ -41,6 +42,8 @@ RUN_LONG_TESTS = "RUN_LONG_TESTS" SKIP_E2E_FIREWALL = "SKIP_E2E_FIREWALL" +TEST_VPC_REGION = None + ALL_ACCOUNT_AVAILABILITIES = { "Linodes", "NodeBalancers", @@ -468,9 +471,13 @@ def test_oauth_client(test_linode_client): @pytest.fixture(scope="session") def create_vpc(test_linode_client): client = test_linode_client - label = get_test_label(length=10) + global TEST_VPC_REGION + TEST_VPC_REGION = get_region( + test_linode_client, {"VPCs", "VPC IPv6 Stack", "Linode Interfaces"} + ) + vpc = client.vpcs.create( label=label, region=get_region( @@ -490,6 +497,32 @@ def create_vpc(test_linode_client): vpc.delete() +@pytest.fixture +def create_vpc_with_rdma_type(test_linode_client): + client = test_linode_client + label = get_test_label(length=10) + + if TEST_VPC_REGION: + region = TEST_VPC_REGION + else: + region = get_region( + # GPUDirect RDMA capability not available for now + # test_linode_client, {Capability.vpcs, Capability.gpudirect_rdma} + test_linode_client, + {Capability.vpcs}, + ) + + vpc = client.vpcs.create( + label=label, + region=region, + description="test description", + vpc_type="rdma", + ) + yield vpc + + vpc.delete() + + @pytest.fixture(scope="session") def create_vpc_with_subnet(test_linode_client, create_vpc): subnet = create_vpc.subnet_create( @@ -525,6 +558,21 @@ def create_vpc_with_subnet_and_linode( instance.delete() +@pytest.fixture +def create_vpc_with_subnet_and_rdma_type(create_vpc_with_rdma_type): + vpc_rdma = create_vpc_with_rdma_type + label = get_test_label(length=10) + + subnet_rdma = vpc_rdma.subnet_create( + label=label, + ipv4="10.0.0.0/24", + ) + + yield vpc_rdma, subnet_rdma + + subnet_rdma.delete() + + @pytest.fixture def create_vpc_with_ipv4(test_linode_client): client = test_linode_client diff --git a/test/integration/models/linode/interfaces/test_interfaces.py b/test/integration/models/linode/interfaces/test_interfaces.py index 797f24156..922776e8c 100644 --- a/test/integration/models/linode/interfaces/test_interfaces.py +++ b/test/integration/models/linode/interfaces/test_interfaces.py @@ -1,6 +1,7 @@ import copy import ipaddress -from test.integration.helpers import get_test_label +import os +from test.integration.helpers import get_test_label, wait_for_condition import pytest @@ -16,6 +17,9 @@ LinodeInterfacePublicIPv6Options, LinodeInterfacePublicIPv6RangeOptions, LinodeInterfacePublicOptions, + LinodeInterfaceRDMAVPCIPv4AddressOptions, + LinodeInterfaceRDMAVPCIPv4Options, + LinodeInterfaceRDMAVPCOptions, LinodeInterfaceVLANOptions, LinodeInterfaceVPCIPv4AddressOptions, LinodeInterfaceVPCIPv4Options, @@ -43,6 +47,22 @@ def build_interface_public_ipv4(firewall, ip_address): ) +def build_interface_rdma_vpc_ipv4(subnet_id: int): + return LinodeInterfaceOptions( + firewall_id=-1, + rdma_vpc=LinodeInterfaceRDMAVPCOptions( + subnet_id=subnet_id, + ipv4=LinodeInterfaceRDMAVPCIPv4Options( + addresses=[ + LinodeInterfaceRDMAVPCIPv4AddressOptions( + address="auto", primary=True + ) + ] + ), + ), + ) + + def create_linode_with_legacy_config( client, ip_address, label, firewall, authorized_key ): @@ -75,6 +95,22 @@ def create_linode_with_standard_interfaces( return linode +def create_multiple_rdma_interfaces(amount: int, subnet_id: int): + """ + Creates multiple VPC RDMA interfaces that may be needed for RDMA Linode instance + + Note: + At least 8 separate VPC RDMA interfaces need to be created for a single RDMA linode instance + """ + interfaces = list() + + for _ in range(amount): + rdma_iface = build_interface_rdma_vpc_ipv4(subnet_id) + interfaces.append(rdma_iface) + + return interfaces + + def test_linode_create_with_linode_interfaces( create_vpc_with_subnet, linode_with_linode_interfaces, @@ -458,3 +494,88 @@ def test_linode_interfaces_with_reserved_ips( assert reserved_ips_list[0].reserved == True assert reserved_ips_list[0].linode_id is None assert reserved_ips_list[0].assigned_entity is None + + +@pytest.mark.skipif( + os.getenv("RUN_RDMA_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="Linode with RDMA interfaces requires manual infra changes; set RUN_RDMA_TESTS=yes to enable", +) +def test_linode_interfaces_with_rdma_vpc_type( + request, + test_linode_client, +): + client = test_linode_client + region = "us-rno-1" + + # Create a regular VPC with a subnet in us-rno-1 + vpc = client.vpcs.create( + label=get_test_label(length=10), + region=region, + description="test description", + ipv6=[{"range": "auto"}], + ) + request.addfinalizer(vpc.delete) + subnet = vpc.subnet_create( + label="test-subnet", + ipv4="10.0.0.0/24", + ipv6=[{"range": "auto"}], + ) + request.addfinalizer(subnet.delete) + + # Create an RDMA VPC with a subnet in us-rno-1 + vpc_rdma = client.vpcs.create( + label=get_test_label(length=10), + region=region, + description="test description", + vpc_type="rdma", + ) + request.addfinalizer(vpc_rdma.delete) + subnet_rdma = vpc_rdma.subnet_create( + label=get_test_label(length=10), + ipv4="10.0.0.0/24", + ) + request.addfinalizer(subnet_rdma.delete) + + # Include RDMA VPC interfaces + multi_ifaces = create_multiple_rdma_interfaces(8, subnet_rdma.id) + + # Include (at least one) regular interface + multi_ifaces.append( + LinodeInterfaceOptions( + firewall_id=-1, + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True, + ), + vpc=LinodeInterfaceVPCOptions( + subnet_id=subnet.id, + ipv4=LinodeInterfaceVPCIPv4Options( + addresses=[ + LinodeInterfaceVPCIPv4AddressOptions( + address="auto", + primary=True, + ) + ], + ), + ), + ), + ) + + instance = client.linode.instance_create( + label="python-test-rdma-" + get_test_label(), + root_pass="aComplex@Password123", + image="linode/ubuntu24.04", + region=vpc.region, + ltype="g3-gpu-rtxpro6000-blackwell-rdma-8", + interface_generation=InterfaceGeneration.LINODE, + interfaces=multi_ifaces, + booted=False, + ) + request.addfinalizer(instance.delete) + + def get_linode_status(): + instance.invalidate() + return instance.status == "offline" + + wait_for_condition(5, 180, get_linode_status) + + assert len(instance.linode_interfaces) == len(multi_ifaces) diff --git a/test/integration/models/vpc/test_vpc.py b/test/integration/models/vpc/test_vpc.py index 1fca6e712..de4a36e5a 100644 --- a/test/integration/models/vpc/test_vpc.py +++ b/test/integration/models/vpc/test_vpc.py @@ -11,6 +11,7 @@ def test_get_vpc(test_linode_client, create_vpc): test_linode_client.vpcs() assert vpc.id == create_vpc.id assert isinstance(vpc.ipv6[0].range, str) + assert vpc.vpc_type == "regular" @pytest.mark.smoke @@ -38,6 +39,8 @@ def test_get_subnet(test_linode_client, create_vpc_with_subnet): vpc.ipv6[0].range.split("::")[0] ) assert loaded_subnet.id == subnet.id + assert loaded_subnet.vpc_type == "regular" + assert loaded_subnet.vpc_type == vpc.vpc_type @pytest.mark.smoke @@ -141,6 +144,34 @@ def test_get_vpc_ipv6s(test_linode_client): assert isinstance(ipv6["ipv6_addresses"], list) +def test_get_vpc_with_rdma_type(test_linode_client, create_vpc_with_rdma_type): + vpc_rdma = create_vpc_with_rdma_type + assert vpc_rdma.vpc_type == "rdma" + assert vpc_rdma.ipv6 is None + + vpc = test_linode_client.load(VPC, vpc_rdma.id) + assert vpc.id == vpc_rdma.id + assert vpc.vpc_type == vpc_rdma.vpc_type + + vpcs = test_linode_client.vpcs(VPC.vpc_type == "rdma") + assert vpc_rdma.id in [vpc.id for vpc in vpcs] + assert all(vpc.vpc_type == "rdma" for vpc in vpcs) + + +def test_get_subnet_with_rdma_type( + test_linode_client, create_vpc_with_subnet_and_rdma_type +): + vpc_rdma, subnet_rdma = create_vpc_with_subnet_and_rdma_type + + assert subnet_rdma.vpc_type == vpc_rdma.vpc_type + assert subnet_rdma.ipv6 is None + + subnet = test_linode_client.load(VPCSubnet, subnet_rdma.id, vpc_rdma.id) + assert subnet.id == subnet_rdma.id + assert subnet.vpc_type == vpc_rdma.vpc_type + assert subnet.ipv6 is None + + def test_get_vpc_default_ranges(test_linode_client): """ Tests that VPC default IPv4 ranges can be retrieved. diff --git a/test/unit/groups/linode_test.py b/test/unit/groups/linode_test.py index 03278f03b..c7e1f40ec 100644 --- a/test/unit/groups/linode_test.py +++ b/test/unit/groups/linode_test.py @@ -1,6 +1,7 @@ from test.unit.base import ClientBaseCase from test.unit.objects.linode_interface_test import ( build_interface_options_public, + build_interface_options_rdma_vpc, build_interface_options_vlan, build_interface_options_vpc, ) @@ -128,6 +129,40 @@ def test_instance_create_with_interfaces_linode(self): "interfaces": [iface._serialize() for iface in interfaces], } + def test_instance_create_with_interfaces_linode_rdma(self): + """ + Tests that a Linode can be created with RDMA VPC LinodeInterfaces. + """ + + interfaces = [ + build_interface_options_rdma_vpc(), + ] + + with self.mock_post("linode/instances/124") as m: + self.client.linode.instance_create( + "g6-nanode-1", + "us-mia", + interface_generation=InterfaceGeneration.LINODE, + interfaces=interfaces, + ) + + assert m.call_data == { + "region": "us-mia", + "type": "g6-nanode-1", + "interface_generation": "linode", + "interfaces": [iface._serialize() for iface in interfaces], + } + + assert m.call_data["interfaces"][0] == { + "firewall_id": None, + "rdma_vpc": { + "subnet_id": 1234, + "ipv4": { + "addresses": [{"address": "auto", "primary": True}] + }, + }, + } + def test_create_with_maintenance_policy(self): """ Tests that you can create a Linode with a maintenance policy diff --git a/test/unit/objects/linode_interface_test.py b/test/unit/objects/linode_interface_test.py index c021334e1..7e8139186 100644 --- a/test/unit/objects/linode_interface_test.py +++ b/test/unit/objects/linode_interface_test.py @@ -10,6 +10,9 @@ LinodeInterfacePublicIPv6Options, LinodeInterfacePublicIPv6RangeOptions, LinodeInterfacePublicOptions, + LinodeInterfaceRDMAVPCIPv4AddressOptions, + LinodeInterfaceRDMAVPCIPv4Options, + LinodeInterfaceRDMAVPCOptions, LinodeInterfaceVLANOptions, LinodeInterfaceVPCIPv4AddressOptions, LinodeInterfaceVPCIPv4Options, @@ -77,6 +80,22 @@ def build_interface_options_vlan(): ) +def build_interface_options_rdma_vpc(): + return LinodeInterfaceOptions( + firewall_id=None, + rdma_vpc=LinodeInterfaceRDMAVPCOptions( + subnet_id=1234, + ipv4=LinodeInterfaceRDMAVPCIPv4Options( + addresses=[ + LinodeInterfaceRDMAVPCIPv4AddressOptions( + address="auto", primary=True + ) + ] + ), + ), + ) + + class LinodeInterfaceTest(ClientBaseCase): """ Tests methods of the LinodeInterface class @@ -330,3 +349,70 @@ def test_firewalls(self): assert firewalls[0].label == "firewall123" assert firewalls[0].rules.inbound[0].action == "ACCEPT" assert firewalls[0].status == "enabled" + + # ------------------------------------------------------------------ + # RDMA VPC interface tests + # ------------------------------------------------------------------ + + @staticmethod + def assert_linode_124_interface_999_rdma(iface: LinodeInterface): + """Asserts a GET on an RDMA VPC interface deserializes correctly.""" + assert iface.id == 999 + assert iface.mac_address == "22:00:f2:9e:d3:48" + assert iface.version == 1 + + # RDMA VPC interfaces never have default routes + assert iface.default_route.ipv4 is False + assert iface.default_route.ipv6 is False + + # Only rdma_vpc is populated + assert iface.public is None + assert iface.vpc is None + assert iface.vlan is None + + assert iface.rdma_vpc is not None + assert iface.rdma_vpc.vpc_id == 7 + assert iface.rdma_vpc.subnet_id == 8 + + assert len(iface.rdma_vpc.ipv4.addresses) == 1 + assert iface.rdma_vpc.ipv4.addresses[0].address == "10.0.0.2" + assert iface.rdma_vpc.ipv4.addresses[0].primary is True + + def test_get_rdma_vpc(self): + iface = LinodeInterface(self.client, 999, 124) + + self.assert_linode_124_interface_999_rdma(iface) + iface.invalidate() + self.assert_linode_124_interface_999_rdma(iface) + + def test_update_rdma_vpc(self): + """ + Tests that PUT serialization works for RDMA VPC fields. + """ + iface = LinodeInterface(self.client, 999, 124) + self.assert_linode_124_interface_999_rdma(iface) + + # Mutate the RDMA interface + iface.rdma_vpc.subnet_id = 4321 + iface.rdma_vpc.ipv4.addresses = [ + LinodeInterfaceRDMAVPCIPv4AddressOptions( + address="10.0.0.25", primary=True + ) + ] + + with self.mock_put("/linode/instances/124/interfaces/999") as m: + iface.save() + + assert m.called + assert m.call_data == { + "default_route": { + "ipv4": False, + "ipv6": False, + }, + "rdma_vpc": { + "subnet_id": 4321, + "ipv4": { + "addresses": [{"address": "10.0.0.25", "primary": True}] + }, + }, + } diff --git a/test/unit/objects/linode_test.py b/test/unit/objects/linode_test.py index b9a6287e2..6e22e6fe6 100644 --- a/test/unit/objects/linode_test.py +++ b/test/unit/objects/linode_test.py @@ -3,6 +3,7 @@ from test.unit.objects.linode_interface_test import ( LinodeInterfaceTest, build_interface_options_public, + build_interface_options_rdma_vpc, build_interface_options_vlan, build_interface_options_vpc, ) @@ -756,6 +757,21 @@ def test_create_interface_vlan(self): LinodeInterfaceTest.assert_linode_124_interface_789(result) + def test_create_interface_rdma_vpc_rejected(self): + """ + Tests that attempting to create an RDMA VPC interface via + interface_create() raises a clear ValueError instead of sending an + invalid request to the API. + """ + instance = Instance(self.client, 124) + + iface = build_interface_options_rdma_vpc() + + with self.assertRaises(ValueError) as ctx: + instance.interface_create(**vars(iface)) + + assert "rdma_vpc" in str(ctx.exception) + class DiskTest(ClientBaseCase): """ diff --git a/test/unit/objects/vpc_test.py b/test/unit/objects/vpc_test.py index b3a79b5b2..fc44722a4 100644 --- a/test/unit/objects/vpc_test.py +++ b/test/unit/objects/vpc_test.py @@ -2,6 +2,7 @@ from test.unit.base import ClientBaseCase from linode_api4 import DATE_FORMAT, VPC, VPCSubnet +from linode_api4.objects.vpc import VPCType class VPCTest(ClientBaseCase): @@ -115,6 +116,7 @@ def validate_vpc_123456(self, vpc: VPC): self.assertEqual(vpc.ipv4[0].range, "10.0.0.0/8") self.assertEqual(vpc.ipv6[0].range, "fd71:1140:a9d0::/52") + self.assertEqual(vpc.vpc_type, "regular") def validate_vpc_subnet_789(self, subnet: VPCSubnet): expected_dt = datetime.datetime.strptime( @@ -139,6 +141,9 @@ def validate_vpc_subnet_789(self, subnet: VPCSubnet): assert not subnet.linodes[0].interfaces[1].active assert subnet.linodes[0].interfaces[1].config_id is None + # New RDMA-related fields + assert subnet.vpc_type == "regular" + self.assertEqual(subnet.ipv6[0].range, "fd71:1140:a9d0::/52") def test_list_vpc_ips(self): @@ -173,3 +178,39 @@ def test_list_vpc_ips(self): self.assertEqual( vpc_ip_2.ipv6_addresses[0].slaac_address, "fd71:1140:a9d0::/52" ) + + def test_create_vpc_with_vpc_type(self): + """ + Tests that ``client.vpcs.create`` forwards ``vpc_type`` to the API. + """ + + with self.mock_post("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/vpcs/123456") as m: + self.client.vpcs.create( + label="rdma-vpc", + region="us-cph", + description="rdma test vpc", + vpc_type=VPCType.rdma, + ) + + assert m.call_url == "/vpcs" + assert m.call_data == { + "label": "rdma-vpc", + "region": "us-cph", + "description": "rdma test vpc", + "vpc_type": "rdma", + } + + def test_create_vpc_without_vpc_type(self): + """ + Tests that ``vpc_type`` is omitted from the request body when not + provided, preserving the previous default behavior. + """ + + with self.mock_post("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/vpcs/123456") as m: + self.client.vpcs.create( + label="regular-vpc", + region="us-east", + ) + + assert m.call_url == "/vpcs" + assert "vpc_type" not in m.call_data