diff --git a/.github/workflows/publish-pypi.yaml b/.github/workflows/publish-pypi.yaml index fb69f9f7b..fee55b206 100644 --- a/.github/workflows/publish-pypi.yaml +++ b/.github/workflows/publish-pypi.yaml @@ -28,4 +28,4 @@ jobs: LINODE_SDK_VERSION: ${{ github.event.release.tag_name }} - name: Publish the release artifacts to PyPI - uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # pin@release/v1.14.1 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # pin@release/v1.14.2 diff --git a/linode_api4/groups/vpc.py b/linode_api4/groups/vpc.py index 2dd412a2a..947c6ff6f 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 @@ -44,6 +45,7 @@ def create( subnets: Optional[List[Dict[str, Any]]] = None, ipv6: Optional[List[Union[VPCIPv6RangeOptions, Dict[str, Any]]]] = None, ipv4: Optional[List[Union[VPCIPv4RangeOptions, Dict[str, Any]]]] = None, + vpc_type: Optional[Union[VPCType, str]] = 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/login_client.py b/linode_api4/login_client.py index e21c5c4b2..8b8fe69c3 100644 --- a/linode_api4/login_client.py +++ b/linode_api4/login_client.py @@ -30,10 +30,11 @@ class OAuthScopes: Lists of OAuth Scopes are accepted when calling the :any:`generate_login_url` method of the :any:`LinodeLoginClient`. - All contained enumerations of OAuth Scopes have two levels, "read_only" and + Most contained enumerations of OAuth Scopes have two levels, "read_only" and "read_write". "read_only" access grants you the ability to get resources and of that type, but not to change, create, or delete them. "read_write" access - allows to full access to resources of the requested type. In the above + allows to full access to resources of the requested type. :any:`OAuthScopes.VPC` + is the exception and exposes only "read_write" and "all". In the above example, you are requesting access to view, modify, create, and delete Linodes, and to view Domains. """ @@ -254,6 +255,47 @@ def __repr__(self): return "longview:*" return "longview:{}".format(self.name) + class Images(Enum): + """ + Access to Images + """ + + read_only = 0 + read_write = 1 + all = 2 + + def __repr__(self): + if self.name == "all": + return "images:*" + return "images:{}".format(self.name) + + class Databases(Enum): + """ + Access to Managed Databases + """ + + read_only = 0 + read_write = 1 + all = 2 + + def __repr__(self): + if self.name == "all": + return "databases:*" + return "databases:{}".format(self.name) + + class VPC(Enum): + """ + Access to VPCs and subnets + """ + + read_write = 1 + all = 2 + + def __repr__(self): + if self.name == "all": + return "vpc:*" + return "vpc:{}".format(self.name) + _scope_families = { "linodes": Linodes, "domains": Domains, @@ -271,6 +313,9 @@ def __repr__(self): "object_storage": ObjectStorage, "nodebalancers": NodeBalancers, "longview": Longview, + "images": Images, + "databases": Databases, + "vpc": VPC, } @staticmethod 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 d81360b97..6470d9df1 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -317,7 +317,7 @@ def get_domain_status(): domain.invalidate() return domain.status == "active" - wait_for_condition(3, 30, get_domain_status) + wait_for_condition(3, 45, get_domain_status) # Create a SRV record domain.record_create( @@ -468,7 +468,6 @@ 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) vpc = client.vpcs.create( @@ -480,6 +479,7 @@ def create_vpc(test_linode_client): "VPC IPv6 Stack", "Linode Interfaces", "Custom VPC IPv4 Ranges", + "Linodes", }, ), description="test description", @@ -490,6 +490,27 @@ 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) + + # GPUDirect RDMA capability not available for now + region = get_region( + test_linode_client, {"VPCs", "VPC IPv6 Stack", "Linode Interfaces"} + ) + + 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 +546,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/account/test_account.py b/test/integration/models/account/test_account.py index 286e9de06..2922b01fa 100644 --- a/test/integration/models/account/test_account.py +++ b/test/integration/models/account/test_account.py @@ -39,6 +39,9 @@ def test_get_account(test_linode_client): assert account_get.tax_id == account.tax_id +@pytest.mark.skip( + reason="Test fails for E2E test user with [504] Server Timeout due to enormous amount of logins. More details: ARB-7420" +) def test_get_login(test_linode_client): client = test_linode_client login = retry_sending_request(3, client.load, Login(client, "", {}), "") @@ -91,6 +94,8 @@ def test_update_maintenance_policy(test_linode_client): assert updated.maintenance_policy == original_policy +# May fail due to multiple events occurring on the test user in the same time +@pytest.mark.flaky(reruns=2, reruns_delay=5) @pytest.mark.smoke def test_latest_get_event(test_linode_client, e2e_test_firewall): client = test_linode_client @@ -115,7 +120,7 @@ def get_linode_status(): wait_for_condition(5, 150, get_linode_status) events = client.load(Event, "") - latest_events = events._raw_json.get("data")[:15] + latest_events = events._raw_json.get("data")[:50] linode.delete() @@ -123,7 +128,7 @@ def get_linode_status(): if label == event["entity"]["label"]: break else: - assert False, f"Linode '{label}' not found in the last 15 events" + assert False, f"Linode '{label}' not found in the last 50 events" def test_get_user(test_linode_client): diff --git a/test/integration/models/image/test_image.py b/test/integration/models/image/test_image.py index 18e223ff0..56b1ecf0d 100644 --- a/test/integration/models/image/test_image.py +++ b/test/integration/models/image/test_image.py @@ -43,12 +43,10 @@ def image_upload_url(test_linode_client): region = get_image_upload_regions(test_linode_client)[0] - test_linode_client.image_create_upload( + image, _ = test_linode_client.image_create_upload( label, region.id, "integration test image upload" ) - image = test_linode_client.images()[0] - yield image image.delete() 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/linode/test_linode.py b/test/integration/models/linode/test_linode.py index 55ae68880..0d607223b 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -102,7 +102,7 @@ def linode_and_vpc_for_legacy_interface_tests_offline( label = get_test_label(length=8) instance = test_linode_client.linode.instance_create( - "g6-standard-1", + "g5-standard-1", vpc.region, booted=False, image="linode/debian11", @@ -134,15 +134,21 @@ def linode_for_vpu_tests(test_linode_client, e2e_test_firewall): pytest.skip("No VPU capacity is currently available") label = get_test_label(length=8) - - linode_instance = client.linode.instance_create( - vpu_type, - region, - image="linode/debian12", - label=label, - firewall=e2e_test_firewall, - root_pass="aComplex@Password123", - ) + try: + linode_instance = client.linode.instance_create( + vpu_type, + region, + image="linode/debian12", + label=label, + firewall=e2e_test_firewall, + root_pass="aComplex@Password123", + ) + except ApiError as e: + reasons = e.errors or [str(e)] + unavailable_msg = "not currently available in the selected region" + if e.status == 400 and any(unavailable_msg in r for r in reasons): + pytest.skip("No VPU capacity is currently available") + raise yield linode_instance @@ -641,8 +647,22 @@ def test_linode_ips(create_linode): def test_linode_initate_migration(test_linode_client, e2e_test_firewall): client = test_linode_client - region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") label = get_test_label() + "_migration" + region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") + region_migrate = get_region( + client, {"Linodes", "Cloud Firewall"}, site_type="core" + ) + + # Cannot migrate linode to the same region + for _ in range(5): + if region_migrate.id != region.id: + break + + region_migrate = get_region( + client, {"Linodes", "Cloud Firewall"}, site_type="core" + ) + else: + pytest.skip("No alternative region to be used for linode migration") linode = client.linode.instance_create( "g6-nanode-1", @@ -657,7 +677,7 @@ def test_linode_initate_migration(test_linode_client, e2e_test_firewall): send_request_when_resource_available( 300, linode.initiate_migration, - region="us-central", + region=region_migrate, migration_type=MigrationType.COLD, ) @@ -1005,18 +1025,24 @@ def test_create_vpc( # TODO:: Add `VPCIPAddress.filters.linode_id == linode.id` filter back - # Attempt to resolve the IP from /vpcs/ips - all_vpc_ips = test_linode_client.vpcs.ips() - matched_ip = next( - ( - ip - for ip in all_vpc_ips - if ip.address == vpc_ip.address - and ip.vpc_id == vpc_ip.vpc_id - and ip.linode_id == vpc_ip.linode_id - ), - None, - ) + # Attempt to resolve the IP from /vpcs/ips. The account-wide listing + # may lag behind instance creation, so poll until the IP appears. + def resolve_vpc_ip(): + return next( + ( + ip + for ip in test_linode_client.vpcs.ips() + if ip.address == vpc_ip.address + and ip.vpc_id == vpc_ip.vpc_id + and ip.linode_id == vpc_ip.linode_id + ), + None, + ) + + try: + matched_ip = wait_for_condition(5, 120, resolve_vpc_ip) + except TimeoutError: + matched_ip = None assert ( matched_ip is not None @@ -1031,21 +1057,26 @@ def test_create_vpc( assert vpc_ips[0].linode_id == linode.id assert vpc_ips[0].nat_1_1 == linode.ips.ipv4.public[0].address - # Validate VPC IPv6 IPs from /vpcs/ips - all_vpc_ipv6 = test_linode_client.get("/vpcs/ipv6s")["data"] - - # Find matching VPC IPv6 entry - matched_ipv6 = next( - ( - ip - for ip in all_vpc_ipv6 - if ip["vpc_id"] == vpc.id - and ip["linode_id"] == linode.id - and ip["interface_id"] == interface.id - and ip["subnet_id"] == subnet.id - ), - None, - ) + # Validate VPC IPv6 IPs from /vpcs/ipv6s. The account-wide listing may + # lag behind instance creation, so poll until the entry appears. + def resolve_vpc_ipv6(): + all_vpc_ipv6 = test_linode_client.get("/vpcs/ipv6s")["data"] + return next( + ( + ip + for ip in all_vpc_ipv6 + if ip["vpc_id"] == vpc.id + and ip["linode_id"] == linode.id + and ip["interface_id"] == interface.id + and ip["subnet_id"] == subnet.id + ), + None, + ) + + try: + matched_ipv6 = wait_for_condition(5, 120, resolve_vpc_ipv6) + except TimeoutError: + matched_ipv6 = None assert ( matched_ipv6 diff --git a/test/integration/models/networking/test_networking.py b/test/integration/models/networking/test_networking.py index 0d028e6c3..18b6dabb1 100644 --- a/test/integration/models/networking/test_networking.py +++ b/test/integration/models/networking/test_networking.py @@ -304,14 +304,14 @@ def test_create_and_delete_vlan(test_linode_client, linode_for_vlan_tests): config.interfaces = [] config.save() - wait_for_condition(3, 100, get_status, linode, "running") + wait_for_condition(3, 150, get_status, linode, "running") retry_sending_request(3, linode.reboot) wait_for_condition(3, 100, get_status, linode, "rebooting") assert linode.status == "rebooting" - wait_for_condition(3, 100, get_status, linode, "running") + wait_for_condition(3, 150, get_status, linode, "running") # Delete the VLAN is_deleted = test_linode_client.networking.delete_vlan( diff --git a/test/integration/models/sharegroups/test_sharegroups.py b/test/integration/models/sharegroups/test_sharegroups.py index 1779c0469..e3ac79652 100644 --- a/test/integration/models/sharegroups/test_sharegroups.py +++ b/test/integration/models/sharegroups/test_sharegroups.py @@ -33,7 +33,7 @@ def wait_for_image_status( ) -@pytest.fixture(scope="class") +@pytest.fixture(scope="module") def sample_linode(test_linode_client, e2e_test_firewall): client = test_linode_client region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") @@ -50,14 +50,16 @@ def sample_linode(test_linode_client, e2e_test_firewall): linode_instance.delete() -@pytest.fixture(scope="class") +@pytest.fixture(scope="module") def create_image_id(test_linode_client, sample_linode): create_image = test_linode_client.images.create( sample_linode.disks[0], - label="linode-api4python-test-image-sharing-image", + label=get_test_label(8) + "_sharing-image", ) wait_for_image_status(test_linode_client, create_image.id, "available") + yield create_image.id + create_image.delete() 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/login_client_test.py b/test/unit/login_client_test.py index 5a17d77c1..74090d636 100644 --- a/test/unit/login_client_test.py +++ b/test/unit/login_client_test.py @@ -53,3 +53,21 @@ def test_parse_scopes_all(self): scopes, [getattr(c, "all") for c in OAuthScopes._scope_families.values()], ) + + def test_parse_scopes_databases_and_vpc(self): + """ + Tests parsing documented databases and vpc scopes + """ + scopes = OAuthScopes.parse( + "databases:read_only,databases:read_write,vpc:read_write,vpc:*" + ) + self.assertEqual( + scopes, + [ + OAuthScopes.Databases.read_only, + OAuthScopes.Databases.read_write, + OAuthScopes.VPC.read_write, + OAuthScopes.VPC.all, + ], + ) + self.assertEqual(OAuthScopes.parse("vpc:read_only"), []) 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("/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