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.
This commit is contained in:
jctanner 2019-03-25 12:15:31 -04:00 committed by Sloane Hertel
parent e9c66ffb6f
commit 6d978bc285
16 changed files with 754 additions and 0 deletions

View file

@ -0,0 +1,2 @@
shippable/posix/group2
needs/file/contrib/inventory/ec2.py

View file

@ -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}" "$@"

View file

@ -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()

View file

@ -0,0 +1,2 @@
import boto.exceptions as exceptions # pylint: disable=useless-import-alias
import boto.session as session # pylint: disable=useless-import-alias

View file

@ -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')]

View file

@ -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()

View file

@ -0,0 +1,19 @@
class BotoServerError(Exception):
pass
class ClientError(Exception):
pass
class PartialCredentialsError(Exception):
pass
class ProfileNotFound(Exception):
pass
class BotoCoreError(Exception):
pass

View file

@ -0,0 +1,19 @@
class BotoServerError(Exception):
pass
class ClientError(Exception):
pass
class PartialCredentialsError(Exception):
pass
class ProfileNotFound(Exception):
pass
class BotoCoreError(Exception):
pass

View file

@ -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
)

View file

@ -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)

View file

@ -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"