Update TODO
[autocluster.git] / autocluster.py
1 #!/usr/bin/env python
2
3 '''Autocluster: Generate test clusters for clustered Samba
4
5    Reads configuration file in YAML format
6
7    Uses Vagrant to create cluster, Ansible to configure
8 '''
9
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.
14 #
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.
19 #
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/>.
22
23 from __future__ import print_function
24
25 import os
26 import errno
27 import sys
28 import re
29 import subprocess
30 import shutil
31
32 import ipaddress
33
34 import yaml
35 try:
36     import libvirt
37 except ImportError as err:
38     LIBVIRT_IMPORT_ERROR = err
39     libvirt = None
40
41 INSTALL_DIR = '.'
42
43 NODE_TYPES = ['nas', 'base', 'build', 'cbuild', 'ad', 'test']
44 GENERATED_KEYS = ['cluster', 'nodes', 'shares']
45
46
47 def usage():
48     '''Print usage message'''
49
50     sys.exit(
51         '''Usage: %s <group> <args>
52   Groups:
53
54     cluster <cluster> <command> ...
55
56        Commands:
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
62             create      Create 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
66
67     host <platform> setup
68 ''' % sys.argv[0])
69
70
71 def sanity_check_cluster_name(cluster):
72     '''Ensure that the cluster name is sane'''
73
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)
78
79
80 def calculate_nodes(cluster, defaults, config):
81     '''Calculate hostname, IP and other attributes for each node'''
82
83     combined = dict(defaults)
84     combined.update(config)
85
86     if 'node_list' not in config:
87         sys.exit('Error: node_list not defined')
88
89     have_dedicated_storage_nodes = False
90     for node_type in combined['node_list']:
91
92         if node_type not in NODE_TYPES:
93             sys.exit('ERROR: Invalid node type %s in node_list' % node_type)
94
95         if type == 'storage':
96             have_dedicated_storage_nodes = True
97
98     nodes = {}
99     type_counts = {}
100     for idx, node_type in enumerate(combined['node_list']):
101         node = {}
102
103         node['type'] = node_type
104
105         # Construct hostname, whether node is CTDB node
106         if node_type == 'nas':
107             tag = 'n'
108             node['is_ctdb_node'] = True
109         else:
110             tag = node_type
111             node['is_ctdb_node'] = False
112
113         type_counts[node_type] = type_counts.get(node_type, 0) + 1
114         hostname = '%s%s%d' % (cluster, tag, type_counts[node_type])
115
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
121         else:
122             node['has_shared_storage'] = False
123
124         # List of IP addresses, one for each network
125         node['ips'] = []
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]))
133
134         nodes[hostname] = node
135
136     config['nodes'] = nodes
137
138
139 def calculate_dependencies_ad(config):
140     '''Calculate nameserver and auth method based on the first AD node'''
141
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
149
150             if 'auth_method' not in config:
151                 config['auth_method'] = 'winbind'
152
153             break
154
155
156 def calculate_dependencies_virthost(defaults, config):
157     '''Handle special values that depend on virthost'''
158
159     if 'virthost' in config:
160         virthost = config['virthost']
161     else:
162         virthost = defaults['virthost']
163
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
168
169     if 'repository_baseurl' not in config:
170         config['repository_baseurl'] = 'http://%s/mediasets' % virthost
171
172     if 'ad' not in config:
173         config['ad'] = {}
174     if 'dns_forwarder' not in config['ad']:
175         config['ad']['dns_forwarder'] = virthost
176
177
178 def calculate_dependencies(cluster, defaults, config):
179     '''Handle special values that depend on updated config values'''
180
181     config['cluster'] = cluster
182
183     calculate_dependencies_ad(config)
184     calculate_dependencies_virthost(defaults, config)
185
186     # domain -> search
187     if 'resolv_conf' in config and \
188        'domain' in config['resolv_conf'] and \
189        'search' not in config['resolv_conf']:
190
191         config['resolv_conf']['search'] = config['resolv_conf']['domain']
192
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
198                 break
199
200
201 def calculate_kdc(config):
202     '''Calculate KDC setting if unset and there is an AD node'''
203
204     if 'kdc' not in config:
205         for hostname, node in config['nodes'].items():
206             if node['type'] == 'ad':
207                 config['kdc'] = hostname
208                 break
209
210
211 def calculate_timezone(config):
212     '''Calculate timezone setting if unset'''
213
214     if 'timezone' not in config:
215         timezone_file = os.environ.get('AUTOCLUSTER_TEST_TIMEZONE_FILE',
216                                        '/etc/timezone')
217         try:
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:
224                 raise
225
226     if 'timezone' not in config:
227         clock_file = os.environ.get('AUTOCLUSTER_TEST_CLOCK_FILE',
228                                     '/etc/sysconfig/clock')
229         try:
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)]
234                 if matches:
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:
239                 raise
240
241
242 def calculate_shares(defaults, config):
243     '''Calculate share definitions based on cluster filesystem mountpoint'''
244
245     if 'clusterfs' in config and 'mountpoint' in config['clusterfs']:
246         mountpoint = config['clusterfs']['mountpoint']
247     else:
248         mountpoint = defaults['clusterfs']['mountpoint']
249     directory = os.path.join(mountpoint, 'data')
250     share = {'name': 'data', 'directory': directory, 'mode': '0o777'}
251
252     config['shares'] = [share]
253
254
255 def load_defaults():
256     '''Load default configuration'''
257
258     # Any failures here are internal errors, so allow default
259     # exceptions
260
261     defaults_file = os.path.join(INSTALL_DIR, 'defaults.yml')
262
263     with open(defaults_file, 'r') as stream:
264         defaults = yaml.safe_load(stream)
265
266     return defaults
267
268
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.'''
273
274     for key, val in src.items():
275         if context is None:
276             ctx = key
277         else:
278             ctx = '%s.%s' % (context, key)
279
280         if key not in dst and key not in GENERATED_KEYS:
281             sys.exit('ERROR: Invalid configuration key "%s"' % ctx)
282
283         if isinstance(val, dict) and key in dst:
284             nested_update(dst[key], val, ctx)
285         else:
286             dst[key] = val
287
288
289 def load_config_with_includes(config_file):
290     '''Load a config file, recursively respecting "include" options'''
291
292     if not os.path.exists(config_file):
293         sys.exit('ERROR: Configuration file %s not found' % config_file)
294
295     with open(config_file, 'r') as stream:
296         try:
297             config = yaml.safe_load(stream)
298         except yaml.YAMLError as exc:
299             sys.exit('Error parsing config file %s, %s' % (config_file, exc))
300
301     if config is None:
302         config = {}
303
304     # Handle include item, either a single string or a list
305     if 'include' not in config:
306         return 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)
313         return config
314     for include in includes:
315         if not isinstance(include, str):
316             print('warning: Ignoring non-string include', file=sys.stderr)
317             continue
318
319         included_config = load_config_with_includes(include)
320         config.update(included_config)
321
322     return config
323
324
325 def load_config(cluster):
326     '''Load default and user configuration; combine them'''
327
328     defaults = load_defaults()
329
330     config_file = '%s.yml' % cluster
331
332     config = load_config_with_includes(config_file)
333
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)
339
340     out = dict(defaults)
341     nested_update(out, config)
342
343     return out
344
345
346 def generate_config_yml(config, outdir):
347     '''Output combined YAML configuration to "config.yml"'''
348
349     outfile = os.path.join(outdir, 'config.yml')
350
351     with open(outfile, 'w') as stream:
352         out = yaml.dump(config, default_flow_style=False)
353
354         print('---', file=stream)
355         print(out, file=stream)
356
357
358 def generate_hosts(cluster, config, outdir):
359     '''Output hosts file snippet to "hosts"'''
360
361     outfile = os.path.join(outdir, 'hosts')
362
363     with open(outfile, 'w') as stream:
364         print("# autocluster %s" % cluster, file=stream)
365
366         domain = config['resolv_conf']['domain']
367
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)
371
372             print(line, file=stream)
373
374
375 def generate_ssh_config(config, outdir):
376     '''Output ssh_config file snippet to "ssh_config"'''
377
378     outfile = os.path.join(outdir, 'ssh_config')
379
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')
384             section = '''Host %s
385   HostName %s
386   User root
387   Port 22
388   UserKnownHostsFile /dev/null
389   StrictHostKeyChecking no
390   PasswordAuthentication no
391   IdentityFile %s
392   IdentitiesOnly yes
393   LogLevel FATAL
394 ''' % (hostname, ip_address, ssh_key)
395
396             print(section, file=stream)
397
398
399 def generate_ansible_inventory(config, outdir):
400     '''Output Ansible inventory file to "ansible.inventory"'''
401
402     type_map = {}
403
404     for hostname, node in config['nodes'].items():
405
406         node_type = node['type']
407         hostnames = type_map.get(node['type'], [])
408         hostnames.append(hostname)
409         type_map[node['type']] = hostnames
410
411     outfile = os.path.join(outdir, 'ansible.inventory')
412
413     with open(outfile, 'w') as stream:
414         for node_type, hostnames in type_map.items():
415             print('[%s-nodes]' % node_type, file=stream)
416             hostnames.sort()
417             for hostname in hostnames:
418                 print(hostname, file=stream)
419             print(file=stream)
420
421
422 def cluster_defaults():
423     '''Dump default YAML configuration to stdout'''
424
425     defaults = load_defaults()
426     out = yaml.dump(defaults, default_flow_style=False)
427     print('---')
428     print(out)
429
430
431 def cluster_dump(cluster):
432     '''Dump cluster YAML configuration to stdout'''
433
434     config = load_config(cluster)
435
436     # Remove some generated, internal values that aren't in an input
437     # configuration
438     for key in ['nodes', 'shares']:
439         config.pop(key, None)
440
441     out = yaml.dump(config, default_flow_style=False)
442     print('---')
443     print(out)
444
445
446 def get_state_dir(cluster):
447     '''Return the state directory for the current cluster'''
448
449     return os.path.join(os.getcwd(), '.autocluster', cluster)
450
451
452 def announce(group, cluster, command):
453     '''Print a banner announcing the current step'''
454
455     hashes = '############################################################'
456     heading = '%s %s %s' % (group, cluster, command)
457     banner = "%s\n# %-56s #\n%s" % (hashes, heading, hashes)
458
459     print(banner)
460
461
462 def cluster_generate(cluster):
463     '''Generate metadata files from configuration'''
464
465     announce('cluster', cluster, 'generate')
466
467     config = load_config(cluster)
468
469     outdir = get_state_dir(cluster)
470     try:
471         os.makedirs(outdir)
472     except OSError as err:
473         if err.errno != errno.EEXIST:
474             raise
475
476     generate_config_yml(config, outdir)
477     generate_hosts(cluster, config, outdir)
478     generate_ssh_config(config, outdir)
479     generate_ansible_inventory(config, outdir)
480
481
482 def vagrant_command(cluster, config, args):
483     '''Run vagrant with the given arguments'''
484
485     state_dir = get_state_dir(cluster)
486
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
491
492     full_args = args[:]  # copy
493     full_args.insert(0, 'vagrant')
494
495     subprocess.check_call(full_args)
496
497
498 def cluster_status(cluster):
499     '''Check status of cluster using Vagrant'''
500
501     announce('cluster', cluster, 'status')
502
503     config = load_config(cluster)
504
505     vagrant_command(cluster, config, ['status'])
506
507
508 def get_shared_disk_names(cluster, config):
509     '''Return shared disks names for cluster, None if none'''
510
511     have_shared_disks = False
512     for _, node in config['nodes'].items():
513         if node['has_shared_storage']:
514             have_shared_disks = True
515             break
516     if not have_shared_disks:
517         return None
518
519     count = config['shared_disks']['count']
520     if count == 0:
521         return None
522
523     return ['autocluster_%s_shared%02d.img' % (cluster, n + 1)
524             for n in range(count)]
525
526
527 def delete_shared_disk_images(cluster, config):
528     '''Delete any shared disks for the given cluster'''
529
530     if config['vagrant_provider'] != 'libvirt':
531         return
532
533     shared_disks = get_shared_disk_names(cluster, config)
534     if shared_disks is None:
535         return
536
537     if libvirt is None:
538         print('warning: unable to check for stale shared disks (no libvirt)',
539               file=sys.stderr)
540         return
541
542     conn = libvirt.open()
543     storage_pool = conn.storagePoolLookupByName('autocluster')
544     for disk in shared_disks:
545         try:
546             volume = storage_pool.storageVolLookupByName(disk)
547             volume.delete()
548         except libvirt.libvirtError as err:
549             if err.get_error_code() != libvirt.VIR_ERR_NO_STORAGE_VOL:
550                 raise err
551     conn.close()
552
553
554 def create_shared_disk_images(cluster, config):
555     '''Create shared disks for the given cluster'''
556
557     if config['vagrant_provider'] != 'libvirt':
558         return
559
560     shared_disks = get_shared_disk_names(cluster, config)
561     if shared_disks is None:
562         return
563
564     if libvirt is None:
565         raise LIBVIRT_IMPORT_ERROR
566
567     conn = libvirt.open()
568     storage_pool = conn.storagePoolLookupByName('autocluster')
569
570     size = str(config['shared_disks']['size'])
571     if size[-1].isdigit():
572         unit = 'B'
573         capacity = size
574     else:
575         unit = size[-1]
576         capacity = size[:-1]
577
578     for disk in shared_disks:
579         xml = '''<volume type='file'>
580   <name>%s</name>
581   <capacity unit="%s">%s</capacity>
582 </volume>''' % (disk, unit, capacity)
583         storage_pool.createXML(xml)
584
585     conn.close()
586
587
588 def cluster_destroy_quiet(cluster):
589     '''Destroy and undefine cluster using Vagrant - don't announce'''
590
591     config = load_config(cluster)
592
593     # First attempt often fails, so try a few times
594     for _ in range(10):
595         try:
596             vagrant_command(cluster,
597                             config,
598                             ['destroy', '-f', '--no-parallel'])
599         except subprocess.CalledProcessError as err:
600             saved_err = err
601         else:
602             delete_shared_disk_images(cluster, config)
603             return
604
605     raise saved_err
606
607
608 def cluster_destroy(cluster):
609     '''Destroy and undefine cluster using Vagrant'''
610
611     announce('cluster', cluster, 'destroy')
612
613     cluster_destroy_quiet(cluster)
614
615
616 def cluster_create(cluster):
617     '''Create and boot cluster using Vagrant'''
618
619     announce('cluster', cluster, 'create')
620
621     config = load_config(cluster)
622
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)
626
627     # First attempt sometimes fails, so try a few times
628     for _ in range(10):
629         try:
630             vagrant_command(cluster, config, ['up'])
631         except subprocess.CalledProcessError as err:
632             saved_err = err
633             cluster_destroy(cluster)
634         else:
635             return
636
637     raise saved_err
638
639
640 def cluster_ssh_config(cluster):
641     '''Install SSH configuration for cluster'''
642
643     announce('cluster', cluster, 'ssh_config')
644
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)
650
651
652 def cluster_setup(cluster):
653     '''Setup cluster using Ansible'''
654
655     announce('cluster', cluster, 'setup')
656
657     # Could put these in the state directory, but disable for now
658     os.environ['ANSIBLE_RETRY_FILES_ENABLED'] = 'false'
659
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,
666             '-i', inventory,
667             playbook]
668     try:
669         subprocess.check_call(args)
670     except subprocess.CalledProcessError as err:
671         sys.exit('ERROR: cluster setup exited with %d' % err.returncode)
672
673
674 def cluster_build(cluster):
675     '''Build cluster using Ansible'''
676
677     cluster_destroy(cluster)
678     cluster_generate(cluster)
679     cluster_create(cluster)
680     cluster_ssh_config(cluster)
681     cluster_setup(cluster)
682
683
684 def cluster_command(cluster, command):
685     '''Run appropriate cluster command function'''
686
687     if command == 'defaults':
688         cluster_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)
705     else:
706         usage()
707
708
709 def get_platform_file(platform):
710     '''Return the name of the host setup file for platform'''
711
712     return os.path.join(INSTALL_DIR,
713                         'ansible/host',
714                         'autocluster_setup_%s.yml' % platform)
715
716
717 def sanity_check_platform_name(platform):
718     '''Ensure that host platform is supported'''
719
720     platform_file = get_platform_file(platform)
721
722     if not os.access(platform_file, os.R_OK):
723         sys.exit('Host platform "%s" not supported' % platform)
724
725
726 def host_setup(platform):
727     '''Set up host machine for use with Autocluster'''
728
729     announce('host', platform, 'setup')
730
731     platform_file = get_platform_file(platform)
732     os.environ['ANSIBLE_RETRY_FILES_ENABLED'] = 'false'
733     args = ['ansible-playbook', platform_file]
734
735     try:
736         subprocess.check_call(args)
737     except subprocess.CalledProcessError as err:
738         sys.exit('ERROR: host setup exited with %d' % err.returncode)
739
740
741 def main():
742     '''Main autocluster command-line handling'''
743
744     if len(sys.argv) < 2:
745         usage()
746
747     if sys.argv[1] == 'cluster':
748         if len(sys.argv) < 4:
749             usage()
750
751         cluster = sys.argv[2]
752
753         sanity_check_cluster_name(cluster)
754
755         for command in sys.argv[3:]:
756             cluster_command(cluster, command)
757
758     elif sys.argv[1] == 'host':
759         if len(sys.argv) < 4:
760             usage()
761
762         platform = sys.argv[2]
763
764         sanity_check_platform_name(platform)
765
766         for command in sys.argv[3:]:
767             if command == 'setup':
768                 host_setup(platform)
769
770     else:
771         usage()
772
773
774 if __name__ == '__main__':
775     sys.exit(main())