Statistics
| Branch: | Tag: | Revision:

root / kamaki / cli.py @ a1c50326

History | View | Annotate | Download (23.1 kB)

1
#!/usr/bin/env python
2

    
3
# Copyright 2011 GRNET S.A. All rights reserved.
4
#
5
# Redistribution and use in source and binary forms, with or
6
# without modification, are permitted provided that the following
7
# conditions are met:
8
#
9
#   1. Redistributions of source code must retain the above
10
#      copyright notice, this list of conditions and the following
11
#      disclaimer.
12
#
13
#   2. Redistributions in binary form must reproduce the above
14
#      copyright notice, this list of conditions and the following
15
#      disclaimer in the documentation and/or other materials
16
#      provided with the distribution.
17
#
18
# THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
19
# OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
20
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
21
# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
22
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
25
# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
26
# AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
27
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
28
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
29
# POSSIBILITY OF SUCH DAMAGE.
30
#
31
# The views and conclusions contained in the software and
32
# documentation are those of the authors and should not be
33
# interpreted as representing official policies, either expressed
34
# or implied, of GRNET S.A.
35

    
36
"""
37
To add a command create a new class and add a 'command' decorator. The class
38
must have a 'main' method which will contain the code to be executed.
39
Optionally a command can implement an 'update_parser' class method in order
40
to add command line arguments, or modify the OptionParser in any way.
41

42
The name of the class is important and it will determine the name and grouping
43
of the command. This behavior can be overriden with the 'group' and 'name'
44
decorator arguments:
45

46
    @command(api='compute')
47
    class server_list(object):
48
        # This command will be named 'list' under group 'server'
49
        ...
50

51
    @command(api='compute', name='ls')
52
    class server_list(object):
53
        # This command will be named 'ls' under group 'server'
54
        ...
55

56
The docstring of a command class will be used as the command description in
57
help messages, unless overriden with the 'description' decorator argument.
58

59
The syntax of a command will be generated dynamically based on the signature
60
of the 'main' method, unless overriden with the 'syntax' decorator argument:
61

62
    def main(self, server_id, network=None):
63
        # This syntax of this command will be: '<server id> [network]'
64
        ...
65

66
The order of commands is important, it will be preserved in the help output.
67
"""
68

    
69
import inspect
70
import logging
71
import os
72

    
73
from base64 import b64encode
74
from grp import getgrgid
75
from optparse import OptionParser
76
from os.path import abspath, basename, exists
77
from pwd import getpwuid
78
from sys import argv, exit
79

    
80
from kamaki import clients
81
from kamaki.config import Config, ConfigError
82
from kamaki.utils import OrderedDict, print_addresses, print_dict, print_items
83

    
84

    
85
log = logging.getLogger('kamaki')
86

    
87
_commands = OrderedDict()
88

    
89

    
90
def command(api=None, group=None, name=None, description=None, syntax=None):
91
    """Class decorator that registers a class as a CLI command."""
92
    
93
    def decorator(cls):
94
        grp, sep, cmd = cls.__name__.partition('_')
95
        if not sep:
96
            grp, cmd = None, cls.__name__
97
        
98
        cls.api = api
99
        cls.group = group or grp
100
        cls.name = name or cmd
101
        cls.description = description or cls.__doc__
102
        cls.syntax = syntax
103
        
104
        if cls.syntax is None:
105
            # Generate a syntax string based on main's arguments
106
            spec = inspect.getargspec(cls.main.im_func)
107
            args = spec.args[1:]
108
            n = len(args) - len(spec.defaults or ())
109
            required = ' '.join('<%s>' % x.replace('_', ' ') for x in args[:n])
110
            optional = ' '.join('[%s]' % x.replace('_', ' ') for x in args[n:])
111
            cls.syntax = ' '.join(x for x in [required, optional] if x)
112
            if spec.varargs:
113
                cls.syntax += ' <%s ...>' % spec.varargs
114
        
115
        if cls.group not in _commands:
116
            _commands[cls.group] = OrderedDict()
117
        _commands[cls.group][cls.name] = cls
118
        return cls
119
    return decorator
120

    
121

    
122
@command()
123
class config_list(object):
124
    """list configuration options"""
125
    
126
    def main(self):
127
        for key, val in sorted(self.config.items()):
128
            print '%s=%s' % (key, val)
129

    
130

    
131
@command()
132
class config_get(object):
133
    """get a configuration option"""
134
    
135
    def main(self, key):
136
        val = self.config.get(key)
137
        if val is not None:
138
            print val
139

    
140

    
141
@command()
142
class config_set(object):
143
    """set a configuration option"""
144
    
145
    def main(self, key, val):
146
        self.config.set(key, val)
147

    
148

    
149
@command()
150
class config_del(object):
151
    """delete a configuration option"""
152
    
153
    def main(self, key):
154
        self.config.delete(key)
155

    
156

    
157
@command(api='compute')
158
class server_list(object):
159
    """list servers"""
160
    
161
    @classmethod
162
    def update_parser(cls, parser):
163
        parser.add_option('-l', dest='detail', action='store_true',
164
                default=False, help='show detailed output')
165
    
166
    def main(self):
167
        servers = self.client.list_servers(self.options.detail)
168
        print_items(servers)
169

    
170

    
171
@command(api='compute')
172
class server_info(object):
173
    """get server details"""
174
    
175
    def main(self, server_id):
176
        server = self.client.get_server_details(int(server_id))
177
        print_dict(server)
178

    
179

    
180
@command(api='compute')
181
class server_create(object):
182
    """create server"""
183
    
184
    @classmethod
185
    def update_parser(cls, parser):
186
        parser.add_option('--personality', dest='personalities',
187
                action='append', default=[],
188
                metavar='PATH[,SERVER PATH[,OWNER[,GROUP,[MODE]]]]',
189
                help='add a personality file')
190
        parser.epilog = "If missing, optional personality values will be " \
191
                "filled based on the file at PATH if missing."
192
    
193
    def main(self, name, flavor_id, image_id):
194
        personalities = []
195
        for personality in self.options.personalities:
196
            p = personality.split(',')
197
            p.extend([None] * (5 - len(p)))     # Fill missing fields with None
198
            
199
            path = p[0]
200
            
201
            if not path:
202
                log.error("Invalid personality argument '%s'", p)
203
                return 1
204
            if not exists(path):
205
                log.error("File %s does not exist", path)
206
                return 1
207
            
208
            with open(path) as f:
209
                contents = b64encode(f.read())
210
            
211
            st = os.stat(path)
212
            personalities.append({
213
                'path': p[1] or abspath(path),
214
                'owner': p[2] or getpwuid(st.st_uid).pw_name,
215
                'group': p[3] or getgrgid(st.st_gid).gr_name,
216
                'mode': int(p[4]) if p[4] else 0x7777 & st.st_mode,
217
                'contents': contents})
218
        
219
        reply = self.client.create_server(name, int(flavor_id), image_id,
220
                personalities)
221
        print_dict(reply)
222

    
223

    
224
@command(api='compute')
225
class server_rename(object):
226
    """update server name"""
227
    
228
    def main(self, server_id, new_name):
229
        self.client.update_server_name(int(server_id), new_name)
230

    
231

    
232
@command(api='compute')
233
class server_delete(object):
234
    """delete server"""
235
    
236
    def main(self, server_id):
237
        self.client.delete_server(int(server_id))
238

    
239

    
240
@command(api='compute')
241
class server_reboot(object):
242
    """reboot server"""
243
    
244
    @classmethod
245
    def update_parser(cls, parser):
246
        parser.add_option('-f', dest='hard', action='store_true',
247
                default=False, help='perform a hard reboot')
248
    
249
    def main(self, server_id):
250
        self.client.reboot_server(int(server_id), self.options.hard)
251

    
252

    
253
@command(api='asterias')
254
class server_start(object):
255
    """start server"""
256
    
257
    def main(self, server_id):
258
        self.client.start_server(int(server_id))
259

    
260

    
261
@command(api='asterias')
262
class server_shutdown(object):
263
    """shutdown server"""
264
    
265
    def main(self, server_id):
266
        self.client.shutdown_server(int(server_id))
267

    
268

    
269
@command(api='asterias')
270
class server_console(object):
271
    """get a VNC console"""
272
    
273
    def main(self, server_id):
274
        reply = self.client.get_server_console(int(server_id))
275
        print_dict(reply)
276

    
277

    
278
@command(api='asterias')
279
class server_firewall(object):
280
    """set the firewall profile"""
281
    
282
    def main(self, server_id, profile):
283
        self.client.set_firewall_profile(int(server_id), profile)
284

    
285

    
286
@command(api='asterias')
287
class server_addr(object):
288
    """list server addresses"""
289
    
290
    def main(self, server_id, network=None):
291
        reply = self.client.list_server_addresses(int(server_id), network)
292
        margin = max(len(x['name']) for x in reply)
293
        print_addresses(reply, margin)
294

    
295

    
296
@command(api='compute')
297
class server_meta(object):
298
    """get server metadata"""
299
    
300
    def main(self, server_id, key=None):
301
        reply = self.client.get_server_metadata(int(server_id), key)
302
        print_dict(reply)
303

    
304

    
305
@command(api='compute')
306
class server_addmeta(object):
307
    """add server metadata"""
308
    
309
    def main(self, server_id, key, val):
310
        reply = self.client.create_server_metadata(int(server_id), key, val)
311
        print_dict(reply)
312

    
313

    
314
@command(api='compute')
315
class server_setmeta(object):
316
    """update server metadata"""
317
    
318
    def main(self, server_id, key, val):
319
        metadata = {key: val}
320
        reply = self.client.update_server_metadata(int(server_id), **metadata)
321
        print_dict(reply)
322

    
323

    
324
@command(api='compute')
325
class server_delmeta(object):
326
    """delete server metadata"""
327
    
328
    def main(self, server_id, key):
329
        self.client.delete_server_metadata(int(server_id), key)
330

    
331

    
332
@command(api='asterias')
333
class server_stats(object):
334
    """get server statistics"""
335
    
336
    def main(self, server_id):
337
        reply = self.client.get_server_stats(int(server_id))
338
        print_dict(reply, exclude=('serverRef',))
339

    
340

    
341
@command(api='compute')
342
class flavor_list(object):
343
    """list flavors"""
344
    
345
    @classmethod
346
    def update_parser(cls, parser):
347
        parser.add_option('-l', dest='detail', action='store_true',
348
                default=False, help='show detailed output')
349
    
350
    def main(self):
351
        flavors = self.client.list_flavors(self.options.detail)
352
        print_items(flavors)
353

    
354

    
355
@command(api='compute')
356
class flavor_info(object):
357
    """get flavor details"""
358
    
359
    def main(self, flavor_id):
360
        flavor = self.client.get_flavor_details(int(flavor_id))
361
        print_dict(flavor)
362

    
363

    
364
@command(api='compute')
365
class image_list(object):
366
    """list images"""
367
    
368
    @classmethod
369
    def update_parser(cls, parser):
370
        parser.add_option('-l', dest='detail', action='store_true',
371
                default=False, help='show detailed output')
372
    
373
    def main(self):
374
        images = self.client.list_images(self.options.detail)
375
        print_items(images)
376

    
377

    
378
@command(api='compute')
379
class image_info(object):
380
    """get image details"""
381
    
382
    def main(self, image_id):
383
        image = self.client.get_image_details(image_id)
384
        print_dict(image)
385

    
386

    
387
@command(api='compute')
388
class image_create(object):
389
    """create image"""
390
    
391
    def main(self, server_id, name):
392
        reply = self.client.create_image(int(server_id), name)
393
        print_dict(reply)
394

    
395

    
396
@command(api='compute')
397
class image_delete(object):
398
    """delete image"""
399
    
400
    def main(self, image_id):
401
        self.client.delete_image(image_id)
402

    
403

    
404
@command(api='compute')
405
class image_meta(object):
406
    """get image metadata"""
407
    
408
    def main(self, image_id, key=None):
409
        reply = self.client.get_image_metadata(image_id, key)
410
        print_dict(reply)
411

    
412

    
413
@command(api='compute')
414
class image_addmeta(object):
415
    """add image metadata"""
416
    
417
    def main(self, image_id, key, val):
418
        reply = self.client.create_image_metadata(image_id, key, val)
419
        print_dict(reply)
420

    
421

    
422
@command(api='compute')
423
class image_setmeta(object):
424
    """update image metadata"""
425
    
426
    def main(self, image_id, key, val):
427
        metadata = {key: val}
428
        reply = self.client.update_image_metadata(image_id, **metadata)
429
        print_dict(reply)
430

    
431

    
432
@command(api='compute')
433
class image_delmeta(object):
434
    """delete image metadata"""
435
    
436
    def main(self, image_id, key):
437
        self.client.delete_image_metadata(image_id, key)
438

    
439

    
440
@command(api='asterias')
441
class network_list(object):
442
    """list networks"""
443
    
444
    @classmethod
445
    def update_parser(cls, parser):
446
        parser.add_option('-l', dest='detail', action='store_true',
447
                default=False, help='show detailed output')
448
    
449
    def main(self):
450
        networks = self.client.list_networks(self.options.detail)
451
        print_items(networks)
452

    
453

    
454
@command(api='asterias')
455
class network_create(object):
456
    """create a network"""
457
    
458
    def main(self, name):
459
        reply = self.client.create_network(name)
460
        print_dict(reply)
461

    
462

    
463
@command(api='asterias')
464
class network_info(object):
465
    """get network details"""
466
    
467
    def main(self, network_id):
468
        network = self.client.get_network_details(network_id)
469
        print_dict(network)
470

    
471

    
472
@command(api='asterias')
473
class network_rename(object):
474
    """update network name"""
475
    
476
    def main(self, network_id, new_name):
477
        self.client.update_network_name(network_id, new_name)
478

    
479

    
480
@command(api='asterias')
481
class network_delete(object):
482
    """delete a network"""
483
    
484
    def main(self, network_id):
485
        self.client.delete_network(network_id)
486

    
487

    
488
@command(api='asterias')
489
class network_connect(object):
490
    """connect a server to a network"""
491
    
492
    def main(self, server_id, network_id):
493
        self.client.connect_server(server_id, network_id)
494

    
495

    
496
@command(api='asterias')
497
class network_disconnect(object):
498
    """disconnect a server from a network"""
499
    
500
    def main(self, server_id, network_id):
501
        self.client.disconnect_server(server_id, network_id)
502

    
503

    
504
@command(api='image')
505
class glance_list(object):
506
    """list images"""
507
    
508
    @classmethod
509
    def update_parser(cls, parser):
510
        parser.add_option('-l', dest='detail', action='store_true',
511
                default=False, help='show detailed output')
512
        parser.add_option('--container-format', dest='container_format',
513
                metavar='FORMAT', help='filter by container format')
514
        parser.add_option('--disk-format', dest='disk_format',
515
                metavar='FORMAT', help='filter by disk format')
516
        parser.add_option('--name', dest='name', metavar='NAME',
517
                help='filter by name')
518
        parser.add_option('--size-min', dest='size_min', metavar='BYTES',
519
                help='filter by minimum size')
520
        parser.add_option('--size-max', dest='size_max', metavar='BYTES',
521
                help='filter by maximum size')
522
        parser.add_option('--status', dest='status', metavar='STATUS',
523
                help='filter by status')
524
        parser.add_option('--order', dest='order', metavar='FIELD',
525
                help='order by FIELD (use a - prefix to reverse order)')
526
    
527
    def main(self):
528
        filters = {}
529
        for filter in ('container_format', 'disk_format', 'name', 'size_min',
530
                       'size_max', 'status'):
531
            val = getattr(self.options, filter, None)
532
            if val is not None:
533
                filters[filter] = val
534
        
535
        order = self.options.order or ''
536
        images = self.client.list_public(self.options.detail, filters=filters,
537
                                         order=order)
538
        print_items(images, title=('name',))
539

    
540

    
541
@command(api='image')
542
class glance_meta(object):
543
    """get image metadata"""
544
    
545
    def main(self, image_id):
546
        image = self.client.get_meta(image_id)
547
        print_dict(image)
548

    
549

    
550
@command(api='image')
551
class glance_register(object):
552
    """register an image"""
553
    
554
    @classmethod
555
    def update_parser(cls, parser):
556
        parser.add_option('--checksum', dest='checksum', metavar='CHECKSUM',
557
                help='set image checksum')
558
        parser.add_option('--container-format', dest='container_format',
559
                metavar='FORMAT', help='set container format')
560
        parser.add_option('--disk-format', dest='disk_format',
561
                metavar='FORMAT', help='set disk format')
562
        parser.add_option('--id', dest='id',
563
                metavar='ID', help='set image ID')
564
        parser.add_option('--owner', dest='owner',
565
                metavar='USER', help='set image owner (admin only)')
566
        parser.add_option('--property', dest='properties', action='append',
567
                metavar='KEY=VAL',
568
                help='add a property (can be used multiple times)')
569
        parser.add_option('--public', dest='is_public', action='store_true',
570
                help='mark image as public')
571
        parser.add_option('--size', dest='size', metavar='SIZE',
572
                help='set image size')
573
    
574
    def main(self, name, location):
575
        params = {}
576
        for key in ('checksum', 'container_format', 'disk_format', 'id',
577
                    'owner', 'is_public', 'size'):
578
            val = getattr(self.options, key)
579
            if val is not None:
580
                params[key] = val
581
        
582
        properties = {}
583
        for property in self.options.properties or []:
584
            key, sep, val = property.partition('=')
585
            if not sep:
586
                log.error("Invalid property '%s'", property)
587
                return 1
588
            properties[key.strip()] = val.strip()
589
        
590
        self.client.register(name, location, params, properties)
591

    
592

    
593
@command(api='image')
594
class glance_members(object):
595
    """get image members"""
596
    
597
    def main(self, image_id):
598
        members = self.client.list_members(image_id)
599
        for member in members:
600
            print member['member_id']
601

    
602

    
603
@command(api='image')
604
class glance_shared(object):
605
    """list shared images"""
606
    
607
    def main(self, member):
608
        images = self.client.list_shared(member)
609
        for image in images:
610
            print image['image_id']
611

    
612

    
613
@command(api='image')
614
class glance_addmember(object):
615
    """add a member to an image"""
616
    
617
    def main(self, image_id, member):
618
        self.client.add_member(image_id, member)
619

    
620

    
621
@command(api='image')
622
class glance_delmember(object):
623
    """remove a member from an image"""
624
    
625
    def main(self, image_id, member):
626
        self.client.remove_member(image_id, member)
627

    
628

    
629
@command(api='image')
630
class glance_setmembers(object):
631
    """set the members of an image"""
632
    
633
    def main(self, image_id, *member):
634
        self.client.set_members(image_id, member)
635

    
636

    
637
@command(api='storage')
638
class store_upload(object):
639
    """upload a file"""
640
    
641
    @classmethod
642
    def update_parser(cls, parser):
643
        parser.add_option('--account', dest='account', metavar='ACCOUNT',
644
                help='use account ACCOUNT')
645
        parser.add_option('--container', dest='container', metavar='CONTAINER',
646
                help='use container CONTAINER')
647
    
648
    def main(self, path, remote_path=None):
649
        account = self.options.account or self.config.get('storage_account')
650
        container = self.options.container or \
651
                    self.config.get('storage_container')
652
        if remote_path is None:
653
            remote_path = basename(path)
654
        with open(path) as f:
655
            self.client.create_object(account, container, remote_path, f)
656

    
657

    
658
def print_groups(groups):
659
    print
660
    print 'Groups:'
661
    for group in groups:
662
        print '  %s' % group
663

    
664

    
665
def print_commands(group, commands):
666
    print
667
    print 'Commands:'
668
    for name, cls in _commands[group].items():
669
        if name in commands:
670
            print '  %s %s' % (name.ljust(10), cls.description)
671

    
672

    
673
def main():
674
    ch = logging.StreamHandler()
675
    ch.setFormatter(logging.Formatter('%(message)s'))
676
    log.addHandler(ch)
677
    
678
    parser = OptionParser(add_help_option=False)
679
    parser.usage = '%prog <group> <command> [options]'
680
    parser.add_option('--help', dest='help', action='store_true',
681
            default=False, help='show this help message and exit')
682
    parser.add_option('-v', dest='verbose', action='store_true', default=False,
683
            help='use verbose output')
684
    parser.add_option('-d', dest='debug', action='store_true', default=False,
685
            help='use debug output')
686
    
687
    # Do a preliminary parsing, ignore any errors since we will print help
688
    # anyway if we don't reach the main parsing.
689
    _error = parser.error
690
    parser.error = lambda msg: None
691
    options, args = parser.parse_args(argv)
692
    parser.error = _error
693
    
694
    if options.debug:
695
        log.setLevel(logging.DEBUG)
696
    elif options.verbose:
697
        log.setLevel(logging.INFO)
698
    else:
699
        log.setLevel(logging.WARNING)
700
    
701
    try:
702
        config = Config()
703
    except ConfigError, e:
704
        log.error('%s', e.args[0])
705
        exit(1)
706
    
707
    apis = config.get('apis').split()
708
    
709
    # Find available groups based on the given APIs
710
    available_groups = []
711
    for group, group_commands in _commands.items():
712
        for name, cls in group_commands.items():
713
            if cls.api is None or cls.api in apis:
714
                available_groups.append(group)
715
                break
716
    
717
    if len(args) < 2:
718
        parser.print_help()
719
        print_groups(available_groups)
720
        exit(0)
721
    
722
    group = args[1]
723
    
724
    if group not in available_groups:
725
        parser.print_help()
726
        print_groups(available_groups)
727
        exit(1)
728
    
729
    # Find available commands based on the given APIs
730
    available_commands = []
731
    for name, cls in _commands[group].items():
732
        if cls.api is None or cls.api in apis:
733
            available_commands.append(name)
734
            continue
735
    
736
    parser.usage = '%%prog %s <command> [options]' % group
737
    
738
    if len(args) < 3:
739
        parser.print_help()
740
        print_commands(group, available_commands)
741
        exit(0)
742
    
743
    name = args[2]
744
    
745
    if name not in available_commands:
746
        parser.print_help()
747
        print_commands(group, available_commands)
748
        exit(1)
749
    
750
    cls = _commands[group][name]
751
    
752
    syntax = '%s [options]' % cls.syntax if cls.syntax else '[options]'
753
    parser.usage = '%%prog %s %s %s' % (group, name, syntax)
754
    parser.epilog = ''
755
    if hasattr(cls, 'update_parser'):
756
        cls.update_parser(parser)
757
    
758
    options, args = parser.parse_args(argv)
759
    if options.help:
760
        parser.print_help()
761
        exit(0)
762
    
763
    cmd = cls()
764
    cmd.config = config
765
    cmd.options = options
766
    
767
    if cmd.api in ('compute', 'image', 'storage', 'cyclades'):
768
        token = config.get('token')
769
        if cmd.api in ('compute', 'image', 'storage'):
770
            url = config.get(cmd.api + '_url')
771
        elif cmd.api == 'cyclades':
772
            url = config.get('compute_url')
773
        cls_name = cmd.api.capitalize() + 'Client'
774
        cmd.client = getattr(clients, cls_name)(url, token)
775
    
776
    try:
777
        ret = cmd.main(*args[3:])
778
        exit(ret)
779
    except TypeError as e:
780
        if e.args and e.args[0].startswith('main()'):
781
            parser.print_help()
782
            exit(1)
783
        else:
784
            raise
785
    except clients.ClientError, err:
786
        log.error('%s', err.message)
787
        log.info('%s', err.details)
788
        exit(2)
789

    
790

    
791
if __name__ == '__main__':
792
    main()