#!/usr/bin/env python # # Copyright 2018 SUNET. All rights reserved. # # Redistribution and use in source and binary forms, with or without modification, are # permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, this list of # conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, this list # of conditions and the following disclaimer in the documentation and/or other materials # provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY SUNET ``AS IS'' AND ANY EXPRESS OR IMPLIED # WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND # FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL SUNET OR # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON # ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. # # The views and conclusions contained in the software and documentation are those of the # authors and should not be interpreted as representing official policies, either expressed # or implied, of SUNET. # # Author: Fredrik Thulin # """ Toolbox for everything that needs to read the instance YAML config file. The idea is to not have a lot of different scripts reading that config file, to make it easier to update the YAML config file format going forward. Main actions: - Print things in the YAML config file. Some things are handled specially, like 'print_ips' that extracts frontend IP addresses and make them convenient to work with from shell scripts. - Print haproxy config. Creates a Jinja2 context from the YAML file and generates a haproxy config from a template. - Print exabgp announce messages. Called whenever a status change is detected for the configured backends. """ import os import sys import glob import time import yaml import pprint import logging import argparse import logging.handlers logger = None _defaults = {'syslog': False, 'debug': False, 'config_dir': '/opt/frontend/config', 'backends_dir': '/opt/frontend/api/backends', 'max_age': 600, } def parse_args(defaults): parser = argparse.ArgumentParser(description = 'SUNET frontend config toolbox', add_help = True, formatter_class = argparse.ArgumentDefaultsHelpFormatter, ) # Positional arguments parser.add_argument('actions', metavar='ACTION', nargs='+', help='Actions to perform', ) # Optional arguments parser.add_argument('--config_dir', dest = 'config_dir', metavar = 'DIR', type = str, default = defaults['config_dir'], help = 'Base directory for configuration data', ) parser.add_argument('--backends_dir', dest = 'backends_dir', metavar = 'DIR', type = str, default = defaults['backends_dir'], help = 'Base directory for API backend registration data', ) parser.add_argument('-c', '--config_fn', dest = 'config_fn', metavar = 'FILENAME', type = str, default = None, help = 'YAML config file to use', ) parser.add_argument('--haproxy_template', dest = 'haproxy_template', metavar = 'FILENAME', type = str, default = None, help = 'haproxy.j2 file to use, relative to --config-dir', ) parser.add_argument('--instance', dest = 'instance', metavar='NAME', type = str, default = None, help='Instance', ) parser.add_argument('--max_age', dest = 'max_age', metavar = 'SECONDS', type = int, default = defaults['max_age'], help = 'Max backend file age to allow', ) parser.add_argument('--fqdn', dest = 'fqdn', metavar='NAME', type = str, default = None, help='Host FQDN (for print_exabgp_announce)', ) parser.add_argument('--status', dest = 'status', metavar='UP/DOWN', type = str, default = None, help='Current backend status (for print_exabgp_announce)', ) parser.add_argument('--debug', dest = 'debug', action = 'store_true', default = defaults['debug'], help = 'Enable debug operation', ) parser.add_argument('--syslog', dest = 'syslog', action = 'store_true', default = defaults['syslog'], help = 'Enable syslog output', ) args = parser.parse_args() return args def main(myname = 'frontend-config', args = None, logger_in = None, defaults = _defaults): if not args: args = parse_args(defaults) global logger # initialize various components if logger_in: logger = logger_in else: # This is the root log level level = logging.INFO if args.debug: level = logging.DEBUG logging.basicConfig(level = level, stream = sys.stderr, format='%(asctime)s: %(name)s: %(threadName)s %(levelname)s %(message)s') logger = logging.getLogger(myname) # If stderr is not a TTY, change the log level of the StreamHandler (stream = sys.stderr above) to WARNING if not sys.stderr.isatty() and not args.debug: for this_h in logging.getLogger('').handlers: this_h.setLevel(logging.WARNING) if args.syslog: syslog_h = logging.handlers.SysLogHandler() formatter = logging.Formatter('%(name)s: %(levelname)s %(message)s') syslog_h.setFormatter(formatter) logger.addHandler(syslog_h) config_fn = args.config_fn if not config_fn: if not args.instance: logger.error('Neither config file nor --instance supplied') return False config_fn = os.path.join(args.config_dir, args.instance, 'config.yml') logger.debug('Loading configuration from {!r}'.format(config_fn)) with open(config_fn, 'r') as fd: config = yaml.safe_load(fd) logger.debug('Config:\n{!s}'.format(pprint.pformat(config))) for this in args.actions: logger.debug('Processing action {!r}'.format(this)) if this.startswith('print_'): if this == 'print_ips': if not print_ips(config, args, logger): return False elif this == 'print_haproxy_config': if not print_haproxy_config(config, args, logger): return False elif this == 'print_exabgp_announce': if not print_exabgp_announce(config, args, logger): return False else: # Print generic value if not print_generic(this[6:], config, args, logger): return False else: sys.stderr.write('Unknown action {!r}\n'.format(this)) return False return True def print_generic(what, config, args, logger): if what not in config: return False print(config[what]) return True def print_ips(config, args, logger): if not 'frontends' in config: return False res = [] for fe in config['frontends'].values(): res += fe['ips'] print('\n'.join(sorted(res))) return True def print_exabgp_announce(config, args, logger): if not args.fqdn: logger.error('Action print_exabgp_announce requires --fqdn') return False if not args.status: logger.error('Action print_exabgp_announce requires --status') return False frontends = _pref_sort_frontends(args.fqdn, sorted(config['frontends'].keys())) bgp_pref = 1000 is_up = True if args.status.startswith('UP') else False if not is_up: # fallback route preference bgp_pref = 100 bgp = [] count=1 frontends.reverse() for fe in frontends: for ip in sorted(config['frontends'][fe]['ips']): if ':' in ip: ip_slash = ip + '/128' else: ip_slash = ip + '/32' logger.debug('Site {}, address {}/{} -> announce {} with BGP preference {}'.format( config['site_name'], count, len(frontends), ip, bgp_pref)) bgp.append('announce route {} next-hop self local-preference {}'.format(ip_slash, bgp_pref)) count += 1 if is_up: bgp_pref += 100 else: bgp_pref += 10 print('\n'.join(sorted(bgp))) return bool(bgp) def _pref_sort_frontends(fqdn, frontends): """ Three load balancers named A, B and C should all announce service IP 1, 2 and 3 with different BGP MED (lower MED wins). A should announce 1 with the lowest MED, and B should announce 2 with the lowest MED. Re-sort the input list of IPs to start with the one specified by the index. """ index = frontends.index(fqdn) return frontends[index:] + frontends[:index] def print_haproxy_config(config, args, logger): if not args.haproxy_template: logger.error('Action print_haproxy_config requires --haproxy_template') return False if not args.instance: logger.error('Action print_haproxy_config requires --instance') return False context = config import pprint import jinja2 from jinja2 import Environment, FileSystemLoader bind_ips = [] for fe in config['frontends'].values(): bind_ips += fe['ips'] context['bind_ips'] = sorted(bind_ips) context['backends'] = _load_backends(config, args, logger) # remove things not meant for the haproxy template for this in ['allow_ports', 'frontends', 'frontend_template']: if this in context: del context[this] logger.debug('Rendering haproxy template {} with context:\n{!s}'.format(args.haproxy_template, pprint.pformat(context))) env = Environment(loader = FileSystemLoader(args.config_dir)) template = env.get_template(args.haproxy_template) print(template.render(**context)) return True def _load_backends(config, args, logger): """ Load data written to disk by the API used by backends to register themselves. """ res = [] for be in sorted(config['backends'].keys()): # be is 'default', 'testing' or similar if be.startswith('_'): continue servers = [] be_name = '{}__{}'.format(config['site_name'], be) for host, params in config['backends'][be].items(): be_ips = params.pop('ips', []) for addr in be_ips: data = _load_backend_data(config['site_name'], addr, args, logger) logger.debug('Backend data for site {} addr {}: {}'.format(config['site_name'], addr, data)) if data is None: logger.debug('Not adding backend {} - probably not registered'.format(addr)) continue params.update(data) servers += [params] res += [{'name': be_name, 'servers': servers, }] return res def _load_backend_data(site_name, addr, args, logger): """ Load data about a registered backend from a file. Return data matching what the haproxy.j2 template wants for 'servers': {'name': 'server1.example.org', 'ip': '192.0.2.1', 'port': '443', 'address_family': 'v4' } """ match = os.path.join(args.backends_dir, site_name, '*_{}.conf'.format(addr)) fns = glob.glob(match) if not fns: logger.debug('Found no file(s) matching {!r}'.format(match)) return None for this in fns: # Check file age before using this file if _get_fileage(this) > args.max_age: logger.info('Ignoring file {} that is older than max_age ({})'.format(this, args.max_age)) continue logger.debug('Found backend file {}'.format(this)) params = _load_backend_file(this) if params.pop('action') == 'register': params['address_family'] = 'v4' if params.get('ip') else None if ':' in params.get('ip', ''): params['address_family'] = 'v6' return params def _get_fileage(filename): st = os.stat(filename) mtime = st.st_mtime real_now = int(time.time()) return int(real_now - mtime) def _load_backend_file(filename): """ Load a backend registration file, created by the sunet-frontend-api. Example file: ACTION=register BACKEND=www.dev.eduid.se SERVER=www-fre-1.eduid.se REMOTE_IP=130.242.130.200 PORT=443 :returns: dict with normalized keys and their values :rtype: dict """ res = {} fd = open(filename) for line in fd.readlines(): while line.endswith('\n'): line = line[:-1] (head, sep, tail) = line.partition('=') if head and tail: key = head.lower() if key == 'remote_ip': key = 'ip' elif key == 'backend': key = 'name' res[key] = tail return res if __name__ == '__main__': try: progname = os.path.basename(sys.argv[0]) res = main(progname) if res is True: sys.exit(0) if res is False: sys.exit(1) sys.exit(int(res)) except KeyboardInterrupt: sys.exit(0)