423 lines
14 KiB
Python
Executable file
423 lines
14 KiB
Python
Executable file
#!/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 <fredrik@thulin.net>
|
|
#
|
|
|
|
"""
|
|
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)
|