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

New module - autoscaling_instance(_info) #2296

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
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
Loading