diff --git a/.doc_gen/metadata/ecs_metadata.yaml b/.doc_gen/metadata/ecs_metadata.yaml index 86387489046..858958318df 100644 --- a/.doc_gen/metadata/ecs_metadata.yaml +++ b/.doc_gen/metadata/ecs_metadata.yaml @@ -7,192 +7,343 @@ ecs_Hello: languages: .NET: versions: - - sdk_version: 4 - github: dotnetv4/ECS - sdkguide: - excerpts: - - description: - snippet_tags: - - ECS.dotnetv4.ECSActions.HelloECS - services: - ecs: {ListClusters} + - sdk_version: 4 + github: dotnetv4/ECS + sdkguide: + excerpts: + - description: + snippet_tags: + - ECS.dotnetv4.ECSActions.HelloECS + Python: + versions: + - sdk_version: 3 + github: python/example_code/ecs + excerpts: + - description: + snippet_tags: + - python.example_code.ecs.Hello + services: + ecs: {ListClusters: null} ecs_CreateCluster: languages: Rust: versions: - - sdk_version: 1 - github: rustv1/examples/ecs - sdkguide: - excerpts: - - description: - snippet_tags: - - ecs.rust.cluster-create + - sdk_version: 1 + github: rustv1/examples/ecs + sdkguide: + excerpts: + - description: + snippet_tags: + - ecs.rust.cluster-create Java: versions: - - sdk_version: 2 - github: javav2/example_code/ecs - sdkguide: - excerpts: - - description: - snippet_tags: - - ecs.java2.create_cluster.main + - sdk_version: 2 + github: javav2/example_code/ecs + sdkguide: + excerpts: + - description: + snippet_tags: + - ecs.java2.create_cluster.main + Python: + versions: + - sdk_version: 3 + github: python/example_code/ecs + excerpts: + - description: + snippet_tags: + - python.example_code.ecs.EcsWrapper.decl + - python.example_code.ecs.CreateCluster services: - ecs: {CreateCluster} + ecs: {CreateCluster: null} ecs_DeleteCluster: languages: Rust: versions: - - sdk_version: 1 - github: rustv1/examples/ecs - sdkguide: - excerpts: - - description: - snippet_tags: - - ecs.rust.cluster-delete + - sdk_version: 1 + github: rustv1/examples/ecs + sdkguide: + excerpts: + - description: + snippet_tags: + - ecs.rust.cluster-delete + Python: + versions: + - sdk_version: 3 + github: python/example_code/ecs + excerpts: + - description: + snippet_tags: + - python.example_code.ecs.EcsWrapper.decl + - python.example_code.ecs.DeleteCluster services: - ecs: {DeleteCluster} + ecs: {DeleteCluster: null} ecs_DescribeClusters: languages: Rust: versions: - - sdk_version: 1 - github: rustv1/examples/ecs - sdkguide: - excerpts: - - description: - snippet_tags: - - ecs.rust.describe-clusters + - sdk_version: 1 + github: rustv1/examples/ecs + sdkguide: + excerpts: + - description: + snippet_tags: + - ecs.rust.describe-clusters Java: versions: - - sdk_version: 2 - github: javav2/example_code/ecs - sdkguide: - excerpts: - - description: - snippet_tags: - - ecs.java2.des_cluster.main + - sdk_version: 2 + github: javav2/example_code/ecs + sdkguide: + excerpts: + - description: + snippet_tags: + - ecs.java2.des_cluster.main services: - ecs: {DescribeClusters} + ecs: {DescribeClusters: null} ecs_CreateService: languages: Java: versions: - - sdk_version: 2 - github: javav2/example_code/ecs - sdkguide: - excerpts: - - description: - snippet_tags: - - ecs.java2.create_service.main + - sdk_version: 2 + github: javav2/example_code/ecs + sdkguide: + excerpts: + - description: + snippet_tags: + - ecs.java2.create_service.main + Python: + versions: + - sdk_version: 3 + github: python/example_code/ecs + excerpts: + - description: + snippet_tags: + - python.example_code.ecs.EcsWrapper.decl + - python.example_code.ecs.CreateService services: - ecs: {CreateService} + ecs: {CreateService: null} ecs_DeleteService: languages: Java: versions: - - sdk_version: 2 - github: javav2/example_code/ecs - sdkguide: - excerpts: - - description: - snippet_tags: - - ecs.java2.delete_service.main + - sdk_version: 2 + github: javav2/example_code/ecs + sdkguide: + excerpts: + - description: + snippet_tags: + - ecs.java2.delete_service.main + Python: + versions: + - sdk_version: 3 + github: python/example_code/ecs + excerpts: + - description: + snippet_tags: + - python.example_code.ecs.EcsWrapper.decl + - python.example_code.ecs.DeleteService services: - ecs: {DeleteService} + ecs: {DeleteService: null} ecs_ListClusters: languages: .NET: versions: - - sdk_version: 3 - github: dotnetv3/ECS - sdkguide: - excerpts: - - description: - snippet_tags: - - ECS.dotnetv3.ECSActions.ListClusters + - sdk_version: 3 + github: dotnetv3/ECS + sdkguide: + excerpts: + - description: + snippet_tags: + - ECS.dotnetv3.ECSActions.ListClusters Java: versions: - - sdk_version: 2 - github: javav2/example_code/ecs - sdkguide: - excerpts: - - description: - snippet_tags: - - ecs.java2.list_clusters.main + - sdk_version: 2 + github: javav2/example_code/ecs + sdkguide: + excerpts: + - description: + snippet_tags: + - ecs.java2.list_clusters.main services: - ecs: {ListClusters} + ecs: {ListClusters: null} ecs_DescribeTasks: languages: Java: versions: - - sdk_version: 2 - github: javav2/example_code/ecs - sdkguide: - excerpts: - - description: - snippet_tags: - - ecs.java2.list_tasks.main + - sdk_version: 2 + github: javav2/example_code/ecs + sdkguide: + excerpts: + - description: + snippet_tags: + - ecs.java2.list_tasks.main + Python: + versions: + - sdk_version: 3 + github: python/example_code/ecs + excerpts: + - description: + snippet_tags: + - python.example_code.ecs.EcsWrapper.decl + - python.example_code.ecs.DescribeTasks services: - ecs: {DescribeTasks} + ecs: {DescribeTasks: null} ecs_ListServices: languages: .NET: versions: - - sdk_version: 3 - github: dotnetv3/ECS - sdkguide: - excerpts: - - description: - snippet_tags: - - ECS.dotnetv3.ECSActions.ListServices + - sdk_version: 3 + github: dotnetv3/ECS + sdkguide: + excerpts: + - description: + snippet_tags: + - ECS.dotnetv3.ECSActions.ListServices services: - ecs: {ListServices} + ecs: {ListServices: null} ecs_ListTasks: languages: .NET: versions: - - sdk_version: 3 - github: dotnetv3/ECS - sdkguide: - excerpts: - - description: - snippet_tags: - - ECS.dotnetv3.ECSActions.ListTasks + - sdk_version: 3 + github: dotnetv3/ECS + sdkguide: + excerpts: + - description: + snippet_tags: + - ECS.dotnetv3.ECSActions.ListTasks + Python: + versions: + - sdk_version: 3 + github: python/example_code/ecs + excerpts: + - description: + snippet_tags: + - python.example_code.ecs.EcsWrapper.decl + - python.example_code.ecs.ListTasks services: - ecs: {ListTasks} + ecs: {ListTasks: null} ecs_UpdateService: languages: Java: versions: - - sdk_version: 2 - github: javav2/example_code/ecs - sdkguide: - excerpts: - - description: - snippet_tags: - - ecs.java2.update_service.main + - sdk_version: 2 + github: javav2/example_code/ecs + sdkguide: + excerpts: + - description: + snippet_tags: + - ecs.java2.update_service.main + Python: + versions: + - sdk_version: 3 + github: python/example_code/ecs + excerpts: + - description: + snippet_tags: + - python.example_code.ecs.EcsWrapper.decl + - python.example_code.ecs.UpdateService services: - ecs: {UpdateService} + ecs: {UpdateService: null} ecs_Scenario_GetClustersServicesAndTasks: title: Get ARN information for &ECS; clusters, services, and tasks using an &AWS; SDK title_abbrev: Get ARN information for clusters, services, and tasks synopsis_list: - - Get a list of all clusters. - - Get services for a cluster. - - Get tasks for a cluster. + - Get a list of all clusters. + - Get services for a cluster. + - Get tasks for a cluster. category: Scenarios languages: .NET: versions: - - sdk_version: 3 - github: dotnetv3/ECS - sdkguide: - excerpts: - - description: Run an interactive scenario at a command prompt. - snippet_tags: - - ECS.dotnetv3.ECSScenario.ECSScenario - - description: Wrapper methods that are called by the scenario to manage &ECS; actions. - snippet_tags: - - ECS.dotnetv3.ECSActions.ECSWrapper - services: - ecs: {ListClusters, ListServices, ListTasks} + - sdk_version: 3 + github: dotnetv3/ECS + sdkguide: + excerpts: + - description: Run an interactive scenario at a command prompt. + snippet_tags: + - ECS.dotnetv3.ECSScenario.ECSScenario + - description: Wrapper methods that are called by the scenario to manage &ECS; actions. + snippet_tags: + - ECS.dotnetv3.ECSActions.ECSWrapper + services: + ecs: {ListClusters: null, ListServices: null, ListTasks: null} +ecs_RegisterTaskDefinition: + title: Register an &ECS; task definition + title_abbrev: Register a task definition + synopsis: register an &ECS; task definition. + category: Actions + languages: + Python: + versions: + - sdk_version: 3 + github: python/example_code/ecs + excerpts: + - description: + snippet_tags: + - python.example_code.ecs.EcsWrapper.decl + - python.example_code.ecs.RegisterTaskDefinition + services: + ecs: {RegisterTaskDefinition: null} +ecs_DescribeTaskDefinition: + title: Describe an &ECS; task definition + title_abbrev: Describe a task definition + synopsis: describe an &ECS; task definition. + category: Actions + languages: + Python: + versions: + - sdk_version: 3 + github: python/example_code/ecs + excerpts: + - description: + snippet_tags: + - python.example_code.ecs.EcsWrapper.decl + - python.example_code.ecs.DescribeTaskDefinition + services: + ecs: {DescribeTaskDefinition: null} +ecs_DescribeServices: + title: Describe &ECS; services + title_abbrev: Describe services + synopsis: describe &ECS; services. + category: Actions + languages: + Python: + versions: + - sdk_version: 3 + github: python/example_code/ecs + excerpts: + - description: + snippet_tags: + - python.example_code.ecs.EcsWrapper.decl + - python.example_code.ecs.DescribeServices + services: + ecs: {DescribeServices: null} +ecs_Scenario: + title: Learn &ECS; basics + title_abbrev: Learn the basics + synopsis: learn &ECS; basics by deploying and managing a containerized web application using Fargate. + synopsis_list: + - "Create an ECS cluster with Container Insights enabled." + - "Register a Fargate-compatible task definition." + - "Describe the task definition to verify registration." + - "Create an ECS service running Apache on Fargate." + - "Wait for service stability and describe the service." + - "List running tasks in the service." + - "Describe task details including network configuration." + - "Scale the service by updating the desired count." + - "Clean up all resources (service, cluster, CloudFormation stack)." + category: Basics + languages: + Python: + versions: + - sdk_version: 3 + github: python/example_code/ecs + excerpts: + - description: Run an interactive scenario demonstrating ECS basics. + snippet_tags: + - python.example_code.ecs.EcsScenario + - description: Create a class that wraps ECS operations. + snippet_tags: + - python.example_code.ecs.EcsWrapper.decl + services: + ecs: {CreateCluster: null, RegisterTaskDefinition: null, DescribeTaskDefinition: null, CreateService: null, DescribeServices: null, ListTasks: null, DescribeTasks: null, UpdateService: null, DeleteService: null, DeleteCluster: null} diff --git a/python/README.md b/python/README.md index b6cb91bcffa..04c7a9aa1f2 100644 --- a/python/README.md +++ b/python/README.md @@ -1,4 +1,4 @@ -# AWS SDK for Python (Boto3) code examples +# AWS SDK for Python (Boto3) code examples ## Overview @@ -219,3 +219,4 @@ Proceed with caution. Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. SPDX-License-Identifier: Apache-2.0 + diff --git a/python/example_code/ecs/ecs_hello.py b/python/example_code/ecs/ecs_hello.py new file mode 100644 index 00000000000..52ca4104b0b --- /dev/null +++ b/python/example_code/ecs/ecs_hello.py @@ -0,0 +1,39 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +""" +Hello Amazon ECS - Verifies ECS connectivity by listing clusters. +""" + +# snippet-start:[python.example_code.ecs.Hello] +import boto3 +from botocore.exceptions import ClientError + + +def hello_ecs(): + """ + Lists all ECS clusters in the account to verify connectivity. + """ + ecs_client = boto3.client("ecs") + + try: + paginator = ecs_client.get_paginator("list_clusters") + cluster_arns = list() + for page in paginator.paginate(): + cluster_arns.extend(page.get("clusterArns", list())) + + if cluster_arns: + print(f"Found {len(cluster_arns)} ECS cluster(s):") + for arn in cluster_arns: + print(f" - {arn}") + else: + print("No ECS clusters found in this account/region.") + + except ClientError as err: + print(f"Error listing ECS clusters: {err.response['Error']['Message']}") + raise + + +if __name__ == "__main__": + hello_ecs() +# snippet-end:[python.example_code.ecs.Hello] diff --git a/python/example_code/ecs/ecs_wrapper.py b/python/example_code/ecs/ecs_wrapper.py new file mode 100644 index 00000000000..c0208c43d7f --- /dev/null +++ b/python/example_code/ecs/ecs_wrapper.py @@ -0,0 +1,473 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +""" +Amazon ECS wrapper class that encapsulates Amazon Elastic Container Service operations. +""" + +import logging +from typing import Any, Dict, List, Optional + +import boto3 +from botocore.exceptions import ClientError + +logger = logging.getLogger(__name__) + + +# snippet-start:[python.example_code.ecs.EcsWrapper.decl] +class EcsWrapper: + """Encapsulates Amazon ECS operations.""" + + def __init__(self, ecs_client: boto3.client): + """ + Initializes the EcsWrapper with an ECS client. + + :param ecs_client: A Boto3 ECS client. + """ + self.ecs_client = ecs_client + + @classmethod + def from_client(cls): + """ + Creates an EcsWrapper instance from a default Boto3 ECS client. + """ + ecs_client = boto3.client("ecs") + return cls(ecs_client) + + # snippet-end:[python.example_code.ecs.EcsWrapper.decl] + + # snippet-start:[python.example_code.ecs.CreateCluster] + def create_cluster(self, cluster_name: str) -> Dict[str, Any]: + """ + Creates an ECS cluster with Container Insights enabled. + + :param cluster_name: The name of the cluster to create. + :return: A dictionary containing cluster information. + :raises ClientError: If a client-side error occurs during cluster creation. + """ + try: + response = self.ecs_client.create_cluster( + clusterName=cluster_name, + settings=[ + {"name": "containerInsights", "value": "enabled"} + ], + ) + cluster = response["cluster"] + logger.info("Created cluster '%s' with ARN: %s", cluster_name, cluster["clusterArn"]) + return cluster + except ClientError as err: + if err.response["Error"]["Code"] == "ClientException": + logger.error( + "Client error creating cluster '%s': %s", + cluster_name, + err.response["Error"]["Message"], + ) + raise + # snippet-end:[python.example_code.ecs.CreateCluster] + + # snippet-start:[python.example_code.ecs.RegisterTaskDefinition] + def register_task_definition( + self, + family: str, + execution_role_arn: str, + container_name: str, + container_image: str, + cpu: str = "256", + memory: str = "512", + container_port: int = 80, + ) -> Dict[str, Any]: + """ + Registers a Fargate-compatible task definition. + + :param family: The family name for the task definition. + :param execution_role_arn: The ARN of the task execution role. + :param container_name: The name of the container. + :param container_image: The container image to use. + :param cpu: The CPU units for the task (default '256'). + :param memory: The memory (MB) for the task (default '512'). + :param container_port: The port to expose (default 80). + :return: A dictionary containing the task definition information. + :raises ClientError: If parameters are invalid. + """ + try: + response = self.ecs_client.register_task_definition( + family=family, + networkMode="awsvpc", + requiresCompatibilities=["FARGATE"], + cpu=cpu, + memory=memory, + executionRoleArn=execution_role_arn, + containerDefinitions=[ + { + "name": container_name, + "image": container_image, + "essential": True, + "portMappings": [ + { + "containerPort": container_port, + "protocol": "tcp", + } + ], + } + ], + ) + task_def = response["taskDefinition"] + logger.info( + "Registered task definition '%s' revision %s", + task_def["family"], + task_def["revision"], + ) + return task_def + except ClientError as err: + if err.response["Error"]["Code"] == "InvalidParameterException": + logger.error( + "Invalid parameter for task definition '%s': %s", + family, + err.response["Error"]["Message"], + ) + raise + # snippet-end:[python.example_code.ecs.RegisterTaskDefinition] + + # snippet-start:[python.example_code.ecs.DescribeTaskDefinition] + def describe_task_definition(self, task_definition: str) -> Dict[str, Any]: + """ + Describes a task definition. + + :param task_definition: The task definition family:revision or full ARN. + :return: A dictionary containing the task definition details. + :raises ClientError: If the task definition is invalid. + """ + try: + response = self.ecs_client.describe_task_definition( + taskDefinition=task_definition + ) + task_def = response["taskDefinition"] + logger.info( + "Described task definition '%s' revision %s, status: %s", + task_def["family"], + task_def["revision"], + task_def["status"], + ) + return task_def + except ClientError as err: + if err.response["Error"]["Code"] == "InvalidParameterException": + logger.error( + "Invalid parameter describing task definition '%s': %s", + task_definition, + err.response["Error"]["Message"], + ) + raise + # snippet-end:[python.example_code.ecs.DescribeTaskDefinition] + + # snippet-start:[python.example_code.ecs.CreateService] + def create_service( + self, + cluster_name: str, + service_name: str, + task_definition: str, + desired_count: int, + subnets: List[str], + security_groups: List[str], + assign_public_ip: bool = True, + ) -> Dict[str, Any]: + """ + Creates an ECS service using Fargate launch type. + + :param cluster_name: The name of the cluster. + :param service_name: The name of the service. + :param task_definition: The task definition (family:revision). + :param desired_count: The number of tasks to run. + :param subnets: List of subnet IDs for the service. + :param security_groups: List of security group IDs. + :param assign_public_ip: Whether to assign a public IP (default True). + :return: A dictionary containing the service information. + :raises ClientError: If the cluster is not found. + """ + try: + response = self.ecs_client.create_service( + cluster=cluster_name, + serviceName=service_name, + taskDefinition=task_definition, + desiredCount=desired_count, + launchType="FARGATE", + networkConfiguration={ + "awsvpcConfiguration": { + "subnets": subnets, + "securityGroups": security_groups, + "assignPublicIp": "ENABLED" if assign_public_ip else "DISABLED", + } + }, + deploymentConfiguration={ + "deploymentCircuitBreaker": { + "enable": True, + "rollback": True, + } + }, + ) + service = response["service"] + logger.info( + "Created service '%s' with ARN: %s", + service_name, + service["serviceArn"], + ) + return service + except ClientError as err: + if err.response["Error"]["Code"] == "ClusterNotFoundException": + logger.error( + "Cluster '%s' not found. Verify the cluster was created successfully.", + cluster_name, + ) + raise + # snippet-end:[python.example_code.ecs.CreateService] + + # snippet-start:[python.example_code.ecs.DescribeServices] + def describe_services( + self, cluster_name: str, service_names: List[str] + ) -> List[Dict[str, Any]]: + """ + Describes one or more ECS services. + + :param cluster_name: The cluster name. + :param service_names: List of service names to describe. + :return: A list of service dictionaries. + :raises ClientError: If the cluster is not found. + """ + try: + response = self.ecs_client.describe_services( + cluster=cluster_name, + services=service_names, + ) + services = response.get("services", list()) + for svc in services: + logger.info( + "Service '%s': status=%s, running=%d, desired=%d", + svc["serviceName"], + svc["status"], + svc["runningCount"], + svc["desiredCount"], + ) + return services + except ClientError as err: + if err.response["Error"]["Code"] == "ClusterNotFoundException": + logger.error( + "Cluster '%s' not found. It may have been deleted.", + cluster_name, + ) + raise + # snippet-end:[python.example_code.ecs.DescribeServices] + + # snippet-start:[python.example_code.ecs.ListTasks] + def list_tasks( + self, cluster_name: str, service_name: str, desired_status: str = "RUNNING" + ) -> List[str]: + """ + Lists tasks for a service using pagination. + + :param cluster_name: The cluster name. + :param service_name: The service name. + :param desired_status: The task status filter (default 'RUNNING'). + :return: A list of task ARNs. + :raises ClientError: If the service is not found. + """ + try: + task_arns = list() + paginator = self.ecs_client.get_paginator("list_tasks") + for page in paginator.paginate( + cluster=cluster_name, + serviceName=service_name, + desiredStatus=desired_status, + ): + task_arns.extend(page.get("taskArns", list())) + logger.info( + "Found %d tasks for service '%s' in cluster '%s'", + len(task_arns), + service_name, + cluster_name, + ) + return task_arns + except ClientError as err: + if err.response["Error"]["Code"] == "ServiceNotFoundException": + logger.error( + "Service '%s' not found in cluster '%s'.", + service_name, + cluster_name, + ) + raise + # snippet-end:[python.example_code.ecs.ListTasks] + + # snippet-start:[python.example_code.ecs.DescribeTasks] + def describe_tasks( + self, cluster_name: str, task_arns: List[str] + ) -> List[Dict[str, Any]]: + """ + Describes specified tasks in a cluster. + + :param cluster_name: The cluster name. + :param task_arns: List of task ARNs to describe. + :return: A list of task detail dictionaries. + :raises ClientError: If the cluster is not found. + """ + try: + response = self.ecs_client.describe_tasks( + cluster=cluster_name, + tasks=task_arns, + ) + tasks = response.get("tasks", list()) + for task in tasks: + logger.info( + "Task '%s': lastStatus=%s, cpu=%s, memory=%s", + task["taskArn"], + task["lastStatus"], + task.get("cpu", "N/A"), + task.get("memory", "N/A"), + ) + return tasks + except ClientError as err: + if err.response["Error"]["Code"] == "ClusterNotFoundException": + logger.error( + "Cluster '%s' not found. Verify the cluster name/ARN is correct.", + cluster_name, + ) + raise + # snippet-end:[python.example_code.ecs.DescribeTasks] + + # snippet-start:[python.example_code.ecs.UpdateService] + def update_service( + self, cluster_name: str, service_name: str, desired_count: int + ) -> Dict[str, Any]: + """ + Updates an ECS service (e.g., to scale up or down). + + :param cluster_name: The cluster name. + :param service_name: The service name. + :param desired_count: The new desired task count. + :return: A dictionary containing the updated service information. + :raises ClientError: If the service is not found. + """ + try: + response = self.ecs_client.update_service( + cluster=cluster_name, + service=service_name, + desiredCount=desired_count, + ) + service = response["service"] + logger.info( + "Updated service '%s' desired count to %d", + service_name, + desired_count, + ) + return service + except ClientError as err: + if err.response["Error"]["Code"] == "ServiceNotFoundException": + logger.error( + "Service '%s' not found. It may have been deleted or is inactive.", + service_name, + ) + raise + # snippet-end:[python.example_code.ecs.UpdateService] + + # snippet-start:[python.example_code.ecs.DeleteService] + def delete_service( + self, cluster_name: str, service_name: str, force: bool = True + ) -> Dict[str, Any]: + """ + Deletes an ECS service. + + :param cluster_name: The cluster name. + :param service_name: The service name to delete. + :param force: Whether to force deletion (default True). + :return: A dictionary containing the deleted service information. + :raises ClientError: If the service is not found. + """ + try: + response = self.ecs_client.delete_service( + cluster=cluster_name, + service=service_name, + force=force, + ) + service = response["service"] + logger.info("Deleted service '%s' from cluster '%s'", service_name, cluster_name) + return service + except ClientError as err: + if err.response["Error"]["Code"] == "ServiceNotFoundException": + logger.error( + "Service '%s' is already deleted. Proceeding with cleanup.", + service_name, + ) + raise + # snippet-end:[python.example_code.ecs.DeleteService] + + # snippet-start:[python.example_code.ecs.DeleteCluster] + def delete_cluster(self, cluster_name: str) -> Dict[str, Any]: + """ + Deletes an ECS cluster. + + :param cluster_name: The name of the cluster to delete. + :return: A dictionary containing the deleted cluster information. + :raises ClientError: If the cluster contains active services. + """ + try: + response = self.ecs_client.delete_cluster(cluster=cluster_name) + cluster = response["cluster"] + logger.info("Deleted cluster '%s'", cluster_name) + return cluster + except ClientError as err: + if err.response["Error"]["Code"] == "ClusterContainsServicesException": + logger.error( + "Cluster '%s' still has active services. " + "Force-delete services before retrying cluster deletion.", + cluster_name, + ) + raise + # snippet-end:[python.example_code.ecs.DeleteCluster] + + # snippet-start:[python.example_code.ecs.ListClusters] + def list_clusters(self) -> List[str]: + """ + Lists all ECS clusters using pagination. + + :return: A list of cluster ARNs. + """ + try: + cluster_arns = list() + paginator = self.ecs_client.get_paginator("list_clusters") + for page in paginator.paginate(): + cluster_arns.extend(page.get("clusterArns", list())) + logger.info("Found %d clusters", len(cluster_arns)) + return cluster_arns + except ClientError as err: + logger.error("Error listing clusters: %s", err.response["Error"]["Message"]) + raise + # snippet-end:[python.example_code.ecs.ListClusters] + + def wait_for_service_stable(self, cluster_name: str, service_name: str) -> None: + """ + Waits for a service to reach a stable state. + + :param cluster_name: The cluster name. + :param service_name: The service name. + """ + logger.info("Waiting for service '%s' to stabilize...", service_name) + waiter = self.ecs_client.get_waiter("services_stable") + waiter.wait( + cluster=cluster_name, + services=[service_name], + WaiterConfig={"Delay": 15, "MaxAttempts": 40}, + ) + logger.info("Service '%s' is stable.", service_name) + + def wait_for_service_inactive(self, cluster_name: str, service_name: str) -> None: + """ + Waits for a service to become inactive. + + :param cluster_name: The cluster name. + :param service_name: The service name. + """ + logger.info("Waiting for service '%s' to become inactive...", service_name) + waiter = self.ecs_client.get_waiter("services_inactive") + waiter.wait( + cluster=cluster_name, + services=[service_name], + WaiterConfig={"Delay": 15, "MaxAttempts": 40}, + ) + logger.info("Service '%s' is now inactive.", service_name) diff --git a/python/example_code/ecs/requirements.txt b/python/example_code/ecs/requirements.txt new file mode 100644 index 00000000000..d3ac2b938e6 --- /dev/null +++ b/python/example_code/ecs/requirements.txt @@ -0,0 +1,3 @@ +boto3>=1.26.0 +botocore>=1.29.0 +pytest>=7.0.0 diff --git a/python/example_code/ecs/scenarios/ecs_basics_scenario.py b/python/example_code/ecs/scenarios/ecs_basics_scenario.py new file mode 100644 index 00000000000..b170f646499 --- /dev/null +++ b/python/example_code/ecs/scenarios/ecs_basics_scenario.py @@ -0,0 +1,402 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +""" +Amazon ECS Basics Scenario + +This scenario demonstrates how to deploy and manage a containerized application +on Amazon ECS using AWS Fargate. The workflow covers: +1. Creating prerequisite infrastructure via CloudFormation (VPC, subnets, IAM role) +2. Creating an ECS cluster +3. Registering a Fargate task definition +4. Describing the task definition +5. Creating an ECS service +6. Waiting for service stability and describing the service +7. Listing running tasks +8. Describing task details (including public IP) +9. Scaling the service (updating desired count) +10. Cleaning up all resources +""" + +# snippet-start:[python.example_code.ecs.EcsScenario] +import json +import logging +import time + +import boto3 +from botocore.exceptions import ClientError, WaiterError + +from ecs_wrapper import EcsWrapper + +logger = logging.getLogger(__name__) + + +def get_cfn_template() -> str: + """Returns the CloudFormation template for prerequisite resources.""" + template = { + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "ECS Basics Scenario - Prerequisite resources (VPC, Subnets, IGW, SG, IAM Role)", + "Resources": { + "VPC": { + "Type": "AWS::EC2::VPC", + "Properties": { + "CidrBlock": "10.0.0.0/16", + "EnableDnsSupport": True, + "EnableDnsHostnames": True, + "Tags": [{"Key": "Name", "Value": "ecs-basics-vpc"}], + }, + }, + "InternetGateway": { + "Type": "AWS::EC2::InternetGateway", + "Properties": { + "Tags": [{"Key": "Name", "Value": "ecs-basics-igw"}] + }, + }, + "AttachGateway": { + "Type": "AWS::EC2::VPCGatewayAttachment", + "Properties": { + "VpcId": {"Ref": "VPC"}, + "InternetGatewayId": {"Ref": "InternetGateway"}, + }, + }, + "PublicSubnet1": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "VpcId": {"Ref": "VPC"}, + "CidrBlock": "10.0.1.0/24", + "AvailabilityZone": {"Fn::Select": ["0", {"Fn::GetAZs": ""}]}, + "MapPublicIpOnLaunch": True, + "Tags": [{"Key": "Name", "Value": "ecs-basics-subnet-1"}], + }, + }, + "PublicSubnet2": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "VpcId": {"Ref": "VPC"}, + "CidrBlock": "10.0.2.0/24", + "AvailabilityZone": {"Fn::Select": ["1", {"Fn::GetAZs": ""}]}, + "MapPublicIpOnLaunch": True, + "Tags": [{"Key": "Name", "Value": "ecs-basics-subnet-2"}], + }, + }, + "PublicRouteTable": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": {"Ref": "VPC"}, + "Tags": [{"Key": "Name", "Value": "ecs-basics-rt"}], + }, + }, + "PublicRoute": { + "Type": "AWS::EC2::Route", + "DependsOn": "AttachGateway", + "Properties": { + "RouteTableId": {"Ref": "PublicRouteTable"}, + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": {"Ref": "InternetGateway"}, + }, + }, + "SubnetRouteTableAssociation1": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "SubnetId": {"Ref": "PublicSubnet1"}, + "RouteTableId": {"Ref": "PublicRouteTable"}, + }, + }, + "SubnetRouteTableAssociation2": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "SubnetId": {"Ref": "PublicSubnet2"}, + "RouteTableId": {"Ref": "PublicRouteTable"}, + }, + }, + "SecurityGroup": { + "Type": "AWS::EC2::SecurityGroup", + "Properties": { + "GroupDescription": "ECS Basics - Allow HTTP inbound", + "VpcId": {"Ref": "VPC"}, + "SecurityGroupIngress": [ + { + "IpProtocol": "tcp", + "FromPort": 80, + "ToPort": 80, + "CidrIp": "0.0.0.0/0", + } + ], + "SecurityGroupEgress": [ + { + "IpProtocol": "-1", + "CidrIp": "0.0.0.0/0", + } + ], + "Tags": [{"Key": "Name", "Value": "ecs-basics-sg"}], + }, + }, + "TaskExecutionRole": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "ecs-tasks.amazonaws.com"}, + "Action": "sts:AssumeRole", + } + ], + }, + "ManagedPolicyArns": [ + "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" + ], + }, + }, + }, + "Outputs": { + "VpcId": {"Value": {"Ref": "VPC"}}, + "SubnetId1": {"Value": {"Ref": "PublicSubnet1"}}, + "SubnetId2": {"Value": {"Ref": "PublicSubnet2"}}, + "SecurityGroupId": {"Value": {"Ref": "SecurityGroup"}}, + "TaskExecutionRoleArn": {"Value": {"Fn::GetAtt": ["TaskExecutionRole", "Arn"]}}, + }, + } + return json.dumps(template) + + +def deploy_prerequisites(cfn_client, stack_name: str) -> dict: + """ + Deploys prerequisite resources using CloudFormation. + + :param cfn_client: A Boto3 CloudFormation client. + :param stack_name: The name for the CloudFormation stack. + :return: A dictionary of stack outputs. + """ + print(f"\n--- Deploying CloudFormation stack: {stack_name} ---") + template_body = get_cfn_template() + + cfn_client.create_stack( + StackName=stack_name, + TemplateBody=template_body, + Capabilities=["CAPABILITY_IAM"], + ) + + print("Waiting for stack creation to complete...") + waiter = cfn_client.get_waiter("stack_create_complete") + waiter.wait(StackName=stack_name, WaiterConfig={"Delay": 15, "MaxAttempts": 60}) + + response = cfn_client.describe_stacks(StackName=stack_name) + outputs = response["Stacks"][0]["Outputs"] + output_map = dict() + for output in outputs: + output_map[output["OutputKey"]] = output["OutputValue"] + + print("Stack created successfully. Resources:") + for key, value in output_map.items(): + print(f" {key}: {value}") + + return output_map + + +def cleanup_stack(cfn_client, stack_name: str) -> None: + """ + Deletes the CloudFormation stack. + + :param cfn_client: A Boto3 CloudFormation client. + :param stack_name: The stack name to delete. + """ + print(f"\n--- Deleting CloudFormation stack: {stack_name} ---") + cfn_client.delete_stack(StackName=stack_name) + waiter = cfn_client.get_waiter("stack_delete_complete") + waiter.wait(StackName=stack_name, WaiterConfig={"Delay": 15, "MaxAttempts": 60}) + print("Stack deleted successfully.") + + +def run_scenario(): + """Runs the Amazon ECS Basics scenario.""" + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + + print("=" * 70) + print("Welcome to the Amazon ECS Basics Scenario!") + print("=" * 70) + print( + "\nThis scenario demonstrates deploying and managing a containerized\n" + "application on Amazon ECS using AWS Fargate.\n" + ) + + # Configuration + timestamp = str(int(time.time())) + cluster_name = f"ecs-basics-cluster-{timestamp}" + service_name = "ecs-basics-service" + task_family = "ecs-basics-task-def" + stack_name = f"ecs-basics-stack-{timestamp}" + container_name = "web-container" + container_image = "public.ecr.aws/docker/library/httpd:latest" + + # Initialize clients + ecs_wrapper = EcsWrapper.from_client() + cfn_client = boto3.client("cloudformation") + + stack_outputs = None + + try: + # Setup: Deploy CloudFormation stack + stack_outputs = deploy_prerequisites(cfn_client, stack_name) + subnet1 = stack_outputs["SubnetId1"] + subnet2 = stack_outputs["SubnetId2"] + security_group_id = stack_outputs["SecurityGroupId"] + execution_role_arn = stack_outputs["TaskExecutionRoleArn"] + + # Step 1: Create an ECS Cluster + print(f"\n--- Step 1: Create ECS Cluster '{cluster_name}' ---") + cluster = ecs_wrapper.create_cluster(cluster_name) + print(f" Cluster ARN: {cluster['clusterArn']}") + print(f" Status: {cluster['status']}") + + # Step 2: Register a Task Definition + print(f"\n--- Step 2: Register Task Definition '{task_family}' ---") + task_def = ecs_wrapper.register_task_definition( + family=task_family, + execution_role_arn=execution_role_arn, + container_name=container_name, + container_image=container_image, + cpu="256", + memory="512", + container_port=80, + ) + task_def_arn = task_def["taskDefinitionArn"] + task_def_revision = f"{task_def['family']}:{task_def['revision']}" + print(f" Task Definition ARN: {task_def_arn}") + print(f" Revision: {task_def['revision']}") + + # Step 3: Describe the Task Definition + print(f"\n--- Step 3: Describe Task Definition '{task_def_revision}' ---") + described_td = ecs_wrapper.describe_task_definition(task_def_revision) + print(f" Family: {described_td['family']}") + print(f" Revision: {described_td['revision']}") + print(f" Status: {described_td['status']}") + print(f" CPU: {described_td['cpu']}, Memory: {described_td['memory']}") + print(f" Network Mode: {described_td['networkMode']}") + if described_td.get("containerDefinitions"): + print(f" Container Image: {described_td['containerDefinitions'][0]['image']}") + + # Step 4: Create an ECS Service + print(f"\n--- Step 4: Create ECS Service '{service_name}' ---") + service = ecs_wrapper.create_service( + cluster_name=cluster_name, + service_name=service_name, + task_definition=task_def_revision, + desired_count=1, + subnets=[subnet1, subnet2], + security_groups=[security_group_id], + assign_public_ip=True, + ) + print(f" Service ARN: {service['serviceArn']}") + print(f" Status: {service['status']}") + + # Step 5: Wait for Service Stability and Describe Service + print(f"\n--- Step 5: Wait for Service Stability ---") + try: + ecs_wrapper.wait_for_service_stable(cluster_name, service_name) + except WaiterError as e: + logger.warning("Waiter timed out: %s. Continuing...", str(e)) + + services = ecs_wrapper.describe_services(cluster_name, [service_name]) + if services: + svc = services[0] + print(f" Service Status: {svc['status']}") + print(f" Running Count: {svc['runningCount']}") + print(f" Desired Count: {svc['desiredCount']}") + + # Step 6: List Running Tasks + print(f"\n--- Step 6: List Running Tasks ---") + task_arns = ecs_wrapper.list_tasks(cluster_name, service_name) + for arn in task_arns: + print(f" Task ARN: {arn}") + + # Step 7: Describe Tasks + if task_arns: + print(f"\n--- Step 7: Describe Tasks ---") + tasks = ecs_wrapper.describe_tasks(cluster_name, task_arns) + for task in tasks: + print(f" Task ARN: {task['taskArn']}") + print(f" Last Status: {task['lastStatus']}") + print(f" Health Status: {task.get('healthStatus', 'N/A')}") + print(f" CPU: {task.get('cpu', 'N/A')}, Memory: {task.get('memory', 'N/A')}") + for container in task.get("containers", list()): + print(f" Container: {container['name']} - {container.get('lastStatus', 'N/A')}") + for ni in container.get("networkInterfaces", list()): + print(f" Private IP: {ni.get('privateIpv4Address', 'N/A')}") + for attachment in task.get("attachments", list()): + for detail in attachment.get("details", list()): + if detail.get("name") == "networkInterfaceId": + print(f" ENI: {detail.get('value', 'N/A')}") + else: + print("\n--- Step 7: No tasks to describe ---") + + # Step 8: Update the Service (Scale Up) + print(f"\n--- Step 8: Scale Service to 2 Tasks ---") + ecs_wrapper.update_service(cluster_name, service_name, desired_count=2) + print(" Waiting for service to stabilize after scaling...") + try: + ecs_wrapper.wait_for_service_stable(cluster_name, service_name) + except WaiterError as e: + logger.warning("Waiter timed out during scale up: %s. Continuing...", str(e)) + + services = ecs_wrapper.describe_services(cluster_name, [service_name]) + if services: + svc = services[0] + print(f" Running Count: {svc['runningCount']}") + print(f" Desired Count: {svc['desiredCount']}") + + finally: + # Cleanup + print("\n" + "=" * 70) + print("CLEANUP") + print("=" * 70) + + # Step 9: Delete the ECS Service + try: + print(f"\n--- Cleanup: Scale down and delete service '{service_name}' ---") + try: + ecs_wrapper.update_service(cluster_name, service_name, desired_count=0) + time.sleep(5) + except ClientError: + pass + + try: + ecs_wrapper.delete_service(cluster_name, service_name, force=True) + print(" Service deletion initiated.") + try: + ecs_wrapper.wait_for_service_inactive(cluster_name, service_name) + print(" Service is now inactive.") + except WaiterError: + logger.warning("Service inactive waiter timed out.") + except ClientError as err: + if err.response["Error"]["Code"] == "ServiceNotFoundException": + print(" Service already deleted.") + else: + logger.error("Error deleting service: %s", err) + except Exception as e: + logger.error("Unexpected error during service cleanup: %s", e) + + # Step 10: Delete the ECS Cluster + try: + print(f"\n--- Cleanup: Delete cluster '{cluster_name}' ---") + ecs_wrapper.delete_cluster(cluster_name) + print(" Cluster deleted.") + except ClientError as err: + logger.error("Error deleting cluster: %s", err) + + # Step 11: Delete the CloudFormation Stack + if stack_outputs is not None: + try: + cleanup_stack(cfn_client, stack_name) + except Exception as e: + logger.error("Error deleting CloudFormation stack: %s", e) + + print("\n" + "=" * 70) + print("Amazon ECS Basics Scenario complete!") + print("=" * 70) + + +if __name__ == "__main__": + run_scenario() +# snippet-end:[python.example_code.ecs.EcsScenario] diff --git a/python/example_code/ecs/test/test_ecs_integ.py b/python/example_code/ecs/test/test_ecs_integ.py new file mode 100644 index 00000000000..569410d4cee --- /dev/null +++ b/python/example_code/ecs/test/test_ecs_integ.py @@ -0,0 +1,744 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +""" +Unit tests for Amazon ECS wrapper using botocore Stubber. +Tests run offline without AWS credentials. +""" + +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import pytest +import boto3 +from botocore.stub import Stubber, ANY +from botocore.exceptions import ClientError + +from ecs_wrapper import EcsWrapper + + +# --- Success Tests --- + + +def test_create_cluster(): + """Test successful cluster creation.""" + client = boto3.client("ecs", region_name="us-east-1") + wrapper = EcsWrapper(client) + stubber = Stubber(client) + + expected_params = { + "clusterName": "test-cluster", + "settings": [{"name": "containerInsights", "value": "enabled"}], + } + response = { + "cluster": { + "clusterArn": "arn:aws:ecs:us-east-1:123456789012:cluster/test-cluster", + "clusterName": "test-cluster", + "status": "ACTIVE", + "registeredContainerInstancesCount": 0, + "runningTasksCount": 0, + "pendingTasksCount": 0, + "activeServicesCount": 0, + "settings": [{"name": "containerInsights", "value": "enabled"}], + } + } + stubber.add_response("create_cluster", response, expected_params) + stubber.activate() + + result = wrapper.create_cluster("test-cluster") + assert result["clusterArn"] == "arn:aws:ecs:us-east-1:123456789012:cluster/test-cluster" + assert result["status"] == "ACTIVE" + + stubber.deactivate() + + +def test_register_task_definition(): + """Test successful task definition registration.""" + client = boto3.client("ecs", region_name="us-east-1") + wrapper = EcsWrapper(client) + stubber = Stubber(client) + + expected_params = { + "family": "test-task-def", + "networkMode": "awsvpc", + "requiresCompatibilities": ["FARGATE"], + "cpu": "256", + "memory": "512", + "executionRoleArn": "arn:aws:iam::123456789012:role/ecsTaskExecutionRole", + "containerDefinitions": [ + { + "name": "web-container", + "image": "public.ecr.aws/docker/library/httpd:latest", + "essential": True, + "portMappings": [{"containerPort": 80, "protocol": "tcp"}], + } + ], + } + response = { + "taskDefinition": { + "taskDefinitionArn": "arn:aws:ecs:us-east-1:123456789012:task-definition/test-task-def:1", + "family": "test-task-def", + "revision": 1, + "status": "ACTIVE", + "networkMode": "awsvpc", + "cpu": "256", + "memory": "512", + "containerDefinitions": [ + { + "name": "web-container", + "image": "public.ecr.aws/docker/library/httpd:latest", + "essential": True, + "portMappings": [{"containerPort": 80, "protocol": "tcp"}], + "cpu": 0, + "memory": 0, + } + ], + "requiresCompatibilities": ["FARGATE"], + "executionRoleArn": "arn:aws:iam::123456789012:role/ecsTaskExecutionRole", + } + } + stubber.add_response("register_task_definition", response, expected_params) + stubber.activate() + + result = wrapper.register_task_definition( + family="test-task-def", + execution_role_arn="arn:aws:iam::123456789012:role/ecsTaskExecutionRole", + container_name="web-container", + container_image="public.ecr.aws/docker/library/httpd:latest", + cpu="256", + memory="512", + container_port=80, + ) + assert result["taskDefinitionArn"] == "arn:aws:ecs:us-east-1:123456789012:task-definition/test-task-def:1" + assert result["revision"] == 1 + + stubber.deactivate() + + +def test_describe_task_definition(): + """Test successful task definition describe.""" + client = boto3.client("ecs", region_name="us-east-1") + wrapper = EcsWrapper(client) + stubber = Stubber(client) + + expected_params = {"taskDefinition": "test-task-def:1"} + response = { + "taskDefinition": { + "taskDefinitionArn": "arn:aws:ecs:us-east-1:123456789012:task-definition/test-task-def:1", + "family": "test-task-def", + "revision": 1, + "status": "ACTIVE", + "networkMode": "awsvpc", + "cpu": "256", + "memory": "512", + "containerDefinitions": [ + { + "name": "web-container", + "image": "public.ecr.aws/docker/library/httpd:latest", + "essential": True, + "portMappings": [{"containerPort": 80, "protocol": "tcp"}], + "cpu": 0, + "memory": 0, + } + ], + "requiresCompatibilities": ["FARGATE"], + } + } + stubber.add_response("describe_task_definition", response, expected_params) + stubber.activate() + + result = wrapper.describe_task_definition("test-task-def:1") + assert result["family"] == "test-task-def" + assert result["status"] == "ACTIVE" + assert result["cpu"] == "256" + + stubber.deactivate() + + +def test_create_service(): + """Test successful service creation.""" + client = boto3.client("ecs", region_name="us-east-1") + wrapper = EcsWrapper(client) + stubber = Stubber(client) + + expected_params = { + "cluster": "test-cluster", + "serviceName": "test-service", + "taskDefinition": "test-task-def:1", + "desiredCount": 1, + "launchType": "FARGATE", + "networkConfiguration": { + "awsvpcConfiguration": { + "subnets": ["subnet-111", "subnet-222"], + "securityGroups": ["sg-123"], + "assignPublicIp": "ENABLED", + } + }, + "deploymentConfiguration": { + "deploymentCircuitBreaker": {"enable": True, "rollback": True} + }, + } + response = { + "service": { + "serviceArn": "arn:aws:ecs:us-east-1:123456789012:service/test-cluster/test-service", + "serviceName": "test-service", + "clusterArn": "arn:aws:ecs:us-east-1:123456789012:cluster/test-cluster", + "status": "ACTIVE", + "desiredCount": 1, + "runningCount": 0, + "taskDefinition": "arn:aws:ecs:us-east-1:123456789012:task-definition/test-task-def:1", + "launchType": "FARGATE", + } + } + stubber.add_response("create_service", response, expected_params) + stubber.activate() + + result = wrapper.create_service( + cluster_name="test-cluster", + service_name="test-service", + task_definition="test-task-def:1", + desired_count=1, + subnets=["subnet-111", "subnet-222"], + security_groups=["sg-123"], + assign_public_ip=True, + ) + assert result["serviceArn"] == "arn:aws:ecs:us-east-1:123456789012:service/test-cluster/test-service" + assert result["status"] == "ACTIVE" + + stubber.deactivate() + + +def test_describe_services(): + """Test successful service describe.""" + client = boto3.client("ecs", region_name="us-east-1") + wrapper = EcsWrapper(client) + stubber = Stubber(client) + + expected_params = { + "cluster": "test-cluster", + "services": ["test-service"], + } + response = { + "services": [ + { + "serviceArn": "arn:aws:ecs:us-east-1:123456789012:service/test-cluster/test-service", + "serviceName": "test-service", + "clusterArn": "arn:aws:ecs:us-east-1:123456789012:cluster/test-cluster", + "status": "ACTIVE", + "desiredCount": 1, + "runningCount": 1, + "taskDefinition": "arn:aws:ecs:us-east-1:123456789012:task-definition/test-task-def:1", + "launchType": "FARGATE", + } + ], + "failures": [], + } + stubber.add_response("describe_services", response, expected_params) + stubber.activate() + + result = wrapper.describe_services("test-cluster", ["test-service"]) + assert len(result) == 1 + assert result[0]["runningCount"] == 1 + assert result[0]["status"] == "ACTIVE" + + stubber.deactivate() + + +def test_list_tasks(): + """Test successful task listing with paginator.""" + client = boto3.client("ecs", region_name="us-east-1") + wrapper = EcsWrapper(client) + stubber = Stubber(client) + + expected_params = { + "cluster": "test-cluster", + "serviceName": "test-service", + "desiredStatus": "RUNNING", + } + response = { + "taskArns": [ + "arn:aws:ecs:us-east-1:123456789012:task/test-cluster/task-id-1" + ], + } + stubber.add_response("list_tasks", response, expected_params) + stubber.activate() + + result = wrapper.list_tasks("test-cluster", "test-service") + assert len(result) == 1 + assert "task-id-1" in result[0] + + stubber.deactivate() + + +def test_describe_tasks(): + """Test successful task describe.""" + client = boto3.client("ecs", region_name="us-east-1") + wrapper = EcsWrapper(client) + stubber = Stubber(client) + + task_arn = "arn:aws:ecs:us-east-1:123456789012:task/test-cluster/task-id-1" + expected_params = { + "cluster": "test-cluster", + "tasks": [task_arn], + } + response = { + "tasks": [ + { + "taskArn": task_arn, + "clusterArn": "arn:aws:ecs:us-east-1:123456789012:cluster/test-cluster", + "lastStatus": "RUNNING", + "desiredStatus": "RUNNING", + "cpu": "256", + "memory": "512", + "containers": [ + { + "containerArn": "arn:aws:ecs:us-east-1:123456789012:container/container-id-1", + "name": "web-container", + "lastStatus": "RUNNING", + "networkInterfaces": [ + { + "attachmentId": "attach-id-1", + "privateIpv4Address": "10.0.1.100", + } + ], + } + ], + "attachments": [ + { + "id": "attach-id-1", + "type": "ElasticNetworkInterface", + "status": "ATTACHED", + "details": [ + {"name": "networkInterfaceId", "value": "eni-abc123"}, + {"name": "privateIPv4Address", "value": "10.0.1.100"}, + ], + } + ], + } + ], + "failures": [], + } + stubber.add_response("describe_tasks", response, expected_params) + stubber.activate() + + result = wrapper.describe_tasks("test-cluster", [task_arn]) + assert len(result) == 1 + assert result[0]["lastStatus"] == "RUNNING" + assert result[0]["cpu"] == "256" + + stubber.deactivate() + + +def test_update_service(): + """Test successful service update (scale up).""" + client = boto3.client("ecs", region_name="us-east-1") + wrapper = EcsWrapper(client) + stubber = Stubber(client) + + expected_params = { + "cluster": "test-cluster", + "service": "test-service", + "desiredCount": 2, + } + response = { + "service": { + "serviceArn": "arn:aws:ecs:us-east-1:123456789012:service/test-cluster/test-service", + "serviceName": "test-service", + "clusterArn": "arn:aws:ecs:us-east-1:123456789012:cluster/test-cluster", + "status": "ACTIVE", + "desiredCount": 2, + "runningCount": 1, + "taskDefinition": "arn:aws:ecs:us-east-1:123456789012:task-definition/test-task-def:1", + "launchType": "FARGATE", + } + } + stubber.add_response("update_service", response, expected_params) + stubber.activate() + + result = wrapper.update_service("test-cluster", "test-service", desired_count=2) + assert result["desiredCount"] == 2 + + stubber.deactivate() + + +def test_delete_service(): + """Test successful service deletion.""" + client = boto3.client("ecs", region_name="us-east-1") + wrapper = EcsWrapper(client) + stubber = Stubber(client) + + expected_params = { + "cluster": "test-cluster", + "service": "test-service", + "force": True, + } + response = { + "service": { + "serviceArn": "arn:aws:ecs:us-east-1:123456789012:service/test-cluster/test-service", + "serviceName": "test-service", + "clusterArn": "arn:aws:ecs:us-east-1:123456789012:cluster/test-cluster", + "status": "DRAINING", + "desiredCount": 0, + "runningCount": 0, + "taskDefinition": "arn:aws:ecs:us-east-1:123456789012:task-definition/test-task-def:1", + "launchType": "FARGATE", + } + } + stubber.add_response("delete_service", response, expected_params) + stubber.activate() + + result = wrapper.delete_service("test-cluster", "test-service", force=True) + assert result["status"] == "DRAINING" + + stubber.deactivate() + + +def test_delete_cluster(): + """Test successful cluster deletion.""" + client = boto3.client("ecs", region_name="us-east-1") + wrapper = EcsWrapper(client) + stubber = Stubber(client) + + expected_params = {"cluster": "test-cluster"} + response = { + "cluster": { + "clusterArn": "arn:aws:ecs:us-east-1:123456789012:cluster/test-cluster", + "clusterName": "test-cluster", + "status": "INACTIVE", + "registeredContainerInstancesCount": 0, + "runningTasksCount": 0, + "pendingTasksCount": 0, + "activeServicesCount": 0, + } + } + stubber.add_response("delete_cluster", response, expected_params) + stubber.activate() + + result = wrapper.delete_cluster("test-cluster") + assert result["status"] == "INACTIVE" + + stubber.deactivate() + + +def test_list_clusters(): + """Test successful cluster listing.""" + client = boto3.client("ecs", region_name="us-east-1") + wrapper = EcsWrapper(client) + stubber = Stubber(client) + + response = { + "clusterArns": [ + "arn:aws:ecs:us-east-1:123456789012:cluster/cluster-1", + "arn:aws:ecs:us-east-1:123456789012:cluster/cluster-2", + ], + } + stubber.add_response("list_clusters", response) + stubber.activate() + + result = wrapper.list_clusters() + assert len(result) == 2 + assert "cluster-1" in result[0] + + stubber.deactivate() + + +# --- Error Tests --- + + +def test_create_cluster_client_exception(): + """Test cluster creation with ClientException.""" + client = boto3.client("ecs", region_name="us-east-1") + wrapper = EcsWrapper(client) + stubber = Stubber(client) + stubber.add_client_error( + "create_cluster", + service_error_code="ClientException", + service_message="Cluster creation failed due to client-side error", + expected_params={ + "clusterName": "bad-cluster", + "settings": [{"name": "containerInsights", "value": "enabled"}], + }, + ) + stubber.activate() + + with pytest.raises(ClientError) as exc_info: + wrapper.create_cluster("bad-cluster") + assert exc_info.value.response["Error"]["Code"] == "ClientException" + + stubber.deactivate() + + +def test_create_service_cluster_not_found(): + """Test service creation when cluster doesn't exist.""" + client = boto3.client("ecs", region_name="us-east-1") + wrapper = EcsWrapper(client) + stubber = Stubber(client) + stubber.add_client_error( + "create_service", + service_error_code="ClusterNotFoundException", + service_message="Cluster not found", + expected_params={ + "cluster": "nonexistent-cluster", + "serviceName": "test-service", + "taskDefinition": "test-task-def:1", + "desiredCount": 1, + "launchType": "FARGATE", + "networkConfiguration": { + "awsvpcConfiguration": { + "subnets": ["subnet-111"], + "securityGroups": ["sg-123"], + "assignPublicIp": "ENABLED", + } + }, + "deploymentConfiguration": { + "deploymentCircuitBreaker": {"enable": True, "rollback": True} + }, + }, + ) + stubber.activate() + + with pytest.raises(ClientError) as exc_info: + wrapper.create_service( + cluster_name="nonexistent-cluster", + service_name="test-service", + task_definition="test-task-def:1", + desired_count=1, + subnets=["subnet-111"], + security_groups=["sg-123"], + ) + assert exc_info.value.response["Error"]["Code"] == "ClusterNotFoundException" + + stubber.deactivate() + + +def test_describe_services_cluster_not_found(): + """Test describe services when cluster doesn't exist.""" + client = boto3.client("ecs", region_name="us-east-1") + wrapper = EcsWrapper(client) + stubber = Stubber(client) + stubber.add_client_error( + "describe_services", + service_error_code="ClusterNotFoundException", + service_message="Cluster not found", + expected_params={ + "cluster": "nonexistent-cluster", + "services": ["test-service"], + }, + ) + stubber.activate() + + with pytest.raises(ClientError) as exc_info: + wrapper.describe_services("nonexistent-cluster", ["test-service"]) + assert exc_info.value.response["Error"]["Code"] == "ClusterNotFoundException" + + stubber.deactivate() + + +def test_update_service_service_not_found(): + """Test update service when service doesn't exist.""" + client = boto3.client("ecs", region_name="us-east-1") + wrapper = EcsWrapper(client) + stubber = Stubber(client) + stubber.add_client_error( + "update_service", + service_error_code="ServiceNotFoundException", + service_message="Service not found", + expected_params={ + "cluster": "test-cluster", + "service": "nonexistent-service", + "desiredCount": 2, + }, + ) + stubber.activate() + + with pytest.raises(ClientError) as exc_info: + wrapper.update_service("test-cluster", "nonexistent-service", desired_count=2) + assert exc_info.value.response["Error"]["Code"] == "ServiceNotFoundException" + + stubber.deactivate() + + +def test_delete_cluster_has_active_services(): + """Test delete cluster when it has active services.""" + client = boto3.client("ecs", region_name="us-east-1") + wrapper = EcsWrapper(client) + stubber = Stubber(client) + stubber.add_client_error( + "delete_cluster", + service_error_code="ClusterContainsServicesException", + service_message="Cluster has active services", + expected_params={ + "cluster": "test-cluster", + }, + ) + stubber.activate() + + with pytest.raises(ClientError) as exc_info: + wrapper.delete_cluster("test-cluster") + assert exc_info.value.response["Error"]["Code"] == "ClusterContainsServicesException" + + stubber.deactivate() + + +def test_list_tasks_service_not_found(): + """Test list tasks when service doesn't exist.""" + client = boto3.client("ecs", region_name="us-east-1") + wrapper = EcsWrapper(client) + stubber = Stubber(client) + stubber.add_client_error( + "list_tasks", + service_error_code="ServiceNotFoundException", + service_message="Service not found", + expected_params={ + "cluster": "test-cluster", + "serviceName": "nonexistent-service", + "desiredStatus": "RUNNING", + }, + ) + stubber.activate() + + with pytest.raises(ClientError) as exc_info: + wrapper.list_tasks("test-cluster", "nonexistent-service") + assert exc_info.value.response["Error"]["Code"] == "ServiceNotFoundException" + + stubber.deactivate() + + +def test_describe_tasks_cluster_not_found(): + """Test describe tasks when cluster doesn't exist.""" + client = boto3.client("ecs", region_name="us-east-1") + wrapper = EcsWrapper(client) + stubber = Stubber(client) + stubber.add_client_error( + "describe_tasks", + service_error_code="ClusterNotFoundException", + service_message="Cluster not found", + expected_params={ + "cluster": "nonexistent-cluster", + "tasks": ["arn:aws:ecs:us-east-1:123456789012:task/task-id-1"], + }, + ) + stubber.activate() + + with pytest.raises(ClientError) as exc_info: + wrapper.describe_tasks( + "nonexistent-cluster", + ["arn:aws:ecs:us-east-1:123456789012:task/task-id-1"], + ) + assert exc_info.value.response["Error"]["Code"] == "ClusterNotFoundException" + + stubber.deactivate() + + +def test_delete_service_not_found(): + """Test delete service when service doesn't exist.""" + client = boto3.client("ecs", region_name="us-east-1") + wrapper = EcsWrapper(client) + stubber = Stubber(client) + stubber.add_client_error( + "delete_service", + service_error_code="ServiceNotFoundException", + service_message="Service not found", + expected_params={ + "cluster": "test-cluster", + "service": "nonexistent-service", + "force": True, + }, + ) + stubber.activate() + + with pytest.raises(ClientError) as exc_info: + wrapper.delete_service("test-cluster", "nonexistent-service", force=True) + assert exc_info.value.response["Error"]["Code"] == "ServiceNotFoundException" + + stubber.deactivate() + + +def test_register_task_definition_invalid_param(): + """Test register task definition with InvalidParameterException.""" + client = boto3.client("ecs", region_name="us-east-1") + wrapper = EcsWrapper(client) + stubber = Stubber(client) + stubber.add_client_error( + "register_task_definition", + service_error_code="InvalidParameterException", + service_message="Invalid parameter: cpu must be a valid value", + expected_params={ + "family": "bad-task-def", + "networkMode": "awsvpc", + "requiresCompatibilities": ["FARGATE"], + "cpu": "999", + "memory": "512", + "executionRoleArn": "arn:aws:iam::123456789012:role/ecsTaskExecutionRole", + "containerDefinitions": [ + { + "name": "web-container", + "image": "public.ecr.aws/docker/library/httpd:latest", + "essential": True, + "portMappings": [{"containerPort": 80, "protocol": "tcp"}], + } + ], + }, + ) + stubber.activate() + + with pytest.raises(ClientError) as exc_info: + wrapper.register_task_definition( + family="bad-task-def", + execution_role_arn="arn:aws:iam::123456789012:role/ecsTaskExecutionRole", + container_name="web-container", + container_image="public.ecr.aws/docker/library/httpd:latest", + cpu="999", + memory="512", + container_port=80, + ) + assert exc_info.value.response["Error"]["Code"] == "InvalidParameterException" + + stubber.deactivate() + + +# --- CloudFormation Stub Tests --- + + +def test_create_stack(): + """Test CloudFormation stack creation stub.""" + cfn_client = boto3.client("cloudformation", region_name="us-east-1") + stubber = Stubber(cfn_client) + + expected_params = { + "StackName": "ecs-basics-stack-123", + "TemplateBody": "template-body", + "Capabilities": ["CAPABILITY_IAM"], + } + response = { + "StackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/ecs-basics-stack-123/guid" + } + stubber.add_response("create_stack", response, expected_params) + stubber.activate() + + result = cfn_client.create_stack( + StackName="ecs-basics-stack-123", + TemplateBody="template-body", + Capabilities=["CAPABILITY_IAM"], + ) + assert "StackId" in result + + stubber.deactivate() + + +def test_delete_stack(): + """Test CloudFormation stack deletion stub.""" + cfn_client = boto3.client("cloudformation", region_name="us-east-1") + stubber = Stubber(cfn_client) + + expected_params = {"StackName": "ecs-basics-stack-123"} + response = dict() + stubber.add_response("delete_stack", response, expected_params) + stubber.activate() + + result = cfn_client.delete_stack(StackName="ecs-basics-stack-123") + assert result is not None + + stubber.deactivate()