Repository: kubernetes-sigs/cluster-api-provider-aws

Issue:
Lifecycle Hooks for MachinePool/ASG
/kind feature

**Describe the solution you'd like**
ASGs have support for Lifecycle Hooks and it's a good idea to expose this in the MachinePool CRD. When I scale up and down my ASGs, I want to be able to execute some Lambda functions to create and delete other resources that my nodes need. Doing this through the lifecycle hook allows me to only allow the creation of the EC2 instance to go ahead if the Lambda function ran successfully.


**Anything else you would like to add:**
[Miscellaneous information that will assist in solving the issue.]


**Environment:**

- Cluster-api-provider-aws version: v2.0.2
- Kubernetes version: (use `kubectl version`): v1.26.0
- OS (e.g. from `/etc/os-release`): Ubuntu 20.04

Hints from issue comments:
@dlipovetsky: @sebltm I think you mean the `AWSMachinePool` CRD.

Please describe how the hooks work with ASGs. Where do the hooks get defined in the ASG API? Do they have to be implemented using AWS Lambda? What changes to IAM policies would be required? Thanks!

/triage needs-information@Ankitasw: @dlipovetsky Here are some links regarding lifecycle hooks:
https://docs.aws.amazon.com/autoscaling/ec2/userguide/lifecycle-hooks.html
https://docs.aws.amazon.com/autoscaling/ec2/userguide/adding-lifecycle-hooks.html#adding-lifecycle-hooks-aws-cli
@k8s-triage-robot: The Kubernetes project currently lacks enough contributors to adequately respond to all issues.

This bot triages un-triaged issues according to the following rules:
- After 90d of inactivity, `lifecycle/stale` is applied
- After 30d of inactivity since `lifecycle/stale` was applied, `lifecycle/rotten` is applied
- After 30d of inactivity since `lifecycle/rotten` was applied, the issue is closed

You can:
- Mark this issue as fresh with `/remove-lifecycle stale`
- Close this issue with `/close`
- Offer to help out with [Issue Triage][1]

