Modularize clients
authorGiorgos Verigakis <verigak@gmail.com>
Thu, 19 Jan 2012 11:08:46 +0000 (13:08 +0200)
committerGiorgos Verigakis <verigak@gmail.com>
Thu, 19 Jan 2012 11:08:46 +0000 (13:08 +0200)
kamaki/cli.py
kamaki/client.py [deleted file]
kamaki/clients/__init__.py [new file with mode: 0644]
kamaki/clients/compute.py [new file with mode: 0644]
kamaki/clients/cyclades.py [new file with mode: 0644]
kamaki/clients/http.py [new file with mode: 0644]
kamaki/clients/image.py [new file with mode: 0644]
kamaki/clients/storage.py [new file with mode: 0644]
kamaki/config.py

index e2c2598..e277324 100755 (executable)
@@ -43,12 +43,12 @@ The name of the class is important and it will determine the name and grouping
 of the command. This behavior can be overriden with the 'group' and 'name'
 decorator arguments:
 
-    @command(api='nova')
+    @command(api='compute')
     class server_list(object):
         # This command will be named 'list' under group 'server'
         ...
 
-    @command(api='nova', name='ls')
+    @command(api='compute', name='ls')
     class server_list(object):
         # This command will be named 'ls' under group 'server'
         ...
@@ -69,32 +69,19 @@ The order of commands is important, it will be preserved in the help output.
 import inspect
 import logging
 import os
-import sys
 
 from base64 import b64encode
 from grp import getgrgid
 from optparse import OptionParser
+from os.path import abspath, basename, exists
 from pwd import getpwuid
+from sys import argv, exit
 
-from kamaki.client import ComputeClient, GlanceClient, ClientError
+from kamaki import clients
 from kamaki.config import Config, ConfigError
 from kamaki.utils import OrderedDict, print_addresses, print_dict, print_items
 
 
-# Path to the file that stores the configuration
-CONFIG_PATH = os.path.expanduser('~/.kamakirc')
-
-# Name of a shell variable to bypass the CONFIG_PATH value
-CONFIG_ENV = 'KAMAKI_CONFIG'
-
-# The defaults also determine the allowed keys
-CONFIG_DEFAULTS = {
-    'apis': 'nova synnefo glance',
-    'token': '',
-    'compute_url': 'https://okeanos.grnet.gr/api/v1',
-    'images_url': 'https://okeanos.grnet.gr/plankton',
-}
-
 log = logging.getLogger('kamaki')
 
 _commands = OrderedDict()
@@ -167,7 +154,7 @@ class config_del(object):
         self.config.delete(key)
 
 
-@command(api='nova')
+@command(api='compute')
 class server_list(object):
     """list servers"""
     
@@ -181,7 +168,7 @@ class server_list(object):
         print_items(servers)
 
 
-@command(api='nova')
+@command(api='compute')
 class server_info(object):
     """get server details"""
     
@@ -190,7 +177,7 @@ class server_info(object):
         print_dict(server)
 
 
-@command(api='nova')
+@command(api='compute')
 class server_create(object):
     """create server"""
     
@@ -214,7 +201,7 @@ class server_create(object):
             if not path:
                 log.error("Invalid personality argument '%s'", p)
                 return 1
-            if not os.path.exists(path):
+            if not exists(path):
                 log.error("File %s does not exist", path)
                 return 1
             
