Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[stable-4] ec2_instance - update build_run_instance_spec to skip TagSpecification if None #1162

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelogs/fragments/1148-build_run_instance_spec.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
bugfixes:
- ec2_instance - fixes ``Invalid type for parameter TagSpecifications, value None`` error when
tags aren't specified (https://github.com/ansible-collections/amazon.aws/issues/1148).
35 changes: 19 additions & 16 deletions plugins/modules/ec2_instance.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,10 +283,10 @@
type: dict
instance_role:
description:
- The ARN or name of an EC2-enabled instance role to be used. If a name is not provided in arn format
then the ListInstanceProfiles permission must also be granted.
U(https://docs.aws.amazon.com/IAM/latest/APIReference/API_ListInstanceProfiles.html) If no full ARN is provided,
the role with a matching name will be used from the active AWS account.
- The ARN or name of an EC2-enabled IAM instance profile to be used.
- If a name is not provided in ARN format then the ListInstanceProfiles permission must also be granted.
U(https://docs.aws.amazon.com/IAM/latest/APIReference/API_ListInstanceProfiles.html)
- If no full ARN is provided, the role with a matching name will be used from the active AWS account.
type: str
placement_group:
description:
Expand Down Expand Up @@ -1333,28 +1333,31 @@ def build_run_instance_spec(params):
MaxCount=1,
MinCount=1,
)
# network parameters
spec.update(**build_top_level_options(params))

spec['NetworkInterfaces'] = build_network_spec(params)
spec['BlockDeviceMappings'] = build_volume_spec(params)
spec.update(**build_top_level_options(params))
spec['TagSpecifications'] = build_instance_tags(params)

tag_spec = build_instance_tags(params)
if tag_spec is not None:
spec['TagSpecifications'] = tag_spec

# IAM profile
if params.get('instance_role'):
spec['IamInstanceProfile'] = dict(Arn=determine_iam_role(params.get('instance_role')))

if module.params.get('exact_count'):
spec['MaxCount'] = module.params.get('to_launch')
spec['MinCount'] = module.params.get('to_launch')
if params.get('exact_count'):
spec['MaxCount'] = params.get('to_launch')
spec['MinCount'] = params.get('to_launch')

if module.params.get('count'):
spec['MaxCount'] = module.params.get('count')
spec['MinCount'] = module.params.get('count')
if params.get('count'):
spec['MaxCount'] = params.get('count')
spec['MinCount'] = params.get('count')

if not module.params.get('launch_template'):
spec['InstanceType'] = params['instance_type'] if module.params.get('instance_type') else 't2.micro'
if not params.get('launch_template'):
spec['InstanceType'] = params['instance_type'] if params.get('instance_type') else 't2.micro'

if module.params.get('launch_template') and module.params.get('instance_type'):
if params.get('launch_template') and params.get('instance_type'):
spec['InstanceType'] = params['instance_type']

return spec
Expand Down
3 changes: 3 additions & 0 deletions tests/integration/targets/ec2_vpc_nat_gateway/aliases
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
time=10m

cloud/aws

ec2_vpc_nat_gateway_info
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# (c) 2022 Red Hat Inc.
#
# This file is part of Ansible
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)

from __future__ import (absolute_import, division, print_function)
__metaclass__ = type

import pytest

from ansible_collections.amazon.aws.tests.unit.compat.mock import sentinel
import ansible_collections.amazon.aws.plugins.modules.ec2_instance as ec2_instance_module


@pytest.fixture
def params_object():
params = {
'instance_role': None,
'exact_count': None,
'count': None,
'launch_template': None,
'instance_type': None,
}
return params


@pytest.fixture
def ec2_instance(monkeypatch):
# monkey patches various ec2_instance module functions, we'll separately test the operation of
# these functions, we just care that it's passing the results into the right place in the
# instance spec.
monkeypatch.setattr(ec2_instance_module, 'build_top_level_options', lambda params: {'TOP_LEVEL_OPTIONS': sentinel.TOP_LEVEL})
monkeypatch.setattr(ec2_instance_module, 'build_network_spec', lambda params: sentinel.NETWORK_SPEC)
monkeypatch.setattr(ec2_instance_module, 'build_volume_spec', lambda params: sentinel.VOlUME_SPEC)
monkeypatch.setattr(ec2_instance_module, 'build_instance_tags', lambda params: sentinel.TAG_SPEC)
monkeypatch.setattr(ec2_instance_module, 'determine_iam_role', lambda params: sentinel.IAM_PROFILE_ARN)
return ec2_instance_module


