From 6d978bc285b4313be1655c287045e92a1a3e3f46 Mon Sep 17 00:00:00 2001
From: jctanner <tanner.jc@gmail.com>
Date: Mon, 25 Mar 2019 12:15:31 -0400
Subject: [PATCH] check aws inv plugin (#53435)

* Add the constructed config with legacy settings enabled to match the script

* Add interesting characters in tags and security group names

* add strict to config

* Add a stopped instance in inventory

* Create symlinks in the test

* Add reservation details to mock

* run script and plugin with a virtual env

* call the script with ansible-inventory

* Fix code coverage collection.
---
 .../targets/inventory_aws_conformance/aliases |   2 +
 .../targets/inventory_aws_conformance/ec2.sh  |   5 +
 .../inventory_diff.py                         |  64 ++++
 .../inventory_aws_conformance/lib/__init__.py |   0
 .../lib/boto/__init__.py                      |   2 +
 .../lib/boto/ec2/__init__.py                  |  45 +++
 .../lib/boto/elasticache/__init__.py          |  29 ++
 .../lib/boto/exception.py                     |  19 +
 .../lib/boto/exceptions.py                    |  19 +
 .../lib/boto/mocks/__init__.py                |   0
 .../lib/boto/mocks/instances.py               | 345 ++++++++++++++++++
 .../inventory_aws_conformance/lib/boto/rds.py |   0
 .../lib/boto/route53.py                       |   0
 .../lib/boto/session.py                       |  73 ++++
 .../inventory_aws_conformance/lib/boto/sts.py |   0
 .../inventory_aws_conformance/runme.sh        | 151 ++++++++
 16 files changed, 754 insertions(+)
 create mode 100644 test/integration/targets/inventory_aws_conformance/aliases
 create mode 100755 test/integration/targets/inventory_aws_conformance/ec2.sh
 create mode 100755 test/integration/targets/inventory_aws_conformance/inventory_diff.py
 create mode 100644 test/integration/targets/inventory_aws_conformance/lib/__init__.py
 create mode 100644 test/integration/targets/inventory_aws_conformance/lib/boto/__init__.py
 create mode 100644 test/integration/targets/inventory_aws_conformance/lib/boto/ec2/__init__.py
 create mode 100644 test/integration/targets/inventory_aws_conformance/lib/boto/elasticache/__init__.py
 create mode 100644 test/integration/targets/inventory_aws_conformance/lib/boto/exception.py
 create mode 100644 test/integration/targets/inventory_aws_conformance/lib/boto/exceptions.py
 create mode 100644 test/integration/targets/inventory_aws_conformance/lib/boto/mocks/__init__.py
 create mode 100644 test/integration/targets/inventory_aws_conformance/lib/boto/mocks/instances.py
 create mode 100644 test/integration/targets/inventory_aws_conformance/lib/boto/rds.py
 create mode 100644 test/integration/targets/inventory_aws_conformance/lib/boto/route53.py
 create mode 100644 test/integration/targets/inventory_aws_conformance/lib/boto/session.py
 create mode 100644 test/integration/targets/inventory_aws_conformance/lib/boto/sts.py
 create mode 100755 test/integration/targets/inventory_aws_conformance/runme.sh

diff --git a/test/integration/targets/inventory_aws_conformance/aliases b/test/integration/targets/inventory_aws_conformance/aliases
new file mode 100644
index 00000000000..092d6ac64b5
--- /dev/null
+++ b/test/integration/targets/inventory_aws_conformance/aliases
@@ -0,0 +1,2 @@
+shippable/posix/group2
+needs/file/contrib/inventory/ec2.py
diff --git a/test/integration/targets/inventory_aws_conformance/ec2.sh b/test/integration/targets/inventory_aws_conformance/ec2.sh
new file mode 100755
index 00000000000..9ae9dee58ab
--- /dev/null
+++ b/test/integration/targets/inventory_aws_conformance/ec2.sh
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+# Wrapper to use the correct Python interpreter and support code coverage.
+ABS_SCRIPT=$(python -c "import os; print(os.path.abspath('../../../../contrib/inventory/ec2.py'))")
+cd "${OUTPUT_DIR}"
+python.py "${ABS_SCRIPT}" "$@"
diff --git a/test/integration/targets/inventory_aws_conformance/inventory_diff.py b/test/integration/targets/inventory_aws_conformance/inventory_diff.py
new file mode 100755
index 00000000000..f50df11b867
--- /dev/null
+++ b/test/integration/targets/inventory_aws_conformance/inventory_diff.py
@@ -0,0 +1,64 @@
+#!/usr/bin/env python
+
+import json
+import sys
+
+
+def check_hosts(contrib, plugin):
+    contrib_hosts = sorted(contrib['_meta']['hostvars'].keys())
+    plugin_hosts = sorted(plugin['_meta']['hostvars'].keys())
+    assert contrib_hosts == plugin_hosts
+    return contrib_hosts, plugin_hosts
+
+
+def check_groups(contrib, plugin):
+    contrib_groups = set(contrib.keys())
+    plugin_groups = set(plugin.keys())
+    missing_groups = contrib_groups.difference(plugin_groups)
+    if missing_groups:
+        print("groups: %s are missing from the plugin" % missing_groups)
+    assert not missing_groups
+    return contrib_groups, plugin_groups
+
+
+def check_host_vars(key, value, plugin, host):
+    # tags are a dict in the plugin
+    if key.startswith('ec2_tag'):
+        print('assert tag', key, value)
+        assert 'tags' in plugin['_meta']['hostvars'][host], 'b file does not have tags in host'
+        btags = plugin['_meta']['hostvars'][host]['tags']
+        tagkey = key.replace('ec2_tag_', '')
+        assert tagkey in btags, '%s tag not in b file host tags' % tagkey
+        assert value == btags[tagkey], '%s != %s' % (value, btags[tagkey])
+    else:
+        print('assert var', key, value, key in plugin['_meta']['hostvars'][host], plugin['_meta']['hostvars'][host].get(key))
+        assert key in plugin['_meta']['hostvars'][host], "%s not in b's %s hostvars" % (key, host)
+        assert value == plugin['_meta']['hostvars'][host][key], "%s != %s" % (value, plugin['_meta']['hostvars'][host][key])
+
+
+def main():
+    # a should be the source of truth (the script output)
+    a = sys.argv[1]
+    # b should be the thing to check (the plugin output)
+    b = sys.argv[2]
+
+    with open(a, 'r') as f:
+        adata = json.loads(f.read())
+    with open(b, 'r') as f:
+        bdata = json.loads(f.read())
+
+    # all hosts should be present obviously
+    ahosts, bhosts = check_hosts(adata, bdata)
+
+    # all groups should be present obviously
+    agroups, bgroups = check_groups(adata, bdata)
+
+    # check host vars can be reconstructed
+    for ahost in ahosts:
+        contrib_host_vars = adata['_meta']['hostvars'][ahost]
+        for key, value in contrib_host_vars.items():
+            check_host_vars(key, value, bdata, ahost)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/test/integration/targets/inventory_aws_conformance/lib/__init__.py b/test/integration/targets/inventory_aws_conformance/lib/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/test/integration/targets/inventory_aws_conformance/lib/boto/__init__.py b/test/integration/targets/inventory_aws_conformance/lib/boto/__init__.py
new file mode 100644
index 00000000000..794d39542ef
--- /dev/null
+++ b/test/integration/targets/inventory_aws_conformance/lib/boto/__init__.py
@@ -0,0 +1,2 @@
+import boto.exceptions as exceptions  # pylint: disable=useless-import-alias
+import boto.session as session  # pylint: disable=useless-import-alias
diff --git a/test/integration/targets/inventory_aws_conformance/lib/boto/ec2/__init__.py b/test/integration/targets/inventory_aws_conformance/lib/boto/ec2/__init__.py
new file mode 100644
index 00000000000..a95dfe53186
--- /dev/null
+++ b/test/integration/targets/inventory_aws_conformance/lib/boto/ec2/__init__.py
@@ -0,0 +1,45 @@
+# boto2
+
+from boto.mocks.instances import BotoInstance, Reservation
+
+
+class Region(object):
+    name = None
+
+    def __init__(self, name):
+        self.name = name
+
+
+class Connection(object):
+    region = None
+    instances = None
+
+    def __init__(self, **kwargs):
+        self.reservations = [Reservation(
+            owner_id='123456789012',
+            instance_ids=['i-0678e70402c0b434c', 'i-16a83b42f01c082a1'],
+            region=kwargs['region']
+        )]
+
+    def get_all_instances(self, *args, **kwargs):
+        return self.reservations
+
+    def describe_cache_clusters(self, *args, **kwargs):
+        return {}
+
+    def get_all_tags(self, *args, **kwargs):
+        tags = []
+        resid = kwargs['filters']['resource-id'][0]
+        for instance in self.reservations[0].instances:
+            if instance.id == resid:
+                tags = instance._tags[:]
+                break
+        return tags
+
+
+def connect_to_region(*args, **kwargs):
+    return Connection(region=args[0])
+
+
+def regions():
+    return [Region('us-east-1')]
diff --git a/test/integration/targets/inventory_aws_conformance/lib/boto/elasticache/__init__.py b/test/integration/targets/inventory_aws_conformance/lib/boto/elasticache/__init__.py
new file mode 100644
index 00000000000..e8060797bce
--- /dev/null
+++ b/test/integration/targets/inventory_aws_conformance/lib/boto/elasticache/__init__.py
@@ -0,0 +1,29 @@
+class Connection(object):
+    def __init__(self):
+        pass
+
+    def get_all_instances(self, *args, **kwargs):
+        return []
+
+    def describe_cache_clusters(self, *args, **kwargs):
+        return {
+            'DescribeCacheClustersResponse': {
+                'DescribeCacheClustersResult': {
+                    'Marker': None,
+                    'CacheClusters': []
+                }
+            }
+        }
+
+    def describe_replication_groups(self, *args, **kwargs):
+        return {
+            'DescribeReplicationGroupsResponse': {
+                'DescribeReplicationGroupsResult': {
+                    'ReplicationGroups': []
+                }
+            }
+        }
+
+
+def connect_to_region(*args, **kwargs):
+    return Connection()
diff --git a/test/integration/targets/inventory_aws_conformance/lib/boto/exception.py b/test/integration/targets/inventory_aws_conformance/lib/boto/exception.py
new file mode 100644
index 00000000000..4617c2896f9
--- /dev/null
+++ b/test/integration/targets/inventory_aws_conformance/lib/boto/exception.py
@@ -0,0 +1,19 @@
+
+class BotoServerError(Exception):
+    pass
+
+
+class ClientError(Exception):
+    pass
+
+
+class PartialCredentialsError(Exception):
+    pass
+
+
+class ProfileNotFound(Exception):
+    pass
+
+
+class BotoCoreError(Exception):
+    pass
diff --git a/test/integration/targets/inventory_aws_conformance/lib/boto/exceptions.py b/test/integration/targets/inventory_aws_conformance/lib/boto/exceptions.py
new file mode 100644
index 00000000000..4617c2896f9
--- /dev/null
+++ b/test/integration/targets/inventory_aws_conformance/lib/boto/exceptions.py
@@ -0,0 +1,19 @@
+
+class BotoServerError(Exception):
+    pass
+
+
+class ClientError(Exception):
+    pass
+
+
+class PartialCredentialsError(Exception):
+    pass
+
+
+class ProfileNotFound(Exception):
+    pass
+
+
+class BotoCoreError(Exception):
+    pass
diff --git a/test/integration/targets/inventory_aws_conformance/lib/boto/mocks/__init__.py b/test/integration/targets/inventory_aws_conformance/lib/boto/mocks/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/test/integration/targets/inventory_aws_conformance/lib/boto/mocks/instances.py b/test/integration/targets/inventory_aws_conformance/lib/boto/mocks/instances.py
new file mode 100644
index 00000000000..4d6713d8d14
--- /dev/null
+++ b/test/integration/targets/inventory_aws_conformance/lib/boto/mocks/instances.py
@@ -0,0 +1,345 @@
+from ansible.module_utils.common._collections_compat import MutableMapping
+
+import datetime
+from dateutil.tz import tzutc
+import sys
+
+try:
+    from ansible.parsing.yaml.objects import AnsibleUnicode
+except ImportError:
+    AnsibleUnicode = str
+
+
+if sys.version_info[0] >= 3:
+    unicode = str
+
+DNSDOMAIN = "ansible.amazon.com"
+
+
+class Reservation(object):
+    def __init__(self, owner_id, instance_ids, region):
+        if len(instance_ids) > 1:
+            stopped_instance = instance_ids[-1]
+        self.instances = []
+        for instance_id in instance_ids:
+            stopped = bool(instance_id == stopped_instance)
+            self.instances.append(BotoInstance(instance_id=instance_id, owner_id=owner_id, region=region, stopped=stopped))
+        self.owner_id = owner_id
+
+
+class Tag(object):
+    res_id = None
+    name = None
+    value = None
+
+    def __init__(self, res_id, name, value):
+        self.res_id = res_id
+        self.name = name
+        self.value = value
+
+
+class SecurityGroup(object):
+    name = 'sg_default'
+    group_id = 'sg-00000'
+    id = 'sg-00000'
+
+    def __init__(self, group_id, group_name):
+        self.name = group_name
+        self.group_id = group_id
+        self.id = self.group_id
+
+    def __str__(self):
+        return self.name
+
+
+class NetworkInterfaceBase(list):
+
+    def __init__(self, owner_id=None, private_ip=None, subnet_id=None, vpc_id=None):
+        self.description = 'Primary network interface'
+        self.mac_address = '06:32:7e:30:3a:20'
+        self.owner_id = owner_id
+        self.private_ip_address = private_ip
+        self.status = 'in-use'
+        self.subnet_id = subnet_id
+        self.vpc_id = vpc_id
+
+        super(NetworkInterfaceBase, self).__init__([self.to_dict()])
+
+    def to_dict(self):
+
+        data = {}
+        for attr in dir(self):
+            if attr.startswith('__') or attr == 'boto3':
+                continue
+
+            val = getattr(self, attr)
+
+            if callable(val):
+                continue
+
+            if self.boto3:
+                attr = ''.join(x.capitalize() or '_' for x in attr.split('_'))
+
+            data[attr] = val
+
+        return data
+
+
+class Boto3NetworkInterface(NetworkInterfaceBase):
+
+    boto3 = True
+
+    def __init__(self, owner_id=None, public_ip=None, public_dns=None, private_ip=None, security_groups=None, subnet_id=None, vpc_id=None):
+        self.association = {
+            'IpOwnerId': 'amazon',
+            'PublicDnsName': public_dns,
+            'PublicIp': public_ip
+        }
+        self.attachment = {
+            'AttachTime': datetime.datetime(2019, 2, 27, 19, 41, 49, tzinfo=tzutc()),
+            'AttachmentId': 'eni-attach-008fda539bfd1877d',
+            'DeleteOnTermination': True,
+            'DeviceIndex': 0,
+            'Status': 'attached'
+        }
+        self.groups = security_groups
+        self.ipv6_addresses = [{'Ipv6Address': '2600:1f18:1af:f6a1:2c8d:7cf:3d14:1224'}]
+        self.network_interface_id = 'eni-00abc58b929197984'
+        self.private_ip_addresses = [{
+            'Association': {
+                'IpOwnerId': 'amazon',
+                'PublicDnsName': public_dns,
+                'PublicIp': public_ip
+            },
+            'Primary': True,
+            'PrivateIpAddress': private_ip
+        }]
+        self.source_dest_check = True
+
+        super(Boto3NetworkInterface, self).__init__(
+            owner_id=owner_id,
+            private_ip=private_ip,
+            subnet_id=subnet_id,
+            vpc_id=vpc_id
+        )
+
+
+class BotoNetworkInterface(NetworkInterfaceBase):
+
+    boto3 = False
+
+    def __init__(self, owner_id=None, public_ip=None, public_dns=None, private_ip=None, subnet_id=None, vpc_id=None):
+        self.tags = {}
+        self.id = 'eni-00abc58b929197984'
+        self.availability_zone = None
+        self.requester_managed = False
+        self.publicIp = public_ip
+        self.publicDnsName = public_dns
+        self.ipOwnerId = 'amazon'
+        self.association = '\n                            '
+        self.item = '\n                        '
+
+        super(BotoNetworkInterface, self).__init__(
+            owner_id=owner_id,
+            private_ip=private_ip,
+            subnet_id=subnet_id,
+            vpc_id=vpc_id
+        )
+
+
+class Volume(object):
+    def __init__(self, volume_id):
+        self.volume_id = volume_id
+
+
+class BlockDeviceMapping(MutableMapping):
+    devices = {}
+
+    def __init__(self, devices):
+        for device, volume_id in devices.items():
+            self.devices[device] = Volume(volume_id)
+
+    def __getitem__(self, key):
+        return self.devices[key]
+
+    def __setitem__(self, key, value):
+        self.devices[key] = Volume(value)
+
+    def __delitem__(self, key):
+        del self.devices[key]
+
+    def __iter__(self):
+        return iter(self.devices)
+
+    def __len__(self):
+        return len(self.devices)
+
+
+class InstanceBase(object):
+    def __init__(self, stopped=False):
+        # set common ignored attribute to make sure instances have identical tags and security groups
+        self._ignore_security_groups = {
+            'sg-0e1d2bd02b45b712e': 'sgname-with-hyphens',
+            'sg-ae5c262eb5c4d712e': 'name@with?invalid!chars'
+        }
+        self._ignore_tags = {
+            'tag-with-hyphens': 'value:with:colons',
+            b'\xec\xaa\xb4'.decode('utf'): 'value1with@invalid:characters',
+            'tag;me': 'value@noplez',
+            'tag!notit': 'value<=ohwhy?'
+        }
+        if not stopped:
+            self._ignore_state = {'Code': 16, 'Name': 'running'}
+        else:
+            self._ignore_state = {'Code': 80, 'Name': 'stopped'}
+
+        # common attributes
+        self.ami_launch_index = '0'
+        self.architecture = 'x86_64'
+        self.client_token = ''
+        self.ebs_optimized = False
+        self.hypervisor = 'xen'
+        self.image_id = 'ami-0ac019f4fcb7cb7e6'
+        self.instance_type = 't2.micro'
+        self.key_name = 'k!y:2/-n@me'
+        self.private_dns_name = 'ip-20-0-0-20.ec2.internal'
+        self.private_ip_address = '20.0.0.20'
+        self.product_codes = []
+        if not stopped:
+            self.public_dns_name = 'ec2-12-3-456-78.compute-1.amazonaws.com'
+        else:
+            self.public_dns_name = ''
+        self.root_device_name = '/dev/sda1'
+        self.root_device_type = 'ebs'
+        self.subnet_id = 'subnet-09564ba2121bca7bd'
+        self.virtualization_type = 'hvm'
+        self.vpc_id = 'vpc-01ae527fabc81dd04'
+
+    def to_dict(self):
+
+        data = {}
+        for attr in dir(self):
+            if attr.startswith(('__', '_ignore')) or attr in ['to_dict', 'boto3']:
+                continue
+
+            val = getattr(self, attr)
+
+            if self.boto3:
+                attr = ''.join(x.capitalize() or '_' for x in attr.split('_'))
+
+            data[attr] = val
+
+        return data
+
+
+class BotoInstance(InstanceBase):
+
+    boto3 = False
+
+    def __init__(self, instance_id=None, owner_id=None, region=None, stopped=False):
+        super(BotoInstance, self).__init__(stopped=stopped)
+
+        self._in_monitoring_element = False
+        self._tags = [Tag(instance_id, k, v) for k, v in self._ignore_tags.items()]
+        self.block_device_mapping = BlockDeviceMapping({'/dev/sda1': 'vol-044a646a9292c82af'})
+        self.dns_name = 'ec2-12-3-456-78.compute-1.amazonaws.com'
+        self.eventsSet = None
+        self.group_name = None
+        self.groups = [SecurityGroup(k, v) for k, v in self._ignore_security_groups.items()]
+        self.id = instance_id
+        self.instance_profile = {
+            'arn': 'arn:aws:iam::{0}:instance-profile/developer'.format(owner_id),
+            'id': 'ABCDE2GHIJKLMN8PQRSTU'
+        }
+        if not stopped:
+            self.ip_address = '12.3.456.7'
+        else:
+            self.ip_address = ''  # variable is returned as empty by boto if the instance is stopped
+        self.item = '\n                '
+        self.kernel = None
+        self.launch_time = '2019-02-27T19:41:49.000Z'
+        self.monitored = False
+        self.monitoring = '\n                    '
+        self.monitoring_state = 'disabled'
+        self.persistent = False
+        self.placement = region + 'e'
+        self.platform = None
+        self.ramdisk = None
+        self.reason = ''
+        self.region = region
+        self.requester_id = None
+        self.sourceDestCheck = 'true'
+        self.spot_instance_request_id = None
+        self.state = self._ignore_state['Name']
+        self.state_code = self._ignore_state['Code']
+        if not stopped:
+            self.state_reason = None
+        else:
+            self.state_reason = {
+                'code': 'Client.UserInitiatedShutdown',
+                'message': 'Client.UserInitiatedShutdown: User initiated shutdown'
+            }
+        self.tags = dict(self._ignore_tags)
+
+        self.interfaces = BotoNetworkInterface(
+            owner_id=owner_id,
+            public_ip=self.ip_address,
+            public_dns=self.public_dns_name,
+            private_ip=self.private_ip_address,
+            subnet_id=self.subnet_id,
+            vpc_id=self.vpc_id,
+        )
+
+
+class Boto3Instance(InstanceBase):
+
+    boto3 = True
+
+    def __init__(self, instance_id=None, owner_id=None, region=None, stopped=False):
+        super(Boto3Instance, self).__init__(stopped=stopped)
+
+        self.block_device_mappings = [{
+            'DeviceName': '/dev/sda1',
+            'Ebs': {
+                'AttachTime': datetime.datetime(2019, 2, 27, 19, 41, 50, tzinfo=tzutc()),
+                'DeleteOnTermination': True,
+                'Status': 'attached',
+                'VolumeId': 'vol-044a646a9292c82af'
+            }
+        }]
+        self.capacity_reservation_specification = {'CapacityReservationPreference': 'open'}
+        self.cpu_options = {'CoreCount': 1, 'ThreadsPerCore': 1}
+        self.ena_support = True
+        self.hibernation_options = {'Configured': False}
+        self.iam_instance_profile = {
+            'Arn': 'arn:aws:iam::{0}:instance-profile/developer'.format(owner_id),
+            'Id': 'ABCDE2GHIJKLMN8PQRSTU'
+        }
+        self.instance_id = instance_id
+        self.launch_time = datetime.datetime(2019, 2, 27, 19, 41, 49, tzinfo=tzutc())
+        self.monitoring = {'State': 'disabled'}
+        self.placement = {'AvailabilityZone': region + 'e', 'GroupName': '', 'Tenancy': 'default'}
+        if not stopped:
+            self.public_ip_address = '12.3.456.7'  # variable is not returned by boto3 if the instance is stopped
+        self.security_groups = [{'GroupId': key, 'GroupName': value} for key, value in self._ignore_security_groups.items()]
+        self.source_dest_check = True
+        self.state = dict(self._ignore_state)
+        if not stopped:
+            self.state_transition_reason = ''
+        else:
+            self.state_transition_reason = 'User initiated (2019-02-11 12:49:13 GMT)'
+            self.state_reason = {  # this variable is only returned by AWS if the instance is stopped
+                'Code': 'Client.UserInitiatedShutdown',
+                'Message': 'Client.UserInitiatedShutdown: User initiated shutdown'
+            }
+        self.tags = [{'Key': k, 'Value': v} for k, v in self._ignore_tags.items()]
+
+        self.network_interfaces = Boto3NetworkInterface(
+            owner_id=owner_id,
+            public_ip=getattr(self, 'public_ip_address', ''),
+            public_dns=self.public_dns_name,
+            private_ip=self.private_ip_address,
+            security_groups=self.security_groups,
+            subnet_id=self.subnet_id,
+            vpc_id=self.vpc_id
+        )
diff --git a/test/integration/targets/inventory_aws_conformance/lib/boto/rds.py b/test/integration/targets/inventory_aws_conformance/lib/boto/rds.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/test/integration/targets/inventory_aws_conformance/lib/boto/route53.py b/test/integration/targets/inventory_aws_conformance/lib/boto/route53.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/test/integration/targets/inventory_aws_conformance/lib/boto/session.py b/test/integration/targets/inventory_aws_conformance/lib/boto/session.py
new file mode 100644
index 00000000000..4624c306169
--- /dev/null
+++ b/test/integration/targets/inventory_aws_conformance/lib/boto/session.py
@@ -0,0 +1,73 @@
+#!/usr/bin/env python
+# boto3
+
+from boto.mocks.instances import Boto3Instance
+
+
+class Paginator(object):
+    def __init__(self, datalist):
+        self.datalist = datalist
+
+    def paginate(self, *args, **kwargs):
+        '''
+        {'Filters': [{'Name': 'instance-state-name',
+            'Values': ['running', 'pending', 'stopping', 'stopped']}]}
+        '''
+        filters = kwargs.get('Filters', [])
+        if not (filters or any([True for f in filters if f['Name'] == 'instance-state-name'])):
+            self.instance_states = ['running', 'pending', 'stopping', 'stopped']
+        else:
+            self.instance_states = [f['Values'] for f in filters if f['Name'] == 'instance-state-name'][0]
+        return self
+
+    def build_full_result(self):
+        filtered_states = set([x.state['Name'] for x in self.datalist]).difference(set(self.instance_states))
+        return {'Reservations': [{
+            'Instances': [x.to_dict() for x in self.datalist if x.state['Name'] not in filtered_states],
+            'OwnerId': '123456789012',
+            'RequesterId': 'AIDAIS3MMFPO53D2T3WWE',
+            'ReservationId': 'r-07889670a282de964'
+        }]}
+
+
+class Client(object):
+    cloud = None
+    region = None
+
+    def __init__(self, *args, **kwargs):
+        self.cloud = args[0]
+        self.region = args[1]
+
+    def get_paginator(self, method):
+        if method == 'describe_instances':
+            return Paginator(
+                [Boto3Instance(instance_id='i-0678e70402c0b434c', owner_id='123456789012', region=self.region),
+                 Boto3Instance(instance_id='i-16a83b42f01c082a1', owner_id='123456789012', region=self.region, stopped=True)]
+            )
+
+
+class Session(object):
+    profile_name = None
+    region = None
+
+    def __init__(self, *args, **kwargs):
+        for k, v in kwargs.items():
+            if hasattr(self, k):
+                setattr(self, k, v)
+
+    def client(self, *args, **kwargs):
+        return Client(*args, **kwargs)
+
+    def get_config_variables(self, key):
+        if hasattr(self, key):
+            return getattr(self, key)
+
+    def get_available_regions(self, *args):
+        return ['us-east-1']
+
+    def get_credentials(self, *args, **kwargs):
+        raise Exception('not implemented')
+
+
+def get_session(*args, **kwargs):
+    return Session(*args, **kwargs)
diff --git a/test/integration/targets/inventory_aws_conformance/lib/boto/sts.py b/test/integration/targets/inventory_aws_conformance/lib/boto/sts.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/test/integration/targets/inventory_aws_conformance/runme.sh b/test/integration/targets/inventory_aws_conformance/runme.sh
new file mode 100755
index 00000000000..810bc8c09e6
--- /dev/null
+++ b/test/integration/targets/inventory_aws_conformance/runme.sh
@@ -0,0 +1,151 @@
+#!/usr/bin/env bash
+
+set -ex
+
+virtualenv --system-site-packages --python "${ANSIBLE_TEST_PYTHON_INTERPRETER:-python}" "${OUTPUT_DIR}/aws-ec2-inventory"
+source "${OUTPUT_DIR}/aws-ec2-inventory/bin/activate"
+pip install "python-dateutil>=2.1,<2.7.0" jmespath "Jinja2>=2.10" PyYaml cryptography paramiko
+
+# create boto3 symlinks
+ln -s "$(pwd)/lib/boto" "$(pwd)/lib/boto3"
+ln -s "$(pwd)/lib/boto" "$(pwd)/lib/botocore"
+
+# override boto's import path(s)
+export PYTHONPATH
+PYTHONPATH="$(pwd)/lib:$PYTHONPATH"
+
+#################################################
+#   RUN THE SCRIPT
+#################################################
+
+# run the script first
+cat << EOF > "$OUTPUT_DIR/ec2.ini"
+[ec2]
+regions = us-east-1
+cache_path = $(pwd)/.cache
+cache_max_age = 0
+group_by_tag_none = False
+
+[credentials]
+aws_access_key_id = FOO
+aws_secret_acccess_key = BAR
+EOF
+
+ANSIBLE_JINJA2_NATIVE=1 ansible-inventory -vvvv -i ./ec2.sh --list --output="$OUTPUT_DIR/script.out"
+RC=$?
+if [[ $RC != 0 ]]; then
+    exit $RC
+fi
+
+#################################################
+#   RUN THE PLUGIN
+#################################################
+
+# run the plugin second
+export ANSIBLE_INVENTORY_ENABLED=aws_ec2
+export ANSIBLE_INVENTORY=test.aws_ec2.yml
+export AWS_ACCESS_KEY_ID=FOO
+export AWS_SECRET_ACCESS_KEY=BAR
+export ANSIBLE_TRANSFORM_INVALID_GROUP_CHARS=never
+
+cat << EOF > "$OUTPUT_DIR/test.aws_ec2.yml"
+plugin: aws_ec2
+cache: False
+use_contrib_script_compatible_sanitization: True
+strict: True
+regions:
+    - us-east-1
+hostnames:
+  - network-interface.addresses.association.public-ip
+  - dns-name
+filters:
+  instance-state-name: running
+compose:
+  # vars that don't exist anymore in any meaningful way
+  ec2_item: undefined | default("")
+  ec2_monitoring: undefined | default("")
+  ec2_previous_state: undefined | default("")
+  ec2_previous_state_code: undefined | default(0)
+  ec2__in_monitoring_element: undefined | default(false)
+  # the following three will be accessible again after #53645
+  ec2_requester_id: undefined | default("")
+  ec2_eventsSet: undefined | default("")
+  ec2_persistent: undefined | default(false)
+
+  # vars that change
+  ansible_host: public_ip_address
+  ec2_block_devices: dict(block_device_mappings | map(attribute='device_name') | map('basename') | list | zip(block_device_mappings | map(attribute='ebs.volume_id') | list))
+  ec2_dns_name: public_dns_name
+  ec2_group_name: placement['group_name']
+  ec2_id: instance_id
+  ec2_instance_profile: iam_instance_profile | default("")
+  ec2_ip_address: public_ip_address
+  ec2_kernel: kernel_id | default("")
+  ec2_monitored:  monitoring['state'] in ['enabled', 'pending']
+  ec2_monitoring_state: monitoring['state']
+  ec2_account_id: owner_id
+  ec2_placement: placement['availability_zone']
+  ec2_ramdisk: ramdisk_id | default("")
+  ec2_reason: state_transition_reason
+  ec2_security_group_ids: security_groups | map(attribute='group_id') | list |  join(',')
+  ec2_security_group_names: security_groups | map(attribute='group_name') | list |  join(',')
+  ec2_state: state['name']
+  ec2_state_code: state['code']
+  ec2_state_reason: state_reason['message'] if state_reason is defined else ""
+  ec2_sourceDestCheck: source_dest_check | lower | string  # butchered snake_case case not a typo.
+
+  # vars that just need ec2_ prefix
+  ec2_ami_launch_index: ami_launch_index | string
+  ec2_architecture: architecture
+  ec2_client_token: client_token
+  ec2_ebs_optimized: ebs_optimized
+  ec2_hypervisor: hypervisor
+  ec2_image_id: image_id
+  ec2_instance_type: instance_type
+  ec2_key_name: key_name
+  ec2_launch_time: 'launch_time | regex_replace(" ", "T") | regex_replace("(\+)(\d\d):(\d)(\d)$", ".\g<2>\g<3>Z")'
+  ec2_platform: platform | default("")
+  ec2_private_dns_name: private_dns_name
+  ec2_private_ip_address: private_ip_address
+  ec2_public_dns_name: public_dns_name
+  ec2_region: placement['region']
+  ec2_root_device_name: root_device_name
+  ec2_root_device_type: root_device_type
+  ec2_spot_instance_request_id: spot_instance_request_id | default("")
+  ec2_subnet_id: subnet_id
+  ec2_virtualization_type: virtualization_type
+  ec2_vpc_id: vpc_id
+  tags: dict(tags.keys() | map('regex_replace', '[^A-Za-z0-9\_]', '_') | list | zip(tags.values() | list))
+
+keyed_groups:
+  - key: '"ec2"'
+    separator: ""
+  - key: 'instance_id'
+    separator: ""
+  - key: tags
+    prefix: tag
+  - key: key_name | regex_replace('-', '_')
+    prefix: key
+  - key: placement['region']
+    separator: ""
+  - key: placement['availability_zone']
+    separator: ""
+  - key: platform | default('undefined')
+    prefix: platform
+  - key: vpc_id | regex_replace('-', '_')
+    prefix: vpc_id
+  - key: instance_type
+    prefix: type
+  - key: "image_id | regex_replace('-', '_')"
+    separator: ""
+  - key: security_groups | map(attribute='group_name') | map("regex_replace", "-", "_") | list
+    prefix: security_group
+EOF
+
+ANSIBLE_JINJA2_NATIVE=1 ansible-inventory -vvvv -i "$OUTPUT_DIR/test.aws_ec2.yml" --list --output="$OUTPUT_DIR/plugin.out"
+
+#################################################
+#   DIFF THE RESULTS
+#################################################
+
+./inventory_diff.py "$OUTPUT_DIR/script.out" "$OUTPUT_DIR/plugin.out"