diff --git a/aws-py-voting-app/Pulumi.yaml b/aws-py-voting-app/Pulumi.yaml new file mode 100644 index 0000000000..b29a095cc7 --- /dev/null +++ b/aws-py-voting-app/Pulumi.yaml @@ -0,0 +1,8 @@ +name: webserver-py +runtime: python +description: Basic example of an AWS web server accessible over HTTP (in Python!) +template: + config: + aws:region: + description: The AWS region to deploy into + default: us-west-2 diff --git a/aws-py-voting-app/README.md b/aws-py-voting-app/README.md new file mode 100644 index 0000000000..04998f19ff --- /dev/null +++ b/aws-py-voting-app/README.md @@ -0,0 +1,82 @@ +[](https://app.pulumi.com/new) + +# Web Server Using Amazon EC2 + +An example based on the Amazon sample at: +http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/deploying.applications.html. The example deploys an EC2 instance and opens port 80. + +## Prerequisites + +1. [Install Pulumi](https://www.pulumi.com/docs/get-started/install/) +1. [Configure Pulumi for AWS](https://www.pulumi.com/docs/intro/cloud-providers/aws/setup/) +1. [Configure Pulumi for Python](https://www.pulumi.com/docs/intro/languages/python/) + +## Deploying and running the program + +1. Install dependencies (a `virtualenv` is recommended - see [Pulumi Python docs](https://www.pulumi.com/docs/intro/languages/python/)): + + ```bash + $ pip install -r requirements.txt + ``` + +1. Create a new stack: + + ```bash + $ pulumi stack init python-webserver-testing + ``` + +1. Set the AWS region: + + ```bash + $ pulumi config set aws:region us-west-2 + ``` + +1. Create a Python virtualenv, activate it, and install dependencies: + + This installs the dependent packages [needed](https://www.pulumi.com/docs/intro/concepts/how-pulumi-works/) for our Pulumi program. + + ```bash + $ python3 -m venv venv + $ source venv/bin/activate + $ pip3 install -r requirements.txt + ``` + +1. Run `pulumi up` to preview and deploy changes: + + ```bash + $ pulumi up + Previewing stack 'python-webserver-testing' + Previewing changes: + ... + + Do you want to proceed? yes + Updating stack 'python-webserver-testing' + Performing changes: + + #: Resource Type Name Status Extra Info + 1: pulumi:pulumi:Stack webserver-py-python-webserver-testing + created + 2: aws:ec2:SecurityGroup web-secgrp + created + 3: aws:ec2:Instance web-server-www + created + + info: 3 changes performed: + + 3 resources created + Update duration: 26.470339302s + + Permalink: https://pulumi.com/lindydonna/examples/webserver-py/python-webserver-testing/updates/1 + ``` + +1. View the host name and IP address of the instance via `stack output`: + + ```bash + $ pulumi stack output + Current stack outputs (2): + OUTPUT VALUE + public_dns ec2-34-217-176-141.us-west-2.compute.amazonaws.com + public_ip 34.217.176.141 + ``` + +1. Verify that the EC2 instance exists, by either using the AWS Console or running `aws ec2 describe-instances`. + +## Clean up + +To clean up resources, run `pulumi destroy` and answer the confirmation question at the prompt. diff --git a/aws-py-voting-app/__main__.py b/aws-py-voting-app/__main__.py new file mode 100644 index 0000000000..ac141e9691 --- /dev/null +++ b/aws-py-voting-app/__main__.py @@ -0,0 +1,290 @@ +# Copyright 2016-2018, Pulumi Corporation. All rights reserved. + +import json +import pulumi +import pulumi_aws as aws +import pulumi_docker as docker + +# Get the password to use for Redis from the pulumi config +config = pulumi.Config() +redis_password = config.require("redis-password") +redis_port = 6379 + +# The ECS cluster in which our application and databse will run +app_cluster = aws.ecs.Cluster("app-cluster") + +# Creating a VPC and a public subnet +app_vpc = aws.ec2.Vpc("app-vpc", + cidr_block="172.31.0.0/16", + enable_dns_hostnames=True) + +app_vpc_subnet = aws.ec2.Subnet("app-vpc-subnet", + cidr_block="172.31.32.0/20", + vpc_id=app_vpc) + +# Creating a gateway to the web for the VPC +app_gateway = aws.ec2.InternetGateway("app-gateway", + vpc_id=app_vpc.id) + +app_routetable = aws.ec2.RouteTable("app-routetable", + routes=[ + { + "cidr_block": "0.0.0.0/0", + "gateway_id": app_gateway.id, + } + ], + vpc_id=app_vpc.id) + +# Associating our gateway with our VPC, to allow our app to communicate with the greater internet +app_routetable_association = aws.ec2.MainRouteTableAssociation("app_routetable_association", + route_table_id=app_routetable.id, + vpc_id=app_vpc) + +# Creating a Security Group that restricts incoming traffic to HTTP +app_security_group = aws.ec2.SecurityGroup("security-group", + vpc_id=app_vpc.id, + description="Enables HTTP access", + ingress=[{ + 'protocol': 'tcp', + 'from_port': 0, + 'to_port': 65535, + 'cidr_blocks': ['0.0.0.0/0'], + }], + egress=[{ + 'protocol': '-1', + 'from_port': 0, + 'to_port': 0, + 'cidr_blocks': ['0.0.0.0/0'], + }]) + +# Creating an IAM role used by Fargate to execute all our services +app_exec_role = aws.iam.Role("app-exec-role", + assume_role_policy="""{ + "Version": "2012-10-17", + "Statement": [ + { + "Action": "sts:AssumeRole", + "Principal": { + "Service": "ecs-tasks.amazonaws.com" + }, + "Effect": "Allow", + "Sid": "" + }] + }""") + +# Attaching execution permissions to the exec role +exec_policy_attachment = aws.iam.RolePolicyAttachment("app-exec-policy", role=app_exec_role.name, + policy_arn="arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy") + +# Creating an IAM role used by Fargate to manage tasks +app_task_role = aws.iam.Role("app-task-role", + assume_role_policy="""{ + "Version": "2012-10-17", + "Statement": [ + { + "Action": "sts:AssumeRole", + "Principal": { + "Service": "ecs-tasks.amazonaws.com" + }, + "Effect": "Allow", + "Sid": "" + }] + }""") + +# Attaching execution permissions to the task role +exec_policy_attachment = aws.iam.RolePolicyAttachment("app-access-policy", role=app_exec_role.name, + policy_arn="arn:aws:iam::aws:policy/AmazonEC2ContainerServiceFullAccess") + +exec_policy_attachment = aws.iam.RolePolicyAttachment("app-lambda-policy", role=app_exec_role.name, + policy_arn="arn:aws:iam::aws:policy/AWSLambdaFullAccess") + +# Creating storage space to upload a docker image of our app to +app_ecr_repo = aws.ecr.Repository("app-ecr-repo", + image_tag_mutability="MUTABLE") + +# Attaching an application life cycle policy to the storage +app_lifecycle_policy = aws.ecr.LifecyclePolicy("app-lifecycle-policy", + repository=app_ecr_repo.name, + policy="""{ + "rules": [ + { + "rulePriority": 10, + "description": "Remove untagged images", + "selection": { + "tagStatus": "untagged", + "countType": "imageCountMoreThan", + "countNumber": 1 + }, + "action": { + "type": "expire" + } + } + ] + }""") + +# The application's backend and data layer: Redis + +# Creating a target group through which the Redis backend receives requests +redis_targetgroup = aws.lb.TargetGroup("redis-targetgroup", + port=redis_port, + protocol="TCP", + target_type="ip", + stickiness= { + "enabled": False, + "type": "lb_cookie", + }, + vpc_id=app_vpc.id) + +# Creating a load balancer to spread out incoming requests +redis_balancer = aws.lb.LoadBalancer("redis-balancer", + load_balancer_type="network", + internal= False, + security_groups=[], + subnets=[app_vpc_subnet.id]) + +# Forwards internal traffic with the Redis port number to the Redis target group +redis_listener = aws.lb.Listener("redis-listener", + load_balancer_arn=redis_balancer.arn, + port=redis_port, + protocol="TCP", + default_actions=[{ + "type": "forward", + "target_group_arn": redis_targetgroup.arn + }]) + +# Creating a task definition for the Redis instance. +redis_task_definition = aws.ecs.TaskDefinition("redis-task-definition", + family="redis-task-definition-family", + cpu="256", + memory="512", + network_mode="awsvpc", + requires_compatibilities=["FARGATE"], + execution_role_arn=app_exec_role.arn, + task_role_arn=app_task_role.arn, + container_definitions=json.dumps([{ + "name": "redis-container", + "image": "redis:alpine", # A pre-built docker image with a functioning redis server + "memory": 512, + "essential": True, + "portMappings": [{ + "containerPort": redis_port, + "hostPort": redis_port, + "protocol": "tcp" + }], + "command": ["redis-server", "--requirepass", redis_password], + }])) + +# Launching our Redis service on Fargate, using our configurations and load balancers +redis_service = aws.ecs.Service("redis-service", + cluster=app_cluster.arn, + desired_count=1, + launch_type="FARGATE", + task_definition=redis_task_definition.arn, + wait_for_steady_state=False, + network_configuration={ + "assign_public_ip": "true", + "subnets": [app_vpc_subnet.id], + "security_groups": [app_security_group.id] + }, + load_balancers=[{ + "target_group_arn": redis_targetgroup.arn, + "container_name": "redis-container", + "container_port": redis_port, + }], + opts=pulumi.ResourceOptions(depends_on=[redis_listener]), +) + +# Creating a special endpoint for the Redis backend, which we will provide +# to the Flask frontend as an environment variable +redis_endpoint = {"host": str(redis_balancer.dns_name), "port": str(redis_port)} + +# The application's frontend: A Flask service + +# Creating a target group through which the Flask frontend receives requests +flask_targetgroup = aws.lb.TargetGroup("flask-targetgroup", + port=80, + protocol="TCP", + target_type="ip", + stickiness= { + "enabled": False, + "type": "lb_cookie", + }, + vpc_id=app_vpc.id) + +# Creating a load balancer to spread out incoming requests +flask_balancer = aws.lb.LoadBalancer("flask-balancer", + load_balancer_type="network", + internal=False, + security_groups=[], + subnets=[app_vpc_subnet.id]) + +# Forwards all public traffic using port 80 to the Flask target group +flask_listener = aws.lb.Listener("flask-listener", + load_balancer_arn=flask_balancer.arn, + port=80, + protocol="TCP", + default_actions=[{ + "type": "forward", + "target_group_arn": flask_targetgroup.arn + }]) + +# Creating a Docker image from "./frontend/Dockerfile", which we will use +# to upload our app +flask_image = docker.Image("flask-dockerimage", + image_name="flask-dockerimage", + build=docker.DockerBuild( + context="./frontend", + ), + skip_push=False, + registry=app_ecr_repo +) +# docker.build_and_push_image("pulumi-user/flask-dockerimage:v1.0.0", "./frontend", app_ecr_repo.repository_url, flask_listener, flask_listener) + +# Creating a task definition for the Flask instance. +flask_task_definition = aws.ecs.TaskDefinition("flask-task-definition", + family="frontend-task-definition-family", + cpu="256", + memory="512", + network_mode="awsvpc", + requires_compatibilities=["FARGATE"], + execution_role_arn=app_exec_role.arn, + task_role_arn=app_task_role.arn, + container_definitions=json.dumps([{ + "name": "flask-container", + "image": "flask-dockerimage", + "memory": 512, + "essential": True, + "portMappings": [{ + "containerPort": 80, + "hostPort": 80, + "protocol": "tcp" + }], + "environment": [ # The Redis endpoint we created is given to Flask, allowing it to communicate with the former + { "name": "REDIS", "value": redis_endpoint["host"] }, + { "name": "REDIS_PORT", "value": redis_endpoint["port"] }, + { "name": "REDIS_PWD", "value": redis_password }, + ], + }])) + +# Launching our Redis service on Fargate, using our configurations and load balancers +flask_service = aws.ecs.Service("flask-service", + cluster=app_cluster.arn, + desired_count=1, + launch_type="FARGATE", + task_definition=flask_task_definition.arn, + wait_for_steady_state=False, + network_configuration={ + "assign_public_ip": "true", + "subnets": [app_vpc_subnet.id], + "security_groups": [app_security_group.id] + }, + load_balancers=[{ + "target_group_arn": flask_targetgroup.arn, + "container_name": "flask-container", + "container_port": 80, + }], + opts=pulumi.ResourceOptions(depends_on=[flask_listener]), +) + +# Exporting the url of our Flask frontend. We can now connect to our app +pulumi.export("app-url", flask_balancer.dns_name) diff --git a/aws-py-voting-app/blogpost.md b/aws-py-voting-app/blogpost.md new file mode 100644 index 0000000000..fb8b71678d --- /dev/null +++ b/aws-py-voting-app/blogpost.md @@ -0,0 +1,109 @@ +# Creating a Python AWS Application Using Flask, Redis, and Pulumi + +I've recently begun developing with Pulumi to learn as much about its inner workings and processes as I could. I decided to construct a production-level application and to document each step that I take and my progress as I go along. + +This blog post features recreating the existing [Typescript voting app example](https://www.pulumi.com/docs/tutorials/aws/aws-ts-voting-app/) step by step in Python with Flask as the frontend and Redis as the backend. In future blog posts, we will explore how to change the front and backends, how to upgrade the app with additional AWS services, and migrating from one cloud provider to another. + +--- + +The first few lines of the `__main\__.py` file indicate which libraries need to be imported, and describe a pair of configuration options that will be used by the application. + +```python +import json +import pulumi +import pulumi_aws as aws +import pulumi_docker as docker + +config = pulumi.Config() +redis_password = config.require("redis-password") +redis_port = 6379 +``` + +After setting the imports and configuration, we create an Elastic Container Service Cluster. +A Cluster represents a group of tasks and services that work together for a certain purpose. In +this instance, the purpose is to provide users with a voting application. + +```python +app_cluster = aws.ecs.Cluster("app-cluster") +``` + +In order to allow different tasks within our cluster to communicate, we create a Virtual Private +Cloud and an associated subnet. + +```python +app_vpc = aws.ec2.Vpc("app-vpc", + cidr_block="172.31.0.0/16", + enable_dns_hostnames=True) + +app_vpc_subnet = aws.ec2.Subnet("app-vpc-subnet", + cidr_block="172.31.32.0/20", + vpc_id=app_vpc) +``` + +A gateway and routing table are needed to allow the VPC to communicate with the greater internet. +Once created, we declare that the routing table is associated with our VPC. + +```python +app_gateway = aws.ec2.InternetGateway("app-gateway", + vpc_id=app_vpc.id) + +app_routetable = aws.ec2.RouteTable("app-routetable", + routes=[ + { + "cidr_block": "0.0.0.0/0", + "gateway_id": app_gateway.id, + } + ], + vpc_id=app_vpc.id) + +app_routetable_association = aws.ec2.MainRouteTableAssociation("app_routetable_association", + route_table_id=app_routetable.id, + vpc_id=app_vpc) +``` + +To control internet traffic that are and aren't allowed to connect with the application, +we create a firewall in the form of a security group. + +```python +app_security_group = aws.ec2.SecurityGroup("security-group", + vpc_id=app_vpc.id, + description="Enables HTTP access", + ingress=[{ + 'protocol': 'tcp', + 'from_port': 0, + 'to_port': 65535, + 'cidr_blocks': ['0.0.0.0/0'], + }], + egress=[{ + 'protocol': '-1', + 'from_port': 0, + 'to_port': 0, + 'cidr_blocks': ['0.0.0.0/0'], + }]) +``` + +In order to allow our services to start, we create an Identity and Access Management role, and +attach execution permissions to it. + +```python +app_exec_role = aws.iam.Role("app-exec-role", + assume_role_policy="""{ + "Version": "2012-10-17", + "Statement": [ + { + "Action": "sts:AssumeRole", + "Principal": { + "Service": "ecs-tasks.amazonaws.com" + }, + "Effect": "Allow", + "Sid": "" + }] + }""") + +exec_policy_attachment = aws.iam.RolePolicyAttachment("app-exec-policy", role=app_exec_role.name, + policy_arn="arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy") +``` + +--- + +The full code for the blog post can be [found on Github.](https://github.com/jetvova/examples/tree/vova/aws-py-flask-redis-voting-app/aws-py-voting-app) diff --git a/aws-py-voting-app/frontend/Dockerfile b/aws-py-voting-app/frontend/Dockerfile new file mode 100755 index 0000000000..690a4775b3 --- /dev/null +++ b/aws-py-voting-app/frontend/Dockerfile @@ -0,0 +1,3 @@ +FROM tiangolo/uwsgi-nginx-flask:python3.6 +RUN pip install redis +COPY /app /app diff --git a/aws-py-voting-app/frontend/LICENSE b/aws-py-voting-app/frontend/LICENSE new file mode 100644 index 0000000000..d1ca00f20a --- /dev/null +++ b/aws-py-voting-app/frontend/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. All rights reserved. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE \ No newline at end of file diff --git a/aws-py-voting-app/frontend/app/config_file.cfg b/aws-py-voting-app/frontend/app/config_file.cfg new file mode 100755 index 0000000000..23c142d8f9 --- /dev/null +++ b/aws-py-voting-app/frontend/app/config_file.cfg @@ -0,0 +1,5 @@ +# UI Configurations +TITLE = 'Pulumi Voting App' +VOTE1VALUE = 'Tabs' +VOTE2VALUE = 'Spaces' +SHOWHOST = 'false' \ No newline at end of file diff --git a/aws-py-voting-app/frontend/app/main.py b/aws-py-voting-app/frontend/app/main.py new file mode 100755 index 0000000000..d1fe50f426 --- /dev/null +++ b/aws-py-voting-app/frontend/app/main.py @@ -0,0 +1,71 @@ +# Copied from https://github.com/Azure-Samples/azure-voting-app-redis + +from flask import Flask, request, render_template +import os +import random +import redis +import socket +import sys + +app = Flask(__name__) + +# Load configurations +app.config.from_pyfile('config_file.cfg') +button1 = app.config['VOTE1VALUE'] +button2 = app.config['VOTE2VALUE'] +title = app.config['TITLE'] + +# Redis configurations +redis_server = os.environ['REDIS'] +redis_port = os.environ['REDIS_PORT'] +redis_password = os.environ['REDIS_PWD'] + +# Redis Connection +try: + r = redis.StrictRedis(host=redis_server, port=redis_port, password=redis_password) + r.ping() +except redis.ConnectionError: + exit('Failed to connect to Redis, terminating.') + +# Init Redis +if not r.get(button1): r.set(button1,0) +if not r.get(button2): r.set(button2,0) + +@app.route('/', methods=['GET', 'POST']) +def index(): + + if request.method == 'GET': + + # Get current values + vote1 = r.get(button1).decode('utf-8') + vote2 = r.get(button2).decode('utf-8') + + # Return index with values + return render_template("index.html", value1=int(vote1), value2=int(vote2), button1=button1, button2=button2, title=title) + + elif request.method == 'POST': + + if request.form['vote'] == 'reset': + + # Empty table and return results + r.set(button1,0) + r.set(button2,0) + vote1 = r.get(button1).decode('utf-8') + vote2 = r.get(button2).decode('utf-8') + return render_template("index.html", value1=int(vote1), value2=int(vote2), button1=button1, button2=button2, title=title) + + else: + + # Insert vote result into DB + vote = request.form['vote'] + r.incr(vote,1) + + # Get current values + vote1 = r.get(button1).decode('utf-8') + vote2 = r.get(button2).decode('utf-8') + + # Return results + return render_template("index.html", value1=int(vote1), value2=int(vote2), button1=button1, button2=button2, title=title) + +if __name__ == "__main__": + app.run() diff --git a/aws-py-voting-app/frontend/app/static/default.css b/aws-py-voting-app/frontend/app/static/default.css new file mode 100755 index 0000000000..cb2cb0ada0 --- /dev/null +++ b/aws-py-voting-app/frontend/app/static/default.css @@ -0,0 +1,96 @@ +body { + background-color:#F8F8F8; +} + +div#container { + margin-top:5%; +} + +div#space { + display:block; + margin: 0 auto; + width: 500px; + height: 10px; + +} + +div#logo { + display:block; + margin: 0 auto; + width: 500px; + text-align: center; + font-size:30px; + font-family: 'PT Sans', sans-serif; + /*border-bottom: 1px solid black;*/ +} + +div#form { + padding: 20px; + padding-right: 20px; + padding-top: 20px; + display:block; + margin: 0 auto; + width: 500px; + text-align: center; + font-size:30px; + font-family: 'PT Sans', sans-serif; + border-bottom: 1px solid black; + border-top: 1px solid black; +} + +div#results { + display:block; + margin: 0 auto; + width: 500px; + text-align: center; + font-size:30px; + font-family: 'PT Sans', sans-serif; +} + +.button { + background-color: #4CAF50; /* Green */ + border: none; + color: white; + padding: 16px 32px; + text-align: center; + text-decoration: none; + display: inline-block; + font-size: 16px; + margin: 4px 2px; + -webkit-transition-duration: 0.4s; /* Safari */ + transition-duration: 0.4s; + cursor: pointer; + width: 250px; +} + +.button1 { + background-color: white; + color: black; + border: 2px solid #008CBA; +} + +.button1:hover { + background-color: #008CBA; + color: white; +} +.button2 { + background-color: white; + color: black; + border: 2px solid #555555; +} + +.button2:hover { + background-color: #555555; + color: white; +} + +.button3 { + background-color: white; + color: black; + border: 2px solid #f44336; +} + +.button3:hover { + background-color: #f44336; + color: white; +} \ No newline at end of file diff --git a/aws-py-voting-app/frontend/app/templates/index.html b/aws-py-voting-app/frontend/app/templates/index.html new file mode 100755 index 0000000000..c92e65976f --- /dev/null +++ b/aws-py-voting-app/frontend/app/templates/index.html @@ -0,0 +1,31 @@ + + +
+ + + +