diff --git a/meta/runtime.yml b/meta/runtime.yml index 15743d53e91..438eae427ed 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -143,6 +143,19 @@ action_groups: - lightsail - lightsail_snapshot - lightsail_static_ip + - medialive_channel_placement_group_info + - medialive_channel_placement_group + - medialive_cluster_info + - medialive_cluster + - medialive_network_info + - medialive_network + - medialive_node + - medialive_node_info + - medialive_node_registration + - medialive_sdi_source + - medialive_sdi_source_info + - medialive_input + - medialive_input_info - mq_broker - mq_broker_config - mq_broker_info diff --git a/plugins/modules/medialive_channel_placement_group.py b/plugins/modules/medialive_channel_placement_group.py new file mode 100644 index 00000000000..47071cc2a5b --- /dev/null +++ b/plugins/modules/medialive_channel_placement_group.py @@ -0,0 +1,458 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r""" +module: medialive_channel_placement_group +short_description: Manage AWS MediaLive Channel Placement Groups +version_added: 10.1.0 +description: + - A module for creating, updating and deleting AWS MediaLive Channel Placement Groups. + - This module requires boto3 >= 1.35.17. +author: + - "David Teach" +options: + id: + description: + - The ID of the channel placement group. + - Required when updating or deleting an existing placement group. + required: false + type: str + aliases: ['channel_placement_group_id'] + cluster_id: + description: + - The ID of the cluster. + - Required for all operations. + required: true + type: str + nodes: + description: + - A list of node IDs to associate with the channel placement group. + - Required when creating a new placement group. + type: list + elements: str + required: false + state: + description: + - Create/update or remove the channel placement group. + required: false + choices: ['present', 'absent'] + default: 'present' + type: str + wait: + description: + - Whether to wait for the channel placement group to reach the desired state. + - When I(state=present), wait for the placement group to reach the ACTIVE state. + - When I(state=absent), wait for the placement group to be deleted. + type: bool + required: false + default: true + wait_timeout: + description: + - The maximum time in seconds to wait for the channel placement group to reach the desired state. + - Defaults to 60 seconds. + type: int + required: false + default: 60 + +extends_documentation_fragment: + - amazon.aws.common.modules + - amazon.aws.region.modules + - amazon.aws.boto3 +""" + +EXAMPLES = r""" +# Create a MediaLive Channel Placement Group +- community.aws.medialive_channel_placement_group: + cluster_id: '123456' + nodes: + - '34598743' + state: present + +# Update a MediaLive Channel Placement Group +- community.aws.medialive_channel_placement_group: + id: '234523' + cluster_id: '123456' + name: 'UpdatedPlacementGroup' + nodes: + - '34598743' + state: present + +# Delete a MediaLive Channel Placement Group +- community.aws.medialive_channel_placement_group: + id: '234523' + cluster_id: '123456' + state: absent +""" + +RETURN = r""" +placement_group: + description: The details of the channel placement group + returned: success + type: dict + contains: + arn: + description: The ARN of the channel placement group + type: str + returned: success + example: "arn:aws:medialive:us-east-1:123456789012:channelplacementgroup/cpg-12345" + channel_placement_group_id: + description: The ID of the channel placement group + type: str + returned: success + example: "234523" + cluster_id: + description: The ID of the cluster + type: str + returned: success + example: "123456" + created: + description: When the channel placement group was created + type: str + returned: success + example: "2025-03-31T23:08:55Z" + state: + description: The state of the channel placement group + type: str + returned: success + example: "ACTIVE" +""" + +import uuid +from typing import Dict, Literal + +try: + from botocore.exceptions import WaiterError, ClientError, BotoCoreError +except ImportError: + pass # caught by AnsibleAWSModule + +from ansible.module_utils.common.dict_transformations import snake_dict_to_camel_dict, camel_dict_to_snake_dict, recursive_diff +from ansible_collections.amazon.aws.plugins.module_utils.core import AnsibleAWSModule +from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_code +from ansible_collections.amazon.aws.plugins.module_utils.exceptions import AnsibleAWSError + + +class MediaLiveChannelPlacementGroupManager: + '''Manage AWS MediaLive Channel Placement Groups''' + + def __init__(self, module: AnsibleAWSModule): + ''' + Initialize the MediaLiveChannelPlacementGroupManager + + Args: + module: AnsibleAWSModule instance + ''' + self.module = module + self.client = self.module.client('medialive') + self._channel_placement_group = {} + self.changed = False + + @property + def channel_placement_group(self): + return self._channel_placement_group + + @channel_placement_group.setter + def channel_placement_group(self, channel_placement_group: Dict): + if channel_placement_group.get('response_metadata'): + del channel_placement_group['response_metadata'] + if channel_placement_group.get('id'): + channel_placement_group['channel_placement_group_id'] = channel_placement_group.get('id') + del channel_placement_group['id'] + self._channel_placement_group = channel_placement_group + + def do_create_channel_placement_group(self, params): + """ + Create a new MediaLive channel placement group + + Args: + params: Parameters for channel placement group creation + """ + allowed_params = ['cluster_id', 'nodes', 'name', 'request_id'] + + create_params = {k: v for k, v in params.items() if k in allowed_params and v} + create_params = snake_dict_to_camel_dict(create_params, capitalize_first=True) + + try: + response = self.client.create_channel_placement_group(**create_params) # type: ignore + self.channel_placement_group = camel_dict_to_snake_dict(response) + self.changed = True + except (ClientError, BotoCoreError) as e: # type: ignore + raise AnsibleAWSError( + message='Unable to create MediaLive Channel Placement Group', + exception=e + ) + + def do_update_channel_placement_group(self, params): + """ + Update a MediaLive channel placement group + + Args: + params: Parameters for channel placement group update + """ + if not params.get('channel_placement_group_id'): + raise AnsibleAWSError( + message='The channel_placement_group_id parameter is required during placement group update.') + + allowed_params = ['cluster_id', 'channel_placement_group_id', 'nodes', 'name'] + + current_params = {k: v for k, v in self.channel_placement_group.items() if k in allowed_params} + updated_params = {k: v for k, v in params.items() if k in allowed_params and v} + + # Check if params need updating + if not recursive_diff(current_params, updated_params): + return + + # Update nodes if provided + if params.get('nodes'): + try: + # Check if nodes need updating + current_nodes = self.channel_placement_group.get('nodes', []) + if set(current_nodes) != set(params.get('nodes')): + update_params = { + 'ChannelPlacementGroupId': params.get('channel_placement_group_id'), + 'ClusterId': params.get('cluster_id'), + 'Nodes': params.get('nodes') + } + + # Add name if provided + if params.get('name'): + update_params['Name'] = params.get('name') + + self.client.update_channel_placement_group(**update_params) # type: ignore + self.changed = True + + # Refresh placement group data + self.get_channel_placement_group_by_id( + params.get('channel_placement_group_id'), + params.get('cluster_id') + ) + except (ClientError, BotoCoreError) as e: # type: ignore + raise AnsibleAWSError( + message='Unable to update nodes for MediaLive Channel Placement Group', + exception=e + ) + # Update name if provided (and nodes not provided) + elif params.get('name') and self.channel_placement_group.get('name') != params.get('name'): + try: + update_params = { + 'ChannelPlacementGroupId': params.get('channel_placement_group_id'), + 'ClusterId': params.get('cluster_id'), + 'Name': params.get('name') + } + + self.client.update_channel_placement_group(**update_params) # type: ignore + self.changed = True + + # Refresh placement group data + self.get_channel_placement_group_by_id( + params.get('channel_placement_group_id'), + params.get('cluster_id') + ) + except (ClientError, BotoCoreError) as e: # type: ignore + raise AnsibleAWSError( + message='Unable to update name for MediaLive Channel Placement Group', + exception=e + ) + + def get_channel_placement_group_by_id(self, placement_group_id: str, cluster_id: str): + """ + Get a channel placement group by ID + + Args: + placement_group_id: The ID of the channel placement group to retrieve + cluster_id: The ID of the cluster + """ + try: + response = self.client.describe_channel_placement_group( # type: ignore + ChannelPlacementGroupId=placement_group_id, + ClusterId=cluster_id + ) + self.channel_placement_group = camel_dict_to_snake_dict(response) + except is_boto3_error_code('ResourceNotFoundException'): + self.channel_placement_group = {} + except (ClientError, BotoCoreError) as e: # type: ignore + raise AnsibleAWSError( + message='Unable to get MediaLive Channel Placement Group', + exception=e + ) + + def get_channel_placement_group_by_name(self, name: str, cluster_id: str): + """ + Get a channel placement group by name + + Args: + name: The name of the channel placement group to retrieve + cluster_id: The ID of the cluster + """ + try: + response = self.client.list_channel_placement_groups(ClusterId=cluster_id) # type: ignore + if response['ChannelPlacementGroups']: + found = [] + for cpg in response['ChannelPlacementGroups']: + if cpg['Name'] == name: + found.append(camel_dict_to_snake_dict(cpg)) + if len(found) > 1: + raise AnsibleAWSError(message='Found more than one Channel Placement Groups under the same name') + elif len(found) == 1: + self.channel_placement_group = camel_dict_to_snake_dict(found[0]) + except (ClientError, BotoCoreError) as e: # type: ignore + raise AnsibleAWSError( + message='Unable to get MediaLive Channel Placement Group', + exception=e + ) + + def delete_channel_placement_group(self, placement_group_id: str, cluster_id: str): + """ + Delete a MediaLive channel placement group + + Args: + placement_group_id: ID of the channel placement group to delete + cluster_id: ID of the cluster + """ + try: + self.client.delete_channel_placement_group( # type: ignore + ChannelPlacementGroupId=placement_group_id, + ClusterId=cluster_id + ) + self.changed = True + except is_boto3_error_code('ResourceNotFoundException'): + self.channel_placement_group = {} + except (ClientError, BotoCoreError) as e: # type: ignore + raise AnsibleAWSError( + message='Unable to delete MediaLive Channel Placement Group', + exception=e + ) + + def wait_for( + self, + want: Literal['channel_placement_group_unassigned', 'channel_placement_group_deleted'], + placement_group_id: str, + cluster_id: str, + wait_timeout = 60) -> None: + """ + Wait for a channel placement group to reach the desired state + + Args: + want: The desired state to wait for + placement_group_id: The ID of the channel placement group + cluster_id: The ID of the cluster + wait_timeout: Maximum time to wait in seconds + """ + try: + waiter = self.client.get_waiter(want) # type: ignore + config = { + 'Delay': min(5, wait_timeout), + 'MaxAttempts': wait_timeout // 5 + } + waiter.wait( + ChannelPlacementGroupId=placement_group_id, + ClusterId=cluster_id, + WaiterConfig=config + ) + except WaiterError as e: # type: ignore + raise AnsibleAWSError( + message=f'Timeout waiting for channel placement group {placement_group_id} to be {want.lower()}', + exception=e + ) + +def get_arg(arg:str, params:dict, spec:dict): + if arg in spec.keys(): + aliases = spec[arg].get('aliases', []) + for k, v in params.items(): + if k in [arg, *aliases] and v: + return v + +def main(): + """Main entry point for the module""" + argument_spec = dict( + id=dict(type='str', required=False, aliases=['channel_placement_group_id']), + cluster_id=dict(type='str', required=True), + nodes=dict(type='list', elements='str', required=False), + name=dict(type='str', required=False), + state=dict(type='str', default='present', choices=['present', 'absent']), + wait=dict(type='bool', default=True), + wait_timeout=dict(type='int', default=60), + ) + + module = AnsibleAWSModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=[('id', 'channel_placement_group_id', 'name')], + required_if=[ + ('state', 'absent', ['id', 'channel_placement_group_id'], True), + ], + ) + + # Extract module parameters + placement_group_id = get_arg('id', module.params, argument_spec) + cluster_id = get_arg('cluster_id', module.params, argument_spec) + state = get_arg('state', module.params, argument_spec) + nodes = get_arg('nodes', module.params, argument_spec) + name = get_arg('name', module.params, argument_spec) + wait = get_arg('wait', module.params, argument_spec) + wait_timeout = get_arg('wait_timeout', module.params, argument_spec) + + # Initialize the manager + manager = MediaLiveChannelPlacementGroupManager(module) + + # Find the placement group by ID if provided + if placement_group_id: + manager.get_channel_placement_group_by_id(placement_group_id, cluster_id) # type: ignore + elif name: + manager.get_channel_placement_group_by_name(name, cluster_id) # type: ignore + + # Do nothing in check mode + if module.check_mode: + module.exit_json(changed=True) + + # Handle present state + if state == 'present': + # Case update + if manager.channel_placement_group: + if not placement_group_id: + placement_group_id = manager.channel_placement_group.get('channel_placement_group_id') + update_params = { + 'channel_placement_group_id': placement_group_id, + 'cluster_id': cluster_id, + 'nodes': nodes, + 'name': name + } + + manager.do_update_channel_placement_group(update_params) + + manager.get_channel_placement_group_by_id(placement_group_id, cluster_id) # type: ignore + + # Case create + else: + create_params = { + 'cluster_id': cluster_id, + 'nodes': nodes, + 'name': name, + 'request_id': str(uuid.uuid4()) + } + + manager.do_create_channel_placement_group(create_params) + placement_group_id = manager.channel_placement_group.get('channel_placement_group_id') + + # Wait for the placement group to be created + if wait and placement_group_id: + manager.wait_for('channel_placement_group_unassigned', placement_group_id, cluster_id, wait_timeout) # type: ignore + manager.get_channel_placement_group_by_id(placement_group_id, cluster_id) # type: ignore + + # Handle absent state + elif state == 'absent': + if manager.channel_placement_group.get('state') != 'DELETED': + # Placement group exists, delete it + manager.delete_channel_placement_group(placement_group_id, cluster_id) # type: ignore + + # Wait for the placement group to be deleted if requested + if wait and placement_group_id: + manager.wait_for('channel_placement_group_deleted', placement_group_id, cluster_id, wait_timeout) # type: ignore + + module.exit_json(changed=manager.changed, channel_placement_group=manager.channel_placement_group) + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/medialive_channel_placement_group_info.py b/plugins/modules/medialive_channel_placement_group_info.py new file mode 100644 index 00000000000..a7f42877d75 --- /dev/null +++ b/plugins/modules/medialive_channel_placement_group_info.py @@ -0,0 +1,138 @@ +#!/usr/bin/python +# Copyright: (c) 2025, Your Name +# 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 + +DOCUMENTATION = r''' +--- +module: medialive_channel_placement_group_info +short_description: Get information about an AWS MediaLive Channel Placement Group +version_added: 10.1.0 +description: + - Get information about an AWS MediaLive Channel Placement Group. + - This module allows retrieving detailed information about a specific channel placement group. + - This module requires boto3 >= 1.35.17. +author: + - "David Teach" +options: + channel_placement_group_id: + description: + - The ID of the Channel Placement Group. + required: true + type: str + cluster_id: + description: + - The ID of the cluster. + required: true + type: str + region: + description: + - The AWS region to use. + required: true + type: str + +extends_documentation_fragment: + - amazon.aws.common.modules + - amazon.aws.region.modules + - amazon.aws.boto3 +''' + +EXAMPLES = r''' +# Get information about a specific channel placement group +- name: Get channel placement group info + aws_medialive_channel_placement_group_info: + channel_placement_group_id: "cpg-12345" + cluster_id: "cluster-67890" + region: "us-west-2" +''' + +RETURN = r''' +placement_group: + description: The channel placement group information + type: dict + returned: always + contains: + arn: + description: The ARN of the channel placement group + type: str + returned: always + channel_placement_group_id: + description: The ID of the channel placement group + type: str + returned: always + cluster_id: + description: The ID of the cluster + type: str + returned: always + created: + description: When the channel placement group was created + type: str + returned: always + state: + description: The state of the channel placement group + type: str + returned: always +''' + +try: + from botocore.exceptions import ClientError, BotoCoreError +except ImportError: + pass # Handled by AnsibleAWSModule + +from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict +from ansible_collections.amazon.aws.plugins.module_utils.core import AnsibleAWSModule +from ansible_collections.amazon.aws.plugins.module_utils.ec2 import AWSRetry + + +def get_channel_placement_group(client, module): + """ + Get the channel placement group information + """ + cpgs = [] + try: + if not module.params['channel_placement_group_id']: + response = client.list_channel_placement_groups( + ClusterId=module.params['cluster_id'] + ) + if response['ChannelPlacementGroups']: + cpgs = response['ChannelPlacementGroups'] + else: + response = client.describe_channel_placement_group( + ChannelPlacementGroupId=module.params['channel_placement_group_id'], + ClusterId=module.params['cluster_id'] + ) + if response.get('Arn', None): + cpgs.append(response) + if len(cpgs) == 0: + return cpgs + results = [camel_dict_to_snake_dict(cpg) for cpg in cpgs] + return results + except client.exceptions.NotFoundException: + return cpgs + except (ClientError, BotoCoreError) as e: # type: ignore + module.fail_json_aws(e, msg="Failed to get channel placement group information") + + +def main(): + argument_spec = dict( + cluster_id=dict(required=True, type='str'), + channel_placement_group_id=dict(required=False, type='str'), + ) + + module = AnsibleAWSModule( + argument_spec=argument_spec, + supports_check_mode=True + ) + + try: + client = module.client('medialive', retry_decorator=AWSRetry.exponential_backoff()) + cpgs = get_channel_placement_group(client, module) + module.exit_json(changed=False, channel_placement_groups=cpgs) + except (ClientError, BotoCoreError) as e: # type: ignore + module.fail_json_aws(e, msg='Failed to connect to AWS') + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/medialive_cluster.py b/plugins/modules/medialive_cluster.py new file mode 100644 index 00000000000..a46114440b2 --- /dev/null +++ b/plugins/modules/medialive_cluster.py @@ -0,0 +1,507 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r""" +module: medialive_cluster +short_description: Manage AWS MediaLive Anywhere clusters +version_added: 10.1.0 +description: + - A module for creating, updating and deleting AWS MediaLive Anywhere clusters. + - This module requires boto3 >= 1.35.17. +author: + - Sergey Papyan (@r363x) +options: + id: + description: + - The ID of the cluster. + - Exactly one of I(id) or I(name) must be provided. + required: false + type: str + aliases: ['cluster_id'] + name: + description: + - The name of the cluster. + - Exactly one of I(id) or I(name) must be provided. + required: false + type: str + aliases: ['cluster_name'] + state: + description: + - Create/update or remove the cluster. + required: false + choices: ['present', 'absent'] + default: 'present' + type: str + cluster_type: + description: + - The hardware type for the cluster. + - Currently only 'ON_PREMISES' is supported. + - Required when creating a new cluster. + type: str + required: false + default: 'ON_PREMISES' + choices: ['ON_PREMISES'] + instance_role_arn: + description: + - The ARN of the IAM role for the Node in this Cluster. + - The role must include all the operations that you expect these Nodes to perform. + - Required when creating a new cluster. + type: str + required: false + network_settings: + description: + - Network settings that connect the Nodes in the Cluster to one or more of the Networks. + - Required when creating a new cluster. + type: dict + required: false + suboptions: + default_route: + description: + - The network interface that is the default route for traffic to and from the node. + - This should match one of the logical interface names defined in interface_mappings. + type: str + required: true + interface_mappings: + description: + - An array of interface mapping objects for this Cluster. + - Each mapping logically connects one interface on the nodes with one Network. + type: list + elements: dict + required: true + suboptions: + logical_interface_name: + description: + - The logical name for one interface that handles a specific type of traffic. + type: str + required: true + network_id: + description: + - The ID of the network to connect to the specified logical interface name. + type: str + required: true + wait: + description: + - Whether to wait for the cluster to reach the desired state. + - When I(state=present), wait for the cluster to reach the ACTIVE state. + - When I(state=absent), wait for the cluster to be deleted. + type: bool + required: false + default: true + wait_timeout: + description: + - The maximum time in seconds to wait for the cluster to reach the desired state. + - Defaults to 600 seconds. + type: int + required: false + default: 600 + +extends_documentation_fragment: + - amazon.aws.common.modules + - amazon.aws.region.modules + - amazon.aws.boto3 +""" + +EXAMPLES = r""" +# Create a MediaLive Anywhere cluster +- community.aws.medialive_cluster: + name: 'ExampleCluster' + state: present + cluster_type: 'ON_PREMISES' + instance_role_arn: 'arn:aws:iam::123456789012:role/MediaLiveAnywhereNodeRole' + network_settings: + default_route: 'management' + interface_mappings: + - logical_interface_name: 'management' + network_id: 'network-1234abcd' + - logical_interface_name: 'input' + network_id: 'network-5678efgh' + tags: + Environment: 'Production' + Project: 'MediaLive' + +# Delete a MediaLive Anywhere cluster +- community.aws.medialive_cluster: + name: 'ExampleCluster' + state: absent +""" + +RETURN = r""" +cluster: + description: The details of the cluster + returned: success + type: dict + contains: + arn: + description: The ARN of the cluster. + type: str + returned: success + example: "arn:aws:medialive:us-east-1:123456789012:cluster/1234abcd-12ab-34cd-56ef-1234567890ab" + channel_ids: + description: The IDs of channels associated with the cluster. + type: list + elements: str + returned: success + example: ["channel-1234abcd"] + cluster_type: + description: The hardware type for the cluster. + type: str + returned: success + example: "ON_PREMISES" + id: + description: The ID of the cluster. + type: str + returned: success + example: "1234abcd-12ab-34cd-56ef-1234567890ab" + instance_role_arn: + description: The ARN of the IAM role for the Node in this Cluster. + type: str + returned: success + example: "arn:aws:iam::123456789012:role/MediaLiveAnywhereNodeRole" + name: + description: The name of the cluster. + type: str + returned: success + example: "ExampleCluster" + network_settings: + description: Network settings that connect the Nodes in the Cluster to Networks. + type: dict + returned: success + contains: + default_route: + description: The network interface that is the default route for traffic. + type: str + returned: success + example: "management" + interface_mappings: + description: An array of interface mapping objects for this Cluster. + type: list + elements: dict + returned: success + contains: + logical_interface_name: + description: The logical name for one interface. + type: str + returned: success + example: "management" + network_id: + description: The ID of the network connected to the interface. + type: str + returned: success + example: "network-1234abcd" + state: + description: The state of the cluster. + type: str + returned: success + example: "ACTIVE" + tags: + description: The tags assigned to the cluster. + type: dict + returned: success + example: {"Environment": "Production", "Project": "MediaLive"} +""" + +import uuid +from typing import Dict, Literal + +try: + from botocore.exceptions import WaiterError, ClientError, BotoCoreError +except ImportError: + pass # caught by AnsibleAWSModule + +from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict, snake_dict_to_camel_dict, recursive_diff +from ansible_collections.amazon.aws.plugins.module_utils.core import AnsibleAWSModule +from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_code +from ansible_collections.amazon.aws.plugins.module_utils.exceptions import AnsibleAWSError + + +class MediaLiveClusterManager: + """Manage AWS MediaLive Anywhere clusters""" + + def __init__(self, module: AnsibleAWSModule): + """ + Initialize the MediaLiveClusterManager + + Args: + module: AnsibleAWSModule instance + """ + self.module = module + self.client = module.client('medialive') + self._cluster = {} + self.changed = False + + @property + def cluster(self): + return self._cluster + + @cluster.setter + def cluster(self, cluster: Dict): + cluster = camel_dict_to_snake_dict(cluster) + if cluster.get('response_metadata'): + del cluster['response_metadata'] + if cluster.get('id'): + cluster['cluster_id'] = cluster.get('id') + del cluster['id'] + self._cluster = cluster + + def do_create_cluster(self, params): + """ + Create a new MediaLive cluster + + Args: + params: Parameters for cluster creation + """ + allowed_params = ['cluster_type', 'instance_role_arn', 'name', 'network_settings', 'request_id'] + required_params = ['instance_role_arn', 'name', 'network_settings'] + + for param in required_params: + if not params.get(param): + raise AnsibleAWSError(message=f'The {param} parameter is required when creating a new cluster') + + create_params = { k: v for k, v in params.items() if k in allowed_params and v } + create_params = snake_dict_to_camel_dict(create_params, capitalize_first=True) + + try: + response = self.client.create_cluster(**create_params) # type: ignore + self.cluster = camel_dict_to_snake_dict(response) + self.changed = True + except (ClientError, BotoCoreError) as e: # type: ignore + raise AnsibleAWSError( + message='Unable to create Medialive Cluster', + exception=e + ) + + def do_update_cluster(self, params): + """ + Update a new MediaLive cluster + + Args: + params: Parameters for cluster update + """ + if not params.get('cluster_id'): + raise AnsibleAWSError(message='The cluster_id parameter is required during cluster update.') + + allowed_params = ['cluster_id', 'name', 'network_settings'] + + + current_params = { k: v for k, v in self.cluster.items() if k in allowed_params } + update_params = { k: v for k, v in params.items() if k in allowed_params and v } + + # Short circuit + if not recursive_diff(current_params, update_params): + return + + update_params = snake_dict_to_camel_dict(update_params, capitalize_first=True) + + try: + response = self.client.update_cluster(**update_params) # type: ignore + self.cluster = camel_dict_to_snake_dict(response) + self.changed = True + except (ClientError, BotoCoreError) as e: # type: ignore + raise AnsibleAWSError( + message='Unable to update Medialive Cluster', + exception=e + ) + + def get_cluster_by_name(self, name: str): + """ + Find a cluster by name + + Args: + name: The name of the cluster to find + """ + try: + paginator = self.client.get_paginator('list_clusters') # type: ignore + found = [] + for page in paginator.paginate(): + for cluster in page.get('Clusters', []): + if cluster.get('Name') == name: + found.append(cluster.get('Id')) + if len(found) > 1: + raise AnsibleAWSError(message='Found more than one Clusters under the same name') + elif len(found) == 1: + self.get_cluster_by_id(found[0]) + + except (ClientError, BotoCoreError) as e: # type: ignore + raise AnsibleAWSError( + message='Unable to get MediaLive Cluster', + exception=e + ) + + def get_cluster_by_id(self, id: str): + """ + Get a cluster by ID + + Args: + id: The ID of the cluster to retrieve + """ + try: + self.cluster = self.client.describe_cluster(ClusterId=id) # type: ignore + return True + except is_boto3_error_code('ResourceNotFoundException'): + self.cluster = {} + + def delete_cluster(self, cluster_id: str): + """ + Delete a MediaLive cluster + + Args: + cluster_id: ID of the cluster to delete + """ + try: + self.client.delete_cluster(ClusterId=cluster_id) # type: ignore + self.changed = True + except is_boto3_error_code('ResourceNotFoundException'): + self.cluster = {} + except (ClientError, BotoCoreError) as e: # type: ignore + raise AnsibleAWSError( + message='Unable to delete Medialive Cluster', + exception=e + ) + + def wait_for( + self, + want: Literal['cluster_created', 'cluster_deleted'], + cluster_id: str, + wait_timeout = 60 + ): + """ + Invoke one of the custom waiters and wait + + Args: + want: the name of the waiter + cluster_id: the ID of the cluster + wait_timeout: the maximum amount of time to wait in seconds (default: 60) + """ + + try: + waiter = self.client.get_waiter(want) # type: ignore + config = { + 'Delay': min(5, wait_timeout), + 'MaxAttempts': wait_timeout // 5 + } + waiter.wait( + ClusterId=cluster_id, + WaiterConfig=config + ) + except WaiterError as e: # type: ignore + raise AnsibleAWSError( + message=f'Timeout waiting for cluster state to become {cluster_id}', + exception=e + ) + +def get_arg(arg:str, params:dict, spec:dict): + if arg in spec.keys(): + aliases = spec[arg].get('aliases', []) + for k, v in params.items(): + if k in [arg, *aliases] and v: + return v + +def main(): + """Main entry point for the module""" + argument_spec = dict( + id=dict(type='str', required=False, aliases=['cluster_id']), + name=dict(type='str', required=False, aliases=['cluster_name']), + state=dict(type='str', default='present', choices=['present', 'absent']), + cluster_type=dict(type='str', required=False, default='ON_PREMISES', choices=['ON_PREMISES']), + instance_role_arn=dict(type='str', required=False), + network_settings=dict( + type='dict', + required=False, + options=dict( + default_route=dict(type='str', required=True), + interface_mappings=dict( + type='list', + elements='dict', + required=True, + options=dict( + logical_interface_name=dict(type='str', required=True), + network_id=dict(type='str', required=True), + ) + ) + ) + ), + wait=dict(type='bool', default=True), + wait_timeout=dict(type='int', default=600), + ) + + module = AnsibleAWSModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=[('id', 'cluster_id', 'name', 'cluster_name')] + ) + + # Extract module parameters + cluster_id = get_arg('id', module.params, argument_spec) + cluster_name = get_arg('name', module.params, argument_spec) + state = get_arg('state', module.params, argument_spec) + cluster_type = get_arg('cluster_type', module.params, argument_spec) + instance_role_arn = get_arg('instance_role_arn', module.params, argument_spec) + network_settings = get_arg('network_settings', module.params, argument_spec) + wait = get_arg('wait', module.params, argument_spec) + wait_timeout = get_arg('wait_timeout', module.params, argument_spec) + + # Initialize the manager + manager = MediaLiveClusterManager(module) + + # Find the cluster by ID or name + # Update manager.cluster with the details + if cluster_id: + manager.get_cluster_by_id(cluster_id) + elif cluster_name: + manager.get_cluster_by_name(cluster_name) + cluster_id = manager.cluster.get('cluster_id') + + # Do nothing in check mode + if module.check_mode: + module.exit_json(changed=True) + + # Handle present state + if state == 'present': + + # Case update + if manager.cluster: + update_params = { + 'name': cluster_name, + 'network_settings': network_settings, + 'cluster_id': cluster_id + } + + manager.do_update_cluster(update_params) + + # Case create + else: + create_params = { + 'name': cluster_name, + 'cluster_type': cluster_type, + 'instance_role_arn': instance_role_arn, + 'network_settings': network_settings, + 'cluster_id': cluster_id, + 'request_id': str(uuid.uuid4()) + } + + manager.do_create_cluster(create_params) + cluster_id = manager.cluster.get('cluster_id') + + # Wait for the cluster to be created + if wait and cluster_id: + manager.wait_for('cluster_created', cluster_id, wait_timeout) # type: ignore + manager.get_cluster_by_id(cluster_id) + + # Handle absent state + elif state == 'absent': + if manager.cluster: + # Cluster exists, delete it + cluster_id = manager.cluster.get('cluster_id') + manager.delete_cluster(cluster_id) # type: ignore + + # Wait for the cluster to be deleted if requested + if wait and cluster_id: + manager.wait_for('cluster_deleted', cluster_id, wait_timeout) # type: ignore + + module.exit_json(changed=manager.changed, cluster=manager.cluster) + +if __name__ == '__main__': + main() diff --git a/plugins/modules/medialive_cluster_info.py b/plugins/modules/medialive_cluster_info.py new file mode 100644 index 00000000000..2a7dcf094f4 --- /dev/null +++ b/plugins/modules/medialive_cluster_info.py @@ -0,0 +1,228 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r""" +module: medialive_cluster +short_description: Gather MediaLive Anywhere cluster info +version_added: 10.1.0 +description: + - Get details about a AWS MediaLive Anywhere cluster. + - This module requires boto3 >= 1.35.17. +author: + - Sergey Papyan (@r363x) +options: + id: + description: + - The ID of the cluster. + - Exactly one of I(id) or I(name) must be provided. + required: false + type: str + aliases: ['cluster_id'] + name: + description: + - The name of the cluster. + - Exactly one of I(id) or I(name) must be provided. + required: false + type: str + aliases: ['cluster_name'] + +extends_documentation_fragment: + - amazon.aws.common.modules + - amazon.aws.region.modules + - amazon.aws.boto3 +""" + +EXAMPLES = r""" +# Find a MediaLive Anywhere cluster by ID +- community.aws.medialive_cluster_info: + id: '1234567' + register: found_cluster + +# Find a MediaLive Anywhere cluster by name +- community.aws.medialive_cluster_info: + name: 'ExampleCluster' + register: found_cluster +""" + +RETURN = r""" +cluster: + description: The details of the cluster + returned: success + type: dict + contains: + arn: + description: The ARN of the cluster. + type: str + returned: success + example: "arn:aws:medialive:us-east-1:123456789012:cluster/1234abcd-12ab-34cd-56ef-1234567890ab" + channel_ids: + description: The IDs of channels associated with the cluster. + type: list + elements: str + returned: success + example: ["channel-1234abcd"] + cluster_type: + description: The hardware type for the cluster. + type: str + returned: success + example: "ON_PREMISES" + id: + description: The ID of the cluster. + type: str + returned: success + example: "1234abcd-12ab-34cd-56ef-1234567890ab" + instance_role_arn: + description: The ARN of the IAM role for the Node in this Cluster. + type: str + returned: success + example: "arn:aws:iam::123456789012:role/MediaLiveAnywhereNodeRole" + name: + description: The name of the cluster. + type: str + returned: success + example: "ExampleCluster" + network_settings: + description: Network settings that connect the Nodes in the Cluster to Networks. + type: dict + returned: success + contains: + default_route: + description: The network interface that is the default route for traffic. + type: str + returned: success + example: "management" + interface_mappings: + description: An array of interface mapping objects for this Cluster. + type: list + elements: dict + returned: success + contains: + logical_interface_name: + description: The logical name for one interface. + type: str + returned: success + example: "management" + network_id: + description: The ID of the network connected to the interface. + type: str + returned: success + example: "network-1234abcd" + state: + description: The state of the cluster. + type: str + returned: success + example: "ACTIVE" +""" + +from typing import Dict + +from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict +from ansible_collections.amazon.aws.plugins.module_utils.core import AnsibleAWSModule +from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_code +from ansible_collections.amazon.aws.plugins.module_utils.exceptions import AnsibleAWSError + + +class MediaLiveClusterGetter: + '''Look up AWS MediaLive Anywhere clusters''' + + def __init__(self, module: AnsibleAWSModule): + ''' + Initialize the MediaLiveClusterGetter + + Args: + module: AnsibleAWSModule instance + ''' + self.module = module + self.client = self.module.client('medialive') + self._cluster = {} + + @property + def cluster(self): + return self._cluster + + @cluster.setter + def cluster(self, cluster: Dict): + cluster = camel_dict_to_snake_dict(cluster) + if cluster.get('response_metadata'): + del cluster['response_metadata'] + if cluster.get('id'): + cluster['cluster_id'] = cluster.get('id') + del cluster['id'] + self._cluster = cluster + + def get_cluster_by_name(self, name: str): + """ + Find a cluster by name + + Args: + name: The name of the cluster to find + """ + try: + paginator = self.client.get_paginator('list_clusters') # type: ignore + for page in paginator.paginate(): + found = [] + for cluster in page.get('Clusters', []): + if cluster.get('Name') == name: + found.append(cluster.get('Id')) + if len(found) > 1: + raise AnsibleAWSError(message='Found more than one Clusters under the same name') + elif len(found) == 1: + self.get_cluster_by_id(found[0]) + except (ClientError, BotoCoreError) as e: # type: ignore + raise AnsibleAWSError( + message='Unable to get MediaLive Cluster', + exception=e + ) + + def get_cluster_by_id(self, id: str): + """ + Get a cluster by ID + + Args: + id: The ID of the cluster to retrieve + """ + try: + self.cluster = self.client.describe_cluster(ClusterId=id) # type: ignore + except is_boto3_error_code('ResourceNotFoundException'): + self.cluster = {} + +def get_arg(arg:str, params:dict, spec:dict): + if arg in spec.keys(): + aliases = spec[arg].get('aliases', []) + for k, v in params.items(): + if k in [arg, *aliases] and v: + return v + +def main(): + """Main entry point for the module""" + argument_spec = dict( + id=dict(type='str', required=False, aliases=['cluster_id']), + name=dict(type='str', required=False, aliases=['cluster_name']), + ) + + module = AnsibleAWSModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=[('id', 'cluster_id', 'name', 'cluster_name')] + ) + + # Extract module parameters + cluster_id = get_arg('id', module.params, argument_spec) + cluster_name = get_arg('name', module.params, argument_spec) + + # Initialize the manager + getter = MediaLiveClusterGetter(module) + + # Find the cluster by ID or name + if cluster_id: + getter.get_cluster_by_id(cluster_id) + elif cluster_name: + getter.get_cluster_by_name(cluster_name) + + module.exit_json(changed=False, cluster=getter.cluster) + +if __name__ == '__main__': + main() diff --git a/plugins/modules/medialive_input.py b/plugins/modules/medialive_input.py new file mode 100644 index 00000000000..1b24a4c045c --- /dev/null +++ b/plugins/modules/medialive_input.py @@ -0,0 +1,952 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r""" +module: medialive_input +short_description: Manage AWS MediaLive Anywhere inputs +version_added: 10.1.0 +description: + - A module for creating, updating and deleting AWS MediaLive inputs. + - Requires boto3 >= 1.37.34 +author: + - Sergey Papyan (@r363x) +options: + name: + description: + - Name of the input + - Mutually exclusive with O(id) + type: str + id: + description: + - ID of the input + - Mutually exclusive with O(name) + type: str + destinations: + description: + - A list of destination settings for PUSH type inputs + - Required if O(type=UDP_PUSH) + - Required if O(type=RTP_PUSH) + - Required if O(type=RTMP_PUSH) + type: list + elements: dict + suboptions: + stream_name: + description: + - A unique name for the location the RTMP stream is being pushed to + required: true + type: str + network: + description: + - The ID of the attached network + - Required if O(input_network_location=ON_PREMISES) + type: str + network_routes: + description: + - The route of the input on the customer local network + - Required if O(input_network_location=ON_PREMISES) + type: list + elements: dict + suboptions: + cidr: + description: + - The CIDR of the route + required: true + type: str + gateway: + description: + - An optional gateway for the route + type: str + static_ip_address: + description: + - IP address of the input on the customer local network + - Optional if O(input_network_location=ON_PREMISES) + type: str + input_devices: + description: + - Settings for the input devices + - Required if O(type=INPUT_DEVICE) + type: list + elements: dict + suboptions: + id: + description: + - The unique ID for the device + type: str + required: true + input_security_groups: + description: + - A list of security groups referenced by IDs to attach to the input + type: list + elements: str + media_connect_flows: + description: + - A list of the MediaConnect Flows that you want to use in this input + - You can specify as few as one Flow and presently, as many as two + - | + The only requirement is when you have more than one is that + each Flow is in a separate Availability Zone as this ensures + your EML input is redundant to AZ issues. + - Required if O(type=MEDIACONNECT) + type: list + elements: dict + suboptions: + flow_arn: + description: + - The ARN of the MediaConnect Flow that you want to use as a source + type: str + required: true + role_arn: + description: + - The Amazon Resource Name (ARN) of the role this input assumes during and after creation + - Required if O(vpc) + type: str + sources: + description: + - The source URLs for PULL type inputs + - Two sources must be provided + - If identical, the input_class of the resulting Input will be SINGLE_PIPELINE + - If not idential, the input_class of the resulting Input will be STANDARD + - Required if O(type=RTMP_PULL) + - Required if O(type=URL_PULL) + - Required if O(type=MP4_FILE) + - Required if O(type=TS_FILE) + type: list + elements: dict + suboptions: + password_param: + description: + - The key used to extract the password from SSM Parameter store + type: str + url: + description: + - This represents the customer’s source URL where stream is pulled from + required: true + type: str + username: + description: + - The username for the input source + type: str + type: + description: + - The type of input to create + - Required if O(state=present) + type: str + choices: + - UDP_PUSH + - RTP_PUSH + - RTMP_PUSH + - RTMP_PULL + - URL_PULL + - MP4_FILE + - MEDIACONNECT + - INPUT_DEVICE + - AWS_CDI + - TS_FILE + - SRT_CALLER + - MULTICAST + - SMPTE_2110_RECEIVER_GROUP + - SDI + vpc: + description: + - Settings for a private VPC Input + - When this property is specified, the input destination addresses will be created in a VPC rather than with public Internet addresses + - Mutually exclusive with O(input_security_groups) + type: dict + suboptions: + security_group_ids: + description: + - A list of up to 5 EC2 VPC security group IDs to attach to the Input VPC network interfaces + - If none are specified then the VPC default security group will be used + type: list + elements: str + subnet_ids: + description: + - A list of 2 VPC subnet IDs from the same VPC + - Subnet IDs must be mapped to two unique availability zones (AZ) + type: list + required: true + elements: str + srt_settings: + description: + - The settings associated with an SRT input + type: dict + suboptions: + srt_caller_sources: + description: + - A list of connection configurations for sources that use SRT as the connection protocol + - In terms of establishing the connection, MediaLive is always the caller and the upstream system is always the listener + - In terms of transmission of the source content, MediaLive is always the receiver and the upstream system is always the sender + type: list + elements: dict + suboptions: + decryption: + description: + - Decryption configuration + - Required only if the content is encrypted + type: dict + suboptions: + algorithm: + description: + - The algorithm used to encrypt content + type: str + passphrase_secret_arn: + description: + - The ARN for the secret in Secrets Manager + - The secret holds the passphrase that MediaLive will use to decrypt the source content + - The secret must be created in advance + type: str + minimum_latency: + description: + - The preferred latency (in milliseconds) for implementing packet loss and recovery + - Packet recovery is a key feature of SRT + - Obtain this value from the operator at the upstream system + type: int + srt_listener_address: + description: + - The IP address at the upstream system (the listener) that MediaLive (the caller) will connect to + type: str + srt_listener_port: + description: + - The port at the upstream system (the listener) that MediaLive (the caller) will connect to + type: str + stream_id: + description: + - | + This value is required if the upstream system uses this identifier because without it, + the SRT handshake between MediaLive (the caller) and the upstream system (the listener) might fail + type: str + input_network_location: + description: + - The location of this input + - AWS for an input existing in the AWS Cloud + - ON_PREMISES for an input in a customer network + - Required if O(type=RTP) + - Required if O(type=RTMP_PUSH) + - Must be AWS if O(type=URL_PULL) + - Must be ON_PREMISES if O(type=SDI) + type: str + choices: ['AWS', 'ON_PREMISES'] + multicast_settings: + description: + - Multicast Input settings + - Required if O(type=MULTICAST) + type: dict + suboptions: + sources: + description: + - List of pairs of multicast urls and source ip addresses that make up a multicast source + type: list + elements: dict + suboptions: + source_ip: + description: + - This represents the ip address of the device sending the multicast stream + type: str + url: + description: + - This represents the ip address of the device sending the multicast stream + type: str + required: true + smpte_2110_receiver_group_settings: + description: + - Settings to identify the stream sources + - Required if O(type=SMPTE_2110_RECEIVER_GROUP) + type: dict + suboptions: + smpte_2110_receiver_groups: + description: + - List of receiver groups + - A receiver group is a collection of video, audio, and ancillary streams that you want to group together and attach to one input + type: list + elements: dict + suboptions: + sdp_settings: + description: + - The single dict of settings that identify the video, audio, and ancillary streams for this receiver group + type: dict + suboptions: + ancillary_sdps: + description: + - A list of input SDP locations + - Each item in the list specifies the SDP file and index for one ancillary SMPTE 2110 stream + - Each stream encapsulates one captions stream (out of any number you can include) or the single SCTE 35 stream that you can include + type: list + elements: dict + suboptions: + media_index: + description: + - The index of the media stream in the SDP file for one SMPTE 2110 stream + type: int + sdp_url: + description: + - The URL of the SDP file for one SMPTE 2110 stream + type: str + audio_sdps: + description: + - A list of input SDP locations + - Each item in the list specifies the SDP file and index for one audio SMPTE 2110 stream in a receiver group + type: list + elements: dict + suboptions: + media_index: + description: + - The index of the media stream in the SDP file for one SMPTE 2110 stream + type: int + sdp_url: + description: + - The URL of the SDP file for one SMPTE 2110 stream + type: str + video_sdp: + description: + - A dict that specifies the SDP file and index for the single video SMPTE 2110 stream for this 2110 input + type: dict + suboptions: + media_index: + description: + - The index of the media stream in the SDP file for one SMPTE 2110 stream + type: int + sdp_url: + description: + - The URL of the SDP file for one SMPTE 2110 stream + type: str + sdi_sources: + description: + - SDI Sources for this Input + - Must contain a single item - the ID of the SDI source + - Required if O(type=SDI) + type: list + elements: str + state: + description: + - Create/update or remove the input + choices: ['present', 'absent'] + default: 'present' + type: str + wait: + description: + - Whether to wait for the input to reach the desired state + - When I(state=present), wait for the input to reach the DETACHED state + - When I(state=absent), wait for the input to reach the DELETED state + type: bool + default: true + wait_timeout: + description: + - The maximum time in seconds to wait for the input to reach the desired state + - Defaults to 60 seconds + type: int + default: 60 + +extends_documentation_fragment: + - amazon.aws.common.modules + - amazon.aws.region.modules + - amazon.aws.boto3 + - amazon.aws.tags +""" + +EXAMPLES = r""" +""" + +RETURN = r""" +""" + +from typing import Dict, List, Literal + +try: + from botocore.exceptions import WaiterError, ClientError, BotoCoreError +except ImportError: + pass # caught by AnsibleAWSModule + +from ansible.module_utils.common.dict_transformations import snake_dict_to_camel_dict, camel_dict_to_snake_dict, recursive_diff +from ansible_collections.amazon.aws.plugins.module_utils.core import AnsibleAWSModule +from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_code +from ansible_collections.amazon.aws.plugins.module_utils.tagging import compare_aws_tags +from ansible_collections.amazon.aws.plugins.module_utils.exceptions import AnsibleAWSError + + +class MediaLiveInputManager: + """Manage AWS MediaLive Anywhere inputs""" + + def __init__(self, module: AnsibleAWSModule): + """ + Initialize the MediaLiveInputManager + + Args: + module: AnsibleAWSModule instance + """ + self.module = module + self.client = self.module.client('medialive') + self._input = {} + self.changed = False + + @property + def input(self): + """Simple getter for input""" + return self._input + + @input.setter + def input(self, input: Dict): + """Setter for input that takes care of normalizing raw API responses""" + tags = input.get('Tags') # To preserve case in tag keys + input = camel_dict_to_snake_dict(input) + if 'response_metadata' in input.keys(): + del input['response_metadata'] + if input.get('id'): + input['input_id'] = input.get('id') + del input['id'] + if tags: + input['tags'] = tags + self._input = input + + def validate_sdi_source(self, sources: List[str], check_use=False): + """ + Validates the following: + * the list of sdi_sources provided by the user contains only a single item + * the SDI Source exists + * if check_use=True then makes sure the state of the SDI Source is not IN_USE + """ + # Though still undocumented, if you pass more than one SDI Input Source ID + # to the CreateInput API you get the following BadRequest error + # "The SDI sources for an SDI input must specify exactly one SDI source" + if len(sources) != 1: + raise AnsibleAWSError(message='The sdi_sources list must contain a single element') + + # Make sure the SDI source exists and is in IDLE state + try: + response = self.client.describe_sdi_source(SdiSourceId=sources[0]) # type: ignore + if check_use and response['SdiSource']['State'] == 'IN_USE': + raise AnsibleAWSError(message='The provided sdi_source is already in use') + + except is_boto3_error_code('ResourceNotFoundException'): + raise AnsibleAWSError(message='The provided sdi_source does not exist') + + def get_input_by_name(self, name: str): + """ + Find a input by name + + Args: + name: The name of the input to find + """ + + try: + paginator = self.client.get_paginator('list_inputs') # type: ignore + for page in paginator.paginate(): + for input in page.get('Inputs', []): + if input.get('Name') == name: + self.get_input_by_id(input.get('Id')) + return + except (ClientError, BotoCoreError) as e: # type: ignore + raise AnsibleAWSError( + message='Unable to get Medialive Input', + exception=e + ) + + def get_input_by_id(self, input_id: str): + """ + Get an input by ID + + Args: + input_id: The ID of the input to retrieve + """ + try: + self.input = self.client.describe_input(InputId=input_id) # type: ignore + except is_boto3_error_code('ResourceNotFoundException'): + self.input = {} + except (ClientError, BotoCoreError) as e: # type: ignore + raise AnsibleAWSError( + message='Unable to get Medialive Input', + exception=e + ) + + def wait_for(self, + want: Literal['input_attached','input_deleted','input_detached'], + input_id: str, + wait_timeout: int = 60): + """ + Wait for an Input to reach the wanted state + + Args: + want: the waiter to invoke + input_id: the ID of the Input + wait_timeout: the maximum amount of time to wait in seconds (default: 60) + """ + + try: + waiter = self.client.get_waiter(want) # type: ignore + config = { + 'Delay': min(5, wait_timeout), + 'MaxAttempts': wait_timeout // 5 + } + waiter.wait( + InputId=input_id, + WaiterConfig=config + ) + except WaiterError as e: # type: ignore + raise AnsibleAWSError( + message=f'Timeout waiting for Input {input_id}', + exception=e + ) + + def do_create_input(self, params): + """ + Create a new MediaLive input + + Args: + params: Parameters for input creation + """ + allowed_params = [ + 'destinations', + 'input_devices', + 'input_security_groups', + 'media_connect_flows', + 'name', + 'role_arn', + 'sources', + 'type', + 'vpc', + 'srt_settings', + 'input_network_location', + 'multicast_settings', + 'smpte_2110_receiver_group_settings', + 'sdi_sources', + 'tags' + ] + + create_params = { k: v for k, v in params.items() if k in allowed_params and v } + + # Do some extra validation + sdi_sources = create_params.get('sdi_sources') + if sdi_sources: + self.validate_sdi_source(sdi_sources) + + tags = create_params.get('tags') # To preserve case in tag keys + create_params = snake_dict_to_camel_dict(create_params, capitalize_first=True) + + if tags and create_params: + create_params['Tags'] = tags + + try: + self.input = self.client.create_input(**create_params)['Input'] # type: ignore + self.changed = True + except (ClientError, BotoCoreError) as e: # type: ignore + raise AnsibleAWSError( + message='Unable to create Medialive Input', + exception=e + ) + + def do_update_input(self, params): + """ + Update a new MediaLive input + + Args: + params: Parameters for input update + """ + if not params.get('input_id'): + raise AnsibleAWSError(message='The input_id parameter is required during input update.') + + tags = params.get('tags') + purge_tags = params.get('purge_tags') + del params['tags'] + del params['purge_tags'] + + allowed_params = [ + 'destinations', + 'input_devices', + 'input_id', + 'input_security_groups', + 'media_connect_flows', + 'name', + 'role_arn', + 'sources', + 'srt_settings', + 'multicast_settings', + 'smpte_2110_receiver_group_settings', + 'sdi_sources' + ] + + update_params = { k: v for k, v in params.items() if k in allowed_params and v } + + current_params = {} + for k, v in self.input.items(): + if k in allowed_params and k in update_params: + current_params[k] = v + + try: + if recursive_diff(current_params, update_params): + update_params = snake_dict_to_camel_dict(update_params, capitalize_first=True) + self.input = self.client.update_input(**update_params)['Input'] # type: ignore + self.changed = True + if tags and self._update_tags(tags, purge_tags): + self.input = self.get_input_by_id(self.input['input_id']) # type: ignore + self.changed = True + + except (ClientError, BotoCoreError) as e: # type: ignore + raise AnsibleAWSError( + message='Unable to update Medialive Input', + exception=e + ) + + def delete_input(self, input_id: str): + """ + Delete a MediaLive input + + Args: + input_id: ID of the input to delete + """ + try: + self.client.delete_input(InputId=input_id) # type: ignore + self.input = {} + self.changed = True + except is_boto3_error_code('ResourceNotFoundException'): + self.input = {} + except (ClientError, BotoCoreError) as e: # type: ignore + raise AnsibleAWSError( + message='Unable to delete Medialive Input', + exception=e + ) + + def _update_tags(self, tags: dict, purge: bool) -> bool: + """ + Takes care of updating Input tags + + Args: + tags: a dict of tags supplied by the user + purge: whether or not to delete existing tags that aren't in the tags dict + Returns: + True if tags were updated, otherwise False + """ + + # Short-circuit + if self.module.check_mode: + return False + + to_add, to_delete = compare_aws_tags(self.input['tags'], tags, purge) + + if not any((to_add, to_delete)): + return False + + try: + if to_add: + self.client.create_tags(ResourceArn=self.input['arn'], Tags=to_add) # type: ignore + if to_delete: + self.client.delete_tags(ResourceArn=self.input['arn'], TagKeys=to_delete) # type: ignore + except (ClientError, BotoCoreError) as e: # type: ignore + raise AnsibleAWSError( + message='Unable to update MediaLive Input resource Tags', + exception=e + ) + + return True + + +def get_arg(arg:str, params:dict, spec:dict): + if arg in spec.keys(): + aliases = spec[arg].get('aliases', []) + for k, v in params.items(): + if k in [arg, *aliases] and v: + return v + +def main(): + """Main entry point for the module""" + argument_spec = dict( + id=dict(type='str', aliases=['input_id']), + name=dict(type='str', aliases=['input_name']), + state=dict(type='str', default='present', choices=['present', 'absent']), + request_id=dict(type='str'), + destinations=dict( + type='list', + elements='dict', + options=dict( + stream_name=dict(type='str', required=True), + network=dict(type='str'), + network_routes=dict( + type='list', + elements='dict', + options=dict( + cidr=dict(type='str', required=True), + gateway=dict(type='str') + ) + ), + static_ip_address=dict(type='str') # TODO: validate in code that input_network_location is ON_PREMISES + + ), + required_if=[('input_network_location', 'ON_PREMISES', ['network', 'network_routes'])] + ), + input_devices=dict( + type='list', + elements='dict', + options=dict( + id=dict(type='str', required=True) + ) + ), + input_security_groups=dict(type='list', elements='str'), + media_connect_flows=dict( + type='list', + elements='dict', + options=dict( + flow_arn=dict(type='str', required=True) + ) + ), + role_arn=dict(type='str'), + sources=dict( + type='list', + elements='dict', + options=dict( + password_param=dict(type='str'), + url=dict(type='str', required=True), + username=dict(type='str') + ) + ), + type=dict( + type='str', + choices=[ + 'UDP_PUSH', + 'RTP_PUSH', + 'RTMP_PUSH', + 'RTMP_PULL', + 'URL_PULL', + 'MP4_FILE', + 'MEDIACONNECT', + 'INPUT_DEVICE', + 'AWS_CDI', + 'TS_FILE', + 'SRT_CALLER', + 'MULTICAST', + 'SMPTE_2110_RECEIVER_GROUP', + 'SDI', + ] + ), + vpc=dict( + type='dict', + options=dict( + security_group_ids=dict(type='list', elements='str'), + subnet_ids=dict(type='list', elements='str', required=True) + ) + ), + srt_settings=dict( + type='dict', + options=dict( + srt_caller_sources=dict( + type='list', + elements='dict', + options=dict( + decryption=dict( + type='dict', + options=dict( + algorithm=dict(type='str'), + passphrase_secret_arn=dict(type='str') + ) + ), + minimum_latency=dict(type='int'), + srt_listener_address=dict(type='str'), + srt_listener_port=dict(type='str'), + stream_id=dict(type='str') + ) + ) + ) + ), + input_network_location=dict( + type='str', + choices=['AWS', 'ON_PREMISES'] # TODO: validate in code: Must be AWS if O(type=URL_PULL), ON_PREMISES if O(type=SDI) + ), + multicast_settings=dict( + type='dict', + options=dict( + sources=dict( + type='list', + elements='dict', + options=dict( + type='dict', + options=dict( + source_ip=dict(type='str'), + url=dict(type='str', required=True) + ) + ) + ) + ) + ), + smpte_2110_receiver_group_settings=dict( + type='dict', + options=dict( + smpte_2110_receiver_groups=dict( + type='list', + elements='dict', + options=dict( + sdp_settings=dict( + type='dict', + options=dict( + ancillary_sdps=dict( + type='list', + elements='dict', + options=dict( + meta_index=dict(type='int'), + sdp_url=dict(type='str') + ) + ), + audio_sdps=dict( + type='list', + elements='dict', + options=dict( + meta_index=dict(type='int'), + sdp_url=dict(type='str') + ) + ), + video_sdp=dict( + type='list', + elements='dict', + options=dict( + meta_index=dict(type='int'), + sdp_url=dict(type='str') + ) + ) + ) + ) + ) + ) + ) + ), + sdi_sources=dict( + type='list', + elements='str' + ), + tags=dict(type='dict'), + purge_tags=dict(type="bool", default=True), + wait=dict(type='bool', default=True), + wait_timeout=dict(type='int', default=60), + ) + + module = AnsibleAWSModule( + argument_spec=argument_spec, + supports_check_mode=True, + mutually_exclusive=[ + ('id', 'input_id', 'name', 'input_name'), + ('vpc', 'input_security_groups') + ], + required_one_of=[('id', 'input_id', 'name', 'input_name')], + required_if=[ + ('state', 'present', ['type']), + ('type', 'UDP_PUSH', ['destinations']), + ('type', 'RTP_PUSH', ['destinations']), + ('type', 'RTMP_PUSH', ['destinations', 'input_network_location']), + ('type', 'INPUT_DEVICE', ['input_devices']), + ('type', 'MEDIACONNECT', ['media_connect_flows']), + ('type', 'RTMP_PULL', ['sources']), + ('type', 'URL_PULL', ['sources', 'input_network_location']), + ('type', 'MP4_FILE', ['sources']), + ('type', 'TS_FILE', ['sources']), + ('type', 'RTP', ['input_network_location']), + ('type', 'SDI', ['input_network_location', 'sdi_sources']), + ('type', 'MULTICAST', ['multicast_settings']), + ('type', 'SMPTE_2110_RECEIVER_GROUP', ['smpte_2110_receiver_group_settings']), + ], + required_by={ + 'vpc': ('role_arn') + } + ) + + # Extract module arguments + input_id = get_arg('id', module.params, argument_spec) + input_name = get_arg('name', module.params, argument_spec) + state = get_arg('state', module.params, argument_spec) + destinations = get_arg('destinations', module.params, argument_spec) + input_devices = get_arg('input_devices', module.params, argument_spec) + input_security_groups = get_arg('input_security_groups', module.params, argument_spec) + media_connect_flows = get_arg('media_connect_flows', module.params, argument_spec) + role_arn = get_arg('role_arn', module.params, argument_spec) + sources = get_arg('sources', module.params, argument_spec) + input_type = get_arg('type', module.params, argument_spec) + vpc = get_arg('vpc', module.params, argument_spec) + srt_settings = get_arg('srt_settings', module.params, argument_spec) + input_network_location = get_arg('input_network_location', module.params, argument_spec) + multicast_settings = get_arg('multicast_settings', module.params, argument_spec) + smpte_2110_receiver_group_settings = get_arg('smpte_2110_receiver_group_settings', module.params, argument_spec) + sdi_sources = get_arg('sdi_sources', module.params, argument_spec) + tags = get_arg('tags', module.params, argument_spec) + purge_tags = get_arg('purge_tags', module.params, argument_spec) + wait = get_arg('wait', module.params, argument_spec) + wait_timeout = get_arg('wait_timeout', module.params, argument_spec) + + # Initialize the manager + manager = MediaLiveInputManager(module) + + # Find the input by ID or name + if input_id: + manager.get_input_by_id(input_id) + elif input_name: + manager.get_input_by_name(input_name) + input_id = manager.input.get('input_id') + + # Do nothing in check mode + if module.check_mode: + module.exit_json(changed=True) + + # Handle present state + if state == 'present': + + # Case update + if manager.input: + + update_params = { + 'destinations': destinations, + 'input_devices': input_devices, + 'input_id': input_id, + 'input_security_groups': input_security_groups, + 'media_connect_flows': media_connect_flows, + 'name': input_name, + 'role_arn': role_arn, + 'sources': sources, + 'srt_settings': srt_settings, + 'multicast_settings': multicast_settings, + 'smpte_2110_receiver_group_settings': smpte_2110_receiver_group_settings, + 'sdi_sources': sdi_sources, + 'tags': tags, + 'purge_tags': purge_tags + } + + manager.do_update_input(update_params) + + # Case create + else: + create_params = { + 'destinations': destinations, + 'input_devices': input_devices, + 'input_security_groups': input_security_groups, + 'media_connect_flows': media_connect_flows, + 'name': input_name, + 'role_arn': role_arn, + 'sources': sources, + 'type': input_type, + 'vpc': vpc, + 'srt_settings': srt_settings, + 'input_network_location': input_network_location, + 'multicast_settings': multicast_settings, + 'smpte_2110_receiver_group_settings': smpte_2110_receiver_group_settings, + 'sdi_sources': sdi_sources, + 'tags': tags + } + + manager.do_create_input(create_params) + input_id = manager.input.get('input_id') + + # Wait for the input to be created + if wait and input_id: + manager.wait_for('input_detached', input_id, wait_timeout) # type: ignore + manager.get_input_by_id(input_id) + + # Handle absent state + elif state == 'absent': + if manager.input: + # Network exists, delete it + input_id = manager.input.get('input_id') + manager.delete_input(input_id) # type: ignore + + # Wait for the input to be deleted + if wait and input_id: + manager.wait_for('input_deleted', input_id, wait_timeout) # type: ignore + + module.exit_json(changed=manager.changed, input=manager.input) + +if __name__ == '__main__': + main() diff --git a/plugins/modules/medialive_input_info.py b/plugins/modules/medialive_input_info.py new file mode 100644 index 00000000000..453f4c1b156 --- /dev/null +++ b/plugins/modules/medialive_input_info.py @@ -0,0 +1,174 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r""" +module: medialive_input_info +short_description: Gather MediaLive Anywhere input info +version_added: 10.1.0 +description: + - A module for gathering information about AWS MediaLive inputs + - Requires boto3 >= 1.37.34 +author: + - Sergey Papyan (@r363x) +options: + name: + description: + - Name of the input + - At least this or id must be provided + type: str + id: + description: + - ID of the input + - At least this or name must be provided + type: str + +extends_documentation_fragment: + - amazon.aws.common.modules + - amazon.aws.region.modules + - amazon.aws.boto3 +""" + +EXAMPLES = r""" +# Find a MediaLive Anywhere Input by ID +- community.aws.medialive_input_info: + id: '1234567' + register: found_input + +# Find a MediaLive Anywhere Input by name +- community.aws.medialive_input_info: + name: 'ExampleInput' + register: found_input +""" + +RETURN = r""" +""" + +from typing import Dict, List + +try: + from botocore.exceptions import WaiterError, ClientError, BotoCoreError +except ImportError: + pass # caught by AnsibleAWSModule + +from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict +from ansible_collections.amazon.aws.plugins.module_utils.core import AnsibleAWSModule +from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_code +from ansible_collections.amazon.aws.plugins.module_utils.exceptions import AnsibleAWSError + + +class MediaLiveInputGetter: + """Gather info about AWS MediaLive Anywhere Inputs""" + + def __init__(self, module: AnsibleAWSModule): + """ + Initialize the MediaLiveInputGetter + + Args: + module: AnsibleAWSModule instance + """ + self.module = module + self.client = self.module.client('medialive') + self._input = {} + + @property + def input(self): + """Simple getter for input""" + return self._input + + @input.setter + def input(self, input: Dict): + """Setter for input that takes care of normalizing raw API responses""" + tags = input.get('Tags') # To preserve case in tag keys + input = camel_dict_to_snake_dict(input) + if 'response_metadata' in input.keys(): + del input['response_metadata'] # Unneeded + if input.get('id'): + input['input_id'] = input.get('id') + del input['id'] + if tags: + input['tags'] = tags + self._input = input + + def find_input_by_name(self, name: str): + """ + Find an Input by name + + Args: + name: The name of the input to find + """ + + try: + paginator = self.client.get_paginator('list_inputs') # type: ignore + found = [] + for page in paginator.paginate(): + for input in page.get('Inputs', []): + if input.get('Name') == name: + found.append(input.get('Id')) + if len(found) > 1: + raise AnsibleAWSError(message='Found more than one Inputs under the same name') + elif len(found) == 1: + self.get_input_by_id(found[0]) + except (ClientError, BotoCoreError) as e: + raise AnsibleAWSError( + message='Unable to get Medialive Input', + exception=e + ) + + def get_input_by_id(self, input_id: str): + """ + Get an input by ID + + Args: + input_id: The ID of the input to retrieve + """ + try: + self.input = self.client.describe_input(InputId=input_id) # type: ignore + except is_boto3_error_code('ResourceNotFoundException'): + self.input = {} + except (ClientError, BotoCoreError) as e: + raise AnsibleAWSError( + message='Unable to get Medialive Input', + exception=e + ) + +def get_arg(arg:str, params:dict, spec:dict): + if arg in spec.keys(): + aliases = spec[arg].get('aliases', []) + for k, v in params.items(): + if k in [arg, *aliases] and v: + return v + +def main(): + """Main entry point for the module""" + argument_spec = dict( + id=dict(type='str', aliases=['input_id']), + name=dict(type='str', aliases=['input_name']), + ) + + module = AnsibleAWSModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=[('id', 'input_id', 'name', 'input_name')] + ) + + # Extract module arguments + input_id = get_arg('id', module.params, argument_spec) + input_name = get_arg('name', module.params, argument_spec) + + # Initialize the manager + manager = MediaLiveInputGetter(module) + + # Find the input by ID or name + if input_id: + manager.get_input_by_id(input_id) + elif input_name: + manager.find_input_by_name(input_name) + input_id = manager.input.get('input_id') + + module.exit_json(changed=False, input=manager.input) + +if __name__ == '__main__': + main() diff --git a/plugins/modules/medialive_network.py b/plugins/modules/medialive_network.py new file mode 100644 index 00000000000..96bbd97978c --- /dev/null +++ b/plugins/modules/medialive_network.py @@ -0,0 +1,412 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r""" +module: medialive_network +short_description: Manage AWS MediaLive Anywhere networks +version_added: 10.1.0 +description: + - A module for creating, updating and deleting AWS MediaLive Anywhere networks. + - This module requires boto3 >= 1.35.17. +author: + - Sergey Papyan (@r363x) +options: + id: + description: + - The ID of the network. + - Exactly one of I(id) or I(name) must be provided. + required: false + type: str + aliases: ['network_id'] + name: + description: + - The name of the network. + - Exactly one of I(id) or I(name) must be provided. + required: false + type: str + aliases: ['network_name'] + state: + description: + - Create/update or remove the network. + required: false + choices: ['present', 'absent'] + default: 'present' + type: str + ip_pools: + description: + - A list of IP pools to associate with the network. + - Required when creating a new network. + type: list + elements: dict + required: false + suboptions: + cidr: + description: + - The CIDR block for the IP pool. + type: str + required: true + routes: + description: + - A list of routes to associate with the network. + type: list + elements: dict + required: false + suboptions: + cidr: + description: + - The CIDR block for the route. + type: str + required: true + gateway: + description: + - The gateway for the route. + type: str + required: true + +extends_documentation_fragment: + - amazon.aws.common.modules + - amazon.aws.region.modules + - amazon.aws.boto3 +""" + +EXAMPLES = r""" +# Create a MediaLive Anywhere network +- community.aws.medialive_network: + name: 'ExampleNetwork' + state: present + ip_pools: + - cidr: '10.0.0.0/24' + routes: + - cidr: '0.0.0.0/0' + gateway: '10.0.0.1' + +# Delete a MediaLive Anywhere network +- community.aws.medialive_network: + name: 'ExampleNetwork' + state: absent +""" + +RETURN = r""" +network: + description: The details of the network + returned: success + type: dict + contains: + arn: + description: The ARN of the network. + type: str + returned: success + example: "arn:aws:medialive:us-east-1:123456789012:network/1234abcd-12ab-34cd-56ef-1234567890ab" + associated_cluster_ids: + description: The IDs of clusters associated with the network. + type: list + elements: str + returned: success + example: ["cluster-1234abcd"] + network_id: + description: The ID of the network. + type: str + returned: success + example: "1234abcd-12ab-34cd-56ef-1234567890ab" + ip_pools: + description: The IP pools associated with the network. + type: list + elements: dict + returned: success + contains: + cidr: + description: The CIDR block for the IP pool. + type: str + returned: success + example: "10.0.0.0/24" + name: + description: The name of the network. + type: str + returned: success + example: "ExampleNetwork" + routes: + description: The routes associated with the network. + type: list + elements: dict + returned: success + contains: + cidr: + description: The CIDR block for the route. + type: str + returned: success + example: "0.0.0.0/0" + gateway: + description: The gateway for the route. + type: str + returned: success + example: "10.0.0.1" + state: + description: The state of the network. + type: str + returned: success + example: "ACTIVE" +""" + +import uuid +from typing import Dict, Literal + +try: + from botocore.exceptions import ClientError, BotoCoreError +except ImportError: + pass # caught by AnsibleAWSModule + +from ansible.module_utils.common.dict_transformations import snake_dict_to_camel_dict, camel_dict_to_snake_dict, recursive_diff +from ansible_collections.amazon.aws.plugins.module_utils.core import AnsibleAWSModule +from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_code +from ansible_collections.amazon.aws.plugins.module_utils.exceptions import AnsibleAWSError + + +class MediaLiveNetworkManager: + '''Manage AWS MediaLive Anywhere networks''' + + def __init__(self, module: AnsibleAWSModule): + ''' + Initialize the MediaLiveNetworkManager + + Args: + module: AnsibleAWSModule instance + ''' + self.module = module + self.client = self.module.client('medialive') + self._network = {} + self.changed = False + + @property + def network(self): + return self._network + + @network.setter + def network(self, network: Dict): + network = camel_dict_to_snake_dict(network) + if network.get('response_metadata'): + del network['response_metadata'] + if network.get('id'): + network['network_id'] = network.get('id') + del network['id'] + self._network = network + + + def do_create_network(self, params): + """ + Create a new MediaLive network + + Args: + params: Parameters for network creation + """ + allowed_params = ['ip_pools', 'name', 'routes', 'request_id'] + required_params = ['ip_pools', 'routes'] + + for param in required_params: + if not params.get(param): + raise AnsibleAWSError(message=f'The {param} parameter is required when creating a new Network') + + create_params = { k: v for k, v in params.items() if k in allowed_params and v } + create_params = snake_dict_to_camel_dict(create_params, capitalize_first=True) + + try: + self.network = self.client.create_network(**create_params) # type: ignore + self.changed = True + except (ClientError, BotoCoreError) as e: # type: ignore + raise AnsibleAWSError( + message='Unable to create Medialive Network', + exception=e + ) + + + def do_update_network(self, params): + """ + Update a new MediaLive network + + Args: + params: Parameters for network update + """ + if not params.get('network_id'): + raise AnsibleAWSError(message='The network_id parameter is required during network update.') + + allowed_params = ['ip_pools', 'name', 'routes', 'network_id'] + + + current_params = { k: v for k, v in self.network.items() if k in allowed_params } + update_params = { k: v for k, v in params.items() if k in allowed_params and v } + + # Short circuit + if not recursive_diff(current_params, update_params): + return + + update_params = snake_dict_to_camel_dict(update_params, capitalize_first=True) + + try: + self.network = self.client.update_network(**update_params) # type: ignore + self.changed = True + except (ClientError, BotoCoreError) as e: # type: ignore + raise AnsibleAWSError( + message='Unable to update Medialive Network', + exception=e + ) + + def get_network_by_name(self, name: str): + """ + Find a network by name + + Args: + name: The name of the network to find + """ + + try: + paginator = self.client.get_paginator('list_networks') # type: ignore + found = [] + for page in paginator.paginate(): + for network in page.get('Networks', []): + if network.get('Name') == name: + found.append(network.get('Id')) + if len(found) > 1: + raise AnsibleAWSError(message='Found more than one Networks under the same name') + elif len(found) == 1: + self.get_network_by_id(found[0]) + + except (ClientError, BotoCoreError) as e: # type: ignore + raise AnsibleAWSError( + message='Unable to get Medialive Network', + exception=e + ) + + def get_network_by_id(self, network_id: str): + """ + Get a network by ID + + Args: + network_id: The ID of the network to retrieve + """ + try: + self.network = self.client.describe_network(NetworkId=network_id) # type: ignore + except is_boto3_error_code('ResourceNotFoundException'): + self.network = {} + except (ClientError, BotoCoreError) as e: # type: ignore + raise AnsibleAWSError( + message='Unable to get Medialive Network', + exception=e + ) + + def delete_network(self, network_id: str): + """ + Delete a MediaLive network + + Args: + network_id: ID of the network to delete + """ + try: + self.client.delete_network(NetworkId=network_id) # type: ignore + self.network = {} + self.changed = True + except is_boto3_error_code('ResourceNotFoundException'): + self.network = {} + except (ClientError, BotoCoreError) as e: # type: ignore + raise AnsibleAWSError( + message='Unable to delete Medialive Network', + exception=e + ) + +def get_arg(arg:str, params:dict, spec:dict): + if arg in spec.keys(): + aliases = spec[arg].get('aliases', []) + for k, v in params.items(): + if k in [arg, *aliases] and v: + return v + +def main(): + """Main entry point for the module""" + argument_spec = dict( + id=dict(type='str', required=False, aliases=['network_id']), + name=dict(type='str', required=False, aliases=['network_name']), + state=dict(type='str', default='present', choices=['present', 'absent']), + ip_pools=dict( + type='list', + elements='dict', + required=False, + options=dict(cidr=dict(type='str', required=True), + )), + routes=dict( + type='list', + elements='dict', + required=False, + options=dict( + cidr=dict(type='str', required=True), + gateway=dict(type='str', required=True), + ) + ) + ) + + module = AnsibleAWSModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=[('id', 'network_id', 'name', 'network_name')] + ) + + # Extract module parameters + network_id = get_arg('id', module.params, argument_spec) + network_name = get_arg('name', module.params, argument_spec) + state = get_arg('state', module.params, argument_spec) + ip_pools = get_arg('ip_pools', module.params, argument_spec) + routes = get_arg('routes', module.params, argument_spec) + + # Initialize the manager + manager = MediaLiveNetworkManager(module) + + # Find the network by ID or name + if network_id: + manager.get_network_by_id(network_id) + elif network_name: + manager.get_network_by_name(network_name) + network_id = manager.network.get('network_id') + + # Do nothing in check mode + if module.check_mode: + module.exit_json(changed=True) + + # Handle present state + if state == 'present': + + # Case update + if manager.network: + + update_params = { + 'name': network_name, + 'ip_pools': ip_pools, + 'routes': routes, + 'network_id': network_id + } + + manager.do_update_network(update_params) + + # Case create + else: + create_params = { + 'name': network_name, + 'ip_pools': ip_pools, + 'routes': routes, + 'request_id': str(uuid.uuid4()) + } + + manager.do_create_network(create_params) + network_id = manager.network.get('network_id') + + # Handle absent state + elif state == 'absent': + if manager.network: + # Network exists, delete it + network_id = manager.network.get('network_id') + manager.delete_network(network_id) # type: ignore + + module.exit_json(changed=manager.changed, network=manager.network) + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/medialive_network_info.py b/plugins/modules/medialive_network_info.py new file mode 100644 index 00000000000..b82c373546c --- /dev/null +++ b/plugins/modules/medialive_network_info.py @@ -0,0 +1,230 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r""" +module: medialive_network +short_description: Gather MediaLive Anywhere network info +version_added: 10.1.0 +description: + - Get details about a AWS MediaLive Anywhere network. + - This module requires boto3 >= 1.35.17. +author: + - Sergey Papyan (@r363x) +options: + id: + description: + - The ID of the network. + - At least this or name must be provided + required: false + type: str + aliases: ['network_id'] + name: + description: + - The name of the network. + - At least this or id must be provided + required: false + type: str + aliases: ['network_name'] + +extends_documentation_fragment: + - amazon.aws.common.modules + - amazon.aws.region.modules + - amazon.aws.boto3 +""" + +EXAMPLES = r""" +# Find a MediaLive Anywhere network by ID +- community.aws.medialive_network_info: + id: '1234567' + register: found_network + +# Find a MediaLive Anywhere network by name +- community.aws.medialive_network_info: + name: 'ExampleNetwork' + register: found_network +""" + +RETURN = r""" +network: + description: The details of the network + returned: success + type: dict + contains: + arn: + description: The ARN of the network. + type: str + returned: success + example: "arn:aws:medialive:us-east-1:123456789012:network:1234567" + associated_cluster_ids: + description: The IDs of clusters associated with the network. + type: list + elements: str + returned: success + example: ["cluster-1234abcd"] + id: + description: The ID of the network. + type: str + returned: success + example: "1234567" + ip_pools: + description: The IP pools associated with the network. + type: list + elements: dict + returned: success + contains: + cidr: + description: The CIDR block for the IP pool. + type: str + returned: success + example: "10.0.0.0/24" + name: + description: The name of the network. + type: str + returned: success + example: "ExampleNetwork" + routes: + description: The routes associated with the network. + type: list + elements: dict + returned: success + contains: + cidr: + description: The CIDR block for the route. + type: str + returned: success + example: "0.0.0.0/0" + gateway: + description: The gateway for the route. + type: str + returned: success + example: "10.0.0.1" + state: + description: The state of the network. + type: str + returned: success + example: "ACTIVE" +""" + +from typing import Dict + +try: + from botocore.exceptions import ClientError, BotoCoreError +except ImportError: + pass # caught by AnsibleAWSModule + +from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict +from ansible_collections.amazon.aws.plugins.module_utils.core import AnsibleAWSModule +from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_code +from ansible_collections.amazon.aws.plugins.module_utils.exceptions import AnsibleAWSError + + +class MediaLiveNetworkManager: + '''Manage AWS MediaLive Anywhere networks''' + + def __init__(self, module: AnsibleAWSModule): + ''' + Initialize the MediaLiveNetworkManager + + Args: + module: AnsibleAWSModule instance + ''' + self.module = module + self.client = self.module.client('medialive') + self._network = {} + + @property + def network(self): + return self._network + + @network.setter + def network(self, network: Dict): + network = camel_dict_to_snake_dict(network) + if network.get('response_metadata'): + del network['response_metadata'] + if network.get('id'): + network['network_id'] = network.get('id') + del network['id'] + self._network = network + + def find_network_by_name(self, name: str): + """ + Find a network by name + + Args: + name: The name of the network to find + """ + + try: + paginator = self.client.get_paginator('list_networks') # type: ignore + found = [] + for page in paginator.paginate(): + for network in page.get('Networks', []): + if network.get('Name') == name: + found.append(network.get('Id')) + if len(found) > 1: + raise AnsibleAWSError(message='Found more than one Networks under the same name') + elif len(found) == 1: + self.get_network_by_id(found[0]) + except (ClientError, BotoCoreError) as e: # type: ignore + raise AnsibleAWSError( + message='Unable to get Medialive Network', + exception=e + ) + + def get_network_by_id(self, id: str): + """ + Get a network by ID + + Args: + id: The ID of the network to retrieve + """ + try: + self.network = self.client.describe_network(NetworkId=id) # type: ignore + except is_boto3_error_code('ResourceNotFoundException'): + self.network = {} + except (ClientError, BotoCoreError) as e: # type: ignore + raise AnsibleAWSError( + message='Unable to get Medialive Network', + exception=e + ) + +def get_arg(arg:str, params:dict, spec:dict): + if arg in spec.keys(): + aliases = spec[arg].get('aliases', []) + for k, v in params.items(): + if k in [arg, *aliases] and v: + return v + +def main(): + """Main entry point for the module""" + argument_spec = dict( + id=dict(type='str', required=False, aliases=['network_id']), + name=dict(type='str', required=False, aliases=['network_name']), + ) + + module = AnsibleAWSModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=[('id', 'network_id', 'name', 'network_name')] + ) + + # Extract module parameters + network_id = get_arg('id', module.params, argument_spec) + network_name = get_arg('name', module.params, argument_spec) + + # Initialize the manager + manager = MediaLiveNetworkManager(module) + + # Find the network by ID or name + if network_id: + manager.get_network_by_id(network_id) + elif network_name: + manager.find_network_by_name(network_name) + + module.exit_json(changed=False, network=manager.network) + +if __name__ == '__main__': + main() diff --git a/plugins/modules/medialive_node.py b/plugins/modules/medialive_node.py new file mode 100644 index 00000000000..f7f70eb9d18 --- /dev/null +++ b/plugins/modules/medialive_node.py @@ -0,0 +1,507 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +DOCUMENTATION = r""" +module: medialive_node +short_description: Manage AWS MediaLive Anywhere nodes +version_added: 10.1.0 +description: + - A module for creating, updating and deleting AWS MediaLive Anywhere nodes. + - This module requires boto3 >= 1.37.34. +author: + - Sergey Papyan (@r363x) +options: + id: + description: + - The ID of the Node to manage. + - Exactly one of I(id) or I(name) must be provided. + required: false + type: str + aliases: ["node_id"] + name: + description: + - The user-specified name of the Node to be created. + - Name should include at least one number or letter. The allowed special characters are - space, at-sign, hyphen, underscore, period, comma, apostrophe and semicolon. + - Exactly one of I(id) or I(name) must be provided. + required: false + type: str + aliases: ["node_name"] + cluster_id: + description: + - The ID of the cluster. + required: true + type: str + aliases: ["cluster"] + node_interface_mappings: + description: + - A list of logical interface to physical interface mappings + required: false + type: list + elements: dict + suboptions: + logical_interface_name: + description: + - one of the logicalInterfaceNames in the Cluster that this node belongs to + type: str + required: true + physical_interface_name: + description: + - the physical interface name that corresponds to the logical interface name + type: str + required: true + network_interface_mode: + description: + - The style of the network – NAT or BRIDGE. + type: str + choices: ["NAT", "BRIDGE"] + required: true + role: + description: + - The initial role of the Node in the Cluster. + - ACTIVE means the Node is available for encoding. + - BACKUP means the Node is a redundant Node and might get used if an ACTIVE Node fails. + required: false + type: str + choices: ["ACTIVE", "BACKUP"] + sdi_source_mappings: + description: + - The mappings of a SDI capture card port to a logical SDI data stream + - Can only be applied to an existing node, not on node creation. + required: false + type: list + elements: dict + suboptions: + card_number: + description: + - A number that uniquely identifies the SDI card on the node hardware. + - For information about how physical cards are identified on your node hardware, see the documentation for your node hardware. + - The numbering always starts at 1. + type: int + required: true + channel_number: + description: + - A number that uniquely identifies a port on the card. + - This must be an SDI port (not a timecode port, for example). + - For information about how ports are identified on physical cards, see the documentation for your node hardware. + type: int + required: true + sdi_source: + description: + - The ID of a SDI source streaming on the given SDI capture card port. + required: true + type: str + +extends_documentation_fragment: + - amazon.aws.common.modules + - amazon.aws.region.modules + - amazon.aws.boto3 +""" + +EXAMPLES = r""" +# Create a MediaLive Anywhere node +- community.aws.medialive_node: + name: 'ExampleNode' + cluster_id: '1234567' + node_interface_mappings: + - logical_interface_name: input-if-1 + physical_interface_name: eth1 + network_interface_mode: NAT + role: 'ACTIVE' + state: present + +# Update an existing MediaLive Anywhere node with SDI mappings +- community.aws.medialive_node: + name: 'ExampleNode' + cluster_id: '1234567' + sdi_source_mappings: + - card_number: 123 + channel_number: 123 + sdi_source: 'string' + state: present + +# Delete a MediaLive Anywhere node +- community.aws.medialive_node: + id: '1234567' + cluster_id: '7654321' + state: absent +""" + +RETURN = r""" +node: + description: The details of the node + returned: success + type: dict + contains: + arn: + description: The ARN of the node. + type: str + returned: success + example: "arn:aws:medialive:us-east-1:123456789012:node:1234567/7654321" + channel_placement_groups: + description: An array of IDs. Each ID is one channel_placement_group that is associated with this Node. Empty if the Node is not yet associated with any groups. + type: list + elements: str + returned: success + example: ["1234567", "7654321"] + cluster_id: + description: The ID of the cluster that the node belongs to. + type: str + returned: success + example: "1234567" + connection_state: + description: The connection state of the node. Can be CONNECTED or DISCONNECTED. + type: str + returned: success + example: "CONNECTED" + node_id: + description: The unique ID of the node. Unique in the cluster. The ID is the resource-id portion of the ARN. + type: str + returned: success + example: "1234567" + instance_arn: + description: The ARN of the EC2/SSM Managed instance hosting the Node. + type: str + returned: success + example: "arn:aws:ssm:us-east-1:123456789012:managed-instance/mi-abcdefgh12345678" + name: + description: The name of the node. + type: str + returned: success + example: "ExampleNode" + node_interface_mappings: + description: A mapping that’s used to pair a logical network interface name on a node with the physical interface name exposed in the operating system. + type: list + elements: dict + returned: success + contains: + logical_interface_name: + description: A uniform logical interface name to address in a MediaLive channel configuration. + type: str + returned: success + example: "input-if-1" + physical_interface_name: + description: The name of the physical interface on the hardware that will be running AWS MediaLive anywhere. + type: str + returned: success + example: "eth1" + network_interface_mode: + description: The style of the network – NAT or BRIDGE. + type: str + returned: success + example: "NAT" + role: + description: The role of the node in the cluster. Can be ACTIVE or BACKUP. + type: str + returned: success + example: "ACTIVE" + sdi_source_mappings: + description: + - An array of SDI source mappings. + - Each mapping connects one logical SdiSource to the physical SDI card and port that the physical SDI source uses. + type: list + elements: dict + returned: success + contains: + card_number: + description: + - A number that uniquely identifies the SDI card on the node hardware. + - For information about how physical cards are identified on your node hardware, see the documentation for your node hardware. + - The numbering always starts at 1. + type: int + returned: success + channel_number: + description: + - A number that uniquely identifies a port on the card. + - This must be an SDI port (not a timecode port, for example). + - For information about how ports are identified on physical cards, see the documentation for your node hardware. + type: int + returned: success + sdi_source: + description: + - The ID of a SDI source streaming on the given SDI capture card port. + returned: success + type: str + state: + description: > + The current state of the node. + Possible values: + - CREATED + - REGISTERING + - READY_TO_ACTIVATE + - REGISTRATION_FAILED + - ACTIVATION_FAILED + - ACTIVE + - READY + - IN_USE + - DEREGISTERING + - DRAINING + - DEREGISTRATION_FAILED + - DEREGISTERED + type: str + returned: success + example: "ACTIVE" +""" + +import uuid +from typing import Dict, Literal + +try: + from botocore.exceptions import WaiterError, ClientError, BotoCoreError +except ImportError: + pass # caught by AnsibleAWSModule + +from ansible.module_utils.common.dict_transformations import snake_dict_to_camel_dict, camel_dict_to_snake_dict, recursive_diff +from ansible_collections.amazon.aws.plugins.module_utils.core import AnsibleAWSModule +from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_code +from ansible_collections.amazon.aws.plugins.module_utils.exceptions import AnsibleAWSError + + +class MediaLiveNodeManager: + '''Manage AWS MediaLive Anywhere node''' + + def __init__(self, module: AnsibleAWSModule): + ''' + Initialize the MediaLiveNodeManager + + Args: + module: AnsibleAWSModule instance + ''' + self.module = module + self.client = self.module.client('medialive') + self._node = {} + self.changed = False + + @property + def node(self): + return self._node + + @node.setter + def node(self, node: Dict): + node = camel_dict_to_snake_dict(node) + if node.get('response_metadata'): + del node['response_metadata'] + if node.get('id'): + node['node_id'] = node.get('id') + del node['id'] + self._node = node + + def do_create_node(self, params): + """ + Create a new MediaLive node + + Args: + params: Parameters for node creation + """ + allowed_params = ['cluster_id', 'name', 'node_interface_mappings', 'role', 'request_id'] + required_params = ['cluster_id', 'name', 'node_interface_mappings', 'role'] + + for param in required_params: + if not params.get(param): + raise AnsibleAWSError( + message=f'The {", ".join(required_params)} parameters are required when creating a new node' + ) + + create_params = { k: v for k, v in params.items() if k in allowed_params and v } + create_params = snake_dict_to_camel_dict(create_params, capitalize_first=True) + + try: + self.node = self.client.create_node(**create_params) # type: ignore + self.changed = True + except (ClientError, BotoCoreError) as e: # type: ignore + raise AnsibleAWSError( + message='Unable to create Medialive node', + exception=e + ) + + def do_update_node(self, params: dict): + """ + Update a new MediaLive node + + Args: + params: Parameters for node update + """ + + allowed_params = ['cluster_id', 'node_id', 'name', 'role'] + + current_params = { k: v for k, v in self.node.items() if k in allowed_params } + update_params = { k: v for k, v in params.items() if k in allowed_params and v } + + update_params['cluster_id'] = self.node.get('cluster_id') + update_params['node_id'] = self.node.get('node_id') + if params.get('sdi_source_mappings'): + update_params['sdi_source_mappings'] = params.get('sdi_source_mappings') + + # Short circuit + if not recursive_diff(current_params, update_params): + return + + update_params = snake_dict_to_camel_dict(update_params, capitalize_first=True) + + try: + response = self.client.update_node(**update_params) # type: ignore + self.node = camel_dict_to_snake_dict(response) + self.changed = True + except (ClientError, BotoCoreError) as e: # type: ignore + raise AnsibleAWSError( + message='Unable to update Medialive node', + exception=e + ) + + def delete_node(self): + """ + Delete a MediaLive node + """ + try: + self.client.delete_node(ClusterId=self.node.get('cluster_id'), NodeId=self.node.get('node_id')) # type: ignore + self.changed = True + except is_boto3_error_code('ResourceNotFoundException'): + self.node = {} + except (ClientError, BotoCoreError) as e: # type: ignore + raise AnsibleAWSError( + message='Unable to delete Medialive node', + exception=e + ) + + def get_node_by_name(self, cluster_id: str, name: str): + """ + Find a node by name + + Args: + cluster_id: The id of the cluster to which the node belongs + name: The name of the node to find + """ + try: + paginator = self.client.get_paginator('list_nodes') # type: ignore + found = [] + for page in paginator.paginate(ClusterId=cluster_id): + for node in page.get('Nodes', []): + if node.get('Name') == name: + found.append(node.get('Id')) + if len(found) > 1: + raise AnsibleAWSError(message='Found more than one Nodes under the same name') + elif len(found) == 1: + self.get_node_by_id(cluster_id, found[0]) + + except (ClientError, BotoCoreError) as e: # type: ignore + raise AnsibleAWSError( + message='Unable to get Medialive Node', + exception=e + ) + + def get_node_by_id(self, cluster_id: str, node_id: str): + """ + Get a node by ID + + Args: + cluster_id: The id of the cluster to which the node belongs + node_id: The ID of the node to retrieve + """ + try: + self.node = self.client.describe_node(ClusterId=cluster_id, NodeId=node_id) # type: ignore + return True + except is_boto3_error_code('ResourceNotFoundException'): + self.node = {} + +def get_arg(arg:str, params:dict, spec:dict): + if arg in spec.keys(): + aliases = spec[arg].get('aliases', []) + for k, v in params.items(): + if k in [arg, *aliases] and v: + return v + +def main(): + """Main entry point for the module""" + argument_spec = dict( + id=dict(type='str', required=False, aliases=['node_id']), + name=dict(type='str', required=False, aliases=['node_name']), + cluster_id=dict(type='str', required=True), + node_interface_mappings=dict( + type='list', + elements='dict', + required=False, + options=dict( + logical_interface_name=dict(type='str', required=True), + physical_interface_name=dict(type='str', required=True), + network_interface_mode=dict(type='str', required=True, choices=['NAT', 'BRIDGE']), + ) + ), + role=dict(type='str', required=False, choices=["ACTIVE", "BACKUP"]), + sdi_source_mappings=dict( + type='list', + elements='dict', + required=False, + options=dict( + card_number=dict(type='int', required=True), + channel_number=dict(type='int', required=True), + sdi_source=dict(type='str', required=True), + ) + ), + state=dict(type='str', default='present', choices=['present', 'absent']) + ) + + module = AnsibleAWSModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=[('id', 'node_id', 'name', 'node_name')] + ) + + # Extract module parameters + node_id = get_arg('id', module.params, argument_spec) + node_name = get_arg('name', module.params, argument_spec) + cluster_id = get_arg('cluster_id', module.params, argument_spec) + node_interface_mappings = get_arg('node_interface_mappings', module.params, argument_spec) + role = get_arg('role', module.params, argument_spec) + sdi_source_mappings = get_arg('sdi_source_mappings', module.params, argument_spec) + state = get_arg('state', module.params, argument_spec) + + # Initialize the manager + manager = MediaLiveNodeManager(module) + + # Find the node by ID or name + # Update manager.node with the details + if node_id: + manager.get_node_by_id(cluster_id, node_id) # type: ignore + elif node_name: + manager.get_node_by_name(cluster_id, node_name) # type: ignore + + # Do nothing in check mode + if module.check_mode: + module.exit_json(changed=True) + + # Handle present state + if state == 'present': + + # Case update + if manager.node: + update_params = { + 'name': node_name, + 'role': role, + 'sdi_source_mappings': sdi_source_mappings + } + + manager.do_update_node(update_params) + + # Case create + else: + create_params = { + 'cluster_id': cluster_id, + 'name': node_name, + 'node_interface_mappings': node_interface_mappings, + 'role': role, + 'request_id': str(uuid.uuid4()) + } + + manager.do_create_node(create_params) + + # Handle absent state + elif state == 'absent': + if manager.node: + # Node exists, delete it + manager.delete_node() + + module.exit_json(changed=manager.changed, node=manager.node) + +if __name__ == '__main__': + main() diff --git a/plugins/modules/medialive_node_info.py b/plugins/modules/medialive_node_info.py new file mode 100644 index 00000000000..8623c2bd80a --- /dev/null +++ b/plugins/modules/medialive_node_info.py @@ -0,0 +1,262 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r""" +module: medialive_node +short_description: Gather AWS MediaLive Anywhere node info +version_added: 10.1.0 +description: + - Get details about a AWS MediaLive Anywhere node. + - This module requires boto3 >= 1.37.34. +author: + - Sergey Papyan (@r363x) +options: + id: + description: + - The ID of the Node. + - Exactly one of I(id) or I(name) must be provided. + required: false + type: str + aliases: ["node_id"] + name: + description: + - The name of the Node. + - Exactly one of I(id) or I(name) must be provided. + required: false + type: str + aliases: ["node_name"] + cluster_id: + description: + - The ID of the cluster. + required: true + type: str + aliases: ["cluster"] + +extends_documentation_fragment: + - amazon.aws.common.modules + - amazon.aws.region.modules + - amazon.aws.boto3 +""" + +EXAMPLES = r""" +# Find a MediaLive Anywhere node by ID +- community.aws.medialive_node_info: + id: '1234567' + cluster_id: '7654321' + register: found_node + +# Find a MediaLive Anywhere node by name +- community.aws.medialive_node_info: + name: 'ExampleNode' + cluster_id: '7654321' + register: found_node +""" + +RETURN = r""" +node: + description: The details of the node + returned: success + type: dict + contains: + arn: + description: The ARN of the node. + type: str + returned: success + example: "arn:aws:medialive:us-east-1:123456789012:node:1234567/7654321" + channel_placement_groups: + description: An array of IDs. Each ID is one channel_placement_group that is associated with this Node. Empty if the Node is not yet associated with any groups. + type: list + elements: str + returned: success + example: ["1234567", "7654321"] + cluster_id: + description: The ID of the cluster that the node belongs to. + type: str + returned: success + example: "1234567" + connection_state: + description: The connection state of the node. Can be CONNECTED or DISCONNECTED. + type: str + returned: success + example: "CONNECTED" + node_id: + description: The unique ID of the node. Unique in the cluster. The ID is the resource-id portion of the ARN. + type: str + returned: success + example: "1234567" + instance_arn: + description: The ARN of the EC2/SSM Managed instance hosting the Node. + type: str + returned: success + example: "arn:aws:ssm:us-east-1:123456789012:managed-instance/mi-abcdefgh12345678" + name: + description: The name of the node. + type: str + returned: success + example: "ExampleNode" + node_interface_mappings: + description: A mapping that’s used to pair a logical network interface name on a node with the physical interface name exposed in the operating system. + type: list + elements: dict + returned: success + contains: + logical_interface_name: + description: A uniform logical interface name to address in a MediaLive channel configuration. + type: str + returned: success + example: "input-if-1" + physical_interface_name: + description: The name of the physical interface on the hardware that will be running AWS MediaLive anywhere. + type: str + returned: success + example: "eth1" + network_interface_mode: + description: The style of the network – NAT or BRIDGE. + type: str + returned: success + example: "NAT" + role: + description: The role of the node in the cluster. Can be ACTIVE or BACKUP. + type: str + returned: success + example: "ACTIVE" + state: + description: > + The current state of the node. + Possible values: + - CREATED + - REGISTERING + - READY_TO_ACTIVATE + - REGISTRATION_FAILED + - ACTIVATION_FAILED + - ACTIVE + - READY + - IN_USE + - DEREGISTERING + - DRAINING + - DEREGISTRATION_FAILED + - DEREGISTERED + type: str + returned: success + example: "ACTIVE" +""" + +from typing import Dict + +from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict +from ansible_collections.amazon.aws.plugins.module_utils.core import AnsibleAWSModule +from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_code +from ansible_collections.amazon.aws.plugins.module_utils.exceptions import AnsibleAWSError + + +class MediaLiveNodeGetter: + '''Look up AWS MediaLive Anywhere nodes''' + + def __init__(self, module: AnsibleAWSModule): + ''' + Initialize the MediaLiveNodeGetter + + Args: + module: AnsibleAWSModule instance + ''' + self.module = module + self.client = self.module.client('medialive') + self._node = {} + + @property + def node(self): + return self._node + + @node.setter + def node(self, node: Dict): + node = camel_dict_to_snake_dict(node) + if node.get('response_metadata'): + del node['response_metadata'] + if node.get('id'): + node['node_id'] = node.get('id') + del node['id'] + self._node = node + + def get_node_by_name(self, cluster_id: str, name: str): + """ + Find a node by name + + Args: + cluster_id: The id of the cluster to which the node belongs + name: The name of the node to find + """ + try: + paginator = self.client.get_paginator('list_nodes') # type: ignore + found = [] + for page in paginator.paginate(ClusterId=cluster_id): + for node in page.get('Nodes', []): + if node.get('Name') == name: + found.append(node.get('Id')) + if len(found) > 1: + raise AnsibleAWSError(message='Found more than one Nodes under the same name') + elif len(found) == 1: + self.get_node_by_id(cluster_id, found[0]) + + except (ClientError, BotoCoreError) as e: + raise AnsibleAWSError( + message='Unable to get Medialive Node', + exception=e + ) + + def get_node_by_id(self, cluster_id: str, node_id: str): + """ + Get a node by ID + + Args: + cluster_id: The id of the cluster to which the node belongs + node_id: The ID of the node to retrieve + """ + try: + self.node = self.client.describe_node(ClusterId=cluster_id, NodeId=node_id) # type: ignore + return True + except is_boto3_error_code('ResourceNotFoundException'): + self.node = {} + +def get_arg(arg:str, params:dict, spec:dict): + if arg in spec.keys(): + aliases = spec[arg].get('aliases', []) + for k, v in params.items(): + if k in [arg, *aliases] and v: + return v + +def main(): + """Main entry point for the module""" + argument_spec = dict( + id=dict(type='str', required=False, aliases=['node_id']), + name=dict(type='str', required=False, aliases=['node_name']), + cluster_id=dict(type='str', required=True) + ) + + module = AnsibleAWSModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=[('id', 'node_id', 'name', 'node_name')], + ) + + # Extract module parameters + node_id = get_arg('id', module.params, argument_spec) + node_name = get_arg('name', module.params, argument_spec) + cluster_id = get_arg('cluster_id', module.params, argument_spec) + + # Initialize the getter + getter = MediaLiveNodeGetter(module) + + # Find the node by ID or name + # Update getter.node with the details + if node_id: + getter.get_node_by_id(cluster_id, node_id) # type: ignore + elif node_name: + getter.get_node_by_name(cluster_id, node_name) # type: ignore + + module.exit_json(changed=False, node=getter.node) + +if __name__ == '__main__': + main() diff --git a/plugins/modules/medialive_node_registration.py b/plugins/modules/medialive_node_registration.py new file mode 100644 index 00000000000..ac95ec649cd --- /dev/null +++ b/plugins/modules/medialive_node_registration.py @@ -0,0 +1,174 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r""" +module: medialive_node_registration +short_description: Manage AWS MediaLive Anywhere nodes +version_added: 10.1.0 +description: + - A module for creating, updating and deleting AWS MediaLive Anywhere nodes. + - This module requires boto3 >= 1.37.34. +author: + - Sergey Papyan (@r363x) +options: + id: + description: + - The ID of the Node to create registration script for. + required: true + type: str + aliases: ["node_id"] + cluster_id: + description: + - The ID of the cluster where the node lives. + required: true + type: str + aliases: ["cluster"] + role: + description: + - The initial role of the Node in the Cluster. + - ACTIVE means the Node is available for encoding. + - BACKUP means the Node is a redundant Node and might get used if an ACTIVE Node fails. + required: true + type: str + choices: ["ACTIVE", "BACKUP"] + +extends_documentation_fragment: + - amazon.aws.common.modules + - amazon.aws.region.modules + - amazon.aws.boto3 +""" + +EXAMPLES = r""" +# Request a MediaLive Anywhere node registration script +- community.aws.medialive_node_registration: + cluster_id: '1234567' + node_id: '7654321' + role: 'ACTIVE' + register: response +""" + +RETURN = r""" +node_registration_script: + description: A script that can be run on a Bring Your Own Device Elemental Anywhere system to create a node in a cluster. + returned: success + type: str +""" + +import uuid +from typing import Dict + +try: + from botocore.exceptions import ClientError, BotoCoreError +except ImportError: + pass # caught by AnsibleAWSModule + +from ansible.module_utils.common.dict_transformations import snake_dict_to_camel_dict, camel_dict_to_snake_dict +from ansible_collections.amazon.aws.plugins.module_utils.core import AnsibleAWSModule +from ansible_collections.amazon.aws.plugins.module_utils.exceptions import AnsibleAWSError + + +class MediaLiveNodeRegistrationScriptManager: + '''Requests AWS MediaLive Anywhere node registration script''' + + def __init__(self, module: AnsibleAWSModule): + ''' + Initialize the MediaLiveNodeRegistrationScriptManager + + Args: + module: AnsibleAWSModule instance + ''' + self.module = module + self.client = self.module.client('medialive') + self._script = {} + self.changed = False + + @property + def script(self): + return self._script + + @script.setter + def script(self, script: Dict): + script = camel_dict_to_snake_dict(script) + if script.get('response_metadata'): + del script['response_metadata'] + self._script = script + + # TODO: this needs to be updated once the API and the docs are fixed + def do_create_registration_script(self, params): + """ + Create a new MediaLive node registration script + + Args: + params: Parameters for node registration script creation + """ + # NOTE: The API docs don't match the reality as of today + allowed_params = ['cluster_id', 'id', 'name', 'node_interface_mappings', 'request_id', 'role'] + required_params = ['cluster_id', 'id', 'role'] + + for param in required_params: + if not params.get(param): + raise AnsibleAWSError( + message=f'The {", ".join(required_params)} parameters are required when creating a new node registration script' + ) + + create_params = { k: v for k, v in params.items() if k in allowed_params and v } + create_params = snake_dict_to_camel_dict(create_params, capitalize_first=True) + + try: + self.script = self.client.create_node_registration_script(**create_params) # type: ignore + self.changed = True + except (ClientError, BotoCoreError) as e: + raise AnsibleAWSError( + message='Unable to create Medialive node registration script', + exception=e + ) + +def get_arg(arg:str, params:dict, spec:dict): + if arg in spec.keys(): + aliases = spec[arg].get('aliases', []) + for k, v in params.items(): + if k in [arg, *aliases] and v: + return v + +def main(): + """Main entry point for the module""" + argument_spec = dict( + id=dict(type='str', required=True, aliases=['node_id']), + cluster_id=dict(type='str', required=True), + role=dict(type='str', required=True, choices=["ACTIVE", "BACKUP"]) + ) + + module = AnsibleAWSModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + + # Extract module parameters + node_id = get_arg('id', module.params, argument_spec) + cluster_id = get_arg('cluster_id', module.params, argument_spec) + role = get_arg('role', module.params, argument_spec) + + # Initialize the manager + manager = MediaLiveNodeRegistrationScriptManager(module) + + # Do nothing in check mode + if module.check_mode: + module.exit_json(changed=True) + + # Create the script + create_params = { + 'cluster_id': cluster_id, + 'id': node_id, + 'role': role, + 'request_id': str(uuid.uuid4()) + } + + manager.do_create_registration_script(create_params) + + module.exit_json(changed=manager.changed, node=manager.script) + +if __name__ == '__main__': + main() diff --git a/plugins/modules/medialive_sdi_source.py b/plugins/modules/medialive_sdi_source.py new file mode 100644 index 00000000000..d85dab9c103 --- /dev/null +++ b/plugins/modules/medialive_sdi_source.py @@ -0,0 +1,367 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r""" +--- +module: medialive_sdi_source +version_added: 10.1.0 +short_description: Manage AWS MediaLive Anywhere SDI sources +description: + - A module for creating, updating and deleting AWS MediaLive Anywhere SDI sources. + - This module requires boto3 >= 1.37.34. +author: + - "Brenton Buxell (@buxell)" +options: + id: + description: + - The ID of the SDI source. + required: false + type: str + aliases: ['sdi_source_id'] + name: + description: + - The name of the SDI source. + required: false + type: str + aliases: ['sdi_source_name'] + state: + description: + - Create/update or remove the SDI source. + required: false + choices: ['present', 'absent'] + default: 'present' + type: str + mode: + description: + - Applies only if the type is 'QUAD' + - Specify the mode for handling the quad-link signal, 'QUADRANT' or 'INTERLEAVE' + type: str + required: false + choices: ['QUADRANT', 'INTERLEAVE'] + type: + description: + - Specify the type of the SDI source + - SINGLE, the source is a single-link source + - QUAD, the source is one part of a quad-link source + - Defaults to 'SINGLE' when creating a new SDI source + default: 'SINGLE' + type: str + required: false + choices: ['SINGLE', 'QUAD'] + +extends_documentation_fragment: + - amazon.aws.common.modules + - amazon.aws.region.modules + - amazon.aws.boto3 +""" + +EXAMPLES = r""" +- name: Create a MediaLive Anywhere SDI source + community.aws.medialive_sdi_source: + name: 'ExampleSdiSource' + state: present + type: 'QUAD' + mode: 'INTERLEAVE' + +- name: Delete a MediaLive Anywhere SDI source + community.aws.medialive_sdi_source: + name: 'ExampleSdiSource' + state: absent +""" + +RETURN = r""" +changed: + description: if a MediaLive SDI Source has been created, updated or deleted + returned: always + type: bool + sample: + changed: true +sdi_source: + description: The details of the SDI source + returned: success + type: dict + contains: + arn: + description: The ARN of the SDI source. + type: str + returned: success + example: "arn:aws:medialive:us-east-1:123456789012:sdiSource/123456" + id: + description: The ID of the SDI source + type: str + returned: success + example: "123456" + inputs: + description: The list of inputs that are currently using this SDI source + type: list + elements: str + returned: success + example: ["Input1", "Input2"] + mode: + description: Applies only if the type is QUAD. The mode for handling the quad-link signal QUADRANT or INTERLEAVE + type: str + returned: success + example: "QUADRANT" + name: + description: The name of the SDI source + type: str + returned: success + example: "ExampleSdiSource" + state: + description: The state of the SDI source. Either 'IDLE', 'IN_USE' or 'DELETED' + type: str + returned: success + example: "IN_USE" + type: + description: The type of the SDI source + type: str + returned: success + example: "SINGLE" +""" + +import uuid +from typing import Dict + +try: + from botocore.exceptions import ClientError, BotoCoreError +except ImportError: + pass # caught by AnsibleAWSModule + +from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict, snake_dict_to_camel_dict, recursive_diff +from ansible_collections.amazon.aws.plugins.module_utils.core import AnsibleAWSModule +from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_code +from ansible_collections.amazon.aws.plugins.module_utils.exceptions import AnsibleAWSError + + +class MediaLiveSdiSourceManager: + """Manage AWS MediaLive Anywhere SDI sources""" + + def __init__(self, module: AnsibleAWSModule): + """ + Initialize the MediaLiveSdiSourceManager + + Args: + module: AnsibleAWSModule instance + """ + self.module = module + self.client = module.client('medialive') + self._sdi_source = {} + self.changed = False + + @property + def sdi_source(self): + return self._sdi_source + + @sdi_source.setter + def sdi_source(self, sdi_source: Dict): + sdi_source = camel_dict_to_snake_dict(sdi_source) + if sdi_source.get('response_metadata'): + del sdi_source['response_metadata'] + + # Handle nested sdi_source + if sdi_source.get('sdi_source'): + sdi_source = sdi_source.get('sdi_source') # type: ignore + + if sdi_source.get('id'): + sdi_source['sdi_source_id'] = sdi_source.get('id') + del sdi_source['id'] + self._sdi_source = sdi_source + + def do_create_sdi_source(self, params): + """ + Create a new MediaLive SDI source + + Args: + params: Parameters for SDI source creation + """ + allowed_params = ['name', 'type', 'mode'] + required_params = ['name', 'type'] + + for param in required_params: + if not params.get(param): + raise AnsibleAWSError(message=f'The {param} parameter is required when creating a new SDI source') + + create_params = { k: v for k, v in params.items() if k in allowed_params and v } + create_params = snake_dict_to_camel_dict(create_params, capitalize_first=True) + + try: + response = self.client.create_sdi_source(**create_params) # type: ignore + self.sdi_source = response + self.changed = True + except (ClientError, BotoCoreError) as e: # type: ignore + raise AnsibleAWSError( + message='Unable to create Medialive SDI Source', + exception=e + ) + + def do_update_sdi_source(self, params): + """ + Update a new MediaLive SDI source + + Args: + params: Parameters for SDI source update + """ + if not params.get('sdi_source_id'): + raise AnsibleAWSError(message='The sdi_source_id parameter is required during SDI source update.') + + allowed_params = ['sdi_source_id', 'name', 'mode', 'type'] + + # Make sure current_params is always a subset of update_params, so we don't update unnecessarily + current_params = { + k: v for k, v in self.sdi_source.items() + if k in allowed_params and k in params and params[k] + } + update_params = { k: v for k, v in params.items() if k in allowed_params and v } + + # Short circuit + if not recursive_diff(current_params, update_params): + return + + update_params = snake_dict_to_camel_dict(update_params, capitalize_first=True) + + try: + response = self.client.update_sdi_source(**update_params) # type: ignore + self.sdi_source = response + self.changed = True + except (ClientError, BotoCoreError) as e: # type: ignore + raise AnsibleAWSError( + message='Unable to update Medialive SDI Source', + exception=e + ) + + def get_sdi_source_by_name(self, name: str): + """ + Find an SDI source by name + + Args: + name: The name of the SDI source to find + """ + try: + paginator = self.client.get_paginator('list_sdi_sources') # type: ignore + found = [] + for page in paginator.paginate(): + for sdi_source in page.get('SdiSources', []): + if sdi_source.get('Name') == name: + found.append(sdi_source.get('Id')) + if len(found) > 1: + raise AnsibleAWSError( + message='Found more than one Medialive SDI Sources under the same name' + ) + elif len(found) == 1: + self.get_sdi_source_by_id(found[0]) + + except (ClientError, BotoCoreError) as e: # type: ignore + raise AnsibleAWSError( + message='Unable to get Medialive SDI Source', + exception=e + ) + + def get_sdi_source_by_id(self, sdi_source_id: str): + """ + Get an SDI source by ID + + Args: + sdi_source_id: The ID of the SDI source to retrieve + """ + try: + self.sdi_source = self.client.describe_sdi_source(SdiSourceId=sdi_source_id) # type: ignore + return True + except is_boto3_error_code('ResourceNotFoundException'): + self.sdi_source = {} + + def delete_sdi_source_by_id(self, sdi_source_id: str): + """ + Delete a MediaLive SDI source + + Args: + sdi_source_id: ID of the SDI source to delete + """ + try: + self.sdi_source = self.client.delete_sdi_source(SdiSourceId=sdi_source_id) # type: ignore + self.changed = True + except is_boto3_error_code('ResourceNotFoundException'): + self.sdi_source = {} + except (ClientError, BotoCoreError) as e: # type: ignore + raise AnsibleAWSError( + message='Unable to delete Medialive SDI source', + exception=e + ) + + +def main(): + """Main entry point for the module""" + argument_spec = dict( + id=dict(type='str', required=False, aliases=['sdi_source_id']), + name=dict(type='str', required=False, aliases=['sdi_source_name']), + state=dict(type='str', default='present', choices=['present', 'absent']), + type=dict(type='str', required=False, choices=['SINGLE', 'QUAD']), + mode=dict(type='str', required=False, choices=['QUADRANT', 'INTERLEAVE']) + ) + + module = AnsibleAWSModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + + # Extract module parameters + sdi_source_id = module.params.get('id') + sdi_source_name = module.params.get('name') + state = module.params.get('state') + source_type = module.params.get('type') + mode = module.params.get('mode') + + # Initialize the manager + manager = MediaLiveSdiSourceManager(module) + + # Find the SDI source by ID or name + # Update manager.sdi_source with the details + if sdi_source_id: + manager.get_sdi_source_by_id(sdi_source_id) + elif sdi_source_name: + manager.get_sdi_source_by_name(sdi_source_name) + sdi_source_id = manager.sdi_source.get('sdi_source_id') + + # Do nothing in check mode + if module.check_mode: + module.exit_json(changed=True) + + # Handle present state + if state == 'present': + + # Case update + if manager.sdi_source: + update_params = { + 'name': sdi_source_name, + 'sdi_source_id': sdi_source_id, + 'mode': mode, + 'type': source_type + } + manager.do_update_sdi_source(update_params) + + # Case create + else: + if not source_type: + source_type = 'SINGLE' + create_params = { + 'mode': mode, + 'name': sdi_source_name, + 'request_id': str(uuid.uuid4()), + 'type': source_type + } + + manager.do_create_sdi_source(create_params) + + # Handle absent state + elif state == 'absent': + if manager.sdi_source and manager.sdi_source.get('state') != 'DELETED': + # SDI source exists, delete it + sdi_source_id = manager.sdi_source.get('sdi_source_id') + manager.delete_sdi_source_by_id(sdi_source_id) # type: ignore + + module.exit_json(changed=manager.changed, sdi_source=manager.sdi_source) + +if __name__ == '__main__': + main() diff --git a/plugins/modules/medialive_sdi_source_info.py b/plugins/modules/medialive_sdi_source_info.py new file mode 100644 index 00000000000..f02e52c7277 --- /dev/null +++ b/plugins/modules/medialive_sdi_source_info.py @@ -0,0 +1,208 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r""" +--- +module: medialive_sdi_source_info +version_added: 10.1.0 +short_description: Gather AWS MediaLive Anywhere SDI source info +description: + - Get details about a AWS MediaLive Anywhere SDI source. + - This module requires boto3 >= 1.37.34. +author: + - "Brenton Buxell (@bbuxell)" +options: + id: + description: + - The ID of the SDI source. + required: false + type: str + aliases: ['sdi_source_id'] + name: + description: + - The name of the SDI source. + required: false + type: str + aliases: ['sdi_source_name'] + +extends_documentation_fragment: + - amazon.aws.common.modules + - amazon.aws.region.modules + - amazon.aws.boto3 + - amazon.aws.tags +""" + +EXAMPLES = r""" +- name: Create a MediaLive Anywhere SDI source by name + community.aws.medialive_sdi_source_info: + name: 'ExampleSdiSource' + register: found_source + +- name: Create a MediaLive Anywhere SDI source by ID + community.aws.medialive_sdi_source_info: + id: '1234567' + register: found_source +""" + +RETURN = r""" +sdi_source: + description: The details of the SDI source + returned: success + type: dict + contains: + arn: + description: The ARN of the SDI source. + type: str + returned: success + example: "arn:aws:medialive:us-east-1:123456789012:sdiSource/123456" + id: + description: The ID of the SDI source + type: str + returned: success + example: "123456" + inputs: + description: The list of inputs that are currently using this SDI source + type: list + elements: str + returned: success + example: ["Input1", "Input2"] + mode: + description: Applies only if the type is QUAD. The mode for handling the quad-link signal QUADRANT or INTERLEAVE + type: str + returned: success + example: "QUADRANT" + name: + description: The name of the SDI source + type: str + returned: success + example: "ExampleSdiSource" + state: + description: The state of the SDI source + type: str + returned: success + example: "IN_USE" + type: + description: The type of the SDI source + type: str + returned: success + example: "SINGLE" +""" + +from typing import Dict + +from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict +from ansible_collections.amazon.aws.plugins.module_utils.core import AnsibleAWSModule +from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_code +from ansible_collections.amazon.aws.plugins.module_utils.exceptions import AnsibleAWSError + + +class MediaLiveSdiSourceGetter: + """Manage AWS MediaLive Anywhere SDI sources""" + + def __init__(self, module: AnsibleAWSModule): + """ + Initialize the MediaLiveSdiSourceManager + + Args: + module: AnsibleAWSModule instance + """ + self.module = module + self.client = module.client('medialive') + self._sdi_source = {} + + @property + def sdi_source(self): + return self._sdi_source + + @sdi_source.setter + def sdi_source(self, sdi_source: Dict): + sdi_source = camel_dict_to_snake_dict(sdi_source) + if sdi_source.get('response_metadata'): + del sdi_source['response_metadata'] + + # Handle nested sdi_source + if sdi_source.get('sdi_source'): + sdi_source = sdi_source.get('sdi_source') # type: ignore + + if sdi_source.get('id'): + sdi_source['sdi_source_id'] = sdi_source.get('id') + del sdi_source['id'] + self._sdi_source = sdi_source + + def get_sdi_source_by_name(self, name: str): + """ + Find an SDI source by name + + Args: + name: The name of the SDI source to find + """ + try: + paginator = self.client.get_paginator('list_sdi_sources') # type: ignore + found = [] + for page in paginator.paginate(): + for sdi_source in page.get('SdiSources', []): + if sdi_source.get('Name') == name: + found.append(sdi_source.get('Id')) + if len(found) > 1: + raise AnsibleAWSError( + message='Found more than one Medialive SDI Sources under the same name' + ) + elif len(found) == 1: + self.get_sdi_source_by_id(found[0]) + + except (ClientError, BotoCoreError) as e: # type: ignore + raise AnsibleAWSError( + message='Unable to get Medialive SDI Source', + exception=e + ) + + def get_sdi_source_by_id(self, sdi_source_id: str): + """ + Get an SDI source by ID + + Args: + sdi_source_id: The ID of the SDI source to retrieve + """ + try: + self.sdi_source = self.client.describe_sdi_source(SdiSourceId=sdi_source_id) # type: ignore + return True + except is_boto3_error_code('ResourceNotFoundException'): + self.sdi_source = {} + +def main(): + """Main entry point for the module""" + argument_spec = dict( + id=dict(type='str', required=False, aliases=['sdi_source_id']), + name=dict(type='str', required=False, aliases=['sdi_source_name']) + ) + + module = AnsibleAWSModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + + # Do nothing in check mode + if module.check_mode: + module.exit_json(changed=True) + + # Extract module parameters + sdi_source_id = module.params.get('id') + sdi_source_name = module.params.get('name') + + # Initialize the getter + getter = MediaLiveSdiSourceGetter(module) + + # Find the SDI source by ID or name + # Update manager.sdi_source with the details + if sdi_source_id: + getter.get_sdi_source_by_id(sdi_source_id) + elif sdi_source_name: + getter.get_sdi_source_by_name(sdi_source_name) + + module.exit_json(changed=False, sdi_source=getter.sdi_source) + +if __name__ == '__main__': + main() diff --git a/tests/integration/targets/medialive_channel_placement_group/aliases b/tests/integration/targets/medialive_channel_placement_group/aliases new file mode 100644 index 00000000000..4ef4b2067d0 --- /dev/null +++ b/tests/integration/targets/medialive_channel_placement_group/aliases @@ -0,0 +1 @@ +cloud/aws diff --git a/tests/integration/targets/medialive_channel_placement_group/meta/main.yml b/tests/integration/targets/medialive_channel_placement_group/meta/main.yml new file mode 100644 index 00000000000..16c24927b55 --- /dev/null +++ b/tests/integration/targets/medialive_channel_placement_group/meta/main.yml @@ -0,0 +1,4 @@ +dependencies: + - role: setup_botocore_pip + vars: + botocore_version: "1.35.17" diff --git a/tests/integration/targets/medialive_channel_placement_group/tasks/cleanup.yml b/tests/integration/targets/medialive_channel_placement_group/tasks/cleanup.yml new file mode 100644 index 00000000000..b66233d7b6e --- /dev/null +++ b/tests/integration/targets/medialive_channel_placement_group/tasks/cleanup.yml @@ -0,0 +1,32 @@ +--- +- name: Cleanup - ensure channel placement group is deleted + community.aws.medialive_channel_placement_group: + id: "{{ cpg_id }}" + cluster_id: "{{ cluster_id }}" + state: absent + wait: true + ignore_errors: true + when: cpg_id is defined + +- name: Cleanup - ensure cluster is deleted + community.aws.medialive_cluster: + id: "{{ cluster_id }}" + state: absent + wait: true + ignore_errors: true + when: cluster_id is defined + +- name: Cleanup - ensure network is deleted + community.aws.medialive_network: + id: "{{ network_id }}" + state: absent + ignore_errors: true + when: network_id is defined + +- name: Cleanup - ensure role is deleted + amazon.aws.iam_role: + name: '{{ instance_role_name }}' + state: absent + wait: true + ignore_errors: true + when: instance_role_arn is defined diff --git a/tests/integration/targets/medialive_channel_placement_group/tasks/main.yml b/tests/integration/targets/medialive_channel_placement_group/tasks/main.yml new file mode 100644 index 00000000000..a248fedd208 --- /dev/null +++ b/tests/integration/targets/medialive_channel_placement_group/tasks/main.yml @@ -0,0 +1,266 @@ +--- +# Integration tests for medialive_channel_placement_group module +- name: Wrap MediaLive CPG tests with 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 }}' + set_fact: + ansible_python_interpreter: '{{ botocore_virtualenv_interpreter }}' + + block: + # Setup test variables + - name: Set up test variables + ansible.builtin.set_fact: + cluster_name: "{{ resource_prefix }}-cluster" + network_name: "{{ resource_prefix }}-network" + instance_role_name: "{{ resource_prefix }}-role" + cpg_name: "{{ resource_prefix }}-cpg" + ip_pool_cidr: "10.21.21.0/24" + route_cidr: "0.0.0.0/0" + route_gateway: "10.21.21.1" + wait_timeout: 60 + + # Create a network first for the cluster to use + - name: Create a MediaLive Anywhere network for the cluster + community.aws.medialive_network: + name: "{{ network_name }}" + state: present + ip_pools: + - cidr: "{{ ip_pool_cidr }}" + routes: + - cidr: "{{ route_cidr }}" + gateway: "{{ route_gateway }}" + register: network_result + retries: 3 + delay: 5 + until: network_result is not failed or 'TooManyRequestsException' not in (network_result.msg | default('')) + + # Create a simple IAM role for MediaLive Anywhere nodes + - name: Create MediaLive Anywhere node IAM role + amazon.aws.iam_role: + name: '{{ instance_role_name }}' + assume_role_policy_document: | + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": [ + "medialive.amazonaws.com", + "ssm.amazonaws.com" + ] + }, + "Action": [ + "sts:AssumeRole", + "sts:TagSession" + ] + } + ] + } + state: present + create_instance_profile: false + register: iam_role + + - name: Capture network_id and instance_role_arn + ansible.builtin.set_fact: + network_id: "{{ network_result.network.network_id }}" + instance_role_arn: "{{ iam_role.iam_role.arn }}" + when: + - network_result is defined + - network_result.changed is defined + - iam_role is defined + - iam_role.changed is defined + + # Create a cluster first to host the channel placement group + - name: Create a MediaLive Anywhere cluster + community.aws.medialive_cluster: + name: "{{ cluster_name }}" + state: present + cluster_type: "ON_PREMISES" + instance_role_arn: "{{ instance_role_arn }}" + network_settings: + default_route: "management" + interface_mappings: + - logical_interface_name: "management" + network_id: "{{ network_id }}" + - logical_interface_name: "input" + network_id: "{{ network_id }}" + wait: true + wait_timeout: "{{ wait_timeout }}" + register: cluster_result + retries: 3 + delay: 5 + until: cluster_result is not failed or 'TooManyRequestsException' not in (cluster_result.msg | default('')) + when: network_id is defined + + - name: Capture cluster_id + ansible.builtin.set_fact: + cluster_id: '{{ cluster_result.cluster.cluster_id }}' + + # Test creating a channel placement group in the cluster + - name: Create a MediaLive Anywhere channel placement group + community.aws.medialive_channel_placement_group: + cluster_id: '{{ cluster_id }}' + name: '{{ cpg_name }}' + state: present + wait: true + wait_timeout: "{{ wait_timeout }}" + register: create_result + retries: 3 + delay: 5 + until: create_result is not failed or 'TooManyRequestsException' not in (create_result.msg | default('')) + when: cluster_id is defined + + - name: Assert channel placement group was created successfully + ansible.builtin.assert: + that: + - create_result is changed + - create_result.channel_placement_group.arn is defined + - create_result.channel_placement_group.arn != '' + - create_result.channel_placement_group.channels is defined + - create_result.channel_placement_group.channels == [] + - create_result.channel_placement_group.cluster_id is defined + - create_result.channel_placement_group.cluster_id != '' + - create_result.channel_placement_group.channel_placement_group_id is defined + - create_result.channel_placement_group.channel_placement_group_id != '' + - create_result.channel_placement_group.name is defined + - create_result.channel_placement_group.name != '' + - create_result.channel_placement_group.nodes is defined + - create_result.channel_placement_group.nodes == [] + - create_result.channel_placement_group.state is defined + - create_result.channel_placement_group.state == 'UNASSIGNED' + fail_msg: "Channel placement group creation failed or returned unexpected data" + success_msg: "Channel placement group created successfully with expected properties" + when: + - create_result is defined + - create_result.channel_placement_group is defined + + - name: Capture cpg_id + ansible.builtin.set_fact: + cpg_id: '{{ create_result.channel_placement_group.channel_placement_group_id }}' + + # Test idempotency + - name: Create the same channel placement group again (idempotency check) + community.aws.medialive_channel_placement_group: + cluster_id: '{{ cluster_id }}' + name: '{{ cpg_name }}' + state: present + wait: true + wait_timeout: "{{ wait_timeout }}" + register: idempotency_result + when: cluster_id is defined + + - name: Assert idempotency + ansible.builtin.assert: + that: + - not idempotency_result.changed + - idempotency_result.channel_placement_group.channel_placement_group_id == cpg_id + fail_msg: "Idempotency check failed - module made changes when none were expected" + success_msg: "Idempotency check passed" + when: + - idempotency_result is defined + - idempotency_result.channel_placement_group is defined + + # Test retrieval by ID + - name: Get channel placement group by ID + community.aws.medialive_channel_placement_group_info: + channel_placement_group_id: "{{ cpg_id }}" + cluster_id: "{{ cluster_id }}" + register: get_by_id_result + when: + - cpg_id is defined + - cluster_id is defined + + - name: Assert channel placement group was retrieved by ID + ansible.builtin.assert: + that: + - not get_by_id_result.changed + - get_by_id_result.channel_placement_group.channel_placement_group_id == cpg_id + - get_by_id_result.channel_placement_group.name == cpg_name + fail_msg: "Failed to retrieve channel placement group by ID" + success_msg: "Successfully retrieved channel placement group by ID" + when: + - get_by_id_result is defined + - get_by_id_result.channel_placement_group is defined + + # Test check mode + - name: Test check mode - attempt to delete channel placement group + community.aws.medialive_channel_placement_group: + channel_placement_group_id: "{{ cpg_id }}" + cluster_id: "{{ cluster_id }}" + state: absent + check_mode: true + register: check_mode_result + when: cpg_id is defined + + - name: Assert check mode works correctly + ansible.builtin.assert: + that: + - check_mode_result is changed + - (check_mode_result.channel_placement_group | default({})) == {} + fail_msg: "Check mode did not work as expected" + success_msg: "Check mode correctly simulated deletion" + when: check_mode_result is defined + + + # Test channel placement group deletion + - name: Delete the channel placement group + community.aws.medialive_channel_placement_group: + channel_placement_group_id: "{{ cpg_id }}" + cluster_id: "{{ cluster_id }}" + state: absent + wait: true + wait_timeout: "{{ wait_timeout }}" + register: delete_result + retries: 3 + delay: 5 + until: delete_result is not failed or 'TooManyRequestsException' not in (delete_result.msg | default('')) + when: cpg_id is defined + + - name: Assert channel placement group was deleted + ansible.builtin.assert: + that: + - delete_result is changed + fail_msg: "Channel placement group deletion failed" + success_msg: "Channel placement group deleted successfully" + when: delete_result is defined + + # Test deletion idempotency + - name: Try to delete the channel placement group again (idempotency check) + community.aws.medialive_channel_placement_group: + id: "{{ cpg_id }}" + cluster_id: "{{ cluster_id }}" + state: absent + register: delete_idempotency_result + when: cpg_id is defined + + - name: Assert delete idempotency + ansible.builtin.assert: + that: + - not delete_idempotency_result.changed + fail_msg: "Delete idempotency check failed" + success_msg: "Delete idempotency check passed" + when: delete_idempotency_result is defined + + # Error handling and cleanup + rescue: + - name: Capture error + ansible.builtin.set_fact: + test_failed: true + + - name: Clean up + ansible.builtin.include_tasks: cleanup.yml + + - name: Fail with useful error message + ansible.builtin.fail: + msg: "MediaLive channel placement group integration tests failed. See previous errors for details." + when: test_failed | default(false) + + always: + - name: Ensure all test resources are cleaned up + ansible.builtin.include_tasks: cleanup.yml + diff --git a/tests/integration/targets/medialive_cluster/aliases b/tests/integration/targets/medialive_cluster/aliases new file mode 100644 index 00000000000..4ef4b2067d0 --- /dev/null +++ b/tests/integration/targets/medialive_cluster/aliases @@ -0,0 +1 @@ +cloud/aws diff --git a/tests/integration/targets/medialive_cluster/meta/main.yml b/tests/integration/targets/medialive_cluster/meta/main.yml new file mode 100644 index 00000000000..16c24927b55 --- /dev/null +++ b/tests/integration/targets/medialive_cluster/meta/main.yml @@ -0,0 +1,4 @@ +dependencies: + - role: setup_botocore_pip + vars: + botocore_version: "1.35.17" diff --git a/tests/integration/targets/medialive_cluster/tasks/main.yml b/tests/integration/targets/medialive_cluster/tasks/main.yml new file mode 100644 index 00000000000..a3a4951370a --- /dev/null +++ b/tests/integration/targets/medialive_cluster/tasks/main.yml @@ -0,0 +1,294 @@ +--- +# Integration tests for medialive_cluster module +- name: Wrap MediaLive Cluster tests with 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 }}' + set_fact: + ansible_python_interpreter: '{{ botocore_virtualenv_interpreter }}' + + block: + # Setup test variables + - name: Set up test variables + set_fact: + cluster_name: "{{ resource_prefix }}-cluster" + network_name: "{{ resource_prefix }}-network" + ip_pool_cidr: "10.21.21.0/24" + route_cidr: "0.0.0.0/0" + route_gateway: "10.21.21.1" + wait_timeout: 30 + + # Create the required IAM role for MediaLive Anywhere nodes + - name: Create MediaLive Anywhere node IAM role + amazon.aws.iam_role: + name: ansible-test-MLANodeRole + assume_role_policy_document: | + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "medialive.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] + } + state: present + create_instance_profile: false + managed_policies: + - arn:aws:iam::aws:policy/AWSElementalMediaLiveFullAccess + tags: + Environment: Test + Purpose: Integration Testing + register: iam_role + + - name: Set IAM role ARN + set_fact: + instance_role_arn: "{{ iam_role.iam_role.arn }}" + + # Create a network first for the cluster to use + - name: Create a MediaLive Anywhere network for the cluster + community.aws.medialive_network: + name: "{{ network_name }}" + state: present + ip_pools: + - cidr: "{{ ip_pool_cidr }}" + routes: + - cidr: "{{ route_cidr }}" + gateway: "{{ route_gateway }}" + register: network_result + retries: 3 # Add retries for API throttling resilience + delay: 5 + until: network_result is not failed or 'TooManyRequestsException' not in (network_result.msg | default('')) + + - name: Store network ID + set_fact: + network_id: "{{ network_result.network.network_id }}" + when: network_result is defined and network_result.changed is defined + + # Test cluster creation + - name: Create a MediaLive Anywhere cluster + community.aws.medialive_cluster: + name: "{{ cluster_name }}" + state: present + cluster_type: "ON_PREMISES" + instance_role_arn: "{{ instance_role_arn }}" + network_settings: + default_route: "management" + interface_mappings: + - logical_interface_name: "management" + network_id: "{{ network_id }}" + - logical_interface_name: "input" + network_id: "{{ network_id }}" + wait: true + wait_timeout: "{{ wait_timeout }}" + register: create_result + retries: 3 # Add retries for API throttling resilience + delay: 5 + until: create_result is not failed or 'TooManyRequestsException' not in (create_result.msg | default('')) + when: network_id is defined + + - name: Assert cluster was created successfully + assert: + that: + - create_result is changed + - create_result.cluster is defined + - create_result.cluster.name == cluster_name + - create_result.cluster.state in ['ACTIVE', 'IDLE', 'IN_USE'] + - create_result.cluster.cluster_type == "ON_PREMISES" + - create_result.cluster.instance_role_arn == instance_role_arn + - create_result.cluster.network_settings.default_route == "management" + - create_result.cluster.network_settings.interface_mappings | length == 2 + - create_result.cluster.cluster_id is defined + - create_result.cluster.arn is defined + fail_msg: "Cluster creation failed or returned unexpected data" + success_msg: "Cluster created successfully with expected properties" + when: create_result is defined and create_result.cluster is defined + + # Store cluster ID for later use + - name: Store cluster ID + set_fact: + cluster_id: "{{ create_result.cluster.cluster_id }}" + when: create_result is defined and create_result.cluster is defined + + # Test idempotency + - name: Create the same cluster again (idempotency check) + community.aws.medialive_cluster: + name: "{{ cluster_name }}" + state: present + cluster_type: "ON_PREMISES" + instance_role_arn: "{{ instance_role_arn }}" + network_settings: + default_route: "management" + interface_mappings: + - logical_interface_name: "management" + network_id: "{{ network_id }}" + - logical_interface_name: "input" + network_id: "{{ network_id }}" + wait: true + wait_timeout: "{{ wait_timeout }}" + register: idempotency_result + when: cluster_id is defined + + - name: Assert idempotency + assert: + that: + - not idempotency_result.changed + - idempotency_result.cluster is defined + - idempotency_result.cluster.cluster_id == cluster_id + fail_msg: "Idempotency check failed - module made changes when none were expected" + success_msg: "Idempotency check passed" + when: idempotency_result is defined and idempotency_result.cluster is defined + + - name: Get cluster by ID + community.aws.medialive_cluster_info: + id: "{{ cluster_id }}" + register: get_by_id_result + when: cluster_id is defined + + - name: Assert cluster was retrieved by ID + assert: + that: + - not get_by_id_result.changed + - get_by_id_result.cluster is defined + - get_by_id_result.cluster.cluster_id == cluster_id + - get_by_id_result.cluster.name == cluster_name + fail_msg: "Failed to retrieve cluster by ID" + success_msg: "Successfully retrieved cluster by ID" + when: get_by_id_result is defined and get_by_id_result.cluster is defined + + - name: Get cluster by name + community.aws.medialive_cluster_info: + name: "{{ cluster_name }}" + register: get_by_name_result + when: cluster_name is defined + + - name: Assert cluster was retrieved by name + assert: + that: + - not get_by_name_result.changed + - get_by_name_result.cluster is defined + - get_by_name_result.cluster.cluster_id == cluster_id + - get_by_name_result.cluster.name == cluster_name + fail_msg: "Failed to retrieve cluster by name" + success_msg: "Successfully retrieved cluster by name" + when: get_by_name_result is defined and get_by_name_result.cluster is defined + + # Test check mode + - name: Test check mode - attempt to delete cluster + community.aws.medialive_cluster: + name: "{{ cluster_name }}" + state: absent + check_mode: true + register: check_mode_result + when: cluster_id is defined + + - name: Assert check mode works correctly + assert: + that: + - check_mode_result is changed + - (check_mode_result.cluster | default({})) == {} + fail_msg: "Check mode did not work as expected" + success_msg: "Check mode correctly simulated deletion" + when: check_mode_result is defined + + # Test cluster deletion + - name: Delete the cluster + community.aws.medialive_cluster: + name: "{{ cluster_name }}" + state: absent + wait: true + wait_timeout: "{{ wait_timeout }}" + register: delete_result + retries: 3 # Add retries for API throttling resilience + delay: 5 + until: delete_result is not failed or 'TooManyRequestsException' not in (delete_result.msg | default('')) + when: cluster_id is defined + + - name: Assert cluster was deleted + assert: + that: + - delete_result is changed + fail_msg: "Cluster deletion failed" + success_msg: "Cluster deleted successfully" + when: delete_result is defined + + # Test deletion idempotency + - name: Try to delete the cluster again (idempotency check) + community.aws.medialive_cluster: + name: "{{ cluster_name }}" + state: absent + register: delete_idempotency_result + when: cluster_id is defined + + - name: Assert delete idempotency + assert: + that: + - not delete_idempotency_result.changed + fail_msg: "Delete idempotency check failed" + success_msg: "Delete idempotency check passed" + when: delete_idempotency_result is defined + + # Clean up the network + - name: Delete the network + community.aws.medialive_network: + name: "{{ network_name }}" + state: absent + when: network_id is defined + ignore_errors: true + + # Error handling and cleanup + rescue: + - name: Capture error + set_fact: + test_failed: true + + - name: Cleanup - ensure cluster is deleted + community.aws.medialive_cluster: + name: "{{ cluster_name }}" + state: absent + wait: true + ignore_errors: true + when: cluster_id is defined + + - name: Cleanup - ensure network is deleted + community.aws.medialive_network: + name: "{{ network_name }}" + state: absent + ignore_errors: true + when: network_id is defined + + - name: Fail with useful error message + fail: + msg: "MediaLive Cluster integration tests failed. See previous errors for details." + when: test_failed | default(false) + + always: + - name: Ensure all test resources are cleaned up (cluster) + community.aws.medialive_cluster: + name: "{{ cluster_name }}" + state: absent + wait: true + ignore_errors: true + when: cluster_id is defined + + - name: Ensure all test resources are cleaned up (network) + community.aws.medialive_network: + name: "{{ network_name }}" + state: absent + ignore_errors: true + when: network_id is defined + + - name: Ensure all test resources are cleaned up (role) + amazon.aws.iam_role: + name: "{{ iam_role.iam_role.role_name }}" + state: absent + wait: true + ignore_errors: true + when: instance_role_arn is defined + diff --git a/tests/integration/targets/medialive_input/aliases b/tests/integration/targets/medialive_input/aliases new file mode 100644 index 00000000000..4ef4b2067d0 --- /dev/null +++ b/tests/integration/targets/medialive_input/aliases @@ -0,0 +1 @@ +cloud/aws diff --git a/tests/integration/targets/medialive_input/meta/main.yml b/tests/integration/targets/medialive_input/meta/main.yml new file mode 100644 index 00000000000..e34d7fe10c7 --- /dev/null +++ b/tests/integration/targets/medialive_input/meta/main.yml @@ -0,0 +1,4 @@ +dependencies: + - role: setup_botocore_pip + vars: + botocore_version: "1.37.34" diff --git a/tests/integration/targets/medialive_input/tasks/main.yml b/tests/integration/targets/medialive_input/tasks/main.yml new file mode 100644 index 00000000000..988d474cb44 --- /dev/null +++ b/tests/integration/targets/medialive_input/tasks/main.yml @@ -0,0 +1,217 @@ +--- +# Integration tests for medialive_input module +- name: Wrap MediaLive Input tests with 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 }}' + set_fact: + ansible_python_interpreter: '{{ botocore_virtualenv_interpreter }}' + + block: + # Setup test variables + - name: Set up test variables + ansible.builtin.set_fact: + input_name: "{{ resource_prefix }}-input" + sdi_source: + name_single: "{{ resource_prefix }}-sdi-source-single" + name_alt: "{{ resource_prefix }}-alt-sdi-source" + name_quad: "{{ resource_prefix }}-sdi-source-quad" + # sdi_source_mode: "QUADRANT" + # sdi_source_mode_interleave: "INTERLEAVE" + # quad_source_type: "QUAD" + # single_source_type: "SINGLE" + wait_timeout: 30 + + # Create MediaLive SDI source + - name: Create a single MediaLive SDI source + community.aws.medialive_sdi_source: + name: '{{ sdi_source.name_single }}' + type: SINGLE + state: present + register: sdi_source_result + + # Test Input creation: SDI source + - name: Create a MediaLive Anywhere Input with SDI source + community.aws.medialive_input: + name: "{{ input_name }}" + state: present + type: SDI + input_network_location: ON_PREMISES + sdi_sources: + - '{{ sdi_source_result.sdi_source.sdi_source_id }}' + tags: + AnsibleTest: '{{ resource_prefix }}-sdi_source' + wait: true + wait_timeout: "{{ wait_timeout }}" + register: create_result + retries: 3 # Add retries for API throttling resilience + delay: 5 + until: create_result is not failed or 'TooManyRequestsException' not in (create_result.msg | default('')) + + - name: Assert Input was created successfully + ansible.builtin.assert: + that: + - create_result is changed + - create_result.input is defined + - create_result.input.name == input_name + - create_result.input.input_id is defined + - create_result.input.arn is defined + - create_result.input.state == 'DETACHED' + - create_result.input.type == 'SDI' + - create_result.input.input_network_location == 'ON_PREMISES' + - create_result.input.sdi_sources | length == 1 + - create_result.input.sdi_sources[0] == sdi_source_result.sdi_source.sdi_source_id + - create_result.input.tags.AnsibleTest is defined + - create_result.input.tags.AnsibleTest == resource_prefix + '-sdi_source' + fail_msg: "Input creation failed or returned unexpected data" + success_msg: "Input created successfully with expected properties" + + # Store Input ID for later use + - name: Store Input ID + ansible.builtin.set_fact: + input_id: "{{ create_result.input.input_id }}" + + # Test idempotency + - name: Create the same Input again (idempotency check) + community.aws.medialive_input: + name: "{{ input_name }}" + state: present + type: SDI + input_network_location: ON_PREMISES + sdi_sources: + - '{{ sdi_source_result.sdi_source.sdi_source_id }}' + tags: + AnsibleTest: '{{ resource_prefix }}-sdi_source' + wait: true + wait_timeout: "{{ wait_timeout }}" + register: idempotency_result + + - name: Assert idempotency + ansible.builtin.assert: + that: + - not idempotency_result.changed + - idempotency_result.input is defined + - idempotency_result.input.input_id == input_id + fail_msg: "Idempotency check failed - module made changes when none were expected" + success_msg: "Idempotency check passed" + + # Test retrieval by ID + - name: Get Input by ID + community.aws.medialive_input_info: + id: "{{ input_id }}" + register: get_by_id_result + + - name: Assert Input was retrieved by ID + ansible.builtin.assert: + that: + - not get_by_id_result.changed + - get_by_id_result.input is defined + - get_by_id_result.input.input_id == input_id + - get_by_id_result.input.name == input_name + fail_msg: "Failed to retrieve Input by ID" + success_msg: "Successfully retrieved Input by ID" + + # Test retrieval by name + - name: Get Input by name + community.aws.medialive_input_info: + name: "{{ input_name }}" + register: get_by_name_result + + - name: Assert Input was retrieved by name + ansible.builtin.assert: + that: + - not get_by_name_result.changed + - get_by_name_result.input is defined + - get_by_name_result.input.input_id == input_id + - get_by_name_result.input.name == input_name + fail_msg: "Failed to retrieve Input by name" + success_msg: "Successfully retrieved Input by name" + + # Test check mode + - name: Test check mode - attempt to delete Input + community.aws.medialive_input: + name: "{{ input_name }}" + state: absent + check_mode: true + register: check_mode_result + + - name: Assert check mode works correctly + ansible.builtin.assert: + that: + - check_mode_result is changed + - (check_mode_result.input | default({})) == {} + fail_msg: "Check mode did not work as expected" + success_msg: "Check mode correctly simulated deletion" + + # Test Input deletion + - name: Delete the Input + community.aws.medialive_input: + name: "{{ input_name }}" + state: absent + wait: true + wait_timeout: "{{ wait_timeout }}" + register: delete_result + retries: 3 # Add retries for API throttling resilience + delay: 5 + until: delete_result is not failed or 'TooManyRequestsException' not in (delete_result.msg | default('')) + + - name: Assert Input was deleted + ansible.builtin.assert: + that: + - delete_result is changed + fail_msg: "Input deletion failed" + success_msg: "Input deleted successfully" + + # Test deletion idempotency + - name: Try to delete the Input again (idempotency check) + community.aws.medialive_input: + name: "{{ input_name }}" + state: absent + register: delete_idempotency_result + + - name: Assert delete idempotency + ansible.builtin.assert: + that: + - not delete_idempotency_result.changed + fail_msg: "Delete idempotency check failed" + success_msg: "Delete idempotency check passed" + + # Error handling and cleanup + rescue: + - name: Capture error + ansible.builtin.set_fact: + test_failed: true + + - name: Cleanup - ensure Input is deleted + community.aws.medialive_input: + name: "{{ input_name }}" + state: absent + wait: true + ignore_errors: true + when: input_id is defined + + - name: Fail with useful error message + ansible.builtin.fail: + msg: "MediaLive Input integration tests failed. See previous errors for details." + when: test_failed | default(false) + + always: + - name: Ensure all test resources are cleaned up + block: + - name: Clean up Input + community.aws.medialive_input: + name: "{{ input_name }}" + state: absent + wait: false # Don't wait in cleanup to speed up test completion + ignore_errors: true + when: input_id is defined + + - name: Clean up SDI source + community.aws.medialive_sdi_source: + name: '{{ sdi_source.name_single }}' + state: absent + ignore_errors: true + when: sdi_source_result is defined diff --git a/tests/integration/targets/medialive_network/aliases b/tests/integration/targets/medialive_network/aliases new file mode 100644 index 00000000000..4ef4b2067d0 --- /dev/null +++ b/tests/integration/targets/medialive_network/aliases @@ -0,0 +1 @@ +cloud/aws diff --git a/tests/integration/targets/medialive_network/meta/main.yml b/tests/integration/targets/medialive_network/meta/main.yml new file mode 100644 index 00000000000..16c24927b55 --- /dev/null +++ b/tests/integration/targets/medialive_network/meta/main.yml @@ -0,0 +1,4 @@ +dependencies: + - role: setup_botocore_pip + vars: + botocore_version: "1.35.17" diff --git a/tests/integration/targets/medialive_network/tasks/main.yml b/tests/integration/targets/medialive_network/tasks/main.yml new file mode 100644 index 00000000000..e174e0da2d9 --- /dev/null +++ b/tests/integration/targets/medialive_network/tasks/main.yml @@ -0,0 +1,183 @@ +--- +# Integration tests for medialive_network module +- name: Wrap MediaLive Network tests with 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 }}' + set_fact: + ansible_python_interpreter: '{{ botocore_virtualenv_interpreter }}' + + block: + # Setup test variables + - name: Set up test variables + ansible.builtin.set_fact: + network_name: "{{ resource_prefix }}-network" + ip_pool_cidr: "10.21.21.0/24" + route_cidr: "0.0.0.0/0" + route_gateway: "10.21.21.1" + + # Test network creation + - name: Create a MediaLive Anywhere network + community.aws.medialive_network: + name: "{{ network_name }}" + state: present + ip_pools: + - cidr: "{{ ip_pool_cidr }}" + routes: + - cidr: "{{ route_cidr }}" + gateway: "{{ route_gateway }}" + register: create_result + retries: 3 # Add retries for API throttling resilience + delay: 5 + until: create_result is not failed or 'TooManyRequestsException' not in (create_result.msg | default('')) + + - name: Assert network was created successfully + ansible.builtin.assert: + that: + - create_result is changed + - create_result.network is defined + - create_result.network.name == network_name + - create_result.network.state in ['ACTIVE', 'IDLE', 'IN_USE'] + - create_result.network.ip_pools | length == 1 + - create_result.network.ip_pools[0].cidr == ip_pool_cidr + - create_result.network.routes | length == 1 + - create_result.network.routes[0].cidr == route_cidr + - create_result.network.routes[0].gateway == route_gateway + - create_result.network.network_id is defined + - create_result.network.arn is defined + fail_msg: "Network creation failed or returned unexpected data" + success_msg: "Network created successfully with expected properties" + + # Store network ID for later use + - name: Store network ID + ansible.builtin.set_fact: + network_id: "{{ create_result.network.network_id }}" + + # Test idempotency + - name: Create the same network again (idempotency check) + community.aws.medialive_network: + name: "{{ network_name }}" + state: present + ip_pools: + - cidr: "{{ ip_pool_cidr }}" + routes: + - cidr: "{{ route_cidr }}" + gateway: "{{ route_gateway }}" + register: idempotency_result + + - name: Assert idempotency + ansible.builtin.assert: + that: + - not idempotency_result.changed + - idempotency_result.network is defined + - idempotency_result.network.network_id == network_id + fail_msg: "Idempotency check failed - module made changes when none were expected" + success_msg: "Idempotency check passed" + + # Test retrieval by ID + - name: Get network by ID + community.aws.medialive_network_info: + id: "{{ network_id }}" + register: get_by_id_result + + - name: Assert network was retrieved by ID + ansible.builtin.assert: + that: + - not get_by_id_result.changed + - get_by_id_result.network is defined + - get_by_id_result.network.network_id == network_id + - get_by_id_result.network.name == network_name + fail_msg: "Failed to retrieve network by ID" + success_msg: "Successfully retrieved network by ID" + + # Test retrieval by name + - name: Get network by name + community.aws.medialive_network_info: + name: "{{ network_name }}" + register: get_by_name_result + + - name: Assert network was retrieved by name + ansible.builtin.assert: + that: + - not get_by_name_result.changed + - get_by_name_result.network is defined + - get_by_name_result.network.network_id == network_id + - get_by_name_result.network.name == network_name + fail_msg: "Failed to retrieve network by name" + success_msg: "Successfully retrieved network by name" + + # Test check mode + - name: Test check mode - attempt to delete network + community.aws.medialive_network: + name: "{{ network_name }}" + state: absent + check_mode: true + register: check_mode_result + + - name: Assert check mode works correctly + ansible.builtin.assert: + that: + - check_mode_result is changed + - (check_mode_result.network | default({})) == {} + fail_msg: "Check mode did not work as expected" + success_msg: "Check mode correctly simulated deletion" + + # Test network deletion + - name: Delete the network + community.aws.medialive_network: + name: "{{ network_name }}" + state: absent + register: delete_result + retries: 3 # Add retries for API throttling resilience + delay: 5 + until: delete_result is not failed or 'TooManyRequestsException' not in (delete_result.msg | default('')) + + - name: Assert network was deleted + ansible.builtin.assert: + that: + - delete_result is changed + fail_msg: "Network deletion failed" + success_msg: "Network deleted successfully" + + # Test deletion idempotency + - name: Try to delete the network again (idempotency check) + community.aws.medialive_network: + name: "{{ network_name }}" + state: absent + register: delete_idempotency_result + + - name: Assert delete idempotency + ansible.builtin.assert: + that: + - not delete_idempotency_result.changed + fail_msg: "Delete idempotency check failed" + success_msg: "Delete idempotency check passed" + + # Error handling and cleanup + rescue: + - name: Capture error + ansible.builtin.set_fact: + test_failed: true + + - name: Cleanup - ensure network is deleted + community.aws.medialive_network: + name: "{{ network_name }}" + state: absent + ignore_errors: true + when: network_id is defined + + - name: Fail with useful error message + ansible.builtin.fail: + msg: "MediaLive Network integration tests failed. See previous errors for details." + when: test_failed | default(false) + + always: + - name: Ensure all test resources are cleaned up + community.aws.medialive_network: + name: "{{ network_name }}" + state: absent + ignore_errors: true + when: network_id is defined diff --git a/tests/integration/targets/medialive_node/aliases b/tests/integration/targets/medialive_node/aliases new file mode 100644 index 00000000000..4ef4b2067d0 --- /dev/null +++ b/tests/integration/targets/medialive_node/aliases @@ -0,0 +1 @@ +cloud/aws diff --git a/tests/integration/targets/medialive_node/meta/main.yml b/tests/integration/targets/medialive_node/meta/main.yml new file mode 100644 index 00000000000..42b16d695fd --- /dev/null +++ b/tests/integration/targets/medialive_node/meta/main.yml @@ -0,0 +1,5 @@ +dependencies: + - role: setup_botocore_pip + vars: + botocore_version: "1.37.34" + boto3_version: "1.37.34" diff --git a/tests/integration/targets/medialive_node/tasks/cleanup.yml b/tests/integration/targets/medialive_node/tasks/cleanup.yml new file mode 100644 index 00000000000..11d20e1c896 --- /dev/null +++ b/tests/integration/targets/medialive_node/tasks/cleanup.yml @@ -0,0 +1,39 @@ +--- +- name: Cleanup - ensure node is deleted + community.aws.medialive_node: + name: "{{ node_name }}" + cluster_id: "{{ cluster_id }}" + state: absent + ignore_errors: true + when: node_id is defined + +- name: Cleanup - ensure cluster is deleted + community.aws.medialive_cluster: + name: "{{ cluster_name }}" + state: absent + wait: true + ignore_errors: true + when: cluster_id is defined + +- name: Cleanup - ensure network is deleted + community.aws.medialive_network: + name: "{{ network_name }}" + state: absent + ignore_errors: true + when: network_id is defined + +- name: Cleanup - ensure role is deleted + amazon.aws.iam_role: + name: '{{ instance_role_name }}' + state: absent + wait: true + ignore_errors: true + when: instance_role_arn is defined + +- name: Cleanup - ensure role policy is deleted + amazon.aws.iam_managed_policy: + name: '{{ instance_role_policy_name }}' + state: absent + wait: true + ignore_errors: true + when: instance_role_policy_arn is defined diff --git a/tests/integration/targets/medialive_node/tasks/main.yml b/tests/integration/targets/medialive_node/tasks/main.yml new file mode 100644 index 00000000000..47970903719 --- /dev/null +++ b/tests/integration/targets/medialive_node/tasks/main.yml @@ -0,0 +1,366 @@ +--- +# Integration tests for medialive_node module +- name: Wrap MediaLive node tests with 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 }}' + set_fact: + ansible_python_interpreter: '{{ botocore_virtualenv_interpreter }}' + + block: + # Setup test variables + - name: Set up test variables + ansible.builtin.set_fact: + cluster_name: "{{ resource_prefix }}-cluster" + network_name: "{{ resource_prefix }}-network" + node_name: "{{ resource_prefix }}-node" + instance_role_name: "{{ resource_prefix }}-role" + instance_role_policy_name: "{{ resource_prefix }}-policy" + ip_pool_cidr: "10.21.21.0/24" + route_cidr: "0.0.0.0/0" + route_gateway: "10.21.21.1" + wait_timeout: 30 + + # Look up Account ID to use in ARN construction + - name: Look up Account ID + amazon.aws.aws_caller_info: + register: caller_info + + # Create a policy for the required IAM role for MediaLive Anywhere nodes + - name: Create MediaLive Anywhere node IAM role policy + amazon.aws.iam_managed_policy: + name: '{{ instance_role_policy_name }}' + state: present + policy: | + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "medialive:SubmitAnywhereStateChange", + "medialive:PollAnywhere" + ], + "Resource": "arn:aws:medialive:*:{{ caller_info.account }}:cluster:*" + }, + { + "Effect": "Allow", + "Action": "iam:PassRole", + "Resource": "arn:aws:iam::{{ caller_info.account }}:role/MediaLiveAccessRole", + "Condition": { + "StringEquals": { + "iam:PassedToService": [ + "medialive.amazonaws.com" + ] + } + } + } + ] + } + register: instance_role_policy + + # Create the required IAM role for MediaLive Anywhere nodes + - name: Create MediaLive Anywhere node IAM role + amazon.aws.iam_role: + name: '{{ instance_role_name }}' + assume_role_policy_document: | + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": [ + "medialive.amazonaws.com", + "ssm.amazonaws.com" + ] + }, + "Action": [ + "sts:AssumeRole", + "sts:TagSession" + ] + } + ] + } + state: present + create_instance_profile: false + managed_policies: + - arn:aws:iam::aws:policy/AWSElementalMediaLiveFullAccess + - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore + - arn:aws:iam::aws:policy/CloudWatchFullAccessV2 + - arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceforEC2Role + - '{{ instance_role_policy.policy.arn }}' + tags: + Environment: Test + Purpose: Integration Testing + register: iam_role + + - name: Create an IAM Instance Profile + amazon.aws.iam_instance_profile: + name: '{{ instance_role_name }}' + role: '{{ instance_role_name }}' + state: present + register: instance_profile + + # Create a network first for the cluster to use + - name: Create a MediaLive Anywhere network for the cluster + community.aws.medialive_network: + name: "{{ network_name }}" + state: present + ip_pools: + - cidr: "{{ ip_pool_cidr }}" + routes: + - cidr: "{{ route_cidr }}" + gateway: "{{ route_gateway }}" + register: network_result + retries: 3 + delay: 5 + until: network_result is not failed or 'TooManyRequestsException' not in (network_result.msg | default('')) + + - name: Capture network_id, instance_role_arn and instance_profile_arn + ansible.builtin.set_fact: + network_id: "{{ network_result.network.network_id }}" + instance_role_arn: "{{ iam_role.iam_role.arn }}" + instance_profile_arn: "{{ instance_profile.iam_instance_profile.arn }}" + when: + - network_result is defined and network_result.changed is defined + - iam_role is defined and iam_role.changed is defined + - instance_profile is defined and instance_profile.changed is defined + + # Create a cluster first to host the node + - name: Create a MediaLive Anywhere cluster + community.aws.medialive_cluster: + name: "{{ cluster_name }}" + state: present + cluster_type: "ON_PREMISES" + instance_role_arn: "{{ instance_role_arn }}" + network_settings: + default_route: "logical-0" + interface_mappings: + - logical_interface_name: "logical-0" + network_id: "{{ network_id }}" + wait: true + wait_timeout: "{{ wait_timeout }}" + register: cluster_result + retries: 3 + delay: 5 + until: cluster_result is not failed or 'TooManyRequestsException' not in (cluster_result.msg | default('')) + when: network_id is defined + + - name: Capture cluster_id + ansible.builtin.set_fact: + cluster_id: '{{ cluster_result.cluster.cluster_id }}' + + # Test creating a node in the cluster + - name: Create a MediaLive Anywhere node + community.aws.medialive_node: + cluster_id: '{{ cluster_id }}' + name: '{{ node_name }}' + state: present + node_interface_mappings: + - logical_interface_name: logical-0 + physical_interface_name: eth0 + network_interface_mode: NAT + role: ACTIVE + register: create_result + retries: 3 + delay: 5 + until: create_result is not failed or 'TooManyRequestsException' not in (create_result.msg | default('')) + when: cluster_id is defined + + - name: Assert node was created successfully + ansible.builtin.assert: + that: + - create_result is changed + - create_result.node is defined + - create_result.node.name == node_name + - create_result.node.state == "REGISTERING" + - create_result.node.connection_state == "DISCONNECTED" + - create_result.node.role == "ACTIVE" + - create_result.node.channel_placement_groups == [] + - create_result.node.instance_arn == "" + - create_result.node.node_interface_mappings | length == 1 + - create_result.node.node_interface_mappings[0].logical_interface_name == "logical-0" + - create_result.node.node_interface_mappings[0].physical_interface_name == "eth0" + - create_result.node.node_interface_mappings[0].network_interface_mode == "NAT" + - create_result.node.cluster_id is defined + - create_result.node.arn is defined + fail_msg: "Node creation failed or returned unexpected data" + success_msg: "Node created successfully with expected properties" + when: create_result is defined and create_result.node is defined + + - name: Capture node_id + ansible.builtin.set_fact: + node_id: '{{ create_result.node.node_id }}' + + # Test idempotency + - name: Create the same node again (idempotency check) + community.aws.medialive_node: + cluster_id: '{{ cluster_id }}' + name: '{{ node_name }}' + state: present + node_interface_mappings: + - logical_interface_name: logical-0 + physical_interface_name: eth0 + network_interface_mode: NAT + role: ACTIVE + register: idempotency_result + when: node_id is defined + + - name: Assert idempotency + ansible.builtin.assert: + that: + - not idempotency_result.changed + - idempotency_result.node is defined + - idempotency_result.node.node_id == node_id + fail_msg: "Idempotency check failed - module made changes when none were expected" + success_msg: "Idempotency check passed" + when: idempotency_result is defined and idempotency_result.node is defined + + # Test retrieval by ID + - name: Get node by ID + community.aws.medialive_node_info: + id: "{{ node_id }}" + cluster_id: "{{ cluster_id }}" + register: get_by_id_result + when: + - node_id is defined + - cluster_id is defined + + - name: Assert node was retrieved by ID + ansible.builtin.assert: + that: + - not get_by_id_result.changed + - get_by_id_result.node is defined + - get_by_id_result.node.node_id == node_id + - get_by_id_result.node.name == node_name + fail_msg: "Failed to retrieve node by ID" + success_msg: "Successfully retrieved node by ID" + when: + - get_by_id_result is defined + - get_by_id_result.node is defined + + # Test retrieval by name + - name: Get node by name + community.aws.medialive_node_info: + name: "{{ node_name }}" + cluster_id: "{{ cluster_id }}" + register: get_by_name_result + when: + - node_name is defined + - cluster_id is defined + + - name: Assert node was retrieved by name + ansible.builtin.assert: + that: + - not get_by_name_result.changed + - get_by_name_result.node is defined + - get_by_name_result.node.node_id == node_id + - get_by_name_result.node.name == node_name + fail_msg: "Failed to retrieve node by name" + success_msg: "Successfully retrieved node by name" + when: + - get_by_name_result is defined + - get_by_name_result.node is defined + + # Test node registration script creation + - name: Create a node registration script + community.aws.medialive_node_registration: + node_id: "{{ node_id }}" + cluster_id: "{{ cluster_id }}" + role: ACTIVE + register: node_registration_result + when: + - node_id is defined + - cluster_id is defined + + - name: Assert registration script was created + ansible.builtin.assert: + that: + - node_registration_result is changed + - node_registration_result.node is defined + - (node_registration_result.node | default({})) != {} + - node_registration_result.node.node_registration_script is defined + - node_registration_result.node.node_registration_script != '' + fail_msg: "Node registration script creation did not work as expected" + success_msg: "Node registration script was successfully created" + when: node_registration_result is defined + + # Test check mode + - name: Test check mode - attempt to delete node + community.aws.medialive_node: + name: "{{ node_name }}" + cluster_id: "{{ cluster_id }}" + state: absent + check_mode: true + register: check_mode_result + when: node_id is defined + + - name: Assert check mode works correctly + ansible.builtin.assert: + that: + - check_mode_result is changed + - (check_mode_result.node | default({})) == {} + fail_msg: "Check mode did not work as expected" + success_msg: "Check mode correctly simulated deletion" + when: check_mode_result is defined + + + # Test node deletion + - name: Delete the node + community.aws.medialive_node: + name: "{{ node_name }}" + cluster_id: "{{ cluster_id }}" + state: absent + register: delete_result + retries: 3 + delay: 5 + until: delete_result is not failed or 'TooManyRequestsException' not in (delete_result.msg | default('')) + when: node_id is defined + + - name: Assert node was deleted + ansible.builtin.assert: + that: + - delete_result is changed + fail_msg: "Node deletion failed" + success_msg: "Node deleted successfully" + when: delete_result is defined + + # Test deletion idempotency + - name: Try to delete the node again (idempotency check) + community.aws.medialive_node: + name: "{{ node_name }}" + cluster_id: "{{ cluster_id }}" + state: absent + register: delete_idempotency_result + when: node_id is defined + + - name: Assert delete idempotency + ansible.builtin.assert: + that: + - not delete_idempotency_result.changed + fail_msg: "Delete idempotency check failed" + success_msg: "Delete idempotency check passed" + when: delete_idempotency_result is defined + + # Error handling and cleanup + rescue: + - name: Capture error + ansible.builtin.set_fact: + test_failed: true + + - name: Clean up + ansible.builtin.include_tasks: cleanup.yml + + - name: Fail with useful error message + ansible.builtin.fail: + msg: "MediaLive node integration tests failed. See previous errors for details." + when: test_failed | default(false) + + always: + - name: Ensure all test resources are cleaned up + ansible.builtin.include_tasks: cleanup.yml + diff --git a/tests/integration/targets/medialive_sdi_source/aliases b/tests/integration/targets/medialive_sdi_source/aliases new file mode 100644 index 00000000000..4ef4b2067d0 --- /dev/null +++ b/tests/integration/targets/medialive_sdi_source/aliases @@ -0,0 +1 @@ +cloud/aws diff --git a/tests/integration/targets/medialive_sdi_source/meta/main.yml b/tests/integration/targets/medialive_sdi_source/meta/main.yml new file mode 100644 index 00000000000..42b16d695fd --- /dev/null +++ b/tests/integration/targets/medialive_sdi_source/meta/main.yml @@ -0,0 +1,5 @@ +dependencies: + - role: setup_botocore_pip + vars: + botocore_version: "1.37.34" + boto3_version: "1.37.34" diff --git a/tests/integration/targets/medialive_sdi_source/tasks/cleanup.yml b/tests/integration/targets/medialive_sdi_source/tasks/cleanup.yml new file mode 100644 index 00000000000..0526bd27267 --- /dev/null +++ b/tests/integration/targets/medialive_sdi_source/tasks/cleanup.yml @@ -0,0 +1,18 @@ +--- +- name: Cleanup - ensure alternatively named SDI source is deleted + community.aws.medialive_sdi_source: + name: "{{ alt_sdi_source_name }}" + state: absent + ignore_errors: true + +- name: Cleanup - ensure single SDI source is deleted + community.aws.medialive_sdi_source: + name: "{{ single_sdi_source_name }}" + state: absent + ignore_errors: true + +- name: Cleanup - ensure quad SDI source is deleted + community.aws.medialive_sdi_source: + name: "{{ quad_sdi_source_name }}" + state: absent + ignore_errors: true diff --git a/tests/integration/targets/medialive_sdi_source/tasks/main.yml b/tests/integration/targets/medialive_sdi_source/tasks/main.yml new file mode 100644 index 00000000000..21ea1fc7b57 --- /dev/null +++ b/tests/integration/targets/medialive_sdi_source/tasks/main.yml @@ -0,0 +1,341 @@ +--- +# Integration tests for medialive_sdi_source module +- name: Wrap MediaLive sdi source tests with 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 }}' + set_fact: + ansible_python_interpreter: "{{ botocore_virtualenv_interpreter }}" + + block: + # Setup test variables + - name: Set up test variables + ansible.builtin.set_fact: + single_sdi_source_name: "{{ resource_prefix }}-sdi-source-single" + alt_sdi_source_name: "{{ resource_prefix }}-alt-sdi-source" + quad_sdi_source_name: "{{ resource_prefix }}-sdi-source-quad" + sdi_source_mode: "QUADRANT" + sdi_source_mode_interleave: "INTERLEAVE" + quad_source_type: "QUAD" + single_source_type: "SINGLE" + + # Create MediaLive SDI source + - name: Create a single MediaLive SDI source + community.aws.medialive_sdi_source: + name: '{{ single_sdi_source_name }}' + type: '{{ single_source_type }}' + state: present + register: create_single_result + + - name: Assert source was created successfully + ansible.builtin.assert: + that: + - create_single_result.changed == true + - create_single_result.sdi_source is defined + - create_single_result.sdi_source.name == single_sdi_source_name + - create_single_result.sdi_source.type == single_source_type + - create_single_result.sdi_source.state == "IDLE" + - create_single_result.sdi_source.arn is defined + fail_msg: "Single source creation failed or returned unexpected data" + success_msg: "Single source created successfully with expected properties" + when: create_single_result is defined and create_single_result.sdi_source is defined + + - name: Capture sdi_source_id + ansible.builtin.set_fact: + sdi_source_id: '{{ create_single_result.sdi_source.sdi_source_id }}' + + - name: Create the same source again (idempotency check) + community.aws.medialive_sdi_source: + name: '{{ single_sdi_source_name }}' + type: '{{ single_source_type }}' + state: present + register: idempotency_result + when: sdi_source_id is defined + + - name: Assert idempotency result + ansible.builtin.assert: + that: + - idempotency_result.changed == false + - idempotency_result.sdi_source is defined + - idempotency_result.sdi_source.name == single_sdi_source_name + - idempotency_result.sdi_source.type == single_source_type + - idempotency_result.sdi_source.state == "IDLE" + - idempotency_result.sdi_source.arn is defined + fail_msg: "Idempotency check failed" + success_msg: "Idempotency check passed" + when: idempotency_result is defined and idempotency_result.sdi_source is defined + + # Create quad + - name: Create a quad MediaLive SDI source + community.aws.medialive_sdi_source: + name: '{{ quad_sdi_source_name }}' + mode: '{{ sdi_source_mode }}' + type: '{{ quad_source_type }}' + state: present + register: create_quad_result + + - name: Assert quad source was created successfully + ansible.builtin.assert: + that: + - create_quad_result.changed == true + - create_quad_result.sdi_source is defined + - create_quad_result.sdi_source.name == quad_sdi_source_name + - create_quad_result.sdi_source.type == quad_source_type + - create_quad_result.sdi_source.mode == sdi_source_mode + - create_quad_result.sdi_source.state == "IDLE" + - create_quad_result.sdi_source.arn is defined + fail_msg: "Quad source creation failed or returned unexpected data" + success_msg: "Quad source created successfully with expected properties" + when: create_quad_result is defined and create_quad_result.sdi_source is defined + + - name: Create the quad source again (idempotency check) + community.aws.medialive_sdi_source: + name: '{{ quad_sdi_source_name }}' + mode: '{{ sdi_source_mode }}' + type: '{{ quad_source_type }}' + state: present + register: idempotency_quad_result + when: create_quad_result is defined + + - name: Assert idempotency result + ansible.builtin.assert: + that: + - idempotency_quad_result.changed == false + - idempotency_quad_result.sdi_source is defined + - idempotency_quad_result.sdi_source.name == quad_sdi_source_name + - idempotency_quad_result.sdi_source.type == quad_source_type + - idempotency_quad_result.sdi_source.mode == sdi_source_mode + - idempotency_quad_result.sdi_source.state == "IDLE" + - idempotency_quad_result.sdi_source.arn is defined + fail_msg: "Idempotency check failed" + success_msg: "Idempotency check passed" + when: idempotency_quad_result is defined and idempotency_quad_result.sdi_source is defined + + # Get sdi source by its id + - name: Get SDI source by its ID + community.aws.medialive_sdi_source_info: + id: '{{ sdi_source_id }}' + register: get_by_id_result + when: sdi_source_id is defined + + - name: Assert get by id result + ansible.builtin.assert: + that: + - get_by_id_result is defined + - get_by_id_result.sdi_source is defined + - get_by_id_result.sdi_source.name == single_sdi_source_name + - get_by_id_result.sdi_source.type == single_source_type + - get_by_id_result.sdi_source.state == "IDLE" + - get_by_id_result.sdi_source.arn is defined + fail_msg: "Get by ID failed or returned unexpected data" + success_msg: "Get by ID passed" + when: get_by_id_result is defined and get_by_id_result.sdi_source is defined + + # Get SDI source by its name + - name: Get SDI source by its name + community.aws.medialive_sdi_source_info: + name: '{{ single_sdi_source_name }}' + register: get_by_name_result + when: sdi_source_id is defined + + - name: Assert get by name result + ansible.builtin.assert: + that: + - get_by_name_result is defined + - get_by_name_result.sdi_source is defined + - get_by_name_result.sdi_source.name == single_sdi_source_name + - get_by_name_result.sdi_source.sdi_source_id == sdi_source_id + - get_by_name_result.sdi_source.type == single_source_type + - get_by_name_result.sdi_source.state == "IDLE" + - get_by_name_result.sdi_source.arn is defined + fail_msg: "Get by name failed or returned unexpected data" + success_msg: "Get by name passed" + when: get_by_name_result is defined and sdi_source_id is defined and get_by_name_result.sdi_source is defined + + # Update SDI source + - name: Update SDI source name by its ID + community.aws.medialive_sdi_source: + id: '{{ sdi_source_id }}' + name: '{{ alt_sdi_source_name }}' + state: present + register: update_result + when: sdi_source_id is defined + + - name: Assert update result + ansible.builtin.assert: + that: + - update_result.changed == true + - update_result.sdi_source is defined + - update_result.sdi_source.name == alt_sdi_source_name + - update_result.sdi_source.type == single_source_type + - update_result.sdi_source.state == "IDLE" + - update_result.sdi_source.arn is defined + fail_msg: "Update failed or returned unexpected data" + success_msg: "Update passed" + when: update_result is defined and update_result.sdi_source is defined + + - name: Update the same source again (idempotency check) + community.aws.medialive_sdi_source: + id: '{{ sdi_source_id }}' + name: '{{ alt_sdi_source_name }}' + state: present + register: idempotency_update_result + when: sdi_source_id is defined + + - name: Assert idempotency update result + ansible.builtin.assert: + that: + - idempotency_update_result.changed == false + - idempotency_update_result.sdi_source is defined + - idempotency_update_result.sdi_source.name == alt_sdi_source_name + - idempotency_update_result.sdi_source.type == single_source_type + - idempotency_update_result.sdi_source.state == "IDLE" + - idempotency_update_result.sdi_source.arn is defined + fail_msg: "Idempotency update check failed" + success_msg: "Idempotency update check passed" + when: idempotency_update_result is defined and idempotency_update_result.sdi_source is defined + + - name: Update SDI source mode by its name + community.aws.medialive_sdi_source: + name: '{{ quad_sdi_source_name }}' + type: '{{ quad_source_type }}' + mode: '{{ sdi_source_mode_interleave }}' + state: present + register: mode_update_result + when: create_quad_result is defined + + - name: Assert update result + ansible.builtin.assert: + that: + - mode_update_result.changed == true + - mode_update_result.sdi_source is defined + - mode_update_result.sdi_source.name == quad_sdi_source_name + - mode_update_result.sdi_source.type == quad_source_type + - mode_update_result.sdi_source.mode == sdi_source_mode_interleave + - mode_update_result.sdi_source.state == "IDLE" + - mode_update_result.sdi_source.arn is defined + fail_msg: "SDI mode update failed or returned unexpected data" + success_msg: "SDI mode update passed" + when: mode_update_result is defined and mode_update_result.sdi_source is defined + + - name: Update the same source again (idempotency check) + community.aws.medialive_sdi_source: + name: '{{ quad_sdi_source_name }}' + type: '{{ quad_source_type }}' + mode: '{{ sdi_source_mode_interleave }}' + state: present + register: idempotency_mode_update_result + when: create_quad_result is defined + + - name: Assert idempotency update result + ansible.builtin.assert: + that: + - idempotency_mode_update_result.changed == false + - idempotency_mode_update_result.sdi_source is defined + - idempotency_mode_update_result.sdi_source.name == quad_sdi_source_name + - idempotency_mode_update_result.sdi_source.type == quad_source_type + - idempotency_mode_update_result.sdi_source.mode == sdi_source_mode_interleave + - idempotency_mode_update_result.sdi_source.state == "IDLE" + - idempotency_mode_update_result.sdi_source.arn is defined + fail_msg: "Idempotency update check failed" + success_msg: "Idempotency update check passed" + when: idempotency_mode_update_result is defined and idempotency_mode_update_result.sdi_source is defined + + # Delete SDI source + - name: Delete SDI source by ID + community.aws.medialive_sdi_source: + id: '{{ sdi_source_id }}' + state: absent + register: delete_result + when: sdi_source_id is defined + + - name: Assert delete result + ansible.builtin.assert: + that: + - delete_result.changed == true + - delete_result.sdi_source is defined + - delete_result.sdi_source.name == alt_sdi_source_name + - delete_result.sdi_source.type == single_source_type + - delete_result.sdi_source.state == "DELETED" + - delete_result.sdi_source.arn is defined + fail_msg: "Delete failed or returned unexpected data" + success_msg: "Delete passed" + when: delete_result is defined and delete_result.sdi_source is defined + + - name: Try to delete the source again (idempotency check) + community.aws.medialive_sdi_source: + id: '{{ sdi_source_id }}' + state: absent + register: idempotency_delete_result + when: sdi_source_id is defined + + - name: Assert idempotency delete result + ansible.builtin.assert: + that: + - idempotency_delete_result.changed == false + - idempotency_delete_result.sdi_source is defined + - idempotency_delete_result.sdi_source.name == alt_sdi_source_name + - idempotency_delete_result.sdi_source.type == single_source_type + - idempotency_delete_result.sdi_source.state == "DELETED" + - idempotency_delete_result.sdi_source.arn is defined + fail_msg: "Idempotency delete check failed" + success_msg: "Idempotency delete check passed" + when: idempotency_delete_result is defined and idempotency_delete_result.sdi_source is defined + + - name: Delete SDI source by name + community.aws.medialive_sdi_source: + name: '{{ quad_sdi_source_name }}' + state: absent + register: delete_by_name_result + when: create_quad_result is defined + + - name: Assert delete by name result + ansible.builtin.assert: + that: + - delete_by_name_result.changed == true + - delete_by_name_result.sdi_source is defined + - delete_by_name_result.sdi_source.name == quad_sdi_source_name + - delete_by_name_result.sdi_source.type == quad_source_type + - delete_by_name_result.sdi_source.state == "DELETED" + - delete_by_name_result.sdi_source.arn is defined + fail_msg: "Delete failed or returned unexpected data" + success_msg: "Delete passed" + when: delete_by_name_result is defined and delete_by_name_result.sdi_source is defined + + - name: Try to delete the source again (idempotency check) + community.aws.medialive_sdi_source: + name: '{{ quad_sdi_source_name }}' + state: absent + register: idempotency_delete_by_name_result + when: create_single_result is defined + + - name: Assert idempotency delete by name result + ansible.builtin.assert: + that: + - idempotency_delete_by_name_result.changed == false + - idempotency_delete_by_name_result.sdi_source is defined + - idempotency_delete_by_name_result.sdi_source == {} + fail_msg: "Idempotency delete check failed" + success_msg: "Idempotency delete check passed" + when: idempotency_delete_by_name_result is defined and idempotency_delete_by_name_result.sdi_source is defined + + # Error handling and cleanup + rescue: + - name: Capture error + ansible.builtin.set_fact: + test_failed: true + + - name: Clean up + ansible.builtin.include_tasks: cleanup.yml + + - name: Fail with useful error message + ansible.builtin.fail: + msg: "MediaLive SDI source integration tests failed. See previous errors for details." + when: test_failed | default(false) + + always: + - name: Ensure all test resources are cleaned up + ansible.builtin.include_tasks: cleanup.yml \ No newline at end of file