diff --git a/changelogs/fragments/1369-inventory_aws_ec2-add-support-for-ssm-inventory.yml b/changelogs/fragments/1369-inventory_aws_ec2-add-support-for-ssm-inventory.yml new file mode 100644 index 00000000000..d66fdba18da --- /dev/null +++ b/changelogs/fragments/1369-inventory_aws_ec2-add-support-for-ssm-inventory.yml @@ -0,0 +1,3 @@ +--- +minor_changes: + - inventory aws ec2 - add parameter `use_ssm_inventory` allowing to query ssm inventory information for configured EC2 instances and populate hostvars (https://github.com/ansible-collections/amazon.aws/issues/704). diff --git a/docs/docsite/rst/aws_ec2_guide.rst b/docs/docsite/rst/aws_ec2_guide.rst index 3891aec2e5e..6d8b9f2123d 100644 --- a/docs/docsite/rst/aws_ec2_guide.rst +++ b/docs/docsite/rst/aws_ec2_guide.rst @@ -481,6 +481,11 @@ Now the output of ``ansible-inventory -i demo.aws_ec2.yml --list``: ``strict_permissions: False`` will ignore 403 errors rather than failing. +``use_ssm_inventory`` +--------------------- + +``use_ssm_inventory: True`` will include SSM inventory variables into hostvars for ssm-configured instances. + ``cache`` --------- diff --git a/plugins/inventory/aws_ec2.py b/plugins/inventory/aws_ec2.py index 2bfbb3a356f..446a35abb90 100644 --- 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, 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 index 00000000000..63d22eaecd8 --- /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 index 00000000000..6c2759f313f --- /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 index 00000000000..21e891b3ef0 --- /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 index dd4501196ac..4423e21f422 100755 --- 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 index 00000000000..56710a2dc9d --- /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 index 9b3792584a4..2bd526eedeb 100644 --- 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)