diff --git a/cmd/clusterawsadm/cloudformation/bootstrap/cluster_api_controller.go b/cmd/clusterawsadm/cloudformation/bootstrap/cluster_api_controller.go index 049de10431..52ab2b5112 100644 --- a/cmd/clusterawsadm/cloudformation/bootstrap/cluster_api_controller.go +++ b/cmd/clusterawsadm/cloudformation/bootstrap/cluster_api_controller.go @@ -177,6 +177,9 @@ func (t Template) ControllersPolicy() *iamv1.PolicyDocument { "elasticloadbalancing:DeleteListener", "autoscaling:DescribeAutoScalingGroups", "autoscaling:DescribeInstanceRefreshes", + "autoscaling:DeleteLifecycleHook", + "autoscaling:DescribeLifecycleHooks", + "autoscaling:PutLifecycleHook", "ec2:CreateLaunchTemplate", "ec2:CreateLaunchTemplateVersion", "ec2:DescribeLaunchTemplates", diff --git a/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/customsuffix.yaml b/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/customsuffix.yaml index 7909fe12d5..6b4f907f72 100644 --- a/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/customsuffix.yaml +++ b/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/customsuffix.yaml @@ -237,6 +237,9 @@ Resources: - elasticloadbalancing:DeleteListener - autoscaling:DescribeAutoScalingGroups - autoscaling:DescribeInstanceRefreshes + - autoscaling:DeleteLifecycleHook + - autoscaling:DescribeLifecycleHooks + - autoscaling:PutLifecycleHook - ec2:CreateLaunchTemplate - ec2:CreateLaunchTemplateVersion - ec2:DescribeLaunchTemplates diff --git a/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/default.yaml b/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/default.yaml index a9290741ba..a3c9102ab2 100644 --- a/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/default.yaml +++ b/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/default.yaml @@ -237,6 +237,9 @@ Resources: - elasticloadbalancing:DeleteListener - autoscaling:DescribeAutoScalingGroups - autoscaling:DescribeInstanceRefreshes + - autoscaling:DeleteLifecycleHook + - autoscaling:DescribeLifecycleHooks + - autoscaling:PutLifecycleHook - ec2:CreateLaunchTemplate - ec2:CreateLaunchTemplateVersion - ec2:DescribeLaunchTemplates diff --git a/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_all_secret_backends.yaml b/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_all_secret_backends.yaml index fa7b5a4d95..3b0ced5ac5 100644 --- a/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_all_secret_backends.yaml +++ b/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_all_secret_backends.yaml @@ -243,6 +243,9 @@ Resources: - elasticloadbalancing:DeleteListener - autoscaling:DescribeAutoScalingGroups - autoscaling:DescribeInstanceRefreshes + - autoscaling:DeleteLifecycleHook + - autoscaling:DescribeLifecycleHooks + - autoscaling:PutLifecycleHook - ec2:CreateLaunchTemplate - ec2:CreateLaunchTemplateVersion - ec2:DescribeLaunchTemplates diff --git a/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_allow_assume_role.yaml b/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_allow_assume_role.yaml index 2390d86097..5cebf6e7ab 100644 --- a/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_allow_assume_role.yaml +++ b/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_allow_assume_role.yaml @@ -237,6 +237,9 @@ Resources: - elasticloadbalancing:DeleteListener - autoscaling:DescribeAutoScalingGroups - autoscaling:DescribeInstanceRefreshes + - autoscaling:DeleteLifecycleHook + - autoscaling:DescribeLifecycleHooks + - autoscaling:PutLifecycleHook - ec2:CreateLaunchTemplate - ec2:CreateLaunchTemplateVersion - ec2:DescribeLaunchTemplates diff --git a/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_bootstrap_user.yaml b/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_bootstrap_user.yaml index 930b879c2e..c71cb9d6ad 100644 --- a/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_bootstrap_user.yaml +++ b/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_bootstrap_user.yaml @@ -243,6 +243,9 @@ Resources: - elasticloadbalancing:DeleteListener - autoscaling:DescribeAutoScalingGroups - autoscaling:DescribeInstanceRefreshes + - autoscaling:DeleteLifecycleHook + - autoscaling:DescribeLifecycleHooks + - autoscaling:PutLifecycleHook - ec2:CreateLaunchTemplate - ec2:CreateLaunchTemplateVersion - ec2:DescribeLaunchTemplates diff --git a/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_custom_bootstrap_user.yaml b/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_custom_bootstrap_user.yaml index 50b9bb3182..aa3db2c042 100644 --- a/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_custom_bootstrap_user.yaml +++ b/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_custom_bootstrap_user.yaml @@ -243,6 +243,9 @@ Resources: - elasticloadbalancing:DeleteListener - autoscaling:DescribeAutoScalingGroups - autoscaling:DescribeInstanceRefreshes + - autoscaling:DeleteLifecycleHook + - autoscaling:DescribeLifecycleHooks + - autoscaling:PutLifecycleHook - ec2:CreateLaunchTemplate - ec2:CreateLaunchTemplateVersion - ec2:DescribeLaunchTemplates diff --git a/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_different_instance_profiles.yaml b/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_different_instance_profiles.yaml index 478967b404..9aea893cc7 100644 --- a/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_different_instance_profiles.yaml +++ b/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_different_instance_profiles.yaml @@ -237,6 +237,9 @@ Resources: - elasticloadbalancing:DeleteListener - autoscaling:DescribeAutoScalingGroups - autoscaling:DescribeInstanceRefreshes + - autoscaling:DeleteLifecycleHook + - autoscaling:DescribeLifecycleHooks + - autoscaling:PutLifecycleHook - ec2:CreateLaunchTemplate - ec2:CreateLaunchTemplateVersion - ec2:DescribeLaunchTemplates diff --git a/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_eks_console.yaml b/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_eks_console.yaml index ae2e279062..dea39d02d9 100644 --- a/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_eks_console.yaml +++ b/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_eks_console.yaml @@ -237,6 +237,9 @@ Resources: - elasticloadbalancing:DeleteListener - autoscaling:DescribeAutoScalingGroups - autoscaling:DescribeInstanceRefreshes + - autoscaling:DeleteLifecycleHook + - autoscaling:DescribeLifecycleHooks + - autoscaling:PutLifecycleHook - ec2:CreateLaunchTemplate - ec2:CreateLaunchTemplateVersion - ec2:DescribeLaunchTemplates diff --git a/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_eks_default_roles.yaml b/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_eks_default_roles.yaml index 3ca015276a..789f347fcc 100644 --- a/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_eks_default_roles.yaml +++ b/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_eks_default_roles.yaml @@ -237,6 +237,9 @@ Resources: - elasticloadbalancing:DeleteListener - autoscaling:DescribeAutoScalingGroups - autoscaling:DescribeInstanceRefreshes + - autoscaling:DeleteLifecycleHook + - autoscaling:DescribeLifecycleHooks + - autoscaling:PutLifecycleHook - ec2:CreateLaunchTemplate - ec2:CreateLaunchTemplateVersion - ec2:DescribeLaunchTemplates diff --git a/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_eks_disable.yaml b/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_eks_disable.yaml index 57c08e20cc..c092783d60 100644 --- a/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_eks_disable.yaml +++ b/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_eks_disable.yaml @@ -237,6 +237,9 @@ Resources: - elasticloadbalancing:DeleteListener - autoscaling:DescribeAutoScalingGroups - autoscaling:DescribeInstanceRefreshes + - autoscaling:DeleteLifecycleHook + - autoscaling:DescribeLifecycleHooks + - autoscaling:PutLifecycleHook - ec2:CreateLaunchTemplate - ec2:CreateLaunchTemplateVersion - ec2:DescribeLaunchTemplates diff --git a/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_eks_kms_prefix.yaml b/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_eks_kms_prefix.yaml index 0bacb55e5c..f34f670fbf 100644 --- a/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_eks_kms_prefix.yaml +++ b/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_eks_kms_prefix.yaml @@ -237,6 +237,9 @@ Resources: - elasticloadbalancing:DeleteListener - autoscaling:DescribeAutoScalingGroups - autoscaling:DescribeInstanceRefreshes + - autoscaling:DeleteLifecycleHook + - autoscaling:DescribeLifecycleHooks + - autoscaling:PutLifecycleHook - ec2:CreateLaunchTemplate - ec2:CreateLaunchTemplateVersion - ec2:DescribeLaunchTemplates diff --git a/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_extra_statements.yaml b/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_extra_statements.yaml index b864e1c1b3..ad61a26906 100644 --- a/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_extra_statements.yaml +++ b/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_extra_statements.yaml @@ -243,6 +243,9 @@ Resources: - elasticloadbalancing:DeleteListener - autoscaling:DescribeAutoScalingGroups - autoscaling:DescribeInstanceRefreshes + - autoscaling:DeleteLifecycleHook + - autoscaling:DescribeLifecycleHooks + - autoscaling:PutLifecycleHook - ec2:CreateLaunchTemplate - ec2:CreateLaunchTemplateVersion - ec2:DescribeLaunchTemplates diff --git a/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_s3_bucket.yaml b/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_s3_bucket.yaml index b376d7cab8..9e84b2f223 100644 --- a/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_s3_bucket.yaml +++ b/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_s3_bucket.yaml @@ -237,6 +237,9 @@ Resources: - elasticloadbalancing:DeleteListener - autoscaling:DescribeAutoScalingGroups - autoscaling:DescribeInstanceRefreshes + - autoscaling:DeleteLifecycleHook + - autoscaling:DescribeLifecycleHooks + - autoscaling:PutLifecycleHook - ec2:CreateLaunchTemplate - ec2:CreateLaunchTemplateVersion - ec2:DescribeLaunchTemplates diff --git a/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_ssm_secret_backend.yaml b/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_ssm_secret_backend.yaml index edc07671d6..a11d38e58f 100644 --- a/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_ssm_secret_backend.yaml +++ b/cmd/clusterawsadm/cloudformation/bootstrap/fixtures/with_ssm_secret_backend.yaml @@ -237,6 +237,9 @@ Resources: - elasticloadbalancing:DeleteListener - autoscaling:DescribeAutoScalingGroups - autoscaling:DescribeInstanceRefreshes + - autoscaling:DeleteLifecycleHook + - autoscaling:DescribeLifecycleHooks + - autoscaling:PutLifecycleHook - ec2:CreateLaunchTemplate - ec2:CreateLaunchTemplateVersion - ec2:DescribeLaunchTemplates 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 6314fe7c62..8bcbaed4db 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmachinepools.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmachinepools.yaml @@ -872,6 +872,55 @@ 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 autoscaling + 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 + - name + 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 9ecac67715..12627475fe 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmanagedmachinepools.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmanagedmachinepools.yaml @@ -879,6 +879,55 @@ 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 + - name + 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 7c39f1fcbd..194ba103ba 100644 --- a/exp/api/v1beta1/conversion.go +++ b/exp/api/v1beta1/conversion.go @@ -52,6 +52,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 @@ -113,6 +116,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 585cbd1504..153b4a55b8 100644 --- a/exp/api/v1beta1/zz_generated.conversion.go +++ b/exp/api/v1beta1/zz_generated.conversion.go @@ -565,6 +565,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 } @@ -741,6 +742,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 526876bcfd..d848d816df 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 autoscaling 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 a4f6a44d41..0ef2e001e3 100644 --- a/exp/api/v1beta2/awsmachinepool_webhook.go +++ b/exp/api/v1beta2/awsmachinepool_webhook.go @@ -162,6 +162,10 @@ func (r *AWSMachinePool) validateRefreshPreferences() field.ErrorList { return allErrs } +func (r *AWSMachinePool) validateLifecycleHooks() field.ErrorList { + return validateLifecycleHooks(r.Spec.AWSLifecycleHooks) +} + // 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)) @@ -176,6 +180,7 @@ func (r *AWSMachinePool) ValidateCreate() (admission.Warnings, error) { allErrs = append(allErrs, r.validateAdditionalSecurityGroups()...) allErrs = append(allErrs, r.validateSpotInstances()...) allErrs = append(allErrs, r.validateRefreshPreferences()...) + allErrs = append(allErrs, r.validateLifecycleHooks()...) if len(allErrs) == 0 { return nil, nil @@ -198,6 +203,7 @@ func (r *AWSMachinePool) ValidateUpdate(_ runtime.Object) (admission.Warnings, e allErrs = append(allErrs, r.validateAdditionalSecurityGroups()...) allErrs = append(allErrs, r.validateSpotInstances()...) allErrs = append(allErrs, r.validateRefreshPreferences()...) + 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 0f14ad1c0a..bdb5755b79 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" @@ -41,9 +42,9 @@ func TestAWSMachinePoolValidateCreate(t *testing.T) { g := NewWithT(t) tests := []struct { - name string - pool *AWSMachinePool - wantErr bool + name string + pool *AWSMachinePool + wantErrToContain *string }{ { name: "pool with valid tags is accepted", @@ -55,8 +56,7 @@ func TestAWSMachinePoolValidateCreate(t *testing.T) { }, }, }, - - wantErr: false, + wantErrToContain: nil, }, { name: "invalid tags are rejected", @@ -70,7 +70,7 @@ func TestAWSMachinePoolValidateCreate(t *testing.T) { }, }, }, - wantErr: true, + wantErrToContain: ptr.To[string]("additionalTags"), }, { name: "Should fail if additional security groups are provided with both ID and Filters", @@ -87,7 +87,7 @@ func TestAWSMachinePoolValidateCreate(t *testing.T) { }}}, }, }, - wantErr: true, + wantErrToContain: ptr.To[string]("filter"), }, { name: "Should fail if both subnet ID and filters passed in AWSMachinePool spec", @@ -105,7 +105,7 @@ func TestAWSMachinePoolValidateCreate(t *testing.T) { }, }, }, - wantErr: true, + wantErrToContain: ptr.To[string]("filter"), }, { name: "Should pass if either subnet ID or filters passed in AWSMachinePool spec", @@ -122,7 +122,7 @@ func TestAWSMachinePoolValidateCreate(t *testing.T) { }, }, }, - wantErr: false, + wantErrToContain: nil, }, { name: "Ensure root volume with device name works (for clusterctl move)", @@ -137,7 +137,7 @@ func TestAWSMachinePoolValidateCreate(t *testing.T) { }, }, }, - wantErr: false, + wantErrToContain: nil, }, { name: "Should fail if both spot market options or mixed instances policy are set", @@ -151,7 +151,7 @@ func TestAWSMachinePoolValidateCreate(t *testing.T) { }, }, }, - wantErr: true, + wantErrToContain: ptr.To[string]("spotMarketOptions"), }, { name: "Should fail if MaxHealthyPercentage is set, but MinHealthyPercentage is not set", @@ -160,7 +160,7 @@ func TestAWSMachinePoolValidateCreate(t *testing.T) { RefreshPreferences: &RefreshPreferences{MaxHealthyPercentage: aws.Int64(100)}, }, }, - wantErr: true, + wantErrToContain: ptr.To[string]("minHealthyPercentage"), }, { name: "Should fail if the difference between MaxHealthyPercentage and MinHealthyPercentage is greater than 100", @@ -172,14 +172,98 @@ func TestAWSMachinePoolValidateCreate(t *testing.T) { }, }, }, - wantErr: true, + wantErrToContain: ptr.To[string]("minHealthyPercentage"), + }, + { + name: "Should fail if lifecycle hook only has roleARN, but not notificationTargetARN", + pool: &AWSMachinePool{ + Spec: AWSMachinePoolSpec{ + AWSLifecycleHooks: []AWSLifecycleHook{ + { + Name: "the-hook", + LifecycleTransition: LifecycleHookTransitionInstanceTerminating, + RoleARN: aws.String("role-arn"), + }, + }, + }, + }, + wantErrToContain: ptr.To[string]("notificationTargetARN"), + }, + { + name: "Should fail if lifecycle hook only has notificationTargetARN, but not roleARN", + pool: &AWSMachinePool{ + Spec: AWSMachinePoolSpec{ + AWSLifecycleHooks: []AWSLifecycleHook{ + { + Name: "the-hook", + LifecycleTransition: LifecycleHookTransitionInstanceTerminating, + NotificationTargetARN: aws.String("notification-target-arn"), + }, + }, + }, + }, + wantErrToContain: ptr.To[string]("roleARN"), + }, + { + name: "Should fail if the lifecycle hook heartbeat timeout is less than 30 seconds", + pool: &AWSMachinePool{ + Spec: AWSMachinePoolSpec{ + AWSLifecycleHooks: []AWSLifecycleHook{ + { + Name: "the-hook", + LifecycleTransition: LifecycleHookTransitionInstanceTerminating, + NotificationTargetARN: aws.String("notification-target-arn"), + RoleARN: aws.String("role-arn"), + HeartbeatTimeout: &metav1.Duration{Duration: 29 * time.Second}, + }, + }, + }, + }, + wantErrToContain: ptr.To[string]("heartbeatTimeout"), + }, + { + name: "Should fail if the lifecycle hook heartbeat timeout is more than 172800 seconds", + pool: &AWSMachinePool{ + Spec: AWSMachinePoolSpec{ + AWSLifecycleHooks: []AWSLifecycleHook{ + { + Name: "the-hook", + LifecycleTransition: LifecycleHookTransitionInstanceTerminating, + NotificationTargetARN: aws.String("notification-target-arn"), + RoleARN: aws.String("role-arn"), + HeartbeatTimeout: &metav1.Duration{Duration: 172801 * time.Second}, + }, + }, + }, + }, + wantErrToContain: ptr.To[string]("heartbeatTimeout"), + }, + { + name: "Should succeed on correct lifecycle hook", + pool: &AWSMachinePool{ + Spec: AWSMachinePoolSpec{ + AWSLifecycleHooks: []AWSLifecycleHook{ + { + Name: "the-hook", + LifecycleTransition: LifecycleHookTransitionInstanceTerminating, + NotificationTargetARN: aws.String("notification-target-arn"), + RoleARN: aws.String("role-arn"), + HeartbeatTimeout: &metav1.Duration{Duration: 180 * time.Second}, + }, + }, + }, + }, + wantErrToContain: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { warn, err := tt.pool.ValidateCreate() - if tt.wantErr { - g.Expect(err).To(HaveOccurred()) + if tt.wantErrToContain != nil { + g.Expect(err).ToNot(BeNil()) + if err != nil { + g.Expect(err.Error()).To(ContainSubstring(*tt.wantErrToContain)) + } } else { g.Expect(err).To(Succeed()) } @@ -193,10 +277,10 @@ func TestAWSMachinePoolValidateUpdate(t *testing.T) { g := NewWithT(t) tests := []struct { - name string - new *AWSMachinePool - old *AWSMachinePool - wantErr bool + name string + new *AWSMachinePool + old *AWSMachinePool + wantErrToContain *string }{ { name: "adding tags is accepted", @@ -215,7 +299,7 @@ func TestAWSMachinePoolValidateUpdate(t *testing.T) { }, }, }, - wantErr: false, + wantErrToContain: nil, }, { name: "adding invalid tags is rejected", @@ -236,7 +320,7 @@ func TestAWSMachinePoolValidateUpdate(t *testing.T) { }, }, }, - wantErr: true, + wantErrToContain: ptr.To[string]("additionalTags"), }, { name: "Should fail update if both subnetID and filters passed in AWSMachinePool spec", @@ -261,7 +345,7 @@ func TestAWSMachinePoolValidateUpdate(t *testing.T) { }, }, }, - wantErr: true, + wantErrToContain: ptr.To[string]("filter"), }, { name: "Should pass update if either subnetID or filters passed in AWSMachinePool spec", @@ -285,7 +369,7 @@ func TestAWSMachinePoolValidateUpdate(t *testing.T) { }, }, }, - wantErr: false, + wantErrToContain: nil, }, { name: "Should fail update if both spec.awsLaunchTemplate.SpotMarketOptions and spec.MixedInstancesPolicy are passed in AWSMachinePool spec", @@ -306,7 +390,7 @@ func TestAWSMachinePoolValidateUpdate(t *testing.T) { }, }, }, - wantErr: true, + wantErrToContain: ptr.To[string]("spotMarketOptions"), }, { name: "Should fail if MaxHealthyPercentage is set, but MinHealthyPercentage is not set", @@ -315,7 +399,7 @@ func TestAWSMachinePoolValidateUpdate(t *testing.T) { RefreshPreferences: &RefreshPreferences{MaxHealthyPercentage: aws.Int64(100)}, }, }, - wantErr: true, + wantErrToContain: ptr.To[string]("minHealthyPercentage"), }, { name: "Should fail if the difference between MaxHealthyPercentage and MinHealthyPercentage is greater than 100", @@ -327,14 +411,17 @@ func TestAWSMachinePoolValidateUpdate(t *testing.T) { }, }, }, - wantErr: true, + wantErrToContain: ptr.To[string]("minHealthyPercentage"), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { warn, err := tt.new.ValidateUpdate(tt.old.DeepCopy()) - if tt.wantErr { - g.Expect(err).To(HaveOccurred()) + if tt.wantErrToContain != nil { + g.Expect(err).ToNot(BeNil()) + if err != nil { + g.Expect(err.Error()).To(ContainSubstring(*tt.wantErrToContain)) + } } else { g.Expect(err).To(Succeed()) } 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..aaa883750f 100644 --- a/exp/api/v1beta2/awsmanagedmachinepool_webhook.go +++ b/exp/api/v1beta2/awsmanagedmachinepool_webhook.go @@ -138,6 +138,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 +163,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 +203,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..ff4cfd4c98 100644 --- a/exp/api/v1beta2/conditions_consts.go +++ b/exp/api/v1beta2/conditions_consts.go @@ -54,6 +54,15 @@ 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" + // 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 0bc4009a2e..7841544b5a 100644 --- a/exp/api/v1beta2/types.go +++ b/exp/api/v1beta2/types.go @@ -221,6 +221,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"` + + // 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 *LifecycleHookDefaultResult `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 ( + // LifecycleHookTransitionInstanceLaunching is the launching state of the EC2 instance. + LifecycleHookTransitionInstanceLaunching LifecycleTransition = "autoscaling:EC2_INSTANCE_LAUNCHING" + // LifecycleHookTransitionInstanceTerminating is the terminating state of the EC2 instance. + LifecycleHookTransitionInstanceTerminating LifecycleTransition = "autoscaling:EC2_INSTANCE_TERMINATING" +) + +func (l LifecycleTransition) String() string { + return string(l) +} + +// LifecycleHookDefaultResult is the default result for the lifecycle hook. +type LifecycleHookDefaultResult string + +const ( + // LifecycleHookDefaultResultContinue is the default result for the lifecycle hook to continue. + LifecycleHookDefaultResultContinue LifecycleHookDefaultResult = "CONTINUE" + // LifecycleHookDefaultResultAbandon is the default result for the lifecycle hook to abandon. + LifecycleHookDefaultResultAbandon LifecycleHookDefaultResult = "ABANDON" +) + +func (d LifecycleHookDefaultResult) String() string { + return string(d) +} + // ASGStatus is a status string returned by the autoscaling API. type ASGStatus string diff --git a/exp/api/v1beta2/validation.go b/exp/api/v1beta2/validation.go new file mode 100644 index 0000000000..e369d6f395 --- /dev/null +++ b/exp/api/v1beta2/validation.go @@ -0,0 +1,50 @@ +/* +Copyright 2024 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 v1beta2 + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/util/validation/field" +) + +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 != LifecycleHookTransitionInstanceLaunching && hook.LifecycleTransition != LifecycleHookTransitionInstanceTerminating { + allErrs = append(allErrs, field.Invalid(field.NewPath("spec.lifecycleHooks.lifecycleTransition"), hook.LifecycleTransition, fmt.Sprintf("LifecycleTransition must be either %q or %q", LifecycleHookTransitionInstanceLaunching, LifecycleHookTransitionInstanceTerminating))) + } + if hook.DefaultResult != nil && (*hook.DefaultResult != LifecycleHookDefaultResultContinue && *hook.DefaultResult != LifecycleHookDefaultResultAbandon) { + allErrs = append(allErrs, field.Invalid(field.NewPath("spec.lifecycleHooks.defaultResult"), *hook.DefaultResult, fmt.Sprintf("DefaultResult must be either %s or %s", LifecycleHookDefaultResultContinue, LifecycleHookDefaultResultAbandon))) + } + 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 +} diff --git a/exp/api/v1beta2/zz_generated.deepcopy.go b/exp/api/v1beta2/zz_generated.deepcopy.go index c8131307ab..21b227ee9d 100644 --- a/exp/api/v1beta2/zz_generated.deepcopy.go +++ b/exp/api/v1beta2/zz_generated.deepcopy.go @@ -148,6 +148,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(LifecycleHookDefaultResult) + **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 @@ -277,6 +317,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. @@ -489,6 +536,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..bd69e785dd 100644 --- a/exp/controllers/awsmachinepool_controller.go +++ b/exp/controllers/awsmachinepool_controller.go @@ -298,6 +298,11 @@ func (r *AWSMachinePoolReconciler) reconcileNormal(ctx context.Context, machineP return nil } + if err := r.reconcileLifecycleHooks(ctx, machinePoolScope, asgsvc); err != nil { + r.Recorder.Eventf(machinePoolScope.AWSMachinePool, corev1.EventTypeWarning, "FailedLifecycleHooksReconcile", "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 { @@ -607,6 +612,13 @@ func machinePoolToInfrastructureMapFunc(gvk schema.GroupVersionKind) handler.Map } } +// reconcileLifecycleHooks periodically reconciles a lifecycle hook for the ASG. +func (r *AWSMachinePoolReconciler) reconcileLifecycleHooks(ctx context.Context, machinePoolScope *scope.MachinePoolScope, asgsvc services.ASGInterface) error { + asgName := machinePoolScope.Name() + + return asg.ReconcileLifecycleHooks(ctx, asgsvc, asgName, machinePoolScope.GetLifecycleHooks(), map[string]bool{}, machinePoolScope.GetMachinePool(), machinePoolScope) +} + func (r *AWSMachinePoolReconciler) getInfraCluster(ctx context.Context, log *logger.Logger, cluster *clusterv1.Cluster, awsMachinePool *expinfrav1.AWSMachinePool) (scope.EC2Scope, error) { var clusterScope *scope.ClusterScope var managedControlPlaneScope *scope.ManagedControlPlaneScope diff --git a/exp/controllers/awsmachinepool_controller_test.go b/exp/controllers/awsmachinepool_controller_test.go index 4902dbb7e7..287ebbe7a1 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, @@ -303,7 +304,7 @@ func TestAWSMachinePoolReconciler(t *testing.T) { asgSvc.EXPECT().CreateASG(gomock.Any()).Return(&expinfrav1.AutoScalingGroup{ Name: "name", }, nil) - asgSvc.EXPECT().SuspendProcesses("name", []string{"Launch", "Terminate"}).Return(nil).AnyTimes().Times(0) + asgSvc.EXPECT().SuspendProcesses("name", []string{"Launch", "Terminate"}).Return(nil).Times(0) err := reconciler.reconcileNormal(context.Background(), ms, cs, cs) g.Expect(err).To(Succeed()) @@ -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) + asgSvc.EXPECT().DescribeLifecycleHooks(gomock.Any()).Return(nil, nil) reconSvc.EXPECT().ReconcileTags(gomock.Any(), gomock.Any()).Return(nil) asgSvc.EXPECT().GetASGByName(gomock.Any()).Return(&expinfrav1.AutoScalingGroup{ Name: "name", @@ -339,7 +341,7 @@ func TestAWSMachinePoolReconciler(t *testing.T) { "InstanceRefresh", "HealthCheck", "ReplaceUnhealthy", - })).Return(nil).AnyTimes().Times(1) + })).Return(nil).Times(1) err := reconciler.reconcileNormal(context.Background(), ms, cs, cs) g.Expect(err).To(Succeed()) @@ -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) + asgSvc.EXPECT().DescribeLifecycleHooks(gomock.Any()).Return(nil, nil) reconSvc.EXPECT().ReconcileTags(gomock.Any(), gomock.Any()).Return(nil) asgSvc.EXPECT().GetASGByName(gomock.Any()).Return(&expinfrav1.AutoScalingGroup{ Name: "name", @@ -370,8 +373,8 @@ func TestAWSMachinePoolReconciler(t *testing.T) { }, nil) asgSvc.EXPECT().SubnetIDs(gomock.Any()).Return([]string{}, nil).Times(1) asgSvc.EXPECT().UpdateASG(gomock.Any()).Return(nil).AnyTimes() - asgSvc.EXPECT().SuspendProcesses("name", []string{"Terminate"}).Return(nil).AnyTimes().Times(1) - asgSvc.EXPECT().ResumeProcesses("name", []string{"process3"}).Return(nil).AnyTimes().Times(1) + asgSvc.EXPECT().SuspendProcesses("name", []string{"Terminate"}).Return(nil).Times(1) + asgSvc.EXPECT().ResumeProcesses("name", []string{"process3"}).Return(nil).Times(1) err := reconciler.reconcileNormal(context.Background(), ms, cs, cs) g.Expect(err).To(Succeed()) @@ -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) + asgSvc.EXPECT().DescribeLifecycleHooks(gomock.Any()).Return(nil, 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) + asgSvc.EXPECT().DescribeLifecycleHooks(gomock.Any()).Return(nil, 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) + asgSvc.EXPECT().DescribeLifecycleHooks(gomock.Any()).Return(nil, 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) + asgSvc.EXPECT().DescribeLifecycleHooks(gomock.Any()).Return(nil, 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().DescribeLifecycleHooks(gomock.Any()).Return(nil, nil) 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().DescribeLifecycleHooks(gomock.Any()).Return(nil, nil) 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().DescribeLifecycleHooks(gomock.Any()).Return(nil, nil) 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().DescribeLifecycleHooks(gomock.Any()).Return(nil, nil) 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) @@ -798,9 +812,193 @@ func TestAWSMachinePoolReconciler(t *testing.T) { g.Eventually(recorder.Events).Should(Receive(ContainSubstring("DeletionInProgress"))) }) }) -} + t.Run("Lifecycle Hooks", func(t *testing.T) { + t.Run("ASG created with lifecycle hooks", func(t *testing.T) { + g := NewWithT(t) + setup(t, g) + defer teardown(t, g) -//TODO: This was taken from awsmachine_controller_test, i think it should be moved to elsewhere in both locations like test/helpers. + newLifecycleHook := expinfrav1.AWSLifecycleHook{ + Name: "new-hook", + LifecycleTransition: "autoscaling:EC2_INSTANCE_LAUNCHING", + } + ms.AWSMachinePool.Spec.AWSLifecycleHooks = append(ms.AWSMachinePool.Spec.AWSLifecycleHooks, newLifecycleHook) + + reconSvc.EXPECT().ReconcileLaunchTemplate(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + + // New ASG must be created with lifecycle hooks (single AWS SDK call is enough) + // + // TODO: Since GetASGByName and CreateASG are both in the same interface, we can't inspect the actual + // `CreateAutoScalingGroupWithContext` requests parameters here. Make this better testable down to + // AWS SDK level and check `CreateAutoScalingGroupInput.LifecycleHookSpecificationList`. + asgSvc.EXPECT().GetASGByName(gomock.Any()).Return(nil, nil) + asgSvc.EXPECT().CreateASG(gomock.Any()).Return(&expinfrav1.AutoScalingGroup{ + Name: "name", + }, nil) + + err := reconciler.reconcileNormal(context.Background(), ms, cs, cs) + g.Expect(err).To(Succeed()) + }) + t.Run("New lifecycle hook is added", func(t *testing.T) { + g := NewWithT(t) + setup(t, g) + defer teardown(t, g) + + newLifecycleHook := expinfrav1.AWSLifecycleHook{ + Name: "new-hook", + LifecycleTransition: "autoscaling:EC2_INSTANCE_LAUNCHING", + } + ms.AWSMachinePool.Spec.AWSLifecycleHooks = append(ms.AWSMachinePool.Spec.AWSLifecycleHooks, newLifecycleHook) + + reconSvc.EXPECT().ReconcileLaunchTemplate(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + asgSvc.EXPECT().DescribeLifecycleHooks(gomock.Eq(ms.Name())).Return(nil, nil) + asgSvc.EXPECT().CreateLifecycleHook(gomock.Any(), ms.Name(), &newLifecycleHook).Return(nil) + reconSvc.EXPECT().ReconcileTags(gomock.Any(), gomock.Any()).Return(nil) + asgSvc.EXPECT().GetASGByName(gomock.Any()).DoAndReturn(func(scope *scope.MachinePoolScope) (*expinfrav1.AutoScalingGroup, error) { + g.Expect(scope.Name()).To(Equal("test")) + + // No difference to `AWSMachinePool.spec` + return &expinfrav1.AutoScalingGroup{ + Name: scope.Name(), + Subnets: []string{ + "subnet-1", + }, + MinSize: awsMachinePool.Spec.MinSize, + MaxSize: awsMachinePool.Spec.MaxSize, + MixedInstancesPolicy: awsMachinePool.Spec.MixedInstancesPolicy.DeepCopy(), + }, nil + }) + 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) + + err := reconciler.reconcileNormal(context.Background(), ms, cs, cs) + g.Expect(err).To(Succeed()) + }) + t.Run("Lifecycle hook to remove", func(t *testing.T) { + g := NewWithT(t) + setup(t, g) + defer teardown(t, g) + + reconSvc.EXPECT().ReconcileLaunchTemplate(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + asgSvc.EXPECT().DescribeLifecycleHooks(gomock.Eq(ms.Name())).Return([]*expinfrav1.AWSLifecycleHook{ + { + Name: "hook-to-remove", + LifecycleTransition: "autoscaling:EC2_INSTANCE_LAUNCHING", + }, + }, nil) + asgSvc.EXPECT().DeleteLifecycleHook(gomock.Any(), ms.Name(), &expinfrav1.AWSLifecycleHook{ + Name: "hook-to-remove", + LifecycleTransition: "autoscaling:EC2_INSTANCE_LAUNCHING", + }).Return(nil) + reconSvc.EXPECT().ReconcileTags(gomock.Any(), gomock.Any()).Return(nil) + asgSvc.EXPECT().GetASGByName(gomock.Any()).DoAndReturn(func(scope *scope.MachinePoolScope) (*expinfrav1.AutoScalingGroup, error) { + g.Expect(scope.Name()).To(Equal("test")) + + // No difference to `AWSMachinePool.spec` + return &expinfrav1.AutoScalingGroup{ + Name: scope.Name(), + Subnets: []string{ + "subnet-1", + }, + MinSize: awsMachinePool.Spec.MinSize, + MaxSize: awsMachinePool.Spec.MaxSize, + MixedInstancesPolicy: awsMachinePool.Spec.MixedInstancesPolicy.DeepCopy(), + }, nil + }) + 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) + + err := reconciler.reconcileNormal(context.Background(), ms, cs, cs) + g.Expect(err).To(Succeed()) + }) + t.Run("One to add, one to remove", func(t *testing.T) { + g := NewWithT(t) + setup(t, g) + defer teardown(t, g) + newLifecycleHook := expinfrav1.AWSLifecycleHook{ + Name: "new-hook", + LifecycleTransition: "autoscaling:EC2_INSTANCE_LAUNCHING", + } + ms.AWSMachinePool.Spec.AWSLifecycleHooks = append(ms.AWSMachinePool.Spec.AWSLifecycleHooks, newLifecycleHook) + + reconSvc.EXPECT().ReconcileLaunchTemplate(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + asgSvc.EXPECT().DescribeLifecycleHooks(gomock.Eq(ms.Name())).Return([]*expinfrav1.AWSLifecycleHook{ + { + Name: "hook-to-remove", + LifecycleTransition: "autoscaling:EC2_INSTANCE_LAUNCHING", + }, + }, nil) + asgSvc.EXPECT().CreateLifecycleHook(gomock.Any(), ms.Name(), &newLifecycleHook).Return(nil) + asgSvc.EXPECT().DeleteLifecycleHook(gomock.Any(), ms.Name(), &expinfrav1.AWSLifecycleHook{ + Name: "hook-to-remove", + LifecycleTransition: "autoscaling:EC2_INSTANCE_LAUNCHING", + }).Return(nil) + reconSvc.EXPECT().ReconcileTags(gomock.Any(), gomock.Any()).Return(nil) + asgSvc.EXPECT().GetASGByName(gomock.Any()).DoAndReturn(func(scope *scope.MachinePoolScope) (*expinfrav1.AutoScalingGroup, error) { + g.Expect(scope.Name()).To(Equal("test")) + + // No difference to `AWSMachinePool.spec` + return &expinfrav1.AutoScalingGroup{ + Name: scope.Name(), + Subnets: []string{ + "subnet-1", + }, + MinSize: awsMachinePool.Spec.MinSize, + MaxSize: awsMachinePool.Spec.MaxSize, + MixedInstancesPolicy: awsMachinePool.Spec.MixedInstancesPolicy.DeepCopy(), + }, nil + }) + 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) + + err := reconciler.reconcileNormal(context.Background(), ms, cs, cs) + g.Expect(err).To(Succeed()) + }) + t.Run("Update hook", func(t *testing.T) { + g := NewWithT(t) + setup(t, g) + defer teardown(t, g) + updateLifecycleHook := expinfrav1.AWSLifecycleHook{ + Name: "hook-to-update", + LifecycleTransition: "autoscaling:EC2_INSTANCE_TERMINATING", + } + ms.AWSMachinePool.Spec.AWSLifecycleHooks = append(ms.AWSMachinePool.Spec.AWSLifecycleHooks, updateLifecycleHook) + + reconSvc.EXPECT().ReconcileLaunchTemplate(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + asgSvc.EXPECT().DescribeLifecycleHooks(gomock.Eq(ms.Name())).Return([]*expinfrav1.AWSLifecycleHook{ + { + Name: "hook-to-update", + LifecycleTransition: "autoscaling:EC2_INSTANCE_LAUNCHING", + }, + }, nil) + asgSvc.EXPECT().UpdateLifecycleHook(gomock.Any(), ms.Name(), &updateLifecycleHook).Return(nil) + reconSvc.EXPECT().ReconcileTags(gomock.Any(), gomock.Any()).Return(nil) + asgSvc.EXPECT().GetASGByName(gomock.Any()).DoAndReturn(func(scope *scope.MachinePoolScope) (*expinfrav1.AutoScalingGroup, error) { + g.Expect(scope.Name()).To(Equal("test")) + + // No difference to `AWSMachinePool.spec` + return &expinfrav1.AutoScalingGroup{ + Name: scope.Name(), + Subnets: []string{ + "subnet-1", + }, + MinSize: awsMachinePool.Spec.MinSize, + MaxSize: awsMachinePool.Spec.MaxSize, + MixedInstancesPolicy: awsMachinePool.Spec.MixedInstancesPolicy.DeepCopy(), + }, nil + }) + 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) + + err := reconciler.reconcileNormal(context.Background(), ms, cs, cs) + g.Expect(err).To(Succeed()) + }) + }) +} type conditionAssertion struct { conditionType clusterv1.ConditionType diff --git a/pkg/cloud/scope/machinepool.go b/pkg/cloud/scope/machinepool.go index 00e8abeadc..affd68513b 100644 --- a/pkg/cloud/scope/machinepool.go +++ b/pkg/cloud/scope/machinepool.go @@ -395,3 +395,8 @@ func (m *MachinePoolScope) LaunchTemplateName() string { func (m *MachinePoolScope) GetRuntimeObject() runtime.Object { return m.AWSMachinePool } + +// GetLifecycleHooks returns the desired lifecycle hooks for the ASG. +func (m *MachinePoolScope) GetLifecycleHooks() []expinfrav1.AWSLifecycleHook { + return m.AWSMachinePool.Spec.AWSLifecycleHooks +} diff --git a/pkg/cloud/scope/managednodegroup.go b/pkg/cloud/scope/managednodegroup.go index e9421d7282..c091f02efe 100644 --- a/pkg/cloud/scope/managednodegroup.go +++ b/pkg/cloud/scope/managednodegroup.go @@ -411,3 +411,8 @@ func (s *ManagedMachinePoolScope) LaunchTemplateName() string { func (s *ManagedMachinePoolScope) GetRuntimeObject() runtime.Object { return s.ManagedMachinePool } + +// GetLifecycleHooks returns the desired lifecycle hooks for the ASG. +func (s *ManagedMachinePoolScope) GetLifecycleHooks() []expinfrav1.AWSLifecycleHook { + return s.ManagedMachinePool.Spec.AWSLifecycleHooks +} diff --git a/pkg/cloud/services/autoscaling/autoscalinggroup.go b/pkg/cloud/services/autoscaling/autoscalinggroup.go index f8e8b18690..1c2c8c96d6 100644 --- a/pkg/cloud/services/autoscaling/autoscalinggroup.go +++ b/pkg/cloud/services/autoscaling/autoscalinggroup.go @@ -163,24 +163,17 @@ func (s *Service) CreateASG(machinePoolScope *scope.MachinePoolScope) (*expinfra return nil, fmt.Errorf("getting subnets for ASG: %w", err) } - input := &expinfrav1.AutoScalingGroup{ - Name: machinePoolScope.Name(), - MaxSize: machinePoolScope.AWSMachinePool.Spec.MaxSize, - MinSize: machinePoolScope.AWSMachinePool.Spec.MinSize, - Subnets: subnets, - DefaultCoolDown: machinePoolScope.AWSMachinePool.Spec.DefaultCoolDown, - DefaultInstanceWarmup: machinePoolScope.AWSMachinePool.Spec.DefaultInstanceWarmup, - CapacityRebalance: machinePoolScope.AWSMachinePool.Spec.CapacityRebalance, - MixedInstancesPolicy: machinePoolScope.AWSMachinePool.Spec.MixedInstancesPolicy, - } + name := machinePoolScope.Name() + s.scope.Info("Creating ASG", "name", name) // Default value of MachinePool replicas set by CAPI is 1. mpReplicas := *machinePoolScope.MachinePool.Spec.Replicas + var desiredCapacity *int32 // Check that MachinePool replicas number is between the minimum and maximum size of the AWSMachinePool. // Ignore the problem for externally managed clusters because MachinePool replicas will be updated to the right value automatically. if mpReplicas >= machinePoolScope.AWSMachinePool.Spec.MinSize && mpReplicas <= machinePoolScope.AWSMachinePool.Spec.MaxSize { - input.DesiredCapacity = &mpReplicas + desiredCapacity = &mpReplicas } else if !annotations.ReplicasManagedByExternalAutoscaler(machinePoolScope.MachinePool) { return nil, fmt.Errorf("incorrect number of replicas %d in MachinePool %v", mpReplicas, machinePoolScope.MachinePool.Name) } @@ -194,62 +187,46 @@ func (s *Service) CreateASG(machinePoolScope *scope.MachinePoolScope) (*expinfra // Set the cloud provider tag additionalTags[infrav1.ClusterAWSCloudProviderTagKey(s.scope.KubernetesClusterName())] = string(infrav1.ResourceLifecycleOwned) - input.Tags = infrav1.Build(infrav1.BuildParams{ - ClusterName: s.scope.KubernetesClusterName(), - Lifecycle: infrav1.ResourceLifecycleOwned, - Name: aws.String(machinePoolScope.Name()), - Role: aws.String("node"), - Additional: additionalTags, - }) - - s.scope.Info("Running instance") - if err := s.runPool(input, machinePoolScope.AWSMachinePool.Status.LaunchTemplateID); err != nil { - // Only record the failure event if the error is not related to failed dependencies. - // This is to avoid spamming failure events since the machine will be requeued by the actuator. - // if !awserrors.IsFailedDependency(errors.Cause(err)) { - // record.Warnf(scope.AWSMachinePool, "FailedCreate", "Failed to create instance: %v", err) - // } - s.scope.Error(err, "unable to create AutoScalingGroup") - return nil, err - } - record.Eventf(machinePoolScope.AWSMachinePool, "SuccessfulCreate", "Created new ASG: %s", machinePoolScope.Name()) - - return nil, nil -} - -func (s *Service) runPool(i *expinfrav1.AutoScalingGroup, launchTemplateID string) error { input := &autoscaling.CreateAutoScalingGroupInput{ - AutoScalingGroupName: aws.String(i.Name), - MaxSize: aws.Int64(int64(i.MaxSize)), - MinSize: aws.Int64(int64(i.MinSize)), - VPCZoneIdentifier: aws.String(strings.Join(i.Subnets, ", ")), - DefaultCooldown: aws.Int64(int64(i.DefaultCoolDown.Duration.Seconds())), - DefaultInstanceWarmup: aws.Int64(int64(i.DefaultInstanceWarmup.Duration.Seconds())), - CapacityRebalance: aws.Bool(i.CapacityRebalance), + AutoScalingGroupName: aws.String(name), + MaxSize: aws.Int64(int64(machinePoolScope.AWSMachinePool.Spec.MaxSize)), + MinSize: aws.Int64(int64(machinePoolScope.AWSMachinePool.Spec.MinSize)), + VPCZoneIdentifier: aws.String(strings.Join(subnets, ", ")), + DefaultCooldown: aws.Int64(int64(machinePoolScope.AWSMachinePool.Spec.DefaultCoolDown.Duration.Seconds())), + DefaultInstanceWarmup: aws.Int64(int64(machinePoolScope.AWSMachinePool.Spec.DefaultInstanceWarmup.Duration.Seconds())), + CapacityRebalance: aws.Bool(machinePoolScope.AWSMachinePool.Spec.CapacityRebalance), + LifecycleHookSpecificationList: getLifecycleHookSpecificationList(machinePoolScope.GetLifecycleHooks()), } - if i.DesiredCapacity != nil { - input.DesiredCapacity = aws.Int64(int64(aws.Int32Value(i.DesiredCapacity))) + if desiredCapacity != nil { + input.DesiredCapacity = aws.Int64(int64(aws.Int32Value(desiredCapacity))) } - if i.MixedInstancesPolicy != nil { - input.MixedInstancesPolicy = createSDKMixedInstancesPolicy(i.Name, i.MixedInstancesPolicy) + if machinePoolScope.AWSMachinePool.Spec.MixedInstancesPolicy != nil { + input.MixedInstancesPolicy = createSDKMixedInstancesPolicy(name, machinePoolScope.AWSMachinePool.Spec.MixedInstancesPolicy) } else { input.LaunchTemplate = &autoscaling.LaunchTemplateSpecification{ - LaunchTemplateId: aws.String(launchTemplateID), + LaunchTemplateId: aws.String(machinePoolScope.AWSMachinePool.Status.LaunchTemplateID), Version: aws.String(expinfrav1.LaunchTemplateLatestVersion), } } - if i.Tags != nil { - input.Tags = BuildTagsFromMap(i.Name, i.Tags) - } + input.Tags = BuildTagsFromMap(name, infrav1.Build(infrav1.BuildParams{ + ClusterName: s.scope.KubernetesClusterName(), + Lifecycle: infrav1.ResourceLifecycleOwned, + Name: aws.String(name), + Role: aws.String("node"), + Additional: additionalTags, + })) if _, err := s.ASGClient.CreateAutoScalingGroupWithContext(context.TODO(), input); err != nil { - return errors.Wrap(err, "failed to create autoscaling group") + s.scope.Error(err, "unable to create AutoScalingGroup") + return nil, errors.Wrap(err, "failed to create autoscaling group") } - return nil + record.Eventf(machinePoolScope.AWSMachinePool, "SuccessfulCreate", "Created new ASG: %s", machinePoolScope.Name()) + + return nil, nil } // DeleteASGAndWait will delete an ASG and wait until it is deleted. @@ -331,7 +308,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..8c6fca6bea --- /dev/null +++ b/pkg/cloud/services/autoscaling/lifecyclehook.go @@ -0,0 +1,241 @@ +/* +Copyright 2024 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" + "k8s.io/utils/ptr" + + expinfrav1 "sigs.k8s.io/cluster-api-provider-aws/v2/exp/api/v1beta2" + "sigs.k8s.io/cluster-api-provider-aws/v2/pkg/cloud/services" + "sigs.k8s.io/cluster-api-provider-aws/v2/pkg/logger" + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + "sigs.k8s.io/cluster-api/util/conditions" +) + +// DescribeLifecycleHooks returns the lifecycle hooks for the given AutoScalingGroup after retrieving them from the AWS API. +func (s *Service) DescribeLifecycleHooks(asgName string) ([]*expinfrav1.AWSLifecycleHook, error) { + input := &autoscaling.DescribeLifecycleHooksInput{ + AutoScalingGroupName: ptr.To(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", asgName) + } + + hooks := make([]*expinfrav1.AWSLifecycleHook, len(out.LifecycleHooks)) + for i, hook := range out.LifecycleHooks { + hooks[i] = s.SDKToLifecycleHook(hook) + } + + return hooks, nil +} + +func getPutLifecycleHookInput(asgName string, hook *expinfrav1.AWSLifecycleHook) (ret *autoscaling.PutLifecycleHookInput) { + ret = &autoscaling.PutLifecycleHookInput{ + AutoScalingGroupName: ptr.To(asgName), + LifecycleHookName: ptr.To(hook.Name), + LifecycleTransition: ptr.To(hook.LifecycleTransition.String()), + + // Optional + RoleARN: hook.RoleARN, + NotificationTargetARN: hook.NotificationTargetARN, + NotificationMetadata: hook.NotificationMetadata, + } + + // For optional fields in the manifest, still fill in the AWS request parameters so any drifted lifecycle hook + // settings are reconciled to the desired state on update. Using AWS default values here. + ret.DefaultResult = ptr.To(ptr.Deref(hook.DefaultResult, expinfrav1.LifecycleHookDefaultResultAbandon).String()) + timeoutSeconds := ptr.Deref(hook.HeartbeatTimeout, metav1.Duration{Duration: 3600 * time.Second}).Duration.Seconds() + ret.HeartbeatTimeout = aws.Int64(int64(timeoutSeconds)) + + return +} + +// CreateLifecycleHook creates a lifecycle hook for the given AutoScalingGroup. +func (s *Service) CreateLifecycleHook(ctx context.Context, asgName string, hook *expinfrav1.AWSLifecycleHook) error { + input := getPutLifecycleHookInput(asgName, hook) + + if _, err := s.ASGClient.PutLifecycleHookWithContext(ctx, input); err != nil { + return errors.Wrapf(err, "failed to create lifecycle hook %q for AutoScalingGroup: %q", hook.Name, asgName) + } + + return nil +} + +// UpdateLifecycleHook updates a lifecycle hook for the given AutoScalingGroup. +func (s *Service) UpdateLifecycleHook(ctx context.Context, asgName string, hook *expinfrav1.AWSLifecycleHook) error { + input := getPutLifecycleHookInput(asgName, hook) + + if _, err := s.ASGClient.PutLifecycleHookWithContext(ctx, input); err != nil { + return errors.Wrapf(err, "failed to update lifecycle hook %q for AutoScalingGroup: %q", hook.Name, asgName) + } + + return nil +} + +// DeleteLifecycleHook deletes a lifecycle hook for the given AutoScalingGroup. +func (s *Service) DeleteLifecycleHook(ctx context.Context, asgName string, hook *expinfrav1.AWSLifecycleHook) error { + input := &autoscaling.DeleteLifecycleHookInput{ + AutoScalingGroupName: ptr.To(asgName), + LifecycleHookName: ptr.To(hook.Name), + } + + if _, err := s.ASGClient.DeleteLifecycleHookWithContext(ctx, input); err != nil { + return errors.Wrapf(err, "failed to delete lifecycle hook %q for AutoScalingGroup: %q", hook.Name, asgName) + } + + 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.LifecycleHookDefaultResult(*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, + } +} + +func getLifecycleHookSpecificationList(lifecycleHooks []expinfrav1.AWSLifecycleHook) (ret []*autoscaling.LifecycleHookSpecification) { + for _, hook := range lifecycleHooks { + spec := &autoscaling.LifecycleHookSpecification{ + LifecycleHookName: ptr.To(hook.Name), + LifecycleTransition: ptr.To(hook.LifecycleTransition.String()), + + // Optional + RoleARN: hook.RoleARN, + NotificationTargetARN: hook.NotificationTargetARN, + NotificationMetadata: hook.NotificationMetadata, + } + + // Optional parameters + if hook.DefaultResult != nil { + spec.DefaultResult = ptr.To(hook.DefaultResult.String()) + } + + if hook.HeartbeatTimeout != nil { + timeoutSeconds := hook.HeartbeatTimeout.Duration.Seconds() + spec.HeartbeatTimeout = aws.Int64(int64(timeoutSeconds)) + } + + ret = append(ret, spec) + } + + return +} + +// ReconcileLifecycleHooks reconciles lifecycle hooks for an ASG +// by creating missing hooks, updating mismatching hooks and +// deleting extraneous hooks (except those specified in +// ignoreLifecycleHooks). +func ReconcileLifecycleHooks(ctx context.Context, asgService services.ASGInterface, asgName string, wantedLifecycleHooks []expinfrav1.AWSLifecycleHook, ignoreLifecycleHooks map[string]bool, storeConditionsOnObject conditions.Setter, log logger.Wrapper) error { + existingHooks, err := asgService.DescribeLifecycleHooks(asgName) + if err != nil { + return err + } + + for i := range wantedLifecycleHooks { + if ignoreLifecycleHooks[wantedLifecycleHooks[i].Name] { + log.Info("Not reconciling lifecycle hook since it's on the ignore list") + continue + } + + if err := reconcileLifecycleHook(ctx, asgService, asgName, &wantedLifecycleHooks[i], existingHooks, storeConditionsOnObject, log); err != nil { + return err + } + } + + for _, existingHook := range existingHooks { + found := false + if ignoreLifecycleHooks[existingHook.Name] { + continue + } + for _, wantedHook := range wantedLifecycleHooks { + if existingHook.Name == wantedHook.Name { + found = true + break + } + } + if !found { + log.Info("Deleting extraneous lifecycle hook", "hook", existingHook.Name) + if err := asgService.DeleteLifecycleHook(ctx, asgName, existingHook); err != nil { + conditions.MarkFalse(storeConditionsOnObject, expinfrav1.LifecycleHookReadyCondition, expinfrav1.LifecycleHookDeletionFailedReason, clusterv1.ConditionSeverityError, err.Error()) + return err + } + } + } + + return nil +} + +func lifecycleHookNeedsUpdate(existing *expinfrav1.AWSLifecycleHook, expected *expinfrav1.AWSLifecycleHook) bool { + return ptr.Deref(existing.DefaultResult, expinfrav1.LifecycleHookDefaultResultAbandon) != ptr.Deref(expected.DefaultResult, expinfrav1.LifecycleHookDefaultResultAbandon) || + ptr.Deref(existing.HeartbeatTimeout, metav1.Duration{Duration: 3600 * time.Second}) != ptr.Deref(expected.HeartbeatTimeout, metav1.Duration{Duration: 3600 * time.Second}) || + existing.LifecycleTransition != expected.LifecycleTransition || + existing.NotificationTargetARN != expected.NotificationTargetARN || + existing.NotificationMetadata != expected.NotificationMetadata +} + +func reconcileLifecycleHook(ctx context.Context, asgService services.ASGInterface, asgName string, wantedHook *expinfrav1.AWSLifecycleHook, existingHooks []*expinfrav1.AWSLifecycleHook, storeConditionsOnObject conditions.Setter, log logger.Wrapper) error { + log = log.WithValues("hook", wantedHook.Name) + + log.Info("Checking for existing lifecycle hook") + var existingHook *expinfrav1.AWSLifecycleHook + for _, h := range existingHooks { + if h.Name == wantedHook.Name { + existingHook = h + break + } + } + + if existingHook == nil { + log.Info("Creating lifecycle hook") + if err := asgService.CreateLifecycleHook(ctx, asgName, wantedHook); err != nil { + conditions.MarkFalse(storeConditionsOnObject, expinfrav1.LifecycleHookReadyCondition, expinfrav1.LifecycleHookCreationFailedReason, clusterv1.ConditionSeverityError, err.Error()) + return err + } + return nil + } + + if lifecycleHookNeedsUpdate(existingHook, wantedHook) { + log.Info("Updating lifecycle hook") + if err := asgService.UpdateLifecycleHook(ctx, asgName, wantedHook); err != nil { + conditions.MarkFalse(storeConditionsOnObject, expinfrav1.LifecycleHookReadyCondition, expinfrav1.LifecycleHookUpdateFailedReason, clusterv1.ConditionSeverityError, err.Error()) + return err + } + } + + conditions.MarkTrue(storeConditionsOnObject, expinfrav1.LifecycleHookReadyCondition) + return nil +} diff --git a/pkg/cloud/services/autoscaling/lifecyclehook_test.go b/pkg/cloud/services/autoscaling/lifecyclehook_test.go new file mode 100644 index 0000000000..5aba913f64 --- /dev/null +++ b/pkg/cloud/services/autoscaling/lifecyclehook_test.go @@ -0,0 +1,131 @@ +/* +Copyright 2024 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 ( + "testing" + "time" + + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + expinfrav1 "sigs.k8s.io/cluster-api-provider-aws/v2/exp/api/v1beta2" +) + +func TestLifecycleHookNeedsUpdate(t *testing.T) { + defaultResultAbandon := expinfrav1.LifecycleHookDefaultResultAbandon + defaultResultContinue := expinfrav1.LifecycleHookDefaultResultContinue + + tests := []struct { + name string + existing expinfrav1.AWSLifecycleHook + expected expinfrav1.AWSLifecycleHook + wantUpdate bool + }{ + { + name: "exactly equal", + existing: expinfrav1.AWSLifecycleHook{ + Name: "andreas-test", + LifecycleTransition: "autoscaling:EC2_INSTANCE_TERMINATING", + HeartbeatTimeout: &metav1.Duration{Duration: 3600 * time.Second}, + DefaultResult: &defaultResultAbandon, + }, + expected: expinfrav1.AWSLifecycleHook{ + Name: "andreas-test", + LifecycleTransition: "autoscaling:EC2_INSTANCE_TERMINATING", + HeartbeatTimeout: &metav1.Duration{Duration: 3600 * time.Second}, + DefaultResult: &defaultResultAbandon, + }, + wantUpdate: false, + }, + + { + name: "heartbeatTimeout and defaultResult not set in manifest, but set to defaults by AWS", + existing: expinfrav1.AWSLifecycleHook{ + Name: "andreas-test", + LifecycleTransition: "autoscaling:EC2_INSTANCE_TERMINATING", + // Describing with AWS SDK always fills these fields with the defaults + HeartbeatTimeout: &metav1.Duration{Duration: 3600 * time.Second}, + DefaultResult: &defaultResultAbandon, + }, + expected: expinfrav1.AWSLifecycleHook{ + Name: "andreas-test", + LifecycleTransition: "autoscaling:EC2_INSTANCE_TERMINATING", + }, + wantUpdate: false, + }, + + { + name: "transition differs", + existing: expinfrav1.AWSLifecycleHook{ + Name: "andreas-test", + LifecycleTransition: "autoscaling:EC2_INSTANCE_LAUNCHING", + HeartbeatTimeout: &metav1.Duration{Duration: 3600 * time.Second}, + DefaultResult: &defaultResultAbandon, + }, + expected: expinfrav1.AWSLifecycleHook{ + Name: "andreas-test", + LifecycleTransition: "autoscaling:EC2_INSTANCE_TERMINATING", + HeartbeatTimeout: &metav1.Duration{Duration: 3600 * time.Second}, + DefaultResult: &defaultResultAbandon, + }, + wantUpdate: true, + }, + + { + name: "heartbeat timeout differs", + existing: expinfrav1.AWSLifecycleHook{ + Name: "andreas-test", + LifecycleTransition: "autoscaling:EC2_INSTANCE_TERMINATING", + HeartbeatTimeout: &metav1.Duration{Duration: 3600 * time.Second}, + DefaultResult: &defaultResultAbandon, + }, + expected: expinfrav1.AWSLifecycleHook{ + Name: "andreas-test", + LifecycleTransition: "autoscaling:EC2_INSTANCE_TERMINATING", + HeartbeatTimeout: &metav1.Duration{Duration: 3601 * time.Second}, + DefaultResult: &defaultResultAbandon, + }, + wantUpdate: true, + }, + + { + name: "default result differs", + existing: expinfrav1.AWSLifecycleHook{ + Name: "andreas-test", + LifecycleTransition: "autoscaling:EC2_INSTANCE_TERMINATING", + HeartbeatTimeout: &metav1.Duration{Duration: 3600 * time.Second}, + DefaultResult: &defaultResultAbandon, + }, + expected: expinfrav1.AWSLifecycleHook{ + Name: "andreas-test", + LifecycleTransition: "autoscaling:EC2_INSTANCE_TERMINATING", + HeartbeatTimeout: &metav1.Duration{Duration: 3600 * time.Second}, + DefaultResult: &defaultResultContinue, + }, + wantUpdate: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + existing := tt.existing + expected := tt.expected + g := NewWithT(t) + g.Expect(lifecycleHookNeedsUpdate(&existing, &expected)).To(Equal(tt.wantUpdate)) + }) + } +} diff --git a/pkg/cloud/services/eks/nodegroup.go b/pkg/cloud/services/eks/nodegroup.go index 763d14b494..17518e033e 100644 --- a/pkg/cloud/services/eks/nodegroup.go +++ b/pkg/cloud/services/eks/nodegroup.go @@ -34,12 +34,20 @@ import ( expinfrav1 "sigs.k8s.io/cluster-api-provider-aws/v2/exp/api/v1beta2" "sigs.k8s.io/cluster-api-provider-aws/v2/pkg/cloud/awserrors" "sigs.k8s.io/cluster-api-provider-aws/v2/pkg/cloud/converters" + asg "sigs.k8s.io/cluster-api-provider-aws/v2/pkg/cloud/services/autoscaling" "sigs.k8s.io/cluster-api-provider-aws/v2/pkg/cloud/services/wait" "sigs.k8s.io/cluster-api-provider-aws/v2/pkg/record" clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" "sigs.k8s.io/cluster-api/util/annotations" ) +// IgnoredEKSLifecycleHooks lists built-in EKS lifecycle hooks +// that should not be changed or deleted. +var IgnoredEKSLifecycleHooks = map[string]bool{ + "Launch-LC-Hook": true, + "Terminate-LC-Hook": true, +} + func (s *NodegroupService) describeNodegroup() (*eks.Nodegroup, error) { eksClusterName := s.scope.KubernetesClusterName() nodegroupName := s.scope.NodegroupName() @@ -150,7 +158,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 @@ -571,6 +579,10 @@ func (s *NodegroupService) reconcileNodegroup(ctx context.Context) error { return errors.Wrapf(err, "failed to reconcile asg tags") } + if err := s.reconcileLifecycleHooks(ctx, ng); err != nil { + return errors.Wrapf(err, "failed to reconcile lifecyle hooks") + } + return nil } @@ -645,3 +657,12 @@ func (s *NodegroupService) waitForNodegroupActive() (*eks.Nodegroup, error) { return ng, nil } + +// reconcileLifecycleHooks periodically reconciles a lifecycle hook for the ASG. +func (s *NodegroupService) reconcileLifecycleHooks(ctx context.Context, ng *eks.Nodegroup) error { + if len(ng.Resources.AutoScalingGroups) == 0 { + return errors.New("no ASG defined for node group") + } + + return asg.ReconcileLifecycleHooks(ctx, s.ASGService, *ng.Resources.AutoScalingGroups[0].Name, s.scope.GetLifecycleHooks(), IgnoredEKSLifecycleHooks, s.scope.GetMachinePool(), s.scope) +} diff --git a/pkg/cloud/services/eks/service.go b/pkg/cloud/services/eks/service.go index 9160a398a1..6f6cb280fb 100644 --- a/pkg/cloud/services/eks/service.go +++ b/pkg/cloud/services/eks/service.go @@ -27,6 +27,8 @@ import ( "github.com/aws/aws-sdk-go/service/sts/stsiface" "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/eks/iam" ) @@ -90,6 +92,7 @@ func NewService(controlPlaneScope *scope.ManagedControlPlaneScope, opts ...Servi // One alternative is to have a large list of functions from the ec2 client. type NodegroupService struct { scope *scope.ManagedMachinePoolScope + ASGService services.ASGInterface AutoscalingClient autoscalingiface.AutoScalingAPI EKSClient eksiface.EKSAPI iam.IAMService @@ -100,6 +103,7 @@ type NodegroupService struct { func NewNodegroupService(machinePoolScope *scope.ManagedMachinePoolScope) *NodegroupService { return &NodegroupService{ scope: machinePoolScope, + ASGService: asg.NewService(machinePoolScope.EC2Scope), AutoscalingClient: scope.NewASGClient(machinePoolScope, machinePoolScope, machinePoolScope, machinePoolScope.ManagedMachinePool), EKSClient: scope.NewEKSClient(machinePoolScope, machinePoolScope, machinePoolScope, machinePoolScope.ManagedMachinePool), IAMService: iam.IAMService{ diff --git a/pkg/cloud/services/interfaces.go b/pkg/cloud/services/interfaces.go index 5e0f3dc2e6..06ee272539 100644 --- a/pkg/cloud/services/interfaces.go +++ b/pkg/cloud/services/interfaces.go @@ -50,6 +50,10 @@ type ASGInterface interface { SuspendProcesses(name string, processes []string) error ResumeProcesses(name string, processes []string) error SubnetIDs(scope *scope.MachinePoolScope) ([]string, error) + DescribeLifecycleHooks(asgName string) ([]*expinfrav1.AWSLifecycleHook, error) + CreateLifecycleHook(ctx context.Context, asgName string, hook *expinfrav1.AWSLifecycleHook) error + UpdateLifecycleHook(ctx context.Context, asgName string, hook *expinfrav1.AWSLifecycleHook) error + DeleteLifecycleHook(ctx context.Context, asgName string, hook *expinfrav1.AWSLifecycleHook) error } // EC2Interface encapsulates the methods exposed to the machine diff --git a/pkg/cloud/services/mock_services/autoscaling_interface_mock.go b/pkg/cloud/services/mock_services/autoscaling_interface_mock.go index b860077f4f..fcee553a07 100644 --- a/pkg/cloud/services/mock_services/autoscaling_interface_mock.go +++ b/pkg/cloud/services/mock_services/autoscaling_interface_mock.go @@ -21,6 +21,7 @@ limitations under the License. package mock_services import ( + context "context" reflect "reflect" gomock "github.com/golang/mock/gomock" @@ -96,6 +97,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 context.Context, arg1 string, arg2 *v1beta2.AWSLifecycleHook) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateLifecycleHook", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateLifecycleHook indicates an expected call of CreateLifecycleHook. +func (mr *MockASGInterfaceMockRecorder) CreateLifecycleHook(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateLifecycleHook", reflect.TypeOf((*MockASGInterface)(nil).CreateLifecycleHook), arg0, arg1, arg2) +} + // DeleteASGAndWait mocks base method. func (m *MockASGInterface) DeleteASGAndWait(arg0 string) error { m.ctrl.T.Helper() @@ -110,6 +125,35 @@ 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 context.Context, arg1 string, arg2 *v1beta2.AWSLifecycleHook) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteLifecycleHook", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteLifecycleHook indicates an expected call of DeleteLifecycleHook. +func (mr *MockASGInterfaceMockRecorder) DeleteLifecycleHook(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteLifecycleHook", reflect.TypeOf((*MockASGInterface)(nil).DeleteLifecycleHook), arg0, arg1, arg2) +} + +// DescribeLifecycleHooks mocks base method. +func (m *MockASGInterface) DescribeLifecycleHooks(arg0 string) ([]*v1beta2.AWSLifecycleHook, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DescribeLifecycleHooks", arg0) + ret0, _ := ret[0].([]*v1beta2.AWSLifecycleHook) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DescribeLifecycleHooks indicates an expected call of DescribeLifecycleHooks. +func (mr *MockASGInterfaceMockRecorder) DescribeLifecycleHooks(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DescribeLifecycleHooks", reflect.TypeOf((*MockASGInterface)(nil).DescribeLifecycleHooks), arg0) +} + // GetASGByName mocks base method. func (m *MockASGInterface) GetASGByName(arg0 *scope.MachinePoolScope) (*v1beta2.AutoScalingGroup, error) { m.ctrl.T.Helper() @@ -196,6 +240,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 context.Context, arg1 string, arg2 *v1beta2.AWSLifecycleHook) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateLifecycleHook", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateLifecycleHook indicates an expected call of UpdateLifecycleHook. +func (mr *MockASGInterfaceMockRecorder) UpdateLifecycleHook(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateLifecycleHook", reflect.TypeOf((*MockASGInterface)(nil).UpdateLifecycleHook), arg0, arg1, arg2) +} + // UpdateResourceTags mocks base method. func (m *MockASGInterface) UpdateResourceTags(arg0 *string, arg1, arg2 map[string]string) error { m.ctrl.T.Helper()