#!/usr/bin/env python
'''Autocluster: Generate test clusters for clustered Samba
Reads configuration file in YAML format
Uses Vagrant to create cluster, Ansible to configure
'''
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see .
from __future__ import print_function
import os
import errno
import sys
import re
import subprocess
import shutil
import ipaddress
import yaml
try:
import libvirt
except ImportError as err:
LIBVIRT_IMPORT_ERROR = err
libvirt = None
INSTALL_DIR = '.'
NODE_TYPES = ['nas', 'base', 'build', 'cbuild', 'ad', 'test']
GENERATED_KEYS = ['cluster', 'nodes', 'shares']
def usage():
'''Print usage message'''
sys.exit(
'''Usage: %s
Groups:
cluster ...
Commands:
defaults Dump default configuration to stdout
dump Dump cluster configuration to stdout
status Show cluster status
generate Generate cluster metadata for Vagrant, Ansible and SSH
destroy Destroy cluster
create Create cluster
ssh_config Install cluster SSH configuration in current account
setup Perform configuration/setup of cluster nodes
build Short for: destroy, generate create ssh_config setup
host setup
''' % sys.argv[0])
def sanity_check_cluster_name(cluster):
'''Ensure that the cluster name is sane'''
if not re.match('^[A-Za-z][A-Za-z0-9]+$', cluster):
sys.exit('''ERROR: Invalid cluster name "%s"
Some cluster filesystems only allow cluster names matching
^[A-Za-z][A-Za-z0-9]+$''' % cluster)
def calculate_nodes(cluster, defaults, config):
'''Calculate hostname, IP and other attributes for each node'''
combined = dict(defaults)
combined.update(config)
if 'node_list' not in config:
sys.exit('Error: node_list not defined')
have_dedicated_storage_nodes = False
for node_type in combined['node_list']:
if node_type not in NODE_TYPES:
sys.exit('ERROR: Invalid node type %s in node_list' % node_type)
if type == 'storage':
have_dedicated_storage_nodes = True
nodes = {}
type_counts = {}
for idx, node_type in enumerate(combined['node_list']):
node = {}
node['type'] = node_type
# Construct hostname, whether node is CTDB node
if node_type == 'nas':
tag = 'n'
node['is_ctdb_node'] = True
else:
tag = node_type
node['is_ctdb_node'] = False
type_counts[node_type] = type_counts.get(node_type, 0) + 1
hostname = '%s%s%d' % (cluster, tag, type_counts[node_type])
# Does the node have shared storage?
if node_type == 'storage':
node['has_shared_storage'] = True
elif node_type == 'nas' and not have_dedicated_storage_nodes:
node['has_shared_storage'] = True
else:
node['has_shared_storage'] = False
# List of IP addresses, one for each network
node['ips'] = []
for net in combined['networks']:
offset = config['firstip'] + idx
if sys.version_info[0] < 3:
# Backported Python 2 ipaddress demands unicode instead of str
net = net.decode('utf-8')
ip_address = ipaddress.ip_network(net, strict=False)
node['ips'].append(str(ip_address[offset]))
nodes[hostname] = node
config['nodes'] = nodes
def calculate_dependencies_ad(config):
'''Calculate nameserver and auth method based on the first AD node'''
for _, node in config['nodes'].items():
if node['type'] == 'ad':
nameserver = node['ips'][0]
if 'resolv_conf' not in config:
config['resolv_conf'] = {}
if 'nameserver' not in config['resolv_conf']:
config['resolv_conf']['nameserver'] = nameserver
if 'auth_method' not in config:
config['auth_method'] = 'winbind'
break
def calculate_dependencies_virthost(defaults, config):
'''Handle special values that depend on virthost'''
if 'virthost' in config:
virthost = config['virthost']
else:
virthost = defaults['virthost']
if 'resolv_conf' not in config:
config['resolv_conf'] = {}
if 'nameserver' not in config['resolv_conf']:
config['resolv_conf']['nameserver'] = virthost
if 'repository_baseurl' not in config:
config['repository_baseurl'] = 'http://%s/mediasets' % virthost
if 'ad' not in config:
config['ad'] = {}
if 'dns_forwarder' not in config['ad']:
config['ad']['dns_forwarder'] = virthost
def calculate_dependencies(cluster, defaults, config):
'''Handle special values that depend on updated config values'''
config['cluster'] = cluster
calculate_dependencies_ad(config)
calculate_dependencies_virthost(defaults, config)
# domain -> search
if 'resolv_conf' in config and \
'domain' in config['resolv_conf'] and \
'search' not in config['resolv_conf']:
config['resolv_conf']['search'] = config['resolv_conf']['domain']
# Presence of distro repositories means delete existing ones
if 'repositories_delete_existing' not in config:
for repo in config['repositories']:
if repo['type'] == 'distro':
config['repositories_delete_existing'] = True
break
def calculate_kdc(config):
'''Calculate KDC setting if unset and there is an AD node'''
if 'kdc' not in config:
for hostname, node in config['nodes'].items():
if node['type'] == 'ad':
config['kdc'] = hostname
break
def calculate_timezone(config):
'''Calculate timezone setting if unset'''
if 'timezone' not in config:
timezone_file = os.environ.get('AUTOCLUSTER_TEST_TIMEZONE_FILE',
'/etc/timezone')
try:
with open(timezone_file) as stream:
content = stream.readlines()
timezone = content[0]
config['timezone'] = timezone.strip()
except IOError as err:
if err.errno != errno.ENOENT:
raise
if 'timezone' not in config:
clock_file = os.environ.get('AUTOCLUSTER_TEST_CLOCK_FILE',
'/etc/sysconfig/clock')
try:
with open(clock_file) as stream:
zone_re = re.compile('^ZONE="([^"]+)".*')
lines = stream.readlines()
matches = [l for l in lines if zone_re.match(l)]
if matches:
timezone = zone_re.match(matches[0]).group(1)
config['timezone'] = timezone.strip()
except IOError as err:
if err.errno != errno.ENOENT:
raise
def calculate_shares(defaults, config):
'''Calculate share definitions based on cluster filesystem mountpoint'''
if 'clusterfs' in config and 'mountpoint' in config['clusterfs']:
mountpoint = config['clusterfs']['mountpoint']
else:
mountpoint = defaults['clusterfs']['mountpoint']
directory = os.path.join(mountpoint, 'data')
share = {'name': 'data', 'directory': directory, 'mode': '0o777'}
config['shares'] = [share]
def load_defaults():
'''Load default configuration'''
# Any failures here are internal errors, so allow default
# exceptions
defaults_file = os.path.join(INSTALL_DIR, 'defaults.yml')
with open(defaults_file, 'r') as stream:
defaults = yaml.safe_load(stream)
return defaults
def nested_update(dst, src, context=None):
'''Update dictionary dst from dictionary src. Sanity check that all
keys in src are defined in dst, except those in GENERATED_KEYS. This
means that defaults.yml acts as a template for configuration options.'''
for key, val in src.items():
if context is None:
ctx = key
else:
ctx = '%s.%s' % (context, key)
if key not in dst and key not in GENERATED_KEYS:
sys.exit('ERROR: Invalid configuration key "%s"' % ctx)
if isinstance(val, dict) and key in dst:
nested_update(dst[key], val, ctx)
else:
dst[key] = val
def load_config_with_includes(config_file):
'''Load a config file, recursively respecting "include" options'''
if not os.path.exists(config_file):
sys.exit('ERROR: Configuration file %s not found' % config_file)
with open(config_file, 'r') as stream:
try:
config = yaml.safe_load(stream)
except yaml.YAMLError as exc:
sys.exit('Error parsing config file %s, %s' % (config_file, exc))
if config is None:
config = {}
# Handle include item, either a single string or a list
if 'include' not in config:
return config
includes = config['include']
config.pop('include', None)
if isinstance(includes, str):
includes = [includes]
if not isinstance(includes, list):
print('warning: Ignoring non-string/list include', file=sys.stderr)
return config
for include in includes:
if not isinstance(include, str):
print('warning: Ignoring non-string include', file=sys.stderr)
continue
included_config = load_config_with_includes(include)
config.update(included_config)
return config
def load_config(cluster):
'''Load default and user configuration; combine them'''
defaults = load_defaults()
config_file = '%s.yml' % cluster
config = load_config_with_includes(config_file)
calculate_nodes(cluster, defaults, config)
calculate_dependencies(cluster, defaults, config)
calculate_timezone(config)
calculate_kdc(config)
calculate_shares(defaults, config)
out = dict(defaults)
nested_update(out, config)
return out
def generate_config_yml(config, outdir):
'''Output combined YAML configuration to "config.yml"'''
outfile = os.path.join(outdir, 'config.yml')
with open(outfile, 'w') as stream:
out = yaml.dump(config, default_flow_style=False)
print('---', file=stream)
print(out, file=stream)
def generate_hosts(cluster, config, outdir):
'''Output hosts file snippet to "hosts"'''
outfile = os.path.join(outdir, 'hosts')
with open(outfile, 'w') as stream:
print("# autocluster %s" % cluster, file=stream)
domain = config['resolv_conf']['domain']
for hostname, node in config['nodes'].items():
ip_address = node['ips'][0]
line = "%s\t%s.%s %s" % (ip_address, hostname, domain, hostname)
print(line, file=stream)
def generate_ssh_config(config, outdir):
'''Output ssh_config file snippet to "ssh_config"'''
outfile = os.path.join(outdir, 'ssh_config')
with open(outfile, 'w') as stream:
for hostname, node in config['nodes'].items():
ip_address = node['ips'][0]
ssh_key = os.path.join(os.environ['HOME'], '.ssh/id_autocluster')
section = '''Host %s
HostName %s
User root
Port 22
UserKnownHostsFile /dev/null
StrictHostKeyChecking no
PasswordAuthentication no
IdentityFile %s
IdentitiesOnly yes
LogLevel FATAL
''' % (hostname, ip_address, ssh_key)
print(section, file=stream)
def generate_ansible_inventory(config, outdir):
'''Output Ansible inventory file to "ansible.inventory"'''
type_map = {}
for hostname, node in config['nodes'].items():
node_type = node['type']
hostnames = type_map.get(node['type'], [])
hostnames.append(hostname)
type_map[node['type']] = hostnames
outfile = os.path.join(outdir, 'ansible.inventory')
with open(outfile, 'w') as stream:
for node_type, hostnames in type_map.items():
print('[%s-nodes]' % node_type, file=stream)
hostnames.sort()
for hostname in hostnames:
print(hostname, file=stream)
print(file=stream)
def cluster_defaults():
'''Dump default YAML configuration to stdout'''
defaults = load_defaults()
out = yaml.dump(defaults, default_flow_style=False)
print('---')
print(out)
def cluster_dump(cluster):
'''Dump cluster YAML configuration to stdout'''
config = load_config(cluster)
# Remove some generated, internal values that aren't in an input
# configuration
for key in ['nodes', 'shares']:
config.pop(key, None)
out = yaml.dump(config, default_flow_style=False)
print('---')
print(out)
def get_state_dir(cluster):
'''Return the state directory for the current cluster'''
return os.path.join(os.getcwd(), '.autocluster', cluster)
def announce(group, cluster, command):
'''Print a banner announcing the current step'''
hashes = '############################################################'
heading = '%s %s %s' % (group, cluster, command)
banner = "%s\n# %-56s #\n%s" % (hashes, heading, hashes)
print(banner)
def cluster_generate(cluster):
'''Generate metadata files from configuration'''
announce('cluster', cluster, 'generate')
config = load_config(cluster)
outdir = get_state_dir(cluster)
try:
os.makedirs(outdir)
except OSError as err:
if err.errno != errno.EEXIST:
raise
generate_config_yml(config, outdir)
generate_hosts(cluster, config, outdir)
generate_ssh_config(config, outdir)
generate_ansible_inventory(config, outdir)
def vagrant_command(cluster, config, args):
'''Run vagrant with the given arguments'''
state_dir = get_state_dir(cluster)
os.environ['VAGRANT_DEFAULT_PROVIDER'] = config['vagrant_provider']
os.environ['VAGRANT_CWD'] = os.path.join(INSTALL_DIR, 'vagrant')
os.environ['VAGRANT_DOTFILE_PATH'] = os.path.join(state_dir, '.vagrant')
os.environ['AUTOCLUSTER_STATE'] = state_dir
full_args = args[:] # copy
full_args.insert(0, 'vagrant')
subprocess.check_call(full_args)
def cluster_status(cluster):
'''Check status of cluster using Vagrant'''
announce('cluster', cluster, 'status')
config = load_config(cluster)
vagrant_command(cluster, config, ['status'])
def get_shared_disk_names(cluster, config):
'''Return shared disks names for cluster, None if none'''
have_shared_disks = False
for _, node in config['nodes'].items():
if node['has_shared_storage']:
have_shared_disks = True
break
if not have_shared_disks:
return None
count = config['shared_disks']['count']
if count == 0:
return None
return ['autocluster_%s_shared%02d.img' % (cluster, n + 1)
for n in range(count)]
def delete_shared_disk_images(cluster, config):
'''Delete any shared disks for the given cluster'''
if config['vagrant_provider'] != 'libvirt':
return
shared_disks = get_shared_disk_names(cluster, config)
if shared_disks is None:
return
if libvirt is None:
print('warning: unable to check for stale shared disks (no libvirt)',
file=sys.stderr)
return
conn = libvirt.open()
storage_pool = conn.storagePoolLookupByName('autocluster')
for disk in shared_disks:
try:
volume = storage_pool.storageVolLookupByName(disk)
volume.delete()
except libvirt.libvirtError as err:
if err.get_error_code() != libvirt.VIR_ERR_NO_STORAGE_VOL:
raise err
conn.close()
def create_shared_disk_images(cluster, config):
'''Create shared disks for the given cluster'''
if config['vagrant_provider'] != 'libvirt':
return
shared_disks = get_shared_disk_names(cluster, config)
if shared_disks is None:
return
if libvirt is None:
raise LIBVIRT_IMPORT_ERROR
conn = libvirt.open()
storage_pool = conn.storagePoolLookupByName('autocluster')
size = str(config['shared_disks']['size'])
if size[-1].isdigit():
unit = 'B'
capacity = size
else:
unit = size[-1]
capacity = size[:-1]
for disk in shared_disks:
xml = '''
%s
%s
''' % (disk, unit, capacity)
storage_pool.createXML(xml)
conn.close()
def cluster_destroy_quiet(cluster):
'''Destroy and undefine cluster using Vagrant - don't announce'''
config = load_config(cluster)
# First attempt often fails, so try a few times
for _ in range(10):
try:
vagrant_command(cluster,
config,
['destroy', '-f', '--no-parallel'])
except subprocess.CalledProcessError as err:
saved_err = err
else:
delete_shared_disk_images(cluster, config)
return
raise saved_err
def cluster_destroy(cluster):
'''Destroy and undefine cluster using Vagrant'''
announce('cluster', cluster, 'destroy')
cluster_destroy_quiet(cluster)
def cluster_create(cluster):
'''Create and boot cluster using Vagrant'''
announce('cluster', cluster, 'create')
config = load_config(cluster)
# Create our own shared disk images to protect against
# https://github.com/vagrant-libvirt/vagrant-libvirt/issues/825
create_shared_disk_images(cluster, config)
# First attempt sometimes fails, so try a few times
for _ in range(10):
try:
vagrant_command(cluster, config, ['up'])
except subprocess.CalledProcessError as err:
saved_err = err
cluster_destroy(cluster)
else:
return
raise saved_err
def cluster_ssh_config(cluster):
'''Install SSH configuration for cluster'''
announce('cluster', cluster, 'ssh_config')
src = os.path.join(get_state_dir(cluster), 'ssh_config')
dst = os.path.join(os.environ['HOME'],
'.ssh/autocluster.d',
'%s.config' % cluster)
shutil.copyfile(src, dst)
def cluster_setup(cluster):
'''Setup cluster using Ansible'''
announce('cluster', cluster, 'setup')
# Could put these in the state directory, but disable for now
os.environ['ANSIBLE_RETRY_FILES_ENABLED'] = 'false'
state_dir = get_state_dir(cluster)
config_file = os.path.join(state_dir, 'config.yml')
inventory = os.path.join(state_dir, 'ansible.inventory')
playbook = os.path.join(INSTALL_DIR, 'ansible/node/site.yml')
args = ['ansible-playbook',
'-e', '@%s' % config_file,
'-i', inventory,
playbook]
try:
subprocess.check_call(args)
except subprocess.CalledProcessError as err:
sys.exit('ERROR: cluster setup exited with %d' % err.returncode)
def cluster_build(cluster):
'''Build cluster using Ansible'''
cluster_destroy(cluster)
cluster_generate(cluster)
cluster_create(cluster)
cluster_ssh_config(cluster)
cluster_setup(cluster)
def cluster_command(cluster, command):
'''Run appropriate cluster command function'''
if command == 'defaults':
cluster_defaults()
elif command == 'dump':
cluster_dump(cluster)
elif command == 'status':
cluster_status(cluster)
elif command == 'generate':
cluster_generate(cluster)
elif command == 'destroy':
cluster_destroy(cluster)
elif command == 'create':
cluster_create(cluster)
elif command == 'ssh_config':
cluster_ssh_config(cluster)
elif command == 'setup':
cluster_setup(cluster)
elif command == 'build':
cluster_build(cluster)
else:
usage()
def get_platform_file(platform):
'''Return the name of the host setup file for platform'''
return os.path.join(INSTALL_DIR,
'ansible/host',
'autocluster_setup_%s.yml' % platform)
def sanity_check_platform_name(platform):
'''Ensure that host platform is supported'''
platform_file = get_platform_file(platform)
if not os.access(platform_file, os.R_OK):
sys.exit('Host platform "%s" not supported' % platform)
def host_setup(platform):
'''Set up host machine for use with Autocluster'''
announce('host', platform, 'setup')
platform_file = get_platform_file(platform)
os.environ['ANSIBLE_RETRY_FILES_ENABLED'] = 'false'
args = ['ansible-playbook', platform_file]
try:
subprocess.check_call(args)
except subprocess.CalledProcessError as err:
sys.exit('ERROR: host setup exited with %d' % err.returncode)
def main():
'''Main autocluster command-line handling'''
if len(sys.argv) < 2:
usage()
if sys.argv[1] == 'cluster':
if len(sys.argv) < 4:
usage()
cluster = sys.argv[2]
sanity_check_cluster_name(cluster)
for command in sys.argv[3:]:
cluster_command(cluster, command)
elif sys.argv[1] == 'host':
if len(sys.argv) < 4:
usage()
platform = sys.argv[2]
sanity_check_platform_name(platform)
for command in sys.argv[3:]:
if command == 'setup':
host_setup(platform)
else:
usage()
if __name__ == '__main__':
sys.exit(main())