diff --git a/heat/engine/loadbalancer.py b/heat/engine/loadbalancer.py new file mode 100644 index 0000000000..4304dd68c4 --- /dev/null +++ b/heat/engine/loadbalancer.py @@ -0,0 +1,295 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import urllib2 +import json +import logging + +from heat.common import exception +from heat.engine.resources import Resource +from heat.db import api as db_api +from heat.engine import parser +from novaclient.exceptions import NotFound + +logger = logging.getLogger(__file__) + +lb_template = ''' +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "Built in HAProxy server", + "Parameters" : { + "KeyName" : { + "Type" : "String" + } + }, + "Resources": { + "LoadBalancerInstance": { + "Type": "AWS::EC2::Instance", + "Metadata": { + "AWS::CloudFormation::Init": { + "config": { + "packages": { + "yum": { + "haproxy" : [] + } + }, + "services": { + "systemd": { + "haproxy" : { "enabled": "true", "ensureRunning": "true" } + } + }, + "files": { + "/etc/haproxy/haproxy.cfg": { + "content": ""}, + "mode": "000644", + "owner": "root", + "group": "root" + } + } + } + }, + "Properties": { + "ImageId": "F16-x86_64-cfntools", + "InstanceType": "m1.small", + "KeyName": { "Ref": "KeyName" }, + "UserData": { "Fn::Base64": { "Fn::Join": ["", [ + "#!/bin/bash -v\\n", + "/opt/aws/bin/cfn-init -s ", + { "Ref": "AWS::StackName" }, + " --region ", { "Ref": "AWS::Region" }, "\\n" + ]]}} + } + } + }, + + "Outputs": { + "PublicIp": { + "Value": { "Fn::GetAtt": [ "LoadBalancerInstance", "PublicIp" ] }, + "Description": "instance IP" + } + } +} +''' + + +# +# TODO(asalkeld) once we have done a couple of these composite +# Resources we should probably make a generic CompositeResource class. +# There will be plenty of scope for it. I resisted doing this initially +# to see how what the other composites require. +# +# Also the above inline template _could_ be placed in an external file +# at the moment this is because we will probably need to implement a +# LoadBalancer based on keepalived as well (for for ssl support). +# +class LoadBalancer(Resource): + + listeners_schema = { + 'InstancePort': {'Type': 'Integer', + 'Required': True}, + 'LoadBalancerPort': {'Type': 'Integer', + 'Required': True}, + 'Protocol': {'Type': 'String', + 'Required': True, + 'AllowedValues': ['TCP', 'HTTP']}, + 'SSLCertificateId': {'Type': 'String', + 'Implemented': False}, + 'PolicyNames': {'Type': 'Map', + 'Implemented': False} + } + healthcheck_schema = { + 'HealthyThreshold': {'Type': 'Integer', + 'Required': True}, + 'Interval': {'Type': 'Integer', + 'Required': True}, + 'Target': {'Type': 'String', + 'Required': True}, + 'Timeout': {'Type': 'Integer', + 'Required': True}, + 'UnHealthyTheshold': {'Type': 'Integer', + 'Required': True}, + } + + properties_schema = { + 'AvailabilityZones': {'Type': 'List', + 'Required': True}, + 'HealthCheck': {'Type': 'Map', + 'Implemented': False, + 'Schema': healthcheck_schema}, + 'Instances': {'Type': 'List'}, + 'Listeners': {'Type': 'List', + 'Schema': listeners_schema}, + 'AppCookieStickinessPolicy': {'Type': 'String', + 'Implemented': False}, + 'LBCookieStickinessPolicy': {'Type': 'String', + 'Implemented': False}, + 'SecurityGroups': {'Type': 'String', + 'Implemented': False}, + 'Subnets': {'Type': 'List', + 'Implemented': False} + } + + def __init__(self, name, json_snippet, stack): + Resource.__init__(self, name, json_snippet, stack) + self._nested = None + + def _params(self): + # total hack - probably need an admin key here. + params = {'KeyName': {'Ref': 'KeyName'}} + p = self.stack.resolve_static_data(params) + return p + + def nested(self): + if self._nested is None: + if self.instance_id is None: + return None + + st = db_api.stack_get(self.stack.context, self.instance_id) + if not st: + raise exception.NotFound('Nested stack not found in DB') + + n = parser.Stack(self.stack.context, st.name, + st.raw_template.parsed_template.template, + self.instance_id, self._params()) + self._nested = n + + return self._nested + + def _instance_to_ipaddress(self, inst): + ''' + Return the server's IP address, fetching it from Nova + ''' + try: + server = self.nova().servers.get(inst) + except NotFound as ex: + logger.warn('Instance IP address not found (%s)' % str(ex)) + else: + for n in server.networks: + return server.networks[n][0] + + return '0.0.0.0' + + def _haproxy_config(self, templ): + # initial simplifications: + # - only one Listener + # - static (only use Instances) + # - only http (no tcp or ssl) + # + # option httpchk HEAD /check.txt HTTP/1.0 + gl = ''' + global + daemon + maxconn 256 + + defaults + mode http + timeout connect 5000ms + timeout client 50000ms + timeout server 50000ms +''' + + listener = self.properties['Listeners'][0] + lb_port = listener['LoadBalancerPort'] + inst_port = listener['InstancePort'] + spaces = ' ' + frontend = ''' + frontend http + bind *:%s +''' % (lb_port) + + backend = ''' + default_backend servers + + backend servers + balance roundrobin + option http-server-close + option forwardfor +''' + servers = [] + n = 1 + for i in self.properties['Instances']: + ip = self._instance_to_ipaddress(i) + servers.append('%sserver server%d %s:%s' % (spaces, n, + ip, inst_port)) + n = n + 1 + + return '%s%s%s%s\n' % (gl, frontend, backend, '\n'.join(servers)) + + def handle_create(self): + templ = json.loads(lb_template) + + md = templ['Resources']['LoadBalancerInstance']['Metadata'] + files = md['AWS::CloudFormation::Init']['config']['files'] + cfg = self._haproxy_config(templ) + files['/etc/haproxy/haproxy.cfg']['content'] = cfg + + self._nested = parser.Stack(self.stack.context, + self.name, + templ, + parms=self._params(), + metadata_server=self.stack.metadata_server) + + rt = {'template': templ, 'stack_name': self.name} + new_rt = db_api.raw_template_create(None, rt) + + parent_stack = db_api.stack_get(self.stack.context, self.stack.id) + + s = {'name': self.name, + 'owner_id': self.stack.id, + 'raw_template_id': new_rt.id, + 'user_creds_id': parent_stack.user_creds_id, + 'username': self.stack.context.username} + new_s = db_api.stack_create(None, s) + self._nested.id = new_s.id + + pt = {'template': self._nested.t, 'raw_template_id': new_rt.id} + new_pt = db_api.parsed_template_create(None, pt) + + self._nested.parsed_template_id = new_pt.id + + self._nested.create() + self.instance_id_set(self._nested.id) + + def handle_delete(self): + try: + stack = self.nested() + except exception.NotFound: + logger.info("Stack not found to delete") + else: + if stack is not None: + stack.delete() + + def FnGetAtt(self, key): + ''' + We don't really support any of these yet. + ''' + allow = ('CanonicalHostedZoneName', + 'CanonicalHostedZoneNameID', + 'DNSName', + 'SourceSecurityGroupName', + 'SourceSecurityGroupOwnerAlias') + + if not key in allow: + raise exception.InvalidTemplateAttribute(resource=self.name, + key=key) + + stack = self.nested() + if stack is None: + # This seems like a hack, to get past validation + return '' + if key == 'DNSName': + return stack.output('PublicIp') + else: + return '' diff --git a/heat/engine/resource_types.py b/heat/engine/resource_types.py index cd9ec49b3f..dbd8fd2b37 100644 --- a/heat/engine/resource_types.py +++ b/heat/engine/resource_types.py @@ -23,6 +23,7 @@ from heat.engine import resources from heat.engine import cloud_watch from heat.engine import eip from heat.engine import instance +from heat.engine import loadbalancer from heat.engine import security_group from heat.engine import stack from heat.engine import user @@ -42,6 +43,7 @@ _resource_classes = { 'AWS::EC2::SecurityGroup': security_group.SecurityGroup, 'AWS::EC2::Volume': volume.Volume, 'AWS::EC2::VolumeAttachment': volume.VolumeAttachment, + 'AWS::ElasticLoadBalancing::LoadBalancer': loadbalancer.LoadBalancer, 'AWS::IAM::User': user.User, 'AWS::IAM::AccessKey': user.AccessKey, 'HEAT::HA::Restarter': instance.Restarter, diff --git a/templates/WordPress_With_LB.template b/templates/WordPress_With_LB.template index 6055dc3f16..a25353de42 100644 --- a/templates/WordPress_With_LB.template +++ b/templates/WordPress_With_LB.template @@ -109,39 +109,59 @@ }, "WikiServerOne": { - "Type": "AWS::CloudFormation::Stack", - "DependsOn": "DatabaseServer", - "Properties": { - "TemplateURL": "https://raw.github.com/heat-api/heat/master/templates/WordPress_And_Http.template", - "Parameters": { - "KeyName" : { "Ref": "KeyName" }, - "InstanceType" : { "Ref": "InstanceType" }, - "DBName" : { "Ref": "DBName" }, - "DBUsername" : { "Ref": "DBUsername" }, - "DBPassword" : { "Ref": "DBPassword" }, - "DBIpaddress" : { "Fn::GetAtt": [ "DatabaseServer", "Outputs.PublicIp" ]}, - "LinuxDistribution": { "Ref": "LinuxDistribution" } + "Type": "AWS::EC2::Instance", + "DependsOn" : "DatabaseServer", + "Metadata": { + "AWS::CloudFormation::Init": { + "config": { + "packages": { + "yum": { + "httpd" : [], + "wordpress" : [] + } + }, + "services": { + "systemd": { + "httpd" : { "enabled": "true", "ensureRunning": "true" } + } + } + } } + }, + "Properties": { + "ImageId": { "Fn::FindInMap": [ "DistroArch2AMI", { "Ref": "LinuxDistribution" }, + { "Fn::FindInMap": [ "AWSInstanceType2Arch", { "Ref": "InstanceType" }, "Arch" ] } ] }, + "InstanceType" : { "Ref": "InstanceType" }, + "KeyName" : { "Ref": "KeyName" }, + "UserData" : { "Fn::Base64": { "Fn::Join": ["", [ + "#!/bin/bash -v\n", + "/opt/aws/bin/cfn-init\n", + "sed --in-place --e s/database_name_here/", { "Ref": "DBName" }, + "/ --e s/username_here/", { "Ref": "DBUsername" }, + "/ --e s/password_here/", { "Ref": "DBPassword" }, + "/ --e s/localhost/", { "Fn::GetAtt": [ "DatabaseServer", "Outputs.PublicIp" ]}, + "/ /usr/share/wordpress/wp-config.php\n" + ]]}} } }, - "LoadBalancer": { - "Type": "AWS::CloudFormation::Stack", - "DependsOn": "WikiServerOne", - "Properties": { - "TemplateURL": "https://raw.github.com/heat-api/heat/master/templates/HAProxy_Single_Instance.template", - "Parameters": { - "KeyName" : { "Ref": "KeyName" }, - "InstanceType" : { "Ref": "InstanceType" }, - "Server1" : { "Fn::Join": ["", [{ "Fn::GetAtt": [ "WikiServerOne", "Outputs.PublicIp" ]}, ":80"]] } - } + "LoadBalancer" : { + "Type" : "AWS::ElasticLoadBalancing::LoadBalancer", + "Properties" : { + "AvailabilityZones" : { "Fn::GetAZs" : "" }, + "Instances" : [{"Ref": "WikiServerOne"}], + "Listeners" : [ { + "LoadBalancerPort" : "80", + "InstancePort" : "80", + "Protocol" : "HTTP" + }] } } }, "Outputs": { "WebsiteURL": { - "Value": { "Fn::Join": ["", ["http://", { "Fn::GetAtt": [ "LoadBalancer", "Outputs.PublicIp" ]}, "/wordpress"]] }, + "Value": { "Fn::Join": ["", ["http://", { "Fn::GetAtt": [ "LoadBalancer", "DNSName" ]}, "/wordpress"]] }, "Description": "URL for Wordpress wiki" } }