├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.rst ├── dev-requirements.txt ├── docs ├── .gitignore ├── Makefile ├── api.rst ├── conf.py ├── index.rst ├── ref │ ├── backup_schedules.rst │ ├── exceptions.rst │ ├── flavors.rst │ ├── images.rst │ ├── index.rst │ ├── ipgroups.rst │ └── servers.rst ├── releases.rst └── shell.rst ├── openstack ├── __init__.py └── compute │ ├── __init__.py │ ├── api.py │ ├── backup_schedules.py │ ├── base.py │ ├── client.py │ ├── exceptions.py │ ├── flavors.py │ ├── images.py │ ├── ipgroups.py │ ├── servers.py │ └── shell.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── fakeserver.py ├── test_auth.py ├── test_backup_schedules.py ├── test_base.py ├── test_client.py ├── test_flavors.py ├── test_images.py ├── test_ipgroups.py ├── test_servers.py ├── test_shell.py ├── testfile.txt └── utils.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | distribute-* 4 | cover/ 5 | pip-log.txt 6 | /.coverage 7 | /dist 8 | /build 9 | /.tox -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009 Jacob Kaplan-Moss 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of this project nor the names of its contributors may 15 | be used to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | recursive-include docs * 3 | recursive-include tests * -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Python bindings to the Rackspace Cloud Servers API 2 | ================================================== 3 | 4 | This is a client for the OpenStack Compute API used by Rackspace Cloud and 5 | others. There's a Python API (the ```openstack.compute`` module), and a 6 | command-line program (installed as ``openstack-compute``). Each implements the 7 | entire OpenStack Compute API (as well as a few Rackspace-only addons). 8 | 9 | `Full documentation is available`__. 10 | 11 | __ http://openstackcompute.rtfd.org/ 12 | 13 | You'll also probably want to read `Rackspace's API guide`__ (PDF) -- the first 14 | bit, at least -- to get an idea of the concepts. Rackspace is doing the cloud 15 | hosting thing a bit differently from Amazon, and if you get the concepts this 16 | library should make more sense. 17 | 18 | __ http://docs.rackspacecloud.com/servers/api/cs-devguide-latest.pdf 19 | 20 | Development takes place on GitHub__. Bug reports and patches may be filed there. 21 | 22 | __ http://github.com/jacobian/openstack.compute 23 | 24 | .. contents:: Contents: 25 | :local: 26 | 27 | Command-line API 28 | ---------------- 29 | 30 | Installing this package gets you a shell command, ``openstack-compute``, that 31 | you can use to interact with Rackspace. 32 | 33 | You'll need to provide your Rackspace username and API key. You can do this 34 | with the ``--username`` and ``--apikey`` params, but it's easier to just set 35 | them as environment variables:: 36 | 37 | export OPENSTACK_COMPUTE_USERNAME=jacobian 38 | export OPENSTACK_COMPUTE_API_KEY=yadayada 39 | 40 | You'll find complete documentation on the shell by running 41 | ``cloudservers help``:: 42 | 43 | usage: openstack-compute [--username USERNAME] [--apikey APIKEY] ... 44 | 45 | Command-line interface to the OpenStack Compute API. 46 | 47 | Positional arguments: 48 | 49 | backup-schedule Show or edit the backup schedule for a server. 50 | backup-schedule-delete 51 | Delete the backup schedule for a server. 52 | boot Boot a new server. 53 | delete Immediately shut down and delete a server. 54 | flavor-list Print a list of available 'flavors' (sizes of 55 | servers). 56 | help Display help about this program or one of its 57 | subcommands. 58 | image-create Create a new image by taking a snapshot of a running 59 | server. 60 | image-delete Delete an image. 61 | image-list Print a list of available images to boot from. 62 | ip-share Share an IP address from the given IP group onto a 63 | server. 64 | ip-unshare Stop sharing an given address with a server. 65 | ipgroup-create Create a new IP group. 66 | ipgroup-delete Delete an IP group. 67 | ipgroup-list Show IP groups. 68 | ipgroup-show Show details about a particular IP group. 69 | list List active servers. 70 | reboot Reboot a server. 71 | rebuild Shutdown, re-image, and re-boot a server. 72 | rename Rename a server. 73 | resize Resize a server. 74 | resize-confirm Confirm a previous resize. 75 | resize-revert Revert a previous resize (and return to the previous 76 | VM). 77 | root-password Change the root password for a server. 78 | show Show details about the given server. 79 | 80 | Optional arguments: 81 | --username USERNAME Defaults to env[OPENSTACK_COMPUTE_USERNAME]. 82 | --apikey APIKEY Defaults to env[OPENSTACK_COMPUTE_API_KEY]. 83 | 84 | See "openstack-compute help COMMAND" for help on a specific command. 85 | 86 | Python API 87 | ---------- 88 | 89 | There's also a `complete Python API`__. 90 | 91 | __ http://openstackcompute.rtfd.org/ 92 | 93 | By way of a quick-start:: 94 | 95 | >>> import openstack.compute 96 | >>> compute = openstack.compute.Compute(USERNAME, API_KEY) 97 | >>> compute.flavors.list() 98 | [...] 99 | >>> compute.servers.list() 100 | [...] 101 | >>> s = compute.servers.create(image=2, flavor=1, name='myserver') 102 | 103 | ... time passes ... 104 | 105 | >>> s.reboot() 106 | 107 | ... time passes ... 108 | 109 | >>> s.delete() 110 | 111 | FAQ 112 | --- 113 | 114 | What's wrong with libcloud? 115 | 116 | Nothing! However, as a cross-service binding it's by definition lowest 117 | common denominator; I needed access to the OpenStack-specific APIs (shared 118 | IP groups, image snapshots, resizing, etc.). I also wanted a command-line 119 | utility. 120 | 121 | What's new? 122 | ----------- 123 | 124 | See `the release notes `_. -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # pip requires for hacking on the code. 3 | # 4 | 5 | mock 6 | nose 7 | coverage 8 | Sphinx 9 | tox -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | _build/ -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | 15 | .PHONY: help clean html dirhtml pickle json htmlhelp qthelp latex changes linkcheck doctest 16 | 17 | help: 18 | @echo "Please use \`make ' where is one of" 19 | @echo " html to make standalone HTML files" 20 | @echo " dirhtml to make HTML files named index.html in directories" 21 | @echo " pickle to make pickle files" 22 | @echo " json to make JSON files" 23 | @echo " htmlhelp to make HTML files and a HTML help project" 24 | @echo " qthelp to make HTML files and a qthelp project" 25 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 26 | @echo " changes to make an overview of all changed/added/deprecated items" 27 | @echo " linkcheck to check all external links for integrity" 28 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 29 | 30 | clean: 31 | -rm -rf $(BUILDDIR)/* 32 | 33 | html: 34 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 35 | @echo 36 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 37 | 38 | dirhtml: 39 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 40 | @echo 41 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 42 | 43 | pickle: 44 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 45 | @echo 46 | @echo "Build finished; now you can process the pickle files." 47 | 48 | json: 49 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 50 | @echo 51 | @echo "Build finished; now you can process the JSON files." 52 | 53 | htmlhelp: 54 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 55 | @echo 56 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 57 | ".hhp project file in $(BUILDDIR)/htmlhelp." 58 | 59 | qthelp: 60 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 61 | @echo 62 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 63 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 64 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/python-cloudservers.qhcp" 65 | @echo "To view the help file:" 66 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/python-cloudservers.qhc" 67 | 68 | latex: 69 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 70 | @echo 71 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 72 | @echo "Run \`make all-pdf' or \`make all-ps' in that directory to" \ 73 | "run these through (pdf)latex." 74 | 75 | changes: 76 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 77 | @echo 78 | @echo "The overview file is in $(BUILDDIR)/changes." 79 | 80 | linkcheck: 81 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 82 | @echo 83 | @echo "Link check complete; look for any errors in the above output " \ 84 | "or in $(BUILDDIR)/linkcheck/output.txt." 85 | 86 | doctest: 87 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 88 | @echo "Testing of doctests in the sources finished, look at the " \ 89 | "results in $(BUILDDIR)/doctest/output.txt." 90 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | The :mod:`openstack.compute` Python API 2 | ======================================= 3 | 4 | .. module:: openstack.compute 5 | :synopsis: A client for the OpenStack Compute API. 6 | 7 | .. currentmodule:: openstack.compute 8 | 9 | Usage 10 | ----- 11 | 12 | First create an instance of :class:`Compute` with your credentials:: 13 | 14 | >>> from openstack.compute import Compute 15 | >>> compute = Compute(username=USERNAME, apikey=API_KEY) 16 | 17 | Then call methods on the :class:`Compute` object: 18 | 19 | .. class:: Compute 20 | 21 | .. attribute:: backup_schedules 22 | 23 | A :class:`BackupScheduleManager` -- manage automatic backup images. 24 | 25 | .. attribute:: flavors 26 | 27 | A :class:`FlavorManager` -- query available "flavors" (hardware 28 | configurations). 29 | 30 | .. attribute:: images 31 | 32 | An :class:`ImageManager` -- query and create server disk images. 33 | 34 | .. attribute:: ipgroups 35 | 36 | A :class:`IPGroupManager` -- manage shared public IP addresses. 37 | 38 | .. attribute:: servers 39 | 40 | A :class:`ServerManager` -- start, stop, and manage virtual machines. 41 | 42 | .. automethod:: authenticate 43 | 44 | For example:: 45 | 46 | >>> compute.servers.list() 47 | [] 48 | 49 | >>> compute.flavors.list() 50 | [, 51 | , 52 | , 53 | , 54 | , 55 | , 56 | ] 57 | 58 | >>> compute.images.list() 59 | [,...] 60 | 61 | >>> fl = compute.flavors.find(ram=512) 62 | >>> im = compute.images.find(name='Ubuntu 10.10 (maverick)') 63 | >>> compute.servers.create("my-server", image=im, flavor=fl) 64 | 65 | 66 | For more information, see the reference: 67 | 68 | .. toctree:: 69 | :maxdepth: 2 70 | 71 | ref/index 72 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # openstack.computedoc documentation build configuration file, created by 4 | # sphinx-quickstart on Sun Dec 6 14:19:25 2009. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys, os 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | sys.path.append(os.path.abspath('..')) 20 | 21 | # -- General configuration ----------------------------------------------------- 22 | 23 | # Add any Sphinx extension module names here, as strings. They can be extensions 24 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 25 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx'] 26 | 27 | # Add any paths that contain templates here, relative to this directory. 28 | templates_path = ['_templates'] 29 | 30 | # The suffix of source filenames. 31 | source_suffix = '.rst' 32 | 33 | # The encoding of source files. 34 | #source_encoding = 'utf-8' 35 | 36 | # The master toctree document. 37 | master_doc = 'index' 38 | 39 | # General information about the project. 40 | project = u'openstack.compute' 41 | copyright = u'Jacob Kaplan-Moss' 42 | 43 | # The version info for the project you're documenting, acts as replacement for 44 | # |version| and |release|, also used in various other places throughout the 45 | # built documents. 46 | # 47 | # The short X.Y version. 48 | version = '2.0' 49 | # The full version, including alpha/beta/rc tags. 50 | release = '2.0a1' 51 | 52 | # The language for content autogenerated by Sphinx. Refer to documentation 53 | # for a list of supported languages. 54 | #language = None 55 | 56 | # There are two options for replacing |today|: either, you set today to some 57 | # non-false value, then it is used: 58 | #today = '' 59 | # Else, today_fmt is used as the format for a strftime call. 60 | #today_fmt = '%B %d, %Y' 61 | 62 | # List of documents that shouldn't be included in the build. 63 | #unused_docs = [] 64 | 65 | # List of directories, relative to source directory, that shouldn't be searched 66 | # for source files. 67 | exclude_trees = ['_build'] 68 | 69 | # The reST default role (used for this markup: `text`) to use for all documents. 70 | #default_role = None 71 | 72 | # If true, '()' will be appended to :func: etc. cross-reference text. 73 | add_function_parentheses = True 74 | 75 | # If true, the current module name will be prepended to all description 76 | # unit titles (such as .. function::). 77 | add_module_names = True 78 | 79 | # If true, sectionauthor and moduleauthor directives will be shown in the 80 | # output. They are ignored by default. 81 | #show_authors = False 82 | 83 | # The name of the Pygments (syntax highlighting) style to use. 84 | pygments_style = 'sphinx' 85 | 86 | # A list of ignored prefixes for module index sorting. 87 | #modindex_common_prefix = [] 88 | 89 | 90 | # -- Options for HTML output --------------------------------------------------- 91 | 92 | # The theme to use for HTML and HTML Help pages. Major themes that come with 93 | # Sphinx are currently 'default' and 'sphinxdoc'. 94 | # html_theme = 'default' 95 | 96 | # Theme options are theme-specific and customize the look and feel of a theme 97 | # further. For a list of options available for each theme, see the 98 | # documentation. 99 | #html_theme_options = {} 100 | 101 | # Add any paths that contain custom themes here, relative to this directory. 102 | #html_theme_path = [] 103 | 104 | # The name for this set of Sphinx documents. If None, it defaults to 105 | # " v documentation". 106 | #html_title = None 107 | 108 | # A shorter title for the navigation bar. Default is the same as html_title. 109 | #html_short_title = None 110 | 111 | # The name of an image file (relative to this directory) to place at the top 112 | # of the sidebar. 113 | #html_logo = None 114 | 115 | # The name of an image file (within the static path) to use as favicon of the 116 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 117 | # pixels large. 118 | #html_favicon = None 119 | 120 | # Add any paths that contain custom static files (such as style sheets) here, 121 | # relative to this directory. They are copied after the builtin static files, 122 | # so a file named "default.css" will overwrite the builtin "default.css". 123 | html_static_path = ['_static'] 124 | 125 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 126 | # using the given strftime format. 127 | #html_last_updated_fmt = '%b %d, %Y' 128 | 129 | # If true, SmartyPants will be used to convert quotes and dashes to 130 | # typographically correct entities. 131 | #html_use_smartypants = True 132 | 133 | # Custom sidebar templates, maps document names to template names. 134 | #html_sidebars = {} 135 | 136 | # Additional templates that should be rendered to pages, maps page names to 137 | # template names. 138 | #html_additional_pages = {} 139 | 140 | # If false, no module index is generated. 141 | #html_use_modindex = True 142 | 143 | # If false, no index is generated. 144 | #html_use_index = True 145 | 146 | # If true, the index is split into individual pages for each letter. 147 | #html_split_index = False 148 | 149 | # If true, links to the reST sources are added to the pages. 150 | #html_show_sourcelink = True 151 | 152 | # If true, an OpenSearch description file will be output, and all pages will 153 | # contain a tag referring to it. The value of this option must be the 154 | # base URL from which the finished HTML is served. 155 | #html_use_opensearch = '' 156 | 157 | # If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml"). 158 | #html_file_suffix = '' 159 | 160 | # Output file base name for HTML help builder. 161 | htmlhelp_basename = 'openstack.computedoc' 162 | 163 | 164 | # -- Options for LaTeX output -------------------------------------------------- 165 | 166 | # The paper size ('letter' or 'a4'). 167 | #latex_paper_size = 'letter' 168 | 169 | # The font size ('10pt', '11pt' or '12pt'). 170 | #latex_font_size = '10pt' 171 | 172 | # Grouping the document tree into LaTeX files. List of tuples 173 | # (source start file, target name, title, author, documentclass [howto/manual]). 174 | latex_documents = [ 175 | ('index', 'openstack.compute.tex', u'openstack.compute Documentation', 176 | u'Jacob Kaplan-Moss', 'manual'), 177 | ] 178 | 179 | # The name of an image file (relative to this directory) to place at the top of 180 | # the title page. 181 | #latex_logo = None 182 | 183 | # For "manual" documents, if this is true, then toplevel headings are parts, 184 | # not chapters. 185 | #latex_use_parts = False 186 | 187 | # Additional stuff for the LaTeX preamble. 188 | #latex_preamble = '' 189 | 190 | # Documents to append as an appendix to all manuals. 191 | #latex_appendices = [] 192 | 193 | # If false, no module index is generated. 194 | #latex_use_modindex = True 195 | 196 | 197 | # Example configuration for intersphinx: refer to the Python standard library. 198 | intersphinx_mapping = {'http://docs.python.org/': None} 199 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Python bindings to the OpenStack Compute API 2 | ============================================ 3 | 4 | This is a client for the OpenStack Compute API used by Rackspace Cloud and 5 | others. There's :doc:`a Python API ` (the :mod:`openstack.compute` 6 | module), and a :doc:`command-line script ` (installed as 7 | :program:`openstack-compute`). Each implements the entire OpenStack Compute 8 | API (as well as a few Rackspace-only addons). 9 | 10 | To try this out, you'll need a `Rackspace Cloud`__ account — or your own 11 | install of OpenStack Compute (also known as Nova). If you're using Rackspace 12 | you'll need to make sure to sign up for both Cloud Servers *and* Cloud Files 13 | -- Rackspace won't let you get an API key unless you've got a Cloud Files 14 | account, too. Once you've got an account, you'll find your API key in the 15 | management console under "Your Account". 16 | 17 | __ http://rackspacecloud.com/ 18 | 19 | .. seealso:: 20 | 21 | You may want to read `Rackspace's API guide`__ (PDF) -- the first bit, at 22 | least -- to get an idea of the concepts. Rackspace/OpenStack is doing the 23 | cloud hosting thing a bit differently from Amazon, and if you get the 24 | concepts this library should make more sense. 25 | 26 | __ http://docs.rackspacecloud.com/servers/api/cs-devguide-latest.pdf 27 | 28 | Contents: 29 | 30 | .. toctree:: 31 | :maxdepth: 2 32 | 33 | shell 34 | api 35 | ref/index 36 | releases 37 | 38 | Contributing 39 | ============ 40 | 41 | Development takes place `on GitHub`__; please file bugs/pull requests there. 42 | 43 | __ http://github.com/jacobian/openstack.compute 44 | 45 | Run tests with ``python setup.py test``. 46 | 47 | Indices and tables 48 | ================== 49 | 50 | * :ref:`genindex` 51 | * :ref:`modindex` 52 | * :ref:`search` 53 | 54 | -------------------------------------------------------------------------------- /docs/ref/backup_schedules.rst: -------------------------------------------------------------------------------- 1 | Backup schedules 2 | ================ 3 | 4 | .. currentmodule:: openstack.compute 5 | 6 | Rackspace allows scheduling of weekly and/or daily backups for virtual 7 | servers. You can access these backup schedules either off the API object as 8 | :attr:`CloudServers.backup_schedules`, or directly off a particular 9 | :class:`Server` instance as :attr:`Server.backup_schedule`. 10 | 11 | Classes 12 | ------- 13 | 14 | .. autoclass:: BackupScheduleManager 15 | :members: create, delete, update, get 16 | 17 | .. autoclass:: BackupSchedule 18 | :members: update, delete 19 | 20 | .. attribute:: enabled 21 | 22 | Is this backup enabled? (boolean) 23 | 24 | .. attribute:: weekly 25 | 26 | The day of week upon which to perform a weekly backup. 27 | 28 | .. attribute:: daily 29 | 30 | The daily time period during which to perform a daily backup. 31 | 32 | Constants 33 | --------- 34 | 35 | Constants for selecting weekly backup days: 36 | 37 | .. data:: BACKUP_WEEKLY_DISABLED 38 | .. data:: BACKUP_WEEKLY_SUNDAY 39 | .. data:: BACKUP_WEEKLY_MONDAY 40 | .. data:: BACKUP_WEEKLY_TUESDAY 41 | .. data:: BACKUP_WEEKLY_WEDNESDA 42 | .. data:: BACKUP_WEEKLY_THURSDAY 43 | .. data:: BACKUP_WEEKLY_FRIDAY 44 | .. data:: BACKUP_WEEKLY_SATURDAY 45 | 46 | Constants for selecting hourly backup windows: 47 | 48 | .. data:: BACKUP_DAILY_DISABLED 49 | .. data:: BACKUP_DAILY_H_0000_0200 50 | .. data:: BACKUP_DAILY_H_0200_0400 51 | .. data:: BACKUP_DAILY_H_0400_0600 52 | .. data:: BACKUP_DAILY_H_0600_0800 53 | .. data:: BACKUP_DAILY_H_0800_1000 54 | .. data:: BACKUP_DAILY_H_1000_1200 55 | .. data:: BACKUP_DAILY_H_1200_1400 56 | .. data:: BACKUP_DAILY_H_1400_1600 57 | .. data:: BACKUP_DAILY_H_1600_1800 58 | .. data:: BACKUP_DAILY_H_1800_2000 59 | .. data:: BACKUP_DAILY_H_2000_2200 60 | .. data:: BACKUP_DAILY_H_2200_0000 61 | -------------------------------------------------------------------------------- /docs/ref/exceptions.rst: -------------------------------------------------------------------------------- 1 | Exceptions 2 | ========== 3 | 4 | .. currentmodule:: openstack.compute 5 | 6 | Exceptions 7 | ---------- 8 | 9 | Exceptions that the API might throw: 10 | 11 | .. automodule:: openstack.compute 12 | :members: ComputeException, BadRequest, Unauthorized, Forbidden, 13 | NotFound, OverLimit 14 | 15 | -------------------------------------------------------------------------------- /docs/ref/flavors.rst: -------------------------------------------------------------------------------- 1 | Flavors 2 | ======= 3 | 4 | From Rackspace's API documentation: 5 | 6 | A flavor is an available hardware configuration for a server. Each flavor 7 | has a unique combination of disk space, memory capacity and priority for 8 | CPU time. 9 | 10 | Classes 11 | ------- 12 | 13 | .. currentmodule:: openstack.compute 14 | 15 | .. autoclass:: FlavorManager 16 | :members: get, list, find, findall 17 | 18 | .. autoclass:: Flavor 19 | :members: 20 | 21 | .. attribute:: id 22 | 23 | This flavor's ID. 24 | 25 | .. attribute:: name 26 | 27 | A human-readable name for this flavor. 28 | 29 | .. attribute:: ram 30 | 31 | The amount of RAM this flavor has, in MB. 32 | 33 | .. attribute:: disk 34 | 35 | The amount of disk space this flavor has, in MB -------------------------------------------------------------------------------- /docs/ref/images.rst: -------------------------------------------------------------------------------- 1 | Images 2 | ====== 3 | 4 | .. currentmodule:: openstack.compute 5 | 6 | An "image" is a snapshot from which you can create new server instances. 7 | 8 | From Rackspace's own API documentation: 9 | 10 | An image is a collection of files used to create or rebuild a server. 11 | Rackspace provides a number of pre-built OS images by default. You may 12 | also create custom images from cloud servers you have launched. These 13 | custom images are useful for backup purposes or for producing "gold" 14 | server images if you plan to deploy a particular server configuration 15 | frequently. 16 | 17 | Classes 18 | ------- 19 | 20 | .. autoclass:: ImageManager 21 | :members: get, list, find, findall, create, delete 22 | 23 | .. autoclass:: Image 24 | :members: delete 25 | 26 | .. attribute:: id 27 | 28 | This image's ID. 29 | 30 | .. attribute:: name 31 | 32 | This image's name. 33 | 34 | .. attribute:: created 35 | 36 | The date/time this image was created. 37 | 38 | .. attribute:: updated 39 | 40 | The date/time this instance was updated. 41 | 42 | .. attribute:: status 43 | 44 | The status of this image (usually ``"SAVING"`` or ``ACTIVE``). 45 | 46 | .. attribute:: progress 47 | 48 | During saving of an image this'll be set to something between 49 | 0 and 100, representing a rough percentage done. 50 | 51 | .. attribute:: serverId 52 | 53 | If this image was created from a :class:`Server` then this attribute 54 | will be set to the ID of the server whence this image came. -------------------------------------------------------------------------------- /docs/ref/index.rst: -------------------------------------------------------------------------------- 1 | API Reference 2 | ============= 3 | 4 | .. toctree:: 5 | :maxdepth: 1 6 | 7 | backup_schedules 8 | exceptions 9 | flavors 10 | images 11 | ipgroups 12 | servers -------------------------------------------------------------------------------- /docs/ref/ipgroups.rst: -------------------------------------------------------------------------------- 1 | Shared IP addresses 2 | =================== 3 | 4 | From the Rackspace API guide: 5 | 6 | Public IP addresses can be shared across multiple servers for use in 7 | various high availability scenarios. When an IP address is shared to 8 | another server, the cloud network restrictions are modified to allow each 9 | server to listen to and respond on that IP address (you may optionally 10 | specify that the target server network configuration be modified). Shared 11 | IP addresses can be used with many standard heartbeat facilities (e.g. 12 | ``keepalived``) that monitor for failure and manage IP failover. 13 | 14 | A shared IP group is a collection of servers that can share IPs with other 15 | members of the group. Any server in a group can share one or more public 16 | IPs with any other server in the group. With the exception of the first 17 | server in a shared IP group, servers must be launched into shared IP 18 | groups. A server may only be a member of one shared IP group. 19 | 20 | .. seealso:: 21 | 22 | Use :meth:`Server.share_ip` and `Server.unshare_ip` to share and unshare 23 | IPs in a group. 24 | 25 | Classes 26 | ------- 27 | 28 | .. currentmodule:: openstack.compute 29 | 30 | .. autoclass:: IPGroupManager 31 | :members: get, list, find, findall, create, delete 32 | 33 | .. autoclass:: IPGroup 34 | :members: delete 35 | 36 | .. attribute:: id 37 | 38 | Shared group ID. 39 | 40 | .. attribute:: name 41 | 42 | Name of the group. 43 | 44 | .. attribute:: servers 45 | 46 | A list of server IDs in this group. -------------------------------------------------------------------------------- /docs/ref/servers.rst: -------------------------------------------------------------------------------- 1 | Servers 2 | ======= 3 | 4 | A virtual machine instance. 5 | 6 | Classes 7 | ------- 8 | 9 | .. currentmodule:: openstack.compute 10 | 11 | .. autoclass:: ServerManager 12 | :members: get, list, find, findall, create, update, delete, share_ip, 13 | unshare_ip, reboot, rebuild, resize, confirm_resize, 14 | revert_resize 15 | 16 | .. autoclass:: Server 17 | :members: update, delete, share_ip, unshare_ip, reboot, rebuild, resize, 18 | confirm_resize, revert_resize 19 | 20 | .. attribute:: id 21 | 22 | This server's ID. 23 | 24 | .. attribute:: name 25 | 26 | The name you gave the server when you booted it. 27 | 28 | .. attribute:: imageId 29 | 30 | The :class:`Image` this server was booted with. 31 | 32 | .. attribute:: flavorId 33 | 34 | This server's current :class:`Flavor`. 35 | 36 | .. attribute:: hostId 37 | 38 | Rackspace doesn't document this value. It appears to be SHA1 hash. 39 | 40 | .. attribute:: status 41 | 42 | The server's status (``BOOTING``, ``ACTIVE``, etc). 43 | 44 | .. attribute:: progress 45 | 46 | When booting, resizing, updating, etc., this will be set to a 47 | value between 0 and 100 giving a rough estimate of the progress 48 | of the current operation. 49 | 50 | .. attribute:: addresses 51 | 52 | The public and private IP addresses of this server. This'll be a dict 53 | of the form:: 54 | 55 | { 56 | "public" : ["67.23.10.138"], 57 | "private" : ["10.176.42.19"] 58 | } 59 | 60 | You *can* get more than one public/private IP provisioned, but not 61 | directly from the API; you'll need to open a support ticket. 62 | 63 | .. attribute:: metadata 64 | 65 | The metadata dict you gave when creating the server. 66 | 67 | Constants 68 | --------- 69 | 70 | Reboot types: 71 | 72 | .. data:: REBOOT_SOFT 73 | .. data:: REBOOT_HARD -------------------------------------------------------------------------------- /docs/releases.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | Release notes 3 | ============= 4 | 5 | 2.0 (TBD) 6 | ========= 7 | 8 | * **Major renaming**: the library is now called ``openstack.compute`` to 9 | reflect that Rackspace Cloud is just one instance of the open source 10 | project. This ripples to a lot of places: 11 | 12 | * The library is now called ``openstack.compute`` instead of 13 | ``cloudservers``, and the main API entry point is now 14 | ``openstack.compute.Compute`` instead of ``cloudservers.CloudServers``. 15 | 16 | * The shell program is now ``openstack-compute`` instead of 17 | ``cloudservers``. Yes, the name's a lot longer. Use ``alias``. 18 | 19 | * The env variables are now ``OPENSTACK_COMPUTE_USERNAME`` and 20 | ``OPENSTACK_COMPUTE_API_KEY``. 21 | 22 | 1.2 (August 15, 2010) 23 | ===================== 24 | 25 | * Support for Python 2.4 - 2.7. 26 | 27 | * Improved output of :program:`cloudservers ipgroup-list`. 28 | 29 | * Made ``cloudservers boot --ipgroup `` work (as well as ``--ipgroup 30 | ``). 31 | 32 | 1.1 (May 6, 2010) 33 | ================= 34 | 35 | * Added a ``--files`` option to :program:`cloudservers boot` supporting 36 | the upload of (up to five) files at boot time. 37 | 38 | * Added a ``--key`` option to :program:`cloudservers boot` to key the server 39 | with an SSH public key at boot time. This is just a shortcut for ``--files``, 40 | but it's a useful shortcut. 41 | 42 | * Changed the default server image to Ubuntu 10.04 LTS. -------------------------------------------------------------------------------- /docs/shell.rst: -------------------------------------------------------------------------------- 1 | The :program:`openstack-compute` shell utility 2 | ============================================== 3 | 4 | .. program:: openstack-compute 5 | .. highlight:: bash 6 | 7 | The :program:`openstack-compute` shell utility interacts with OpenStack 8 | Compute servers from the command line. It supports the entirety of the 9 | OpenStack Compute API (plus a few Rackspace-specific additions), including 10 | some commands not available from the Rackspace web console. 11 | 12 | To try this out, you'll need a `Rackspace Cloud`__ account — or your own 13 | install of OpenStack Compute (also known as Nova). If you're using Rackspace 14 | you'll need to make sure to sign up for both Cloud Servers *and* Cloud Files 15 | -- Rackspace won't let you get an API key unless you've got a Cloud Files 16 | account, too. Once you've got an account, you'll find your API key in the 17 | management console under "Your Account". 18 | 19 | __ http://rackspacecloud.com/ 20 | 21 | You'll need to provide :program:`openstack-compute` with your Rackspace 22 | username and API key. You can do this with the :option:`--username` and 23 | :option:`--apikey` options, but it's easier to just set them as environment 24 | variables by setting two environment variables: 25 | 26 | .. envvar:: OPENSTACK_COMPUTE_USERNAME 27 | 28 | Your Rackspace Cloud username. 29 | 30 | .. envvar:: OPENSTACK_COMPUTE_API_KEY 31 | 32 | Your API key. 33 | 34 | For example, in Bash you'd use:: 35 | 36 | export COPENSTACK_COMPUTE_USERNAME=yourname 37 | export COPENSTACK_COMPUTE_API_KEY=yadayadayada 38 | 39 | From there, all shell commands take the form:: 40 | 41 | openstack-compute [arguments...] 42 | 43 | Run :program:`openstack-compute help` to get a full list of all possible 44 | commands, and run :program:`openstack-compute help ` to get detailed 45 | help for that command. -------------------------------------------------------------------------------- /openstack/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | import pkg_resources 3 | pkg_resources.declare_namespace(__name__) 4 | except ImportError: 5 | import pkgutil 6 | __path__ = pkgutil.extend_path(__path__, __name__) 7 | -------------------------------------------------------------------------------- /openstack/compute/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '2.0a1' 2 | 3 | import os 4 | import ConfigParser 5 | from distutils.util import strtobool 6 | from openstack.compute.backup_schedules import (BackupSchedule, BackupScheduleManager, 7 | BACKUP_WEEKLY_DISABLED, BACKUP_WEEKLY_SUNDAY, BACKUP_WEEKLY_MONDAY, 8 | BACKUP_WEEKLY_TUESDAY, BACKUP_WEEKLY_WEDNESDAY, 9 | BACKUP_WEEKLY_THURSDAY, BACKUP_WEEKLY_FRIDAY, BACKUP_WEEKLY_SATURDAY, 10 | BACKUP_DAILY_DISABLED, BACKUP_DAILY_H_0000_0200, 11 | BACKUP_DAILY_H_0200_0400, BACKUP_DAILY_H_0400_0600, 12 | BACKUP_DAILY_H_0600_0800, BACKUP_DAILY_H_0800_1000, 13 | BACKUP_DAILY_H_1000_1200, BACKUP_DAILY_H_1200_1400, 14 | BACKUP_DAILY_H_1400_1600, BACKUP_DAILY_H_1600_1800, 15 | BACKUP_DAILY_H_1800_2000, BACKUP_DAILY_H_2000_2200, 16 | BACKUP_DAILY_H_2200_0000) 17 | from openstack.compute.client import ComputeClient 18 | from openstack.compute.exceptions import (ComputeException, BadRequest, Unauthorized, 19 | Forbidden, NotFound, OverLimit) 20 | from openstack.compute.flavors import FlavorManager, Flavor 21 | from openstack.compute.images import ImageManager, Image 22 | from openstack.compute.ipgroups import IPGroupManager, IPGroup 23 | from openstack.compute.servers import ServerManager, Server, REBOOT_HARD, REBOOT_SOFT 24 | from openstack.compute.api import API_OPTIONS 25 | 26 | DEFAULT_CONFIG_FILE = os.path.expanduser('~/.openstack/compute.conf') 27 | 28 | class Compute(object): 29 | """ 30 | Top-level object to access the OpenStack Compute API. 31 | 32 | Create an instance with your creds:: 33 | 34 | >>> compute = Compute(username=USERNAME, apikey=API_KEY) 35 | 36 | Then call methods on its managers:: 37 | 38 | >>> compute.servers.list() 39 | ... 40 | >>> compute.flavors.list() 41 | ... 42 | 43 | &c. 44 | """ 45 | 46 | def __init__(self, **kwargs): 47 | self.config = self._get_config(kwargs) 48 | self.backup_schedules = BackupScheduleManager(self) 49 | self.client = ComputeClient(self.config) 50 | self.flavors = FlavorManager(self) 51 | self.images = ImageManager(self) 52 | self.servers = ServerManager(self) 53 | if 'IPGROUPS' in API_OPTIONS[self.config.cloud_api]: 54 | self.ipgroups = IPGroupManager(self) 55 | 56 | def authenticate(self): 57 | """ 58 | Authenticate against the server. 59 | 60 | Normally this is called automatically when you first access the API, 61 | but you can call this method to force authentication right now. 62 | 63 | Returns on success; raises :exc:`~openstack.compute.Unauthorized` if 64 | the credentials are wrong. 65 | """ 66 | self.client.authenticate() 67 | 68 | def _get_config(self, kwargs): 69 | """ 70 | Get a Config object for this API client. 71 | 72 | Broken out into a seperate method so that the test client can easily 73 | mock it up. 74 | """ 75 | return Config( 76 | config_file = kwargs.pop('config_file', None), 77 | env = kwargs.pop('env', None), 78 | overrides = kwargs, 79 | ) 80 | 81 | class Config(object): 82 | """ 83 | Encapsulates getting config from a number of places. 84 | 85 | Config passed in __init__ overrides config found in the environ, which 86 | finally overrides config found in a config file. 87 | """ 88 | 89 | DEFAULTS = { 90 | 'username': None, 91 | 'apikey': None, 92 | 'auth_url': "https://auth.api.rackspacecloud.com/v1.0", 93 | 'user_agent': 'python-openstack-compute/%s' % __version__, 94 | 'allow_cache': False, 95 | 'cloud_api' : 'RACKSPACE', 96 | } 97 | 98 | def __init__(self, config_file, env, overrides, env_prefix="OPENSTACK_COMPUTE_"): 99 | config_file = config_file or DEFAULT_CONFIG_FILE 100 | env = env or os.environ 101 | 102 | self.config = self.DEFAULTS.copy() 103 | self.update_config_from_file(config_file) 104 | self.update_config_from_env(env, env_prefix) 105 | self.config.update(dict((k,v) for (k,v) in overrides.items() if v is not None)) 106 | self.apply_fixups() 107 | 108 | def __getattr__(self, attr): 109 | try: 110 | return self.config[attr] 111 | except KeyError: 112 | raise AttributeError(attr) 113 | 114 | def update_config_from_file(self, config_file): 115 | """ 116 | Update the config from a .ini file. 117 | """ 118 | configparser = ConfigParser.RawConfigParser() 119 | if os.path.exists(config_file): 120 | configparser.read([config_file]) 121 | 122 | # Mash together a bunch of sections -- "be liberal in what you accept." 123 | for section in ('global', 'compute', 'openstack.compute'): 124 | if configparser.has_section(section): 125 | self.config.update(dict(configparser.items(section))) 126 | 127 | def update_config_from_env(self, env, env_prefix): 128 | """ 129 | Update the config from the environ. 130 | """ 131 | for key, value in env.iteritems(): 132 | if key.startswith(env_prefix): 133 | key = key.replace(env_prefix, '').lower() 134 | self.config[key] = value 135 | 136 | def apply_fixups(self): 137 | """ 138 | Fix the types of any updates based on the original types in DEFAULTS. 139 | """ 140 | for key, value in self.DEFAULTS.iteritems(): 141 | if isinstance(value, bool) and not isinstance(self.config[key], bool): 142 | self.config[key] = strtobool(self.config[key]) 143 | -------------------------------------------------------------------------------- /openstack/compute/api.py: -------------------------------------------------------------------------------- 1 | # maps supported api versions to the optional features that they support 2 | API_OPTIONS = { 'RACKSPACE' : ['IPGROUPS'], 3 | 'OPENSTACK' : [] } -------------------------------------------------------------------------------- /openstack/compute/backup_schedules.py: -------------------------------------------------------------------------------- 1 | from openstack.compute import base 2 | 3 | BACKUP_WEEKLY_DISABLED = 'DISABLED' 4 | BACKUP_WEEKLY_SUNDAY = 'SUNDAY' 5 | BACKUP_WEEKLY_MONDAY = 'MONDAY' 6 | BACKUP_WEEKLY_TUESDAY = 'TUESDAY' 7 | BACKUP_WEEKLY_WEDNESDAY = 'WEDNESDAY' 8 | BACKUP_WEEKLY_THURSDAY = 'THURSDAY' 9 | BACKUP_WEEKLY_FRIDAY = 'FRIDAY' 10 | BACKUP_WEEKLY_SATURDAY = 'SATURDAY' 11 | 12 | BACKUP_DAILY_DISABLED = 'DISABLED' 13 | BACKUP_DAILY_H_0000_0200 = 'H_0000_0200' 14 | BACKUP_DAILY_H_0200_0400 = 'H_0200_0400' 15 | BACKUP_DAILY_H_0400_0600 = 'H_0400_0600' 16 | BACKUP_DAILY_H_0600_0800 = 'H_0600_0800' 17 | BACKUP_DAILY_H_0800_1000 = 'H_0800_1000' 18 | BACKUP_DAILY_H_1000_1200 = 'H_1000_1200' 19 | BACKUP_DAILY_H_1200_1400 = 'H_1200_1400' 20 | BACKUP_DAILY_H_1400_1600 = 'H_1400_1600' 21 | BACKUP_DAILY_H_1600_1800 = 'H_1600_1800' 22 | BACKUP_DAILY_H_1800_2000 = 'H_1800_2000' 23 | BACKUP_DAILY_H_2000_2200 = 'H_2000_2200' 24 | BACKUP_DAILY_H_2200_0000 = 'H_2200_0000' 25 | 26 | class BackupSchedule(base.Resource): 27 | """ 28 | Represents the daily or weekly backup schedule for some server. 29 | """ 30 | def get(self): 31 | """ 32 | Get this `BackupSchedule` again from the API. 33 | """ 34 | return self.manager.get(server=self.server) 35 | 36 | def delete(self): 37 | """ 38 | Delete (i.e. disable and remove) this scheduled backup. 39 | """ 40 | self.manager.delete(server=self.server) 41 | 42 | def update(self, enabled=True, weekly=BACKUP_WEEKLY_DISABLED, daily=BACKUP_DAILY_DISABLED): 43 | """ 44 | Update this backup schedule. 45 | 46 | See :meth:`BackupScheduleManager.create` for details. 47 | """ 48 | self.manager.create(self.server, enabled, weekly, daily) 49 | 50 | class BackupScheduleManager(base.Manager): 51 | """ 52 | Manage server backup schedules. 53 | """ 54 | resource_class = BackupSchedule 55 | 56 | def get(self, server): 57 | """ 58 | Get the current backup schedule for a server. 59 | 60 | :arg server: The server (or its ID). 61 | :rtype: :class:`BackupSchedule` 62 | """ 63 | s = base.getid(server) 64 | schedule = self._get('/servers/%s/backup_schedule' % s, 'backupSchedule') 65 | schedule.server = server 66 | return schedule 67 | 68 | # Backup schedules use POST for both create and update, so allow both here. 69 | # Unlike the rest of the API, POST here returns no body, so we can't use the 70 | # nice little helper methods. 71 | 72 | def create(self, server, enabled=True, weekly=BACKUP_WEEKLY_DISABLED, daily=BACKUP_DAILY_DISABLED): 73 | """ 74 | Create or update the backup schedule for the given server. 75 | 76 | :arg server: The server (or its ID). 77 | :arg enabled: boolean; should this schedule be enabled? 78 | :arg weekly: Run a weekly backup on this day (one of the `BACKUP_WEEKLY_*` constants) 79 | :arg daily: Run a daily backup at this time (one of the `BACKUP_DAILY_*` constants) 80 | """ 81 | s = base.getid(server) 82 | body = {'backupSchedule': { 83 | 'enabled': enabled, 'weekly': weekly, 'daily': daily 84 | }} 85 | self.api.client.post('/servers/%s/backup_schedule' % s, body=body) 86 | 87 | update = create 88 | 89 | def delete(self, server): 90 | """ 91 | Remove the scheduled backup for `server`. 92 | 93 | :arg server: The server (or its ID). 94 | """ 95 | s = base.getid(server) 96 | self._delete('/servers/%s/backup_schedule' % s) -------------------------------------------------------------------------------- /openstack/compute/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | Base utilities to build API operation managers and objects on top of. 3 | """ 4 | 5 | from openstack.compute.exceptions import NotFound 6 | 7 | # Python 2.4 compat 8 | try: 9 | all 10 | except NameError: 11 | def all(iterable): 12 | return True not in (not x for x in iterable) 13 | 14 | class Manager(object): 15 | """ 16 | Managers interact with a particular type of API (servers, flavors, images, 17 | etc.) and provide CRUD operations for them. 18 | """ 19 | resource_class = None 20 | 21 | def __init__(self, api): 22 | self.api = api 23 | 24 | def _list(self, url, response_key): 25 | resp, body = self.api.client.get(url) 26 | return [self.resource_class(self, res) for res in body[response_key]] 27 | 28 | def _get(self, url, response_key): 29 | resp, body = self.api.client.get(url) 30 | return self.resource_class(self, body[response_key]) 31 | 32 | def _create(self, url, body, response_key): 33 | resp, body = self.api.client.post(url, body=body) 34 | return self.resource_class(self, body[response_key]) 35 | 36 | def _delete(self, url): 37 | resp, body = self.api.client.delete(url) 38 | 39 | def _update(self, url, body): 40 | resp, body = self.api.client.put(url, body=body) 41 | 42 | class ManagerWithFind(Manager): 43 | """ 44 | Like a `Manager`, but with additional `find()`/`findall()` methods. 45 | """ 46 | def find(self, **kwargs): 47 | """ 48 | Find a single item with attributes matching ``**kwargs``. 49 | 50 | This isn't very efficient: it loads the entire list then filters on 51 | the Python side. 52 | """ 53 | rl = self.findall(**kwargs) 54 | try: 55 | return rl[0] 56 | except IndexError: 57 | raise NotFound(404, "No %s matching %s." % (self.resource_class.__name__, kwargs)) 58 | 59 | def findall(self, **kwargs): 60 | """ 61 | Find all items with attributes matching ``**kwargs``. 62 | 63 | This isn't very efficient: it loads the entire list then filters on 64 | the Python side. 65 | """ 66 | found = [] 67 | searches = kwargs.items() 68 | 69 | for obj in self.list(): 70 | try: 71 | if all(getattr(obj, attr) == value for (attr, value) in searches): 72 | found.append(obj) 73 | except AttributeError: 74 | continue 75 | 76 | return found 77 | 78 | class Resource(object): 79 | """ 80 | A resource represents a particular instance of an object (server, flavor, 81 | etc). This is pretty much just a bag for attributes. 82 | """ 83 | def __init__(self, manager, info): 84 | self.manager = manager 85 | self._info = info 86 | self._add_details(info) 87 | 88 | def _add_details(self, info): 89 | for (k, v) in info.iteritems(): 90 | setattr(self, k, v) 91 | 92 | def __getattr__(self, k): 93 | self.get() 94 | if k not in self.__dict__: 95 | raise AttributeError(k) 96 | else: 97 | return self.__dict__[k] 98 | 99 | def __repr__(self): 100 | reprkeys = sorted(k for k in self.__dict__.keys() if k[0] != '_' and k != 'manager') 101 | info = ", ".join("%s=%s" % (k, getattr(self, k)) for k in reprkeys) 102 | return "<%s %s>" % (self.__class__.__name__, info) 103 | 104 | def get(self): 105 | new = self.manager.get(self.id) 106 | self._add_details(new._info) 107 | 108 | def __eq__(self, other): 109 | if not isinstance(other, self.__class__): 110 | return False 111 | if hasattr(self, 'id') and hasattr(other, 'id'): 112 | return self.id == other.id 113 | return self._info == other._info 114 | 115 | def getid(obj): 116 | """ 117 | Abstracts the common pattern of allowing both an object or an object's ID 118 | (integer) as a parameter when dealing with relationships. 119 | """ 120 | try: 121 | return obj.id 122 | except AttributeError: 123 | return int(obj) -------------------------------------------------------------------------------- /openstack/compute/client.py: -------------------------------------------------------------------------------- 1 | import time 2 | import urlparse 3 | import urllib 4 | import httplib2 5 | try: 6 | import json 7 | except ImportError: 8 | import simplejson as json 9 | 10 | # Python 2.5 compat fix 11 | if not hasattr(urlparse, 'parse_qsl'): 12 | import cgi 13 | urlparse.parse_qsl = cgi.parse_qsl 14 | 15 | from openstack.compute import exceptions 16 | 17 | class ComputeClient(httplib2.Http): 18 | 19 | def __init__(self, config): 20 | super(ComputeClient, self).__init__() 21 | self.config = config 22 | self.management_url = None 23 | self.auth_token = None 24 | 25 | # httplib2 overrides 26 | self.force_exception_to_status_code = True 27 | 28 | def request(self, *args, **kwargs): 29 | kwargs.setdefault('headers', {}) 30 | kwargs['headers']['User-Agent'] = self.config.user_agent 31 | if 'body' in kwargs: 32 | kwargs['headers']['Content-Type'] = 'application/json' 33 | kwargs['body'] = json.dumps(kwargs['body']) 34 | 35 | resp, body = super(ComputeClient, self).request(*args, **kwargs) 36 | if body: 37 | try: 38 | body = json.loads(body) 39 | except ValueError: 40 | # OpenStack is JSON expect when it's not -- error messages 41 | # sometimes aren't actually JSON. 42 | body = {'error' : {'message' : body}} 43 | else: 44 | body = None 45 | 46 | if resp.status in (400, 401, 403, 404, 413, 500): 47 | raise exceptions.from_response(resp, body) 48 | 49 | return resp, body 50 | 51 | def _cs_request(self, url, method, **kwargs): 52 | if not self.management_url: 53 | self.authenticate() 54 | 55 | # Perform the request once. If we get a 401 back then it 56 | # might be because the auth token expired, so try to 57 | # re-authenticate and try again. If it still fails, bail. 58 | try: 59 | kwargs.setdefault('headers', {})['X-Auth-Token'] = self.auth_token 60 | resp, body = self.request(self.management_url + url, method, **kwargs) 61 | return resp, body 62 | except exceptions.Unauthorized, ex: 63 | try: 64 | self.authenticate() 65 | resp, body = self.request(self.management_url + url, method, **kwargs) 66 | return resp, body 67 | except exceptions.Unauthorized: 68 | raise ex 69 | 70 | def get(self, url, **kwargs): 71 | url = self._munge_get_url(url) 72 | return self._cs_request(url, 'GET', **kwargs) 73 | 74 | def post(self, url, **kwargs): 75 | return self._cs_request(url, 'POST', **kwargs) 76 | 77 | def put(self, url, **kwargs): 78 | return self._cs_request(url, 'PUT', **kwargs) 79 | 80 | def delete(self, url, **kwargs): 81 | return self._cs_request(url, 'DELETE', **kwargs) 82 | 83 | def authenticate(self): 84 | headers = { 85 | 'X-Auth-User': self.config.username, 86 | 'X-Auth-Key': self.config.apikey, 87 | } 88 | resp, body = self.request(self.config.auth_url, 'GET', headers=headers) 89 | self.management_url = resp['x-server-management-url'] 90 | self.auth_token = resp['x-auth-token'] 91 | 92 | def _munge_get_url(self, url): 93 | """ 94 | Munge GET URLs to always return uncached content if 95 | self.config.allow_cache is False (the default). 96 | 97 | The Cloud Servers API caches data *very* agressively and doesn't respect 98 | cache headers. To avoid stale data, then, we append a little bit of 99 | nonsense onto GET parameters; this appears to force the data not to be 100 | cached. 101 | """ 102 | if self.config.allow_cache: 103 | return url 104 | else: 105 | scheme, netloc, path, query, frag = urlparse.urlsplit(url) 106 | query = urlparse.parse_qsl(query) 107 | query.append(('fresh', str(time.time()))) 108 | query = urllib.urlencode(query) 109 | return urlparse.urlunsplit((scheme, netloc, path, query, frag)) 110 | -------------------------------------------------------------------------------- /openstack/compute/exceptions.py: -------------------------------------------------------------------------------- 1 | class ComputeException(Exception): 2 | """ 3 | The base exception class for all exceptions this library raises. 4 | """ 5 | def __init__(self, code, message=None, details=None): 6 | self.code = code 7 | self.message = message or self.__class__.message 8 | self.details = details 9 | 10 | def __str__(self): 11 | return "%s (HTTP %s)" % (self.message, self.code) 12 | 13 | class BadRequest(ComputeException): 14 | """ 15 | HTTP 400 - Bad request: you sent some malformed data. 16 | """ 17 | http_status = 400 18 | message = "Bad request" 19 | 20 | class Unauthorized(ComputeException): 21 | """ 22 | HTTP 401 - Unauthorized: bad credentials. 23 | """ 24 | http_status = 401 25 | message = "Unauthorized" 26 | 27 | class Forbidden(ComputeException): 28 | """ 29 | HTTP 403 - Forbidden: your credentials don't give you access to this resource. 30 | """ 31 | http_status = 403 32 | message = "Forbidden" 33 | 34 | class NotFound(ComputeException): 35 | """ 36 | HTTP 404 - Not found 37 | """ 38 | http_status = 404 39 | message = "Not found" 40 | 41 | class OverLimit(ComputeException): 42 | """ 43 | HTTP 413 - Over limit: you're over the API limits for this time period. 44 | """ 45 | http_status = 413 46 | message = "Over limit" 47 | 48 | # In Python 2.4 Exception is old-style and thus doesn't have a __subclasses__() 49 | # so we can do this: 50 | # _code_map = dict((c.http_status, c) for c in ComputeException.__subclasses__()) 51 | # 52 | # Instead, we have to hardcode it: 53 | _code_map = dict((c.http_status, c) for c in [BadRequest, Unauthorized, Forbidden, NotFound, OverLimit]) 54 | 55 | def from_response(response, body): 56 | """ 57 | Return an instance of a ComputeException or subclass 58 | based on an httplib2 response. 59 | 60 | Usage:: 61 | 62 | resp, body = http.request(...) 63 | if resp.status != 200: 64 | raise exception_from_response(resp, body) 65 | """ 66 | cls = _code_map.get(response.status, ComputeException) 67 | if body: 68 | error = body[body.keys()[0]] 69 | return cls(code=response.status, 70 | message=error.get('message', None), 71 | details=error.get('details', None)) 72 | else: 73 | return cls(code=response.status) -------------------------------------------------------------------------------- /openstack/compute/flavors.py: -------------------------------------------------------------------------------- 1 | from openstack.compute import base 2 | 3 | class Flavor(base.Resource): 4 | """ 5 | A flavor is an available hardware configuration for a server. 6 | """ 7 | def __repr__(self): 8 | return "" % self.name 9 | 10 | class FlavorManager(base.ManagerWithFind): 11 | """ 12 | Manage :class:`Flavor` resources. 13 | """ 14 | resource_class = Flavor 15 | 16 | def list(self): 17 | """ 18 | Get a list of all flavors. 19 | 20 | :rtype: list of :class:`Flavor`. 21 | """ 22 | return self._list("/flavors/detail", "flavors") 23 | 24 | def get(self, flavor): 25 | """ 26 | Get a specific flavor. 27 | 28 | :param flavor: The ID of the :class:`Flavor` to get. 29 | :rtype: :class:`Flavor` 30 | """ 31 | return self._get("/flavors/%s" % base.getid(flavor), "flavor") -------------------------------------------------------------------------------- /openstack/compute/images.py: -------------------------------------------------------------------------------- 1 | from openstack.compute import base 2 | 3 | class Image(base.Resource): 4 | """ 5 | An image is a collection of files used to create or rebuild a server. 6 | """ 7 | def __repr__(self): 8 | return "" % self.name 9 | 10 | def delete(self): 11 | """ 12 | Delete this image. 13 | """ 14 | return self.manager.delete(self) 15 | 16 | class ImageManager(base.ManagerWithFind): 17 | """ 18 | Manage :class:`Image` resources. 19 | """ 20 | resource_class = Image 21 | 22 | def get(self, image): 23 | """ 24 | Get an image. 25 | 26 | :param image: The ID of the image to get. 27 | :rtype: :class:`Image` 28 | """ 29 | return self._get("/images/%s" % base.getid(image), "image") 30 | 31 | def list(self): 32 | """ 33 | Get a list of all images. 34 | 35 | :rtype: list of :class:`Image` 36 | """ 37 | return self._list("/images/detail", "images") 38 | 39 | def create(self, name, server): 40 | """ 41 | Create a new image by snapshotting a running :class:`Server` 42 | 43 | :param name: An (arbitrary) name for the new image. 44 | :param server: The :class:`Server` (or its ID) to make a snapshot of. 45 | :rtype: :class:`Image` 46 | """ 47 | data = {"image": {"serverId": base.getid(server), "name": name}} 48 | return self._create("/images", data, "image") 49 | 50 | def delete(self, image): 51 | """ 52 | Delete an image. 53 | 54 | It should go without saying that you can't delete an image 55 | that you didn't create. 56 | 57 | :param image: The :class:`Image` (or its ID) to delete. 58 | """ 59 | self._delete("/images/%s" % base.getid(image)) -------------------------------------------------------------------------------- /openstack/compute/ipgroups.py: -------------------------------------------------------------------------------- 1 | from openstack.compute import base 2 | 3 | class IPGroup(base.Resource): 4 | def __repr__(self): 5 | return "" % self.name 6 | 7 | def delete(self): 8 | """ 9 | Delete this group. 10 | """ 11 | self.manager.delete(self) 12 | 13 | class IPGroupManager(base.ManagerWithFind): 14 | resource_class = IPGroup 15 | 16 | def list(self): 17 | """ 18 | Get a list of all groups. 19 | 20 | :rtype: list of :class:`IPGroup` 21 | """ 22 | return self._list("/shared_ip_groups/detail", "sharedIpGroups") 23 | 24 | def get(self, group): 25 | """ 26 | Get an IP group. 27 | 28 | :param group: ID of the image to get. 29 | :rtype: :class:`IPGroup` 30 | """ 31 | return self._get("/shared_ip_groups/%s" % base.getid(group), "sharedIpGroup") 32 | 33 | def create(self, name, server=None): 34 | """ 35 | Create a new :class:`IPGroup` 36 | 37 | :param name: An (arbitrary) name for the new image. 38 | :param server: A :class:`Server` (or its ID) to make a member of this group. 39 | :rtype: :class:`IPGroup` 40 | """ 41 | data = {"sharedIpGroup": {"name": name}} 42 | if server: 43 | data['sharedIpGroup']['server'] = base.getid(server) 44 | return self._create('/shared_ip_groups', data, "sharedIpGroup") 45 | 46 | def delete(self, group): 47 | """ 48 | Delete a group. 49 | 50 | :param group: The :class:`IPGroup` (or its ID) to delete. 51 | """ 52 | self._delete("/shared_ip_groups/%s" % base.getid(group)) 53 | -------------------------------------------------------------------------------- /openstack/compute/servers.py: -------------------------------------------------------------------------------- 1 | from openstack.compute import base 2 | from openstack.compute.api import API_OPTIONS 3 | 4 | REBOOT_SOFT, REBOOT_HARD = 'SOFT', 'HARD' 5 | 6 | class Server(base.Resource): 7 | def __repr__(self): 8 | return "" % self.name 9 | 10 | def delete(self): 11 | """ 12 | Delete (i.e. shut down and delete the image) this server. 13 | """ 14 | self.manager.delete(self) 15 | 16 | def update(self, name=None, password=None): 17 | """ 18 | Update the name or the password for this server. 19 | 20 | :param name: Update the server's name. 21 | :param password: Update the root password. 22 | """ 23 | self.manager.update(self, name, password) 24 | 25 | def share_ip(self, ipgroup=None, address=None, configure=True): 26 | """ 27 | Share an IP address from the given IP group onto this server. 28 | 29 | :param ipgroup: The :class:`IPGroup` that the given address belongs to. 30 | DEPRICATED in OpenStack. 31 | :param address: The IP address to share. 32 | :param configure: If ``True``, the server will be automatically 33 | configured to use this IP. I don't know why you'd 34 | want this to be ``False``. 35 | """ 36 | # to make ipgroup optional without making address optional or changing the 37 | # order of the parameters in the function signature 38 | if address == None: 39 | raise TypeError("Address is required") 40 | 41 | self.manager.share_ip(self, ipgroup, address, configure) 42 | 43 | def unshare_ip(self, address): 44 | """ 45 | Stop sharing the given address. 46 | 47 | :param address: The IP address to stop sharing. 48 | """ 49 | self.manager.unshare_ip(self, address) 50 | 51 | def reboot(self, type=REBOOT_SOFT): 52 | """ 53 | Reboot the server. 54 | 55 | :param type: either :data:`REBOOT_SOFT` for a software-level reboot, 56 | or `REBOOT_HARD` for a virtual power cycle hard reboot. 57 | """ 58 | self.manager.reboot(self, type) 59 | 60 | def rebuild(self, image): 61 | """ 62 | Rebuild -- shut down and then re-image -- this server. 63 | 64 | :param image: the :class:`Image` (or its ID) to re-image with. 65 | """ 66 | self.manager.rebuild(self, image) 67 | 68 | def resize(self, flavor): 69 | """ 70 | Resize the server's resources. 71 | 72 | :param flavor: the :class:`Flavor` (or its ID) to resize to. 73 | 74 | Until a resize event is confirmed with :meth:`confirm_resize`, the old 75 | server will be kept around and you'll be able to roll back to the old 76 | flavor quickly with :meth:`revert_resize`. All resizes are 77 | automatically confirmed after 24 hours. 78 | """ 79 | self.manager.resize(self, flavor) 80 | 81 | def confirm_resize(self): 82 | """ 83 | Confirm that the resize worked, thus removing the original server. 84 | """ 85 | self.manager.confirm_resize(self) 86 | 87 | def revert_resize(self): 88 | """ 89 | Revert a previous resize, switching back to the old server. 90 | """ 91 | self.manager.revert_resize(self) 92 | 93 | @property 94 | def backup_schedule(self): 95 | """ 96 | This server's :class:`BackupSchedule`. 97 | """ 98 | return self.manager.api.backup_schedules.get(self) 99 | 100 | @property 101 | def public_ip(self): 102 | """ 103 | Shortcut to get this server's primary public IP address. 104 | """ 105 | if self.addresses['public']: 106 | return self.addresses['public'][0] 107 | else: 108 | return u'' 109 | 110 | @property 111 | def private_ip(self): 112 | """ 113 | Shortcut to get this server's primary private IP address. 114 | """ 115 | if self.addresses['private']: 116 | return self.addresses['private'][0] 117 | else: 118 | return u'' 119 | 120 | class ServerManager(base.ManagerWithFind): 121 | resource_class = Server 122 | 123 | def get(self, server): 124 | """ 125 | Get a server. 126 | 127 | :param server: ID of the :class:`Server` to get. 128 | :rtype: :class:`Server` 129 | """ 130 | return self._get("/servers/%s" % base.getid(server), "server") 131 | 132 | def list(self): 133 | """ 134 | Get a list of servers. 135 | :rtype: list of :class:`Server` 136 | """ 137 | return self._list("/servers/detail", "servers") 138 | 139 | def create(self, name, image, flavor, ipgroup=None, meta=None, files=None): 140 | """ 141 | Create (boot) a new server. 142 | 143 | :param name: Something to name the server. 144 | :param image: The :class:`Image` to boot with. 145 | :param flavor: The :class:`Flavor` to boot onto. 146 | :param ipgroup: An initial :class:`IPGroup` for this server. 147 | :param meta: A dict of arbitrary key/value metadata to store for this 148 | server. A maximum of five entries is allowed, and both 149 | keys and values must be 255 characters or less. 150 | :param files: A dict of files to overrwrite on the server upon boot. 151 | Keys are file names (i.e. ``/etc/passwd``) and values 152 | are the file contents (either as a string or as a 153 | file-like object). A maximum of five entries is allowed, 154 | and each file must be 10k or less. 155 | 156 | There's a bunch more info about how a server boots in Rackspace's 157 | official API docs, page 23. 158 | """ 159 | body = {"server": { 160 | "name": name, 161 | "imageId": base.getid(image), 162 | "flavorId": base.getid(flavor), 163 | }} 164 | if ipgroup: 165 | body["server"]["sharedIpGroupId"] = base.getid(ipgroup) 166 | if meta: 167 | body["server"]["metadata"] = meta 168 | 169 | # Files are a slight bit tricky. They're passed in a "personality" 170 | # list to the POST. Each item is a dict giving a file name and the 171 | # base64-encoded contents of the file. We want to allow passing 172 | # either an open file *or* some contents as files here. 173 | if files: 174 | personality = body['server']['personality'] = [] 175 | for filepath, file_or_string in files.items(): 176 | if hasattr(file_or_string, 'read'): 177 | data = file_or_string.read() 178 | else: 179 | data = file_or_string 180 | personality.append({ 181 | 'path': filepath, 182 | 'contents': data.encode('base64'), 183 | }) 184 | 185 | return self._create("/servers", body, "server") 186 | 187 | def update(self, server, name=None, password=None): 188 | """ 189 | Update the name or the password for a server. 190 | 191 | :param server: The :class:`Server` (or its ID) to update. 192 | :param name: Update the server's name. 193 | :param password: Update the root password. 194 | """ 195 | 196 | if name is None and password is None: 197 | return 198 | body = {"server": {}} 199 | if name: 200 | body["server"]["name"] = name 201 | if password: 202 | body["server"]["adminPass"] = password 203 | self._update("/servers/%s" % base.getid(server), body) 204 | 205 | def delete(self, server): 206 | """ 207 | Delete (i.e. shut down and delete the image) this server. 208 | """ 209 | self._delete("/servers/%s" % base.getid(server)) 210 | 211 | def share_ip(self, server, ipgroup=None, address=None, configure=True): 212 | """ 213 | Share an IP address from the given IP group onto a server. 214 | 215 | :param server: The :class:`Server` (or its ID) to share onto. 216 | :param ipgroup: The :class:`IPGroup` that the given address belongs to. 217 | DEPRICATED in OpenStack 218 | :param address: The IP address to share. 219 | :param configure: If ``True``, the server will be automatically 220 | configured to use this IP. I don't know why you'd 221 | want this to be ``False``. 222 | """ 223 | # to make ipgroup optional without making address optional or changing the 224 | # order of the parameters in the function signature 225 | if address == None: 226 | raise TypeError("Address is required") 227 | 228 | if 'IPGROUPS' in API_OPTIONS[self.api.config.cloud_api]: 229 | if ipgroup == None: 230 | raise TypeError("IPGroup is required") 231 | server = base.getid(server) 232 | ipgroup = base.getid(ipgroup) 233 | body = {'shareIp': {'sharedIpGroupId': ipgroup, 'configureServer': configure}} 234 | self._update("/servers/%s/ips/public/%s" % (server, address), body) 235 | else: 236 | #TODO: Jwilcox(2011-04-18) share ip without ipgroup openstack 1.1 api 237 | pass 238 | 239 | def unshare_ip(self, server, address): 240 | """ 241 | Stop sharing the given address. 242 | 243 | :param server: The :class:`Server` (or its ID) to share onto. 244 | :param address: The IP address to stop sharing. 245 | """ 246 | server = base.getid(server) 247 | self._delete("/servers/%s/ips/public/%s" % (server, address)) 248 | 249 | def reboot(self, server, type=REBOOT_SOFT): 250 | """ 251 | Reboot a server. 252 | 253 | :param server: The :class:`Server` (or its ID) to share onto. 254 | :param type: either :data:`REBOOT_SOFT` for a software-level reboot, 255 | or `REBOOT_HARD` for a virtual power cycle hard reboot. 256 | """ 257 | self._action('reboot', server, {'type':type}) 258 | 259 | def rebuild(self, server, image): 260 | """ 261 | Rebuild -- shut down and then re-image -- a server. 262 | 263 | :param server: The :class:`Server` (or its ID) to share onto. 264 | :param image: the :class:`Image` (or its ID) to re-image with. 265 | """ 266 | self._action('rebuild', server, {'imageId': base.getid(image)}) 267 | 268 | def resize(self, server, flavor): 269 | """ 270 | Resize a server's resources. 271 | 272 | :param server: The :class:`Server` (or its ID) to share onto. 273 | :param flavor: the :class:`Flavor` (or its ID) to resize to. 274 | 275 | Until a resize event is confirmed with :meth:`confirm_resize`, the old 276 | server will be kept around and you'll be able to roll back to the old 277 | flavor quickly with :meth:`revert_resize`. All resizes are 278 | automatically confirmed after 24 hours. 279 | """ 280 | self._action('resize', server, {'flavorId': base.getid(flavor)}) 281 | 282 | def confirm_resize(self, server): 283 | """ 284 | Confirm that the resize worked, thus removing the original server. 285 | 286 | :param server: The :class:`Server` (or its ID) to share onto. 287 | """ 288 | self._action('confirmResize', server) 289 | 290 | def revert_resize(self, server): 291 | """ 292 | Revert a previous resize, switching back to the old server. 293 | 294 | :param server: The :class:`Server` (or its ID) to share onto. 295 | """ 296 | self._action('revertResize', server) 297 | 298 | def _action(self, action, server, info=None): 299 | """ 300 | Perform a server "action" -- reboot/rebuild/resize/etc. 301 | """ 302 | self.api.client.post('/servers/%s/action' % base.getid(server), body={action: info}) 303 | -------------------------------------------------------------------------------- /openstack/compute/shell.py: -------------------------------------------------------------------------------- 1 | """ 2 | Command-line interface to the OpenStack Compute API. 3 | """ 4 | 5 | import argparse 6 | import getpass 7 | import httplib2 8 | import os 9 | import prettytable 10 | import sys 11 | from openstack import compute 12 | 13 | # Choices for flags. 14 | DAY_CHOICES = [getattr(compute, i).lower() 15 | for i in dir(compute) 16 | if i.startswith('BACKUP_WEEKLY_')] 17 | HOUR_CHOICES = [getattr(compute, i).lower() 18 | for i in dir(compute) 19 | if i.startswith('BACKUP_DAILY_')] 20 | 21 | def pretty_choice_list(l): return ', '.join("'%s'" % i for i in l) 22 | 23 | # Sentinal for boot --key 24 | AUTO_KEY = object() 25 | 26 | # Decorator for args 27 | def arg(*args, **kwargs): 28 | def _decorator(func): 29 | # Because of the sematics of decorator composition if we just append 30 | # to the options list positional options will appear to be backwards. 31 | func.__dict__.setdefault('arguments', []).insert(0, (args, kwargs)) 32 | return func 33 | return _decorator 34 | 35 | class CommandError(Exception): 36 | pass 37 | 38 | def env(e): 39 | return os.environ.get(e, '') 40 | 41 | class ComputeShell(object): 42 | 43 | # Hook for the test suite to inject a fake server. 44 | _api_class = compute.Compute 45 | 46 | def __init__(self): 47 | self.parser = argparse.ArgumentParser( 48 | prog = 'openstack-compute', 49 | description = __doc__.strip(), 50 | epilog = 'See "openstack-compute help COMMAND" for help on a specific command.', 51 | add_help = False, 52 | formatter_class = ComputeHelpFormatter, 53 | ) 54 | 55 | # Global arguments 56 | self.parser.add_argument('-h', '--help', 57 | action = 'help', 58 | help = argparse.SUPPRESS, 59 | ) 60 | 61 | self.parser.add_argument('--debug', 62 | default = False, 63 | action = 'store_true', 64 | help = argparse.SUPPRESS) 65 | 66 | self.parser.add_argument('-f', '--config-file', 67 | metavar = 'PATH', 68 | default = None, 69 | help = 'Path to config file (default: ~/.openstack/compute.conf)') 70 | self.parser.add_argument('--username', 71 | help = 'Account username. Required if not in a config file/environ.') 72 | self.parser.add_argument('--apikey', 73 | help = 'Account API key. Required if not in a config file/environ.') 74 | self.parser.add_argument('--auth-url', 75 | help = "Service URL (default: Rackspace's US auth URL)") 76 | self.parser.add_argument('--allow-cache', 77 | action = 'store_true', 78 | default = False, 79 | help = "Allow the API to returned cached results.") 80 | self.parser.add_argument('--cloud-api', 81 | help = "API of the cloud service to be managed: either RACKSPACE or OPENSTACK") 82 | 83 | # Subcommands 84 | subparsers = self.parser.add_subparsers(metavar='') 85 | self.subcommands = {} 86 | 87 | # Everything that's do_* is a subcommand. 88 | for attr in (a for a in dir(self) if a.startswith('do_')): 89 | # I prefer to be hypen-separated instead of underscores. 90 | command = attr[3:].replace('_', '-') 91 | callback = getattr(self, attr) 92 | desc = callback.__doc__ or '' 93 | help = desc.strip().split('\n')[0] 94 | arguments = getattr(callback, 'arguments', []) 95 | 96 | subparser = subparsers.add_parser(command, 97 | help = help, 98 | description = desc, 99 | add_help=False, 100 | formatter_class = ComputeHelpFormatter 101 | ) 102 | subparser.add_argument('-h', '--help', 103 | action = 'help', 104 | help = argparse.SUPPRESS, 105 | ) 106 | self.subcommands[command] = subparser 107 | for (args, kwargs) in arguments: 108 | subparser.add_argument(*args, **kwargs) 109 | subparser.set_defaults(func=callback) 110 | 111 | def main(self, argv): 112 | # Parse args and call whatever callback was selected 113 | args = self.parser.parse_args(argv) 114 | 115 | # Short-circuit and deal with help right away. 116 | if args.func == self.do_help: 117 | self.do_help(args) 118 | return 0 119 | 120 | # Deal with global arguments 121 | if args.debug: 122 | httplib2.debuglevel = 1 123 | 124 | self.compute = self._api_class( 125 | config_file = args.config_file, 126 | username = args.username, 127 | apikey = args.apikey, 128 | auth_url = args.auth_url, 129 | allow_cache = args.allow_cache, 130 | ) 131 | if not self.compute.config.username: 132 | raise CommandError("You must provide a username, either via " 133 | "--username or env[OPENSTACK_COMPUTE_USERNAME], " 134 | "or a config file.") 135 | if not self.compute.config.apikey: 136 | raise CommandError("You must provide an API key, either via " 137 | "--apikey or via env[OPENSTACK_COMPUTE_APIKEY], " 138 | "or a config file.") 139 | try: 140 | self.compute.authenticate() 141 | except compute.Unauthorized: 142 | raise CommandError("Invalid Cloud Servers credentials.") 143 | 144 | args.func(args) 145 | 146 | @arg('command', metavar='', nargs='?', help='Display help for ') 147 | def do_help(self, args): 148 | """ 149 | Display help about this program or one of its subcommands. 150 | """ 151 | if args.command: 152 | if args.command in self.subcommands: 153 | self.subcommands[args.command].print_help() 154 | else: 155 | raise CommandError("'%s' is not a valid subcommand." % args.command) 156 | else: 157 | self.parser.print_help() 158 | 159 | @arg('server', metavar='', help='Name or ID of server.') 160 | @arg('--enable', dest='enabled', default=None, action='store_true', help='Enable backups.') 161 | @arg('--disable', dest='enabled', action='store_false', help='Disable backups.') 162 | @arg('--weekly', metavar='', choices=DAY_CHOICES, 163 | help='Schedule a weekly backup for (one of: %s).' % pretty_choice_list(DAY_CHOICES)) 164 | @arg('--daily', metavar='', choices=HOUR_CHOICES, 165 | help='Schedule a daily backup during (one of: %s).' % pretty_choice_list(HOUR_CHOICES)) 166 | def do_backup_schedule(self, args): 167 | """ 168 | Show or edit the backup schedule for a server. 169 | 170 | With no flags, the backup schedule will be shown. If flags are given, 171 | the backup schedule will be modified accordingly. 172 | """ 173 | server = self._find_server(args.server) 174 | 175 | # If we have some flags, update the backup 176 | backup = {} 177 | if args.daily: 178 | backup['daily'] = getattr(compute, 'BACKUP_DAILY_%s' % args.daily.upper()) 179 | if args.weekly: 180 | backup['weekly'] = getattr(compute, 'BACKUP_WEEKLY_%s' % args.weekly.upper()) 181 | if args.enabled is not None: 182 | backup['enabled'] = args.enabled 183 | if backup: 184 | server.backup_schedule.update(**backup) 185 | else: 186 | print_dict(server.backup_schedule._info) 187 | 188 | @arg('server', metavar='', help='Name or ID of server.') 189 | def do_backup_schedule_delete(self, args): 190 | """ 191 | Delete the backup schedule for a server. 192 | """ 193 | server = self._find_server(args.server) 194 | server.backup_schedule.delete() 195 | 196 | @arg('--flavor', 197 | default = None, 198 | metavar = '', 199 | help = "Flavor ID (see 'cloudservers flavors'). Defaults to 256MB RAM instance.") 200 | @arg('--image', 201 | default = None, 202 | metavar = '', 203 | help = "Image ID (see 'cloudservers images'). Defaults to Ubuntu 10.04 LTS.") 204 | @arg('--ipgroup', 205 | default = None, 206 | metavar = '', 207 | help = "IP group name or ID (see 'cloudservers ipgroup-list'). DEPRICATED in OpenStack") 208 | @arg('--meta', 209 | metavar = "", 210 | action = 'append', 211 | default = [], 212 | help = "Record arbitrary key/value metadata. May be give multiple times.") 213 | @arg('--file', 214 | metavar = "", 215 | action = 'append', 216 | dest = 'files', 217 | default = [], 218 | help = "Store arbitrary files from locally to "\ 219 | "on the new server. You may store up to 5 files.") 220 | @arg('--key', 221 | metavar = '', 222 | nargs = '?', 223 | const = AUTO_KEY, 224 | help = "Key the server with an SSH keypair. Looks in ~/.ssh for a key, "\ 225 | "or takes an explicit to one.") 226 | @arg('name', metavar='', help='Name for the new server') 227 | def do_boot(self, args): 228 | """Boot a new server.""" 229 | flavor = args.flavor or self.compute.flavors.find(ram=256) 230 | image = args.image or self.compute.images.find(name="Ubuntu 10.04 LTS (lucid)") 231 | 232 | # Map --ipgroup to an ID. 233 | # XXX do this for flavor/image? 234 | if args.ipgroup: 235 | ipgroup = self._find_ipgroup(args.ipgroup) 236 | else: 237 | ipgroup = None 238 | 239 | metadata = dict(v.split('=') for v in args.meta) 240 | 241 | files = {} 242 | for f in args.files: 243 | dst, src = f.split('=', 1) 244 | try: 245 | files[dst] = open(src) 246 | except IOError, e: 247 | raise CommandError("Can't open '%s': %s" % (src, e)) 248 | 249 | if args.key is AUTO_KEY: 250 | possible_keys = [os.path.join(os.path.expanduser('~'), '.ssh', k) 251 | for k in ('id_dsa.pub', 'id_rsa.pub')] 252 | for k in possible_keys: 253 | if os.path.exists(k): 254 | keyfile = k 255 | break 256 | else: 257 | raise CommandError("Couldn't find a key file: tried ~/.ssh/id_dsa.pub or ~/.ssh/id_rsa.pub") 258 | elif args.key: 259 | keyfile = args.key 260 | else: 261 | keyfile = None 262 | 263 | if keyfile: 264 | try: 265 | files['/root/.ssh/authorized_keys2'] = open(keyfile) 266 | except IOError, e: 267 | raise CommandError("Can't open '%s': %s" % (keyfile, e)) 268 | 269 | server = self.compute.servers.create(args.name, image, flavor, ipgroup, metadata, files) 270 | print_dict(server._info) 271 | 272 | def do_flavor_list(self, args): 273 | """Print a list of available 'flavors' (sizes of servers).""" 274 | print_list(self.compute.flavors.list(), ['ID', 'Name', 'RAM', 'Disk']) 275 | 276 | def do_image_list(self, args): 277 | """Print a list of available images to boot from.""" 278 | print_list(self.compute.images.list(), ['ID', 'Name', 'Status']) 279 | 280 | @arg('server', metavar='', help='Name or ID of server.') 281 | @arg('name', metavar='', help='Name for the new image.') 282 | def do_image_create(self, args): 283 | """Create a new image by taking a snapshot of a running server.""" 284 | server = self._find_server(args.server) 285 | image = self.compute.images.create(args.name, server) 286 | print_dict(image._info) 287 | 288 | @arg('image', metavar='', help='Name or ID of image.') 289 | def do_image_delete(self, args): 290 | """ 291 | Delete an image. 292 | 293 | It should go without saying, but you cn only delete images you 294 | created. 295 | """ 296 | image = self._find_image(args.image) 297 | image.delete() 298 | 299 | @arg('server', metavar='', help='Name or ID of server.') 300 | @arg('group', metavar='', help='Name or ID of group.') 301 | @arg('address', metavar='
', help='IP address to share.') 302 | def do_ip_share(self, args): 303 | """Share an IP address from the given IP group onto a server.""" 304 | server = self._find_server(args.server) 305 | group = self._find_ipgroup(args.group) 306 | server.share_ip(group, args.address) 307 | 308 | @arg('server', metavar='', help='Name or ID of server.') 309 | @arg('address', metavar='
', help='Shared IP address to remove from the server.') 310 | def do_ip_unshare(self, args): 311 | """Stop sharing an given address with a server.""" 312 | server = self._find_server(args.server) 313 | server.unshare_ip(args.address) 314 | 315 | def do_ipgroup_list(self, args): 316 | """Show IP groups.""" 317 | def pretty_server_list(ipgroup): 318 | return ", ".join(self.compute.servers.get(id).name for id in ipgroup.servers) 319 | 320 | print_list(self.compute.ipgroups.list(), 321 | fields = ['ID', 'Name', 'Server List'], 322 | formatters = {'Server List': pretty_server_list}) 323 | 324 | @arg('group', metavar='', help='Name or ID of group.') 325 | def do_ipgroup_show(self, args): 326 | """Show details about a particular IP group.""" 327 | group = self._find_ipgroup(args.group) 328 | print_dict(group._info) 329 | 330 | @arg('name', metavar='', help='What to name this new group.') 331 | @arg('server', metavar='', nargs='?', 332 | help='Server (name or ID) to make a member of this new group.') 333 | def do_ipgroup_create(self, args): 334 | """Create a new IP group.""" 335 | if args.server: 336 | server = self._find_server(args.server) 337 | else: 338 | server = None 339 | group = self.compute.ipgroups.create(args.name, server) 340 | print_dict(group._info) 341 | 342 | @arg('group', metavar='', help='Name or ID of group.') 343 | def do_ipgroup_delete(self, args): 344 | """Delete an IP group.""" 345 | self._find_ipgroup(args.group).delete() 346 | 347 | def do_list(self, args): 348 | """List active servers.""" 349 | print_list(self.compute.servers.list(), ['ID', 'Name', 'Status', 'Public IP', 'Private IP']) 350 | 351 | @arg('--hard', 352 | dest = 'reboot_type', 353 | action = 'store_const', 354 | const = compute.REBOOT_HARD, 355 | default = compute.REBOOT_SOFT, 356 | help = 'Perform a hard reboot (instead of a soft one).') 357 | @arg('server', metavar='', help='Name or ID of server.') 358 | def do_reboot(self, args): 359 | """Reboot a server.""" 360 | self._find_server(args.server).reboot(args.reboot_type) 361 | 362 | @arg('server', metavar='', help='Name or ID of server.') 363 | @arg('image', metavar='', help="Name or ID of new image.") 364 | def do_rebuild(self, args): 365 | """Shutdown, re-image, and re-boot a server.""" 366 | server = self._find_server(args.server) 367 | image = self._find_image(args.image) 368 | server.rebuild(image) 369 | 370 | @arg('server', metavar='', help='Name (old name) or ID of server.') 371 | @arg('name', metavar='', help='New name for the server.') 372 | def do_rename(self, args): 373 | """Rename a server.""" 374 | self._find_server(args.server).update(name=args.name) 375 | 376 | @arg('server', metavar='', help='Name or ID of server.') 377 | @arg('flavor', metavar='', help = "Name or ID of new flavor.") 378 | def do_resize(self, args): 379 | """Resize a server.""" 380 | server = self._find_server(args.server) 381 | flavor = self._find_flavor(args.flavor) 382 | server.resize(flavor) 383 | 384 | @arg('server', metavar='', help='Name or ID of server.') 385 | def do_resize_confirm(self, args): 386 | """Confirm a previous resize.""" 387 | self._find_server(args.server).confirm_resize() 388 | 389 | @arg('server', metavar='', help='Name or ID of server.') 390 | def do_resize_revert(self, args): 391 | """Revert a previous resize (and return to the previous VM).""" 392 | self._find_server(args.server).revert_resize() 393 | 394 | @arg('server', metavar='', help='Name or ID of server.') 395 | def do_root_password(self, args): 396 | """ 397 | Change the root password for a server. 398 | """ 399 | server = self._find_server(args.server) 400 | p1 = getpass.getpass('New password: ') 401 | p2 = getpass.getpass('Again: ') 402 | if p1 != p2: 403 | raise CommandError("Passwords do not match.") 404 | server.update(password=p1) 405 | 406 | @arg('server', metavar='', help='Name or ID of server.') 407 | def do_show(self, args): 408 | """Show details about the given server.""" 409 | s = self.compute.servers.get(self._find_server(args.server)) 410 | 411 | info = s._info.copy() 412 | addresses = info.pop('addresses') 413 | for addrtype in addresses: 414 | info['%s ip' % addrtype] = ', '.join(addresses[addrtype]) 415 | 416 | info['flavor'] = self._find_flavor(info.pop('flavorId')).name 417 | info['image'] = self._find_image(info.pop('imageId')).name 418 | 419 | print_dict(info) 420 | 421 | @arg('server', metavar='', help='Name or ID of server.') 422 | def do_delete(self, args): 423 | """Immediately shut down and delete a server.""" 424 | self._find_server(args.server).delete() 425 | 426 | def _find_server(self, server): 427 | """Get a server by name or ID.""" 428 | return self._find_resource(self.compute.servers, server) 429 | 430 | def _find_ipgroup(self, group): 431 | """Get an IP group by name or ID.""" 432 | return self._find_resource(self.compute.ipgroups, group) 433 | 434 | def _find_image(self, image): 435 | """Get an image by name or ID.""" 436 | return self._find_resource(self.compute.images, image) 437 | 438 | def _find_flavor(self, flavor): 439 | """Get a flavor by name, ID, or RAM size.""" 440 | try: 441 | return self._find_resource(self.compute.flavors, flavor) 442 | except compute.NotFound: 443 | return self.compute.flavors.find(ram=flavor) 444 | 445 | def _find_resource(self, manager, name_or_id): 446 | """Helper for the _find_* methods.""" 447 | try: 448 | if isinstance(name_or_id, int) or name_or_id.isdigit(): 449 | return manager.get(int(name_or_id)) 450 | else: 451 | return manager.find(name=name_or_id) 452 | except compute.NotFound: 453 | raise CommandError("No %s with a name or ID of '%s' exists." 454 | % (manager.resource_class.__name__.lower(), name_or_id)) 455 | 456 | # I'm picky about my shell help. 457 | class ComputeHelpFormatter(argparse.HelpFormatter): 458 | def start_section(self, heading): 459 | # Title-case the headings 460 | heading = '%s%s' % (heading[0].upper(), heading[1:]) 461 | super(ComputeHelpFormatter, self).start_section(heading) 462 | 463 | # Helpers 464 | def print_list(objs, fields, formatters={}): 465 | pt = prettytable.PrettyTable([f for f in fields], caching=False) 466 | pt.aligns = ['l' for f in fields] 467 | 468 | for o in objs: 469 | row = [] 470 | for field in fields: 471 | if field in formatters: 472 | row.append(formatters[field](o)) 473 | else: 474 | row.append(getattr(o, field.lower().replace(' ', '_'), '')) 475 | pt.add_row(row) 476 | 477 | pt.printt(sortby=fields[0]) 478 | 479 | def print_dict(d): 480 | pt = prettytable.PrettyTable(['Property', 'Value'], caching=False) 481 | pt.aligns = ['l', 'l'] 482 | [pt.add_row(list(r)) for r in d.iteritems()] 483 | pt.printt(sortby='Property') 484 | 485 | def main(): 486 | try: 487 | ComputeShell().main(sys.argv[1:]) 488 | except CommandError, e: 489 | print >> sys.stderr, e 490 | sys.exit(1) -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [nosetests] 2 | with-coverage = true 3 | cover-package = openstack.compute 4 | cover-html = true 5 | cover-erase = true 6 | cover-inclusive = true 7 | 8 | [build_sphinx] 9 | source-dir = docs/ 10 | build-dir = docs/_build 11 | all_files = 1 12 | 13 | [upload_sphinx] 14 | upload-dir = docs/_build/html -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from setuptools import setup, find_packages 4 | 5 | def read(fname): 6 | return open(os.path.join(os.path.dirname(__file__), fname)).read() 7 | 8 | requirements = ['httplib2', 'argparse', 'prettytable'] 9 | if sys.version_info < (2,6): 10 | requirements.append('simplejson') 11 | 12 | setup( 13 | name = "openstack.compute", 14 | version = "2.0a1", 15 | description = "Client library for the OpenStack Compute API", 16 | long_description = read('README.rst'), 17 | url = 'http://openstack.compute.rtfd.org/', 18 | license = 'BSD', 19 | author = 'Jacob Kaplan-Moss', 20 | author_email = 'jacob@jacobian.org', 21 | packages = find_packages(exclude=['tests']), 22 | classifiers = [ 23 | 'Development Status :: 5 - Production/Stable', 24 | 'Environment :: Console', 25 | 'Intended Audience :: Developers', 26 | 'Intended Audience :: Information Technology', 27 | 'License :: OSI Approved :: BSD License', 28 | 'Operating System :: OS Independent', 29 | 'Programming Language :: Python', 30 | ], 31 | namespace_packages = ["openstack"], 32 | install_requires = requirements, 33 | 34 | tests_require = ["nose", "mock"], 35 | test_suite = "nose.collector", 36 | 37 | entry_points = { 38 | 'console_scripts': ['openstack-compute = openstack.compute.shell:main'] 39 | } 40 | ) -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobian/openstack.compute/89c5c6a3fcc0864e6de2a8bc10bcaf93b13a259e/tests/__init__.py -------------------------------------------------------------------------------- /tests/fakeserver.py: -------------------------------------------------------------------------------- 1 | """ 2 | A fake server that "responds" to API methods with pre-canned responses. 3 | 4 | All of these responses come from the spec, so if for some reason the spec's 5 | wrong the tests might fail. I've indicated in comments the places where actual 6 | behavior differs from the spec. 7 | """ 8 | 9 | import mock 10 | import httplib2 11 | from nose.tools import assert_equal 12 | from openstack.compute import Compute, Config 13 | from openstack.compute.api import API_OPTIONS 14 | from openstack.compute.client import ComputeClient 15 | from utils import fail, assert_in, assert_not_in, assert_has_keys 16 | 17 | class FakeConfig(object): 18 | username = "username" 19 | apikey = "key" 20 | auth_url = "https://auth.api.rackspacecloud.com/v1.0" 21 | user_agent = 'python-openstack-compute/test' 22 | allow_cache = False 23 | cloud_api = 'RACKSPACE' 24 | #cloud_api = 'OPENSTACK' 25 | 26 | class FakeServer(Compute): 27 | def __init__(self, **kwargs): 28 | super(FakeServer, self).__init__(**kwargs) 29 | self.client = FakeClient() 30 | 31 | def _get_config(self, kwargs): 32 | return FakeConfig() 33 | 34 | def assert_called(self, method, url, body=None): 35 | """ 36 | Assert than an API method was just called. 37 | """ 38 | expected = (method, url) 39 | called = self.client.callstack[-1][0:2] 40 | 41 | assert self.client.callstack, "Expected %s %s but no calls were made." % expected 42 | 43 | assert expected == called, 'Expected %s %s; got %s %s' % (expected + called) 44 | 45 | if body is not None: 46 | assert_equal(self.client.callstack[-1][2], body) 47 | 48 | self.client.callstack = [] 49 | 50 | def authenticate(self): 51 | pass 52 | 53 | class FakeClient(ComputeClient): 54 | def __init__(self): 55 | self.username = 'username' 56 | self.apikey = 'apikey' 57 | self.callstack = [] 58 | self.cloud_api = 'RACKSPACE' 59 | #self.cloud_api = 'OPENSTACK' 60 | 61 | def _cs_request(self, url, method, **kwargs): 62 | # Check that certain things are called correctly 63 | if method in ['GET', 'DELETE']: 64 | assert_not_in('body', kwargs) 65 | elif method in ['PUT', 'POST']: 66 | assert_in('body', kwargs) 67 | 68 | # Call the method 69 | munged_url = url.strip('/').replace('/', '_').replace('.', '_') 70 | callback = "%s_%s" % (method.lower(), munged_url) 71 | if not hasattr(self, callback): 72 | fail('Called unknown API method: %s %s' % (method, url)) 73 | 74 | # Note the call 75 | self.callstack.append((method, url, kwargs.get('body', None))) 76 | 77 | status, body = getattr(self, callback)(**kwargs) 78 | return httplib2.Response({"status": status}), body 79 | 80 | def _munge_get_url(self, url): 81 | return url 82 | 83 | # 84 | # Limits 85 | # 86 | 87 | def get_limits(self, **kw): 88 | return (200, {"limits" : { 89 | "rate" : [ 90 | { 91 | "verb" : "POST", 92 | "URI" : "*", 93 | "regex" : ".*", 94 | "value" : 10, 95 | "remaining" : 2, 96 | "unit" : "MINUTE", 97 | "resetTime" : 1244425439 98 | }, 99 | { 100 | "verb" : "POST", 101 | "URI" : "*/servers", 102 | "regex" : "^/servers", 103 | "value" : 50, 104 | "remaining" : 49, 105 | "unit" : "DAY", "resetTime" : 1244511839 106 | }, 107 | { 108 | "verb" : "PUT", 109 | "URI" : "*", 110 | "regex" : ".*", 111 | "value" : 10, 112 | "remaining" : 2, 113 | "unit" : "MINUTE", 114 | "resetTime" : 1244425439 115 | }, 116 | { 117 | "verb" : "GET", 118 | "URI" : "*changes-since*", 119 | "regex" : "changes-since", 120 | "value" : 3, 121 | "remaining" : 3, 122 | "unit" : "MINUTE", 123 | "resetTime" : 1244425439 124 | }, 125 | { 126 | "verb" : "DELETE", 127 | "URI" : "*", 128 | "regex" : ".*", 129 | "value" : 100, 130 | "remaining" : 100, 131 | "unit" : "MINUTE", 132 | "resetTime" : 1244425439 133 | } 134 | ], 135 | "absolute" : { 136 | "maxTotalRAMSize" : 51200, 137 | "maxIPGroups" : 50, 138 | "maxIPGroupMembers" : 25 139 | } 140 | }}) 141 | 142 | # 143 | # Servers 144 | # 145 | 146 | def get_servers(self, **kw): 147 | return (200, {"servers": [ 148 | {'id': 1234, 'name': 'sample-server'}, 149 | {'id': 5678, 'name': 'sample-server2'} 150 | ]}) 151 | 152 | def get_servers_detail(self, **kw): 153 | return (200, {"servers" : [ 154 | { 155 | "id" : 1234, 156 | "name" : "sample-server", 157 | "imageId" : 2, 158 | "flavorId" : 1, 159 | "hostId" : "e4d909c290d0fb1ca068ffaddf22cbd0", 160 | "status" : "BUILD", 161 | "progress" : 60, 162 | "addresses" : { 163 | "public" : ["1.2.3.4", "5.6.7.8"], 164 | "private" : ["10.11.12.13"] 165 | }, 166 | "metadata" : { 167 | "Server Label" : "Web Head 1", 168 | "Image Version" : "2.1" 169 | } 170 | }, 171 | { 172 | "id" : 5678, 173 | "name" : "sample-server2", 174 | "imageId" : 2, 175 | "flavorId" : 1, 176 | "hostId" : "9e107d9d372bb6826bd81d3542a419d6", 177 | "status" : "ACTIVE", 178 | "addresses" : { 179 | "public" : ["9.10.11.12"], 180 | "private" : ["10.11.12.14"] 181 | }, 182 | "metadata" : { 183 | "Server Label" : "DB 1" 184 | } 185 | } 186 | ]}) 187 | 188 | def post_servers(self, body, **kw): 189 | assert_equal(body.keys(), ['server']) 190 | assert_has_keys(body['server'], 191 | required = ['name', 'imageId', 'flavorId'], 192 | optional = ['sharedIpGroupId', 'metadata', 'personality']) 193 | if 'personality' in body['server']: 194 | for pfile in body['server']['personality']: 195 | assert_has_keys(pfile, required=['path', 'contents']) 196 | return (202, self.get_servers_1234()[1]) 197 | 198 | def get_servers_1234(self, **kw): 199 | r = {'server': self.get_servers_detail()[1]['servers'][0]} 200 | return (200, r) 201 | 202 | def get_servers_5678(self, **kw): 203 | r = {'server': self.get_servers_detail()[1]['servers'][1]} 204 | return (200, r) 205 | 206 | def put_servers_1234(self, body, **kw): 207 | assert_equal(body.keys(), ['server']) 208 | assert_has_keys(body['server'], optional=['name', 'adminPass']) 209 | return (204, None) 210 | 211 | def delete_servers_1234(self, **kw): 212 | return (202, None) 213 | 214 | # 215 | # Server Addresses 216 | # 217 | 218 | def get_servers_1234_ips(self, **kw): 219 | return (200, {'addresses': self.get_servers_1234()[1]['server']['addresses']}) 220 | 221 | def get_servers_1234_ips_public(self, **kw): 222 | return (200, {'public': self.get_servers_1234_ips()[1]['addresses']['public']}) 223 | 224 | def get_servers_1234_ips_private(self, **kw): 225 | return (200, {'private': self.get_servers_1234_ips()[1]['addresses']['private']}) 226 | 227 | def put_servers_1234_ips_public_1_2_3_4(self, body, **kw): 228 | assert_equal(body.keys(), ['shareIp']) 229 | assert_has_keys(body['shareIp'], required=['sharedIpGroupId', 'configureServer']) 230 | return (202, None) 231 | 232 | def delete_servers_1234_ips_public_1_2_3_4(self, **kw): 233 | return (202, None) 234 | 235 | # 236 | # Server actions 237 | # 238 | 239 | def post_servers_1234_action(self, body, **kw): 240 | assert_equal(len(body.keys()), 1) 241 | action = body.keys()[0] 242 | if action == 'reboot': 243 | assert_equal(body[action].keys(), ['type']) 244 | assert_in(body[action]['type'], ['HARD', 'SOFT']) 245 | elif action == 'rebuild': 246 | assert_equal(body[action].keys(), ['imageId']) 247 | elif action == 'resize': 248 | assert_equal(body[action].keys(), ['flavorId']) 249 | elif action == 'confirmResize': 250 | assert_equal(body[action], None) 251 | # This one method returns a different response code 252 | return (204, None) 253 | elif action == 'revertResize': 254 | assert_equal(body[action], None) 255 | else: 256 | fail("Unexpected server action: %s" % action) 257 | return (202, None) 258 | 259 | # 260 | # Flavors 261 | # 262 | 263 | def get_flavors(self, **kw): 264 | return (200, {'flavors': [ 265 | {'id': 1, 'name': '256 MB Server'}, 266 | {'id': 2, 'name': '512 MB Server'} 267 | ]}) 268 | 269 | def get_flavors_detail(self, **kw): 270 | return (200, {'flavors': [ 271 | {'id': 1, 'name': '256 MB Server', 'ram': 256, 'disk': 10}, 272 | {'id': 2, 'name': '512 MB Server', 'ram': 512, 'disk': 20} 273 | ]}) 274 | 275 | def get_flavors_1(self, **kw): 276 | return (200, {'flavor': self.get_flavors_detail()[1]['flavors'][0]}) 277 | 278 | def get_flavors_2(self, **kw): 279 | return (200, {'flavor': self.get_flavors_detail()[1]['flavors'][1]}) 280 | 281 | # 282 | # Images 283 | # 284 | def get_images(self, **kw): 285 | return (200, {'images': [ 286 | {'id': 1, 'name': 'CentOS 5.2'}, 287 | {'id': 2, 'name': 'My Server Backup'} 288 | ]}) 289 | 290 | def get_images_detail(self, **kw): 291 | return (200, {'images': [ 292 | { 293 | 'id': 1, 294 | 'name': 'CentOS 5.2', 295 | "updated" : "2010-10-10T12:00:00Z", 296 | "created" : "2010-08-10T12:00:00Z", 297 | "status" : "ACTIVE" 298 | }, 299 | { 300 | "id" : 743, 301 | "name" : "My Server Backup", 302 | "serverId" : 12, 303 | "updated" : "2010-10-10T12:00:00Z", 304 | "created" : "2010-08-10T12:00:00Z", 305 | "status" : "SAVING", 306 | "progress" : 80 307 | } 308 | ]}) 309 | 310 | def get_images_1(self, **kw): 311 | return (200, {'image': self.get_images_detail()[1]['images'][0]}) 312 | 313 | def get_images_2(self, **kw): 314 | return (200, {'image': self.get_images_detail()[1]['images'][1]}) 315 | 316 | def post_images(self, body, **kw): 317 | assert_equal(body.keys(), ['image']) 318 | assert_has_keys(body['image'], required=['serverId', 'name']) 319 | return (202, self.get_images_1()[1]) 320 | 321 | def delete_images_1(self, **kw): 322 | return (204, None) 323 | 324 | # 325 | # Backup schedules 326 | # 327 | def get_servers_1234_backup_schedule(self, **kw): 328 | return (200, {"backupSchedule" : { 329 | "enabled" : True, 330 | "weekly" : "THURSDAY", 331 | "daily" : "H_0400_0600" 332 | }}) 333 | 334 | def post_servers_1234_backup_schedule(self, body, **kw): 335 | assert_equal(body.keys(), ['backupSchedule']) 336 | assert_has_keys(body['backupSchedule'], required=['enabled'], optional=['weekly', 'daily']) 337 | return (204, None) 338 | 339 | def delete_servers_1234_backup_schedule(self, **kw): 340 | return (204, None) 341 | 342 | # 343 | # Shared IP groups 344 | # 345 | def get_shared_ip_groups(self, **kw): 346 | if 'IPGROUPS' in API_OPTIONS[self.cloud_api]: 347 | return (200, {'sharedIpGroups': [ 348 | {'id': 1, 'name': 'group1'}, 349 | {'id': 2, 'name': 'group2'}, 350 | ]}) 351 | else: 352 | return (501, {u'notImplemented': {u'message': u'The server has either erred or is incapable of performing\r\nthe requested operation.\r\n', u'code': 501}}) 353 | 354 | def get_shared_ip_groups_detail(self, **kw): 355 | if 'IPGROUPS' in API_OPTIONS[self.cloud_api]: 356 | return (200, {'sharedIpGroups': [ 357 | {'id': 1, 'name': 'group1', 'servers': [1234]}, 358 | {'id': 2, 'name': 'group2', 'servers': [5678]}, 359 | ]}) 360 | else: 361 | return (501, {u'notImplemented': {u'message': u'The server has either erred or is incapable of performing\r\nthe requested operation.\r\n', u'code': 501}}) 362 | 363 | def get_shared_ip_groups_1(self, **kw): 364 | if 'IPGROUPS' in API_OPTIONS[self.cloud_api]: 365 | return (200, {'sharedIpGroup': self.get_shared_ip_groups_detail()[1]['sharedIpGroups'][0]}) 366 | else: 367 | return (501, {u'notImplemented': {u'message': u'The server has either erred or is incapable of performing\r\nthe requested operation.\r\n', u'code': 501}}) 368 | 369 | 370 | def post_shared_ip_groups(self, body, **kw): 371 | assert_equal(body.keys(), ['sharedIpGroup']) 372 | assert_has_keys(body['sharedIpGroup'], required=['name'], optional=['server']) 373 | if 'IPGROUPS' in API_OPTIONS[self.cloud_api]: 374 | return (201, {'sharedIpGroup': { 375 | 'id': 10101, 376 | 'name': body['sharedIpGroup']['name'], 377 | 'servers': 'server' in body['sharedIpGroup'] and [body['sharedIpGroup']['server']] or None 378 | }}) 379 | else: 380 | return (501, {u'notImplemented': {u'message': u'The server has either erred or is incapable of performing\r\nthe requested operation.\r\n', u'code': 501}}) 381 | 382 | def delete_shared_ip_groups_1(self, **kw): 383 | return (204, None) 384 | return (501, {u'notImplemented': {u'message': u'The server has either erred or is incapable of performing\r\nthe requested operation.\r\n', u'code': 501}}) 385 | -------------------------------------------------------------------------------- /tests/test_auth.py: -------------------------------------------------------------------------------- 1 | import mock 2 | import httplib2 3 | from nose.tools import assert_raises, assert_equal 4 | from openstack import compute 5 | 6 | def test_authenticate_success(): 7 | cs = compute.Compute(username="username", apikey="apikey") 8 | auth_response = httplib2.Response({ 9 | 'status': 204, 10 | 'x-server-management-url': 'https://servers.api.rackspacecloud.com/v1.0/443470', 11 | 'x-auth-token': '1b751d74-de0c-46ae-84f0-915744b582d1', 12 | }) 13 | mock_request = mock.Mock(return_value=(auth_response, None)) 14 | 15 | @mock.patch.object(httplib2.Http, "request", mock_request) 16 | def test_auth_call(): 17 | cs.client.authenticate() 18 | mock_request.assert_called_with(cs.config.auth_url, 'GET', 19 | headers = { 20 | 'X-Auth-User': 'username', 21 | 'X-Auth-Key': 'apikey', 22 | 'User-Agent': cs.config.user_agent 23 | }) 24 | assert_equal(cs.client.management_url, auth_response['x-server-management-url']) 25 | assert_equal(cs.client.auth_token, auth_response['x-auth-token']) 26 | 27 | test_auth_call() 28 | 29 | def test_authenticate_failure(): 30 | cs = compute.Compute(username="username", apikey="apikey") 31 | auth_response = httplib2.Response({'status': 401}) 32 | mock_request = mock.Mock(return_value=(auth_response, None)) 33 | 34 | @mock.patch.object(httplib2.Http, "request", mock_request) 35 | def test_auth_call(): 36 | assert_raises(compute.Unauthorized, cs.client.authenticate) 37 | 38 | test_auth_call() 39 | 40 | def test_auth_automatic(): 41 | client = compute.Compute(username="username", apikey="apikey").client 42 | client.management_url = '' 43 | mock_request = mock.Mock(return_value=(None, None)) 44 | 45 | @mock.patch.object(client, 'request', mock_request) 46 | @mock.patch.object(client, 'authenticate') 47 | def test_auth_call(m): 48 | client.get('/') 49 | m.assert_called() 50 | mock_request.assert_called() 51 | 52 | test_auth_call() 53 | 54 | def test_auth_manual(): 55 | cs = compute.Compute(username="username", apikey="apikey") 56 | 57 | @mock.patch.object(cs.client, 'authenticate') 58 | def test_auth_call(m): 59 | cs.authenticate() 60 | m.assert_called() 61 | 62 | test_auth_call() -------------------------------------------------------------------------------- /tests/test_backup_schedules.py: -------------------------------------------------------------------------------- 1 | 2 | from openstack.compute.backup_schedules import * 3 | from fakeserver import FakeServer 4 | from utils import assert_isinstance 5 | 6 | cs = FakeServer() 7 | 8 | def test_get_backup_schedule(): 9 | s = cs.servers.get(1234) 10 | 11 | # access via manager 12 | b = cs.backup_schedules.get(server=s) 13 | assert_isinstance(b, BackupSchedule) 14 | cs.assert_called('GET', '/servers/1234/backup_schedule') 15 | 16 | b = cs.backup_schedules.get(server=1234) 17 | assert_isinstance(b, BackupSchedule) 18 | cs.assert_called('GET', '/servers/1234/backup_schedule') 19 | 20 | # access via instance 21 | assert_isinstance(s.backup_schedule, BackupSchedule) 22 | cs.assert_called('GET', '/servers/1234/backup_schedule') 23 | 24 | # Just for coverage's sake 25 | b = s.backup_schedule.get() 26 | cs.assert_called('GET', '/servers/1234/backup_schedule') 27 | 28 | def test_create_update_backup_schedule(): 29 | s = cs.servers.get(1234) 30 | 31 | # create/update via manager 32 | cs.backup_schedules.update( 33 | server = s, 34 | enabled = True, 35 | weekly = BACKUP_WEEKLY_THURSDAY, 36 | daily = BACKUP_DAILY_H_1000_1200 37 | ) 38 | cs.assert_called('POST', '/servers/1234/backup_schedule') 39 | 40 | # and via instance 41 | s.backup_schedule.update(enabled=False) 42 | cs.assert_called('POST', '/servers/1234/backup_schedule') 43 | 44 | def test_delete_backup_schedule(): 45 | s = cs.servers.get(1234) 46 | 47 | # delete via manager 48 | cs.backup_schedules.delete(s) 49 | cs.assert_called('DELETE', '/servers/1234/backup_schedule') 50 | cs.backup_schedules.delete(1234) 51 | cs.assert_called('DELETE', '/servers/1234/backup_schedule') 52 | 53 | # and via instance 54 | s.backup_schedule.delete() 55 | cs.assert_called('DELETE', '/servers/1234/backup_schedule') 56 | -------------------------------------------------------------------------------- /tests/test_base.py: -------------------------------------------------------------------------------- 1 | 2 | import mock 3 | import openstack.compute.base 4 | from openstack.compute import Flavor 5 | from openstack.compute.exceptions import NotFound 6 | from openstack.compute.base import Resource 7 | from nose.tools import assert_equal, assert_not_equal, assert_raises 8 | from fakeserver import FakeServer 9 | 10 | cs = FakeServer() 11 | 12 | def test_resource_repr(): 13 | r = Resource(None, dict(foo="bar", baz="spam")) 14 | assert_equal(repr(r), "") 15 | 16 | def test_getid(): 17 | assert_equal(openstack.compute.base.getid(4), 4) 18 | class O(object): 19 | id = 4 20 | assert_equal(openstack.compute.base.getid(O), 4) 21 | 22 | def test_resource_lazy_getattr(): 23 | f = Flavor(cs.flavors, {'id': 1}) 24 | assert_equal(f.name, '256 MB Server') 25 | cs.assert_called('GET', '/flavors/1') 26 | 27 | # Missing stuff still fails after a second get 28 | assert_raises(AttributeError, getattr, f, 'blahblah') 29 | cs.assert_called('GET', '/flavors/1') 30 | 31 | def test_eq(): 32 | # Two resources of the same type with the same id: equal 33 | r1 = Resource(None, {'id':1, 'name':'hi'}) 34 | r2 = Resource(None, {'id':1, 'name':'hello'}) 35 | assert_equal(r1, r2) 36 | 37 | # Two resoruces of different types: never equal 38 | r1 = Resource(None, {'id': 1}) 39 | r2 = Flavor(None, {'id': 1}) 40 | assert_not_equal(r1, r2) 41 | 42 | # Two resources with no ID: equal if their info is equal 43 | r1 = Resource(None, {'name': 'joe', 'age': 12}) 44 | r2 = Resource(None, {'name': 'joe', 'age': 12}) 45 | assert_equal(r1, r2) 46 | 47 | def test_findall_invalid_attribute(): 48 | # Make sure findall with an invalid attribute doesn't cause errors. 49 | # The following should not raise an exception. 50 | cs.flavors.findall(vegetable='carrot') 51 | 52 | # However, find() should raise an error 53 | assert_raises(NotFound, cs.flavors.find, vegetable='carrot') -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | import mock 2 | import httplib2 3 | from openstack.compute.client import ComputeClient 4 | from nose.tools import assert_equal 5 | from fakeserver import FakeConfig 6 | 7 | fake_response = httplib2.Response({"status": 200}) 8 | fake_body = '{"hi": "there"}' 9 | mock_request = mock.Mock(return_value=(fake_response, fake_body)) 10 | 11 | def client(): 12 | cl = ComputeClient(FakeConfig()) 13 | cl.management_url = "http://example.com" 14 | cl.auth_token = "token" 15 | return cl 16 | 17 | def test_get(): 18 | cl = client() 19 | 20 | @mock.patch.object(httplib2.Http, "request", mock_request) 21 | @mock.patch('time.time', mock.Mock(return_value=1234)) 22 | def test_get_call(): 23 | resp, body = cl.get("/hi") 24 | mock_request.assert_called_with("http://example.com/hi?fresh=1234", "GET", 25 | headers={"X-Auth-Token": "token", "User-Agent": cl.config.user_agent}) 26 | # Automatic JSON parsing 27 | assert_equal(body, {"hi":"there"}) 28 | 29 | test_get_call() 30 | 31 | def test_get_allow_cache(): 32 | cl = client() 33 | cl.config.allow_cache = True 34 | 35 | @mock.patch.object(httplib2.Http, "request", mock_request) 36 | def test_get_call(): 37 | resp, body = cl.get("/hi") 38 | # No ?fresh because we're allowing caching. 39 | mock_request.assert_called_with("http://example.com/hi", "GET", 40 | headers={"X-Auth-Token": "token", "User-Agent": cl.config.user_agent}) 41 | 42 | test_get_call() 43 | 44 | def test_post(): 45 | cl = client() 46 | 47 | @mock.patch.object(httplib2.Http, "request", mock_request) 48 | def test_post_call(): 49 | cl.post("/hi", body=[1, 2, 3]) 50 | mock_request.assert_called_with("http://example.com/hi", "POST", 51 | headers = { 52 | "X-Auth-Token": "token", 53 | "Content-Type": "application/json", 54 | "User-Agent": cl.config.user_agent}, 55 | body = '[1, 2, 3]' 56 | ) 57 | 58 | test_post_call() -------------------------------------------------------------------------------- /tests/test_flavors.py: -------------------------------------------------------------------------------- 1 | from openstack.compute import Flavor, NotFound 2 | from fakeserver import FakeServer 3 | from utils import assert_isinstance 4 | from nose.tools import assert_raises, assert_equal 5 | 6 | cs = FakeServer() 7 | 8 | def test_list_flavors(): 9 | fl = cs.flavors.list() 10 | cs.assert_called('GET', '/flavors/detail') 11 | [assert_isinstance(f, Flavor) for f in fl] 12 | 13 | def test_get_flavor_details(): 14 | f = cs.flavors.get(1) 15 | cs.assert_called('GET', '/flavors/1') 16 | assert_isinstance(f, Flavor) 17 | assert_equal(f.ram, 256) 18 | assert_equal(f.disk, 10) 19 | 20 | def test_find(): 21 | f = cs.flavors.find(ram=256) 22 | cs.assert_called('GET', '/flavors/detail') 23 | assert_equal(f.name, '256 MB Server') 24 | 25 | f = cs.flavors.find(disk=20) 26 | assert_equal(f.name, '512 MB Server') 27 | 28 | assert_raises(NotFound, cs.flavors.find, disk=12345) -------------------------------------------------------------------------------- /tests/test_images.py: -------------------------------------------------------------------------------- 1 | from openstack.compute import Image 2 | from fakeserver import FakeServer 3 | from utils import assert_isinstance 4 | from nose.tools import assert_equal 5 | 6 | cs = FakeServer() 7 | 8 | def test_list_images(): 9 | il = cs.images.list() 10 | cs.assert_called('GET', '/images/detail') 11 | [assert_isinstance(i, Image) for i in il] 12 | 13 | def test_get_image_details(): 14 | i = cs.images.get(1) 15 | cs.assert_called('GET', '/images/1') 16 | assert_isinstance(i, Image) 17 | assert_equal(i.id, 1) 18 | assert_equal(i.name, 'CentOS 5.2') 19 | 20 | def test_create_image(): 21 | i = cs.images.create(server=1234, name="Just in case") 22 | cs.assert_called('POST', '/images') 23 | assert_isinstance(i, Image) 24 | 25 | def test_delete_image(): 26 | cs.images.delete(1) 27 | cs.assert_called('DELETE', '/images/1') 28 | 29 | def test_find(): 30 | i = cs.images.find(name="CentOS 5.2") 31 | assert_equal(i.id, 1) 32 | cs.assert_called('GET', '/images/detail') 33 | 34 | iml = cs.images.findall(status='SAVING') 35 | assert_equal(len(iml), 1) 36 | assert_equal(iml[0].name, 'My Server Backup') -------------------------------------------------------------------------------- /tests/test_ipgroups.py: -------------------------------------------------------------------------------- 1 | from openstack.compute import IPGroup 2 | from fakeserver import FakeServer 3 | from utils import assert_isinstance 4 | from nose.tools import assert_equal 5 | 6 | cs = FakeServer() 7 | 8 | def test_list_ipgroups(): 9 | ipl = cs.ipgroups.list() 10 | cs.assert_called('GET', '/shared_ip_groups/detail') 11 | [assert_isinstance(ipg, IPGroup) for ipg in ipl] 12 | 13 | def test_get_ipgroup(): 14 | ipg = cs.ipgroups.get(1) 15 | cs.assert_called('GET', '/shared_ip_groups/1') 16 | assert_isinstance(ipg, IPGroup) 17 | 18 | def test_create_ipgroup(): 19 | ipg = cs.ipgroups.create("My group", 1234) 20 | cs.assert_called('POST', '/shared_ip_groups') 21 | assert_isinstance(ipg, IPGroup) 22 | 23 | def test_delete_ipgroup(): 24 | ipg = cs.ipgroups.get(1) 25 | ipg.delete() 26 | cs.assert_called('DELETE', '/shared_ip_groups/1') 27 | cs.ipgroups.delete(ipg) 28 | cs.assert_called('DELETE', '/shared_ip_groups/1') 29 | cs.ipgroups.delete(1) 30 | cs.assert_called('DELETE', '/shared_ip_groups/1') 31 | 32 | def test_find(): 33 | ipg = cs.ipgroups.find(name='group1') 34 | cs.assert_called('GET', '/shared_ip_groups/detail') 35 | assert_equal(ipg.name, 'group1') 36 | ipgl = cs.ipgroups.findall(id=1) 37 | assert_equal(ipgl, [IPGroup(None, {'id': 1})]) -------------------------------------------------------------------------------- /tests/test_servers.py: -------------------------------------------------------------------------------- 1 | import StringIO 2 | from nose.tools import assert_equal 3 | from fakeserver import FakeServer 4 | from utils import assert_isinstance 5 | from openstack.compute import Server 6 | 7 | cs = FakeServer() 8 | 9 | def test_list_servers(): 10 | sl = cs.servers.list() 11 | cs.assert_called('GET', '/servers/detail') 12 | [assert_isinstance(s, Server) for s in sl] 13 | 14 | def test_get_server_details(): 15 | s = cs.servers.get(1234) 16 | cs.assert_called('GET', '/servers/1234') 17 | assert_isinstance(s, Server) 18 | assert_equal(s.id, 1234) 19 | assert_equal(s.status, 'BUILD') 20 | 21 | def test_create_server(): 22 | s = cs.servers.create( 23 | name = "My server", 24 | image = 1, 25 | flavor = 1, 26 | meta = {'foo': 'bar'}, 27 | ipgroup = 1, 28 | files = { 29 | '/etc/passwd': 'some data', # a file 30 | '/tmp/foo.txt': StringIO.StringIO('data') # a stream 31 | } 32 | ) 33 | cs.assert_called('POST', '/servers') 34 | assert_isinstance(s, Server) 35 | 36 | def test_update_server(): 37 | s = cs.servers.get(1234) 38 | 39 | # Update via instance 40 | s.update(name='hi') 41 | cs.assert_called('PUT', '/servers/1234') 42 | s.update(name='hi', password='there') 43 | cs.assert_called('PUT', '/servers/1234') 44 | 45 | # Silly, but not an error 46 | s.update() 47 | 48 | # Update via manager 49 | cs.servers.update(s, name='hi') 50 | cs.assert_called('PUT', '/servers/1234') 51 | cs.servers.update(1234, password='there') 52 | cs.assert_called('PUT', '/servers/1234') 53 | cs.servers.update(s, name='hi', password='there') 54 | cs.assert_called('PUT', '/servers/1234') 55 | 56 | def test_delete_server(): 57 | s = cs.servers.get(1234) 58 | s.delete() 59 | cs.assert_called('DELETE', '/servers/1234') 60 | cs.servers.delete(1234) 61 | cs.assert_called('DELETE', '/servers/1234') 62 | cs.servers.delete(s) 63 | cs.assert_called('DELETE', '/servers/1234') 64 | 65 | def test_find(): 66 | s = cs.servers.find(name='sample-server') 67 | cs.assert_called('GET', '/servers/detail') 68 | assert_equal(s.name, 'sample-server') 69 | 70 | # Find with multiple results arbitraility returns the first item 71 | s = cs.servers.find(flavorId=1) 72 | sl = cs.servers.findall(flavorId=1) 73 | assert_equal(sl[0], s) 74 | assert_equal([s.id for s in sl], [1234, 5678]) 75 | 76 | def test_share_ip(): 77 | s = cs.servers.get(1234) 78 | 79 | # Share via instance 80 | s.share_ip(ipgroup=1, address='1.2.3.4') 81 | cs.assert_called('PUT', '/servers/1234/ips/public/1.2.3.4') 82 | 83 | # Share via manager 84 | cs.servers.share_ip(s, ipgroup=1, address='1.2.3.4', configure=False) 85 | cs.assert_called('PUT', '/servers/1234/ips/public/1.2.3.4') 86 | 87 | def test_unshare_ip(): 88 | s = cs.servers.get(1234) 89 | 90 | # Unshare via instance 91 | s.unshare_ip('1.2.3.4') 92 | cs.assert_called('DELETE', '/servers/1234/ips/public/1.2.3.4') 93 | 94 | # Unshare via manager 95 | cs.servers.unshare_ip(s, '1.2.3.4') 96 | cs.assert_called('DELETE', '/servers/1234/ips/public/1.2.3.4') 97 | 98 | def test_reboot_server(): 99 | s = cs.servers.get(1234) 100 | s.reboot() 101 | cs.assert_called('POST', '/servers/1234/action') 102 | cs.servers.reboot(s, type='HARD') 103 | cs.assert_called('POST', '/servers/1234/action') 104 | 105 | def test_rebuild_server(): 106 | s = cs.servers.get(1234) 107 | s.rebuild(image=1) 108 | cs.assert_called('POST', '/servers/1234/action') 109 | cs.servers.rebuild(s, image=1) 110 | cs.assert_called('POST', '/servers/1234/action') 111 | 112 | def test_resize_server(): 113 | s = cs.servers.get(1234) 114 | s.resize(flavor=1) 115 | cs.assert_called('POST', '/servers/1234/action') 116 | cs.servers.resize(s, flavor=1) 117 | cs.assert_called('POST', '/servers/1234/action') 118 | 119 | def test_confirm_resized_server(): 120 | s = cs.servers.get(1234) 121 | s.confirm_resize() 122 | cs.assert_called('POST', '/servers/1234/action') 123 | cs.servers.confirm_resize(s) 124 | cs.assert_called('POST', '/servers/1234/action') 125 | 126 | def test_revert_resized_server(): 127 | s = cs.servers.get(1234) 128 | s.revert_resize() 129 | cs.assert_called('POST', '/servers/1234/action') 130 | cs.servers.revert_resize(s) 131 | cs.assert_called('POST', '/servers/1234/action') -------------------------------------------------------------------------------- /tests/test_shell.py: -------------------------------------------------------------------------------- 1 | import os 2 | import mock 3 | import httplib2 4 | from nose.tools import assert_raises, assert_equal 5 | from openstack.compute.shell import ComputeShell, CommandError 6 | from fakeserver import FakeServer 7 | from utils import assert_in 8 | 9 | # Patch os.environ to avoid required auth info. 10 | def setup(): 11 | global _old_env 12 | fake_env = { 13 | 'OPENSTACK_COMPUTE_USERNAME': 'username', 14 | 'OPENSTACK_COMPUTE_APIKEY': 'password' 15 | } 16 | _old_env, os.environ = os.environ, fake_env.copy() 17 | 18 | # Make a fake shell object, a helping wrapper to call it, and a quick way 19 | # of asserting that certain API calls were made. 20 | global shell, _shell, assert_called 21 | _shell = ComputeShell() 22 | _shell._api_class = FakeServer 23 | assert_called = lambda m, u, b=None: _shell.compute.assert_called(m, u, b) 24 | shell = lambda cmd: _shell.main(cmd.split()) 25 | 26 | def teardown(): 27 | global _old_env 28 | os.environ = _old_env 29 | 30 | def test_backup_schedule(): 31 | shell('backup-schedule 1234') 32 | assert_called('GET', '/servers/1234/backup_schedule') 33 | 34 | shell('backup-schedule sample-server --weekly monday') 35 | assert_called( 36 | 'POST', '/servers/1234/backup_schedule', 37 | {'backupSchedule': {'enabled': True, 'daily': 'DISABLED', 38 | 'weekly': 'MONDAY'}} 39 | ) 40 | 41 | shell('backup-schedule sample-server --weekly disabled --daily h_0000_0200') 42 | assert_called( 43 | 'POST', '/servers/1234/backup_schedule', 44 | {'backupSchedule': {'enabled': True, 'daily': 'H_0000_0200', 45 | 'weekly': 'DISABLED'}} 46 | ) 47 | 48 | shell('backup-schedule sample-server --disable') 49 | assert_called( 50 | 'POST', '/servers/1234/backup_schedule', 51 | {'backupSchedule': {'enabled': False, 'daily': 'DISABLED', 52 | 'weekly': 'DISABLED'}} 53 | ) 54 | 55 | def test_backup_schedule_delete(): 56 | shell('backup-schedule-delete 1234') 57 | assert_called('DELETE', '/servers/1234/backup_schedule') 58 | 59 | def test_boot(): 60 | shell('boot --image 1 some-server') 61 | assert_called( 62 | 'POST', '/servers', 63 | {'server': {'flavorId': 1, 'name': 'some-server', 'imageId': 1}} 64 | ) 65 | 66 | shell('boot --image 1 --meta foo=bar --meta spam=eggs some-server ') 67 | assert_called( 68 | 'POST', '/servers', 69 | {'server': {'flavorId': 1, 'name': 'some-server', 'imageId': 1, 70 | 'metadata': {'foo': 'bar', 'spam': 'eggs'}}} 71 | ) 72 | 73 | def test_boot_files(): 74 | testfile = os.path.join(os.path.dirname(__file__), 'testfile.txt') 75 | expected_file_data = open(testfile).read().encode('base64') 76 | 77 | shell('boot some-server --image 1 --file /tmp/foo=%s --file /tmp/bar=%s' % (testfile, testfile)) 78 | 79 | assert_called( 80 | 'POST', '/servers', 81 | {'server': {'flavorId': 1, 'name': 'some-server', 'imageId': 1, 82 | 'personality': [ 83 | {'path': '/tmp/bar', 'contents': expected_file_data}, 84 | {'path': '/tmp/foo', 'contents': expected_file_data} 85 | ]} 86 | } 87 | ) 88 | 89 | def test_boot_invalid_file(): 90 | invalid_file = os.path.join(os.path.dirname(__file__), 'asdfasdfasdfasdf') 91 | assert_raises(CommandError, shell, 'boot some-server --image 1 --file /foo=%s' % invalid_file) 92 | 93 | def test_boot_key_auto(): 94 | mock_exists = mock.Mock(return_value=True) 95 | mock_open = mock.Mock() 96 | mock_open.return_value = mock.Mock() 97 | mock_open.return_value.read = mock.Mock(return_value='SSHKEY') 98 | 99 | @mock.patch('os.path.exists', mock_exists) 100 | @mock.patch('__builtin__.open', mock_open) 101 | def test_shell_call(): 102 | shell('boot some-server --image 1 --key') 103 | assert_called( 104 | 'POST', '/servers', 105 | {'server': {'flavorId': 1, 'name': 'some-server', 'imageId': 1, 106 | 'personality': [{ 107 | 'path': '/root/.ssh/authorized_keys2', 108 | 'contents': ('SSHKEY').encode('base64')}, 109 | ]} 110 | } 111 | ) 112 | 113 | test_shell_call() 114 | 115 | def test_boot_key_auto_no_keys(): 116 | mock_exists = mock.Mock(return_value=False) 117 | 118 | @mock.patch('os.path.exists', mock_exists) 119 | def test_shell_call(): 120 | assert_raises(CommandError, shell, 'boot some-server --image 1 --key') 121 | 122 | test_shell_call() 123 | 124 | def test_boot_key_file(): 125 | testfile = os.path.join(os.path.dirname(__file__), 'testfile.txt') 126 | expected_file_data = open(testfile).read().encode('base64') 127 | shell('boot some-server --image 1 --key %s' % testfile) 128 | assert_called( 129 | 'POST', '/servers', 130 | {'server': {'flavorId': 1, 'name': 'some-server', 'imageId': 1, 131 | 'personality': [ 132 | {'path': '/root/.ssh/authorized_keys2', 'contents': expected_file_data}, 133 | ]} 134 | } 135 | ) 136 | 137 | def test_boot_invalid_keyfile(): 138 | invalid_file = os.path.join(os.path.dirname(__file__), 'asdfasdfasdfasdf') 139 | assert_raises(CommandError, shell, 'boot some-server --image 1 --key %s' % invalid_file) 140 | 141 | def test_boot_ipgroup(): 142 | shell('boot --image 1 --ipgroup 1 some-server') 143 | assert_called( 144 | 'POST', '/servers', 145 | {'server': {'flavorId': 1, 'name': 'some-server', 'imageId': 1, 'sharedIpGroupId': 1}} 146 | ) 147 | 148 | def test_boot_ipgroup_name(): 149 | shell('boot --image 1 --ipgroup group1 some-server') 150 | assert_called( 151 | 'POST', '/servers', 152 | {'server': {'flavorId': 1, 'name': 'some-server', 'imageId': 1, 'sharedIpGroupId': 1}} 153 | ) 154 | 155 | def test_flavor_list(): 156 | shell('flavor-list') 157 | assert_called('GET', '/flavors/detail') 158 | 159 | def test_image_list(): 160 | shell('image-list') 161 | assert_called('GET', '/images/detail') 162 | 163 | def test_image_create(): 164 | shell('image-create sample-server new-image') 165 | assert_called( 166 | 'POST', '/images', 167 | {'image': {'name': 'new-image', 'serverId': 1234}} 168 | ) 169 | 170 | def test_image_delete(): 171 | shell('image-delete 1') 172 | assert_called('DELETE', '/images/1') 173 | 174 | def test_ip_share(): 175 | shell('ip-share sample-server 1 1.2.3.4') 176 | assert_called( 177 | 'PUT', '/servers/1234/ips/public/1.2.3.4', 178 | {'shareIp': {'sharedIpGroupId': 1, 'configureServer': True}} 179 | ) 180 | 181 | def test_ip_unshare(): 182 | shell('ip-unshare sample-server 1.2.3.4') 183 | assert_called('DELETE', '/servers/1234/ips/public/1.2.3.4') 184 | 185 | def test_ipgroup_list(): 186 | shell('ipgroup-list') 187 | assert_in(('GET', '/shared_ip_groups/detail', None), _shell.compute.client.callstack) 188 | assert_called('GET', '/servers/5678') 189 | 190 | def test_ipgroup_show(): 191 | shell('ipgroup-show 1') 192 | assert_called('GET', '/shared_ip_groups/1') 193 | shell('ipgroup-show group2') 194 | # does a search, not a direct GET 195 | assert_called('GET', '/shared_ip_groups/detail') 196 | 197 | def test_ipgroup_create(): 198 | shell('ipgroup-create a-group') 199 | assert_called( 200 | 'POST', '/shared_ip_groups', 201 | {'sharedIpGroup': {'name': 'a-group'}} 202 | ) 203 | shell('ipgroup-create a-group sample-server') 204 | assert_called( 205 | 'POST', '/shared_ip_groups', 206 | {'sharedIpGroup': {'name': 'a-group', 'server': 1234}} 207 | ) 208 | 209 | def test_ipgroup_delete(): 210 | shell('ipgroup-delete group1') 211 | assert_called('DELETE', '/shared_ip_groups/1') 212 | 213 | def test_list(): 214 | shell('list') 215 | assert_called('GET', '/servers/detail') 216 | 217 | def test_reboot(): 218 | shell('reboot sample-server') 219 | assert_called('POST', '/servers/1234/action', {'reboot': {'type': 'SOFT'}}) 220 | shell('reboot sample-server --hard') 221 | assert_called('POST', '/servers/1234/action', {'reboot': {'type': 'HARD'}}) 222 | 223 | def test_rebuild(): 224 | shell('rebuild sample-server 1') 225 | assert_called('POST', '/servers/1234/action', {'rebuild': {'imageId': 1}}) 226 | 227 | def test_rename(): 228 | shell('rename sample-server newname') 229 | assert_called('PUT', '/servers/1234', {'server': {'name':'newname'}}) 230 | 231 | def test_resize(): 232 | shell('resize sample-server 1') 233 | assert_called('POST', '/servers/1234/action', {'resize': {'flavorId': 1}}) 234 | 235 | def test_resize_confirm(): 236 | shell('resize-confirm sample-server') 237 | assert_called('POST', '/servers/1234/action', {'confirmResize': None}) 238 | 239 | def test_resize_revert(): 240 | shell('resize-revert sample-server') 241 | assert_called('POST', '/servers/1234/action', {'revertResize': None}) 242 | 243 | @mock.patch('getpass.getpass', mock.Mock(return_value='p')) 244 | def test_root_password(): 245 | shell('root-password sample-server') 246 | assert_called('PUT', '/servers/1234', {'server': {'adminPass':'p'}}) 247 | 248 | def test_show(): 249 | shell('show 1234') 250 | # XXX need a way to test multiple calls 251 | # assert_called('GET', '/servers/1234') 252 | assert_called('GET', '/images/2') 253 | 254 | def test_delete(): 255 | shell('delete 1234') 256 | assert_called('DELETE', '/servers/1234') 257 | shell('delete sample-server') 258 | assert_called('DELETE', '/servers/1234') 259 | 260 | def test_help(): 261 | @mock.patch.object(_shell.parser, 'print_help') 262 | def test_help(m): 263 | shell('help') 264 | m.assert_called() 265 | 266 | @mock.patch.object(_shell.subcommands['delete'], 'print_help') 267 | def test_help_delete(m): 268 | shell('help delete') 269 | m.assert_called() 270 | 271 | test_help() 272 | test_help_delete() 273 | 274 | assert_raises(CommandError, shell, 'help foofoo') 275 | 276 | def test_debug(): 277 | httplib2.debuglevel = 0 278 | shell('--debug list') 279 | assert httplib2.debuglevel == 1 280 | -------------------------------------------------------------------------------- /tests/testfile.txt: -------------------------------------------------------------------------------- 1 | OH HAI! -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from nose.tools import ok_ 2 | 3 | def fail(msg): 4 | raise AssertionError(msg) 5 | 6 | def assert_in(thing, seq, msg=None): 7 | msg = msg or "'%s' not found in %s" % (thing, seq) 8 | ok_(thing in seq, msg) 9 | 10 | def assert_not_in(thing, seq, msg=None): 11 | msg = msg or "unexpected '%s' found in %s" % (thing, seq) 12 | ok_(thing not in seq, msg) 13 | 14 | def assert_has_keys(dict, required=[], optional=[]): 15 | keys = dict.keys() 16 | for k in required: 17 | assert_in(k, keys, "required key %s missing from %s" % (k, dict)) 18 | allowed_keys = set(required) | set(optional) 19 | extra_keys = set(keys).difference(set(required + optional)) 20 | if extra_keys: 21 | fail("found unexpected keys: %s" % list(extra_keys)) 22 | 23 | def assert_isinstance(thing, kls): 24 | ok_(isinstance(thing, kls), "%s is not an instance of %s" % (thing, kls)) -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py24,py25,py26,py27 3 | 4 | [testenv] 5 | deps = nose 6 | mock 7 | commands = nosetests --------------------------------------------------------------------------------