Repository: ansible-collections/amazon.aws

Issue:
ec2_instance metadata_options are not updated
### Summary

Trying to update metadata_options. on existing instances

### Issue Type

Bug Report

### Component Name

ec2_instance

### Ansible Version

```console (paste below)
$ ansible --version
ansible [core 2.15.2]
```


### Collection Versions

```console (paste below)
$ ansible-galaxy collection list
amazon.aws                    7.0.0
```


### AWS SDK versions

```console (paste below)
$ pip show boto boto3 botocore
Name: boto3
Version: 1.28.15
Summary: The AWS SDK for Python
Home-page: https://github.com/boto/boto3
Author: Amazon Web Services
Author-email: 
License: Apache License 2.0
Location: /home/devops/.local/lib/python3.9/site-packages
Requires: botocore, jmespath, s3transfer
Required-by: 
---
Name: botocore
Version: 1.31.15
Summary: Low-level, data-driven core of boto 3.
Home-page: https://github.com/boto/botocore
Author: Amazon Web Services
Author-email: 
License: Apache License 2.0
Location: /home/devops/.local/lib/python3.9/site-packages
Requires: jmespath, python-dateutil, urllib3
Required-by: awscli, boto3, s3transfer
```


### Configuration

```console (paste below)
$ ansible-config dump --only-changed

```


### OS / Environment

_No response_

### Steps to Reproduce

<!--- Paste example playbooks or commands between quotes below -->
```yaml (paste below)
    - name: Get EC2 instance info
      amazon.aws.ec2_instance_info:
        access_key: "{{ aws_config.access_key }}"
        secret_key: "{{ aws_config.secret_key }}"
        session_token: "{{ aws_config.session_token }}"
        region: "{{ aws_config.region }}"
      register: ec2_instances


    - name: Set http_tokens required for AWS instance
      amazon.aws.ec2_instance:
        access_key: "{{ aws_config.access_key }}"
        secret_key: "{{ aws_config.secret_key }}"
        session_token: "{{ aws_config.session_token }}"
        region: "{{ aws_config.region }}"
        instance_ids: "{{ ec2_instances.instances | selectattr('metadata_options.http_tokens', 'search', 'optional') | map(attribute='instance_id') | list }}"
        metadata_options:
          http_endpoint: enabled
          http_tokens: required
          http_put_response_hop_limit: 2
```


### Expected Results

metadata_options to be updated, but hop count is still 1 and IMDSv2 still optional

### Actual Results

```console (paste below)

```


### Code of Conduct

- [X] I agree to follow the Ansible Code of Conduct


Discussion:

Resolving patch:
diff --git a/plugins/modules/ec2_instance.py b/plugins/modules/ec2_instance.py
--- a/plugins/modules/ec2_instance.py
+++ b/plugins/modules/ec2_instance.py
@@ -1630,6 +1630,46 @@ def value_wrapper(v):
     return changes_to_apply
 
 
+def change_instance_metadata_options(instance, params):
+    metadata_options_to_apply = params.get("metadata_options")
+
+    if metadata_options_to_apply is None:
+        return False
+
+    existing_metadata_options = camel_dict_to_snake_dict(instance.get("MetadataOptions"))
+
+    changes_to_apply = {
+        key: metadata_options_to_apply[key]
+        for key in set(existing_metadata_options) & set(metadata_options_to_apply)
+        if existing_metadata_options[key] != metadata_options_to_apply[key]
+    }
+
+    if not changes_to_apply:
+        return False
+
+    request_args = {
+        "InstanceId": instance["InstanceId"],
+        "HttpTokens": changes_to_apply.get("http_tokens") or existing_metadata_options.get("http_tokens"),
+        "HttpPutResponseHopLimit": changes_to_apply.get("http_put_response_hop_limit")
+        or existing_metadata_options.get("http_put_response_hop_limit"),
+        "HttpEndpoint": changes_to_apply.get("http_endpoint") or existing_metadata_options.get("http_endpoint"),
+        "HttpProtocolIpv6": changes_to_apply.get("http_protocol_ipv6")
+        or existing_metadata_options.get("http_protocol_ipv6"),
+        "InstanceMetadataTags": changes_to_apply.get("instance_metadata_tags")
+        or existing_metadata_options.get("instance_metadata_tags"),
+    }
+
+    if module.check_mode:
+        return True
+    try:
+        client.modify_instance_metadata_options(aws_retry=True, **request_args)
+    except (botocore.exceptions.BotoCoreError, botocore.exceptions.ClientError) as e:
+        module.fail_json_aws(
+            e, msg=f"Failed to update instance metadata options for instance ID: {instance['InstanceId']}"
+        )
+    return True
+
+
 def change_network_attachments(instance, params):
     if (params.get("network") or {}).get("interfaces") is not None:
         new_ids = []
