From 8151e25ce26b76c79ac37a6884f70cf2215f538f Mon Sep 17 00:00:00 2001 From: Ye Chen Date: Sat, 30 May 2026 14:30:57 -0400 Subject: [PATCH] init --- linode_api4/groups/vpc.py | 8 ++ linode_api4/objects/linode.py | 5 + linode_api4/objects/linode_interfaces.py | 82 +++++++++++ linode_api4/objects/region.py | 1 + linode_api4/objects/vpc.py | 13 +- .../linode_instances_124_interfaces_999.json | 27 ++++ test/fixtures/vpcs.json | 1 + test/fixtures/vpcs_123456.json | 1 + test/fixtures/vpcs_123456_subnets.json | 1 + test/fixtures/vpcs_123456_subnets_789.json | 1 + test/unit/objects/linode_interface_test.py | 135 ++++++++++++++++++ test/unit/objects/vpc_test.py | 50 +++++++ 12 files changed, 324 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/linode_instances_124_interfaces_999.json diff --git a/linode_api4/groups/vpc.py b/linode_api4/groups/vpc.py index eda931292..78ef10ff3 100644 --- a/linode_api4/groups/vpc.py +++ b/linode_api4/groups/vpc.py @@ -4,6 +4,7 @@ from linode_api4.groups import Group from linode_api4.objects import VPC, Region, VPCIPAddress, 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 @@ -36,6 +37,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, **kwargs, ) -> VPC: """ @@ -53,6 +55,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]] :returns: The new VPC object. :rtype: VPC @@ -63,6 +70,7 @@ def create( "description": description, "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 f27fac472..e9a4283c0 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -2106,6 +2106,11 @@ 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 """ 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 9a77dc485..65e94193d 100644 --- a/linode_api4/objects/region.py +++ b/linode_api4/objects/region.py @@ -65,6 +65,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 4adecc2e3..24558c47e 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 VPCIPv6RangeOptions(JSONObject): """ @@ -89,6 +98,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), } @@ -110,6 +120,7 @@ class VPC(Base): "region": Property(slug_relationship=Region), "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 822f3bae1..5824eab3b 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", "ipv6": [ { "range": "fd71:1140:a9d0::/52" diff --git a/test/fixtures/vpcs_123456.json b/test/fixtures/vpcs_123456.json index af6d2cff8..a35fcb096 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", "ipv6": [ { "range": "fd71:1140:a9d0::/52" 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/unit/objects/linode_interface_test.py b/test/unit/objects/linode_interface_test.py index c021334e1..c9277d4fa 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,30 @@ def build_interface_options_vlan(): ) +def build_interface_options_rdma_vpc(): + return LinodeInterfaceOptions( + firewall_id=-1, + rdma_vpc=LinodeInterfaceRDMAVPCOptions( + subnet_id=1234, + ipv4=LinodeInterfaceRDMAVPCIPv4Options( + addresses=[ + LinodeInterfaceRDMAVPCIPv4AddressOptions( + address="auto", primary=True + ) + ] + ), + ), + ) + + +def build_interface_options_rdma_vpc_minimal(): + """Per spec: ipv4 may be omitted; defaults to a primary "auto" address.""" + return LinodeInterfaceOptions( + firewall_id=-1, + rdma_vpc=LinodeInterfaceRDMAVPCOptions(subnet_id=1234), + ) + + class LinodeInterfaceTest(ClientBaseCase): """ Tests methods of the LinodeInterface class @@ -330,3 +357,111 @@ 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} + ] + }, + }, + } + + def test_rdma_vpc_options_serialization(self): + """ + Tests that the POST-shaped ``LinodeInterfaceOptions`` for an RDMA VPC + interface serializes exactly as described in the API spec. + """ + opts = build_interface_options_rdma_vpc() + + assert opts.dict == { + "firewall_id": -1, + "rdma_vpc": { + "subnet_id": 1234, + "ipv4": { + "addresses": [{"address": "auto", "primary": True}], + }, + }, + } + + def test_rdma_vpc_options_serialization_minimal(self): + """ + Tests the minimal RDMA VPC payload (no ``ipv4`` block) per spec + default (the API will assign 1 primary ``auto`` address). + """ + opts = build_interface_options_rdma_vpc_minimal() + + assert opts.dict == { + "firewall_id": -1, + "rdma_vpc": {"subnet_id": 1234}, + } + + def test_rdma_vpc_options_dropped_when_none(self): + """ + Ensures the new ``rdma_vpc`` field doesn't leak into the request + body when unused on a non-RDMA interface. + """ + opts = build_interface_options_public() + + assert "rdma_vpc" not in opts.dict + diff --git a/test/unit/objects/vpc_test.py b/test/unit/objects/vpc_test.py index 90ec348da..b0240a1b6 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): @@ -114,6 +115,7 @@ def validate_vpc_123456(self, vpc: VPC): self.assertEqual(vpc.updated, expected_dt) 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( @@ -138,6 +140,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): @@ -172,3 +177,48 @@ 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("/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("/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 + + def test_vpc_type_enum_values(self): + """ + Sanity checks on the ``VPCType`` string enum values. + """ + + assert VPCType.regular == "regular" + assert VPCType.rdma == "rdma" + assert str(VPCType.rdma) == "rdma"