AWS ECS with Container Instances and Automation API
ADD
aws-py-ecs-instances-autoapi/.gitignore
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+
+# Diagnostic reports (https://nodejs.org/api/report.html)
+report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+*.lcov
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directories
+node_modules/
+jspm_packages/
+
+# TypeScript v1 declaration files
+typings/
+
+# TypeScript cache
+*.tsbuildinfo
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Microbundle cache
+.rpt2_cache/
+.rts2_cache_cjs/
+.rts2_cache_es/
+.rts2_cache_umd/
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# Yarn Integrity file
+.yarn-integrity
+
+# dotenv environment variables file
+.env
+.env.test
+
+# parcel-bundler cache (https://parceljs.org/)
+.cache
+
+# Next.js build output
+.next
+
+# Nuxt.js build / generate output
+.nuxt
+dist
+
+# Gatsby files
+.cache/
+# Comment in the public line in if your project uses Gatsby and *not* Next.js
+# https://nextjs.org/blog/next-9-1#public-directory-support
+# public
+
+# vuepress build output
+.vuepress/dist
+
+# Serverless directories
+.serverless/
+
+# FuseBox cache
+.fusebox/
+
+# DynamoDB Local files
+.dynamodb/
+
+# TernJS port file
+.tern-port

ADD
aws-py-ecs-instances-autoapi/README.md
+# AWS ECS with Container Instances and Delete Orchestration
+
+This example demonstrates three use-cases:
+
+- AWS ECS using Container Instances (Python): A Python Pulumi program that stands up a custom AWS ECS cluster that uses instances instead of fargate for the infrastructure.
+- Automation API Orchestration: Destroying this stack without any sort of orchestration will fail due to this issue in the underlying provider: https://github.com/hashicorp/terraform-provider-aws/issues/4852. So, Automation API to the rescue. By orchestrating sizing of the autoscaling group to 0 before the destroy, the destroy is able to complete as expected.
+- Automation API cross-language support: Although the automation logic is written in TypeScript, the ECS cluster stack is written in Python.
+
+## Project Structure
+
+### `/py-ecs-instance`:
+
+This is a Pulumi project/stack python program that deploys the following:
+
+- ECS Cluster using "container instances" instead of Fargate.
+- An nginx "hello world" test container and related load balancer and networking.
+ One can change to this directory and run `pulumi up` and deploy the stack just as would be done with any Pulumi project ...
+
+But wait, there's more ...
+
+### `/automation`
+
+This directory contains the automation api code (`index.ts`) that handles deploying and, more importantly, orchestrating the deletion of the stack to avoid a dependency constraint.
+
+## How to Use
+
+To run this example you'll need a few pre-reqs:
+
+1. A Pulumi CLI installation ([v2.15.6](https://www.pulumi.com/docs/get-started/install/versions/) or later)
+2. Python 3.6+
+3. The AWS CLI, with appropriate credentials.
+
+To run our automation program we just `cd` to the `automation` directory and use `yarn` to run the automation api code.
+
+```shell
+$ yarn install
+$ yarn start
+yarn run v1.19.1
+$ ./node_modules/ts-node/dist/bin.js index.ts
+successfully initialized stack
+setting up config
+config set
+refreshing stack...
+Refreshing (dev)
+...
+refresh complete
+updating stack...
+Updating (dev)
+...
+
+update summary:
+{
+ "same": 0,
+ "update": 16
+}
+website url: http://load-balancer-xxxxxxxxx.us-east-1.elb.amazonaws.com
+```
+
+To destroy the stack, we run the automation program with an additional `destroy` argument:
+
+```shell
+$ yarn start destroy
+yarn run v1.19.1
+$ ./node_modules/ts-node/dist/bin.js index.ts destroy
+successfully initialized stack
+setting up config
+config set
+refreshing stack...
+Refreshing (dev)
+destroying stack ...
+Destroying (dev)
+...
+@ Destroying ...
+...
+Resources:
+ - 16 deleted
+
+The resources in the stack have been deleted, but the history and configuration associated with the stack are still maintained.
+If you want to remove the stack completely, run 'pulumi stack rm dev'.
+
+stack destroy complete
+```

ADD
aws-py-ecs-instances-autoapi/automation/index.ts
+import { LocalProgramArgs, LocalWorkspace } from "@pulumi/pulumi/x/automation";
+import * as upath from "upath";
+
+const process = require("process");
+
+const args = process.argv.slice(2);
+let destroy = false;
+if (args.length > 0 && args[0]) {
+ destroy = args[0] === "destroy";
+}
+
+const run = async () => {
+
+
+ // Create our stack using a local program
+ // in the ../fargate directory
+ const args: LocalProgramArgs = {
+ stackName: "dev",
+ workDir: upath.joinSafe(__dirname, "..", "py-ecs-instance"),
+ };
+ const asgSize = "1"
+
+ // create (or select if one already exists) a stack that uses our local program
+ const stack = await LocalWorkspace.createOrSelectStack(args);
+
+ console.info("successfully initialized stack");
+ console.info("setting up config");
+ await stack.setConfig("aws:region", { value: "us-east-1" });
+ await stack.setConfig("cfg:autoscalingGroupSize", { value: asgSize });
+ console.info("config set");
+ console.info("refreshing stack...");
+ await stack.refresh({ onOutput: console.info });
+ console.info("refresh complete");
+
+ if (destroy) {
+ // The autoscaling group is sized down to 0 so that there are no instances running.
+ // This allows the cluster to be deleted. Otherwise, the cluster delete will fail due to the existence of instances.
+ console.info("resizing autoscaling group size to 0 before destroying the stack ...")
+ await stack.setConfig("cfg:autoscalingGroupSize", { value: "0" });
+ await stack.up({ onOutput: console.info });
+ await stack.setConfig("cfg:autoscalingGroupSize", { value: asgSize });
+
+ console.info("destroying stack...");
+ await stack.destroy({onOutput: console.info});
+
+ console.info("stack destroy complete");
+ process.exit(0);
+ }
+
+ console.info("updating stack...");
+ const upRes = await stack.up({ onOutput: console.info });
+ console.log(`update summary: \n${JSON.stringify(upRes.summary.resourceChanges, null, 4)}`);
+ console.log(`website url: ${upRes.outputs.app_url.value}`);
+};
+
+run().catch(err => console.log(err));

ADD
aws-py-ecs-instances-autoapi/automation/package.json
+{
+ "name": "automation",
+ "version": "1.0.0",
+ "main": "index.ts",
+ "repository": "github.com/pulumi/automation-api-examples",
+ "author": "EvanBoyle",
+ "license": "MIT",
+ "dependencies": {
+ "@pulumi/aws": "^3.6.1",
+ "@pulumi/pulumi": "^2.12.0",
+ "@types/node": "^14.14.20",
+ "ts-node": "^9.0.0",
+ "upath": "^2.0.0"
+ },
+ "scripts": {
+ "start": "./node_modules/ts-node/dist/bin.js index.ts"
+ }
+}

ADD
aws-py-ecs-instances-autoapi/py-ecs-instance/Pulumi.yaml
+name: aws-py-ecs-instances-autoapi
+description: A container running in AWS ECS Container Instance, using Python infrastructure as code
+runtime:
+ name: python
+ options:
+ virtualenv: venv

ADD
aws-py-ecs-instances-autoapi/py-ecs-instance/__main__.py
+"""Deploys ECS with EC2 container instances (as opposed to using Fargate)."""
+import json
+import pulumi
+import pulumi_aws as aws
+
+region = aws.config.region or "us-east-1"
+avail_zone = region+"a" # e.g. us-east-1a
+
+stack_config = pulumi.Config("cfg")
+asg_size = int(stack_config.require("autoscalingGroupSize"))
+
+# Get the default VPC.
+default_vpc = aws.ec2.get_vpc(default=True)
+default_vpc_subnets = aws.ec2.get_subnet_ids(vpc_id=default_vpc.id)
+
+# Security group to access the nginx container.
+sg = aws.ec2.SecurityGroup(
+ "nginx-sg",
+ description="Allow HTTP",
+ vpc_id=default_vpc.id,
+ ingress=[
+ aws.ec2.SecurityGroupIngressArgs(protocol="tcp", from_port=80, to_port=80, cidr_blocks=["0.0.0.0/0"])
+ ],
+ egress=[
+ aws.ec2.SecurityGroupEgressArgs(protocol=-1, from_port=0, to_port=0, cidr_blocks=["0.0.0.0/0"])
+ ]
+)
+
+# IAM role for:
+# - Task Role: to allow the TaskDefinition to launch tasks on the cluster.
+# Used in the TaskDefinition declaration below.
+task_execution_role = aws.iam.Role(
+ "task-execution-role",
+ assume_role_policy=json.dumps(
+ {
+ "Version": "2012-10-17",
+ "Statement":
+ [
+ {
+ "Sid": "",
+ "Effect": "Allow",
+ "Principal": {"Service": "ecs-tasks.amazonaws.com"},
+ "Action": "sts:AssumeRole"
+ }
+ ],
+ }
+ ),
+)
+task_execution_role_policy_attach = aws.iam.RolePolicyAttachment(
+ "task-excution-policy-attach",
+ role=task_execution_role.name,
+ policy_arn="arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
+)
+
+# IAM role and profile for:
+# - Instance IAM profile: to allow the EC2 instances permission to join the ECS cluster.
+# Used in the Launch Configuration declaration below.
+ecs_instance_role = aws.iam.Role(
+ "ecs-instance-role",
+ assume_role_policy=json.dumps(
+ {
+ "Version": "2012-10-17",
+ "Statement":
+ [
+ {
+ "Sid": "",
+ "Effect": "Allow",
+ "Principal": {"Service": "ec2.amazonaws.com"},
+ "Action": "sts:AssumeRole"
+ }
+ ],
+ }
+ ),
+)
+ecs_instance_role_policy_attach = aws.iam.RolePolicyAttachment(
+ "ecs-instance-policy-attach",
+ role=ecs_instance_role.name,
+ policy_arn="arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceforEC2Role"
+)
+ecs_instance_profile = aws.iam.InstanceProfile("ecs-iam-instance-profile", role=ecs_instance_role.name)
+
+# Find an "ECS optimized" AMI to use for the EC2 container instances.
+ecs_instance_ami = aws.get_ami(
+ most_recent="true",
+ owners=["amazon"],
+ filters=[
+ {
+ "name": "name",
+ "values": ["amzn2-ami-ecs-hvm-*-x86_64-*"]
+ }
+ ]
+)
+
+# User-data so the EC2 container instance will connect to the created cluster.
+cluster_name = "my-fancy-new-ecs-cluster"
+user_data='''#!/bin/bash
+echo ECS_CLUSTER={cluster_nm} >> /etc/ecs/ecs.config'''.format(cluster_nm=cluster_name)
+
+# create launch configuration
+launch_config = aws.ec2.LaunchConfiguration(
+ "launch-config",
+ image_id=ecs_instance_ami.id,
+ instance_type="t2.micro",
+ iam_instance_profile=ecs_instance_profile.name, # needed to give instance authority to join the ECS cluster.
+ user_data=user_data, # Required so instance knows to connect to the cluster created below.
+)
+
+# Create cluster and related bits (i.e. autoscaling group and CapacityProvider)
+auto_scaling = aws.autoscaling.Group(
+ "auto-scaling",
+ availability_zones=[avail_zone],
+ launch_configuration=launch_config.name,
+ min_size=asg_size,
+ max_size=asg_size,
+ protect_from_scale_in=False
+)
+cp = aws.ecs.CapacityProvider(
+ "capacity-provider",
+ auto_scaling_group_provider=aws.ecs.CapacityProviderAutoScalingGroupProviderArgs(
+ auto_scaling_group_arn=auto_scaling.arn,
+ managed_termination_protection="DISABLED",
+ managed_scaling=aws.ecs.CapacityProviderAutoScalingGroupProviderManagedScalingArgs(
+ status="DISABLED"
+ )
+ )
+)
+cluster = aws.ecs.Cluster(
+ "cluster",
+ name=cluster_name, # Use explicit name property so that we know the cluster name - this is required for the user data above.
+ capacity_providers=[cp.name],
+)
+
+# Application load balancer and related bits
+load_balancer = aws.lb.LoadBalancer(
+ "load-balancer",
+ load_balancer_type="application",
+ security_groups=[sg.id],
+ subnets=default_vpc_subnets.ids,
+ internal=False,
+)
+atg = aws.lb.TargetGroup(
+ "app-tg",
+ port=80,
+ protocol="HTTP",
+ target_type="ip",
+ vpc_id=default_vpc.id,
+)
+wl = aws.lb.Listener(
+ "web",
+ load_balancer_arn=load_balancer.arn,
+ port=80,
+ default_actions=[aws.lb.ListenerDefaultActionArgs(type="forward", target_group_arn=atg.arn)]
+)
+
+# Task definition for creating our containers.
+task_def = aws.ecs.TaskDefinition(
+ "my-app",
+ family="ec2-task-definition",
+ cpu="256",
+ memory="512",
+ network_mode="awsvpc",
+ requires_compatibilities=["EC2"],
+ execution_role_arn=task_execution_role.arn, # Needed so it has permission to launch tasks on the cluster.
+ container_definitions=json.dumps([{
+ "name": "my-app",
+ "image": "nginx", # a simple nginx example
+ "portMappings": [{
+ "containerPort": 80,
+ "hostPort": 80,
+ "protocol": "tcp"
+ }]
+ }]),
+ opts=pulumi.ResourceOptions(depends_on=[cluster])
+)
+
+# Service declaration to build the service on the ECS cluster.
+service = aws.ecs.Service(
+ "my-task-runner",
+ cluster=cluster.arn,
+ launch_type="EC2",
+ desired_count=1,
+ task_definition=task_def.arn,
+ network_configuration=aws.ecs.ServiceNetworkConfigurationArgs(
+ assign_public_ip=False,
+ subnets=default_vpc_subnets.ids,
+ security_groups=[sg.id],
+ ),
+ load_balancers=[aws.ecs.ServiceLoadBalancerArgs(
+ target_group_arn=atg.arn,
+ container_name="my-app",
+ container_port=80,
+ )],
+ opts=pulumi.ResourceOptions(depends_on=[wl])
+)
+
+# Provide a clickable link to the nginx service via the load balancer.
+pulumi.export("app_url", pulumi.Output.concat("http://",load_balancer.dns_name))
+pulumi.export("NOTE", "You may have to wait a minute for AWS to spin up the service. So if the URL throws a 503 error, try again after a bit.")

ADD
aws-py-ecs-instances-autoapi/py-ecs-instance/requirements.txt
+pulumi>=2.0.0,<3.0.0
+pulumi-aws>=3.0.0,<4.0.0

AWS ECS with Container Instances and Automation API (#845)

Co-authored-by: Justin Van Patten <jvp@justinvp.com>