diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmachinepools.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmachinepools.yaml index 7b6acd1ccc..268de920ef 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmachinepools.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmachinepools.yaml @@ -839,6 +839,54 @@ spec: after it enters the InService state. If no value is supplied by user a default value of 300 seconds is set type: string + lifecycleHooks: + description: AWSLifecycleHooks specifies lifecycle hooks for the managed + node group. + items: + description: AWSLifecycleHook describes an AWS lifecycle hook + properties: + defaultResult: + description: The default result for the lifecycle hook. The + possible values are CONTINUE and ABANDON. + enum: + - CONTINUE + - ABANDON + type: string + heartbeatTimeout: + description: |- + The maximum time, in seconds, that an instance can remain in a Pending:Wait or + Terminating:Wait state. The maximum is 172800 seconds (48 hours) or 100 times + HeartbeatTimeout, whichever is smaller. + format: duration + type: string + lifecycleTransition: + description: The state of the EC2 instance to which to attach + the lifecycle hook. + enum: + - autoscaling:EC2_INSTANCE_LAUNCHING + - autoscaling:EC2_INSTANCE_TERMINATING + type: string + name: + description: The name of the lifecycle hook. + type: string + notificationMetadata: + description: Contains additional metadata that will be passed + to the notification target. + type: string + notificationTargetARN: + description: |- + The ARN of the notification target that Amazon EC2 Auto Scaling uses to + notify you when an instance is in the transition state for the lifecycle hook. + type: string + roleARN: + description: |- + The ARN of the IAM role that allows the Auto Scaling group to publish to the + specified notification target. + type: string + required: + - lifecycleTransition + type: object + type: array maxSize: default: 1 description: MaxSize defines the maximum size of the group. diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmanagedmachinepools.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmanagedmachinepools.yaml index 3b4e76e87c..58fe530fae 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmanagedmachinepools.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmanagedmachinepools.yaml @@ -846,6 +846,54 @@ spec: type: string description: Labels specifies labels for the Kubernetes node objects type: object + lifecycleHooks: + description: AWSLifecycleHooks specifies lifecycle hooks for the managed + node group. + items: + description: AWSLifecycleHook describes an AWS lifecycle hook + properties: + defaultResult: + description: The default result for the lifecycle hook. The + possible values are CONTINUE and ABANDON. + enum: + - CONTINUE + - ABANDON + type: string + heartbeatTimeout: + description: |- + The maximum time, in seconds, that an instance can remain in a Pending:Wait or + Terminating:Wait state. The maximum is 172800 seconds (48 hours) or 100 times + HeartbeatTimeout, whichever is smaller. + format: duration + type: string + lifecycleTransition: + description: The state of the EC2 instance to which to attach + the lifecycle hook. + enum: + - autoscaling:EC2_INSTANCE_LAUNCHING + - autoscaling:EC2_INSTANCE_TERMINATING + type: string + name: + description: The name of the lifecycle hook. + type: string + notificationMetadata: + description: Contains additional metadata that will be passed + to the notification target. + type: string + notificationTargetARN: + description: |- + The ARN of the notification target that Amazon EC2 Auto Scaling uses to + notify you when an instance is in the transition state for the lifecycle hook. + type: string + roleARN: + description: |- + The ARN of the IAM role that allows the Auto Scaling group to publish to the + specified notification target. + type: string + required: + - lifecycleTransition + type: object + type: array providerIDList: description: |- ProviderIDList are the provider IDs of instances in the diff --git a/exp/api/v1beta1/conversion.go b/exp/api/v1beta1/conversion.go index 16cf651fdf..5780b82caa 100644 --- a/exp/api/v1beta1/conversion.go +++ b/exp/api/v1beta1/conversion.go @@ -50,6 +50,9 @@ func (src *AWSMachinePool) ConvertTo(dstRaw conversion.Hub) error { if restored.Spec.AvailabilityZoneSubnetType != nil { dst.Spec.AvailabilityZoneSubnetType = restored.Spec.AvailabilityZoneSubnetType } + if restored.Spec.AWSLifecycleHooks != nil { + dst.Spec.AWSLifecycleHooks = restored.Spec.AWSLifecycleHooks + } if restored.Spec.AWSLaunchTemplate.PrivateDNSName != nil { dst.Spec.AWSLaunchTemplate.PrivateDNSName = restored.Spec.AWSLaunchTemplate.PrivateDNSName @@ -109,6 +112,9 @@ func (src *AWSManagedMachinePool) ConvertTo(dstRaw conversion.Hub) error { if restored.Spec.AvailabilityZoneSubnetType != nil { dst.Spec.AvailabilityZoneSubnetType = restored.Spec.AvailabilityZoneSubnetType } + if restored.Spec.AWSLifecycleHooks != nil { + dst.Spec.AWSLifecycleHooks = restored.Spec.AWSLifecycleHooks + } return nil } diff --git a/exp/api/v1beta1/zz_generated.conversion.go b/exp/api/v1beta1/zz_generated.conversion.go index 869a3c13d4..14f188aa17 100644 --- a/exp/api/v1beta1/zz_generated.conversion.go +++ b/exp/api/v1beta1/zz_generated.conversion.go @@ -559,6 +559,7 @@ func autoConvert_v1beta2_AWSMachinePoolSpec_To_v1beta1_AWSMachinePoolSpec(in *v1 } out.CapacityRebalance = in.CapacityRebalance // WARNING: in.SuspendProcesses requires manual conversion: does not exist in peer-type + // WARNING: in.AWSLifecycleHooks requires manual conversion: does not exist in peer-type return nil } @@ -735,6 +736,7 @@ func autoConvert_v1beta2_AWSManagedMachinePoolSpec_To_v1beta1_AWSManagedMachineP } else { out.AWSLaunchTemplate = nil } + // WARNING: in.AWSLifecycleHooks requires manual conversion: does not exist in peer-type return nil } diff --git a/exp/api/v1beta2/awsmachinepool_types.go b/exp/api/v1beta2/awsmachinepool_types.go index a9c26a3e60..7783aef876 100644 --- a/exp/api/v1beta2/awsmachinepool_types.go +++ b/exp/api/v1beta2/awsmachinepool_types.go @@ -101,6 +101,10 @@ type AWSMachinePoolSpec struct { // SuspendProcesses defines a list of processes to suspend for the given ASG. This is constantly reconciled. // If a process is removed from this list it will automatically be resumed. SuspendProcesses *SuspendProcessesTypes `json:"suspendProcesses,omitempty"` + + // AWSLifecycleHooks specifies lifecycle hooks for the managed node group. + // +optional + AWSLifecycleHooks []AWSLifecycleHook `json:"lifecycleHooks,omitempty"` } // SuspendProcessesTypes contains user friendly auto-completable values for suspended process names. diff --git a/exp/api/v1beta2/awsmachinepool_webhook.go b/exp/api/v1beta2/awsmachinepool_webhook.go index ab434ffb4b..853a096086 100644 --- a/exp/api/v1beta2/awsmachinepool_webhook.go +++ b/exp/api/v1beta2/awsmachinepool_webhook.go @@ -42,8 +42,10 @@ func (r *AWSMachinePool) SetupWebhookWithManager(mgr ctrl.Manager) error { // +kubebuilder:webhook:verbs=create;update,path=/validate-infrastructure-cluster-x-k8s-io-v1beta2-awsmachinepool,mutating=false,failurePolicy=fail,matchPolicy=Equivalent,groups=infrastructure.cluster.x-k8s.io,resources=awsmachinepools,versions=v1beta2,name=validation.awsmachinepool.infrastructure.cluster.x-k8s.io,sideEffects=None,admissionReviewVersions=v1;v1beta1 // +kubebuilder:webhook:verbs=create;update,path=/mutate-infrastructure-cluster-x-k8s-io-v1beta2-awsmachinepool,mutating=true,failurePolicy=fail,matchPolicy=Equivalent,groups=infrastructure.cluster.x-k8s.io,resources=awsmachinepools,versions=v1beta2,name=default.awsmachinepool.infrastructure.cluster.x-k8s.io,sideEffects=None,admissionReviewVersions=v1;v1beta1 -var _ webhook.Defaulter = &AWSMachinePool{} -var _ webhook.Validator = &AWSMachinePool{} +var ( + _ webhook.Defaulter = &AWSMachinePool{} + _ webhook.Validator = &AWSMachinePool{} +) func (r *AWSMachinePool) validateDefaultCoolDown() field.ErrorList { var allErrs field.ErrorList @@ -108,6 +110,7 @@ func (r *AWSMachinePool) validateAdditionalSecurityGroups() field.ErrorList { } return allErrs } + func (r *AWSMachinePool) validateSpotInstances() field.ErrorList { var allErrs field.ErrorList if r.Spec.AWSLaunchTemplate.SpotMarketOptions != nil && r.Spec.MixedInstancesPolicy != nil { @@ -116,6 +119,37 @@ func (r *AWSMachinePool) validateSpotInstances() field.ErrorList { return allErrs } +func (r *AWSMachinePool) validateLifecycleHooks() field.ErrorList { + return validateLifecycleHooks(r.Spec.AWSLifecycleHooks) +} + +func validateLifecycleHooks(hooks []AWSLifecycleHook) field.ErrorList { + var allErrs field.ErrorList + + for _, hook := range hooks { + if hook.Name == "" { + allErrs = append(allErrs, field.Required(field.NewPath("spec.lifecycleHooks.name"), "Name is required")) + } + if hook.NotificationTargetARN != nil && hook.RoleARN == nil { + allErrs = append(allErrs, field.Required(field.NewPath("spec.lifecycleHooks.roleARN"), "RoleARN is required if NotificationTargetARN is provided")) + } + if hook.RoleARN != nil && hook.NotificationTargetARN == nil { + allErrs = append(allErrs, field.Required(field.NewPath("spec.lifecycleHooks.notificationTargetARN"), "NotificationTargetARN is required if RoleARN is provided")) + } + if hook.LifecycleTransition != LifecycleTransitionInstanceLaunch && hook.LifecycleTransition != LifecycleTransitionInstanceTerminate { + allErrs = append(allErrs, field.Invalid(field.NewPath("spec.lifecycleHooks.lifecycleTransition"), hook.LifecycleTransition, "LifecycleTransition must be either EC2_INSTANCE_LAUNCHING or EC2_INSTANCE_TERMINATING")) + } + if hook.DefaultResult != nil && (*hook.DefaultResult != DefaultResultContinue && *hook.DefaultResult != DefaultResultAbandon) { + allErrs = append(allErrs, field.Invalid(field.NewPath("spec.lifecycleHooks.defaultResult"), *hook.DefaultResult, "DefaultResult must be either CONTINUE or ABANDON")) + } + if hook.HeartbeatTimeout != nil && (hook.HeartbeatTimeout.Seconds() < float64(30) || hook.HeartbeatTimeout.Seconds() > float64(172800)) { + allErrs = append(allErrs, field.Invalid(field.NewPath("spec.lifecycleHooks.heartbeatTimeout"), *hook.HeartbeatTimeout, "HeartbeatTimeout must be between 30 and 172800 seconds")) + } + } + + return allErrs +} + // ValidateCreate will do any extra validation when creating a AWSMachinePool. func (r *AWSMachinePool) ValidateCreate() (admission.Warnings, error) { log.Info("AWSMachinePool validate create", "machine-pool", klog.KObj(r)) @@ -128,6 +162,7 @@ func (r *AWSMachinePool) ValidateCreate() (admission.Warnings, error) { allErrs = append(allErrs, r.validateSubnets()...) allErrs = append(allErrs, r.validateAdditionalSecurityGroups()...) allErrs = append(allErrs, r.validateSpotInstances()...) + allErrs = append(allErrs, r.validateLifecycleHooks()...) if len(allErrs) == 0 { return nil, nil @@ -149,6 +184,7 @@ func (r *AWSMachinePool) ValidateUpdate(_ runtime.Object) (admission.Warnings, e allErrs = append(allErrs, r.validateSubnets()...) allErrs = append(allErrs, r.validateAdditionalSecurityGroups()...) allErrs = append(allErrs, r.validateSpotInstances()...) + allErrs = append(allErrs, r.validateLifecycleHooks()...) if len(allErrs) == 0 { return nil, nil diff --git a/exp/api/v1beta2/awsmachinepool_webhook_test.go b/exp/api/v1beta2/awsmachinepool_webhook_test.go index 3f7f30a101..3015437213 100644 --- a/exp/api/v1beta2/awsmachinepool_webhook_test.go +++ b/exp/api/v1beta2/awsmachinepool_webhook_test.go @@ -19,6 +19,7 @@ package v1beta2 import ( "strings" "testing" + "time" "github.com/aws/aws-sdk-go/aws" . "github.com/onsi/gomega" @@ -153,6 +154,33 @@ func TestAWSMachinePoolValidateCreate(t *testing.T) { }, wantErr: true, }, + { + name: "Should fail if either roleARN or notifcationARN is set but not both", + pool: &AWSMachinePool{ + Spec: AWSMachinePoolSpec{ + AWSLifecycleHooks: []AWSLifecycleHook{ + { + RoleARN: aws.String("role-arn"), + }, + }, + }, + }, + wantErr: true, + }, + { + name: "Should fail if the heartbeat timeout is less than 30 seconds", + pool: &AWSMachinePool{ + Spec: AWSMachinePoolSpec{ + AWSLifecycleHooks: []AWSLifecycleHook{ + { + RoleARN: aws.String("role-arn"), + HeartbeatTimeout: &metav1.Duration{Duration: 29 * time.Second}, + }, + }, + }, + }, + wantErr: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/exp/api/v1beta2/awsmanagedmachinepool_types.go b/exp/api/v1beta2/awsmanagedmachinepool_types.go index c7e70fcf55..62bbcbf69b 100644 --- a/exp/api/v1beta2/awsmanagedmachinepool_types.go +++ b/exp/api/v1beta2/awsmanagedmachinepool_types.go @@ -159,6 +159,10 @@ type AWSManagedMachinePoolSpec struct { // are prohibited (https://docs.aws.amazon.com/eks/latest/userguide/launch-templates.html). // +optional AWSLaunchTemplate *AWSLaunchTemplate `json:"awsLaunchTemplate,omitempty"` + + // AWSLifecycleHooks specifies lifecycle hooks for the managed node group. + // +optional + AWSLifecycleHooks []AWSLifecycleHook `json:"lifecycleHooks,omitempty"` } // ManagedMachinePoolScaling specifies scaling options. diff --git a/exp/api/v1beta2/awsmanagedmachinepool_webhook.go b/exp/api/v1beta2/awsmanagedmachinepool_webhook.go index effd87a2d1..bcc13f8029 100644 --- a/exp/api/v1beta2/awsmanagedmachinepool_webhook.go +++ b/exp/api/v1beta2/awsmanagedmachinepool_webhook.go @@ -51,8 +51,10 @@ func (r *AWSManagedMachinePool) SetupWebhookWithManager(mgr ctrl.Manager) error // +kubebuilder:webhook:verbs=create;update,path=/validate-infrastructure-cluster-x-k8s-io-v1beta2-awsmanagedmachinepool,mutating=false,failurePolicy=fail,matchPolicy=Equivalent,groups=infrastructure.cluster.x-k8s.io,resources=awsmanagedmachinepools,versions=v1beta2,name=validation.awsmanagedmachinepool.infrastructure.cluster.x-k8s.io,sideEffects=None,admissionReviewVersions=v1;v1beta1 // +kubebuilder:webhook:verbs=create;update,path=/mutate-infrastructure-cluster-x-k8s-io-v1beta2-awsmanagedmachinepool,mutating=true,failurePolicy=fail,matchPolicy=Equivalent,groups=infrastructure.cluster.x-k8s.io,resources=awsmanagedmachinepools,versions=v1beta2,name=default.awsmanagedmachinepool.infrastructure.cluster.x-k8s.io,sideEffects=None,admissionReviewVersions=v1;v1beta1 -var _ webhook.Defaulter = &AWSManagedMachinePool{} -var _ webhook.Validator = &AWSManagedMachinePool{} +var ( + _ webhook.Defaulter = &AWSManagedMachinePool{} + _ webhook.Validator = &AWSManagedMachinePool{} +) func (r *AWSManagedMachinePool) validateScaling() field.ErrorList { var allErrs field.ErrorList @@ -138,6 +140,10 @@ func (r *AWSManagedMachinePool) validateLaunchTemplate() field.ErrorList { return allErrs } +func (r *AWSManagedMachinePool) validateLifecycleHooks() field.ErrorList { + return validateLifecycleHooks(r.Spec.AWSLifecycleHooks) +} + // ValidateCreate will do any extra validation when creating a AWSManagedMachinePool. func (r *AWSManagedMachinePool) ValidateCreate() (admission.Warnings, error) { mmpLog.Info("AWSManagedMachinePool validate create", "managed-machine-pool", klog.KObj(r)) @@ -159,6 +165,9 @@ func (r *AWSManagedMachinePool) ValidateCreate() (admission.Warnings, error) { if errs := r.validateLaunchTemplate(); len(errs) > 0 { allErrs = append(allErrs, errs...) } + if errs := r.validateLifecycleHooks(); len(errs) > 0 { + allErrs = append(allErrs, errs...) + } allErrs = append(allErrs, r.Spec.AdditionalTags.Validate()...) @@ -196,6 +205,9 @@ func (r *AWSManagedMachinePool) ValidateUpdate(old runtime.Object) (admission.Wa if errs := r.validateLaunchTemplate(); len(errs) > 0 { allErrs = append(allErrs, errs...) } + if errs := r.validateLifecycleHooks(); len(errs) > 0 { + allErrs = append(allErrs, errs...) + } if len(allErrs) == 0 { return nil, nil diff --git a/exp/api/v1beta2/conditions_consts.go b/exp/api/v1beta2/conditions_consts.go index 2d052fae53..c1a83eb42c 100644 --- a/exp/api/v1beta2/conditions_consts.go +++ b/exp/api/v1beta2/conditions_consts.go @@ -54,6 +54,19 @@ const ( InstanceRefreshNotReadyReason = "InstanceRefreshNotReady" // InstanceRefreshFailedReason used to report when there instance refresh is not initiated. InstanceRefreshFailedReason = "InstanceRefreshFailed" + + // LifecycleHookReadyCondition reports on the status of the lifecycle hook. + LifecycleHookReadyCondition clusterv1.ConditionType = "LifecycleHookReady" + // LifecycleHookNotFoundReason used when the lifecycle hook couldn't be retrieved. + LifecycleHookNotFoundReason = "LifecycleHookNotFound" + // LifecycleHookExistsCondition reports on the existence of the lifecycle hook. + LifecycleHookExistsCondition clusterv1.ConditionType = "LifecycleHookExists" + // LifecycleHookCreationFailedReason used for failures during lifecycle hook creation. + LifecycleHookCreationFailedReason = "LifecycleHookCreationFailed" + // LifecycleHookUpdateFailedReason used for failures during lifecycle hook update. + LifecycleHookUpdateFailedReason = "LifecycleHookUpdateFailed" + // LifecycleHookDeletionFailedReason used for failures during lifecycle hook deletion. + LifecycleHookDeletionFailedReason = "LifecycleHookDeletionFailed" ) const ( diff --git a/exp/api/v1beta2/types.go b/exp/api/v1beta2/types.go index ef589c2951..d7867e0925 100644 --- a/exp/api/v1beta2/types.go +++ b/exp/api/v1beta2/types.go @@ -217,6 +217,71 @@ type AutoScalingGroup struct { CurrentlySuspendProcesses []string `json:"currentlySuspendProcesses,omitempty"` } +// AWSLifecycleHook describes an AWS lifecycle hook +type AWSLifecycleHook struct { + // The name of the lifecycle hook. + Name string `json:"name,omitempty"` + + // The ARN of the notification target that Amazon EC2 Auto Scaling uses to + // notify you when an instance is in the transition state for the lifecycle hook. + // +optional + NotificationTargetARN *string `json:"notificationTargetARN,omitempty"` + + // The ARN of the IAM role that allows the Auto Scaling group to publish to the + // specified notification target. + // +optional + RoleARN *string `json:"roleARN,omitempty"` + + // The state of the EC2 instance to which to attach the lifecycle hook. + // +kubebuilder:validation:Enum="autoscaling:EC2_INSTANCE_LAUNCHING";"autoscaling:EC2_INSTANCE_TERMINATING" + LifecycleTransition LifecycleTransition `json:"lifecycleTransition"` + + // The maximum time, in seconds, that an instance can remain in a Pending:Wait or + // Terminating:Wait state. The maximum is 172800 seconds (48 hours) or 100 times + // HeartbeatTimeout, whichever is smaller. + // +optional + // +kubebuilder:validation:Format=duration + HeartbeatTimeout *metav1.Duration `json:"heartbeatTimeout,omitempty"` + + // The default result for the lifecycle hook. The possible values are CONTINUE and ABANDON. + // +optional + // +kubebuilder:validation:Enum=CONTINUE;ABANDON + // +kubebuilder:validation:default:=none + DefaultResult *DefaultResult `json:"defaultResult,omitempty"` + + // Contains additional metadata that will be passed to the notification target. + // +optional + NotificationMetadata *string `json:"notificationMetadata,omitempty"` +} + +// LifecycleTransition is the state of the EC2 instance to which to attach the lifecycle hook. +type LifecycleTransition string + +const ( + // LifecycleTransitionInstanceLaunch is the launching state of the EC2 instance. + LifecycleTransitionInstanceLaunch LifecycleTransition = "autoscaling:EC2_INSTANCE_LAUNCHING" + // LifecycleTransitionInstanceTerminate is the terminating state of the EC2 instance. + LifecycleTransitionInstanceTerminate LifecycleTransition = "autoscaling:EC2_INSTANCE_TERMINATING" +) + +func (l *LifecycleTransition) String() string { + return string(*l) +} + +// DefaultResult is the default result for the lifecycle hook. +type DefaultResult string + +const ( + // DefaultResultContinue is the default result for the lifecycle hook to continue. + DefaultResultContinue DefaultResult = "CONTINUE" + // DefaultResultAbandon is the default result for the lifecycle hook to abandon. + DefaultResultAbandon DefaultResult = "ABANDON" +) + +func (d *DefaultResult) String() string { + return string(*d) +} + // ASGStatus is a status string returned by the autoscaling API. type ASGStatus string diff --git a/exp/api/v1beta2/zz_generated.deepcopy.go b/exp/api/v1beta2/zz_generated.deepcopy.go index e19e13c59b..20a47b36d5 100644 --- a/exp/api/v1beta2/zz_generated.deepcopy.go +++ b/exp/api/v1beta2/zz_generated.deepcopy.go @@ -141,6 +141,46 @@ func (in *AWSLaunchTemplate) DeepCopy() *AWSLaunchTemplate { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AWSLifecycleHook) DeepCopyInto(out *AWSLifecycleHook) { + *out = *in + if in.NotificationTargetARN != nil { + in, out := &in.NotificationTargetARN, &out.NotificationTargetARN + *out = new(string) + **out = **in + } + if in.RoleARN != nil { + in, out := &in.RoleARN, &out.RoleARN + *out = new(string) + **out = **in + } + if in.HeartbeatTimeout != nil { + in, out := &in.HeartbeatTimeout, &out.HeartbeatTimeout + *out = new(v1.Duration) + **out = **in + } + if in.DefaultResult != nil { + in, out := &in.DefaultResult, &out.DefaultResult + *out = new(DefaultResult) + **out = **in + } + if in.NotificationMetadata != nil { + in, out := &in.NotificationMetadata, &out.NotificationMetadata + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AWSLifecycleHook. +func (in *AWSLifecycleHook) DeepCopy() *AWSLifecycleHook { + if in == nil { + return nil + } + out := new(AWSLifecycleHook) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AWSMachinePool) DeepCopyInto(out *AWSMachinePool) { *out = *in @@ -270,6 +310,13 @@ func (in *AWSMachinePoolSpec) DeepCopyInto(out *AWSMachinePoolSpec) { *out = new(SuspendProcessesTypes) (*in).DeepCopyInto(*out) } + if in.AWSLifecycleHooks != nil { + in, out := &in.AWSLifecycleHooks, &out.AWSLifecycleHooks + *out = make([]AWSLifecycleHook, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AWSMachinePoolSpec. @@ -482,6 +529,13 @@ func (in *AWSManagedMachinePoolSpec) DeepCopyInto(out *AWSManagedMachinePoolSpec *out = new(AWSLaunchTemplate) (*in).DeepCopyInto(*out) } + if in.AWSLifecycleHooks != nil { + in, out := &in.AWSLifecycleHooks, &out.AWSLifecycleHooks + *out = make([]AWSLifecycleHook, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AWSManagedMachinePoolSpec. diff --git a/exp/controllers/awsmachinepool_controller.go b/exp/controllers/awsmachinepool_controller.go index 741cdcdb10..5585550070 100644 --- a/exp/controllers/awsmachinepool_controller.go +++ b/exp/controllers/awsmachinepool_controller.go @@ -298,6 +298,21 @@ func (r *AWSMachinePoolReconciler) reconcileNormal(ctx context.Context, machineP return nil } + lifecycleHookScope, err := scope.NewLifecycleHookScope(scope.LifecycleHookScopeParams{ + Client: r.Client, + Logger: &machinePoolScope.Logger, + MachinePool: machinePoolScope.MachinePool, + AWSMachinePool: machinePoolScope.AWSMachinePool, + }) + if err != nil { + return errors.Wrap(err, "failed to create lifecycle hook scope") + } + + if err := reconSvc.ReconcileLifecycleHooks(*lifecycleHookScope, asgsvc); err != nil { + r.Recorder.Eventf(machinePoolScope.AWSMachinePool, corev1.EventTypeWarning, "FaileLifecycleHooksReconcile", "Failed to reconcile lifecycle hooks: %v", err) + return errors.Wrap(err, "failed to reconcile lifecycle hooks") + } + if annotations.ReplicasManagedByExternalAutoscaler(machinePoolScope.MachinePool) { // Set MachinePool replicas to the ASG DesiredCapacity if *machinePoolScope.MachinePool.Spec.Replicas != *asg.DesiredCapacity { diff --git a/exp/controllers/awsmachinepool_controller_test.go b/exp/controllers/awsmachinepool_controller_test.go index 4902dbb7e7..02b53f4232 100644 --- a/exp/controllers/awsmachinepool_controller_test.go +++ b/exp/controllers/awsmachinepool_controller_test.go @@ -163,13 +163,14 @@ func TestAWSMachinePoolReconciler(t *testing.T) { recorder = record.NewFakeRecorder(2) reconciler = AWSMachinePoolReconciler{ + Client: testEnv.Client, ec2ServiceFactory: func(scope.EC2Scope) services.EC2Interface { return ec2Svc }, asgServiceFactory: func(cloud.ClusterScoper) services.ASGInterface { return asgSvc }, - reconcileServiceFactory: func(scope.EC2Scope) services.MachinePoolReconcileInterface { + reconcileServiceFactory: func(scope scope.EC2Scope) services.MachinePoolReconcileInterface { return reconSvc }, Recorder: recorder, @@ -323,6 +324,7 @@ func TestAWSMachinePoolReconciler(t *testing.T) { setSuspendedProcesses(t, g) ms.AWSMachinePool.Spec.SuspendProcesses.All = true reconSvc.EXPECT().ReconcileLaunchTemplate(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + reconSvc.EXPECT().ReconcileLifecycleHooks(gomock.Any(), asgSvc).Return(nil) reconSvc.EXPECT().ReconcileTags(gomock.Any(), gomock.Any()).Return(nil) asgSvc.EXPECT().GetASGByName(gomock.Any()).Return(&expinfrav1.AutoScalingGroup{ Name: "name", @@ -363,6 +365,7 @@ func TestAWSMachinePoolReconciler(t *testing.T) { setSuspendedProcesses(t, g) reconSvc.EXPECT().ReconcileLaunchTemplate(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + reconSvc.EXPECT().ReconcileLifecycleHooks(gomock.Any(), asgSvc).Return(nil) reconSvc.EXPECT().ReconcileTags(gomock.Any(), gomock.Any()).Return(nil) asgSvc.EXPECT().GetASGByName(gomock.Any()).Return(&expinfrav1.AutoScalingGroup{ Name: "name", @@ -388,6 +391,7 @@ func TestAWSMachinePoolReconciler(t *testing.T) { DesiredCapacity: ptr.To[int32](1), } reconSvc.EXPECT().ReconcileLaunchTemplate(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + reconSvc.EXPECT().ReconcileLifecycleHooks(gomock.Any(), asgSvc).Return(nil) asgSvc.EXPECT().GetASGByName(gomock.Any()).Return(&asg, nil) asgSvc.EXPECT().SubnetIDs(gomock.Any()).Return([]string{}, nil) asgSvc.EXPECT().UpdateASG(gomock.Any()).Return(nil) @@ -424,8 +428,10 @@ func TestAWSMachinePoolReconciler(t *testing.T) { }, }, }, - Subnets: []string{"subnet1", "subnet2"}} + Subnets: []string{"subnet1", "subnet2"}, + } reconSvc.EXPECT().ReconcileLaunchTemplate(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + reconSvc.EXPECT().ReconcileLifecycleHooks(gomock.Any(), asgSvc).Return(nil) reconSvc.EXPECT().ReconcileTags(gomock.Any(), gomock.Any()).Return(nil) asgSvc.EXPECT().GetASGByName(gomock.Any()).Return(&asg, nil).AnyTimes() asgSvc.EXPECT().SubnetIDs(gomock.Any()).Return([]string{"subnet2", "subnet1"}, nil).Times(1) @@ -442,8 +448,10 @@ func TestAWSMachinePoolReconciler(t *testing.T) { asg := expinfrav1.AutoScalingGroup{ MinSize: int32(0), MaxSize: int32(100), - Subnets: []string{"subnet1", "subnet2"}} + Subnets: []string{"subnet1", "subnet2"}, + } reconSvc.EXPECT().ReconcileLaunchTemplate(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + reconSvc.EXPECT().ReconcileLifecycleHooks(gomock.Any(), asgSvc).Return(nil) reconSvc.EXPECT().ReconcileTags(gomock.Any(), gomock.Any()).Return(nil) asgSvc.EXPECT().GetASGByName(gomock.Any()).Return(&asg, nil).AnyTimes() asgSvc.EXPECT().SubnetIDs(gomock.Any()).Return([]string{"subnet1"}, nil).Times(1) @@ -460,8 +468,10 @@ func TestAWSMachinePoolReconciler(t *testing.T) { asg := expinfrav1.AutoScalingGroup{ MinSize: int32(0), MaxSize: int32(2), - Subnets: []string{}} + Subnets: []string{}, + } reconSvc.EXPECT().ReconcileLaunchTemplate(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + reconSvc.EXPECT().ReconcileLifecycleHooks(gomock.Any(), asgSvc).Return(nil) reconSvc.EXPECT().ReconcileTags(gomock.Any(), gomock.Any()).Return(nil) asgSvc.EXPECT().GetASGByName(gomock.Any()).Return(&asg, nil).AnyTimes() asgSvc.EXPECT().SubnetIDs(gomock.Any()).Return([]string{}, nil).Times(1) @@ -535,6 +545,7 @@ func TestAWSMachinePoolReconciler(t *testing.T) { MixedInstancesPolicy: awsMachinePool.Spec.MixedInstancesPolicy.DeepCopy(), }, nil }) + asgSvc.EXPECT().GetLifecycleHooks(gomock.Any()).Return(nil, nil).AnyTimes() asgSvc.EXPECT().SubnetIDs(gomock.Any()).Return([]string{"subnet-1"}, nil) // no change // No changes, so there must not be an ASG update! asgSvc.EXPECT().UpdateASG(gomock.Any()).Times(0) @@ -588,6 +599,7 @@ func TestAWSMachinePoolReconciler(t *testing.T) { MixedInstancesPolicy: awsMachinePool.Spec.MixedInstancesPolicy.DeepCopy(), }, nil }) + asgSvc.EXPECT().GetLifecycleHooks(gomock.Any()) asgSvc.EXPECT().SubnetIDs(gomock.Any()).Return([]string{"subnet-1"}, nil) // no change // No changes, so there must not be an ASG update! asgSvc.EXPECT().UpdateASG(gomock.Any()).Times(0) @@ -644,6 +656,7 @@ func TestAWSMachinePoolReconciler(t *testing.T) { MixedInstancesPolicy: awsMachinePool.Spec.MixedInstancesPolicy.DeepCopy(), }, nil }) + asgSvc.EXPECT().GetLifecycleHooks(gomock.Any()) asgSvc.EXPECT().SubnetIDs(gomock.Any()).Return([]string{"subnet-1"}, nil) // no change // No changes, so there must not be an ASG update! asgSvc.EXPECT().UpdateASG(gomock.Any()).Times(0) @@ -728,6 +741,7 @@ func TestAWSMachinePoolReconciler(t *testing.T) { MixedInstancesPolicy: awsMachinePool.Spec.MixedInstancesPolicy.DeepCopy(), }, nil }) + asgSvc.EXPECT().GetLifecycleHooks(gomock.Any()) asgSvc.EXPECT().SubnetIDs(gomock.Any()).Return([]string{"subnet-1"}, nil) // no change // No changes, so there must not be an ASG update! asgSvc.EXPECT().UpdateASG(gomock.Any()).Times(0) @@ -800,8 +814,7 @@ func TestAWSMachinePoolReconciler(t *testing.T) { }) } -//TODO: This was taken from awsmachine_controller_test, i think it should be moved to elsewhere in both locations like test/helpers. - +// TODO: This was taken from awsmachine_controller_test, i think it should be moved to elsewhere in both locations like test/helpers. type conditionAssertion struct { conditionType clusterv1.ConditionType status corev1.ConditionStatus diff --git a/exp/controllers/awsmanagedmachinepool_controller.go b/exp/controllers/awsmanagedmachinepool_controller.go index 8c0d75c2ec..9ff6867257 100644 --- a/exp/controllers/awsmanagedmachinepool_controller.go +++ b/exp/controllers/awsmanagedmachinepool_controller.go @@ -18,6 +18,7 @@ package controllers import ( "context" + "fmt" "github.com/pkg/errors" corev1 "k8s.io/api/core/v1" @@ -34,10 +35,14 @@ import ( "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/reconcile" + infrav1 "sigs.k8s.io/cluster-api-provider-aws/v2/api/v1beta2" + "sigs.k8s.io/cluster-api-provider-aws/v2/controllers" ekscontrolplanev1 "sigs.k8s.io/cluster-api-provider-aws/v2/controlplane/eks/api/v1beta2" expinfrav1 "sigs.k8s.io/cluster-api-provider-aws/v2/exp/api/v1beta2" + "sigs.k8s.io/cluster-api-provider-aws/v2/pkg/cloud" "sigs.k8s.io/cluster-api-provider-aws/v2/pkg/cloud/scope" "sigs.k8s.io/cluster-api-provider-aws/v2/pkg/cloud/services" + asg "sigs.k8s.io/cluster-api-provider-aws/v2/pkg/cloud/services/autoscaling" "sigs.k8s.io/cluster-api-provider-aws/v2/pkg/cloud/services/ec2" "sigs.k8s.io/cluster-api-provider-aws/v2/pkg/cloud/services/eks" "sigs.k8s.io/cluster-api-provider-aws/v2/pkg/logger" @@ -52,6 +57,7 @@ import ( // AWSManagedMachinePoolReconciler reconciles a AWSManagedMachinePool object. type AWSManagedMachinePoolReconciler struct { client.Client + asgServiceFactory func(cloud.ClusterScoper) services.ASGInterface Recorder record.EventRecorder Endpoints []scope.ServiceEndpoint EnableIAM bool @@ -189,13 +195,23 @@ func (r *AWSManagedMachinePoolReconciler) Reconcile(ctx context.Context, req ctr return ctrl.Result{}, r.reconcileDelete(ctx, machinePoolScope, managedControlPlaneScope) } - return ctrl.Result{}, r.reconcileNormal(ctx, machinePoolScope, managedControlPlaneScope) + infraCluster, err := r.getInfraCluster(ctx, log, cluster, machinePoolScope.ManagedMachinePool) + if err != nil { + return ctrl.Result{}, fmt.Errorf("getting infra provider cluster or control plane object: %w", err) + } + if infraCluster == nil { + log.Info("AWSCluster or AWSManagedControlPlane is not ready yet") + return ctrl.Result{}, nil + } + + return ctrl.Result{}, r.reconcileNormal(ctx, machinePoolScope, managedControlPlaneScope, infraCluster) } func (r *AWSManagedMachinePoolReconciler) reconcileNormal( ctx context.Context, machinePoolScope *scope.ManagedMachinePoolScope, ec2Scope scope.EC2Scope, + clusterScope cloud.ClusterScoper, ) error { machinePoolScope.Info("Reconciling AWSManagedMachinePool") @@ -207,6 +223,7 @@ func (r *AWSManagedMachinePoolReconciler) reconcileNormal( ekssvc := eks.NewNodegroupService(machinePoolScope) ec2svc := r.getEC2Service(ec2Scope) + asgsvc := r.getASGService(clusterScope) reconSvc := r.getReconcileService(ec2Scope) if machinePoolScope.ManagedMachinePool.Spec.AWSLaunchTemplate != nil { @@ -240,6 +257,21 @@ func (r *AWSManagedMachinePoolReconciler) reconcileNormal( return errors.Wrapf(err, "failed to reconcile machine pool for AWSManagedMachinePool %s/%s", machinePoolScope.ManagedMachinePool.Namespace, machinePoolScope.ManagedMachinePool.Name) } + lifecycleHookScope, err := scope.NewLifecycleHookScope(scope.LifecycleHookScopeParams{ + Client: r.Client, + Logger: &machinePoolScope.Logger, + MachinePool: machinePoolScope.MachinePool, + AWSManagedMachinePool: machinePoolScope.ManagedMachinePool, + }) + if err != nil { + return errors.Wrap(err, "failed to create lifecycle hook scope") + } + + if err := reconSvc.ReconcileLifecycleHooks(*lifecycleHookScope, asgsvc); err != nil { + r.Recorder.Eventf(machinePoolScope.ManagedMachinePool, corev1.EventTypeWarning, "FaileLifecycleHooksReconcile", "Failed to reconcile lifecycle hooks: %v", err) + return errors.Wrap(err, "failed to reconcile lifecycle hooks") + } + return nil } @@ -349,6 +381,73 @@ func (r *AWSManagedMachinePoolReconciler) getEC2Service(scope scope.EC2Scope) se return ec2.NewService(scope) } +func (r *AWSManagedMachinePoolReconciler) getASGService(scope cloud.ClusterScoper) services.ASGInterface { + if r.asgServiceFactory != nil { + return r.asgServiceFactory(scope) + } + return asg.NewService(scope) +} + func (r *AWSManagedMachinePoolReconciler) getReconcileService(scope scope.EC2Scope) services.MachinePoolReconcileInterface { return ec2.NewService(scope) } + +func (r *AWSManagedMachinePoolReconciler) getInfraCluster(ctx context.Context, log *logger.Logger, cluster *clusterv1.Cluster, awsMachinePool *expinfrav1.AWSManagedMachinePool) (scope.EC2Scope, error) { + var clusterScope *scope.ClusterScope + var managedControlPlaneScope *scope.ManagedControlPlaneScope + var err error + + if cluster.Spec.ControlPlaneRef != nil && cluster.Spec.ControlPlaneRef.Kind == controllers.AWSManagedControlPlaneRefKind { + controlPlane := &ekscontrolplanev1.AWSManagedControlPlane{} + controlPlaneName := client.ObjectKey{ + Namespace: awsMachinePool.Namespace, + Name: cluster.Spec.ControlPlaneRef.Name, + } + + if err := r.Get(ctx, controlPlaneName, controlPlane); err != nil { + // AWSManagedControlPlane is not ready + return nil, nil //nolint:nilerr + } + + managedControlPlaneScope, err = scope.NewManagedControlPlaneScope(scope.ManagedControlPlaneScopeParams{ + Client: r.Client, + Logger: log, + Cluster: cluster, + ControlPlane: controlPlane, + ControllerName: "awsManagedControlPlane", + TagUnmanagedNetworkResources: r.TagUnmanagedNetworkResources, + }) + if err != nil { + return nil, err + } + + return managedControlPlaneScope, nil + } + + awsCluster := &infrav1.AWSCluster{} + + infraClusterName := client.ObjectKey{ + Namespace: awsMachinePool.Namespace, + Name: cluster.Spec.InfrastructureRef.Name, + } + + if err := r.Client.Get(ctx, infraClusterName, awsCluster); err != nil { + // AWSCluster is not ready + return nil, nil //nolint:nilerr + } + + // Create the cluster scope + clusterScope, err = scope.NewClusterScope(scope.ClusterScopeParams{ + Client: r.Client, + Logger: log, + Cluster: cluster, + AWSCluster: awsCluster, + ControllerName: "awsmachine", + TagUnmanagedNetworkResources: r.TagUnmanagedNetworkResources, + }) + if err != nil { + return nil, err + } + + return clusterScope, nil +} diff --git a/pkg/cloud/scope/lifecyclehook.go b/pkg/cloud/scope/lifecyclehook.go new file mode 100644 index 0000000000..730a5da47f --- /dev/null +++ b/pkg/cloud/scope/lifecyclehook.go @@ -0,0 +1,106 @@ +/* +Copyright 2022 The Kubernetes Authors. + +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 ( + "github.com/pkg/errors" + "k8s.io/klog/v2" + "sigs.k8s.io/controller-runtime/pkg/client" + + expinfrav1 "sigs.k8s.io/cluster-api-provider-aws/v2/exp/api/v1beta2" + "sigs.k8s.io/cluster-api-provider-aws/v2/pkg/logger" + expclusterv1 "sigs.k8s.io/cluster-api/exp/api/v1beta1" +) + +// LifecycleHookScope defines a scope defined around a machine pool and its lifecycle hooks. +type LifecycleHookScope struct { + client.Client + logger.Logger + + PoolName string + LifecycleHooks []expinfrav1.AWSLifecycleHook + MachinePool *expclusterv1.MachinePool + AWSMachinePool *expinfrav1.AWSMachinePool + AWSManagedMachinePool *expinfrav1.AWSManagedMachinePool +} + +// LifecycleHookScopeParams defines the input parameters used to create a new LifecycleHookScope. +type LifecycleHookScopeParams struct { + Client client.Client + Logger *logger.Logger + + MachinePool *expclusterv1.MachinePool + AWSMachinePool *expinfrav1.AWSMachinePool + AWSManagedMachinePool *expinfrav1.AWSManagedMachinePool +} + +// NewLifecycleHookScope creates a new LifecycleHookScope. +func NewLifecycleHookScope(params LifecycleHookScopeParams) (*LifecycleHookScope, error) { + if params.Client == nil { + return nil, errors.New("client is required when creating a LifecycleHookScope") + } + if params.MachinePool == nil { + return nil, errors.New("MachinePool is required when creating a LifecycleHookScope") + } + if params.AWSMachinePool == nil && params.AWSManagedMachinePool == nil { + return nil, errors.New("either AWSMachinePool or AWSManagedMachinePool is required when creating a LifecycleHookScope") + } + if params.AWSMachinePool != nil && params.AWSManagedMachinePool != nil { + return nil, errors.New("AWSMachinePool and AWSManagedMachinePool cannot be set at the same time") + } + + name := "" + lifecycleHooks := []expinfrav1.AWSLifecycleHook{} + if params.AWSMachinePool != nil { + name = params.AWSMachinePool.Name + lifecycleHooks = params.AWSMachinePool.Spec.AWSLifecycleHooks + } + if params.AWSManagedMachinePool != nil { + name = params.AWSManagedMachinePool.Name + lifecycleHooks = params.AWSManagedMachinePool.Spec.AWSLifecycleHooks + } + + if params.Logger == nil { + log := klog.Background() + params.Logger = logger.NewLogger(log) + } + + return &LifecycleHookScope{ + Client: params.Client, + Logger: *params.Logger, + PoolName: name, + LifecycleHooks: lifecycleHooks, + MachinePool: params.MachinePool, + AWSManagedMachinePool: params.AWSManagedMachinePool, + AWSMachinePool: params.AWSMachinePool, + }, nil +} + +// GetASGName returns the name of the AutoScalingGroup. +func (s *LifecycleHookScope) GetASGName() string { + return s.PoolName +} + +// GetLifecycleHooks returns the lifecycle hooks for the AutoScalingGroup. +func (s *LifecycleHookScope) GetLifecycleHooks() []expinfrav1.AWSLifecycleHook { + return s.LifecycleHooks +} + +// GetMachinePool returns the machine pool. +func (s *LifecycleHookScope) GetMachinePool() *expclusterv1.MachinePool { + return s.MachinePool +} diff --git a/pkg/cloud/services/autoscaling/autoscalinggroup.go b/pkg/cloud/services/autoscaling/autoscalinggroup.go index d473010d12..51ef52dac5 100644 --- a/pkg/cloud/services/autoscaling/autoscalinggroup.go +++ b/pkg/cloud/services/autoscaling/autoscalinggroup.go @@ -331,7 +331,7 @@ func (s *Service) CanStartASGInstanceRefresh(scope *scope.MachinePoolScope) (boo return false, err } hasUnfinishedRefresh := false - if err == nil && len(refreshes.InstanceRefreshes) != 0 { + if len(refreshes.InstanceRefreshes) != 0 { for i := range refreshes.InstanceRefreshes { if *refreshes.InstanceRefreshes[i].Status == autoscaling.InstanceRefreshStatusInProgress || *refreshes.InstanceRefreshes[i].Status == autoscaling.InstanceRefreshStatusPending || diff --git a/pkg/cloud/services/autoscaling/lifecyclehook.go b/pkg/cloud/services/autoscaling/lifecyclehook.go new file mode 100644 index 0000000000..37f3be9368 --- /dev/null +++ b/pkg/cloud/services/autoscaling/lifecyclehook.go @@ -0,0 +1,190 @@ +/* +Copyright 2018 The Kubernetes Authors. + +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 asg + +import ( + "context" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/autoscaling" + "github.com/pkg/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + expinfrav1 "sigs.k8s.io/cluster-api-provider-aws/v2/exp/api/v1beta2" + "sigs.k8s.io/cluster-api-provider-aws/v2/pkg/cloud/scope" +) + +// LifecycleHookNeedsUpdate returns true if the supplied expected lifecycle hook differs from the existing lifecycle hook. +func (s *Service) LifecycleHookNeedsUpdate(scope scope.LifecycleHookScope, existing *expinfrav1.AWSLifecycleHook, expected *expinfrav1.AWSLifecycleHook) bool { + return existing.DefaultResult != expected.DefaultResult || + existing.HeartbeatTimeout != expected.HeartbeatTimeout || + existing.LifecycleTransition != expected.LifecycleTransition || + existing.NotificationTargetARN != expected.NotificationTargetARN || + existing.NotificationMetadata != expected.NotificationMetadata +} + +// GetLifecycleHooks returns the lifecycle hooks for the given AutoScalingGroup after retrieving them from the AWS API. +func (s *Service) GetLifecycleHooks(scope scope.LifecycleHookScope) ([]*expinfrav1.AWSLifecycleHook, error) { + asgName := scope.GetASGName() + input := &autoscaling.DescribeLifecycleHooksInput{ + AutoScalingGroupName: aws.String(asgName), + } + + out, err := s.ASGClient.DescribeLifecycleHooksWithContext(context.TODO(), input) + if err != nil { + return nil, errors.Wrapf(err, "failed to describe lifecycle hooks for AutoScalingGroup: %q", scope.GetASGName()) + } + + hooks := make([]*expinfrav1.AWSLifecycleHook, len(out.LifecycleHooks)) + for i, hook := range out.LifecycleHooks { + hooks[i] = s.SDKToLifecycleHook(hook) + } + + return hooks, nil +} + +// GetLifecycleHook returns a specific lifecycle hook for the given AutoScalingGroup after retrieving it from the AWS API. +func (s *Service) GetLifecycleHook(scope scope.LifecycleHookScope, hook *expinfrav1.AWSLifecycleHook) (*expinfrav1.AWSLifecycleHook, error) { + asgName := scope.GetASGName() + input := &autoscaling.DescribeLifecycleHooksInput{ + AutoScalingGroupName: aws.String(asgName), + LifecycleHookNames: []*string{aws.String(hook.Name)}, + } + + out, err := s.ASGClient.DescribeLifecycleHooksWithContext(context.TODO(), input) + if err != nil { + return nil, errors.Wrapf(err, "failed to describe lifecycle hook %q for AutoScalingGroup: %q", hook.Name, scope.GetASGName()) + } + + if len(out.LifecycleHooks) == 0 { + return nil, nil + } + + return s.SDKToLifecycleHook(out.LifecycleHooks[0]), nil +} + +// CreateLifecycleHook creates a lifecycle hook for the given AutoScalingGroup. +func (s *Service) CreateLifecycleHook(scope scope.LifecycleHookScope, hook *expinfrav1.AWSLifecycleHook) error { + asgName := scope.GetASGName() + input := &autoscaling.PutLifecycleHookInput{ + AutoScalingGroupName: aws.String(asgName), + LifecycleHookName: aws.String(hook.Name), + LifecycleTransition: aws.String(hook.LifecycleTransition.String()), + } + + // Optional parameters + if hook.DefaultResult != nil { + input.DefaultResult = aws.String(hook.DefaultResult.String()) + } + + if hook.HeartbeatTimeout != nil { + timeoutSeconds := hook.HeartbeatTimeout.Duration.Seconds() + input.HeartbeatTimeout = aws.Int64(int64(timeoutSeconds)) + } + + if hook.NotificationTargetARN != nil { + input.NotificationTargetARN = hook.NotificationTargetARN + } + + if hook.RoleARN != nil { + input.RoleARN = hook.RoleARN + } + + if hook.NotificationMetadata != nil { + input.NotificationMetadata = hook.NotificationMetadata + } + + if _, err := s.ASGClient.PutLifecycleHookWithContext(context.TODO(), input); err != nil { + return errors.Wrapf(err, "failed to create lifecycle hook %q for AutoScalingGroup: %q", hook.Name, scope.GetASGName()) + } + + return nil +} + +// UpdateLifecycleHook updates a lifecycle hook for the given AutoScalingGroup. +func (s *Service) UpdateLifecycleHook(scope scope.LifecycleHookScope, hook *expinfrav1.AWSLifecycleHook) error { + asgName := scope.GetASGName() + input := &autoscaling.PutLifecycleHookInput{ + AutoScalingGroupName: aws.String(asgName), + LifecycleHookName: aws.String(hook.Name), + LifecycleTransition: aws.String(hook.LifecycleTransition.String()), + } + + // Optional parameters + if hook.DefaultResult != nil { + input.DefaultResult = aws.String(hook.DefaultResult.String()) + } + + if hook.HeartbeatTimeout != nil { + timeoutSeconds := hook.HeartbeatTimeout.Duration.Seconds() + input.HeartbeatTimeout = aws.Int64(int64(timeoutSeconds)) + } + + if hook.NotificationTargetARN != nil { + input.NotificationTargetARN = hook.NotificationTargetARN + } + + if hook.RoleARN != nil { + input.RoleARN = hook.RoleARN + } + + if hook.NotificationMetadata != nil { + input.NotificationMetadata = hook.NotificationMetadata + } + + if _, err := s.ASGClient.PutLifecycleHookWithContext(context.TODO(), input); err != nil { + return errors.Wrapf(err, "failed to update lifecycle hook %q for AutoScalingGroup: %q", hook.Name, scope.GetASGName()) + } + + return nil +} + +// DeleteLifecycleHook deletes a lifecycle hook for the given AutoScalingGroup. +func (s *Service) DeleteLifecycleHook( + scope scope.LifecycleHookScope, + hook *expinfrav1.AWSLifecycleHook, +) error { + input := &autoscaling.DeleteLifecycleHookInput{ + AutoScalingGroupName: aws.String(scope.GetASGName()), + LifecycleHookName: aws.String(hook.Name), + } + + if _, err := s.ASGClient.DeleteLifecycleHookWithContext(context.TODO(), input); err != nil { + return errors.Wrapf(err, "failed to delete lifecycle hook %q for AutoScalingGroup: %q", hook.Name, scope.GetASGName()) + } + + return nil +} + +// SDKToLifecycleHook converts an AWS SDK LifecycleHook to the CAPA lifecycle hook type. +func (s *Service) SDKToLifecycleHook(hook *autoscaling.LifecycleHook) *expinfrav1.AWSLifecycleHook { + timeoutDuration := time.Duration(*hook.HeartbeatTimeout) * time.Second + metav1Duration := metav1.Duration{Duration: timeoutDuration} + defaultResult := expinfrav1.DefaultResult(*hook.DefaultResult) + lifecycleTransition := expinfrav1.LifecycleTransition(*hook.LifecycleTransition) + + return &expinfrav1.AWSLifecycleHook{ + Name: *hook.LifecycleHookName, + DefaultResult: &defaultResult, + HeartbeatTimeout: &metav1Duration, + LifecycleTransition: lifecycleTransition, + NotificationTargetARN: hook.NotificationTargetARN, + RoleARN: hook.RoleARN, + NotificationMetadata: hook.NotificationMetadata, + } +} diff --git a/pkg/cloud/services/ec2/launchtemplate.go b/pkg/cloud/services/ec2/launchtemplate.go index 245248d7d2..a1c8a8db16 100644 --- a/pkg/cloud/services/ec2/launchtemplate.go +++ b/pkg/cloud/services/ec2/launchtemplate.go @@ -125,7 +125,7 @@ func (s *Service) ReconcileLaunchTemplate( } // Check if the instance tags were changed. If they were, create a new LaunchTemplate. - tagsChanged, _, _, _ := tagsChanged(annotation, scope.AdditionalTags()) //nolint:dogsled + tagsChanged, _, _, _ := TagsChanged(annotation, scope.AdditionalTags()) //nolint:dogsled needsUpdate, err := ec2svc.LaunchTemplateNeedsUpdate(scope, scope.GetLaunchTemplate(), launchTemplate) if err != nil { @@ -210,7 +210,7 @@ func (s *Service) ensureTags(scope scope.LaunchTemplateScope, resourceServicesTo // It would be possible here to only send new/updated tags, but for the // moment we send everything, even if only a single tag was created or // upated. - changed, created, deleted, newAnnotation := tagsChanged(annotation, additionalTags) + changed, created, deleted, newAnnotation := TagsChanged(annotation, additionalTags) if changed { for _, resourceServiceToUpdate := range resourceServicesToUpdate { err := resourceServiceToUpdate.ResourceService.UpdateResourceTags(resourceServiceToUpdate.ResourceID, created, deleted) @@ -276,8 +276,8 @@ func updateMachinePoolAnnotation(lts scope.LaunchTemplateScope, annotation, cont lts.GetObjectMeta().SetAnnotations(annotations) } -// tagsChanged determines which tags to delete and which to add. -func tagsChanged(annotation map[string]interface{}, src map[string]string) (bool, map[string]string, map[string]string, map[string]interface{}) { +// TagsChanged determines which tags to delete and which to add. +func TagsChanged(annotation map[string]interface{}, src map[string]string) (bool, map[string]string, map[string]string, map[string]interface{}) { // Bool tracking if we found any changed state. changed := false diff --git a/pkg/cloud/services/ec2/lifecyclehook.go b/pkg/cloud/services/ec2/lifecyclehook.go new file mode 100644 index 0000000000..055458be75 --- /dev/null +++ b/pkg/cloud/services/ec2/lifecyclehook.go @@ -0,0 +1,91 @@ +/* +Copyright 2018 The Kubernetes Authors. + +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 ec2 + +import ( + expinfrav1 "sigs.k8s.io/cluster-api-provider-aws/v2/exp/api/v1beta2" + "sigs.k8s.io/cluster-api-provider-aws/v2/pkg/cloud/scope" + "sigs.k8s.io/cluster-api-provider-aws/v2/pkg/cloud/services" + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + "sigs.k8s.io/cluster-api/util/conditions" +) + +// ReconcileLifecycleHooks periodically reconciles a lifecycle hook for the ASG. +func (s *Service) ReconcileLifecycleHooks(scope scope.LifecycleHookScope, asgsvc services.ASGInterface) error { + lifecyleHooks := scope.GetLifecycleHooks() + for i := range lifecyleHooks { + if err := s.reconcileLifecycleHook(scope, asgsvc, &lifecyleHooks[i]); err != nil { + return err + } + } + + // Get a list of lifecycle hooks that are registered with the ASG but not defined in the MachinePool and delete them. + hooks, err := asgsvc.GetLifecycleHooks(scope) + if err != nil { + return err + } + for _, hook := range hooks { + found := false + for _, definedHook := range scope.GetLifecycleHooks() { + if hook.Name == definedHook.Name { + found = true + break + } + } + if !found { + scope.Info("Deleting lifecycle hook", "hook", hook.Name) + if err := asgsvc.DeleteLifecycleHook(scope, hook); err != nil { + conditions.MarkFalse(scope.GetMachinePool(), expinfrav1.LifecycleHookExistsCondition, expinfrav1.LifecycleHookDeletionFailedReason, clusterv1.ConditionSeverityError, err.Error()) + return err + } + } + } + + return nil +} + +func (s *Service) reconcileLifecycleHook(scope scope.LifecycleHookScope, asgsvc services.ASGInterface, hook *expinfrav1.AWSLifecycleHook) error { + scope.Info("Checking for existing lifecycle hook") + existingHook, err := asgsvc.GetLifecycleHook(scope, hook) + if err != nil { + conditions.MarkUnknown(scope.GetMachinePool(), expinfrav1.LifecycleHookReadyCondition, expinfrav1.LifecycleHookNotFoundReason, err.Error()) + return err + } + + if existingHook == nil { + scope.Info("Creating lifecycle hook") + if err := asgsvc.CreateLifecycleHook(scope, hook); err != nil { + conditions.MarkFalse(scope.GetMachinePool(), expinfrav1.LifecycleHookExistsCondition, expinfrav1.LifecycleHookCreationFailedReason, clusterv1.ConditionSeverityError, err.Error()) + return err + } + return nil + } + + // If the lifecycle hook exists, we need to check if it's up to date + needsUpdate := asgsvc.LifecycleHookNeedsUpdate(scope, existingHook, hook) + + if needsUpdate { + scope.Info("Updating lifecycle hook") + if err := asgsvc.UpdateLifecycleHook(scope, hook); err != nil { + conditions.MarkFalse(scope.GetMachinePool(), expinfrav1.LifecycleHookExistsCondition, expinfrav1.LifecycleHookUpdateFailedReason, clusterv1.ConditionSeverityError, err.Error()) + return err + } + } + + conditions.MarkTrue(scope.GetMachinePool(), expinfrav1.LifecycleHookExistsCondition) + return nil +} diff --git a/pkg/cloud/services/eks/nodegroup.go b/pkg/cloud/services/eks/nodegroup.go index 763d14b494..da7e08e968 100644 --- a/pkg/cloud/services/eks/nodegroup.go +++ b/pkg/cloud/services/eks/nodegroup.go @@ -150,7 +150,7 @@ func (s *NodegroupService) remoteAccess() (*eks.RemoteAccessConfig, error) { // SourceSecurityGroups is validated to be empty if PublicAccess is true // but just in case we use an empty list to take advantage of the documented // API behavior - var sSGs = []string{} + sSGs := []string{} if !pool.RemoteAccess.Public { sSGs = pool.RemoteAccess.SourceSecurityGroups diff --git a/pkg/cloud/services/interfaces.go b/pkg/cloud/services/interfaces.go index f993d9bd84..45f6bc18ac 100644 --- a/pkg/cloud/services/interfaces.go +++ b/pkg/cloud/services/interfaces.go @@ -50,6 +50,12 @@ type ASGInterface interface { SuspendProcesses(name string, processes []string) error ResumeProcesses(name string, processes []string) error SubnetIDs(scope *scope.MachinePoolScope) ([]string, error) + GetLifecycleHooks(scope scope.LifecycleHookScope) ([]*expinfrav1.AWSLifecycleHook, error) + GetLifecycleHook(scope scope.LifecycleHookScope, hook *expinfrav1.AWSLifecycleHook) (*expinfrav1.AWSLifecycleHook, error) + CreateLifecycleHook(scope scope.LifecycleHookScope, hook *expinfrav1.AWSLifecycleHook) error + UpdateLifecycleHook(scope scope.LifecycleHookScope, hook *expinfrav1.AWSLifecycleHook) error + DeleteLifecycleHook(scope scope.LifecycleHookScope, hook *expinfrav1.AWSLifecycleHook) error + LifecycleHookNeedsUpdate(scope scope.LifecycleHookScope, incoming *expinfrav1.AWSLifecycleHook, existing *expinfrav1.AWSLifecycleHook) bool } // EC2Interface encapsulates the methods exposed to the machine @@ -92,6 +98,7 @@ type EC2Interface interface { // separate from EC2Interface so that we can mock AWS requests separately. For example, by not mocking the // ReconcileLaunchTemplate function, but mocking EC2Interface, we can test which EC2 API operations would have been called. type MachinePoolReconcileInterface interface { + ReconcileLifecycleHooks(scope scope.LifecycleHookScope, asgsvc ASGInterface) error ReconcileLaunchTemplate(scope scope.LaunchTemplateScope, ec2svc EC2Interface, canUpdateLaunchTemplate func() (bool, error), runPostLaunchTemplateUpdateOperation func() error) error ReconcileTags(scope scope.LaunchTemplateScope, resourceServicesToUpdate []scope.ResourceServiceToUpdate) error } diff --git a/pkg/cloud/services/mock_services/autoscaling_interface_mock.go b/pkg/cloud/services/mock_services/autoscaling_interface_mock.go index b860077f4f..1850bf4d88 100644 --- a/pkg/cloud/services/mock_services/autoscaling_interface_mock.go +++ b/pkg/cloud/services/mock_services/autoscaling_interface_mock.go @@ -96,6 +96,20 @@ func (mr *MockASGInterfaceMockRecorder) CreateASG(arg0 interface{}) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateASG", reflect.TypeOf((*MockASGInterface)(nil).CreateASG), arg0) } +// CreateLifecycleHook mocks base method. +func (m *MockASGInterface) CreateLifecycleHook(arg0 scope.LifecycleHookScope, arg1 *v1beta2.AWSLifecycleHook) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateLifecycleHook", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateLifecycleHook indicates an expected call of CreateLifecycleHook. +func (mr *MockASGInterfaceMockRecorder) CreateLifecycleHook(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateLifecycleHook", reflect.TypeOf((*MockASGInterface)(nil).CreateLifecycleHook), arg0, arg1) +} + // DeleteASGAndWait mocks base method. func (m *MockASGInterface) DeleteASGAndWait(arg0 string) error { m.ctrl.T.Helper() @@ -110,6 +124,20 @@ func (mr *MockASGInterfaceMockRecorder) DeleteASGAndWait(arg0 interface{}) *gomo return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteASGAndWait", reflect.TypeOf((*MockASGInterface)(nil).DeleteASGAndWait), arg0) } +// DeleteLifecycleHook mocks base method. +func (m *MockASGInterface) DeleteLifecycleHook(arg0 scope.LifecycleHookScope, arg1 *v1beta2.AWSLifecycleHook) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteLifecycleHook", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteLifecycleHook indicates an expected call of DeleteLifecycleHook. +func (mr *MockASGInterfaceMockRecorder) DeleteLifecycleHook(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteLifecycleHook", reflect.TypeOf((*MockASGInterface)(nil).DeleteLifecycleHook), arg0, arg1) +} + // GetASGByName mocks base method. func (m *MockASGInterface) GetASGByName(arg0 *scope.MachinePoolScope) (*v1beta2.AutoScalingGroup, error) { m.ctrl.T.Helper() @@ -125,6 +153,50 @@ func (mr *MockASGInterfaceMockRecorder) GetASGByName(arg0 interface{}) *gomock.C return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetASGByName", reflect.TypeOf((*MockASGInterface)(nil).GetASGByName), arg0) } +// GetLifecycleHook mocks base method. +func (m *MockASGInterface) GetLifecycleHook(arg0 scope.LifecycleHookScope, arg1 *v1beta2.AWSLifecycleHook) (*v1beta2.AWSLifecycleHook, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetLifecycleHook", arg0, arg1) + ret0, _ := ret[0].(*v1beta2.AWSLifecycleHook) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetLifecycleHook indicates an expected call of GetLifecycleHook. +func (mr *MockASGInterfaceMockRecorder) GetLifecycleHook(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLifecycleHook", reflect.TypeOf((*MockASGInterface)(nil).GetLifecycleHook), arg0, arg1) +} + +// GetLifecycleHooks mocks base method. +func (m *MockASGInterface) GetLifecycleHooks(arg0 scope.LifecycleHookScope) ([]*v1beta2.AWSLifecycleHook, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetLifecycleHooks", arg0) + ret0, _ := ret[0].([]*v1beta2.AWSLifecycleHook) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetLifecycleHooks indicates an expected call of GetLifecycleHooks. +func (mr *MockASGInterfaceMockRecorder) GetLifecycleHooks(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLifecycleHooks", reflect.TypeOf((*MockASGInterface)(nil).GetLifecycleHooks), arg0) +} + +// LifecycleHookNeedsUpdate mocks base method. +func (m *MockASGInterface) LifecycleHookNeedsUpdate(arg0 scope.LifecycleHookScope, arg1, arg2 *v1beta2.AWSLifecycleHook) bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LifecycleHookNeedsUpdate", arg0, arg1, arg2) + ret0, _ := ret[0].(bool) + return ret0 +} + +// LifecycleHookNeedsUpdate indicates an expected call of LifecycleHookNeedsUpdate. +func (mr *MockASGInterfaceMockRecorder) LifecycleHookNeedsUpdate(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LifecycleHookNeedsUpdate", reflect.TypeOf((*MockASGInterface)(nil).LifecycleHookNeedsUpdate), arg0, arg1, arg2) +} + // ResumeProcesses mocks base method. func (m *MockASGInterface) ResumeProcesses(arg0 string, arg1 []string) error { m.ctrl.T.Helper() @@ -196,6 +268,20 @@ func (mr *MockASGInterfaceMockRecorder) UpdateASG(arg0 interface{}) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateASG", reflect.TypeOf((*MockASGInterface)(nil).UpdateASG), arg0) } +// UpdateLifecycleHook mocks base method. +func (m *MockASGInterface) UpdateLifecycleHook(arg0 scope.LifecycleHookScope, arg1 *v1beta2.AWSLifecycleHook) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateLifecycleHook", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateLifecycleHook indicates an expected call of UpdateLifecycleHook. +func (mr *MockASGInterfaceMockRecorder) UpdateLifecycleHook(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateLifecycleHook", reflect.TypeOf((*MockASGInterface)(nil).UpdateLifecycleHook), arg0, arg1) +} + // UpdateResourceTags mocks base method. func (m *MockASGInterface) UpdateResourceTags(arg0 *string, arg1, arg2 map[string]string) error { m.ctrl.T.Helper() diff --git a/pkg/cloud/services/mock_services/reconcile_interface_mock.go b/pkg/cloud/services/mock_services/reconcile_interface_mock.go index 3771e81e3a..002e6f32b6 100644 --- a/pkg/cloud/services/mock_services/reconcile_interface_mock.go +++ b/pkg/cloud/services/mock_services/reconcile_interface_mock.go @@ -65,6 +65,20 @@ func (mr *MockMachinePoolReconcileInterfaceMockRecorder) ReconcileLaunchTemplate return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReconcileLaunchTemplate", reflect.TypeOf((*MockMachinePoolReconcileInterface)(nil).ReconcileLaunchTemplate), arg0, arg1, arg2, arg3) } +// ReconcileLifecycleHooks mocks base method. +func (m *MockMachinePoolReconcileInterface) ReconcileLifecycleHooks(arg0 scope.LifecycleHookScope, arg1 services.ASGInterface) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReconcileLifecycleHooks", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// ReconcileLifecycleHooks indicates an expected call of ReconcileLifecycleHooks. +func (mr *MockMachinePoolReconcileInterfaceMockRecorder) ReconcileLifecycleHooks(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReconcileLifecycleHooks", reflect.TypeOf((*MockMachinePoolReconcileInterface)(nil).ReconcileLifecycleHooks), arg0, arg1) +} + // ReconcileTags mocks base method. func (m *MockMachinePoolReconcileInterface) ReconcileTags(arg0 scope.LaunchTemplateScope, arg1 []scope.ResourceServiceToUpdate) error { m.ctrl.T.Helper()