From 74848ed927215f12335fb69eb94faf36bb036297 Mon Sep 17 00:00:00 2001 From: Richard Kovacs Date: Mon, 18 Dec 2023 16:37:08 +0100 Subject: [PATCH] Create and delete machines via controller (#36) * Create and delete machines via controller * Update cloud/scope/cluster.go Co-authored-by: Alex Vest --------- Co-authored-by: Richard Kovacs Co-authored-by: Alex Vest --- Makefile | 5 +- README.md | 2 +- api/v1alpha1/linodemachine_types.go | 132 ++++++- api/v1alpha1/zz_generated.deepcopy.go | 138 +++++++- cloud/scope/cluster.go | 34 +- cloud/scope/machine.go | 25 +- cmd/main.go | 19 +- ...cture.cluster.x-k8s.io_linodemachines.yaml | 240 ++++++++++++- config/default/.gitignore | 1 + config/default/kustomization.yaml | 5 + config/manager/manager.yaml | 6 + config/rbac/role.yaml | 24 ++ ...infrastructure_v1alpha1_linodemachine.yaml | 3 +- controller/linodemachine_controller.go | 326 ++++++++++++++++-- .../linodemachine_controller_helpers.go | 151 ++++++++ controller/linodemachine_controller_test.go | 67 ++++ go.mod | 6 +- go.sum | 1 + util/helpers.go | 6 + util/reconciler/defaults.go | 5 + 20 files changed, 1114 insertions(+), 82 deletions(-) create mode 100644 config/default/.gitignore create mode 100644 controller/linodemachine_controller_helpers.go create mode 100644 controller/linodemachine_controller_test.go create mode 100644 util/helpers.go diff --git a/Makefile b/Makefile index b382480ae..a6a175c3e 100644 --- a/Makefile +++ b/Makefile @@ -127,8 +127,9 @@ undeploy: ## Undeploy controller from the K8s cluster specified in ~/.kube/confi .PHONY: tilt-cluster tilt-cluster: ctlptl tilt clusterctl - ctlptl apply -f ctlptl-config.yaml - tilt up + @echo -n "LINODE_TOKEN=$(LINODE_TOKEN)" > config/default/.env.linode + $(CTLPTL) apply -f ctlptl-config.yaml + $(TILT) up ##@ Build Dependencies diff --git a/README.md b/README.md index 50f69daef..dcac5f330 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A [Cluster API](https://cluster-api.sigs.k8s.io/) implementation for the [Linode For local development execute the following `make` target: ```bash -make tilt-cluster +LINODE_TOKEN= make tilt-cluster ``` This command creates a Kind cluster, and deploys resources via Tilt. You can freely change the code and wait for Tilt to update provider. diff --git a/api/v1alpha1/linodemachine_types.go b/api/v1alpha1/linodemachine_types.go index a5177aa00..ef07a3472 100644 --- a/api/v1alpha1/linodemachine_types.go +++ b/api/v1alpha1/linodemachine_types.go @@ -17,7 +17,10 @@ limitations under the License. package v1alpha1 import ( + "github.com/linode/linodego" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + "sigs.k8s.io/cluster-api/errors" ) // EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! @@ -25,17 +28,126 @@ import ( // LinodeMachineSpec defines the desired state of LinodeMachine type LinodeMachineSpec struct { - // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster - // Important: Run "make" to regenerate code after modifying this file + // ProviderID is the unique identifier as specified by the cloud provider. + ProviderID *string `json:"providerID,omitempty"` + // InstanceID is the Linode instance ID for this machine. + InstanceID *int `json:"instanceID,omitempty"` - // Foo is an example field of LinodeMachine. Edit linodemachine_types.go to remove/update - Foo string `json:"foo,omitempty"` + // +kubebuilder:validation:Required + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" + Region string `json:"region"` + // +kubebuilder:validation:Required + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" + Type string `json:"type"` + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" + Label string `json:"label,omitempty"` + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" + Group string `json:"group,omitempty"` + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" + RootPass string `json:"rootPass,omitempty"` + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" + AuthorizedKeys []string `json:"authorizedKeys,omitempty"` + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" + AuthorizedUsers []string `json:"authorizedUsers,omitempty"` + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" + StackScriptID int `json:"stackscriptId,omitempty"` + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" + StackScriptData map[string]string `json:"stackscriptData,omitempty"` + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" + BackupID int `json:"backupId,omitempty"` + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" + Image string `json:"image,omitempty"` + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" + Interfaces []InstanceConfigInterfaceCreateOptions `json:"interfaces,omitempty"` + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" + BackupsEnabled bool `json:"backupsEnabled,omitempty"` + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" + PrivateIP bool `json:"privateIp,omitempty"` + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" + Tags []string `json:"tags,omitempty"` + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" + Metadata *InstanceMetadataOptions `json:"metadata,omitempty"` + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" + FirewallID int `json:"firewallId,omitempty"` +} + +// InstanceMetadataOptions defines metadata of instance +type InstanceMetadataOptions struct { + // UserData expects a Base64-encoded string + 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"` + SubnetID *int `json:"subnetId,omitempty"` + IPv4 *VPCIPv4 `json:"ipv4,omitempty"` + IPRanges []string `json:"ipRanges,omitempty"` +} + +// VPCIPv4 defines VPC IPV4 settings +type VPCIPv4 struct { + VPC string `json:"vpc,omitempty"` + NAT1To1 string `json:"nat1to1,omitempty"` } // LinodeMachineStatus defines the observed state of LinodeMachine type LinodeMachineStatus struct { - // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster - // Important: Run "make" to regenerate code after modifying this file + // Ready is true when the provider resource is ready. + // +optional + Ready bool `json:"ready"` + + // Addresses contains the Linode instance associated addresses. + Addresses []clusterv1.MachineAddress `json:"addresses,omitempty"` + + // InstanceState is the state of the Linode instance for this machine. + // +optional + InstanceState *linodego.InstanceStatus `json:"instanceState,omitempty"` + + // FailureReason will be set in the event that there is a terminal problem + // reconciling the Machine 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 Machine'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 Machines + // can be added as events to the Machine object and/or logged in the + // controller's output. + // +optional + FailureReason *errors.MachineStatusError `json:"failureReason,omitempty"` + + // FailureMessage will be set in the event that there is a terminal problem + // reconciling the Machine 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 Machine'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 Machines + // can be added as events to the Machine object and/or logged in the + // controller's output. + // +optional + FailureMessage *string `json:"failureMessage,omitempty"` + + // Conditions defines current service state of the LinodeMachine. + // +optional + Conditions clusterv1.Conditions `json:"conditions,omitempty"` } //+kubebuilder:object:root=true @@ -50,6 +162,14 @@ type LinodeMachine struct { Status LinodeMachineStatus `json:"status,omitempty"` } +func (lm *LinodeMachine) GetConditions() clusterv1.Conditions { + return lm.Status.Conditions +} + +func (lm *LinodeMachine) SetConditions(conditions clusterv1.Conditions) { + lm.Status.Conditions = conditions +} + //+kubebuilder:object:root=true // LinodeMachineList contains a list of LinodeMachine diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 4a954e66d..624b53adc 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -21,9 +21,57 @@ limitations under the License. package v1alpha1 import ( + "github.com/linode/linodego" runtime "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/cluster-api/api/v1beta1" + "sigs.k8s.io/cluster-api/errors" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InstanceConfigInterfaceCreateOptions) DeepCopyInto(out *InstanceConfigInterfaceCreateOptions) { + *out = *in + if in.SubnetID != nil { + in, out := &in.SubnetID, &out.SubnetID + *out = new(int) + **out = **in + } + if in.IPv4 != nil { + in, out := &in.IPv4, &out.IPv4 + *out = new(VPCIPv4) + **out = **in + } + if in.IPRanges != nil { + in, out := &in.IPRanges, &out.IPRanges + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InstanceConfigInterfaceCreateOptions. +func (in *InstanceConfigInterfaceCreateOptions) DeepCopy() *InstanceConfigInterfaceCreateOptions { + if in == nil { + return nil + } + out := new(InstanceConfigInterfaceCreateOptions) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InstanceMetadataOptions) DeepCopyInto(out *InstanceMetadataOptions) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InstanceMetadataOptions. +func (in *InstanceMetadataOptions) DeepCopy() *InstanceMetadataOptions { + if in == nil { + return nil + } + out := new(InstanceMetadataOptions) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *LinodeCluster) DeepCopyInto(out *LinodeCluster) { *out = *in @@ -118,8 +166,8 @@ func (in *LinodeMachine) DeepCopyInto(out *LinodeMachine) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec - out.Status = in.Status + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LinodeMachine. @@ -175,6 +223,50 @@ func (in *LinodeMachineList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *LinodeMachineSpec) DeepCopyInto(out *LinodeMachineSpec) { *out = *in + if in.ProviderID != nil { + in, out := &in.ProviderID, &out.ProviderID + *out = new(string) + **out = **in + } + if in.InstanceID != nil { + in, out := &in.InstanceID, &out.InstanceID + *out = new(int) + **out = **in + } + if in.AuthorizedKeys != nil { + in, out := &in.AuthorizedKeys, &out.AuthorizedKeys + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.AuthorizedUsers != nil { + in, out := &in.AuthorizedUsers, &out.AuthorizedUsers + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.StackScriptData != nil { + in, out := &in.StackScriptData, &out.StackScriptData + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Interfaces != nil { + in, out := &in.Interfaces, &out.Interfaces + *out = make([]InstanceConfigInterfaceCreateOptions, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Tags != nil { + in, out := &in.Tags, &out.Tags + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Metadata != nil { + in, out := &in.Metadata, &out.Metadata + *out = new(InstanceMetadataOptions) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LinodeMachineSpec. @@ -190,6 +282,33 @@ func (in *LinodeMachineSpec) DeepCopy() *LinodeMachineSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *LinodeMachineStatus) DeepCopyInto(out *LinodeMachineStatus) { *out = *in + if in.Addresses != nil { + in, out := &in.Addresses, &out.Addresses + *out = make([]v1beta1.MachineAddress, len(*in)) + copy(*out, *in) + } + if in.InstanceState != nil { + in, out := &in.InstanceState, &out.InstanceState + *out = new(linodego.InstanceStatus) + **out = **in + } + if in.FailureReason != nil { + in, out := &in.FailureReason, &out.FailureReason + *out = new(errors.MachineStatusError) + **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 LinodeMachineStatus. @@ -201,3 +320,18 @@ func (in *LinodeMachineStatus) DeepCopy() *LinodeMachineStatus { 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 +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VPCIPv4. +func (in *VPCIPv4) DeepCopy() *VPCIPv4 { + if in == nil { + return nil + } + out := new(VPCIPv4) + in.DeepCopyInto(out) + return out +} diff --git a/cloud/scope/cluster.go b/cloud/scope/cluster.go index 8a60e242e..f58f8c3e1 100644 --- a/cloud/scope/cluster.go +++ b/cloud/scope/cluster.go @@ -17,10 +17,9 @@ limitations under the License. package scope import ( - "context" + "errors" "fmt" "net/http" - "os" infrav1 "github.com/linode/cluster-api-provider-linode/api/v1alpha1" "github.com/linode/linodego" @@ -34,25 +33,20 @@ import ( type ClusterScopeParams struct { Client client.Client Cluster *clusterv1.Cluster - LinodeClient *linodego.Client LinodeCluster *infrav1.LinodeCluster } func validateClusterScopeParams(params ClusterScopeParams) error { if params.Cluster == nil { - return fmt.Errorf("Cluster is required when creating a ClusterScope") + return errors.New("cluster is required when creating a ClusterScope") } if params.LinodeCluster == nil { - return fmt.Errorf("LinodeCluster is required when creating a ClusterScope") + return errors.New("linodeCluster is required when creating a ClusterScope") } return nil } -func createLinodeClient() (*linodego.Client, error) { - apiKey, ok := os.LookupEnv("LINODE_TOKEN") - if !ok { - return nil, fmt.Errorf("failed to get LINODE_TOKEN environment variable") - } +func createLinodeClient(apiKey string) (*linodego.Client, error) { tokenSource := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: apiKey}) oauth2Client := &http.Client{ @@ -66,18 +60,14 @@ func createLinodeClient() (*linodego.Client, error) { // NewClusterScope creates a new Scope from the supplied parameters. // This is meant to be called for each reconcile iteration. -func NewClusterScope(ctx context.Context, params ClusterScopeParams) (*ClusterScope, error) { - // TODO +func NewClusterScope(apiKey string, params ClusterScopeParams) (*ClusterScope, error) { if err := validateClusterScopeParams(params); err != nil { return nil, err } - if params.LinodeClient == nil { - if linodeClient, err := createLinodeClient(); err != nil { - return nil, err - } else { - params.LinodeClient = linodeClient - } + linodeClient, err := createLinodeClient(apiKey) + if err != nil { + return nil, err } helper, err := patch.NewHelper(params.LinodeCluster, params.Client) @@ -88,17 +78,17 @@ func NewClusterScope(ctx context.Context, params ClusterScopeParams) (*ClusterSc return &ClusterScope{ client: params.Client, Cluster: params.Cluster, - LinodeClient: params.LinodeClient, + LinodeClient: linodeClient, LinodeCluster: params.LinodeCluster, - patchHelper: helper, + PatchHelper: helper, }, nil } // ClusterScope defines the basic context for an actuator to operate upon. type ClusterScope struct { - client client.Client - patchHelper *patch.Helper + client client.Client + PatchHelper *patch.Helper LinodeClient *linodego.Client Cluster *clusterv1.Cluster LinodeCluster *infrav1.LinodeCluster diff --git a/cloud/scope/machine.go b/cloud/scope/machine.go index b9cacb250..5f66b7e33 100644 --- a/cloud/scope/machine.go +++ b/cloud/scope/machine.go @@ -1,9 +1,11 @@ package scope import ( + "errors" "fmt" infrav1 "github.com/linode/cluster-api-provider-linode/api/v1alpha1" + "github.com/linode/linodego" clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" "sigs.k8s.io/cluster-api/util/patch" "sigs.k8s.io/controller-runtime/pkg/client" @@ -18,36 +20,42 @@ type MachineScopeParams struct { } type MachineScope struct { - client client.Client - patchHelper *patch.Helper + client client.Client + PatchHelper *patch.Helper Cluster *clusterv1.Cluster Machine *clusterv1.Machine + LinodeClient *linodego.Client LinodeCluster *infrav1.LinodeCluster LinodeMachine *infrav1.LinodeMachine } func validateMachineScopeParams(params MachineScopeParams) error { if params.Cluster == nil { - return fmt.Errorf("Cluster is required when creating a MachineScope") + return errors.New("custer is required when creating a MachineScope") } if params.Machine == nil { - return fmt.Errorf("Machine is required when creating a MachineScope") + return errors.New("machine is required when creating a MachineScope") } if params.LinodeCluster == nil { - return fmt.Errorf("LinodeCluster is required when creating a MachineScope") + return errors.New("linodeCluster is required when creating a MachineScope") } if params.LinodeMachine == nil { - return fmt.Errorf("LinodeMachine is required when creating a MachineScope") + return errors.New("linodeMachine is required when creating a MachineScope") } return nil } -func NewMachineScope(params MachineScopeParams) (*MachineScope, error) { +func NewMachineScope(apiKey string, params MachineScopeParams) (*MachineScope, error) { if err := validateMachineScopeParams(params); err != nil { return nil, err } + linodeClient, err := createLinodeClient(apiKey) + if err != nil { + return nil, err + } + helper, err := patch.NewHelper(params.LinodeMachine, params.Client) if err != nil { return nil, fmt.Errorf("failed to init patch helper: %w", err) @@ -55,9 +63,10 @@ func NewMachineScope(params MachineScopeParams) (*MachineScope, error) { return &MachineScope{ client: params.Client, - patchHelper: helper, + PatchHelper: helper, Cluster: params.Cluster, Machine: params.Machine, + LinodeClient: linodeClient, LinodeCluster: params.LinodeCluster, LinodeMachine: params.LinodeMachine, }, nil diff --git a/cmd/main.go b/cmd/main.go index aa484ccd8..d588c73c5 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -17,6 +17,7 @@ limitations under the License. package main import ( + "errors" "flag" "os" @@ -31,6 +32,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" + capi "sigs.k8s.io/cluster-api/api/v1beta1" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/log/zap" @@ -47,15 +49,23 @@ var ( func init() { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) - + utilruntime.Must(capi.AddToScheme(scheme)) utilruntime.Must(infrastructurev1alpha1.AddToScheme(scheme)) //+kubebuilder:scaffold:scheme } func main() { + linodeToken := os.Getenv("LINODE_TOKEN") + if linodeToken == "" { + setupLog.Error(errors.New("failed to get LINODE_TOKEN environment variable"), "unable to start operator") + os.Exit(1) + } + + var machineWatchFilter string var metricsAddr string var enableLeaderElection bool var probeAddr string + flag.StringVar(&machineWatchFilter, "machine-watch-filter", "", "The machines to watch by label.") flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") flag.BoolVar(&enableLeaderElection, "leader-elect", false, @@ -100,8 +110,11 @@ func main() { os.Exit(1) } if err = (&controller2.LinodeMachineReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: mgr.GetEventRecorderFor("LinodeMachineReconciler"), + WatchFilterValue: machineWatchFilter, + LinodeApiKey: linodeToken, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "LinodeMachine") os.Exit(1) 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 0c0615307..dfe857d2d 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_linodemachines.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_linodemachines.yaml @@ -34,13 +34,247 @@ spec: spec: description: LinodeMachineSpec defines the desired state of LinodeMachine properties: - foo: - description: Foo is an example field of LinodeMachine. Edit linodemachine_types.go - to remove/update + authorizedKeys: + items: + type: string + type: array + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf + authorizedUsers: + items: + type: string + type: array + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf + backupId: + type: integer + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf + backupsEnabled: + type: boolean + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf + firewallId: + type: integer + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf + group: type: string + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf + image: + type: string + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf + instanceID: + description: InstanceID is the Linode instance ID for this machine. + type: integer + interfaces: + items: + description: InstanceConfigInterfaceCreateOptions defines network + interface config + properties: + ipRanges: + items: + type: string + type: array + ipamAddress: + type: string + ipv4: + description: VPCIPv4 defines VPC IPV4 settings + properties: + nat1to1: + type: string + vpc: + type: string + type: object + label: + type: string + primary: + type: boolean + purpose: + description: ConfigInterfacePurpose options start with InterfacePurpose + and include all known interface purpose types + type: string + subnetId: + type: integer + type: object + type: array + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf + label: + type: string + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf + metadata: + description: InstanceMetadataOptions defines metadata of instance + properties: + userData: + description: UserData expects a Base64-encoded string + type: string + type: object + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf + privateIp: + type: boolean + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf + providerID: + description: ProviderID is the unique identifier as specified by the + cloud provider. + type: string + region: + type: string + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf + rootPass: + type: string + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf + stackscriptData: + additionalProperties: + type: string + type: object + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf + stackscriptId: + type: integer + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf + tags: + items: + type: string + type: array + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf + type: + type: string + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf + required: + - region + - type type: object status: description: LinodeMachineStatus defines the observed state of LinodeMachine + properties: + addresses: + description: Addresses contains the Linode instance associated addresses. + items: + description: MachineAddress contains information for the node's + address. + properties: + address: + description: The machine address. + type: string + type: + description: Machine address type, one of Hostname, ExternalIP, + InternalIP, ExternalDNS or InternalDNS. + type: string + required: + - address + - type + type: object + type: array + conditions: + description: Conditions defines current service state of the LinodeMachine. + 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 Machine 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 Machine'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 Machines can be added as events + to the Machine 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 Machine 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 Machine'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 Machines can be added as events to + the Machine object and/or logged in the controller's output." + type: string + instanceState: + description: InstanceState is the state of the Linode instance for + this machine. + type: string + ready: + description: Ready is true when the provider resource is ready. + type: boolean type: object type: object served: true diff --git a/config/default/.gitignore b/config/default/.gitignore new file mode 100644 index 000000000..49686d40f --- /dev/null +++ b/config/default/.gitignore @@ -0,0 +1 @@ +.env.linode diff --git a/config/default/kustomization.yaml b/config/default/kustomization.yaml index d2ad34845..3120c0ce4 100644 --- a/config/default/kustomization.yaml +++ b/config/default/kustomization.yaml @@ -26,6 +26,11 @@ resources: # [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'. #- ../prometheus +secretGenerator: + - name: token + envs: + - .env.linode + patchesStrategicMerge: # Protect the /metrics endpoint by putting it behind auth. # If you want your controller-manager to expose the /metrics diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index 5e09bc899..8fa75b04c 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -71,6 +71,12 @@ spec: args: - --leader-elect image: controller:latest + env: + - name: LINODE_TOKEN + valueFrom: + secretKeyRef: + name: token + key: LINODE_TOKEN name: manager securityContext: allowPrivilegeEscalation: false diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 45be7508e..5f7cf5498 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -4,6 +4,30 @@ kind: ClusterRole metadata: name: manager-role rules: +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch + - update +- apiGroups: + - cluster.x-k8s.io + resources: + - clusters + verbs: + - get + - list + - watch +- apiGroups: + - cluster.x-k8s.io + resources: + - machines + verbs: + - get + - list + - watch - apiGroups: - infrastructure.cluster.x-k8s.io resources: diff --git a/config/samples/infrastructure_v1alpha1_linodemachine.yaml b/config/samples/infrastructure_v1alpha1_linodemachine.yaml index 1423c548e..7aea80f4b 100644 --- a/config/samples/infrastructure_v1alpha1_linodemachine.yaml +++ b/config/samples/infrastructure_v1alpha1_linodemachine.yaml @@ -9,4 +9,5 @@ metadata: app.kubernetes.io/created-by: cluster-api-provider-linode name: linodemachine-sample spec: - # TODO(user): Add fields here + region: us-east + type: g6-nanode-1 diff --git a/controller/linodemachine_controller.go b/controller/linodemachine_controller.go index d382c4d9f..7842971fd 100644 --- a/controller/linodemachine_controller.go +++ b/controller/linodemachine_controller.go @@ -18,23 +18,65 @@ package controller import ( "context" + "encoding/json" + "errors" "fmt" + "net/http" + "strings" "time" + "github.com/go-logr/logr" "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" + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" - "sigs.k8s.io/cluster-api/util" - "sigs.k8s.io/cluster-api/util/annotations" + "k8s.io/client-go/tools/record" + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + cerrs "sigs.k8s.io/cluster-api/errors" + kutil "sigs.k8s.io/cluster-api/util" + "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" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/source" infrav1 "github.com/linode/cluster-api-provider-linode/api/v1alpha1" ) +var skippedMachinePhases = map[string]bool{ + string(clusterv1.MachinePhasePending): true, + string(clusterv1.MachinePhaseProvisioning): true, + string(clusterv1.MachinePhaseFailed): true, + string(clusterv1.MachinePhaseUnknown): true, +} + +var skippedInstanceStatuses = map[linodego.InstanceStatus]bool{ + linodego.InstanceOffline: true, + linodego.InstanceShuttingDown: true, + linodego.InstanceDeleting: true, +} + +var requeueInstanceStatuses = map[linodego.InstanceStatus]bool{ + linodego.InstanceBooting: true, + linodego.InstanceRebooting: true, + linodego.InstanceProvisioning: true, + linodego.InstanceMigrating: true, + linodego.InstanceRebuilding: true, + linodego.InstanceCloning: true, + linodego.InstanceRestoring: true, + linodego.InstanceResizing: true, +} + // LinodeMachineReconciler reconciles a LinodeMachine object type LinodeMachineReconciler struct { client.Client + Recorder record.EventRecorder + LinodeApiKey string + WatchFilterValue string Scheme *runtime.Scheme ReconcileTimeout time.Duration } @@ -43,12 +85,12 @@ type LinodeMachineReconciler struct { //+kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=linodemachines/status,verbs=get;update;patch //+kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=linodemachines/finalizers,verbs=update +//+kubebuilder:rbac:groups=cluster.x-k8s.io,resources=clusters,verbs=get;watch;list +//+kubebuilder:rbac:groups=cluster.x-k8s.io,resources=machines,verbs=get;watch;list +//+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 cluster closer to the desired state. -// TODO(user): Modify the Reconcile function to compare the state specified by -// the LinodeMachine object against the actual cluster state, and then -// perform operations to make the cluster 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 @@ -56,28 +98,51 @@ func (r *LinodeMachineReconciler) Reconcile(ctx context.Context, req ctrl.Reques ctx, cancel := context.WithTimeout(ctx, reconciler.DefaultedLoopTimeout(r.ReconcileTimeout)) defer cancel() - log := ctrl.LoggerFrom(ctx) + log := ctrl.LoggerFrom(ctx).WithName("LinodeMachineReconciler").WithValues("name", req.NamespacedName.String()) - // TODO(user): your logic here linodeMachine := &infrav1.LinodeMachine{} - if err := r.Get(ctx, req.NamespacedName, linodeMachine); err != nil { + if err := r.Client.Get(ctx, req.NamespacedName, linodeMachine); err != nil { + log.Info("Failed to fetch Linode machine", "error", err.Error()) + return ctrl.Result{}, client.IgnoreNotFound(err) } - machine, err := util.GetOwnerMachine(ctx, r.Client, linodeMachine.ObjectMeta) - if err != nil { - return ctrl.Result{}, err - } - if machine == nil { - log.Info("Machine Controller has not yet set OwnerRef") + machine, err := kutil.GetOwnerMachine(ctx, r.Client, linodeMachine.ObjectMeta) + switch { + case err != nil: + log.Info("Failed to fetch owner machine", "error", err.Error()) + + return ctrl.Result{}, client.IgnoreNotFound(err) + case machine == nil: + log.Info("Machine Controller has not yet set OwnerRef, skipping reconciliation") + + return ctrl.Result{}, nil + case skippedMachinePhases[machine.Status.Phase]: + log.Info("Machine phase is not the one we are looking for, skipping reconciliation", "phase", machine.Status.Phase) + return ctrl.Result{}, nil + default: + match := false + for i := range linodeMachine.OwnerReferences { + if match = linodeMachine.OwnerReferences[i].UID == machine.UID; match { + break + } + } + + if !match { + log.Info("Failed to find the referenced owner machine, skipping reconciliation", "references", linodeMachine.OwnerReferences, "machine", machine.ObjectMeta) + + return ctrl.Result{}, nil + } } log = log.WithValues("Linode machine: ", machine.Name) - cluster, err := util.GetClusterFromMetadata(ctx, r.Client, machine.ObjectMeta) + + cluster, err := kutil.GetClusterFromMetadata(ctx, r.Client, machine.ObjectMeta) if err != nil { - log.Info("Machine is missing cluster label or cluster does not exist") - return ctrl.Result{}, nil + log.Info("Failed to fetch cluster by label", "error", err.Error()) + + return ctrl.Result{}, client.IgnoreNotFound(err) } linodeCluster := &infrav1.LinodeCluster{} @@ -87,17 +152,13 @@ func (r *LinodeMachineReconciler) Reconcile(ctx context.Context, req ctrl.Reques } if err := r.Client.Get(ctx, linodeClusterKey, linodeCluster); err != nil { - log.Info("LinodeCluster is not available yet") - return ctrl.Result{}, nil - } + log.Info("Failed to fetch Linode cluster", "error", err.Error()) - if annotations.IsPaused(cluster, linodeCluster) { - log.Info("LinodeMachine or linked Cluster is marked as paused. Won't reconcile") - return ctrl.Result{}, nil + return ctrl.Result{}, client.IgnoreNotFound(err) } clusterScope, err := scope.NewClusterScope( - ctx, + r.LinodeApiKey, scope.ClusterScopeParams{ Client: r.Client, Cluster: cluster, @@ -105,10 +166,13 @@ func (r *LinodeMachineReconciler) Reconcile(ctx context.Context, req ctrl.Reques }, ) if err != nil { - return ctrl.Result{}, fmt.Errorf("failed to create scope: %w", err) + log.Info("Failed to create cluster scope", "error", err.Error()) + + return ctrl.Result{}, fmt.Errorf("failed to create cluster scope: %w", err) } machineScope, err := scope.NewMachineScope( + r.LinodeApiKey, scope.MachineScopeParams{ Client: r.Client, Cluster: cluster, @@ -117,22 +181,220 @@ func (r *LinodeMachineReconciler) Reconcile(ctx context.Context, req ctrl.Reques LinodeMachine: linodeMachine, }, ) + if err != nil { + log.Info("Failed to create machine scope", "error", err.Error()) - return r.reconcile(ctx, machineScope, clusterScope) + return ctrl.Result{}, fmt.Errorf("failed to create machine scope: %w", err) + } + + return r.reconcile(ctx, machineScope, clusterScope, log) } func (r *LinodeMachineReconciler) reconcile( ctx context.Context, machineScope *scope.MachineScope, clusterScope *scope.ClusterScope, -) (ctrl.Result, error) { - // TODO - return ctrl.Result{}, nil + logger logr.Logger, +) (res ctrl.Result, err error) { + machineScope.LinodeMachine.Status.Ready = false + machineScope.LinodeMachine.Status.FailureReason = nil + machineScope.LinodeMachine.Status.FailureMessage = util.Pointer("") + + failureReason := cerrs.MachineStatusError("UnknownError") + 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()) + + r.Recorder.Event(machineScope.LinodeMachine, corev1.EventTypeWarning, string(failureReason), err.Error()) + } + + if patchErr := machineScope.PatchHelper.Patch(ctx, machineScope.LinodeMachine); patchErr != nil && + client.IgnoreNotFound(patchErr) != nil { + + logger.Error(patchErr, "failed to patch LinodeMachine") + + err = errors.Join(err, patchErr) + } + }() + + // Delete + if !machineScope.LinodeMachine.ObjectMeta.DeletionTimestamp.IsZero() { + failureReason = cerrs.DeleteMachineError + + 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()) + + // Not found is not an error + if apiErr, ok := err.(*linodego.Error); ok && apiErr.Code != http.StatusNotFound { + return + } + + err = nil + } + } else { + logger.Info("Machine ID is missing, nothing to do") + } + + conditions.MarkFalse(machineScope.LinodeMachine, clusterv1.ReadyCondition, clusterv1.DeletedReason, clusterv1.ConditionSeverityInfo, "instance deleted") + + machineScope.LinodeMachine.Spec.ProviderID = nil + machineScope.LinodeMachine.Spec.InstanceID = nil + controllerutil.RemoveFinalizer(machineScope.LinodeMachine, infrav1.GroupVersion.String()) + + return + } + + controllerutil.AddFinalizer(machineScope.LinodeMachine, infrav1.GroupVersion.String()) + + var linodeInstance *linodego.Instance + defer func() { + machineScope.LinodeMachine.Status.InstanceState = util.Pointer(linodego.InstanceOffline) + if linodeInstance != nil { + machineScope.LinodeMachine.Status.InstanceState = &linodeInstance.Status + } + }() + + // Update + if machineScope.LinodeMachine.Spec.InstanceID != nil { + failureReason = cerrs.UpdateMachineError + + logger = logger.WithValues("ID", *machineScope.LinodeMachine.Spec.InstanceID) + + if linodeInstance, err = machineScope.LinodeClient.GetInstance(ctx, *machineScope.LinodeMachine.Spec.InstanceID); err != nil { + logger.Info("Failed to get Linode machine instance", "error", err.Error()) + + // Not found is not an error + if apiErr, ok := err.(*linodego.Error); ok && apiErr.Code == http.StatusNotFound { + conditions.MarkFalse(machineScope.LinodeMachine, clusterv1.ReadyCondition, string(failureReason), clusterv1.ConditionSeverityWarning, "instance not found") + + err = nil + } + + return + } + + if _, ok := requeueInstanceStatuses[linodeInstance.Status]; ok { + if linodeInstance.Updated.Add(reconciler.DefaultMachineControllerWaitForRunningTimeout).After(time.Now()) { + logger.Info("Instance has one operaton running, re-queuing reconciliation", "status", linodeInstance.Status) + + res = ctrl.Result{RequeueAfter: reconciler.DefaultMachineControllerWaitForRunningDelay} + } else { + logger.Info("Instance has one operaton long running, skipping reconciliation", "status", linodeInstance.Status) + } + + return + } else if _, ok := skippedInstanceStatuses[linodeInstance.Status]; ok || linodeInstance.Status != linodego.InstanceRunning { + logger.Info("Instance has incompatible status, skipping reconciliation", "status", linodeInstance.Status) + + conditions.MarkFalse(machineScope.LinodeMachine, clusterv1.ReadyCondition, string(linodeInstance.Status), clusterv1.ConditionSeverityInfo, "incompatible status") + + return + } + + conditions.MarkTrue(machineScope.LinodeMachine, clusterv1.ReadyCondition) + + r.Recorder.Event(machineScope.LinodeMachine, corev1.EventTypeNormal, string(clusterv1.ReadyCondition), "instance is running") + + return + } + + // Create + failureReason = cerrs.CreateMachineError + + tags := []string{string(machineScope.LinodeCluster.UID), string(machineScope.LinodeMachine.UID)} + filter := map[string]string{ + "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 ...") + } + + 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()) + + return + } + + switch len(linodeInstances) { + case 1: + logger.Info("Linode instance already exists") + + linodeInstance = &linodeInstances[0] + case 0: + createConfig := linodeMachineSpecToCreateInstanceConfig(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") + + return + } + createConfig.Tags = tags + + if linodeInstance, err = machineScope.LinodeClient.CreateInstance(ctx, *createConfig); err != nil { + logger.Info("Failed to create Linode machine instance", "error", err.Error()) + + // Already exists is not an error + if apiErr, ok := err.(*linodego.Error); ok && apiErr.Code != http.StatusFound { + return + } + + err = nil + + logger.Info("Linode instance already exists", "existing", linodeInstance.ID) + } + default: + logger.Error(errors.New("multiple instances found"), "Panic! Multiple instances found. This might be a concurrency issue in the controller!!!", "filters", string(rawFilter)) + + return + } + + logger = logger.WithValues("ID", linodeInstance.ID) + + machineScope.LinodeMachine.Status.Ready = true + machineScope.LinodeMachine.Spec.InstanceID = &linodeInstance.ID + machineScope.LinodeMachine.Spec.ProviderID = util.Pointer(fmt.Sprintf("linode:///%s/%d", linodeInstance.Region, linodeInstance.ID)) + + machineScope.LinodeMachine.Status.Addresses = []clusterv1.MachineAddress{} + for _, add := range linodeInstance.IPv4 { + machineScope.LinodeMachine.Status.Addresses = append(machineScope.LinodeMachine.Status.Addresses, clusterv1.MachineAddress{ + Type: clusterv1.MachineExternalIP, + Address: add.String(), + }) + } + + return } // SetupWithManager sets up the controller with the Manager. func (r *LinodeMachineReconciler) SetupWithManager(mgr ctrl.Manager) error { - return ctrl.NewControllerManagedBy(mgr). + controller, err := ctrl.NewControllerManagedBy(mgr). For(&infrav1.LinodeMachine{}). - Complete(r) + Watches( + &clusterv1.Machine{}, + handler.EnqueueRequestsFromMapFunc(kutil.MachineToInfrastructureMapFunc(infrav1.GroupVersion.WithKind("LinodeMachine"))), + ). + Watches( + &infrav1.LinodeCluster{}, + handler.EnqueueRequestsFromMapFunc(r.linodeClusterToLinodeMachines(mgr.GetLogger())), + ). + WithEventFilter(predicates.ResourceNotPausedAndHasFilterLabel(mgr.GetLogger(), r.WatchFilterValue)). + Build(r) + if err != nil { + return fmt.Errorf("failed to build controller: %w", err) + } + + return controller.Watch( + source.Kind(mgr.GetCache(), &clusterv1.Cluster{}), + handler.EnqueueRequestsFromMapFunc(r.requeueLinodeMachinesForUnpausedCluster(mgr.GetLogger())), + predicates.ClusterUnpausedAndInfrastructureReady(mgr.GetLogger()), + ) } diff --git a/controller/linodemachine_controller_helpers.go b/controller/linodemachine_controller_helpers.go new file mode 100644 index 000000000..f9d6696d1 --- /dev/null +++ b/controller/linodemachine_controller_helpers.go @@ -0,0 +1,151 @@ +/* +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" + + "github.com/go-logr/logr" + infrav1 "github.com/linode/cluster-api-provider-linode/api/v1alpha1" + "github.com/linode/cluster-api-provider-linode/util/reconciler" + "github.com/linode/linodego" + apierrors "k8s.io/apimachinery/pkg/api/errors" + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + kutil "sigs.k8s.io/cluster-api/util" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/handler" +) + +func (r *LinodeMachineReconciler) linodeClusterToLinodeMachines(logger logr.Logger) handler.MapFunc { + logger = logger.WithName("LinodeMachineReconciler").WithName("linodeClusterToLinodeMachines") + + return func(ctx context.Context, o client.Object) []ctrl.Request { + ctx, cancel := context.WithTimeout(context.Background(), reconciler.DefaultMappingTimeout) + defer cancel() + + linodeCluster, ok := o.(*infrav1.LinodeCluster) + if !ok { + logger.Info("Failed to cast object to Cluster") + + return nil + } + + if !linodeCluster.ObjectMeta.DeletionTimestamp.IsZero() { + logger.Info("Cluster has a deletion timestamp, skipping mapping") + + return nil + } + + cluster, err := kutil.GetOwnerCluster(ctx, r.Client, linodeCluster.ObjectMeta) + switch { + case apierrors.IsNotFound(err) || cluster == nil: + logger.Info("Cluster for LinodeCluster not found, skipping mapping") + + return nil + case err != nil: + logger.Info("Failed to get owning cluster, skipping mapping", "error", err.Error()) + + return nil + } + + request, err := r.requestsForCluster(cluster.Namespace, cluster.Name) + if err != nil { + logger.Info("Failed to create request for cluster", "error", err.Error()) + + return nil + } + + return request + } +} + +func (r *LinodeMachineReconciler) requeueLinodeMachinesForUnpausedCluster(logger logr.Logger) handler.MapFunc { + logger = logger.WithName("LinodeMachineReconciler").WithName("requeueLinodeMachinesForUnpausedCluster") + + return func(ctx context.Context, o client.Object) []ctrl.Request { + cluster, ok := o.(*clusterv1.Cluster) + if !ok { + logger.Info("Failed to cast object to Cluster") + + return nil + } + + if !cluster.ObjectMeta.DeletionTimestamp.IsZero() { + logger.Info("Cluster has a deletion timestamp, skipping mapping") + + return nil + } + + request, err := r.requestsForCluster(cluster.Namespace, cluster.Name) + if err != nil { + logger.Info("Failed to create request for cluster", "error", err.Error()) + + return nil + } + + return request + } +} + +func (r *LinodeMachineReconciler) requestsForCluster(namespace, name string) ([]ctrl.Request, error) { + ctx, cancel := context.WithTimeout(context.Background(), r.ReconcileTimeout) + defer cancel() + + labels := map[string]string{clusterv1.ClusterNameLabel: name} + + machineList := clusterv1.MachineList{} + if err := r.Client.List(ctx, &machineList, client.InNamespace(namespace), client.MatchingLabels(labels)); err != nil { + return nil, err + } + + result := make([]ctrl.Request, 0, len(machineList.Items)) + for _, item := range machineList.Items { + if item.Spec.InfrastructureRef.GroupVersionKind().Kind != "LinodeMachine" || item.Spec.InfrastructureRef.Name == "" { + continue + } + + result = append(result, ctrl.Request{ + NamespacedName: client.ObjectKey{ + Namespace: item.Namespace, + Name: item.Spec.InfrastructureRef.Name, + }, + }) + } + + return result, nil +} + +func linodeMachineSpecToCreateInstanceConfig(machineSpec infrav1.LinodeMachineSpec) *linodego.InstanceCreateOptions { + var buf bytes.Buffer + enc := gob.NewEncoder(&buf) + err := enc.Encode(machineSpec) + if err != nil { + return nil + } + + var createConfig linodego.InstanceCreateOptions + dec := gob.NewDecoder(&buf) + err = dec.Decode(&createConfig) + if err != nil { + return nil + } + + return &createConfig +} diff --git a/controller/linodemachine_controller_test.go b/controller/linodemachine_controller_test.go new file mode 100644 index 000000000..045d017a7 --- /dev/null +++ b/controller/linodemachine_controller_test.go @@ -0,0 +1,67 @@ +package controller + +import ( + "bytes" + "encoding/gob" + "testing" + + infrav1 "github.com/linode/cluster-api-provider-linode/api/v1alpha1" + "github.com/linode/linodego" + "github.com/stretchr/testify/assert" +) + +func TestLinodeMachineSpecToCreateInstanceConfig(t *testing.T) { + t.Parallel() + + sID := 1 + + machineSpec := infrav1.LinodeMachineSpec{ + Region: "region", + Type: "type", + Label: "label", + Group: "group", + RootPass: "rootPass", + AuthorizedKeys: []string{"key"}, + AuthorizedUsers: []string{"user"}, + StackScriptID: 1, + StackScriptData: map[string]string{"script": "data"}, + BackupID: 1, + Image: "image", + Interfaces: []infrav1.InstanceConfigInterfaceCreateOptions{ + { + IPAMAddress: "address", + Label: "label", + Purpose: linodego.InterfacePurposePublic, + Primary: true, + SubnetID: &sID, + IPv4: &infrav1.VPCIPv4{ + VPC: "vpc", + NAT1To1: "nat11", + }, + IPRanges: []string{"ip"}, + }, + }, + BackupsEnabled: true, + PrivateIP: true, + Tags: []string{"tag"}, + Metadata: &infrav1.InstanceMetadataOptions{ + UserData: "userdata", + }, + FirewallID: 1, + } + + createConfig := linodeMachineSpecToCreateInstanceConfig(machineSpec) + assert.NotNil(t, createConfig, "Failed to convert LinodeMachineSpec to InstanceCreateOptions") + + var buf bytes.Buffer + enc := gob.NewEncoder(&buf) + err := enc.Encode(createConfig) + assert.NoError(t, err, "Failed to encode InstanceCreateOptions") + + var actualMachineSpec infrav1.LinodeMachineSpec + dec := gob.NewDecoder(&buf) + err = dec.Decode(&actualMachineSpec) + assert.NoError(t, err, "Failed to decode LinodeMachineSpec") + + assert.Equal(t, machineSpec, actualMachineSpec) +} diff --git a/go.mod b/go.mod index c754226e4..44d9847e4 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,14 @@ module github.com/linode/cluster-api-provider-linode go 1.20 require ( + github.com/go-logr/logr v1.3.0 github.com/linode/linodego v1.25.0 github.com/onsi/ginkgo/v2 v2.13.1 github.com/onsi/gomega v1.29.0 + github.com/stretchr/testify v1.8.4 go.uber.org/automaxprocs v1.5.3 golang.org/x/oauth2 v0.14.0 + k8s.io/api v0.28.3 k8s.io/apimachinery v0.28.3 k8s.io/client-go v0.28.3 sigs.k8s.io/cluster-api v1.5.3 @@ -23,7 +26,6 @@ require ( github.com/emicklei/go-restful/v3 v3.11.0 // indirect github.com/evanphx/json-patch/v5 v5.6.0 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect - github.com/go-logr/logr v1.3.0 // indirect github.com/go-logr/zapr v1.2.4 // indirect github.com/go-openapi/jsonpointer v0.19.6 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect @@ -48,6 +50,7 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_golang v1.16.0 // indirect github.com/prometheus/client_model v0.4.0 // indirect github.com/prometheus/common v0.44.0 // indirect @@ -69,7 +72,6 @@ require ( gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/api v0.28.3 // indirect k8s.io/apiextensions-apiserver v0.28.3 // indirect k8s.io/component-base v0.28.3 // indirect k8s.io/klog/v2 v2.100.1 // indirect diff --git a/go.sum b/go.sum index 5fe61d252..60641f622 100644 --- a/go.sum +++ b/go.sum @@ -140,6 +140,7 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= diff --git a/util/helpers.go b/util/helpers.go new file mode 100644 index 000000000..21067e436 --- /dev/null +++ b/util/helpers.go @@ -0,0 +1,6 @@ +package util + +// Pointer returns the pointer of any type +func Pointer[T any](t T) *T { + return &t +} diff --git a/util/reconciler/defaults.go b/util/reconciler/defaults.go index abdea743e..0c789e20a 100644 --- a/util/reconciler/defaults.go +++ b/util/reconciler/defaults.go @@ -25,6 +25,11 @@ const ( DefaultLoopTimeout = 90 * time.Minute // DefaultMappingTimeout is the default timeout for a controller request mapping func. DefaultMappingTimeout = 60 * time.Second + + // DefaultMachineControllerWaitForRunningDelay is the default requeue delay if instance is not running. + DefaultMachineControllerWaitForRunningDelay = 5 * time.Second + // DefaultMachineControllerWaitForRunningTimeout is the default timeout if instance is not running. + DefaultMachineControllerWaitForRunningTimeout = 20 * time.Minute ) // DefaultedLoopTimeout will default the timeout if it is zero-valued.