def _assert_defaults(instance_spec, to_skip=None):
if not to_skip:
to_skip = []

assert isinstance(instance_spec, dict)

if 'TagSpecifications' not in to_skip:
assert 'TagSpecifications' in instance_spec
assert instance_spec['TagSpecifications'] is sentinel.TAG_SPEC

if 'NetworkInterfaces' not in to_skip:
assert 'NetworkInterfaces' in instance_spec
assert instance_spec['NetworkInterfaces'] is sentinel.NETWORK_SPEC

if 'BlockDeviceMappings' not in to_skip:
assert 'BlockDeviceMappings' in instance_spec
assert instance_spec['BlockDeviceMappings'] is sentinel.VOlUME_SPEC

if 'IamInstanceProfile' not in to_skip:
# By default, this shouldn't be returned
assert 'IamInstanceProfile' not in instance_spec

if 'MinCount' not in to_skip:
assert 'MinCount' in instance_spec
instance_spec['MinCount'] == 1

if 'MaxCount' not in to_skip:
assert 'MaxCount' in instance_spec
instance_spec['MaxCount'] == 1

if 'TOP_LEVEL_OPTIONS' not in to_skip:
assert 'TOP_LEVEL_OPTIONS' in instance_spec
assert instance_spec['TOP_LEVEL_OPTIONS'] is sentinel.TOP_LEVEL


def test_build_run_instance_spec_defaults(params_object, ec2_instance):
instance_spec = ec2_instance.build_run_instance_spec(params_object)
_assert_defaults(instance_spec)


def test_build_run_instance_spec_tagging(params_object, ec2_instance, monkeypatch):
# build_instance_tags can return None, RunInstance doesn't like this
monkeypatch.setattr(ec2_instance_module, 'build_instance_tags', lambda params: None)
instance_spec = ec2_instance.build_run_instance_spec(params_object)
_assert_defaults(instance_spec, ['TagSpecifications'])
assert 'TagSpecifications' not in instance_spec

# if someone *explicitly* passes {} (rather than not setting it), then [] can be returned
monkeypatch.setattr(ec2_instance_module, 'build_instance_tags', lambda params: [])
instance_spec = ec2_instance.build_run_instance_spec(params_object)
_assert_defaults(instance_spec, ['TagSpecifications'])
assert 'TagSpecifications' in instance_spec
assert instance_spec['TagSpecifications'] == []


def test_build_run_instance_spec_instance_profile(params_object, ec2_instance):
params_object['instance_role'] = sentinel.INSTANCE_PROFILE_NAME
instance_spec = ec2_instance.build_run_instance_spec(params_object)
_assert_defaults(instance_spec, ['IamInstanceProfile'])
assert 'IamInstanceProfile' in instance_spec
assert instance_spec['IamInstanceProfile'] == {'Arn': sentinel.IAM_PROFILE_ARN}


def test_build_run_instance_spec_count(params_object, ec2_instance):
# When someone passes 'count', that number of instances will be *launched*
params_object['count'] = sentinel.COUNT
instance_spec = ec2_instance.build_run_instance_spec(params_object)
_assert_defaults(instance_spec, ['MaxCount', 'MinCount'])
assert 'MaxCount' in instance_spec
assert 'MinCount' in instance_spec
assert instance_spec['MaxCount'] == sentinel.COUNT
assert instance_spec['MinCount'] == sentinel.COUNT


def test_build_run_instance_spec_exact_count(params_object, ec2_instance):
# The "exact_count" logic relies on enforce_count doing the math to figure out how many
# instances to start/stop. The enforce_count call is responsible for ensuring that 'to_launch'
# is set and is a positive integer.
params_object['exact_count'] = sentinel.EXACT_COUNT
params_object['to_launch'] = sentinel.TO_LAUNCH
instance_spec = ec2_instance.build_run_instance_spec(params_object)

_assert_defaults(instance_spec, ['MaxCount', 'MinCount'])
assert 'MaxCount' in instance_spec
assert 'MinCount' in instance_spec
assert instance_spec['MaxCount'] == sentinel.TO_LAUNCH
assert instance_spec['MinCount'] == sentinel.TO_LAUNCH
102 changes: 102 additions & 0 deletions tests/unit/plugins/modules/ec2_instance/test_determine_iam_role.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# (c) 2022 Red Hat Inc.
#
# This file is part of Ansible
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)