Please send feedback to sig-contributor-experience at [kubernetes/community](https://github.com/kubernetes/community).

/lifecycle stale

[1]: https://www.kubernetes.dev/docs/guide/issue-triage/@k8s-triage-robot: The Kubernetes project currently lacks enough active contributors to adequately respond to all issues.

This bot triages un-triaged issues according to the following rules:
- After 90d of inactivity, `lifecycle/stale` is applied
- After 30d of inactivity since `lifecycle/stale` was applied, `lifecycle/rotten` is applied
- After 30d of inactivity since `lifecycle/rotten` was applied, the issue is closed

You can:
- Mark this issue as fresh with `/remove-lifecycle rotten`
- Close this issue with `/close`
- Offer to help out with [Issue Triage][1]

Please send feedback to sig-contributor-experience at [kubernetes/community](https://github.com/kubernetes/community).

/lifecycle rotten

[1]: https://www.kubernetes.dev/docs/guide/issue-triage/@sebltm: Sorry I’d forgotten about this one! The hooks can target SNS, SQS or EventBridge - whether there’s also a Lambda or something else shouldn’t be part of the scope of this implementation. Rather should be led to the user to implement in my opinion.
We should just add a ``lifecycleHook`` with the ARN of the resource they’ve decided to use:
```yaml
lifecycleHook:
  sns:
  sqs:
  eventBridge:
```
(Only one of the options above should be mentioned)
I’d be interested in working on it@k8s-triage-robot: The Kubernetes project currently lacks enough active contributors to adequately respond to all issues and PRs.

This bot triages issues according to the following rules:
- After 90d of inactivity, `lifecycle/stale` is applied
- After 30d of inactivity since `lifecycle/stale` was applied, `lifecycle/rotten` is applied
- After 30d of inactivity since `lifecycle/rotten` was applied, the issue is closed

You can:
- Reopen this issue with `/reopen`
- Mark this issue as fresh with `/remove-lifecycle rotten`
- Offer to help out with [Issue Triage][1]

Please send feedback to sig-contributor-experience at [kubernetes/community](https://github.com/kubernetes/community).

/close not-planned

[1]: https://www.kubernetes.dev/docs/guide/issue-triage/@k8s-ci-robot: @k8s-triage-robot: Closing this issue, marking it as "Not Planned".

<details>

In response to [this](https://github.com/kubernetes-sigs/cluster-api-provider-aws/issues/4020#issuecomment-1642760619):

>The Kubernetes project currently lacks enough active contributors to adequately respond to all issues and PRs.
>
>This bot triages issues according to the following rules:
>- After 90d of inactivity, `lifecycle/stale` is applied
>- After 30d of inactivity since `lifecycle/stale` was applied, `lifecycle/rotten` is applied
>- After 30d of inactivity since `lifecycle/rotten` was applied, the issue is closed
>
>You can:
>- Reopen this issue with `/reopen`
>- Mark this issue as fresh with `/remove-lifecycle rotten`
>- Offer to help out with [Issue Triage][1]
>
>Please send feedback to sig-contributor-experience at [kubernetes/community](https://github.com/kubernetes/community).
>
>/close not-planned
>
>[1]: https://www.kubernetes.dev/docs/guide/issue-triage/


Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes/test-infra](https://github.com/kubernetes/test-infra/issues/new?title=Prow%20issue:) repository.
</details>@sebltm: Hi, can we reopen this issue I'd like to take a look at it@sebltm: @dlipovetsky 
Lifecycle hooks pause the process of scaling in or scaling out instances with a notification system. Notifications are sent to SQS, SNS or EventBridge and a thirdparty system processes the notification, takes some actions and then allows or denies continuing the scale in/out actions.

They are part of the ASG definition in the API, so the user would need to define as a part of the AWSMachinePool or AWSManagedMachinePool which ARN is going to process the notificaiton (ARN of the SNS or SQS queue for example). The actual management process of the SNS/SQS/EventBridge should not be in scope for these, we can assume the user created the appropriate infrastructure through a thirdparty system. Therefore no changes to the IAM policies are required.

The lifecycle hooks need to be created after the ASG. I propose adding the AWSMachinePool and AWSManagedMachinePool CRDs a field:

```yaml
lifecycleHooks:
- name: <name of the lifecycle hook; required>
  notificationTargetARN: <ARN of a target like an SQS or SNS queue otherwise the default is EventBridge; optional>
  roleARN: <role ARN to use for the hook; optional>
  lifecycleTransition: <the instance state to which to attach the hook; required>
  heartbeatTimeout: <the timeout in seconds associated to the hook after which the default action is used, default is 3600 seconds; optional>
  defaultResult: <action taken after timeout, either CONTINUE or ABANDON, default is ABANDON; optional>
  notificationMetadata: <some additional string data passed to the target; optional>
```

which would be reconciled after the ASG is created and deleted after the ASG is deleted.@sebltm: /reopen@k8s-ci-robot: @sebltm: Reopened this issue.

<details>

In response to [this](https://github.com/kubernetes-sigs/cluster-api-provider-aws/issues/4020#issuecomment-2080445269):

>/reopen


Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes/test-infra](https://github.com/kubernetes/test-infra/issues/new?title=Prow%20issue:) repository.
</details>@sebltm: /remove-lifecycle rotten@sebltm: @dlipovetsky any more information needed on this one?@sebltm: @Ankitasw @dlipovetsky how can I help move this one forward please? :)@k8s-triage-robot: The Kubernetes project currently lacks enough contributors to adequately respond to all issues.

This bot triages un-triaged issues according to the following rules:
- After 90d of inactivity, `lifecycle/stale` is applied
- After 30d of inactivity since `lifecycle/stale` was applied, `lifecycle/rotten` is applied
- After 30d of inactivity since `lifecycle/rotten` was applied, the issue is closed

You can:
- Mark this issue as fresh with `/remove-lifecycle stale`
- Close this issue with `/close`
- Offer to help out with [Issue Triage][1]

Please send feedback to sig-contributor-experience at [kubernetes/community](https://github.com/kubernetes/community).

/lifecycle stale

[1]: https://www.kubernetes.dev/docs/guide/issue-triage/@k8s-triage-robot: The Kubernetes project currently lacks enough active contributors to adequately respond to all issues.

This bot triages un-triaged issues according to the following rules:
- After 90d of inactivity, `lifecycle/stale` is applied
- After 30d of inactivity since `lifecycle/stale` was applied, `lifecycle/rotten` is applied
- After 30d of inactivity since `lifecycle/rotten` was applied, the issue is closed

You can:
- Mark this issue as fresh with `/remove-lifecycle rotten`
- Close this issue with `/close`
- Offer to help out with [Issue Triage][1]

Please send feedback to sig-contributor-experience at [kubernetes/community](https://github.com/kubernetes/community).

/lifecycle rotten

[1]: https://www.kubernetes.dev/docs/guide/issue-triage/@richardcase: /remove-lifecycle rotten
@k8s-triage-robot: The Kubernetes project currently lacks enough contributors to adequately respond to all issues.

This bot triages un-triaged issues according to the following rules:
- After 90d of inactivity, `lifecycle/stale` is applied
- After 30d of inactivity since `lifecycle/stale` was applied, `lifecycle/rotten` is applied
- After 30d of inactivity since `lifecycle/rotten` was applied, the issue is closed

You can:
- Mark this issue as fresh with `/remove-lifecycle stale`
- Close this issue with `/close`
- Offer to help out with [Issue Triage][1]

Please send feedback to sig-contributor-experience at [kubernetes/community](https://github.com/kubernetes/community).

/lifecycle stale

[1]: https://www.kubernetes.dev/docs/guide/issue-triage/@k8s-triage-robot: The Kubernetes project currently lacks enough active contributors to adequately respond to all issues.

This bot triages un-triaged issues according to the following rules:
- After 90d of inactivity, `lifecycle/stale` is applied
- After 30d of inactivity since `lifecycle/stale` was applied, `lifecycle/rotten` is applied
- After 30d of inactivity since `lifecycle/rotten` was applied, the issue is closed

You can:
- Mark this issue as fresh with `/remove-lifecycle rotten`
- Close this issue with `/close`
- Offer to help out with [Issue Triage][1]

Please send feedback to sig-contributor-experience at [kubernetes/community](https://github.com/kubernetes/community).

/lifecycle rotten

[1]: https://www.kubernetes.dev/docs/guide/issue-triage/@k8s-triage-robot: The Kubernetes project currently lacks enough active contributors to adequately respond to all issues and PRs.

This bot triages issues according to the following rules:
- After 90d of inactivity, `lifecycle/stale` is applied
- After 30d of inactivity since `lifecycle/stale` was applied, `lifecycle/rotten` is applied
- After 30d of inactivity since `lifecycle/rotten` was applied, the issue is closed

You can:
- Reopen this issue with `/reopen`
- Mark this issue as fresh with `/remove-lifecycle rotten`
- Offer to help out with [Issue Triage][1]

Please send feedback to sig-contributor-experience at [kubernetes/community](https://github.com/kubernetes/community).

/close not-planned

[1]: https://www.kubernetes.dev/docs/guide/issue-triage/@k8s-ci-robot: @k8s-triage-robot: Closing this issue, marking it as "Not Planned".

<details>

In response to [this](https://github.com/kubernetes-sigs/cluster-api-provider-aws/issues/4020#issuecomment-2722603118):

>The Kubernetes project currently lacks enough active contributors to adequately respond to all issues and PRs.
>
>This bot triages issues according to the following rules:
>- After 90d of inactivity, `lifecycle/stale` is applied
>- After 30d of inactivity since `lifecycle/stale` was applied, `lifecycle/rotten` is applied
>- After 30d of inactivity since `lifecycle/rotten` was applied, the issue is closed
>
>You can:
>- Reopen this issue with `/reopen`
>- Mark this issue as fresh with `/remove-lifecycle rotten`
>- Offer to help out with [Issue Triage][1]
>
>Please send feedback to sig-contributor-experience at [kubernetes/community](https://github.com/kubernetes/community).
>
>/close not-planned
>
>[1]: https://www.kubernetes.dev/docs/guide/issue-triage/


Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository.
</details>@sebltm: /reopen@k8s-ci-robot: @sebltm: Reopened this issue.

<details>

In response to [this](https://github.com/kubernetes-sigs/cluster-api-provider-aws/issues/4020#issuecomment-2816640950):

>/reopen


Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository.
</details>

Solution patch:
diff --git a/cmd/clusterawsadm/cloudformation/bootstrap/cluster_api_controller.go b/cmd/clusterawsadm/cloudformation/bootstrap/cluster_api_controller.go
index 049de1043..52ab2b511 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 7909fe12d..6b4f907f7 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 a9290741b..a3c9102ab 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 fa7b5a4d9..3b0ced5ac 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 2390d8609..5cebf6e7a 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 930b879c2..c71cb9d6a 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 50b9bb318..aa3db2c04 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 478967b40..9aea893cc 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 ae2e27906..dea39d02d 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 3ca015276..789f347fc 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 57c08e20c..c092783d6 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 0bacb55e5..f34f670fb 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 b864e1c1b..ad61a2690 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 b376d7cab..9e84b2f22 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 edc07671d..a11d38e58 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 949d48da9..955399c12 100644
--- a/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmachinepools.yaml
+++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmachinepools.yaml
@@ -889,6 +889,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 83d114ce0..44355f22b 100644
--- a/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmanagedmachinepools.yaml
+++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmanagedmachinepools.yaml
@@ -896,6 +896,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/docs/book/src/development/amis.md b/docs/book/src/development/amis.md
index 6d24d65a9..d86a5a12f 100644
--- a/docs/book/src/development/amis.md
+++ b/docs/book/src/development/amis.md
@@ -22,6 +22,12 @@ For a new Kubernetes version that you want to build an AMI for you will need to
 
 You can determine these values directly or by looking at the publish debian apt repositories for the k8s release.
 
+A quick way to get these values is by using the find-ami-publishing-inputs script.
+The script lives under `/scripts` in the root directory of CAPA, and can be invoked like so (supposing you want to publish k8s v1.32 AMIs):
+```
+./scripts/find-ami-publishing-inputs.sh v1.32
+```
+
 ## Build
 
 ### Using GitHub Actions Workflow
diff --git a/exp/api/v1beta1/conversion.go b/exp/api/v1beta1/conversion.go
index 227033b13..e69c17691 100644
--- a/exp/api/v1beta1/conversion.go
+++ b/exp/api/v1beta1/conversion.go
@@ -53,6 +53,9 @@ func (src *AWSMachinePool) ConvertTo(dstRaw conversion.Hub) error {
 		dst.Spec.AvailabilityZoneSubnetType = restored.Spec.AvailabilityZoneSubnetType
 	}
 	dst.Status.InfrastructureMachineKind = restored.Status.InfrastructureMachineKind
+	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
@@ -129,6 +132,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
+	}
 
 	dst.Spec.RolePath = restored.Spec.RolePath
 	dst.Spec.RolePermissionsBoundary = restored.Spec.RolePermissionsBoundary
diff --git a/exp/api/v1beta1/zz_generated.conversion.go b/exp/api/v1beta1/zz_generated.conversion.go
index 346a6ca70..a6dfeb5cb 100644
--- a/exp/api/v1beta1/zz_generated.conversion.go
+++ b/exp/api/v1beta1/zz_generated.conversion.go
@@ -586,6 +586,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
 }
 
@@ -760,6 +761,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 c9451730e..878021c20 100644
--- a/exp/api/v1beta2/awsmachinepool_types.go
+++ b/exp/api/v1beta2/awsmachinepool_types.go
@@ -100,6 +100,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 6ad576bc3..8a38280b4 100644
--- a/exp/api/v1beta2/awsmachinepool_webhook.go
+++ b/exp/api/v1beta2/awsmachinepool_webhook.go
@@ -163,6 +163,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))
@@ -178,6 +182,7 @@ func (r *AWSMachinePool) ValidateCreate() (admission.Warnings, error) {
 	allErrs = append(allErrs, r.validateSpotInstances()...)
 	allErrs = append(allErrs, r.validateRefreshPreferences()...)
 	allErrs = append(allErrs, r.validateInstanceMarketType()...)
+	allErrs = append(allErrs, r.validateLifecycleHooks()...)
 
 	if len(allErrs) == 0 {
 		return nil, nil
@@ -205,7 +210,7 @@ func (r *AWSMachinePool) validateInstanceMarketType() field.ErrorList {
 	switch r.Spec.AWSLaunchTemplate.MarketType {
 	case "", v1beta2.MarketTypeOnDemand, v1beta2.MarketTypeSpot, v1beta2.MarketTypeCapacityBlock:
 	default:
-		allErrs = append(allErrs, field.Invalid(field.NewPath("spec.awsLaunchTemplate.MarketType"), r.Spec.AWSLaunchTemplate.MarketType, fmt.Sprintf("Valid values are: %s, %s, %s and omitted", v1beta2.MarketTypeOnDemand, v1beta2.MarketTypeSpot, v1beta2.MarketTypeCapacityBlock)))
+		allErrs = append(allErrs, field.Invalid(field.NewPath("spec.awsLaunchTemplate.marketType"), r.Spec.AWSLaunchTemplate.MarketType, fmt.Sprintf("Valid values are: %s, %s, %s and omitted", v1beta2.MarketTypeOnDemand, v1beta2.MarketTypeSpot, v1beta2.MarketTypeCapacityBlock)))
 	}
 	if r.Spec.AWSLaunchTemplate.MarketType == v1beta2.MarketTypeSpot && r.Spec.AWSLaunchTemplate.CapacityReservationID != nil {
 		allErrs = append(allErrs, field.Forbidden(field.NewPath("spec.awsLaunchTemplate.marketType"), "cannot be set to 'Spot' when CapacityReservationID is specified"))
@@ -228,6 +233,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/awsmanagedmachinepool_types.go b/exp/api/v1beta2/awsmanagedmachinepool_types.go
index d7dc3f40a..08cf8568e 100644
--- a/exp/api/v1beta2/awsmanagedmachinepool_types.go
+++ b/exp/api/v1beta2/awsmanagedmachinepool_types.go
@@ -180,6 +180,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 6c7e90146..cd8d58450 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 0c7b0fbd9..0f3d8675c 100644
--- a/exp/api/v1beta2/conditions_consts.go
+++ b/exp/api/v1beta2/conditions_consts.go
@@ -59,6 +59,14 @@ const (
 	AWSMachineCreationFailed = "AWSMachineCreationFailed"
 	// AWSMachineDeletionFailed reports if deleting AWSMachines failed.
 	AWSMachineDeletionFailed = "AWSMachineDeletionFailed"
+	// 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 aaf048714..1b76e0945 100644
--- a/exp/api/v1beta2/types.go
+++ b/exp/api/v1beta2/types.go
@@ -239,6 +239,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 000000000..e369d6f39
--- /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 3332aaeed..26223522e 100644
--- a/exp/api/v1beta2/zz_generated.deepcopy.go
+++ b/exp/api/v1beta2/zz_generated.deepcopy.go
@@ -152,6 +152,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
@@ -281,6 +321,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.
@@ -493,6 +540,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 4c5014239..93c6f7c04 100644
--- a/exp/controllers/awsmachinepool_controller.go
+++ b/exp/controllers/awsmachinepool_controller.go
@@ -353,6 +353,11 @@ func (r *AWSMachinePoolReconciler) reconcileNormal(ctx context.Context, machineP
 		}
 	}
 
+	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 ctrl.Result{}, 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 {
@@ -678,6 +683,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/pkg/cloud/scope/machinepool.go b/pkg/cloud/scope/machinepool.go
index a102f82c4..d086b98ce 100644
--- a/pkg/cloud/scope/machinepool.go
+++ b/pkg/cloud/scope/machinepool.go
@@ -394,3 +394,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 7411cc6df..bc75313e1 100644
--- a/pkg/cloud/scope/managednodegroup.go
+++ b/pkg/cloud/scope/managednodegroup.go
@@ -413,3 +413,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 557e1082a..3108dbfbc 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 000000000..3559a799c
--- /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, "%s", 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, "%s", 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, "%s", err.Error())
+			return err
+		}
+	}
+
+	conditions.MarkTrue(storeConditionsOnObject, expinfrav1.LifecycleHookReadyCondition)
+	return nil
+}
diff --git a/pkg/cloud/services/eks/nodegroup.go b/pkg/cloud/services/eks/nodegroup.go
index d56db48ed..3ece1a942 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
@@ -576,6 +584,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
 }
 
@@ -650,3 +662,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 9160a398a..6f6cb280f 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 5e0f3dc2e..06ee27253 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 b860077f4..fcee553a0 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()
diff --git a/scripts/find-ami-publishing-inputs.sh b/scripts/find-ami-publishing-inputs.sh
new file mode 100755
index 000000000..701ad0c90
--- /dev/null
+++ b/scripts/find-ami-publishing-inputs.sh
@@ -0,0 +1,222 @@
+#!/bin/bash
+#
+# Script to find various Kubernetes-related package versions for CAPA AMI publishing
+# based on a target Kubernetes release series (e.g., v1.31 or 1.31) or a
+# specific patch version (e.g., v1.31.5 or 1.31.5).
+#
+# Usage: ./script.sh [vX.Y | X.Y | vX.Y.Z | X.Y.Z]
+# Example: ./script.sh v1.31
+#          ./script.sh 1.31
+#          ./script.sh v1.31.5
+#          ./script.sh 1.31.5
+#          ./script.sh
+#
+
+# Exit immediately if a command exits with a non-zero status.
+# Treat unset variables as an error.
+# The return value of a pipeline is the status of the last command to exit with a non-zero status,
+# or zero if no command exited with a non-zero status.
+set -euo pipefail
+
+# --- Dependency Checks ---
+# Check for container runtime
+if command -v podman &> /dev/null; then
+    CONTAINER_CMD="podman"
+elif command -v docker &> /dev/null; then
+    CONTAINER_CMD="docker"
+else
+    echo "ERROR: Neither 'podman' nor 'docker' command found. Please install one." >&2
+    exit 1
+fi
+echo "INFO: Using container runtime: $CONTAINER_CMD"
+
+# Check for other required host tools
+REQUIRED_TOOLS=( "curl" "grep" "sed" "awk" "cut" "sort" "tail" )
+MISSING_TOOLS=()
+for tool in "${REQUIRED_TOOLS[@]}"; do
+    if ! command -v "$tool" &> /dev/null; then
+        MISSING_TOOLS+=("$tool")
+    fi
+done
+
+if [ ${#MISSING_TOOLS[@]} -ne 0 ]; then
+    echo "ERROR: Missing required command-line tools: ${MISSING_TOOLS[*]}" >&2
+    echo "Please install them and try again." >&2
+    exit 1
+fi
+echo "INFO: All required host tools found."
+
+
+# --- Configuration & Input Handling ---
+DEFAULT_K8S_RELEASE_SERIES_NO_V="1.31"
+K8S_RELEASE_ARG="${1:-}" # Get potential argument
+K8S_TARGET_VERSION_NO_V="" # Specific X.Y.Z requested, if any
+K8S_TARGET_VERSION_V=""    # Specific vX.Y.Z requested, if any
+
+if [[ -n "$K8S_RELEASE_ARG" ]]; then
+    # Argument provided, validate and normalize
+    if [[ "$K8S_RELEASE_ARG" =~ ^v([0-9]+\.[0-9]+)\.([0-9]+)$ ]]; then # Matches vX.Y.Z
+        K8S_RELEASE_SERIES="v${BASH_REMATCH[1]}" # vX.Y
+        K8S_RELEASE_SERIES_NO_V="${BASH_REMATCH[1]}" # X.Y
+        K8S_TARGET_VERSION_NO_V="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}" # X.Y.Z
+        K8S_TARGET_VERSION_V="$K8S_RELEASE_ARG" # vX.Y.Z
+        echo "INFO: Specific version requested: ${K8S_TARGET_VERSION_V}"
+    elif [[ "$K8S_RELEASE_ARG" =~ ^([0-9]+\.[0-9]+)\.([0-9]+)$ ]]; then # Matches X.Y.Z
+        K8S_RELEASE_SERIES="v${BASH_REMATCH[1]}" # vX.Y
+        K8S_RELEASE_SERIES_NO_V="${BASH_REMATCH[1]}" # X.Y
+        K8S_TARGET_VERSION_NO_V="$K8S_RELEASE_ARG" # X.Y.Z
+        K8S_TARGET_VERSION_V="v$K8S_RELEASE_ARG" # vX.Y.Z
+        echo "INFO: Specific version requested: ${K8S_TARGET_VERSION_V}"
+    elif [[ "$K8S_RELEASE_ARG" =~ ^v([0-9]+\.[0-9]+)$ ]]; then # Matches vX.Y
+        K8S_RELEASE_SERIES="$K8S_RELEASE_ARG"
+        K8S_RELEASE_SERIES_NO_V="${BASH_REMATCH[1]}"
+        echo "INFO: Release series requested: ${K8S_RELEASE_SERIES}"
+    elif [[ "$K8S_RELEASE_ARG" =~ ^([0-9]+\.[0-9]+)$ ]]; then # Matches X.Y
+        K8S_RELEASE_SERIES="v$K8S_RELEASE_ARG"
+        K8S_RELEASE_SERIES_NO_V="$K8S_RELEASE_ARG"
+        echo "INFO: Release series requested: ${K8S_RELEASE_SERIES}"
+    else
+        echo "ERROR: Invalid format for Kubernetes version/series argument: '$K8S_RELEASE_ARG'." >&2
+        echo "       Expected format 'vX.Y', 'X.Y', 'vX.Y.Z', or 'X.Y.Z'." >&2
+        exit 1
+    fi
+else
+    # No argument, use default series
+    K8S_RELEASE_SERIES_NO_V="$DEFAULT_K8S_RELEASE_SERIES_NO_V"
+    K8S_RELEASE_SERIES="v$K8S_RELEASE_SERIES_NO_V"
+    echo "INFO: No version specified, using default series: ${K8S_RELEASE_SERIES}"
+fi
+
+echo "--- Finding versions for Kubernetes release series: ${K8S_RELEASE_SERIES} ---"
+echo "INFO: Using Kubernetes major.minor: ${K8S_RELEASE_SERIES_NO_V}"
+
+# --- Get Deb Package Versions ---
+echo "INFO: Running container (${CONTAINER_CMD}) to fetch deb package versions from pkgs.k8s.io..."
+
+# Construct the command for kubectl lookup based on whether a specific version was requested
+if [[ -n "$K8S_TARGET_VERSION_NO_V" ]]; then
+    # Look for the exact X.Y.Z version (allow any deb revision like -1.1)
+    # Need to escape special characters for grep and ensure we match start of version
+    KUBECTL_LOOKUP_PATTERN="${K8S_TARGET_VERSION_NO_V}-[0-9]"
+    KUBECTL_NOT_FOUND_MSG="kubectl version ${K8S_TARGET_VERSION_NO_V} not found"
+    # We still sort and take tail -n 1 in case multiple deb revisions exist for the same k8s version
+    KUBECTL_MADISON_CMD="apt-cache madison kubectl | grep \"${KUBECTL_LOOKUP_PATTERN}\" | sort -V | tail -n 1 || echo \"${KUBECTL_NOT_FOUND_MSG}\""
+else
+    # Look for the latest within the X.Y series
+    KUBECTL_LOOKUP_PATTERN="${K8S_RELEASE_SERIES_NO_V}"
+    KUBECTL_NOT_FOUND_MSG="kubectl version not found for series ${K8S_RELEASE_SERIES_NO_V}"
+    KUBECTL_MADISON_CMD="apt-cache madison kubectl | grep \"${KUBECTL_LOOKUP_PATTERN}\" | sort -V | tail -n 1 || echo \"${KUBECTL_NOT_FOUND_MSG}\""
+fi
+
+PACKAGE_INFO=$(${CONTAINER_CMD} run --platform=linux/amd64 --rm -i ubuntu:22.04 /bin/bash <<EOF
+set -e # Exit on error inside the container script
+export DEBIAN_FRONTEND=noninteractive
+apt-get update > /dev/null
+apt-get install -y gpg curl sudo > /dev/null
+
+# Add Kubernetes APT repository using the vX.Y format
+echo "deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/${K8S_RELEASE_SERIES}/deb/ /" | tee /etc/apt/sources.list.d/kubernetes.list > /dev/null
+mkdir -p -m 755 /etc/apt/keyrings
+curl -fsSL "https://pkgs.k8s.io/core:/stable:/${K8S_RELEASE_SERIES}/deb/Release.key" | gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg > /dev/null
+apt-get update > /dev/null
+
+# Output marker and package info for kubectl using the constructed command
+echo "---KUBECTL_START---"
+eval "$KUBECTL_MADISON_CMD" # Use eval to run the command string built on the host
+echo "---KUBECTL_END---"
+
+# Output marker and package info for CNI (always latest for the series)
+echo "---CNI_START---"
+apt-cache madison kubernetes-cni | sort -V | tail -n 1 || echo "kubernetes-cni version not found"
+echo "---CNI_END---"
+EOF
+)
+
+# Check if container command was successful
+if [ $? -ne 0 ]; then
+    echo "ERROR: Failed to run ${CONTAINER_CMD} container." >&2
+    exit 1
+fi
+
+echo "INFO: Processing package information..."
+
+# Extract kubectl version found by the container
+K8S_DEB_VERSION_LINE=$(echo "${PACKAGE_INFO}" | awk '/---KUBECTL_START---/{flag=1; next} /---KUBECTL_END---/{flag=0} flag')
+if [[ -z "$K8S_DEB_VERSION_LINE" || "$K8S_DEB_VERSION_LINE" == *"not found"* ]]; then
+    # Use the specific "not found" message from the container if available
+    NOT_FOUND_MSG=$(echo "$K8S_DEB_VERSION_LINE" | grep "not found" || echo "Could not find requested kubectl deb package version.")
+    echo "ERROR: ${NOT_FOUND_MSG}" >&2
+    exit 1
+fi
+# Extract the version part (e.g., 1.31.5-1.1) which is the 3rd field
+K8S_DEB_VERSION=$(echo "$K8S_DEB_VERSION_LINE" | awk '{print $3}')
+
+# Extract latest CNI version (always latest for the series)
+CNI_DEB_VERSION_LINE=$(echo "${PACKAGE_INFO}" | awk '/---CNI_START---/{flag=1; next} /---CNI_END---/{flag=0} flag')
+if [[ -z "$CNI_DEB_VERSION_LINE" || "$CNI_DEB_VERSION_LINE" == *"not found"* ]]; then
+    echo "ERROR: Could not find kubernetes-cni deb package version for series ${K8S_RELEASE_SERIES}." >&2
+    exit 1
+fi
+# Extract the version part (e.g., 1.5.1-1.1) which is the 3rd field
+CNI_DEB_VERSION=$(echo "$CNI_DEB_VERSION_LINE" | awk '{print $3}')
+
+# --- Derive K8s Versions ---
+# K8s Semver (vX.Y.Z)
+if [[ -n "$K8S_TARGET_VERSION_V" ]]; then
+    # If specific version was requested, use that as the Semver base
+    K8S_SEMVER="$K8S_TARGET_VERSION_V"
+    # Sanity check: ensure the found deb package matches the requested semver
+    FOUND_SEMVER_BASE="v$(echo "$K8S_DEB_VERSION" | cut -d'-' -f1)"
+    if [[ "$FOUND_SEMVER_BASE" != "$K8S_SEMVER" ]]; then
+         echo "WARN: Found kubectl deb version (${K8S_DEB_VERSION}) does not match requested K8s Semver (${K8S_SEMVER}). Using found version's base." >&2
+         K8S_SEMVER="$FOUND_SEMVER_BASE" # Trust the found version more
+    fi
+else
+    # If only series was requested, derive Semver from the found deb package
+    K8S_SEMVER="v$(echo "$K8S_DEB_VERSION" | cut -d'-' -f1)"
+fi
+
+# K8s Release Series (vX.Y) - Always derived from the input/default series
+K8S_RELEASE_SERIES_OUTPUT="${K8S_RELEASE_SERIES}"
+
+# K8s RPM Package Version (X.Y.Z) - Derived from the final K8S_SEMVER
+K8S_RPM_VERSION="${K8S_SEMVER#v}"
+
+# --- Derive CNI Version ---
+# CNI Semver (vX.Y.Z) - Derived from the latest CNI deb package found for the series
+CNI_SEMVER="v$(echo "$CNI_DEB_VERSION" | cut -d'-' -f1)"
+
+# --- Determine CRICTL Version ---
+# Always based on the K8S_RELEASE_SERIES (vX.Y)
+echo "INFO: Fetching crictl release tags from GitHub API for series ${K8S_RELEASE_SERIES}..."
+CRICTL_RAW_TAG=$(curl -s https://api.github.com/repos/kubernetes-sigs/cri-tools/releases \
+  | grep '"tag_name":' \
+  | grep "\"${K8S_RELEASE_SERIES}." \
+  | sed -E 's/.*"tag_name": *"([^"]+)".*/\1/' \
+  | sort -V \
+  | tail -n 1 \
+  || echo "" ) # Avoid script exit if curl or grep fails in the pipe
+
+if [ -z "$CRICTL_RAW_TAG" ]; then
+    echo "WARN: Could not automatically determine latest crictl tag for ${K8S_RELEASE_SERIES} from GitHub API." >&2
+    echo "WARN: Please verify the crictl version manually at https://github.com/kubernetes-sigs/cri-tools/releases" >&2
+    CRICTL_VERSION="<Check Manually for ${K8S_RELEASE_SERIES}>"
+else
+    CRICTL_VERSION="${CRICTL_RAW_TAG#v}"
+fi
+
+
+# --- Output Results ---
+echo
+echo "--- Derived Versions for K8s ${K8S_RELEASE_SERIES} (Target: ${K8S_TARGET_VERSION_V:-Latest}) ---"
+# Output order from previous request is maintained
+printf "%-25s : %s\n" "K8s Semver" "${K8S_SEMVER}"
+printf "%-25s : %s\n" "K8s Release Series" "${K8S_RELEASE_SERIES_OUTPUT}"
+printf "%-25s : %s\n" "K8s RPM Package Version" "${K8S_RPM_VERSION}"
+printf "%-25s : %s\n" "K8s Deb Package Version" "${K8S_DEB_VERSION}"
+printf "%-25s : %s\n" "CNI Semver" "${CNI_SEMVER}"
+printf "%-25s : %s\n" "CNI Deb Package Version" "${CNI_DEB_VERSION}"
+printf "%-25s : %s\n" "CRICTL Version" "${CRICTL_VERSION}"
+echo "---------------------------------------"
+
+exit 0



Test patch:
diff --git a/exp/api/v1beta2/awsmachinepool_webhook_test.go b/exp/api/v1beta2/awsmachinepool_webhook_test.go
index f3f3b256e..813bc5fef 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,7 +172,88 @@ 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,
 		},
 		{
 			name: "with invalid MarketType provided",
@@ -183,7 +264,7 @@ func TestAWSMachinePoolValidateCreate(t *testing.T) {
 					},
 				},
 			},
-			wantErr: true,
+			wantErrToContain: ptr.To("invalid: spec.awsLaunchTemplate.marketType"),
 		},
 		{
 			name: "with MarketType empty value provided",
@@ -194,7 +275,7 @@ func TestAWSMachinePoolValidateCreate(t *testing.T) {
 					},
 				},
 			},
-			wantErr: false,
+			wantErrToContain: nil,
 		},
 		{
 			name: "with MarketType Spot and CapacityReservationID value provided",
@@ -206,7 +287,7 @@ func TestAWSMachinePoolValidateCreate(t *testing.T) {
 					},
 				},
 			},
-			wantErr: true,
+			wantErrToContain: ptr.To("cannot be set to 'Spot' when CapacityReservationID is specified"),
 		},
 		{
 			name: "with CapacityReservationID and SpotMarketOptions value provided",
@@ -218,7 +299,7 @@ func TestAWSMachinePoolValidateCreate(t *testing.T) {
 					},
 				},
 			},
-			wantErr: true,
+			wantErrToContain: ptr.To("cannot be set to when CapacityReservationID is specified"),
 		},
 
 		{
@@ -231,7 +312,7 @@ func TestAWSMachinePoolValidateCreate(t *testing.T) {
 					},
 				},
 			},
-			wantErr: true,
+			wantErrToContain: ptr.To[string]("setting marketType to CapacityBlock and spotMarketOptions cannot be used together"),
 		},
 		{
 			name: "invalid, MarketType set to MarketTypeOnDemand and spotMarketOptions are specified",
@@ -243,7 +324,7 @@ func TestAWSMachinePoolValidateCreate(t *testing.T) {
 					},
 				},
 			},
