From: Giorgos Verigakis Date: Tue, 21 Feb 2012 17:51:35 +0000 (+0200) Subject: Refactored networking X-Git-Tag: v0.5.1~36 X-Git-Url: https://code.grnet.gr/git/kamaki/commitdiff_plain/df79206f0c7b9bdee6848545d832190bf6706ccc Refactored networking Adds requests dependency. --- diff --git a/kamaki/cli.py b/kamaki/cli.py index 1a66447..33b4034 100755 --- a/kamaki/cli.py +++ b/kamaki/cli.py @@ -77,9 +77,13 @@ from os.path import abspath, basename, exists, expanduser from pwd import getpwuid from sys import argv, exit, stdout -from clint.textui import puts, puts_err, indent +from clint import args +from clint.textui import puts, puts_err, indent, progress +from clint.textui.colored import magenta, red, yellow from clint.textui.cols import columns +from requests.exceptions import ConnectionError + from kamaki import clients from kamaki.config import Config from kamaki.utils import OrderedDict, print_addresses, print_dict, print_items @@ -116,8 +120,6 @@ def command(api=None, group=None, name=None, syntax=None): cls.api = api cls.group = group or grp cls.name = name or cmd - cls.description = description or cls.__doc__ - cls.syntax = syntax short_description, sep, long_description = cls.__doc__.partition('\n') cls.description = short_description @@ -194,7 +196,7 @@ class config_delete(object): @command(api='compute') class server_list(object): - """list servers""" + """List servers""" def update_parser(cls, parser): parser.add_option('-l', dest='detail', action='store_true', @@ -207,7 +209,7 @@ class server_list(object): @command(api='compute') class server_info(object): - """get server details""" + """Get server details""" def main(self, server_id): server = self.client.get_server_details(int(server_id)) @@ -216,15 +218,15 @@ class server_info(object): @command(api='compute') class server_create(object): - """create server""" + """Create a server""" def update_parser(cls, parser): parser.add_option('--personality', dest='personalities', - action='append', default=[], - metavar='PATH[,SERVER PATH[,OWNER[,GROUP,[MODE]]]]', - help='add a personality file') + action='append', default=[], + metavar='PATH[,SERVER PATH[,OWNER[,GROUP,[MODE]]]]', + help='add a personality file') parser.epilog = "If missing, optional personality values will be " \ - "filled based on the file at PATH if missing." + "filled based on the file at PATH." def main(self, name, flavor_id, image_id): personalities = [] @@ -259,7 +261,7 @@ class server_create(object): @command(api='compute') class server_rename(object): - """update server name""" + """Update a server's name""" def main(self, server_id, new_name): self.client.update_server_name(int(server_id), new_name) @@ -267,7 +269,7 @@ class server_rename(object): @command(api='compute') class server_delete(object): - """delete server""" + """Delete a server""" def main(self, server_id): self.client.delete_server(int(server_id)) @@ -275,7 +277,7 @@ class server_delete(object): @command(api='compute') class server_reboot(object): - """reboot server""" + """Reboot a server""" def update_parser(cls, parser): parser.add_option('-f', dest='hard', action='store_true', @@ -287,7 +289,7 @@ class server_reboot(object): @command(api='cyclades') class server_start(object): - """start server""" + """Start a server""" def main(self, server_id): self.client.start_server(int(server_id)) @@ -295,7 +297,7 @@ class server_start(object): @command(api='cyclades') class server_shutdown(object): - """shutdown server""" + """Shutdown a server""" def main(self, server_id): self.client.shutdown_server(int(server_id)) @@ -303,7 +305,7 @@ class server_shutdown(object): @command(api='cyclades') class server_console(object): - """get a VNC console""" + """Get a VNC console""" def main(self, server_id): reply = self.client.get_server_console(int(server_id)) @@ -312,7 +314,7 @@ class server_console(object): @command(api='cyclades') class server_firewall(object): - """set the firewall profile""" + """Set the server's firewall profile""" def main(self, server_id, profile): self.client.set_firewall_profile(int(server_id), profile) @@ -320,7 +322,7 @@ class server_firewall(object): @command(api='cyclades') class server_addr(object): - """list server addresses""" + """List a server's addresses""" def main(self, server_id, network=None): reply = self.client.list_server_addresses(int(server_id), network) @@ -330,7 +332,7 @@ class server_addr(object): @command(api='compute') class server_meta(object): - """get server metadata""" + """Get a server's metadata""" def main(self, server_id, key=None): reply = self.client.get_server_metadata(int(server_id), key) @@ -339,7 +341,7 @@ class server_meta(object): @command(api='compute') class server_addmeta(object): - """add server metadata""" + """Add server metadata""" def main(self, server_id, key, val): reply = self.client.create_server_metadata(int(server_id), key, val) @@ -348,7 +350,7 @@ class server_addmeta(object): @command(api='compute') class server_setmeta(object): - """update server metadata""" + """Update server's metadata""" def main(self, server_id, key, val): metadata = {key: val} @@ -358,7 +360,7 @@ class server_setmeta(object): @command(api='compute') class server_delmeta(object): - """delete server metadata""" + """Delete server metadata""" def main(self, server_id, key): self.client.delete_server_metadata(int(server_id), key) @@ -366,7 +368,7 @@ class server_delmeta(object): @command(api='cyclades') class server_stats(object): - """get server statistics""" + """Get server statistics""" def main(self, server_id): reply = self.client.get_server_stats(int(server_id)) @@ -375,7 +377,7 @@ class server_stats(object): @command(api='compute') class flavor_list(object): - """list flavors""" + """List flavors""" def update_parser(cls, parser): parser.add_option('-l', dest='detail', action='store_true', @@ -388,7 +390,7 @@ class flavor_list(object): @command(api='compute') class flavor_info(object): - """get flavor details""" + """Get flavor details""" def main(self, flavor_id): flavor = self.client.get_flavor_details(int(flavor_id)) @@ -397,7 +399,7 @@ class flavor_info(object): @command(api='compute') class image_list(object): - """list images""" + """List images""" def update_parser(cls, parser): parser.add_option('-l', dest='detail', action='store_true', @@ -410,7 +412,7 @@ class image_list(object): @command(api='compute') class image_info(object): - """get image details""" + """Get image details""" def main(self, image_id): image = self.client.get_image_details(image_id) @@ -419,7 +421,7 @@ class image_info(object): @command(api='compute') class image_delete(object): - """delete image""" + """Delete image""" def main(self, image_id): self.client.delete_image(image_id) @@ -427,7 +429,7 @@ class image_delete(object): @command(api='compute') class image_meta(object): - """get image metadata""" + """Get image metadata""" def main(self, image_id, key=None): reply = self.client.get_image_metadata(image_id, key) @@ -436,7 +438,7 @@ class image_meta(object): @command(api='compute') class image_addmeta(object): - """add image metadata""" + """Add image metadata""" def main(self, image_id, key, val): reply = self.client.create_image_metadata(image_id, key, val) @@ -445,7 +447,7 @@ class image_addmeta(object): @command(api='compute') class image_setmeta(object): - """update image metadata""" + """Update image metadata""" def main(self, image_id, key, val): metadata = {key: val} @@ -455,7 +457,7 @@ class image_setmeta(object): @command(api='compute') class image_delmeta(object): - """delete image metadata""" + """Delete image metadata""" def main(self, image_id, key): self.client.delete_image_metadata(image_id, key) @@ -463,7 +465,7 @@ class image_delmeta(object): @command(api='cyclades') class network_list(object): - """list networks""" + """List networks""" def update_parser(cls, parser): parser.add_option('-l', dest='detail', action='store_true', @@ -476,7 +478,7 @@ class network_list(object): @command(api='cyclades') class network_create(object): - """create a network""" + """Create a network""" def main(self, name): reply = self.client.create_network(name) @@ -485,7 +487,7 @@ class network_create(object): @command(api='cyclades') class network_info(object): - """get network details""" + """Get network details""" def main(self, network_id): network = self.client.get_network_details(network_id) @@ -494,7 +496,7 @@ class network_info(object): @command(api='cyclades') class network_rename(object): - """update network name""" + """Update network name""" def main(self, network_id, new_name): self.client.update_network_name(network_id, new_name) @@ -502,7 +504,7 @@ class network_rename(object): @command(api='cyclades') class network_delete(object): - """delete a network""" + """Delete a network""" def main(self, network_id): self.client.delete_network(network_id) @@ -510,7 +512,7 @@ class network_delete(object): @command(api='cyclades') class network_connect(object): - """connect a server to a network""" + """Connect a server to a network""" def main(self, server_id, network_id): self.client.connect_server(server_id, network_id) @@ -518,7 +520,7 @@ class network_connect(object): @command(api='cyclades') class network_disconnect(object): - """disconnect a server from a network""" + """Disconnect a server from a network""" def main(self, server_id, network_id): self.client.disconnect_server(server_id, network_id) @@ -526,7 +528,7 @@ class network_disconnect(object): @command(api='image') class glance_list(object): - """list images""" + """List images""" def update_parser(cls, parser): parser.add_option('-l', dest='detail', action='store_true', @@ -562,7 +564,7 @@ class glance_list(object): @command(api='image') class glance_meta(object): - """get image metadata""" + """Get image metadata""" def main(self, image_id): image = self.client.get_meta(image_id) @@ -571,7 +573,7 @@ class glance_meta(object): @command(api='image') class glance_register(object): - """register an image""" + """Register an image""" def update_parser(cls, parser): parser.add_option('--checksum', dest='checksum', metavar='CHECKSUM', @@ -595,11 +597,14 @@ class glance_register(object): def main(self, name, location): params = {} for key in ('checksum', 'container_format', 'disk_format', 'id', - 'owner', 'is_public', 'size'): + 'owner', 'size'): val = getattr(self.options, key) if val is not None: params[key] = val + if self.options.is_public: + params['is_public'] = 'true' + properties = {} for property in self.options.properties or []: key, sep, val = property.partition('=') @@ -613,7 +618,7 @@ class glance_register(object): @command(api='image') class glance_members(object): - """get image members""" + """Get image members""" def main(self, image_id): members = self.client.list_members(image_id) @@ -623,7 +628,7 @@ class glance_members(object): @command(api='image') class glance_shared(object): - """list shared images""" + """List shared images""" def main(self, member): images = self.client.list_shared(member) @@ -633,7 +638,7 @@ class glance_shared(object): @command(api='image') class glance_addmember(object): - """add a member to an image""" + """Add a member to an image""" def main(self, image_id, member): self.client.add_member(image_id, member) @@ -641,7 +646,7 @@ class glance_addmember(object): @command(api='image') class glance_delmember(object): - """remove a member from an image""" + """Remove a member from an image""" def main(self, image_id, member): self.client.remove_member(image_id, member) @@ -649,7 +654,7 @@ class glance_delmember(object): @command(api='image') class glance_setmembers(object): - """set the members of an image""" + """Set the members of an image""" def main(self, image_id, *member): self.client.set_members(image_id, member) @@ -660,72 +665,107 @@ class store_command(object): def update_parser(cls, parser): parser.add_option('--account', dest='account', metavar='NAME', - help='use account NAME') + help="Specify an account to use") parser.add_option('--container', dest='container', metavar='NAME', - help='use container NAME') + help="Specify a container to use") - def main(self): - self.config.override('storage_account', self.options.account) - self.config.override('storage_container', self.options.container) + def progress(self, message): + """Return a generator function to be used for progress tracking""" + + MESSAGE_LENGTH = 25 + MAX_PROGRESS_LENGTH = 32 + + def progress_gen(n): + msg = message.ljust(MESSAGE_LENGTH) + width = min(n, MAX_PROGRESS_LENGTH) + hide = self.config.get('global', 'silent') or (n < 2) + for i in progress.bar(range(n), msg, width, hide): + yield + yield - # Use the more efficient Pithos client if available - if 'pithos' in self.config.get('apis').split(): - self.client = clients.PithosClient(self.config) + return progress_gen + + def main(self): + if self.options.account is not None: + self.client.account = self.options.account + if self.options.container is not None: + self.client.container = self.options.container @command(api='storage') class store_create(object): - """create a container""" + """Create a container""" def update_parser(cls, parser): - parser.add_option('--account', dest='account', metavar='ACCOUNT', - help='use account ACCOUNT') + parser.add_option('--account', dest='account', metavar='NAME', + help="Specify an account to use") def main(self, container): - self.config.override('storage_account', self.options.account) + if self.options.account: + self.client.account = self.options.account self.client.create_container(container) @command(api='storage') -class store_container(store_command): - """get container info""" +class store_container(object): + """Get container info""" - def main(self): - store_command.main(self) - reply = self.client.get_container_meta() + def update_parser(cls, parser): + parser.add_option('--account', dest='account', metavar='NAME', + help="Specify an account to use") + + def main(self, container): + if self.options.account: + self.client.account = self.options.account + reply = self.client.get_container_meta(container) print_dict(reply) @command(api='storage') class store_upload(store_command): - """upload a file""" + """Upload a file""" def main(self, path, remote_path=None): - store_command.main(self) + super(store_upload, self).main() + if remote_path is None: remote_path = basename(path) with open(path) as f: - self.client.create_object(remote_path, f) + hash_cb = self.progress('Calculating block hashes') + upload_cb = self.progress('Uploading blocks') + self.client.create_object(remote_path, f, hash_cb=hash_cb, + upload_cb=upload_cb) @command(api='storage') class store_download(store_command): - """download a file""" - - def main(self, remote_path, local_path): - store_command.main(self) - f = self.client.get_object(remote_path) + """Download a file""" + + def main(self, remote_path, local_path='-'): + super(store_download, self).main() + + f, size = self.client.get_object(remote_path) out = open(local_path, 'w') if local_path != '-' else stdout - block = 4096 - data = f.read(block) + + blocksize = 4 * 1024**2 + nblocks = 1 + (size - 1) // blocksize + + cb = self.progress('Downloading blocks') if local_path != '-' else None + if cb: + gen = cb(nblocks) + gen.next() + + data = f.read(blocksize) while data: out.write(data) - data = f.read(block) + data = f.read(blocksize) + if cb: + gen.next() @command(api='storage') class store_delete(store_command): - """delete a file""" + """Delete a file""" def main(self, path): store_command.main(self) @@ -751,6 +791,15 @@ def print_commands(group): puts(columns([name, 12], [cls.description, 60])) +def add_handler(name, level, prefix=''): + h = logging.StreamHandler() + fmt = logging.Formatter(prefix + '%(message)s') + h.setFormatter(fmt) + logger = logging.getLogger(name) + logger.addHandler(h) + logger.setLevel(level) + + def main(): parser = OptionParser(add_help_option=False) parser.usage = '%prog [options]' @@ -759,6 +808,9 @@ def main(): help="Show this help message and exit") parser.add_option('--config', dest='config', metavar='PATH', help="Specify the path to the configuration file") + parser.add_option('-d', '--debug', dest='debug', action='store_true', + default=False, + help="Include debug output") parser.add_option('-i', '--include', dest='include', action='store_true', default=False, help="Include protocol headers in the output") @@ -780,15 +832,6 @@ def main(): print "kamaki %s" % kamaki.__version__ exit(0) - if args.contains(['-s', '--silent']): - level = logging.CRITICAL - elif args.contains(['-v', '--verbose']): - level = logging.INFO - else: - level = logging.WARNING - - logging.basicConfig(level=level, format='%(message)s') - if '--config' in args: config_path = args.grouped['--config'].get(0) else: @@ -859,34 +902,54 @@ def main(): if hasattr(cmd, 'update_parser'): cmd.update_parser(parser) - if args.contains(['-h', '--help']): + options, arguments = parser.parse_args(argv) + + if options.help: parser.print_help() exit(0) - cmd.options, cmd.args = parser.parse_args(argv) + if options.silent: + add_handler('', logging.CRITICAL) + elif options.debug: + add_handler('requests', logging.INFO, prefix='* ') + add_handler('clients.send', logging.DEBUG, prefix='> ') + add_handler('clients.recv', logging.DEBUG, prefix='< ') + elif options.verbose: + add_handler('requests', logging.INFO, prefix='* ') + add_handler('clients.send', logging.INFO, prefix='> ') + add_handler('clients.recv', logging.INFO, prefix='< ') + elif options.include: + add_handler('clients.recv', logging.INFO) + else: + add_handler('', logging.WARNING) api = cmd.api - if api == 'config': - cmd.config = config - elif api in ('compute', 'image', 'storage'): - token = config.get(api, 'token') or config.get('gobal', 'token') - url = config.get(api, 'url') - client_cls = getattr(clients, api) - kwargs = dict(base_url=url, token=token) - - # Special cases - if api == 'compute' and config.getboolean(api, 'cyclades_extensions'): - client_cls = clients.cyclades - elif api == 'storage': - kwargs['account'] = config.get(api, 'account') - kwargs['container'] = config.get(api, 'container') - if config.getboolean(api, 'pithos_extensions'): - client_cls = clients.pithos - - cmd.client = client_cls(**kwargs) - + if api in ('compute', 'cyclades'): + url = config.get('compute', 'url') + token = config.get('compute', 'token') or config.get('global', 'token') + if config.getboolean('compute', 'cyclades_extensions'): + cmd.client = clients.cyclades(url, token) + else: + cmd.client = clients.compute(url, token) + elif api in ('storage', 'pithos'): + url = config.get('storage', 'url') + token = config.get('storage', 'token') or config.get('global', 'token') + account = config.get('storage', 'account') + container = config.get('storage', 'container') + if config.getboolean('storage', 'pithos_extensions'): + cmd.client = clients.pithos(url, token, account, container) + else: + cmd.client = clients.storage(url, token, account, container) + elif api == 'image': + url = config.get('image', 'url') + token = config.get('image', 'token') or config.get('global', 'token') + cmd.client = clients.image(url, token) + + cmd.options = options + cmd.config = config + try: - ret = cmd.main(*args.grouped['_'][2:]) + ret = cmd.main(*arguments[3:]) exit(ret) except TypeError as e: if e.args and e.args[0].startswith('main()'): @@ -894,10 +957,21 @@ def main(): exit(1) else: raise - except clients.ClientError, err: - log.error('%s', err.message) - log.info('%s', err.details) + except clients.ClientError as err: + if err.status == 404: + color = yellow + elif 500 <= err.status < 600: + color = magenta + else: + color = red + + puts_err(color(err.message)) + if err.details and (options.verbose or options.debug): + puts_err(err.details) exit(2) + except ConnectionError as err: + puts_err(red("Connection error")) + exit(1) if __name__ == '__main__': diff --git a/kamaki/clients/__init__.py b/kamaki/clients/__init__.py index 8044312..ce5ce24 100644 --- a/kamaki/clients/__init__.py +++ b/kamaki/clients/__init__.py @@ -31,26 +31,122 @@ # interpreted as representing official policies, either expressed # or implied, of GRNET S.A. +import json +import logging + +import requests + +from requests.auth import AuthBase + + +sendlog = logging.getLogger('clients.send') +recvlog = logging.getLogger('clients.recv') + + +# Add a convenience json property to the responses +def _json(self): + try: + return json.loads(self.content) + except ValueError: + raise ClientError("Invalid JSON reply", self.status_code) +requests.Response.json = property(_json) + +# Add a convenience status property to the responses +def _status(self): + return requests.status_codes._codes[self.status_code][0].upper() +requests.Response.status = property(_status) + + +class XAuthTokenAuth(AuthBase): + def __init__(self, token): + self.token = token + + def __call__(self, r): + r.headers['X-Auth-Token'] = self.token + return r + + 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 +class Client(object): + def __init__(self, base_url, token, include=False, verbose=False): + self.base_url = base_url + self.auth = XAuthTokenAuth(token) + self.include = include + self.verbose = verbose + + def raise_for_status(self, r): + if 400 <= r.status_code < 500: + message, sep, details = r.text.partition('\n') + elif 500 <= r.status_code < 600: + message = '%d Server Error' % (r.status_code,) + details = r.text + else: + message = '%d Unknown Error' % (r.status_code,) + details = r.text + + message = message or "HTTP Error %d" % (r.status_code,) + raise ClientError(message, r.status_code, details) + + def request(self, method, path, **kwargs): + raw = kwargs.pop('raw', False) + success = kwargs.pop('success', 200) + if 'json' in kwargs: + data = json.dumps(kwargs.pop('json')) + kwargs['data'] = data + headers = kwargs.setdefault('headers', {}) + headers['content-type'] = 'application/json' + + url = self.base_url + path + kwargs.setdefault('auth', self.auth) + r = requests.request(method, url, **kwargs) + + req = r.request + sendlog.info('%s %s', req.method, req.url) + for key, val in req.headers.items(): + sendlog.info('%s: %s', key, val) + sendlog.info('') + if req.data: + sendlog.info('%s', req.data) + + recvlog.info('%d %s', r.status_code, r.status) + for key, val in r.headers.items(): + recvlog.info('%s: %s', key, val) + recvlog.info('') + if not raw and r.text: + recvlog.debug(r.text) + + if success is not None: + # Success can either be an in or a collection + success = (success,) if isinstance(success, int) else success + if r.status_code not in success: + self.raise_for_status(r) + return r + def delete(self, path, **kwargs): + return self.request('delete', path, **kwargs) + + def get(self, path, **kwargs): + return self.request('get', path, **kwargs) + + def head(self, path, **kwargs): + return self.request('head', path, **kwargs) + + def post(self, path, **kwargs): + return self.request('post', path, **kwargs) + + def put(self, path, **kwargs): + return self.request('put', path, **kwargs) + -from .compute import ComputeClient -from .image import ImageClient -from .storage import StorageClient -from .cyclades import CycladesClient -from .pithos import PithosClient +from .compute import ComputeClient as compute +from .image import ImageClient as image +from .storage import StorageClient as storage +from .cyclades import CycladesClient as cyclades +from .pithos import PithosClient as pithos diff --git a/kamaki/clients/compute.py b/kamaki/clients/compute.py index 5ec3b86..00b82c5 100644 --- a/kamaki/clients/compute.py +++ b/kamaki/clients/compute.py @@ -31,40 +31,33 @@ # interpreted as representing official policies, either expressed # or implied, of GRNET S.A. -import json +from . import Client, ClientError -from . import ClientError -from .http import HTTPClient - -class ComputeClient(HTTPClient): +class ComputeClient(Client): """OpenStack Compute API 1.1 client""" - @property - def url(self): - url = self.config.get('compute_url') or self.config.get('url') - if not url: - raise ClientError('No URL was given') - return url - - @property - def token(self): - token = self.config.get('compute_token') or self.config.get('token') - if not token: - raise ClientError('No token was given') - return token + def raise_for_status(self, r): + d = r.json + key = d.keys()[0] + val = d[key] + message = '%s: %s' % (key, val.get('message', '')) + details = val.get('details', '') + raise ClientError(message, r.status_code, details) 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'] + r = self.get(path, success=200) + return r.json['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'] + + path = '/servers/%s' % (server_id,) + r = self.get(path, success=200) + return r.json['server'] def create_server(self, name, flavor_id, image_id, personality=None): """Submit request to create a new server @@ -78,13 +71,14 @@ class ComputeClient(HTTPClient): The call returns a dictionary describing the newly created server. """ - req = {'name': name, 'flavorRef': flavor_id, 'imageRef': image_id} + req = {'server': {'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'] + r = self.post('/servers', json=req, success=202) + return r.json['server'] def update_server_name(self, server_id, new_name): """Update the name of the server as reported by the API. @@ -92,90 +86,92 @@ class ComputeClient(HTTPClient): 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) + path = '/servers/%s' % (server_id,) + req = {'server': {'name': new_name}} + self.put(path, json=req, success=204) 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) + + path = '/servers/%s' % (server_id,) + self.delete(path, success=204) 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) + path = '/servers/%s/action' % (server_id,) + type = 'HARD' if hard else 'SOFT' + req = {'reboot': {'type': type}} + self.post(path, json=req, success=202) + def get_server_metadata(self, server_id, key=None): - path = '/servers/%d/meta' % server_id + path = '/servers/%s/meta' % (server_id,) if key: path += '/%s' % key - reply = self.http_get(path) - return reply['meta'] if key else reply['metadata']['values'] + r = self.get(path, success=200) + return r.json['meta'] if key else r.json['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'] + req = {'meta': {key: val}} + r = self.put(path, json=req, success=201) + return r.json['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'] + path = '/servers/%d/meta' % (server_id,) + req = {'metadata': metadata} + r = self.post(path, json=req, success=201) + return r.json['metadata'] def delete_server_metadata(self, server_id, key): path = '/servers/%d/meta/%s' % (server_id, key) - reply = self.http_delete(path) - - + self.delete(path, success=204) + + def list_flavors(self, detail=False): path = '/flavors/detail' if detail else '/flavors' - reply = self.http_get(path) - return reply['flavors']['values'] + r = self.get(path, success=200) + return r.json['flavors']['values'] def get_flavor_details(self, flavor_id): path = '/flavors/%d' % flavor_id - reply = self.http_get(path) - return reply['flavor'] + r = self.get(path, success=200) + return r.json['flavor'] def list_images(self, detail=False): path = '/images/detail' if detail else '/images' - reply = self.http_get(path) - return reply['images']['values'] + r = self.get(path, success=200) + return r.json['images']['values'] def get_image_details(self, image_id): - path = '/images/%s' % image_id - reply = self.http_get(path) - return reply['image'] + path = '/images/%s' % (image_id,) + r = self.get(path, success=200) + return r.json['image'] def delete_image(self, image_id): - path = '/images/%s' % image_id - self.http_delete(path) + path = '/images/%s' % (image_id,) + self.delete(path, success=204) def get_image_metadata(self, image_id, key=None): - path = '/images/%s/meta' % image_id + path = '/images/%s/meta' % (image_id,) if key: path += '/%s' % key - reply = self.http_get(path) - return reply['meta'] if key else reply['metadata']['values'] + r = self.get(path, success=200) + return r.json['meta'] if key else r.json['metadata']['values'] def create_image_metadata(self, image_id, key, val): path = '/images/%s/meta/%s' % (image_id, key) - body = json.dumps({'meta': {key: val}}) - reply = self.http_put(path, body, success=201) - return reply['meta'] + req = {'meta': {key: val}} + r = self.put(path, json=req, success=201) + return r.json['meta'] def update_image_metadata(self, image_id, **metadata): - path = '/images/%s/meta' % image_id - body = json.dumps({'metadata': metadata}) - reply = self.http_post(path, body, success=201) - return reply['metadata'] + path = '/images/%s/meta' % (image_id,) + req = {'metadata': metadata} + r = self.post(path, json=req, success=201) + return r.json['metadata'] def delete_image_metadata(self, image_id, key): path = '/images/%s/meta/%s' % (image_id, key) - self.http_delete(path) + self.delete(path, success=204) diff --git a/kamaki/clients/cyclades.py b/kamaki/clients/cyclades.py index 7e6b5e9..f4503cc 100644 --- a/kamaki/clients/cyclades.py +++ b/kamaki/clients/cyclades.py @@ -31,8 +31,6 @@ # interpreted as representing official policies, either expressed # or implied, of GRNET S.A. -import json - from .compute import ComputeClient @@ -41,22 +39,25 @@ class CycladesClient(ComputeClient): 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) + + path = '/servers/%s/action' % (server_id,) + req = {'start': {}} + self.post(path, json=req, success=202) 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) + + path = '/servers/%s/action' % (server_id,) + req = {'shutdown': {}} + self.post(path, json=req, success=202) 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'] + + path = '/servers/%s/action' % (server_id,) + req = {'console': {'type': 'vnc'}} + r = self.post(path, json=req, success=200) + return r.json['console'] def set_firewall_profile(self, server_id, profile): """Set the firewall profile for the public interface of a server @@ -64,53 +65,56 @@ class CycladesClient(ComputeClient): 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) + path = '/servers/%s/action' % (server_id,) + req = {'firewallProfile': {'profile': profile}} + self.post(path, json=req, success=202) def list_server_addresses(self, server_id, network=None): - path = '/servers/%d/ips' % server_id + path = '/servers/%s/ips' % (server_id,) if network: path += '/%s' % network - reply = self.http_get(path) - return [reply['network']] if network else reply['addresses']['values'] + r = self.get(path, success=200) + if network: + return [r.json['network']] + else: + return r.json['addresses']['values'] def get_server_stats(self, server_id): - path = '/servers/%d/stats' % server_id - reply = self.http_get(path) - return reply['stats'] + path = '/servers/%s/stats' % (server_id,) + r = self.get(path, success=200) + return r.json['stats'] def list_networks(self, detail=False): path = '/networks/detail' if detail else '/networks' - reply = self.http_get(path) - return reply['networks']['values'] + r = self.get(path, success=200) + return r.json['networks']['values'] def create_network(self, name): - body = json.dumps({'network': {'name': name}}) - reply = self.http_post('/networks', body) - return reply['network'] + req = {'network': {'name': name}} + r = self.post('/networks', json=req, success=202) + return r.json['network'] def get_network_details(self, network_id): - path = '/networks/%s' % network_id - reply = self.http_get(path) - return reply['network'] + path = '/networks/%s' % (network_id,) + r = self.get(path, success=200) + return r.json['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) + path = '/networks/%s' % (network_id,) + req = {'network': {'name': new_name}} + self.put(path, json=req, success=204) def delete_network(self, network_id): - path = '/networks/%s' % network_id - self.http_delete(path) + path = '/networks/%s' % (network_id,) + self.delete(path, success=204) 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) + path = '/networks/%s/action' % (network_id,) + req = {'add': {'serverRef': server_id}} + self.post(path, json=req, success=202) 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) + path = '/networks/%s/action' % (network_id,) + req = {'remove': {'serverRef': server_id}} + self.post(path, json=req, success=202) diff --git a/kamaki/clients/pithos.py b/kamaki/clients/pithos.py index ab41816..99f4901 100644 --- a/kamaki/clients/pithos.py +++ b/kamaki/clients/pithos.py @@ -32,59 +32,86 @@ # or implied, of GRNET S.A. import hashlib -import json +import os -from . import ClientError -from .storage import StorageClient from ..utils import OrderedDict +from .storage import StorageClient + + +def pithos_hash(block, blockhash): + h = hashlib.new(blockhash) + h.update(block.rstrip('\x00')) + return h.hexdigest() + class PithosClient(StorageClient): """GRNet Pithos API client""" def put_block(self, data, hash): - path = '/%s/%s?update' % (self.account, self.container) + path = '/%s/%s' % (self.account, self.container) + params = {'update': ''} headers = {'Content-Type': 'application/octet-stream', - 'Content-Length': len(data)} - resp, reply = self.raw_http_cmd('POST', path, data, headers, - success=202) - assert reply.strip() == hash, 'Local hash does not match server' + 'Content-Length': str(len(data))} + r = self.post(path, params=params, data=data, headers=headers, + success=202) + assert r.text.strip() == hash, 'Local hash does not match server' - def create_object(self, object, f): - meta = self.get_container_meta() + def create_object(self, object, f, hash_cb=None, upload_cb=None): + """Create an object by uploading only the missing blocks + + hash_cb is a generator function taking the total number of blocks to + be hashed as an argument. Its next() will be called every time a block + is hashed. + + upload_cb is a generator function with the same properties that is + called every time a block is uploaded. + """ + self.assert_container() + + meta = self.get_container_meta(self.container) blocksize = int(meta['block-size']) blockhash = meta['block-hash'] - size = 0 + file_size = os.fstat(f.fileno()).st_size + nblocks = 1 + (file_size - 1) // blocksize hashes = OrderedDict() - data = f.read(blocksize) - while data: - bytes = len(data) - h = hashlib.new(blockhash) - h.update(data.rstrip('\x00')) - hash = h.hexdigest() + + size = 0 + + if hash_cb: + hash_gen = hash_cb(nblocks) + hash_gen.next() + for i in range(nblocks): + block = f.read(blocksize) + bytes = len(block) + hash = pithos_hash(block, blockhash) hashes[hash] = (size, bytes) size += bytes - data = f.read(blocksize) + if hash_cb: + hash_gen.next() + + assert size == file_size - path = '/%s/%s/%s?hashmap&format=json' % (self.account, self.container, - object) + path = '/%s/%s/%s' % (self.account, self.container, object) + params = {'hashmap': '', 'format': 'json'} hashmap = dict(bytes=size, hashes=hashes.keys()) - req = json.dumps(hashmap) - resp, reply = self.raw_http_cmd('PUT', path, req, success=None) - - if resp.status not in (201, 409): - raise ClientError('Invalid response from the server') + r = self.put(path, params=params, json=hashmap, success=(201, 409)) - if resp.status == 201: + if r.status_code == 201: return - missing = json.loads(reply) + missing = r.json + if upload_cb: + upload_gen = upload_cb(len(missing)) + upload_gen.next() for hash in missing: offset, bytes = hashes[hash] f.seek(offset) data = f.read(bytes) self.put_block(data, hash) + if upload_cb: + upload_gen.next() - self.http_put(path, req, success=201) + self.put(path, params=params, json=hashmap, success=201) diff --git a/kamaki/clients/storage.py b/kamaki/clients/storage.py index e055a1e..f7b5a76 100644 --- a/kamaki/clients/storage.py +++ b/kamaki/clients/storage.py @@ -31,67 +31,64 @@ # interpreted as representing official policies, either expressed # or implied, of GRNET S.A. -from . import ClientError -from .http import HTTPClient +from . import Client, ClientError -class StorageClient(HTTPClient): +class StorageClient(Client): """OpenStack Object Storage API 1.0 client""" - @property - def url(self): - url = self.config.get('storage_url') or self.config.get('url') - if not url: - raise ClientError('No URL was given') - return url - - @property - def token(self): - token = self.config.get('storage_token') or self.config.get('token') - if not token: - raise ClientError('No token was given') - return token + def __init__(self, base_url, token, account=None, container=None): + super(StorageClient, self).__init__(base_url, token) + self.account = account + self.container = container - @property - def account(self): - account = self.config.get('storage_account') - if not account: - raise ClientError('No account was given') - return account + def assert_account(self): + if not self.account: + raise ClientError("Please provide an account") - @property - def container(self): - container = self.config.get('storage_container') - if not container: - raise ClientError('No container was given') - return container + def assert_container(self): + self.assert_account() + if not self.container: + raise ClientError("Please provide a container") def create_container(self, container): + self.assert_account() path = '/%s/%s' % (self.account, container) - self.http_put(path, success=201) + r = self.put(path, success=(201, 202)) + if r.status_code == 202: + raise ClientError("Container already exists") - def get_container_meta(self): - path = '/%s/%s' % (self.account, self.container) - resp, reply = self.raw_http_cmd('HEAD', path, success=204) + def get_container_meta(self, container): + self.assert_account() + path = '/%s/%s' % (self.account, container) + r = self.head(path, success=(204, 404)) + if r.status_code == 404: + raise ClientError("Container does not exist", r.status_code) + reply = {} prefix = 'x-container-' - for key, val in resp.getheaders(): + for key, val in r.headers.items(): key = key.lower() if key.startswith(prefix): reply[key[len(prefix):]] = val + return reply - def create_object(self, object, f): + def create_object(self, object, f, hash_cb=None, upload_cb=None): + # This is a naive implementation, it loads the whole file in memory + self.assert_container() path = '/%s/%s/%s' % (self.account, self.container, object) data = f.read() - self.http_put(path, data, success=201) - + self.put(path, data=data, success=201) + def get_object(self, object): + self.assert_container() path = '/%s/%s/%s' % (self.account, self.container, object) - resp, reply = self.raw_http_cmd('GET', path, success=200, - skip_read=True) - return resp.fp - + r = self.get(path, raw=True) + size = int(r.headers['content-length']) + return r.raw, size + def delete_object(self, object): + self.assert_container() path = '/%s/%s/%s' % (self.account, self.container, object) - self.http_delete(path) + self.delete(path, success=204) diff --git a/setup.py b/setup.py index 2d59f08..3b02e0d 100755 --- a/setup.py +++ b/setup.py @@ -51,6 +51,7 @@ setup( 'console_scripts': ['kamaki = kamaki.cli:main'] }, install_requires=[ + 'requests>=0.10.2', 'clint>=0.3' ] )