Revision 6a0b1658

b/kamaki/cli.py
77 77
from pwd import getpwuid
78 78
from sys import argv, exit, stdout
79 79

  
80
from clint.textui import puts, puts_err, indent
80
from clint import args
81
from clint.textui import puts, puts_err, indent, progress
82
from clint.textui.colored import magenta, red, yellow
81 83
from clint.textui.cols import columns
82 84

  
85
from requests.exceptions import ConnectionError
86

  
83 87
from kamaki import clients
84 88
from kamaki.config import Config
85 89
from kamaki.utils import OrderedDict, print_addresses, print_dict, print_items
......
116 120
        cls.api = api
117 121
        cls.group = group or grp
118 122
        cls.name = name or cmd
119
        cls.description = description or cls.__doc__
120
        cls.syntax = syntax
121 123
        
122 124
        short_description, sep, long_description = cls.__doc__.partition('\n')
123 125
        cls.description = short_description
......
194 196

  
195 197
@command(api='compute')
196 198
class server_list(object):
197
    """list servers"""
199
    """List servers"""
198 200
    
199 201
    def update_parser(cls, parser):
200 202
        parser.add_option('-l', dest='detail', action='store_true',
......
207 209

  
208 210
@command(api='compute')
209 211
class server_info(object):
210
    """get server details"""
212
    """Get server details"""
211 213
    
212 214
    def main(self, server_id):
213 215
        server = self.client.get_server_details(int(server_id))
......
216 218

  
217 219
@command(api='compute')
218 220
class server_create(object):
219
    """create server"""
221
    """Create a server"""
220 222
    
221 223
    def update_parser(cls, parser):
222 224
        parser.add_option('--personality', dest='personalities',
223
                action='append', default=[],
224
                metavar='PATH[,SERVER PATH[,OWNER[,GROUP,[MODE]]]]',
225
                help='add a personality file')
225
                          action='append', default=[],
226
                          metavar='PATH[,SERVER PATH[,OWNER[,GROUP,[MODE]]]]',
227
                          help='add a personality file')
226 228
        parser.epilog = "If missing, optional personality values will be " \
227
                "filled based on the file at PATH if missing."
229
                        "filled based on the file at PATH."
228 230
    
229 231
    def main(self, name, flavor_id, image_id):
230 232
        personalities = []
......
259 261

  
260 262
@command(api='compute')
261 263
class server_rename(object):
262
    """update server name"""
264
    """Update a server's name"""
263 265
    
264 266
    def main(self, server_id, new_name):
265 267
        self.client.update_server_name(int(server_id), new_name)
......
267 269

  
268 270
@command(api='compute')
269 271
class server_delete(object):
270
    """delete server"""
272
    """Delete a server"""
271 273
    
272 274
    def main(self, server_id):
273 275
        self.client.delete_server(int(server_id))
......
275 277

  
276 278
@command(api='compute')
277 279
class server_reboot(object):
278
    """reboot server"""
280
    """Reboot a server"""
279 281
    
280 282
    def update_parser(cls, parser):