from __future__ import (absolute_import, division, print_function)
__metaclass__ = type

import pytest
import sys

from ansible_collections.amazon.aws.tests.unit.compat.mock import MagicMock
from ansible_collections.amazon.aws.tests.unit.compat.mock import sentinel
import ansible_collections.amazon.aws.plugins.modules.ec2_instance as ec2_instance_module
import ansible_collections.amazon.aws.plugins.module_utils.arn as utils_arn
from ansible_collections.amazon.aws.plugins.module_utils.botocore import HAS_BOTO3

try:
import botocore
except ImportError:
pass

pytest.mark.skipif(not HAS_BOTO3, reason="test_determine_iam_role.py requires the python modules 'boto3' and 'botocore'")


def _client_error(code='GenericError'):
return botocore.exceptions.ClientError(
{'Error': {'Code': code, 'Message': 'Something went wrong'},
'ResponseMetadata': {'RequestId': '01234567-89ab-cdef-0123-456789abcdef'}},
'some_called_method')


@pytest.fixture
def params_object():
params = {
'instance_role': None,
'exact_count': None,
'count': None,
'launch_template': None,
'instance_type': None,
}
return params


class FailJsonException(Exception):
def __init__(self):
pass


@pytest.fixture
def ec2_instance(monkeypatch):
monkeypatch.setattr(ec2_instance_module, 'parse_aws_arn', lambda arn: None)
monkeypatch.setattr(ec2_instance_module, 'module', MagicMock())
ec2_instance_module.module.fail_json.side_effect = FailJsonException()
ec2_instance_module.module.fail_json_aws.side_effect = FailJsonException()
return ec2_instance_module


def test_determine_iam_role_arn(params_object, ec2_instance, monkeypatch):
# Revert the default monkey patch to make it simple to try passing a valid ARNs
monkeypatch.setattr(ec2_instance, 'parse_aws_arn', utils_arn.parse_aws_arn)

# Simplest example, someone passes a valid instance profile ARN
arn = ec2_instance.determine_iam_role('arn:aws:iam::123456789012:instance-profile/myprofile')
assert arn == 'arn:aws:iam::123456789012:instance-profile/myprofile'


def test_determine_iam_role_name(params_object, ec2_instance):
profile_description = {'InstanceProfile': {'Arn': sentinel.IAM_PROFILE_ARN}}
iam_client = MagicMock(**{"get_instance_profile.return_value": profile_description})
ec2_instance_module.module.client.return_value = iam_client

arn = ec2_instance.determine_iam_role(sentinel.IAM_PROFILE_NAME)
assert arn == sentinel.IAM_PROFILE_ARN


def test_determine_iam_role_missing(params_object, ec2_instance):
missing_exception = _client_error('NoSuchEntity')
iam_client = MagicMock(**{"get_instance_profile.side_effect": missing_exception})
ec2_instance_module.module.client.return_value = iam_client

with pytest.raises(FailJsonException) as exception:
arn = ec2_instance.determine_iam_role(sentinel.IAM_PROFILE_NAME)

assert ec2_instance_module.module.fail_json_aws.call_count == 1
assert ec2_instance_module.module.fail_json_aws.call_args.args[0] is missing_exception
assert 'Could not find' in ec2_instance_module.module.fail_json_aws.call_args.kwargs['msg']


@pytest.mark.skipif(sys.version_info < (3, 8), reason='call_args behaviour changed in Python 3.8')
def test_determine_iam_role_missing(params_object, ec2_instance):
missing_exception = _client_error()
iam_client = MagicMock(**{"get_instance_profile.side_effect": missing_exception})
ec2_instance_module.module.client.return_value = iam_client

with pytest.raises(FailJsonException) as exception:
arn = ec2_instance.determine_iam_role(sentinel.IAM_PROFILE_NAME)

assert ec2_instance_module.module.fail_json_aws.call_count == 1
assert ec2_instance_module.module.fail_json_aws.call_args.args[0] is missing_exception
assert 'An error occurred while searching' in ec2_instance_module.module.fail_json_aws.call_args.kwargs['msg']
assert 'Please try supplying the full ARN' in ec2_instance_module.module.fail_json_aws.call_args.kwargs['msg']