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