Repository: ansible-collections/amazon.aws

Issue:
[FR] aws_ec2 (dynamic inventory) use "aws ssm get-inventory" info also
### Summary

Hi,

I think `aws_ec2` plugin use ec2 metadata only,
but user who already set up ssm inventroy can use/get ssm inventroy info, too.

When I was searching aws_ec2 plugin,
there's no way to get OS (distro) info,
and `ansible_distribution` can not use.

so I request the way to get ssm inventory or use directly in aws_ec2 plugin.

Thanks,

### Issue Type

Feature Idea

### Component Name

aws_ec2

### Additional Information

<!--- Paste example playbooks or commands between quotes below -->
```yaml (paste below)

```


### Code of Conduct

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


Discussion:
Files identified in the description:
* [`plugins/inventory/aws_ec2.py`](https://github.com/['ansible-collections/amazon.aws', 'ansible-collections/community.aws', 'ansible-collections/community.vmware']/blob/main/plugins/inventory/aws_ec2.py)

If these files are inaccurate, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->

Resolving patch:
diff --git a/plugins/inventory/aws_ec2.py b/plugins/inventory/aws_ec2.py
--- a/plugins/inventory/aws_ec2.py
+++ b/plugins/inventory/aws_ec2.py
@@ -133,6 +133,12 @@
       - The suffix for host variables names coming from AWS.
     type: str
     version_added: 3.1.0
+  use_ssm_inventory:
+    description:
+      - Add SSM inventory information into hostvars.
+    type: bool
+    default: False
+    version_added: 6.0.0
 """
 
 EXAMPLES = r"""
@@ -461,6 +467,11 @@ def _describe_ec2_instances(connection, filters):
     return paginator.paginate(Filters=filters).build_full_result()
 
 
+def _get_ssm_information(client, filters):
+    paginator = client.get_paginator("get_inventory")
+    return paginator.paginate(Filters=filters).build_full_result()
+
+
 class InventoryModule(AWSInventoryBase):
 
     NAME = 'amazon.aws.aws_ec2'
@@ -582,7 +593,7 @@ def _get_all_hostnames(self, instance, hostnames):
 
         return hostname_list
 
-    def _query(self, regions, include_filters, exclude_filters, strict_permissions):
+    def _query(self, regions, include_filters, exclude_filters, strict_permissions, use_ssm_inventory):
         '''
             :param regions: a list of regions to query
             :param include_filters: a list of boto3 filter dictionaries
@@ -609,8 +620,23 @@ def _query(self, regions, include_filters, exclude_filters, strict_permissions):
 
         instances = sorted(instances, key=lambda x: x['InstanceId'])
 
+        if use_ssm_inventory and instances:
+            for connection, _region in self.all_clients("ssm"):
+                self._add_ssm_information(connection, instances)
+
         return {'aws_ec2': instances}
 
+    def _add_ssm_information(self, connection, instances):
+        filters = [{"Key": "AWS:InstanceInformation.InstanceId", "Values": [x["InstanceId"] for x in instances]}]
+        result = _get_ssm_information(connection, filters)
+        for entity in result.get("Entities", []):
+            for x in instances:
+                if x["InstanceId"] == entity["Id"]:
+                    content = entity.get("Data", {}).get("AWS:InstanceInformation", {}).get("Content", [])
+                    if content:
+                        x["SsmInventory"] = content[0]
+                    break
+
     def _populate(self, groups, hostnames, allow_duplicated_hosts=False,
                   hostvars_prefix=None, hostvars_suffix=None,
                   use_contrib_script_compatible_ec2_tag_keys=False):
@@ -701,7 +727,8 @@ def parse(self, inventory, loader, path, cache=True):
 
         hostvars_prefix = self.get_option("hostvars_prefix")
         hostvars_suffix = self.get_option("hostvars_suffix")
-        use_contrib_script_compatible_ec2_tag_keys = self.get_option('use_contrib_script_compatible_ec2_tag_keys')
+        use_contrib_script_compatible_ec2_tag_keys = self.get_option("use_contrib_script_compatible_ec2_tag_keys")
+        use_ssm_inventory = self.get_option("use_ssm_inventory")
 
         if self.get_option('include_extra_api_calls'):
             self.display.deprecate(
@@ -712,7 +739,7 @@ def parse(self, inventory, loader, path, cache=True):
         result_was_cached, results = self.get_cached_result(path, cache)
 
         if not result_was_cached:
-            results = self._query(regions, include_filters, exclude_filters, strict_permissions)
+            results = self._query(regions, include_filters, exclude_filters, strict_permissions, use_ssm_inventory)
 
         self._populate(
             results,


Test patch:
diff --git a/tests/integration/targets/inventory_aws_ec2/playbooks/files/ec2-trust-policy.json b/tests/integration/targets/inventory_aws_ec2/playbooks/files/ec2-trust-policy.json
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/inventory_aws_ec2/playbooks/files/ec2-trust-policy.json
@@ -0,0 +1,13 @@
+{
+	"Version": "2008-10-17",
+	"Statement": [
+	  {
+		"Sid": "",
+		"Effect": "Allow",
+		"Principal": {
+		  "Service": "ec2.amazonaws.com"
+		},
+		"Action": "sts:AssumeRole"
+	  }
+	]
+  }
diff --git a/tests/integration/targets/inventory_aws_ec2/playbooks/library/test_get_ssm_inventory.py b/tests/integration/targets/inventory_aws_ec2/playbooks/library/test_get_ssm_inventory.py
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/inventory_aws_ec2/playbooks/library/test_get_ssm_inventory.py
@@ -0,0 +1,68 @@
+#!/usr/bin/python
+# Copyright (c) 2023 Ansible Project
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+DOCUMENTATION = """
+module: test_get_ssm_inventory
+short_description: Get SSM inventory information for EC2 instance
+description:
+    - Gather SSM inventory for EC2 instance configured with SSM.
+author: 'Aubin Bikouo (@abikouo)'
+options:
+  instance_id:
+    description:
+      - EC2 instance id.
+    required: true
+    type: str
+extends_documentation_fragment:
+- amazon.aws.aws
+- amazon.aws.ec2
+- amazon.aws.boto3
+"""
+
+
+RETURN = """
+ssm_inventory:
+    returned: on success
+    description: >
+        SSM inventory information.
+    type: dict
+    sample: {
+        'agent_type': 'amazon-ssm-agent',
+        'agent_version': '3.2.582.0',
+        'computer_name': 'ip-172-31-44-166.ec2.internal',
+        'instance_id': 'i-039eb9b1f55934ab6',
+        'instance_status': 'Active',
+        'ip_address': '172.31.44.166',
+        'platform_name': 'Fedora Linux',
+        'platform_type': 'Linux',
+        'platform_version': '37',
+        'resource_type': 'EC2Instance'
+    }
+"""
+
+
+from ansible_collections.amazon.aws.plugins.module_utils.modules import AnsibleAWSModule
+from ansible_collections.amazon.aws.plugins.module_utils.ec2 import camel_dict_to_snake_dict
+
+
+def main():
+    argument_spec = dict(instance_id=dict(required=True, type="str"))
+
+    module = AnsibleAWSModule(argument_spec=argument_spec, supports_check_mode=True)
+
+    connection = module.client("ssm")
+
+    filters = [{"Key": "AWS:InstanceInformation.InstanceId", "Values": [module.params.get("instance_id")]}]
+    response = connection.get_inventory(Filters=filters)
+    entities = response.get("Entities", [])
+    ssm_inventory = {}
+    if entities:
+        content = entities[0].get("Data", {}).get("AWS:InstanceInformation", {}).get("Content", [])
+        if content:
+            ssm_inventory = camel_dict_to_snake_dict(content[0])
+    module.exit_json(ssm_inventory=ssm_inventory)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/tests/integration/targets/inventory_aws_ec2/playbooks/test_inventory_ssm.yml b/tests/integration/targets/inventory_aws_ec2/playbooks/test_inventory_ssm.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/inventory_aws_ec2/playbooks/test_inventory_ssm.yml
@@ -0,0 +1,137 @@
+---
+- hosts: 127.0.0.1
+  connection: local
+  gather_facts: false
+  environment: "{{ ansible_test.environment }}"
+
+  collections:
+    - amazon.aws
+
+  module_defaults:
+    group/aws:
+      aws_access_key: '{{ aws_access_key }}'
+      aws_secret_key: '{{ aws_secret_key }}'
+      security_token: '{{ security_token | default(omit) }}'
+      region: '{{ aws_region }}'
+
+  vars:
+    ami_details:
+        owner: 125523088429
+        name: Fedora-Cloud-Base-37-1.2.x86_64*
+        user_data: |
+            #!/bin/sh
+            sudo dnf install -y https://s3.amazonaws.com/ec2-downloads-windows/SSMAgent/latest/linux_amd64/amazon-ssm-agent.rpm
+            sudo systemctl start amazon-ssm-agent
+        os_type: linux
+    iam_role_name: "{{ resource_prefix }}-inventory-ssm"
+
+  tasks:
+    - block:
+
+        # Create VPC, subnet, security group, and find image_id to create instance
+        - include_tasks: tasks/setup.yml
+
+        - name: Ensure IAM instance role exists
+          iam_role:
+            name: "{{ iam_role_name }}"
+            assume_role_policy_document: "{{ lookup('file', 'files/ec2-trust-policy.json') }}"
+            state: present
+            create_instance_profile: yes
+            managed_policy:
+            - AmazonSSMManagedInstanceCore
+            wait: True
+          register: role_output
+
+        - name: AMI Lookup (ami_info)
+          ec2_ami_info:
+            owners: '{{ ami_details.owner | default("amazon") }}'
+            filters:
+              name: '{{ ami_details.name }}'
+          register: ec2_amis
+          no_log: true
+
+        - name: Set facts with latest AMIs
+          vars:
+            latest_ami: '{{ ec2_amis.images | default([]) | sort(attribute="creation_date") | last }}'
+          set_fact:
+            latest_ami_id: '{{ ssm_amis | default(latest_ami.image_id) }}'
+
+        - name: Create EC2 instance
+          ec2_instance:
+            instance_type: "t3.micro"
+            ebs_optimized: True
+            image_id: "{{ latest_ami_id }}"
+            wait: "yes"
+            instance_role: "{{ role_output.iam_role.role_name }}"
+            name: "{{ resource_prefix }}-inventory-ssm"
+            user_data: "{{ ami_details.user_data }}"
+            state: running
+            tags:
+              TestPrefix: '{{ resource_prefix }}'
+          register: instance_output
+
+        - set_fact:
+            instances_ids: "{{ [instance_output.instance_ids[0]] }}"
+
+        - name: Get ssm inventory information
+          test_get_ssm_inventory:
+            instance_id: '{{ instance_output.instance_ids[0] }}'
+            aws_access_key: '{{ aws_access_key }}'
+            aws_secret_key: '{{ aws_secret_key }}'
+            security_token: '{{ security_token | default(omit) }}'
+            region: '{{ aws_region }}'
+          register: result
+          until: result.ssm_inventory != {}
+          retries: 18
+          delay: 10
+
+        - name: validate EC2 ssm-configured instance
+          assert:
+            that:
+              - result.ssm_inventory != {}
+
+        # Create 'Standard' EC2 instance (without ssm configured)
+        - name: Create another EC2 instance without SSM configured
+          amazon.aws.ec2_instance:
+            name: "{{ resource_prefix }}-inventory-std"
+            instance_type: "t3.micro"
+            image_id: "{{ latest_ami_id }}"
+            wait: true
+            state: running
+          register: _instance
+
+        - set_fact:
+            instances_ids: "{{ instances_ids + _instance.instance_ids }}"
+
+        # refresh inventory
+        - meta: refresh_inventory
+
+        - debug: var=hostvars
+
+        - name: assert hostvars was populated with ssm_inventory information
+          assert:
+            that:
+              - ssm_hostname in hostvars
+              - std_hostname in hostvars
+              - '"ssm_inventory" in hostvars[ssm_hostname]'
+              - hostvars[ssm_hostname].ssm_inventory["agent_type"] == "amazon-ssm-agent"
+              - hostvars[ssm_hostname].ssm_inventory["platform_type"] == "Linux"
+              - hostvars[ssm_hostname].ssm_inventory["platform_name"] == "Fedora Linux"
+              - '"ssm_inventory" not in hostvars[std_hostname]'
+          vars:
+            ssm_hostname: '{{ resource_prefix }}-inventory-ssm'
+            std_hostname: '{{ resource_prefix }}-inventory-std'
+
+      always:
+        - name: Delete IAM role
+          iam_role:
+            name: "{{ iam_role_name }}"
+            state: absent
+            wait: True
+
+        - name: Delete EC2 instances
+          amazon.aws.ec2_instance:
+            instance_ids: "{{ instances_ids }}"
+            wait: true
+            state: absent
+          when: instances_ids is defined
diff --git a/tests/integration/targets/inventory_aws_ec2/runme.sh b/tests/integration/targets/inventory_aws_ec2/runme.sh
--- a/tests/integration/targets/inventory_aws_ec2/runme.sh
+++ b/tests/integration/targets/inventory_aws_ec2/runme.sh
@@ -74,6 +74,10 @@ ansible-playbook playbooks/create_inventory_config.yml -e "template='inventory_w
 ansible-playbook playbooks/populate_cache.yml "$@"
 ansible-playbook playbooks/test_inventory_cache.yml "$@"
 
+# generate inventory config with ssm inventory information
+ansible-playbook playbooks/create_inventory_config.yml -e "template='inventory_with_ssm.yml.j2'" "$@"
+ansible-playbook playbooks/test_inventory_ssm.yml "$@"
+
 # remove inventory cache
 rm -r aws_ec2_cache_dir/
 
diff --git a/tests/integration/targets/inventory_aws_ec2/templates/inventory_with_ssm.yml.j2 b/tests/integration/targets/inventory_aws_ec2/templates/inventory_with_ssm.yml.j2
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/inventory_aws_ec2/templates/inventory_with_ssm.yml.j2
@@ -0,0 +1,14 @@
+plugin: amazon.aws.aws_ec2
+aws_access_key_id: '{{ aws_access_key }}'
+aws_secret_access_key: '{{ aws_secret_key }}'
+{% if security_token | default(false) %}
+aws_security_token: '{{ security_token }}'
+{% endif %}
+regions:
+- '{{ aws_region }}'
+filters:
+  tag:Name:
+  - '{{ resource_prefix }}-inventory-*'
+hostnames:
+- tag:Name
+use_ssm_inventory: true
diff --git a/tests/unit/plugins/inventory/test_aws_ec2.py b/tests/unit/plugins/inventory/test_aws_ec2.py
--- a/tests/unit/plugins/inventory/test_aws_ec2.py
+++ b/tests/unit/plugins/inventory/test_aws_ec2.py
@@ -436,10 +436,11 @@ def test_inventory_query(inventory, include_filters, exclude_filters, instances_
     strict = False
 
     params = {
-        'regions': regions,
-        'strict_permissions': strict,
-        'include_filters': [],
-        'exclude_filters': [],
+        "regions": regions,
+        "strict_permissions": strict,
+        "include_filters": [],
+        "exclude_filters": [],
+        "use_ssm_inventory": False,
     }
 
     for u in include_filters:
@@ -626,3 +627,44 @@ def test_inventory_get_all_hostnames_failure(inventory):
     with pytest.raises(AnsibleError) as err:
         inventory._get_all_hostnames(instance, hostnames)
         assert "A 'name' key must be defined in a hostnames dictionary." in err
+
+
+@patch("ansible_collections.amazon.aws.plugins.inventory.aws_ec2._get_ssm_information")
+def test_inventory__add_ssm_information(m_get_ssm_information, inventory):
+    instances = [
+        {"InstanceId": "i-001", "Name": "first-instance"},
+        {"InstanceId": "i-002", "Name": "another-instance"},
+    ]
+
+    result = {
+        "StatusCode": 200,
+        "Entities": [
+            {"Id": "i-001", "Data": {}},
+            {
+                "Id": "i-002",
+                "Data": {
+                    "AWS:InstanceInformation": {
+                        "Content": [{"os_type": "Linux", "os_name": "Fedora", "os_version": 37}]
+                    }
+                },
+            },
+        ],
+    }
+    m_get_ssm_information.return_value = result
+
+    connection = MagicMock()
+
+    expected = [
+        {"InstanceId": "i-001", "Name": "first-instance"},
+        {
+            "InstanceId": "i-002",
+            "Name": "another-instance",
+            "SsmInventory": {"os_type": "Linux", "os_name": "Fedora", "os_version": 37},
+        },
+    ]
+
+    inventory._add_ssm_information(connection, instances)
+    assert expected == instances
+
+    filters = [{"Key": "AWS:InstanceInformation.InstanceId", "Values": [x["InstanceId"] for x in instances]}]
+    m_get_ssm_information.assert_called_once_with(connection, filters)