From 8894477313afcb952d1de4a984a53322c544b03a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=A9rence=20Chateign=C3=A9?= Date: Fri, 1 Dec 2023 11:37:46 +0100 Subject: [PATCH] feat(ecs): add ecs scheduling --- CHANGELOG.md | 12 ++++++ README.md | 47 +++++++++++++++++++++--- function/scheduler/ecs.py | 75 ++++++++++++++++++++++++++++++++++++++ function/scheduler/main.py | 20 ++++++++-- main.tf | 23 +++++++++++- variables.tf | 6 +++ versions.tf | 2 +- 7 files changed, 175 insertions(+), 10 deletions(-) create mode 100644 function/scheduler/ecs.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 102b81e..e2cad63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.0](https://github.com/padok-team/terraform-aws-start-stop-scheduler/compare/v0.3.0...v0.4.0) (2023-12-01) + +### ⚠ BREAKING CHANGES + +* upgrade to latest aws and terraform version + +### Features + +* **ecs:** add python code to schedule ecs services +* **ecs:** add iam permissions to update ecs services + + ## [0.3.0](https://github.com/padok-team/terraform-aws-start-stop-scheduler/compare/v0.2.0...v0.3.0) (2023-03-21) diff --git a/README.md b/README.md index ab84e3a..a045a06 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ It supports : - **AutoscalingGroups**: it suspends the ASG and terminates its instances. At the start, it resumes the ASG, which launches new instances by itself. - RDS: support simple RDS DB instance. Run the function stop and start on them. +- ECS: scaling of ECS services to 0 - ~~EC2 instances~~: maybe The lambda function is _idempotent_, so you can launch it on an already stopped/started resource without any risks! It simplifies your job when planning with crons. @@ -102,27 +103,64 @@ aws lambda invoke --function-name --payload '{"actio ``` +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | ~> 1.0 | +| [archive](#requirement\_archive) | ~> 2.0 | +| [aws](#requirement\_aws) | ~> 5.0 | ## Providers | Name | Version | |------|---------| -| [archive](#provider\_archive) | 2.3.0 | -| [aws](#provider\_aws) | 4.59.0 | +| [archive](#provider\_archive) | ~> 2.0 | +| [aws](#provider\_aws) | ~> 5.0 | + +## Modules + +No modules. + +## Resources + +| Name | Type | +|------|------| +| [aws_cloudwatch_event_rule.start_stop](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | +| [aws_cloudwatch_event_target.start_stop](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_target) | resource | +| [aws_cloudwatch_log_group.start_stop_scheduler](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | +| [aws_iam_role.lambda](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role_policy.lambda_autoscalinggroup](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.lambda_ec2](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.lambda_ecs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.lambda_rds](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.lambda_tagging_api](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy_attachment.lambda_basic](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_lambda_function.start_stop_scheduler](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function) | resource | +| [aws_lambda_permission.allow_cloudwatch_start](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | +| [archive_file.lambda_zip](https://registry.terraform.io/providers/hashicorp/archive/latest/docs/data-sources/file) | data source | +| [aws_iam_policy_document.lambda_assume_role_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.lambda_autoscalinggroup](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.lambda_ec2](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.lambda_ecs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.lambda_rds](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.lambda_tagging_api](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_region.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/region) | data source | ## Inputs | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| -| [name](#input\_name) | A name used to create resources in module | `string` | n/a | yes | -| [schedules](#input\_schedules) | List of map containing, the following keys: name (for jobs name), start (cron for the start schedule), stop (cron for stop schedule), tag\_key and tag\_value (target recources) |
list(object({
name = string
start = string
stop = string
tag_key = string
tag_value = string
}))
| n/a | yes | | [asg\_schedule](#input\_asg\_schedule) | Run the scheduler on AutoScalingGroup. | `bool` | `true` | no | | [aws\_regions](#input\_aws\_regions) | List of AWS region where the scheduler will be applied. By default target the current region. | `list(string)` | `null` | no | | [custom\_iam\_lambda\_role](#input\_custom\_iam\_lambda\_role) | Use a custom role used for the lambda. Useful if you cannot create IAM ressource directly with your AWS profile, or to share a role between several resources. | `bool` | `false` | no | | [custom\_iam\_lambda\_role\_arn](#input\_custom\_iam\_lambda\_role\_arn) | Custom role arn used for the lambda. Used only if custom\_iam\_lambda\_role is set to true. | `string` | `null` | no | | [ec2\_schedule](#input\_ec2\_schedule) | Run the scheduler on EC2 instances. (only allows downscaling) | `bool` | `false` | no | +| [ecs\_schedule](#input\_ecs\_schedule) | Run the scheduler on ECS services. | `bool` | `false` | no | | [lambda\_timeout](#input\_lambda\_timeout) | Amount of time your Lambda Function has to run in seconds. | `number` | `10` | no | +| [name](#input\_name) | A name used to create resources in module | `string` | n/a | yes | | [rds\_schedule](#input\_rds\_schedule) | Run the scheduler on RDS. | `bool` | `true` | no | +| [schedules](#input\_schedules) | List of map containing, the following keys: name (for jobs name), start (cron for the start schedule), stop (cron for stop schedule), tag\_key and tag\_value (target recources) |
list(object({
name = string
start = string
stop = string
tag_key = string
tag_value = string
}))
| n/a | yes | | [tags](#input\_tags) | Custom Resource tags | `map(string)` | `{}` | no | ## Outputs @@ -139,7 +177,6 @@ aws lambda invoke --function-name --payload '{"actio | [lambda\_function\_version](#output\_lambda\_function\_version) | Latest published version of your Lambda function | | [lambda\_iam\_role\_arn](#output\_lambda\_iam\_role\_arn) | The ARN of the IAM role used by Lambda function | | [lambda\_iam\_role\_name](#output\_lambda\_iam\_role\_name) | The name of the IAM role used by Lambda function | - ## Advanced features diff --git a/function/scheduler/ecs.py b/function/scheduler/ecs.py new file mode 100644 index 0000000..e1fd064 --- /dev/null +++ b/function/scheduler/ecs.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- + +""" +ECS service scheduler. + +Source: https://github.com/diodonfrost/terraform-aws-lambda-scheduler-stop-start/blob/master/package/scheduler/ +""" + +import logging +from dataclasses import dataclass +from typing import Dict, Iterator, List, Any + +import boto3 +from botocore.exceptions import ClientError + + +logger = logging.getLogger() + + +@dataclass +class ECSService: + """ECS service""" + + service_name: str + cluster_name: str + ecs: Any + + def stop(self) -> None: + """ + Stop AWS ECS service + """ + try: + self.ecs.update_service( + cluster=self.cluster_name, service=self.service_name, desiredCount=0 + ) + except Exception as e: + logger.warn(e) + + def start(self, terminate: bool = True) -> None: + """ + Start AWS ECS service + """ + try: + self.ecs.update_service( + cluster=self.cluster_name, service=self.service_name, desiredCount=1 + ) + except Exception as e: + logger.warn(e) + + +def list_ecs_services_by_tags(tag_key: str, tag_value: str) -> List[ECSService]: + """ + Aws ECS service list function. + """ + + ecs = boto3.client("ecs") + rgta = boto3.client("resourcegroupstaggingapi") + + ecs_list = [] + paginator = rgta.get_paginator("get_resources") + page_iterator = paginator.paginate( + TagFilters=[{"Key": tag_key, "Values": [tag_value]}], + ResourceTypeFilters=["ecs:service"], + ) + for page in page_iterator: + for resource_tag_map in page["ResourceTagMappingList"]: + ecs_list.append( + ECSService( + cluster_name=resource_tag_map["ResourceARN"].split("/")[-2], + service_name=resource_tag_map["ResourceARN"].split("/")[-1], + ecs=ecs, + ) + ) + + return ecs_list diff --git a/function/scheduler/main.py b/function/scheduler/main.py index 45d6f2a..b85fab8 100644 --- a/function/scheduler/main.py +++ b/function/scheduler/main.py @@ -14,6 +14,7 @@ from scheduler.autoscaling import list_asg_by_tags from scheduler.rds import list_rds_by_tags from scheduler.ec2 import list_ec2_by_tags +from scheduler.ecs import list_ecs_services_by_tags logging.basicConfig() logger = logging.getLogger() @@ -22,6 +23,7 @@ ASG_SCHEDULE = os.getenv("ASG_SCHEDULE", "true") RDS_SCHEDULE = os.getenv("RDS_SCHEDULE", "true") EC2_SCHEDULE = os.getenv("EC2_SCHEDULE", "true") +ECS_SCHEDULE = os.getenv("ECS_SCHEDULE", "true") def lambda_handler(event, context): @@ -52,7 +54,6 @@ def lambda_handler(event, context): response = {"action": action, "tag": tag, "affected_resources": {}} if ASG_SCHEDULE == "true": - logger.info(f"Select autoscaling groups with tags {tag['key']}={tag['value']}") asgs = list_asg_by_tags(tag["key"], tag["value"]) @@ -69,7 +70,6 @@ def lambda_handler(event, context): response["affected_resources"]["asg"] = [a.name for a in asgs] if RDS_SCHEDULE == "true": - logger.info(f"Select RDS instances with tags {tag['key']}={tag['value']}") rds_list = list_rds_by_tags(tag["key"], tag["value"]) @@ -85,7 +85,6 @@ def lambda_handler(event, context): response["affected_resources"]["rds"] = [r.db_id for r in rds_list] if EC2_SCHEDULE == "true": - logger.info(f"Select EC2 instances with tags {tag['key']}={tag['value']}") ec2_list = list_ec2_by_tags(tag["key"], tag["value"]) @@ -97,6 +96,21 @@ def lambda_handler(event, context): response["affected_resources"]["ec2"] = [r.instance_id for r in ec2_list] + if ECS_SCHEDULE == "true": + logger.info(f"Select ECS services with tags {tag['key']}={tag['value']}") + ecs_list = list_ecs_services_by_tags(tag["key"], tag["value"]) + + logger.info(f"Run {action} function on {len(ecs_list)} ecs services") + + for ecs in ecs_list: + logger.info(f"Run {action} on {ecs.service_name}") + if action == "start": + ecs.start() + elif action == "stop": + ecs.stop() + + response["affected_resources"]["ecs"] = [r.service_name for r in ecs_list] + return response diff --git a/main.tf b/main.tf index 3ded36f..884e4d0 100644 --- a/main.tf +++ b/main.tf @@ -114,6 +114,26 @@ data "aws_iam_policy_document" "lambda_ec2" { } } +resource "aws_iam_role_policy" "lambda_ecs" { + count = var.custom_iam_lambda_role ? 0 : 1 + + name_prefix = "${local.name_prefix}_ecs" + role = aws_iam_role.lambda[0].id + policy = data.aws_iam_policy_document.lambda_ecs.json +} + +data "aws_iam_policy_document" "lambda_ecs" { + statement { + actions = [ + "ecs:UpdateService", + ] + + resources = [ + "*", + ] + } +} + resource "aws_iam_role_policy" "lambda_ec2" { count = var.custom_iam_lambda_role ? 0 : 1 @@ -144,7 +164,7 @@ resource "aws_lambda_function" "start_stop_scheduler" { source_code_hash = filebase64sha256(data.archive_file.lambda_zip.output_path) - runtime = "python3.8" + runtime = "python3.11" environment { variables = { @@ -152,6 +172,7 @@ resource "aws_lambda_function" "start_stop_scheduler" { RDS_SCHEDULE = tostring(var.rds_schedule) ASG_SCHEDULE = tostring(var.asg_schedule) EC2_SCHEDULE = tostring(var.ec2_schedule) + ECS_SCHEDULE = tostring(var.ecs_schedule) } } diff --git a/variables.tf b/variables.tf index 10382cb..cc3a3c0 100644 --- a/variables.tf +++ b/variables.tf @@ -53,6 +53,12 @@ variable "ec2_schedule" { type = bool } +variable "ecs_schedule" { + default = false + description = "Run the scheduler on ECS services." + type = bool +} + variable "aws_regions" { default = null description = "List of AWS region where the scheduler will be applied. By default target the current region." diff --git a/versions.tf b/versions.tf index a52d267..144043f 100644 --- a/versions.tf +++ b/versions.tf @@ -4,7 +4,7 @@ terraform { required_providers { aws = { source = "hashicorp/aws" - version = "~> 4.0" + version = "~> 5.0" } archive = {