3 '''Autocluster: Generate test clusters for clustered Samba
5 Reads configuration file in YAML format
7 Uses Vagrant to create cluster, Ansible to configure
10 # This program is free software; you can redistribute it and/or modify
11 # it under the terms of the GNU General Public License as published by
12 # the Free Software Foundation; either version 3 of the License, or
13 # (at your option) any later version.
15 # This program is distributed in the hope that it will be useful,
16 # but WITHOUT ANY WARRANTY; without even the implied warranty of
17 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18 # GNU General Public License for more details.
20 # You should have received a copy of the GNU General Public License
21 # along with this program; if not, see <http://www.gnu.org/licenses/>.
23 from __future__ import print_function
38 except ImportError as err:
39 LIBVIRT_IMPORT_ERROR = err
44 NODE_TYPES = ['nas', 'base', 'build', 'cbuild', 'ad', 'test']
45 GENERATED_KEYS = ['cluster', 'nodes', 'shares']
49 '''Print usage message'''
52 '''Usage: %s <group> <args>
55 cluster <cluster> <command> ...
58 defaults Dump default configuration to stdout
59 dump Dump cluster configuration to stdout
60 status Show cluster status
61 generate Generate cluster metadata for Vagrant, Ansible and SSH
62 destroy Destroy cluster
64 ssh_config Install cluster SSH configuration in current account
65 setup Perform configuration/setup of cluster nodes
66 build Short for: destroy generate create ssh_config setup
72 def sanity_check_cluster_name(cluster):
73 '''Ensure that the cluster name is sane'''
75 if not re.match('^[A-Za-z][A-Za-z0-9]+$', cluster):
76 sys.exit('''ERROR: Invalid cluster name "%s"
77 Some cluster filesystems only allow cluster names matching
78 ^[A-Za-z][A-Za-z0-9]+$''' % cluster)
81 def calculate_nodes(cluster, defaults, config):
82 '''Calculate hostname, IP and other attributes for each node'''
84 combined = dict(defaults)
85 combined.update(config)
87 if 'node_list' not in config:
88 sys.exit('Error: node_list not defined')
90 have_dedicated_storage_nodes = False
91 for node_type in combined['node_list']:
93 if node_type not in NODE_TYPES:
94 sys.exit('ERROR: Invalid node type %s in node_list' % node_type)
97 have_dedicated_storage_nodes = True
101 for idx, node_type in enumerate(combined['node_list']):
104 node['type'] = node_type
106 # Construct hostname, whether node is CTDB node
107 if node_type == 'nas':
109 node['is_ctdb_node'] = True
112 node['is_ctdb_node'] = False
114 type_counts[node_type] = type_counts.get(node_type, 0) + 1
115 hostname = '%s%s%d' % (cluster, tag, type_counts[node_type])
117 # Does the node have shared storage?
118 if node_type == 'storage':
119 node['has_shared_storage'] = True
120 elif node_type == 'nas' and not have_dedicated_storage_nodes:
121 node['has_shared_storage'] = True
123 node['has_shared_storage'] = False
125 # List of IP addresses, one for each network
127 for net in combined['networks']:
128 offset = config['firstip'] + idx
129 if sys.version_info[0] < 3:
130 # Backported Python 2 ipaddress demands unicode instead of str
131 net = net.decode('utf-8')
132 ip_address = ipaddress.ip_network(net, strict=False)
133 node['ips'].append(str(ip_address[offset]))
135 nodes[hostname] = node
137 config['nodes'] = nodes
140 def calculate_dependencies_ad(config):
141 '''Calculate nameserver and auth method based on the first AD node'''
143 for _, node in config['nodes'].items():
144 if node['type'] == 'ad':
145 nameserver = node['ips'][0]
146 if 'resolv_conf' not in config:
147 config['resolv_conf'] = {}
148 if 'nameserver' not in config['resolv_conf']:
149 config['resolv_conf']['nameserver'] = nameserver
151 if 'auth_method' not in config:
152 config['auth_method'] = 'winbind'
157 def calculate_dependencies_virthost(defaults, config):
158 '''Handle special values that depend on virthost'''
160 if 'virthost' in config:
161 virthost = config['virthost']
163 virthost = defaults['virthost']
165 if 'resolv_conf' not in config:
166 config['resolv_conf'] = {}
167 if 'nameserver' not in config['resolv_conf']:
168 config['resolv_conf']['nameserver'] = virthost
170 if 'repository_baseurl' not in config:
171 config['repository_baseurl'] = 'http://%s/mediasets' % virthost
173 if 'ad' not in config:
175 if 'dns_forwarder' not in config['ad']:
176 config['ad']['dns_forwarder'] = virthost
179 def calculate_dependencies(cluster, defaults, config):
180 '''Handle special values that depend on updated config values'''
182 config['cluster'] = cluster
184 calculate_dependencies_ad(config)
185 calculate_dependencies_virthost(defaults, config)
188 if 'resolv_conf' in config and \
189 'domain' in config['resolv_conf'] and \
190 'search' not in config['resolv_conf']:
192 config['resolv_conf']['search'] = config['resolv_conf']['domain']
194 # Presence of distro repositories means delete existing ones
195 if 'repositories' in config and \
196 'repositories_delete_existing' not in config:
197 for repo in config['repositories']:
198 if repo['type'] == 'distro':
199 config['repositories_delete_existing'] = True
203 def calculate_kdc(config):
204 '''Calculate KDC setting if unset and there is an AD node'''
206 if 'kdc' not in config:
207 for hostname, node in config['nodes'].items():
208 if node['type'] == 'ad':
209 config['kdc'] = hostname
213 def calculate_timezone(config):
214 '''Calculate timezone setting if unset'''
216 if 'timezone' not in config:
217 timezone_file = os.environ.get('AUTOCLUSTER_TEST_TIMEZONE_FILE',
220 with open(timezone_file) as stream:
221 content = stream.readlines()
222 timezone = content[0]
223 config['timezone'] = timezone.strip()
224 except IOError as err:
225 if err.errno != errno.ENOENT:
228 if 'timezone' not in config:
229 clock_file = os.environ.get('AUTOCLUSTER_TEST_CLOCK_FILE',
230 '/etc/sysconfig/clock')
232 with open(clock_file) as stream:
233 zone_re = re.compile('^ZONE="([^"]+)".*')
234 lines = stream.readlines()
235 matches = [l for l in lines if zone_re.match(l)]
237 timezone = zone_re.match(matches[0]).group(1)
238 config['timezone'] = timezone.strip()
239 except IOError as err:
240 if err.errno != errno.ENOENT:
244 def calculate_shares(defaults, config):
245 '''Calculate share definitions based on cluster filesystem mountpoint'''
247 if 'clusterfs' in config and 'mountpoint' in config['clusterfs']:
248 mountpoint = config['clusterfs']['mountpoint']
250 mountpoint = defaults['clusterfs']['mountpoint']
251 directory = os.path.join(mountpoint, 'data')
252 share = {'name': 'data', 'directory': directory, 'mode': '0o777'}
254 config['shares'] = [share]
258 '''Load default configuration'''
260 # Any failures here are internal errors, so allow default
263 defaults_file = os.path.join(INSTALL_DIR, 'defaults.yml')
265 with open(defaults_file, 'r') as stream:
266 defaults = yaml.safe_load(stream)
271 def nested_update(dst, src, context=None):
272 '''Update dictionary dst from dictionary src. Sanity check that all
273 keys in src are defined in dst, except those in GENERATED_KEYS. This
274 means that defaults.yml acts as a template for configuration options.'''
276 for key, val in src.items():
280 ctx = '%s.%s' % (context, key)
282 if key not in dst and key not in GENERATED_KEYS:
283 sys.exit('ERROR: Invalid configuration key "%s"' % ctx)
285 if isinstance(val, dict) and key in dst:
286 nested_update(dst[key], val, ctx)
291 def load_config_with_includes(config_file):
292 '''Load a config file, recursively respecting "include" options'''
294 if not os.path.exists(config_file):
295 sys.exit('ERROR: Configuration file %s not found' % config_file)
297 with open(config_file, 'r') as stream:
299 config = yaml.safe_load(stream)
300 except yaml.YAMLError as exc:
301 sys.exit('Error parsing config file %s, %s' % (config_file, exc))
306 # Handle include item, either a single string or a list
307 if 'include' not in config:
309 includes = config['include']
310 config.pop('include', None)
311 if isinstance(includes, str):
312 includes = [includes]
313 if not isinstance(includes, list):
314 print('warning: Ignoring non-string/list include', file=sys.stderr)
316 for include in includes:
317 if not isinstance(include, str):
318 print('warning: Ignoring non-string include', file=sys.stderr)
321 included_config = load_config_with_includes(include)
322 config.update(included_config)
327 def load_config(cluster):
328 '''Load default and user configuration; combine them'''
330 defaults = load_defaults()
332 config_file = '%s.yml' % cluster
334 config = load_config_with_includes(config_file)
336 calculate_nodes(cluster, defaults, config)
337 calculate_dependencies(cluster, defaults, config)
338 calculate_timezone(config)
339 calculate_kdc(config)
340 calculate_shares(defaults, config)
343 nested_update(out, config)
348 def generate_config_yml(cluster, config):
349 '''Output combined YAML configuration to "config.yml"'''
351 outfile = get_config_file_path(cluster)
353 with open(outfile, 'w') as stream:
354 out = yaml.dump(config, default_flow_style=False)
356 print('---', file=stream)
357 print(out, file=stream)
360 def generate_hosts(cluster, config, outdir):
361 '''Output hosts file snippet to "hosts"'''
363 outfile = os.path.join(outdir, 'hosts')
365 with open(outfile, 'w') as stream:
366 print("# autocluster %s" % cluster, file=stream)
368 domain = config['resolv_conf']['domain']
370 for hostname, node in config['nodes'].items():
371 ip_address = node['ips'][0]
372 line = "%s\t%s.%s %s" % (ip_address, hostname, domain, hostname)
374 print(line, file=stream)
377 def generate_ssh_config(config, outdir):
378 '''Output ssh_config file snippet to "ssh_config"'''
380 outfile = os.path.join(outdir, 'ssh_config')
382 with open(outfile, 'w') as stream:
383 for hostname, node in config['nodes'].items():
384 ip_address = node['ips'][0]
385 ssh_key = os.path.join(os.environ['HOME'], '.ssh/id_autocluster')
390 UserKnownHostsFile /dev/null
391 StrictHostKeyChecking no
392 PasswordAuthentication no
396 ''' % (hostname, ip_address, ssh_key)
398 print(section, file=stream)
401 def generate_ansible_inventory(config, outdir):
402 '''Output Ansible inventory file to "ansible.inventory"'''
406 for hostname, node in config['nodes'].items():
408 node_type = node['type']
409 hostnames = type_map.get(node['type'], [])
410 hostnames.append(hostname)
411 type_map[node['type']] = hostnames
413 outfile = os.path.join(outdir, 'ansible.inventory')
415 with open(outfile, 'w') as stream:
416 for node_type, hostnames in type_map.items():
417 print('[%s_nodes]' % node_type, file=stream)
419 for hostname in hostnames:
420 print(hostname, file=stream)
424 def cluster_defaults():
425 '''Dump default YAML configuration to stdout'''
427 defaults = load_defaults()
428 out = yaml.dump(defaults, default_flow_style=False)
433 def cluster_dump(cluster):
434 '''Dump cluster YAML configuration to stdout'''
436 config = load_config(cluster)
438 # Remove some generated, internal values that aren't in an input
440 for key in ['nodes', 'shares']:
441 config.pop(key, None)
443 out = yaml.dump(config, default_flow_style=False)
448 def get_state_dir(cluster):
449 '''Return the state directory for the current cluster'''
451 return os.path.join(os.getcwd(), '.autocluster', cluster)
454 def get_config_file_path(cluster):
455 '''Return the name of the generated config file for cluster'''
457 return os.path.join(get_state_dir(cluster), 'config.yml')
460 def announce(group, cluster, command):
461 '''Print a banner announcing the current step'''
463 hashes = '############################################################'
464 heading = '%s %s %s' % (group, cluster, command)
465 banner = "%s\n# %-56s #\n%s" % (hashes, heading, hashes)
470 def cluster_generate(cluster):
471 '''Generate metadata files from configuration'''
473 announce('cluster', cluster, 'generate')
475 config = load_config(cluster)
477 outdir = get_state_dir(cluster)
480 except OSError as err:
481 if err.errno != errno.EEXIST:
484 generate_config_yml(cluster, config)
485 generate_hosts(cluster, config, outdir)
486 generate_ssh_config(config, outdir)
487 generate_ansible_inventory(config, outdir)
490 def vagrant_command(cluster, config, args):
491 '''Run vagrant with the given arguments'''
493 state_dir = get_state_dir(cluster)
495 os.environ['VAGRANT_DEFAULT_PROVIDER'] = config['vagrant_provider']
496 os.environ['VAGRANT_CWD'] = os.path.join(INSTALL_DIR, 'vagrant')
497 os.environ['VAGRANT_DOTFILE_PATH'] = os.path.join(state_dir, '.vagrant')
498 os.environ['AUTOCLUSTER_STATE'] = state_dir
500 full_args = args[:] # copy
501 full_args.insert(0, 'vagrant')
503 subprocess.check_call(full_args)
506 def cluster_status(cluster):
507 '''Check status of cluster using Vagrant'''
509 announce('cluster', cluster, 'status')
511 config = load_config(cluster)
513 vagrant_command(cluster, config, ['status'])
516 def get_shared_disk_names(cluster, config):
517 '''Return shared disks names for cluster, None if none'''
519 have_shared_disks = False
520 for _, node in config['nodes'].items():
521 if node['has_shared_storage']:
522 have_shared_disks = True
524 if not have_shared_disks:
527 count = config['shared_disks']['count']
531 return ['autocluster_%s_shared%02d.img' % (cluster, n + 1)
532 for n in range(count)]
535 def delete_shared_disk_images(cluster, config):
536 '''Delete any shared disks for the given cluster'''
538 if config['vagrant_provider'] != 'libvirt':
541 shared_disks = get_shared_disk_names(cluster, config)
542 if shared_disks is None:
546 print('warning: unable to check for stale shared disks (no libvirt)',
550 conn = libvirt.open()
551 storage_pool = conn.storagePoolLookupByName('autocluster')
552 for disk in shared_disks:
554 volume = storage_pool.storageVolLookupByName(disk)
556 except libvirt.libvirtError as err:
557 if err.get_error_code() != libvirt.VIR_ERR_NO_STORAGE_VOL:
562 def create_shared_disk_images(cluster, config):
563 '''Create shared disks for the given cluster'''
565 if config['vagrant_provider'] != 'libvirt':
568 shared_disks = get_shared_disk_names(cluster, config)
569 if shared_disks is None:
573 raise LIBVIRT_IMPORT_ERROR
575 conn = libvirt.open()
576 storage_pool = conn.storagePoolLookupByName('autocluster')
578 size = str(config['shared_disks']['size'])
579 if size[-1].isdigit():
586 for disk in shared_disks:
587 xml = '''<volume type='file'>
589 <capacity unit="%s">%s</capacity>
590 </volume>''' % (disk, unit, capacity)
591 storage_pool.createXML(xml)
596 def cluster_destroy_quiet(cluster):
597 '''Destroy and undefine cluster using Vagrant - don't announce'''
599 config = load_config(cluster)
601 # First attempt often fails, so try a few times
604 vagrant_command(cluster,
606 ['destroy', '-f', '--no-parallel'])
607 except subprocess.CalledProcessError as err:
610 delete_shared_disk_images(cluster, config)
616 def cluster_destroy(cluster):
617 '''Destroy and undefine cluster using Vagrant'''
619 announce('cluster', cluster, 'destroy')
621 config_file = get_config_file_path(cluster)
622 if not os.path.exists(config_file):
623 sys.exit('ERROR: Generated configuration for cluster does not exist')
625 cluster_destroy_quiet(cluster)
628 def cluster_create(cluster):
629 '''Create and boot cluster using Vagrant'''
631 announce('cluster', cluster, 'create')
633 config = load_config(cluster)
635 # Create our own shared disk images to protect against
636 # https://github.com/vagrant-libvirt/vagrant-libvirt/issues/825
637 create_shared_disk_images(cluster, config)
639 # First attempt sometimes fails, so try a few times
642 vagrant_command(cluster, config, ['up'])
643 except subprocess.CalledProcessError as err:
645 cluster_destroy(cluster)
652 def cluster_ssh_config(cluster):
653 '''Install SSH configuration for cluster'''
655 announce('cluster', cluster, 'ssh_config')
657 src = os.path.join(get_state_dir(cluster), 'ssh_config')
658 dst = os.path.join(os.environ['HOME'],
659 '.ssh/autocluster.d',
660 '%s.config' % cluster)
661 shutil.copyfile(src, dst)
664 def cluster_setup(cluster):
665 '''Setup cluster using Ansible'''
667 announce('cluster', cluster, 'setup')
669 # Could put these in the state directory, but disable for now
670 os.environ['ANSIBLE_RETRY_FILES_ENABLED'] = 'false'
672 state_dir = get_state_dir(cluster)
673 config_file = get_config_file_path(cluster)
674 inventory = os.path.join(state_dir, 'ansible.inventory')
675 playbook = os.path.join(INSTALL_DIR, 'ansible/node/site.yml')
676 args = ['ansible-playbook',
677 '-e', '@%s' % config_file,
681 # First attempt sometimes fails, so try a few times
684 subprocess.check_call(args)
685 except subprocess.CalledProcessError as err:
686 print('warning: cluster setup exited with %d, retrying' %
694 sys.exit('ERROR: cluster setup exited with %d' % saved_err.returncode)
697 def cluster_build(cluster):
698 '''Build cluster using Ansible'''
700 config_file = get_config_file_path(cluster)
701 if os.path.exists(config_file):
702 cluster_destroy(cluster)
703 cluster_generate(cluster)
704 cluster_create(cluster)
705 cluster_ssh_config(cluster)
706 cluster_setup(cluster)
709 def cluster_command(cluster, command):
710 '''Run appropriate cluster command function'''
712 if command == 'defaults':
714 elif command == 'dump':
715 cluster_dump(cluster)
716 elif command == 'status':
717 cluster_status(cluster)
718 elif command == 'generate':
719 cluster_generate(cluster)
720 elif command == 'destroy':
721 cluster_destroy(cluster)
722 elif command == 'create':
723 cluster_create(cluster)
724 elif command == 'ssh_config':
725 cluster_ssh_config(cluster)
726 elif command == 'setup':
727 cluster_setup(cluster)
728 elif command == 'build':
729 cluster_build(cluster)
734 def get_host_setup_path(file):
735 '''Return the path for host setup file'''
737 return os.path.join(INSTALL_DIR, 'ansible/host', file)
740 def get_platform_file(platform):
741 '''Return the name of the host setup file for platform'''
743 return get_host_setup_path('autocluster_setup_%s.yml' % platform)
746 def sanity_check_platform_name(platform):
747 '''Ensure that host platform is supported'''
749 platform_file = get_platform_file(platform)
751 if not os.access(platform_file, os.R_OK):
752 sys.exit('Host platform "%s" not supported' % platform)
755 def host_setup(platform):
756 '''Set up host machine for use with Autocluster'''
758 announce('host', platform, 'setup')
760 platform_file = get_platform_file(platform)
761 ssh_file = get_host_setup_path('autocluster_setup_%s.yml' % 'ssh')
762 os.environ['ANSIBLE_RETRY_FILES_ENABLED'] = 'false'
763 args = ['ansible-playbook', platform_file, ssh_file]
766 subprocess.check_call(args)
767 except subprocess.CalledProcessError as err:
768 sys.exit('ERROR: host setup exited with %d' % err.returncode)
772 '''Main autocluster command-line handling'''
774 if len(sys.argv) < 2:
777 if sys.argv[1] == 'cluster':
778 if len(sys.argv) < 4:
781 cluster = sys.argv[2]
783 sanity_check_cluster_name(cluster)
785 for command in sys.argv[3:]:
786 cluster_command(cluster, command)
788 elif sys.argv[1] == 'host':
789 if len(sys.argv) < 4:
792 platform = sys.argv[2]
794 sanity_check_platform_name(platform)
796 for command in sys.argv[3:]:
797 if command == 'setup':
804 if __name__ == '__main__':