eid-ops/fe-common/overlay/opt/frontend/OLD.scripts/frontend-config
2019-06-05 11:44:27 +02:00

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)