diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 93df15b1..ac26c82c 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -3,10 +3,10 @@ on: push: pull_request: schedule: - - cron: '0 18 * * sun' + - cron: '0 18 * * SUN' jobs: - tflint: + lint: name: Terraform validate ${{ matrix.terraform_version }} runs-on: ubuntu-latest strategy: @@ -14,15 +14,28 @@ jobs: matrix: terraform_version: - latest - - 0.14.0 - - 0.13.0 + - 1.2.9 + - 1.1.9 steps: - uses: actions/checkout@master - - name: Terraform validate - run: tests/sanity/terraform_tests.sh - env: + - uses: hashicorp/setup-terraform@v2 + with: terraform_version: "${{ matrix.terraform_version }}" + - name: Terraform version + id: version + run: terraform version + - name: Terraform fmt + id: fmt + run: terraform fmt -check + continue-on-error: true + - name: Terraform init + id: init + run: terraform init + - name: Terraform Validate + id: validate + run: terraform validate -no-color + pythontest: name: ${{ matrix.config.toxenv }} runs-on: ubuntu-latest @@ -30,24 +43,26 @@ jobs: fail-fast: false matrix: config: - - toxenv: py35 - python-version: 3.5 - - toxenv: py36 - python-version: 3.6 - toxenv: py37 python-version: 3.7 - toxenv: py38 python-version: 3.8 + - toxenv: py39 + python-version: 3.9 + - toxenv: py310 + python-version: '3.10' + # - toxenv: py311 + # python-version: 3.11 - toxenv: flake8 - python-version: 3.7 + python-version: 3.8 - toxenv: pylint - python-version: 3.7 + python-version: 3.8 - toxenv: black - python-version: 3.7 + python-version: 3.8 - toxenv: mypy - python-version: 3.7 + python-version: 3.8 - toxenv: pytest - python-version: 3.7 + python-version: 3.8 steps: - name: Checkout repository diff --git a/.python-version b/.python-version new file mode 100644 index 00000000..21af9507 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.9.13 diff --git a/README.md b/README.md index 7d8f08d4..1a7e07bd 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ If you are using Terraform 0.11 you can use versions v1.*. * Aws lambda runtine Python 3.7 * ec2 instances scheduling +* ecs service scheduling * rds clusters scheduling * rds instances scheduling * autoscalings scheduling @@ -29,6 +30,7 @@ module "stop_ec2_instance" { schedule_action = "stop" autoscaling_schedule = "false" ec2_schedule = "true" + ecs_schedule = "false" rds_schedule = "false" cloudwatch_alarm_schedule = "false" scheduler_tag = { @@ -44,6 +46,7 @@ module "start_ec2_instance" { schedule_action = "start" autoscaling_schedule = "false" ec2_schedule = "true" + ecs_schedule = "false" rds_schedule = "false" cloudwatch_alarm_schedule = "false" scheduler_tag = { @@ -72,6 +75,7 @@ module "start_ec2_instance" { | cloudwatch_schedule_expression | The scheduling expression | string | `"cron(0 22 ? * MON-FRI *)"` | yes | | autoscaling_schedule | Enable scheduling on autoscaling resources | string | `"false"` | no | | ec2_schedule | Enable scheduling on ec2 instance resources | string | `"false"` | no | +| ecs_schedule | Enable scheduling on ecs services resources | string | `"false"` | no | | rds_schedule | Enable scheduling on rds resources | string | `"false"` | no | | cloudwatch_alarm_schedule | Enable scheduleding on cloudwatch alarm resources | string | `"false"` | no | | schedule_action | Define schedule action to apply on resources | string | `"stop"` | yes | @@ -157,6 +161,7 @@ Apache 2 Licensed. See LICENSE for full details. * [cloudwatch schedule expressions](https://docs.aws.amazon.com/AmazonCloudWatch/latest/events/ScheduledEvents.html) * [Python boto3 ec2](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ec2.html) +* [Python boto3 ecs](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ecs.html) * [Python boto3 rds](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/rds.html) * [Python boto3 autoscaling](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/autoscaling.html) * [Terratest](https://github.com/gruntwork-io/terratest) diff --git a/examples/ecs-scheduler/cloudwatch_alarm.tf b/examples/ecs-scheduler/cloudwatch_alarm.tf new file mode 100644 index 00000000..d7e1a0ef --- /dev/null +++ b/examples/ecs-scheduler/cloudwatch_alarm.tf @@ -0,0 +1,18 @@ +resource "aws_cloudwatch_metric_alarm" "service_count" { + alarm_name = "ecs-cluster-hello-service-count" + comparison_operator = "LessThanThreshold" + evaluation_periods = "2" + metric_name = "CPUUtilization" + namespace = "AWS/ECS" + period = "60" + statistic = "SampleCount" + threshold = "2" + alarm_description = "Less than 2 Running Service on cluster" + dimensions = { + ClusterName = aws_ecs_cluster.hello.id + } + + tags = { + tostop = "true" + } +} diff --git a/examples/ecs-scheduler/ecs.tf b/examples/ecs-scheduler/ecs.tf new file mode 100644 index 00000000..af3bb0d7 --- /dev/null +++ b/examples/ecs-scheduler/ecs.tf @@ -0,0 +1,81 @@ +resource "aws_ecs_cluster" "hello" { + name = "ecs-scheduler-test-cluster" + + setting { + name = "containerInsights" + value = "disabled" + } +} + +resource "aws_ecs_service" "hello" { + name = "ecs-scheduler-test-service" + cluster = aws_ecs_cluster.hello.id + task_definition = aws_ecs_task_definition.hello.arn + desired_count = 1 + launch_type = "FARGATE" + + network_configuration { + subnets = [aws_subnet.primary.id] + } + + tags = { + tostop = "true", + terratest_tag = var.random_tag + } + lifecycle { + ignore_changes = [ + desired_count, + tags + ] + } +} + +resource "aws_ecs_service" "hello-false" { + name = "ecs-scheduler-test-false-service" + cluster = aws_ecs_cluster.hello.id + task_definition = aws_ecs_task_definition.hello.arn + desired_count = 1 + launch_type = "FARGATE" + + network_configuration { + subnets = [aws_subnet.primary.id] + } + + tags = { + tostop = "false", + terratest_tag = var.random_tag + } + lifecycle { + ignore_changes = [ + desired_count, + tags + ] + } +} + +resource "aws_ecs_task_definition" "hello" { + family = "hello-world-1" + + # Refer to https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-cpu-memory-error.html + # for cpu and memory values + cpu = 256 + memory = 512 + + requires_compatibilities = ["FARGATE"] + network_mode = "awsvpc" + + # execution_role_arn = aws_iam_role.ecs_service.arn + task_role_arn = aws_iam_role.hello_ecs_task_execution_role.arn + + container_definitions = jsonencode([ + { + name = "hello-world-rest" + image = "public.ecr.aws/docker/library/busybox:latest" + essential = true + } + ]) + + tags = { + terratest_tag = var.random_tag + } +} \ No newline at end of file diff --git a/examples/ecs-scheduler/iam.tf b/examples/ecs-scheduler/iam.tf new file mode 100644 index 00000000..10e9d90b --- /dev/null +++ b/examples/ecs-scheduler/iam.tf @@ -0,0 +1,24 @@ +resource "aws_iam_role" "hello_ecs_task_execution_role" { + name = "hello-ecsTaskExecutionRole" + + assume_role_policy = < None: + """Initialize ECS service scheduler.""" + if region_name: + self.ecs = boto3.client("ecs", region_name=region_name) + else: + self.ecs = boto3.client("ecs") + self.tag_api = FilterByTags(region_name=region_name) + + def stop(self, aws_tags: List[Dict]) -> None: + """Aws ecs instance stop function. + + Stop ecs service with defined tags and disable its Cloudwatch + alarms. + + :param list[map] aws_tags: + Aws tags to use for filter resources. + For example: + [ + { + 'Key': 'string', + 'Values': [ + 'string', + ] + } + ] + """ + for service_arn in self.tag_api.get_resources("ecs:service", aws_tags): + service_name = service_arn.split("/")[-1] + cluster_name = service_arn.split("/")[-2] + try: + self.ecs.update_service( + cluster=cluster_name, service=service_name, desiredCount=0 + ) + print( + "Stop ECS Service {0} on Cluster {1}".format( + service_name, cluster_name + ) + ) + except ClientError as exc: + ecs_exception("ECS Service", service_name, exc) + + def start(self, aws_tags: List[Dict]) -> None: + """Aws ec2 instance start function. + + Start ec2 instances with defined tags. + + Aws tags to use for filter resources + Aws tags to use for filter resources. + For example: + [ + { + 'Key': 'string', + 'Values': [ + 'string', + ] + } + ] + """ + for service_arn in self.tag_api.get_resources("ecs:service", aws_tags): + service_name = service_arn.split("/")[-1] + cluster_name = service_arn.split("/")[-2] + try: + self.ecs.update_service( + cluster=cluster_name, service=service_name, desiredCount=1 + ) + print( + "Start ECS Service {0} on Cluster {1}".format( + service_name, cluster_name + ) + ) + except ClientError as exc: + ecs_exception("ECS Service", service_name, exc) diff --git a/package/scheduler/exceptions.py b/package/scheduler/exceptions.py index c5e51840..64dd26ff 100644 --- a/package/scheduler/exceptions.py +++ b/package/scheduler/exceptions.py @@ -48,6 +48,49 @@ def ec2_exception(resource_name: str, resource_id: str, exception) -> None: ) +def ecs_exception(resource_name: str, resource_id: str, exception) -> None: + """Exception raised during execution of ecs scheduler. + + Log instance, spot instance and autoscaling groups exceptions + on the specific aws resources. + + :param str resource_name: + Aws resource name + :param str resource_id: + Aws resource id + :param str exception: + Human readable string describing the exception + """ + info_codes = ["ClusterNotFoundException"] + warning_codes = [ + "ServiceNotActiveException", + "ServiceNotFoundException", + "InvalidParameterException", + ] + + if exception.response["Error"]["Code"] in info_codes: + logging.info( + "%s %s: %s", + resource_name, + resource_id, + exception, + ) + elif exception.response["Error"]["Code"] in warning_codes: + logging.warning( + "%s %s: %s", + resource_name, + resource_id, + exception, + ) + else: + logging.error( + "Unexpected error on %s %s: %s", + resource_name, + resource_id, + exception, + ) + + def rds_exception(resource_name: str, resource_id: str, exception) -> None: """Exception raised during execution of rds scheduler. diff --git a/package/scheduler/main.py b/package/scheduler/main.py index 2d1aa3f2..2ee6ef1e 100644 --- a/package/scheduler/main.py +++ b/package/scheduler/main.py @@ -6,6 +6,7 @@ from scheduler.autoscaling_handler import AutoscalingScheduler from scheduler.cloudwatch_handler import CloudWatchAlarmScheduler +from scheduler.ecs_handler import EcsScheduler from scheduler.instance_handler import InstanceScheduler from scheduler.rds_handler import RdsScheduler @@ -17,6 +18,7 @@ def lambda_handler(event, context): - rds instances - rds aurora clusters - instance ec2 + - ecs services Suspend and resume AWS resources: - ec2 autoscaling groups @@ -31,6 +33,7 @@ def lambda_handler(event, context): _strategy = {} _strategy[AutoscalingScheduler] = os.getenv("AUTOSCALING_SCHEDULE") _strategy[InstanceScheduler] = os.getenv("EC2_SCHEDULE") + _strategy[EcsScheduler] = os.getenv("ECS_SCHEDULE") _strategy[RdsScheduler] = os.getenv("RDS_SCHEDULE") _strategy[CloudWatchAlarmScheduler] = os.getenv("CLOUDWATCH_ALARM_SCHEDULE") diff --git a/tox.ini b/tox.ini index 048b4633..5667cf93 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] minversion=2.3.1 -envlist = py35,py36,py37,py38,pytest,flake8,black,bandit,pylint,mypy +envlist = py35,py36,py37,py38,py39,py310,pytest,flake8,black,bandit,pylint,mypy skipsdist = True # Unit tests @@ -38,6 +38,7 @@ deps = flake8-typing-imports==1.9.0 pep8-naming==0.11.1 pycodestyle==2.6.0 + importlib_metadata==4.2.0 commands = flake8 package/ diff --git a/variables.tf b/variables.tf index f3e23102..fab2c66d 100644 --- a/variables.tf +++ b/variables.tf @@ -67,6 +67,13 @@ variable "ec2_schedule" { default = false } +variable "ecs_schedule" { + description = "Enable scheduling on ecs services" + type = bool + default = false +} + + variable "rds_schedule" { description = "Enable scheduling on rds resources" type = any