autocluster.py: Remove state directory on cluster destroy
[martins/autocluster.git] / autocluster.py
1 #!/usr/bin/env python3
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
11 # Copyright (C) Martin Schwenke  2019, 2020
12 #
13 # Based on ideas from a previous design/implementation:
14 #
15 #   Copyright (C) 2008 Andrew Tridgell and Martin Schwenke
16
17
18 # This program is free software; you can redistribute it and/or modify
19 # it under the terms of the GNU General Public License as published by
20 # the Free Software Foundation; either version 3 of the License, or
21 # (at your option) any later version.
22 #
23 # This program is distributed in the hope that it will be useful,
24 # but WITHOUT ANY WARRANTY; without even the implied warranty of
25 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
26 # GNU General Public License for more details.
27 #
28 # You should have received a copy of the GNU General Public License
29 # along with this program; if not, see <http://www.gnu.org/licenses/>.
30
31 from __future__ import print_function
32
33 import os
34 import errno
35 import sys
36 import re
37 import subprocess
38 import shutil
39 import time
40
41 import ipaddress
42
43 import yaml
44 try:
45     import libvirt
46 except ImportError as err:
47     LIBVIRT_IMPORT_ERROR = err
48     libvirt = None
49
50 INSTALL_DIR = os.path.dirname(os.path.realpath(sys.argv[0]))
51
52 NODE_TYPES = ['nas', 'base', 'build', 'cbuild', 'tbuild', 'ad', 'test']
53 GENERATED_KEYS = ['cluster', 'nodes', 'shares']
54
55
56 def usage():
57     '''Print usage message'''
58
59     sys.exit(
60         '''Usage: %s <group> <args>
61   Groups:
62
63     cluster <cluster> <command> ...
64
65        Commands:
66             defaults    Dump default configuration to stdout
67             dump        Dump cluster configuration to stdout
68             status      Show cluster status
69             generate    Generate cluster metadata for Vagrant, Ansible and SSH
70             destroy     Destroy cluster
71             create      Create cluster
72             ssh_config  Install cluster SSH configuration in current account
73             setup       Perform configuration/setup of cluster nodes
74             build       Short for: destroy generate create ssh_config setup
75
76     host <platform> setup
77 ''' % sys.argv[0])
78
79
80 def sanity_check_cluster_name(cluster):
81     '''Ensure that the cluster name is sane'''
82
83     if not re.match('^[A-Za-z][A-Za-z0-9]+$', cluster):
84         sys.exit('''ERROR: Invalid cluster name "%s"
85   Some cluster filesystems only allow cluster names matching
86   ^[A-Za-z][A-Za-z0-9]+$''' % cluster)
87
88
89 def calculate_nodes(cluster, defaults, config):
90     '''Calculate hostname, IP and other attributes for each node'''
91
92     combined = dict(defaults)
93     combined.update(config)
94
95     if 'node_list' not in config:
96         sys.exit('Error: node_list not defined')
97
98     have_dedicated_storage_nodes = False
99     for node_type in combined['node_list']:
100
101         if node_type not in NODE_TYPES:
102             sys.exit('ERROR: Invalid node type %s in node_list' % node_type)
103
104         if type == 'storage':
105             have_dedicated_storage_nodes = True
106
107     nodes = {}
108     type_counts = {}
109     for idx, node_type in enumerate(combined['node_list']):
110         node = {}
111
112         node['type'] = node_type
113
114         # Construct hostname, whether node is CTDB node
115         if node_type == 'nas':
116             tag = 'n'
117             node['is_ctdb_node'] = True
118         else:
119             tag = node_type
120             node['is_ctdb_node'] = False
121
122         type_counts[node_type] = type_counts.get(node_type, 0) + 1
123         hostname = '%s%s%d' % (cluster, tag, type_counts[node_type])
124
125         # Does the node have shared storage?
126         if node_type == 'storage':
127             node['has_shared_storage'] = True
128         elif node_type == 'nas' and not have_dedicated_storage_nodes:
129             node['has_shared_storage'] = True
130         else:
131             node['has_shared_storage'] = False
132
133         # List of IP addresses, one for each network
134         node['ips'] = []
135         for net in combined['networks']:
136             offset = config['firstip'] + idx
137             if sys.version_info[0] < 3:
138                 # Backported Python 2 ipaddress demands unicode instead of str
139                 net = net.decode('utf-8')
140             ip_address = ipaddress.ip_network(net, strict=False)
141             node['ips'].append(str(ip_address[offset]))
142
143         nodes[hostname] = node
144
145     config['nodes'] = nodes
146
147
148 def calculate_dependencies_ad(config):
149     '''Calculate nameserver and auth method based on the first AD node'''
150
151     for _, node in config['nodes'].items():
152         if node['type'] == 'ad':
153             nameserver = node['ips'][0]
154             if 'resolv_conf' not in config:
155                 config['resolv_conf'] = {}
156             if 'nameserver' not in config['resolv_conf']:
157                 config['resolv_conf']['nameserver'] = nameserver
158
159             if 'auth_method' not in config:
160                 config['auth_method'] = 'winbind'
161
162             break
163
164
165 def calculate_dependencies_virthost(defaults, config):
166     '''Handle special values that depend on virthost'''
167
168     if 'virthost' in config:
169         virthost = config['virthost']
170     else:
171         virthost = defaults['virthost']
172
173     if 'resolv_conf' not in config:
174         config['resolv_conf'] = {}
175     if 'nameserver' not in config['resolv_conf']:
176         config['resolv_conf']['nameserver'] = virthost
177
178     if 'repository_baseurl' not in config:
179         config['repository_baseurl'] = 'http://%s/mediasets' % virthost
180
181     if 'ad' not in config:
182         config['ad'] = {}
183     if 'dns_forwarder' not in config['ad']:
184         config['ad']['dns_forwarder'] = virthost
185
186
187 def calculate_dependencies(cluster, defaults, config):
188     '''Handle special values that depend on updated config values'''
189
190     config['cluster'] = cluster
191
192     calculate_dependencies_ad(config)
193     calculate_dependencies_virthost(defaults, config)
194
195     # domain -> search
196     if 'resolv_conf' in config and \
197        'domain' in config['resolv_conf'] and \
198        'search' not in config['resolv_conf']:
199
200         config['resolv_conf']['search'] = config['resolv_conf']['domain']
201
202     # Presence of distro repositories means delete existing ones
203     if 'repositories' in config and \
204        'repositories_delete_existing' not in config:
205         for repo in config['repositories']:
206             if repo['type'] == 'distro':
207                 config['repositories_delete_existing'] = True
208                 break
209
210
211 def calculate_kdc(config):
212     '''Calculate KDC setting if unset and there is an AD node'''
213
214     if 'kdc' not in config:
215         for hostname, node in config['nodes'].items():
216             if node['type'] == 'ad':
217                 config['kdc'] = hostname
218                 break
219
220
221 def calculate_timezone(config):
222     '''Calculate timezone setting if unset'''
223
224     if 'timezone' not in config:
225         timezone_file = os.environ.get('AUTOCLUSTER_TEST_TIMEZONE_FILE',
226                                        '/etc/timezone')
227         try:
228             with open(timezone_file) as stream:
229                 content = stream.readlines()
230                 timezone = content[0]
231                 config['timezone'] = timezone.strip()
232         except IOError as err:
233             if err.errno != errno.ENOENT:
234                 raise
235
236     if 'timezone' not in config:
237         clock_file = os.environ.get('AUTOCLUSTER_TEST_CLOCK_FILE',
238                                     '/etc/sysconfig/clock')
239         try:
240             with open(clock_file) as stream:
241                 zone_re = re.compile('^ZONE="([^"]+)".*')
242                 lines = stream.readlines()
243                 matches = [*filter(zone_re.match, lines)]
244                 if matches:
245                     timezone = zone_re.match(matches[0]).group(1)
246                     config['timezone'] = timezone.strip()
247         except IOError as err:
248             if err.errno != errno.ENOENT:
249                 raise
250
251
252 def calculate_shares(defaults, config):
253     '''Calculate share definitions based on cluster filesystem mountpoint'''
254
255     if 'clusterfs' in config and 'mountpoint' in config['clusterfs']:
256         mountpoint = config['clusterfs']['mountpoint']
257     else:
258         mountpoint = defaults['clusterfs']['mountpoint']
259     directory = os.path.join(mountpoint, 'data')
260     share = {'name': 'data', 'directory': directory, 'mode': '0o777'}
261
262     config['shares'] = [share]
263
264
265 def load_defaults():
266     '''Load default configuration'''
267
268     # Any failures here are internal errors, so allow default
269     # exceptions
270
271     defaults_file = os.path.join(INSTALL_DIR, 'defaults.yml')
272
273     with open(defaults_file, 'r') as stream:
274         defaults = yaml.safe_load(stream)
275
276     return defaults
277
278
279 def nested_update(dst, src, context=None):
280     '''Update dictionary dst from dictionary src.  Sanity check that all
281 keys in src are defined in dst, except those in GENERATED_KEYS.  This
282 means that defaults.yml acts as a template for configuration options.'''
283
284     for key, val in src.items():
285         if context is None:
286             ctx = key
287         else:
288             ctx = '%s.%s' % (context, key)
289
290         if key not in dst and key not in GENERATED_KEYS:
291             sys.exit('ERROR: Invalid configuration key "%s"' % ctx)
292
293         if isinstance(val, dict) and key in dst:
294             nested_update(dst[key], val, ctx)
295         else:
296             dst[key] = val
297
298
299 def load_config_with_includes(config_file):
300     '''Load a config file, recursively respecting "include" options'''
301
302     if not os.path.exists(config_file):
303         sys.exit('ERROR: Configuration file %s not found' % config_file)
304
305     with open(config_file, 'r') as stream:
306         try:
307             config = yaml.safe_load(stream)
308         except yaml.YAMLError as exc:
309             sys.exit('Error parsing config file %s, %s' % (config_file, exc))
310
311     if config is None:
312         config = {}
313
314     # Handle include item, either a single string or a list
315     if 'include' not in config:
316         return config
317     includes = config['include']
318     config.pop('include', None)
319     if isinstance(includes, str):
320         includes = [includes]
321     if not isinstance(includes, list):
322         print('warning: Ignoring non-string/list include', file=sys.stderr)
323         return config
324     for include in includes:
325         if not isinstance(include, str):
326             print('warning: Ignoring non-string include', file=sys.stderr)
327             continue
328
329         included_config = load_config_with_includes(include)
330         config.update(included_config)
331
332     return config
333
334
335 def load_config(cluster):
336     '''Load default and user configuration; combine them'''
337
338     defaults = load_defaults()
339
340     config_file = '%s.yml' % cluster
341
342     config = load_config_with_includes(config_file)
343
344     calculate_nodes(cluster, defaults, config)
345     calculate_dependencies(cluster, defaults, config)
346     calculate_timezone(config)
347     calculate_kdc(config)
348     calculate_shares(defaults, config)
349
350     out = dict(defaults)
351     nested_update(out, config)
352
353     return out
354
355
356 def generate_config_yml(cluster, config):
357     '''Output combined YAML configuration to "config.yml"'''
358
359     outfile = get_config_file_path(cluster)
360
361     with open(outfile, 'w') as stream:
362         out = yaml.dump(config, default_flow_style=False)
363
364         print('---', file=stream)
365         print(out, file=stream)
366
367
368 def generate_hosts(cluster, config, outdir):
369     '''Output hosts file snippet to "hosts"'''
370
371     outfile = os.path.join(outdir, 'hosts')
372
373     with open(outfile, 'w') as stream:
374         print("# autocluster %s" % cluster, file=stream)
375
376         domain = config['resolv_conf']['domain']
377
378         for hostname, node in config['nodes'].items():
379             ip_address = node['ips'][0]
380             line = "%s\t%s.%s %s" % (ip_address, hostname, domain, hostname)
381
382             print(line, file=stream)
383
384
385 def generate_ssh_config(config, outdir):
386     '''Output ssh_config file snippet to "ssh_config"'''
387
388     outfile = os.path.join(outdir, 'ssh_config')
389
390     with open(outfile, 'w') as stream:
391         for hostname, node in config['nodes'].items():
392             ip_address = node['ips'][0]
393             ssh_key = os.path.join(os.environ['HOME'], '.ssh/id_autocluster')
394             section = '''Host %s
395   HostName %s
396   User root
397   Port 22
398   UserKnownHostsFile /dev/null
399   StrictHostKeyChecking no
400   PasswordAuthentication no
401   IdentityFile %s
402   IdentitiesOnly yes
403   LogLevel FATAL
404 ''' % (hostname, ip_address, ssh_key)
405
406             print(section, file=stream)
407
408
409 def generate_ansible_inventory(config, outdir):
410     '''Output Ansible inventory file to "ansible.inventory"'''
411
412     type_map = {}
413
414     for hostname, node in config['nodes'].items():
415
416         node_type = node['type']
417         hostnames = type_map.get(node['type'], [])
418         hostnames.append(hostname)
419         type_map[node['type']] = hostnames
420
421     outfile = os.path.join(outdir, 'ansible.inventory')
422
423     with open(outfile, 'w') as stream:
424         for node_type, hostnames in type_map.items():
425             print('[%s_nodes]' % node_type, file=stream)
426             hostnames.sort()
427             for hostname in hostnames:
428                 print(hostname, file=stream)
429             print(file=stream)
430
431
432 def cluster_defaults():
433     '''Dump default YAML configuration to stdout'''
434
435     defaults = load_defaults()
436     out = yaml.dump(defaults, default_flow_style=False)
437     print('---')
438     print(out)
439
440
441 def cluster_dump(cluster):
442     '''Dump cluster YAML configuration to stdout'''
443
444     config = load_config(cluster)
445
446     # Remove some generated, internal values that aren't in an input
447     # configuration
448     for key in ['nodes', 'shares']:
449         config.pop(key, None)
450
451     out = yaml.dump(config, default_flow_style=False)
452     print('---')
453     print(out)
454
455
456 def get_state_dir(cluster):
457     '''Return the state directory for the current cluster'''
458
459     return os.path.join(os.getcwd(), '.autocluster', cluster)
460
461
462 def get_config_file_path(cluster):
463     '''Return the name of the generated config file for cluster'''
464
465     return os.path.join(get_state_dir(cluster), 'config.yml')
466
467
468 def announce(group, cluster, command):
469     '''Print a banner announcing the current step'''
470
471     hashes = '############################################################'
472     heading = '%s %s %s' % (group, cluster, command)
473     banner = "%s\n# %-56s #\n%s" % (hashes, heading, hashes)
474
475     print(banner)
476
477
478 def cluster_generate(cluster):
479     '''Generate metadata files from configuration'''
480
481     announce('cluster', cluster, 'generate')
482
483     config = load_config(cluster)
484
485     outdir = get_state_dir(cluster)
486     os.makedirs(outdir, exist_ok=True)
487
488     generate_config_yml(cluster, config)
489     generate_hosts(cluster, config, outdir)
490     generate_ssh_config(config, outdir)
491     generate_ansible_inventory(config, outdir)
492
493
494 def vagrant_command(cluster, config, args):
495     '''Run vagrant with the given arguments'''
496
497     state_dir = get_state_dir(cluster)
498
499     os.environ['VAGRANT_DEFAULT_PROVIDER'] = config['vagrant_provider']
500     os.environ['VAGRANT_CWD'] = os.path.join(INSTALL_DIR, 'vagrant')
501     os.environ['VAGRANT_DOTFILE_PATH'] = os.path.join(state_dir, '.vagrant')
502     os.environ['AUTOCLUSTER_STATE'] = state_dir
503
504     full_args = args[:]  # copy
505     full_args.insert(0, 'vagrant')
506
507     subprocess.check_call(full_args)
508
509
510 def cluster_status(cluster):
511     '''Check status of cluster using Vagrant'''
512
513     announce('cluster', cluster, 'status')
514
515     config = load_config(cluster)
516
517     vagrant_command(cluster, config, ['status'])
518
519
520 def get_shared_disk_names(cluster, config):
521     '''Return shared disks names for cluster, None if none'''
522
523     have_shared_disks = False
524     for _, node in config['nodes'].items():
525         if node['has_shared_storage']:
526             have_shared_disks = True
527             break
528     if not have_shared_disks:
529         return None
530
531     count = config['shared_disks']['count']
532     if count == 0:
533         return None
534
535     return ['autocluster_%s_shared%02d.img' % (cluster, n + 1)
536             for n in range(count)]
537
538
539 def delete_shared_disk_images(cluster, config):
540     '''Delete any shared disks for the given cluster'''
541
542     if config['vagrant_provider'] != 'libvirt':
543         return
544
545     shared_disks = get_shared_disk_names(cluster, config)
546     if shared_disks is None:
547         return
548
549     if libvirt is None:
550         print('warning: unable to check for stale shared disks (no libvirt)',
551               file=sys.stderr)
552         return
553
554     conn = libvirt.open()
555     storage_pool = conn.storagePoolLookupByName('autocluster')
556     for disk in shared_disks:
557         try:
558             volume = storage_pool.storageVolLookupByName(disk)
559             volume.delete()
560         except libvirt.libvirtError as err:
561             if err.get_error_code() != libvirt.VIR_ERR_NO_STORAGE_VOL:
562                 raise err
563     conn.close()
564
565
566 def create_shared_disk_images(cluster, config):
567     '''Create shared disks for the given cluster'''
568
569     if config['vagrant_provider'] != 'libvirt':
570         return
571
572     shared_disks = get_shared_disk_names(cluster, config)
573     if shared_disks is None:
574         return
575
576     if libvirt is None:
577         raise LIBVIRT_IMPORT_ERROR
578
579     conn = libvirt.open()
580     storage_pool = conn.storagePoolLookupByName('autocluster')
581
582     size = str(config['shared_disks']['size'])
583     if size[-1].isdigit():
584         unit = 'B'
585         capacity = size
586     else:
587         unit = size[-1]
588         capacity = size[:-1]
589
590     for disk in shared_disks:
591         xml = '''<volume type='file'>
592   <name>%s</name>
593   <capacity unit="%s">%s</capacity>
594 </volume>''' % (disk, unit, capacity)
595         storage_pool.createXML(xml)
596
597     conn.close()
598
599
600 def cluster_destroy_quiet(cluster, retries=1):
601     '''Destroy and undefine cluster using Vagrant - don't announce'''
602
603     config = load_config(cluster)
604
605     # First attempt often fails, so try a few times
606     for _ in range(retries):
607         try:
608             vagrant_command(cluster,
609                             config,
610                             ['destroy', '-f', '--no-parallel'])
611         except subprocess.CalledProcessError as err:
612             saved_err = err
613         else:
614             delete_shared_disk_images(cluster, config)
615             # Remove the state directory
616             statedir = get_state_dir(cluster)
617             shutil.rmtree(statedir)
618             return
619
620     raise saved_err
621
622
623 def cluster_destroy(cluster, retries=1):
624     '''Destroy and undefine cluster using Vagrant'''
625
626     announce('cluster', cluster, 'destroy')
627
628     config_file = get_config_file_path(cluster)
629     if not os.path.exists(config_file):
630         sys.exit('ERROR: Generated configuration for cluster does not exist')
631
632     cluster_destroy_quiet(cluster, retries)
633
634
635 def cluster_create(cluster, retries=1):
636     '''Create and boot cluster using Vagrant'''
637
638     announce('cluster', cluster, 'create')
639
640     config = load_config(cluster)
641
642     # Create our own shared disk images to protect against
643     # https://github.com/vagrant-libvirt/vagrant-libvirt/issues/825
644     create_shared_disk_images(cluster, config)
645
646     # First attempt sometimes fails, so try a few times
647     for _ in range(retries):
648         try:
649             vagrant_command(cluster, config, ['up'])
650         except subprocess.CalledProcessError as err:
651             saved_err = err
652             cluster_destroy(cluster)
653         else:
654             return
655
656     raise saved_err
657
658
659 def cluster_ssh_config(cluster):
660     '''Install SSH configuration for cluster'''
661
662     announce('cluster', cluster, 'ssh_config')
663
664     src = os.path.join(get_state_dir(cluster), 'ssh_config')
665     dst = os.path.join(os.environ['HOME'],
666                        '.ssh/autocluster.d',
667                        '%s.config' % cluster)
668     shutil.copyfile(src, dst)
669
670
671 def cluster_setup(cluster, retries=1):
672     '''Setup cluster using Ansible'''
673
674     announce('cluster', cluster, 'setup')
675
676     # Could put these in the state directory, but disable for now
677     os.environ['ANSIBLE_RETRY_FILES_ENABLED'] = 'false'
678
679     state_dir = get_state_dir(cluster)
680     config_file = get_config_file_path(cluster)
681     inventory = os.path.join(state_dir, 'ansible.inventory')
682     playbook = os.path.join(INSTALL_DIR, 'ansible/node/site.yml')
683     args = ['ansible-playbook',
684             '-e', '@%s' % config_file,
685             '-i', inventory,
686             playbook]
687
688     # First attempt sometimes fails, so try a few times
689     for _ in range(retries):
690         try:
691             subprocess.check_call(args)
692         except subprocess.CalledProcessError as err:
693             print('warning: cluster setup exited with %d, retrying' %
694                   err.returncode,
695                   file=sys.stderr)
696             saved_err = err
697             time.sleep(1)
698         else:
699             return
700
701     sys.exit('ERROR: cluster setup exited with %d' % saved_err.returncode)
702
703
704 def cluster_build(cluster):
705     '''Build cluster using Ansible'''
706
707     config_file = get_config_file_path(cluster)
708     if os.path.exists(config_file):
709         cluster_destroy(cluster, 10)
710     cluster_generate(cluster)
711     cluster_create(cluster, 10)
712     cluster_ssh_config(cluster)
713     cluster_setup(cluster, 5)
714
715
716 def cluster_command(cluster, command):
717     '''Run appropriate cluster command function'''
718
719     if command == 'defaults':
720         cluster_defaults()
721     elif command == 'dump':
722         cluster_dump(cluster)
723     elif command == 'status':
724         cluster_status(cluster)
725     elif command == 'generate':
726         cluster_generate(cluster)
727     elif command == 'destroy':
728         cluster_destroy(cluster, 10)
729     elif command == 'create':
730         cluster_create(cluster)
731     elif command == 'ssh_config':
732         cluster_ssh_config(cluster)
733     elif command == 'setup':
734         cluster_setup(cluster)
735     elif command == 'build':
736         cluster_build(cluster)
737     else:
738         usage()
739
740
741 def get_host_setup_path(file):
742     '''Return the path for host setup file'''
743
744     return os.path.join(INSTALL_DIR, 'ansible/host', file)
745
746
747 def get_platform_file(platform):
748     '''Return the name of the host setup file for platform'''
749
750     return get_host_setup_path('autocluster_setup_%s.yml' % platform)
751
752
753 def sanity_check_platform_name(platform):
754     '''Ensure that host platform is supported'''
755
756     platform_file = get_platform_file(platform)
757
758     if not os.access(platform_file, os.R_OK):
759         sys.exit('Host platform "%s" not supported' % platform)
760
761
762 def host_setup(platform):
763     '''Set up host machine for use with Autocluster'''
764
765     announce('host', platform, 'setup')
766
767     platform_file = get_platform_file(platform)
768     ssh_file = get_host_setup_path('autocluster_setup_%s.yml' % 'ssh')
769     os.environ['ANSIBLE_RETRY_FILES_ENABLED'] = 'false'
770     args = ['ansible-playbook', platform_file, ssh_file]
771
772     try:
773         subprocess.check_call(args)
774     except subprocess.CalledProcessError as err:
775         sys.exit('ERROR: host setup exited with %d' % err.returncode)
776
777
778 def main():
779     '''Main autocluster command-line handling'''
780
781     if len(sys.argv) < 2:
782         usage()
783
784     if sys.argv[1] == 'cluster':
785         if len(sys.argv) < 4:
786             usage()
787
788         cluster = sys.argv[2]
789
790         sanity_check_cluster_name(cluster)
791
792         for command in sys.argv[3:]:
793             cluster_command(cluster, command)
794
795     elif sys.argv[1] == 'host':
796         if len(sys.argv) < 4:
797             usage()
798
799         platform = sys.argv[2]
800
801         sanity_check_platform_name(platform)
802
803         for command in sys.argv[3:]:
804             if command == 'setup':
805                 host_setup(platform)
806
807     else:
808         usage()
809
810
811 if __name__ == '__main__':
812     sys.exit(main())