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
37 except ImportError as err:
38 LIBVIRT_IMPORT_ERROR = err
43 NODE_TYPES = ['nas', 'base', 'build', 'cbuild', 'ad', 'test']
44 GENERATED_KEYS = ['cluster', 'nodes', 'shares']
48 '''Print usage message'''
51 '''Usage: %s <group> <args>
54 cluster <cluster> <command> ...
57 defaults Dump default configuration to stdout
58 dump Dump cluster configuration to stdout
59 status Show cluster status
60 generate Generate cluster metadata for Vagrant, Ansible and SSH
61 destroy Destroy cluster
63 ssh_config Install cluster SSH configuration in current account
64 setup Perform configuration/setup of cluster nodes
65 build Short for: destroy, generate create ssh_config setup
71 def sanity_check_cluster_name(cluster):
72 '''Ensure that the cluster name is sane'''
74 if not re.match('^[A-Za-z][A-Za-z0-9]+$', cluster):
75 sys.exit('''ERROR: Invalid cluster name "%s"
76 Some cluster filesystems only allow cluster names matching
77 ^[A-Za-z][A-Za-z0-9]+$''' % cluster)
80 def calculate_nodes(cluster, defaults, config):
81 '''Calculate hostname, IP and other attributes for each node'''
83 combined = dict(defaults)
84 combined.update(config)
86 if 'node_list' not in config:
87 sys.exit('Error: node_list not defined')
89 have_dedicated_storage_nodes = False
90 for node_type in combined['node_list']:
92 if node_type not in NODE_TYPES:
93 sys.exit('ERROR: Invalid node type %s in node_list' % node_type)
96 have_dedicated_storage_nodes = True
100 for idx, node_type in enumerate(combined['node_list']):
103 node['type'] = node_type
105 # Construct hostname, whether node is CTDB node
106 if node_type == 'nas':
108 node['is_ctdb_node'] = True
111 node['is_ctdb_node'] = False
113 type_counts[node_type] = type_counts.get(node_type, 0) + 1
114 hostname = '%s%s%d' % (cluster, tag, type_counts[node_type])
116 # Does the node have shared storage?
117 if node_type == 'storage':
118 node['has_shared_storage'] = True
119 elif node_type == 'nas' and not have_dedicated_storage_nodes:
120 node['has_shared_storage'] = True
122 node['has_shared_storage'] = False
124 # List of IP addresses, one for each network
126 for net in combined['networks']:
127 offset = config['firstip'] + idx
128 if sys.version_info[0] < 3:
129 # Backported Python 2 ipaddress demands unicode instead of str
130 net = net.decode('utf-8')
131 ip_address = ipaddress.ip_network(net, strict=False)
132 node['ips'].append(str(ip_address[offset]))
134 nodes[hostname] = node
136 config['nodes'] = nodes
139 def calculate_dependencies_ad(config):
140 '''Calculate nameserver and auth method based on the first AD node'''
142 for _, node in config['nodes'].items():
143 if node['type'] == 'ad':
144 nameserver = node['ips'][0]
145 if 'resolv_conf' not in config:
146 config['resolv_conf'] = {}
147 if 'nameserver' not in config['resolv_conf']:
148 config['resolv_conf']['nameserver'] = nameserver
150 if 'auth_method' not in config:
151 config['auth_method'] = 'winbind'
156 def calculate_dependencies_virthost(defaults, config):
157 '''Handle special values that depend on virthost'''
159 if 'virthost' in config:
160 virthost = config['virthost']
162 virthost = defaults['virthost']
164 if 'resolv_conf' not in config:
165 config['resolv_conf'] = {}
166 if 'nameserver' not in config['resolv_conf']:
167 config['resolv_conf']['nameserver'] = virthost
169 if 'repository_baseurl' not in config:
170 config['repository_baseurl'] = 'http://%s/mediasets' % virthost
172 if 'ad' not in config:
174 if 'dns_forwarder' not in config['ad']:
175 config['ad']['dns_forwarder'] = virthost
178 def calculate_dependencies(cluster, defaults, config):
179 '''Handle special values that depend on updated config values'''
181 config['cluster'] = cluster
183 calculate_dependencies_ad(config)
184 calculate_dependencies_virthost(defaults, config)
187 if 'resolv_conf' in config and \
188 'domain' in config['resolv_conf'] and \
189 'search' not in config['resolv_conf']:
191 config['resolv_conf']['search'] = config['resolv_conf']['domain']
193 # Presence of distro repositories means delete existing ones
194 if 'repositories_delete_existing' not in config:
195 for repo in config['repositories']:
196 if repo['type'] == 'distro':
197 config['repositories_delete_existing'] = True
201 def calculate_kdc(config):
202 '''Calculate KDC setting if unset and there is an AD node'''
204 if 'kdc' not in config:
205 for hostname, node in config['nodes'].items():
206 if node['type'] == 'ad':
207 config['kdc'] = hostname
211 def calculate_timezone(config):
212 '''Calculate timezone setting if unset'''
214 if 'timezone' not in config:
215 timezone_file = os.environ.get('AUTOCLUSTER_TEST_TIMEZONE_FILE',
218 with open(timezone_file) as stream:
219 content = stream.readlines()
220 timezone = content[0]
221 config['timezone'] = timezone.strip()
222 except IOError as err:
223 if err.errno != errno.ENOENT:
226 if 'timezone' not in config:
227 clock_file = os.environ.get('AUTOCLUSTER_TEST_CLOCK_FILE',
228 '/etc/sysconfig/clock')
230 with open(clock_file) as stream:
231 zone_re = re.compile('^ZONE="([^"]+)".*')
232 lines = stream.readlines()
233 matches = [l for l in lines if zone_re.match(l)]
235 timezone = zone_re.match(matches[0]).group(1)
236 config['timezone'] = timezone.strip()
237 except IOError as err:
238 if err.errno != errno.ENOENT:
242 def calculate_shares(defaults, config):
243 '''Calculate share definitions based on cluster filesystem mountpoint'''
245 if 'clusterfs' in config and 'mountpoint' in config['clusterfs']:
246 mountpoint = config['clusterfs']['mountpoint']
248 mountpoint = defaults['clusterfs']['mountpoint']
249 directory = os.path.join(mountpoint, 'data')
250 share = {'name': 'data', 'directory': directory, 'mode': '0o777'}
252 config['shares'] = [share]
256 '''Load default configuration'''
258 # Any failures here are internal errors, so allow default
261 defaults_file = os.path.join(INSTALL_DIR, 'defaults.yml')
263 with open(defaults_file, 'r') as stream:
264 defaults = yaml.safe_load(stream)
269 def nested_update(dst, src, context=None):
270 '''Update dictionary dst from dictionary src. Sanity check that all
271 keys in src are defined in dst, except those in GENERATED_KEYS. This
272 means that defaults.yml acts as a template for configuration options.'''
274 for key, val in src.items():
278 ctx = '%s.%s' % (context, key)
280 if key not in dst and key not in GENERATED_KEYS:
281 sys.exit('ERROR: Invalid configuration key "%s"' % ctx)
283 if isinstance(val, dict) and key in dst:
284 nested_update(dst[key], val, ctx)
289 def load_config_with_includes(config_file):
290 '''Load a config file, recursively respecting "include" options'''
292 if not os.path.exists(config_file):
293 sys.exit('ERROR: Configuration file %s not found' % config_file)
295 with open(config_file, 'r') as stream:
297 config = yaml.safe_load(stream)
298 except yaml.YAMLError as exc:
299 sys.exit('Error parsing config file %s, %s' % (config_file, exc))
304 # Handle include item, either a single string or a list
305 if 'include' not in config:
307 includes = config['include']
308 config.pop('include', None)
309 if isinstance(includes, str):
310 includes = [includes]
311 if not isinstance(includes, list):
312 print('warning: Ignoring non-string/list include', file=sys.stderr)
314 for include in includes:
315 if not isinstance(include, str):
316 print('warning: Ignoring non-string include', file=sys.stderr)
319 included_config = load_config_with_includes(include)
320 config.update(included_config)
325 def load_config(cluster):
326 '''Load default and user configuration; combine them'''
328 defaults = load_defaults()
330 config_file = '%s.yml' % cluster
332 config = load_config_with_includes(config_file)
334 calculate_nodes(cluster, defaults, config)
335 calculate_dependencies(cluster, defaults, config)
336 calculate_timezone(config)
337 calculate_kdc(config)
338 calculate_shares(defaults, config)
341 nested_update(out, config)
346 def generate_config_yml(config, outdir):
347 '''Output combined YAML configuration to "config.yml"'''
349 outfile = os.path.join(outdir, 'config.yml')
351 with open(outfile, 'w') as stream:
352 out = yaml.dump(config, default_flow_style=False)
354 print('---', file=stream)
355 print(out, file=stream)
358 def generate_hosts(cluster, config, outdir):
359 '''Output hosts file snippet to "hosts"'''
361 outfile = os.path.join(outdir, 'hosts')
363 with open(outfile, 'w') as stream:
364 print("# autocluster %s" % cluster, file=stream)
366 domain = config['resolv_conf']['domain']
368 for hostname, node in config['nodes'].items():
369 ip_address = node['ips'][0]
370 line = "%s\t%s.%s %s" % (ip_address, hostname, domain, hostname)
372 print(line, file=stream)
375 def generate_ssh_config(config, outdir):
376 '''Output ssh_config file snippet to "ssh_config"'''
378 outfile = os.path.join(outdir, 'ssh_config')
380 with open(outfile, 'w') as stream:
381 for hostname, node in config['nodes'].items():
382 ip_address = node['ips'][0]
383 ssh_key = os.path.join(os.environ['HOME'], '.ssh/id_autocluster')
388 UserKnownHostsFile /dev/null
389 StrictHostKeyChecking no
390 PasswordAuthentication no
394 ''' % (hostname, ip_address, ssh_key)
396 print(section, file=stream)
399 def generate_ansible_inventory(config, outdir):
400 '''Output Ansible inventory file to "ansible.inventory"'''
404 for hostname, node in config['nodes'].items():
406 node_type = node['type']
407 hostnames = type_map.get(node['type'], [])
408 hostnames.append(hostname)
409 type_map[node['type']] = hostnames
411 outfile = os.path.join(outdir, 'ansible.inventory')
413 with open(outfile, 'w') as stream:
414 for node_type, hostnames in type_map.items():
415 print('[%s-nodes]' % node_type, file=stream)
417 for hostname in hostnames:
418 print(hostname, file=stream)
422 def cluster_defaults():
423 '''Dump default YAML configuration to stdout'''
425 defaults = load_defaults()
426 out = yaml.dump(defaults, default_flow_style=False)
431 def cluster_dump(cluster):
432 '''Dump cluster YAML configuration to stdout'''
434 config = load_config(cluster)
436 # Remove some generated, internal values that aren't in an input
438 for key in ['nodes', 'shares']:
439 config.pop(key, None)
441 out = yaml.dump(config, default_flow_style=False)
446 def get_state_dir(cluster):
447 '''Return the state directory for the current cluster'''
449 return os.path.join(os.getcwd(), '.autocluster', cluster)
452 def announce(group, cluster, command):
453 '''Print a banner announcing the current step'''
455 hashes = '############################################################'
456 heading = '%s %s %s' % (group, cluster, command)
457 banner = "%s\n# %-56s #\n%s" % (hashes, heading, hashes)
462 def cluster_generate(cluster):
463 '''Generate metadata files from configuration'''
465 announce('cluster', cluster, 'generate')
467 config = load_config(cluster)
469 outdir = get_state_dir(cluster)
472 except OSError as err:
473 if err.errno != errno.EEXIST:
476 generate_config_yml(config, outdir)
477 generate_hosts(cluster, config, outdir)
478 generate_ssh_config(config, outdir)
479 generate_ansible_inventory(config, outdir)
482 def vagrant_command(cluster, config, args):
483 '''Run vagrant with the given arguments'''
485 state_dir = get_state_dir(cluster)
487 os.environ['VAGRANT_DEFAULT_PROVIDER'] = config['vagrant_provider']
488 os.environ['VAGRANT_CWD'] = os.path.join(INSTALL_DIR, 'vagrant')
489 os.environ['VAGRANT_DOTFILE_PATH'] = os.path.join(state_dir, '.vagrant')
490 os.environ['AUTOCLUSTER_STATE'] = state_dir
492 full_args = args[:] # copy
493 full_args.insert(0, 'vagrant')
495 subprocess.check_call(full_args)
498 def cluster_status(cluster):
499 '''Check status of cluster using Vagrant'''
501 announce('cluster', cluster, 'status')
503 config = load_config(cluster)
505 vagrant_command(cluster, config, ['status'])
508 def get_shared_disk_names(cluster, config):
509 '''Return shared disks names for cluster, None if none'''
511 have_shared_disks = False
512 for _, node in config['nodes'].items():
513 if node['has_shared_storage']:
514 have_shared_disks = True
516 if not have_shared_disks:
519 count = config['shared_disks']['count']
523 return ['autocluster_%s_shared%02d.img' % (cluster, n + 1)
524 for n in range(count)]
527 def delete_shared_disk_images(cluster, config):
528 '''Delete any shared disks for the given cluster'''
530 if config['vagrant_provider'] != 'libvirt':
533 shared_disks = get_shared_disk_names(cluster, config)
534 if shared_disks is None:
538 print('warning: unable to check for stale shared disks (no libvirt)',
542 conn = libvirt.open()
543 storage_pool = conn.storagePoolLookupByName('autocluster')
544 for disk in shared_disks:
546 volume = storage_pool.storageVolLookupByName(disk)
548 except libvirt.libvirtError as err:
549 if err.get_error_code() != libvirt.VIR_ERR_NO_STORAGE_VOL:
554 def create_shared_disk_images(cluster, config):
555 '''Create shared disks for the given cluster'''
557 if config['vagrant_provider'] != 'libvirt':
560 shared_disks = get_shared_disk_names(cluster, config)
561 if shared_disks is None:
565 raise LIBVIRT_IMPORT_ERROR
567 conn = libvirt.open()
568 storage_pool = conn.storagePoolLookupByName('autocluster')
570 size = str(config['shared_disks']['size'])
571 if size[-1].isdigit():
578 for disk in shared_disks:
579 xml = '''<volume type='file'>
581 <capacity unit="%s">%s</capacity>
582 </volume>''' % (disk, unit, capacity)
583 storage_pool.createXML(xml)
588 def cluster_destroy_quiet(cluster):
589 '''Destroy and undefine cluster using Vagrant - don't announce'''
591 config = load_config(cluster)
593 # First attempt often fails, so try a few times
596 vagrant_command(cluster,
598 ['destroy', '-f', '--no-parallel'])
599 except subprocess.CalledProcessError as err:
602 delete_shared_disk_images(cluster, config)
608 def cluster_destroy(cluster):
609 '''Destroy and undefine cluster using Vagrant'''
611 announce('cluster', cluster, 'destroy')
613 cluster_destroy_quiet(cluster)
616 def cluster_create(cluster):
617 '''Create and boot cluster using Vagrant'''
619 announce('cluster', cluster, 'create')
621 config = load_config(cluster)
623 # Create our own shared disk images to protect against
624 # https://github.com/vagrant-libvirt/vagrant-libvirt/issues/825
625 create_shared_disk_images(cluster, config)
627 # First attempt sometimes fails, so try a few times
630 vagrant_command(cluster, config, ['up'])
631 except subprocess.CalledProcessError as err:
633 cluster_destroy(cluster)
640 def cluster_ssh_config(cluster):
641 '''Install SSH configuration for cluster'''
643 announce('cluster', cluster, 'ssh_config')
645 src = os.path.join(get_state_dir(cluster), 'ssh_config')
646 dst = os.path.join(os.environ['HOME'],
647 '.ssh/autocluster.d',
648 '%s.config' % cluster)
649 shutil.copyfile(src, dst)
652 def cluster_setup(cluster):
653 '''Setup cluster using Ansible'''
655 announce('cluster', cluster, 'setup')
657 # Could put these in the state directory, but disable for now
658 os.environ['ANSIBLE_RETRY_FILES_ENABLED'] = 'false'
660 state_dir = get_state_dir(cluster)
661 config_file = os.path.join(state_dir, 'config.yml')
662 inventory = os.path.join(state_dir, 'ansible.inventory')
663 playbook = os.path.join(INSTALL_DIR, 'ansible/node/site.yml')
664 args = ['ansible-playbook',
665 '-e', '@%s' % config_file,
669 subprocess.check_call(args)
670 except subprocess.CalledProcessError as err:
671 sys.exit('ERROR: cluster setup exited with %d' % err.returncode)
674 def cluster_build(cluster):
675 '''Build cluster using Ansible'''
677 cluster_destroy(cluster)
678 cluster_generate(cluster)
679 cluster_create(cluster)
680 cluster_ssh_config(cluster)
681 cluster_setup(cluster)
684 def cluster_command(cluster, command):
685 '''Run appropriate cluster command function'''
687 if command == 'defaults':
689 elif command == 'dump':
690 cluster_dump(cluster)
691 elif command == 'status':
692 cluster_status(cluster)
693 elif command == 'generate':
694 cluster_generate(cluster)
695 elif command == 'destroy':
696 cluster_destroy(cluster)
697 elif command == 'create':
698 cluster_create(cluster)
699 elif command == 'ssh_config':
700 cluster_ssh_config(cluster)
701 elif command == 'setup':
702 cluster_setup(cluster)
703 elif command == 'build':
704 cluster_build(cluster)
709 def get_platform_file(platform):
710 '''Return the name of the host setup file for platform'''
712 return os.path.join(INSTALL_DIR,
714 'autocluster_setup_%s.yml' % platform)
717 def sanity_check_platform_name(platform):
718 '''Ensure that host platform is supported'''
720 platform_file = get_platform_file(platform)
722 if not os.access(platform_file, os.R_OK):
723 sys.exit('Host platform "%s" not supported' % platform)
726 def host_setup(platform):
727 '''Set up host machine for use with Autocluster'''
729 announce('host', platform, 'setup')
731 platform_file = get_platform_file(platform)
732 os.environ['ANSIBLE_RETRY_FILES_ENABLED'] = 'false'
733 args = ['ansible-playbook', platform_file]
736 subprocess.check_call(args)
737 except subprocess.CalledProcessError as err:
738 sys.exit('ERROR: host setup exited with %d' % err.returncode)
742 '''Main autocluster command-line handling'''
744 if len(sys.argv) < 2:
747 if sys.argv[1] == 'cluster':
748 if len(sys.argv) < 4:
751 cluster = sys.argv[2]
753 sanity_check_cluster_name(cluster)
755 for command in sys.argv[3:]:
756 cluster_command(cluster, command)
758 elif sys.argv[1] == 'host':
759 if len(sys.argv) < 4:
762 platform = sys.argv[2]
764 sanity_check_platform_name(platform)
766 for command in sys.argv[3:]:
767 if command == 'setup':
774 if __name__ == '__main__':