From ab0b7cf10d208717d7c2ef383834ec7777d995ce Mon Sep 17 00:00:00 2001 From: Ashley Dumaine Date: Thu, 31 Jul 2025 17:00:16 -0400 Subject: [PATCH 01/10] add support for new network interfaces on LinodeMachines --- api/v1alpha2/linodemachine_types.go | 93 +++ api/v1alpha2/zz_generated.deepcopy.go | 341 ++++++++ clients/clients.go | 6 + ...cture.cluster.x-k8s.io_linodemachines.yaml | 143 ++++ ...uster.x-k8s.io_linodemachinetemplates.yaml | 146 ++++ docs/src/reference/out.md | 256 ++++++ go.mod | 2 +- go.sum | 4 +- .../linodemachine_controller_helpers.go | 507 ++++++++++-- .../linodemachine_controller_helpers_test.go | 777 +++++++++++++++++- .../linodemachine_controller_test.go | 752 ++++++++++++++++- .../webhook/v1alpha2/linodemachine_webhook.go | 13 +- mock/client.go | 53 ++ .../wrappers/linodeclient/linodeclient.gen.go | 26 + 14 files changed, 3009 insertions(+), 110 deletions(-) diff --git a/api/v1alpha2/linodemachine_types.go b/api/v1alpha2/linodemachine_types.go index c103c4811..66e32641a 100644 --- a/api/v1alpha2/linodemachine_types.go +++ b/api/v1alpha2/linodemachine_types.go @@ -62,6 +62,9 @@ type LinodeMachineSpec struct { // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" Interfaces []InstanceConfigInterfaceCreateOptions `json:"interfaces,omitempty"` // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" + // +kubebuilder:object:generate=true + LinodeInterfaces []LinodeInterfaceCreateOptions `json:"linodeInterfaces,omitempty"` + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" BackupsEnabled bool `json:"backupsEnabled,omitempty"` // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" PrivateIP *bool `json:"privateIP,omitempty"` @@ -194,6 +197,96 @@ type InstanceConfigInterfaceCreateOptions struct { IPRanges []string `json:"ipRanges,omitempty"` } +// LinodeInterfaceCreateOptions defines the linode network interface config +type LinodeInterfaceCreateOptions struct { + FirewallID *int `json:"firewall_id,omitempty"` + DefaultRoute *InterfaceDefaultRoute `json:"default_route,omitempty"` + Public *PublicInterfaceCreateOptions `json:"public,omitempty"` + VPC *VPCInterfaceCreateOptions `json:"vpc,omitempty"` + VLAN *VLANInterface `json:"vlan,omitempty"` +} + +// InterfaceDefaultRoute defines the default IPv4 and IPv6 routes for an interface +type InterfaceDefaultRoute struct { + IPv4 *bool `json:"ipv4,omitempty"` + IPv6 *bool `json:"ipv6,omitempty"` +} + +// PublicInterfaceCreateOptions defines the IPv4 and IPv6 public interface create options +type PublicInterfaceCreateOptions struct { + IPv4 *PublicInterfaceIPv4CreateOptions `json:"ipv4,omitempty"` + IPv6 *PublicInterfaceIPv6CreateOptions `json:"ipv6,omitempty"` +} + +// PublicInterfaceIPv4CreateOptions defines the PublicInterfaceIPv4AddressCreateOptions for addresses +type PublicInterfaceIPv4CreateOptions struct { + Addresses []PublicInterfaceIPv4AddressCreateOptions `json:"addresses,omitempty"` +} + +// PublicInterfaceIPv4AddressCreateOptions defines the public IPv4 address and whether it is primary +type PublicInterfaceIPv4AddressCreateOptions struct { + Address string `json:"address"` + Primary *bool `json:"primary,omitempty"` +} + +// PublicInterfaceIPv6CreateOptions defines the PublicInterfaceIPv6RangeCreateOptions +type PublicInterfaceIPv6CreateOptions struct { + Ranges []PublicInterfaceIPv6RangeCreateOptions `json:"ranges,omitempty"` +} + +// PublicInterfaceIPv6RangeCreateOptions defines the IPv6 range for a public interface +type PublicInterfaceIPv6RangeCreateOptions struct { + Range string `json:"range"` +} + +// VPCInterfaceCreateOptions defines the VPC interface configuration for an instance +type VPCInterfaceCreateOptions struct { + SubnetID int `json:"subnet_id"` + IPv4 *VPCInterfaceIPv4CreateOptions `json:"ipv4,omitempty"` + IPv6 *VPCInterfaceIPv6CreateOptions `json:"ipv6,omitempty"` +} + +// VPCInterfaceIPv6CreateOptions defines the IPv6 configuration for a VPC interface +type VPCInterfaceIPv6CreateOptions struct { + SLAAC []VPCInterfaceIPv6SLAACCreateOptions `json:"slaac,omitempty"` + Ranges []VPCInterfaceIPv6RangeCreateOptions `json:"ranges,omitempty"` + IsPublic bool `json:"is_public"` +} + +// VPCInterfaceIPv6SLAACCreateOptions defines the Range for IPv6 SLAAC +type VPCInterfaceIPv6SLAACCreateOptions struct { + Range string `json:"range"` +} + +// VPCInterfaceIPv6RangeCreateOptions defines the IPv6 range for a VPC interface +type VPCInterfaceIPv6RangeCreateOptions struct { + Range string `json:"range"` +} + +// VPCInterfaceIPv4CreateOptions defines the IPv4 address and range configuration for a VPC interface +type VPCInterfaceIPv4CreateOptions struct { + Addresses []VPCInterfaceIPv4AddressCreateOptions `json:"addresses,omitempty"` + Ranges []VPCInterfaceIPv4RangeCreateOptions `json:"ranges,omitempty"` +} + +// VPCInterfaceIPv4AddressCreateOptions defines the IPv4 configuration for a VPC interface +type VPCInterfaceIPv4AddressCreateOptions struct { + Address string `json:"address"` + Primary *bool `json:"primary,omitempty"` + NAT1To1Address *string `json:"nat_1_1_address,omitempty"` +} + +// VPCInterfaceIPv4RangeCreateOptions defines the IPv4 range for a VPC interface +type VPCInterfaceIPv4RangeCreateOptions struct { + Range string `json:"range"` +} + +// VLANInterface defines the VLAN interface configuration for an instance +type VLANInterface struct { + VLANLabel string `json:"vlan_label"` + IPAMAddress *string `json:"ipam_address,omitempty"` +} + // VPCIPv4 defines VPC IPV4 settings type VPCIPv4 struct { VPC string `json:"vpc,omitempty"` diff --git a/api/v1alpha2/zz_generated.deepcopy.go b/api/v1alpha2/zz_generated.deepcopy.go index aca089cd7..25df77f57 100644 --- a/api/v1alpha2/zz_generated.deepcopy.go +++ b/api/v1alpha2/zz_generated.deepcopy.go @@ -383,6 +383,31 @@ func (in *InstanceMetadataOptions) DeepCopy() *InstanceMetadataOptions { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InterfaceDefaultRoute) DeepCopyInto(out *InterfaceDefaultRoute) { + *out = *in + if in.IPv4 != nil { + in, out := &in.IPv4, &out.IPv4 + *out = new(bool) + **out = **in + } + if in.IPv6 != nil { + in, out := &in.IPv6, &out.IPv6 + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InterfaceDefaultRoute. +func (in *InterfaceDefaultRoute) DeepCopy() *InterfaceDefaultRoute { + if in == nil { + return nil + } + out := new(InterfaceDefaultRoute) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *LinodeCluster) DeepCopyInto(out *LinodeCluster) { *out = *in @@ -758,6 +783,46 @@ func (in *LinodeFirewallStatus) DeepCopy() *LinodeFirewallStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LinodeInterfaceCreateOptions) DeepCopyInto(out *LinodeInterfaceCreateOptions) { + *out = *in + if in.FirewallID != nil { + in, out := &in.FirewallID, &out.FirewallID + *out = new(int) + **out = **in + } + if in.DefaultRoute != nil { + in, out := &in.DefaultRoute, &out.DefaultRoute + *out = new(InterfaceDefaultRoute) + (*in).DeepCopyInto(*out) + } + if in.Public != nil { + in, out := &in.Public, &out.Public + *out = new(PublicInterfaceCreateOptions) + (*in).DeepCopyInto(*out) + } + if in.VPC != nil { + in, out := &in.VPC, &out.VPC + *out = new(VPCInterfaceCreateOptions) + (*in).DeepCopyInto(*out) + } + if in.VLAN != nil { + in, out := &in.VLAN, &out.VLAN + *out = new(VLANInterface) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LinodeInterfaceCreateOptions. +func (in *LinodeInterfaceCreateOptions) DeepCopy() *LinodeInterfaceCreateOptions { + if in == nil { + return nil + } + out := new(LinodeInterfaceCreateOptions) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *LinodeMachine) DeepCopyInto(out *LinodeMachine) { *out = *in @@ -847,6 +912,13 @@ func (in *LinodeMachineSpec) DeepCopyInto(out *LinodeMachineSpec) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.LinodeInterfaces != nil { + in, out := &in.LinodeInterfaces, &out.LinodeInterfaces + *out = make([]LinodeInterfaceCreateOptions, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } if in.PrivateIP != nil { in, out := &in.PrivateIP, &out.PrivateIP *out = new(bool) @@ -1710,6 +1782,128 @@ func (in *ObjectStore) DeepCopy() *ObjectStore { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PublicInterfaceCreateOptions) DeepCopyInto(out *PublicInterfaceCreateOptions) { + *out = *in + if in.IPv4 != nil { + in, out := &in.IPv4, &out.IPv4 + *out = new(PublicInterfaceIPv4CreateOptions) + (*in).DeepCopyInto(*out) + } + if in.IPv6 != nil { + in, out := &in.IPv6, &out.IPv6 + *out = new(PublicInterfaceIPv6CreateOptions) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PublicInterfaceCreateOptions. +func (in *PublicInterfaceCreateOptions) DeepCopy() *PublicInterfaceCreateOptions { + if in == nil { + return nil + } + out := new(PublicInterfaceCreateOptions) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PublicInterfaceIPv4AddressCreateOptions) DeepCopyInto(out *PublicInterfaceIPv4AddressCreateOptions) { + *out = *in + if in.Primary != nil { + in, out := &in.Primary, &out.Primary + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PublicInterfaceIPv4AddressCreateOptions. +func (in *PublicInterfaceIPv4AddressCreateOptions) DeepCopy() *PublicInterfaceIPv4AddressCreateOptions { + if in == nil { + return nil + } + out := new(PublicInterfaceIPv4AddressCreateOptions) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PublicInterfaceIPv4CreateOptions) DeepCopyInto(out *PublicInterfaceIPv4CreateOptions) { + *out = *in + if in.Addresses != nil { + in, out := &in.Addresses, &out.Addresses + *out = make([]PublicInterfaceIPv4AddressCreateOptions, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PublicInterfaceIPv4CreateOptions. +func (in *PublicInterfaceIPv4CreateOptions) DeepCopy() *PublicInterfaceIPv4CreateOptions { + if in == nil { + return nil + } + out := new(PublicInterfaceIPv4CreateOptions) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PublicInterfaceIPv6CreateOptions) DeepCopyInto(out *PublicInterfaceIPv6CreateOptions) { + *out = *in + if in.Ranges != nil { + in, out := &in.Ranges, &out.Ranges + *out = make([]PublicInterfaceIPv6RangeCreateOptions, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PublicInterfaceIPv6CreateOptions. +func (in *PublicInterfaceIPv6CreateOptions) DeepCopy() *PublicInterfaceIPv6CreateOptions { + if in == nil { + return nil + } + out := new(PublicInterfaceIPv6CreateOptions) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PublicInterfaceIPv6RangeCreateOptions) DeepCopyInto(out *PublicInterfaceIPv6RangeCreateOptions) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PublicInterfaceIPv6RangeCreateOptions. +func (in *PublicInterfaceIPv6RangeCreateOptions) DeepCopy() *PublicInterfaceIPv6RangeCreateOptions { + if in == nil { + return nil + } + out := new(PublicInterfaceIPv6RangeCreateOptions) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VLANInterface) DeepCopyInto(out *VLANInterface) { + *out = *in + if in.IPAMAddress != nil { + in, out := &in.IPAMAddress, &out.IPAMAddress + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VLANInterface. +func (in *VLANInterface) DeepCopy() *VLANInterface { + if in == nil { + return nil + } + out := new(VLANInterface) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *VPCCreateOptionsIPv6) DeepCopyInto(out *VPCCreateOptionsIPv6) { *out = *in @@ -1750,6 +1944,153 @@ func (in *VPCIPv4) DeepCopy() *VPCIPv4 { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VPCInterfaceCreateOptions) DeepCopyInto(out *VPCInterfaceCreateOptions) { + *out = *in + if in.IPv4 != nil { + in, out := &in.IPv4, &out.IPv4 + *out = new(VPCInterfaceIPv4CreateOptions) + (*in).DeepCopyInto(*out) + } + if in.IPv6 != nil { + in, out := &in.IPv6, &out.IPv6 + *out = new(VPCInterfaceIPv6CreateOptions) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VPCInterfaceCreateOptions. +func (in *VPCInterfaceCreateOptions) DeepCopy() *VPCInterfaceCreateOptions { + if in == nil { + return nil + } + out := new(VPCInterfaceCreateOptions) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VPCInterfaceIPv4AddressCreateOptions) DeepCopyInto(out *VPCInterfaceIPv4AddressCreateOptions) { + *out = *in + if in.Primary != nil { + in, out := &in.Primary, &out.Primary + *out = new(bool) + **out = **in + } + if in.NAT1To1Address != nil { + in, out := &in.NAT1To1Address, &out.NAT1To1Address + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VPCInterfaceIPv4AddressCreateOptions. +func (in *VPCInterfaceIPv4AddressCreateOptions) DeepCopy() *VPCInterfaceIPv4AddressCreateOptions { + if in == nil { + return nil + } + out := new(VPCInterfaceIPv4AddressCreateOptions) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VPCInterfaceIPv4CreateOptions) DeepCopyInto(out *VPCInterfaceIPv4CreateOptions) { + *out = *in + if in.Addresses != nil { + in, out := &in.Addresses, &out.Addresses + *out = make([]VPCInterfaceIPv4AddressCreateOptions, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Ranges != nil { + in, out := &in.Ranges, &out.Ranges + *out = make([]VPCInterfaceIPv4RangeCreateOptions, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VPCInterfaceIPv4CreateOptions. +func (in *VPCInterfaceIPv4CreateOptions) DeepCopy() *VPCInterfaceIPv4CreateOptions { + if in == nil { + return nil + } + out := new(VPCInterfaceIPv4CreateOptions) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VPCInterfaceIPv4RangeCreateOptions) DeepCopyInto(out *VPCInterfaceIPv4RangeCreateOptions) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VPCInterfaceIPv4RangeCreateOptions. +func (in *VPCInterfaceIPv4RangeCreateOptions) DeepCopy() *VPCInterfaceIPv4RangeCreateOptions { + if in == nil { + return nil + } + out := new(VPCInterfaceIPv4RangeCreateOptions) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VPCInterfaceIPv6CreateOptions) DeepCopyInto(out *VPCInterfaceIPv6CreateOptions) { + *out = *in + if in.SLAAC != nil { + in, out := &in.SLAAC, &out.SLAAC + *out = make([]VPCInterfaceIPv6SLAACCreateOptions, len(*in)) + copy(*out, *in) + } + if in.Ranges != nil { + in, out := &in.Ranges, &out.Ranges + *out = make([]VPCInterfaceIPv6RangeCreateOptions, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VPCInterfaceIPv6CreateOptions. +func (in *VPCInterfaceIPv6CreateOptions) DeepCopy() *VPCInterfaceIPv6CreateOptions { + if in == nil { + return nil + } + out := new(VPCInterfaceIPv6CreateOptions) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VPCInterfaceIPv6RangeCreateOptions) DeepCopyInto(out *VPCInterfaceIPv6RangeCreateOptions) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VPCInterfaceIPv6RangeCreateOptions. +func (in *VPCInterfaceIPv6RangeCreateOptions) DeepCopy() *VPCInterfaceIPv6RangeCreateOptions { + if in == nil { + return nil + } + out := new(VPCInterfaceIPv6RangeCreateOptions) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VPCInterfaceIPv6SLAACCreateOptions) DeepCopyInto(out *VPCInterfaceIPv6SLAACCreateOptions) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VPCInterfaceIPv6SLAACCreateOptions. +func (in *VPCInterfaceIPv6SLAACCreateOptions) DeepCopy() *VPCInterfaceIPv6SLAACCreateOptions { + if in == nil { + return nil + } + out := new(VPCInterfaceIPv6SLAACCreateOptions) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *VPCSubnetCreateOptions) DeepCopyInto(out *VPCSubnetCreateOptions) { *out = *in diff --git a/clients/clients.go b/clients/clients.go index 6b6a50507..cafb0c9c6 100644 --- a/clients/clients.go +++ b/clients/clients.go @@ -23,6 +23,7 @@ type LinodeClient interface { LinodePlacementGroupClient LinodeFirewallClient LinodeTokenClient + LinodeInterfacesClient OnAfterResponse(m func(response *resty.Response) error) } @@ -125,6 +126,11 @@ type LinodeFirewallClient interface { DeleteFirewallDevice(ctx context.Context, firewallID, deviceID int) error } +// LinodeInterfacesClient defines the methods that interact with Linode's Interfaces service. +type LinodeInterfacesClient interface { + ListInterfaces(ctx context.Context, linodeID int, opts *linodego.ListOptions) ([]linodego.LinodeInterface, error) +} + type K8sClient interface { client.Client } diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_linodemachines.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_linodemachines.yaml index 32a92ace3..48436f3cd 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_linodemachines.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_linodemachines.yaml @@ -300,6 +300,149 @@ spec: x-kubernetes-validations: - message: Value is immutable rule: self == oldSelf + linodeInterfaces: + items: + description: LinodeInterfaceCreateOptions defines the linode network + interface config + properties: + default_route: + description: InterfaceDefaultRoute defines the default IPv4 + and IPv6 routes for an interface + properties: + ipv4: + type: boolean + ipv6: + type: boolean + type: object + firewall_id: + type: integer + public: + description: PublicInterfaceCreateOptions defines the IPv4 and + IPv6 public interface create options + properties: + ipv4: + description: PublicInterfaceIPv4CreateOptions defines the + PublicInterfaceIPv4AddressCreateOptions for addresses + properties: + addresses: + items: + description: PublicInterfaceIPv4AddressCreateOptions + defines the public IPv4 address and whether it is + primary + properties: + address: + type: string + primary: + type: boolean + required: + - address + type: object + type: array + type: object + ipv6: + description: PublicInterfaceIPv6CreateOptions defines the + PublicInterfaceIPv6RangeCreateOptions + properties: + ranges: + items: + description: PublicInterfaceIPv6RangeCreateOptions + defines the IPv6 range for a public interface + properties: + range: + type: string + required: + - range + type: object + type: array + type: object + type: object + vlan: + description: VLANInterface defines the VLAN interface configuration + for an instance + properties: + ipam_address: + type: string + vlan_label: + type: string + required: + - vlan_label + type: object + vpc: + description: VPCInterfaceCreateOptions defines the VPC interface + configuration for an instance + properties: + ipv4: + description: VPCInterfaceIPv4CreateOptions defines the IPv4 + address and range configuration for a VPC interface + properties: + addresses: + items: + description: VPCInterfaceIPv4AddressCreateOptions + defines the IPv4 configuration for a VPC interface + properties: + address: + type: string + nat_1_1_address: + type: string + primary: + type: boolean + required: + - address + type: object + type: array + ranges: + items: + description: VPCInterfaceIPv4RangeCreateOptions defines + the IPv4 range for a VPC interface + properties: + range: + type: string + required: + - range + type: object + type: array + type: object + ipv6: + description: VPCInterfaceIPv6CreateOptions defines the IPv6 + configuration for a VPC interface + properties: + is_public: + type: boolean + ranges: + items: + description: VPCInterfaceIPv6RangeCreateOptions defines + the IPv6 range for a VPC interface + properties: + range: + type: string + required: + - range + type: object + type: array + slaac: + items: + description: VPCInterfaceIPv6SLAACCreateOptions defines + the Range for IPv6 SLAAC + properties: + range: + type: string + required: + - range + type: object + type: array + required: + - is_public + type: object + subnet_id: + type: integer + required: + - subnet_id + type: object + type: object + type: array + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf networkHelper: description: |- NetworkHelper is an option usually enabled on account level. It helps configure networking automatically for instances. diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_linodemachinetemplates.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_linodemachinetemplates.yaml index 52efc9f41..2dae501e2 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_linodemachinetemplates.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_linodemachinetemplates.yaml @@ -292,6 +292,152 @@ spec: x-kubernetes-validations: - message: Value is immutable rule: self == oldSelf + linodeInterfaces: + items: + description: LinodeInterfaceCreateOptions defines the linode + network interface config + properties: + default_route: + description: InterfaceDefaultRoute defines the default + IPv4 and IPv6 routes for an interface + properties: + ipv4: + type: boolean + ipv6: + type: boolean + type: object + firewall_id: + type: integer + public: + description: PublicInterfaceCreateOptions defines the + IPv4 and IPv6 public interface create options + properties: + ipv4: + description: PublicInterfaceIPv4CreateOptions defines + the PublicInterfaceIPv4AddressCreateOptions for + addresses + properties: + addresses: + items: + description: PublicInterfaceIPv4AddressCreateOptions + defines the public IPv4 address and whether + it is primary + properties: + address: + type: string + primary: + type: boolean + required: + - address + type: object + type: array + type: object + ipv6: + description: PublicInterfaceIPv6CreateOptions defines + the PublicInterfaceIPv6RangeCreateOptions + properties: + ranges: + items: + description: PublicInterfaceIPv6RangeCreateOptions + defines the IPv6 range for a public interface + properties: + range: + type: string + required: + - range + type: object + type: array + type: object + type: object + vlan: + description: VLANInterface defines the VLAN interface + configuration for an instance + properties: + ipam_address: + type: string + vlan_label: + type: string + required: + - vlan_label + type: object + vpc: + description: VPCInterfaceCreateOptions defines the VPC + interface configuration for an instance + properties: + ipv4: + description: VPCInterfaceIPv4CreateOptions defines + the IPv4 address and range configuration for a + VPC interface + properties: + addresses: + items: + description: VPCInterfaceIPv4AddressCreateOptions + defines the IPv4 configuration for a VPC + interface + properties: + address: + type: string + nat_1_1_address: + type: string + primary: + type: boolean + required: + - address + type: object + type: array + ranges: + items: + description: VPCInterfaceIPv4RangeCreateOptions + defines the IPv4 range for a VPC interface + properties: + range: + type: string + required: + - range + type: object + type: array + type: object + ipv6: + description: VPCInterfaceIPv6CreateOptions defines + the IPv6 configuration for a VPC interface + properties: + is_public: + type: boolean + ranges: + items: + description: VPCInterfaceIPv6RangeCreateOptions + defines the IPv6 range for a VPC interface + properties: + range: + type: string + required: + - range + type: object + type: array + slaac: + items: + description: VPCInterfaceIPv6SLAACCreateOptions + defines the Range for IPv6 SLAAC + properties: + range: + type: string + required: + - range + type: object + type: array + required: + - is_public + type: object + subnet_id: + type: integer + required: + - subnet_id + type: object + type: object + type: array + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf networkHelper: description: |- NetworkHelper is an option usually enabled on account level. It helps configure networking automatically for instances. diff --git a/docs/src/reference/out.md b/docs/src/reference/out.md index 7a1e0e5db..730f9f849 100644 --- a/docs/src/reference/out.md +++ b/docs/src/reference/out.md @@ -316,6 +316,23 @@ _Appears in:_ +#### InterfaceDefaultRoute + + + +InterfaceDefaultRoute defines the default IPv4 and IPv6 routes for an interface + + + +_Appears in:_ +- [LinodeInterfaceCreateOptions](#linodeinterfacecreateoptions) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `ipv4` _boolean_ | | | | +| `ipv6` _boolean_ | | | | + + #### LinodeCluster @@ -559,6 +576,26 @@ _Appears in:_ | `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.32/#condition-v1-meta) array_ | Conditions defines current service state of the LinodeFirewall. | | | +#### LinodeInterfaceCreateOptions + + + +LinodeInterfaceCreateOptions defines the linode network interface config + + + +_Appears in:_ +- [LinodeMachineSpec](#linodemachinespec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `firewall_id` _integer_ | | | | +| `default_route` _[InterfaceDefaultRoute](#interfacedefaultroute)_ | | | | +| `public` _[PublicInterfaceCreateOptions](#publicinterfacecreateoptions)_ | | | | +| `vpc` _[VPCInterfaceCreateOptions](#vpcinterfacecreateoptions)_ | | | | +| `vlan` _[VLANInterface](#vlaninterface)_ | | | | + + #### LinodeMachine @@ -626,6 +663,7 @@ _Appears in:_ | `backupID` _integer_ | | | | | `image` _string_ | | | | | `interfaces` _[InstanceConfigInterfaceCreateOptions](#instanceconfiginterfacecreateoptions) array_ | | | | +| `linodeInterfaces` _[LinodeInterfaceCreateOptions](#linodeinterfacecreateoptions) array_ | | | | | `backupsEnabled` _boolean_ | | | | | `privateIP` _boolean_ | | | | | `tags` _string array_ | Tags is a list of tags to apply to the Linode instance. | | | @@ -1211,6 +1249,105 @@ _Appears in:_ | `credentialsRef` _[SecretReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.32/#secretreference-v1-core)_ | CredentialsRef is a reference to a Secret that contains the credentials to use for accessing the Cluster Object Store. | | | +#### PublicInterfaceCreateOptions + + + +PublicInterfaceCreateOptions defines the IPv4 and IPv6 public interface create options + + + +_Appears in:_ +- [LinodeInterfaceCreateOptions](#linodeinterfacecreateoptions) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `ipv4` _[PublicInterfaceIPv4CreateOptions](#publicinterfaceipv4createoptions)_ | | | | +| `ipv6` _[PublicInterfaceIPv6CreateOptions](#publicinterfaceipv6createoptions)_ | | | | + + +#### PublicInterfaceIPv4AddressCreateOptions + + + +PublicInterfaceIPv4AddressCreateOptions defines the public IPv4 address and whether it is primary + + + +_Appears in:_ +- [PublicInterfaceIPv4CreateOptions](#publicinterfaceipv4createoptions) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `address` _string_ | | | | +| `primary` _boolean_ | | | | + + +#### PublicInterfaceIPv4CreateOptions + + + +PublicInterfaceIPv4CreateOptions defines the PublicInterfaceIPv4AddressCreateOptions for addresses + + + +_Appears in:_ +- [PublicInterfaceCreateOptions](#publicinterfacecreateoptions) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `addresses` _[PublicInterfaceIPv4AddressCreateOptions](#publicinterfaceipv4addresscreateoptions) array_ | | | | + + +#### PublicInterfaceIPv6CreateOptions + + + +PublicInterfaceIPv6CreateOptions defines the PublicInterfaceIPv6RangeCreateOptions + + + +_Appears in:_ +- [PublicInterfaceCreateOptions](#publicinterfacecreateoptions) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `ranges` _[PublicInterfaceIPv6RangeCreateOptions](#publicinterfaceipv6rangecreateoptions) array_ | | | | + + +#### PublicInterfaceIPv6RangeCreateOptions + + + +PublicInterfaceIPv6RangeCreateOptions defines the IPv6 range for a public interface + + + +_Appears in:_ +- [PublicInterfaceIPv6CreateOptions](#publicinterfaceipv6createoptions) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `range` _string_ | | | | + + +#### VLANInterface + + + +VLANInterface defines the VLAN interface configuration for an instance + + + +_Appears in:_ +- [LinodeInterfaceCreateOptions](#linodeinterfacecreateoptions) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `vlan_label` _string_ | | | | +| `ipam_address` _string_ | | | | + + #### VPCCreateOptionsIPv6 @@ -1248,6 +1385,125 @@ _Appears in:_ | `nat1to1` _string_ | | | | +#### VPCInterfaceCreateOptions + + + +VPCInterfaceCreateOptions defines the VPC interface configuration for an instance + + + +_Appears in:_ +- [LinodeInterfaceCreateOptions](#linodeinterfacecreateoptions) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `subnet_id` _integer_ | | | | +| `ipv4` _[VPCInterfaceIPv4CreateOptions](#vpcinterfaceipv4createoptions)_ | | | | +| `ipv6` _[VPCInterfaceIPv6CreateOptions](#vpcinterfaceipv6createoptions)_ | | | | + + +#### VPCInterfaceIPv4AddressCreateOptions + + + +VPCInterfaceIPv4AddressCreateOptions defines the IPv4 configuration for a VPC interface + + + +_Appears in:_ +- [VPCInterfaceIPv4CreateOptions](#vpcinterfaceipv4createoptions) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `address` _string_ | | | | +| `primary` _boolean_ | | | | +| `nat_1_1_address` _string_ | | | | + + +#### VPCInterfaceIPv4CreateOptions + + + +VPCInterfaceIPv4CreateOptions defines the IPv4 address and range configuration for a VPC interface + + + +_Appears in:_ +- [VPCInterfaceCreateOptions](#vpcinterfacecreateoptions) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `addresses` _[VPCInterfaceIPv4AddressCreateOptions](#vpcinterfaceipv4addresscreateoptions) array_ | | | | +| `ranges` _[VPCInterfaceIPv4RangeCreateOptions](#vpcinterfaceipv4rangecreateoptions) array_ | | | | + + +#### VPCInterfaceIPv4RangeCreateOptions + + + +VPCInterfaceIPv4RangeCreateOptions defines the IPv4 range for a VPC interface + + + +_Appears in:_ +- [VPCInterfaceIPv4CreateOptions](#vpcinterfaceipv4createoptions) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `range` _string_ | | | | + + +#### VPCInterfaceIPv6CreateOptions + + + +VPCInterfaceIPv6CreateOptions defines the IPv6 configuration for a VPC interface + + + +_Appears in:_ +- [VPCInterfaceCreateOptions](#vpcinterfacecreateoptions) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `slaac` _[VPCInterfaceIPv6SLAACCreateOptions](#vpcinterfaceipv6slaaccreateoptions) array_ | | | | +| `ranges` _[VPCInterfaceIPv6RangeCreateOptions](#vpcinterfaceipv6rangecreateoptions) array_ | | | | +| `is_public` _boolean_ | | | | + + +#### VPCInterfaceIPv6RangeCreateOptions + + + +VPCInterfaceIPv6RangeCreateOptions defines the IPv6 range for a VPC interface + + + +_Appears in:_ +- [VPCInterfaceIPv6CreateOptions](#vpcinterfaceipv6createoptions) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `range` _string_ | | | | + + +#### VPCInterfaceIPv6SLAACCreateOptions + + + +VPCInterfaceIPv6SLAACCreateOptions defines the Range for IPv6 SLAAC + + + +_Appears in:_ +- [VPCInterfaceIPv6CreateOptions](#vpcinterfaceipv6createoptions) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `range` _string_ | | | | + + #### VPCStatusError _Underlying type:_ _string_ diff --git a/go.mod b/go.mod index 85f5420d0..efa9be5cf 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( github.com/go-logr/logr v1.4.3 github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 - github.com/linode/linodego v1.53.1-0.20250728194520-172cba1c457a + github.com/linode/linodego v1.54.1-0.20250728194520-172cba1c457a github.com/onsi/ginkgo/v2 v2.23.4 github.com/onsi/gomega v1.38.0 github.com/stretchr/testify v1.10.0 diff --git a/go.sum b/go.sum index bcc169f9f..64485e6ce 100644 --- a/go.sum +++ b/go.sum @@ -177,8 +177,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/linode/linodego v1.53.1-0.20250728194520-172cba1c457a h1:5PaGcDTgxlOZOaYNChSKHnzZp4oKFvzqEn8TQ7hv2Pg= -github.com/linode/linodego v1.53.1-0.20250728194520-172cba1c457a/go.mod h1:VHlFAbhj18634Cd7B7L5D723kFKFQMOxzIutSMcWsB4= +github.com/linode/linodego v1.54.1-0.20250728194520-172cba1c457a h1:WtHqziHGlueosyUPxXIswDO5tQewj2fgeekD4GsdHoo= +github.com/linode/linodego v1.54.1-0.20250728194520-172cba1c457a/go.mod h1:VHlFAbhj18634Cd7B7L5D723kFKFQMOxzIutSMcWsB4= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= diff --git a/internal/controller/linodemachine_controller_helpers.go b/internal/controller/linodemachine_controller_helpers.go index 21f794974..d5d7b77c6 100644 --- a/internal/controller/linodemachine_controller_helpers.go +++ b/internal/controller/linodemachine_controller_helpers.go @@ -169,15 +169,29 @@ func configureVPCInterface(ctx context.Context, machineScope *scope.MachineScope // addVPCInterfaceFromDirectID handles adding a VPC interface from a direct ID func addVPCInterfaceFromDirectID(ctx context.Context, machineScope *scope.MachineScope, createConfig *linodego.InstanceCreateOptions, logger logr.Logger, vpcID int) error { - iface, err := getVPCInterfaceConfigFromDirectID(ctx, machineScope, createConfig.Interfaces, logger, vpcID) - if err != nil { - logger.Error(err, "Failed to get VPC interface config from direct ID") - return err - } + switch { + case createConfig.LinodeInterfaces != nil: + iface, err := getVPCLinodeInterfaceConfigFromDirectID(ctx, machineScope, createConfig.LinodeInterfaces, logger, vpcID) + if err != nil { + logger.Error(err, "Failed to get VPC linode interface config from direct ID") + return err + } - if iface != nil { - // add VPC interface as first interface - createConfig.Interfaces = slices.Insert(createConfig.Interfaces, 0, *iface) + if iface != nil { + // add VPC interface as first interface + createConfig.LinodeInterfaces = slices.Insert(createConfig.LinodeInterfaces, 0, *iface) + } + default: + iface, err := getVPCInterfaceConfigFromDirectID(ctx, machineScope, createConfig.Interfaces, logger, vpcID) + if err != nil { + logger.Error(err, "Failed to get VPC interface config from direct ID") + return err + } + + if iface != nil { + // add VPC interface as first interface + createConfig.Interfaces = slices.Insert(createConfig.Interfaces, 0, *iface) + } } return nil @@ -185,15 +199,29 @@ func addVPCInterfaceFromDirectID(ctx context.Context, machineScope *scope.Machin // addVPCInterfaceFromReference handles adding a VPC interface from a reference func addVPCInterfaceFromReference(ctx context.Context, machineScope *scope.MachineScope, createConfig *linodego.InstanceCreateOptions, logger logr.Logger, vpcRef *corev1.ObjectReference) error { - iface, err := getVPCInterfaceConfig(ctx, machineScope, createConfig.Interfaces, logger, vpcRef) - if err != nil { - logger.Error(err, "Failed to get VPC interface config") - return err - } + switch { + case createConfig.LinodeInterfaces != nil: + iface, err := getVPCLinodeInterfaceConfig(ctx, machineScope, createConfig.LinodeInterfaces, logger, vpcRef) + if err != nil { + logger.Error(err, "Failed to get VPC interface config") + return err + } + + if iface != nil { + // add VPC interface as first interface + createConfig.LinodeInterfaces = slices.Insert(createConfig.LinodeInterfaces, 0, *iface) + } + default: + iface, err := getVPCInterfaceConfig(ctx, machineScope, createConfig.Interfaces, logger, vpcRef) + if err != nil { + logger.Error(err, "Failed to get VPC interface config") + return err + } - if iface != nil { - // add VPC interface as first interface - createConfig.Interfaces = slices.Insert(createConfig.Interfaces, 0, *iface) + if iface != nil { + // add VPC interface as first interface + createConfig.Interfaces = slices.Insert(createConfig.Interfaces, 0, *iface) + } } return nil @@ -238,10 +266,49 @@ func buildInstanceAddrs(ctx context.Context, machineScope *scope.MachineScope, i } if machineScope.LinodeCluster.Spec.Network.UseVlan { + vlanIps, err := handleVlanIps(ctx, machineScope, instanceID) + if err != nil { + return nil, fmt.Errorf("handle vlan ips: %w", err) + } + ips = append(ips, vlanIps...) + } + + // if a node has private ip, store it as well + // NOTE: We specifically store VPC ips first so that they are used first during + // bootstrap when we set `registrationMethod: internal-only-ips` + if len(addresses.IPv4.Private) != 0 { + ips = append(ips, clusterv1.MachineAddress{ + Address: addresses.IPv4.Private[0].Address, + Type: clusterv1.MachineInternalIP, + }) + } + + return ips, nil +} + +func handleVlanIps(ctx context.Context, machineScope *scope.MachineScope, instanceID int) ([]clusterv1.MachineAddress, error) { + ips := []clusterv1.MachineAddress{} + switch { + case machineScope.LinodeMachine.Spec.LinodeInterfaces != nil: + ifaces, err := machineScope.LinodeClient.ListInterfaces(ctx, instanceID, &linodego.ListOptions{}) + if err != nil || len(ifaces) == 0 { + return ips, fmt.Errorf("list interfaces: %w", err) + } + // Iterate over interfaces in config and find VLAN specific ips + for _, iface := range ifaces { + if iface.VLAN != nil { + // vlan addresses have a /11 appended to them - we should strip it out. + ips = append(ips, clusterv1.MachineAddress{ + Address: netip.MustParsePrefix(*iface.VLAN.IPAMAddress).Addr().String(), + Type: clusterv1.MachineInternalIP, + }) + } + } + default: // get the default instance config configs, err := machineScope.LinodeClient.ListInstanceConfigs(ctx, instanceID, &linodego.ListOptions{}) if err != nil || len(configs) == 0 { - return nil, fmt.Errorf("list instance configs: %w", err) + return ips, fmt.Errorf("list instance configs: %w", err) } // Iterate over interfaces in config and find VLAN specific ips @@ -256,16 +323,6 @@ func buildInstanceAddrs(ctx context.Context, machineScope *scope.MachineScope, i } } - // if a node has private ip, store it as well - // NOTE: We specifically store VPC ips first so that they are used first during - // bootstrap when we set `registrationMethod: internal-only-ips` - if len(addresses.IPv4.Private) != 0 { - ips = append(ips, clusterv1.MachineAddress{ - Address: addresses.IPv4.Private[0].Address, - Type: clusterv1.MachineInternalIP, - }) - } - return ips, nil } @@ -425,8 +482,33 @@ func getVlanInterfaceConfig(ctx context.Context, machineScope *scope.MachineScop }, nil } -// getVPCInterfaceConfig returns the interface configuration for a VPC based on the provided VPC reference -func getVPCInterfaceConfig(ctx context.Context, machineScope *scope.MachineScope, interfaces []linodego.InstanceConfigInterfaceCreateOptions, logger logr.Logger, vpcRef *corev1.ObjectReference) (*linodego.InstanceConfigInterfaceCreateOptions, error) { +func getVlanLinodeInterfaceConfig(ctx context.Context, machineScope *scope.MachineScope, interfaces []linodego.LinodeInterfaceCreateOptions, logger logr.Logger) (*linodego.LinodeInterfaceCreateOptions, error) { + logger = logger.WithValues("vlanName", machineScope.Cluster.Name) + + // Try to obtain a IP for the machine using its name + ip, err := util.GetNextVlanIP(ctx, machineScope.Cluster.Name, machineScope.Cluster.Namespace, machineScope.Client) + if err != nil { + return nil, fmt.Errorf("getting vlanIP: %w", err) + } + + logger.Info("obtained IP for machine", "name", machineScope.LinodeMachine.Name, "ip", ip) + + for i, netInterface := range interfaces { + if netInterface.VLAN != nil { + interfaces[i].VLAN.IPAMAddress = ptr.To(fmt.Sprintf(vlanIPFormat, ip)) + return nil, nil //nolint:nilnil // it is important we don't return an interface if a VLAN interface already exists + } + } + + return &linodego.LinodeInterfaceCreateOptions{ + VLAN: &linodego.VLANInterface{ + VLANLabel: machineScope.Cluster.Name, + IPAMAddress: ptr.To(fmt.Sprintf(vlanIPFormat, ip)), + }, + }, nil +} + +func getVPCFromRef(ctx context.Context, machineScope *scope.MachineScope, logger logr.Logger, vpcRef *corev1.ObjectReference) (*infrav1alpha2.LinodeVPC, error) { // Get namespace from VPC ref or default to machine namespace namespace := vpcRef.Namespace if namespace == "" { @@ -459,11 +541,21 @@ func getVPCInterfaceConfig(ctx context.Context, machineScope *scope.MachineScope return nil, errors.New("failed to find subnet") } - var subnetID int + return &linodeVPC, nil +} - subnetName := machineScope.LinodeCluster.Spec.Network.SubnetName // name of subnet to use +// getVPCInterfaceConfig returns the interface configuration for a VPC based on the provided VPC reference +func getVPCInterfaceConfig(ctx context.Context, machineScope *scope.MachineScope, interfaces []linodego.InstanceConfigInterfaceCreateOptions, logger logr.Logger, vpcRef *corev1.ObjectReference) (*linodego.InstanceConfigInterfaceCreateOptions, error) { + linodeVPC, err := getVPCFromRef(ctx, machineScope, logger, vpcRef) + if err != nil { + return nil, err + } - var ipv6Config *linodego.InstanceConfigInterfaceCreateOptionsIPv6 + var ( + ipv6Config *linodego.InstanceConfigInterfaceCreateOptionsIPv6 + subnetID int + ) + subnetName := machineScope.LinodeCluster.Spec.Network.SubnetName // name of subnet to use if subnetName != "" { for _, subnet := range linodeVPC.Spec.Subnets { if subnet.Label == subnetName { @@ -513,8 +605,72 @@ func getVPCInterfaceConfig(ctx context.Context, machineScope *scope.MachineScope return vpcIntfCreateOpts, nil } -// getVPCInterfaceConfigFromDirectID returns the interface configuration for a VPC based on a direct VPC ID -func getVPCInterfaceConfigFromDirectID(ctx context.Context, machineScope *scope.MachineScope, interfaces []linodego.InstanceConfigInterfaceCreateOptions, logger logr.Logger, vpcID int) (*linodego.InstanceConfigInterfaceCreateOptions, error) { +func getVPCLinodeInterfaceConfig(ctx context.Context, machineScope *scope.MachineScope, linodeInterfaces []linodego.LinodeInterfaceCreateOptions, logger logr.Logger, vpcRef *corev1.ObjectReference) (*linodego.LinodeInterfaceCreateOptions, error) { + linodeVPC, err := getVPCFromRef(ctx, machineScope, logger, vpcRef) + if err != nil { + return nil, err + } + + var ( + ipv6Config *linodego.VPCInterfaceIPv6CreateOptions + subnetID int + ) + subnetName := machineScope.LinodeCluster.Spec.Network.SubnetName // name of subnet to use + if subnetName != "" { + for _, subnet := range linodeVPC.Spec.Subnets { + if subnet.Label == subnetName { + subnetID = subnet.SubnetID + ipv6Config = getVPCInterfaceIPv6Config(machineScope, len(subnet.IPv6)) + break + } + } + + if subnetID == 0 { + logger.Info("Failed to fetch subnet ID for specified subnet name") + } + } else { + subnetID = linodeVPC.Spec.Subnets[0].SubnetID // get first subnet if nothing specified + ipv6Config = getVPCInterfaceIPv6Config(machineScope, len(linodeVPC.Spec.Subnets[0].IPv6)) + } + + if subnetID == 0 { + return nil, errors.New("failed to find subnet as subnet id set is 0") + } + + // Check if a VPC interface already exists + for i, netInterface := range linodeInterfaces { + if netInterface.VPC != nil { + linodeInterfaces[i].VPC.SubnetID = subnetID + // If IPv6 range config is not empty, add it to the interface configuration + if !isVPCInterfaceIPv6ConfigEmpty(ipv6Config) { + linodeInterfaces[i].VPC.IPv6 = ipv6Config + } + return nil, nil //nolint:nilnil // it is important we don't return an interface if a VPC interface already exists + } + } + + // Create a new VPC interface + vpcIntfCreateOpts := &linodego.LinodeInterfaceCreateOptions{ + VPC: &linodego.VPCInterfaceCreateOptions{ + SubnetID: subnetID, + IPv4: &linodego.VPCInterfaceIPv4CreateOptions{ + Addresses: []linodego.VPCInterfaceIPv4AddressCreateOptions{{ + Primary: ptr.To(true), + NAT1To1Address: ptr.To("any"), + }}, + }, + }, + } + + // If IPv6 config is not empty, add it to the interface configuration + if !isVPCInterfaceIPv6ConfigEmpty(ipv6Config) { + vpcIntfCreateOpts.VPC.IPv6 = ipv6Config + } + + return vpcIntfCreateOpts, nil +} + +func getVPCFromID(ctx context.Context, machineScope *scope.MachineScope, logger logr.Logger, vpcID int) (*linodego.VPC, error) { vpc, err := machineScope.LinodeClient.GetVPC(ctx, vpcID) if err != nil { logger.Error(err, "Failed to fetch VPC from Linode API", "vpcID", vpcID) @@ -526,8 +682,88 @@ func getVPCInterfaceConfigFromDirectID(ctx context.Context, machineScope *scope. return nil, errors.New("no subnets found in VPC") } - var subnetID int - var subnetName string + return vpc, nil +} + +// getVPCLinodeInterfaceConfigFromDirectID returns the linode interface configuration for a VPC based on a direct VPC ID +func getVPCLinodeInterfaceConfigFromDirectID(ctx context.Context, machineScope *scope.MachineScope, linodeInterfaces []linodego.LinodeInterfaceCreateOptions, logger logr.Logger, vpcID int) (*linodego.LinodeInterfaceCreateOptions, error) { + vpc, err := getVPCFromID(ctx, machineScope, logger, vpcID) + if err != nil { + return nil, err + } + + var ( + subnetID int + subnetName string + ipv6Config *linodego.VPCInterfaceIPv6CreateOptions + ) + + // Safety check for when LinodeCluster is nil (e.g., when using direct VPCID without cluster) + if machineScope.LinodeCluster != nil && machineScope.LinodeCluster.Spec.Network.SubnetName != "" { + subnetName = machineScope.LinodeCluster.Spec.Network.SubnetName + } + + // If subnet name specified, find matching subnet; otherwise use first subnet + if subnetName != "" { + for _, subnet := range vpc.Subnets { + if subnet.Label == subnetName { + subnetID = subnet.ID + ipv6Config = getVPCInterfaceIPv6Config(machineScope, len(subnet.IPv6)) + break + } + } + if subnetID == 0 { + return nil, fmt.Errorf("subnet with label %s not found in VPC", subnetName) + } + } else { + subnetID = vpc.Subnets[0].ID + ipv6Config = getVPCInterfaceIPv6Config(machineScope, len(vpc.Subnets[0].IPv6)) + } + + // Check if a VPC interface already exists + for i, netInterface := range linodeInterfaces { + if netInterface.VPC != nil { + linodeInterfaces[i].VPC.SubnetID = subnetID + if !isVPCInterfaceIPv6ConfigEmpty(ipv6Config) { + linodeInterfaces[i].VPC.IPv6 = ipv6Config + } + return nil, nil //nolint:nilnil // it is important we don't return an interface if a VPC interface already exists + } + } + + // Create a new VPC interface + vpcIntfCreateOpts := &linodego.LinodeInterfaceCreateOptions{ + VPC: &linodego.VPCInterfaceCreateOptions{ + SubnetID: subnetID, + IPv4: &linodego.VPCInterfaceIPv4CreateOptions{ + Addresses: []linodego.VPCInterfaceIPv4AddressCreateOptions{{ + Primary: ptr.To(true), + NAT1To1Address: ptr.To("any"), + }}, + }, + }, + } + + // If IPv6 range config is not empty, add it to the interface configuration + if !isVPCInterfaceIPv6ConfigEmpty(ipv6Config) { + vpcIntfCreateOpts.VPC.IPv6 = ipv6Config + } + + return vpcIntfCreateOpts, nil +} + +// getVPCInterfaceConfigFromDirectID returns the interface configuration for a VPC based on a direct VPC ID +func getVPCInterfaceConfigFromDirectID(ctx context.Context, machineScope *scope.MachineScope, interfaces []linodego.InstanceConfigInterfaceCreateOptions, logger logr.Logger, vpcID int) (*linodego.InstanceConfigInterfaceCreateOptions, error) { + vpc, err := getVPCFromID(ctx, machineScope, logger, vpcID) + if err != nil { + return nil, err + } + + var ( + subnetID int + subnetName string + ipv6Config *linodego.InstanceConfigInterfaceCreateOptionsIPv6 + ) // Safety check for when LinodeCluster is nil (e.g., when using direct VPCID without cluster) if machineScope.LinodeCluster != nil && machineScope.LinodeCluster.Spec.Network.SubnetName != "" { @@ -535,7 +771,6 @@ func getVPCInterfaceConfigFromDirectID(ctx context.Context, machineScope *scope. } // If subnet name specified, find matching subnet; otherwise use first subnet - var ipv6Config *linodego.InstanceConfigInterfaceCreateOptionsIPv6 if subnetName != "" { for _, subnet := range vpc.Subnets { if subnet.Label == subnetName { @@ -589,6 +824,13 @@ func isIPv6ConfigEmpty(opts *linodego.InstanceConfigInterfaceCreateOptionsIPv6) (opts.IsPublic == nil) } +func isVPCInterfaceIPv6ConfigEmpty(opts *linodego.VPCInterfaceIPv6CreateOptions) bool { + return opts == nil || + len(opts.SLAAC) == 0 && + len(opts.Ranges) == 0 && + !opts.IsPublic +} + // getMachineIPv6Config returns the IPv6 configuration for a LinodeMachine. // It checks the LinodeMachine's IPv6Options for SLAAC and Ranges settings. // If `EnableSLAAC` is set, it will enable SLAAC with the default IPv6 CIDR range. @@ -625,6 +867,160 @@ func getMachineIPv6Config(machineScope *scope.MachineScope, numIPv6RangesInSubne return intfOpts } +// getVPCInterfaceIPv6Config returns the IPv6 configuration for a LinodeMachine. +// It checks the LinodeMachine's IPv6Options for SLAAC and Ranges settings. +// If `EnableSLAAC` is set, it will enable SLAAC with the default IPv6 CIDR range. +// If `EnableRanges` is set, it will enable IPv6 ranges with the default IPv6 CIDR range. +// If `IsPublicIPv6` is set, it will be used to determine if the IPv6 range should be publicly routable or not. +func getVPCInterfaceIPv6Config(machineScope *scope.MachineScope, numIPv6RangesInSubnet int) *linodego.VPCInterfaceIPv6CreateOptions { + intfOpts := &linodego.VPCInterfaceIPv6CreateOptions{} + + // If there are no IPv6 ranges in the subnet or if IPv6 options are not specified, return nil. + if numIPv6RangesInSubnet == 0 || machineScope.LinodeMachine.Spec.IPv6Options == nil { + return intfOpts + } + + if machineScope.LinodeMachine.Spec.IPv6Options.IsPublicIPv6 != nil { + // Set the public IPv6 flag based on the IsPublicIPv6 specification. + intfOpts.IsPublic = *machineScope.LinodeMachine.Spec.IPv6Options.IsPublicIPv6 + } + + if machineScope.LinodeMachine.Spec.IPv6Options.EnableSLAAC != nil && *machineScope.LinodeMachine.Spec.IPv6Options.EnableSLAAC { + intfOpts.SLAAC = []linodego.VPCInterfaceIPv6SLAACCreateOptions{ + { + Range: defaultNodeIPv6CIDRRange, + }, + } + } + if machineScope.LinodeMachine.Spec.IPv6Options.EnableRanges != nil && *machineScope.LinodeMachine.Spec.IPv6Options.EnableRanges { + intfOpts.Ranges = []linodego.VPCInterfaceIPv6RangeCreateOptions{ + { + Range: defaultNodeIPv6CIDRRange, + }, + } + } + + return intfOpts +} + +// Unfortunately, this is necessary since DeepCopy can't be generated for linodego.LinodeInterfaceCreateOptions +// so here we manually create the options for Linode interfaces. +// / +// +//nolint:gocognit,cyclop,gocritic,nestif,nolintlint // Also, unfortunately, this cannot be made any reasonably simpler with how complicated the linodego struct is +func constructLinodeInterfaceCreateOpts(createOpts []infrav1alpha2.LinodeInterfaceCreateOptions) []linodego.LinodeInterfaceCreateOptions { + linodeInterfaces := make([]linodego.LinodeInterfaceCreateOptions, len(createOpts)) + for idx, iface := range createOpts { + ifaceCreateOpts := linodego.LinodeInterfaceCreateOptions{} + // Handle VLAN + if iface.VLAN != nil { + ifaceCreateOpts.VLAN = &linodego.VLANInterface{ + VLANLabel: iface.VLAN.VLANLabel, + IPAMAddress: iface.VLAN.IPAMAddress, + } + } + // Handle VPC + if iface.VPC != nil { + var ( + ipv4Addrs []linodego.VPCInterfaceIPv4AddressCreateOptions + ipv4Ranges []linodego.VPCInterfaceIPv4RangeCreateOptions + ipv6Ranges []linodego.VPCInterfaceIPv6RangeCreateOptions + ipv6SLAAC []linodego.VPCInterfaceIPv6SLAACCreateOptions + ipv6IsPublic bool + ) + if iface.VPC.IPv4 != nil { + for _, addr := range iface.VPC.IPv4.Addresses { + ipv4Addrs = append(ipv4Addrs, linodego.VPCInterfaceIPv4AddressCreateOptions{ + Address: addr.Address, + Primary: addr.Primary, + NAT1To1Address: addr.NAT1To1Address, + }) + } + for _, rng := range iface.VPC.IPv4.Ranges { + ipv4Ranges = append(ipv4Ranges, linodego.VPCInterfaceIPv4RangeCreateOptions{ + Range: rng.Range, + }) + } + } else { + // If no IPv4 addresses are specified, we set a default NAT1To1 address to "any" + ipv4Addrs = []linodego.VPCInterfaceIPv4AddressCreateOptions{ + { + Primary: ptr.To(true), + NAT1To1Address: ptr.To("any"), + }, + } + } + if iface.VPC.IPv6 != nil { + for _, slaac := range iface.VPC.IPv6.SLAAC { + ipv6SLAAC = append(ipv6SLAAC, linodego.VPCInterfaceIPv6SLAACCreateOptions{ + Range: slaac.Range, + }) + } + for _, rng := range iface.VPC.IPv6.Ranges { + ipv6Ranges = append(ipv6Ranges, linodego.VPCInterfaceIPv6RangeCreateOptions{ + Range: rng.Range, + }) + } + ipv6IsPublic = iface.VPC.IPv6.IsPublic + } + ifaceCreateOpts.VPC = &linodego.VPCInterfaceCreateOptions{ + SubnetID: iface.VPC.SubnetID, + IPv4: &linodego.VPCInterfaceIPv4CreateOptions{ + Addresses: ipv4Addrs, + Ranges: ipv4Ranges, + }, + IPv6: &linodego.VPCInterfaceIPv6CreateOptions{ + SLAAC: ipv6SLAAC, + Ranges: ipv6Ranges, + IsPublic: ipv6IsPublic, + }, + } + } + // Handle Public Interface + if iface.Public != nil { + var ( + ipv4Addrs []linodego.PublicInterfaceIPv4AddressCreateOptions + ipv6Ranges []linodego.PublicInterfaceIPv6RangeCreateOptions + ) + if iface.Public.IPv4 != nil { + for _, addr := range iface.Public.IPv4.Addresses { + ipv4Addrs = append(ipv4Addrs, linodego.PublicInterfaceIPv4AddressCreateOptions{ + Address: addr.Address, + Primary: addr.Primary, + }) + } + } + if iface.Public.IPv6 != nil { + for _, rng := range iface.Public.IPv6.Ranges { + ipv6Ranges = append(ipv6Ranges, linodego.PublicInterfaceIPv6RangeCreateOptions{ + Range: rng.Range, + }) + } + } + ifaceCreateOpts.Public = &linodego.PublicInterfaceCreateOptions{ + IPv4: &linodego.PublicInterfaceIPv4CreateOptions{ + Addresses: ipv4Addrs, + }, + IPv6: &linodego.PublicInterfaceIPv6CreateOptions{ + Ranges: ipv6Ranges, + }, + } + } + // Handle Default Route + if iface.DefaultRoute != nil { + ifaceCreateOpts.DefaultRoute = &linodego.InterfaceDefaultRoute{ + IPv4: iface.DefaultRoute.IPv4, + IPv6: iface.DefaultRoute.IPv6, + } + } + ifaceCreateOpts.FirewallID = iface.FirewallID + // createOpts is now fully populated with the interface options + linodeInterfaces[idx] = ifaceCreateOpts + } + + return linodeInterfaces +} + func linodeMachineSpecToInstanceCreateConfig(machineSpec infrav1alpha2.LinodeMachineSpec, machineTags []string) *linodego.InstanceCreateOptions { interfaces := make([]linodego.InstanceConfigInterfaceCreateOptions, len(machineSpec.Interfaces)) for idx, iface := range machineSpec.Interfaces { @@ -641,7 +1037,7 @@ func linodeMachineSpecToInstanceCreateConfig(machineSpec infrav1alpha2.LinodeMac if machineSpec.PrivateIP != nil { privateIP = *machineSpec.PrivateIP } - return &linodego.InstanceCreateOptions{ + instCreateOpts := &linodego.InstanceCreateOptions{ Region: machineSpec.Region, Type: machineSpec.Type, AuthorizedKeys: machineSpec.AuthorizedKeys, @@ -654,6 +1050,11 @@ func linodeMachineSpecToInstanceCreateConfig(machineSpec infrav1alpha2.LinodeMac FirewallID: machineSpec.FirewallID, DiskEncryption: linodego.InstanceDiskEncryption(machineSpec.DiskEncryption), } + if len(machineSpec.LinodeInterfaces) > 0 { + instCreateOpts.LinodeInterfaces = constructLinodeInterfaceCreateOpts(machineSpec.LinodeInterfaces) + } + + return instCreateOpts } func compressUserData(bootstrapData []byte) ([]byte, error) { @@ -1016,15 +1417,29 @@ func getVPCRefFromScope(machineScope *scope.MachineScope) *corev1.ObjectReferenc // configureVlanInterface adds a VLAN interface to the configuration func configureVlanInterface(ctx context.Context, machineScope *scope.MachineScope, createConfig *linodego.InstanceCreateOptions, logger logr.Logger) error { - iface, err := getVlanInterfaceConfig(ctx, machineScope, createConfig.Interfaces, logger) - if err != nil { - logger.Error(err, "Failed to get VLAN interface config") - return err - } + switch { + case createConfig.LinodeInterfaces != nil: + iface, err := getVlanLinodeInterfaceConfig(ctx, machineScope, createConfig.LinodeInterfaces, logger) + if err != nil { + logger.Error(err, "Failed to get VLAN interface config") + return err + } + + if iface != nil { + // add VLAN interface as first interface + createConfig.LinodeInterfaces = slices.Insert(createConfig.LinodeInterfaces, 0, *iface) + } + default: + iface, err := getVlanInterfaceConfig(ctx, machineScope, createConfig.Interfaces, logger) + if err != nil { + logger.Error(err, "Failed to get VLAN interface config") + return err + } - if iface != nil { - // add VLAN interface as first interface - createConfig.Interfaces = slices.Insert(createConfig.Interfaces, 0, *iface) + if iface != nil { + // add VLAN interface as first interface + createConfig.Interfaces = slices.Insert(createConfig.Interfaces, 0, *iface) + } } return nil diff --git a/internal/controller/linodemachine_controller_helpers_test.go b/internal/controller/linodemachine_controller_helpers_test.go index e02f93529..b2ab2e62a 100644 --- a/internal/controller/linodemachine_controller_helpers_test.go +++ b/internal/controller/linodemachine_controller_helpers_test.go @@ -390,11 +390,12 @@ func validateInterfaceExpectations( t *testing.T, err error, iface *linodego.InstanceConfigInterfaceCreateOptions, + linodeIface *linodego.LinodeInterfaceCreateOptions, expectErr bool, expectErrMsg string, expectInterface bool, + expectLinodeInterface bool, expectSubnetID int, - interfaces interface{}, ) { t.Helper() @@ -423,6 +424,22 @@ func validateInterfaceExpectations( } else { require.Nil(t, iface) } + if expectLinodeInterface { + require.NotNil(t, linodeIface) + require.NotNil(t, linodeIface.VPC) + if linodeIface.VPC.IPv6 != nil && linodeIface.VPC.IPv6.SLAAC != nil { + require.Equal(t, defaultNodeIPv6CIDRRange, linodeIface.VPC.IPv6.SLAAC[0].Range) + } else if linodeIface.VPC.IPv6 != nil && linodeIface.VPC.IPv6.Ranges != nil { + require.Equal(t, defaultNodeIPv6CIDRRange, linodeIface.VPC.IPv6.Ranges[0].Range) + } + require.NotNil(t, linodeIface.VPC.SubnetID) + require.Equal(t, expectSubnetID, linodeIface.VPC.SubnetID) + require.NotNil(t, linodeIface.VPC.IPv4) + require.NotNil(t, linodeIface.VPC.IPv4.Addresses[0].NAT1To1Address) + require.Equal(t, "any", *linodeIface.VPC.IPv4.Addresses[0].NAT1To1Address) + } else { + require.Nil(t, linodeIface) + } } func TestGetVPCInterfaceConfigFromDirectID(t *testing.T) { @@ -629,7 +646,7 @@ func TestGetVPCInterfaceConfigFromDirectID(t *testing.T) { iface, err := getVPCInterfaceConfigFromDirectID(ctx, machineScope, tc.interfaces, logger, tc.vpcID) // Check expectations - validateInterfaceExpectations(t, err, iface, tc.expectErr, tc.expectErrMsg, tc.expectInterface, tc.expectSubnetID, tc.interfaces) + validateInterfaceExpectations(t, err, iface, nil, tc.expectErr, tc.expectErrMsg, tc.expectInterface, false, tc.expectSubnetID) // Additional check for interface updates if !tc.expectErr && !tc.expectInterface && len(tc.interfaces) > 0 && tc.interfaces[0].Purpose == linodego.InterfacePurposeVPC { @@ -640,19 +657,251 @@ func TestGetVPCInterfaceConfigFromDirectID(t *testing.T) { } } +func TestGetVPCLinodeInterfaceConfigFromDirectID(t *testing.T) { + t.Parallel() + + // Setup test cases + testCases := []struct { + name string + vpcID int + linodeInterfaces []linodego.LinodeInterfaceCreateOptions + subnetName string + mockSetup func(mockLinodeClient *mock.MockLinodeClient) + expectErr bool + expectErrMsg string + expectLinodeInterface bool + expectSubnetID int + }{ + { + name: "Success - Valid VPC with subnets, no subnet name", + vpcID: 123, + linodeInterfaces: []linodego.LinodeInterfaceCreateOptions{}, + mockSetup: func(mockLinodeClient *mock.MockLinodeClient) { + mockLinodeClient.EXPECT().GetVPC(gomock.Any(), 123).Return(&linodego.VPC{ + ID: 123, + Subnets: []linodego.VPCSubnet{ + { + ID: 456, + Label: "subnet-1", + }, + { + ID: 789, + Label: "subnet-2", + }, + }, + }, nil) + }, + expectErr: false, + expectLinodeInterface: true, + expectSubnetID: 456, // First subnet ID + }, + { + name: "Success - Valid VPC with subnets, specific subnet name", + vpcID: 123, + linodeInterfaces: []linodego.LinodeInterfaceCreateOptions{}, + subnetName: "subnet-2", + mockSetup: func(mockLinodeClient *mock.MockLinodeClient) { + mockLinodeClient.EXPECT().GetVPC(gomock.Any(), 123).Return(&linodego.VPC{ + ID: 123, + Subnets: []linodego.VPCSubnet{ + { + ID: 456, + Label: "subnet-1", + }, + { + ID: 789, + Label: "subnet-2", + }, + }, + }, nil) + }, + expectErr: false, + expectLinodeInterface: true, + expectSubnetID: 789, // Matching subnet ID + }, + { + name: "Success - Valid VPC with subnets and ipv6 ranges, specific subnet name", + vpcID: 123, + linodeInterfaces: []linodego.LinodeInterfaceCreateOptions{}, + subnetName: "subnet-2", + mockSetup: func(mockLinodeClient *mock.MockLinodeClient) { + mockLinodeClient.EXPECT().GetVPC(gomock.Any(), 123).Return(&linodego.VPC{ + ID: 123, + Subnets: []linodego.VPCSubnet{ + { + ID: 456, + Label: "subnet-1", + }, + { + ID: 789, + Label: "subnet-2", + IPv6: []linodego.VPCIPv6Range{ + { + Range: "2001:0db8::/56", + }, + }, + }, + }, + }, nil) + }, + expectErr: false, + expectLinodeInterface: true, + expectSubnetID: 789, // Matching subnet ID + }, + { + name: "Success - VPC interface already exists", + vpcID: 123, + linodeInterfaces: []linodego.LinodeInterfaceCreateOptions{{VPC: &linodego.VPCInterfaceCreateOptions{}}}, + mockSetup: func(mockLinodeClient *mock.MockLinodeClient) { + mockLinodeClient.EXPECT().GetVPC(gomock.Any(), 123).Return(&linodego.VPC{ + ID: 123, + Subnets: []linodego.VPCSubnet{ + { + ID: 456, + Label: "subnet-1", + IPv6: []linodego.VPCIPv6Range{ + { + Range: "2001:0db8::/56", + }, + }, + }, + }, + }, nil) + }, + expectErr: false, + expectLinodeInterface: false, + expectSubnetID: 456, + }, + { + name: "Error - VPC does not exist", + vpcID: 999, + linodeInterfaces: []linodego.LinodeInterfaceCreateOptions{}, + mockSetup: func(mockLinodeClient *mock.MockLinodeClient) { + mockLinodeClient.EXPECT().GetVPC(gomock.Any(), 999).Return(nil, fmt.Errorf("VPC not found")) + }, + expectErr: true, + expectErrMsg: "VPC not found", + expectLinodeInterface: false, + }, + { + name: "Error - VPC has no subnets", + vpcID: 123, + linodeInterfaces: []linodego.LinodeInterfaceCreateOptions{}, + mockSetup: func(mockLinodeClient *mock.MockLinodeClient) { + mockLinodeClient.EXPECT().GetVPC(gomock.Any(), 123).Return(&linodego.VPC{ + ID: 123, + Subnets: []linodego.VPCSubnet{}, + }, nil) + }, + expectErr: true, + expectErrMsg: "no subnets found in VPC", + expectLinodeInterface: false, + }, + { + name: "Error - Subnet name not found", + vpcID: 123, + linodeInterfaces: []linodego.LinodeInterfaceCreateOptions{}, + subnetName: "non-existent", + mockSetup: func(mockLinodeClient *mock.MockLinodeClient) { + mockLinodeClient.EXPECT().GetVPC(gomock.Any(), 123).Return(&linodego.VPC{ + ID: 123, + Subnets: []linodego.VPCSubnet{ + { + ID: 456, + Label: "subnet-1", + }, + }, + }, nil) + }, + expectErr: true, + expectErrMsg: "subnet with label non-existent not found in VPC", + expectLinodeInterface: false, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Setup mock controller and client + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLinodeClient := mock.NewMockLinodeClient(ctrl) + mockK8sClient := mock.NewMockK8sClient(ctrl) + + tc.mockSetup(mockLinodeClient) + + // Create test context + ctx := t.Context() + logger := testr.New(t) + + // Create machine scope + machineScope := &scope.MachineScope{ + LinodeClient: mockLinodeClient, + Client: mockK8sClient, + LinodeMachine: &infrav1alpha2.LinodeMachine{ + Spec: infrav1alpha2.LinodeMachineSpec{}, + }, + LinodeCluster: &infrav1alpha2.LinodeCluster{ + Spec: infrav1alpha2.LinodeClusterSpec{ + Network: infrav1alpha2.NetworkSpec{ + SubnetName: tc.subnetName, + }, + }, + }, + } + + // Call the function being tested + linodeIface, err := getVPCLinodeInterfaceConfigFromDirectID(ctx, machineScope, tc.linodeInterfaces, logger, tc.vpcID) + + // Check expectations + validateInterfaceExpectations(t, err, nil, linodeIface, tc.expectErr, tc.expectErrMsg, false, tc.expectLinodeInterface, tc.expectSubnetID) + + // Additional check for interface updates + if !tc.expectErr && !tc.expectLinodeInterface && len(tc.linodeInterfaces) > 0 && tc.linodeInterfaces[0].VPC != nil { + require.NotNil(t, tc.linodeInterfaces[0].VPC.SubnetID) + require.Equal(t, tc.expectSubnetID, tc.linodeInterfaces[0].VPC.SubnetID) + } + }) + } +} + func TestAddVPCInterfaceFromDirectID(t *testing.T) { t.Parallel() // Setup test cases testCases := []struct { - name string - vpcID int - createConfig *linodego.InstanceCreateOptions - mockSetup func(mockLinodeClient *mock.MockLinodeClient) - expectErr bool - expectErrMsg string - expectNoIface bool + name string + vpcID int + createConfig *linodego.InstanceCreateOptions + mockSetup func(mockLinodeClient *mock.MockLinodeClient) + expectErr bool + expectErrMsg string + expectNoIface bool + expectNoLinodeIface bool }{ + { + name: "Success - Interface added correctly with new network interfaces", + vpcID: 123, + createConfig: &linodego.InstanceCreateOptions{ + LinodeInterfaces: []linodego.LinodeInterfaceCreateOptions{}, + }, + mockSetup: func(mockLinodeClient *mock.MockLinodeClient) { + mockLinodeClient.EXPECT().GetVPC(gomock.Any(), 123).Return(&linodego.VPC{ + ID: 123, + Subnets: []linodego.VPCSubnet{ + { + ID: 456, + Label: "subnet-1", + }, + }, + }, nil) + }, + expectErr: false, + expectNoIface: true, + }, { name: "Success - Interface added correctly", vpcID: 123, @@ -670,7 +919,8 @@ func TestAddVPCInterfaceFromDirectID(t *testing.T) { }, }, nil) }, - expectErr: false, + expectErr: false, + expectNoLinodeIface: true, }, { name: "Error - getVPCInterfaceConfigFromDirectID returns error", @@ -684,6 +934,18 @@ func TestAddVPCInterfaceFromDirectID(t *testing.T) { expectErr: true, expectErrMsg: "VPC not found", }, + { + name: "Error - getVPCInterfaceConfigFromDirectID returns error with new network interfaces", + vpcID: 999, + createConfig: &linodego.InstanceCreateOptions{ + LinodeInterfaces: []linodego.LinodeInterfaceCreateOptions{}, + }, + mockSetup: func(mockLinodeClient *mock.MockLinodeClient) { + mockLinodeClient.EXPECT().GetVPC(gomock.Any(), 999).Return(nil, fmt.Errorf("VPC not found")) + }, + expectErr: true, + expectErrMsg: "VPC not found", + }, { name: "Success - Interface already exists", vpcID: 123, @@ -705,8 +967,34 @@ func TestAddVPCInterfaceFromDirectID(t *testing.T) { }, }, nil) }, - expectErr: false, - expectNoIface: true, + expectErr: false, + expectNoIface: true, + expectNoLinodeIface: true, + }, + { + name: "Success - Interface already exists with new network interfaces", + vpcID: 123, + createConfig: &linodego.InstanceCreateOptions{ + LinodeInterfaces: []linodego.LinodeInterfaceCreateOptions{ + { + VPC: &linodego.VPCInterfaceCreateOptions{}, + }, + }, + }, + mockSetup: func(mockLinodeClient *mock.MockLinodeClient) { + mockLinodeClient.EXPECT().GetVPC(gomock.Any(), 123).Return(&linodego.VPC{ + ID: 123, + Subnets: []linodego.VPCSubnet{ + { + ID: 456, + Label: "subnet-1", + }, + }, + }, nil) + }, + expectErr: false, + expectNoIface: true, + expectNoLinodeIface: true, }, } @@ -735,13 +1023,12 @@ func TestAddVPCInterfaceFromDirectID(t *testing.T) { LinodeMachine: &infrav1alpha2.LinodeMachine{ Spec: infrav1alpha2.LinodeMachineSpec{}, }, - LinodeCluster: &infrav1alpha2.LinodeCluster{ - Spec: infrav1alpha2.LinodeClusterSpec{}, - }, + LinodeCluster: &infrav1alpha2.LinodeCluster{}, } // Store original interface count - originalCount := len(tc.createConfig.Interfaces) + originalIfaceCount := len(tc.createConfig.Interfaces) + originalLinodeIfaceCount := len(tc.createConfig.LinodeInterfaces) // Call the function being tested err := addVPCInterfaceFromDirectID(ctx, machineScope, tc.createConfig, logger, tc.vpcID) @@ -752,12 +1039,22 @@ func TestAddVPCInterfaceFromDirectID(t *testing.T) { require.Contains(t, err.Error(), tc.expectErrMsg) } else { require.NoError(t, err) - if tc.expectNoIface { + switch { + case tc.expectNoLinodeIface: + // If Linode interface already existed, count should remain the same + require.Len(t, tc.createConfig.LinodeInterfaces, originalLinodeIfaceCount) + default: + // If Linode interface was added, count should increase + require.Len(t, tc.createConfig.LinodeInterfaces, originalLinodeIfaceCount+1) + require.NotNil(t, tc.createConfig.LinodeInterfaces[0].VPC) + } + switch { + case tc.expectNoIface: // If interface already existed, count should remain the same - require.Len(t, tc.createConfig.Interfaces, originalCount) - } else { + require.Len(t, tc.createConfig.Interfaces, originalIfaceCount) + default: // If interface was added, count should increase - require.Len(t, tc.createConfig.Interfaces, originalCount+1) + require.Len(t, tc.createConfig.Interfaces, originalIfaceCount+1) require.Equal(t, linodego.InterfacePurposeVPC, tc.createConfig.Interfaces[0].Purpose) require.True(t, tc.createConfig.Interfaces[0].Primary) } @@ -778,15 +1075,16 @@ func TestConfigureVPCInterface(t *testing.T) { // Setup test cases testCases := []struct { - name string - machineVPCID *int - clusterVPCID *int - vpcRef *corev1.ObjectReference - createConfig *linodego.InstanceCreateOptions - mockSetup func(mockLinodeClient *mock.MockLinodeClient, mockK8sClient *mock.MockK8sClient) - expectErr bool - expectErrMsg string - expectInterface bool + name string + machineVPCID *int + clusterVPCID *int + vpcRef *corev1.ObjectReference + createConfig *linodego.InstanceCreateOptions + mockSetup func(mockLinodeClient *mock.MockLinodeClient, mockK8sClient *mock.MockK8sClient) + expectErr bool + expectErrMsg string + expectInterface bool + expectLinodeInterface bool }{ { name: "Success - VPCID on machine", @@ -813,6 +1111,31 @@ func TestConfigureVPCInterface(t *testing.T) { expectErr: false, expectInterface: true, }, + { + name: "Success - VPCID on machine with new network interfaces", + machineVPCID: ptr.To(123), + createConfig: &linodego.InstanceCreateOptions{ + LinodeInterfaces: []linodego.LinodeInterfaceCreateOptions{}, + }, + mockSetup: func(mockLinodeClient *mock.MockLinodeClient, mockK8sClient *mock.MockK8sClient) { + mockLinodeClient.EXPECT().GetVPC(gomock.Any(), 123).Return(&linodego.VPC{ + ID: 123, + Subnets: []linodego.VPCSubnet{ + { + ID: 456, + Label: "subnet-1", + IPv6: []linodego.VPCIPv6Range{ + { + Range: "2001:0db8::/56", + }, + }, + }, + }, + }, nil) + }, + expectErr: false, + expectLinodeInterface: true, + }, { name: "Success - VPCID on cluster", clusterVPCID: ptr.To(123), @@ -833,6 +1156,26 @@ func TestConfigureVPCInterface(t *testing.T) { expectErr: false, expectInterface: true, }, + { + name: "Success - VPCID on cluster with new network interfaces", + clusterVPCID: ptr.To(123), + createConfig: &linodego.InstanceCreateOptions{ + LinodeInterfaces: []linodego.LinodeInterfaceCreateOptions{}, + }, + mockSetup: func(mockLinodeClient *mock.MockLinodeClient, mockK8sClient *mock.MockK8sClient) { + mockLinodeClient.EXPECT().GetVPC(gomock.Any(), 123).Return(&linodego.VPC{ + ID: 123, + Subnets: []linodego.VPCSubnet{ + { + ID: 456, + Label: "subnet-1", + }, + }, + }, nil) + }, + expectErr: false, + expectLinodeInterface: true, + }, { name: "Success - VPC reference", vpcRef: vpcRef, @@ -859,16 +1202,52 @@ func TestConfigureVPCInterface(t *testing.T) { expectInterface: true, }, { - name: "Success - No VPC configuration", + name: "Success - VPC reference with new network interfaces", + vpcRef: vpcRef, createConfig: &linodego.InstanceCreateOptions{ - Interfaces: []linodego.InstanceConfigInterfaceCreateOptions{}, + LinodeInterfaces: []linodego.LinodeInterfaceCreateOptions{}, }, mockSetup: func(mockLinodeClient *mock.MockLinodeClient, mockK8sClient *mock.MockK8sClient) { - // No expectations needed - }, - expectErr: false, + mockK8sClient.EXPECT().Get(gomock.Any(), client.ObjectKey{ + Name: "test-vpc", + Namespace: "default", + }, gomock.Any()).DoAndReturn(func(_ context.Context, _ client.ObjectKey, vpc *infrav1alpha2.LinodeVPC, _ ...client.GetOption) error { + vpc.Status.Ready = true + vpc.Spec.VPCID = ptr.To(123) + vpc.Spec.Subnets = []infrav1alpha2.VPCSubnetCreateOptions{ + { + SubnetID: subnetID, + Label: "subnet-1", + }, + } + return nil + }) + }, + expectErr: false, + expectLinodeInterface: true, + }, + { + name: "Success - No VPC configuration", + createConfig: &linodego.InstanceCreateOptions{ + Interfaces: []linodego.InstanceConfigInterfaceCreateOptions{}, + }, + mockSetup: func(mockLinodeClient *mock.MockLinodeClient, mockK8sClient *mock.MockK8sClient) { + // No expectations needed + }, + expectErr: false, expectInterface: false, }, + { + name: "Success - No VPC configuration with new network interfaces", + createConfig: &linodego.InstanceCreateOptions{ + LinodeInterfaces: []linodego.LinodeInterfaceCreateOptions{}, + }, + mockSetup: func(mockLinodeClient *mock.MockLinodeClient, mockK8sClient *mock.MockK8sClient) { + // No expectations needed + }, + expectErr: false, + expectLinodeInterface: false, + }, { name: "Error - VPCID on machine, VPC not found", machineVPCID: ptr.To(999), @@ -881,6 +1260,18 @@ func TestConfigureVPCInterface(t *testing.T) { expectErr: true, expectErrMsg: "VPC not found", }, + { + name: "Error - VPCID on machine, VPC not found with new network interfaces", + machineVPCID: ptr.To(999), + createConfig: &linodego.InstanceCreateOptions{ + LinodeInterfaces: []linodego.LinodeInterfaceCreateOptions{}, + }, + mockSetup: func(mockLinodeClient *mock.MockLinodeClient, mockK8sClient *mock.MockK8sClient) { + mockLinodeClient.EXPECT().GetVPC(gomock.Any(), 999).Return(nil, fmt.Errorf("VPC not found")) + }, + expectErr: true, + expectErrMsg: "VPC not found", + }, { name: "Error - VPCID on cluster, VPC not found", clusterVPCID: ptr.To(999), @@ -893,6 +1284,18 @@ func TestConfigureVPCInterface(t *testing.T) { expectErr: true, expectErrMsg: "VPC not found", }, + { + name: "Error - VPCID on cluster, VPC not found with new network interfaces", + clusterVPCID: ptr.To(999), + createConfig: &linodego.InstanceCreateOptions{ + LinodeInterfaces: []linodego.LinodeInterfaceCreateOptions{}, + }, + mockSetup: func(mockLinodeClient *mock.MockLinodeClient, mockK8sClient *mock.MockK8sClient) { + mockLinodeClient.EXPECT().GetVPC(gomock.Any(), 999).Return(nil, fmt.Errorf("VPC not found")) + }, + expectErr: true, + expectErrMsg: "VPC not found", + }, } for _, tc := range testCases { @@ -932,7 +1335,8 @@ func TestConfigureVPCInterface(t *testing.T) { } // Store original interface count - originalCount := len(tc.createConfig.Interfaces) + originalIfaceCount := len(tc.createConfig.Interfaces) + originalLinodeIfaceCount := len(tc.createConfig.LinodeInterfaces) // Call the function being tested err := configureVPCInterface(ctx, machineScope, tc.createConfig, logger) @@ -943,13 +1347,24 @@ func TestConfigureVPCInterface(t *testing.T) { require.Contains(t, err.Error(), tc.expectErrMsg) } else { require.NoError(t, err) - if tc.expectInterface { + switch { + case tc.expectLinodeInterface: + // If Linode interface was added, count should increase + require.Len(t, tc.createConfig.LinodeInterfaces, originalLinodeIfaceCount+1) + require.NotNil(t, tc.createConfig.LinodeInterfaces[0].VPC) + default: + // If no Linode interface was added, count should remain the same + require.Len(t, tc.createConfig.LinodeInterfaces, originalLinodeIfaceCount) + } + switch { + case tc.expectInterface: // If interface was added, count should increase - require.Len(t, tc.createConfig.Interfaces, originalCount+1) + require.Len(t, tc.createConfig.Interfaces, originalIfaceCount+1) require.Equal(t, linodego.InterfacePurposeVPC, tc.createConfig.Interfaces[0].Purpose) - } else { + require.True(t, tc.createConfig.Interfaces[0].Primary) + default: // If no interface was added, count should remain the same - require.Len(t, tc.createConfig.Interfaces, originalCount) + require.Len(t, tc.createConfig.Interfaces, originalIfaceCount) } } }) @@ -1227,7 +1642,7 @@ func TestGetVPCInterfaceConfig(t *testing.T) { iface, err := getVPCInterfaceConfig(ctx, machineScope, tc.interfaces, logger, tc.vpcRef) // Check expectations - validateInterfaceExpectations(t, err, iface, tc.expectErr, tc.expectErrMsg, tc.expectInterface, tc.expectSubnetID, tc.interfaces) + validateInterfaceExpectations(t, err, iface, nil, tc.expectErr, tc.expectErrMsg, tc.expectInterface, false, tc.expectSubnetID) // Additional check for interface updates if !tc.expectErr && !tc.expectInterface && len(tc.interfaces) > 0 && tc.interfaces[0].Purpose == linodego.InterfacePurposeVPC { @@ -1238,6 +1653,288 @@ func TestGetVPCInterfaceConfig(t *testing.T) { } } +func TestGetVPCLinodeInterfaceConfig(t *testing.T) { + t.Parallel() + + // Setup test cases + testCases := []struct { + name string + vpcRef *corev1.ObjectReference + linodeInterfaces []linodego.LinodeInterfaceCreateOptions + subnetName string + mockSetup func(mockK8sClient *mock.MockK8sClient) + expectErr bool + expectErrMsg string + expectLinodeInterface bool + expectSubnetID int + }{ + { + name: "Success - Finding VPC with default namespace", + vpcRef: &corev1.ObjectReference{ + Name: "test-vpc", + }, + linodeInterfaces: []linodego.LinodeInterfaceCreateOptions{}, + mockSetup: func(mockK8sClient *mock.MockK8sClient) { + mockK8sClient.EXPECT().Get(gomock.Any(), client.ObjectKey{ + Name: "test-vpc", + Namespace: "default", // Default namespace + }, gomock.Any()).DoAndReturn(func(_ context.Context, _ client.ObjectKey, vpc *infrav1alpha2.LinodeVPC, _ ...client.GetOption) error { + vpc.Status.Ready = true + vpc.Spec.VPCID = ptr.To(123) + vpc.Spec.Subnets = []infrav1alpha2.VPCSubnetCreateOptions{ + { + SubnetID: 456, + Label: "subnet-1", + IPv6: []linodego.VPCIPv6Range{ + { + Range: "2001:0db8::/56", + }, + }, + }, + } + return nil + }) + }, + expectErr: false, + expectLinodeInterface: true, + expectSubnetID: 456, // First subnet ID + }, + { + name: "Success - Finding VPC with specific namespace", + vpcRef: &corev1.ObjectReference{ + Name: "test-vpc", + Namespace: "custom-namespace", + }, + linodeInterfaces: []linodego.LinodeInterfaceCreateOptions{}, + mockSetup: func(mockK8sClient *mock.MockK8sClient) { + mockK8sClient.EXPECT().Get(gomock.Any(), client.ObjectKey{ + Name: "test-vpc", + Namespace: "custom-namespace", + }, gomock.Any()).DoAndReturn(func(_ context.Context, _ client.ObjectKey, vpc *infrav1alpha2.LinodeVPC, _ ...client.GetOption) error { + vpc.Status.Ready = true + vpc.Spec.VPCID = ptr.To(123) + vpc.Spec.Subnets = []infrav1alpha2.VPCSubnetCreateOptions{ + { + SubnetID: 456, + Label: "subnet-1", + }, + } + return nil + }) + }, + expectErr: false, + expectLinodeInterface: true, + expectSubnetID: 456, + }, + { + name: "Success - With subnet name specified and found", + vpcRef: &corev1.ObjectReference{ + Name: "test-vpc", + }, + subnetName: "subnet-2", + linodeInterfaces: []linodego.LinodeInterfaceCreateOptions{}, + mockSetup: func(mockK8sClient *mock.MockK8sClient) { + mockK8sClient.EXPECT().Get(gomock.Any(), client.ObjectKey{ + Name: "test-vpc", + Namespace: "default", + }, gomock.Any()).DoAndReturn(func(_ context.Context, _ client.ObjectKey, vpc *infrav1alpha2.LinodeVPC, _ ...client.GetOption) error { + vpc.Status.Ready = true + vpc.Spec.VPCID = ptr.To(123) + vpc.Spec.Subnets = []infrav1alpha2.VPCSubnetCreateOptions{ + { + SubnetID: 456, + Label: "subnet-1", + }, + { + SubnetID: 789, + Label: "subnet-2", + }, + } + return nil + }) + }, + expectErr: false, + expectLinodeInterface: true, + expectSubnetID: 789, + }, + { + name: "Success - VPC interface already exists", + vpcRef: &corev1.ObjectReference{ + Name: "test-vpc", + }, + linodeInterfaces: []linodego.LinodeInterfaceCreateOptions{ + { + VPC: &linodego.VPCInterfaceCreateOptions{}, + }, + }, + mockSetup: func(mockK8sClient *mock.MockK8sClient) { + mockK8sClient.EXPECT().Get(gomock.Any(), client.ObjectKey{ + Name: "test-vpc", + Namespace: "default", + }, gomock.Any()).DoAndReturn(func(_ context.Context, _ client.ObjectKey, vpc *infrav1alpha2.LinodeVPC, _ ...client.GetOption) error { + vpc.Status.Ready = true + vpc.Spec.VPCID = ptr.To(123) + vpc.Spec.Subnets = []infrav1alpha2.VPCSubnetCreateOptions{ + { + SubnetID: 456, + Label: "subnet-1", + IPv6: []linodego.VPCIPv6Range{ + { + Range: "2001:0db8::/56", + }, + }, + }, + } + return nil + }) + }, + expectErr: false, + expectLinodeInterface: false, + expectSubnetID: 456, + }, + { + name: "Error - Failed to fetch LinodeVPC", + vpcRef: &corev1.ObjectReference{ + Name: "nonexistent-vpc", + }, + linodeInterfaces: []linodego.LinodeInterfaceCreateOptions{}, + mockSetup: func(mockK8sClient *mock.MockK8sClient) { + mockK8sClient.EXPECT().Get(gomock.Any(), client.ObjectKey{ + Name: "nonexistent-vpc", + Namespace: "default", + }, gomock.Any()).Return(fmt.Errorf("vpc not found")) + }, + expectErr: true, + expectErrMsg: "vpc not found", + expectLinodeInterface: false, + }, + { + name: "Error - VPC is not ready", + vpcRef: &corev1.ObjectReference{ + Name: "test-vpc", + }, + linodeInterfaces: []linodego.LinodeInterfaceCreateOptions{}, + mockSetup: func(mockK8sClient *mock.MockK8sClient) { + mockK8sClient.EXPECT().Get(gomock.Any(), client.ObjectKey{ + Name: "test-vpc", + Namespace: "default", + }, gomock.Any()).DoAndReturn(func(_ context.Context, _ client.ObjectKey, vpc *infrav1alpha2.LinodeVPC, _ ...client.GetOption) error { + vpc.Status.Ready = false + vpc.Spec.VPCID = ptr.To(123) + return nil + }) + }, + expectErr: true, + expectErrMsg: "vpc is not available", + expectLinodeInterface: false, + }, + { + name: "Error - VPC has no subnets", + vpcRef: &corev1.ObjectReference{ + Name: "test-vpc", + }, + linodeInterfaces: []linodego.LinodeInterfaceCreateOptions{}, + mockSetup: func(mockK8sClient *mock.MockK8sClient) { + mockK8sClient.EXPECT().Get(gomock.Any(), client.ObjectKey{ + Name: "test-vpc", + Namespace: "default", + }, gomock.Any()).DoAndReturn(func(_ context.Context, _ client.ObjectKey, vpc *infrav1alpha2.LinodeVPC, _ ...client.GetOption) error { + vpc.Status.Ready = true + vpc.Spec.VPCID = ptr.To(123) + vpc.Spec.Subnets = []infrav1alpha2.VPCSubnetCreateOptions{} + return nil + }) + }, + expectErr: true, + expectErrMsg: "failed to find subnet", + expectLinodeInterface: false, + }, + { + name: "Error - Subnet name not found", + vpcRef: &corev1.ObjectReference{ + Name: "test-vpc", + }, + subnetName: "nonexistent-subnet", + linodeInterfaces: []linodego.LinodeInterfaceCreateOptions{}, + mockSetup: func(mockK8sClient *mock.MockK8sClient) { + mockK8sClient.EXPECT().Get(gomock.Any(), client.ObjectKey{ + Name: "test-vpc", + Namespace: "default", + }, gomock.Any()).DoAndReturn(func(_ context.Context, _ client.ObjectKey, vpc *infrav1alpha2.LinodeVPC, _ ...client.GetOption) error { + vpc.Status.Ready = true + vpc.Spec.VPCID = ptr.To(123) + vpc.Spec.Subnets = []infrav1alpha2.VPCSubnetCreateOptions{ + { + SubnetID: 456, + Label: "subnet-1", + }, + } + return nil + }) + }, + expectErr: true, + expectErrMsg: "failed to find subnet as subnet id set is 0", + expectLinodeInterface: false, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Setup mock controller and client + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockK8sClient := mock.NewMockK8sClient(ctrl) + mockLinodeClient := mock.NewMockLinodeClient(ctrl) + + tc.mockSetup(mockK8sClient) + + // Create test context + ctx := t.Context() + logger := testr.New(t) + + // Create machine scope + machineScope := &scope.MachineScope{ + LinodeClient: mockLinodeClient, + Client: mockK8sClient, + LinodeMachine: &infrav1alpha2.LinodeMachine{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + }, + Spec: infrav1alpha2.LinodeMachineSpec{ + IPv6Options: &infrav1alpha2.IPv6CreateOptions{ + EnableSLAAC: ptr.To(true), + IsPublicIPv6: ptr.To(true), + }, + }, + }, + LinodeCluster: &infrav1alpha2.LinodeCluster{ + Spec: infrav1alpha2.LinodeClusterSpec{ + Network: infrav1alpha2.NetworkSpec{ + SubnetName: tc.subnetName, + }, + }, + }, + } + + // Call the function being tested + linodeIface, err := getVPCLinodeInterfaceConfig(ctx, machineScope, tc.linodeInterfaces, logger, tc.vpcRef) + + // Check expectations + validateInterfaceExpectations(t, err, nil, linodeIface, tc.expectErr, tc.expectErrMsg, false, tc.expectLinodeInterface, tc.expectSubnetID) + + // Additional check for interface updates + if !tc.expectErr && !tc.expectLinodeInterface && len(tc.linodeInterfaces) > 0 && tc.linodeInterfaces[0].VPC != nil { + require.NotNil(t, tc.linodeInterfaces[0].VPC.SubnetID) + require.Equal(t, tc.expectSubnetID, tc.linodeInterfaces[0].VPC.SubnetID) + } + }) + } +} + func TestGetTags(t *testing.T) { t.Parallel() diff --git a/internal/controller/linodemachine_controller_test.go b/internal/controller/linodemachine_controller_test.go index 0a8a0ad3c..7a9eef780 100644 --- a/internal/controller/linodemachine_controller_test.go +++ b/internal/controller/linodemachine_controller_test.go @@ -2571,6 +2571,349 @@ var _ = Describe("machine in VPC", Label("machine", "VPC"), Ordered, func() { }) }) +var _ = Describe("machine in VPC with new network interfaces", Label("machine", "newNetworkInterfaces", "VPC"), Ordered, func() { + var machine clusterv1.Machine + var secret corev1.Secret + var lvpcReconciler *LinodeVPCReconciler + var linodeVPC infrav1alpha2.LinodeVPC + + var mockCtrl *gomock.Controller + var testLogs *bytes.Buffer + var logger logr.Logger + + cluster := clusterv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mock", + Namespace: defaultNamespace, + }, + } + + linodeCluster := infrav1alpha2.LinodeCluster{ + Spec: infrav1alpha2.LinodeClusterSpec{ + Region: "us-ord", + Network: infrav1alpha2.NetworkSpec{ + LoadBalancerType: "dns", + DNSRootDomain: "lkedevs.net", + DNSUniqueIdentifier: "abc123", + DNSTTLSec: 30, + SubnetName: "test", + }, + VPCRef: &corev1.ObjectReference{ + Namespace: "default", + Kind: "LinodeVPC", + Name: "test-cluster", + }, + }, + } + + recorder := record.NewFakeRecorder(10) + + BeforeEach(func(ctx SpecContext) { + secret = corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bootstrap-secret", + Namespace: defaultNamespace, + }, + Data: map[string][]byte{ + "value": []byte("userdata"), + }, + } + Expect(k8sClient.Create(ctx, &secret)).To(Succeed()) + + machine = clusterv1.Machine{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: defaultNamespace, + Labels: make(map[string]string), + }, + Spec: clusterv1.MachineSpec{ + Bootstrap: clusterv1.Bootstrap{ + DataSecretName: ptr.To("bootstrap-secret"), + }, + }, + } + + linodeVPC = infrav1alpha2.LinodeVPC{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster", + Namespace: defaultNamespace, + UID: "5123122", + }, + Spec: infrav1alpha2.LinodeVPCSpec{ + VPCID: ptr.To(1), + Region: "us-ord", + Subnets: []infrav1alpha2.VPCSubnetCreateOptions{ + { + IPv4: "10.0.0.0/8", + SubnetID: 1, + Label: "test", + }, + }, + }, + Status: infrav1alpha2.LinodeVPCStatus{ + Ready: true, + }, + } + Expect(k8sClient.Create(ctx, &linodeVPC)).To(Succeed()) + + lvpcReconciler = &LinodeVPCReconciler{ + Recorder: recorder, + Client: k8sClient, + } + + mockCtrl = gomock.NewController(GinkgoT()) + testLogs = &bytes.Buffer{} + logger = zap.New( + zap.WriteTo(GinkgoWriter), + zap.WriteTo(testLogs), + zap.UseDevMode(true), + ) + }) + + AfterEach(func(ctx SpecContext) { + Expect(k8sClient.Delete(ctx, &secret)).To(Succeed()) + var currentVPC infrav1alpha2.LinodeVPC + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&linodeVPC), ¤tVPC)).To(Succeed()) + currentVPC.Finalizers = nil + + Expect(k8sClient.Update(ctx, ¤tVPC)).To(Succeed()) + + Expect(k8sClient.Delete(ctx, ¤tVPC)).To(Succeed()) + + mockCtrl.Finish() + for len(recorder.Events) > 0 { + <-recorder.Events + } + }) + + It("creates a instance with vpc", func(ctx SpecContext) { + linodeMachine := infrav1alpha2.LinodeMachine{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mock", + Namespace: defaultNamespace, + UID: "12345", + }, + Spec: infrav1alpha2.LinodeMachineSpec{ + ProviderID: ptr.To("linode://0"), + Type: "g6-nanode-1", + LinodeInterfaces: []infrav1alpha2.LinodeInterfaceCreateOptions{{}}, + }, + } + mockLinodeClient := mock.NewMockLinodeClient(mockCtrl) + mockLinodeClient.EXPECT(). + ListVPCs(ctx, gomock.Any()). + Return([]linodego.VPC{}, nil) + mockLinodeClient.EXPECT(). + CreateVPC(ctx, gomock.Any()). + Return(&linodego.VPC{ID: 1, Subnets: []linodego.VPCSubnet{{ + ID: 1, + Label: "test", + IPv4: "10.0.0.0/24", + }}}, nil) + helper, err := patch.NewHelper(&linodeVPC, k8sClient) + Expect(err).NotTo(HaveOccurred()) + + _, err = lvpcReconciler.reconcile(ctx, logger, &scope.VPCScope{ + PatchHelper: helper, + Client: k8sClient, + LinodeClient: mockLinodeClient, + LinodeVPC: &linodeVPC, + }) + + Expect(err).NotTo(HaveOccurred()) + + mScope := scope.MachineScope{ + Client: k8sClient, + LinodeClient: mockLinodeClient, + Cluster: &cluster, + Machine: &machine, + LinodeCluster: &linodeCluster, + LinodeMachine: &linodeMachine, + } + + patchHelper, err := patch.NewHelper(mScope.LinodeMachine, k8sClient) + Expect(err).NotTo(HaveOccurred()) + mScope.PatchHelper = patchHelper + + createOpts, err := newCreateConfig(ctx, &mScope, gzipCompressionFlag, logger) + Expect(err).NotTo(HaveOccurred()) + Expect(createOpts).NotTo(BeNil()) + Expect(createOpts.LinodeInterfaces).To(Equal([]linodego.LinodeInterfaceCreateOptions{ + { + VPC: &linodego.VPCInterfaceCreateOptions{ + SubnetID: 1, + IPv4: &linodego.VPCInterfaceIPv4CreateOptions{ + Addresses: []linodego.VPCInterfaceIPv4AddressCreateOptions{{ + NAT1To1Address: ptr.To("any"), + Primary: ptr.To(true), + }}, + }, + IPv6: nil, + }, + }, + {FirewallID: nil, DefaultRoute: nil, Public: nil, VPC: nil, VLAN: nil}, + })) + }) + It("creates a instance with pre defined vpc interface", func(ctx SpecContext) { + linodeMachine := infrav1alpha2.LinodeMachine{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mock", + Namespace: defaultNamespace, + UID: "12345", + }, + Spec: infrav1alpha2.LinodeMachineSpec{ + ProviderID: ptr.To("linode://0"), + Type: "g6-nanode-1", + LinodeInterfaces: []infrav1alpha2.LinodeInterfaceCreateOptions{ + { + VPC: &infrav1alpha2.VPCInterfaceCreateOptions{}, + Public: &infrav1alpha2.PublicInterfaceCreateOptions{}, + }, + }, + }, + } + mockLinodeClient := mock.NewMockLinodeClient(mockCtrl) + mockLinodeClient.EXPECT(). + ListVPCs(ctx, gomock.Any()). + Return([]linodego.VPC{}, nil) + mockLinodeClient.EXPECT(). + CreateVPC(ctx, gomock.Any()). + Return(&linodego.VPC{ID: 1, Subnets: []linodego.VPCSubnet{{ + ID: 1, + Label: "test", + IPv4: "10.0.0.0/24", + }}}, nil) + helper, err := patch.NewHelper(&linodeVPC, k8sClient) + Expect(err).NotTo(HaveOccurred()) + + _, err = lvpcReconciler.reconcile(ctx, logger, &scope.VPCScope{ + PatchHelper: helper, + Client: k8sClient, + LinodeClient: mockLinodeClient, + LinodeVPC: &linodeVPC, + }) + + Expect(err).NotTo(HaveOccurred()) + + mScope := scope.MachineScope{ + Client: k8sClient, + LinodeClient: mockLinodeClient, + Cluster: &cluster, + Machine: &machine, + LinodeCluster: &linodeCluster, + LinodeMachine: &linodeMachine, + } + + patchHelper, err := patch.NewHelper(mScope.LinodeMachine, k8sClient) + Expect(err).NotTo(HaveOccurred()) + mScope.PatchHelper = patchHelper + + createOpts, err := newCreateConfig(ctx, &mScope, gzipCompressionFlag, logger) + Expect(err).NotTo(HaveOccurred()) + Expect(createOpts).NotTo(BeNil()) + Expect(createOpts.LinodeInterfaces).To(Equal([]linodego.LinodeInterfaceCreateOptions{ + { + VPC: &linodego.VPCInterfaceCreateOptions{ + SubnetID: 1, + IPv4: &linodego.VPCInterfaceIPv4CreateOptions{ + Addresses: []linodego.VPCInterfaceIPv4AddressCreateOptions{{ + NAT1To1Address: ptr.To("any"), + Primary: ptr.To(true), + }}, + }, + IPv6: &linodego.VPCInterfaceIPv6CreateOptions{ + Ranges: nil, + SLAAC: nil, + IsPublic: false, + }, + }, + Public: &linodego.PublicInterfaceCreateOptions{ + IPv4: &linodego.PublicInterfaceIPv4CreateOptions{ + Addresses: nil, + }, + IPv6: &linodego.PublicInterfaceIPv6CreateOptions{ + Ranges: nil, + }, + }, + }, + })) + }) + It("creates an instance with vpc with a specific subnet", func(ctx SpecContext) { + linodeMachine := infrav1alpha2.LinodeMachine{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mock", + Namespace: defaultNamespace, + UID: "12345", + }, + Spec: infrav1alpha2.LinodeMachineSpec{ + ProviderID: ptr.To("linode://0"), + Type: "g6-nanode-1", + LinodeInterfaces: []infrav1alpha2.LinodeInterfaceCreateOptions{{ + VPC: &infrav1alpha2.VPCInterfaceCreateOptions{}, + }}, + }, + } + mockLinodeClient := mock.NewMockLinodeClient(mockCtrl) + mockLinodeClient.EXPECT(). + ListVPCs(ctx, gomock.Any()). + Return([]linodego.VPC{}, nil) + mockLinodeClient.EXPECT(). + CreateVPC(ctx, gomock.Any()). + Return(&linodego.VPC{ID: 1, Subnets: []linodego.VPCSubnet{{ + ID: 1, + Label: "primary", + IPv4: "192.16.0.0/24", + }, + { + ID: 27, + Label: "test", + IPv4: "10.0.0.0/24", + }, + }}, nil) + helper, err := patch.NewHelper(&linodeVPC, k8sClient) + Expect(err).NotTo(HaveOccurred()) + + _, err = lvpcReconciler.reconcile(ctx, logger, &scope.VPCScope{ + PatchHelper: helper, + Client: k8sClient, + LinodeClient: mockLinodeClient, + LinodeVPC: &linodeVPC, + }) + + Expect(err).NotTo(HaveOccurred()) + + mScope := scope.MachineScope{ + Client: k8sClient, + LinodeClient: mockLinodeClient, + Cluster: &cluster, + Machine: &machine, + LinodeCluster: &linodeCluster, + LinodeMachine: &linodeMachine, + } + + patchHelper, err := patch.NewHelper(mScope.LinodeMachine, k8sClient) + Expect(err).NotTo(HaveOccurred()) + mScope.PatchHelper = patchHelper + + createOpts, err := newCreateConfig(ctx, &mScope, gzipCompressionFlag, logger) + Expect(err).NotTo(HaveOccurred()) + Expect(createOpts).NotTo(BeNil()) + Expect(createOpts.LinodeInterfaces).To(Equal([]linodego.LinodeInterfaceCreateOptions{ + { + VPC: &linodego.VPCInterfaceCreateOptions{ + SubnetID: 27, + IPv4: &linodego.VPCInterfaceIPv4CreateOptions{ + Addresses: []linodego.VPCInterfaceIPv4AddressCreateOptions{{ + NAT1To1Address: ptr.To("any"), + Primary: ptr.To(true), + }}, + }, + IPv6: &linodego.VPCInterfaceIPv6CreateOptions{SLAAC: nil, Ranges: nil, IsPublic: false}, + }, + }, + })) + }) +}) + var _ = Describe("machine in vlan", Label("machine", "vlan"), Ordered, func() { var machine clusterv1.Machine var secret corev1.Secret @@ -2634,14 +2977,196 @@ var _ = Describe("machine in vlan", Label("machine", "vlan"), Ordered, func() { Type: "g6-nanode-1", Image: rutil.DefaultMachineControllerLinodeImage, DiskEncryption: string(linodego.InstanceDiskEncryptionEnabled), - Interfaces: []infrav1alpha2.InstanceConfigInterfaceCreateOptions{ - { - Purpose: linodego.InterfacePurposePublic, - }, - { - Purpose: linodego.InterfacePurposeVLAN, - }, - }, + Interfaces: []infrav1alpha2.InstanceConfigInterfaceCreateOptions{ + { + Purpose: linodego.InterfacePurposePublic, + }, + { + Purpose: linodego.InterfacePurposeVLAN, + }, + }, + }, + } + + mockCtrl = gomock.NewController(GinkgoT()) + testLogs = &bytes.Buffer{} + logger = zap.New( + zap.WriteTo(GinkgoWriter), + zap.WriteTo(testLogs), + zap.UseDevMode(true), + ) + reconciler = &LinodeMachineReconciler{ + Recorder: recorder, + } + }) + + AfterEach(func(ctx SpecContext) { + Expect(k8sClient.Delete(ctx, &secret)).To(Succeed()) + + mockCtrl.Finish() + for len(recorder.Events) > 0 { + <-recorder.Events + } + }) + + It("creates an instance with vlan", func(ctx SpecContext) { + mockLinodeClient := mock.NewMockLinodeClient(mockCtrl) + getRegion := mockLinodeClient.EXPECT(). + GetRegion(ctx, gomock.Any()). + Return(&linodego.Region{Capabilities: []string{linodego.CapabilityMetadata, linodego.CapabilityDiskEncryption}}, nil) + getImage := mockLinodeClient.EXPECT(). + GetImage(ctx, gomock.Any()). + After(getRegion). + Return(&linodego.Image{Capabilities: []string{"cloud-init"}}, nil) + createInst := mockLinodeClient.EXPECT(). + CreateInstance(ctx, gomock.Any()). + After(getImage). + Return(&linodego.Instance{ + ID: 123, + IPv4: []*net.IP{ptr.To(net.IPv4(192, 168, 0, 2))}, + IPv6: "fd00::", + Status: linodego.InstanceOffline, + }, nil) + mockLinodeClient.EXPECT(). + OnAfterResponse(gomock.Any()). + Return() + listInstConfs := mockLinodeClient.EXPECT(). + ListInstanceConfigs(ctx, 123, gomock.Any()). + After(createInst). + Return([]linodego.InstanceConfig{{ + ID: 1, + }}, nil) + mockLinodeClient.EXPECT().UpdateInstanceConfig(ctx, 123, 1, linodego.InstanceConfigUpdateOptions{ + Helpers: &linodego.InstanceConfigHelpers{Network: true}, + }). + After(listInstConfs). + Return(nil, nil) + bootInst := mockLinodeClient.EXPECT(). + BootInstance(ctx, 123, 0). + After(createInst). + Return(nil) + getAddrs := mockLinodeClient.EXPECT(). + GetInstanceIPAddresses(ctx, 123). + After(bootInst). + Return(&linodego.InstanceIPAddressResponse{ + IPv4: &linodego.InstanceIPv4Response{ + Private: []*linodego.InstanceIP{{Address: "192.168.0.2"}}, + Public: []*linodego.InstanceIP{{Address: "172.0.0.2"}}, + VPC: []*linodego.VPCIP{}, + }, + IPv6: &linodego.InstanceIPv6Response{ + SLAAC: &linodego.InstanceIP{ + Address: "fd00::", + }, + }, + }, nil) + mockLinodeClient.EXPECT(). + ListInstanceConfigs(ctx, 123, gomock.Any()). + After(getAddrs). + Return([]linodego.InstanceConfig{{ + Interfaces: []linodego.InstanceConfigInterface{ + { + Purpose: linodego.InterfacePurposePublic, + }, + { + Purpose: linodego.InterfacePurposeVLAN, + IPAMAddress: "10.0.0.2/11", + }, + }, + }}, nil) + + mScope := scope.MachineScope{ + Client: k8sClient, + LinodeClient: mockLinodeClient, + Cluster: &cluster, + Machine: &machine, + LinodeCluster: &linodeCluster, + LinodeMachine: &linodeMachine, + } + + patchHelper, err := patch.NewHelper(mScope.LinodeMachine, k8sClient) + Expect(err).NotTo(HaveOccurred()) + mScope.PatchHelper = patchHelper + + _, err = reconciler.reconcileCreate(ctx, logger, &mScope) + Expect(err).NotTo(HaveOccurred()) + _, err = reconciler.reconcileCreate(ctx, logger, &mScope) + Expect(err).NotTo(HaveOccurred()) + + Expect(rutil.ConditionTrue(&linodeMachine, ConditionPreflightMetadataSupportConfigured)).To(BeTrue()) + Expect(rutil.ConditionTrue(&linodeMachine, ConditionPreflightCreated)).To(BeTrue()) + Expect(rutil.ConditionTrue(&linodeMachine, ConditionPreflightConfigured)).To(BeTrue()) + Expect(rutil.ConditionTrue(&linodeMachine, ConditionPreflightBootTriggered)).To(BeTrue()) + Expect(rutil.ConditionTrue(&linodeMachine, ConditionPreflightReady)).To(BeTrue()) + }) +}) + +var _ = Describe("machine in vlan for new network interfaces", Label("machine", "newNetworkInterfaces", "vlan"), Ordered, func() { + var machine clusterv1.Machine + var secret corev1.Secret + + var mockCtrl *gomock.Controller + var testLogs *bytes.Buffer + var logger logr.Logger + + var reconciler *LinodeMachineReconciler + var linodeMachine infrav1alpha2.LinodeMachine + + cluster := clusterv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mock", + Namespace: defaultNamespace, + }, + } + + linodeCluster := infrav1alpha2.LinodeCluster{ + Spec: infrav1alpha2.LinodeClusterSpec{ + Region: "us-ord", + Network: infrav1alpha2.NetworkSpec{ + UseVlan: true, + }, + }, + } + + recorder := record.NewFakeRecorder(10) + + BeforeEach(func(ctx SpecContext) { + secret = corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bootstrap-secret", + Namespace: defaultNamespace, + }, + Data: map[string][]byte{ + "value": []byte("userdata"), + }, + } + Expect(k8sClient.Create(ctx, &secret)).To(Succeed()) + + machine = clusterv1.Machine{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: defaultNamespace, + Labels: make(map[string]string), + }, + Spec: clusterv1.MachineSpec{ + Bootstrap: clusterv1.Bootstrap{ + DataSecretName: ptr.To("bootstrap-secret"), + }, + }, + } + + linodeMachine = infrav1alpha2.LinodeMachine{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mock", + Namespace: defaultNamespace, + UID: "12345", + }, + Spec: infrav1alpha2.LinodeMachineSpec{ + Type: "g6-nanode-1", + Image: rutil.DefaultMachineControllerLinodeImage, + DiskEncryption: string(linodego.InstanceDiskEncryptionEnabled), + LinodeInterfaces: []infrav1alpha2.LinodeInterfaceCreateOptions{{ + VLAN: &infrav1alpha2.VLANInterface{}, + }}, }, } @@ -2718,17 +3243,11 @@ var _ = Describe("machine in vlan", Label("machine", "vlan"), Ordered, func() { }, }, nil) mockLinodeClient.EXPECT(). - ListInstanceConfigs(ctx, 123, gomock.Any()). + ListInterfaces(ctx, 123, gomock.Any()). After(getAddrs). - Return([]linodego.InstanceConfig{{ - Interfaces: []linodego.InstanceConfigInterface{ - { - Purpose: linodego.InterfacePurposePublic, - }, - { - Purpose: linodego.InterfacePurposeVLAN, - IPAMAddress: "10.0.0.2/11", - }, + Return([]linodego.LinodeInterface{{ + VLAN: &linodego.VLANInterface{ + IPAMAddress: ptr.To("10.0.0.2/11"), }, }}, nil) @@ -2950,6 +3469,203 @@ var _ = Describe("create machine with direct VPCID", Label("machine", "VPCID"), }) }) +var _ = Describe("create machine with direct VPCID with new network interfaces", Label("machine", "newNetworkInterfaces", "VPCID"), Ordered, func() { + var ( + reconciler LinodeMachineReconciler + linodeMachine infrav1alpha2.LinodeMachine + machineKey client.ObjectKey + bootstrapSecret corev1.Secret + ) + + BeforeAll(func(ctx SpecContext) { + reconciler = LinodeMachineReconciler{ + Client: k8sClient, + Recorder: record.NewFakeRecorder(100), + } + + linodeMachine = infrav1alpha2.LinodeMachine{ + ObjectMeta: metav1.ObjectMeta{ + Name: "machine-with-direct-vpcid-new-network-interfaces", + Namespace: defaultNamespace, + }, + Spec: infrav1alpha2.LinodeMachineSpec{ + Type: "g6-nanode-1", + Image: "linode/ubuntu22.04", + Region: "us-east", + VPCID: ptr.To(12345), + LinodeInterfaces: []infrav1alpha2.LinodeInterfaceCreateOptions{ + { + VPC: &infrav1alpha2.VPCInterfaceCreateOptions{}, + }, + }, + }, + } + machineKey = client.ObjectKeyFromObject(&linodeMachine) + + bootstrapSecret = corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bootstrap-secret-vpcid-new-network-interfaces", + Namespace: defaultNamespace, + }, + Data: map[string][]byte{ + "value": []byte("userdata"), + }, + } + + Expect(k8sClient.Create(ctx, &bootstrapSecret)).To(Succeed()) + Expect(k8sClient.Create(ctx, &linodeMachine)).To(Succeed()) + }) + + AfterAll(func(ctx SpecContext) { + Expect(k8sClient.Delete(ctx, &linodeMachine)).To(Succeed()) + Expect(k8sClient.Delete(ctx, &bootstrapSecret)).To(Succeed()) + }) + + It("creates a machine with direct VPCID", func(ctx SpecContext) { + mockCtrl := gomock.NewController(GinkgoT()) + defer mockCtrl.Finish() + + mockLinodeClient := mock.NewMockLinodeClient(mockCtrl) + mockLinodeClient.EXPECT(). + GetRegion(gomock.Any(), gomock.Any()). + Return(&linodego.Region{ID: "us-east", Capabilities: []string{"Metadata"}}, nil). + AnyTimes() + mockLinodeClient.EXPECT(). + GetImage(gomock.Any(), gomock.Any()). + Return(&linodego.Image{ID: "linode/ubuntu22.04", Capabilities: []string{"cloud-init"}}, nil). + AnyTimes() + mockLinodeClient.EXPECT(). + GetVPC(gomock.Any(), gomock.Eq(12345)). + Return(&linodego.VPC{ + ID: 12345, + Label: "test-vpc", + Region: "us-east", + Subnets: []linodego.VPCSubnet{ + { + ID: 1001, + Label: "subnet-1", + }, + }, + }, nil). + AnyTimes() + mockLinodeClient.EXPECT(). + CreateInstance(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, opts linodego.InstanceCreateOptions) (*linodego.Instance, error) { + // Verify that the instance is created with the correct VPC interface + Expect(opts.LinodeInterfaces).To(HaveLen(1)) + Expect(opts.LinodeInterfaces[0].VPC).ToNot(BeNil()) + Expect(opts.LinodeInterfaces[0].VPC.SubnetID).To(Equal(1001)) + + return &linodego.Instance{ + ID: 12345, + Label: opts.Label, + Region: opts.Region, + Status: linodego.InstanceRunning, + IPv4: []*net.IP{ptr.To(net.ParseIP("192.168.1.2"))}, + IPv6: "2001:db8::2", + }, nil + }). + AnyTimes() + mockLinodeClient.EXPECT(). + OnAfterResponse(gomock.Any()). + Return(). + AnyTimes() + mockLinodeClient.EXPECT(). + ListInstanceConfigs(gomock.Any(), gomock.Any(), gomock.Any()). + Return([]linodego.InstanceConfig{ + { + ID: 1, + Label: "My Config", + Devices: &linodego.InstanceConfigDeviceMap{}, + }, + }, nil). + AnyTimes() + mockLinodeClient.EXPECT(). + UpdateInstanceConfig(gomock.Any(), 12345, 1, gomock.Any()). + Return(nil, nil). + AnyTimes() + mockLinodeClient.EXPECT(). + GetInstanceIPAddresses(gomock.Any(), gomock.Any()). + Return(&linodego.InstanceIPAddressResponse{ + IPv4: &linodego.InstanceIPv4Response{ + Public: []*linodego.InstanceIP{{Address: "192.168.1.2"}}, + Private: []*linodego.InstanceIP{}, + }, + IPv6: &linodego.InstanceIPv6Response{ + SLAAC: &linodego.InstanceIP{Address: "2001:db8::2"}, + }, + }, nil). + AnyTimes() + mockLinodeClient.EXPECT(). + BootInstance(gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil). + AnyTimes() + + // Create a machine scope with the mock client + machine := &clusterv1.Machine{ + ObjectMeta: metav1.ObjectMeta{ + Name: "machine-with-direct-vpcid-new-network-interfaces", + Namespace: defaultNamespace, + }, + Spec: clusterv1.MachineSpec{ + Bootstrap: clusterv1.Bootstrap{ + DataSecretName: ptr.To("bootstrap-secret-vpcid-new-network-interfaces"), + }, + }, + } + + cluster := &clusterv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster", + Namespace: defaultNamespace, + }, + } + + // Get the LinodeMachine + Expect(k8sClient.Get(ctx, machineKey, &linodeMachine)).To(Succeed()) + + // Create a machine scope + patchHelper, err := patch.NewHelper(&linodeMachine, k8sClient) + Expect(err).NotTo(HaveOccurred()) + + // Create a LinodeCluster for the machineScope + linodeCluster := &infrav1alpha2.LinodeCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster", + Namespace: defaultNamespace, + }, + Spec: infrav1alpha2.LinodeClusterSpec{ + Region: "us-east", + }, + } + + // Set the VPC preflight check condition to true + conditions.Set(&linodeMachine, metav1.Condition{ + Type: ConditionPreflightLinodeVPCReady, + Status: metav1.ConditionTrue, + Reason: "VPCReady", + }) + + mScope := &scope.MachineScope{ + Client: k8sClient, + Cluster: cluster, + Machine: machine, + LinodeMachine: &linodeMachine, + LinodeCluster: linodeCluster, // Add the LinodeCluster to the scope + PatchHelper: patchHelper, + LinodeClient: mockLinodeClient, + } + + // Reconcile the machine + result, err := reconciler.reconcile(ctx, logr.Discard(), mScope) + Expect(err).NotTo(HaveOccurred()) + Expect(result.IsZero()).To(BeTrue()) + + // Verify that the preflight check for VPC is successful + Expect(rutil.ConditionTrue(&linodeMachine, ConditionPreflightLinodeVPCReady)).To(BeTrue()) + }) +}) + var _ = Describe("direct vpc functions", Label("machine", "vpc", "functions"), Ordered, func() { var mockCtrl *gomock.Controller var mockLinodeClient *mock.MockLinodeClient diff --git a/internal/webhook/v1alpha2/linodemachine_webhook.go b/internal/webhook/v1alpha2/linodemachine_webhook.go index 2c13730a2..98c13f9a8 100644 --- a/internal/webhook/v1alpha2/linodemachine_webhook.go +++ b/internal/webhook/v1alpha2/linodemachine_webhook.go @@ -128,10 +128,17 @@ func (r *linodeMachineValidator) ValidateDelete(ctx context.Context, obj runtime func (r *linodeMachineValidator) validateLinodeMachineSpec(ctx context.Context, linodeclient clients.LinodeClient, spec infrav1alpha2.LinodeMachineSpec, skipAPIValidation bool) field.ErrorList { var errs field.ErrorList - if !skipAPIValidation { - if err := validateRegion(ctx, linodeclient, spec.Region, field.NewPath("spec").Child("region")); err != nil { - errs = append(errs, err) + if !skipAPIValidation { //nolint:nestif // too simple for switch + if spec.LinodeInterfaces != nil { + if err := validateRegion(ctx, linodeclient, spec.Region, field.NewPath("spec").Child("region"), linodego.CapabilityLinodeInterfaces); err != nil { + errs = append(errs, err) + } + } else { + if err := validateRegion(ctx, linodeclient, spec.Region, field.NewPath("spec").Child("region")); err != nil { + errs = append(errs, err) + } } + plan, err := validateLinodeType(ctx, linodeclient, spec.Type, field.NewPath("spec").Child("type")) if err != nil { errs = append(errs, err) diff --git a/mock/client.go b/mock/client.go index b6f1f4c34..0bf9ee4f6 100644 --- a/mock/client.go +++ b/mock/client.go @@ -726,6 +726,21 @@ func (mr *MockLinodeClientMockRecorder) ListInstances(ctx, opts any) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListInstances", reflect.TypeOf((*MockLinodeClient)(nil).ListInstances), ctx, opts) } +// ListInterfaces mocks base method. +func (m *MockLinodeClient) ListInterfaces(ctx context.Context, linodeID int, opts *linodego.ListOptions) ([]linodego.LinodeInterface, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListInterfaces", ctx, linodeID, opts) + ret0, _ := ret[0].([]linodego.LinodeInterface) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListInterfaces indicates an expected call of ListInterfaces. +func (mr *MockLinodeClientMockRecorder) ListInterfaces(ctx, linodeID, opts any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListInterfaces", reflect.TypeOf((*MockLinodeClient)(nil).ListInterfaces), ctx, linodeID, opts) +} + // ListNodeBalancerNodes mocks base method. func (m *MockLinodeClient) ListNodeBalancerNodes(ctx context.Context, nodebalancerID, configID int, opts *linodego.ListOptions) ([]linodego.NodeBalancerNode, error) { m.ctrl.T.Helper() @@ -2167,6 +2182,44 @@ func (mr *MockLinodeFirewallClientMockRecorder) UpdateFirewallRules(ctx, firewal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateFirewallRules", reflect.TypeOf((*MockLinodeFirewallClient)(nil).UpdateFirewallRules), ctx, firewallID, rules) } +// MockLinodeInterfacesClient is a mock of LinodeInterfacesClient interface. +type MockLinodeInterfacesClient struct { + ctrl *gomock.Controller + recorder *MockLinodeInterfacesClientMockRecorder +} + +// MockLinodeInterfacesClientMockRecorder is the mock recorder for MockLinodeInterfacesClient. +type MockLinodeInterfacesClientMockRecorder struct { + mock *MockLinodeInterfacesClient +} + +// NewMockLinodeInterfacesClient creates a new mock instance. +func NewMockLinodeInterfacesClient(ctrl *gomock.Controller) *MockLinodeInterfacesClient { + mock := &MockLinodeInterfacesClient{ctrl: ctrl} + mock.recorder = &MockLinodeInterfacesClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockLinodeInterfacesClient) EXPECT() *MockLinodeInterfacesClientMockRecorder { + return m.recorder +} + +// ListInterfaces mocks base method. +func (m *MockLinodeInterfacesClient) ListInterfaces(ctx context.Context, linodeID int, opts *linodego.ListOptions) ([]linodego.LinodeInterface, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListInterfaces", ctx, linodeID, opts) + ret0, _ := ret[0].([]linodego.LinodeInterface) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListInterfaces indicates an expected call of ListInterfaces. +func (mr *MockLinodeInterfacesClientMockRecorder) ListInterfaces(ctx, linodeID, opts any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListInterfaces", reflect.TypeOf((*MockLinodeInterfacesClient)(nil).ListInterfaces), ctx, linodeID, opts) +} + // MockK8sClient is a mock of K8sClient interface. type MockK8sClient struct { ctrl *gomock.Controller diff --git a/observability/wrappers/linodeclient/linodeclient.gen.go b/observability/wrappers/linodeclient/linodeclient.gen.go index 2a8ff21e5..407e07503 100644 --- a/observability/wrappers/linodeclient/linodeclient.gen.go +++ b/observability/wrappers/linodeclient/linodeclient.gen.go @@ -1195,6 +1195,32 @@ func (_d LinodeClientWithTracing) ListInstances(ctx context.Context, opts *linod return _d.LinodeClient.ListInstances(ctx, opts) } +// ListInterfaces implements _sourceClients.LinodeClient +func (_d LinodeClientWithTracing) ListInterfaces(ctx context.Context, linodeID int, opts *linodego.ListOptions) (la1 []linodego.LinodeInterface, err error) { + ctx, _span := tracing.Start(ctx, "_sourceClients.LinodeClient.ListInterfaces") + defer func() { + if _d._spanDecorator != nil { + _d._spanDecorator(_span, map[string]interface{}{ + "ctx": ctx, + "linodeID": linodeID, + "opts": opts}, map[string]interface{}{ + "la1": la1, + "err": err}) + } + + if err != nil { + _span.RecordError(err) + _span.SetAttributes( + attribute.String("event", "error"), + attribute.String("message", err.Error()), + ) + } + + _span.End() + }() + return _d.LinodeClient.ListInterfaces(ctx, linodeID, opts) +} + // ListNodeBalancerNodes implements _sourceClients.LinodeClient func (_d LinodeClientWithTracing) ListNodeBalancerNodes(ctx context.Context, nodebalancerID int, configID int, opts *linodego.ListOptions) (na1 []linodego.NodeBalancerNode, err error) { ctx, _span := tracing.Start(ctx, "_sourceClients.LinodeClient.ListNodeBalancerNodes") From 2d6edaec8e79748028f173e6be044e4995e23dbb Mon Sep 17 00:00:00 2001 From: Ashley Dumaine Date: Tue, 5 Aug 2025 16:14:34 -0400 Subject: [PATCH 02/10] add some fixes and validation for linode creation with new network interfaces --- .../controller/linodemachine_controller.go | 4 + .../linodemachine_controller_helpers.go | 79 +++++++++++++------ .../linodemachine_controller_helpers_test.go | 2 +- .../linodemachine_controller_test.go | 11 ++- .../webhook/v1alpha2/linodemachine_webhook.go | 25 ++++++ templates/infra/linodeMachineTemplate.yaml | 12 +++ 6 files changed, 103 insertions(+), 30 deletions(-) diff --git a/internal/controller/linodemachine_controller.go b/internal/controller/linodemachine_controller.go index d4e36363b..142728dcd 100644 --- a/internal/controller/linodemachine_controller.go +++ b/internal/controller/linodemachine_controller.go @@ -624,6 +624,10 @@ func (r *LinodeMachineReconciler) reconcilePreflightConfigure(ctx context.Contex Network: true, }, } + // LinodeInterfaces does not support network helpers, so we disable it + if machineScope.LinodeMachine.Spec.LinodeInterfaces != nil { + configData.Helpers.Network = false + } if machineScope.LinodeMachine.Spec.Configuration != nil && machineScope.LinodeMachine.Spec.Configuration.Kernel != "" { configData.Kernel = machineScope.LinodeMachine.Spec.Configuration.Kernel diff --git a/internal/controller/linodemachine_controller_helpers.go b/internal/controller/linodemachine_controller_helpers.go index d5d7b77c6..73b5d85c4 100644 --- a/internal/controller/linodemachine_controller_helpers.go +++ b/internal/controller/linodemachine_controller_helpers.go @@ -84,7 +84,14 @@ func fillCreateConfig(createConfig *linodego.InstanceCreateOptions, machineScope if machineScope.LinodeMachine.Spec.PrivateIP != nil { createConfig.PrivateIP = *machineScope.LinodeMachine.Spec.PrivateIP } else { - createConfig.PrivateIP = true + if machineScope.LinodeMachine.Spec.LinodeInterfaces == nil { + // Supported only for legacy network interfaces. + createConfig.PrivateIP = true + } else { + // Network Helper is not supported for the new network interfaces. + createConfig.NetworkHelper = ptr.To(false) + createConfig.InterfaceGeneration = linodego.GenerationLinode + } } if createConfig.Tags == nil { @@ -638,12 +645,21 @@ func getVPCLinodeInterfaceConfig(ctx context.Context, machineScope *scope.Machin } // Check if a VPC interface already exists - for i, netInterface := range linodeInterfaces { + for iface, netInterface := range linodeInterfaces { if netInterface.VPC != nil { - linodeInterfaces[i].VPC.SubnetID = subnetID + linodeInterfaces[iface].VPC.SubnetID = subnetID // If IPv6 range config is not empty, add it to the interface configuration if !isVPCInterfaceIPv6ConfigEmpty(ipv6Config) { - linodeInterfaces[i].VPC.IPv6 = ipv6Config + linodeInterfaces[iface].VPC.IPv6 = ipv6Config + } + if netInterface.VPC.IPv4 == nil { + linodeInterfaces[iface].VPC.IPv4 = &linodego.VPCInterfaceIPv4CreateOptions{ + Addresses: []linodego.VPCInterfaceIPv4AddressCreateOptions{{ + Primary: ptr.To(true), + NAT1To1Address: ptr.To("auto"), + Address: "auto", + }}, + } } return nil, nil //nolint:nilnil // it is important we don't return an interface if a VPC interface already exists } @@ -656,7 +672,8 @@ func getVPCLinodeInterfaceConfig(ctx context.Context, machineScope *scope.Machin IPv4: &linodego.VPCInterfaceIPv4CreateOptions{ Addresses: []linodego.VPCInterfaceIPv4AddressCreateOptions{{ Primary: ptr.To(true), - NAT1To1Address: ptr.To("any"), + NAT1To1Address: ptr.To("auto"), + Address: "auto", }}, }, }, @@ -667,6 +684,8 @@ func getVPCLinodeInterfaceConfig(ctx context.Context, machineScope *scope.Machin vpcIntfCreateOpts.VPC.IPv6 = ipv6Config } + logger.Info("Creating LinodeInterfaceCreateOptions", "VPC", *vpcIntfCreateOpts) + return vpcIntfCreateOpts, nil } @@ -738,7 +757,7 @@ func getVPCLinodeInterfaceConfigFromDirectID(ctx context.Context, machineScope * IPv4: &linodego.VPCInterfaceIPv4CreateOptions{ Addresses: []linodego.VPCInterfaceIPv4AddressCreateOptions{{ Primary: ptr.To(true), - NAT1To1Address: ptr.To("any"), + NAT1To1Address: ptr.To("auto"), }}, }, }, @@ -905,7 +924,6 @@ func getVPCInterfaceIPv6Config(machineScope *scope.MachineScope, numIPv6RangesIn // Unfortunately, this is necessary since DeepCopy can't be generated for linodego.LinodeInterfaceCreateOptions // so here we manually create the options for Linode interfaces. -// / // //nolint:gocognit,cyclop,gocritic,nestif,nolintlint // Also, unfortunately, this cannot be made any reasonably simpler with how complicated the linodego struct is func constructLinodeInterfaceCreateOpts(createOpts []infrav1alpha2.LinodeInterfaceCreateOptions) []linodego.LinodeInterfaceCreateOptions { @@ -946,7 +964,8 @@ func constructLinodeInterfaceCreateOpts(createOpts []infrav1alpha2.LinodeInterfa ipv4Addrs = []linodego.VPCInterfaceIPv4AddressCreateOptions{ { Primary: ptr.To(true), - NAT1To1Address: ptr.To("any"), + NAT1To1Address: ptr.To("auto"), + Address: "auto", // Default to auto-assigned address }, } } @@ -1021,22 +1040,8 @@ func constructLinodeInterfaceCreateOpts(createOpts []infrav1alpha2.LinodeInterfa return linodeInterfaces } +// for converting LinodeMachineSpec to linodego.InstanceCreateOptions. Any defaulting should be done in fillCreateConfig instead func linodeMachineSpecToInstanceCreateConfig(machineSpec infrav1alpha2.LinodeMachineSpec, machineTags []string) *linodego.InstanceCreateOptions { - interfaces := make([]linodego.InstanceConfigInterfaceCreateOptions, len(machineSpec.Interfaces)) - for idx, iface := range machineSpec.Interfaces { - interfaces[idx] = linodego.InstanceConfigInterfaceCreateOptions{ - IPAMAddress: iface.IPAMAddress, - Label: iface.Label, - Purpose: iface.Purpose, - Primary: iface.Primary, - SubnetID: iface.SubnetID, - IPRanges: iface.IPRanges, - } - } - privateIP := false - if machineSpec.PrivateIP != nil { - privateIP = *machineSpec.PrivateIP - } instCreateOpts := &linodego.InstanceCreateOptions{ Region: machineSpec.Region, Type: machineSpec.Type, @@ -1044,14 +1049,30 @@ func linodeMachineSpecToInstanceCreateConfig(machineSpec infrav1alpha2.LinodeMac AuthorizedUsers: machineSpec.AuthorizedUsers, RootPass: machineSpec.RootPass, Image: machineSpec.Image, - Interfaces: interfaces, - PrivateIP: privateIP, Tags: machineTags, FirewallID: machineSpec.FirewallID, DiskEncryption: linodego.InstanceDiskEncryption(machineSpec.DiskEncryption), } + + if machineSpec.PrivateIP != nil { + instCreateOpts.PrivateIP = *machineSpec.PrivateIP + } + if len(machineSpec.LinodeInterfaces) > 0 { instCreateOpts.LinodeInterfaces = constructLinodeInterfaceCreateOpts(machineSpec.LinodeInterfaces) + } else { + interfaces := make([]linodego.InstanceConfigInterfaceCreateOptions, len(machineSpec.Interfaces)) + for idx, iface := range machineSpec.Interfaces { + interfaces[idx] = linodego.InstanceConfigInterfaceCreateOptions{ + IPAMAddress: iface.IPAMAddress, + Label: iface.Label, + Purpose: iface.Purpose, + Primary: iface.Primary, + SubnetID: iface.SubnetID, + IPRanges: iface.IPRanges, + } + } + instCreateOpts.Interfaces = interfaces } return instCreateOpts @@ -1478,6 +1499,14 @@ func configureFirewall(ctx context.Context, machineScope *scope.MachineScope, cr } createConfig.FirewallID = fwID + + // If using LinodeInterfaces that needs to know about the firewall ID + if machineScope.LinodeMachine.Spec.LinodeInterfaces != nil { + for i := range createConfig.LinodeInterfaces { + createConfig.LinodeInterfaces[i].FirewallID = ptr.To(fwID) + } + } + return nil } diff --git a/internal/controller/linodemachine_controller_helpers_test.go b/internal/controller/linodemachine_controller_helpers_test.go index b2ab2e62a..09144a04c 100644 --- a/internal/controller/linodemachine_controller_helpers_test.go +++ b/internal/controller/linodemachine_controller_helpers_test.go @@ -436,7 +436,7 @@ func validateInterfaceExpectations( require.Equal(t, expectSubnetID, linodeIface.VPC.SubnetID) require.NotNil(t, linodeIface.VPC.IPv4) require.NotNil(t, linodeIface.VPC.IPv4.Addresses[0].NAT1To1Address) - require.Equal(t, "any", *linodeIface.VPC.IPv4.Addresses[0].NAT1To1Address) + require.Equal(t, "auto", *linodeIface.VPC.IPv4.Addresses[0].NAT1To1Address) } else { require.Nil(t, linodeIface) } diff --git a/internal/controller/linodemachine_controller_test.go b/internal/controller/linodemachine_controller_test.go index 7a9eef780..3d536784c 100644 --- a/internal/controller/linodemachine_controller_test.go +++ b/internal/controller/linodemachine_controller_test.go @@ -2743,8 +2743,9 @@ var _ = Describe("machine in VPC with new network interfaces", Label("machine", SubnetID: 1, IPv4: &linodego.VPCInterfaceIPv4CreateOptions{ Addresses: []linodego.VPCInterfaceIPv4AddressCreateOptions{{ - NAT1To1Address: ptr.To("any"), + NAT1To1Address: ptr.To("auto"), Primary: ptr.To(true), + Address: "auto", }}, }, IPv6: nil, @@ -2816,8 +2817,9 @@ var _ = Describe("machine in VPC with new network interfaces", Label("machine", SubnetID: 1, IPv4: &linodego.VPCInterfaceIPv4CreateOptions{ Addresses: []linodego.VPCInterfaceIPv4AddressCreateOptions{{ - NAT1To1Address: ptr.To("any"), + NAT1To1Address: ptr.To("auto"), Primary: ptr.To(true), + Address: "auto", }}, }, IPv6: &linodego.VPCInterfaceIPv6CreateOptions{ @@ -2903,8 +2905,9 @@ var _ = Describe("machine in VPC with new network interfaces", Label("machine", SubnetID: 27, IPv4: &linodego.VPCInterfaceIPv4CreateOptions{ Addresses: []linodego.VPCInterfaceIPv4AddressCreateOptions{{ - NAT1To1Address: ptr.To("any"), + NAT1To1Address: ptr.To("auto"), Primary: ptr.To(true), + Address: "auto", }}, }, IPv6: &linodego.VPCInterfaceIPv6CreateOptions{SLAAC: nil, Ranges: nil, IsPublic: false}, @@ -3219,7 +3222,7 @@ var _ = Describe("machine in vlan for new network interfaces", Label("machine", ID: 1, }}, nil) mockLinodeClient.EXPECT().UpdateInstanceConfig(ctx, 123, 1, linodego.InstanceConfigUpdateOptions{ - Helpers: &linodego.InstanceConfigHelpers{Network: true}, + Helpers: &linodego.InstanceConfigHelpers{Network: false}, }). After(listInstConfs). Return(nil, nil) diff --git a/internal/webhook/v1alpha2/linodemachine_webhook.go b/internal/webhook/v1alpha2/linodemachine_webhook.go index 98c13f9a8..8ca82900f 100644 --- a/internal/webhook/v1alpha2/linodemachine_webhook.go +++ b/internal/webhook/v1alpha2/linodemachine_webhook.go @@ -125,6 +125,7 @@ func (r *linodeMachineValidator) ValidateDelete(ctx context.Context, obj runtime return nil, nil } +//nolint:cyclop // as simple as it gets func (r *linodeMachineValidator) validateLinodeMachineSpec(ctx context.Context, linodeclient clients.LinodeClient, spec infrav1alpha2.LinodeMachineSpec, skipAPIValidation bool) field.ErrorList { var errs field.ErrorList @@ -156,6 +157,30 @@ func (r *linodeMachineValidator) validateLinodeMachineSpec(ctx context.Context, }) } + if spec.LinodeInterfaces != nil && spec.Interfaces != nil { + errs = append(errs, &field.Error{ + Field: "spec.linodeInterfaces/spec.interfaces", + Type: field.ErrorTypeInvalid, + Detail: "Cannot specify both LinodeInterfaces and Interfaces", + }) + } + + if spec.LinodeInterfaces != nil && spec.PrivateIP != nil && *spec.PrivateIP { + errs = append(errs, &field.Error{ + Field: "spec.linodeInterfaces/spec.privateIP", + Type: field.ErrorTypeInvalid, + Detail: "Linode Interfaces do not support private IPs", + }) + } + + if spec.LinodeInterfaces != nil && spec.NetworkHelper != nil && *spec.NetworkHelper { + errs = append(errs, &field.Error{ + Field: "spec.linodeInterfaces/spec.networkHelper", + Type: field.ErrorTypeInvalid, + Detail: "Linode Interfaces do not support network helper (enabled by default), it must be explicitly set to false", + }) + } + if spec.FirewallID != 0 && spec.FirewallRef != nil { errs = append(errs, &field.Error{ Field: "spec.firewallID/spec.firewallRef", diff --git a/templates/infra/linodeMachineTemplate.yaml b/templates/infra/linodeMachineTemplate.yaml index 586c37293..ce92b6574 100644 --- a/templates/infra/linodeMachineTemplate.yaml +++ b/templates/infra/linodeMachineTemplate.yaml @@ -14,6 +14,12 @@ spec: kind: LinodeFirewall name: ${CLUSTER_NAME} # diskEncryption: disabled + # + # linodeInterfaces can't be used for the control plane + # if using the default NodeBalancer for the associated + # LinodeCluster's .Spec.Network.LoadBalancerType + # because we need a private IP to add the machine to the + # NodeBalancer interfaces: - purpose: public authorizedKeys: @@ -37,6 +43,12 @@ spec: # diskEncryption: disabled interfaces: - purpose: public + # uncomment if using new network interfaces and comment out interfaces above (requires beta opt-in) + # linodeInterfaces: + # - public: + # ipv4: + # addresses: + # - address: "auto" authorizedKeys: # uncomment to include your ssh key in linode provisioning # - ${LINODE_SSH_PUBKEY} From c4906aeda5898d5f4b041407d23260aa8eff27f4 Mon Sep 17 00:00:00 2001 From: Ashley Dumaine Date: Thu, 7 Aug 2025 11:52:18 -0400 Subject: [PATCH 03/10] We cannot configure network helper at all if using the new linode interfaces --- .../controller/linodemachine_controller.go | 31 ++++++--------- .../linodemachine_controller_helpers.go | 2 +- .../webhook/v1alpha2/linodemachine_webhook.go | 39 +++++++++++++------ 3 files changed, 40 insertions(+), 32 deletions(-) diff --git a/internal/controller/linodemachine_controller.go b/internal/controller/linodemachine_controller.go index 142728dcd..3c7b71e2c 100644 --- a/internal/controller/linodemachine_controller.go +++ b/internal/controller/linodemachine_controller.go @@ -619,20 +619,10 @@ func (r *LinodeMachineReconciler) reconcilePreflightConfigure(ctx context.Contex return ctrl.Result{RequeueAfter: reconciler.DefaultMachineControllerWaitForRunningDelay}, nil } - configData := linodego.InstanceConfigUpdateOptions{ - Helpers: &linodego.InstanceConfigHelpers{ - Network: true, - }, - } - // LinodeInterfaces does not support network helpers, so we disable it - if machineScope.LinodeMachine.Spec.LinodeInterfaces != nil { - configData.Helpers.Network = false - } - + configData := &linodego.InstanceConfigUpdateOptions{} if machineScope.LinodeMachine.Spec.Configuration != nil && machineScope.LinodeMachine.Spec.Configuration.Kernel != "" { configData.Kernel = machineScope.LinodeMachine.Spec.Configuration.Kernel } - // For cases where the network helper is not enabled on account level, we can enable it per instance level // Default is true, so we only need to update if it's explicitly set to false if machineScope.LinodeMachine.Spec.NetworkHelper != nil { @@ -641,14 +631,17 @@ func (r *LinodeMachineReconciler) reconcilePreflightConfigure(ctx context.Contex } } - instanceConfig, err := getDefaultInstanceConfig(ctx, machineScope, instanceID) - if err != nil { - logger.Error(err, "Failed to get default instance configuration") - return retryIfTransient(err, logger) - } - if _, err := machineScope.LinodeClient.UpdateInstanceConfig(ctx, instanceID, instanceConfig.ID, configData); err != nil { - logger.Error(err, "Failed to update default instance configuration") - return retryIfTransient(err, logger) + // only update the instance configuration if there are changes + if configData != nil { + instanceConfig, err := getDefaultInstanceConfig(ctx, machineScope, instanceID) + if err != nil { + logger.Error(err, "Failed to get default instance configuration") + return retryIfTransient(err, logger) + } + if _, err := machineScope.LinodeClient.UpdateInstanceConfig(ctx, instanceID, instanceConfig.ID, *configData); err != nil { + logger.Error(err, "Failed to update default instance configuration", "configuration", *configData) + return retryIfTransient(err, logger) + } } conditions.Set(machineScope.LinodeMachine, metav1.Condition{ diff --git a/internal/controller/linodemachine_controller_helpers.go b/internal/controller/linodemachine_controller_helpers.go index 73b5d85c4..8f54c75a6 100644 --- a/internal/controller/linodemachine_controller_helpers.go +++ b/internal/controller/linodemachine_controller_helpers.go @@ -89,7 +89,7 @@ func fillCreateConfig(createConfig *linodego.InstanceCreateOptions, machineScope createConfig.PrivateIP = true } else { // Network Helper is not supported for the new network interfaces. - createConfig.NetworkHelper = ptr.To(false) + createConfig.NetworkHelper = nil createConfig.InterfaceGeneration = linodego.GenerationLinode } } diff --git a/internal/webhook/v1alpha2/linodemachine_webhook.go b/internal/webhook/v1alpha2/linodemachine_webhook.go index 8ca82900f..cc8ad89b9 100644 --- a/internal/webhook/v1alpha2/linodemachine_webhook.go +++ b/internal/webhook/v1alpha2/linodemachine_webhook.go @@ -157,35 +157,50 @@ func (r *linodeMachineValidator) validateLinodeMachineSpec(ctx context.Context, }) } - if spec.LinodeInterfaces != nil && spec.Interfaces != nil { + if spec.LinodeInterfaces != nil { + if ifaceErrs := r.validateLinodeInterfaces(spec); ifaceErrs != nil { + errs = append(errs, ifaceErrs...) + } + } + + if spec.FirewallID != 0 && spec.FirewallRef != nil { errs = append(errs, &field.Error{ - Field: "spec.linodeInterfaces/spec.interfaces", + Field: "spec.firewallID/spec.firewallRef", Type: field.ErrorTypeInvalid, - Detail: "Cannot specify both LinodeInterfaces and Interfaces", + Detail: "Cannot specify both FirewallID and FirewallRef", }) } - if spec.LinodeInterfaces != nil && spec.PrivateIP != nil && *spec.PrivateIP { + if len(errs) == 0 { + return nil + } + return errs +} + +func (r *linodeMachineValidator) validateLinodeInterfaces(spec infrav1alpha2.LinodeMachineSpec) field.ErrorList { + var errs field.ErrorList + + if spec.Interfaces != nil { errs = append(errs, &field.Error{ - Field: "spec.linodeInterfaces/spec.privateIP", + Field: "spec.linodeInterfaces/spec.interfaces", Type: field.ErrorTypeInvalid, - Detail: "Linode Interfaces do not support private IPs", + Detail: "Cannot specify both LinodeInterfaces and Interfaces", }) } - if spec.LinodeInterfaces != nil && spec.NetworkHelper != nil && *spec.NetworkHelper { + if spec.PrivateIP != nil && *spec.PrivateIP { errs = append(errs, &field.Error{ - Field: "spec.linodeInterfaces/spec.networkHelper", + Field: "spec.linodeInterfaces/spec.privateIP", Type: field.ErrorTypeInvalid, - Detail: "Linode Interfaces do not support network helper (enabled by default), it must be explicitly set to false", + Detail: "Linode Interfaces do not support private IPs", }) } - if spec.FirewallID != 0 && spec.FirewallRef != nil { + if spec.NetworkHelper != nil { errs = append(errs, &field.Error{ - Field: "spec.firewallID/spec.firewallRef", + Field: "spec.linodeInterfaces/spec.networkHelper", Type: field.ErrorTypeInvalid, - Detail: "Cannot specify both FirewallID and FirewallRef", + Detail: "Linode Interfaces do not support configuring network helper", }) } From f12567806d9d980a1acdda6ba73c31bda9449e82 Mon Sep 17 00:00:00 2001 From: Ashley Dumaine Date: Thu, 7 Aug 2025 17:09:07 -0400 Subject: [PATCH 04/10] allow skipping of setting interfaces and linodeInterfaces via InterfaceGeneration on LinodeMachines, only update instanceConfig if configuring kernel or network helpers --- api/v1alpha2/linodemachine_types.go | 7 + ...cture.cluster.x-k8s.io_linodemachines.yaml | 10 ++ ...uster.x-k8s.io_linodemachinetemplates.yaml | 10 ++ docs/src/reference/out.md | 1 + .../controller/linodemachine_controller.go | 14 +- .../linodemachine_controller_helpers.go | 18 +-- .../linodemachine_controller_test.go | 144 ++---------------- .../webhook/v1alpha2/linodemachine_webhook.go | 1 - 8 files changed, 60 insertions(+), 145 deletions(-) diff --git a/api/v1alpha2/linodemachine_types.go b/api/v1alpha2/linodemachine_types.go index 66e32641a..8a2aa2f53 100644 --- a/api/v1alpha2/linodemachine_types.go +++ b/api/v1alpha2/linodemachine_types.go @@ -128,6 +128,13 @@ type LinodeMachineSpec struct { // For more information, see https://techdocs.akamai.com/cloud-computing/docs/automatically-configure-networking // Defaults to true. NetworkHelper *bool `json:"networkHelper,omitempty"` + + // InterfaceGeneration is the generation of the interface to use for the cluster's + // nodes in interface / linodeInterface are not specified for a LinodeMachine. + // If not set, defaults to "legacy_config". + // +kubebuilder:validation:Enum=legacy_config;linode + // +kubebuilder:default=legacy_config + InterfaceGeneration linodego.InterfaceGeneration `json:"interfaceGeneration,omitempty"` } // IPv6CreateOptions defines the IPv6 options for the instance. diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_linodemachines.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_linodemachines.yaml index 48436f3cd..e92291409 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_linodemachines.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_linodemachines.yaml @@ -227,6 +227,16 @@ spec: instanceID: description: InstanceID is the Linode instance ID for this machine. type: integer + interfaceGeneration: + default: legacy_config + description: |- + InterfaceGeneration is the generation of the interface to use for the cluster's + nodes in interface / linodeInterface are not specified for a LinodeMachine. + If not set, defaults to "legacy_config". + enum: + - legacy_config + - linode + type: string interfaces: items: description: InstanceConfigInterfaceCreateOptions defines network diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_linodemachinetemplates.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_linodemachinetemplates.yaml index 2dae501e2..3e65d65ec 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_linodemachinetemplates.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_linodemachinetemplates.yaml @@ -218,6 +218,16 @@ spec: description: InstanceID is the Linode instance ID for this machine. type: integer + interfaceGeneration: + default: legacy_config + description: |- + InterfaceGeneration is the generation of the interface to use for the cluster's + nodes in interface / linodeInterface are not specified for a LinodeMachine. + If not set, defaults to "legacy_config". + enum: + - legacy_config + - linode + type: string interfaces: items: description: InstanceConfigInterfaceCreateOptions defines diff --git a/docs/src/reference/out.md b/docs/src/reference/out.md index 730f9f849..625ccdd0c 100644 --- a/docs/src/reference/out.md +++ b/docs/src/reference/out.md @@ -679,6 +679,7 @@ _Appears in:_ | `vpcID` _integer_ | VPCID is the ID of an existing VPC in Linode. This allows using a VPC that is not managed by CAPL. | | | | `ipv6Options` _[IPv6CreateOptions](#ipv6createoptions)_ | IPv6Options defines the IPv6 options for the instance.
If not specified, IPv6 ranges won't be allocated to instance. | | | | `networkHelper` _boolean_ | NetworkHelper is an option usually enabled on account level. It helps configure networking automatically for instances.
You can use this to enable/disable the network helper for a specific instance.
For more information, see https://techdocs.akamai.com/cloud-computing/docs/automatically-configure-networking
Defaults to true. | | | +| `interfaceGeneration` _[InterfaceGeneration](#interfacegeneration)_ | InterfaceGeneration is the generation of the interface to use for the cluster's
nodes in interface / linodeInterface are not specified for a LinodeMachine.
If not set, defaults to "legacy_config". | legacy_config | Enum: [legacy_config linode]
| #### LinodeMachineStatus diff --git a/internal/controller/linodemachine_controller.go b/internal/controller/linodemachine_controller.go index 3c7b71e2c..3c076247e 100644 --- a/internal/controller/linodemachine_controller.go +++ b/internal/controller/linodemachine_controller.go @@ -274,7 +274,7 @@ func (r *LinodeMachineReconciler) reconcileCreate( } if machineScope.LinodeMachine.Spec.FirewallRef != nil { - if !reconciler.ConditionTrue(machineScope.LinodeMachine, string(ConditionPreflightLinodeFirewallReady)) && machineScope.LinodeMachine.Spec.ProviderID == nil { + if !reconciler.ConditionTrue(machineScope.LinodeMachine, ConditionPreflightLinodeFirewallReady) && machineScope.LinodeMachine.Spec.ProviderID == nil { res, err := r.reconcilePreflightLinodeFirewallCheck(ctx, logger, machineScope) if err != nil || !res.IsZero() { conditions.Set(machineScope.LinodeMachine, metav1.Condition{ @@ -553,6 +553,10 @@ func (r *LinodeMachineReconciler) reconcilePreflightMetadataSupportConfigure(ctx } func (r *LinodeMachineReconciler) reconcilePreflightCreate(ctx context.Context, logger logr.Logger, machineScope *scope.MachineScope) (ctrl.Result, error) { + // default to legacy interface generation if not set for now + if machineScope.LinodeMachine.Spec.InterfaceGeneration == "" { + machineScope.LinodeMachine.Spec.InterfaceGeneration = linodego.GenerationLegacyConfig + } // get the bootstrap data for the Linode instance and set it for create config createOpts, err := newCreateConfig(ctx, machineScope, r.GzipCompressionEnabled, logger) if err != nil { @@ -619,7 +623,7 @@ func (r *LinodeMachineReconciler) reconcilePreflightConfigure(ctx context.Contex return ctrl.Result{RequeueAfter: reconciler.DefaultMachineControllerWaitForRunningDelay}, nil } - configData := &linodego.InstanceConfigUpdateOptions{} + configData := linodego.InstanceConfigUpdateOptions{} if machineScope.LinodeMachine.Spec.Configuration != nil && machineScope.LinodeMachine.Spec.Configuration.Kernel != "" { configData.Kernel = machineScope.LinodeMachine.Spec.Configuration.Kernel } @@ -632,14 +636,14 @@ func (r *LinodeMachineReconciler) reconcilePreflightConfigure(ctx context.Contex } // only update the instance configuration if there are changes - if configData != nil { + if configData.Kernel != "" || configData.Helpers != nil { instanceConfig, err := getDefaultInstanceConfig(ctx, machineScope, instanceID) if err != nil { logger.Error(err, "Failed to get default instance configuration") return retryIfTransient(err, logger) } - if _, err := machineScope.LinodeClient.UpdateInstanceConfig(ctx, instanceID, instanceConfig.ID, *configData); err != nil { - logger.Error(err, "Failed to update default instance configuration", "configuration", *configData) + if _, err := machineScope.LinodeClient.UpdateInstanceConfig(ctx, instanceID, instanceConfig.ID, configData); err != nil { + logger.Error(err, "Failed to update default instance configuration", "configuration", configData) return retryIfTransient(err, logger) } } diff --git a/internal/controller/linodemachine_controller_helpers.go b/internal/controller/linodemachine_controller_helpers.go index 8f54c75a6..9f85e6ca6 100644 --- a/internal/controller/linodemachine_controller_helpers.go +++ b/internal/controller/linodemachine_controller_helpers.go @@ -84,7 +84,7 @@ func fillCreateConfig(createConfig *linodego.InstanceCreateOptions, machineScope if machineScope.LinodeMachine.Spec.PrivateIP != nil { createConfig.PrivateIP = *machineScope.LinodeMachine.Spec.PrivateIP } else { - if machineScope.LinodeMachine.Spec.LinodeInterfaces == nil { + if machineScope.LinodeMachine.Spec.LinodeInterfaces == nil && machineScope.LinodeMachine.Spec.InterfaceGeneration == linodego.GenerationLegacyConfig { // Supported only for legacy network interfaces. createConfig.PrivateIP = true } else { @@ -177,7 +177,7 @@ func configureVPCInterface(ctx context.Context, machineScope *scope.MachineScope // addVPCInterfaceFromDirectID handles adding a VPC interface from a direct ID func addVPCInterfaceFromDirectID(ctx context.Context, machineScope *scope.MachineScope, createConfig *linodego.InstanceCreateOptions, logger logr.Logger, vpcID int) error { switch { - case createConfig.LinodeInterfaces != nil: + case createConfig.LinodeInterfaces != nil || (createConfig.LinodeInterfaces == nil && machineScope.LinodeMachine.Spec.InterfaceGeneration == linodego.GenerationLinode): iface, err := getVPCLinodeInterfaceConfigFromDirectID(ctx, machineScope, createConfig.LinodeInterfaces, logger, vpcID) if err != nil { logger.Error(err, "Failed to get VPC linode interface config from direct ID") @@ -207,7 +207,7 @@ func addVPCInterfaceFromDirectID(ctx context.Context, machineScope *scope.Machin // addVPCInterfaceFromReference handles adding a VPC interface from a reference func addVPCInterfaceFromReference(ctx context.Context, machineScope *scope.MachineScope, createConfig *linodego.InstanceCreateOptions, logger logr.Logger, vpcRef *corev1.ObjectReference) error { switch { - case createConfig.LinodeInterfaces != nil: + case createConfig.LinodeInterfaces != nil || (createConfig.LinodeInterfaces == nil && machineScope.LinodeMachine.Spec.InterfaceGeneration == linodego.GenerationLinode): iface, err := getVPCLinodeInterfaceConfig(ctx, machineScope, createConfig.LinodeInterfaces, logger, vpcRef) if err != nil { logger.Error(err, "Failed to get VPC interface config") @@ -296,7 +296,7 @@ func buildInstanceAddrs(ctx context.Context, machineScope *scope.MachineScope, i func handleVlanIps(ctx context.Context, machineScope *scope.MachineScope, instanceID int) ([]clusterv1.MachineAddress, error) { ips := []clusterv1.MachineAddress{} switch { - case machineScope.LinodeMachine.Spec.LinodeInterfaces != nil: + case machineScope.LinodeMachine.Spec.LinodeInterfaces != nil || (machineScope.LinodeMachine.Spec.LinodeInterfaces == nil && machineScope.LinodeMachine.Spec.InterfaceGeneration == linodego.GenerationLinode): ifaces, err := machineScope.LinodeClient.ListInterfaces(ctx, instanceID, &linodego.ListOptions{}) if err != nil || len(ifaces) == 0 { return ips, fmt.Errorf("list interfaces: %w", err) @@ -1060,7 +1060,7 @@ func linodeMachineSpecToInstanceCreateConfig(machineSpec infrav1alpha2.LinodeMac if len(machineSpec.LinodeInterfaces) > 0 { instCreateOpts.LinodeInterfaces = constructLinodeInterfaceCreateOpts(machineSpec.LinodeInterfaces) - } else { + } else if len(machineSpec.Interfaces) > 0 { interfaces := make([]linodego.InstanceConfigInterfaceCreateOptions, len(machineSpec.Interfaces)) for idx, iface := range machineSpec.Interfaces { interfaces[idx] = linodego.InstanceConfigInterfaceCreateOptions{ @@ -1439,7 +1439,7 @@ func getVPCRefFromScope(machineScope *scope.MachineScope) *corev1.ObjectReferenc // configureVlanInterface adds a VLAN interface to the configuration func configureVlanInterface(ctx context.Context, machineScope *scope.MachineScope, createConfig *linodego.InstanceCreateOptions, logger logr.Logger) error { switch { - case createConfig.LinodeInterfaces != nil: + case createConfig.LinodeInterfaces != nil || (createConfig.LinodeInterfaces == nil && machineScope.LinodeMachine.Spec.InterfaceGeneration == linodego.GenerationLinode): iface, err := getVlanLinodeInterfaceConfig(ctx, machineScope, createConfig.LinodeInterfaces, logger) if err != nil { logger.Error(err, "Failed to get VLAN interface config") @@ -1501,10 +1501,8 @@ func configureFirewall(ctx context.Context, machineScope *scope.MachineScope, cr createConfig.FirewallID = fwID // If using LinodeInterfaces that needs to know about the firewall ID - if machineScope.LinodeMachine.Spec.LinodeInterfaces != nil { - for i := range createConfig.LinodeInterfaces { - createConfig.LinodeInterfaces[i].FirewallID = ptr.To(fwID) - } + for i := range createConfig.LinodeInterfaces { + createConfig.LinodeInterfaces[i].FirewallID = ptr.To(fwID) } return nil diff --git a/internal/controller/linodemachine_controller_test.go b/internal/controller/linodemachine_controller_test.go index 3d536784c..a742b84fd 100644 --- a/internal/controller/linodemachine_controller_test.go +++ b/internal/controller/linodemachine_controller_test.go @@ -229,17 +229,6 @@ var _ = Describe("create", Label("machine", "create"), func() { createInst := mockLinodeClient.EXPECT(). OnAfterResponse(gomock.Any()). Return() - listInstConfs := mockLinodeClient.EXPECT(). - ListInstanceConfigs(ctx, 123, gomock.Any()). - After(createInst). - Return([]linodego.InstanceConfig{{ - ID: 1, - }}, nil) - mockLinodeClient.EXPECT().UpdateInstanceConfig(ctx, 123, 1, linodego.InstanceConfigUpdateOptions{ - Helpers: &linodego.InstanceConfigHelpers{Network: true}, - }). - After(listInstConfs). - Return(nil, nil) bootInst := mockLinodeClient.EXPECT(). BootInstance(ctx, 123, 0). After(createInst). @@ -385,17 +374,6 @@ var _ = Describe("create", Label("machine", "create"), func() { createInst := mockLinodeClient.EXPECT(). OnAfterResponse(gomock.Any()). Return() - listInstConfs := mockLinodeClient.EXPECT(). - ListInstanceConfigs(ctx, 123, gomock.Any()). - After(createInst). - Return([]linodego.InstanceConfig{{ - ID: 1, - }}, nil) - mockLinodeClient.EXPECT().UpdateInstanceConfig(ctx, 123, 1, linodego.InstanceConfigUpdateOptions{ - Helpers: &linodego.InstanceConfigHelpers{Network: true}, - }). - After(listInstConfs). - Return(nil, nil) bootInst := mockLinodeClient.EXPECT(). BootInstance(ctx, 123, 0). After(createInst). @@ -463,17 +441,6 @@ var _ = Describe("create", Label("machine", "create"), func() { mockLinodeClient.EXPECT(). OnAfterResponse(gomock.Any()). Return() - listInstConfs := mockLinodeClient.EXPECT(). - ListInstanceConfigs(ctx, 123, gomock.Any()). - After(createInst). - Return([]linodego.InstanceConfig{{ - ID: 1, - }}, nil) - mockLinodeClient.EXPECT().UpdateInstanceConfig(ctx, 123, 1, linodego.InstanceConfigUpdateOptions{ - Helpers: &linodego.InstanceConfigHelpers{Network: true}, - }). - After(listInstConfs). - Return(nil, nil) bootInst := mockLinodeClient.EXPECT(). BootInstance(ctx, 123, 0). After(createInst). @@ -560,17 +527,6 @@ var _ = Describe("create", Label("machine", "create"), func() { mockLinodeClient.EXPECT(). OnAfterResponse(gomock.Any()). Return() - listInstConfs := mockLinodeClient.EXPECT(). - ListInstanceConfigs(ctx, 123, gomock.Any()). - After(createInst). - Return([]linodego.InstanceConfig{{ - ID: 1, - }}, nil) - mockLinodeClient.EXPECT().UpdateInstanceConfig(ctx, 123, 1, linodego.InstanceConfigUpdateOptions{ - Helpers: &linodego.InstanceConfigHelpers{Network: true}, - }). - After(listInstConfs). - Return(nil, nil) bootInst := mockLinodeClient.EXPECT(). BootInstance(ctx, 123, 0). After(listInst). @@ -791,9 +747,6 @@ var _ = Describe("create", Label("machine", "create"), func() { SDA: &linodego.InstanceConfigDevice{DiskID: 100}, }, }}, nil).MaxTimes(3) - mockLinodeClient.EXPECT().UpdateInstanceConfig(ctx, 123, 1, linodego.InstanceConfigUpdateOptions{ - Helpers: &linodego.InstanceConfigHelpers{Network: true}, - }).Return(nil, nil) getInstDisk := mockLinodeClient.EXPECT(). GetInstanceDisk(ctx, 123, 100). Return(&linodego.InstanceDisk{ID: 100, Size: 15000}, nil) @@ -942,11 +895,6 @@ var _ = Describe("create", Label("machine", "create"), func() { SDA: &linodego.InstanceConfigDevice{DiskID: 100}, }, }}, nil) - mockLinodeClient.EXPECT().UpdateInstanceConfig(ctx, 123, 1, linodego.InstanceConfigUpdateOptions{ - Helpers: &linodego.InstanceConfigHelpers{Network: true}, - }). - After(listInstConfs). - Return(nil, nil) getInstDisk := mockLinodeClient.EXPECT(). GetInstanceDisk(ctx, 123, 100). After(listInstConfs). @@ -994,9 +942,6 @@ var _ = Describe("create", Label("machine", "create"), func() { SDA: &linodego.InstanceConfigDevice{DiskID: 100}, }, }}, nil).AnyTimes() - mockLinodeClient.EXPECT().UpdateInstanceConfig(ctx, 123, 1, linodego.InstanceConfigUpdateOptions{ - Helpers: &linodego.InstanceConfigHelpers{Network: true}, - }).Return(nil, nil).AnyTimes() getInst := mockLinodeClient.EXPECT(). GetInstance(ctx, 123). After(createFailedEtcdDisk). @@ -1200,17 +1145,6 @@ var _ = Describe("createDNS", Label("machine", "createDNS"), func() { mockLinodeClient.EXPECT(). OnAfterResponse(gomock.Any()). Return() - listInstConfs := mockLinodeClient.EXPECT(). - ListInstanceConfigs(ctx, 123, gomock.Any()). - After(createInst). - Return([]linodego.InstanceConfig{{ - ID: 1, - }}, nil) - mockLinodeClient.EXPECT().UpdateInstanceConfig(ctx, 123, 1, linodego.InstanceConfigUpdateOptions{ - Helpers: &linodego.InstanceConfigHelpers{Network: true}, - }). - After(listInstConfs). - Return(nil, nil) bootInst := mockLinodeClient.EXPECT(). BootInstance(ctx, 123, 0). After(createInst). @@ -2370,6 +2304,7 @@ var _ = Describe("machine in VPC", Label("machine", "VPC"), Ordered, func() { Primary: true, }, }, + InterfaceGeneration: linodego.GenerationLegacyConfig, }, } mockLinodeClient := mock.NewMockLinodeClient(mockCtrl) @@ -2693,9 +2628,9 @@ var _ = Describe("machine in VPC with new network interfaces", Label("machine", UID: "12345", }, Spec: infrav1alpha2.LinodeMachineSpec{ - ProviderID: ptr.To("linode://0"), - Type: "g6-nanode-1", - LinodeInterfaces: []infrav1alpha2.LinodeInterfaceCreateOptions{{}}, + ProviderID: ptr.To("linode://0"), + Type: "g6-nanode-1", + InterfaceGeneration: linodego.GenerationLinode, }, } mockLinodeClient := mock.NewMockLinodeClient(mockCtrl) @@ -2748,10 +2683,8 @@ var _ = Describe("machine in VPC with new network interfaces", Label("machine", Address: "auto", }}, }, - IPv6: nil, }, }, - {FirewallID: nil, DefaultRoute: nil, Public: nil, VPC: nil, VLAN: nil}, })) }) It("creates a instance with pre defined vpc interface", func(ctx SpecContext) { @@ -2762,14 +2695,9 @@ var _ = Describe("machine in VPC with new network interfaces", Label("machine", UID: "12345", }, Spec: infrav1alpha2.LinodeMachineSpec{ - ProviderID: ptr.To("linode://0"), - Type: "g6-nanode-1", - LinodeInterfaces: []infrav1alpha2.LinodeInterfaceCreateOptions{ - { - VPC: &infrav1alpha2.VPCInterfaceCreateOptions{}, - Public: &infrav1alpha2.PublicInterfaceCreateOptions{}, - }, - }, + ProviderID: ptr.To("linode://0"), + Type: "g6-nanode-1", + InterfaceGeneration: linodego.GenerationLinode, }, } mockLinodeClient := mock.NewMockLinodeClient(mockCtrl) @@ -2822,19 +2750,6 @@ var _ = Describe("machine in VPC with new network interfaces", Label("machine", Address: "auto", }}, }, - IPv6: &linodego.VPCInterfaceIPv6CreateOptions{ - Ranges: nil, - SLAAC: nil, - IsPublic: false, - }, - }, - Public: &linodego.PublicInterfaceCreateOptions{ - IPv4: &linodego.PublicInterfaceIPv4CreateOptions{ - Addresses: nil, - }, - IPv6: &linodego.PublicInterfaceIPv6CreateOptions{ - Ranges: nil, - }, }, }, })) @@ -2847,11 +2762,9 @@ var _ = Describe("machine in VPC with new network interfaces", Label("machine", UID: "12345", }, Spec: infrav1alpha2.LinodeMachineSpec{ - ProviderID: ptr.To("linode://0"), - Type: "g6-nanode-1", - LinodeInterfaces: []infrav1alpha2.LinodeInterfaceCreateOptions{{ - VPC: &infrav1alpha2.VPCInterfaceCreateOptions{}, - }}, + ProviderID: ptr.To("linode://0"), + Type: "g6-nanode-1", + InterfaceGeneration: linodego.GenerationLinode, }, } mockLinodeClient := mock.NewMockLinodeClient(mockCtrl) @@ -2910,7 +2823,6 @@ var _ = Describe("machine in VPC with new network interfaces", Label("machine", Address: "auto", }}, }, - IPv6: &linodego.VPCInterfaceIPv6CreateOptions{SLAAC: nil, Ranges: nil, IsPublic: false}, }, }, })) @@ -3033,17 +2945,6 @@ var _ = Describe("machine in vlan", Label("machine", "vlan"), Ordered, func() { mockLinodeClient.EXPECT(). OnAfterResponse(gomock.Any()). Return() - listInstConfs := mockLinodeClient.EXPECT(). - ListInstanceConfigs(ctx, 123, gomock.Any()). - After(createInst). - Return([]linodego.InstanceConfig{{ - ID: 1, - }}, nil) - mockLinodeClient.EXPECT().UpdateInstanceConfig(ctx, 123, 1, linodego.InstanceConfigUpdateOptions{ - Helpers: &linodego.InstanceConfigHelpers{Network: true}, - }). - After(listInstConfs). - Return(nil, nil) bootInst := mockLinodeClient.EXPECT(). BootInstance(ctx, 123, 0). After(createInst). @@ -3215,17 +3116,6 @@ var _ = Describe("machine in vlan for new network interfaces", Label("machine", mockLinodeClient.EXPECT(). OnAfterResponse(gomock.Any()). Return() - listInstConfs := mockLinodeClient.EXPECT(). - ListInstanceConfigs(ctx, 123, gomock.Any()). - After(createInst). - Return([]linodego.InstanceConfig{{ - ID: 1, - }}, nil) - mockLinodeClient.EXPECT().UpdateInstanceConfig(ctx, 123, 1, linodego.InstanceConfigUpdateOptions{ - Helpers: &linodego.InstanceConfigHelpers{Network: false}, - }). - After(listInstConfs). - Return(nil, nil) bootInst := mockLinodeClient.EXPECT(). BootInstance(ctx, 123, 0). After(createInst). @@ -3492,15 +3382,11 @@ var _ = Describe("create machine with direct VPCID with new network interfaces", Namespace: defaultNamespace, }, Spec: infrav1alpha2.LinodeMachineSpec{ - Type: "g6-nanode-1", - Image: "linode/ubuntu22.04", - Region: "us-east", - VPCID: ptr.To(12345), - LinodeInterfaces: []infrav1alpha2.LinodeInterfaceCreateOptions{ - { - VPC: &infrav1alpha2.VPCInterfaceCreateOptions{}, - }, - }, + Type: "g6-nanode-1", + Image: "linode/ubuntu22.04", + Region: "us-east", + VPCID: ptr.To(12345), + InterfaceGeneration: linodego.GenerationLinode, }, } machineKey = client.ObjectKeyFromObject(&linodeMachine) diff --git a/internal/webhook/v1alpha2/linodemachine_webhook.go b/internal/webhook/v1alpha2/linodemachine_webhook.go index cc8ad89b9..5f71cf758 100644 --- a/internal/webhook/v1alpha2/linodemachine_webhook.go +++ b/internal/webhook/v1alpha2/linodemachine_webhook.go @@ -125,7 +125,6 @@ func (r *linodeMachineValidator) ValidateDelete(ctx context.Context, obj runtime return nil, nil } -//nolint:cyclop // as simple as it gets func (r *linodeMachineValidator) validateLinodeMachineSpec(ctx context.Context, linodeclient clients.LinodeClient, spec infrav1alpha2.LinodeMachineSpec, skipAPIValidation bool) field.ErrorList { var errs field.ErrorList From 07e673b97910b429b148c25000faf5a38c640b94 Mon Sep 17 00:00:00 2001 From: Ashley Dumaine Date: Thu, 7 Aug 2025 17:29:30 -0400 Subject: [PATCH 05/10] add webhook test --- .../v1alpha2/linodemachine_webhook_test.go | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/internal/webhook/v1alpha2/linodemachine_webhook_test.go b/internal/webhook/v1alpha2/linodemachine_webhook_test.go index 8303dc253..0d6fe6336 100644 --- a/internal/webhook/v1alpha2/linodemachine_webhook_test.go +++ b/internal/webhook/v1alpha2/linodemachine_webhook_test.go @@ -20,6 +20,7 @@ import ( "context" "errors" "math" + "slices" "strconv" "testing" @@ -54,6 +55,8 @@ func TestValidateLinodeMachine(t *testing.T) { Type: "example", }, } + region = linodego.Region{ID: "test"} + capabilities = []string{linodego.CapabilityLinodeInterfaces} disk = infrav1alpha2.InstanceDisk{Size: resource.MustParse("1G")} disk_zero = infrav1alpha2.InstanceDisk{Size: *resource.NewQuantity(0, resource.BinarySI)} plan = linodego.LinodeType{Disk: 2 * int(disk.Size.ScaledValue(resource.Mega))} @@ -163,6 +166,72 @@ func TestValidateLinodeMachine(t *testing.T) { } }), ), + Path( + Call("invalid linode interfaces with private IP", func(ctx context.Context, mck Mock) { + region := region + region.Capabilities = slices.Clone(capabilities) + mck.LinodeClient.EXPECT().GetRegion(gomock.Any(), gomock.Any()).Return(®ion, nil).AnyTimes() + mck.LinodeClient.EXPECT().GetType(gomock.Any(), gomock.Any()).Return(&plan_max, nil).AnyTimes() + }), + Result("error", func(ctx context.Context, mck Mock) { + machine := machine + machine.Spec.LinodeInterfaces = []infrav1alpha2.LinodeInterfaceCreateOptions{{}} + machine.Spec.PrivateIP = ptr.To(true) + errs := validator.validateLinodeMachineSpec(ctx, mck.LinodeClient, machine.Spec, SkipAPIValidation) + for _, err := range errs { + assert.ErrorContains(t, err, "Linode Interfaces do not support private IPs") + } + }), + ), + Path( + Call("invalid linode interfaces with network helpers", func(ctx context.Context, mck Mock) { + region := region + region.Capabilities = slices.Clone(capabilities) + mck.LinodeClient.EXPECT().GetRegion(gomock.Any(), gomock.Any()).Return(®ion, nil).AnyTimes() + mck.LinodeClient.EXPECT().GetType(gomock.Any(), gomock.Any()).Return(&plan_max, nil).AnyTimes() + }), + Result("error", func(ctx context.Context, mck Mock) { + machine := machine + machine.Spec.LinodeInterfaces = []infrav1alpha2.LinodeInterfaceCreateOptions{{}} + machine.Spec.NetworkHelper = ptr.To(true) + errs := validator.validateLinodeMachineSpec(ctx, mck.LinodeClient, machine.Spec, SkipAPIValidation) + for _, err := range errs { + assert.ErrorContains(t, err, "Linode Interfaces do not support configuring network helper") + } + }), + ), + Path( + Call("invalid linode interfaces with legacy interfaces", func(ctx context.Context, mck Mock) { + region := region + region.Capabilities = slices.Clone(capabilities) + mck.LinodeClient.EXPECT().GetRegion(gomock.Any(), gomock.Any()).Return(®ion, nil).AnyTimes() + mck.LinodeClient.EXPECT().GetType(gomock.Any(), gomock.Any()).Return(&plan_max, nil).AnyTimes() + }), + Result("error", func(ctx context.Context, mck Mock) { + machine := machine + machine.Spec.LinodeInterfaces = []infrav1alpha2.LinodeInterfaceCreateOptions{{}} + machine.Spec.Interfaces = []infrav1alpha2.InstanceConfigInterfaceCreateOptions{{}} + errs := validator.validateLinodeMachineSpec(ctx, mck.LinodeClient, machine.Spec, SkipAPIValidation) + for _, err := range errs { + assert.ErrorContains(t, err, "Cannot specify both LinodeInterfaces and Interfaces") + } + }), + ), + Path( + Call("linode interfaces with invalid region", func(ctx context.Context, mck Mock) { + region := region + mck.LinodeClient.EXPECT().GetRegion(gomock.Any(), gomock.Any()).Return(®ion, nil).AnyTimes() + mck.LinodeClient.EXPECT().GetType(gomock.Any(), gomock.Any()).Return(&plan_max, nil).AnyTimes() + }), + Result("error", func(ctx context.Context, mck Mock) { + machine := machine + machine.Spec.LinodeInterfaces = []infrav1alpha2.LinodeInterfaceCreateOptions{{}} + errs := validator.validateLinodeMachineSpec(ctx, mck.LinodeClient, machine.Spec, SkipAPIValidation) + for _, err := range errs { + assert.ErrorContains(t, err, "no capability: Linode Interfaces") + } + }), + ), ), ) } From 6ed8cd57fb697ef7c7f83d612977ce4274287b53 Mon Sep 17 00:00:00 2001 From: Ashley Dumaine Date: Fri, 8 Aug 2025 15:19:26 -0400 Subject: [PATCH 06/10] fix setting of interfaceGeneration --- .../controller/linodemachine_controller.go | 4 --- .../linodemachine_controller_helpers.go | 33 ++++++++++++------- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/internal/controller/linodemachine_controller.go b/internal/controller/linodemachine_controller.go index 3c076247e..cea7d1cff 100644 --- a/internal/controller/linodemachine_controller.go +++ b/internal/controller/linodemachine_controller.go @@ -553,10 +553,6 @@ func (r *LinodeMachineReconciler) reconcilePreflightMetadataSupportConfigure(ctx } func (r *LinodeMachineReconciler) reconcilePreflightCreate(ctx context.Context, logger logr.Logger, machineScope *scope.MachineScope) (ctrl.Result, error) { - // default to legacy interface generation if not set for now - if machineScope.LinodeMachine.Spec.InterfaceGeneration == "" { - machineScope.LinodeMachine.Spec.InterfaceGeneration = linodego.GenerationLegacyConfig - } // get the bootstrap data for the Linode instance and set it for create config createOpts, err := newCreateConfig(ctx, machineScope, r.GzipCompressionEnabled, logger) if err != nil { diff --git a/internal/controller/linodemachine_controller_helpers.go b/internal/controller/linodemachine_controller_helpers.go index 9f85e6ca6..979bd709f 100644 --- a/internal/controller/linodemachine_controller_helpers.go +++ b/internal/controller/linodemachine_controller_helpers.go @@ -81,16 +81,20 @@ func retryIfTransient(err error, logger logr.Logger) (ctrl.Result, error) { } func fillCreateConfig(createConfig *linodego.InstanceCreateOptions, machineScope *scope.MachineScope) { + // This will only be empty if no interfaces or linodeInterfaces were specified in the LinodeMachine spec. + // In that case we default to legacy interfaces. + if createConfig.InterfaceGeneration == "" { + createConfig.InterfaceGeneration = linodego.GenerationLegacyConfig + } if machineScope.LinodeMachine.Spec.PrivateIP != nil { createConfig.PrivateIP = *machineScope.LinodeMachine.Spec.PrivateIP } else { - if machineScope.LinodeMachine.Spec.LinodeInterfaces == nil && machineScope.LinodeMachine.Spec.InterfaceGeneration == linodego.GenerationLegacyConfig { + if createConfig.InterfaceGeneration == linodego.GenerationLegacyConfig { // Supported only for legacy network interfaces. createConfig.PrivateIP = true } else { // Network Helper is not supported for the new network interfaces. createConfig.NetworkHelper = nil - createConfig.InterfaceGeneration = linodego.GenerationLinode } } @@ -1040,18 +1044,19 @@ func constructLinodeInterfaceCreateOpts(createOpts []infrav1alpha2.LinodeInterfa return linodeInterfaces } -// for converting LinodeMachineSpec to linodego.InstanceCreateOptions. Any defaulting should be done in fillCreateConfig instead +// For converting LinodeMachineSpec to linodego.InstanceCreateOptions. Any defaulting should be done in fillCreateConfig instead func linodeMachineSpecToInstanceCreateConfig(machineSpec infrav1alpha2.LinodeMachineSpec, machineTags []string) *linodego.InstanceCreateOptions { instCreateOpts := &linodego.InstanceCreateOptions{ - Region: machineSpec.Region, - Type: machineSpec.Type, - AuthorizedKeys: machineSpec.AuthorizedKeys, - AuthorizedUsers: machineSpec.AuthorizedUsers, - RootPass: machineSpec.RootPass, - Image: machineSpec.Image, - Tags: machineTags, - FirewallID: machineSpec.FirewallID, - DiskEncryption: linodego.InstanceDiskEncryption(machineSpec.DiskEncryption), + Region: machineSpec.Region, + Type: machineSpec.Type, + AuthorizedKeys: machineSpec.AuthorizedKeys, + AuthorizedUsers: machineSpec.AuthorizedUsers, + RootPass: machineSpec.RootPass, + Image: machineSpec.Image, + Tags: machineTags, + FirewallID: machineSpec.FirewallID, + InterfaceGeneration: machineSpec.InterfaceGeneration, + DiskEncryption: linodego.InstanceDiskEncryption(machineSpec.DiskEncryption), } if machineSpec.PrivateIP != nil { @@ -1060,6 +1065,8 @@ func linodeMachineSpecToInstanceCreateConfig(machineSpec infrav1alpha2.LinodeMac if len(machineSpec.LinodeInterfaces) > 0 { instCreateOpts.LinodeInterfaces = constructLinodeInterfaceCreateOpts(machineSpec.LinodeInterfaces) + // If LinodeInterfaces are specified, the InterfaceGeneration must be GenerationLinode + instCreateOpts.InterfaceGeneration = linodego.GenerationLinode } else if len(machineSpec.Interfaces) > 0 { interfaces := make([]linodego.InstanceConfigInterfaceCreateOptions, len(machineSpec.Interfaces)) for idx, iface := range machineSpec.Interfaces { @@ -1073,6 +1080,8 @@ func linodeMachineSpecToInstanceCreateConfig(machineSpec infrav1alpha2.LinodeMac } } instCreateOpts.Interfaces = interfaces + // If Interfaces are specified, the InterfaceGeneration must be GenerationLegacyConfig + instCreateOpts.InterfaceGeneration = linodego.GenerationLegacyConfig } return instCreateOpts From 3f9319bb48007bcec97c076f75d181f5062068ba Mon Sep 17 00:00:00 2001 From: Ashley Dumaine Date: Tue, 12 Aug 2025 13:20:22 -0400 Subject: [PATCH 07/10] update with latest commit on proj/vpc-dual-stack linodego branch --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index efa9be5cf..6a84bbe9e 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( github.com/go-logr/logr v1.4.3 github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 - github.com/linode/linodego v1.54.1-0.20250728194520-172cba1c457a + github.com/linode/linodego v1.54.1-0.20250812173013-ca1c9b03408c github.com/onsi/ginkgo/v2 v2.23.4 github.com/onsi/gomega v1.38.0 github.com/stretchr/testify v1.10.0 diff --git a/go.sum b/go.sum index 64485e6ce..bc0dfcc38 100644 --- a/go.sum +++ b/go.sum @@ -177,8 +177,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/linode/linodego v1.54.1-0.20250728194520-172cba1c457a h1:WtHqziHGlueosyUPxXIswDO5tQewj2fgeekD4GsdHoo= -github.com/linode/linodego v1.54.1-0.20250728194520-172cba1c457a/go.mod h1:VHlFAbhj18634Cd7B7L5D723kFKFQMOxzIutSMcWsB4= +github.com/linode/linodego v1.54.1-0.20250812173013-ca1c9b03408c h1:EnDv76oCGlC3YGbmtdLJD67GGCkbKT5rQf+bJSQ5X9A= +github.com/linode/linodego v1.54.1-0.20250812173013-ca1c9b03408c/go.mod h1:VHlFAbhj18634Cd7B7L5D723kFKFQMOxzIutSMcWsB4= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= From f6ed3eee656fad8af3bfdad9a02e5262bdf00297 Mon Sep 17 00:00:00 2001 From: Ashley Dumaine Date: Tue, 12 Aug 2025 15:00:45 -0400 Subject: [PATCH 08/10] update firewall listing for new linode interfaces --- clients/clients.go | 1 + .../controller/linodemachine_controller.go | 31 ++++++++++++++++--- mock/client.go | 30 ++++++++++++++++++ .../wrappers/linodeclient/linodeclient.gen.go | 27 ++++++++++++++++ 4 files changed, 84 insertions(+), 5 deletions(-) diff --git a/clients/clients.go b/clients/clients.go index cafb0c9c6..01e1817cd 100644 --- a/clients/clients.go +++ b/clients/clients.go @@ -129,6 +129,7 @@ type LinodeFirewallClient interface { // LinodeInterfacesClient defines the methods that interact with Linode's Interfaces service. type LinodeInterfacesClient interface { ListInterfaces(ctx context.Context, linodeID int, opts *linodego.ListOptions) ([]linodego.LinodeInterface, error) + ListInterfaceFirewalls(ctx context.Context, linodeID int, interfaceID int, opts *linodego.ListOptions) ([]linodego.Firewall, error) } type K8sClient interface { diff --git a/internal/controller/linodemachine_controller.go b/internal/controller/linodemachine_controller.go index cea7d1cff..5f2a3e4d7 100644 --- a/internal/controller/linodemachine_controller.go +++ b/internal/controller/linodemachine_controller.go @@ -785,11 +785,32 @@ func (r *LinodeMachineReconciler) reconcileUpdate(ctx context.Context, logger lo } func (r *LinodeMachineReconciler) reconcileFirewallID(ctx context.Context, logger logr.Logger, machineScope *scope.MachineScope, instanceID int) (ctrl.Result, error) { - // get the instance's firewalls - firewalls, err := machineScope.LinodeClient.ListInstanceFirewalls(ctx, instanceID, nil) - if err != nil { - logger.Error(err, "Failed to list firewalls for Linode instance") - return ctrl.Result{RequeueAfter: reconciler.DefaultMachineControllerWaitForRunningDelay}, nil + var ( + firewalls []linodego.Firewall + err error + ) + // Get the instance's firewalls normally if this is not using the new linode interfaces, + // otherwise we have to get firewalls per linode interface + if machineScope.LinodeMachine.Spec.InterfaceGeneration == linodego.GenerationLegacyConfig { + firewalls, err = machineScope.LinodeClient.ListInstanceFirewalls(ctx, instanceID, nil) + if err != nil { + logger.Error(err, "Failed to list firewalls for Linode instance") + return ctrl.Result{RequeueAfter: reconciler.DefaultMachineControllerWaitForRunningDelay}, nil + } + } else { + interfaces, err := machineScope.LinodeClient.ListInterfaces(ctx, instanceID, nil) + if err != nil { + logger.Error(err, "Failed to list interfaces for Linode instance") + return ctrl.Result{RequeueAfter: reconciler.DefaultMachineControllerWaitForRunningDelay}, nil + } + for _, iface := range interfaces { + ifaceFWs, err := machineScope.LinodeClient.ListInterfaceFirewalls(ctx, instanceID, iface.ID, nil) + if err != nil { + logger.Error(err, "Failed to list firewalls for Linode instance interface", "interfaceID", iface.ID) + return ctrl.Result{RequeueAfter: reconciler.DefaultMachineControllerWaitForRunningDelay}, nil + } + firewalls = append(firewalls, ifaceFWs...) + } } attachedFWIDs := make([]int, 0, len(firewalls)) diff --git a/mock/client.go b/mock/client.go index 0bf9ee4f6..17c764974 100644 --- a/mock/client.go +++ b/mock/client.go @@ -726,6 +726,21 @@ func (mr *MockLinodeClientMockRecorder) ListInstances(ctx, opts any) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListInstances", reflect.TypeOf((*MockLinodeClient)(nil).ListInstances), ctx, opts) } +// ListInterfaceFirewalls mocks base method. +func (m *MockLinodeClient) ListInterfaceFirewalls(ctx context.Context, linodeID, interfaceID int, opts *linodego.ListOptions) ([]linodego.Firewall, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListInterfaceFirewalls", ctx, linodeID, interfaceID, opts) + ret0, _ := ret[0].([]linodego.Firewall) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListInterfaceFirewalls indicates an expected call of ListInterfaceFirewalls. +func (mr *MockLinodeClientMockRecorder) ListInterfaceFirewalls(ctx, linodeID, interfaceID, opts any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListInterfaceFirewalls", reflect.TypeOf((*MockLinodeClient)(nil).ListInterfaceFirewalls), ctx, linodeID, interfaceID, opts) +} + // ListInterfaces mocks base method. func (m *MockLinodeClient) ListInterfaces(ctx context.Context, linodeID int, opts *linodego.ListOptions) ([]linodego.LinodeInterface, error) { m.ctrl.T.Helper() @@ -2205,6 +2220,21 @@ func (m *MockLinodeInterfacesClient) EXPECT() *MockLinodeInterfacesClientMockRec return m.recorder } +// ListInterfaceFirewalls mocks base method. +func (m *MockLinodeInterfacesClient) ListInterfaceFirewalls(ctx context.Context, linodeID, interfaceID int, opts *linodego.ListOptions) ([]linodego.Firewall, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListInterfaceFirewalls", ctx, linodeID, interfaceID, opts) + ret0, _ := ret[0].([]linodego.Firewall) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListInterfaceFirewalls indicates an expected call of ListInterfaceFirewalls. +func (mr *MockLinodeInterfacesClientMockRecorder) ListInterfaceFirewalls(ctx, linodeID, interfaceID, opts any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListInterfaceFirewalls", reflect.TypeOf((*MockLinodeInterfacesClient)(nil).ListInterfaceFirewalls), ctx, linodeID, interfaceID, opts) +} + // ListInterfaces mocks base method. func (m *MockLinodeInterfacesClient) ListInterfaces(ctx context.Context, linodeID int, opts *linodego.ListOptions) ([]linodego.LinodeInterface, error) { m.ctrl.T.Helper() diff --git a/observability/wrappers/linodeclient/linodeclient.gen.go b/observability/wrappers/linodeclient/linodeclient.gen.go index 407e07503..6b7a227aa 100644 --- a/observability/wrappers/linodeclient/linodeclient.gen.go +++ b/observability/wrappers/linodeclient/linodeclient.gen.go @@ -1195,6 +1195,33 @@ func (_d LinodeClientWithTracing) ListInstances(ctx context.Context, opts *linod return _d.LinodeClient.ListInstances(ctx, opts) } +// ListInterfaceFirewalls implements _sourceClients.LinodeClient +func (_d LinodeClientWithTracing) ListInterfaceFirewalls(ctx context.Context, linodeID int, interfaceID int, opts *linodego.ListOptions) (fa1 []linodego.Firewall, err error) { + ctx, _span := tracing.Start(ctx, "_sourceClients.LinodeClient.ListInterfaceFirewalls") + defer func() { + if _d._spanDecorator != nil { + _d._spanDecorator(_span, map[string]interface{}{ + "ctx": ctx, + "linodeID": linodeID, + "interfaceID": interfaceID, + "opts": opts}, map[string]interface{}{ + "fa1": fa1, + "err": err}) + } + + if err != nil { + _span.RecordError(err) + _span.SetAttributes( + attribute.String("event", "error"), + attribute.String("message", err.Error()), + ) + } + + _span.End() + }() + return _d.LinodeClient.ListInterfaceFirewalls(ctx, linodeID, interfaceID, opts) +} + // ListInterfaces implements _sourceClients.LinodeClient func (_d LinodeClientWithTracing) ListInterfaces(ctx context.Context, linodeID int, opts *linodego.ListOptions) (la1 []linodego.LinodeInterface, err error) { ctx, _span := tracing.Start(ctx, "_sourceClients.LinodeClient.ListInterfaces") From 22ba316eb11b107cb7c630050e3c6c01a5535b38 Mon Sep 17 00:00:00 2001 From: Ashley Dumaine Date: Tue, 12 Aug 2025 15:10:25 -0400 Subject: [PATCH 09/10] flip the conditional check just in case InstanceGeneration doesn't get set for existing LinodeMachines --- internal/controller/linodemachine_controller.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/controller/linodemachine_controller.go b/internal/controller/linodemachine_controller.go index 5f2a3e4d7..394dde3b2 100644 --- a/internal/controller/linodemachine_controller.go +++ b/internal/controller/linodemachine_controller.go @@ -791,13 +791,7 @@ func (r *LinodeMachineReconciler) reconcileFirewallID(ctx context.Context, logge ) // Get the instance's firewalls normally if this is not using the new linode interfaces, // otherwise we have to get firewalls per linode interface - if machineScope.LinodeMachine.Spec.InterfaceGeneration == linodego.GenerationLegacyConfig { - firewalls, err = machineScope.LinodeClient.ListInstanceFirewalls(ctx, instanceID, nil) - if err != nil { - logger.Error(err, "Failed to list firewalls for Linode instance") - return ctrl.Result{RequeueAfter: reconciler.DefaultMachineControllerWaitForRunningDelay}, nil - } - } else { + if machineScope.LinodeMachine.Spec.InterfaceGeneration == linodego.GenerationLinode { interfaces, err := machineScope.LinodeClient.ListInterfaces(ctx, instanceID, nil) if err != nil { logger.Error(err, "Failed to list interfaces for Linode instance") @@ -811,6 +805,12 @@ func (r *LinodeMachineReconciler) reconcileFirewallID(ctx context.Context, logge } firewalls = append(firewalls, ifaceFWs...) } + } else { + firewalls, err = machineScope.LinodeClient.ListInstanceFirewalls(ctx, instanceID, nil) + if err != nil { + logger.Error(err, "Failed to list firewalls for Linode instance") + return ctrl.Result{RequeueAfter: reconciler.DefaultMachineControllerWaitForRunningDelay}, nil + } } attachedFWIDs := make([]int, 0, len(firewalls)) From 265d4f005feb73b226aec9cc012ca1a0e9a89d75 Mon Sep 17 00:00:00 2001 From: Ashley Dumaine Date: Wed, 13 Aug 2025 11:46:39 -0400 Subject: [PATCH 10/10] add some comments and handle network helper --- api/v1alpha2/linodemachine_types.go | 2 + ...cture.cluster.x-k8s.io_linodemachines.yaml | 5 + ...uster.x-k8s.io_linodemachinetemplates.yaml | 5 + docs/src/reference/out.md | 4 +- .../controller/linodemachine_controller.go | 13 +- .../linodemachine_controller_helpers.go | 223 +++++++++--------- .../linodemachine_controller_test.go | 88 +++++++ .../webhook/v1alpha2/linodemachine_webhook.go | 8 - .../v1alpha2/linodemachine_webhook_test.go | 17 -- 9 files changed, 227 insertions(+), 138 deletions(-) diff --git a/api/v1alpha2/linodemachine_types.go b/api/v1alpha2/linodemachine_types.go index 8a2aa2f53..13fd76f05 100644 --- a/api/v1alpha2/linodemachine_types.go +++ b/api/v1alpha2/linodemachine_types.go @@ -59,8 +59,10 @@ type LinodeMachineSpec struct { BackupID int `json:"backupID,omitempty"` // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" Image string `json:"image,omitempty"` + // Interfaces is a list of legacy network interfaces to use for the instance. // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" Interfaces []InstanceConfigInterfaceCreateOptions `json:"interfaces,omitempty"` + // LinodeInterfaces is a list of Linode network interfaces to use for the instance. Requires Linode Interfaces beta opt-in to use. // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" // +kubebuilder:object:generate=true LinodeInterfaces []LinodeInterfaceCreateOptions `json:"linodeInterfaces,omitempty"` diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_linodemachines.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_linodemachines.yaml index e92291409..f952afac3 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_linodemachines.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_linodemachines.yaml @@ -238,6 +238,8 @@ spec: - linode type: string interfaces: + description: Interfaces is a list of legacy network interfaces to + use for the instance. items: description: InstanceConfigInterfaceCreateOptions defines network interface config @@ -311,6 +313,9 @@ spec: - message: Value is immutable rule: self == oldSelf linodeInterfaces: + description: LinodeInterfaces is a list of Linode network interfaces + to use for the instance. Requires Linode Interfaces beta opt-in + to use. items: description: LinodeInterfaceCreateOptions defines the linode network interface config diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_linodemachinetemplates.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_linodemachinetemplates.yaml index 3e65d65ec..9cb0d4b12 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_linodemachinetemplates.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_linodemachinetemplates.yaml @@ -229,6 +229,8 @@ spec: - linode type: string interfaces: + description: Interfaces is a list of legacy network interfaces + to use for the instance. items: description: InstanceConfigInterfaceCreateOptions defines network interface config @@ -303,6 +305,9 @@ spec: - message: Value is immutable rule: self == oldSelf linodeInterfaces: + description: LinodeInterfaces is a list of Linode network + interfaces to use for the instance. Requires Linode Interfaces + beta opt-in to use. items: description: LinodeInterfaceCreateOptions defines the linode network interface config diff --git a/docs/src/reference/out.md b/docs/src/reference/out.md index 625ccdd0c..f23055674 100644 --- a/docs/src/reference/out.md +++ b/docs/src/reference/out.md @@ -662,8 +662,8 @@ _Appears in:_ | `authorizedUsers` _string array_ | | | | | `backupID` _integer_ | | | | | `image` _string_ | | | | -| `interfaces` _[InstanceConfigInterfaceCreateOptions](#instanceconfiginterfacecreateoptions) array_ | | | | -| `linodeInterfaces` _[LinodeInterfaceCreateOptions](#linodeinterfacecreateoptions) array_ | | | | +| `interfaces` _[InstanceConfigInterfaceCreateOptions](#instanceconfiginterfacecreateoptions) array_ | Interfaces is a list of legacy network interfaces to use for the instance. | | | +| `linodeInterfaces` _[LinodeInterfaceCreateOptions](#linodeinterfacecreateoptions) array_ | LinodeInterfaces is a list of Linode network interfaces to use for the instance. Requires Linode Interfaces beta opt-in to use. | | | | `backupsEnabled` _boolean_ | | | | | `privateIP` _boolean_ | | | | | `tags` _string array_ | Tags is a list of tags to apply to the Linode instance. | | | diff --git a/internal/controller/linodemachine_controller.go b/internal/controller/linodemachine_controller.go index 394dde3b2..84b1e02fe 100644 --- a/internal/controller/linodemachine_controller.go +++ b/internal/controller/linodemachine_controller.go @@ -623,11 +623,18 @@ func (r *LinodeMachineReconciler) reconcilePreflightConfigure(ctx context.Contex if machineScope.LinodeMachine.Spec.Configuration != nil && machineScope.LinodeMachine.Spec.Configuration.Kernel != "" { configData.Kernel = machineScope.LinodeMachine.Spec.Configuration.Kernel } + // helpers.network does not work on instances using the new linode interfaces. // For cases where the network helper is not enabled on account level, we can enable it per instance level // Default is true, so we only need to update if it's explicitly set to false - if machineScope.LinodeMachine.Spec.NetworkHelper != nil { - configData.Helpers = &linodego.InstanceConfigHelpers{ - Network: *machineScope.LinodeMachine.Spec.NetworkHelper, + if machineScope.LinodeMachine.Spec.InterfaceGeneration != linodego.GenerationLinode { + if machineScope.LinodeMachine.Spec.NetworkHelper != nil { + configData.Helpers = &linodego.InstanceConfigHelpers{ + Network: *machineScope.LinodeMachine.Spec.NetworkHelper, + } + } else { + configData.Helpers = &linodego.InstanceConfigHelpers{ + Network: true, + } } } diff --git a/internal/controller/linodemachine_controller_helpers.go b/internal/controller/linodemachine_controller_helpers.go index 979bd709f..805851b9b 100644 --- a/internal/controller/linodemachine_controller_helpers.go +++ b/internal/controller/linodemachine_controller_helpers.go @@ -83,19 +83,26 @@ func retryIfTransient(err error, logger logr.Logger) (ctrl.Result, error) { func fillCreateConfig(createConfig *linodego.InstanceCreateOptions, machineScope *scope.MachineScope) { // This will only be empty if no interfaces or linodeInterfaces were specified in the LinodeMachine spec. // In that case we default to legacy interfaces. - if createConfig.InterfaceGeneration == "" { + switch createConfig.InterfaceGeneration { + case linodego.GenerationLinode: + // networkHelper is only applicable for Linode interfaces. + // legacy interfaces have nework helper configured in reconcilePreflightConfigure at the instance level. + if machineScope.LinodeMachine.Spec.NetworkHelper != nil { + createConfig.NetworkHelper = machineScope.LinodeMachine.Spec.NetworkHelper + } else { + createConfig.NetworkHelper = ptr.To(true) + } + case linodego.GenerationLegacyConfig: + createConfig.InterfaceGeneration = linodego.GenerationLegacyConfig + default: createConfig.InterfaceGeneration = linodego.GenerationLegacyConfig } + if machineScope.LinodeMachine.Spec.PrivateIP != nil { createConfig.PrivateIP = *machineScope.LinodeMachine.Spec.PrivateIP - } else { - if createConfig.InterfaceGeneration == linodego.GenerationLegacyConfig { - // Supported only for legacy network interfaces. - createConfig.PrivateIP = true - } else { - // Network Helper is not supported for the new network interfaces. - createConfig.NetworkHelper = nil - } + } else if createConfig.InterfaceGeneration != linodego.GenerationLinode { + // Supported only for legacy network interfaces. + createConfig.PrivateIP = true } if createConfig.Tags == nil { @@ -631,7 +638,7 @@ func getVPCLinodeInterfaceConfig(ctx context.Context, machineScope *scope.Machin for _, subnet := range linodeVPC.Spec.Subnets { if subnet.Label == subnetName { subnetID = subnet.SubnetID - ipv6Config = getVPCInterfaceIPv6Config(machineScope, len(subnet.IPv6)) + ipv6Config = getVPCLinodeInterfaceIPv6Config(machineScope, len(subnet.IPv6)) break } } @@ -641,7 +648,7 @@ func getVPCLinodeInterfaceConfig(ctx context.Context, machineScope *scope.Machin } } else { subnetID = linodeVPC.Spec.Subnets[0].SubnetID // get first subnet if nothing specified - ipv6Config = getVPCInterfaceIPv6Config(machineScope, len(linodeVPC.Spec.Subnets[0].IPv6)) + ipv6Config = getVPCLinodeInterfaceIPv6Config(machineScope, len(linodeVPC.Spec.Subnets[0].IPv6)) } if subnetID == 0 { @@ -656,15 +663,6 @@ func getVPCLinodeInterfaceConfig(ctx context.Context, machineScope *scope.Machin if !isVPCInterfaceIPv6ConfigEmpty(ipv6Config) { linodeInterfaces[iface].VPC.IPv6 = ipv6Config } - if netInterface.VPC.IPv4 == nil { - linodeInterfaces[iface].VPC.IPv4 = &linodego.VPCInterfaceIPv4CreateOptions{ - Addresses: []linodego.VPCInterfaceIPv4AddressCreateOptions{{ - Primary: ptr.To(true), - NAT1To1Address: ptr.To("auto"), - Address: "auto", - }}, - } - } return nil, nil //nolint:nilnil // it is important we don't return an interface if a VPC interface already exists } } @@ -731,7 +729,7 @@ func getVPCLinodeInterfaceConfigFromDirectID(ctx context.Context, machineScope * for _, subnet := range vpc.Subnets { if subnet.Label == subnetName { subnetID = subnet.ID - ipv6Config = getVPCInterfaceIPv6Config(machineScope, len(subnet.IPv6)) + ipv6Config = getVPCLinodeInterfaceIPv6Config(machineScope, len(subnet.IPv6)) break } } @@ -740,7 +738,7 @@ func getVPCLinodeInterfaceConfigFromDirectID(ctx context.Context, machineScope * } } else { subnetID = vpc.Subnets[0].ID - ipv6Config = getVPCInterfaceIPv6Config(machineScope, len(vpc.Subnets[0].IPv6)) + ipv6Config = getVPCLinodeInterfaceIPv6Config(machineScope, len(vpc.Subnets[0].IPv6)) } // Check if a VPC interface already exists @@ -762,6 +760,7 @@ func getVPCLinodeInterfaceConfigFromDirectID(ctx context.Context, machineScope * Addresses: []linodego.VPCInterfaceIPv4AddressCreateOptions{{ Primary: ptr.To(true), NAT1To1Address: ptr.To("auto"), + Address: "auto", }}, }, }, @@ -890,12 +889,12 @@ func getMachineIPv6Config(machineScope *scope.MachineScope, numIPv6RangesInSubne return intfOpts } -// getVPCInterfaceIPv6Config returns the IPv6 configuration for a LinodeMachine. +// getVPCLinodeInterfaceIPv6Config returns the IPv6 configuration for a LinodeMachine. // It checks the LinodeMachine's IPv6Options for SLAAC and Ranges settings. // If `EnableSLAAC` is set, it will enable SLAAC with the default IPv6 CIDR range. // If `EnableRanges` is set, it will enable IPv6 ranges with the default IPv6 CIDR range. // If `IsPublicIPv6` is set, it will be used to determine if the IPv6 range should be publicly routable or not. -func getVPCInterfaceIPv6Config(machineScope *scope.MachineScope, numIPv6RangesInSubnet int) *linodego.VPCInterfaceIPv6CreateOptions { +func getVPCLinodeInterfaceIPv6Config(machineScope *scope.MachineScope, numIPv6RangesInSubnet int) *linodego.VPCInterfaceIPv6CreateOptions { intfOpts := &linodego.VPCInterfaceIPv6CreateOptions{} // If there are no IPv6 ranges in the subnet or if IPv6 options are not specified, return nil. @@ -928,8 +927,6 @@ func getVPCInterfaceIPv6Config(machineScope *scope.MachineScope, numIPv6RangesIn // Unfortunately, this is necessary since DeepCopy can't be generated for linodego.LinodeInterfaceCreateOptions // so here we manually create the options for Linode interfaces. -// -//nolint:gocognit,cyclop,gocritic,nestif,nolintlint // Also, unfortunately, this cannot be made any reasonably simpler with how complicated the linodego struct is func constructLinodeInterfaceCreateOpts(createOpts []infrav1alpha2.LinodeInterfaceCreateOptions) []linodego.LinodeInterfaceCreateOptions { linodeInterfaces := make([]linodego.LinodeInterfaceCreateOptions, len(createOpts)) for idx, iface := range createOpts { @@ -943,91 +940,11 @@ func constructLinodeInterfaceCreateOpts(createOpts []infrav1alpha2.LinodeInterfa } // Handle VPC if iface.VPC != nil { - var ( - ipv4Addrs []linodego.VPCInterfaceIPv4AddressCreateOptions - ipv4Ranges []linodego.VPCInterfaceIPv4RangeCreateOptions - ipv6Ranges []linodego.VPCInterfaceIPv6RangeCreateOptions - ipv6SLAAC []linodego.VPCInterfaceIPv6SLAACCreateOptions - ipv6IsPublic bool - ) - if iface.VPC.IPv4 != nil { - for _, addr := range iface.VPC.IPv4.Addresses { - ipv4Addrs = append(ipv4Addrs, linodego.VPCInterfaceIPv4AddressCreateOptions{ - Address: addr.Address, - Primary: addr.Primary, - NAT1To1Address: addr.NAT1To1Address, - }) - } - for _, rng := range iface.VPC.IPv4.Ranges { - ipv4Ranges = append(ipv4Ranges, linodego.VPCInterfaceIPv4RangeCreateOptions{ - Range: rng.Range, - }) - } - } else { - // If no IPv4 addresses are specified, we set a default NAT1To1 address to "any" - ipv4Addrs = []linodego.VPCInterfaceIPv4AddressCreateOptions{ - { - Primary: ptr.To(true), - NAT1To1Address: ptr.To("auto"), - Address: "auto", // Default to auto-assigned address - }, - } - } - if iface.VPC.IPv6 != nil { - for _, slaac := range iface.VPC.IPv6.SLAAC { - ipv6SLAAC = append(ipv6SLAAC, linodego.VPCInterfaceIPv6SLAACCreateOptions{ - Range: slaac.Range, - }) - } - for _, rng := range iface.VPC.IPv6.Ranges { - ipv6Ranges = append(ipv6Ranges, linodego.VPCInterfaceIPv6RangeCreateOptions{ - Range: rng.Range, - }) - } - ipv6IsPublic = iface.VPC.IPv6.IsPublic - } - ifaceCreateOpts.VPC = &linodego.VPCInterfaceCreateOptions{ - SubnetID: iface.VPC.SubnetID, - IPv4: &linodego.VPCInterfaceIPv4CreateOptions{ - Addresses: ipv4Addrs, - Ranges: ipv4Ranges, - }, - IPv6: &linodego.VPCInterfaceIPv6CreateOptions{ - SLAAC: ipv6SLAAC, - Ranges: ipv6Ranges, - IsPublic: ipv6IsPublic, - }, - } + ifaceCreateOpts.VPC = constructLinodeInterfaceVPC(iface) } // Handle Public Interface if iface.Public != nil { - var ( - ipv4Addrs []linodego.PublicInterfaceIPv4AddressCreateOptions - ipv6Ranges []linodego.PublicInterfaceIPv6RangeCreateOptions - ) - if iface.Public.IPv4 != nil { - for _, addr := range iface.Public.IPv4.Addresses { - ipv4Addrs = append(ipv4Addrs, linodego.PublicInterfaceIPv4AddressCreateOptions{ - Address: addr.Address, - Primary: addr.Primary, - }) - } - } - if iface.Public.IPv6 != nil { - for _, rng := range iface.Public.IPv6.Ranges { - ipv6Ranges = append(ipv6Ranges, linodego.PublicInterfaceIPv6RangeCreateOptions{ - Range: rng.Range, - }) - } - } - ifaceCreateOpts.Public = &linodego.PublicInterfaceCreateOptions{ - IPv4: &linodego.PublicInterfaceIPv4CreateOptions{ - Addresses: ipv4Addrs, - }, - IPv6: &linodego.PublicInterfaceIPv6CreateOptions{ - Ranges: ipv6Ranges, - }, - } + ifaceCreateOpts.Public = constructLinodeInterfacePublic(iface) } // Handle Default Route if iface.DefaultRoute != nil { @@ -1044,6 +961,96 @@ func constructLinodeInterfaceCreateOpts(createOpts []infrav1alpha2.LinodeInterfa return linodeInterfaces } +// constructLinodeInterfaceVPC constructs a Linode VPC interface configuration from the provided LinodeInterfaceCreateOptions. +func constructLinodeInterfaceVPC(iface infrav1alpha2.LinodeInterfaceCreateOptions) *linodego.VPCInterfaceCreateOptions { + var ( + ipv4Addrs []linodego.VPCInterfaceIPv4AddressCreateOptions + ipv4Ranges []linodego.VPCInterfaceIPv4RangeCreateOptions + ipv6Ranges []linodego.VPCInterfaceIPv6RangeCreateOptions + ipv6SLAAC []linodego.VPCInterfaceIPv6SLAACCreateOptions + ipv6IsPublic bool + ) + if iface.VPC.IPv4 != nil { + for _, addr := range iface.VPC.IPv4.Addresses { + ipv4Addrs = append(ipv4Addrs, linodego.VPCInterfaceIPv4AddressCreateOptions{ + Address: addr.Address, + Primary: addr.Primary, + NAT1To1Address: addr.NAT1To1Address, + }) + } + for _, rng := range iface.VPC.IPv4.Ranges { + ipv4Ranges = append(ipv4Ranges, linodego.VPCInterfaceIPv4RangeCreateOptions{ + Range: rng.Range, + }) + } + } else { + // If no IPv4 addresses are specified, we set a default NAT1To1 address to "any" + ipv4Addrs = []linodego.VPCInterfaceIPv4AddressCreateOptions{ + { + Primary: ptr.To(true), + NAT1To1Address: ptr.To("auto"), + Address: "auto", // Default to auto-assigned address + }, + } + } + if iface.VPC.IPv6 != nil { + for _, slaac := range iface.VPC.IPv6.SLAAC { + ipv6SLAAC = append(ipv6SLAAC, linodego.VPCInterfaceIPv6SLAACCreateOptions{ + Range: slaac.Range, + }) + } + for _, rng := range iface.VPC.IPv6.Ranges { + ipv6Ranges = append(ipv6Ranges, linodego.VPCInterfaceIPv6RangeCreateOptions{ + Range: rng.Range, + }) + } + ipv6IsPublic = iface.VPC.IPv6.IsPublic + } + return &linodego.VPCInterfaceCreateOptions{ + SubnetID: iface.VPC.SubnetID, + IPv4: &linodego.VPCInterfaceIPv4CreateOptions{ + Addresses: ipv4Addrs, + Ranges: ipv4Ranges, + }, + IPv6: &linodego.VPCInterfaceIPv6CreateOptions{ + SLAAC: ipv6SLAAC, + Ranges: ipv6Ranges, + IsPublic: ipv6IsPublic, + }, + } +} + +// constructLinodeInterfacePublic constructs a Linode Public interface configuration from the provided LinodeInterfaceCreateOptions. +func constructLinodeInterfacePublic(iface infrav1alpha2.LinodeInterfaceCreateOptions) *linodego.PublicInterfaceCreateOptions { + var ( + ipv4Addrs []linodego.PublicInterfaceIPv4AddressCreateOptions + ipv6Ranges []linodego.PublicInterfaceIPv6RangeCreateOptions + ) + if iface.Public.IPv4 != nil { + for _, addr := range iface.Public.IPv4.Addresses { + ipv4Addrs = append(ipv4Addrs, linodego.PublicInterfaceIPv4AddressCreateOptions{ + Address: addr.Address, + Primary: addr.Primary, + }) + } + } + if iface.Public.IPv6 != nil { + for _, rng := range iface.Public.IPv6.Ranges { + ipv6Ranges = append(ipv6Ranges, linodego.PublicInterfaceIPv6RangeCreateOptions{ + Range: rng.Range, + }) + } + } + return &linodego.PublicInterfaceCreateOptions{ + IPv4: &linodego.PublicInterfaceIPv4CreateOptions{ + Addresses: ipv4Addrs, + }, + IPv6: &linodego.PublicInterfaceIPv6CreateOptions{ + Ranges: ipv6Ranges, + }, + } +} + // For converting LinodeMachineSpec to linodego.InstanceCreateOptions. Any defaulting should be done in fillCreateConfig instead func linodeMachineSpecToInstanceCreateConfig(machineSpec infrav1alpha2.LinodeMachineSpec, machineTags []string) *linodego.InstanceCreateOptions { instCreateOpts := &linodego.InstanceCreateOptions{ diff --git a/internal/controller/linodemachine_controller_test.go b/internal/controller/linodemachine_controller_test.go index a742b84fd..c59bd7a2e 100644 --- a/internal/controller/linodemachine_controller_test.go +++ b/internal/controller/linodemachine_controller_test.go @@ -229,6 +229,17 @@ var _ = Describe("create", Label("machine", "create"), func() { createInst := mockLinodeClient.EXPECT(). OnAfterResponse(gomock.Any()). Return() + listInstConfs := mockLinodeClient.EXPECT(). + ListInstanceConfigs(ctx, 123, gomock.Any()). + After(createInst). + Return([]linodego.InstanceConfig{{ + ID: 1, + }}, nil) + mockLinodeClient.EXPECT().UpdateInstanceConfig(ctx, 123, 1, linodego.InstanceConfigUpdateOptions{ + Helpers: &linodego.InstanceConfigHelpers{Network: true}, + }). + After(listInstConfs). + Return(nil, nil) bootInst := mockLinodeClient.EXPECT(). BootInstance(ctx, 123, 0). After(createInst). @@ -374,6 +385,17 @@ var _ = Describe("create", Label("machine", "create"), func() { createInst := mockLinodeClient.EXPECT(). OnAfterResponse(gomock.Any()). Return() + listInstConfs := mockLinodeClient.EXPECT(). + ListInstanceConfigs(ctx, 123, gomock.Any()). + After(createInst). + Return([]linodego.InstanceConfig{{ + ID: 1, + }}, nil) + mockLinodeClient.EXPECT().UpdateInstanceConfig(ctx, 123, 1, linodego.InstanceConfigUpdateOptions{ + Helpers: &linodego.InstanceConfigHelpers{Network: true}, + }). + After(listInstConfs). + Return(nil, nil) bootInst := mockLinodeClient.EXPECT(). BootInstance(ctx, 123, 0). After(createInst). @@ -441,6 +463,17 @@ var _ = Describe("create", Label("machine", "create"), func() { mockLinodeClient.EXPECT(). OnAfterResponse(gomock.Any()). Return() + listInstConfs := mockLinodeClient.EXPECT(). + ListInstanceConfigs(ctx, 123, gomock.Any()). + After(createInst). + Return([]linodego.InstanceConfig{{ + ID: 1, + }}, nil) + mockLinodeClient.EXPECT().UpdateInstanceConfig(ctx, 123, 1, linodego.InstanceConfigUpdateOptions{ + Helpers: &linodego.InstanceConfigHelpers{Network: true}, + }). + After(listInstConfs). + Return(nil, nil) bootInst := mockLinodeClient.EXPECT(). BootInstance(ctx, 123, 0). After(createInst). @@ -527,6 +560,17 @@ var _ = Describe("create", Label("machine", "create"), func() { mockLinodeClient.EXPECT(). OnAfterResponse(gomock.Any()). Return() + listInstConfs := mockLinodeClient.EXPECT(). + ListInstanceConfigs(ctx, 123, gomock.Any()). + After(createInst). + Return([]linodego.InstanceConfig{{ + ID: 1, + }}, nil) + mockLinodeClient.EXPECT().UpdateInstanceConfig(ctx, 123, 1, linodego.InstanceConfigUpdateOptions{ + Helpers: &linodego.InstanceConfigHelpers{Network: true}, + }). + After(listInstConfs). + Return(nil, nil) bootInst := mockLinodeClient.EXPECT(). BootInstance(ctx, 123, 0). After(listInst). @@ -747,6 +791,9 @@ var _ = Describe("create", Label("machine", "create"), func() { SDA: &linodego.InstanceConfigDevice{DiskID: 100}, }, }}, nil).MaxTimes(3) + mockLinodeClient.EXPECT().UpdateInstanceConfig(ctx, 123, 1, linodego.InstanceConfigUpdateOptions{ + Helpers: &linodego.InstanceConfigHelpers{Network: true}, + }).Return(nil, nil) getInstDisk := mockLinodeClient.EXPECT(). GetInstanceDisk(ctx, 123, 100). Return(&linodego.InstanceDisk{ID: 100, Size: 15000}, nil) @@ -895,6 +942,11 @@ var _ = Describe("create", Label("machine", "create"), func() { SDA: &linodego.InstanceConfigDevice{DiskID: 100}, }, }}, nil) + mockLinodeClient.EXPECT().UpdateInstanceConfig(ctx, 123, 1, linodego.InstanceConfigUpdateOptions{ + Helpers: &linodego.InstanceConfigHelpers{Network: true}, + }). + After(listInstConfs). + Return(nil, nil) getInstDisk := mockLinodeClient.EXPECT(). GetInstanceDisk(ctx, 123, 100). After(listInstConfs). @@ -942,6 +994,9 @@ var _ = Describe("create", Label("machine", "create"), func() { SDA: &linodego.InstanceConfigDevice{DiskID: 100}, }, }}, nil).AnyTimes() + mockLinodeClient.EXPECT().UpdateInstanceConfig(ctx, 123, 1, linodego.InstanceConfigUpdateOptions{ + Helpers: &linodego.InstanceConfigHelpers{Network: true}, + }).Return(nil, nil).AnyTimes() getInst := mockLinodeClient.EXPECT(). GetInstance(ctx, 123). After(createFailedEtcdDisk). @@ -1145,6 +1200,17 @@ var _ = Describe("createDNS", Label("machine", "createDNS"), func() { mockLinodeClient.EXPECT(). OnAfterResponse(gomock.Any()). Return() + listInstConfs := mockLinodeClient.EXPECT(). + ListInstanceConfigs(ctx, 123, gomock.Any()). + After(createInst). + Return([]linodego.InstanceConfig{{ + ID: 1, + }}, nil) + mockLinodeClient.EXPECT().UpdateInstanceConfig(ctx, 123, 1, linodego.InstanceConfigUpdateOptions{ + Helpers: &linodego.InstanceConfigHelpers{Network: true}, + }). + After(listInstConfs). + Return(nil, nil) bootInst := mockLinodeClient.EXPECT(). BootInstance(ctx, 123, 0). After(createInst). @@ -2945,6 +3011,17 @@ var _ = Describe("machine in vlan", Label("machine", "vlan"), Ordered, func() { mockLinodeClient.EXPECT(). OnAfterResponse(gomock.Any()). Return() + listInstConfs := mockLinodeClient.EXPECT(). + ListInstanceConfigs(ctx, 123, gomock.Any()). + After(createInst). + Return([]linodego.InstanceConfig{{ + ID: 1, + }}, nil) + mockLinodeClient.EXPECT().UpdateInstanceConfig(ctx, 123, 1, linodego.InstanceConfigUpdateOptions{ + Helpers: &linodego.InstanceConfigHelpers{Network: true}, + }). + After(listInstConfs). + Return(nil, nil) bootInst := mockLinodeClient.EXPECT(). BootInstance(ctx, 123, 0). After(createInst). @@ -3116,6 +3193,17 @@ var _ = Describe("machine in vlan for new network interfaces", Label("machine", mockLinodeClient.EXPECT(). OnAfterResponse(gomock.Any()). Return() + listInstConfs := mockLinodeClient.EXPECT(). + ListInstanceConfigs(ctx, 123, gomock.Any()). + After(createInst). + Return([]linodego.InstanceConfig{{ + ID: 1, + }}, nil) + mockLinodeClient.EXPECT().UpdateInstanceConfig(ctx, 123, 1, linodego.InstanceConfigUpdateOptions{ + Helpers: &linodego.InstanceConfigHelpers{Network: true}, + }). + After(listInstConfs). + Return(nil, nil) bootInst := mockLinodeClient.EXPECT(). BootInstance(ctx, 123, 0). After(createInst). diff --git a/internal/webhook/v1alpha2/linodemachine_webhook.go b/internal/webhook/v1alpha2/linodemachine_webhook.go index 5f71cf758..6e01d7ab0 100644 --- a/internal/webhook/v1alpha2/linodemachine_webhook.go +++ b/internal/webhook/v1alpha2/linodemachine_webhook.go @@ -195,14 +195,6 @@ func (r *linodeMachineValidator) validateLinodeInterfaces(spec infrav1alpha2.Lin }) } - if spec.NetworkHelper != nil { - errs = append(errs, &field.Error{ - Field: "spec.linodeInterfaces/spec.networkHelper", - Type: field.ErrorTypeInvalid, - Detail: "Linode Interfaces do not support configuring network helper", - }) - } - if len(errs) == 0 { return nil } diff --git a/internal/webhook/v1alpha2/linodemachine_webhook_test.go b/internal/webhook/v1alpha2/linodemachine_webhook_test.go index 0d6fe6336..68c7ecfa2 100644 --- a/internal/webhook/v1alpha2/linodemachine_webhook_test.go +++ b/internal/webhook/v1alpha2/linodemachine_webhook_test.go @@ -183,23 +183,6 @@ func TestValidateLinodeMachine(t *testing.T) { } }), ), - Path( - Call("invalid linode interfaces with network helpers", func(ctx context.Context, mck Mock) { - region := region - region.Capabilities = slices.Clone(capabilities) - mck.LinodeClient.EXPECT().GetRegion(gomock.Any(), gomock.Any()).Return(®ion, nil).AnyTimes() - mck.LinodeClient.EXPECT().GetType(gomock.Any(), gomock.Any()).Return(&plan_max, nil).AnyTimes() - }), - Result("error", func(ctx context.Context, mck Mock) { - machine := machine - machine.Spec.LinodeInterfaces = []infrav1alpha2.LinodeInterfaceCreateOptions{{}} - machine.Spec.NetworkHelper = ptr.To(true) - errs := validator.validateLinodeMachineSpec(ctx, mck.LinodeClient, machine.Spec, SkipAPIValidation) - for _, err := range errs { - assert.ErrorContains(t, err, "Linode Interfaces do not support configuring network helper") - } - }), - ), Path( Call("invalid linode interfaces with legacy interfaces", func(ctx context.Context, mck Mock) { region := region