diff --git a/api/v1alpha1/linodecluster_types.go b/api/v1alpha1/linodecluster_types.go index 7b9595e4b..7b07ace8b 100644 --- a/api/v1alpha1/linodecluster_types.go +++ b/api/v1alpha1/linodecluster_types.go @@ -17,6 +17,7 @@ limitations under the License. package v1alpha1 import ( + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" "sigs.k8s.io/cluster-api/errors" @@ -35,6 +36,10 @@ type LinodeClusterSpec struct { // NetworkSpec encapsulates all things related to Linode network. // +optional Network NetworkSpec `json:"network"` + + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" + // +optional + VPCRef *corev1.ObjectReference `json:"vpcRef,omitempty"` } // LinodeClusterStatus defines the observed state of LinodeCluster diff --git a/api/v1alpha1/linodemachine_types.go b/api/v1alpha1/linodemachine_types.go index 4b6b40693..151b9afb1 100644 --- a/api/v1alpha1/linodemachine_types.go +++ b/api/v1alpha1/linodemachine_types.go @@ -41,7 +41,10 @@ type LinodeMachineSpec struct { // +kubebuilder:validation:Required // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" Type string `json:"type"` + // +kubebuilder:validation:MinLength=3 + // +kubebuilder:validation:MaxLength=63 // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" + // +optional Label string `json:"label,omitempty"` // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" Group string `json:"group,omitempty"` @@ -77,25 +80,38 @@ type LinodeMachineSpec struct { // InstanceMetadataOptions defines metadata of instance type InstanceMetadataOptions struct { // UserData expects a Base64-encoded string + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" UserData string `json:"userData,omitempty"` } // InstanceConfigInterfaceCreateOptions defines network interface config type InstanceConfigInterfaceCreateOptions struct { - IPAMAddress string `json:"ipamAddress,omitempty"` - Label string `json:"label,omitempty"` - Purpose linodego.ConfigInterfacePurpose `json:"purpose,omitempty"` - Primary bool `json:"primary,omitempty"` + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" + IPAMAddress string `json:"ipamAddress,omitempty"` + // +kubebuilder:validation:MinLength=3 + // +kubebuilder:validation:MaxLength=63 + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" + // +optional + Label string `json:"label,omitempty"` + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" + Purpose linodego.ConfigInterfacePurpose `json:"purpose,omitempty"` + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" + Primary bool `json:"primary,omitempty"` + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" // +optional SubnetID *int `json:"subnetId,omitempty"` + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" // +optional - IPv4 *VPCIPv4 `json:"ipv4,omitempty"` + IPv4 *VPCIPv4 `json:"ipv4,omitempty"` + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" IPRanges []string `json:"ipRanges,omitempty"` } // VPCIPv4 defines VPC IPV4 settings type VPCIPv4 struct { - VPC string `json:"vpc,omitempty"` + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" + VPC string `json:"vpc,omitempty"` + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" NAT1To1 string `json:"nat1to1,omitempty"` } @@ -103,6 +119,7 @@ type VPCIPv4 struct { type LinodeMachineStatus struct { // Ready is true when the provider resource is ready. // +optional + // +kubebuilder:default=false Ready bool `json:"ready"` // Addresses contains the Linode instance associated addresses. diff --git a/api/v1alpha1/linodevpc_types.go b/api/v1alpha1/linodevpc_types.go new file mode 100644 index 000000000..71bfa67d6 --- /dev/null +++ b/api/v1alpha1/linodevpc_types.go @@ -0,0 +1,156 @@ +/* +Copyright 2023 Akamai Technologies, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" +) + +// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! +// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. + +// LinodeVPCSpec defines the desired state of LinodeVPC +type LinodeVPCSpec struct { + // +optional + VPCID *int `json:"vpcID,omitempty"` + + // +kubebuilder:validation:MinLength=3 + // +kubebuilder:validation:MaxLength=63 + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" + // +optional + Label string `json:"label,omitempty"` + // +optional + Description string `json:"description,omitempty"` + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" + Region string `json:"region"` + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" + // +optional + Subnets []VPCSubnetCreateOptions `json:"subnets,omitempty"` +} + +// VPCSubnetCreateOptions defines subnet options +type VPCSubnetCreateOptions struct { + // +kubebuilder:validation:MinLength=3 + // +kubebuilder:validation:MaxLength=63 + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" + // +optional + Label string `json:"label,omitempty"` + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" + // +optional + IPv4 string `json:"ipv4,omitempty"` +} + +// LinodeVPCStatus defines the observed state of LinodeVPC +type LinodeVPCStatus struct { + // Ready is true when the provider resource is ready. + // +optional + // +kubebuilder:default=false + Ready bool `json:"ready"` + + // FailureReason will be set in the event that there is a terminal problem + // reconciling the VPC and will contain a succinct value suitable + // for machine interpretation. + // + // This field should not be set for transitive errors that a controller + // faces that are expected to be fixed automatically over + // time (like service outages), but instead indicate that something is + // fundamentally wrong with the VPC's spec or the configuration of + // the controller, and that manual intervention is required. Examples + // of terminal errors would be invalid combinations of settings in the + // spec, values that are unsupported by the controller, or the + // responsible controller itself being critically misconfigured. + // + // Any transient errors that occur during the reconciliation of VPCs + // can be added as events to the VPC object and/or logged in the + // controller's output. + // +optional + FailureReason *VPCStatusError `json:"failureReason,omitempty"` + + // FailureMessage will be set in the event that there is a terminal problem + // reconciling the VPC and will contain a more verbose string suitable + // for logging and human consumption. + // + // This field should not be set for transitive errors that a controller + // faces that are expected to be fixed automatically over + // time (like service outages), but instead indicate that something is + // fundamentally wrong with the VPC's spec or the configuration of + // the controller, and that manual intervention is required. Examples + // of terminal errors would be invalid combinations of settings in the + // spec, values that are unsupported by the controller, or the + // responsible controller itself being critically misconfigured. + // + // Any transient errors that occur during the reconciliation of VPCs + // can be added as events to the VPC object and/or logged in the + // controller's output. + // +optional + FailureMessage *string `json:"failureMessage,omitempty"` + + // Conditions defines current service state of the LinodeVPC. + // +optional + Conditions clusterv1.Conditions `json:"conditions,omitempty"` +} + +//+kubebuilder:object:root=true +//+kubebuilder:subresource:status + +// LinodeVPC is the Schema for the linodemachines API +type LinodeVPC struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec LinodeVPCSpec `json:"spec,omitempty"` + Status LinodeVPCStatus `json:"status,omitempty"` +} + +func (lm *LinodeVPC) GetConditions() clusterv1.Conditions { + return lm.Status.Conditions +} + +func (lm *LinodeVPC) SetConditions(conditions clusterv1.Conditions) { + lm.Status.Conditions = conditions +} + +//+kubebuilder:object:root=true + +// LinodeVPCList contains a list of LinodeVPC +type LinodeVPCList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []LinodeVPC `json:"items"` +} + +func init() { + SchemeBuilder.Register(&LinodeVPC{}, &LinodeVPCList{}) +} + +// VPCStatusError defines errors states for VPC objects. +type VPCStatusError string + +const ( + // CreateVPCError indicates that an error was encountered + // when trying to create the VPC. + CreateVPCError VPCStatusError = "CreateError" + + // UpdateVPCError indicates that an error was encountered + // when trying to update the VPC. + UpdateVPCError VPCStatusError = "UpdateError" + + // DeleteVPCError indicates that an error was encountered + // when trying to delete the VPC. + DeleteVPCError VPCStatusError = "DeleteError" +) diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 117c52bfb..b01eaae74 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -22,6 +22,7 @@ package v1alpha1 import ( "github.com/linode/linodego" + "k8s.io/api/core/v1" runtime "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/cluster-api/api/v1beta1" "sigs.k8s.io/cluster-api/errors" @@ -77,7 +78,7 @@ func (in *LinodeCluster) DeepCopyInto(out *LinodeCluster) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec + in.Spec.DeepCopyInto(&out.Spec) in.Status.DeepCopyInto(&out.Status) } @@ -136,6 +137,11 @@ func (in *LinodeClusterSpec) DeepCopyInto(out *LinodeClusterSpec) { *out = *in out.ControlPlaneEndpoint = in.ControlPlaneEndpoint out.Network = in.Network + if in.VPCRef != nil { + in, out := &in.VPCRef, &out.VPCRef + *out = new(v1.ObjectReference) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LinodeClusterSpec. @@ -520,6 +526,122 @@ func (in *LinodeMachineTemplateSpec) DeepCopy() *LinodeMachineTemplateSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LinodeVPC) DeepCopyInto(out *LinodeVPC) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LinodeVPC. +func (in *LinodeVPC) DeepCopy() *LinodeVPC { + if in == nil { + return nil + } + out := new(LinodeVPC) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *LinodeVPC) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LinodeVPCList) DeepCopyInto(out *LinodeVPCList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]LinodeVPC, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LinodeVPCList. +func (in *LinodeVPCList) DeepCopy() *LinodeVPCList { + if in == nil { + return nil + } + out := new(LinodeVPCList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *LinodeVPCList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LinodeVPCSpec) DeepCopyInto(out *LinodeVPCSpec) { + *out = *in + if in.VPCID != nil { + in, out := &in.VPCID, &out.VPCID + *out = new(int) + **out = **in + } + if in.Subnets != nil { + in, out := &in.Subnets, &out.Subnets + *out = make([]VPCSubnetCreateOptions, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LinodeVPCSpec. +func (in *LinodeVPCSpec) DeepCopy() *LinodeVPCSpec { + if in == nil { + return nil + } + out := new(LinodeVPCSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LinodeVPCStatus) DeepCopyInto(out *LinodeVPCStatus) { + *out = *in + if in.FailureReason != nil { + in, out := &in.FailureReason, &out.FailureReason + *out = new(VPCStatusError) + **out = **in + } + if in.FailureMessage != nil { + in, out := &in.FailureMessage, &out.FailureMessage + *out = new(string) + **out = **in + } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make(v1beta1.Conditions, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LinodeVPCStatus. +func (in *LinodeVPCStatus) DeepCopy() *LinodeVPCStatus { + if in == nil { + return nil + } + out := new(LinodeVPCStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *NetworkSpec) DeepCopyInto(out *NetworkSpec) { *out = *in @@ -549,3 +671,18 @@ func (in *VPCIPv4) DeepCopy() *VPCIPv4 { 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 +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VPCSubnetCreateOptions. +func (in *VPCSubnetCreateOptions) DeepCopy() *VPCSubnetCreateOptions { + if in == nil { + return nil + } + out := new(VPCSubnetCreateOptions) + in.DeepCopyInto(out) + return out +} diff --git a/cloud/scope/cluster.go b/cloud/scope/cluster.go index 99fbaa08b..b3cae6807 100644 --- a/cloud/scope/cluster.go +++ b/cloud/scope/cluster.go @@ -19,11 +19,9 @@ package scope import ( "errors" "fmt" - "net/http" infrav1 "github.com/linode/cluster-api-provider-linode/api/v1alpha1" "github.com/linode/linodego" - "golang.org/x/oauth2" clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" "sigs.k8s.io/cluster-api/util/patch" "sigs.k8s.io/controller-runtime/pkg/client" @@ -47,19 +45,6 @@ func validateClusterScopeParams(params ClusterScopeParams) error { return nil } -func createLinodeClient(apiKey string) *linodego.Client { - tokenSource := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: apiKey}) - - oauth2Client := &http.Client{ - Transport: &oauth2.Transport{ - Source: tokenSource, - }, - } - linodeClient := linodego.NewClient(oauth2Client) - - return &linodeClient -} - // NewClusterScope creates a new Scope from the supplied parameters. // This is meant to be called for each reconcile iteration. func NewClusterScope(apiKey string, params ClusterScopeParams) (*ClusterScope, error) { diff --git a/cloud/scope/common.go b/cloud/scope/common.go new file mode 100644 index 000000000..518edc5a8 --- /dev/null +++ b/cloud/scope/common.go @@ -0,0 +1,21 @@ +package scope + +import ( + "net/http" + + "github.com/linode/linodego" + "golang.org/x/oauth2" +) + +func createLinodeClient(apiKey string) *linodego.Client { + tokenSource := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: apiKey}) + + oauth2Client := &http.Client{ + Transport: &oauth2.Transport{ + Source: tokenSource, + }, + } + linodeClient := linodego.NewClient(oauth2Client) + + return &linodeClient +} diff --git a/cloud/scope/vpc.go b/cloud/scope/vpc.go new file mode 100644 index 000000000..6a06de0df --- /dev/null +++ b/cloud/scope/vpc.go @@ -0,0 +1,72 @@ +/* +Copyright 2023 Akamai Technologies, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package scope + +import ( + "errors" + "fmt" + + infrav1 "github.com/linode/cluster-api-provider-linode/api/v1alpha1" + "github.com/linode/linodego" + "sigs.k8s.io/cluster-api/util/patch" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// VPCScopeParams defines the input parameters used to create a new Scope. +type VPCScopeParams struct { + Client client.Client + LinodeVPC *infrav1.LinodeVPC +} + +func validateVPCScopeParams(params VPCScopeParams) error { + if params.LinodeVPC == nil { + return errors.New("linodeVPC is required when creating a VPCScope") + } + + return nil +} + +// NewVPCScope creates a new Scope from the supplied parameters. +// This is meant to be called for each reconcile iteration. +func NewVPCScope(apiKey string, params VPCScopeParams) (*VPCScope, error) { + if err := validateVPCScopeParams(params); err != nil { + return nil, err + } + + linodeClient := createLinodeClient(apiKey) + + helper, err := patch.NewHelper(params.LinodeVPC, params.Client) + if err != nil { + return nil, fmt.Errorf("failed to init patch helper: %w", err) + } + + return &VPCScope{ + client: params.Client, + LinodeClient: linodeClient, + LinodeVPC: params.LinodeVPC, + PatchHelper: helper, + }, nil +} + +// VPCScope defines the basic context for an actuator to operate upon. +type VPCScope struct { + client client.Client + + PatchHelper *patch.Helper + LinodeClient *linodego.Client + LinodeVPC *infrav1.LinodeVPC +} diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_linodeclusters.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_linodeclusters.yaml index 706c0ceb3..a5fd9a85f 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_linodeclusters.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_linodeclusters.yaml @@ -75,6 +75,69 @@ spec: region: description: The Linode Region the LinodeCluster lives in. type: string + vpcRef: + description: "ObjectReference contains enough information to let you + inspect or modify the referred object. --- New uses of this type + are discouraged because of difficulty describing its usage when + embedded in APIs. 1. Ignored fields. It includes many fields which + are not generally honored. For instance, ResourceVersion and FieldPath + are both very rarely valid in actual usage. 2. Invalid usage help. + \ It is impossible to add specific help for individual usage. In + most embedded usages, there are particular restrictions like, \"must + refer only to types A and B\" or \"UID not honored\" or \"name must + be restricted\". Those cannot be well described when embedded. 3. + Inconsistent validation. Because the usages are different, the + validation rules are different by usage, which makes it hard for + users to predict what will happen. 4. The fields are both imprecise + and overly precise. Kind is not a precise mapping to a URL. This + can produce ambiguity during interpretation and require a REST mapping. + \ In most cases, the dependency is on the group,resource tuple and + the version of the actual struct is irrelevant. 5. We cannot easily + change it. Because this type is embedded in many locations, updates + to this type will affect numerous schemas. Don't make new APIs + embed an underspecified API type they do not control. \n Instead + of using this type, create a locally provided and used type that + is well-focused on your reference. For example, ServiceReferences + for admission registration: https://github.com/kubernetes/api/blob/release-1.17/admissionregistration/v1/types.go#L533 + ." + properties: + apiVersion: + description: API version of the referent. + type: string + fieldPath: + description: 'If referring to a piece of an object instead of + an entire object, this string should contain a valid JSON/Go + field access statement, such as desiredState.manifest.containers[2]. + For example, if the object reference is to a container within + a pod, this would take on a value like: "spec.containers{name}" + (where "name" refers to the name of the container that triggered + the event) or if no container name is specified "spec.containers[2]" + (container with index 2 in this pod). This syntax is chosen + only to have some well-defined way of referencing a part of + an object. TODO: this design is not final and this field is + subject to change in the future.' + type: string + kind: + description: 'Kind of the referent. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' + type: string + namespace: + description: 'Namespace of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/' + type: string + resourceVersion: + description: 'Specific resourceVersion to which this reference + is made, if any. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency' + type: string + uid: + description: 'UID of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids' + type: string + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf required: - region type: object 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 dfe857d2d..d0681ee1b 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_linodemachines.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_linodemachines.yaml @@ -85,32 +85,63 @@ spec: items: type: string type: array + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf ipamAddress: type: string + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf ipv4: description: VPCIPv4 defines VPC IPV4 settings properties: nat1to1: type: string + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf vpc: type: string + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf type: object + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf label: + maxLength: 63 + minLength: 3 type: string + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf primary: type: boolean + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf purpose: description: ConfigInterfacePurpose options start with InterfacePurpose and include all known interface purpose types type: string + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf subnetId: type: integer + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf type: object type: array x-kubernetes-validations: - message: Value is immutable rule: self == oldSelf label: + maxLength: 63 + minLength: 3 type: string x-kubernetes-validations: - message: Value is immutable @@ -121,6 +152,9 @@ spec: userData: description: UserData expects a Base64-encoded string type: string + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf type: object x-kubernetes-validations: - message: Value is immutable @@ -273,6 +307,7 @@ spec: this machine. type: string ready: + default: false description: Ready is true when the provider resource is ready. type: boolean type: object diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_linodevpcs.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_linodevpcs.yaml new file mode 100644 index 000000000..a9f8b94bb --- /dev/null +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_linodevpcs.yaml @@ -0,0 +1,164 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.13.0 + name: linodevpcs.infrastructure.cluster.x-k8s.io +spec: + group: infrastructure.cluster.x-k8s.io + names: + kind: LinodeVPC + listKind: LinodeVPCList + plural: linodevpcs + singular: linodevpc + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: LinodeVPC is the Schema for the linodemachines API + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + description: LinodeVPCSpec defines the desired state of LinodeVPC + properties: + description: + type: string + label: + maxLength: 63 + minLength: 3 + type: string + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf + region: + type: string + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf + subnets: + items: + description: VPCSubnetCreateOptions defines subnet options + properties: + ipv4: + type: string + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf + label: + maxLength: 63 + minLength: 3 + type: string + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf + type: object + type: array + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf + vpcID: + type: integer + required: + - region + type: object + status: + description: LinodeVPCStatus defines the observed state of LinodeVPC + properties: + conditions: + description: Conditions defines current service state of the LinodeVPC. + items: + description: Condition defines an observation of a Cluster API resource + operational state. + properties: + lastTransitionTime: + description: Last time the condition transitioned from one status + to another. This should be when the underlying condition changed. + If that is not known, then using the time when the API field + changed is acceptable. + format: date-time + type: string + message: + description: A human readable message indicating details about + the transition. This field may be empty. + type: string + reason: + description: The reason for the condition's last transition + in CamelCase. The specific API may choose whether or not this + field is considered a guaranteed API. This field may not be + empty. + type: string + severity: + description: Severity provides an explicit classification of + Reason code, so the users or machines can immediately understand + the current situation and act accordingly. The Severity field + MUST be set only when Status=False. + type: string + status: + description: Status of the condition, one of True, False, Unknown. + type: string + type: + description: Type of condition in CamelCase or in foo.example.com/CamelCase. + Many .condition.type values are consistent across resources + like Available, but because arbitrary conditions can be useful + (see .node.status.conditions), the ability to deconflict is + important. + type: string + required: + - lastTransitionTime + - status + - type + type: object + type: array + failureMessage: + description: "FailureMessage will be set in the event that there is + a terminal problem reconciling the VPC and will contain a more verbose + string suitable for logging and human consumption. \n This field + should not be set for transitive errors that a controller faces + that are expected to be fixed automatically over time (like service + outages), but instead indicate that something is fundamentally wrong + with the VPC's spec or the configuration of the controller, and + that manual intervention is required. Examples of terminal errors + would be invalid combinations of settings in the spec, values that + are unsupported by the controller, or the responsible controller + itself being critically misconfigured. \n Any transient errors that + occur during the reconciliation of VPCs can be added as events to + the VPC object and/or logged in the controller's output." + type: string + failureReason: + description: "FailureReason will be set in the event that there is + a terminal problem reconciling the VPC and will contain a succinct + value suitable for machine interpretation. \n This field should + not be set for transitive errors that a controller faces that are + expected to be fixed automatically over time (like service outages), + but instead indicate that something is fundamentally wrong with + the VPC's spec or the configuration of the controller, and that + manual intervention is required. Examples of terminal errors would + be invalid combinations of settings in the spec, values that are + unsupported by the controller, or the responsible controller itself + being critically misconfigured. \n Any transient errors that occur + during the reconciliation of VPCs can be added as events to the + VPC object and/or logged in the controller's output." + type: string + ready: + default: false + description: Ready is true when the provider resource is ready. + type: boolean + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 5f7cf5498..c2b1f49e7 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -80,3 +80,29 @@ rules: - get - patch - update +- apiGroups: + - infrastructure.cluster.x-k8s.io + resources: + - linodevpcs + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - infrastructure.cluster.x-k8s.io + resources: + - linodevpcs/finalizers + verbs: + - update +- apiGroups: + - infrastructure.cluster.x-k8s.io + resources: + - linodevpcs/status + verbs: + - get + - patch + - update diff --git a/controller/linodemachine_controller.go b/controller/linodemachine_controller.go index 5f3f3e135..f2e51fa65 100644 --- a/controller/linodemachine_controller.go +++ b/controller/linodemachine_controller.go @@ -18,11 +18,9 @@ package controller import ( "context" - "encoding/json" "errors" "fmt" "net/http" - "strings" "time" "github.com/go-logr/logr" @@ -103,7 +101,7 @@ func (r *LinodeMachineReconciler) Reconcile(ctx context.Context, req ctrl.Reques linodeMachine := &infrav1.LinodeMachine{} if err := r.Client.Get(ctx, req.NamespacedName, linodeMachine); err != nil { - log.Info("Failed to fetch Linode machine", "error", err.Error()) + log.Error(err, "Failed to fetch Linode machine") return ctrl.Result{}, client.IgnoreNotFound(err) } @@ -111,7 +109,7 @@ func (r *LinodeMachineReconciler) Reconcile(ctx context.Context, req ctrl.Reques machine, err := kutil.GetOwnerMachine(ctx, r.Client, linodeMachine.ObjectMeta) switch { case err != nil: - log.Info("Failed to fetch owner machine", "error", err.Error()) + log.Error(err, "Failed to fetch owner machine") return ctrl.Result{}, client.IgnoreNotFound(err) case machine == nil: @@ -141,7 +139,7 @@ func (r *LinodeMachineReconciler) Reconcile(ctx context.Context, req ctrl.Reques cluster, err := kutil.GetClusterFromMetadata(ctx, r.Client, machine.ObjectMeta) if err != nil { - log.Info("Failed to fetch cluster by label", "error", err.Error()) + log.Info("Failed to fetch cluster by label") return ctrl.Result{}, client.IgnoreNotFound(err) } else if cluster == nil { @@ -157,7 +155,7 @@ func (r *LinodeMachineReconciler) Reconcile(ctx context.Context, req ctrl.Reques } if err := r.Client.Get(ctx, linodeClusterKey, linodeCluster); err != nil { - log.Info("Failed to fetch Linode cluster", "error", err.Error()) + log.Error(err, "Failed to fetch Linode cluster") return ctrl.Result{}, client.IgnoreNotFound(err) } @@ -173,7 +171,7 @@ func (r *LinodeMachineReconciler) Reconcile(ctx context.Context, req ctrl.Reques }, ) if err != nil { - log.Info("Failed to create machine scope", "error", err.Error()) + log.Error(err, "Failed to create machine scope") return ctrl.Result{}, fmt.Errorf("failed to create machine scope: %w", err) } @@ -193,12 +191,13 @@ func (r *LinodeMachineReconciler) reconcile( machineScope.LinodeMachine.Status.FailureMessage = util.Pointer("") failureReason := cerrs.MachineStatusError("UnknownError") + //nolint:dupl // Code duplication is simplicity in this case. defer func() { if err != nil { machineScope.LinodeMachine.Status.FailureReason = util.Pointer(failureReason) machineScope.LinodeMachine.Status.FailureMessage = util.Pointer(err.Error()) - conditions.MarkFalse(machineScope.LinodeMachine, clusterv1.ReadyCondition, string(failureReason), clusterv1.ConditionSeverityError, "%s", err.Error()) + conditions.MarkFalse(machineScope.LinodeMachine, clusterv1.ReadyCondition, string(failureReason), clusterv1.ConditionSeverityError, err.Error()) r.Recorder.Event(machineScope.LinodeMachine, corev1.EventTypeWarning, string(failureReason), err.Error()) } @@ -248,21 +247,12 @@ func (r *LinodeMachineReconciler) reconcile( return } -func (*LinodeMachineReconciler) reconcileCreate(ctx context.Context, machineScope *scope.MachineScope, logger logr.Logger) (*linodego.Instance, error) { +func (r *LinodeMachineReconciler) reconcileCreate(ctx context.Context, machineScope *scope.MachineScope, logger logr.Logger) (*linodego.Instance, error) { tags := []string{string(machineScope.LinodeCluster.UID), string(machineScope.LinodeMachine.UID)} - filter := map[string]string{ - "tags": strings.Join(tags, ","), - } - rawFilter, err := json.Marshal(filter) + linodeInstances, err := machineScope.LinodeClient.ListInstances(ctx, linodego.NewListOptions(1, util.CreateLinodeAPIFilter("", tags))) if err != nil { - // This should never happen - panic(err.Error() + " Oh, snap... Earth has over, we can't parse map[string]string to JSON! I'm going to die ...") - } - - var linodeInstances []linodego.Instance - if linodeInstances, err = machineScope.LinodeClient.ListInstances(ctx, linodego.NewListOptions(1, string(rawFilter))); err != nil { - logger.Info("Failed to list Linode machine instances", "error", err.Error()) + logger.Error(err, "Failed to list Linode machine instances") return nil, err } @@ -274,33 +264,41 @@ func (*LinodeMachineReconciler) reconcileCreate(ctx context.Context, machineScop linodeInstance = &linodeInstances[0] case 0: - createConfig := linodeMachineSpecToCreateInstanceConfig(machineScope.LinodeMachine.Spec) + createConfig := linodeMachineSpecToInstanceCreateConfig(machineScope.LinodeMachine.Spec) if createConfig == nil { - logger.Error(errors.New("failed to convert machine spec to create isntance config"), "Panic! Struct of LinodeMachineSpec is different then InstanceCreateOptions") + err = errors.New("failed to convert machine spec to create isntance config") + + logger.Error(err, "Panic! Struct of LinodeMachineSpec is different than InstanceCreateOptions") return nil, err } createConfig.Tags = tags - if linodeInstance, err = machineScope.LinodeClient.CreateInstance(ctx, *createConfig); err != nil { - logger.Info("Failed to create Linode machine instance", "error", err.Error()) + if createConfig.Label == "" { + createConfig.Label = util.RenderObjectLabel(machineScope.LinodeMachine.UID) + } + + if machineScope.LinodeCluster.Spec.VPCRef != nil { + iface, err := r.getVPCInterfaceConfig(ctx, machineScope, createConfig.Interfaces, logger) + if err != nil { + logger.Error(err, "Failed to get VPC interface confiog") - // Already exists is not an error - apiErr := linodego.Error{} - if errors.As(err, &apiErr) && apiErr.Code != http.StatusFound { return nil, err } - err = nil + createConfig.Interfaces = append(createConfig.Interfaces, *iface) + } - if linodeInstance != nil { - logger.Info("Linode instance already exists", "existing", linodeInstance.ID) - } + linodeInstance, err = machineScope.LinodeClient.CreateInstance(ctx, *createConfig) + if err != nil { + logger.Error(err, "Failed to create Linode machine instance") + + return nil, err } default: err = errors.New("multiple instances") - logger.Error(err, "Panic! Multiple instances found. This might be a concurrency issue in the controller!!!", "filters", string(rawFilter)) + logger.Error(err, "Panic! Multiple instances found. This might be a concurrency issue in the controller!!!", "tags", tags) return nil, err } @@ -338,14 +336,18 @@ func (r *LinodeMachineReconciler) reconcileUpdate(ctx context.Context, logger lo res = ctrl.Result{} if linodeInstance, err = machineScope.LinodeClient.GetInstance(ctx, *machineScope.LinodeMachine.Spec.InstanceID); err != nil { - logger.Info("Failed to get Linode machine instance", "error", err.Error()) + logger.Error(err, "Failed to get Linode machine instance") // Not found is not an error - apiErr := linodego.Error{} - if errors.As(err, &apiErr) && apiErr.Code == http.StatusNotFound { - conditions.MarkFalse(machineScope.LinodeMachine, clusterv1.ReadyCondition, string("missing"), clusterv1.ConditionSeverityWarning, "instance not found") - + apiErr := linodego.Error{Code: http.StatusNotFound} + if apiErr.Is(err) { err = nil + + // Create new machine + machineScope.LinodeMachine.Spec.ProviderID = nil + machineScope.LinodeMachine.Spec.InstanceID = nil + + conditions.MarkFalse(machineScope.LinodeMachine, clusterv1.ReadyCondition, string("missing"), clusterv1.ConditionSeverityWarning, "instance not found") } return @@ -376,16 +378,16 @@ func (r *LinodeMachineReconciler) reconcileUpdate(ctx context.Context, logger lo return } -func (*LinodeMachineReconciler) reconcileDelete(ctx context.Context, logger logr.Logger, machineScope *scope.MachineScope) error { +func (r *LinodeMachineReconciler) reconcileDelete(ctx context.Context, logger logr.Logger, machineScope *scope.MachineScope) error { logger.Info("deleting machine") if machineScope.LinodeMachine.Spec.InstanceID != nil { if err := machineScope.LinodeClient.DeleteInstance(ctx, *machineScope.LinodeMachine.Spec.InstanceID); err != nil { - logger.Info("Failed to delete Linode machine instance", "error", err.Error()) + logger.Info("Failed to delete Linode machine instance") // Not found is not an error - apiErr := linodego.Error{} - if errors.As(err, &apiErr) && apiErr.Code != http.StatusNotFound { + apiErr := linodego.Error{Code: http.StatusNotFound} + if !apiErr.Is(err) { return err } } @@ -395,6 +397,8 @@ func (*LinodeMachineReconciler) reconcileDelete(ctx context.Context, logger logr conditions.MarkFalse(machineScope.LinodeMachine, clusterv1.ReadyCondition, clusterv1.DeletedReason, clusterv1.ConditionSeverityInfo, "instance deleted") + r.Recorder.Event(machineScope.LinodeMachine, corev1.EventTypeNormal, clusterv1.DeletedReason, "instance has cleaned up") + machineScope.LinodeMachine.Spec.ProviderID = nil machineScope.LinodeMachine.Spec.InstanceID = nil controllerutil.RemoveFinalizer(machineScope.LinodeMachine, infrav1.GroupVersion.String()) diff --git a/controller/linodemachine_controller_helpers.go b/controller/linodemachine_controller_helpers.go index dcbc46f00..621415c70 100644 --- a/controller/linodemachine_controller_helpers.go +++ b/controller/linodemachine_controller_helpers.go @@ -20,12 +20,16 @@ import ( "bytes" "context" "encoding/gob" + "errors" + "sort" "github.com/go-logr/logr" infrav1 "github.com/linode/cluster-api-provider-linode/api/v1alpha1" + "github.com/linode/cluster-api-provider-linode/cloud/scope" "github.com/linode/cluster-api-provider-linode/util/reconciler" "github.com/linode/linodego" apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" kutil "sigs.k8s.io/cluster-api/util" ctrl "sigs.k8s.io/controller-runtime" @@ -60,14 +64,14 @@ func (r *LinodeMachineReconciler) linodeClusterToLinodeMachines(logger logr.Logg return nil case err != nil: - logger.Info("Failed to get owning cluster, skipping mapping", "error", err.Error()) + logger.Error(err, "Failed to get owning cluster, skipping mapping") return nil } request, err := r.requestsForCluster(ctx, cluster.Namespace, cluster.Name) if err != nil { - logger.Info("Failed to create request for cluster", "error", err.Error()) + logger.Error(err, "Failed to create request for cluster") return nil } @@ -96,14 +100,14 @@ func (r *LinodeMachineReconciler) requeueLinodeMachinesForUnpausedCluster(logger return nil } - request, err := r.requestsForCluster(ctx, cluster.Namespace, cluster.Name) + requests, err := r.requestsForCluster(ctx, cluster.Namespace, cluster.Name) if err != nil { - logger.Info("Failed to create request for cluster", "error", err.Error()) + logger.Error(err, "Failed to create request for cluster") return nil } - return request + return requests } } @@ -132,7 +136,76 @@ func (r *LinodeMachineReconciler) requestsForCluster(ctx context.Context, namesp return result, nil } -func linodeMachineSpecToCreateInstanceConfig(machineSpec infrav1.LinodeMachineSpec) *linodego.InstanceCreateOptions { +func (r *LinodeMachineReconciler) getVPCInterfaceConfig(ctx context.Context, machineScope *scope.MachineScope, existingIfaces []linodego.InstanceConfigInterfaceCreateOptions, logger logr.Logger) (*linodego.InstanceConfigInterfaceCreateOptions, error) { + name := machineScope.LinodeCluster.Spec.VPCRef.Name + namespace := machineScope.LinodeCluster.Spec.VPCRef.Namespace + + logger = logger.WithValues("vpcName", name, "vpcNamespace", namespace) + + linodeVPC := infrav1.LinodeVPC{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: name, + }, + } + if err := r.Get(ctx, client.ObjectKeyFromObject(&linodeVPC), &linodeVPC); err != nil { + logger.Error(err, "Failed to fetch LinodeVPC") + + return nil, err + } else if !linodeVPC.Status.Ready || linodeVPC.Spec.VPCID == nil { + logger.Info("LinodeVPC is not available") + + return nil, errors.New("vpc is not available") + } + + hasPrimary := false + for i := range existingIfaces { + if existingIfaces[i].Primary { + hasPrimary = true + + break + } + } + + var subnetID int + vpc, err := machineScope.LinodeClient.GetVPC(ctx, *linodeVPC.Spec.VPCID) + switch { + case err != nil: + logger.Error(err, "Failed to fetch LinodeVPC") + + return nil, err + case vpc == nil: + err = errors.New("failed to fetch VPC") + + logger.Error(err, "Failed to fetch VPC") + + return nil, err + case len(vpc.Subnets) == 0: + err = errors.New("failed to find subnet") + + logger.Error(err, "Failed to find subnet") + + return nil, err + default: + // Place node into the less busiest subnet + sortedSubnets := make([]linodego.VPCSubnet, len(vpc.Subnets)) + copy(sortedSubnets, vpc.Subnets) + + sort.Slice(sortedSubnets, func(i, j int) bool { + return len(sortedSubnets[i].Linodes) > len(sortedSubnets[j].Linodes) + }) + + subnetID = sortedSubnets[0].ID + } + + return &linodego.InstanceConfigInterfaceCreateOptions{ + Purpose: linodego.InterfacePurposeVPC, + Primary: !hasPrimary, + SubnetID: &subnetID, + }, nil +} + +func linodeMachineSpecToInstanceCreateConfig(machineSpec infrav1.LinodeMachineSpec) *linodego.InstanceCreateOptions { var buf bytes.Buffer enc := gob.NewEncoder(&buf) err := enc.Encode(machineSpec) diff --git a/controller/linodemachine_controller_test.go b/controller/linodemachine_controller_helpers_test.go similarity index 94% rename from controller/linodemachine_controller_test.go rename to controller/linodemachine_controller_helpers_test.go index 61e1f907c..3a00d3a6b 100644 --- a/controller/linodemachine_controller_test.go +++ b/controller/linodemachine_controller_helpers_test.go @@ -14,7 +14,7 @@ import ( func TestLinodeMachineSpecToCreateInstanceConfig(t *testing.T) { t.Parallel() - sID := 1 + subnetID := 1 machineSpec := infrav1.LinodeMachineSpec{ Region: "region", @@ -34,7 +34,7 @@ func TestLinodeMachineSpecToCreateInstanceConfig(t *testing.T) { Label: "label", Purpose: linodego.InterfacePurposePublic, Primary: true, - SubnetID: &sID, + SubnetID: &subnetID, IPv4: &infrav1.VPCIPv4{ VPC: "vpc", NAT1To1: "nat11", @@ -51,7 +51,7 @@ func TestLinodeMachineSpecToCreateInstanceConfig(t *testing.T) { FirewallID: 1, } - createConfig := linodeMachineSpecToCreateInstanceConfig(machineSpec) + createConfig := linodeMachineSpecToInstanceCreateConfig(machineSpec) assert.NotNil(t, createConfig, "Failed to convert LinodeMachineSpec to InstanceCreateOptions") var buf bytes.Buffer diff --git a/controller/linodevpc_controller.go b/controller/linodevpc_controller.go new file mode 100644 index 000000000..f6e6b07ad --- /dev/null +++ b/controller/linodevpc_controller.go @@ -0,0 +1,226 @@ +/* +Copyright 2023 Akamai Technologies, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + "sigs.k8s.io/cluster-api/util/conditions" + "sigs.k8s.io/cluster-api/util/predicates" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + "github.com/go-logr/logr" + infrav1 "github.com/linode/cluster-api-provider-linode/api/v1alpha1" + "github.com/linode/cluster-api-provider-linode/cloud/scope" + "github.com/linode/cluster-api-provider-linode/util" + "github.com/linode/cluster-api-provider-linode/util/reconciler" + "github.com/linode/linodego" +) + +// LinodeVPCReconciler reconciles a LinodeVPC object +type LinodeVPCReconciler struct { + client.Client + Recorder record.EventRecorder + LinodeApiKey string + WatchFilterValue string + Scheme *runtime.Scheme + ReconcileTimeout time.Duration +} + +//+kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=linodevpcs,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=linodevpcs/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=linodevpcs/finalizers,verbs=update + +//+kubebuilder:rbac:groups="",resources=events,verbs=create;update;patch + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the VPC closer to the desired state. +// TODO(user): Modify the Reconcile function to compare the state specified by +// the LinodeVPC object against the actual VPC state, and then +// perform operations to make the VPC state reflect the state specified by +// the user. +// +// For more details, check Reconcile and its Result here: +// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.16.0/pkg/reconcile +func (r *LinodeVPCReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + ctx, cancel := context.WithTimeout(ctx, reconciler.DefaultedLoopTimeout(r.ReconcileTimeout)) + defer cancel() + + log := ctrl.LoggerFrom(ctx).WithName("LinodeVPCReconciler").WithValues("name", req.NamespacedName.String()) + + linodeVPC := &infrav1.LinodeVPC{} + if err := r.Client.Get(ctx, req.NamespacedName, linodeVPC); err != nil { + log.Error(err, "Failed to fetch LinodeVPC") + + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + vpcScope, err := scope.NewVPCScope( + r.LinodeApiKey, + scope.VPCScopeParams{ + Client: r.Client, + LinodeVPC: linodeVPC, + }, + ) + if err != nil { + log.Error(err, "Failed to create VPC scope") + + return ctrl.Result{}, fmt.Errorf("failed to create VPC scope: %w", err) + } + + return r.reconcile(ctx, vpcScope, log) +} + +func (r *LinodeVPCReconciler) reconcile( + ctx context.Context, + vpcScope *scope.VPCScope, + logger logr.Logger, +) (res ctrl.Result, err error) { + res = ctrl.Result{} + + vpcScope.LinodeVPC.Status.Ready = false + vpcScope.LinodeVPC.Status.FailureReason = nil + vpcScope.LinodeVPC.Status.FailureMessage = util.Pointer("") + + failureReason := infrav1.VPCStatusError("UnknownError") + //nolint:dupl // Code duplication is simplicity in this case. + defer func() { + if err != nil { + vpcScope.LinodeVPC.Status.FailureReason = util.Pointer(failureReason) + vpcScope.LinodeVPC.Status.FailureMessage = util.Pointer(err.Error()) + + conditions.MarkFalse(vpcScope.LinodeVPC, clusterv1.ReadyCondition, string(failureReason), clusterv1.ConditionSeverityError, err.Error()) + + r.Recorder.Event(vpcScope.LinodeVPC, corev1.EventTypeWarning, string(failureReason), err.Error()) + } + + if patchErr := vpcScope.PatchHelper.Patch(ctx, vpcScope.LinodeVPC); patchErr != nil && client.IgnoreNotFound(patchErr) != nil { + logger.Error(patchErr, "failed to patch LinodeVPC") + + err = errors.Join(err, patchErr) + } + }() + + // Delete + if !vpcScope.LinodeVPC.ObjectMeta.DeletionTimestamp.IsZero() { + failureReason = infrav1.DeleteVPCError + + err = r.reconcileDelete(ctx, logger, vpcScope) + + return + } + + controllerutil.AddFinalizer(vpcScope.LinodeVPC, infrav1.GroupVersion.String()) + + // Update + if vpcScope.LinodeVPC.Spec.VPCID != nil { + failureReason = infrav1.UpdateVPCError + + logger = logger.WithValues("vpcID", *vpcScope.LinodeVPC.Spec.VPCID) + + err = r.reconcileUpdate(ctx, logger, vpcScope) + + return + } + + // Create + failureReason = infrav1.CreateVPCError + + err = r.reconcileCreate(ctx, vpcScope, logger) + + return +} + +func (r *LinodeVPCReconciler) reconcileCreate(ctx context.Context, vpcScope *scope.VPCScope, logger logr.Logger) error { + if err := r.reconcileVPC(ctx, vpcScope, logger); err != nil { + logger.Error(err, "Failed to create VPC") + + conditions.MarkFalse(vpcScope.LinodeVPC, clusterv1.ReadyCondition, string(infrav1.CreateVPCError), clusterv1.ConditionSeverityError, err.Error()) + + r.Recorder.Event(vpcScope.LinodeVPC, corev1.EventTypeWarning, string(infrav1.CreateVPCError), err.Error()) + + return err + } + vpcScope.LinodeVPC.Status.Ready = true + + return nil +} + +func (r *LinodeVPCReconciler) reconcileUpdate(ctx context.Context, logger logr.Logger, vpcScope *scope.VPCScope) error { + if err := r.reconcileVPC(ctx, vpcScope, logger); err != nil { + logger.Error(err, "Failed to update VPC") + + conditions.MarkFalse(vpcScope.LinodeVPC, clusterv1.ReadyCondition, string(infrav1.UpdateVPCError), clusterv1.ConditionSeverityError, err.Error()) + + r.Recorder.Event(vpcScope.LinodeVPC, corev1.EventTypeWarning, string(infrav1.UpdateVPCError), err.Error()) + + return err + } + vpcScope.LinodeVPC.Status.Ready = true + + return nil +} + +func (r *LinodeVPCReconciler) reconcileDelete(ctx context.Context, logger logr.Logger, vpcScope *scope.VPCScope) error { + logger.Info("deleting VPC") + + if vpcScope.LinodeVPC.Spec.VPCID != nil { + if err := vpcScope.LinodeClient.DeleteVPC(ctx, *vpcScope.LinodeVPC.Spec.VPCID); err != nil { + logger.Error(err, "Failed to delete VPC") + + // Not found is not an error + apiErr := linodego.Error{Code: http.StatusNotFound} + if !apiErr.Is(err) { + return err + } + } + } else { + logger.Info("VPC ID is missing, nothing to do") + } + + conditions.MarkFalse(vpcScope.LinodeVPC, clusterv1.ReadyCondition, clusterv1.DeletedReason, clusterv1.ConditionSeverityInfo, "VPC deleted") + + r.Recorder.Event(vpcScope.LinodeVPC, corev1.EventTypeNormal, clusterv1.DeletedReason, "VPC has cleaned up") + + vpcScope.LinodeVPC.Spec.VPCID = nil + controllerutil.RemoveFinalizer(vpcScope.LinodeVPC, infrav1.GroupVersion.String()) + + return nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *LinodeVPCReconciler) SetupWithManager(mgr ctrl.Manager) error { + _, err := ctrl.NewControllerManagedBy(mgr). + For(&infrav1.LinodeVPC{}). + WithEventFilter(predicates.ResourceNotPausedAndHasFilterLabel(mgr.GetLogger(), r.WatchFilterValue)). + Build(r) + if err != nil { + return fmt.Errorf("failed to build controller: %w", err) + } + + return nil +} diff --git a/controller/linodevpc_controller_helpers.go b/controller/linodevpc_controller_helpers.go new file mode 100644 index 000000000..67b2185a1 --- /dev/null +++ b/controller/linodevpc_controller_helpers.go @@ -0,0 +1,91 @@ +/* +Copyright 2023 Akamai Technologies, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "bytes" + "context" + "encoding/gob" + "errors" + + "github.com/go-logr/logr" + infrav1 "github.com/linode/cluster-api-provider-linode/api/v1alpha1" + "github.com/linode/cluster-api-provider-linode/cloud/scope" + "github.com/linode/cluster-api-provider-linode/util" + "github.com/linode/linodego" +) + +func (r *LinodeVPCReconciler) reconcileVPC(ctx context.Context, vpcScope *scope.VPCScope, logger logr.Logger) error { + createConfig := linodeVPCSpecToVPCCreateConfig(vpcScope.LinodeVPC.Spec) + if createConfig == nil { + err := errors.New("failed to convert VPC spec to create VPC config") + + logger.Error(err, "Panic! Struct of LinodeVPCSpec is different than VPCCreateOptions") + + return err + } + + if createConfig.Label == "" { + createConfig.Label = util.RenderObjectLabel(vpcScope.LinodeVPC.UID) + } + + if vpcs, err := vpcScope.LinodeClient.ListVPCs(ctx, linodego.NewListOptions(1, util.CreateLinodeAPIFilter(createConfig.Label, nil))); err != nil { + logger.Error(err, "Failed to list VPCs") + + return err + } else if len(vpcs) != 0 { + // Labels are unique + vpcScope.LinodeVPC.Spec.VPCID = &vpcs[0].ID + + return nil + } + + linodeVPC, err := vpcScope.LinodeClient.CreateVPC(ctx, *createConfig) + if err != nil { + logger.Error(err, "Failed to create VPC") + + return err + } else if linodeVPC == nil { + err = errors.New("missing VPC") + + logger.Error(err, "Panic! Failed to create VPC") + + return err + } + + vpcScope.LinodeVPC.Spec.VPCID = &linodeVPC.ID + + return nil +} + +func linodeVPCSpecToVPCCreateConfig(vpcSpec infrav1.LinodeVPCSpec) *linodego.VPCCreateOptions { + var buf bytes.Buffer + enc := gob.NewEncoder(&buf) + err := enc.Encode(vpcSpec) + if err != nil { + return nil + } + + var createConfig linodego.VPCCreateOptions + dec := gob.NewDecoder(&buf) + err = dec.Decode(&createConfig) + if err != nil { + return nil + } + + return &createConfig +} diff --git a/controller/linodevpc_controller_helpers_test.go b/controller/linodevpc_controller_helpers_test.go new file mode 100644 index 000000000..1408200ac --- /dev/null +++ b/controller/linodevpc_controller_helpers_test.go @@ -0,0 +1,42 @@ +package controller + +import ( + "bytes" + "encoding/gob" + "testing" + + infrav1 "github.com/linode/cluster-api-provider-linode/api/v1alpha1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLinodeVPCSpecToCreateVPCConfig(t *testing.T) { + t.Parallel() + + vpcSpec := infrav1.LinodeVPCSpec{ + Label: "label", + Description: "description", + Region: "region", + Subnets: []infrav1.VPCSubnetCreateOptions{ + { + Label: "subnet", + IPv4: "ipv4", + }, + }, + } + + createConfig := linodeVPCSpecToVPCCreateConfig(vpcSpec) + assert.NotNil(t, createConfig, "Failed to convert LinodeVPCSpec to VPCCreateOptions") + + var buf bytes.Buffer + enc := gob.NewEncoder(&buf) + err := enc.Encode(createConfig) + require.NoError(t, err, "Failed to encode VPCCreateOptions") + + var actualVPCSpec infrav1.LinodeVPCSpec + dec := gob.NewDecoder(&buf) + err = dec.Decode(&actualVPCSpec) + require.NoError(t, err, "Failed to decode LinodeVPCSpec") + + assert.Equal(t, vpcSpec, actualVPCSpec) +} diff --git a/go.mod b/go.mod index 253da33ae..0a8da1feb 100644 --- a/go.mod +++ b/go.mod @@ -31,7 +31,7 @@ require ( github.com/go-openapi/jsonpointer v0.19.6 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.22.3 // indirect - github.com/go-resty/resty/v2 v2.9.1 // indirect + github.com/go-resty/resty/v2 v2.11.0 // indirect github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect github.com/gobuffalo/flect v1.0.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect diff --git a/go.sum b/go.sum index 3178ba3d7..63deebaf2 100644 --- a/go.sum +++ b/go.sum @@ -53,8 +53,8 @@ github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2Kv github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g= github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= -github.com/go-resty/resty/v2 v2.9.1 h1:PIgGx4VrHvag0juCJ4dDv3MiFRlDmP0vicBucwf+gLM= -github.com/go-resty/resty/v2 v2.9.1/go.mod h1:4/GYJVjh9nhkhGR6AUNW3XhpDYNUr+Uvy9gV/VGZIy4= +github.com/go-resty/resty/v2 v2.11.0 h1:i7jMfNOJYMp69lq7qozJP+bjgzfAzeOhuGlyDrqxT/8= +github.com/go-resty/resty/v2 v2.11.0/go.mod h1:iiP/OpA0CkcL3IGt1O0+/SIItFUbkkyw5BGXiVdTu+A= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= github.com/gobuffalo/flect v1.0.2 h1:eqjPGSo2WmjgY2XlpGwo2NXgL3RucAKo4k4qQMNA5sA= @@ -181,7 +181,7 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= @@ -205,7 +205,7 @@ golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96b golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ= @@ -232,14 +232,14 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE= golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -251,7 +251,6 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/util/helpers.go b/util/helpers.go index 21067e436..352555ec6 100644 --- a/util/helpers.go +++ b/util/helpers.go @@ -1,6 +1,40 @@ package util +import ( + "encoding/json" + "fmt" + "strings" + + "k8s.io/apimachinery/pkg/types" +) + // Pointer returns the pointer of any type func Pointer[T any](t T) *T { return &t } + +// RenderObjectLabel renders a 63 charater long unique label +func RenderObjectLabel(i types.UID) string { + return fmt.Sprintf("CLi-%s", strings.ReplaceAll(string(i), "-", "")) +} + +// CreateLinodeAPIFilter converts variables to API filter string +func CreateLinodeAPIFilter(label string, tags []string) string { + filter := map[string]string{} + + if label != "" { + filter["label"] = label + } + + if len(tags) != 0 { + filter["tags"] = strings.Join(tags, ",") + } + + rawFilter, err := json.Marshal(filter) + if err != nil { + // This should never happen + panic(err.Error() + " Oh, snap... Earth has over, we can't parse map[string]string to JSON! I'm going to die ...") + } + + return string(rawFilter) +}