281 283
        parser.add_option('-f', dest='hard', action='store_true',
......
287 289

  
288 290
@command(api='cyclades')
289 291
class server_start(object):
290
    """start server"""
292
    """Start a server"""
291 293
    
292 294
    def main(self, server_id):
293 295
        self.client.start_server(int(server_id))
......
295 297

  
296 298
@command(api='cyclades')
297 299
class server_shutdown(object):
298
    """shutdown server"""
300
    """Shutdown a server"""
299 301
    
300 302
    def main(self, server_id):
301 303
        self.client.shutdown_server(int(server_id))
......
303 305

  
304 306
@command(api='cyclades')
305 307
class server_console(object):
306
    """get a VNC console"""
308
    """Get a VNC console"""
307 309
    
308 310
    def main(self, server_id):
309 311
        reply = self.client.get_server_console(int(server_id))
......
312 314

  
313 315
@command(api='cyclades')
314 316
class server_firewall(object):
315
    """set the firewall profile"""
317
    """Set the server's firewall profile"""
316 318
    
317 319
    def main(self, server_id, profile):
318 320
        self.client.set_firewall_profile(int(server_id), profile)
......
320 322

  
321 323
@command(api='cyclades')
322 324
class server_addr(object):
323
    """list server addresses"""
325
    """List a server's addresses"""
324 326
    
325 327
    def main(self, server_id, network=None):
326 328
        reply = self.client.list_server_addresses(int(server_id), network)
......
330 332

  
331 333
@command(api='compute')
332 334
class server_meta(object):
333
    """get server metadata"""
335
    """Get a server's metadata"""
334 336
    
335 337
    def main(self, server_id, key=None):
336 338
        reply = self.client.get_server_metadata(int(server_id), key)
......
339 341

  
340 342
@command(api='compute')
341 343
class server_addmeta(object):
342
    """add server metadata"""
344
    """Add server metadata"""
343 345
    
344 346
    def main(self, server_id, key, val):
345 347
        reply = self.client.create_server_metadata(int(server_id), key, val)
......
348 350

  
349 351
@command(api='compute')
350 352
class server_setmeta(object):
351
    """update server metadata"""
353
    """Update server's metadata"""
352 354
    
353 355
    def main(self, server_id, key, val):
354 356
        metadata = {key: val}
......
358 360

  
359 361
@command(api='compute')
360 362
class server_delmeta(object):
361
    """delete server metadata"""
363
    """Delete server metadata"""
362 364
    
363 365
    def main(self, server_id, key):
364 366
        self.client.delete_server_metadata(int(server_id), key)
......
366 368

  
367 369
@command(api='cyclades')
368 370
class server_stats(object):
369
    """get server statistics"""
371
    """Get server statistics"""
370 372
    
371 373
    def main(self, server_id):
372 374
        reply = self.client.get_server_stats(int(server_id))
......
375 377

  
376 378
@command(api='compute')
377 379
class flavor_list(object):
378
    """list flavors"""
380
    """List flavors"""
379 381
    
380 382
    def update_parser(cls, parser):
381 383
        parser.add_option('-l', dest='detail', action='store_true',
......
388 390

  
389 391
@command(api='compute')
390 392
class flavor_info(object):
391
    """get flavor details"""
393
    """Get flavor details"""
392 394
    
393 395
    def main(self, flavor_id):
394 396
        flavor = self.client.get_flavor_details(int(flavor_id))
......
397 399

  
398 400
@command(api='compute')
399 401
class image_list(object):
400
    """list images"""
402
    """List images"""
401 403
    
402 404
    def update_parser(cls, parser):
403 405
        parser.add_option('-l', dest='detail', action='store_true',
......
410 412

  
411 413
@command(api='compute')
412 414
class image_info(object):
413
    """get image details"""
415
    """Get image details"""
414 416
    
415 417
    def main(self, image_id):
416 418
        image = self.client.get_image_details(image_id)
......
419 421

  
420 422
@command(api='compute')
421 423
class image_delete(object):
422
    """delete image"""
424
    """Delete image"""
423 425
    
424 426
    def main(self, image_id):
425 427
        self.client.delete_image(image_id)
......
427 429

  
428 430
@command(api='compute')
429 431
class image_meta(object):
430
    """get image metadata"""
432
    """Get image metadata"""
431 433
    
432 434
    def main(self, image_id, key=None):
433 435
        reply = self.client.get_image_metadata(image_id, key)
......
436 438

  
437 439
@command(api='compute')
438 440
class image_addmeta(object):
439
    """add image metadata"""
441
    """Add image metadata"""
440 442
    
441 443
    def main(self, image_id, key, val):
442 444
        reply = self.client.create_image_metadata(image_id, key, val)
......
445 447

  
446 448
@command(api='compute')
447 449
class image_setmeta(object):
448
    """update image metadata"""
450
    """Update image metadata"""
449 451
    
450 452
    def main(self, image_id, key, val):
451 453
        metadata = {key: val}
......
455 457

  
456 458
@command(api='compute')
457 459
class image_delmeta(object):
458
    """delete image metadata"""
460
    """Delete image metadata"""
459 461
    
460 462
    def main(self, image_id, key):
461 463
        self.client.delete_image_metadata(image_id, key)
......
463 465

  
464 466
@command(api='cyclades')
465 467
class network_list(object):
466
    """list networks"""
468
    """List networks"""
467 469
    
468 470
    def update_parser(cls, parser):
469 471
        parser.add_option('-l', dest='detail', action='store_true',
......
476 478

  
477 479
@command(api='cyclades')
478 480
class network_create(object):
479
    """create a network"""
481
    """Create a network"""
480 482
    
481 483
    def main(self, name):
482 484
        reply = self.client.create_network(name)
......
485 487

  
486 488
@command(api='cyclades')
487 489
class network_info(object):
488
    """get network details"""
490
    """Get network details"""
489 491
    
490 492
    def main(self, network_id):
491 493
        network = self.client.get_network_details(network_id)
......
494 496

  
495 497
@command(api='cyclades')
496 498
class network_rename(object):
497
    """update network name"""
499
    """Update network name"""
498 500
    
499 501
    def main(self, network_id, new_name):
500 502
        self.client.update_network_name(network_id, new_name)
......
502 504

  
503 505
@command(api='cyclades')
504 506
class network_delete(object):
505
    """delete a network"""
507
    """Delete a network"""
506 508
    
507 509
    def main(self, network_id):
508 510
        self.client.delete_network(network_id)
......
510 512

  
511 513
@command(api='cyclades')
512 514
class network_connect(object):
513
    """connect a server to a network"""
515
    """Connect a server to a network"""
514 516
    
515 517
    def main(self, server_id, network_id):
516 518
        self.client.connect_server(server_id, network_id)
......
518 520

  
519 521
@command(api='cyclades')
520 522
class network_disconnect(object):
521
    """disconnect a server from a network"""
523
    """Disconnect a server from a network"""
522 524
    
523 525
    def main(self, server_id, network_id):
524 526
        self.client.disconnect_server(server_id, network_id)
......
526 528

  
527 529
@command(api='image')
528 530
class glance_list(object):
529
    """list images"""
531
    """List images"""
530 532
    
531 533
    def update_parser(cls, parser):
532 534
        parser.add_option('-l', dest='detail', action='store_true',
......
562 564

  
563 565
@command(api='image')
564 566
class glance_meta(object):
565
    """get image metadata"""
567
    """Get image metadata"""
566 568
    
567 569
    def main(self, image_id):
568 570
        image = self.client.get_meta(image_id)
......
571 573

  
572 574
@command(api='image')
573 575
class glance_register(object):
574
    """register an image"""
576
    """Register an image"""
575 577
    
576 578
    def update_parser(cls, parser):
577 579
        parser.add_option('--checksum', dest='checksum', metavar='CHECKSUM',
......
595 597
    def main(self, name, location):
596 598
        params = {}
597 599
        for key in ('checksum', 'container_format', 'disk_format', 'id',
598
                    'owner', 'is_public', 'size'):
600
                    'owner', 'size'):
599 601
            val = getattr(self.options, key)
600 602
            if val is not None:
601 603
                params[key] = val
602 604
        
605
        if self.options.is_public:
606
            params['is_public'] = 'true'
607
        
603 608
        properties = {}
604 609
        for property in self.options.properties or []:
605 610
            key, sep, val = property.partition('=')
......
613 618

  
614 619
@command(api='image')
615 620
class glance_members(object):
616
    """get image members"""
621
    """Get image members"""
617 622
    
618 623
    def main(self, image_id):
619 624
        members = self.client.list_members(image_id)
......
623 628

  
624 629
@command(api='image')
625 630
class glance_shared(object):
626
    """list shared images"""
631
    """List shared images"""
627 632
    
628 633
    def main(self, member):
629 634
        images = self.client.list_shared(member)
......
633 638

  
634 639
@command(api='image')
635 640
class glance_addmember(object):
636
    """add a member to an image"""
641
    """Add a member to an image"""
637 642
    
638 643
    def main(self, image_id, member):
639 644
        self.client.add_member(image_id, member)
......
641 646

  
642 647
@command(api='image')
643 648
class glance_delmember(object):
644
    """remove a member from an image"""
649
    """Remove a member from an image"""
645 650
    
646 651
    def main(self, image_id, member):
647 652
        self.client.remove_member(image_id, member)
......
649 654

  
650 655
@command(api='image')
651 656
class glance_setmembers(object):
652
    """set the members of an image"""
657
    """Set the members of an image"""
653 658
    
654 659
    def main(self, image_id, *member):
655 660
        self.client.set_members(image_id, member)
......
660 665
    
661 666
    def update_parser(cls, parser):
662 667
        parser.add_option('--account', dest='account', metavar='NAME',
663
                help='use account NAME')
668
                          help="Specify an account to use")
664 669
        parser.add_option('--container', dest='container', metavar='NAME',
665
                help='use container NAME')
670
                          help="Specify a container to use")
666 671
    
667
    def main(self):
668
        self.config.override('storage_account', self.options.account)
669
        self.config.override('storage_container', self.options.container)
672
    def progress(self, message):
673
        """Return a generator function to be used for progress tracking"""
674
        
675
        MESSAGE_LENGTH = 25
676
        MAX_PROGRESS_LENGTH = 32
677
        
678
        def progress_gen(n):
679
            msg = message.ljust(MESSAGE_LENGTH)
680
            width = min(n, MAX_PROGRESS_LENGTH)
681
            hide = self.config.get('global', 'silent') or (n < 2)
682
            for i in progress.bar(range(n), msg, width, hide):
683
                yield
684
            yield
670 685
        
671
        # Use the more efficient Pithos client if available
672
        if 'pithos' in self.config.get('apis').split():
673
            self.client = clients.PithosClient(self.config)
686
        return progress_gen
687
    
688
    def main(self):
689
        if self.options.account is not None:
690
            self.client.account = self.options.account
691
        if self.options.container is not None:
692
            self.client.container = self.options.container
674 693

  
675 694

  
676 695
@command(api='storage')
677 696
class store_create(object):
678
    """create a container"""
697
    """Create a container"""
679 698
    
680 699
    def update_parser(cls, parser):
681
        parser.add_option('--account', dest='account', metavar='ACCOUNT',
682
                help='use account ACCOUNT')
700
        parser.add_option('--account', dest='account', metavar='NAME',
701
                          help="Specify an account to use")
683 702
    
684 703
    def main(self, container):
685
        self.config.override('storage_account', self.options.account)
704
        if self.options.account:
705
            self.client.account = self.options.account
686 706
        self.client.create_container(container)
687 707

  
688 708

  
689 709
@command(api='storage')
690
class store_container(store_command):
691
    """get container info"""
710
class store_container(object):
711
    """Get container info"""
692 712
    
693
    def main(self):
694
        store_command.main(self)
695
        reply = self.client.get_container_meta()
713
    def update_parser(cls, parser):
714
        parser.add_option('--account', dest='account', metavar='NAME',
715
                          help="Specify an account to use")
716
    
717
    def main(self, container):
718
        if self.options.account:
719
            self.client.account = self.options.account
720
        reply = self.client.get_container_meta(container)
696 721
        print_dict(reply)
697 722

  
698 723

  
699 724
@command(api='storage')
700 725
class store_upload(store_command):
701
    """upload a file"""
726
    """Upload a file"""
702 727
    
703 728
    def main(self, path, remote_path=None):
704
        store_command.main(self)
729
        super(store_upload, self).main()
730
        
705 731
        if remote_path is None:
706 732
            remote_path = basename(path)
707 733
        with open(path) as f:
708
            self.client.create_object(remote_path, f)
734
            hash_cb = self.progress('Calculating block hashes')
735
            upload_cb = self.progress('Uploading blocks')
736
            self.client.create_object(remote_path, f, hash_cb=hash_cb,
737
                                      upload_cb=upload_cb)
709 738

  
710 739

  
711 740
@command(api='storage')
712 741
class store_download(store_command):
713
    """download a file"""
714
    
715
    def main(self, remote_path, local_path):
716
        store_command.main(self)
717
        f = self.client.get_object(remote_path)
742
    """Download a file"""
743
        
744
    def main(self, remote_path, local_path='-'):
745
        super(store_download, self).main()
746
        
747
        f, size = self.client.get_object(remote_path)
718 748
        out = open(local_path, 'w') if local_path != '-' else stdout
719
        block = 4096
720
        data = f.read(block)
749
        
750
        blocksize = 4 * 1024**2
751
        nblocks = 1 + (size - 1) // blocksize
752
        
753
        cb = self.progress('Downloading blocks') if local_path != '-' else None
754
        if cb:
755
            gen = cb(nblocks)
756
            gen.next()
757
        
758
        data = f.read(blocksize)
721 759
        while data:
722 760
            out.write(data)
723
            data = f.read(block)
761
            data = f.read(blocksize)
762
            if cb:
763
                gen.next()
724 764

  
725 765

  
726 766
@command(api='storage')
727 767
class store_delete(store_command):
728
    """delete a file"""
768
    """Delete a file"""
729 769
    
730 770
    def main(self, path):
731 771
        store_command.main(self)
......
751 791
            puts(columns([name, 12], [cls.description, 60]))
752 792

  
753 793

  
794
def add_handler(name, level, prefix=''):
795
    h = logging.StreamHandler()
796
    fmt = logging.Formatter(prefix + '%(message)s')
797
    h.setFormatter(fmt)
798
    logger = logging.getLogger(name)
799
    logger.addHandler(h)
800
    logger.setLevel(level)
801

  
802

  
754 803
def main():
755 804
    parser = OptionParser(add_help_option=False)
756 805
    parser.usage = '%prog <group> <command> [options]'
......
759 808
                      help="Show this help message and exit")
760 809
    parser.add_option('--config', dest='config', metavar='PATH',
761 810
                      help="Specify the path to the configuration file")
811
    parser.add_option('-d', '--debug', dest='debug', action='store_true',
812
                      default=False,
813
                      help="Include debug output")
762 814
    parser.add_option('-i', '--include', dest='include', action='store_true',
763 815
                      default=False,
764 816
                      help="Include protocol headers in the output")
......
780 832
        print "kamaki %s" % kamaki.__version__
781 833
        exit(0)
782 834
    
783
    if args.contains(['-s', '--silent']):
784
        level = logging.CRITICAL
785
    elif args.contains(['-v', '--verbose']):
786
        level = logging.INFO
787
    else:
788
        level = logging.WARNING
789
    
790
    logging.basicConfig(level=level, format='%(message)s')
791
    
792 835
    if '--config' in args:
793 836
        config_path = args.grouped['--config'].get(0)
794 837
    else:
......
859 902
    if hasattr(cmd, 'update_parser'):
860 903
        cmd.update_parser(parser)
861 904
    
862
    if args.contains(['-h', '--help']):
905
    options, arguments = parser.parse_args(argv)
906
    
907
    if options.help:
863 908
        parser.print_help()
864 909
        exit(0)
865 910
    
866
    cmd.options, cmd.args = parser.parse_args(argv)
911
    if options.silent:
912
        add_handler('', logging.CRITICAL)
913
    elif options.debug:
914
        add_handler('requests', logging.INFO, prefix='* ')
915
        add_handler('clients.send', logging.DEBUG, prefix='> ')
916
        add_handler('clients.recv', logging.DEBUG, prefix='< ')
917
    elif options.verbose:
918
        add_handler('requests', logging.INFO, prefix='* ')
919
        add_handler('clients.send', logging.INFO, prefix='> ')
920
        add_handler('clients.recv', logging.INFO, prefix='< ')
921
    elif options.include:
922
        add_handler('clients.recv', logging.INFO)
923
    else:
924
        add_handler('', logging.WARNING)
867 925
    
868 926
    api = cmd.api
869
    if api == 'config':
870
        cmd.config = config
871
    elif api in ('compute', 'image', 'storage'):
872
        token = config.get(api, 'token') or config.get('gobal', 'token')
873
        url = config.get(api, 'url')
874
        client_cls = getattr(clients, api)
875
        kwargs = dict(base_url=url, token=token)
876
        
877
        # Special cases
878
        if api == 'compute' and config.getboolean(api, 'cyclades_extensions'):
879
            client_cls = clients.cyclades
880
        elif api == 'storage':
881
            kwargs['account'] = config.get(api, 'account')
882
            kwargs['container'] = config.get(api, 'container')
883
            if config.getboolean(api, 'pithos_extensions'):
884
                client_cls = clients.pithos
885
        
886
        cmd.client = client_cls(**kwargs)
887
        
927
    if api in ('compute', 'cyclades'):
928
        url = config.get('compute', 'url')
929
        token = config.get('compute', 'token') or config.get('global', 'token')
930
        if config.getboolean('compute', 'cyclades_extensions'):
931
            cmd.client = clients.cyclades(url, token)
932
        else:
933
            cmd.client = clients.compute(url, token)
934
    elif api in ('storage', 'pithos'):
935
        url = config.get('storage', 'url')
936
        token = config.get('storage', 'token') or config.get('global', 'token')
937
        account = config.get('storage', 'account')
938
        container = config.get('storage', 'container')
939
        if config.getboolean('storage', 'pithos_extensions'):
940
            cmd.client = clients.pithos(url, token, account, container)
941
        else:
942
            cmd.client = clients.storage(url, token, account, container)
943
    elif api == 'image':
944
        url = config.get('image', 'url')
945
        token = config.get('image', 'token') or config.get('global', 'token')
946
        cmd.client = clients.image(url, token)
947
    
948
    cmd.options = options
949
    cmd.config = config
950
    
888 951
    try:
889
        ret = cmd.main(*args.grouped['_'][2:])
952
        ret = cmd.main(*arguments[3:])
890 953
        exit(ret)
891 954
    except TypeError as e:
892 955
        if e.args and e.args[0].startswith('main()'):
......
894 957
            exit(1)
895 958
        else:
896 959
            raise
897
    except clients.ClientError, err:
898
        log.error('%s', err.message)
899
        log.info('%s', err.details)
960
    except clients.ClientError as err:
961
        if err.status == 404:
962
            color = yellow
963
        elif 500 <= err.status < 600:
964
            color = magenta
965
        else:
966
            color = red
967
        
968
        puts_err(color(err.message))
969
        if err.details and (options.verbose or options.debug):
970
            puts_err(err.details)
900 971
        exit(2)
972
    except ConnectionError as err:
973
        puts_err(red("Connection error"))
974
        exit(1)
901 975

  
902 976

  
903 977
if __name__ == '__main__':
b/kamaki/clients/__init__.py
31 31
# interpreted as representing official policies, either expressed
32 32
# or implied, of GRNET S.A.
33 33

  
34
import json
35
import logging
36

  
37
import requests
38

  
39
from requests.auth import AuthBase
40

  
41

  
42
sendlog = logging.getLogger('clients.send')
43
recvlog = logging.getLogger('clients.recv')
44

  
45

  
46
# Add a convenience json property to the responses
47
def _json(self):
48
    try:
49
        return json.loads(self.content)
50
    except ValueError:
51
        raise ClientError("Invalid JSON reply", self.status_code)
52
requests.Response.json = property(_json)
53

  
54
# Add a convenience status property to the responses
55
def _status(self):
56
    return requests.status_codes._codes[self.status_code][0].upper()
57
requests.Response.status = property(_status)
58

  
59

  
60
class XAuthTokenAuth(AuthBase):
61
    def __init__(self, token):
62
        self.token = token
63
    
64
    def __call__(self, r):
65
        r.headers['X-Auth-Token'] = self.token
66
        return r
67

  
68

  
34 69
class ClientError(Exception):
35 70
    def __init__(self, message, status=0, details=''):
36 71
        self.message = message
37 72
        self.status = status
38 73
        self.details = details
39 74

  
40
    def __int__(self):
41
        return int(self.status)
42 75

  
43
    def __str__(self):
44
        r = self.message
45
        if self.status:
46
            r += "\nHTTP Status: %d" % self.status
47
        if self.details:
48
            r += "\nDetails: \n%s" % self.details
76
class Client(object):
77
    def __init__(self, base_url, token, include=False, verbose=False):
78
        self.base_url = base_url
79
        self.auth = XAuthTokenAuth(token)
80
        self.include = include
81
        self.verbose = verbose
82
    
83
    def raise_for_status(self, r):
84
        if 400 <= r.status_code < 500:
85
            message, sep, details = r.text.partition('\n')
86
        elif 500 <= r.status_code < 600:
87
            message = '%d Server Error' % (r.status_code,)
88
            details = r.text
89
        else:
90
            message = '%d Unknown Error' % (r.status_code,)
91
            details = r.text
92
        
93
        message = message or "HTTP Error %d" % (r.status_code,)
94
        raise ClientError(message, r.status_code, details)
95

  
96
    def request(self, method, path, **kwargs):
97
        raw = kwargs.pop('raw', False)
98
        success = kwargs.pop('success', 200)
99
        if 'json' in kwargs:
100
            data = json.dumps(kwargs.pop('json'))
101
            kwargs['data'] = data
102
            headers = kwargs.setdefault('headers', {})
103
            headers['content-type'] = 'application/json'
104

  
105
        url = self.base_url + path
106
        kwargs.setdefault('auth', self.auth)
107
        r = requests.request(method, url, **kwargs)
108
        
109
        req = r.request
110
        sendlog.info('%s %s', req.method, req.url)
111
        for key, val in req.headers.items():
112
            sendlog.info('%s: %s', key, val)
113
        sendlog.info('')
114
        if req.data:
115
            sendlog.info('%s', req.data)
116
        
117
        recvlog.info('%d %s', r.status_code, r.status)
118
        for key, val in r.headers.items():
119
            recvlog.info('%s: %s', key, val)
120
        recvlog.info('')
121
        if not raw and r.text:
122
            recvlog.debug(r.text)
123
        
124
        if success is not None:
125
            # Success can either be an in or a collection
126
            success = (success,) if isinstance(success, int) else success
127
            if r.status_code not in success:
128
                self.raise_for_status(r)
129

  
49 130
        return r
50 131

  
132
    def delete(self, path, **kwargs):
133
        return self.request('delete', path, **kwargs)
134

  
135
    def get(self, path, **kwargs):
136
        return self.request('get', path, **kwargs)
137

  
138
    def head(self, path, **kwargs):
139
        return self.request('head', path, **kwargs)
140

  
141
    def post(self, path, **kwargs):
142
        return self.request('post', path, **kwargs)
143

  
144
    def put(self, path, **kwargs):
145
        return self.request('put', path, **kwargs)
146

  
51 147

  
52
from .compute import ComputeClient
53
from .image import ImageClient
54
from .storage import StorageClient
55
from .cyclades import CycladesClient
56
from .pithos import PithosClient
148
from .compute import ComputeClient as compute
149
from .image import ImageClient as image
150
from .storage import StorageClient as storage
151
from .cyclades import CycladesClient as cyclades
152
from .pithos import PithosClient as pithos
b/kamaki/clients/compute.py
31 31
# interpreted as representing official policies, either expressed
32 32
# or implied, of GRNET S.A.
33 33

  
34
import json
34
from . import Client, ClientError
35 35

  
36
from . import ClientError
37
from .http import HTTPClient
38 36

  
39

  
40
class ComputeClient(HTTPClient):
37
class ComputeClient(Client):
41 38
    """OpenStack Compute API 1.1 client"""
42 39
    
43
    @property
44
    def url(self):
45
        url = self.config.get('compute_url') or self.config.get('url')
46
        if not url:
47
            raise ClientError('No URL was given')
48
        return url
49
    
50
    @property
51
    def token(self):
52
        token = self.config.get('compute_token') or self.config.get('token')
53
        if not token:
54
            raise ClientError('No token was given')
55
        return token
40
    def raise_for_status(self, r):
41
        d = r.json
42
        key = d.keys()[0]
43
        val = d[key]
44
        message = '%s: %s' % (key, val.get('message', ''))
45
        details = val.get('details', '')
46
        raise ClientError(message, r.status_code, details)
56 47
    
57 48
    def list_servers(self, detail=False):
58 49
        """List servers, returned detailed output if detailed is True"""
50
        
59 51
        path = '/servers/detail' if detail else '/servers'
60
        reply = self.http_get(path)
61
        return reply['servers']['values']
52
        r = self.get(path, success=200)
53
        return r.json['servers']['values']
62 54
    
63 55
    def get_server_details(self, server_id):
64 56
        """Return detailed output on a server specified by its id"""
65
        path = '/servers/%d' % server_id
66
        reply = self.http_get(path)
67
        return reply['server']
57
        
58
        path = '/servers/%s' % (server_id,)
59
        r = self.get(path, success=200)
60
        return r.json['server']
68 61
    
69 62
    def create_server(self, name, flavor_id, image_id, personality=None):
70 63
        """Submit request to create a new server
......
78 71

  
79 72
        The call returns a dictionary describing the newly created server.
80 73
        """
81
        req = {'name': name, 'flavorRef': flavor_id, 'imageRef': image_id}
74
        req = {'server': {'name': name,
75
                          'flavorRef': flavor_id,
76
                          'imageRef': image_id}}
82 77
        if personality:
83 78
            req['personality'] = personality
84 79
        
85
        body = json.dumps({'server': req})
86
        reply = self.http_post('/servers', body)
87
        return reply['server']
80
        r = self.post('/servers', json=req, success=202)
81
        return r.json['server']
88 82
    
89 83
    def update_server_name(self, server_id, new_name):
90 84
        """Update the name of the server as reported by the API.
......
92 86
        This call does not modify the hostname actually used by the server
93 87
        internally.
94 88
        """
95
        path = '/servers/%d' % server_id
96
        body = json.dumps({'server': {'name': new_name}})
97
        self.http_put(path, body)
89
        path = '/servers/%s' % (server_id,)
90
        req = {'server': {'name': new_name}}
91
        self.put(path, json=req, success=204)
98 92
    
99 93
    def delete_server(self, server_id):
100 94
        """Submit a deletion request for a server specified by id"""
101
        path = '/servers/%d' % server_id
102
        self.http_delete(path)
95
        
96
        path = '/servers/%s' % (server_id,)
97
        self.delete(path, success=204)
103 98
    
104 99
    def reboot_server(self, server_id, hard=False):
105 100
        """Submit a reboot request for a server specified by id"""
106
        path = '/servers/%d/action' % server_id
107
        type = 'HARD' if hard else 'SOFT'
108
        body = json.dumps({'reboot': {'type': type}})
109
        self.http_post(path, body)
110 101
        
102
        path = '/servers/%s/action' % (server_id,)
103
        type = 'HARD' if hard else 'SOFT'
104
        req = {'reboot': {'type': type}}
105
        self.post(path, json=req, success=202)
106
    
111 107
    def get_server_metadata(self, server_id, key=None):
112
        path = '/servers/%d/meta' % server_id
108
        path = '/servers/%s/meta' % (server_id,)
113 109
        if key:
114 110
            path += '/%s' % key
115
        reply = self.http_get(path)
116
        return reply['meta'] if key else reply['metadata']['values']
111
        r = self.get(path, success=200)
112
        return r.json['meta'] if key else r.json['metadata']['values']
117 113
    
118 114
    def create_server_metadata(self, server_id, key, val):
119 115
        path = '/servers/%d/meta/%s' % (server_id, key)
120
        body = json.dumps({'meta': {key: val}})
121
        reply = self.http_put(path, body, success=201)
122
        return reply['meta']
116
        req = {'meta': {key: val}}
117
        r = self.put(path, json=req, success=201)
118
        return r.json['meta']
123 119
    
124 120
    def update_server_metadata(self, server_id, **metadata):
125
        path = '/servers/%d/meta' % server_id
126
        body = json.dumps({'metadata': metadata})
127
        reply = self.http_post(path, body, success=201)
128
        return reply['metadata']
121
        path = '/servers/%d/meta' % (server_id,)
122
        req = {'metadata': metadata}
123
        r = self.post(path, json=req, success=201)
124
        return r.json['metadata']
129 125
    
130 126
    def delete_server_metadata(self, server_id, key):
131 127
        path = '/servers/%d/meta/%s' % (server_id, key)
132
        reply = self.http_delete(path)
133
        
134
        
128
        self.delete(path, success=204)
129
    
130
    
135 131
    def list_flavors(self, detail=False):
136 132
        path = '/flavors/detail' if detail else '/flavors'
137
        reply = self.http_get(path)
138
        return reply['flavors']['values']
133
        r = self.get(path, success=200)
134
        return r.json['flavors']['values']
139 135

  
140 136
    def get_flavor_details(self, flavor_id):
141 137
        path = '/flavors/%d' % flavor_id
142
        reply = self.http_get(path)
143
        return reply['flavor']
138
        r = self.get(path, success=200)
139
        return r.json['flavor']
144 140
    
145 141
    
146 142
    def list_images(self, detail=False):
147 143
        path = '/images/detail' if detail else '/images'
148
        reply = self.http_get(path)
149
        return reply['images']['values']
144
        r = self.get(path, success=200)
145
        return r.json['images']['values']
150 146
    
151 147
    def get_image_details(self, image_id):
152
        path = '/images/%s' % image_id
153
        reply = self.http_get(path)
154
        return reply['image']
148
        path = '/images/%s' % (image_id,)
149
        r = self.get(path, success=200)
150
        return r.json['image']
155 151
    
156 152
    def delete_image(self, image_id):
157
        path = '/images/%s' % image_id
158
        self.http_delete(path)
153
        path = '/images/%s' % (image_id,)
154
        self.delete(path, success=204)
159 155

  
160 156
    def get_image_metadata(self, image_id, key=None):
161
        path = '/images/%s/meta' % image_id
157
        path = '/images/%s/meta' % (image_id,)
162 158
        if key:
163 159
            path += '/%s' % key
164
        reply = self.http_get(path)
165
        return reply['meta'] if key else reply['metadata']['values']
160
        r = self.get(path, success=200)
161
        return r.json['meta'] if key else r.json['metadata']['values']
166 162
    
167 163
    def create_image_metadata(self, image_id, key, val):
168 164
        path = '/images/%s/meta/%s' % (image_id, key)
169
        body = json.dumps({'meta': {key: val}})
170
        reply = self.http_put(path, body, success=201)
171
        return reply['meta']
165
        req = {'meta': {key: val}}
166
        r = self.put(path, json=req, success=201)
167
        return r.json['meta']
172 168

  
173 169
    def update_image_metadata(self, image_id, **metadata):
174
        path = '/images/%s/meta' % image_id
175
        body = json.dumps({'metadata': metadata})
176
        reply = self.http_post(path, body, success=201)
177
        return reply['metadata']
170
        path = '/images/%s/meta' % (image_id,)
171
        req = {'metadata': metadata}
172
        r = self.post(path, json=req, success=201)
173
        return r.json['metadata']
178 174

  
179 175
    def delete_image_metadata(self, image_id, key):
180 176
        path = '/images/%s/meta/%s' % (image_id, key)
181
        self.http_delete(path)
177
        self.delete(path, success=204)
b/kamaki/clients/cyclades.py
31 31
# interpreted as representing official policies, either expressed
32 32
# or implied, of GRNET S.A.
33 33

  
34
import json
35

  
36 34
from .compute import ComputeClient
37 35

  
38 36

  
......
41 39
    
42 40
    def start_server(self, server_id):
43 41
        """Submit a startup request for a server specified by id"""
44
        path = '/servers/%d/action' % server_id
45
        body = json.dumps({'start': {}})
46
        self.http_post(path, body)
42
        
43
        path = '/servers/%s/action' % (server_id,)
44
        req = {'start': {}}
45
        self.post(path, json=req, success=202)
47 46
    
48 47
    def shutdown_server(self, server_id):
49 48
        """Submit a shutdown request for a server specified by id"""
50
        path = '/servers/%d/action' % server_id
51
        body = json.dumps({'shutdown': {}})
52
        self.http_post(path, body)
49
        
50
        path = '/servers/%s/action' % (server_id,)
51
        req = {'shutdown': {}}
52
        self.post(path, json=req, success=202)
53 53
    
54 54
    def get_server_console(self, server_id):
55 55
        """Get a VNC connection to the console of a server specified by id"""
56
        path = '/servers/%d/action' % server_id
57
        body = json.dumps({'console': {'type': 'vnc'}})
58
        reply = self.http_post(path, body, success=200)
59
        return reply['console']
56
        
57
        path = '/servers/%s/action' % (server_id,)
58
        req = {'console': {'type': 'vnc'}}
59
        r = self.post(path, json=req, success=200)
60
        return r.json['console']
60 61
    
61 62
    def set_firewall_profile(self, server_id, profile):
62 63
        """Set the firewall profile for the public interface of a server
......
64 65
        The server is specified by id, the profile argument
65 66
        is one of (ENABLED, DISABLED, PROTECTED).
66 67
        """
67
        path = '/servers/%d/action' % server_id
68
        body = json.dumps({'firewallProfile': {'profile': profile}})
69
        self.http_post(path, body)
68
        path = '/servers/%s/action' % (server_id,)
69
        req = {'firewallProfile': {'profile': profile}}
70
        self.post(path, json=req, success=202)
70 71
    
71 72
    def list_server_addresses(self, server_id, network=None):
72
        path = '/servers/%d/ips' % server_id
73
        path = '/servers/%s/ips' % (server_id,)
73 74
        if network:
74 75
            path += '/%s' % network
75
        reply = self.http_get(path)
76
        return [reply['network']] if network else reply['addresses']['values']
76
        r = self.get(path, success=200)
77
        if network:
78
            return [r.json['network']]
79
        else:
80
            return r.json['addresses']['values']
77 81
    
78 82
    def get_server_stats(self, server_id):
79
        path = '/servers/%d/stats' % server_id
80
        reply = self.http_get(path)
81
        return reply['stats']
83
        path = '/servers/%s/stats' % (server_id,)
84
        r = self.get(path, success=200)
85
        return r.json['stats']
82 86
    
83 87
    
84 88
    def list_networks(self, detail=False):
85 89
        path = '/networks/detail' if detail else '/networks'
86
        reply = self.http_get(path)
87
        return reply['networks']['values']
90
        r = self.get(path, success=200)
91
        return r.json['networks']['values']
88 92

  
89 93
    def create_network(self, name):
90
        body = json.dumps({'network': {'name': name}})
91
        reply = self.http_post('/networks', body)
92
        return reply['network']
94
        req = {'network': {'name': name}}
95
        r = self.post('/networks', json=req, success=202)
96
        return r.json['network']
93 97

  
94 98
    def get_network_details(self, network_id):
95
        path = '/networks/%s' % network_id
96
        reply = self.http_get(path)
97
        return reply['network']
99
        path = '/networks/%s' % (network_id,)
100
        r = self.get(path, success=200)
101
        return r.json['network']
98 102

  
99 103
    def update_network_name(self, network_id, new_name):
100
        path = '/networks/%s' % network_id
101
        body = json.dumps({'network': {'name': new_name}})
102
        self.http_put(path, body)
104
        path = '/networks/%s' % (network_id,)
105
        req = {'network': {'name': new_name}}
106
        self.put(path, json=req, success=204)
103 107

  
104 108
    def delete_network(self, network_id):
105
        path = '/networks/%s' % network_id
106
        self.http_delete(path)
109
        path = '/networks/%s' % (network_id,)
110
        self.delete(path, success=204)
107 111

  
108 112
    def connect_server(self, server_id, network_id):
109
        path = '/networks/%s/action' % network_id
110
        body = json.dumps({'add': {'serverRef': server_id}})
111
        self.http_post(path, body)
113
        path = '/networks/%s/action' % (network_id,)
114
        req = {'add': {'serverRef': server_id}}
115
        self.post(path, json=req, success=202)
112 116

  
113 117
    def disconnect_server(self, server_id, network_id):
114
        path = '/networks/%s/action' % network_id
115
        body = json.dumps({'remove': {'serverRef': server_id}})
116
        self.http_post(path, body)
118
        path = '/networks/%s/action' % (network_id,)
119
        req = {'remove': {'serverRef': server_id}}
120
        self.post(path, json=req, success=202)
b/kamaki/clients/pithos.py
32 32
# or implied, of GRNET S.A.
33 33

  
34 34
import hashlib
35
import json
35
import os
36 36

  
37
from . import ClientError
38
from .storage import StorageClient
39 37
from ..utils import OrderedDict
40 38

  
39
from .storage import StorageClient
40

  
41

  
42
def pithos_hash(block, blockhash):
43
    h = hashlib.new(blockhash)
44
    h.update(block.rstrip('\x00'))
45
    return h.hexdigest()
46

  
41 47

  
42 48
class PithosClient(StorageClient):
43 49
    """GRNet Pithos API client"""
44 50
    
45 51
    def put_block(self, data, hash):
46
        path = '/%s/%s?update' % (self.account, self.container)
52
        path = '/%s/%s' % (self.account, self.container)
53
        params = {'update': ''}
47 54
        headers = {'Content-Type': 'application/octet-stream',
48
                   'Content-Length': len(data)}
49
        resp, reply = self.raw_http_cmd('POST', path, data, headers,
50
                                        success=202)
51
        assert reply.strip() == hash, 'Local hash does not match server'
55
                   'Content-Length': str(len(data))}
56
        r = self.post(path, params=params, data=data, headers=headers,
57
                      success=202)
58
        assert r.text.strip() == hash, 'Local hash does not match server'
52 59
    
53
    def create_object(self, object, f):
54
        meta = self.get_container_meta()
60
    def create_object(self, object, f, hash_cb=None, upload_cb=None):
61
        """Create an object by uploading only the missing blocks
62
        
63
        hash_cb is a generator function taking the total number of blocks to
64
        be hashed as an argument. Its next() will be called every time a block
65
        is hashed.
66
        
67
        upload_cb is a generator function with the same properties that is
68
        called every time a block is uploaded.
69
        """
70
        self.assert_container()
71
        
72
        meta = self.get_container_meta(self.container)
55 73
        blocksize = int(meta['block-size'])
56 74
        blockhash = meta['block-hash']
57 75
        
58
        size = 0
76
        file_size = os.fstat(f.fileno()).st_size
77
        nblocks = 1 + (file_size - 1) // blocksize
59 78
        hashes = OrderedDict()
60
        data = f.read(blocksize)
61
        while data:
62
            bytes = len(data)
63
            h = hashlib.new(blockhash)
64
            h.update(data.rstrip('\x00'))
65
            hash = h.hexdigest()
79
        
80
        size = 0
81
        
82
        if hash_cb:
83
            hash_gen = hash_cb(nblocks)
84
            hash_gen.next()
85
        for i in range(nblocks):
86
            block = f.read(blocksize)
87
            bytes = len(block)
88
            hash = pithos_hash(block, blockhash)
66 89
            hashes[hash] = (size, bytes)
67 90
            size += bytes
68
            data = f.read(blocksize)
91
            if hash_cb:
92
                hash_gen.next()
93
        
94
        assert size == file_size
69 95
                
70
        path = '/%s/%s/%s?hashmap&format=json' % (self.account, self.container,
71
                                                  object)
96
        path = '/%s/%s/%s' % (self.account, self.container, object)
97
        params = {'hashmap': '', 'format': 'json'}
72 98
        hashmap = dict(bytes=size, hashes=hashes.keys())
73
        req = json.dumps(hashmap)
74
        resp, reply = self.raw_http_cmd('PUT', path, req, success=None)
75
        
76
        if resp.status not in (201, 409):
77
            raise ClientError('Invalid response from the server')
99
        r = self.put(path, params=params, json=hashmap, success=(201, 409))
78 100
        
79
        if resp.status == 201:
101
        if r.status_code == 201:
80 102
            return
81 103
        
82
        missing = json.loads(reply)
104
        missing = r.json
83 105
        
106
        if upload_cb:
107
            upload_gen = upload_cb(len(missing))
108
            upload_gen.next()
84 109
        for hash in missing:
85 110
            offset, bytes = hashes[hash]
86 111
            f.seek(offset)
87 112
            data = f.read(bytes)
88 113
            self.put_block(data, hash)
114
            if upload_cb:
115
                upload_gen.next()
89 116
        
90
        self.http_put(path, req, success=201)
117
        self.put(path, params=params, json=hashmap, success=201)
b/kamaki/clients/storage.py
31 31
# interpreted as representing official policies, either expressed
32 32
# or implied, of GRNET S.A.
33 33

  
34
from . import ClientError
35
from .http import HTTPClient
34
from . import Client, ClientError
36 35

  
37 36

  
38
class StorageClient(HTTPClient):
37
class StorageClient(Client):
39 38
    """OpenStack Object Storage API 1.0 client"""
40 39
    
41
    @property
42
    def url(self):
43
        url = self.config.get('storage_url') or self.config.get('url')
44
        if not url:
45
            raise ClientError('No URL was given')
46
        return url
47

  
48
    @property
49
    def token(self):
50
        token = self.config.get('storage_token') or self.config.get('token')
51
        if not token:
52
            raise ClientError('No token was given')
53
        return token
40
    def __init__(self, base_url, token, account=None, container=None):
41
        super(StorageClient, self).__init__(base_url, token)
42
        self.account = account
43
        self.container = container
54 44
    
55
    @property
56
    def account(self):
57
        account = self.config.get('storage_account')
58
        if not account:
59
            raise ClientError('No account was given')
60
        return account
45
    def assert_account(self):
46
        if not self.account:
47
            raise ClientError("Please provide an account")
61 48
    
62
    @property
63
    def container(self):
64
        container = self.config.get('storage_container')
65
        if not container:
66
            raise ClientError('No container was given')
67
        return container
49
    def assert_container(self):
50
        self.assert_account()
51
        if not self.container:
52
            raise ClientError("Please provide a container")
68 53
    
69 54
    def create_container(self, container):
55
        self.assert_account()
70 56
        path = '/%s/%s' % (self.account, container)
71
        self.http_put(path, success=201)
57
        r = self.put(path, success=(201, 202))
58
        if r.status_code == 202:
59
            raise ClientError("Container already exists")
72 60
    
73
    def get_container_meta(self):
74
        path = '/%s/%s' % (self.account, self.container)
75
        resp, reply = self.raw_http_cmd('HEAD', path, success=204)
61
    def get_container_meta(self, container):
62
        self.assert_account()
63
        path = '/%s/%s' % (self.account, container)
64
        r = self.head(path, success=(204, 404))
65
        if r.status_code == 404:
66
            raise ClientError("Container does not exist", r.status_code)
67
        
76 68
        reply = {}
77 69
        prefix = 'x-container-'
78
        for key, val in resp.getheaders():
70
        for key, val in r.headers.items():
79 71
            key = key.lower()
80 72
            if key.startswith(prefix):
81 73
                reply[key[len(prefix):]] = val
74
        
82 75
        return reply
83 76
    
84
    def create_object(self, object, f):
77
    def create_object(self, object, f, hash_cb=None, upload_cb=None):
78
        # This is a naive implementation, it loads the whole file in memory
79
        self.assert_container()
85 80
        path = '/%s/%s/%s' % (self.account, self.container, object)
86 81
        data = f.read()
87
        self.http_put(path, data, success=201)
88

  
82
        self.put(path, data=data, success=201)
83
    
89 84
    def get_object(self, object):
85
        self.assert_container()
90 86
        path = '/%s/%s/%s' % (self.account, self.container, object)
91
        resp, reply = self.raw_http_cmd('GET', path, success=200,
92
                skip_read=True)
93
        return resp.fp
94

  
87
        r = self.get(path, raw=True)
88
        size = int(r.headers['content-length'])
89
        return r.raw, size
90
    
95 91
    def delete_object(self, object):
92
        self.assert_container()
96 93
        path = '/%s/%s/%s' % (self.account, self.container, object)
97
        self.http_delete(path)
94
        self.delete(path, success=204)
b/setup.py
51 51
        'console_scripts': ['kamaki = kamaki.cli:main']
52 52
    },
53 53
    install_requires=[
54
        'requests>=0.10.2',
54 55
        'clint>=0.3'
55 56
    ]
56 57
)

Also available in: Unified diff