Skip to content

Commit

Permalink
New module - autoscaling_instance(_info) (ansible-collections#2296)
Browse files Browse the repository at this point in the history
Depends-On: ansible/ansible-zuul-jobs#1885
SUMMARY
Looking to simplify autoscaling_group, add autoscaling_instance as a new module so we can deprecate support for it in autoscaling_group
ISSUE TYPE

New Module Pull Request

COMPONENT NAME
autoscaling_instance
ADDITIONAL INFORMATION

Reviewed-by: Alina Buzachis
Reviewed-by: Mike Graves <[email protected]>
Reviewed-by: Mark Chappell
  • Loading branch information
tremble authored Oct 25, 2024
1 parent bc7dd1e commit 1700d40
Show file tree
Hide file tree
Showing 29 changed files with 3,476 additions and 18 deletions.
2 changes: 2 additions & 0 deletions meta/runtime.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
requires_ansible: ">=2.15.0"
action_groups:
aws:
- autoscaling_instance
- autoscaling_instance_info
- autoscaling_group
- autoscaling_group_info
- autoscaling_instance_refresh
Expand Down
Empty file.
25 changes: 25 additions & 0 deletions plugins/module_utils/_autoscaling/common.py
Original file line number Diff line number Diff line change
@@ -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")
22 changes: 22 additions & 0 deletions plugins/module_utils/_autoscaling/groups.py
Original file line number Diff line number Diff line change
@@ -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"]
20 changes: 20 additions & 0 deletions plugins/module_utils/_autoscaling/instances.py
Original file line number Diff line number Diff line change
@@ -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"]
71 changes: 71 additions & 0 deletions plugins/module_utils/_autoscaling/transformations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# -*- 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 __future__ import annotations

import typing

if typing.TYPE_CHECKING:
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_list_to_ansible_dict
from ..transformation import boto3_resource_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,
) -> AnsibleAWSResource:
"""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))


def normalize_autoscaling_groups(autoscaling_groups: BotoResourceList) -> AnsibleAWSResourceList:
"""Converts a list of AutoScaling Groups from the CamelCase boto3 format to the snake_case Ansible format"""
autoscaling_groups = boto3_resource_list_to_ansible_dict(autoscaling_groups)
return sorted(autoscaling_groups, key=lambda d: d.get("auto_scaling_group_name", None))
138 changes: 138 additions & 0 deletions plugins/module_utils/_autoscaling/waiters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# -*- 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 ..waiter import BaseWaiterFactory

WAITER_MAP = {
"Standby": "instances_in_standby",
"Terminated": "instances_terminated",
"Detached": "instances_detached",
"InService": "instances_in_service",
"HEALTHY": "instances_healthy",
"Healthy": "instances_healthy",
"UNHEALTHY": "instances_unhealthy",
"Unhealthy": "instances_unhealthy",
"Protected": "instances_protected",
"NotProtected": "instances_not_protected",
}


def _fail_on_instance_lifecycle_states(state):
return dict(state="failure", matcher="pathAny", expected=state, argument="AutoScalingInstances[].LifecycleState")


def _success_on_instance_lifecycle_states(state):
return dict(state="success", matcher="pathAll", expected=state, argument="AutoScalingInstances[].LifecycleState")


def _success_on_instance_health(health):
return dict(state="success", matcher="pathAll", expected=health, argument="AutoScalingInstances[].HealthStatus")


def _success_on_instance_protection(state):
return dict(
state="success", matcher="pathAll", expected=state, argument="AutoScalingInstances[].ProtectedFromScaleIn"
)


def _no_instances(result):
return dict(state=result, matcher="path", expected=True, argument="length(AutoScalingInstances[]) == `0`")


class AutoscalingWaiterFactory(BaseWaiterFactory):
@property
def _waiter_model_data(self):
data = dict(
instances_healthy=dict(
operation="DescribeAutoScalingInstances",
delay=5,
maxAttempts=120,
acceptors=[
_success_on_instance_health("HEALTHY"),
# Terminated Instances can't reach "Healthy"
_fail_on_instance_lifecycle_states("Terminating"),
_fail_on_instance_lifecycle_states("Terminated"),
_fail_on_instance_lifecycle_states("Terminating:Wait"),
_fail_on_instance_lifecycle_states("Terminating:Proceed"),
],
),
instances_unhealthy=dict(
operation="DescribeAutoScalingInstances",
delay=5,
maxAttempts=120,
acceptors=[
_success_on_instance_health("UNHEALTHY"),
# Instances in an unhealthy state can end up being automatically terminated
_no_instances("success"),
],
),
instances_protected=dict(
operation="DescribeAutoScalingInstances",
delay=5,
maxAttempts=120,
acceptors=[
_success_on_instance_protection(True),
],
),
instances_not_protected=dict(
operation="DescribeAutoScalingInstances",
delay=5,
maxAttempts=120,
acceptors=[
_success_on_instance_protection(False),
# Instances without protection can end up being automatically terminated
_no_instances("success"),
],
),
instances_in_service=dict(
operation="DescribeAutoScalingInstances",
delay=5,
maxAttempts=120,
acceptors=[
_success_on_instance_lifecycle_states("InService"),
# Terminated instances can't reach InService
_fail_on_instance_lifecycle_states("Terminating"),
_fail_on_instance_lifecycle_states("Terminated"),
_fail_on_instance_lifecycle_states("Terminating:Wait"),
_fail_on_instance_lifecycle_states("Terminating:Proceed"),
],
),
instances_in_standby=dict(
operation="DescribeAutoScalingInstances",
delay=5,
maxAttempts=120,
acceptors=[
_success_on_instance_lifecycle_states("Standby"),
# Terminated instances can't reach Standby
_fail_on_instance_lifecycle_states("Terminating"),
_fail_on_instance_lifecycle_states("Terminated"),
_fail_on_instance_lifecycle_states("Terminating:Wait"),
_fail_on_instance_lifecycle_states("Terminating:Proceed"),
],
),
instances_detached=dict(
operation="DescribeAutoScalingInstances",
delay=5,
maxAttempts=120,
acceptors=[
_success_on_instance_lifecycle_states("Detached"),
_no_instances("success"),
],
),
instances_terminated=dict(
operation="DescribeAutoScalingInstances",
delay=5,
maxAttempts=120,
acceptors=[
_success_on_instance_lifecycle_states("Terminated"),
_no_instances("success"),
],
),
)

return data


waiter_factory = AutoscalingWaiterFactory()
74 changes: 59 additions & 15 deletions plugins/module_utils/autoscaling.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,73 @@
# -*- coding: utf-8 -*-

# Copyright (c) 2024 Ansible Project
# 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 Any
from typing import Dict
from typing import List
from typing import Optional
# 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 .botocore import is_boto3_error_code
from .errors import AWSErrorHandler
from .exceptions import AnsibleAWSError

from __future__ import annotations

import typing

# Not intended for general re-use / re-import
from ._autoscaling import common as _common
from ._autoscaling import groups as _groups
from ._autoscaling import instances as _instances
from ._autoscaling import transformations as _transformations
from ._autoscaling import waiters as _waiters
from .retries import AWSRetry

if typing.TYPE_CHECKING:
from typing import Any
from typing import Dict
from typing import List
from typing import Optional

from .retries import RetryingBotoClientWrapper
from .transformation import AnsibleAWSResourceList
from .transformation import BotoResourceList

# Intended for general use / re-import
AnsibleAutoScalingError = _common.AnsibleAutoScalingError
AutoScalingErrorHandler = _common.AutoScalingErrorHandler
WAITER_MAP = _waiters.WAITER_MAP


def get_autoscaling_groups(
client: RetryingBotoClientWrapper, group_names: Optional[List[str]] = None
) -> AnsibleAWSResourceList:
groups = _groups.describe_auto_scaling_groups(client, group_names)
return _transformations.normalize_autoscaling_groups(groups)


def _get_autoscaling_instances(
client: RetryingBotoClientWrapper, instance_ids: Optional[List[str]] = None, group_name: Optional[str] = None
) -> BotoResourceList:
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: RetryingBotoClientWrapper, instance_ids: Optional[List[str]] = None, group_name: Optional[str] = None
) -> AnsibleAWSResourceList:
instances = _get_autoscaling_instances(client, instance_ids=instance_ids, group_name=group_name)
return _transformations.normalize_autoscaling_instances(instances, group_name=group_name)

class AnsibleAutoScalingError(AnsibleAWSError):
pass

def get_autoscaling_waiter(client: RetryingBotoClientWrapper, waiter_name: str) -> Any:
return _waiters.waiter_factory.get_waiter(client, waiter_name)

class AutoScalingErrorHandler(AWSErrorHandler):
_CUSTOM_EXCEPTION = AnsibleAutoScalingError

@classmethod
def _is_missing(cls):
return is_boto3_error_code("")
# ====================================
# TODO Move these about and refactor
# ====================================


@AutoScalingErrorHandler.list_error_handler("describe InstanceRefreshes", {})
Expand Down
Loading

0 comments on commit 1700d40

Please sign in to comment.