@@ -223,7 +210,7 @@ class server_create(object):
             
             st = os.stat(path)
             personalities.append({
-                'path': p[1] or os.path.abspath(path),
+                'path': p[1] or abspath(path),
                 'owner': p[2] or getpwuid(st.st_uid).pw_name,
                 'group': p[3] or getgrgid(st.st_gid).gr_name,
                 'mode': int(p[4]) if p[4] else 0x7777 & st.st_mode,
@@ -234,7 +221,7 @@ class server_create(object):
         print_dict(reply)
 
 
-@command(api='nova')
+@command(api='compute')
 class server_rename(object):
     """update server name"""
     
@@ -242,7 +229,7 @@ class server_rename(object):
         self.client.update_server_name(int(server_id), new_name)
 
 
-@command(api='nova')
+@command(api='compute')
 class server_delete(object):
     """delete server"""
     
@@ -250,7 +237,7 @@ class server_delete(object):
         self.client.delete_server(int(server_id))
 
 
-@command(api='nova')
+@command(api='compute')
 class server_reboot(object):
     """reboot server"""
     
@@ -263,7 +250,7 @@ class server_reboot(object):
         self.client.reboot_server(int(server_id), self.options.hard)
 
 
-@command(api='synnefo')
+@command(api='asterias')
 class server_start(object):
     """start server"""
     
@@ -271,7 +258,7 @@ class server_start(object):
         self.client.start_server(int(server_id))
 
 
-@command(api='synnefo')
+@command(api='asterias')
 class server_shutdown(object):
     """shutdown server"""
     
@@ -279,7 +266,7 @@ class server_shutdown(object):
         self.client.shutdown_server(int(server_id))
 
 
-@command(api='synnefo')
+@command(api='asterias')
 class server_console(object):
     """get a VNC console"""
     
@@ -288,7 +275,7 @@ class server_console(object):
         print_dict(reply)
 
 
-@command(api='synnefo')
+@command(api='asterias')
 class server_firewall(object):
     """set the firewall profile"""
     
@@ -296,7 +283,7 @@ class server_firewall(object):
         self.client.set_firewall_profile(int(server_id), profile)
 
 
-@command(api='synnefo')
+@command(api='asterias')
 class server_addr(object):
     """list server addresses"""
     
@@ -306,7 +293,7 @@ class server_addr(object):
         print_addresses(reply, margin)
 
 
-@command(api='nova')
+@command(api='compute')
 class server_meta(object):
     """get server metadata"""
     
@@ -315,7 +302,7 @@ class server_meta(object):
         print_dict(reply)
 
 
-@command(api='nova')
+@command(api='compute')
 class server_addmeta(object):
     """add server metadata"""
     
@@ -324,7 +311,7 @@ class server_addmeta(object):
         print_dict(reply)
 
 
-@command(api='nova')
+@command(api='compute')
 class server_setmeta(object):
     """update server metadata"""
     
@@ -334,7 +321,7 @@ class server_setmeta(object):
         print_dict(reply)
 
 
-@command(api='nova')
+@command(api='compute')
 class server_delmeta(object):
     """delete server metadata"""
     
@@ -342,7 +329,7 @@ class server_delmeta(object):
         self.client.delete_server_metadata(int(server_id), key)
 
 
-@command(api='synnefo')
+@command(api='asterias')
 class server_stats(object):
     """get server statistics"""
     
@@ -351,7 +338,7 @@ class server_stats(object):
         print_dict(reply, exclude=('serverRef',))
 
 
-@command(api='nova')
+@command(api='compute')
 class flavor_list(object):
     """list flavors"""
     
@@ -365,7 +352,7 @@ class flavor_list(object):
         print_items(flavors)
 
 
-@command(api='nova')
+@command(api='compute')
 class flavor_info(object):
     """get flavor details"""
     
@@ -374,7 +361,7 @@ class flavor_info(object):
         print_dict(flavor)
 
 
-@command(api='nova')
+@command(api='compute')
 class image_list(object):
     """list images"""
     
@@ -388,7 +375,7 @@ class image_list(object):
         print_items(images)
 
 
-@command(api='nova')
+@command(api='compute')
 class image_info(object):
     """get image details"""
     
@@ -397,7 +384,7 @@ class image_info(object):
         print_dict(image)
 
 
-@command(api='nova')
+@command(api='compute')
 class image_create(object):
     """create image"""
     
@@ -406,7 +393,7 @@ class image_create(object):
         print_dict(reply)
 
 
-@command(api='nova')
+@command(api='compute')
 class image_delete(object):
     """delete image"""
     
@@ -414,7 +401,7 @@ class image_delete(object):
         self.client.delete_image(image_id)
 
 
-@command(api='nova')
+@command(api='compute')
 class image_meta(object):
     """get image metadata"""
     
@@ -423,7 +410,7 @@ class image_meta(object):
         print_dict(reply)
 
 
-@command(api='nova')
+@command(api='compute')
 class image_addmeta(object):
     """add image metadata"""
     
@@ -432,7 +419,7 @@ class image_addmeta(object):
         print_dict(reply)
 
 
-@command(api='nova')
+@command(api='compute')
 class image_setmeta(object):
     """update image metadata"""
     
@@ -442,7 +429,7 @@ class image_setmeta(object):
         print_dict(reply)
 
 
-@command(api='nova')
+@command(api='compute')
 class image_delmeta(object):
     """delete image metadata"""
     
@@ -450,7 +437,7 @@ class image_delmeta(object):
         self.client.delete_image_metadata(image_id, key)
 
 
-@command(api='synnefo')
+@command(api='asterias')
 class network_list(object):
     """list networks"""
     
@@ -464,7 +451,7 @@ class network_list(object):
         print_items(networks)
 
 
-@command(api='synnefo')
+@command(api='asterias')
 class network_create(object):
     """create a network"""
     
@@ -473,7 +460,7 @@ class network_create(object):
         print_dict(reply)
 
 
-@command(api='synnefo')
+@command(api='asterias')
 class network_info(object):
     """get network details"""
     
@@ -482,7 +469,7 @@ class network_info(object):
         print_dict(network)
 
 
-@command(api='synnefo')
+@command(api='asterias')
 class network_rename(object):
     """update network name"""
     
@@ -490,7 +477,7 @@ class network_rename(object):
         self.client.update_network_name(network_id, new_name)
 
 
-@command(api='synnefo')
+@command(api='asterias')
 class network_delete(object):
     """delete a network"""
     
@@ -498,7 +485,7 @@ class network_delete(object):
         self.client.delete_network(network_id)
 
 
-@command(api='synnefo')
+@command(api='asterias')
 class network_connect(object):
     """connect a server to a network"""
     
@@ -506,7 +493,7 @@ class network_connect(object):
         self.client.connect_server(server_id, network_id)
 
 
-@command(api='synnefo')
+@command(api='asterias')
 class network_disconnect(object):
     """disconnect a server from a network"""
     
@@ -514,7 +501,7 @@ class network_disconnect(object):
         self.client.disconnect_server(server_id, network_id)
 
 
-@command(api='glance')
+@command(api='image')
 class glance_list(object):
     """list images"""
     
@@ -551,7 +538,7 @@ class glance_list(object):
         print_items(images, title=('name',))
 
 
-@command(api='glance')
+@command(api='image')
 class glance_meta(object):
     """get image metadata"""
     
@@ -560,7 +547,7 @@ class glance_meta(object):
         print_dict(image)
 
 
-@command(api='glance')
+@command(api='image')
 class glance_register(object):
     """register an image"""
     
@@ -603,7 +590,7 @@ class glance_register(object):
         self.client.register(name, location, params, properties)
 
 
-@command(api='glance')
+@command(api='image')
 class glance_members(object):
     """get image members"""
     
@@ -613,7 +600,7 @@ class glance_members(object):
             print member['member_id']
 
 
-@command(api='glance')
+@command(api='image')
 class glance_shared(object):
     """list shared images"""
     
@@ -623,7 +610,7 @@ class glance_shared(object):
             print image['image_id']
 
 
-@command(api='glance')
+@command(api='image')
 class glance_addmember(object):
     """add a member to an image"""
     
@@ -631,7 +618,7 @@ class glance_addmember(object):
         self.client.add_member(image_id, member)
 
 
-@command(api='glance')
+@command(api='image')
 class glance_delmember(object):
     """remove a member from an image"""
     
@@ -639,7 +626,7 @@ class glance_delmember(object):
         self.client.remove_member(image_id, member)
 
 
-@command(api='glance')
+@command(api='image')
 class glance_setmembers(object):
     """set the members of an image"""
     
@@ -647,6 +634,27 @@ class glance_setmembers(object):
         self.client.set_members(image_id, member)
 
 
+@command(api='storage')
+class store_upload(object):
+    """upload a file"""
+    
+    @classmethod
+    def update_parser(cls, parser):
+        parser.add_option('--account', dest='account', metavar='ACCOUNT',
+                help='use account ACCOUNT')
+        parser.add_option('--container', dest='container', metavar='CONTAINER',
+                help='use container CONTAINER')
+    
+    def main(self, path, remote_path=None):
+        account = self.options.account or self.config.get('storage_account')
+        container = self.options.container or \
+                    self.config.get('storage_container')
+        if remote_path is None:
+            remote_path = basename(path)
+        with open(path) as f:
+            self.client.create_object(account, container, remote_path, f)
+
+
 def print_groups(groups):
     print
     print 'Groups:'
@@ -663,18 +671,14 @@ def print_commands(group, commands):
 
 
 def main():
+    ch = logging.StreamHandler()
+    ch.setFormatter(logging.Formatter('%(message)s'))
+    log.addHandler(ch)
+    
     parser = OptionParser(add_help_option=False)
     parser.usage = '%prog <group> <command> [options]'
     parser.add_option('--help', dest='help', action='store_true',
             default=False, help='show this help message and exit')
-    parser.add_option('--api', dest='apis', action='append', metavar='API',
-            help='API to use (can be used multiple times)')
-    parser.add_option('--compute-url', dest='compute_url', metavar='URL',
-            help='URL for the compute API')
-    parser.add_option('--images-url', dest='images_url', metavar='URL',
-            help='URL for the images API')
-    parser.add_option('--token', dest='token', metavar='TOKEN',
-            help='use token TOKEN')
     parser.add_option('-v', dest='verbose', action='store_true', default=False,
             help='use verbose output')
     parser.add_option('-d', dest='debug', action='store_true', default=False,
@@ -684,7 +688,7 @@ def main():
     # anyway if we don't reach the main parsing.
     _error = parser.error
     parser.error = lambda msg: None
-    options, args = parser.parse_args(sys.argv)
+    options, args = parser.parse_args(argv)
     parser.error = _error
     
     if options.debug:
@@ -695,13 +699,10 @@ def main():
         log.setLevel(logging.WARNING)
     
     try:
-        config = Config(CONFIG_PATH, CONFIG_ENV, CONFIG_DEFAULTS)
+        config = Config()
     except ConfigError, e:
         log.error('%s', e.args[0])
-        return 1
-    
-    for key in CONFIG_DEFAULTS:
-        config.override(key, getattr(options, key))
+        exit(1)
     
     apis = config.get('apis').split()
     
@@ -716,14 +717,14 @@ def main():
     if len(args) < 2:
         parser.print_help()
         print_groups(available_groups)
-        return 0
+        exit(0)
     
     group = args[1]
     
     if group not in available_groups:
         parser.print_help()
         print_groups(available_groups)
-        return 1
+        exit(1)
     
     # Find available commands based on the given APIs
     available_commands = []
@@ -737,17 +738,16 @@ def main():
     if len(args) < 3:
         parser.print_help()
         print_commands(group, available_commands)
-        return 0
+        exit(0)
     
     name = args[2]
     
     if name not in available_commands:
         parser.print_help()
         print_commands(group, available_commands)
-        return 1
+        exit(1)
     
     cls = _commands[group][name]
-    cls.config = config
     
     syntax = '%s [options]' % cls.syntax if cls.syntax else '[options]'
     parser.usage = '%%prog %s %s %s' % (group, name, syntax)
@@ -755,41 +755,38 @@ def main():
     if hasattr(cls, 'update_parser'):
         cls.update_parser(parser)
     
-    options, args = parser.parse_args(sys.argv)
+    options, args = parser.parse_args(argv)
     if options.help:
         parser.print_help()
-        return 0
+        exit(0)
     
     cmd = cls()
     cmd.config = config
     cmd.options = options
     
-    if cmd.api in ('nova', 'synnefo'):
-        url = config.get('compute_url')
+    if cmd.api in ('compute', 'image', 'storage', 'cyclades'):
         token = config.get('token')
-        cmd.client = ComputeClient(url, token)
-    elif cmd.api == 'glance':
-        url = config.get('images_url')
-        token = config.get('token')
-        cmd.client = GlanceClient(url, token)
+        if cmd.api in ('compute', 'image', 'storage'):
+            url = config.get(cmd.api + '_url')
+        elif cmd.api == 'cyclades':
+            url = config.get('compute_url')
+        cls_name = cmd.api.capitalize() + 'Client'
+        cmd.client = getattr(clients, cls_name)(url, token)
     
     try:
-        return cmd.main(*args[3:])
+        ret = cmd.main(*args[3:])
+        exit(ret)
     except TypeError as e:
         if e.args and e.args[0].startswith('main()'):
             parser.print_help()
-            return 1
+            exit(1)
         else:
             raise
-    except ClientError, err:
+    except clients.ClientError, err:
         log.error('%s', err.message)
         log.info('%s', err.details)
-        return 2
+        exit(2)
 
 
 if __name__ == '__main__':
-    ch = logging.StreamHandler()
-    ch.setFormatter(logging.Formatter('%(message)s'))
-    log.addHandler(ch)
-    err = main() or 0
-    sys.exit(err)
+    main()
diff --git a/kamaki/client.py b/kamaki/client.py
deleted file mode 100644 (file)
index 517aa46..0000000
+++ /dev/null
@@ -1,437 +0,0 @@
-# Copyright 2011 GRNET S.A. All rights reserved.
-#
-# Redistribution and use in source and binary forms, with or
-# without modification, are permitted provided that the following
-# conditions are met:
-#
-#   1. Redistributions of source code must retain the above
-#      copyright notice, this list of conditions and the following
-#      disclaimer.
-#
-#   2. Redistributions in binary form must reproduce the above
-#      copyright notice, this list of conditions and the following
-#      disclaimer in the documentation and/or other materials
-#      provided with the distribution.
-#
-# THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
-# OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
-# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
-# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
-# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
-# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
-# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
-# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
-# AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
-# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
-# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-# POSSIBILITY OF SUCH DAMAGE.
-#
-# The views and conclusions contained in the software and
-# documentation are those of the authors and should not be
-# interpreted as representing official policies, either expressed
-# or implied, of GRNET S.A.
-
-import json
-import logging
-
-from httplib import HTTPConnection, HTTPSConnection
-from urllib import quote
-from urlparse import urlparse
-
-
-log = logging.getLogger('kamaki.client')
-
-
-class ClientError(Exception):
-    def __init__(self, message, status=0, details=''):
-        self.message = message
-        self.status = status
-        self.details = details
-
-    def __int__(self):
-        return int(self.status)
-
-    def __str__(self):
-        r = self.message
-        if self.status:
-            r += "\nHTTP Status: %d" % self.status
-        if self.details:
-            r += "\nDetails: \n%s" % self.details
-        return r
-
-
-class Client(object):
-    def __init__(self, url, token=''):
-        self.url = url
-        self.token = token
-    
-    def raw_http_cmd(self, method, path, body=None, headers=None, success=200,
-                     json_reply=False):
-        p = urlparse(self.url)
-        path = p.path + path
-        if p.scheme == 'http':
-            conn = HTTPConnection(p.netloc)
-        elif p.scheme == 'https':
-            conn = HTTPSConnection(p.netloc)
-        else:
-            raise ClientError('Unknown URL scheme')
-        
-        headers = headers or {}
-        headers['X-Auth-Token'] = self.token
-        if body:
-            headers['Content-Type'] = 'application/json'
-            headers['Content-Length'] = len(body)
-        
-        log.debug('>' * 50)
-        log.debug('%s %s', method, path)
-        for key, val in headers.items():
-            log.debug('%s: %s', key, val)
-        if body:
-            log.debug('')
-            log.debug(body)
-        
-        conn.request(method, path, body, headers)
-        
-        resp = conn.getresponse()
-        reply = resp.read()
-        
-        log.debug('<' * 50)
-        log.info('%d %s', resp.status, resp.reason)
-        for key, val in resp.getheaders():
-            log.info('%s: %s', key.capitalize(), val)
-        log.info('')
-        log.debug(reply)
-        log.debug('-' * 50)
-        
-        if json_reply:
-            try:
-                reply = json.loads(reply) if reply else {}
-            except ValueError:
-                raise ClientError('Did not receive valid JSON reply',
-                                  resp.status, reply)
-        
-        if resp.status != success:
-            if len(reply) == 1:
-                if json_reply:
-                    key = reply.keys()[0]
-                    val = reply[key]
-                    message = '%s: %s' % (key, val.get('message', ''))
-                    details = val.get('details', '')
-                else:
-                    message = reply
-                    details = ''
-    
-                raise ClientError(message, resp.status, details)
-            else:
-                raise ClientError('Invalid response from the server')
-        
-        return resp, reply
-    
-    def http_cmd(self, method, path, body=None, headers=None, success=200):
-        resp, reply = self.raw_http_cmd(method, path, body, headers, success,
-                                        json_reply=True)
-        return reply
-    
-    def http_get(self, path, success=200):
-        return self.http_cmd('GET', path, success=success)
-    
-    def http_post(self, path, body=None, headers=None, success=202):
-        return self.http_cmd('POST', path, body, headers, success)
-    
-    def http_put(self, path, body=None, success=204):
-        return self.http_cmd('PUT', path, body, success=success)
-    
-    def http_delete(self, path, success=204):
-        return self.http_cmd('DELETE', path, success=success)
-
-
-class ComputeClient(Client):
-    # Servers
-    
-    def list_servers(self, detail=False):
-        """List servers, returned detailed output if detailed is True"""
-        path = '/servers/detail' if detail else '/servers'
-        reply = self.http_get(path)
-        return reply['servers']['values']
-    
-    def get_server_details(self, server_id):
-        """Return detailed output on a server specified by its id"""
-        path = '/servers/%d' % server_id
-        reply = self.http_get(path)
-        return reply['server']
-    
-    def create_server(self, name, flavor_id, image_id, personality=None):
-        """Submit request to create a new server
-
-        The flavor_id specifies the hardware configuration to use,
-        the image_id specifies the OS Image to be deployed inside the new
-        server.
-
-        The personality argument is a list of (file path, file contents)
-        tuples, describing files to be injected into the server upon creation.
-
-        The call returns a dictionary describing the newly created server.
-
-        """
-        
-        req = {'name': name, 'flavorRef': flavor_id, 'imageRef': image_id}
-        if personality:
-            req['personality'] = personality
-        
-        body = json.dumps({'server': req})
-        reply = self.http_post('/servers', body)
-        return reply['server']
-    
-    def update_server_name(self, server_id, new_name):
-        """Update the name of the server as reported by the API.
-
-        This call does not modify the hostname actually used by the server
-        internally.
-
-        """
-        path = '/servers/%d' % server_id
-        body = json.dumps({'server': {'name': new_name}})
-        self.http_put(path, body)
-    
-    def delete_server(self, server_id):
-        """Submit a deletion request for a server specified by id"""
-        path = '/servers/%d' % server_id
-        self.http_delete(path)
-    
-    def reboot_server(self, server_id, hard=False):
-        """Submit a reboot request for a server specified by id"""
-        path = '/servers/%d/action' % server_id
-        type = 'HARD' if hard else 'SOFT'
-        body = json.dumps({'reboot': {'type': type}})
-        self.http_post(path, body)
-    
-    def start_server(self, server_id):
-        """Submit a startup request for a server specified by id"""
-        path = '/servers/%d/action' % server_id
-        body = json.dumps({'start': {}})
-        self.http_post(path, body)
-    
-    def shutdown_server(self, server_id):
-        """Submit a shutdown request for a server specified by id"""
-        path = '/servers/%d/action' % server_id
-        body = json.dumps({'shutdown': {}})
-        self.http_post(path, body)
-    
-    def get_server_console(self, server_id):
-        """Get a VNC connection to the console of a server specified by id"""
-        path = '/servers/%d/action' % server_id
-        body = json.dumps({'console': {'type': 'vnc'}})
-        reply = self.http_post(path, body, success=200)
-        return reply['console']
-    
-    def set_firewall_profile(self, server_id, profile):
-        """Set the firewall profile for the public interface of a server
-
-        The server is specified by id, the profile argument
-        is one of (ENABLED, DISABLED, PROTECTED).
-        """
-        path = '/servers/%d/action' % server_id
-        body = json.dumps({'firewallProfile': {'profile': profile}})
-        self.http_post(path, body)
-    
-    def list_server_addresses(self, server_id, network=None):
-        path = '/servers/%d/ips' % server_id
-        if network:
-            path += '/%s' % network
-        reply = self.http_get(path)
-        return [reply['network']] if network else reply['addresses']['values']
-    
-    def get_server_metadata(self, server_id, key=None):
-        path = '/servers/%d/meta' % server_id
-        if key:
-            path += '/%s' % key
-        reply = self.http_get(path)
-        return reply['meta'] if key else reply['metadata']['values']
-    
-    def create_server_metadata(self, server_id, key, val):
-        path = '/servers/%d/meta/%s' % (server_id, key)
-        body = json.dumps({'meta': {key: val}})
-        reply = self.http_put(path, body, 201)
-        return reply['meta']
-    
-    def update_server_metadata(self, server_id, **metadata):
-        path = '/servers/%d/meta' % server_id
-        body = json.dumps({'metadata': metadata})
-        reply = self.http_post(path, body, success=201)
-        return reply['metadata']
-    
-    def delete_server_metadata(self, server_id, key):
-        path = '/servers/%d/meta/%s' % (server_id, key)
-        reply = self.http_delete(path)
-    
-    def get_server_stats(self, server_id):
-        path = '/servers/%d/stats' % server_id
-        reply = self.http_get(path)
-        return reply['stats']
-    
-    
-    # Flavors
-    
-    def list_flavors(self, detail=False):
-        path = '/flavors/detail' if detail else '/flavors'
-        reply = self.http_get(path)
-        return reply['flavors']['values']
-
-    def get_flavor_details(self, flavor_id):
-        path = '/flavors/%d' % flavor_id
-        reply = self.http_get(path)
-        return reply['flavor']
-    
-    
-    # Images
-    
-    def list_images(self, detail=False):
-        path = '/images/detail' if detail else '/images'
-        reply = self.http_get(path)
-        return reply['images']['values']
-
-    def get_image_details(self, image_id):
-        path = '/images/%d' % image_id
-        reply = self.http_get(path)
-        return reply['image']
-
-    def create_image(self, server_id, name):
-        req = {'name': name, 'serverRef': server_id}
-        body = json.dumps({'image': req})
-        reply = self.http_post('/images', body)
-        return reply['image']
-
-    def delete_image(self, image_id):
-        path = '/images/%d' % image_id
-        self.http_delete(path)
-
-    def get_image_metadata(self, image_id, key=None):
-        path = '/images/%d/meta' % image_id
-        if key:
-            path += '/%s' % key
-        reply = self.http_get(path)
-        return reply['meta'] if key else reply['metadata']['values']
-    
-    def create_image_metadata(self, image_id, key, val):
-        path = '/images/%d/meta/%s' % (image_id, key)
-        body = json.dumps({'meta': {key: val}})
-        reply = self.http_put(path, body, 201)
-        reply['meta']
-
-    def update_image_metadata(self, image_id, **metadata):
-        path = '/images/%d/meta' % image_id
-        body = json.dumps({'metadata': metadata})
-        reply = self.http_post(path, body, success=201)
-        return reply['metadata']
-
-    def delete_image_metadata(self, image_id, key):
-        path = '/images/%d/meta/%s' % (image_id, key)
-        reply = self.http_delete(path)
-    
-    
-    # Networks
-    
-    def list_networks(self, detail=False):
-        path = '/networks/detail' if detail else '/networks'
-        reply = self.http_get(path)
-        return reply['networks']['values']
-    
-    def create_network(self, name):
-        body = json.dumps({'network': {'name': name}})
-        reply = self.http_post('/networks', body)
-        return reply['network']
-    
-    def get_network_details(self, network_id):
-        path = '/networks/%s' % network_id
-        reply = self.http_get(path)
-        return reply['network']
-    
-    def update_network_name(self, network_id, new_name):
-        path = '/networks/%s' % network_id
-        body = json.dumps({'network': {'name': new_name}})
-        self.http_put(path, body)
-    
-    def delete_network(self, network_id):
-        path = '/networks/%s' % network_id
-        self.http_delete(path)
-
-    def connect_server(self, server_id, network_id):
-        path = '/networks/%s/action' % network_id
-        body = json.dumps({'add': {'serverRef': server_id}})
-        self.http_post(path, body)
-    
-    def disconnect_server(self, server_id, network_id):
-        path = '/networks/%s/action' % network_id
-        body = json.dumps({'remove': {'serverRef': server_id}})
-        self.http_post(path, body)
-
-
-class GlanceClient(Client):
-    def list_public(self, detail=False, filters={}, order=''):
-        path = '/images/detail' if detail else '/images/'
-        params = {}
-        params.update(filters)
-        
-        if order.startswith('-'):
-            params['sort_dir'] = 'desc'
-            order = order[1:]
-        else:
-            params['sort_dir'] = 'asc'
-        
-        if order:
-            params['sort_key'] = order
-        
-        if params:
-            path += '?' + '&'.join('%s=%s' % item for item in params.items())
-        return self.http_get(path)
-    
-    def get_meta(self, image_id):
-        path = '/images/%s' % image_id
-        resp, buf = self.raw_http_cmd('HEAD', path)
-        reply = {}
-        prefix = 'x-image-meta-'
-        for key, val in resp.getheaders():
-            key = key.lower()
-            if not key.startswith(prefix):
-                continue
-            key = key[len(prefix):]
-            reply[key] = val
-        return reply
-    
-    def register(self, name, location, params={}, properties={}):
-        path = '/images/'
-        headers = {}
-        headers['x-image-meta-name'] = quote(name)
-        headers['x-image-meta-location'] = location
-        for key, val in params.items():
-            if key in ('id', 'store', 'disk_format', 'container_format',
-                       'size', 'checksum', 'is_public', 'owner'):
-                key = 'x-image-meta-' + key.replace('_', '-')
-                headers[key] = val
-        for key, val in properties.items():
-            headers['x-image-meta-property-' + quote(key)] = quote(val)
-        return self.http_post(path, headers=headers, success=200)
-    
-    def list_members(self, image_id):
-        path = '/images/%s/members' % image_id
-        reply = self.http_get(path)
-        return reply['members']
-
-    def list_shared(self, member):
-        path = '/shared-images/%s' % member
-        reply = self.http_get(path)
-        return reply['shared_images']
-
-    def add_member(self, image_id, member):
-        path = '/images/%s/members/%s' % (image_id, member)
-        self.http_put(path)
-    
-    def remove_member(self, image_id, member):
-        path = '/images/%s/members/%s' % (image_id, member)
-        self.http_delete(path)
-    
-    def set_members(self, image_id, members):
-        path = '/images/%s/members' % image_id
-        req = {'memberships': [{'member_id': member} for member in members]}
-        body = json.dumps(req)
-        self.http_put(path, body)
diff --git a/kamaki/clients/__init__.py b/kamaki/clients/__init__.py
new file mode 100644 (file)
index 0000000..80fb848
--- /dev/null
@@ -0,0 +1,55 @@
+# Copyright 2011 GRNET S.A. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or
+# without modification, are permitted provided that the following
+# conditions are met:
+#
+#   1. Redistributions of source code must retain the above
+#      copyright notice, this list of conditions and the following
+#      disclaimer.
+#
+#   2. Redistributions in binary form must reproduce the above
+#      copyright notice, this list of conditions and the following
+#      disclaimer in the documentation and/or other materials
+#      provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
+# OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
+# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
+# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
+# AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# The views and conclusions contained in the software and
+# documentation are those of the authors and should not be
+# interpreted as representing official policies, either expressed
+# or implied, of GRNET S.A.
+
+class ClientError(Exception):
+    def __init__(self, message, status=0, details=''):
+        self.message = message
+        self.status = status
+        self.details = details
+
+    def __int__(self):
+        return int(self.status)
+
+    def __str__(self):
+        r = self.message
+        if self.status:
+            r += "\nHTTP Status: %d" % self.status
+        if self.details:
+            r += "\nDetails: \n%s" % self.details
+        return r
+
+
+from .compute import ComputeClient
+from .image import ImageClient
+from .storage import StorageClient
+from .cyclades import CycladesClient
diff --git a/kamaki/clients/compute.py b/kamaki/clients/compute.py
new file mode 100644 (file)
index 0000000..4aeffd8
--- /dev/null
@@ -0,0 +1,174 @@
+# Copyright 2011 GRNET S.A. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or
+# without modification, are permitted provided that the following
+# conditions are met:
+#
+#   1. Redistributions of source code must retain the above
+#      copyright notice, this list of conditions and the following
+#      disclaimer.
+#
+#   2. Redistributions in binary form must reproduce the above
+#      copyright notice, this list of conditions and the following
+#      disclaimer in the documentation and/or other materials
+#      provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
+# OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
+# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
+# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
+# AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# The views and conclusions contained in the software and
+# documentation are those of the authors and should not be
+# interpreted as representing official policies, either expressed
+# or implied, of GRNET S.A.
+
+"""
+    OpenStack Compute API 1.1 client
+"""
+
+import json
+
+from .http import HTTPClient
+
+
+class ComputeClient(HTTPClient):    
+    def list_servers(self, detail=False):
+        """List servers, returned detailed output if detailed is True"""
+        path = '/servers/detail' if detail else '/servers'
+        reply = self.http_get(path)
+        return reply['servers']['values']
+    
+    def get_server_details(self, server_id):
+        """Return detailed output on a server specified by its id"""
+        path = '/servers/%d' % server_id
+        reply = self.http_get(path)
+        return reply['server']
+    
+    def create_server(self, name, flavor_id, image_id, personality=None):
+        """Submit request to create a new server
+
+        The flavor_id specifies the hardware configuration to use,
+        the image_id specifies the OS Image to be deployed inside the new
+        server.
+
+        The personality argument is a list of (file path, file contents)
+        tuples, describing files to be injected into the server upon creation.
+
+        The call returns a dictionary describing the newly created server.
+        """
+        req = {'name': name, 'flavorRef': flavor_id, 'imageRef': image_id}
+        if personality:
+            req['personality'] = personality
+        
+        body = json.dumps({'server': req})
+        reply = self.http_post('/servers', body)
+        return reply['server']
+    
+    def update_server_name(self, server_id, new_name):
+        """Update the name of the server as reported by the API.
+
+        This call does not modify the hostname actually used by the server
+        internally.
+        """
+        path = '/servers/%d' % server_id
+        body = json.dumps({'server': {'name': new_name}})
+        self.http_put(path, body)
+    
+    def delete_server(self, server_id):
+        """Submit a deletion request for a server specified by id"""
+        path = '/servers/%d' % server_id
+        self.http_delete(path)
+    
+    def reboot_server(self, server_id, hard=False):
+        """Submit a reboot request for a server specified by id"""
+        path = '/servers/%d/action' % server_id
+        type = 'HARD' if hard else 'SOFT'
+        body = json.dumps({'reboot': {'type': type}})
+        self.http_post(path, body)
+        
+    def get_server_metadata(self, server_id, key=None):
+        path = '/servers/%d/meta' % server_id
+        if key:
+            path += '/%s' % key
+        reply = self.http_get(path)
+        return reply['meta'] if key else reply['metadata']['values']
+    
+    def create_server_metadata(self, server_id, key, val):
+        path = '/servers/%d/meta/%s' % (server_id, key)
+        body = json.dumps({'meta': {key: val}})
+        reply = self.http_put(path, body, success=201)
+        return reply['meta']
+    
+    def update_server_metadata(self, server_id, **metadata):
+        path = '/servers/%d/meta' % server_id
+        body = json.dumps({'metadata': metadata})
+        reply = self.http_post(path, body, success=201)
+        return reply['metadata']
+    
+    def delete_server_metadata(self, server_id, key):
+        path = '/servers/%d/meta/%s' % (server_id, key)
+        reply = self.http_delete(path)
+        
+        
+    def list_flavors(self, detail=False):
+        path = '/flavors/detail' if detail else '/flavors'
+        reply = self.http_get(path)
+        return reply['flavors']['values']
+
+    def get_flavor_details(self, flavor_id):
+        path = '/flavors/%d' % flavor_id
+        reply = self.http_get(path)
+        return reply['flavor']
+    
+    
+    def list_images(self, detail=False):
+        path = '/images/detail' if detail else '/images'
+        reply = self.http_get(path)
+        return reply['images']['values']
+
+    def get_image_details(self, image_id):
+        path = '/images/%d' % image_id
+        reply = self.http_get(path)
+        return reply['image']
+
+    def create_image(self, server_id, name):
+        req = {'name': name, 'serverRef': server_id}
+        body = json.dumps({'image': req})
+        reply = self.http_post('/images', body)
+        return reply['image']
+
+    def delete_image(self, image_id):
+        path = '/images/%d' % image_id
+        self.http_delete(path)
+
+    def get_image_metadata(self, image_id, key=None):
+        path = '/images/%d/meta' % image_id
+        if key:
+            path += '/%s' % key
+        reply = self.http_get(path)
+        return reply['meta'] if key else reply['metadata']['values']
+    
+    def create_image_metadata(self, image_id, key, val):
+        path = '/images/%d/meta/%s' % (image_id, key)
+        body = json.dumps({'meta': {key: val}})
+        reply = self.http_put(path, body, success=201)
+        reply['meta']
+
+    def update_image_metadata(self, image_id, **metadata):
+        path = '/images/%d/meta' % image_id
+        body = json.dumps({'metadata': metadata})
+        reply = self.http_post(path, body, success=201)
+        return reply['metadata']
+
+    def delete_image_metadata(self, image_id, key):
+        path = '/images/%d/meta/%s' % (image_id, key)
+        reply = self.http_delete(path)
diff --git a/kamaki/clients/cyclades.py b/kamaki/clients/cyclades.py
new file mode 100644 (file)
index 0000000..fcaffcb
--- /dev/null
@@ -0,0 +1,118 @@
+# Copyright 2011 GRNET S.A. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or
+# without modification, are permitted provided that the following
+# conditions are met:
+#
+#   1. Redistributions of source code must retain the above
+#      copyright notice, this list of conditions and the following
+#      disclaimer.
+#
+#   2. Redistributions in binary form must reproduce the above
+#      copyright notice, this list of conditions and the following
+#      disclaimer in the documentation and/or other materials
+#      provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
+# OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
+# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
+# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
+# AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# The views and conclusions contained in the software and
+# documentation are those of the authors and should not be
+# interpreted as representing official policies, either expressed
+# or implied, of GRNET S.A.
+
+"""
+    GRNet Cyclades API client
+"""
+
+import json
+
+from .http import HTTPClient
+
+
+class CycladesClient(HTTPClient):    
+    def start_server(self, server_id):
+        """Submit a startup request for a server specified by id"""
+        path = '/servers/%d/action' % server_id
+        body = json.dumps({'start': {}})
+        self.http_post(path, body)
+    
+    def shutdown_server(self, server_id):
+        """Submit a shutdown request for a server specified by id"""
+        path = '/servers/%d/action' % server_id
+        body = json.dumps({'shutdown': {}})
+        self.http_post(path, body)
+    
+    def get_server_console(self, server_id):
+        """Get a VNC connection to the console of a server specified by id"""
+        path = '/servers/%d/action' % server_id
+        body = json.dumps({'console': {'type': 'vnc'}})
+        reply = self.http_post(path, body, success=200)
+        return reply['console']
+    
+    def set_firewall_profile(self, server_id, profile):
+        """Set the firewall profile for the public interface of a server
+
+        The server is specified by id, the profile argument
+        is one of (ENABLED, DISABLED, PROTECTED).
+        """
+        path = '/servers/%d/action' % server_id
+        body = json.dumps({'firewallProfile': {'profile': profile}})
+        self.http_post(path, body)
+    
+    def list_server_addresses(self, server_id, network=None):
+        path = '/servers/%d/ips' % server_id
+        if network:
+            path += '/%s' % network
+        reply = self.http_get(path)
+        return [reply['network']] if network else reply['addresses']['values']
+    
+    def get_server_stats(self, server_id):
+        path = '/servers/%d/stats' % server_id
+        reply = self.http_get(path)
+        return reply['stats']
+    
+    
+    def list_networks(self, detail=False):
+        path = '/networks/detail' if detail else '/networks'
+        reply = self.http_get(path)
+        return reply['networks']['values']
+
+    def create_network(self, name):
+        body = json.dumps({'network': {'name': name}})
+        reply = self.http_post('/networks', body)
+        return reply['network']
+
+    def get_network_details(self, network_id):
+        path = '/networks/%s' % network_id
+        reply = self.http_get(path)
+        return reply['network']
+
+    def update_network_name(self, network_id, new_name):
+        path = '/networks/%s' % network_id
+        body = json.dumps({'network': {'name': new_name}})
+        self.http_put(path, body)
+
+    def delete_network(self, network_id):
+        path = '/networks/%s' % network_id
+        self.http_delete(path)
+
+    def connect_server(self, server_id, network_id):
+        path = '/networks/%s/action' % network_id
+        body = json.dumps({'add': {'serverRef': server_id}})
+        self.http_post(path, body)
+
+    def disconnect_server(self, server_id, network_id):
+        path = '/networks/%s/action' % network_id
+        body = json.dumps({'remove': {'serverRef': server_id}})
+        self.http_post(path, body)
diff --git a/kamaki/clients/http.py b/kamaki/clients/http.py
new file mode 100644 (file)
index 0000000..ddfdc18
--- /dev/null
@@ -0,0 +1,128 @@
+# Copyright 2011 GRNET S.A. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or
+# without modification, are permitted provided that the following
+# conditions are met:
+#
+#   1. Redistributions of source code must retain the above
+#      copyright notice, this list of conditions and the following
+#      disclaimer.
+#
+#   2. Redistributions in binary form must reproduce the above
+#      copyright notice, this list of conditions and the following
+#      disclaimer in the documentation and/or other materials
+#      provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
+# OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
+# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
+# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
+# AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# The views and conclusions contained in the software and
+# documentation are those of the authors and should not be
+# interpreted as representing official policies, either expressed
+# or implied, of GRNET S.A.
+
+import json
+import logging
+
+from httplib import HTTPConnection, HTTPSConnection
+from urlparse import urlparse
+
+from . import ClientError
+
+
+log = logging.getLogger('kamaki.clients')
+
+
+class HTTPClient(object):
+    def __init__(self, url, token):
+        self.url = url
+        self.token = token
+    
+    def raw_http_cmd(self, method, path, body=None, headers=None, success=200,
+                     json_reply=False):
+        p = urlparse(self.url)
+        path = p.path + path
+        if p.scheme == 'http':
+            conn = HTTPConnection(p.netloc)
+        elif p.scheme == 'https':
+            conn = HTTPSConnection(p.netloc)
+        else:
+            raise ClientError('Unknown URL scheme')
+        
+        headers = headers or {}
+        headers['X-Auth-Token'] = self.token
+        if body:
+            headers['Content-Type'] = 'application/json'
+            headers['Content-Length'] = len(body)
+        
+        log.debug('>' * 50)
+        log.debug('%s %s', method, path)
+        for key, val in headers.items():
+            log.debug('%s: %s', key, val)
+        if body:
+            log.debug('')
+            log.debug(body)
+        
+        conn.request(method, path, body, headers)
+        
+        resp = conn.getresponse()
+        reply = resp.read()
+        
+        log.debug('<' * 50)
+        log.info('%d %s', resp.status, resp.reason)
+        for key, val in resp.getheaders():
+            log.info('%s: %s', key.capitalize(), val)
+        log.info('')
+        log.debug(reply)
+        log.debug('-' * 50)
+        
+        if json_reply:
+            try:
+                reply = json.loads(reply) if reply else {}
+            except ValueError:
+                raise ClientError('Did not receive valid JSON reply',
+                                  resp.status, reply)
+        
+        if resp.status != success:
+            if len(reply) == 1:
+                if json_reply:
+                    key = reply.keys()[0]
+                    val = reply[key]
+                    message = '%s: %s' % (key, val.get('message', ''))
+                    details = val.get('details', '')
+                else:
+                    message = reply
+                    details = ''
+    
+                raise ClientError(message, resp.status, details)
+            else:
+                raise ClientError('Invalid response from the server')
+        
+        return resp, reply
+    
+    def http_cmd(self, method, path, body=None, headers=None, success=200):
+        resp, reply = self.raw_http_cmd(method, path, body, headers, success,
+                                        json_reply=True)
+        return reply
+    
+    def http_get(self, path, success=200):
+        return self.http_cmd('GET', path, success=success)
+    
+    def http_post(self, path, body=None, headers=None, success=202):
+        return self.http_cmd('POST', path, body, headers, success)
+    
+    def http_put(self, path, body=None, headers=None, success=204):
+        return self.http_cmd('PUT', path, body, headers, success)
+    
+    def http_delete(self, path, success=204):
+        return self.http_cmd('DELETE', path, success=success)
diff --git a/kamaki/clients/image.py b/kamaki/clients/image.py
new file mode 100644 (file)
index 0000000..5d0fc08
--- /dev/null
@@ -0,0 +1,111 @@
+# Copyright 2011 GRNET S.A. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or
+# without modification, are permitted provided that the following
+# conditions are met:
+#
+#   1. Redistributions of source code must retain the above
+#      copyright notice, this list of conditions and the following
+#      disclaimer.
+#
+#   2. Redistributions in binary form must reproduce the above
+#      copyright notice, this list of conditions and the following
+#      disclaimer in the documentation and/or other materials
+#      provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
+# OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
+# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
+# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
+# AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# The views and conclusions contained in the software and
+# documentation are those of the authors and should not be
+# interpreted as representing official policies, either expressed
+# or implied, of GRNET S.A.
+
+"""
+    OpenStack Image Service API 1.0 client
+"""
+
+from urllib import quote
+
+from .http import HTTPClient
+
+
+class ImageClient(HTTPClient):
+    def list_public(self, detail=False, filters={}, order=''):
+        path = '/images/detail' if detail else '/images/'
+        params = {}
+        params.update(filters)
+        
+        if order.startswith('-'):
+            params['sort_dir'] = 'desc'
+            order = order[1:]
+        else:
+            params['sort_dir'] = 'asc'
+        
+        if order:
+            params['sort_key'] = order
+        
+        if params:
+            path += '?' + '&'.join('%s=%s' % item for item in params.items())
+        return self.http_get(path)
+    
+    def get_meta(self, image_id):
+        path = '/images/%s' % image_id
+        resp, buf = self.raw_http_cmd('HEAD', path)
+        reply = {}
+        prefix = 'x-image-meta-'
+        for key, val in resp.getheaders():
+            key = key.lower()
+            if not key.startswith(prefix):
+                continue
+            key = key[len(prefix):]
+            reply[key] = val
+        return reply
+    
+    def register(self, name, location, params={}, properties={}):
+        path = '/images/'
+        headers = {}
+        headers['x-image-meta-name'] = quote(name)
+        headers['x-image-meta-location'] = location
+        for key, val in params.items():
+            if key in ('id', 'store', 'disk_format', 'container_format',
+                       'size', 'checksum', 'is_public', 'owner'):
+                key = 'x-image-meta-' + key.replace('_', '-')
+                headers[key] = val
+        for key, val in properties.items():
+            headers['x-image-meta-property-' + quote(key)] = quote(val)
+        return self.http_post(path, headers=headers, success=200)
+    
+    def list_members(self, image_id):
+        path = '/images/%s/members' % image_id
+        reply = self.http_get(path)
+        return reply['members']
+
+    def list_shared(self, member):
+        path = '/shared-images/%s' % member
+        reply = self.http_get(path)
+        return reply['shared_images']
+
+    def add_member(self, image_id, member):
+        path = '/images/%s/members/%s' % (image_id, member)
+        self.http_put(path)
+    
+    def remove_member(self, image_id, member):
+        path = '/images/%s/members/%s' % (image_id, member)
+        self.http_delete(path)
+    
+    def set_members(self, image_id, members):
+        path = '/images/%s/members' % image_id
+        req = {'memberships': [{'member_id': member} for member in members]}
+        body = json.dumps(req)
+        self.http_put(path, body)
diff --git a/kamaki/clients/storage.py b/kamaki/clients/storage.py
new file mode 100644 (file)
index 0000000..11c47bf
--- /dev/null
@@ -0,0 +1,55 @@
+# Copyright 2011 GRNET S.A. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or
+# without modification, are permitted provided that the following
+# conditions are met:
+#
+#   1. Redistributions of source code must retain the above
+#      copyright notice, this list of conditions and the following
+#      disclaimer.
+#
+#   2. Redistributions in binary form must reproduce the above
+#      copyright notice, this list of conditions and the following
+#      disclaimer in the documentation and/or other materials
+#      provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
+# OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
+# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
+# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
+# AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# The views and conclusions contained in the software and
+# documentation are those of the authors and should not be
+# interpreted as representing official policies, either expressed
+# or implied, of GRNET S.A.
+
+"""
+    OpenStack Object Storage API 1.0 client
+"""
+
+from .http import HTTPClient
+
+
+class StorageClient(HTTPClient):
+    def __init__(self, url, token, account, container):
+        self.url = url
+        self.token = token
+        self.account = account
+        self.container = container
+    
+    def create_object(self, object, f):
+        path = '/%s/%s/%s' % (self.account, self.container, object)
+        data = f.read()
+        self.http_put(path, data, success=201)
+
+    def delete_object(self, object):
+        path = '/%s/%s/%s' % (self.account, self.container, object)
+        self.http_delete(path)
index d51420c..9110662 100644 (file)
@@ -35,6 +35,27 @@ import json
 import logging
 import os
 
+from os.path import exists, expanduser
+
+
+# Path to the file that stores the configuration
+CONFIG_PATH = expanduser('~/.kamakirc')
+
+# Name of a shell variable to bypass the CONFIG_PATH value
+CONFIG_ENV = 'KAMAKI_CONFIG'
+
+# The defaults also determine the allowed keys
+CONFIG_DEFAULTS = {
+    'apis': 'compute image storage cyclades',
+    'token': '',
+    'compute_url': 'https://okeanos.grnet.gr/api/v1',
+    'image_url': 'https://okeanos.grnet.gr/plankton',
+    'storage_url': 'https://plus.pithos.grnet.gr/v1',
+    'storage_account': '',
+    'storage_container': '',
+    'test_token': ''
+}
+
 
 log = logging.getLogger('kamaki.config')
 
@@ -44,20 +65,20 @@ class ConfigError(Exception):
 
 
 class Config(object):
-    def __init__(self, path, env, defaults):
-        self.path = os.environ.get(env, path)
-        self.defaults = defaults
+    def __init__(self):
+        self.path = os.environ.get(CONFIG_ENV, CONFIG_PATH)
+        self.defaults = CONFIG_DEFAULTS
         
         d = self.read()
         for key, val in d.items():
-            if key not in defaults:
+            if key not in self.defaults:
                 log.warning('Ignoring unknown config key "%s".', key)
         
         self.d = d
         self.overrides = {}
     
     def read(self):
-        if not os.path.exists(self.path):
+        if not exists(self.path):
             return {}
         
         with open(self.path) as f:
@@ -86,7 +107,7 @@ class Config(object):
             return self.overrides[key]
         if key in self.d:
             return self.d[key]
-        return self.defaults.get(key)
+        return self.defaults.get(key, '')
     
     def set(self, key, val):
         if key not in self.defaults: