From 972677fc3dc24a5084657fc337fb2b2981a30e0c Mon Sep 17 00:00:00 2001 From: Jay Pipes Date: Wed, 29 Feb 2012 16:42:26 -0500 Subject: [PATCH] Initial checkin for new CLI and client package Copied mostly from python-keystoneclient with some Glance-specific stuff. README.rst shows what WILL be the way to do things, not what is currently coded :) --- .gitignore | 11 ++ AUTHORS | 1 + HACKING.rst | 186 +++++++++++++++++++++++ LICENSE | 209 ++++++++++++++++++++++++++ MANIFEST.in | 4 + README.rst | 109 ++++++++++++++ glanceclient/__init__.py | 0 glanceclient/base.py | 195 ++++++++++++++++++++++++ glanceclient/client.py | 175 ++++++++++++++++++++++ glanceclient/exceptions.py | 132 +++++++++++++++++ glanceclient/generic/__init__.py | 0 glanceclient/generic/client.py | 205 ++++++++++++++++++++++++++ glanceclient/generic/shell.py | 57 +++++++ glanceclient/service_catalog.py | 81 ++++++++++ glanceclient/shell.py | 246 +++++++++++++++++++++++++++++++ glanceclient/utils.py | 94 ++++++++++++ glanceclient/v1_1/__init__.py | 1 + glanceclient/v1_1/client.py | 113 ++++++++++++++ glanceclient/v1_1/images.py | 88 +++++++++++ glanceclient/v1_1/shell.py | 77 ++++++++++ run_tests.sh | 153 +++++++++++++++++++ setup.cfg | 13 ++ setup.py | 42 ++++++ tox.ini | 14 ++ 24 files changed, 2206 insertions(+) create mode 100644 .gitignore create mode 100644 AUTHORS create mode 100644 HACKING.rst create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 README.rst create mode 100644 glanceclient/__init__.py create mode 100644 glanceclient/base.py create mode 100644 glanceclient/client.py create mode 100644 glanceclient/exceptions.py create mode 100644 glanceclient/generic/__init__.py create mode 100644 glanceclient/generic/client.py create mode 100644 glanceclient/generic/shell.py create mode 100644 glanceclient/service_catalog.py create mode 100644 glanceclient/shell.py create mode 100644 glanceclient/utils.py create mode 100644 glanceclient/v1_1/__init__.py create mode 100644 glanceclient/v1_1/client.py create mode 100644 glanceclient/v1_1/images.py create mode 100755 glanceclient/v1_1/shell.py create mode 100755 run_tests.sh create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 tox.ini diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..097d2085 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.coverage +.venv +*,cover +cover +*.pyc +.idea +*.swp +*~ +build +dist +python_keystoneclient.egg-info diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 00000000..dcd8dc7f --- /dev/null +++ b/AUTHORS @@ -0,0 +1 @@ +Jay Pipes diff --git a/HACKING.rst b/HACKING.rst new file mode 100644 index 00000000..b6494bb9 --- /dev/null +++ b/HACKING.rst @@ -0,0 +1,186 @@ +Glance Style Commandments +========================= + +- Step 1: Read http://www.python.org/dev/peps/pep-0008/ +- Step 2: Read http://www.python.org/dev/peps/pep-0008/ again +- Step 3: Read on + + +General +------- +- Put two newlines between top-level code (funcs, classes, etc) +- Put one newline between methods in classes and anywhere else +- Do not write "except:", use "except Exception:" at the very least +- Include your name with TODOs as in "#TODO(termie)" +- Do not name anything the same name as a built-in or reserved word + + +Imports +------- +- Do not make relative imports +- Order your imports by the full module path +- Organize your imports according to the following template + +Example:: + + # vim: tabstop=4 shiftwidth=4 softtabstop=4 + {{stdlib imports in human alphabetical order}} + \n + {{third-party lib imports in human alphabetical order}} + \n + {{glance imports in human alphabetical order}} + \n + \n + {{begin your code}} + + +Human Alphabetical Order Examples +--------------------------------- +Example:: + + import httplib + import logging + import random + import StringIO + import time + import unittest + + import eventlet + import webob.exc + + import glance.api.middleware + from glance.api import images + from glance.auth import users + import glance.common + from glance.endpoint import cloud + from glance import test + + +Docstrings +---------- + +Docstrings are required for all functions and methods. + +Docstrings should ONLY use triple-double-quotes (``"""``) + +Single-line docstrings should NEVER have extraneous whitespace +between enclosing triple-double-quotes. + +**INCORRECT** :: + + """ There is some whitespace between the enclosing quotes :( """ + +**CORRECT** :: + + """There is no whitespace between the enclosing quotes :)""" + +Docstrings that span more than one line should look like this: + +Example:: + + """ + Start the docstring on the line following the opening triple-double-quote + + If you are going to describe parameters and return values, use Sphinx, the + appropriate syntax is as follows. + + :param foo: the foo parameter + :param bar: the bar parameter + :returns: return_type -- description of the return value + :returns: description of the return value + :raises: AttributeError, KeyError + """ + +**DO NOT** leave an extra newline before the closing triple-double-quote. + + +Dictionaries/Lists +------------------ +If a dictionary (dict) or list object is longer than 80 characters, its items +should be split with newlines. Embedded iterables should have their items +indented. Additionally, the last item in the dictionary should have a trailing +comma. This increases readability and simplifies future diffs. + +Example:: + + my_dictionary = { + "image": { + "name": "Just a Snapshot", + "size": 2749573, + "properties": { + "user_id": 12, + "arch": "x86_64", + }, + "things": [ + "thing_one", + "thing_two", + ], + "status": "ACTIVE", + }, + } + + +Calling Methods +--------------- +Calls to methods 80 characters or longer should format each argument with +newlines. This is not a requirement, but a guideline:: + + unnecessarily_long_function_name('string one', + 'string two', + kwarg1=constants.ACTIVE, + kwarg2=['a', 'b', 'c']) + + +Rather than constructing parameters inline, it is better to break things up:: + + list_of_strings = [ + 'what_a_long_string', + 'not as long', + ] + + dict_of_numbers = { + 'one': 1, + 'two': 2, + 'twenty four': 24, + } + + object_one.call_a_method('string three', + 'string four', + kwarg1=list_of_strings, + kwarg2=dict_of_numbers) + + +Internationalization (i18n) Strings +----------------------------------- +In order to support multiple languages, we have a mechanism to support +automatic translations of exception and log strings. + +Example:: + + msg = _("An error occurred") + raise HTTPBadRequest(explanation=msg) + +If you have a variable to place within the string, first internationalize the +template string then do the replacement. + +Example:: + + msg = _("Missing parameter: %s") % ("flavor",) + LOG.error(msg) + +If you have multiple variables to place in the string, use keyword parameters. +This helps our translators reorder parameters when needed. + +Example:: + + msg = _("The server with id %(s_id)s has no key %(m_key)s") + LOG.error(msg % {"s_id": "1234", "m_key": "imageId"}) + + +Creating Unit Tests +------------------- +For every new feature, unit tests should be created that both test and +(implicitly) document the usage of said feature. If submitting a patch for a +bug that had no unit test, a new passing unit test should be added. If a +submitted bug fix does have a unit test, be sure to add a new one that fails +without the patch and passes with the patch. diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..32b66114 --- /dev/null +++ b/LICENSE @@ -0,0 +1,209 @@ +Copyright (c) 2009 Jacob Kaplan-Moss - initial codebase (< v2.1) +Copyright (c) 2011 Rackspace - OpenStack extensions (>= v2.1) +Copyright (c) 2011 Nebula, Inc - Keystone refactor (>= v2.7) +All rights reserved. + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +--- License for python-keystoneclient versions prior to 2.1 --- + +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. + + 3. Neither the name of this project nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "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 THE COPYRIGHT OWNER 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. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..b023d223 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,4 @@ +include README.rst +include LICENSE +recursive-include docs * +recursive-include tests * diff --git a/README.rst b/README.rst new file mode 100644 index 00000000..f865371f --- /dev/null +++ b/README.rst @@ -0,0 +1,109 @@ +Python bindings to the OpenStack Glance API +============================================= + +This is a client for the OpenStack Glance API. There's a Python API (the +``glanceclient`` module), and a command-line script (``glance``). The +Glance 2.0 API is still a moving target, so this module will remain in +"Beta" status until the API is finalized and fully implemented. + +Development takes place via the usual OpenStack processes as outlined in +the `OpenStack wiki`_. The master repository is on GitHub__. + +__ http://wiki.openstack.org/HowToContribute +__ http://github.com/openstack/python-glanceclient + +This code a fork of `Rackspace's python-novaclient`__ which is in turn a fork of +`Jacobian's python-cloudservers`__. The python-glanceclient is licensed under +the Apache License like the rest of OpenStack. + +__ http://github.com/rackspace/python-novaclient +__ http://github.com/jacobian/python-cloudservers + +.. contents:: Contents: + :local: + +Python API +---------- + +By way of a quick-start:: + + # use v2.0 auth with http://example.com:5000/v2.0") + >>> from glanceclient.v2_0 import client + >>> glance = client.Client(username=USERNAME, password=PASSWORD, tenant_name=TENANT, auth_url=KEYSTONE_URL) + >>> glance.images.list() + >>> image = glance.images.create(name="My Test Image") + >>> print image.status + 'queued' + >>> image.upload(open('/tmp/myimage.iso', 'rb')) + >>> print image.status + 'active' + >>> image_file = image.image_file + >>> with open('/tmp/copyimage.iso', 'wb') as f: + for chunk in image_file: + f.write(chunk) + >>> image.delete() + + +Command-line API +---------------- + +Installing this package gets you a command-line tool, ``glance``, that you +can use to interact with Glance's Identity API. + +You'll need to provide your OpenStack tenant, username and password. You can do this +with the ``tenant_name``, ``--username`` and ``--password`` params, but it's +easier to just set them as environment variables:: + + export OS_TENANT_NAME=project + export OS_USERNAME=user + export OS_PASSWORD=pass + +You will also need to define the authentication url with ``--auth_url`` and the +version of the API with ``--identity_api_version``. Or set them as an environment +variables as well:: + + export OS_AUTH_URL=http://example.com:5000/v2.0 + export OS_IDENTITY_API_VERSION=2.0 + +Since the Identity service that Glance uses can return multiple regional image +endpoints in the Service Catalog, you can specify the one you want with +``--region_name`` (or ``export OS_REGION_NAME``). +It defaults to the first in the list returned. + +You'll find complete documentation on the shell by running +``glance help``:: + + usage: glance [--username USERNAME] [--password PASSWORD] + [--tenant_name TENANT_NAME | --tenant_id TENANT_ID] + [--auth_url AUTH_URL] [--region_name REGION_NAME] + [--identity_api_version IDENTITY_API_VERSION] + ... + + Command-line interface to the OpenStack Identity API. + + Positional arguments: + + catalog List all image services in service catalog + image-create Create new image + image-delete Delete image + image-list List images + image-update Update image's name and other properties + image-upload Upload an image file + image-download Download an image file + help Display help about this program or one of its + subcommands. + + Optional arguments: + --username USERNAME Defaults to env[OS_USERNAME] + --password PASSWORD Defaults to env[OS_PASSWORD] + --tenant_name TENANT_NAME + Defaults to env[OS_TENANT_NAME] + --tenant_id TENANT_ID + Defaults to env[OS_TENANT_ID] + --auth_url AUTH_URL Defaults to env[OS_AUTH_URL] + --region_name REGION_NAME + Defaults to env[OS_REGION_NAME] + --identity_api_version IDENTITY_API_VERSION + Defaults to env[OS_IDENTITY_API_VERSION] or 2.0 + +See "glance help COMMAND" for help on a specific command. diff --git a/glanceclient/__init__.py b/glanceclient/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/glanceclient/base.py b/glanceclient/base.py new file mode 100644 index 00000000..ad6fb2e5 --- /dev/null +++ b/glanceclient/base.py @@ -0,0 +1,195 @@ +# Copyright 2010 Jacob Kaplan-Moss +# Copyright 2011 OpenStack LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Base utilities to build API operation managers and objects on top of. +""" + +from glanceclient import exceptions + + +# Python 2.4 compat +try: + all +except NameError: + def all(iterable): + return True not in (not x for x in iterable) + + +def getid(obj): + """ + Abstracts the common pattern of allowing both an object or an object's ID + (UUID) as a parameter when dealing with relationships. + """ + + # Try to return the object's UUID first, if we have a UUID. + try: + if obj.uuid: + return obj.uuid + except AttributeError: + pass + try: + return obj.id + except AttributeError: + return obj + + +class Manager(object): + """ + Managers interact with a particular type of API (servers, flavors, images, + etc.) and provide CRUD operations for them. + """ + resource_class = None + + def __init__(self, api): + self.api = api + + def _list(self, url, response_key, obj_class=None, body=None): + resp = None + if body: + resp, body = self.api.post(url, body=body) + else: + resp, body = self.api.get(url) + + if obj_class is None: + obj_class = self.resource_class + + data = body[response_key] + return [obj_class(self, res, loaded=True) for res in data if res] + + def _get(self, url, response_key): + resp, body = self.api.get(url) + return self.resource_class(self, body[response_key]) + + def _create(self, url, body, response_key, return_raw=False): + resp, body = self.api.post(url, body=body) + if return_raw: + return body[response_key] + return self.resource_class(self, body[response_key]) + + def _delete(self, url): + resp, body = self.api.delete(url) + + def _update(self, url, body, response_key=None, method="PUT"): + methods = {"PUT": self.api.put, + "POST": self.api.post} + try: + resp, body = methods[method](url, body=body) + except KeyError: + raise exceptions.ClientException("Invalid update method: %s" + % method) + # PUT requests may not return a body + if body: + return self.resource_class(self, body[response_key]) + + +class ManagerWithFind(Manager): + """ + Like a `Manager`, but with additional `find()`/`findall()` methods. + """ + def find(self, **kwargs): + """ + Find a single item with attributes matching ``**kwargs``. + + This isn't very efficient: it loads the entire list then filters on + the Python side. + """ + rl = self.findall(**kwargs) + try: + return rl[0] + except IndexError: + msg = "No %s matching %s." % (self.resource_class.__name__, kwargs) + raise exceptions.NotFound(404, msg) + + def findall(self, **kwargs): + """ + Find all items with attributes matching ``**kwargs``. + + This isn't very efficient: it loads the entire list then filters on + the Python side. + """ + found = [] + searches = kwargs.items() + + for obj in self.list(): + try: + if all(getattr(obj, attr) == value + for (attr, value) in searches): + found.append(obj) + except AttributeError: + continue + + return found + + +class Resource(object): + """ + A resource represents a particular instance of an object (tenant, user, + etc). This is pretty much just a bag for attributes. + + :param manager: Manager object + :param info: dictionary representing resource attributes + :param loaded: prevent lazy-loading if set to True + """ + def __init__(self, manager, info, loaded=False): + self.manager = manager + self._info = info + self._add_details(info) + self._loaded = loaded + + def _add_details(self, info): + for (k, v) in info.iteritems(): + setattr(self, k, v) + + def __getattr__(self, k): + if k not in self.__dict__: + #NOTE(bcwaldon): disallow lazy-loading if already loaded once + if not self.is_loaded(): + self.get() + return self.__getattr__(k) + + raise AttributeError(k) + else: + return self.__dict__[k] + + def __repr__(self): + reprkeys = sorted(k for k in self.__dict__.keys() if k[0] != '_' and + k != 'manager') + info = ", ".join("%s=%s" % (k, getattr(self, k)) for k in reprkeys) + return "<%s %s>" % (self.__class__.__name__, info) + + def get(self): + # set_loaded() first ... so if we have to bail, we know we tried. + self.set_loaded(True) + if not hasattr(self.manager, 'get'): + return + + new = self.manager.get(self.id) + if new: + self._add_details(new._info) + + def __eq__(self, other): + if not isinstance(other, self.__class__): + return False + if hasattr(self, 'id') and hasattr(other, 'id'): + return self.id == other.id + return self._info == other._info + + def is_loaded(self): + return self._loaded + + def set_loaded(self, val): + self._loaded = val diff --git a/glanceclient/client.py b/glanceclient/client.py new file mode 100644 index 00000000..09755842 --- /dev/null +++ b/glanceclient/client.py @@ -0,0 +1,175 @@ +# Copyright 2010 Jacob Kaplan-Moss +# Copyright 2011 OpenStack LLC. +# Copyright 2011 Piston Cloud Computing, Inc. +# Copyright 2011 Nebula, Inc. + +# All Rights Reserved. +""" +OpenStack Client interface. Handles the REST calls and responses. +""" + +import copy +import logging +import os +import time +import urllib +import urlparse + +import httplib2 + +try: + import json +except ImportError: + import simplejson as json + +# Python 2.5 compat fix +if not hasattr(urlparse, 'parse_qsl'): + import cgi + urlparse.parse_qsl = cgi.parse_qsl + + +from glanceclient import exceptions + + +_logger = logging.getLogger(__name__) + + +class HTTPClient(httplib2.Http): + + USER_AGENT = 'python-glanceclient' + + def __init__(self, username=None, tenant_id=None, tenant_name=None, + password=None, auth_url=None, region_name=None, timeout=None, + endpoint=None, token=None): + super(HTTPClient, self).__init__(timeout=timeout) + self.username = username + self.tenant_id = tenant_id + self.tenant_name = tenant_name + self.password = password + self.auth_url = auth_url.rstrip('/') if auth_url else None + self.version = 'v2.0' + self.region_name = region_name + self.auth_token = token + + self.management_url = endpoint + + # httplib2 overrides + self.force_exception_to_status_code = True + + def authenticate(self): + """ Authenticate against the keystone API. + + Not implemented here because auth protocols should be API + version-specific. + """ + raise NotImplementedError + + def _extract_service_catalog(self, url, body): + """ Set the client's service catalog from the response data. + + Not implemented here because data returned may be API + version-specific. + """ + raise NotImplementedError + + def http_log(self, args, kwargs, resp, body): + if os.environ.get('GLANCECLIENT_DEBUG', False): + ch = logging.StreamHandler() + _logger.setLevel(logging.DEBUG) + _logger.addHandler(ch) + elif not _logger.isEnabledFor(logging.DEBUG): + return + + string_parts = ['curl -i'] + for element in args: + if element in ('GET', 'POST'): + string_parts.append(' -X %s' % element) + else: + string_parts.append(' %s' % element) + + for element in kwargs['headers']: + header = ' -H "%s: %s"' % (element, kwargs['headers'][element]) + string_parts.append(header) + + _logger.debug("REQ: %s\n" % "".join(string_parts)) + if 'body' in kwargs: + _logger.debug("REQ BODY: %s\n" % (kwargs['body'])) + _logger.debug("RESP: %s\nRESP BODY: %s\n", resp, body) + + def request(self, url, method, **kwargs): + """ Send an http request with the specified characteristics. + + Wrapper around httplib2.Http.request to handle tasks such as + setting headers, JSON encoding/decoding, and error handling. + """ + # Copy the kwargs so we can reuse the original in case of redirects + request_kwargs = copy.copy(kwargs) + request_kwargs.setdefault('headers', kwargs.get('headers', {})) + request_kwargs['headers']['User-Agent'] = self.USER_AGENT + if 'body' in kwargs: + request_kwargs['headers']['Content-Type'] = 'application/json' + request_kwargs['body'] = json.dumps(kwargs['body']) + + resp, body = super(HTTPClient, self).request(url, + method, + **request_kwargs) + + self.http_log((url, method,), request_kwargs, resp, body) + + if body: + try: + body = json.loads(body) + except ValueError, e: + _logger.debug("Could not decode JSON from body: %s" % body) + else: + _logger.debug("No body was returned.") + body = None + + if resp.status in (400, 401, 403, 404, 408, 409, 413, 500, 501): + _logger.exception("Request returned failure status.") + raise exceptions.from_response(resp, body) + elif resp.status in (301, 302, 305): + # Redirected. Reissue the request to the new location. + return self.request(resp['location'], method, **kwargs) + + return resp, body + + def _cs_request(self, url, method, **kwargs): + if not self.management_url: + self.authenticate() + + kwargs.setdefault('headers', {}) + if self.auth_token: + kwargs['headers']['X-Auth-Token'] = self.auth_token + + # Perform the request once. If we get a 401 back then it + # might be because the auth token expired, so try to + # re-authenticate and try again. If it still fails, bail. + try: + resp, body = self.request(self.management_url + url, method, + **kwargs) + return resp, body + except exceptions.Unauthorized: + try: + if getattr(self, '_failures', 0) < 1: + self._failures = getattr(self, '_failures', 0) + 1 + self.authenticate() + resp, body = self.request(self.management_url + url, + method, **kwargs) + return resp, body + else: + raise + except exceptions.Unauthorized: + raise + + def get(self, url, **kwargs): + return self._cs_request(url, 'GET', **kwargs) + + def post(self, url, **kwargs): + return self._cs_request(url, 'POST', **kwargs) + + def put(self, url, **kwargs): + return self._cs_request(url, 'PUT', **kwargs) + + def delete(self, url, **kwargs): + return self._cs_request(url, 'DELETE', **kwargs) diff --git a/glanceclient/exceptions.py b/glanceclient/exceptions.py new file mode 100644 index 00000000..f52800d8 --- /dev/null +++ b/glanceclient/exceptions.py @@ -0,0 +1,132 @@ +# Copyright 2010 Jacob Kaplan-Moss +# Copyright 2011 Nebula, Inc. +""" +Exception definitions. +""" + + +class CommandError(Exception): + pass + + +class AuthorizationFailure(Exception): + pass + + +class NoTokenLookupException(Exception): + """This form of authentication does not support looking up + endpoints from an existing token.""" + pass + + +class EndpointNotFound(Exception): + """Could not find Service or Region in Service Catalog.""" + pass + + +class ClientException(Exception): + """ + The base exception class for all exceptions this library raises. + """ + def __init__(self, code, message=None, details=None): + self.code = code + self.message = message or self.__class__.message + self.details = details + + def __str__(self): + return "%s (HTTP %s)" % (self.message, self.code) + + +class BadRequest(ClientException): + """ + HTTP 400 - Bad request: you sent some malformed data. + """ + http_status = 400 + message = "Bad request" + + +class Unauthorized(ClientException): + """ + HTTP 401 - Unauthorized: bad credentials. + """ + http_status = 401 + message = "Unauthorized" + + +class Forbidden(ClientException): + """ + HTTP 403 - Forbidden: your credentials don't give you access to this + resource. + """ + http_status = 403 + message = "Forbidden" + + +class NotFound(ClientException): + """ + HTTP 404 - Not found + """ + http_status = 404 + message = "Not found" + + +class Conflict(ClientException): + """ + HTTP 409 - Conflict + """ + http_status = 409 + message = "Conflict" + + +class OverLimit(ClientException): + """ + HTTP 413 - Over limit: you're over the API limits for this time period. + """ + http_status = 413 + message = "Over limit" + + +# NotImplemented is a python keyword. +class HTTPNotImplemented(ClientException): + """ + HTTP 501 - Not Implemented: the server does not support this operation. + """ + http_status = 501 + message = "Not Implemented" + + +# In Python 2.4 Exception is old-style and thus doesn't have a __subclasses__() +# so we can do this: +# _code_map = dict((c.http_status, c) +# for c in ClientException.__subclasses__()) +# +# Instead, we have to hardcode it: +_code_map = dict((c.http_status, c) for c in [BadRequest, Unauthorized, + Forbidden, NotFound, OverLimit, HTTPNotImplemented]) + + +def from_response(response, body): + """ + Return an instance of an ClientException or subclass + based on an httplib2 response. + + Usage:: + + resp, body = http.request(...) + if resp.status != 200: + raise exception_from_response(resp, body) + """ + cls = _code_map.get(response.status, ClientException) + if body: + if hasattr(body, 'keys'): + error = body[body.keys()[0]] + message = error.get('message', None) + details = error.get('details', None) + else: + # If we didn't get back a properly formed error message we + # probably couldn't communicate with Keystone at all. + message = "Unable to communicate with identity service: %s." % body + details = None + return cls(code=response.status, message=message, details=details) + else: + return cls(code=response.status) diff --git a/glanceclient/generic/__init__.py b/glanceclient/generic/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/glanceclient/generic/client.py b/glanceclient/generic/client.py new file mode 100644 index 00000000..65335728 --- /dev/null +++ b/glanceclient/generic/client.py @@ -0,0 +1,205 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2010 OpenStack LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import logging +import urlparse + +from glanceclient import client +from glanceclient import exceptions + +_logger = logging.getLogger(__name__) + + +class Client(client.HTTPClient): + """Client for the OpenStack Images pre-version calls API. + + :param string endpoint: A user-supplied endpoint URL for the glance + service. + :param integer timeout: Allows customization of the timeout for client + http requests. (optional) + + Example:: + + >>> from glanceclient.generic import client + >>> root = client.Client(auth_url=KEYSTONE_URL) + >>> versions = root.discover() + ... + >>> from glanceclient.v1_1 import client as v11client + >>> glance = v11client.Client(auth_url=versions['v1.1']['url']) + ... + >>> image = glance.images.get(IMAGE_ID) + >>> image.delete() + + """ + + def __init__(self, endpoint=None, **kwargs): + """ Initialize a new client for the Glance v2.0 API. """ + super(Client, self).__init__(endpoint=endpoint, **kwargs) + self.endpoint = endpoint + + def discover(self, url=None): + """ Discover Glance servers and return API versions supported. + + :param url: optional url to test (without version) + + Returns:: + + { + 'message': 'Glance found at http://127.0.0.1:5000/', + 'v2.0': { + 'status': 'beta', + 'url': 'http://127.0.0.1:5000/v2.0/', + 'id': 'v2.0' + }, + } + + """ + if url: + return self._check_glance_versions(url) + else: + return self._local_glance_exists() + + def _local_glance_exists(self): + """ Checks if Glance is available on default local port 9292 """ + return self._check_glance_versions("http://localhost:9292") + + def _check_glance_versions(self, url): + """ Calls Glance URL and detects the available API versions """ + try: + httpclient = client.HTTPClient() + resp, body = httpclient.request(url, "GET", + headers={'Accept': 'application/json'}) + if resp.status in (300): # Glance returns a 300 Multiple Choices + try: + results = {} + if 'version' in body: + results['message'] = "Glance found at %s" % url + version = body['version'] + # Stable/diablo incorrect format + id, status, version_url = self._get_version_info( + version, url) + results[str(id)] = {"id": id, + "status": status, + "url": version_url} + return results + elif 'versions' in body: + # Correct format + results['message'] = "Glance found at %s" % url + for version in body['versions']['values']: + id, status, version_url = self._get_version_info( + version, url) + results[str(id)] = {"id": id, + "status": status, + "url": version_url} + return results + else: + results['message'] = "Unrecognized response from %s" \ + % url + return results + except KeyError: + raise exceptions.AuthorizationFailure() + elif resp.status == 305: + return self._check_glance_versions(resp['location']) + else: + raise exceptions.from_response(resp, body) + except Exception as e: + _logger.exception(e) + + def discover_extensions(self, url=None): + """ Discover Glance extensions supported. + + :param url: optional url to test (should have a version in it) + + Returns:: + + { + 'message': 'Glance extensions at http://127.0.0.1:35357/v2', + 'OS-KSEC2': 'OpenStack EC2 Credentials Extension', + } + + """ + if url: + return self._check_glance_extensions(url) + + def _check_glance_extensions(self, url): + """ Calls Glance URL and detects the available extensions """ + try: + httpclient = client.HTTPClient() + if not url.endswith("/"): + url += '/' + resp, body = httpclient.request("%sextensions" % url, "GET", + headers={'Accept': 'application/json'}) + if resp.status in (200, 204): # in some cases we get No Content + try: + results = {} + if 'extensions' in body: + if 'values' in body['extensions']: + # Parse correct format (per contract) + for extension in body['extensions']['values']: + alias, name = self._get_extension_info( + extension['extension']) + results[alias] = name + return results + else: + # Support incorrect, but prevalent format + for extension in body['extensions']: + alias, name = self._get_extension_info( + extension) + results[alias] = name + return results + else: + results['message'] = "Unrecognized extensions" \ + " response from %s" % url + return results + except KeyError: + raise exceptions.AuthorizationFailure() + elif resp.status == 305: + return self._check_glance_extensions(resp['location']) + else: + raise exceptions.from_response(resp, body) + except Exception as e: + _logger.exception(e) + + @staticmethod + def _get_version_info(version, root_url): + """ Parses version information + + :param version: a dict of a Glance version response + :param root_url: string url used to construct + the version if no URL is provided. + :returns: tuple - (verionId, versionStatus, versionUrl) + """ + id = version['id'] + status = version['status'] + ref = urlparse.urljoin(root_url, id) + if 'links' in version: + for link in version['links']: + if link['rel'] == 'self': + ref = link['href'] + break + return (id, status, ref) + + @staticmethod + def _get_extension_info(extension): + """ Parses extension information + + :param extension: a dict of a Glance extension response + :returns: tuple - (alias, name) + """ + alias = extension['alias'] + name = extension['name'] + return (alias, name) diff --git a/glanceclient/generic/shell.py b/glanceclient/generic/shell.py new file mode 100644 index 00000000..caffbba7 --- /dev/null +++ b/glanceclient/generic/shell.py @@ -0,0 +1,57 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2010 OpenStack LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from glanceclient import utils +from glanceclient.generic import client + +CLIENT_CLASS = client.Client + + +@utils.unauthenticated +def do_discover(cs, args): + """ + Discover Keystone servers and show authentication protocols and + extensions supported. + + Usage:: + $ glance discover + Image Service found at http://localhost:9292 + - supports version v1.0 (DEPRECATED) here http://localhost:9292/v1.0 + - supports version v1.1 (CURRENT) here http://localhost:9292/v1.1 + - supports version v2.0 (BETA) here http://localhost:9292/v2.0 + - and RAX-KSKEY: Rackspace API Key Authentication Admin Extension + - and RAX-KSGRP: Rackspace Keystone Group Extensions + """ + if cs.auth_url: + versions = cs.discover(cs.auth_url) + else: + versions = cs.discover() + if versions: + if 'message' in versions: + print versions['message'] + for key, version in versions.iteritems(): + if key != 'message': + print " - supports version %s (%s) here %s" % \ + (version['id'], version['status'], version['url']) + extensions = cs.discover_extensions(version['url']) + if extensions: + for key, extension in extensions.iteritems(): + if key != 'message': + print " - and %s: %s" % \ + (key, extension) + else: + print "No Glance-compatible endpoint found" diff --git a/glanceclient/service_catalog.py b/glanceclient/service_catalog.py new file mode 100644 index 00000000..e302977a --- /dev/null +++ b/glanceclient/service_catalog.py @@ -0,0 +1,81 @@ +# Copyright 2011 OpenStack LLC. +# Copyright 2011, Piston Cloud Computing, Inc. +# Copyright 2011 Nebula, Inc. +# +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from glanceclient import exceptions + + +class ServiceCatalog(object): + """ + Helper methods for dealing with an OpenStack Identity + Service Catalog. + """ + + def __init__(self, resource_dict): + self.catalog = resource_dict + + def get_token(self): + """Fetch token details fron service catalog""" + token = {'id': self.catalog['token']['id'], + 'expires': self.catalog['token']['expires']} + try: + token['tenant'] = self.catalog['token']['tenant']['id'] + except: + # just leave the tenant out if it doesn't exist + pass + return token + + def url_for(self, attr=None, filter_value=None, + service_type='image', endpoint_type='publicURL'): + """Fetch an endpoint from the service catalog. + + Fetch the specified endpoint from the service catalog for + a particular endpoint attribute. If no attribute is given, return + the first endpoint of the specified type. + + See tests for a sample service catalog. + """ + catalog = self.catalog.get('serviceCatalog', []) + + for service in catalog: + if service['type'] != service_type: + continue + + endpoints = service['endpoints'] + for endpoint in endpoints: + if not filter_value or endpoint.get(attr) == filter_value: + return endpoint[endpoint_type] + + raise exceptions.EndpointNotFound('Endpoint not found.') + + def get_endpoints(self, service_type=None, endpoint_type=None): + """Fetch and filter endpoints for the specified service(s) + + Returns endpoints for the specified service (or all) and + that contain the specified type (or all). + """ + sc = {} + for service in self.catalog.get('serviceCatalog', []): + if service_type and service_type != service['type']: + continue + sc[service['type']] = [] + for endpoint in service['endpoints']: + if endpoint_type and endpoint_type not in endpoint.keys(): + continue + sc[service['type']].append(endpoint) + return sc diff --git a/glanceclient/shell.py b/glanceclient/shell.py new file mode 100644 index 00000000..2eb9ed16 --- /dev/null +++ b/glanceclient/shell.py @@ -0,0 +1,246 @@ +# Copyright 2010 Jacob Kaplan-Moss +# Copyright 2011 OpenStack LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Command-line interface to the OpenStack Images API. +""" + +import argparse +import httplib2 +import os +import sys + +from glanceclient import exceptions as exc +from glanceclient import utils +from glanceclient.v2_0 import shell as shell_v2_0 +from glanceclient.generic import shell as shell_generic + + +def env(*vars, **kwargs): + """Search for the first defined of possibly many env vars + + Returns the first environment variable defined in vars, or + returns the default defined in kwargs. + + """ + for v in vars: + value = os.environ.get(v, None) + if value: + return value + return kwargs.get('default', '') + + +class OpenStackImagesShell(object): + + def get_base_parser(self): + parser = argparse.ArgumentParser( + prog='glance', + description=__doc__.strip(), + epilog='See "glance help COMMAND" '\ + 'for help on a specific command.', + add_help=False, + formatter_class=OpenStackHelpFormatter, + ) + + # Global arguments + parser.add_argument('-h', '--help', + action='store_true', + help=argparse.SUPPRESS, + ) + + parser.add_argument('--debug', + default=False, + action='store_true', + help=argparse.SUPPRESS) + + parser.add_argument('--username', + default=env('OS_USERNAME'), + help='Defaults to env[OS_USERNAME]') + + parser.add_argument('--password', + default=env('OS_PASSWORD'), + help='Defaults to env[OS_PASSWORD]') + + parser.add_argument('--tenant_name', + default=env('OS_TENANT_NAME'), + help='Defaults to env[OS_TENANT_NAME]') + + parser.add_argument('--tenant_id', + default=env('OS_TENANT_ID'), dest='os_tenant_id', + help='Defaults to env[OS_TENANT_ID]') + + parser.add_argument('--auth_url', + default=env('OS_AUTH_URL'), + help='Defaults to env[OS_AUTH_URL]') + + parser.add_argument('--region_name', + default=env('OS_REGION_NAME'), + help='Defaults to env[OS_REGION_NAME]') + + parser.add_argument('--identity_api_version', + default=env('OS_IDENTITY_API_VERSION', 'KEYSTONE_VERSION'), + help='Defaults to env[OS_IDENTITY_API_VERSION] or 2.0') + + return parser + + def get_subcommand_parser(self, version): + parser = self.get_base_parser() + + self.subcommands = {} + subparsers = parser.add_subparsers(metavar='') + + try: + actions_module = { + '2.0': shell_v2_0, + }[version] + except KeyError: + actions_module = shell_v2_0 + + self._find_actions(subparsers, actions_module) + self._find_actions(subparsers, shell_generic) + self._find_actions(subparsers, self) + + return parser + + def _find_actions(self, subparsers, actions_module): + for attr in (a for a in dir(actions_module) if a.startswith('do_')): + # I prefer to be hypen-separated instead of underscores. + command = attr[3:].replace('_', '-') + callback = getattr(actions_module, attr) + desc = callback.__doc__ or '' + help = desc.strip().split('\n')[0] + arguments = getattr(callback, 'arguments', []) + + subparser = subparsers.add_parser(command, + help=help, + description=desc, + add_help=False, + formatter_class=OpenStackHelpFormatter + ) + subparser.add_argument('-h', '--help', + action='help', + help=argparse.SUPPRESS, + ) + self.subcommands[command] = subparser + for (args, kwargs) in arguments: + subparser.add_argument(*args, **kwargs) + subparser.set_defaults(func=callback) + + def main(self, argv): + # Parse args once to find version + parser = self.get_base_parser() + (options, args) = parser.parse_known_args(argv) + + # build available subcommands based on version + api_version = options.identity_api_version + subcommand_parser = self.get_subcommand_parser(api_version) + self.parser = subcommand_parser + + # Handle top-level --help/-h before attempting to parse + # a command off the command line + if options.help: + self.do_help(options) + return 0 + + # Parse args again and call whatever callback was selected + args = subcommand_parser.parse_args(argv) + + # Deal with global arguments + if args.debug: + httplib2.debuglevel = 1 + + # Short-circuit and deal with help command right away. + if args.func == self.do_help: + self.do_help(args) + return 0 + + #FIXME(usrleon): Here should be restrict for project id same as + # for username or apikey but for compatibility it is not. + + if not utils.isunauthenticated(args.func): + if not args.username: + raise exc.CommandError("You must provide a username " + "via either --username or env[OS_USERNAME]") + + if not args.password: + raise exc.CommandError("You must provide a password " + "via either --password or env[OS_PASSWORD]") + + if not args.auth_url: + raise exc.CommandError("You must provide an auth url " + "via either --auth_url or via env[OS_AUTH_URL]") + + if utils.isunauthenticated(args.func): + self.cs = shell_generic.CLIENT_CLASS(endpoint=args.auth_url) + else: + api_version = options.identity_api_version + self.cs = self.get_api_class(api_version)( + username=args.username, + tenant_name=args.tenant_name, + tenant_id=args.os_tenant_id, + password=args.password, + auth_url=args.auth_url, + region_name=args.region_name) + + try: + args.func(self.cs, args) + except exc.Unauthorized: + raise exc.CommandError("Invalid OpenStack Identity credentials.") + except exc.AuthorizationFailure: + raise exc.CommandError("Unable to authorize user") + + def get_api_class(self, version): + try: + return { + "2.0": shell_v2_0.CLIENT_CLASS, + }[version] + except KeyError: + return shell_v2_0.CLIENT_CLASS + + @utils.arg('command', metavar='', nargs='?', + help='Display help for ') + def do_help(self, args): + """ + Display help about this program or one of its subcommands. + """ + if getattr(args, 'command', None): + if args.command in self.subcommands: + self.subcommands[args.command].print_help() + else: + raise exc.CommandError("'%s' is not a valid subcommand" % + args.command) + else: + self.parser.print_help() + + +# I'm picky about my shell help. +class OpenStackHelpFormatter(argparse.HelpFormatter): + def start_section(self, heading): + # Title-case the headings + heading = '%s%s' % (heading[0].upper(), heading[1:]) + super(OpenStackHelpFormatter, self).start_section(heading) + + +def main(): + try: + OpenStackImagesShell().main(sys.argv[1:]) + + except Exception, e: + if httplib2.debuglevel == 1: + raise # dump stack. + else: + print >> sys.stderr, e + sys.exit(1) diff --git a/glanceclient/utils.py b/glanceclient/utils.py new file mode 100644 index 00000000..9880294e --- /dev/null +++ b/glanceclient/utils.py @@ -0,0 +1,94 @@ +import uuid + +import prettytable + +from glanceclient import exceptions + + +# Decorator for cli-args +def arg(*args, **kwargs): + def _decorator(func): + # Because of the sematics of decorator composition if we just append + # to the options list positional options will appear to be backwards. + func.__dict__.setdefault('arguments', []).insert(0, (args, kwargs)) + return func + return _decorator + + +def pretty_choice_list(l): + return ', '.join("'%s'" % i for i in l) + + +def print_list(objs, fields, formatters={}): + pt = prettytable.PrettyTable([f for f in fields], caching=False) + pt.aligns = ['l' for f in fields] + + for o in objs: + row = [] + for field in fields: + if field in formatters: + row.append(formatters[field](o)) + else: + field_name = field.lower().replace(' ', '_') + data = getattr(o, field_name, '') + row.append(data) + pt.add_row(row) + + pt.printt(sortby=fields[0]) + + +def print_dict(d): + pt = prettytable.PrettyTable(['Property', 'Value'], caching=False) + pt.aligns = ['l', 'l'] + [pt.add_row(list(r)) for r in d.iteritems()] + pt.printt(sortby='Property') + + +def find_resource(manager, name_or_id): + """Helper for the _find_* methods.""" + # first try to get entity as integer id + try: + if isinstance(name_or_id, int) or name_or_id.isdigit(): + return manager.get(int(name_or_id)) + except exceptions.NotFound: + pass + + # now try to get entity as uuid + try: + uuid.UUID(str(name_or_id)) + return manager.get(name_or_id) + except (ValueError, exceptions.NotFound): + pass + + # finally try to find entity by name + try: + return manager.find(name=name_or_id) + except exceptions.NotFound: + msg = "No %s with a name or ID of '%s' exists." % \ + (manager.resource_class.__name__.lower(), name_or_id) + raise exceptions.CommandError(msg) + + +def unauthenticated(f): + """ Adds 'unauthenticated' attribute to decorated function. + + Usage: + @unauthenticated + def mymethod(f): + ... + """ + f.unauthenticated = True + return f + + +def isunauthenticated(f): + """ + Checks to see if the function is marked as not requiring authentication + with the @unauthenticated decorator. Returns True if decorator is + set to True, False otherwise. + """ + return getattr(f, 'unauthenticated', False) + + +def string_to_bool(arg): + return arg.strip().lower() in ('t', 'true', 'yes', '1') diff --git a/glanceclient/v1_1/__init__.py b/glanceclient/v1_1/__init__.py new file mode 100644 index 00000000..44ad28e7 --- /dev/null +++ b/glanceclient/v1_1/__init__.py @@ -0,0 +1 @@ +from keystoneclient.v2_0.client import Client diff --git a/glanceclient/v1_1/client.py b/glanceclient/v1_1/client.py new file mode 100644 index 00000000..735cecb4 --- /dev/null +++ b/glanceclient/v1_1/client.py @@ -0,0 +1,113 @@ +# Copyright 2011 Nebula, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +import logging + +from glanceclient import client +from glanceclient import exceptions +from glanceclient import service_catalog +from glanceclient.v1_1 import images + + +_logger = logging.getLogger(__name__) + + +class Client(client.HTTPClient): + """Client for the OpenStack Images v1.1 API. + + :param string username: Username for authentication. (optional) + :param string password: Password for authentication. (optional) + :param string token: Token for authentication. (optional) + :param string tenant_name: Tenant id. (optional) + :param string tenant_id: Tenant name. (optional) + :param string auth_url: Keystone service endpoint for authorization. + :param string region_name: Name of a region to select when choosing an + endpoint from the service catalog. + :param string endpoint: A user-supplied endpoint URL for the glance + service. Lazy-authentication is possible for API + service calls if endpoint is set at + instantiation.(optional) + :param integer timeout: Allows customization of the timeout for client + http requests. (optional) + + Example:: + + >>> from glanceclient.v1_1 import client + >>> glance = client.Client(username=USER, + password=PASS, + tenant_name=TENANT_NAME, + auth_url=KEYSTONE_URL) + >>> glance.images.list() + ... + >>> image = glance.images.get(IMAGE_ID) + >>> image.delete() + + """ + + def __init__(self, endpoint=None, **kwargs): + """ Initialize a new client for the Images v1.1 API. """ + super(Client, self).__init__(endpoint=endpoint, **kwargs) + self.images = images.ImageManager(self) + # NOTE(gabriel): If we have a pre-defined endpoint then we can + # get away with lazy auth. Otherwise auth immediately. + if endpoint is None: + self.authenticate() + else: + self.management_url = endpoint + + def authenticate(self): + """ Authenticate against the Keystone API. + + Uses the data provided at instantiation to authenticate against + the Keystone server. This may use either a username and password + or token for authentication. If a tenant id was provided + then the resulting authenticated client will be scoped to that + tenant and contain a service catalog of available endpoints. + + Returns ``True`` if authentication was successful. + """ + self.management_url = self.auth_url + try: + raw_token = self.tokens.authenticate(username=self.username, + tenant_id=self.tenant_id, + tenant_name=self.tenant_name, + password=self.password, + token=self.auth_token, + return_raw=True) + self._extract_service_catalog(self.auth_url, raw_token) + return True + except (exceptions.AuthorizationFailure, exceptions.Unauthorized): + raise + except Exception, e: + _logger.exception("Authorization Failed.") + raise exceptions.AuthorizationFailure("Authorization Failed: " + "%s" % e) + + def _extract_service_catalog(self, url, body): + """ Set the client's service catalog from the response data. """ + self.service_catalog = service_catalog.ServiceCatalog(body) + try: + self.auth_token = self.service_catalog.get_token()['id'] + except KeyError: + raise exceptions.AuthorizationFailure() + + # FIXME(ja): we should be lazy about setting managment_url. + # in fact we should rewrite the client to support the service + # catalog (api calls should be directable to any endpoints) + try: + self.management_url = self.service_catalog.url_for(attr='region', + filter_value=self.region_name, endpoint_type='adminURL') + except: + # Unscoped tokens don't return a service catalog + _logger.exception("unable to retrieve service catalog with token") diff --git a/glanceclient/v1_1/images.py b/glanceclient/v1_1/images.py new file mode 100644 index 00000000..b76e560d --- /dev/null +++ b/glanceclient/v1_1/images.py @@ -0,0 +1,88 @@ +# Copyright 2011 OpenStack LLC. +# Copyright 2011 Nebula, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import urllib + +from glanceclient import base + + +class Image(base.Resource): + def __repr__(self): + return "" % self._info + + def delete(self): + return self.manager.delete(self) + + def list_roles(self, tenant=None): + return self.manager.list_roles(self.id, base.getid(tenant)) + + +class ImageManager(base.ManagerWithFind): + resource_class = Image + + def get(self, image): + return self._get("/images/%s" % base.getid(image), "image") + + def update(self, image, **kwargs): + """ + Update image data. + + Supported arguments include ``name`` and ``is_public``. + """ + params = {"image": kwargs} + params['image']['id'] = base.getid(image) + url = "/images/%s" % base.getid(image) + return self._update(url, params, "image") + + def create(self, name, is_public=True): + """ + Create an image. + """ + params = { + "image": { + "name": name, + "is_public": is_public + } + } + return self._create('/images', params, "image") + + def delete(self, image): + """ + Delete a image. + """ + return self._delete("/images/%s" % base.getid(image)) + + def list(self, limit=None, marker=None): + """ + Get a list of images (optionally limited to a tenant) + + :rtype: list of :class:`Image` + """ + + params = {} + if limit: + params['limit'] = int(limit) + if marker: + params['marker'] = int(marker) + + query = "" + if params: + query = "?" + urllib.urlencode(params) + + return self._list("/images%s" % query, "images") + + def list_members(self, image): + return self.api.members.members_for_image(base.getid(image)) diff --git a/glanceclient/v1_1/shell.py b/glanceclient/v1_1/shell.py new file mode 100755 index 00000000..7a34f786 --- /dev/null +++ b/glanceclient/v1_1/shell.py @@ -0,0 +1,77 @@ +# Copyright 2010 Jacob Kaplan-Moss +# Copyright 2011 OpenStack LLC. +# Copyright 2011 Nebula, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from glanceclient.v1_1 import client +from glanceclient import utils + +CLIENT_CLASS = client.Client + + +@utils.arg('tenant', metavar='', nargs='?', default=None, + help='Tenant ID (Optional); lists all images if not specified') +def do_image_list(gc, args): + """List images""" + images = gc.images.list(tenant_id=args.tenant) + utils.print_list(images, ['id', 'is_public', 'email', 'name']) + + +@utils.arg('--name', metavar='', required=True, + help='New image name (must be unique)') +@utils.arg('--is-public', metavar='', default=True, + help='Initial image is_public status (default true)') +def do_image_create(gc, args): + """Create new image""" + image = gc.images.create(args.name, args.passwd, args.email, + tenant_id=args.tenant_id, is_public=args.is_public) + utils.print_dict(image._info) + + +@utils.arg('--name', metavar='', + help='Desired new image name') +@utils.arg('--is-public', metavar='', + help='Enable or disable image') +@utils.arg('id', metavar='', help='Image ID to update') +def do_image_update(gc, args): + """Update image's name, email, and is_public status""" + kwargs = {} + if args.name: + kwargs['name'] = args.name + if args.email: + kwargs['email'] = args.email + if args.is_public: + kwargs['is_public'] = utils.string_to_bool(args.is_public) + + if not len(kwargs): + print "User not updated, no arguments present." + return + + try: + gc.images.update(args.id, **kwargs) + print 'User has been updated.' + except Exception, e: + print 'Unable to update image: %s' % e + + +@utils.arg('id', metavar='', help='User ID to delete') +def do_image_delete(gc, args): + """Delete image""" + gc.images.delete(args.id) + + +def do_token_get(gc, args): + """Display the current user's token""" + utils.print_dict(gc.service_catalog.get_token()) diff --git a/run_tests.sh b/run_tests.sh new file mode 100755 index 00000000..4cd3e3fc --- /dev/null +++ b/run_tests.sh @@ -0,0 +1,153 @@ +#!/bin/bash + +set -eu + +function usage { + echo "Usage: $0 [OPTION]..." + echo "Run python-keystoneclient test suite" + echo "" + echo " -V, --virtual-env Always use virtualenv. Install automatically if not present" + echo " -N, --no-virtual-env Don't use virtualenv. Run tests in local environment" + echo " -s, --no-site-packages Isolate the virtualenv from the global Python environment" + echo " -x, --stop Stop running tests after the first error or failure." + echo " -f, --force Force a clean re-build of the virtual environment. Useful when dependencies have been added." + echo " -p, --pep8 Just run pep8" + echo " -P, --no-pep8 Don't run pep8" + echo " -c, --coverage Generate coverage report" + echo " -h, --help Print this usage message" + echo " --hide-elapsed Don't print the elapsed time for each test along with slow test list" + echo "" + echo "Note: with no options specified, the script will try to run the tests in a virtual environment," + echo " If no virtualenv is found, the script will ask if you would like to create one. If you " + echo " prefer to run tests NOT in a virtual environment, simply pass the -N option." + exit +} + +function process_option { + case "$1" in + -h|--help) usage;; + -V|--virtual-env) always_venv=1; never_venv=0;; + -N|--no-virtual-env) always_venv=0; never_venv=1;; + -s|--no-site-packages) no_site_packages=1;; + -f|--force) force=1;; + -p|--pep8) just_pep8=1;; + -P|--no-pep8) no_pep8=1;; + -c|--coverage) coverage=1;; + -*) noseopts="$noseopts $1";; + *) noseargs="$noseargs $1" + esac +} + +venv=.venv +with_venv=tools/with_venv.sh +always_venv=0 +never_venv=0 +force=0 +no_site_packages=0 +installvenvopts= +noseargs= +noseopts= +wrapper="" +just_pep8=0 +no_pep8=0 +coverage=0 + +for arg in "$@"; do + process_option $arg +done + +# If enabled, tell nose to collect coverage data +if [ $coverage -eq 1 ]; then + noseopts="$noseopts --with-coverage --cover-package=keystoneclient" +fi + +if [ $no_site_packages -eq 1 ]; then + installvenvopts="--no-site-packages" +fi + +function run_tests { + # Just run the test suites in current environment + ${wrapper} $NOSETESTS + # If we get some short import error right away, print the error log directly + RESULT=$? + return $RESULT +} + +function run_pep8 { + echo "Running pep8 ..." + srcfiles="keystoneclient tests" + # Just run PEP8 in current environment + # + # NOTE(sirp): W602 (deprecated 3-arg raise) is being ignored for the + # following reasons: + # + # 1. It's needed to preserve traceback information when re-raising + # exceptions; this is needed b/c Eventlet will clear exceptions when + # switching contexts. + # + # 2. There doesn't appear to be an alternative, "pep8-tool" compatible way of doing this + # in Python 2 (in Python 3 `with_traceback` could be used). + # + # 3. Can find no corroborating evidence that this is deprecated in Python 2 + # other than what the PEP8 tool claims. It is deprecated in Python 3, so, + # perhaps the mistake was thinking that the deprecation applied to Python 2 + # as well. + ${wrapper} pep8 --repeat --show-pep8 --show-source \ + --ignore=E202,W602 \ + ${srcfiles} +} + +NOSETESTS="nosetests $noseopts $noseargs" + +if [ $never_venv -eq 0 ] +then + # Remove the virtual environment if --force used + if [ $force -eq 1 ]; then + echo "Cleaning virtualenv..." + rm -rf ${venv} + fi + if [ -e ${venv} ]; then + wrapper="${with_venv}" + else + if [ $always_venv -eq 1 ]; then + # Automatically install the virtualenv + python tools/install_venv.py $installvenvopts + wrapper="${with_venv}" + else + echo -e "No virtual environment found...create one? (Y/n) \c" + read use_ve + if [ "x$use_ve" = "xY" -o "x$use_ve" = "x" -o "x$use_ve" = "xy" ]; then + # Install the virtualenv and run the test suite in it + python tools/install_venv.py $installvenvopts + wrapper=${with_venv} + fi + fi + fi +fi + +# Delete old coverage data from previous runs +if [ $coverage -eq 1 ]; then + ${wrapper} coverage erase +fi + +if [ $just_pep8 -eq 1 ]; then + run_pep8 + exit +fi + +run_tests + +# NOTE(sirp): we only want to run pep8 when we're running the full-test suite, +# not when we're running tests individually. To handle this, we need to +# distinguish between options (noseopts), which begin with a '-', and +# arguments (noseargs). +if [ -z "$noseargs" ]; then + if [ $no_pep8 -eq 0 ]; then + run_pep8 + fi +fi + +if [ $coverage -eq 1 ]; then + echo "Generating coverage report in covhtml/" + ${wrapper} coverage html -d covhtml -i +fi diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..ab2a9497 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,13 @@ +[nosetests] +cover-package = glanceclient +cover-html = true +cover-erase = true +cover-inclusive = true + +[build_sphinx] +source-dir = docs/ +build-dir = docs/_build +all_files = 1 + +[upload_sphinx] +upload-dir = docs/_build/html diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..098d2d91 --- /dev/null +++ b/setup.py @@ -0,0 +1,42 @@ +import os +import sys +from setuptools import setup, find_packages + + +def read(fname): + return open(os.path.join(os.path.dirname(__file__), fname)).read() + +requirements = ['httplib2', 'prettytable'] +if sys.version_info < (2, 6): + requirements.append('simplejson') +if sys.version_info < (2, 7): + requirements.append('argparse') + +setup( + name = "python-glanceclient", + version = "2012.1", + description = "Client library for OpenStack Glance API", + long_description = read('README.rst'), + url = 'https://github.com/openstack/python-glanceclient', + license = 'Apache', + author = 'Jay Pipes, based on work by Rackspace and Jacob Kaplan-Moss', + author_email = 'jay.pipes@gmail.com', + packages = find_packages(exclude=['tests', 'tests.*']), + classifiers = [ + 'Development Status :: 4 - Beta', + 'Environment :: Console', + 'Intended Audience :: Developers', + 'Intended Audience :: Information Technology', + 'License :: OSI Approved :: Apache Software License', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + ], + install_requires = requirements, + + tests_require = ["nose", "mock", "mox"], + test_suite = "nose.collector", + + entry_points = { + 'console_scripts': ['glance = glanceclient.shell:main'] + } +) diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000..c81dd64d --- /dev/null +++ b/tox.ini @@ -0,0 +1,14 @@ +[tox] +envlist = py26,py27 + +[testenv] +deps = -r{toxinidir}/tools/pip-requires +commands = /bin/bash run_tests.sh -N + +[testenv:pep8] +deps = pep8 +commands = /bin/bash run_tests.sh -N --pep8 + +[testenv:coverage] +deps = pep8 +commands = /bin/bash run_tests.sh -N --with-coverage