diff --git a/meta/runtime.yml b/meta/runtime.yml index 94614615f1e..e1db13eaa16 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -2,6 +2,8 @@ requires_ansible: ">=2.15.0" action_groups: aws: + - autoscaling_instance + - autoscaling_instance_info - autoscaling_group - autoscaling_group_info - aws_az_info diff --git a/plugins/module_utils/_autoscaling/__init__.py b/plugins/module_utils/_autoscaling/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/plugins/module_utils/_autoscaling/common.py b/plugins/module_utils/_autoscaling/common.py new file mode 100644 index 00000000000..279d316938d --- /dev/null +++ b/plugins/module_utils/_autoscaling/common.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- + +# Copyright: Contributors to the Ansible project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# try: +# import botocore +# except ImportError: +# pass # Modules are responsible for handling this. + +from ..botocore import is_boto3_error_code +from ..errors import AWSErrorHandler +from ..exceptions import AnsibleAWSError + + +class AnsibleAutoScalingError(AnsibleAWSError): + pass + + +class AutoScalingErrorHandler(AWSErrorHandler): + _CUSTOM_EXCEPTION = AnsibleAutoScalingError + + @classmethod + def _is_missing(cls): + return is_boto3_error_code("NoSuchEntity") diff --git a/plugins/module_utils/_autoscaling/groups.py b/plugins/module_utils/_autoscaling/groups.py new file mode 100644 index 00000000000..8b4f220ed98 --- /dev/null +++ b/plugins/module_utils/_autoscaling/groups.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- + +# Copyright: Contributors to the Ansible project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from ..retries import AWSRetry + +# from .common import AnsibleAutoScalingError +from .common import AutoScalingErrorHandler + + +@AutoScalingErrorHandler.list_error_handler("list auto scaling groups", default_value=[]) +@AWSRetry.jittered_backoff() +def describe_auto_scaling_groups(client, group_names=None, filters=None): + args = {} + if group_names: + args["AutoScalingGroupNames"] = group_names + if filters: + args["Filters"] = filters + + paginator = client.get_paginator("describe_auto_scaling_groups") + return paginator.paginate(**args).build_full_result()["AutoScalingGroups"] diff --git a/plugins/module_utils/_autoscaling/instances.py b/plugins/module_utils/_autoscaling/instances.py new file mode 100644 index 00000000000..e23271dac6a --- /dev/null +++ b/plugins/module_utils/_autoscaling/instances.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- + +# Copyright: Contributors to the Ansible project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from ..retries import AWSRetry + +# from .common import AnsibleAutoScalingError +from .common import AutoScalingErrorHandler + + +@AutoScalingErrorHandler.list_error_handler("list auto scaling instances", default_value=[]) +@AWSRetry.jittered_backoff() +def describe_auto_scaling_instances(client, instance_ids=None): + args = {} + if instance_ids: + args["InstanceIds"] = instance_ids + + paginator = client.get_paginator("describe_auto_scaling_instances") + return paginator.paginate(**args).build_full_result()["AutoScalingInstances"] diff --git a/plugins/module_utils/_autoscaling/transformations.py b/plugins/module_utils/_autoscaling/transformations.py new file mode 100644 index 00000000000..93ff4e3bc79 --- /dev/null +++ b/plugins/module_utils/_autoscaling/transformations.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- + +# Copyright: Contributors to the Ansible project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from typing import Optional + +# from ..transformation import AnsibleAWSResource +from ..transformation import AnsibleAWSResourceList +from ..transformation import BotoResource +from ..transformation import BotoResourceList +from ..transformation import boto3_resource_to_ansible_dict + +# from ..transformation import boto3_resource_list_to_ansible_dict + + +def _inject_asg_name( + instance: BotoResource, + group_name: Optional[str] = None, +) -> BotoResource: + if not group_name: + return instance + if "AutoScalingGroupName" in instance: + return instance + instance["AutoScalingGroupName"] = group_name + return instance + + +def normalize_autoscaling_instance( + instance: BotoResource, + group_name: Optional[str] = None, +): + """Converts an AutoScaling Instance from the CamelCase boto3 format to the snake_case Ansible format. + + Also handles inconsistencies in the output between describe_autoscaling_group() and describe_autoscaling_instances(). + """ + if not instance: + return instance + + # describe_autoscaling_group doesn't add AutoScalingGroupName + instance = _inject_asg_name(instance, group_name) + + try: + # describe_autoscaling_group and describe_autoscaling_instances aren't consistent + instance["HealthStatus"] = instance["HealthStatus"].upper() + except KeyError: + pass + + return boto3_resource_to_ansible_dict(instance, force_tags=False) + + +def normalize_autoscaling_instances( + autoscaling_instances: BotoResourceList, + group_name: Optional[str] = None, +) -> AnsibleAWSResourceList: + """Converts a list of AutoScaling Instances from the CamelCase boto3 format to the snake_case Ansible format""" + if not autoscaling_instances: + return autoscaling_instances + autoscaling_instances = [normalize_autoscaling_instance(i, group_name) for i in autoscaling_instances] + return sorted(autoscaling_instances, key=lambda d: d.get("instance_id", None)) diff --git a/plugins/module_utils/autoscaling.py b/plugins/module_utils/autoscaling.py new file mode 100644 index 00000000000..f80c7473d24 --- /dev/null +++ b/plugins/module_utils/autoscaling.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- + +# Copyright: Contributors to the Ansible project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# It would be nice to be able to use autoscaling.XYZ, but we're bound by Ansible's "empty-init" +# policy: https://docs.ansible.com/ansible-core/devel/dev_guide/testing/sanity/empty-init.html + +from ._autoscaling import groups as _groups +from ._autoscaling import instances as _instances +from ._autoscaling import transformations as _transformations +from ._autoscaling.common import AnsibleAutoScalingError # pylint: disable=unused-import +from ._autoscaling.common import AutoScalingErrorHandler # pylint: disable=unused-import + + +def get_autoscaling_groups(client, group_names=None): + return _groups.describe_auto_scaling_groups(client, group_names) + + +def _get_autoscaling_instances(client, instance_ids=None, group_name=None): + if group_name: + try: + groups = _groups.describe_auto_scaling_groups(client, [group_name]) + return groups[0]["Instances"] + except (KeyError, IndexError): + return None + return _instances.describe_auto_scaling_instances(client, instance_ids) + + +def get_autoscaling_instances(client, instance_ids=None, group_name=None): + instances = _get_autoscaling_instances(client, instance_ids=instance_ids, group_name=group_name) + return _transformations.normalize_autoscaling_instances(instances, group_name=group_name) diff --git a/plugins/modules/autoscaling_instance.py b/plugins/modules/autoscaling_instance.py new file mode 100644 index 00000000000..be79fc923d5 --- /dev/null +++ b/plugins/modules/autoscaling_instance.py @@ -0,0 +1,233 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: Contributors to the Ansible project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r""" +--- +module: autoscaling_instance +version_added: 8.3.0 +short_description: manage instances associated with AWS AutoScaling Groups (ASGs) +description: + - Manage instances associated with AWS AutoScaling Groups (ASGs). +author: + - "Mark Chappell (@tremble)" +options: + group_name: + description: + - Name of the AutoScaling Group to manage. + type: str + required: True + state: + description: + - The expected state of the instances. + - V(present) - The instance(s) should be attached to the AutoScaling Group. + - V(attached) - The instance(s) should be attached to the AutoScaling Group. + Any instances in standby will exit standby mode. + - V(standby) - The instance(s) should be placed into standby. + - V(detached) - The instance(s) will be detached from the AutoScaling Group. + - V(terminate) - The instance(s) will be terminated. + choices: ['present', 'attached', 'terminate', 'detached', 'standby'] + default: present + type: str + instance_ids: + description: + - The IDs of the EC2 instances. + required: true + type: list + elements: str + purge_instances: + description: + - Ignored unless O(state=present) or O(state=attached). + - If O(purge_instances=true), any instances not in O(instance_ids) will be scheduled for B(termination). + default: false + type: bool + decrement_desired_capacity: + description: + - When O(decrement_desired_capacity=True), detaching instances, terminating instances, or + placing instances in standby mode will decrement the desired capacity of the AutoScaling Group + default: false + type: bool + health: + description: + - Sets the health of an instance to a specific state. + type: str + choices: ["Healthy", "Unhealthy"] + respect_grace_period: + description: + - Set O(respect_grace_period=False) to ignore the grace period associated with the Auto + Scaling group when modifying the O(health). + - Ignored unless O(health) is set. + - AWS defaults to respecting the grace period when modifying the health state of an instance. + type: bool + protection: + description: + - Sets the scale-in protection attribute. + type: bool + wait: + description: + - When O(wait=True) will wait for instances to reach the requested state before returning. + type: bool + default: True + wait_timeout: + description: + - Maximum time to wait for instances to reach the desired state. + type: int + default: 120 +extends_documentation_fragment: + - amazon.aws.common.modules + - amazon.aws.region.modules + - amazon.aws.boto3 +""" + +EXAMPLES = r""" +""" + +RETURN = r""" +auto_scaling_instances: + description: A description of the EC2 instances attached to an Auto Scaling group. + returned: always + type: list + contains: + availability_zone: + description: The availability zone that the instance is in. + returned: always + type: str + sample: "us-east-1a" + health_status: + description: The last reported health status of the instance. + returned: always + type: str + sample: "Healthy" + instance_id: + description: The ID of the instance. + returned: always + type: str + sample: "i-123456789abcdef01" + instance_type: + description: The instance type of the instance. + returned: always + type: str + sample: "t3.micro" + launch_configuration_name: + description: The name of the launch configuration used when launching the instance. + returned: When the instance was launched using an Auto Scaling launch configuration. + type: str + sample: "ansible-test-49630214-mchappel-thinkpadt14gen3-asg-instance-1" + launch_template: + description: A description of the launch template used when launching the instance. + returned: When the instance was launched using an Auto Scaling launch template. + type: dict + contains: + launch_template_id: + description: The ID of the launch template used when launching the instance. + returned: always + type: str + sample: "12345678-abcd-ef12-2345-6789abcdef01" + launch_template_name: + description: The name of the launch template used when launching the instance. + returned: always + type: str + sample: "example-launch-configuration" + version: + description: The version of the launch template used when launching the instance. + returned: always + type: str + sample: "$Default" + lifecycle_state: + description: The lifecycle state of the instance. + returned: always + type: str + sample: "InService" + protected_from_scale_in: + description: Whether the instance is protected from termination when the Auto Scaling group is scaled in. + returned: always + type: bool + sample: false +""" + +from copy import deepcopy + +from ansible_collections.amazon.aws.plugins.module_utils.autoscaling import get_autoscaling_instances +from ansible_collections.amazon.aws.plugins.module_utils.errors import AnsibleAWSError +from ansible_collections.amazon.aws.plugins.module_utils.modules import AnsibleAWSModule +from ansible_collections.amazon.aws.plugins.module_utils.retries import AWSRetry + +# from typing import List +# from typing import Set +# from typing import Tuple + + +def ensure_instance_pool( + client, + check_mode, + instances_start, + group_name, + state, + instance_ids, + purge_instances, + decrement_desired_capacity, + respect_grace_period, + wait, + wait_timeout, +): + return False, instances_start + + +def do(module): + client = module.client("autoscaling", retry_decorator=AWSRetry.jittered_backoff()) + + instances_start = get_autoscaling_instances(client, group_name=module.params["group_name"]) + + changed, instances = ensure_instance_pool( + client, + check_mode=module.check_mode, + instances_start=deepcopy(instances_start), + group_name=module.params["group_name"], + state=module.params["state"], + instance_ids=module.params["instance_ids"], + purge_instances=module.params["purge_instances"], + decrement_desired_capacity=module.params["decrement_desired_capacity"], + respect_grace_period=module.params["respect_grace_period"], + wait=module.params["wait"], + wait_timeout=module.params["wait_timeout"], + ) + + result = {"changed": changed} + + if module._diff: + result["old"] = instances_start + result["new"] = instances + + module.exit_json(changed=False, auto_scaling_instances=instances) + + +def main(): + argument_spec = dict( + group_name=dict(type="str", required=True), + state=dict(choices=["present", "attached", "terminate", "detached", "standby"], default="present", type="str"), + instance_ids=dict(required=True, type="list", elements="str"), + purge_instances=dict(default=False, type="bool"), + decrement_desired_capacity=dict(default=False, type="bool"), + health=dict(type="str", choices=["Healthy", "Unhealthy"]), + respect_grace_period=dict(type="bool"), + protection=dict(type="bool"), + wait=dict(type="bool", default=True), + wait_timeout=dict(type="int", default=120), + ) + + module = AnsibleAWSModule( + argument_spec=argument_spec, + supports_check_mode=True, + mutually_exclusive=[], + ) + + try: + do(module) + except AnsibleAWSError as e: + module.fail_json_aws_error(e) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/autoscaling_instance_info.py b/plugins/modules/autoscaling_instance_info.py new file mode 100644 index 00000000000..57531e0f042 --- /dev/null +++ b/plugins/modules/autoscaling_instance_info.py @@ -0,0 +1,143 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: Contributors to the Ansible project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r""" +--- +module: autoscaling_instance_info +version_added: 8.3.0 +short_description: describe instances associated with AWS AutoScaling Groups (ASGs) +description: + - Describe instances associated with AWS AutoScaling Groups (ASGs). +author: + - "Mark Chappell (@tremble)" +options: + group_name: + description: + - Name of the AutoScaling Group to manage. + - O(group_name) and O(instance_ids) are mutually exclusive. + type: str + instance_ids: + description: + - The IDs of the EC2 instances. + - O(group_name) and O(instance_ids) are mutually exclusive. + type: list + elements: str +extends_documentation_fragment: + - amazon.aws.common.modules + - amazon.aws.region.modules + - amazon.aws.boto3 +""" + +EXAMPLES = r""" +- name: Describe all instances in a region + amazon.aws.autoscaling_instance_info: + register: instances + +- name: Describe a specific instance + amazon.aws.autoscaling_instance_info: + instance_ids: + - "i-123456789abcdef01" + register: instances + +- name: Describe the instances attached to a specific Auto Scaling Group + amazon.aws.autoscaling_instance_info: + group_name: example-asg + register: instances +""" + +RETURN = r""" +auto_scaling_instances: + description: A description of the EC2 instances attached to an Auto Scaling group. + returned: always + type: list + contains: + availability_zone: + description: The availability zone that the instance is in. + returned: always + type: str + sample: "us-east-1a" + health_status: + description: The last reported health status of the instance. + returned: always + type: str + sample: "Healthy" + instance_id: + description: The ID of the instance. + returned: always + type: str + sample: "i-123456789abcdef01" + instance_type: + description: The instance type of the instance. + returned: always + type: str + sample: "t3.micro" + launch_configuration_name: + description: The name of the launch configuration used when launching the instance. + returned: When the instance was launched using an Auto Scaling launch configuration. + type: str + sample: "ansible-test-49630214-mchappel-thinkpadt14gen3-asg-instance-1" + launch_template: + description: A description of the launch template used when launching the instance. + returned: When the instance was launched using an Auto Scaling launch template. + type: dict + contains: + launch_template_id: + description: The ID of the launch template used when launching the instance. + returned: always + type: str + sample: "12345678-abcd-ef12-2345-6789abcdef01" + launch_template_name: + description: The name of the launch template used when launching the instance. + returned: always + type: str + sample: "example-launch-configuration" + version: + description: The version of the launch template used when launching the instance. + returned: always + type: str + sample: "$Default" + lifecycle_state: + description: The lifecycle state of the instance. + returned: always + type: str + sample: "InService" + protected_from_scale_in: + description: Whether the instance is protected from termination when the Auto Scaling group is scaled in. + returned: always + type: bool + sample: false +""" + +from ansible_collections.amazon.aws.plugins.module_utils.autoscaling import get_autoscaling_instances +from ansible_collections.amazon.aws.plugins.module_utils.modules import AnsibleAWSModule +from ansible_collections.amazon.aws.plugins.module_utils.retries import AWSRetry + + +def main(): + argument_spec = dict( + group_name=dict(type="str"), + instance_ids=dict(type="list", elements="str"), + ) + + module = AnsibleAWSModule( + argument_spec=argument_spec, + supports_check_mode=True, + mutually_exclusive=[["instance_ids", "group_name"]], + ) + + client = module.client("autoscaling", retry_decorator=AWSRetry.jittered_backoff()) + + instances = get_autoscaling_instances( + client, + instance_ids=module.params["instance_ids"], + group_name=module.params["group_name"], + ) + + module.exit_json(changed=False, auto_scaling_instances=instances) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/autoscaling_instance/aliases b/tests/integration/targets/autoscaling_instance/aliases new file mode 100644 index 00000000000..cc06f7fdbfb --- /dev/null +++ b/tests/integration/targets/autoscaling_instance/aliases @@ -0,0 +1,4 @@ +time=30m +cloud/aws + +autoscaling_instance_info diff --git a/tests/integration/targets/autoscaling_instance/defaults/main.yml b/tests/integration/targets/autoscaling_instance/defaults/main.yml new file mode 100644 index 00000000000..a35f284be06 --- /dev/null +++ b/tests/integration/targets/autoscaling_instance/defaults/main.yml @@ -0,0 +1,11 @@ +--- +ec2_asg_setup_run_once: true +default_resource_name: "{{ resource_prefix }}-asg-instance" +default_tiny_name: "{{ tiny_prefix }}-asg-i" + +vpc_seed: "{{ default_resource_name }}" +vpc_cidr: 10.{{ 256 | random(seed=vpc_seed) }}.0.0/16 +subnet_a_az: "{{ ec2_availability_zone_names[0] }}" +subnet_a_cidr: 10.{{ 256 | random(seed=vpc_seed) }}.32.0/24 +subnet_b_az: "{{ ec2_availability_zone_names[1] }}" +subnet_b_cidr: 10.{{ 256 | random(seed=vpc_seed) }}.33.0/24 diff --git a/tests/integration/targets/autoscaling_instance/meta/main.yml b/tests/integration/targets/autoscaling_instance/meta/main.yml new file mode 100644 index 00000000000..33bfa8e0612 --- /dev/null +++ b/tests/integration/targets/autoscaling_instance/meta/main.yml @@ -0,0 +1,4 @@ +--- +dependencies: + - setup_ec2_facts + - setup_ec2_vpc diff --git a/tests/integration/targets/autoscaling_instance/tasks/env_cleanup.yml b/tests/integration/targets/autoscaling_instance/tasks/env_cleanup.yml new file mode 100644 index 00000000000..fa9fb675477 --- /dev/null +++ b/tests/integration/targets/autoscaling_instance/tasks/env_cleanup.yml @@ -0,0 +1,46 @@ +--- +- name: kill asg + amazon.aws.autoscaling_group: + name: "{{ default_resource_name }}" + state: absent + register: removed + until: removed is not failed + ignore_errors: true + retries: 10 + +- name: remove target group + community.aws.elb_target_group: + name: "{{ item }}" + state: absent + register: removed + until: removed is not failed + ignore_errors: true + retries: 10 + loop: + - "{{ default_tiny_name }}-1" + - "{{ default_tiny_name }}-2" + +- name: remove launch configs + community.aws.autoscaling_launch_config: + name: "{{ item }}" + state: absent + register: removed + until: removed is not failed + ignore_errors: true + retries: 10 + loop: + - "{{ default_resource_name }}-1" + - "{{ default_resource_name }}-2" + +- name: delete launch template + community.aws.ec2_launch_template: + name: "{{ default_resource_name }}" + state: absent + register: del_lt + retries: 10 + until: del_lt is not failed + ignore_errors: true + +- ansible.builtin.include_role: + name: setup_ec2_vpc + tasks_from: cleanup.yml diff --git a/tests/integration/targets/autoscaling_instance/tasks/env_setup.yml b/tests/integration/targets/autoscaling_instance/tasks/env_setup.yml new file mode 100644 index 00000000000..cddf422d149 --- /dev/null +++ b/tests/integration/targets/autoscaling_instance/tasks/env_setup.yml @@ -0,0 +1,94 @@ +--- +# Set up the testing dependencies: VPC, subnet, security group, and two launch configurations +- name: Create VPC for use in testing + amazon.aws.ec2_vpc_net: + name: "{{ default_resource_name }}" + cidr_block: "{{ vpc_cidr }}" + tenancy: default + register: testing_vpc + +- ansible.builtin.set_fact: + vpc_id: "{{ testing_vpc.vpc.id }}" + +- name: Create internet gateway for use in testing + amazon.aws.ec2_vpc_igw: + vpc_id: "{{ vpc_id }}" + state: present + tags: + Name: "{{ default_resource_name }}" + register: igw + +- name: Create subnet for use in testing + amazon.aws.ec2_vpc_subnet: + state: present + vpc_id: "{{ vpc_id }}" + cidr: "{{ subnet_a_cidr }}" + az: "{{ subnet_a_az }}" + tags: + Name: "{{ default_resource_name }}" + + register: testing_subnet +- name: create routing rules + amazon.aws.ec2_vpc_route_table: + vpc_id: "{{ vpc_id }}" + tags: + Name: "{{ default_resource_name }}" + routes: + - dest: "0.0.0.0/0" + gateway_id: "{{ igw.gateway_id }}" + subnets: + - "{{ testing_subnet.subnet.id }}" + +- name: create a security group with the vpc created in the ec2_setup + amazon.aws.ec2_security_group: + name: "{{ default_resource_name }}" + description: a security group for ansible tests + vpc_id: "{{ vpc_id }}" + rules: + - proto: tcp + from_port: 22 + to_port: 22 + cidr_ip: "0.0.0.0/0" + - proto: tcp + from_port: 80 + to_port: 80 + cidr_ip: "0.0.0.0/0" + register: sg + +- name: ensure launch configs exist + community.aws.autoscaling_launch_config: + name: "{{ item }}" + assign_public_ip: true + image_id: "{{ ec2_ami_id }}" + user_data: | + #cloud-config + package_upgrade: true + package_update: true + packages: + - httpd + runcmd: + - "service httpd start" + security_groups: "{{ sg.group_id }}" + instance_type: t3.micro + loop: + - "{{ default_resource_name }}-1" + - "{{ default_resource_name }}-2" + +- name: create asg and wait for instances to be deemed healthy (no ELB) + amazon.aws.autoscaling_group: + name: "{{ default_resource_name }}" + launch_config_name: "{{ default_resource_name }}-1" + desired_capacity: 2 + min_size: 0 + max_size: 4 + vpc_zone_identifier: "{{ testing_subnet.subnet.id }}" + state: present + wait_for_instances: true + register: create_asg +- ansible.builtin.assert: + that: + - create_asg.viable_instances == 2 + - create_asg.instances | length == 2 + +- ansible.builtin.set_fact: + initial_instances: "{{ create_asg.instances }}" diff --git a/tests/integration/targets/autoscaling_instance/tasks/main.yml b/tests/integration/targets/autoscaling_instance/tasks/main.yml new file mode 100644 index 00000000000..fbd45db5eda --- /dev/null +++ b/tests/integration/targets/autoscaling_instance/tasks/main.yml @@ -0,0 +1,19 @@ +--- +# Beware: most of our tests here are run in parallel. +# To add new tests you'll need to add a new host to the inventory and a matching +# '{{ inventory_hostname }}'.yml file in roles/ec2_asg/tasks/ + +- name: Wrap up all tests and setup AWS credentials + module_defaults: + group/aws: + access_key: "{{ aws_access_key }}" + secret_key: "{{ aws_secret_key }}" + session_token: "{{ security_token | default(omit) }}" + region: "{{ aws_region }}" + collections: + - community.aws + block: + - ansible.builtin.include_tasks: env_setup.yml + - ansible.builtin.include_tasks: tests.yml + always: + - ansible.builtin.include_tasks: env_cleanup.yml diff --git a/tests/integration/targets/autoscaling_instance/tasks/tests.yml b/tests/integration/targets/autoscaling_instance/tasks/tests.yml new file mode 100644 index 00000000000..4121f79c36b --- /dev/null +++ b/tests/integration/targets/autoscaling_instance/tasks/tests.yml @@ -0,0 +1,266 @@ +--- +- ansible.builtin.debug: + msg: Ready to run tests + +### Simple _info tests + +- name: List all instances + amazon.aws.autoscaling_instance_info: + register: instance_info + +- ansible.builtin.assert: + that: + - "'auto_scaling_instances' in instance_info" + - instance_info.auto_scaling_instances | length >= 2 + - initial_instances[0] in listed_instance_ids + - initial_instances[1] in listed_instance_ids + - "'auto_scaling_group_name' in specific_instance_info" + - specific_instance_info.auto_scaling_group_name == default_resource_name + - "'availability_zone' in specific_instance_info" + - "'health_status' in specific_instance_info" + - specific_instance_info.health_status == "HEALTHY" + - "'instance_id' in specific_instance_info" + - specific_instance_info.instance_id == initial_instances[0] + - "'instance_type' in specific_instance_info" + - specific_instance_info.instance_type == "t3.micro" + - "'launch_configuration_name' in specific_instance_info" + - specific_instance_info.launch_configuration_name.startswith(default_resource_name) + - "'lifecycle_state' in specific_instance_info" + - specific_instance_info.lifecycle_state == "InService" + - "'protected_from_scale_in' in specific_instance_info" + - specific_instance_info.protected_from_scale_in == False + vars: + listed_instance_ids: "{{ instance_info.auto_scaling_instances | map(attribute='instance_id') | list }}" + specific_instance_info: "{{ instance_info.auto_scaling_instances | selectattr('instance_id', 'equalto', initial_instances[0]) | first }}" + +- name: List all instances attached to a specific ASG + amazon.aws.autoscaling_instance_info: + group_name: "{{ default_resource_name }}" + register: instance_info + +- ansible.builtin.assert: + that: + - "'auto_scaling_instances' in instance_info" + - instance_info.auto_scaling_instances | length == 2 + - initial_instances[0] in listed_instance_ids + - initial_instances[1] in listed_instance_ids + - "'auto_scaling_group_name' in instance_info.auto_scaling_instances[0]" + - "'availability_zone' in instance_info.auto_scaling_instances[0]" + - "'health_status' in instance_info.auto_scaling_instances[0]" + - "'instance_id' in instance_info.auto_scaling_instances[0]" + - "'instance_type' in instance_info.auto_scaling_instances[0]" + - "'launch_configuration_name' in instance_info.auto_scaling_instances[0]" + - "'lifecycle_state' in instance_info.auto_scaling_instances[0]" + - "'protected_from_scale_in' in instance_info.auto_scaling_instances[0]" + - "'auto_scaling_group_name' in instance_info.auto_scaling_instances[1]" + - "'availability_zone' in instance_info.auto_scaling_instances[1]" + - "'health_status' in instance_info.auto_scaling_instances[1]" + - "'instance_id' in instance_info.auto_scaling_instances[1]" + - "'instance_type' in instance_info.auto_scaling_instances[1]" + - "'launch_configuration_name' in instance_info.auto_scaling_instances[1]" + - "'lifecycle_state' in instance_info.auto_scaling_instances[1]" + - "'protected_from_scale_in' in instance_info.auto_scaling_instances[1]" + - specific_instance_info.auto_scaling_group_name == default_resource_name + - specific_instance_info.health_status == "HEALTHY" + - specific_instance_info.instance_id == initial_instances[0] + - specific_instance_info.instance_type == "t3.micro" + - specific_instance_info.launch_configuration_name.startswith(default_resource_name) + - specific_instance_info.lifecycle_state == "InService" + - specific_instance_info.protected_from_scale_in == False + vars: + listed_instance_ids: "{{ instance_info.auto_scaling_instances | map(attribute='instance_id') | list }}" + specific_instance_info: "{{ instance_info.auto_scaling_instances | selectattr('instance_id', 'equalto', initial_instances[0]) | first }}" + +- amazon.aws.autoscaling_instance_info: + instance_ids: "{{ instance_info.auto_scaling_instances | map(attribute='instance_id') | list }}" + register: instance_info + +- ansible.builtin.assert: + that: + - "'auto_scaling_instances' in instance_info" + - instance_info.auto_scaling_instances | length == 2 + - initial_instances[0] in listed_instance_ids + - initial_instances[1] in listed_instance_ids + - "'auto_scaling_group_name' in instance_info.auto_scaling_instances[0]" + - "'availability_zone' in instance_info.auto_scaling_instances[0]" + - "'health_status' in instance_info.auto_scaling_instances[0]" + - "'instance_id' in instance_info.auto_scaling_instances[0]" + - "'instance_type' in instance_info.auto_scaling_instances[0]" + - "'launch_configuration_name' in instance_info.auto_scaling_instances[0]" + - "'lifecycle_state' in instance_info.auto_scaling_instances[0]" + - "'protected_from_scale_in' in instance_info.auto_scaling_instances[0]" + - "'auto_scaling_group_name' in instance_info.auto_scaling_instances[1]" + - "'availability_zone' in instance_info.auto_scaling_instances[1]" + - "'health_status' in instance_info.auto_scaling_instances[1]" + - "'instance_id' in instance_info.auto_scaling_instances[1]" + - "'instance_type' in instance_info.auto_scaling_instances[1]" + - "'launch_configuration_name' in instance_info.auto_scaling_instances[1]" + - "'lifecycle_state' in instance_info.auto_scaling_instances[1]" + - "'protected_from_scale_in' in instance_info.auto_scaling_instances[1]" + - specific_instance_info.auto_scaling_group_name == default_resource_name + - specific_instance_info.health_status == "HEALTHY" + - specific_instance_info.instance_id == initial_instances[0] + - specific_instance_info.instance_type == "t3.micro" + - specific_instance_info.launch_configuration_name.startswith(default_resource_name) + - specific_instance_info.lifecycle_state == "InService" + - specific_instance_info.protected_from_scale_in == False + vars: + listed_instance_ids: "{{ instance_info.auto_scaling_instances | map(attribute='instance_id') | list }}" + specific_instance_info: "{{ instance_info.auto_scaling_instances | selectattr('instance_id', 'equalto', initial_instances[0]) | first }}" + +- amazon.aws.autoscaling_instance_info: + instance_ids: "{{ initial_instances[0] }}" + register: instance_info + +- ansible.builtin.assert: + that: + - "'auto_scaling_instances' in instance_info" + - instance_info.auto_scaling_instances | length == 1 + - initial_instances[0] in listed_instance_ids + - "'auto_scaling_group_name' in specific_instance_info" + - specific_instance_info.auto_scaling_group_name == default_resource_name + - "'availability_zone' in specific_instance_info" + - "'health_status' in specific_instance_info" + - specific_instance_info.health_status == "HEALTHY" + - "'instance_id' in specific_instance_info" + - specific_instance_info.instance_id == initial_instances[0] + - "'instance_type' in specific_instance_info" + - specific_instance_info.instance_type == "t3.micro" + - "'launch_configuration_name' in specific_instance_info" + - specific_instance_info.launch_configuration_name.startswith(default_resource_name) + - "'lifecycle_state' in specific_instance_info" + - specific_instance_info.lifecycle_state == "InService" + - "'protected_from_scale_in' in specific_instance_info" + - specific_instance_info.protected_from_scale_in == False + vars: + listed_instance_ids: "{{ instance_info.auto_scaling_instances | map(attribute='instance_id') | list }}" + specific_instance_info: "{{ instance_info.auto_scaling_instances[0] }}" + +### instance_ids - idempotency + +# All current instances passed, no purge requested +# - no change should happen +- name: instance_ids - idempotency/all - no purge - check_mode + amazon.aws.autoscaling_instance: + instance_ids: "{{ initial_instances }}" + group_name: "{{ default_resource_name }}" + state: present + purge_instances: False + diff: True + register: present_no_change + check_mode: True + +- name: instance_ids - idempotency/all - no purge + amazon.aws.autoscaling_instance: + instance_ids: "{{ initial_instances }}" + group_name: "{{ default_resource_name }}" + state: present + purge_instances: False + diff: True + register: present_no_change + +# One of the current instances passed, no purge requested +# - no change should happen +- name: instance_ids - idempotency/partial - no purge - check_mode + amazon.aws.autoscaling_instance: + instance_ids: "{{ initial_instances[0] }}" + group_name: "{{ default_resource_name }}" + state: present + purge_instances: False + diff: True + register: present_no_change + check_mode: True + +- name: instance_ids - idempotency/partial - no purge + amazon.aws.autoscaling_instance: + instance_ids: "{{ initial_instances[0] }}" + group_name: "{{ default_resource_name }}" + state: present + purge_instances: False + diff: True + register: present_no_change + +# All current instances passed, purge requested +# - no change should happen as there are no instances that are attached but not requested +- name: instance_ids - idempotency/all - purge - check_mode + amazon.aws.autoscaling_instance: + instance_ids: "{{ initial_instances }}" + group_name: "{{ default_resource_name }}" + purge_instances: True + state: present + diff: True + register: present_no_change + check_mode: True + +- name: instance_ids - idempotency/all - purge + amazon.aws.autoscaling_instance: + instance_ids: "{{ initial_instances }}" + group_name: "{{ default_resource_name }}" + purge_instances: True + state: present + diff: True + register: present_no_change + +### instance_ids - attach/detach +# Detach a specific instance +- name: instance_ids - single instance - detach - check_mode + amazon.aws.autoscaling_instance: + instance_ids: "{{ initial_instances[0] }}" + group_name: "{{ default_resource_name }}" + state: detached + diff: True + register: absent_one + check_mode: True + +- name: instance_ids - single instance - detach + amazon.aws.autoscaling_instance: + instance_ids: "{{ initial_instances[0] }}" + group_name: "{{ default_resource_name }}" + state: detached + diff: True + register: absent_one + +# Ensure present state (not using standby - should be the same as attached) +- name: instance_ids - single instance - attach/present - check_mode + amazon.aws.autoscaling_instance: + instance_ids: "{{ initial_instances[0] }}" + group_name: "{{ default_resource_name }}" + state: present + diff: True + register: present_one + check_mode: True + +- name: instance_ids - single instance - attach/present + amazon.aws.autoscaling_instance: + instance_ids: "{{ initial_instances[0] }}" + group_name: "{{ default_resource_name }}" + state: present + diff: True + register: present_one + +# Detach it again so we can reattach +- name: instance_ids - single instance - detach (again - prepare to attach) + amazon.aws.autoscaling_instance: + instance_ids: "{{ initial_instances[0] }}" + group_name: "{{ default_resource_name }}" + state: detached + diff: True + register: detach_one + +# Ensure attached state (not using standby - should be the same as attached) +- name: instance_ids - single instance - attach/attach - check_mode + amazon.aws.autoscaling_instance: + instance_ids: "{{ initial_instances[0] }}" + group_name: "{{ default_resource_name }}" + state: attached + diff: True + register: attached_one + check_mode: True + +- name: instance_ids - single instance - attach/attach + amazon.aws.autoscaling_instance: + instance_ids: "{{ initial_instances[0] }}" + group_name: "{{ default_resource_name }}" + state: attached + diff: True + register: attached_one diff --git a/tests/integration/targets/autoscaling_instance/tmp/inventory b/tests/integration/targets/autoscaling_instance/tmp/inventory new file mode 100644 index 00000000000..edc19ef5f3c --- /dev/null +++ b/tests/integration/targets/autoscaling_instance/tmp/inventory @@ -0,0 +1,8 @@ +[tests] +create_update_delete +tag_operations +instance_detach + +[all:vars] +ansible_connection=local +ansible_python_interpreter="{{ ansible_playbook_python }}" diff --git a/tests/integration/targets/autoscaling_instance/tmp/main.yml b/tests/integration/targets/autoscaling_instance/tmp/main.yml new file mode 100644 index 00000000000..709499c4470 --- /dev/null +++ b/tests/integration/targets/autoscaling_instance/tmp/main.yml @@ -0,0 +1,34 @@ +--- +# Beware: most of our tests here are run in parallel. +# To add new tests you'll need to add a new host to the inventory and a matching +# '{{ inventory_hostname }}'.yml file in roles/ec2_asg/tasks/ +# Prepare the VPC and figure out which AMI to use +- hosts: all + gather_facts: false + tasks: + - module_defaults: + group/aws: + access_key: "{{ aws_access_key }}" + secret_key: "{{ aws_secret_key }}" + session_token: "{{ security_token | default(omit) }}" + region: "{{ aws_region }}" + block: + - ansible.builtin.include_role: + name: setup_ec2_facts + - ansible.builtin.include_role: + name: ec2_asg + tasks_from: env_setup.yml + rescue: + - ansible.builtin.include_role: + name: ec2_asg + tasks_from: env_cleanup.yml + run_once: true + - ansible.builtin.fail: + msg: Environment preparation failed + run_once: true +- hosts: all + gather_facts: false + strategy: free + serial: 6 + roles: + - ec2_asg diff --git a/tests/integration/targets/autoscaling_instance/tmp/runme.sh b/tests/integration/targets/autoscaling_instance/tmp/runme.sh new file mode 100755 index 00000000000..aa324772bbe --- /dev/null +++ b/tests/integration/targets/autoscaling_instance/tmp/runme.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# +# Beware: most of our tests here are run in parallel. +# To add new tests you'll need to add a new host to the inventory and a matching +# '{{ inventory_hostname }}'.yml file in roles/ec2_instance/tasks/ + + +set -eux + +export ANSIBLE_ROLES_PATH=../ + +ansible-playbook main.yml -i inventory "$@" diff --git a/tests/integration/targets/setup_ec2_vpc/tasks/cleanup.yml b/tests/integration/targets/setup_ec2_vpc/tasks/cleanup.yml index 4efd66d30b5..32a6259a1ba 100644 --- a/tests/integration/targets/setup_ec2_vpc/tasks/cleanup.yml +++ b/tests/integration/targets/setup_ec2_vpc/tasks/cleanup.yml @@ -1,6 +1,6 @@ --- # ============================================================ -- name: Run all tests +- name: Cleanup after all tests module_defaults: group/aws: access_key: "{{ aws_access_key }}" @@ -88,7 +88,7 @@ loop: "{{ remaining_subnets.subnets }}" until: subnets_removed is not failed when: - - item.name != 'default' + - (item.name | default("")) != 'default' ignore_errors: true retries: 10 @@ -106,7 +106,7 @@ # ============================================================ - - name: (VPC Cleanup) Delete remaining route tables + - name: (VPC Cleanup) Delete route tables (excluding main table) amazon.aws.ec2_vpc_route_table: state: absent vpc_id: "{{ vpc_id }}" @@ -114,7 +114,11 @@ lookup: id register: rtbs_removed loop: "{{ remaining_rtbs.route_tables }}" + when: + - True not in main_associations ignore_errors: true + vars: + main_associations: "{{ item.associations | default([]) | map(attribute='main') | list}}" # ============================================================ @@ -126,3 +130,15 @@ until: vpc_removed is not failed ignore_errors: true retries: 10 + + # ============================================================ + + - name: (VPC Cleanup) (retry) Delete remaining route tables (including main table) + amazon.aws.ec2_vpc_route_table: + state: absent + vpc_id: "{{ vpc_id }}" + route_table_id: "{{ item.id }}" + lookup: id + register: rtbs_removed + loop: "{{ remaining_rtbs.route_tables }}" + ignore_errors: true diff --git a/tests/unit/module_utils/autoscaling/test_autoscaling_error_handler.py b/tests/unit/module_utils/autoscaling/test_autoscaling_error_handler.py new file mode 100644 index 00000000000..68ebfdab045 --- /dev/null +++ b/tests/unit/module_utils/autoscaling/test_autoscaling_error_handler.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- + +# Copyright: Contributors to the Ansible project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +try: + import botocore +except ImportError: + pass + +import pytest + +from ansible_collections.amazon.aws.plugins.module_utils.autoscaling import AnsibleAutoScalingError +from ansible_collections.amazon.aws.plugins.module_utils.autoscaling import AutoScalingErrorHandler +from ansible_collections.amazon.aws.plugins.module_utils.botocore import HAS_BOTO3 + +if not HAS_BOTO3: + pytestmark = pytest.mark.skip("test_iam_error_handler.py requires the python modules 'boto3' and 'botocore'") + + +class TestAutoScalingDeletionHandler: + def test_no_failures(self): + self.counter = 0 + + @AutoScalingErrorHandler.deletion_error_handler("no error") + def no_failures(): + self.counter += 1 + + no_failures() + assert self.counter == 1 + + def test_client_error(self): + self.counter = 0 + err_response = {"Error": {"Code": "MalformedPolicyDocument"}} + + @AutoScalingErrorHandler.deletion_error_handler("do something") + def raise_client_error(): + self.counter += 1 + raise botocore.exceptions.ClientError(err_response, "Something bad") + + with pytest.raises(AnsibleAutoScalingError) as e_info: + raise_client_error() + assert self.counter == 1 + raised = e_info.value + assert isinstance(raised.exception, botocore.exceptions.ClientError) + assert "do something" in raised.message + assert "Something bad" in str(raised.exception) + + def test_ignore_error(self): + self.counter = 0 + err_response = {"Error": {"Code": "NoSuchEntity"}} + + @AutoScalingErrorHandler.deletion_error_handler("do something") + def raise_client_error(): + self.counter += 1 + raise botocore.exceptions.ClientError(err_response, "I couldn't find it") + + ret_val = raise_client_error() + assert self.counter == 1 + assert ret_val is False + + +class TestIamListHandler: + def test_no_failures(self): + self.counter = 0 + + @AutoScalingErrorHandler.list_error_handler("no error") + def no_failures(): + self.counter += 1 + + no_failures() + assert self.counter == 1 + + def test_client_error(self): + self.counter = 0 + err_response = {"Error": {"Code": "MalformedPolicyDocument"}} + + @AutoScalingErrorHandler.list_error_handler("do something") + def raise_client_error(): + self.counter += 1 + raise botocore.exceptions.ClientError(err_response, "Something bad") + + with pytest.raises(AnsibleAutoScalingError) as e_info: + raise_client_error() + assert self.counter == 1 + raised = e_info.value + assert isinstance(raised.exception, botocore.exceptions.ClientError) + assert "do something" in raised.message + assert "Something bad" in str(raised.exception) + + def test_list_error(self): + self.counter = 0 + err_response = {"Error": {"Code": "NoSuchEntity"}} + + @AutoScalingErrorHandler.list_error_handler("do something") + def raise_client_error(): + self.counter += 1 + raise botocore.exceptions.ClientError(err_response, "I couldn't find it") + + ret_val = raise_client_error() + assert self.counter == 1 + assert ret_val is None + + +class TestIamCommonHandler: + def test_no_failures(self): + self.counter = 0 + + @AutoScalingErrorHandler.common_error_handler("no error") + def no_failures(): + self.counter += 1 + + no_failures() + assert self.counter == 1 + + def test_client_error(self): + self.counter = 0 + err_response = {"Error": {"Code": "MalformedPolicyDocument"}} + + @AutoScalingErrorHandler.common_error_handler("do something") + def raise_client_error(): + self.counter += 1 + raise botocore.exceptions.ClientError(err_response, "Something bad") + + with pytest.raises(AnsibleAutoScalingError) as e_info: + raise_client_error() + assert self.counter == 1 + raised = e_info.value + assert isinstance(raised.exception, botocore.exceptions.ClientError) + assert "do something" in raised.message + assert "Something bad" in str(raised.exception) diff --git a/tests/unit/module_utils/autoscaling/test_autoscaling_resource_transforms.py b/tests/unit/module_utils/autoscaling/test_autoscaling_resource_transforms.py new file mode 100644 index 00000000000..a8f0511cb01 --- /dev/null +++ b/tests/unit/module_utils/autoscaling/test_autoscaling_resource_transforms.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- + +# Copyright: Contributors to the Ansible project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from ansible_collections.amazon.aws.plugins.module_utils._autoscaling.transformations import ( + normalize_autoscaling_instances, +) + +# The various normalize_ functions are based upon ..transformation.boto3_resource_to_ansible_dict +# As such these tests will be relatively light touch. + + +class TestAutoScalingResourceToAnsibleDict: + def setup_method(self): + pass + + def test_normalize_autoscaling_instances(self): + INPUT = [ + { + "AvailabilityZone": "us-east-1a", + "HealthStatus": "UNHEALTHY", + "InstanceId": "i-123456789abcdef12", + "InstanceType": "t3.small", + "LaunchConfigurationName": "ansible-test-lc-2", + "LifecycleState": "Standby", + "ProtectedFromScaleIn": True, + }, + { + "AutoScalingGroupName": "ansible-test-asg", + "AvailabilityZone": "us-east-1a", + "HealthStatus": "Healthy", + "InstanceId": "i-0123456789abcdef0", + "InstanceType": "t3.micro", + "LaunchConfigurationName": "ansible-test-lc", + "LifecycleState": "InService", + "ProtectedFromScaleIn": False, + }, + ] + OUTPUT = [ + { + "auto_scaling_group_name": "ansible-test-asg", + "availability_zone": "us-east-1a", + "health_status": "HEALTHY", + "instance_id": "i-0123456789abcdef0", + "instance_type": "t3.micro", + "launch_configuration_name": "ansible-test-lc", + "lifecycle_state": "InService", + "protected_from_scale_in": False, + }, + { + "availability_zone": "us-east-1a", + "health_status": "UNHEALTHY", + "instance_id": "i-123456789abcdef12", + "instance_type": "t3.small", + "launch_configuration_name": "ansible-test-lc-2", + "lifecycle_state": "Standby", + "protected_from_scale_in": True, + }, + ] + + assert OUTPUT == normalize_autoscaling_instances(INPUT) + + def test_normalize_autoscaling_instances_with_group(self): + INPUT = [ + { + "AvailabilityZone": "us-east-1a", + "HealthStatus": "Unhealthy", + "InstanceId": "i-123456789abcdef12", + "InstanceType": "t3.small", + "LaunchConfigurationName": "ansible-test-lc-2", + "LifecycleState": "Standby", + "ProtectedFromScaleIn": True, + }, + { + "AutoScalingGroupName": "ansible-test-asg", + "AvailabilityZone": "us-east-1a", + "HealthStatus": "HEALTHY", + "InstanceId": "i-0123456789abcdef0", + "InstanceType": "t3.micro", + "LaunchConfigurationName": "ansible-test-lc", + "LifecycleState": "InService", + "ProtectedFromScaleIn": False, + }, + ] + OUTPUT = [ + { + "auto_scaling_group_name": "ansible-test-asg", + "availability_zone": "us-east-1a", + "health_status": "HEALTHY", + "instance_id": "i-0123456789abcdef0", + "instance_type": "t3.micro", + "launch_configuration_name": "ansible-test-lc", + "lifecycle_state": "InService", + "protected_from_scale_in": False, + }, + { + "auto_scaling_group_name": "ansible-test-asg-2", + "availability_zone": "us-east-1a", + "health_status": "UNHEALTHY", + "instance_id": "i-123456789abcdef12", + "instance_type": "t3.small", + "launch_configuration_name": "ansible-test-lc-2", + "lifecycle_state": "Standby", + "protected_from_scale_in": True, + }, + ] + + assert OUTPUT == normalize_autoscaling_instances(INPUT, "ansible-test-asg-2") diff --git a/tox.ini b/tox.ini index 75e7bef8b9f..16529573efd 100644 --- a/tox.ini +++ b/tox.ini @@ -106,6 +106,12 @@ commands = isort --check-only --diff {toxinidir}/plugins {toxinidir}/tests flake8 {posargs} {toxinidir}/plugins {toxinidir}/tests +[testenv:ansible-sanity] +deps = + git+https://github.com/ansible/ansible.git@milestone +commands = + ansible-test sanity + [flake8] # E123, E125 skipped as they are invalid PEP-8. show-source = True