-			wantErr: true,
+			wantErrToContain: ptr.To[string]("setting marketType to OnDemand and spotMarketOptions cannot be used together"),
 		},
 		{
 			name: "valid MarketType set to MarketTypeCapacityBlock is specified and CapacityReservationId is not provided",
@@ -254,7 +335,7 @@ func TestAWSMachinePoolValidateCreate(t *testing.T) {
 					},
 				},
 			},
-			wantErr: true,
+			wantErrToContain: ptr.To[string]("capacityReservationID: Forbidden: is required when CapacityBlock is provided"),
 		},
 		{
 			name: "valid MarketType set to MarketTypeCapacityBlock and CapacityReservationId are specified",
@@ -266,14 +347,17 @@ func TestAWSMachinePoolValidateCreate(t *testing.T) {
 					},
 				},
 			},
-			wantErr: false,
+			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())
 			}
@@ -287,10 +371,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",
@@ -309,7 +393,7 @@ func TestAWSMachinePoolValidateUpdate(t *testing.T) {
 					},
 				},
 			},
-			wantErr: false,
+			wantErrToContain: nil,
 		},
 		{
 			name: "adding invalid tags is rejected",
@@ -330,7 +414,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",
@@ -355,7 +439,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",
@@ -379,7 +463,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",
@@ -400,7 +484,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",
@@ -409,7 +493,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",
@@ -421,14 +505,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/controllers/awsmachinepool_controller_test.go b/exp/controllers/awsmachinepool_controller_test.go
index d849aebca..af03c76e9 100644
--- a/exp/controllers/awsmachinepool_controller_test.go
+++ b/exp/controllers/awsmachinepool_controller_test.go
@@ -178,17 +178,17 @@ 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,
-			Client:   testEnv.Client,
 		}
 	}
 
