From e38a3991f4b03718655f62df945ef765ce8fa8f1 Mon Sep 17 00:00:00 2001 From: Ye Chen Date: Mon, 8 Jun 2026 16:28:32 -0400 Subject: [PATCH 1/4] init --- instances.go | 26 +- interfaces.go | 76 ++++ regions.go | 1 + .../instance_create_with_rdma_interfaces.json | 9 + .../unit/fixtures/interface_get_rdma_vpc.json | 27 ++ .../fixtures/interface_list_with_rdma.json | 92 ++++ .../fixtures/interface_update_rdma_vpc.json | 27 ++ test/unit/fixtures/vpc_rdma_create.json | 25 ++ test/unit/fixtures/vpc_rdma_get.json | 36 ++ test/unit/rdma_vpc_test.go | 397 ++++++++++++++++++ vpc.go | 21 + vpc_subnet.go | 3 + 12 files changed, 738 insertions(+), 2 deletions(-) create mode 100644 test/unit/fixtures/instance_create_with_rdma_interfaces.json create mode 100644 test/unit/fixtures/interface_get_rdma_vpc.json create mode 100644 test/unit/fixtures/interface_list_with_rdma.json create mode 100644 test/unit/fixtures/interface_update_rdma_vpc.json create mode 100644 test/unit/fixtures/vpc_rdma_create.json create mode 100644 test/unit/fixtures/vpc_rdma_get.json create mode 100644 test/unit/rdma_vpc_test.go diff --git a/instances.go b/instances.go index b68d83094..25a4747c4 100644 --- a/instances.go +++ b/instances.go @@ -203,12 +203,22 @@ type InstanceCreateOptions struct { DiskEncryption InstanceDiskEncryption `json:"disk_encryption,omitempty"` PlacementGroup *InstanceCreatePlacementGroupOptions `json:"placement_group,omitempty"` + // LinodeInstanceInterfaces are the Linode Interfaces (including + // RDMA VPC interfaces) to create the new instance with. + // Conflicts with Interfaces and LinodeInterfaces. + // NOTE: RDMA VPC interfaces may not currently be available to all users. + LinodeInstanceInterfaces []LinodeInstanceInterfaceCreateOptions `json:"-"` + // Linode Interfaces to create the new instance with. - // Conflicts with Interfaces. + // Conflicts with Interfaces and LinodeInstanceInterfaces. + // + // Deprecated: Use LinodeInstanceInterfaces instead. LinodeInstanceInterfaces + // additionally allows specifying RDMA VPC interfaces. + // LinodeInterfaces is retained for backwards compatibility. LinodeInterfaces []LinodeInterfaceCreateOptions `json:"-"` // Legacy (config) Interfaces to create the new instance with. - // Conflicts with LinodeInterfaces. + // Conflicts with LinodeInterfaces and LinodeInstanceInterfaces. Interfaces []InstanceConfigInterfaceCreateOptions `json:"-"` // Creation fields that need to be set explicitly false, "", or 0 use pointers @@ -265,6 +275,14 @@ func (i InstanceCreateOptions) MarshalJSON() ([]byte, error) { return nil, fmt.Errorf("fields Interfaces and LinodeInterfaces cannot be specified together") } + if i.Interfaces != nil && i.LinodeInstanceInterfaces != nil { + return nil, fmt.Errorf("fields Interfaces and LinodeInstanceInterfaces cannot be specified together") + } + + if i.LinodeInterfaces != nil && i.LinodeInstanceInterfaces != nil { + return nil, fmt.Errorf("fields LinodeInterfaces and LinodeInstanceInterfaces cannot be specified together") + } + if i.Interfaces != nil { resultData.Interfaces = i.Interfaces } @@ -273,6 +291,10 @@ func (i InstanceCreateOptions) MarshalJSON() ([]byte, error) { resultData.Interfaces = i.LinodeInterfaces } + if i.LinodeInstanceInterfaces != nil { + resultData.Interfaces = i.LinodeInstanceInterfaces + } + return json.Marshal(resultData) } diff --git a/interfaces.go b/interfaces.go index b2d4e53c4..d612fadca 100644 --- a/interfaces.go +++ b/interfaces.go @@ -18,6 +18,11 @@ type LinodeInterface struct { Public *PublicInterface `json:"public"` VPC *VPCInterface `json:"vpc"` VLAN *VLANInterface `json:"vlan"` + + // RDMAVPC contains the configuration for an RDMA VPC interface attached + // to a GPUDirect RDMA capable Linode. + // NOTE: RDMA VPC interfaces may not currently be available to all users. + RDMAVPC *RDMAVPCInterface `json:"rdma_vpc"` } type InterfaceDefaultRoute struct { @@ -111,6 +116,29 @@ type VLANInterface struct { IPAMAddress *string `json:"ipam_address,omitempty"` } +// RDMAVPCInterface contains information about an RDMA VPC interface attached +// to a GPUDirect RDMA capable Linode. +// NOTE: RDMA VPC interfaces may not currently be available to all users. +type RDMAVPCInterface struct { + VPCID int `json:"vpc_id"` + SubnetID int `json:"subnet_id"` + IPv4 RDMAVPCInterfaceIPv4 `json:"ipv4"` +} + +// RDMAVPCInterfaceIPv4 contains the IPv4 configuration for an RDMA VPC interface. +// NOTE: RDMA VPC interfaces may not currently be available to all users. +type RDMAVPCInterfaceIPv4 struct { + Addresses []RDMAVPCInterfaceIPv4Address `json:"addresses"` +} + +// RDMAVPCInterfaceIPv4Address represents a single IPv4 address assigned to an +// RDMA VPC interface. +// NOTE: RDMA VPC interfaces may not currently be available to all users. +type RDMAVPCInterfaceIPv4Address struct { + Address string `json:"address"` + Primary bool `json:"primary"` +} + type LinodeInterfaceCreateOptions struct { FirewallID *int `json:"firewall_id,omitempty"` DefaultRoute *InterfaceDefaultRoute `json:"default_route,omitempty"` @@ -119,10 +147,27 @@ type LinodeInterfaceCreateOptions struct { VLAN *VLANInterface `json:"vlan,omitempty"` } +// LinodeInstanceInterfaceCreateOptions specifies a Linode Interface to be +// created as part of a Linode creation request with RDMA VPC options available. +// +// The standalone interface create endpoint does NOT accept RDMA VPC +// interfaces and therefore continues to use LinodeInterfaceCreateOptions. +type LinodeInstanceInterfaceCreateOptions struct { + LinodeInterfaceCreateOptions + + // RDMAVPC creates an RDMA VPC interface attached to the new Linode. + // Only one of Public, VPC, VLAN or RDMAVPC may be set per interface. + // NOTE: RDMA VPC interfaces may not currently be available to all users. + RDMAVPC *RDMAVPCInterfaceCreateOptions `json:"rdma_vpc,omitzero"` +} + type LinodeInterfaceUpdateOptions struct { DefaultRoute *InterfaceDefaultRoute `json:"default_route,omitempty"` Public *PublicInterfaceCreateOptions `json:"public,omitempty"` VPC *VPCInterfaceUpdateOptions `json:"vpc,omitempty"` + + // NOTE: RDMA VPC interfaces may not currently be available to all users. + RDMAVPC *RDMAVPCInterfaceUpdateOptions `json:"rdma_vpc,omitzero"` } type PublicInterfaceCreateOptions struct { @@ -193,6 +238,37 @@ type VPCInterfaceUpdateOptions struct { IPv6 *VPCInterfaceIPv6CreateOptions `json:"ipv6,omitempty"` } +// RDMAVPCInterfaceCreateOptions specifies parameters for creating an RDMA VPC +// interface as part of a Linode creation. +// NOTE: RDMA VPC interfaces may not currently be available to all users. +type RDMAVPCInterfaceCreateOptions struct { + SubnetID int `json:"subnet_id"` + IPv4 RDMAVPCInterfaceIPv4Options `json:"ipv4,omitzero"` +} + +// RDMAVPCInterfaceUpdateOptions specifies the mutable fields of an RDMA VPC +// interface. +// NOTE: RDMA VPC interfaces may not currently be available to all users. +type RDMAVPCInterfaceUpdateOptions struct { + SubnetID int `json:"subnet_id,omitzero"` + IPv4 RDMAVPCInterfaceIPv4Options `json:"ipv4,omitzero"` +} + +// RDMAVPCInterfaceIPv4CreateOptions specifies IPv4 parameters for an RDMA VPC +// interface. +// NOTE: RDMA VPC interfaces may not currently be available to all users. +type RDMAVPCInterfaceIPv4Options struct { + Addresses []RDMAVPCInterfaceIPv4AddressOptions `json:"addresses,omitzero"` +} + +// RDMAVPCInterfaceIPv4AddressCreateOptions represents a single IPv4 address +// configuration for an RDMA VPC interface. +// NOTE: RDMA VPC interfaces may not currently be available to all users. +type RDMAVPCInterfaceIPv4AddressOptions struct { + Address string `json:"address,omitzero"` + Primary bool `json:"primary,omitzero"` +} + type LinodeInterfacesUpgrade struct { ConfigID int `json:"config_id"` DryRun bool `json:"dry_run"` diff --git a/regions.go b/regions.go index 935d9aba4..3c56cdee4 100644 --- a/regions.go +++ b/regions.go @@ -29,6 +29,7 @@ const ( CapabilityDistributedPlans string = "Distributed Plans" CapabilityEdgePlans string = "Edge Plans" CapabilityGPU string = "GPU Linodes" + CapabilityGPUDirectRDMA string = "GPUDirect RDMA" CapabilityKubernetesEnterprise string = "Kubernetes Enterprise" CapabilityKubernetesEnterpriseBYOVPC string = "Kubernetes Enterprise BYO VPC" CapabilityKubernetesEnterpriseDualStack string = "Kubernetes Enterprise Dual Stack" diff --git a/test/unit/fixtures/instance_create_with_rdma_interfaces.json b/test/unit/fixtures/instance_create_with_rdma_interfaces.json new file mode 100644 index 000000000..f31bb4de0 --- /dev/null +++ b/test/unit/fixtures/instance_create_with_rdma_interfaces.json @@ -0,0 +1,9 @@ +{ + "id": 506958, + "label": "rdma-gpu-instance", + "region": "fake-cph-5", + "type": "g2-gpu-rdma-1", + "status": "provisioning", + "interface_generation": "linode" +} + diff --git a/test/unit/fixtures/interface_get_rdma_vpc.json b/test/unit/fixtures/interface_get_rdma_vpc.json new file mode 100644 index 000000000..edd70ad0f --- /dev/null +++ b/test/unit/fixtures/interface_get_rdma_vpc.json @@ -0,0 +1,27 @@ +{ + "id": 10, + "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/unit/fixtures/interface_list_with_rdma.json b/test/unit/fixtures/interface_list_with_rdma.json new file mode 100644 index 000000000..633f68f7f --- /dev/null +++ b/test/unit/fixtures/interface_list_with_rdma.json @@ -0,0 +1,92 @@ +{ + "data": [ + { + "id": 10, + "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": 39, + "subnet_id": 1234, + "ipv4": { + "addresses": [ + { + "address": "10.0.0.1", + "primary": true + } + ] + } + } + }, + { + "id": 11, + "mac_address": "22:00:f2:9e:d3:49", + "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": 39, + "subnet_id": 1234, + "ipv4": { + "addresses": [ + { + "address": "10.0.0.25", + "primary": true + } + ] + } + } + }, + { + "id": 12, + "mac_address": "22:00:f2:9e:d3:4a", + "created": "2026-03-12T09:54:34", + "updated": "2026-03-12T09:54:35", + "default_route": { + "ipv4": true, + "ipv6": false + }, + "version": 1, + "public": null, + "vlan": null, + "rdma_vpc": null, + "vpc": { + "vpc_id": 3, + "subnet_id": 4, + "ipv4": { + "addresses": [ + { + "address": "10.0.0.25", + "primary": true + } + ], + "ranges": [] + }, + "ipv6": { + "slaac": [], + "ranges": [], + "is_public": null + } + } + } + ], + "page": 1, + "pages": 1, + "results": 3 +} + diff --git a/test/unit/fixtures/interface_update_rdma_vpc.json b/test/unit/fixtures/interface_update_rdma_vpc.json new file mode 100644 index 000000000..067fc91c5 --- /dev/null +++ b/test/unit/fixtures/interface_update_rdma_vpc.json @@ -0,0 +1,27 @@ +{ + "id": 10, + "mac_address": "22:00:f2:9e:d3:48", + "created": "2026-03-12T09:54:34", + "updated": "2026-03-13T10:00:00", + "default_route": { + "ipv4": false, + "ipv6": false + }, + "version": 2, + "public": null, + "vpc": null, + "vlan": null, + "rdma_vpc": { + "vpc_id": 7, + "subnet_id": 9, + "ipv4": { + "addresses": [ + { + "address": "10.0.1.5", + "primary": true + } + ] + } + } +} + diff --git a/test/unit/fixtures/vpc_rdma_create.json b/test/unit/fixtures/vpc_rdma_create.json new file mode 100644 index 000000000..a5a1e2225 --- /dev/null +++ b/test/unit/fixtures/vpc_rdma_create.json @@ -0,0 +1,25 @@ +{ + "id": 39, + "label": "new-rdma-vpc", + "vpc_type": "rdma", + "description": "A new RDMA VPC", + "region": "fake-cph-5", + "ipv6": [], + "subnets": [ + { + "id": 40, + "label": "rdma-subnet-1", + "ipv4": "10.0.0.0/24", + "vpc_type": "rdma", + "ipv6": [], + "linodes": [], + "databases": [], + "nodebalancers": [], + "created": "2026-03-12T09:30:01", + "updated": "2026-03-12T09:30:01" + } + ], + "created": "2026-03-12T09:30:01", + "updated": "2026-03-12T09:30:01" +} + diff --git a/test/unit/fixtures/vpc_rdma_get.json b/test/unit/fixtures/vpc_rdma_get.json new file mode 100644 index 000000000..b073fea31 --- /dev/null +++ b/test/unit/fixtures/vpc_rdma_get.json @@ -0,0 +1,36 @@ +{ + "created": "2026-03-12T09:30:01", + "description": "RDMA VPC for GPUDirect", + "id": 7, + "label": "test-vpc-rdma", + "vpc_type": "rdma", + "region": "fake-cph-5", + "ipv6": [], + "subnets": [ + { + "id": 8, + "label": "rdma-subnet", + "ipv4": "10.0.0.0/8", + "vpc_type": "rdma", + "ipv6": [], + "linodes": [ + { + "id": 506958, + "interfaces": [ + { + "id": 10, + "config_id": null, + "active": false + } + ] + } + ], + "databases": [], + "nodebalancers": [], + "created": "2026-03-12T09:51:58", + "updated": "2026-03-12T09:51:58" + } + ], + "updated": "2026-03-12T09:30:01" +} + diff --git a/test/unit/rdma_vpc_test.go b/test/unit/rdma_vpc_test.go new file mode 100644 index 000000000..82ebef206 --- /dev/null +++ b/test/unit/rdma_vpc_test.go @@ -0,0 +1,397 @@ +package unit + +import ( + "context" + "encoding/json" + "testing" + + "github.com/jarcoal/httpmock" + "github.com/linode/linodego" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVPC_RDMA_Get(t *testing.T) { + fixtureData, err := fixtures.GetFixture("vpc_rdma_get") + require.NoError(t, err) + + var base ClientBaseCase + base.SetUp(t) + defer base.TearDown(t) + + base.MockGet("vpcs/7", fixtureData) + + vpc, err := base.Client.GetVPC(context.Background(), 7) + require.NoError(t, err) + require.NotNil(t, vpc) + + assert.Equal(t, 7, vpc.ID) + assert.Equal(t, "test-vpc-rdma", vpc.Label) + assert.Equal(t, "fake-cph-5", vpc.Region) + assert.Equal(t, linodego.VPCTypeRDMA, vpc.VPCType) + assert.Equal(t, "RDMA VPC for GPUDirect", vpc.Description) + assert.Empty(t, vpc.IPv6) + + // Subnet assertions + require.Len(t, vpc.Subnets, 1) + subnet := vpc.Subnets[0] + assert.Equal(t, 8, subnet.ID) + assert.Equal(t, "rdma-subnet", subnet.Label) + assert.Equal(t, "10.0.0.0/8", subnet.IPv4) + assert.Equal(t, linodego.VPCTypeRDMA, subnet.VPCType) + assert.Empty(t, subnet.IPv6) + + // Subnet linode/interface assertions + require.Len(t, subnet.Linodes, 1) + assert.Equal(t, 506958, subnet.Linodes[0].ID) + require.Len(t, subnet.Linodes[0].Interfaces, 1) + assert.Equal(t, 10, subnet.Linodes[0].Interfaces[0].ID) + assert.Nil(t, subnet.Linodes[0].Interfaces[0].ConfigID) + assert.Equal(t, false, subnet.Linodes[0].Interfaces[0].Active) +} + +func TestVPC_RDMA_Create(t *testing.T) { + fixtureData, err := fixtures.GetFixture("vpc_rdma_create") + require.NoError(t, err) + + var base ClientBaseCase + base.SetUp(t) + defer base.TearDown(t) + + createOptions := linodego.VPCCreateOptions{ + Label: "new-rdma-vpc", + Description: "A new RDMA VPC", + Region: "fake-cph-5", + VPCType: linodego.VPCTypeRDMA, + Subnets: []linodego.VPCSubnetCreateOptions{ + {Label: "rdma-subnet-1", IPv4: "10.0.0.0/24"}, + }, + } + + httpmock.RegisterRegexpResponder( + "POST", + mockRequestURL(t, "/vpcs"), + mockRequestBodyValidate(t, createOptions, fixtureData), + ) + + vpc, err := base.Client.CreateVPC(context.Background(), createOptions) + require.NoError(t, err) + require.NotNil(t, vpc) + + assert.Equal(t, 39, vpc.ID) + assert.Equal(t, "new-rdma-vpc", vpc.Label) + assert.Equal(t, linodego.VPCTypeRDMA, vpc.VPCType) + assert.Equal(t, "fake-cph-5", vpc.Region) + + require.Len(t, vpc.Subnets, 1) + assert.Equal(t, 40, vpc.Subnets[0].ID) + assert.Equal(t, linodego.VPCTypeRDMA, vpc.Subnets[0].VPCType) +} + +func TestVPC_Regular_VPCType(t *testing.T) { + var base ClientBaseCase + base.SetUp(t) + defer base.TearDown(t) + + base.MockPost("vpcs", linodego.VPC{ + ID: 100, + Label: "regular-vpc", + Region: "us-east", + VPCType: linodego.VPCTypeRegular, + }) + + vpc, err := base.Client.CreateVPC(context.Background(), linodego.VPCCreateOptions{ + Label: "regular-vpc", + Region: "us-east", + VPCType: linodego.VPCTypeRegular, + }) + require.NoError(t, err) + assert.Equal(t, linodego.VPCTypeRegular, vpc.VPCType) +} + +func TestVPC_VPCType_OmittedWhenEmpty(t *testing.T) { + opts := linodego.VPCCreateOptions{ + Label: "test", + Region: "us-east", + } + + data, err := json.Marshal(opts) + require.NoError(t, err) + + var parsed map[string]interface{} + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + _, exists := parsed["vpc_type"] + assert.False(t, exists, "vpc_type should be omitted when empty") +} + +func TestInterface_GetRDMAVPC(t *testing.T) { + fixtureData, err := fixtures.GetFixture("interface_get_rdma_vpc") + require.NoError(t, err) + + var base ClientBaseCase + base.SetUp(t) + defer base.TearDown(t) + + base.MockGet("linode/instances/506958/interfaces/10", fixtureData) + + iface, err := base.Client.GetInterface(context.Background(), 506958, 10) + require.NoError(t, err) + require.NotNil(t, iface) + + assert.Equal(t, 10, iface.ID) + assert.Equal(t, 1, iface.Version) + assert.Equal(t, "22:00:f2:9e:d3:48", iface.MACAddress) + assert.Equal(t, false, *iface.DefaultRoute.IPv4) + assert.Equal(t, false, *iface.DefaultRoute.IPv6) + + // Non-RDMA fields should be nil + assert.Nil(t, iface.Public) + assert.Nil(t, iface.VPC) + assert.Nil(t, iface.VLAN) + + // RDMA VPC assertions + require.NotNil(t, iface.RDMAVPC) + assert.Equal(t, 7, iface.RDMAVPC.VPCID) + assert.Equal(t, 8, iface.RDMAVPC.SubnetID) + + require.Len(t, iface.RDMAVPC.IPv4.Addresses, 1) + assert.Equal(t, "10.0.0.2", iface.RDMAVPC.IPv4.Addresses[0].Address) + assert.Equal(t, true, iface.RDMAVPC.IPv4.Addresses[0].Primary) +} + +func TestInterface_ListWithRDMA(t *testing.T) { + fixtureData, err := fixtures.GetFixture("interface_list_with_rdma") + require.NoError(t, err) + + var base ClientBaseCase + base.SetUp(t) + defer base.TearDown(t) + + base.MockGet("linode/instances/506958/interfaces", fixtureData) + + ifaces, err := base.Client.ListInterfaces(context.Background(), 506958, nil) + require.NoError(t, err) + require.Len(t, ifaces, 3) + + // First interface: RDMA VPC + assert.Equal(t, 10, ifaces[0].ID) + require.NotNil(t, ifaces[0].RDMAVPC) + assert.Equal(t, 39, ifaces[0].RDMAVPC.VPCID) + assert.Equal(t, 1234, ifaces[0].RDMAVPC.SubnetID) + assert.Equal(t, "10.0.0.1", ifaces[0].RDMAVPC.IPv4.Addresses[0].Address) + assert.True(t, ifaces[0].RDMAVPC.IPv4.Addresses[0].Primary) + assert.Nil(t, ifaces[0].VPC) + assert.Nil(t, ifaces[0].Public) + + // Second interface: RDMA VPC + assert.Equal(t, 11, ifaces[1].ID) + require.NotNil(t, ifaces[1].RDMAVPC) + assert.Equal(t, 39, ifaces[1].RDMAVPC.VPCID) + assert.Equal(t, "10.0.0.25", ifaces[1].RDMAVPC.IPv4.Addresses[0].Address) + + // Third interface: regular VPC + assert.Equal(t, 12, ifaces[2].ID) + assert.Nil(t, ifaces[2].RDMAVPC) + require.NotNil(t, ifaces[2].VPC) + assert.Equal(t, 3, ifaces[2].VPC.VPCID) + assert.Equal(t, 4, ifaces[2].VPC.SubnetID) + assert.True(t, *ifaces[2].DefaultRoute.IPv4) +} + +func TestInterface_UpdateRDMAVPC(t *testing.T) { + fixtureData, err := fixtures.GetFixture("interface_update_rdma_vpc") + require.NoError(t, err) + + var base ClientBaseCase + base.SetUp(t) + defer base.TearDown(t) + + base.MockPut("linode/instances/506958/interfaces/10", fixtureData) + + opts := linodego.LinodeInterfaceUpdateOptions{ + RDMAVPC: &linodego.RDMAVPCInterfaceUpdateOptions{ + SubnetID: 9, + IPv4: linodego.RDMAVPCInterfaceIPv4Options{ + Addresses: []linodego.RDMAVPCInterfaceIPv4AddressOptions{ + {Address: "10.0.1.5", Primary: true}, + }, + }, + }, + } + + iface, err := base.Client.UpdateInterface(context.Background(), 506958, 10, opts) + require.NoError(t, err) + require.NotNil(t, iface) + + assert.Equal(t, 10, iface.ID) + assert.Equal(t, 2, iface.Version) + require.NotNil(t, iface.RDMAVPC) + assert.Equal(t, 7, iface.RDMAVPC.VPCID) + assert.Equal(t, 9, iface.RDMAVPC.SubnetID) + assert.Equal(t, "10.0.1.5", iface.RDMAVPC.IPv4.Addresses[0].Address) + assert.True(t, iface.RDMAVPC.IPv4.Addresses[0].Primary) +} + +// ============================================================================= +// Instance Create with RDMA Interfaces (LinodeInstanceInterfaces) Tests +// ============================================================================= + +func TestInstance_CreateWithRDMAInterfaces_MarshalJSON(t *testing.T) { + createOptions := linodego.InstanceCreateOptions{ + Region: "fake-cph-5", + Type: "g2-gpu-rdma-1", + InterfaceGeneration: linodego.GenerationLinode, + LinodeInstanceInterfaces: []linodego.LinodeInstanceInterfaceCreateOptions{ + { + RDMAVPC: &linodego.RDMAVPCInterfaceCreateOptions{ + SubnetID: 1234, + IPv4: linodego.RDMAVPCInterfaceIPv4Options{ + Addresses: []linodego.RDMAVPCInterfaceIPv4AddressOptions{ + {Address: "auto", Primary: true}, + }, + }, + }, + }, + }, + } + + data, err := json.Marshal(createOptions) + require.NoError(t, err) + + var parsed map[string]interface{} + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + // Verify interfaces array is correctly serialized + ifaces, ok := parsed["interfaces"].([]interface{}) + require.True(t, ok, "expected interfaces to be an array") + require.Len(t, ifaces, 1) + + ifaceMap, ok := ifaces[0].(map[string]interface{}) + require.True(t, ok) + + rdmaVPC, ok := ifaceMap["rdma_vpc"].(map[string]interface{}) + require.True(t, ok, "expected rdma_vpc key in interface") + assert.Equal(t, float64(1234), rdmaVPC["subnet_id"]) + + ipv4, ok := rdmaVPC["ipv4"].(map[string]interface{}) + require.True(t, ok) + addresses, ok := ipv4["addresses"].([]interface{}) + require.True(t, ok) + require.Len(t, addresses, 1) + + addr, ok := addresses[0].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "auto", addr["address"]) + assert.Equal(t, true, addr["primary"]) +} + +func TestInstance_CreateWithLinodeInterfaces_BackwardsCompatible(t *testing.T) { + // Ensure existing LinodeInterfaces field still works + createOptions := linodego.InstanceCreateOptions{ + Region: "us-east", + Type: "g6-standard-1", + InterfaceGeneration: linodego.GenerationLinode, + LinodeInterfaces: []linodego.LinodeInterfaceCreateOptions{ + { + VPC: &linodego.VPCInterfaceCreateOptions{ + SubnetID: 4, + IPv4: &linodego.VPCInterfaceIPv4CreateOptions{ + Addresses: &[]linodego.VPCInterfaceIPv4AddressCreateOptions{ + { + Address: linodego.Pointer("10.0.0.5"), + Primary: linodego.Pointer(true), + }, + }, + }, + }, + }, + }, + } + + data, err := json.Marshal(createOptions) + require.NoError(t, err) + + var parsed map[string]interface{} + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + // Verify interfaces array is correctly serialized + ifaces, ok := parsed["interfaces"].([]interface{}) + require.True(t, ok, "expected interfaces to be an array") + require.Len(t, ifaces, 1) + + ifaceMap, ok := ifaces[0].(map[string]interface{}) + require.True(t, ok) + + vpcData, ok := ifaceMap["vpc"].(map[string]interface{}) + require.True(t, ok, "expected vpc key in interface") + assert.Equal(t, float64(4), vpcData["subnet_id"]) +} + +func TestInstance_Create_ConflictingInterfaceFields(t *testing.T) { + t.Run("LinodeInterfaces_and_LinodeInstanceInterfaces", func(t *testing.T) { + createOptions := linodego.InstanceCreateOptions{ + Region: "us-east", + Type: "g6-standard-1", + LinodeInterfaces: []linodego.LinodeInterfaceCreateOptions{ + {}, + }, + LinodeInstanceInterfaces: []linodego.LinodeInstanceInterfaceCreateOptions{ + {}, + }, + } + + _, err := json.Marshal(createOptions) + require.Error(t, err) + assert.Contains(t, err.Error(), "LinodeInterfaces and LinodeInstanceInterfaces") + }) + + t.Run("Interfaces_and_LinodeInstanceInterfaces", func(t *testing.T) { + createOptions := linodego.InstanceCreateOptions{ + Region: "us-east", + Type: "g6-standard-1", + Interfaces: []linodego.InstanceConfigInterfaceCreateOptions{ + {}, + }, + LinodeInstanceInterfaces: []linodego.LinodeInstanceInterfaceCreateOptions{ + {}, + }, + } + + _, err := json.Marshal(createOptions) + require.Error(t, err) + assert.Contains(t, err.Error(), "Interfaces and LinodeInstanceInterfaces") + }) +} + + +// ============================================================================= +// VPC Subnet RDMA Type Tests +// ============================================================================= + +func TestVPCSubnet_RDMA_VPCType(t *testing.T) { + // Test that VPCSubnet properly unmarshals vpc_type field + jsonData := `{ + "id": 8, + "label": "rdma-subnet", + "ipv4": "10.0.0.0/8", + "vpc_type": "rdma", + "ipv6": [], + "linodes": [], + "databases": [], + "nodebalancers": [], + "created": "2026-03-12T09:51:58", + "updated": "2026-03-12T09:51:58" + }` + + var subnet linodego.VPCSubnet + err := json.Unmarshal([]byte(jsonData), &subnet) + require.NoError(t, err) + + assert.Equal(t, 8, subnet.ID) + assert.Equal(t, "rdma-subnet", subnet.Label) + assert.Equal(t, linodego.VPCTypeRDMA, subnet.VPCType) +} \ No newline at end of file diff --git a/vpc.go b/vpc.go index 6b26f6bc2..716945a24 100644 --- a/vpc.go +++ b/vpc.go @@ -8,12 +8,27 @@ import ( "github.com/linode/linodego/internal/parseabletime" ) +// VPCType represents the type of a VPC. +type VPCType string + +const ( + // VPCTypeRegular is the default VPC type. + VPCTypeRegular VPCType = "regular" + + // VPCTypeRDMA represents a GPUDirect RDMA VPC. + // NOTE: RDMA VPCs may not currently be available to all users. + VPCTypeRDMA VPCType = "rdma" +) + type VPC struct { ID int `json:"id"` Label string `json:"label"` Description string `json:"description"` Region string `json:"region"` + // NOTE: RDMA VPCs may not currently be available to all users. + VPCType VPCType `json:"vpc_type"` + // NOTE: IPv6 VPCs may not currently be available to all users. IPv6 []VPCIPv6Range `json:"ipv6"` @@ -33,6 +48,11 @@ type VPCCreateOptions struct { Description string `json:"description,omitempty"` Region string `json:"region"` + // This field is omitted by the API for customers that do not have + // access to the GPUDirect RDMA functionality. + // NOTE: RDMA VPCs may not currently be available to all users. + VPCType VPCType `json:"vpc_type,omitzero"` + // NOTE: IPv6 VPCs may not currently be available to all users. IPv6 []VPCCreateOptionsIPv6 `json:"ipv6,omitempty"` @@ -62,6 +82,7 @@ func (v VPC) GetCreateOptions() VPCCreateOptions { Label: v.Label, Description: v.Description, Region: v.Region, + VPCType: v.VPCType, Subnets: subnetCreations, IPv6: mapSlice(v.IPv6, func(i VPCIPv6Range) VPCCreateOptionsIPv6 { return VPCCreateOptionsIPv6{ diff --git a/vpc_subnet.go b/vpc_subnet.go index e44b61326..323c29ebf 100644 --- a/vpc_subnet.go +++ b/vpc_subnet.go @@ -46,6 +46,9 @@ type VPCSubnet struct { Label string `json:"label"` IPv4 string `json:"ipv4"` + // NOTE: RDMA VPCs may not currently be available to all users. + VPCType VPCType `json:"vpc_type"` + // NOTE: IPv6 VPCs may not currently be available to all users. IPv6 []VPCIPv6Range `json:"ipv6"` From 096e5fec087c2b829c8d0ff2aabe11ea3a3871ce Mon Sep 17 00:00:00 2001 From: Ye Chen Date: Mon, 8 Jun 2026 16:39:47 -0400 Subject: [PATCH 2/4] lint --- interfaces.go | 4 ++-- test/unit/rdma_vpc_test.go | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/interfaces.go b/interfaces.go index d612fadca..4550afb45 100644 --- a/interfaces.go +++ b/interfaces.go @@ -254,14 +254,14 @@ type RDMAVPCInterfaceUpdateOptions struct { IPv4 RDMAVPCInterfaceIPv4Options `json:"ipv4,omitzero"` } -// RDMAVPCInterfaceIPv4CreateOptions specifies IPv4 parameters for an RDMA VPC +// RDMAVPCInterfaceIPv4Options specifies IPv4 parameters for an RDMA VPC // interface. // NOTE: RDMA VPC interfaces may not currently be available to all users. type RDMAVPCInterfaceIPv4Options struct { Addresses []RDMAVPCInterfaceIPv4AddressOptions `json:"addresses,omitzero"` } -// RDMAVPCInterfaceIPv4AddressCreateOptions represents a single IPv4 address +// RDMAVPCInterfaceIPv4AddressOptions represents a single IPv4 address // configuration for an RDMA VPC interface. // NOTE: RDMA VPC interfaces may not currently be available to all users. type RDMAVPCInterfaceIPv4AddressOptions struct { diff --git a/test/unit/rdma_vpc_test.go b/test/unit/rdma_vpc_test.go index 82ebef206..cc167c9d4 100644 --- a/test/unit/rdma_vpc_test.go +++ b/test/unit/rdma_vpc_test.go @@ -367,7 +367,6 @@ func TestInstance_Create_ConflictingInterfaceFields(t *testing.T) { }) } - // ============================================================================= // VPC Subnet RDMA Type Tests // ============================================================================= @@ -394,4 +393,4 @@ func TestVPCSubnet_RDMA_VPCType(t *testing.T) { assert.Equal(t, 8, subnet.ID) assert.Equal(t, "rdma-subnet", subnet.Label) assert.Equal(t, linodego.VPCTypeRDMA, subnet.VPCType) -} \ No newline at end of file +} From 3e80244670e4a42a06a38f1ce13badc041768f12 Mon Sep 17 00:00:00 2001 From: Ye Chen Date: Tue, 16 Jun 2026 12:37:16 -0400 Subject: [PATCH 3/4] address comments for omitzero --- interfaces.go | 2 +- .../instance_create_with_rdma_interfaces.json | 9 --- test/unit/rdma_vpc_test.go | 70 ++++++++++++++++++- 3 files changed, 69 insertions(+), 12 deletions(-) delete mode 100644 test/unit/fixtures/instance_create_with_rdma_interfaces.json diff --git a/interfaces.go b/interfaces.go index 4550afb45..9f6b75605 100644 --- a/interfaces.go +++ b/interfaces.go @@ -266,7 +266,7 @@ type RDMAVPCInterfaceIPv4Options struct { // NOTE: RDMA VPC interfaces may not currently be available to all users. type RDMAVPCInterfaceIPv4AddressOptions struct { Address string `json:"address,omitzero"` - Primary bool `json:"primary,omitzero"` + Primary *bool `json:"primary,omitzero"` } type LinodeInterfacesUpgrade struct { diff --git a/test/unit/fixtures/instance_create_with_rdma_interfaces.json b/test/unit/fixtures/instance_create_with_rdma_interfaces.json deleted file mode 100644 index f31bb4de0..000000000 --- a/test/unit/fixtures/instance_create_with_rdma_interfaces.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "id": 506958, - "label": "rdma-gpu-instance", - "region": "fake-cph-5", - "type": "g2-gpu-rdma-1", - "status": "provisioning", - "interface_generation": "linode" -} - diff --git a/test/unit/rdma_vpc_test.go b/test/unit/rdma_vpc_test.go index cc167c9d4..64f23e34b 100644 --- a/test/unit/rdma_vpc_test.go +++ b/test/unit/rdma_vpc_test.go @@ -215,7 +215,7 @@ func TestInterface_UpdateRDMAVPC(t *testing.T) { SubnetID: 9, IPv4: linodego.RDMAVPCInterfaceIPv4Options{ Addresses: []linodego.RDMAVPCInterfaceIPv4AddressOptions{ - {Address: "10.0.1.5", Primary: true}, + {Address: "10.0.1.5", Primary: linodego.Pointer(true)}, }, }, }, @@ -249,7 +249,7 @@ func TestInstance_CreateWithRDMAInterfaces_MarshalJSON(t *testing.T) { SubnetID: 1234, IPv4: linodego.RDMAVPCInterfaceIPv4Options{ Addresses: []linodego.RDMAVPCInterfaceIPv4AddressOptions{ - {Address: "auto", Primary: true}, + {Address: "auto", Primary: linodego.Pointer(true)}, }, }, }, @@ -394,3 +394,69 @@ func TestVPCSubnet_RDMA_VPCType(t *testing.T) { assert.Equal(t, "rdma-subnet", subnet.Label) assert.Equal(t, linodego.VPCTypeRDMA, subnet.VPCType) } + +// ============================================================================= +// RDMA VPC Interface Option Marshaling-Semantics Tests +// ============================================================================= + +// jsonToMap marshals v and unmarshals it into a generic map for key inspection. +func jsonToMap(t *testing.T, v any) map[string]any { + t.Helper() + + data, err := json.Marshal(v) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(data, &parsed)) + + return parsed +} + +func TestRDMAVPCInterface_ExplicitPrimaryFalse(t *testing.T) { + // A caller MUST be able to send "primary": false explicitly. + // Primary is a pointer precisely so that false != unset. + opts := linodego.RDMAVPCInterfaceCreateOptions{ + SubnetID: 1234, + IPv4: linodego.RDMAVPCInterfaceIPv4Options{ + Addresses: []linodego.RDMAVPCInterfaceIPv4AddressOptions{ + { + Address: "10.0.0.5", + Primary: linodego.Pointer(false), + }, + }, + }, + } + + parsed := jsonToMap(t, opts) + + ipv4 := parsed["ipv4"].(map[string]any) + addresses := ipv4["addresses"].([]any) + require.Len(t, addresses, 1) + + addr := addresses[0].(map[string]any) + primary, exists := addr["primary"] + require.True(t, exists, "primary must be present when explicitly set to false") + assert.Equal(t, false, primary) +} + +func TestRDMAVPCInterface_ExplicitEmptyAddresses(t *testing.T) { + // On update, a caller MUST be able to send an explicit empty addresses list. + // A non-nil empty slice is NOT the zero value, so omitzero still marshals it. + opts := linodego.RDMAVPCInterfaceUpdateOptions{ + IPv4: linodego.RDMAVPCInterfaceIPv4Options{ + Addresses: []linodego.RDMAVPCInterfaceIPv4AddressOptions{}, + }, + } + + parsed := jsonToMap(t, opts) + + ipv4, exists := parsed["ipv4"] + require.True(t, exists, "ipv4 must be present when it holds an explicit empty addresses list") + + addresses, exists := ipv4.(map[string]any)["addresses"] + require.True(t, exists, "addresses must be present when explicitly set to an empty slice") + + addrSlice, ok := addresses.([]any) + require.True(t, ok, "addresses should serialize as a JSON array") + assert.Empty(t, addrSlice, "addresses should be an explicit empty array") +} From 9caa5376709abfa80d8d1835083097372759cf26 Mon Sep 17 00:00:00 2001 From: Ye Chen Date: Tue, 16 Jun 2026 12:40:33 -0400 Subject: [PATCH 4/4] lint --- interfaces.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/interfaces.go b/interfaces.go index 9f6b75605..4ef04a8a5 100644 --- a/interfaces.go +++ b/interfaces.go @@ -266,7 +266,7 @@ type RDMAVPCInterfaceIPv4Options struct { // NOTE: RDMA VPC interfaces may not currently be available to all users. type RDMAVPCInterfaceIPv4AddressOptions struct { Address string `json:"address,omitzero"` - Primary *bool `json:"primary,omitzero"` + Primary *bool `json:"primary,omitzero"` } type LinodeInterfacesUpgrade struct {