diff --git a/api/v1alpha2/linodevpc_types.go b/api/v1alpha2/linodevpc_types.go index c2555242b..018b36dc2 100644 --- a/api/v1alpha2/linodevpc_types.go +++ b/api/v1alpha2/linodevpc_types.go @@ -17,6 +17,7 @@ limitations under the License. package v1alpha2 import ( + "github.com/linode/linodego" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -36,6 +37,16 @@ type LinodeVPCSpec struct { // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" Region string `json:"region"` // +optional + // IPv6 is a list of IPv6 ranges allocated to the VPC. + // Once ranges are allocated based on the IPv6Range field, they will be + // added to this field. + IPv6 []linodego.VPCIPv6Range `json:"ipv6,omitempty"` + // +optional + // IPv6Range is a list of IPv6 ranges to allocate to the VPC. + // If not specified, the VPC will not have an IPv6 range allocated. + // Once ranges are allocated, they will be added to the IPv6 field. + IPv6Range []VPCCreateOptionsIPv6 `json:"ipv6Range,omitempty"` + // +optional Subnets []VPCSubnetCreateOptions `json:"subnets,omitempty"` // Retain allows you to keep the VPC after the LinodeVPC object is deleted. @@ -52,6 +63,17 @@ type LinodeVPCSpec struct { CredentialsRef *corev1.SecretReference `json:"credentialsRef,omitempty"` } +// VPCCreateOptionsIPv6 defines the options for creating an IPv6 range in a VPC. +// It's copied from linodego.VPCCreateOptionsIPv6 and should be kept in sync. +// Values supported by the linode API should be used here. +// See https://techdocs.akamai.com/linode-api/reference/post-vpc for more details. +type VPCCreateOptionsIPv6 struct { + // Range is the IPv6 prefix for the VPC. + Range *string `json:"range,omitempty"` + // IPv6 inventory from which the VPC prefix should be allocated. + AllocationClass *string `json:"allocation_class,omitempty"` +} + // VPCSubnetCreateOptions defines subnet options type VPCSubnetCreateOptions struct { // +kubebuilder:validation:MinLength=3 @@ -60,6 +82,16 @@ type VPCSubnetCreateOptions struct { Label string `json:"label,omitempty"` // +optional IPv4 string `json:"ipv4,omitempty"` + // +optional + // IPv6 is a list of IPv6 ranges allocated to the subnet. + // Once ranges are allocated based on the IPv6Range field, they will be + // added to this field. + IPv6 []linodego.VPCIPv6Range `json:"ipv6,omitempty"` + // +optional + // IPv6Range is a list of IPv6 ranges to allocate to the subnet. + // If not specified, the subnet will not have an IPv6 range allocated. + // Once ranges are allocated, they will be added to the IPv6 field. + IPv6Range []VPCSubnetCreateOptionsIPv6 `json:"ipv6Range,omitempty"` // SubnetID is subnet id for the subnet // +optional SubnetID int `json:"subnetID,omitempty"` @@ -70,6 +102,14 @@ type VPCSubnetCreateOptions struct { Retain bool `json:"retain,omitempty"` } +// VPCSubnetCreateOptionsIPv6 defines the options for creating an IPv6 range in a VPC subnet. +// It's copied from linodego.VPCSubnetCreateOptionsIPv6 and should be kept in sync. +// Values supported by the linode API should be used here. +// See https://techdocs.akamai.com/linode-api/reference/post-vpc-subnet for more details. +type VPCSubnetCreateOptionsIPv6 struct { + Range *string `json:"range,omitempty"` +} + // LinodeVPCStatus defines the observed state of LinodeVPC type LinodeVPCStatus struct { // Ready is true when the provider resource is ready. diff --git a/api/v1alpha2/zz_generated.deepcopy.go b/api/v1alpha2/zz_generated.deepcopy.go index 83a2bcac4..a5abe3618 100644 --- a/api/v1alpha2/zz_generated.deepcopy.go +++ b/api/v1alpha2/zz_generated.deepcopy.go @@ -1511,10 +1511,24 @@ func (in *LinodeVPCSpec) DeepCopyInto(out *LinodeVPCSpec) { *out = new(int) **out = **in } + if in.IPv6 != nil { + in, out := &in.IPv6, &out.IPv6 + *out = make([]linodego.VPCIPv6Range, len(*in)) + copy(*out, *in) + } + if in.IPv6Range != nil { + in, out := &in.IPv6Range, &out.IPv6Range + *out = make([]VPCCreateOptionsIPv6, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } if in.Subnets != nil { in, out := &in.Subnets, &out.Subnets *out = make([]VPCSubnetCreateOptions, len(*in)) - copy(*out, *in) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } if in.CredentialsRef != nil { in, out := &in.CredentialsRef, &out.CredentialsRef @@ -1656,6 +1670,31 @@ 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 *VPCCreateOptionsIPv6) DeepCopyInto(out *VPCCreateOptionsIPv6) { + *out = *in + if in.Range != nil { + in, out := &in.Range, &out.Range + *out = new(string) + **out = **in + } + if in.AllocationClass != nil { + in, out := &in.AllocationClass, &out.AllocationClass + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VPCCreateOptionsIPv6. +func (in *VPCCreateOptionsIPv6) DeepCopy() *VPCCreateOptionsIPv6 { + if in == nil { + return nil + } + out := new(VPCCreateOptionsIPv6) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *VPCIPv4) DeepCopyInto(out *VPCIPv4) { *out = *in @@ -1674,6 +1713,18 @@ func (in *VPCIPv4) DeepCopy() *VPCIPv4 { // 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 + if in.IPv6 != nil { + in, out := &in.IPv6, &out.IPv6 + *out = make([]linodego.VPCIPv6Range, len(*in)) + copy(*out, *in) + } + if in.IPv6Range != nil { + in, out := &in.IPv6Range, &out.IPv6Range + *out = make([]VPCSubnetCreateOptionsIPv6, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VPCSubnetCreateOptions. @@ -1685,3 +1736,23 @@ func (in *VPCSubnetCreateOptions) DeepCopy() *VPCSubnetCreateOptions { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VPCSubnetCreateOptionsIPv6) DeepCopyInto(out *VPCSubnetCreateOptionsIPv6) { + *out = *in + if in.Range != nil { + in, out := &in.Range, &out.Range + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VPCSubnetCreateOptionsIPv6. +func (in *VPCSubnetCreateOptionsIPv6) DeepCopy() *VPCSubnetCreateOptionsIPv6 { + if in == nil { + return nil + } + out := new(VPCSubnetCreateOptionsIPv6) + in.DeepCopyInto(out) + return out +} diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_linodevpcs.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_linodevpcs.yaml index 22a7de944..4bc2762c7 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_linodevpcs.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_linodevpcs.yaml @@ -67,6 +67,42 @@ spec: x-kubernetes-map-type: atomic description: type: string + ipv6: + description: |- + IPv6 is a list of IPv6 ranges allocated to the VPC. + Once ranges are allocated based on the IPv6Range field, they will be + added to this field. + items: + description: VPCIPv6Range represents a single IPv6 range assigned + to a VPC. + properties: + range: + type: string + required: + - range + type: object + type: array + ipv6Range: + description: |- + IPv6Range is a list of IPv6 ranges to allocate to the VPC. + If not specified, the VPC will not have an IPv6 range allocated. + Once ranges are allocated, they will be added to the IPv6 field. + items: + description: |- + VPCCreateOptionsIPv6 defines the options for creating an IPv6 range in a VPC. + It's copied from linodego.VPCCreateOptionsIPv6 and should be kept in sync. + Values supported by the linode API should be used here. + See https://techdocs.akamai.com/linode-api/reference/post-vpc for more details. + properties: + allocation_class: + description: IPv6 inventory from which the VPC prefix should + be allocated. + type: string + range: + description: Range is the IPv6 prefix for the VPC. + type: string + type: object + type: array region: type: string x-kubernetes-validations: @@ -86,6 +122,37 @@ spec: properties: ipv4: type: string + ipv6: + description: |- + IPv6 is a list of IPv6 ranges allocated to the subnet. + Once ranges are allocated based on the IPv6Range field, they will be + added to this field. + items: + description: VPCIPv6Range represents a single IPv6 range assigned + to a VPC. + properties: + range: + type: string + required: + - range + type: object + type: array + ipv6Range: + description: |- + IPv6Range is a list of IPv6 ranges to allocate to the subnet. + If not specified, the subnet will not have an IPv6 range allocated. + Once ranges are allocated, they will be added to the IPv6 field. + items: + description: |- + VPCSubnetCreateOptionsIPv6 defines the options for creating an IPv6 range in a VPC subnet. + It's copied from linodego.VPCSubnetCreateOptionsIPv6 and should be kept in sync. + Values supported by the linode API should be used here. + See https://techdocs.akamai.com/linode-api/reference/post-vpc-subnet for more details. + properties: + range: + type: string + type: object + type: array label: maxLength: 63 minLength: 3 diff --git a/docs/src/reference/out.md b/docs/src/reference/out.md index f19a309d8..c1043e715 100644 --- a/docs/src/reference/out.md +++ b/docs/src/reference/out.md @@ -1078,6 +1078,8 @@ _Appears in:_ | `vpcID` _integer_ | | | | | `description` _string_ | | | | | `region` _string_ | | | | +| `ipv6` _VPCIPv6Range array_ | IPv6 is a list of IPv6 ranges allocated to the VPC.
Once ranges are allocated based on the IPv6Range field, they will be
added to this field. | | | +| `ipv6Range` _[VPCCreateOptionsIPv6](#vpccreateoptionsipv6) array_ | IPv6Range is a list of IPv6 ranges to allocate to the VPC.
If not specified, the VPC will not have an IPv6 range allocated.
Once ranges are allocated, they will be added to the IPv6 field. | | | | `subnets` _[VPCSubnetCreateOptions](#vpcsubnetcreateoptions) array_ | | | | | `retain` _boolean_ | Retain allows you to keep the VPC after the LinodeVPC object is deleted.
This is useful if you want to use an existing VPC that was not created by this controller.
If set to true, the controller will not delete the VPC resource in Linode.
Defaults to false. | false | | | `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 provisioning this VPC. If not
supplied then the credentials of the controller will be used. | | | @@ -1187,6 +1189,26 @@ _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. | | | +#### VPCCreateOptionsIPv6 + + + +VPCCreateOptionsIPv6 defines the options for creating an IPv6 range in a VPC. +It's copied from linodego.VPCCreateOptionsIPv6 and should be kept in sync. +Values supported by the linode API should be used here. +See https://techdocs.akamai.com/linode-api/reference/post-vpc for more details. + + + +_Appears in:_ +- [LinodeVPCSpec](#linodevpcspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `range` _string_ | Range is the IPv6 prefix for the VPC. | | | +| `allocation_class` _string_ | IPv6 inventory from which the VPC prefix should be allocated. | | | + + #### VPCIPv4 @@ -1237,7 +1259,28 @@ _Appears in:_ | --- | --- | --- | --- | | `label` _string_ | | | MaxLength: 63
MinLength: 3
| | `ipv4` _string_ | | | | +| `ipv6` _VPCIPv6Range array_ | IPv6 is a list of IPv6 ranges allocated to the subnet.
Once ranges are allocated based on the IPv6Range field, they will be
added to this field. | | | +| `ipv6Range` _[VPCSubnetCreateOptionsIPv6](#vpcsubnetcreateoptionsipv6) array_ | IPv6Range is a list of IPv6 ranges to allocate to the subnet.
If not specified, the subnet will not have an IPv6 range allocated.
Once ranges are allocated, they will be added to the IPv6 field. | | | | `subnetID` _integer_ | SubnetID is subnet id for the subnet | | | | `retain` _boolean_ | Retain allows you to keep the Subnet after the LinodeVPC object is deleted.
This is only applicable when the parent VPC has retain set to true. | false | | +#### VPCSubnetCreateOptionsIPv6 + + + +VPCSubnetCreateOptionsIPv6 defines the options for creating an IPv6 range in a VPC subnet. +It's copied from linodego.VPCSubnetCreateOptionsIPv6 and should be kept in sync. +Values supported by the linode API should be used here. +See https://techdocs.akamai.com/linode-api/reference/post-vpc-subnet for more details. + + + +_Appears in:_ +- [VPCSubnetCreateOptions](#vpcsubnetcreateoptions) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `range` _string_ | | | | + + diff --git a/go.mod b/go.mod index 21b0deb84..b7f7613cc 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.0 + github.com/linode/linodego v1.53.1-0.20250709175023-9b152d30578c github.com/onsi/ginkgo/v2 v2.23.4 github.com/onsi/gomega v1.37.0 github.com/stretchr/testify v1.10.0 diff --git a/go.sum b/go.sum index 9945775a5..19015e034 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.0 h1:UWr7bUUVMtcfsuapC+6blm6+jJLPd7Tf9MZUpdOERnI= -github.com/linode/linodego v1.53.0/go.mod h1:bI949fZaVchjWyKIA08hNyvAcV6BAS+PM2op3p7PAWA= +github.com/linode/linodego v1.53.1-0.20250709175023-9b152d30578c h1:WlZm+YNHBuphycMZG2s2+F04hx2wx1ShuOwPAIInjP8= +github.com/linode/linodego v1.53.1-0.20250709175023-9b152d30578c/go.mod h1:bI949fZaVchjWyKIA08hNyvAcV6BAS+PM2op3p7PAWA= 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 dabc34ace..d73a44bd1 100644 --- a/internal/controller/linodemachine_controller_helpers.go +++ b/internal/controller/linodemachine_controller_helpers.go @@ -57,6 +57,7 @@ import ( const ( maxBootstrapDataBytesCloudInit = 16384 vlanIPFormat = "%s/11" + defaultNodeIPv6CIDRRange = "/64" // Default IPv6 range for VPC interfaces ) var ( @@ -462,10 +463,13 @@ func getVPCInterfaceConfig(ctx context.Context, machineScope *scope.MachineScope subnetName := machineScope.LinodeCluster.Spec.Network.SubnetName // name of subnet to use + var ipv6RangeConfig []linodego.InstanceConfigInterfaceCreateOptionsIPv6Range if subnetName != "" { for _, subnet := range linodeVPC.Spec.Subnets { if subnet.Label == subnetName { subnetID = subnet.SubnetID + ipv6RangeConfig = machineIPv6RangeConfig(len(subnet.IPv6)) + break } } @@ -474,6 +478,7 @@ func getVPCInterfaceConfig(ctx context.Context, machineScope *scope.MachineScope } } else { subnetID = linodeVPC.Spec.Subnets[0].SubnetID // get first subnet if nothing specified + ipv6RangeConfig = machineIPv6RangeConfig(len(linodeVPC.Spec.Subnets[0].IPv6)) } if subnetID == 0 { @@ -483,18 +488,32 @@ func getVPCInterfaceConfig(ctx context.Context, machineScope *scope.MachineScope for i, netInterface := range interfaces { if netInterface.Purpose == linodego.InterfacePurposeVPC { interfaces[i].SubnetID = &subnetID + if len(ipv6RangeConfig) > 0 { + interfaces[i].IPv6 = &linodego.InstanceConfigInterfaceCreateOptionsIPv6{ + Ranges: ipv6RangeConfig, + } + } return nil, nil //nolint:nilnil // it is important we don't return an interface if a VPC interface already exists } } - return &linodego.InstanceConfigInterfaceCreateOptions{ + vpcIntfCreateOpts := &linodego.InstanceConfigInterfaceCreateOptions{ Purpose: linodego.InterfacePurposeVPC, Primary: true, SubnetID: &subnetID, IPv4: &linodego.VPCIPv4{ NAT1To1: ptr.To("any"), }, - }, nil + } + + // If IPv6 range config is not empty, add it to the interface configuration + if len(ipv6RangeConfig) > 0 { + vpcIntfCreateOpts.IPv6 = &linodego.InstanceConfigInterfaceCreateOptionsIPv6{ + Ranges: ipv6RangeConfig, + } + } + + return vpcIntfCreateOpts, nil } // getVPCInterfaceConfigFromDirectID returns the interface configuration for a VPC based on a direct VPC ID @@ -519,10 +538,12 @@ func getVPCInterfaceConfigFromDirectID(ctx context.Context, machineScope *scope. } // If subnet name specified, find matching subnet; otherwise use first subnet + var ipv6RangeConfig []linodego.InstanceConfigInterfaceCreateOptionsIPv6Range if subnetName != "" { for _, subnet := range vpc.Subnets { if subnet.Label == subnetName { subnetID = subnet.ID + ipv6RangeConfig = machineIPv6RangeConfig(len(subnet.IPv6)) break } } @@ -531,25 +552,54 @@ func getVPCInterfaceConfigFromDirectID(ctx context.Context, machineScope *scope. } } else { subnetID = vpc.Subnets[0].ID + ipv6RangeConfig = machineIPv6RangeConfig(len(vpc.Subnets[0].IPv6)) } // Check if a VPC interface already exists for i, netInterface := range interfaces { if netInterface.Purpose == linodego.InterfacePurposeVPC { interfaces[i].SubnetID = &subnetID + if len(ipv6RangeConfig) > 0 { + interfaces[i].IPv6 = &linodego.InstanceConfigInterfaceCreateOptionsIPv6{ + Ranges: ipv6RangeConfig, + } + } 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 - return &linodego.InstanceConfigInterfaceCreateOptions{ + vpcIntfCreateOpts := &linodego.InstanceConfigInterfaceCreateOptions{ Purpose: linodego.InterfacePurposeVPC, Primary: true, SubnetID: &subnetID, IPv4: &linodego.VPCIPv4{ NAT1To1: ptr.To("any"), }, - }, nil + } + + // If IPv6 range config is not empty, add it to the interface configuration + if len(ipv6RangeConfig) > 0 { + vpcIntfCreateOpts.IPv6 = &linodego.InstanceConfigInterfaceCreateOptionsIPv6{ + Ranges: ipv6RangeConfig, + } + } + + return vpcIntfCreateOpts, nil +} + +// machineIPv6RangeConfig returns the IPv6 range configuration if subnet has IPv6 ranges. +// For now, we support only a single IPv6 range for machine per subnet. +// If this changes, we may need to adjust this logic. +func machineIPv6RangeConfig(numIPv6RangesInSubnet int) []linodego.InstanceConfigInterfaceCreateOptionsIPv6Range { + if numIPv6RangesInSubnet == 0 { + return nil // No IPv6 ranges available in subnet, return empty slice + } + return []linodego.InstanceConfigInterfaceCreateOptionsIPv6Range{ + { + Range: ptr.To(defaultNodeIPv6CIDRRange), + }, + } } func linodeMachineSpecToInstanceCreateConfig(machineSpec infrav1alpha2.LinodeMachineSpec, machineTags []string) *linodego.InstanceCreateOptions { diff --git a/internal/controller/linodemachine_controller_helpers_test.go b/internal/controller/linodemachine_controller_helpers_test.go index 884b11f36..8e0cac29e 100644 --- a/internal/controller/linodemachine_controller_helpers_test.go +++ b/internal/controller/linodemachine_controller_helpers_test.go @@ -409,6 +409,9 @@ func validateInterfaceExpectations( if expectInterface { require.NotNil(t, iface) require.Equal(t, linodego.InterfacePurposeVPC, iface.Purpose) + if iface.IPv6 != nil { + require.Equal(t, defaultNodeIPv6CIDRRange, *iface.IPv6.Ranges[0].Range) + } require.True(t, iface.Primary) require.NotNil(t, iface.SubnetID) require.Equal(t, expectSubnetID, *iface.SubnetID) @@ -497,6 +500,11 @@ func TestGetVPCInterfaceConfigFromDirectID(t *testing.T) { { ID: 456, Label: "subnet-1", + IPv6: []linodego.VPCIPv6Range{ + { + Range: "2001:0db8::/56", + }, + }, }, }, }, nil) @@ -762,6 +770,11 @@ func TestConfigureVPCInterface(t *testing.T) { { ID: 456, Label: "subnet-1", + IPv6: []linodego.VPCIPv6Range{ + { + Range: "2001:0db8::/56", + }, + }, }, }, }, nil) @@ -944,6 +957,11 @@ func TestGetVPCInterfaceConfig(t *testing.T) { { SubnetID: 456, Label: "subnet-1", + IPv6: []linodego.VPCIPv6Range{ + { + Range: "2001:0db8::/56", + }, + }, }, } return nil @@ -1032,6 +1050,11 @@ func TestGetVPCInterfaceConfig(t *testing.T) { { SubnetID: 456, Label: "subnet-1", + IPv6: []linodego.VPCIPv6Range{ + { + Range: "2001:0db8::/56", + }, + }, }, } return nil diff --git a/internal/controller/linodevpc_controller_helpers.go b/internal/controller/linodevpc_controller_helpers.go index 4c7860b07..11a061523 100644 --- a/internal/controller/linodevpc_controller_helpers.go +++ b/internal/controller/linodevpc_controller_helpers.go @@ -69,20 +69,21 @@ func reconcileVPC(ctx context.Context, vpcScope *scope.VPCScope, logger logr.Log return err } - vpcScope.LinodeVPC.Spec.VPCID = &vpc.ID + setVPCFields(&vpcScope.LinodeVPC.Spec, vpc) updateVPCSpecSubnets(vpcScope, vpc) return nil } func reconcileExistingVPC(ctx context.Context, vpcScope *scope.VPCScope, vpc *linodego.VPC) error { - // Labels are unique - vpcScope.LinodeVPC.Spec.VPCID = &vpc.ID + setVPCFields(&vpcScope.LinodeVPC.Spec, vpc) // build a map of existing subnets to easily check for existence existingSubnets := make(map[string]int, len(vpc.Subnets)) + existingSubnetsIPv6 := make(map[string][]linodego.VPCIPv6Range, len(vpc.Subnets)) for _, subnet := range vpc.Subnets { existingSubnets[subnet.Label] = subnet.ID + existingSubnetsIPv6[subnet.Label] = subnet.IPv6 } // adopt or create subnets @@ -92,16 +93,25 @@ func reconcileExistingVPC(ctx context.Context, vpcScope *scope.VPCScope, vpc *li } if id, ok := existingSubnets[subnet.Label]; ok { vpcScope.LinodeVPC.Spec.Subnets[idx].SubnetID = id + vpcScope.LinodeVPC.Spec.Subnets[idx].IPv6 = existingSubnetsIPv6[subnet.Label] } else { + ipv6 := []linodego.VPCSubnetCreateOptionsIPv6{} + for _, ipv6Range := range subnet.IPv6Range { + ipv6 = append(ipv6, linodego.VPCSubnetCreateOptionsIPv6{ + Range: ipv6Range.Range, + }) + } createSubnetConfig := linodego.VPCSubnetCreateOptions{ Label: subnet.Label, IPv4: subnet.IPv4, + IPv6: ipv6, } + newSubnet, err := vpcScope.LinodeClient.CreateVPCSubnet(ctx, createSubnetConfig, *vpcScope.LinodeVPC.Spec.VPCID) if err != nil { return err } - vpcScope.LinodeVPC.Spec.Subnets[idx].SubnetID = newSubnet.ID + setSubnetFields(&vpcScope.LinodeVPC.Spec.Subnets[idx], newSubnet) } } @@ -113,25 +123,61 @@ func updateVPCSpecSubnets(vpcScope *scope.VPCScope, vpc *linodego.VPC) { for idx, specSubnet := range vpcScope.LinodeVPC.Spec.Subnets { for _, vpcSubnet := range vpc.Subnets { if specSubnet.Label == vpcSubnet.Label { - vpcScope.LinodeVPC.Spec.Subnets[idx].SubnetID = vpcSubnet.ID + setSubnetFields(&vpcScope.LinodeVPC.Spec.Subnets[idx], &vpcSubnet) break } } } } +// setVPCFields sets the VPCID and IPv6 in the LinodeVPCSpec from the Linode VPC. +func setVPCFields(vpc *infrav1alpha2.LinodeVPCSpec, linodeVPC *linodego.VPC) { + vpc.VPCID = &linodeVPC.ID + // Clear existing IPv6 ranges and set new ones + vpc.IPv6 = nil + for _, ipv6 := range linodeVPC.IPv6 { + vpc.IPv6 = append(vpc.IPv6, linodego.VPCIPv6Range{Range: ipv6.Range}) + } +} + +// setSubnetFields sets the SubnetID and IPv6 in the VPCSubnetCreateOptions from the Linode VPCSubnet. +func setSubnetFields(subnet *infrav1alpha2.VPCSubnetCreateOptions, vpcSubnet *linodego.VPCSubnet) { + subnet.SubnetID = vpcSubnet.ID + // Clear existing IPv6 ranges and set new ones + subnet.IPv6 = nil + for _, ipv6 := range vpcSubnet.IPv6 { + subnet.IPv6 = append(subnet.IPv6, linodego.VPCIPv6Range{Range: ipv6.Range}) + } +} + func linodeVPCSpecToVPCCreateConfig(vpcSpec infrav1alpha2.LinodeVPCSpec) *linodego.VPCCreateOptions { + vpcIPv6 := make([]linodego.VPCCreateOptionsIPv6, len(vpcSpec.IPv6Range)) + for idx, ipv6 := range vpcSpec.IPv6Range { + vpcIPv6[idx] = linodego.VPCCreateOptionsIPv6{ + Range: ipv6.Range, + } + } + subnets := make([]linodego.VPCSubnetCreateOptions, len(vpcSpec.Subnets)) for idx, subnet := range vpcSpec.Subnets { + ipv6 := []linodego.VPCSubnetCreateOptionsIPv6{} + for _, ipv6Range := range subnet.IPv6Range { + ipv6 = append(ipv6, linodego.VPCSubnetCreateOptionsIPv6{ + Range: ipv6Range.Range, + }) + } subnets[idx] = linodego.VPCSubnetCreateOptions{ Label: subnet.Label, IPv4: subnet.IPv4, + IPv6: ipv6, } } + return &linodego.VPCCreateOptions{ Description: vpcSpec.Description, Region: vpcSpec.Region, Subnets: subnets, + IPv6: vpcIPv6, } } diff --git a/internal/controller/linodevpc_controller_test.go b/internal/controller/linodevpc_controller_test.go index 4c83220e9..43e7aeb99 100644 --- a/internal/controller/linodevpc_controller_test.go +++ b/internal/controller/linodevpc_controller_test.go @@ -115,7 +115,7 @@ var _ = Describe("lifecycle", Ordered, Label("vpc", "lifecycle"), func() { ID: 1, Region: "us-east", Subnets: []linodego.VPCSubnet{ - {Label: "subnet1", IPv4: "10.0.0.0/8"}, + {Label: "subnet1", IPv4: "10.0.0.0/8", IPv6: []linodego.VPCIPv6Range{{Range: "2001:db8::/52"}}}, }, }, nil) }), @@ -126,6 +126,7 @@ var _ = Describe("lifecycle", Ordered, Label("vpc", "lifecycle"), func() { Expect(k8sClient.Get(ctx, objectKey, &linodeVPC)).To(Succeed()) Expect(*linodeVPC.Spec.VPCID).To(Equal(1)) Expect(linodeVPC.Spec.Subnets[0].IPv4).To(Equal("10.0.0.0/8")) + Expect(linodeVPC.Spec.Subnets[0].IPv6).To(ContainElement(linodego.VPCIPv6Range{Range: "2001:db8::/52"})) Expect(linodeVPC.Spec.Subnets[0].Label).To(Equal("subnet1")) Expect(mck.Logs()).NotTo(ContainSubstring("Failed to create VPC")) }), @@ -148,6 +149,7 @@ var _ = Describe("lifecycle", Ordered, Label("vpc", "lifecycle"), func() { { Label: "subnet1", IPv4: "10.0.0.0/8", + IPv6: []linodego.VPCIPv6Range{{Range: "2001:db8::/52"}}, }, }, }, @@ -310,11 +312,12 @@ var _ = Describe("retained VPC", Label("vpc", "lifecycle"), func() { Finalizers: []string{infrav1alpha2.VPCFinalizer}, }, Spec: infrav1alpha2.LinodeVPCSpec{ - VPCID: ptr.To(123), - Region: "us-east", + VPCID: ptr.To(123), + Region: "us-east", + IPv6Range: []infrav1alpha2.VPCCreateOptionsIPv6{{Range: ptr.To("/52")}}, Subnets: []infrav1alpha2.VPCSubnetCreateOptions{ - {Label: "subnet1", IPv4: "10.0.0.0/8", SubnetID: 1, Retain: true}, - {Label: "subnet2", IPv4: "10.0.1.0/24", SubnetID: 2}, + {Label: "subnet1", IPv4: "10.0.0.0/8", SubnetID: 1, Retain: true, IPv6Range: []infrav1alpha2.VPCSubnetCreateOptionsIPv6{{Range: ptr.To("/56")}}}, + {Label: "subnet2", IPv4: "10.0.1.0/24", SubnetID: 2, IPv6Range: []infrav1alpha2.VPCSubnetCreateOptionsIPv6{{Range: ptr.To("/56")}}}, }, }, } @@ -356,9 +359,10 @@ var _ = Describe("retained VPC", Label("vpc", "lifecycle"), func() { Label: "vpc1", Region: "us-east", Updated: ptr.To(time.Now()), + IPv6: []linodego.VPCIPv6Range{{Range: "2001:db8::/52"}}, Subnets: []linodego.VPCSubnet{ - {ID: 1, Label: "subnet1", IPv4: "10.0.0.0/8"}, - {ID: 2, Label: "subnet2", IPv4: "10.0.1.0/24"}, + {ID: 1, Label: "subnet1", IPv4: "10.0.0.0/8", IPv6: []linodego.VPCIPv6Range{{Range: "2001:db8:8:1::/56"}}}, + {ID: 2, Label: "subnet2", IPv4: "10.0.1.0/24", IPv6: []linodego.VPCIPv6Range{{Range: "2001:db8:8:2::/56"}}}, }, }, nil) diff --git a/internal/webhook/v1alpha2/linodevpc_webhook.go b/internal/webhook/v1alpha2/linodevpc_webhook.go index 28538c4dc..731b0790f 100644 --- a/internal/webhook/v1alpha2/linodevpc_webhook.go +++ b/internal/webhook/v1alpha2/linodevpc_webhook.go @@ -23,6 +23,7 @@ import ( "net/netip" "regexp" "slices" + "strconv" "strings" "go4.org/netipx" @@ -157,6 +158,15 @@ func (r *linodeVPCValidator) validateLinodeVPCSpec(ctx context.Context, linodecl errs = slices.Concat(errs, err) } + // Validate VPC IPv6 Ranges + for idx, ipv6Range := range spec.IPv6Range { + ipv6RangePath := field.NewPath("spec").Child("IPv6Range").Index(idx).Child("Range") + rangeErr := validateIPv6Range(ipv6Range.Range, ipv6RangePath) + if rangeErr != nil { + errs = append(errs, rangeErr) + } + } + if len(errs) == 0 { return nil } @@ -171,12 +181,12 @@ func (r *linodeVPCValidator) validateLinodeVPCSubnets(spec infrav1alpha2.LinodeV labels = []string{} ) - for i, subnet := range spec.Subnets { + for idx, subnet := range spec.Subnets { var ( label = subnet.Label - labelPath = field.NewPath("spec").Child("Subnets").Index(i).Child("Label") + labelPath = field.NewPath("spec").Child("Subnets").Index(idx).Child("Label") ip = subnet.IPv4 - ipPath = field.NewPath("spec").Child("Subnets").Index(i).Child("IPv4") + ipPath = field.NewPath("spec").Child("Subnets").Index(idx).Child("IPv4") ) // Validate Subnet Label @@ -202,6 +212,15 @@ func (r *linodeVPCValidator) validateLinodeVPCSubnets(spec infrav1alpha2.LinodeV if cidrs, err = builder.IPSet(); err != nil { return append(field.ErrorList{}, field.InternalError(ipPath, fmt.Errorf("build ip set: %w", err))) } + + // Validate Subnet IPv6 Ranges + for subnetIdx, ipv6Range := range spec.Subnets[idx].IPv6Range { + ipv6RangePath := field.NewPath("spec").Child("Subnets").Index(idx).Child("IPv6Range").Index(subnetIdx).Child("Range") + rangeErr := validateIPv6Range(ipv6Range.Range, ipv6RangePath) + if rangeErr != nil { + errs = append(errs, rangeErr) + } + } } if len(errs) == 0 { @@ -282,3 +301,35 @@ func validateSubnetIPv4CIDR(cidr string, path *field.Path) (*netipx.IPSet, *fiel } return set, nil } + +func validateIPv6Range(ipv6Range *string, path *field.Path) *field.Error { + const ( + errIPv6RangeFormat = "IPv6 range must be either 'auto' or start with /. Example: /52" + errIPv6RangeNoNumber = "IPv6 range doesn't contain a valid number after /" + errIPv6RangeBounds = "IPv6 range must be between /0 and /128" + ) + + ipv6RangeStr := "auto" + if ipv6Range != nil { + ipv6RangeStr = *ipv6Range + } + + // "auto" is valid + if ipv6RangeStr == "auto" { + return nil + } + + if ipv6RangeStr == "" || !strings.HasPrefix(ipv6RangeStr, "/") { + return field.Invalid(path, ipv6RangeStr, errIPv6RangeFormat) + } + + numStr := strings.TrimPrefix(ipv6RangeStr, "/") + num, err := strconv.Atoi(numStr) + if err != nil { + return field.Invalid(path, ipv6RangeStr, errIPv6RangeNoNumber) + } + if num < 0 || num > 128 { + return field.Invalid(path, ipv6RangeStr, errIPv6RangeBounds) + } + return nil +} diff --git a/internal/webhook/v1alpha2/linodevpc_webhook_test.go b/internal/webhook/v1alpha2/linodevpc_webhook_test.go index 31b523ebb..ca44c747a 100644 --- a/internal/webhook/v1alpha2/linodevpc_webhook_test.go +++ b/internal/webhook/v1alpha2/linodevpc_webhook_test.go @@ -29,6 +29,7 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" infrav1alpha2 "github.com/linode/cluster-api-provider-linode/api/v1alpha2" @@ -269,6 +270,135 @@ func TestValidateLinodeVPC(t *testing.T) { } }), ), + Path( + Call("subnet ipv6 range is incorrect", 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() + }), + Result("error", func(ctx context.Context, mck Mock) { + vpc := vpc + vpc.Spec.Subnets = []infrav1alpha2.VPCSubnetCreateOptions{{Label: "foo", IPv4: "10.0.0.0/8", IPv6Range: []infrav1alpha2.VPCSubnetCreateOptionsIPv6{{Range: ptr.To("")}}}} + errs := validator.validateLinodeVPCSpec(ctx, mck.LinodeClient, vpc.Spec, SkipAPIValidation) + for _, err := range errs { + require.Error(t, err) + } + }), + ), + ), + ) +} + +func TestValidateVPCIPv6Ranges(t *testing.T) { + t.Parallel() + + var ( + vpc = infrav1alpha2.LinodeVPC{ + ObjectMeta: metav1.ObjectMeta{ + Name: "example", + Namespace: "example", + }, + Spec: infrav1alpha2.LinodeVPCSpec{ + Region: "example", + }, + } + region = linodego.Region{ID: "test"} + capabilities = []string{LinodeVPCCapability} + ErrorIPv6RangeInvalid = "spec.IPv6Range[0].Range: Invalid value: \"48\": IPv6 range must be either 'auto' or start with /. Example: /52" + ErrorIPv6RangeInvalidChars = "spec.IPv6Range[0].Range: Invalid value: \"/a48\": IPv6 range doesn't contain a valid number after /" + ErrorIPv6RangeOutOfRange = "spec.IPv6Range[0].Range: Invalid value: \"/130\": IPv6 range must be between /0 and /128" + validator = &linodeVPCValidator{} + ) + + NewSuite(t, mock.MockLinodeClient{}).Run( + OneOf( + Path( + Call("valid ipv6 ranges in vpc", 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() + }), + Result("success", func(ctx context.Context, mck Mock) { + vpc := vpc + vpc.Spec.IPv6Range = []infrav1alpha2.VPCCreateOptionsIPv6{ + {Range: ptr.To("/48")}, + {Range: ptr.To("/52")}, + {Range: ptr.To("auto")}, + } + errs := validator.validateLinodeVPCSpec(ctx, mck.LinodeClient, vpc.Spec, SkipAPIValidation) + require.Empty(t, errs) + }), + ), + Path( + Call("valid ipv6 ranges in subnets", 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() + }), + Result("success", func(ctx context.Context, mck Mock) { + vpc := vpc + vpc.Spec.Subnets = []infrav1alpha2.VPCSubnetCreateOptions{ + {Label: "foo", IPv4: "10.0.0.0/24", IPv6Range: []infrav1alpha2.VPCSubnetCreateOptionsIPv6{{Range: ptr.To("/52")}}}, + {Label: "bar", IPv4: "10.0.1.0/24", IPv6Range: []infrav1alpha2.VPCSubnetCreateOptionsIPv6{{Range: ptr.To("/64")}}}, + {Label: "buzz", IPv4: "10.0.2.0/24", IPv6Range: []infrav1alpha2.VPCSubnetCreateOptionsIPv6{{Range: ptr.To("auto")}}}, + } + errs := validator.validateLinodeVPCSpec(ctx, mck.LinodeClient, vpc.Spec, SkipAPIValidation) + require.Empty(t, errs) + }), + ), + ), + OneOf( + Path( + Call("ipv6 range missing /", 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() + }), + Result("error", func(ctx context.Context, mck Mock) { + vpc := vpc + vpc.Spec.IPv6Range = []infrav1alpha2.VPCCreateOptionsIPv6{ + {Range: ptr.To("48")}, + } + errs := validator.validateLinodeVPCSpec(ctx, mck.LinodeClient, vpc.Spec, SkipAPIValidation) + for _, err := range errs { + assert.ErrorContains(t, err, ErrorIPv6RangeInvalid) + } + }), + ), + Path( + Call("ipv6 range containing chars", 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() + }), + Result("error", func(ctx context.Context, mck Mock) { + vpc := vpc + vpc.Spec.IPv6Range = []infrav1alpha2.VPCCreateOptionsIPv6{ + {Range: ptr.To("/a48")}, + } + errs := validator.validateLinodeVPCSpec(ctx, mck.LinodeClient, vpc.Spec, SkipAPIValidation) + for _, err := range errs { + assert.ErrorContains(t, err, ErrorIPv6RangeInvalidChars) + } + }), + ), + Path( + Call("ipv6 range out of bounds", 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() + }), + Result("error", func(ctx context.Context, mck Mock) { + vpc := vpc + vpc.Spec.IPv6Range = []infrav1alpha2.VPCCreateOptionsIPv6{ + {Range: ptr.To("/130")}, + } + errs := validator.validateLinodeVPCSpec(ctx, mck.LinodeClient, vpc.Spec, SkipAPIValidation) + for _, err := range errs { + assert.ErrorContains(t, err, ErrorIPv6RangeOutOfRange) + } + }), + ), ), ) }