diff --git a/stacker_blueprints/ecs.py b/stacker_blueprints/ecs.py new file mode 100644 index 00000000..1ce496c3 --- /dev/null +++ b/stacker_blueprints/ecs.py @@ -0,0 +1,283 @@ +from awacs.helpers.trust import get_ecs_assumerole_policy + +from troposphere import ( + ecs, + iam, +) + +from troposphere import ( + NoValue, + Output, + Region, + Sub, +) + +from stacker.blueprints.base import Blueprint + +from .policies import ecs_task_execution_policy + + +class Cluster(Blueprint): + def create_template(self): + t = self.template + + cluster = t.add_resource(ecs.Cluster("Cluster")) + + t.add_output(Output("ClusterId", Value=cluster.Ref())) + t.add_output(Output("ClusterArn", Value=cluster.GetAtt("Arn"))) + + +class SimpleFargateService(Blueprint): + VARIABLES = { + "ServiceName": { + "type": str, + "description": "A simple name for the service.", + }, + "Image": { + "type": str, + "description": "The docker image to use for the task.", + }, + "Command": { + "type": str, + "description": "The command to run inside the container. If left " + "blank, will use the default command for the " + "image.", + "default": "", + }, + "Cluster": { + "type": str, + "description": "The name or Amazon Resource Name (ARN) of the " + "ECS cluster that you want to run your tasks on.", + }, + "CPU": { + "type": int, + "description": "The relative CPU shares used by each instance of " + "the task.", + }, + "Memory": { + "type": int, + "description": "The amount of memory (in megabytes) to reserve " + "for each instance of the task.", + }, + "Count": { + "type": int, + "description": "The number of instances of the task to create.", + "default": 1, + }, + "TaskRoleArn": { + "type": str, + "description": "An optional role to run the task as.", + "default": "", + }, + "TaskExecutionRoleArn": { + "type": str, + "description": "An optional task execution role arn. If not " + "provided, one will be attempted to be created.", + "default": "", + }, + "Subnets": { + "type": list, + "description": "The list of VPC subnets to deploy the task in.", + }, + "SecurityGroup": { + "type": str, + "description": "The SecurityGroup to attach to the task.", + }, + "Environment": { + "type": dict, + "description": "A dictionary representing the environment of the " + "task.", + "default": {}, + }, + "LogGroup": { + "type": str, + "description": "An optional CloudWatch LogGroup name to send logs " + "to.", + "default": "", + }, + } + + @property + def service_name(self): + return self.get_variables()["ServiceName"] + + @property + def image(self): + return self.get_variables()["Image"] + + @property + def command(self): + return self.get_variables()["Command"] or NoValue + + @property + def cluster(self): + return self.get_variables()["Cluster"] + + @property + def cpu(self): + return self.get_variables()["CPU"] + + @property + def memory(self): + return self.get_variables()["Memory"] + + @property + def count(self): + return self.get_variables()["Count"] + + @property + def task_role_arn(self): + return self.get_variables()["TaskRoleArn"] or NoValue + + @property + def subnets(self): + return self.get_variables()["Subnets"] + + @property + def security_group(self): + return self.get_variables()["SecurityGroup"] + + @property + def log_group(self): + return self.get_variables()["LogGroup"] + + @property + def log_configuration(self): + if not self.log_group: + return NoValue + + return ecs.LogConfiguration( + LogDriver="awslogs", + Options={ + "awslogs-group": self.log_group, + "awslogs-region": Region, + "awslogs-stream-prefix": self.service_name, + } + ) + + @property + def environment(self): + env_dict = self.get_variables()["Environment"] + if not env_dict: + return NoValue + + env_list = [] + for k, v in env_dict.items(): + env_list.append(ecs.Environment(Name=k, Value=v)) + + return env_list + + def generate_container_definition(self): + return ecs.ContainerDefinition( + Command=[self.command], + Cpu=self.cpu, + Environment=self.environment, + Essential=True, + Image=self.image, + LogConfiguration=self.log_configuration, + Memory=self.memory, + Name=self.service_name, + ) + + def create_task_execution_role(self): + t = self.template + + self.task_execution_role = t.add_resource( + iam.Role( + "TaskExecutionRole", + AssumeRolePolicyDocument=get_ecs_assumerole_policy(), + ) + ) + + t.add_output( + Output( + "TaskExecutionRoleName", + Value=self.task_execution_role.Ref() + ) + ) + + t.add_output( + Output( + "TaskExecutionRoleArn", + Value=self.task_execution_role.GetAtt("Arn") + ) + ) + + def create_task_execution_role_policy(self): + t = self.template + + policy_name = Sub("${AWS::StackName}-task-exeuction-role-policy") + + self.task_execution_role_policy = t.add_resource( + iam.PolicyType( + "TaskExecutionRolePolicy", + PolicyName=policy_name, + PolicyDocument=ecs_task_execution_policy( + log_group=self.log_group, + log_stream_prefix=self.service_name + "/*" + ), + Roles=[self.task_execution_role.Ref()], + ) + ) + + def create_task_definition(self): + t = self.template + + self.task_definition = t.add_resource( + ecs.TaskDefinition( + "TaskDefinition", + Cpu=str(self.cpu), + ExecutionRoleArn=self.task_execution_role.GetAtt("Arn"), + Family=self.service_name, + Memory=str(self.memory), + NetworkMode="awsvpc", + TaskRoleArn=self.task_role_arn, + ContainerDefinitions=[self.generate_container_definition()] + ) + ) + + t.add_output( + Output( + "TaskDefinitionArn", + Value=self.task_definition.Ref() + ) + ) + + def create_service(self): + t = self.template + self.service = t.add_resource( + ecs.Service( + "Service", + Cluster=self.cluster, + DesiredCount=self.count, + LaunchType="FARGATE", + NetworkConfiguration=ecs.NetworkConfiguration( + AwsvpcConfiguration=ecs.AwsvpcConfiguration( + SecurityGroups=[self.security_group], + Subnets=self.subnets, + ) + ), + ServiceName=self.service_name, + TaskDefinition=self.task_definition.Ref(), + ) + ) + + t.add_output( + Output( + "ServiceArn", + Value=self.task_execution_role.Ref() + ) + ) + + t.add_output( + Output( + "ServiceName", + Value=self.task_execution_role.GetAtt("Name") + ) + ) + + def create_template(self): + self.create_task_execution_role() + self.create_task_execution_role_policy() + self.create_task_definition() + self.create_service() diff --git a/tests/fixtures/blueprints/ecs__cluster.json b/tests/fixtures/blueprints/ecs__cluster.json new file mode 100644 index 00000000..9f751026 --- /dev/null +++ b/tests/fixtures/blueprints/ecs__cluster.json @@ -0,0 +1,22 @@ +{ + "Outputs": { + "ClusterArn": { + "Value": { + "Fn::GetAtt": [ + "Cluster", + "Arn" + ] + } + }, + "ClusterId": { + "Value": { + "Ref": "Cluster" + } + } + }, + "Resources": { + "Cluster": { + "Type": "AWS::ECS::Cluster" + } + } +} \ No newline at end of file diff --git a/tests/fixtures/blueprints/ecs__simple_fargate_service.json b/tests/fixtures/blueprints/ecs__simple_fargate_service.json new file mode 100644 index 00000000..2773054f --- /dev/null +++ b/tests/fixtures/blueprints/ecs__simple_fargate_service.json @@ -0,0 +1,160 @@ +{ + "Outputs": { + "ServiceArn": { + "Value": { + "Ref": "TaskExecutionRole" + } + }, + "ServiceName": { + "Value": { + "Fn::GetAtt": [ + "TaskExecutionRole", + "Name" + ] + } + }, + "TaskDefinitionArn": { + "Value": { + "Ref": "TaskDefinition" + } + }, + "TaskExecutionRoleArn": { + "Value": { + "Fn::GetAtt": [ + "TaskExecutionRole", + "Arn" + ] + } + }, + "TaskExecutionRoleName": { + "Value": { + "Ref": "TaskExecutionRole" + } + } + }, + "Resources": { + "Service": { + "Properties": { + "Cluster": "fake-fargate-cluster", + "DesiredCount": 3, + "LaunchType": "FARGATE", + "NetworkConfiguration": { + "AwsvpcConfiguration": { + "SecurityGroups": [ + "sg-abc1234" + ], + "Subnets": [ + "net-123456", + "net-5678910" + ] + } + }, + "ServiceName": "WorkerService", + "TaskDefinition": { + "Ref": "TaskDefinition" + } + }, + "Type": "AWS::ECS::Service" + }, + "TaskDefinition": { + "Properties": { + "ContainerDefinitions": [ + { + "Command": [ + "/bin/run" + ], + "Cpu": 1024, + "Environment": [ + { + "Name": "DEBUG", + "Value": "false" + }, + { + "Name": "DATABASE_URL", + "Value": "sql://fake_db/fake_db" + } + ], + "Essential": "true", + "Image": "fake_repo/image:12345", + "LogConfiguration": { + "Ref": "AWS::NoValue" + }, + "Memory": 2048, + "Name": "WorkerService" + } + ], + "Cpu": "1024", + "ExecutionRoleArn": { + "Fn::GetAtt": [ + "TaskExecutionRole", + "Arn" + ] + }, + "Family": "WorkerService", + "Memory": "2048", + "NetworkMode": "awsvpc", + "TaskRoleArn": { + "Ref": "AWS::NoValue" + } + }, + "Type": "AWS::ECS::TaskDefinition" + }, + "TaskExecutionRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "ecs.amazonaws.com" + ] + } + } + ] + } + }, + "Type": "AWS::IAM::Role" + }, + "TaskExecutionRolePolicy": { + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "ecr:GetAuthorizationToken" + ], + "Effect": "Allow", + "Resource": [ + "*" + ] + }, + { + "Action": [ + "ecr:BatchCheckLayerAvailability", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage" + ], + "Effect": "Allow", + "Resource": [ + "*" + ] + } + ] + }, + "PolicyName": { + "Fn::Sub": "${AWS::StackName}-task-exeuction-role-policy" + }, + "Roles": [ + { + "Ref": "TaskExecutionRole" + } + ] + }, + "Type": "AWS::IAM::Policy" + } + } +} \ No newline at end of file diff --git a/tests/test_ecs.py b/tests/test_ecs.py new file mode 100644 index 00000000..5470c6e3 --- /dev/null +++ b/tests/test_ecs.py @@ -0,0 +1,51 @@ +from stacker.context import Context +from stacker.variables import Variable +from stacker_blueprints.ecs import Cluster, SimpleFargateService +from stacker.blueprints.testutil import BlueprintTestCase + + +class TestCluster(BlueprintTestCase): + def setUp(self): + self.ctx = Context({'namespace': 'test', 'environment': 'test'}) + + def test_ecs_cluster(self): + bp = Cluster("ecs__cluster", self.ctx) + bp.resolve_variables([]) + bp.create_template() + self.assertRenderedBlueprint(bp) + + +class TestSimpleFargateService(BlueprintTestCase): + def setUp(self): + self.common_variables = { + "ServiceName": "WorkerService", + "Image": "fake_repo/image:12345", + "Command": "/bin/run", + "Cluster": "fake-fargate-cluster", + "CPU": 1024, + "Memory": 2048, + "Count": 3, + "Subnets": ["net-123456", "net-5678910"], + "SecurityGroup": "sg-abc1234", + "Environment": { + "DATABASE_URL": "sql://fake_db/fake_db", + "DEBUG": "false", + }, + } + + self.ctx = Context({'namespace': 'test', 'environment': 'test'}) + + def create_blueprint(self, name): + return SimpleFargateService(name, self.ctx) + + def generate_variables(self, variable_dict=None): + variable_dict = variable_dict or {} + self.common_variables.update(variable_dict) + + return [Variable(k, v) for k, v in self.common_variables.items()] + + def test_ecs_simple_fargate_service(self): + bp = self.create_blueprint("ecs__simple_fargate_service") + bp.resolve_variables(self.generate_variables()) + bp.create_template() + self.assertRenderedBlueprint(bp)