├── haproxy ├── haproxy_autoscale ├── __init__.py └── haproxy_autoscale.py ├── .gitignore ├── tests ├── README.md ├── data │ ├── simple_example.tpl │ ├── autobackends_example.tpl │ ├── autobackends_one_prefix_example.tpl │ ├── autobackends_three_prefixes_example.tpl │ └── running_instances_mock.yml └── test_haproxy_template.py ├── setup.py ├── templates └── haproxy.tpl ├── LICENSE.txt ├── failover-haproxy.py ├── update-haproxy.py └── README.md /haproxy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/markcaudill/haproxy-autoscale/HEAD/haproxy -------------------------------------------------------------------------------- /haproxy_autoscale/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = [ 'haproxy_autoscale' ] 2 | from haproxy_autoscale import * 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.swp 3 | bin 4 | include 5 | lib* 6 | build 7 | templates/testing.tpl 8 | local 9 | haproxy.cfg 10 | .idea 11 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | You may need to run this as root if you don't have special perms set on haproxy for launch. 2 | 3 | No fancy fixtures on tests, just run unittest discover from this directory: 4 | 5 | ```python -m unittest discover --pattern=*.py``` 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup 4 | 5 | setup(name='haproxy-autoscale', 6 | version='0.5.1', 7 | description='HAProxy wrapper for handling auto-scaling EC2 instances.', 8 | author='Mark Caudill', 9 | author_email='mark@markcaudill.me', 10 | url='https://github.com/markcaudill/haproxy-update', 11 | install_requires=['boto>=2.48', 'mako>=0.5.0', 'argparse>=1.2.1'], 12 | packages=['haproxy_autoscale'], 13 | scripts=[ 'update-haproxy.py','failover-haproxy.py'], 14 | license='MIT' 15 | ) 16 | -------------------------------------------------------------------------------- /templates/haproxy.tpl: -------------------------------------------------------------------------------- 1 | global 2 | daemon 3 | maxconn 256 4 | 5 | defaults 6 | mode http 7 | timeout connect 5000ms 8 | timeout client 5000ms 9 | timeout server 5000ms 10 | 11 | frontend www *:80 12 | mode http 13 | maxconn 50000 14 | default_backend servers 15 | 16 | backend servers 17 | mode http 18 | balance roundrobin 19 | % for instance in instances['security-group-1']: 20 | server ${ instance.id } ${ instance.private_dns_name } 21 | % endfor 22 | 23 | frontend java *:8080,*:8443 24 | mode http 25 | maxconn 50000 26 | default_backend java_servers 27 | 28 | backend java_servers 29 | mode http 30 | balance roundrobin 31 | % for instance in instances['security-group-2']: 32 | server ${ instance.id } ${ instance.private_dns_name } 33 | % endfor 34 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011 Mark Caudill 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /tests/data/simple_example.tpl: -------------------------------------------------------------------------------- 1 | global 2 | log 127.0.0.1 local0 debug# see /etc/rsyslog.d/10-haproxy 3 | maxconn 2048 4 | user haproxy 5 | group haproxy 6 | daemon 7 | stats socket /tmp/haproxy.sock 8 | 9 | defaults 10 | log global 11 | mode http 12 | option httplog 13 | option dontlognull 14 | option redispatch 15 | retries 3 16 | contimeout 5000 17 | clitimeout 50000 18 | srvtimeout 20000 19 | 20 | frontend http-in 21 | mode http 22 | bind 0.0.0.0:80 23 | option http-server-close 24 | 25 | frontend haproxyconfig 26 | mode http 27 | bind 0.0.0.0:8080 28 | option http-server-close 29 | default_backend haproxyconfig 30 | 31 | backend haproxyconfig 32 | mode http 33 | option httpclose 34 | #errorfile 503 /etc/haproxy/haproxy.cfg 35 | 36 | frontend haproxytemplate 37 | mode http 38 | bind 0.0.0.0:8081 39 | option http-server-close 40 | default_backend haproxytemplate 41 | 42 | backend haproxytemplate 43 | mode http 44 | option httpclose 45 | #errorfile 503 /etc/haproxy/templates/example.tpl 46 | 47 | backend simple_exampletpl 48 | mode http 49 | % for instance in instances['example_sg_1']: 50 | server ${ instance.id } ${ instance.private_dns_name }:80 cookie ${ instance.id } 51 | % endfor 52 | option httpclose 53 | -------------------------------------------------------------------------------- /tests/data/autobackends_example.tpl: -------------------------------------------------------------------------------- 1 | <% 2 | from haproxy_autoscale import Backends 3 | backends = Backends() 4 | %> 5 | global 6 | log 127.0.0.1 local0 debug# see /etc/rsyslog.d/10-haproxy 7 | maxconn 2048 8 | user haproxy 9 | group haproxy 10 | daemon 11 | stats socket /tmp/haproxy.sock 12 | 13 | defaults 14 | log global 15 | mode http 16 | option httplog 17 | option dontlognull 18 | option redispatch 19 | retries 3 20 | contimeout 5000 21 | clitimeout 50000 22 | srvtimeout 20000 23 | 24 | frontend http-in 25 | mode http 26 | bind 0.0.0.0:80 27 | option http-server-close 28 | ${backends.get_acls(instances_dict=instances, tabindent=4, domain='example.com')} 29 | 30 | frontend haproxyconfig 31 | mode http 32 | bind 0.0.0.0:8080 33 | option http-server-close 34 | default_backend haproxyconfig 35 | 36 | backend haproxyconfig 37 | mode http 38 | option httpclose 39 | #errorfile 503 /etc/haproxy/haproxy.cfg 40 | 41 | frontend haproxytemplate 42 | mode http 43 | bind 0.0.0.0:8081 44 | option http-server-close 45 | default_backend haproxytemplate 46 | 47 | backend haproxytemplate 48 | mode http 49 | option httpclose 50 | #errorfile 503 /etc/haproxy/templates/example.tpl 51 | 52 | ${backends.generate(template_name='default', tabindent=4, cookie=True)} 53 | -------------------------------------------------------------------------------- /tests/data/autobackends_one_prefix_example.tpl: -------------------------------------------------------------------------------- 1 | <% 2 | from haproxy_autoscale import Backends 3 | backends = Backends() 4 | %> 5 | global 6 | log 127.0.0.1 local0 debug# see /etc/rsyslog.d/10-haproxy 7 | maxconn 2048 8 | user haproxy 9 | group haproxy 10 | daemon 11 | stats socket /tmp/haproxy.sock 12 | 13 | defaults 14 | log global 15 | mode http 16 | option httplog 17 | option dontlognull 18 | option redispatch 19 | retries 3 20 | contimeout 5000 21 | clitimeout 50000 22 | srvtimeout 20000 23 | 24 | frontend http-in 25 | mode http 26 | bind 0.0.0.0:80 27 | option http-server-close 28 | ${backends.get_acls(instances_dict=instances, tabindent=4, domain='example.com', prefixes=["Environment"])} 29 | 30 | frontend haproxyconfig 31 | mode http 32 | bind 0.0.0.0:8080 33 | option http-server-close 34 | default_backend haproxyconfig 35 | 36 | backend haproxyconfig 37 | mode http 38 | option httpclose 39 | #errorfile 503 /etc/haproxy/haproxy.cfg 40 | 41 | frontend haproxytemplate 42 | mode http 43 | bind 0.0.0.0:8081 44 | option http-server-close 45 | default_backend haproxytemplate 46 | 47 | backend haproxytemplate 48 | mode http 49 | option httpclose 50 | #errorfile 503 /etc/haproxy/templates/example.tpl 51 | 52 | ${backends.generate(template_name='default', tabindent=4, cookie=True)} 53 | -------------------------------------------------------------------------------- /tests/data/autobackends_three_prefixes_example.tpl: -------------------------------------------------------------------------------- 1 | <% 2 | from haproxy_autoscale import Backends 3 | backends = Backends() 4 | %> 5 | global 6 | log 127.0.0.1 local0 debug# see /etc/rsyslog.d/10-haproxy 7 | maxconn 2048 8 | user haproxy 9 | group haproxy 10 | daemon 11 | stats socket /tmp/haproxy.sock 12 | 13 | defaults 14 | log global 15 | mode http 16 | option httplog 17 | option dontlognull 18 | option redispatch 19 | retries 3 20 | contimeout 5000 21 | clitimeout 50000 22 | srvtimeout 20000 23 | 24 | frontend http-in 25 | mode http 26 | bind 0.0.0.0:80 27 | option http-server-close 28 | ${backends.get_acls(instances_dict=instances, tabindent=4, domain='example.com', prefixes=["Environment","AppVersion","BooBaz"])} 29 | 30 | frontend haproxyconfig 31 | mode http 32 | bind 0.0.0.0:8080 33 | option http-server-close 34 | default_backend haproxyconfig 35 | 36 | backend haproxyconfig 37 | mode http 38 | option httpclose 39 | #errorfile 503 /etc/haproxy/haproxy.cfg 40 | 41 | frontend haproxytemplate 42 | mode http 43 | bind 0.0.0.0:8081 44 | option http-server-close 45 | default_backend haproxytemplate 46 | 47 | backend haproxytemplate 48 | mode http 49 | option httpclose 50 | #errorfile 503 /etc/haproxy/templates/example.tpl 51 | 52 | ${backends.generate(template_name='default', tabindent=4, cookie=True)} 53 | -------------------------------------------------------------------------------- /failover-haproxy.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | import argparse 3 | import logging 4 | from haproxy_autoscale import steal_elastic_ip 5 | import urllib2 6 | 7 | def main(): 8 | # Parse up the command line arguments. 9 | parser = argparse.ArgumentParser(description='Update haproxy to use all instances running in a security group.') 10 | parser.add_argument('--access-key', required=True) 11 | parser.add_argument('--secret-key', required=True) 12 | parser.add_argument('--eip', 13 | help='The Elastic IP to bind to when VIP seems unhealthy.') 14 | parser.add_argument('--health-check-url', 15 | help='The URL to check. Assigns EIP to self if health check fails.') 16 | args = parser.parse_args() 17 | 18 | # Do a health check on the url if specified. 19 | try: 20 | if args.health_check_url and args.eip: 21 | logging.info('Performing health check.') 22 | try: 23 | logging.info('Checking %s', args.health_check_url) 24 | response = urllib2.urlopen(args.health_check_url) 25 | logging.info('Response: %s', response.read()) 26 | 27 | except: 28 | # Assign the EIP to self. 29 | logging.warn('Health check failed. Assigning %s to self.', args.eip) 30 | steal_elastic_ip(access_key=args.access_key, 31 | secret_key=args.secret_key, 32 | ip=args.eip ) 33 | 34 | except: 35 | pass 36 | 37 | if __name__ == '__main__': 38 | logging.getLogger().setLevel(logging.INFO) 39 | main() 40 | -------------------------------------------------------------------------------- /tests/data/running_instances_mock.yml: -------------------------------------------------------------------------------- 1 | example_sg_1: 2 | i-cef14aa0: 3 | id: i-cef14aa0 4 | public_dns_name: ec2-54-202-50-199.us-west-2.compute.amazonaws.com 5 | private_dns_name: 10.198.26.60 6 | tags: 7 | Name: example-instance-1 8 | i-5e4cec7d: 9 | id: i-5e4cec7d 10 | public_dns_name: ec2-54-201-40-167.us-west-1.compute.amazonaws.com 11 | private_dns_name: 10.199.21.54 12 | tags: 13 | Name: example-instance-2 14 | Salesforceenvironment: dev 15 | Appname: HelloWorld 16 | AppPort: 8080 17 | AppVersion: 42 18 | i-5e4cec7d: 19 | id: i-b0dc57eb 20 | public_dns_name: ec2-218-214-80-76.us-east-1.compute.amazonaws.com 21 | private_dns_name: 10.178.27.130 22 | tags: 23 | Name: example-instance-3 24 | Environment: dev 25 | AppName: HelloWorld 26 | AppPort: 8080 27 | AppVersion: 42 28 | RedirectURI: /example/login.html 29 | i-7ae2f522: 30 | id: i-7ae2f522 31 | public_dns_name: ec2-126-116-20-15.us-west-1.compute.amazonaws.com 32 | private_dns_name: 10.109.12.197 33 | tags: 34 | Name: example-instance-5 35 | Environment: dev 36 | AppName: Foobar 37 | AppPort: 8080 38 | AppVersion: 42 39 | RedirectURI: /example/login.html 40 | i-7ae2f522: 41 | id: i-7ae2f522 42 | public_dns_name: ec2-117-214-30-66.us-east-1.compute.amazonaws.com 43 | private_dns_name: 10.168.27.129 44 | tags: 45 | Name: example-instance-6 46 | Environment: dev 47 | AppName: Foobar 48 | AppPort: 8080 49 | AppVersion: 42 50 | RedirectURI: /example/login.html 51 | i-4e4cfc7a: 52 | id: i-4e4cfc7a 53 | public_dns_name: ec2-116-116-20-15.us-west-1.compute.amazonaws.com 54 | private_dns_name: 10.108.12.198 55 | tags: 56 | Name: example-instance-4 57 | Environment: prd 58 | AppName: HelloWorld 59 | AppPort: 8080 60 | AppVersion: 42 61 | BooBaz: LoremIpsum 62 | RedirectURI: /example/login.html 63 | i-6d4cfa7b: 64 | id: i-6d4cfa7b 65 | public_dns_name: ec2-117-116-21-15.us-west-2.compute.amazonaws.com 66 | private_dns_name: 10.108.12.198 67 | tags: 68 | Name: example-instance-5 69 | Environment: dev 70 | AppName: HelloWorld 71 | AppPort: 8080 72 | AppVersion: 42 73 | RedirectURI: /example/login.html 74 | -------------------------------------------------------------------------------- /update-haproxy.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | import argparse 3 | import logging 4 | import sys 5 | import time 6 | from haproxy_autoscale import get_running_instances, exists_empty_security_group, file_contents, \ 7 | generate_haproxy_config, \ 8 | reload_haproxy 9 | 10 | 11 | def parse_args(): 12 | # Parse up the command line arguments. 13 | parser = argparse.ArgumentParser(description='Update haproxy to use all instances running in a security group.') 14 | parser.add_argument('--security-group', required=True, nargs='+', type=str) 15 | parser.add_argument('--access-key') 16 | parser.add_argument('--secret-key') 17 | parser.add_argument('--region', default=None, 18 | help='Defaults to all regions if not specified.') 19 | parser.add_argument('--output', default='haproxy.cfg', 20 | help='Defaults to ./haproxy.cfg if not specified.') 21 | parser.add_argument('--template', default='templates/haproxy.tpl', 22 | help="the template you want to use for generating the HAproxys config file") 23 | parser.add_argument('--servicename', default="haproxy", 24 | help='The OS service name to reload (Ubuntu only)') 25 | parser.add_argument('--haproxy', default=None, 26 | help='The haproxy binary to call. Defaults to haproxy if not specified.\n\ 27 | Not needed if --servicename is used') 28 | parser.add_argument('--pid', default='/var/run/haproxy.pid', 29 | help='The pid file for haproxy. Defaults to /var/run/haproxy.pid.\n\ 30 | Not needed if --servicename is used') 31 | parser.add_argument('--sleep', default=False, type=int, 32 | help='If specified this script will go in a continous loop, sleeping this amount between runs.') 33 | parser.add_argument('--safe-mode', action='store_true', default=False, 34 | help='If specified, the script exits if there is any AWS exception. E.g., wrong key/secret.' 35 | 'Also no reload haproxy conf, if there is no instance in any of the security groups') 36 | parser.add_argument('--delay', default=0, type=int, 37 | help='If specified, instances that were just created within DELAY seconds are not added into ' 38 | 'haproxy conf. Use when you want to give it some time to make sure the service on the ' 39 | 'instance is up and running before adding it into haproxy.') 40 | 41 | args = parser.parse_args() 42 | 43 | # syntax checking 44 | if args.servicename != "haproxy" and args.haproxy: 45 | logging.fatal("you must supply either \"--servicename\" OR \"--haproxy\", dont know what to reload now") 46 | sys.exit(2) 47 | 48 | if args.servicename == "haproxy" and not args.haproxy: 49 | logging.info("no \"--servicename\" OR \"--haproxy\" arguments found, defaulting to reloading service haproxy") 50 | 51 | return args 52 | 53 | 54 | def main(args): 55 | # Fetch a list of all the instances in these security groups. 56 | instances = {} 57 | for security_group in args.security_group: 58 | logging.info('Getting instances for %s.', security_group) 59 | instances[security_group] = get_running_instances(access_key=args.access_key, 60 | secret_key=args.secret_key, 61 | security_group=security_group, 62 | region=args.region, 63 | safe_mode=args.safe_mode, 64 | delay=args.delay) 65 | 66 | # Generate the new config from the template. 67 | logging.info('Generating configuration for haproxy.') 68 | new_configuration = generate_haproxy_config(template=args.template, 69 | instances=instances) 70 | 71 | # See if this new config is different. If it is then reload using it. 72 | # Otherwise just delete the temporary file and do nothing. 73 | logging.info('Comparing to existing configuration.') 74 | old_configuration = file_contents(filename=args.output) 75 | 76 | if new_configuration != old_configuration: 77 | logging.info('Existing configuration is outdated.') 78 | 79 | # Overwite the existing config file. 80 | logging.info('Writing new configuration.') 81 | file_contents(filename=args.output, 82 | content=generate_haproxy_config(template=args.template, 83 | instances=instances)) 84 | 85 | if args.safe_mode and exists_empty_security_group(instances): 86 | logging.info('Safe mode enabled. haproxy conf generated but NOT reloaded, ' 87 | 'since at least one of the security groups is empty.') 88 | exit(1) 89 | 90 | reload_haproxy(args) 91 | 92 | else: 93 | logging.info('Configuration unchanged. Skipping reload.') 94 | 95 | 96 | if __name__ == '__main__': 97 | logging.getLogger() 98 | log_format = '%(asctime)s - %(levelname)s - %(message)s' 99 | logging.basicConfig(stream=sys.stdout, level=logging.INFO, format=log_format) 100 | 101 | args = parse_args() 102 | 103 | if args.sleep: # continous runs 104 | logging.info("continous mode, sleeping %i seconds between runs\n", args.sleep) 105 | while True: 106 | main(args) 107 | time.sleep(args.sleep) 108 | 109 | else: # standard, onetime run 110 | main(args) 111 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # haproxy-autoscale # 2 | 3 | ## Description ## 4 | I had a project I was working on where I needed a private load balancer to use 5 | on Amazon Web Services. Unfortunately, AWS Elastic Load Balancers do not 6 | support private listeners so I needed to make my own load balancer using 7 | Linux. 8 | 9 | Making a load balancer is pretty straight forward. The challenge was that the 10 | instances under it were auto-scaling and there was no built-in mechanism for 11 | the load balancer to know to send traffic to new instances and to stop sending 12 | traffic to deleted instances. 13 | 14 | Enter haproxy-autoscale. This is a wrapper of sorts that will automatically add 15 | all instances in a security group that are currently in a running state to the 16 | haproxy configuration. It then reloads haproxy in a manner which gracefully 17 | terminates connections so there is no downtime. Also, haproxy will only be 18 | reloaded if there are changes. If there are no changes in the isntances that 19 | should be sent traffic then it just exits. 20 | 21 | I've actually bundled the haproxy binary with this repo to make things easier 22 | when getting started. 23 | 24 | The update-haproxy.py script does a good job with basic setups using the 25 | default options but as things get more complex you may want to get more 26 | specific with the paramters. For instance, if you decide to run multiple 27 | instances at the same time then you should probably specifify different 28 | templates, outputs and pid files for each. 29 | 30 | ## Installation ## 31 | Run `sudo python setup.py install` and if everything goes well you're ready to 32 | configure (if you have complex needs) and run the update-haproxy.py command. 33 | 34 | OR 35 | 36 | ``sudo pip install git+https://github.com/markcaudill/haproxy-autoscale`` 37 | 38 | ## Configuration ## 39 | Most of the configuration is done via command line options. The only 40 | configuration that may need to be done is the haproxy.cfg template. You can 41 | customize it to suit your needs or you can specify a different on on the 42 | command line. Make sure to read the existing template to see what variables 43 | will be available to use. 44 | 45 | ### IAM ### 46 | Below is an example policy that should provide the minimal access necessary for 47 | this to work. 48 | 49 | { 50 | "Version": "2012-10-17", 51 | "Statement": [ 52 | { 53 | "Sid": "Stmt1458960803000", 54 | "Effect": "Allow", 55 | "Action": [ 56 | "ec2:AssociateAddress", 57 | "ec2:DescribeAddresses", 58 | "ec2:DescribeAvailabilityZones", 59 | "ec2:DescribeInstanceAttribute", 60 | "ec2:DescribeInstanceStatus", 61 | "ec2:DescribeInstances", 62 | "ec2:DescribeRegions", 63 | "ec2:DescribeSecurityGroups" 64 | ], 65 | "Resource": [ 66 | "arn:aws:ec2:*" 67 | ] 68 | } 69 | ] 70 | } 71 | 72 | ## Usage ## 73 | haproxy-autoscale was designed to be run from the load balancer itself as a cron 74 | job. Ideally it would be run every minute. 75 | 76 | update-haproxy.py [-h] --security-group SECURITY_GROUP 77 | [SECURITY_GROUP ...] --access-key ACCESS_KEY 78 | --secret-key SECRET_KEY [--output OUTPUT] 79 | [--template TEMPLATE] [--haproxy HAPROXY] [--pid PID] 80 | [--eip EIP] [--health-check-url HEALTH_CHECK_URL] 81 | [--safe-mode] 82 | 83 | Update haproxy to use all instances running in a security group. 84 | 85 | optional arguments: 86 | -h, --help show this help message and exit 87 | --security-group SECURITY_GROUP [SECURITY_GROUP ...] 88 | [--access-key ACCESS_KEY 89 | --secret-key SECRET_KEY] 90 | Optional parameters in case the policy is assigned to the instance's role 91 | --output OUTPUT Defaults to haproxy.cfg if not specified. 92 | --template TEMPLATE 93 | --haproxy HAPROXY The haproxy binary to call. Defaults to haproxy if not 94 | specified. 95 | --pid PID The pid file for haproxy. Defaults to 96 | /var/run/haproxy.pid. 97 | --eip EIP The Elastic IP to bind to when VIP seems unhealthy. 98 | --health-check-url HEALTH_CHECK_URL 99 | The URL to check. Assigns EIP to self if health check 100 | fails. 101 | --safe-mode If enabled, the script exits if there is any AWS exception. 102 | E.g., wrong key/secret.' 103 | Also if there is no instance in any of the security groups, haproxy conf 104 | will be generated but NOT reloaded. 105 | 106 | Example: 107 | 108 | /usr/bin/python update-haproxy.py --access-key='SOMETHING' --secret-key='SoMeThInGeLsE' --security-group='webheads' 'tomcat-servers' 109 | 110 | /usr/bin/python update-haproxy.py --access-key='SOMETHING' --secret-key='SoMeThInGeLsE' --security-group='webheads' 'tomcat-servers' --safe-mode 111 | 112 | ## Changelog ## 113 | * v0.1 - Initial release. 114 | * v0.2 - Added ability to specify multiple security groups. This version is 115 | **not** compatible with previous versions' templates. 116 | * v0.3 - Added support for all regions. 117 | * v0.4 - Added accessor class for autobackend generation (see tests/data/autobackends_example.tpl for example usage) 118 | * v0.5 - Made access and security keys optional, replaced haproxy restart to reload, added path to the service command 119 | 120 | (Above change logs are for version 0.4.1 and before.) 121 | 122 | * version 0.5.0 - Add --safe-mode: exit on aws exception, and no reload when security group is empty 123 | Redirect log to stdout so you can pipe it to other programs; 124 | Add timestamp for logging. 125 | * version 0.5.1 - Add --delay option. 126 | If specified, instances that were just created within DELAY seconds are not added into haproxy conf. 127 | Use when you want to give it some time to make sure the service on the instance is up and running before adding it into haproxy. 128 | -------------------------------------------------------------------------------- /tests/test_haproxy_template.py: -------------------------------------------------------------------------------- 1 | """Tests for python blocks within haproxy-autoscale mako templates.""" 2 | 3 | import logging 4 | import os 5 | import subprocess 6 | 7 | import unittest 8 | from tempfile import NamedTemporaryFile 9 | import yaml 10 | 11 | import haproxy_autoscale 12 | from boto.ec2.connection import EC2Connection 13 | 14 | BASE_DIR_STR = os.path.realpath(__file__) 15 | INSTANCE_DATA_PATH=os.path.join(os.path.dirname(BASE_DIR_STR), 16 | 'data/running_instances_mock.yml') 17 | 18 | class BotoEc2InstanceMock(): 19 | """Mock boto.ec2.instance.Instance from yaml instance of HttpMock.""" 20 | 21 | def __init__(self, mock_dict): 22 | for key in mock_dict.keys(): 23 | setattr(self, key, mock_dict[key]) 24 | 25 | 26 | class templateTestCase(unittest.TestCase): 27 | 28 | def setUp(self): 29 | try: 30 | self.version = subprocess.check_output(['haproxy', '-vv']) 31 | except OSError as e: 32 | raise RuntimeError('No haproxy binary found in path') 33 | 34 | logging.NullHandler 35 | data_filehandle = open(INSTANCE_DATA_PATH) 36 | self.mock_data = yaml.safe_load(data_filehandle) 37 | mock_sg_one_str = self.mock_data.keys()[0] 38 | mock_instances_sg_one = [] 39 | for instance_data in self.mock_data[mock_sg_one_str]: 40 | mock_instances_sg_one.append(BotoEc2InstanceMock( 41 | self.mock_data[mock_sg_one_str][instance_data])) 42 | mock_instances = {mock_sg_one_str: mock_instances_sg_one} 43 | 44 | self.data_dir_str = os.path.join(os.path.dirname(BASE_DIR_STR), 'data') 45 | 46 | self.running_instances_mock = mock_instances 47 | 48 | 49 | def tearDown(self): 50 | #pass 51 | os.remove(self.test_conf_filepath) 52 | 53 | def test_simple_config(self): 54 | 55 | template = os.path.join(self.data_dir_str, 'simple_example.tpl') 56 | self.test_conf_filepath = NamedTemporaryFile(delete=True).name 57 | haproxy_conf_str = haproxy_autoscale.generate_haproxy_config( 58 | template=template, 59 | instances=self.running_instances_mock) 60 | haproxy_autoscale.file_contents(filename=self.test_conf_filepath, 61 | content=haproxy_conf_str) 62 | test_conf_output = check_conf(conf_filepath=self.test_conf_filepath) 63 | self.assertEqual(test_conf_output[0], True, 64 | ("Configuration parsing failed:\n%s" 65 | %test_conf_output[1])) 66 | 67 | 68 | @unittest.skip('stub') 69 | def test_find_required_tag(self): 70 | pass 71 | 72 | @unittest.skip('stub') 73 | def test_autobackend_config(self): 74 | template = os.path.join(self.data_dir_str, 75 | 'autobackends_example.tpl') 76 | self.test_conf_filepath = NamedTemporaryFile(delete=True).name 77 | haproxy_conf_str = haproxy_autoscale.generate_haproxy_config( 78 | template=template, 79 | instances=self.running_instances_mock) 80 | haproxy_autoscale.file_contents(filename=self.test_conf_filepath, 81 | content=haproxy_conf_str) 82 | test_conf_output = check_conf(conf_filepath=self.test_conf_filepath) 83 | self.assertEqual(test_conf_output[0], True, 84 | ("Configuration parsing failed:\n%s" 85 | %test_conf_output[1])) 86 | 87 | def test_autobackend_one_prefix(self): 88 | template = os.path.join(self.data_dir_str, 89 | 'autobackends_one_prefix_example.tpl') 90 | self.test_conf_filepath = NamedTemporaryFile(delete=True).name 91 | haproxy_conf_str = haproxy_autoscale.generate_haproxy_config( 92 | template=template, 93 | instances=self.running_instances_mock) 94 | haproxy_autoscale.file_contents(filename=self.test_conf_filepath, 95 | content=haproxy_conf_str) 96 | test_conf_output = check_conf(conf_filepath=self.test_conf_filepath) 97 | self.assertEqual(test_conf_output[0], True, 98 | ("Configuration parsing failed:\n%s" 99 | %test_conf_output[1])) 100 | 101 | def test_autobackend_three_prefixes(self): 102 | template = os.path.join(self.data_dir_str, 103 | 'autobackends_three_prefixes_example.tpl') 104 | self.test_conf_filepath = NamedTemporaryFile(delete=True).name 105 | haproxy_conf_str = haproxy_autoscale.generate_haproxy_config( 106 | template=template, 107 | instances=self.running_instances_mock) 108 | haproxy_autoscale.file_contents(filename=self.test_conf_filepath, 109 | content=haproxy_conf_str) 110 | test_conf_output = check_conf(conf_filepath=self.test_conf_filepath) 111 | self.assertEqual(test_conf_output[0], True, 112 | ("Configuration parsing failed:\n%s" 113 | %test_conf_output[1])) 114 | 115 | @unittest.skip('stub') 116 | def test_skip_without_required_tag(self): 117 | pass 118 | 119 | 120 | # TODO: merge tests to module haproxy-autoscale/tests branch and import here 121 | def check_conf(conf_filepath): 122 | """Wrap haproxy -c -f. 123 | 124 | Args: 125 | conf_filepath: Str, path to an haproxy configuration file. 126 | Returns: 127 | valid_config: Bool, true if configuration passed parsing. 128 | """ 129 | 130 | try: 131 | subprocess.check_output(['haproxy', '-c', '-f', conf_filepath], 132 | stderr=subprocess.STDOUT) 133 | valid_config = True 134 | error_output = None 135 | except subprocess.CalledProcessError as e: 136 | valid_config = False 137 | error_output = e.output 138 | 139 | return (valid_config, error_output) 140 | 141 | 142 | if __name__ == '__main__': 143 | templateTestSuite = (unittest.TestLoader(). 144 | loadTestsFromTestCase(templateTestCase)) 145 | -------------------------------------------------------------------------------- /haproxy_autoscale/haproxy_autoscale.py: -------------------------------------------------------------------------------- 1 | from boto.ec2 import EC2Connection, get_region 2 | import boto.exception 3 | import logging 4 | import subprocess 5 | import urllib2 6 | from mako.template import Template 7 | from datetime import datetime 8 | 9 | __version__ = '0.5.1' 10 | 11 | 12 | def get_self_instance_id(): 13 | ''' 14 | Get this instance's id. 15 | ''' 16 | logging.debug('get_self_instance_id()') 17 | response = urllib2.urlopen('http://169.254.169.254/1.0/meta-data/instance-id') 18 | instance_id = response.read() 19 | return instance_id 20 | 21 | 22 | def steal_elastic_ip(access_key=None, secret_key=None, ip=None): 23 | ''' 24 | Assign an elastic IP to this instance. 25 | ''' 26 | logging.debug('steal_elastic_ip()') 27 | instance_id = get_self_instance_id() 28 | conn = EC2Connection(aws_access_key_id=access_key, 29 | aws_secret_access_key=secret_key) 30 | conn.associate_address(instance_id=instance_id, public_ip=ip) 31 | 32 | 33 | def get_running_instances(access_key=None, secret_key=None, security_group=None, region=None, safe_mode=False, delay=0): 34 | ''' 35 | Get all running instances. Only within a security group if specified. 36 | ''' 37 | logging.debug('get_running_instances()') 38 | 39 | instances_all_regions_list = [] 40 | if region is None: 41 | conn = EC2Connection(aws_access_key_id=access_key, 42 | aws_secret_access_key=secret_key) 43 | ec2_region_list = conn.get_all_regions() 44 | else: 45 | ec2_region_list = [get_region(region)] 46 | 47 | for region in ec2_region_list: 48 | conn = EC2Connection(aws_access_key_id=access_key, 49 | aws_secret_access_key=secret_key, 50 | region=region) 51 | 52 | running_instances = [] 53 | date_format = '%Y-%m-%dT%H:%M:%S.%fZ' 54 | try: 55 | for s in conn.get_all_security_groups(): 56 | if s.name == security_group: 57 | running_instances.extend([ 58 | i for i in s.instances() 59 | if i.state == 'running' and ( 60 | datetime.utcnow() - datetime.strptime(i.launch_time, date_format)).seconds > delay 61 | ]) 62 | except boto.exception.EC2ResponseError: 63 | logging.error('Region [' + region.name + '] inaccessible') 64 | if safe_mode: 65 | logging.error('Safe mode enabled. No new haproxy cfg is generated. Exit now.') 66 | exit(1) 67 | 68 | if running_instances: 69 | for instance in running_instances: 70 | instances_all_regions_list.append(instance) 71 | 72 | return instances_all_regions_list 73 | 74 | 75 | def exists_empty_security_group(instances): 76 | for sg, instances in instances.iteritems(): 77 | if not instances: 78 | logging.error('There is no instance in security group %s', sg) 79 | return True 80 | return False 81 | 82 | 83 | def file_contents(filename=None, content=None): 84 | ''' 85 | Just return the contents of a file as a string or write if content 86 | is specified. Returns the contents of the filename either way. 87 | ''' 88 | logging.debug('file_contents()') 89 | if content: 90 | f = open(filename, 'w') 91 | f.write(content) 92 | f.close() 93 | 94 | try: 95 | f = open(filename, 'r') 96 | text = f.read() 97 | f.close() 98 | except: 99 | text = None 100 | 101 | return text 102 | 103 | 104 | def generate_haproxy_config(template=None, instances=None): 105 | ''' 106 | Generate an haproxy configuration based on the template and instances list. 107 | ''' 108 | return Template(filename=template).render(instances=instances) 109 | 110 | 111 | def reload_haproxy(args): 112 | ''' 113 | Reload haproxy, either by an Ubuntu service or standalone binary 114 | ''' 115 | logging.info('Reloading haproxy.') 116 | 117 | if args.haproxy: 118 | # Get PID if haproxy is already running. 119 | logging.debug('Fetching PID from %s.', args.pid) 120 | pid = file_contents(filename=args.pid) 121 | command = '''%s -p %s -f %s -sf %s''' % (args.haproxy, args.pid, args.output, pid or '') 122 | 123 | else: 124 | command = "/sbin/service %s reload" % args.servicename 125 | 126 | logging.debug('Executing: %s', command) 127 | subprocess.call(command, shell=True) 128 | 129 | 130 | class Backends(object): 131 | """ 132 | this class is used for the tests functionality 133 | """ 134 | 135 | # instances without these tags will be excluded from backends 136 | required_keys = ['AppName', 137 | 'AppPort'] 138 | 139 | backend_templates = {'default': {'mode': 'http', 140 | 'option': 'httpchk', 141 | 'balance': 'roundrobin'}, 142 | 'ssl-backend': {'mode': 'https', 143 | 'option': 'httpchk', 144 | 'balance': 'roundrobin'}} 145 | comment = ("# Autogenerated with haproxy_autoscale version %s" 146 | % __version__) 147 | 148 | def get_acls(self, instances_dict, tabindent, domain, prefixes=None): 149 | """Generate neatly printed cfg-worthy backends for haproxy. 150 | 151 | Args: 152 | instances_dict: haproxy_autoscale.get_running_instances return. 153 | tabindent: Int, number of spaces to prepend hanging config lines. 154 | domain: Str, TLD to serve all backends from. 155 | prefixes: List, strings to prepend to acls and backends. 156 | Returns: 157 | return_comment: Str, version comment information. 158 | """ 159 | 160 | # flatten all security group lookups into single instance list 161 | self.all_instances = [] 162 | for instance_list in instances_dict.values(): 163 | for instance in instance_list: 164 | self.all_instances.append(instance) 165 | 166 | self.all_backends = [] 167 | self.included_instances = [] 168 | self.excluded_instances = [] 169 | 170 | if type(prefixes) is list: 171 | for prefix in prefixes: 172 | self.required_keys.append(prefix) 173 | else: 174 | prefixes = [] 175 | 176 | for instance in self.all_instances: 177 | instance.missing_tags = [] 178 | for key in self.required_keys: 179 | if key not in instance.tags: 180 | instance.missing_tags.append(key) 181 | if len(instance.missing_tags) is 0: 182 | self.included_instances.append(instance) 183 | app_name = instance.tags['AppName'] 184 | prefix_str = '' 185 | if len(prefixes) > 0: 186 | for prefix in prefixes: 187 | prefix_str = prefix_str + "%s-" % instance.tags[prefix] 188 | 189 | backend_name = "%s%s" % (prefix_str, app_name) 190 | instance.tags['backend'] = backend_name 191 | if backend_name not in self.all_backends: 192 | self.all_backends.append(backend_name) 193 | else: 194 | self.excluded_instances.append(instance) 195 | 196 | # generate acls and redirects 197 | tabindent_str = (' ' * tabindent) 198 | return_str = "\n%s%s" % (tabindent_str, self.comment) 199 | for backend in self.all_backends: 200 | return_str = return_str + ("\n%sacl %s hdr(host) -i %s.%s" 201 | % (tabindent_str, 202 | backend, 203 | backend, 204 | domain)) 205 | return_str = return_str + ("\n%suse_backend %s if %s" 206 | % (tabindent_str, 207 | backend, 208 | backend)) 209 | return return_str 210 | 211 | def generate(self, template_name, tabindent, cookie=True): 212 | """Iterate over all backend objects and generate default backend. 213 | 214 | Args: 215 | template_name: Str, a haproxy_autoscale.Backends.backend_templates. 216 | tabindent: Int, number of spaces to prepend hanging config lines. 217 | cookie: Bool, False to disabled sticky sessions. 218 | Returns: 219 | return_str: Str, formatted haproxy backend text block. 220 | """ 221 | 222 | template = self.backend_templates.get(template_name) 223 | tabindent_str = (' ' * tabindent) 224 | return_str = '' 225 | 226 | # generate backend cfg from template 227 | for backend in self.all_backends: 228 | return_str = return_str + ("\n\n%s\nbackend %s" 229 | % (self.comment, backend)) 230 | for key, value in template.iteritems(): 231 | return_str = return_str + "\n%s%s %s" % (tabindent_str, 232 | key, value) 233 | if cookie is True: 234 | return_str = return_str + ("\n%scookie SERVERID insert" 235 | " indirect nocache" % tabindent_str) 236 | return_str = return_str + "\n" 237 | 238 | # populate backend with instances 239 | for instance in self.included_instances: 240 | if instance.tags['backend'] == backend: 241 | return_str = return_str + ("\n%sserver %s %s:%s" 242 | % (tabindent_str, 243 | instance.id, 244 | instance.private_dns_name, 245 | instance.tags['AppPort'])) 246 | if cookie is True: 247 | return_str = return_str + " cookie %s" % (instance.id) 248 | 249 | return return_str 250 | --------------------------------------------------------------------------------