@@ -1959,6 +1999,9 @@ def handle_existing(existing_matches, state, filters):
 
     for instance in existing_matches:
         changed |= ensure_ec2_tags(client, module, instance["InstanceId"], tags=tags, purge_tags=purge_tags)
+
+        changed |= change_instance_metadata_options(instance, module.params)
+
         changes = diff_instance_and_params(instance, module.params)
         for c in changes:
             if not module.check_mode:


Test patch:
diff --git a/tests/integration/targets/ec2_instance_metadata_options/tasks/main.yml b/tests/integration/targets/ec2_instance_metadata_options/tasks/main.yml
--- a/tests/integration/targets/ec2_instance_metadata_options/tasks/main.yml
+++ b/tests/integration/targets/ec2_instance_metadata_options/tasks/main.yml
@@ -7,20 +7,21 @@
       session_token: "{{ security_token | default(omit) }}"
       region: "{{ aws_region }}"
   block:
-    - name: create t3.nano instance with metadata_options
+
+    - name: Create a new instance
       amazon.aws.ec2_instance:
-        state: present
-        name: "{{ resource_prefix }}-test-t3nano-enabled-required"
+        state: running
+        name: "{{ resource_prefix }}-test-basic"
+        instance_type: "{{ ec2_instance_type }}"
         image_id: "{{ ec2_ami_id }}"
         tags:
           TestId: "{{ ec2_instance_tag_TestId }}"
-        vpc_subnet_id: "{{ testing_subnet_a.subnet.id }}"
-        instance_type: t3.nano
         metadata_options:
           http_endpoint: enabled
           http_tokens: required
           instance_metadata_tags: enabled
-        wait: false
+          http_put_response_hop_limit: 2
+        wait: true
       register: instance_creation
 
     - name: instance with metadata_options created with the right options
@@ -31,6 +32,7 @@
           - instance_creation.spec.MetadataOptions.HttpEndpoint == 'enabled'
           - instance_creation.spec.MetadataOptions.HttpTokens == 'required'
           - instance_creation.spec.MetadataOptions.InstanceMetadataTags == 'enabled'
+          - instance_creation.spec.MetadataOptions.HttpPutResponseHopLimit == 2
 
     - name: modify metadata_options on existing instance
       amazon.aws.ec2_instance:
@@ -44,9 +46,9 @@
         metadata_options:
           http_endpoint: enabled
           http_tokens: optional
+          http_put_response_hop_limit: 4
         wait: false
       register: metadata_options_update
-      ignore_errors: true
 
     - name: fact presented ec2 instance
       amazon.aws.ec2_instance_info:
@@ -54,12 +56,13 @@
           tag:Name: "{{ resource_prefix }}-test-t3nano-enabled-required"
       register: presented_instance_fact
 
-    - name: modify metadata_options has no effect on existing instance
+    - name: Assert that instance metadata options have been modified successfully
       ansible.builtin.assert:
         that:
           - metadata_options_update is success
-          - metadata_options_update is not changed
+          - metadata_options_update is changed
           - presented_instance_fact.instances | length > 0
           - presented_instance_fact.instances.0.state.name in ['running','pending']
           - presented_instance_fact.instances.0.metadata_options.http_endpoint == 'enabled'
-          - presented_instance_fact.instances.0.metadata_options.http_tokens == 'required'
+          - presented_instance_fact.instances.0.metadata_options.http_tokens == 'optional'
+          - presented_instance_fact.instances.0.metadata_options.http_put_response_hop_limit == 4