@@ -321,6 +321,7 @@ func TestAWSMachinePoolReconciler(t *testing.T) {
 
 				reconSvc.EXPECT().ReconcileLaunchTemplate(gomock.Any(), ec2Svc, gomock.Any(), gomock.Any()).Return(nil)
 				asgSvc.EXPECT().GetASGByName(gomock.Any()).Return(asg, nil)
+				asgSvc.EXPECT().DescribeLifecycleHooks(gomock.Any()).Return(nil, nil)
 				asgSvc.EXPECT().SubnetIDs(gomock.Any()).Return([]string{}, nil)
 				asgSvc.EXPECT().UpdateASG(gomock.Any()).Return(nil)
 				reconSvc.EXPECT().ReconcileTags(gomock.Any(), gomock.Any()).Return(nil)
@@ -360,6 +361,7 @@ func TestAWSMachinePoolReconciler(t *testing.T) {
 				asgSvc.EXPECT().GetASGByName(gomock.Any()).Return(asg, nil)
 				ec2Svc.EXPECT().InstanceIfExists(aws.String("1")).Return(&infrav1.Instance{ID: "1", Type: "m6.2xlarge"}, nil)
 				ec2Svc.EXPECT().InstanceIfExists(aws.String("2")).Return(&infrav1.Instance{ID: "2", Type: "m6.2xlarge"}, nil)
+				asgSvc.EXPECT().DescribeLifecycleHooks(gomock.Any()).Return(nil, nil)
 				asgSvc.EXPECT().SubnetIDs(gomock.Any()).Return([]string{}, nil)
 				asgSvc.EXPECT().UpdateASG(gomock.Any()).Return(nil)
 				reconSvc.EXPECT().ReconcileTags(gomock.Any(), gomock.Any()).Return(nil)
@@ -458,6 +460,7 @@ func TestAWSMachinePoolReconciler(t *testing.T) {
 
 				reconSvc.EXPECT().ReconcileLaunchTemplate(gomock.Any(), ec2Svc, gomock.Any(), gomock.Any()).Return(nil)
 				asgSvc.EXPECT().GetASGByName(gomock.Any()).Return(asg, nil)
+				asgSvc.EXPECT().DescribeLifecycleHooks(gomock.Any()).Return(nil, nil)
 				asgSvc.EXPECT().SubnetIDs(gomock.Any()).Return([]string{}, nil)
 				asgSvc.EXPECT().UpdateASG(gomock.Any()).Return(nil)
 				reconSvc.EXPECT().ReconcileTags(gomock.Any(), gomock.Any()).Return(nil)
@@ -495,7 +498,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())
@@ -515,6 +518,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",
@@ -531,7 +535,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())
@@ -555,6 +559,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",
@@ -562,8 +567,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())
@@ -580,6 +585,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)
@@ -617,8 +623,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)
@@ -635,8 +643,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)
@@ -653,8 +663,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)
@@ -728,6 +740,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)
@@ -781,6 +794,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)
@@ -837,6 +851,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)
@@ -921,6 +936,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)
@@ -992,9 +1008,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/services/autoscaling/lifecyclehook_test.go b/pkg/cloud/services/autoscaling/lifecyclehook_test.go
new file mode 100644
index 000000000..5aba913f6
--- /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))
+		})
+	}
+}