├── .editorconfig ├── .gitignore ├── LICENSE ├── README.md ├── app ├── hello │ ├── __init__.py │ ├── hello_app.py │ └── hello_test.py └── requirements.txt ├── circle.yml ├── config ├── dev_site.yml ├── group_vars │ └── all ├── kill_prod_site.yml ├── prod_site.yml └── roles │ ├── common │ └── tasks │ │ └── main.yml │ ├── hello │ ├── files │ │ ├── hello.conf │ │ └── hello.ini │ ├── meta │ │ └── main.yml │ └── tasks │ │ └── main.yml │ ├── nginx │ ├── meta │ │ └── main.yml │ └── tasks │ │ └── main.yml │ └── uwsgi │ ├── files │ └── uwsgi.conf │ ├── meta │ └── main.yml │ └── tasks │ └── main.yml └── deploy.sh /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.py] 2 | indent_style = space 3 | indent_size = 4 4 | 5 | [*.yml] 6 | indent_style = space 7 | indent_size = 2 8 | 9 | [*.sh] 10 | indent_style = space 11 | indent_size = 4 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.swp 3 | .DS_STORE 4 | 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2014 CircleCI 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ansible-aws 2 | =========== 3 | 4 | The project demonstrates continuous integration and delivery of a simple python application using CircleCI, Ansible and AWS. Ansible is used to define everything about the deployment environment, from AWS resources to config files and application code, and CircleCI handles continuous integration and continuous deployment to AWS through Ansible Tower. 5 | 6 | ##Project overview 7 | The project consists of two major sections: the app, which is a simple Flask-based web application, and the Ansible playbooks (the "config" directory), which follow [Ansible's recommended directory layout](http://docs.ansible.com/playbooks_best_practices.html#directory-layout). Because the application is deployed to the servers with a "push" model from Ansible Tower, the entire source tree is first pulled down to Ansible Tower, and then the python application code is pushed to the app servers using [synchronize module](http://docs.ansible.com/synchronize_module.html), which is just a wrapper around rsync (the are only needed on the Tower server, where the instruct Ansible what commands to run against the remote hosts). 8 | 9 | ##Running locally 10 | To run the (very simple) Flask application locally, simply clone the repository, run `pip install -r requirements.txt` (optionally inside of a virtualenv), and then run `python app/hello/hello_app.py` to start the app on port 5000 (the `app.run` method takes an optional “port” keyword argument, which is not currently passed in from the command line args). 11 | 12 | ##Getting Ansible 13 | Refer to [the Ansible docs](http://docs.ansible.com/) for detailed instructions on installing and running ansible. 14 | 15 | Running from a git clone may truly be the easiest way to get started with Ansible: 16 | 17 | ``` 18 | $ git clone git://github.com/ansible/ansible.git 19 | $ source ansible/hacking/env-setup 20 | ``` 21 | 22 | You will also need to install the following python dependencies for Ansible to work: 23 | 24 | ``` 25 | $ sudo pip install paramiko PyYAML jinja2 httplib2 26 | ``` 27 | 28 | See [the Ansible docs](http://docs.ansible.com/) for instructions on installing Ansible from various package managers. 29 | 30 | ##Configuring Ansible hosts 31 | Ansible works by SSHing into your servers and running commands on them (or by simply using a “local” connection in the case of commands run on your local machine). There are a number of ways to configure which hosts Ansible tries to use, but one place Ansible looks by default is /etc/ansible/hosts, which can be either a file or a directory. If you are primarily using Ansible with AWS, it can be convenient to configure it to be a directory with the following structure: 32 | 33 | ``` 34 | └── hosts 35 | ├── ec2 36 | ├── ec2.ini 37 | └── local 38 | ``` 39 | 40 | Where ec2 is [this script](https://raw.github.com/ansible/ansible/devel/plugins/inventory/ec2.py), ec2.ini is [this file](https://raw.github.com/ansible/ansible/devel/plugins/inventory/ec2.ini), and the contents of “local” are as follows: 41 | 42 | ``` 43 | [local] 44 | localhost ansible_connection=local 45 | ``` 46 | 47 | The ec2 script must be executable, so run `chmod +x /etc/ansible/ec2` once you download it. 48 | 49 | The ec2 script is what Ansible calls a “dynamic inventory”, which means that its contents can fluctuate over time as VMs are provisioned and killed, which makes sense in the context of AWS. 50 | 51 | There’s one more thing that needs to be done before the inventories are usable, which is to make your AWS credentials available to the boto library, which Ansible uses to interact with AWS. There are [a number of ways to do this](http://boto.readthedocs.org/en/latest/boto_config_tut.html), but one way is to create a file at ~/.boto with the following contents: 52 | 53 | ``` 54 | [Credentials] 55 | aws_access_key_id = 56 | aws_secret_access_key = 57 | ``` 58 | 59 | With all of this done, you should be able to run `ansible all -m ping -u ubuntu` and see a response from both your local machine and the remote hosts (you will need to have SSH access to the remote machines, and replace “ubuntu” with the appropriate remote username). 60 | 61 | ##Deploying to a single EC2 server 62 | You can now run `ansible-playbook config/dev_site.yml` to launch an ec2 server running the “Hello World” application. Note that the dev_site playbook assumes that you have an AWS security group called “ssh-http”, uses the us-west-2 AWS region, and uses the “ubuntu” remote user. You may need to change this to suit your needs. 63 | 64 | ##The production environment 65 | The Ansible playbook included in the project creates an AWS Elastic Load Balancer (ELB) and an Auto Scaling Group (ASG). The ELB and ASG together ensure that traffic is always spread between a certain number of instances, and will take care of killing and replacing “unhealthy” instances. When an instance is replaced by auto scaling, it will execute a user data script that performs a callback to Ansible and triggers a re-run of the playbook. Since playbooks should be idempotent, this has no impact on existing servers, but fully installs the app on the newly provisioned server. 66 | 67 | ##Ansible Tower 68 | The “production-style” Ansible playbook in this project assumes the use of an Ansible Tower server. Ansible Tower is free for managing up to 10 hosts. You can find out more about how to get it up and running [here](http://www.ansible.com/tower). 69 | 70 | Note that these instructions assume the use of Ansible 1.6.5 and and Tower 1.4.11. 71 | 72 | Once you have an Ansible Tower instance up and running, you will need to follow the following steps: 73 | 74 | 1. Setup at least one organization 75 | 2. Setup credentials for accessing AWS, Git, and EC2 instances via SSH. 76 | 3. Create a project that pulls from the Git repository and is set to “Update on Launch” 77 | 4. Create an inventory called, for example, “AWS” and in that inventory create a group called, for example, “ec2”, and configure the group to pull from EC2 using the AWS credentials setup in (2). Also, setup the group to “Update on Launch” 78 | 5. Create a job template that uses all of the information configured above (use the EC2 SSH key for the “Machine Credential”) and uses the playbook “config/prod_site.yml”. 79 | 6. Check the “Allow Callbacks” box, and add the full callback URL as “callback_url” and the “Host Config Key” as the “callback_key” in the “Extra Variables” section. 80 | 7. Add a “stack_name” extra variable calling the stack whatever you like, save the job template, and you should be able to launch the job. 81 | 82 | ##Deployment from CircleCI 83 | The circle.yml file included in the project already specifies all of the test and deployment instructions necessary. However, there are several environment variables that must be specified in Circle for it to work correctly. These are: 84 | 85 | * ANSIBLE_TOWER_JOB_TEMPLATE_ID 86 | 87 | * ANSIBLE_TOWER_USER (must have permission to launch the job) 88 | 89 | * ANSIBLE_TOWER_PASS 90 | 91 | * ANSIBLE_TOWER_SERVER (full URL to the server) 92 | 93 | When those environment variables are set, any CircleCI build on master will trigger a job launch and deployment to AWS. 94 | 95 | ##See Also 96 | * The AWS docs on [Auto Scaling Groups](http://docs.aws.amazon.com/AutoScaling/latest/DeveloperGuide/WhatIsAutoScaling.html) and [Elastic Load Balancing](http://docs.aws.amazon.com/ElasticLoadBalancing/latest/DeveloperGuide/SvcIntro.html) 97 | * [The Ansible docs](http://docs.ansible.com/) 98 | * [Ansible Tower](http://www.ansible.com/tower) 99 | * [The Flask Quickstart](http://flask.pocoo.org/docs/quickstart/#quickstart) 100 | * [Testing Flask Applications](http://flask.pocoo.org/docs/testing/) from the Flask docs 101 | * [The nose documentation](https://nose.readthedocs.org/en/latest/) for more information about the nose test runner 102 | -------------------------------------------------------------------------------- /app/hello/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CircleCI-Archived/ansible-aws/173549fea4e1e0e8468cd8164dd338da0097dea2/app/hello/__init__.py -------------------------------------------------------------------------------- /app/hello/hello_app.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | app = Flask(__name__) 3 | 4 | @app.route("/") 5 | def hello(): 6 | return "Hello World, I love continuous delivery!" 7 | 8 | if __name__ == "__main__": 9 | app.run() 10 | -------------------------------------------------------------------------------- /app/hello/hello_test.py: -------------------------------------------------------------------------------- 1 | from .hello_app import app 2 | 3 | 4 | def test_app(): 5 | tc = app.test_client() 6 | response = tc.get('/') 7 | assert 'Hello' in response.data 8 | -------------------------------------------------------------------------------- /app/requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==0.10.1 2 | Jinja2==2.7.3 3 | MarkupSafe==0.23 4 | Werkzeug==0.9.6 5 | argparse==1.2.1 6 | itsdangerous==0.24 7 | nose==1.3.3 8 | wsgiref==0.1.2 9 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | python: 3 | version: 2.7.5 4 | dependencies: 5 | override: 6 | - git clone https://github.com/ansible/tower-cli.git ../tower-cli 7 | - make install: 8 | pwd: ../tower-cli 9 | - pip install -r app/requirements.txt 10 | test: 11 | override: 12 | - (cd app && nosetests) 13 | deployment: 14 | prod: 15 | branch: master 16 | commands: 17 | - bash -x deploy.sh 18 | -------------------------------------------------------------------------------- /config/dev_site.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: ensure that a single instance is launched 3 | hosts: localhost 4 | # Specify this in the inventory file relevant to localhost 5 | # instead of here, as it is required for other things like 6 | # the 'synchronize' module 7 | # connection: local 8 | gather_facts: no 9 | vars: 10 | instance_name: dev 11 | group_name: ec2_hosts 12 | count_tags: { "Name": "{{ instance_name }}" } 13 | tasks: 14 | - name: lookup ami id 15 | ec2_ami_search: distro=ubuntu region={{ region }} release=trusty 16 | register: ubuntu_image 17 | - name: ensure one instance exists 18 | ec2: 19 | image: "{{ ubuntu_image.ami }}" 20 | instance_type: "{{ instance_type }}" 21 | instance_tags: "{{ count_tags }}" 22 | region: "{{ region }}" 23 | group: "{{ security_group }}" 24 | wait: yes 25 | exact_count: 1 26 | count_tag: "{{ count_tags }}" 27 | key_name: "{{ keypair }}" 28 | register: ec2_info 29 | - name: add instance to in-memory hosts 30 | add_host: hostname={{ ec2_info.tagged_instances[0].public_dns_name }} groupname=ec2_hosts 31 | - name: wait for instances to listen on port 22 32 | wait_for: state=started host={{ ec2_info.tagged_instances[0].public_dns_name }} port=22 33 | 34 | - name: ensure that the complete app is installed on the instance 35 | hosts: ec2_hosts 36 | remote_user: ubuntu 37 | gather_facts: yes 38 | sudo: yes 39 | roles: 40 | - hello 41 | 42 | -------------------------------------------------------------------------------- /config/group_vars/all: -------------------------------------------------------------------------------- 1 | --- 2 | azs: "us-west-2a,us-west-2b,us-west-2c" 3 | instance_type: t1.micro 4 | keypair: deploy-key 5 | region: us-west-2 6 | security_group: ssh-http 7 | -------------------------------------------------------------------------------- /config/kill_prod_site.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - hosts: localhost 3 | connection: local 4 | gather_facts: False 5 | tasks: 6 | - name: validate input 7 | fail: msg="The variables 'stack_name' and 'lc_version' must be specified as extra vars" 8 | when: stack_name is not defined or lc_version is not defined 9 | - name: kill asg 10 | ec2_asg: 11 | name: "{{ stack_name }}-asg" 12 | region: "{{ region }}" 13 | state: absent 14 | - name: kill launch config 15 | ec2_lc: 16 | name: "{{ stack_name }}-lc-{{ lc_version }}" 17 | region: "{{ region }}" 18 | state: absent 19 | - name: kill elb 20 | ec2_elb_lb: 21 | name: "{{ stack_name }}-elb" 22 | region: "{{ region }}" 23 | state: absent 24 | -------------------------------------------------------------------------------- /config/prod_site.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: create an autoscaling group based on a launch configuration 3 | hosts: localhost 4 | connection: local 5 | gather_facts: no 6 | tasks: 7 | - name: validate input 8 | fail: msg="The variables 'stack_name', 'lc_version', 'callback_url', and 'callback_key' must be specified as extra vars" 9 | when: stack_name is not defined or lc_version is not defined or callback_url is not defined or callback_key is not defined 10 | - name: ensure elb exists 11 | ec2_elb_lb: 12 | name: "{{ stack_name }}-elb" 13 | region: "{{ region }}" 14 | state: present 15 | zones: "{{ azs }}" 16 | listeners: 17 | - protocol: http 18 | load_balancer_port: 80 19 | instance_port: 8000 20 | health_check: 21 | ping_protocol: http 22 | ping_port: 8000 23 | ping_path: "/" 24 | response_timeout: 5 # seconds 25 | interval: 30 # seconds 26 | unhealthy_threshold: 2 27 | healthy_threshold: 5 28 | register: elb_data 29 | - name: lookup ami id 30 | ec2_ami_search: distro=ubuntu region={{ region }} release=trusty 31 | register: ubuntu_image 32 | # Note that launch configs cannot be updated -- only created or deleted 33 | - name: ensure launch config exists 34 | ec2_lc: 35 | name: "{{ stack_name }}-lc-{{ lc_version }}" 36 | region: "{{ region }}" 37 | image_id: "{{ ubuntu_image.ami }}" 38 | key_name: "{{ keypair }}" 39 | security_groups: "{{ security_group }}" 40 | instance_type: "{{ instance_type }}" 41 | user_data: | 42 | #!/bin/bash 43 | retry_attempts=10 44 | attempt=0 45 | while [[ $attempt -lt $retry_attempts ]] 46 | do 47 | status_code=`curl -k -s -i --data "host_config_key={{ callback_key }}" \ 48 | {{ callback_url }} | head -n 1 | awk '{print $2}'` 49 | if [[ $status_code == 202 ]] 50 | then 51 | exit 0 52 | fi 53 | attempt=$(( attempt + 1 )) 54 | echo "${status_code} received... retrying in 1 minute. (Attempt ${attempt})" 55 | sleep 60 56 | done 57 | exit 1 58 | - name: ensure asg exists 59 | ec2_asg: 60 | name: "{{ stack_name }}-asg" 61 | launch_config_name: "{{ stack_name }}-lc-{{ lc_version }}" 62 | load_balancers: "{{ stack_name}}-elb" 63 | min_size: 1 64 | max_size: 1 65 | region: "{{ region }}" 66 | availability_zones: "{{ azs }}" 67 | state: present 68 | 69 | - name: deploy the app to instances 70 | hosts: tag_aws_autoscaling_groupName_{{ stack_name }}-asg 71 | remote_user: ubuntu 72 | gather_facts: yes 73 | sudo: yes 74 | roles: 75 | - hello 76 | -------------------------------------------------------------------------------- /config/roles/common/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # It speeds things up a bit to do this just once 3 | - name: apt update 4 | apt: update_cache=yes 5 | -------------------------------------------------------------------------------- /config/roles/hello/files/hello.conf: -------------------------------------------------------------------------------- 1 | # the upstream component nginx needs to connect to 2 | upstream flask { 3 | server unix:///tmp/uwsgi.sock; 4 | } 5 | 6 | # configuration of the server 7 | server { 8 | # the port your site will be served on 9 | listen 8000; 10 | charset utf-8; 11 | 12 | # max upload size 13 | client_max_body_size 75M; # adjust to taste 14 | 15 | # Finally, send all non-media requests to the Django server. 16 | location / { 17 | uwsgi_pass flask; 18 | include /etc/nginx/uwsgi_params; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /config/roles/hello/files/hello.ini: -------------------------------------------------------------------------------- 1 | [uwsgi] 2 | socket = /tmp/uwsgi.sock 3 | chmod-socket = 664 4 | chown-socket = www-data:www-data 5 | module = hello.hello_app 6 | callable = app 7 | pythonpath = /opt/app 8 | 9 | -------------------------------------------------------------------------------- /config/roles/hello/meta/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | dependencies: 3 | - { role: common, tags: dep } 4 | - { role: uwsgi, tags: dep } 5 | - { role: nginx, tags: dep } 6 | -------------------------------------------------------------------------------- /config/roles/hello/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: gather facts based on ec2 metadata 3 | ec2_facts: 4 | - name: ensure required packages installed 5 | apt: name={{ item }} state=present 6 | with_items: 7 | - git 8 | - python-pip 9 | - name: synchronize source tree 10 | synchronize: src=../app dest=/opt recursive=yes delete=yes archive=no 11 | - name: ensure pip packages installed 12 | pip: 13 | requirements: /opt/app/requirements.txt 14 | - name: ensure uwsgi config file in place 15 | copy: src=hello.ini dest=/etc/uwsgi/ 16 | - name: ensure nginx config file in place 17 | copy: src=hello.conf dest=/etc/nginx/sites-available/ 18 | - name: ensure nginx site enabled 19 | file: src=/etc/nginx/sites-available/hello.conf dest=/etc/nginx/sites-enabled/hello.conf state=link 20 | - name: ensure services reloaded 21 | service: name={{ item }} state=reloaded 22 | with_items: 23 | - nginx 24 | - uwsgi 25 | -------------------------------------------------------------------------------- /config/roles/nginx/meta/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | dependencies: 3 | - { role: common } 4 | -------------------------------------------------------------------------------- /config/roles/nginx/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: ensure nginx installed 3 | apt: name=nginx state=present 4 | - name: ensure nginx service started 5 | service: name=nginx state=started 6 | -------------------------------------------------------------------------------- /config/roles/uwsgi/files/uwsgi.conf: -------------------------------------------------------------------------------- 1 | # Emperor uWSGI script 2 | 3 | description "uWSGI Emperor" 4 | start on runlevel [2345] 5 | stop on runlevel [06] 6 | 7 | exec uwsgi --emperor /etc/uwsgi --uid www-data --gid www-data 8 | -------------------------------------------------------------------------------- /config/roles/uwsgi/meta/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | dependencies: 3 | - { role: common } 4 | -------------------------------------------------------------------------------- /config/roles/uwsgi/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: ensure required packages installed 3 | apt: name={{ item }} state=present 4 | with_items: 5 | - python-pip 6 | - python-dev 7 | - name: install from pip 8 | pip: name=uwsgi 9 | - name: ensure vassal directory in place 10 | file: path=/etc/uwsgi/ state=directory 11 | - name: setup upstart 12 | copy: src=uwsgi.conf dest=/etc/init/ 13 | - name: ensure uwsgi service started 14 | service: name=uwsgi state=started 15 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | tower-cli joblaunch \ 4 | --template $ANSIBLE_TOWER_JOB_TEMPLATE_ID \ 5 | --username $ANSIBLE_TOWER_USER \ 6 | --password $ANSIBLE_TOWER_PASS \ 7 | --server $ANSIBLE_TOWER_SERVER 8 | --------------------------------------------------------------------------------