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