From fa63a9b5f46d277d9dfff1b5a591adf460e58115 Mon Sep 17 00:00:00 2001 From: James Martin Date: Fri, 22 Feb 2013 15:52:23 -0500 Subject: [PATCH] CloudFormation support. --- examples/playbooks/cloudformation.yaml | 45 ++ .../files/cloudformation-example.json | 399 ++++++++++++++++++ library/cloudformation | 243 +++++++++++ 3 files changed, 687 insertions(+) create mode 100644 examples/playbooks/cloudformation.yaml create mode 100644 examples/playbooks/files/cloudformation-example.json create mode 100644 library/cloudformation diff --git a/examples/playbooks/cloudformation.yaml b/examples/playbooks/cloudformation.yaml new file mode 100644 index 0000000000..242def3a44 --- /dev/null +++ b/examples/playbooks/cloudformation.yaml @@ -0,0 +1,45 @@ +--- +# This playbook demonstrates how to use the ansible cloudformation module to launch an AWS CloudFormation stack. +# +# This module requires that the boto python library is installed, and that you have your AWS credentials +# in $HOME/.boto + +#The thought here is to bring up a bare infrastructure with CloudFormation, but use ansible to configure it. +#I generally do this in 2 different playbook runs as to allow the ec2.py inventory to be updated. + +#This module also uses "complex arguments" which were introduced in ansible 1.1 allowing you to specify the +#Cloudformation template parameters + +#This example launches a 3 node AutoScale group, with a security group, and an InstanceProfile with root permissions. + +#If a stack does not exist, it will be created. If it does exist and the template file has changed, the stack will be updated. +#If the parameters are different, the stack will also be updated. + +#CloudFormation stacks can take awhile to provision, if you are curious about its status, use the AWS +#web console or one of the CloudFormation CLI's. + +#Example update -- try first launching the stack with 3 as the ClusterSize. After it is launched, change it to 4 +#and run the playbook again. + +- name: provision stack + hosts: localhost + connection: local + gather_facts: false + + # Launch the cloudformation-example.json template. Register the output. + + tasks: + - name: launch ansible cloudformation example + cloudformation: > + stack_name="ansible-cloudformation" state=present + region=us-east-1 disable_rollback=true + template=files/cloudformation-example.json + args: + template_parameters: + KeyName: jmartin + DiskType: ephemeral + InstanceType: m1.small + ClusterSize: 3 + register: stack + - name: show stack outputs + debug: msg="My stack outputs are ${stack.stack_outputs}" \ No newline at end of file diff --git a/examples/playbooks/files/cloudformation-example.json b/examples/playbooks/files/cloudformation-example.json new file mode 100644 index 0000000000..945e4e4d7c --- /dev/null +++ b/examples/playbooks/files/cloudformation-example.json @@ -0,0 +1,399 @@ +{ + "Outputs" : { + "ClusterSecGroup" : { + "Description" : "Name of RegionalManagerSecGroup", + "Value" : { + "Ref" : "InstanceSecurityGroup" + } + } + }, + "AWSTemplateFormatVersion" : "2010-09-09", + "Description" : "Launches an example cluster", + "Mappings" : { + "ebs" : { + "ap-northeast-1" : { + "AMI" : "ami-4e6cd34f" + }, + "ap-southeast-1" : { + "AMI" : "ami-a6a7e7f4" + }, + "eu-west-1" : { + "AMI" : "ami-c37474b7" + }, + "sa-east-1" : { + "AMI" : "ami-1e08d103" + }, + "us-east-1" : { + "AMI" : "ami-1624987f" + }, + "us-west-1" : { + "AMI" : "ami-1bf9de5e" + }, + "us-west-2" : { + "AMI" : "ami-2a31bf1a" + } + }, + "ephemeral" : { + "ap-northeast-1" : { + "AMI" : "ami-5a6cd35b" + }, + "ap-southeast-1" : { + "AMI" : "ami-a8a7e7fa" + }, + "eu-west-1" : { + "AMI" : "ami-b57474c1" + }, + "sa-east-1" : { + "AMI" : "ami-1608d10b" + }, + "us-east-1" : { + "AMI" : "ami-e8249881" + }, + "us-west-1" : { + "AMI" : "ami-21f9de64" + }, + "us-west-2" : { + "AMI" : "ami-2e31bf1e" + } + } + }, + "Parameters" : { + "ClusterSize" : { + "Description" : "Number of nodes in the cluster", + "Type" : "String" + }, + "DiskType" : { + "AllowedValues" : [ + "ephemeral", + "ebs" + ], + "Default" : "ephemeral", + "Description" : "Type of Disk to use ( ephemeral/ebs )", + "Type" : "String" + }, + "InstanceType" : { + "AllowedValues" : [ + "t1.micro", + "m1.small", + "m1.medium", + "m1.large", + "m1.xlarge", + "m2.xlarge", + "m2.2xlarge", + "m2.4xlarge", + "c1.medium", + "c1.xlarge", + "cc1.4xlarge" + ], + "ConstraintDescription" : "must be valid instance type. ", + "Default" : "m1.large", + "Description" : "Type of EC2 instance for cluster", + "Type" : "String" + }, + "KeyName" : { + "Description" : "Name of an existing EC2 KeyPair to enable SSH access to the cluster", + "Type" : "String" + } + }, + "Resources" : { + "ApplicationWaitCondition" : { + "DependsOn" : "ClusterServerGroup", + "Properties" : { + "Handle" : { + "Ref" : "ApplicationWaitHandle" + }, + "Timeout" : "4500" + }, + "Type" : "AWS::CloudFormation::WaitCondition" + }, + "ApplicationWaitHandle" : { + "Type" : "AWS::CloudFormation::WaitConditionHandle" + }, + "CFNInitUser" : { + "Properties" : { + "Path" : "/", + "Policies" : [ + { + "PolicyDocument" : { + "Statement" : [ + { + "Action" : [ + "cloudformation:DescribeStackResource", + "s3:GetObject" + ], + "Effect" : "Allow", + "Resource" : "*" + } + ] + }, + "PolicyName" : "AccessForCFNInit" + } + ] + }, + "Type" : "AWS::IAM::User" + }, + "CFNKeys" : { + "Properties" : { + "UserName" : { + "Ref" : "CFNInitUser" + } + }, + "Type" : "AWS::IAM::AccessKey" + }, + "ClusterCommunication1" : { + "Properties" : { + "FromPort" : "-1", + "GroupName" : { + "Ref" : "InstanceSecurityGroup" + }, + "IpProtocol" : "icmp", + "SourceSecurityGroupName" : { + "Ref" : "InstanceSecurityGroup" + }, + "ToPort" : "-1" + }, + "Type" : "AWS::EC2::SecurityGroupIngress" + }, + "ClusterCommunication2" : { + "Properties" : { + "FromPort" : "1", + "GroupName" : { + "Ref" : "InstanceSecurityGroup" + }, + "IpProtocol" : "tcp", + "SourceSecurityGroupName" : { + "Ref" : "InstanceSecurityGroup" + }, + "ToPort" : "65356" + }, + "Type" : "AWS::EC2::SecurityGroupIngress" + }, + "ClusterCommunication3" : { + "Properties" : { + "FromPort" : "1", + "GroupName" : { + "Ref" : "InstanceSecurityGroup" + }, + "IpProtocol" : "udp", + "SourceSecurityGroupName" : { + "Ref" : "InstanceSecurityGroup" + }, + "ToPort" : "65356" + }, + "Type" : "AWS::EC2::SecurityGroupIngress" + }, + "InstanceSecurityGroup" : { + "Properties" : { + "GroupDescription" : "Enable SSH access via port 22", + "SecurityGroupIngress" : [ + { + "CidrIp" : "0.0.0.0/0", + "FromPort" : "22", + "IpProtocol" : "tcp", + "ToPort" : "22" + } + ] + }, + "Type" : "AWS::EC2::SecurityGroup" + }, + "LaunchConfig" : { + "Properties" : { + "IamInstanceProfile" : { + "Ref" : "RootInstanceProfile" + }, + "ImageId" : { + "Fn::FindInMap" : [ + { + "Ref" : "DiskType" + }, + { + "Ref" : "AWS::Region" + }, + "AMI" + ] + }, + "InstanceType" : { + "Ref" : "InstanceType" + }, + "KeyName" : { + "Ref" : "KeyName" + }, + "SecurityGroups" : [ + { + "Ref" : "InstanceSecurityGroup" + } + ], + "UserData" : { + "Fn::Base64" : { + "Fn::Join" : [ + "\n", + [ + "#!/bin/bash -v", + "exec > >(tee /var/log/cfn-data.log|logger -t user-data -s 2>/dev/console) 2>&1", + "", + "sleep 10", + "", + "function retry {", + " nTrys=0", + " maxTrys=5", + " status=256", + " until [ $status == 0 ] ; do", + " $1", + " status=$?", + " nTrys=$(($nTrys + 1))", + " if [ $nTrys -gt $maxTrys ] ; then", + " echo \"Number of re-trys exceeded. Exit code: $status\"", + " exit $status", + " fi", + " if [ $status != 0 ] ; then", + " echo \"Failed (exit code $status)... retry $nTrys\"", + " sleep 10", + " fi", + " done", + "}", + "", + "yum update -y aws-cfn-bootstrap", + "", + "#for all the stuff that complains about sudo and tty", + "sed -i 's,Defaults requiretty,#Defaults requiretty,g' /etc/sudoers", + "", + "function error_exit", + "{", + { + "Fn::Join" : [ + "", + [ + " /opt/aws/bin/cfn-signal -e 1 -r \"$1\" '", + { + "Ref" : "ApplicationWaitHandle" + }, + "'" + ] + ] + }, + "}", + "yum update -y aws-cfn-bootstrap", + "#this runs the first stage of cfinit", + { + "Fn::Join" : [ + "", + [ + "#/opt/aws/bin/cfn-init -c ascending -v --region ", + { + "Ref" : "AWS::Region" + }, + " -s ", + { + "Ref" : "AWS::StackName" + }, + " -r ", + "LaunchConfig", + " --access-key ", + { + "Ref" : "CFNKeys" + }, + " --secret-key ", + { + "Fn::GetAtt" : [ + "CFNKeys", + "SecretAccessKey" + ] + }, + " || error_exit 'Failed to initialize client using cfn-init'" + ] + ] + }, + "", + "", + "", + "result_code=$?", + { + "Fn::Join" : [ + "", + [ + "/opt/aws/bin/cfn-signal -e $result_code '", + { + "Ref" : "ApplicationWaitHandle" + }, + "'" + ] + ] + } + ] + ] + } + } + }, + "Type" : "AWS::AutoScaling::LaunchConfiguration" + }, + "ClusterServerGroup" : { + "Properties" : { + "AvailabilityZones" : { + "Fn::GetAZs" : "" + }, + "LaunchConfigurationName" : { + "Ref" : "LaunchConfig" + }, + "MaxSize" : { + "Ref" : "ClusterSize" + }, + "MinSize" : { + "Ref" : "ClusterSize" + } + }, + "Type" : "AWS::AutoScaling::AutoScalingGroup" + }, + "RolePolicies" : { + "Properties" : { + "PolicyDocument" : { + "Statement" : [ + { + "Action" : "*", + "Effect" : "Allow", + "Resource" : "*" + } + ] + }, + "PolicyName" : "root", + "Roles" : [ + { + "Ref" : "RootRole" + } + ] + }, + "Type" : "AWS::IAM::Policy" + }, + "RootInstanceProfile" : { + "Properties" : { + "Path" : "/", + "Roles" : [ + { + "Ref" : "RootRole" + } + ] + }, + "Type" : "AWS::IAM::InstanceProfile" + }, + "RootRole" : { + "Properties" : { + "AssumeRolePolicyDocument" : { + "Statement" : [ + { + "Action" : [ + "sts:AssumeRole" + ], + "Effect" : "Allow", + "Principal" : { + "Service" : [ + "ec2.amazonaws.com" + ] + } + } + ] + }, + "Path" : "/" + }, + "Type" : "AWS::IAM::Role" + } + } +} \ No newline at end of file diff --git a/library/cloudformation b/library/cloudformation new file mode 100644 index 0000000000..5ab2d1a93e --- /dev/null +++ b/library/cloudformation @@ -0,0 +1,243 @@ +#!/usr/bin/python -tt +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +DOCUMENTATION = ''' +--- +module: cloudformation +short_description: create a AWS CloudFormation stack +description: + - Launches an AWS CloudFormation stack and waits for it complete. +version_added: "1.1" +options: + stack_name: + description: + - name of the cloudformation stack + required: true + default: null + aliases: [] + disable_rollback: + description: + - If a stacks fails to form, rollback will remove the stack + required: false + default: false + aliases: [] + template_parameters: + description: + - a list of hashes of all the template variables for the stack + required: true + default: null + aliases: [] + region: + description: + - The AWS region the stack will be launched in + required: true + default: null + aliases: [] + state: + description: + - If state is "present", stack will be created. If state is "present" and if stack exists and template has changed, it will be updated. + If state is present, stack will be removed. + required: true + default: null + aliases: [] + template: + description: + - the path of the cloudformation template + required: true + default: null + aliases: [] + wait_for: + description: + - Wait while the stack is being created/updated/deleted. + required: false + default: true + aliases: [] + +examples: + + tasks: + - name: launch ansible cloudformation example + cloudformation: > + stack_name="ansible-cloudformation" state=present + region=us-east-1 disable_rollback=true + template=files/cloudformation-example.json + args: + template_parameters: + KeyName: jmartin + DiskType: ephemeral + InstanceType: m1.small + ClusterSize: 3 + +requirements: [ "boto" ] +author: James S. Martin +''' + +import boto.cloudformation.connection +import json + + +class Region: + def __init__(self, region): + self.name = region + self.endpoint = 'cloudformation.%s.amazonaws.com' % region + + +def boto_exception(err): + + if hasattr(err, 'error_message'): + error = err.error_message + elif hasattr(err, 'message'): + error = err.message + else: + error = '%s: %s' % (Exception, err) + try: + error_msg = json.loads(error) + except: + error_msg = {'Error': error} + return error_msg + + +def stack_operation(cfn, stack_name, operation): + existed = [] + result = {} + operation_complete = False + while operation_complete == False: + try: + stack = cfn.describe_stacks(stack_name)[0] + existed.append('yes') + except: + if 'yes' in existed: + result = {'changed': True, 'output': 'Stack Deleted'} + result['events'] = map(str, list(stack.describe_events())) + else: + result = {'changed': True, 'output': 'Stack Not Found'} + break + if '%s_COMPLETE' % operation == stack.stack_status: + result['changed'] = True + result['events'] = map(str, list(stack.describe_events())) + result['output'] = 'Stack %s complete' % operation + break + elif '%s_FAILED' % operation == stack.stack_status: + result['changed'] = False + result['events'] = map(str, list(stack.describe_events())) + result['output'] = 'Stack %s failed' % operation + break + else: + time.sleep(5) + return result + + +def main(): + module = AnsibleModule( + argument_spec=dict( + stack_name=dict(required=True), + template_parameters=dict(required=False), + region=dict(required=True, + choices=['ap-northeast-1', 'ap-southeast-1', + 'ap-southeast-2', 'eu-west-1', + 'sa-east-1', 'us-east-1', 'us-west-1', + 'us-west-2']), + state=dict(default='present', choices=['present', 'absent']), + template=dict(default=None, required=True), + disable_rollback=dict(default=False), + wait_for=dict(default=True) + ) + ) + + wait_for = module.params['wait_for'] + state = module.params['state'] + stack_name = module.params['stack_name'] + region = Region(module.params['region']) + template_body = open(module.params['template'], 'r').read() + disable_rollback = module.params['disable_rollback'] + template_parameters = module.params['template_parameters'] + + template_parameters_tup = [(k, v) for k, v in template_parameters.items()] + stack_outputs = {} + stack_outputs[module.params['region']] = {} + stack_outputs[module.params['region']][stack_name] = {} + + try: + cfn = boto.cloudformation.connection.CloudFormationConnection( + region=region) + except boto.exception.NoAuthHandlerFound, e: + module.fail_json(msg=str(e)) + update = False + stack_events = [] + result = {} + operation = None + output = '' + + if state == 'present': + try: + cfn.create_stack(stack_name, parameters=template_parameters_tup, + template_body=template_body, + disable_rollback=disable_rollback, + capabilities=['CAPABILITY_IAM']) + operation = 'CREATE' + except Exception, err: + error_msg = boto_exception(err) + if error_msg['Error']['Code'] == 'AlreadyExistsException': + update = True + else: + result = {'changed': False, 'output': error_msg} + module.fail_json(**result) + if not update: + result = stack_operation(cfn, stack_name, operation) + if update: + try: + cfn.update_stack(stack_name, parameters=template_parameters_tup, + template_body=template_body, + disable_rollback=disable_rollback, + capabilities=['CAPABILITY_IAM']) + operation = 'UPDATE' + except Exception, err: + error_msg = boto_exception(err) + if error_msg['Error']['Message'] == 'No updates are to be performed.': + output = error_msg['Error']['Message'] + result = {'changed': False, 'output': output} + if operation == 'UPDATE': + result = stack_operation(cfn, stack_name, operation) + + if state == 'present' or update: + stack = cfn.describe_stacks(stack_name)[0] + for output in stack.outputs: + stack_outputs[module.params['region']][stack_name][ + output.key] = output.value + result['stack_outputs'] = stack_outputs + +# absent state is different because of the way delete_stack works. +# problem is it it doesn't give an error if stack isn't found +# so must describe the stack first + + if state == 'absent': + try: + cfn.describe_stacks(stack_name) + operation = 'DELETE' + except Exception, err: + error_msg = boto_exception(err) + result = {'changed': False, 'output': error_msg} + module.fail_json(result) + if operation == 'DELETE': + cfn.delete_stack(stack_name) + result = stack_operation(cfn, stack_name, operation) + + module.exit_json(**result) + +# this is magic, see lib/ansible/module_common.py +#<> + +main()