Initial commit from fork

This commit is contained in:
Josh Kearney 2011-01-25 14:01:22 -06:00
commit 7304ed80df
43 changed files with 3790 additions and 0 deletions

27
LICENSE Normal file
View File

@ -0,0 +1,27 @@
Copyright (c) 2009 Jacob Kaplan-Moss
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. Neither the name of this project nor the names of its contributors may
be used to endorse or promote products derived from this software without
specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

3
MANIFEST.in Normal file
View File

@ -0,0 +1,3 @@
include README.rst
recursive-include docs *
recursive-include tests *

135
README.rst Normal file
View File

@ -0,0 +1,135 @@
Python bindings to the Rackspace Cloud Servers API
==================================================
This is a client for Rackspace's Cloud Servers API. There's a Python API (the
``cloudservers`` module), and a command-line script (``cloudservers``). Each
implements 100% of the Rackspace API.
`Full documentation is available`__.
__ http://packages.python.org/python-cloudservers/
You'll also probably want to read `Rackspace's API guide`__ (PDF) -- the first
bit, at least -- to get an idea of the concepts. Rackspace is doing the cloud
hosting thing a bit differently from Amazon, and if you get the concepts this
library should make more sense.
__ http://docs.rackspacecloud.com/servers/api/cs-devguide-latest.pdf
Development takes place on GitHub__. Bug reports and patches may be filed there.
__ http://github.com/jacobian/python-cloudservers
.. contents:: Contents:
:local:
Command-line API
----------------
Installing this package gets you a shell command, ``cloudservers``, that you
can use to interact with any Rackspace compatible API (including OpenStack).
You'll need to provide your Rackspace username and API key. You can do this
with the ``--username`` and ``--apikey`` params, but it's easier to just
set them as environment variables::
export CLOUD_SERVERS_USERNAME=jacobian
export CLOUD_SERVERS_API_KEY=yadayada
If you are using OpenStack or another Rackspace compatible API, you can
optionally define its authentication url with ``--url``. Or set it as
an environment variable as well::
export CLOUD_SERVERS_URL=http://myserver:port/v1.0/
You'll find complete documentation on the shell by running
``cloudservers help``::
usage: cloudservers [--username USERNAME] [--apikey APIKEY]
[--url AUTH_URL] <subcommand> ...
Command-line interface to the Cloud Servers API.
Positional arguments:
<subcommand>
backup-schedule Show or edit the backup schedule for a server.
backup-schedule-delete
Delete the backup schedule for a server.
boot Boot a new server.
delete Immediately shut down and delete a server.
flavor-list Print a list of available 'flavors' (sizes of
servers).
help Display help about this program or one of its
subcommands.
image-create Create a new image by taking a snapshot of a running
server.
image-delete Delete an image.
image-list Print a list of available images to boot from.
ip-share Share an IP address from the given IP group onto a
server.
ip-unshare Stop sharing an given address with a server.
ipgroup-create Create a new IP group.
ipgroup-delete Delete an IP group.
ipgroup-list Show IP groups.
ipgroup-show Show details about a particular IP group.
list List active servers.
reboot Reboot a server.
rebuild Shutdown, re-image, and re-boot a server.
rename Rename a server.
rescue Rescue a server.
resize Resize a server.
resize-confirm Confirm a previous resize.
resize-revert Revert a previous resize (and return to the previous
VM).
root-password Change the root password for a server.
show Show details about the given server.
unrescue Unrescue a server.
Optional arguments:
--username USERNAME Defaults to env[CLOUD_SERVERS_USERNAME].
--apikey APIKEY Defaults to env[CLOUD_SERVERS_API_KEY].
--url AUTH_URL Defaults to env[CLOUD_SERVERS_URL] or
https://auth.api.rackspacecloud.com/v1.0
if undefined.
See "cloudservers help COMMAND" for help on a specific command.
Python API
----------
There's also a `complete Python API`__.
__ http://packages.python.org/python-cloudservers/
By way of a quick-start::
>>> import cloudservers
>>> cs = cloudservers.CloudServers(USERNAME, API_KEY [, AUTH_URL])
>>> cs.flavors.list()
[...]
>>> cs.servers.list()
[...]
>>> s = cs.servers.create(image=2, flavor=1, name='myserver')
... time passes ...
>>> s.reboot()
... time passes ...
>>> s.delete()
FAQ
---
What's wrong with libcloud?
Nothing! However, as a cross-service binding it's by definition lowest
common denominator; I needed access to the Rackspace-specific APIs (shared
IP groups, image snapshots, resizing, etc.). I also wanted a command-line
utility.
What's new?
-----------
See `the release notes <http://packages.python.org/python-cloudservers/releases.html>`_.

62
cloudservers/__init__.py Normal file
View File

@ -0,0 +1,62 @@
__version__ = '1.2'
from cloudservers.backup_schedules import (
BackupSchedule, BackupScheduleManager,
BACKUP_WEEKLY_DISABLED, BACKUP_WEEKLY_SUNDAY, BACKUP_WEEKLY_MONDAY,
BACKUP_WEEKLY_TUESDAY, BACKUP_WEEKLY_WEDNESDAY,
BACKUP_WEEKLY_THURSDAY, BACKUP_WEEKLY_FRIDAY, BACKUP_WEEKLY_SATURDAY,
BACKUP_DAILY_DISABLED, BACKUP_DAILY_H_0000_0200,
BACKUP_DAILY_H_0200_0400, BACKUP_DAILY_H_0400_0600,
BACKUP_DAILY_H_0600_0800, BACKUP_DAILY_H_0800_1000,
BACKUP_DAILY_H_1000_1200, BACKUP_DAILY_H_1200_1400,
BACKUP_DAILY_H_1400_1600, BACKUP_DAILY_H_1600_1800,
BACKUP_DAILY_H_1800_2000, BACKUP_DAILY_H_2000_2200,
BACKUP_DAILY_H_2200_0000)
from cloudservers.client import CloudServersClient
from cloudservers.exceptions import (CloudServersException, BadRequest,
Unauthorized, Forbidden, NotFound, OverLimit)
from cloudservers.flavors import FlavorManager, Flavor
from cloudservers.images import ImageManager, Image
from cloudservers.ipgroups import IPGroupManager, IPGroup
from cloudservers.servers import (ServerManager, Server, REBOOT_HARD,
REBOOT_SOFT)
class CloudServers(object):
"""
Top-level object to access the Rackspace Cloud Servers API.
Create an instance with your creds::
>>> cs = CloudServers(USERNAME, API_KEY [, AUTH_URL])
Then call methods on its managers::
>>> cs.servers.list()
...
>>> cs.flavors.list()
...
&c.
"""
def __init__(self, username, apikey,
auth_url='https://auth.api.rackspacecloud.com/v1.0'):
self.backup_schedules = BackupScheduleManager(self)
self.client = CloudServersClient(username, apikey, auth_url)
self.flavors = FlavorManager(self)
self.images = ImageManager(self)
self.ipgroups = IPGroupManager(self)
self.servers = ServerManager(self)
def authenticate(self):
"""
Authenticate against the server.
Normally this is called automatically when you first access the API,
but you can call this method to force authentication right now.
Returns on success; raises :exc:`cloudservers.Unauthorized` if the
credentials are wrong.
"""
self.client.authenticate()

View File

@ -0,0 +1,103 @@
from cloudservers import base
BACKUP_WEEKLY_DISABLED = 'DISABLED'
BACKUP_WEEKLY_SUNDAY = 'SUNDAY'
BACKUP_WEEKLY_MONDAY = 'MONDAY'
BACKUP_WEEKLY_TUESDAY = 'TUESDAY'
BACKUP_WEEKLY_WEDNESDAY = 'WEDNESDAY'
BACKUP_WEEKLY_THURSDAY = 'THURSDAY'
BACKUP_WEEKLY_FRIDAY = 'FRIDAY'
BACKUP_WEEKLY_SATURDAY = 'SATURDAY'
BACKUP_DAILY_DISABLED = 'DISABLED'
BACKUP_DAILY_H_0000_0200 = 'H_0000_0200'
BACKUP_DAILY_H_0200_0400 = 'H_0200_0400'
BACKUP_DAILY_H_0400_0600 = 'H_0400_0600'
BACKUP_DAILY_H_0600_0800 = 'H_0600_0800'
BACKUP_DAILY_H_0800_1000 = 'H_0800_1000'
BACKUP_DAILY_H_1000_1200 = 'H_1000_1200'
BACKUP_DAILY_H_1200_1400 = 'H_1200_1400'
BACKUP_DAILY_H_1400_1600 = 'H_1400_1600'
BACKUP_DAILY_H_1600_1800 = 'H_1600_1800'
BACKUP_DAILY_H_1800_2000 = 'H_1800_2000'
BACKUP_DAILY_H_2000_2200 = 'H_2000_2200'
BACKUP_DAILY_H_2200_0000 = 'H_2200_0000'
class BackupSchedule(base.Resource):
"""
Represents the daily or weekly backup schedule for some server.
"""
def get(self):
"""
Get this `BackupSchedule` again from the API.
"""
return self.manager.get(server=self.server)
def delete(self):
"""
Delete (i.e. disable and remove) this scheduled backup.
"""
self.manager.delete(server=self.server)
def update(self, enabled=True, weekly=BACKUP_WEEKLY_DISABLED,
daily=BACKUP_DAILY_DISABLED):
"""
Update this backup schedule.
See :meth:`BackupScheduleManager.create` for details.
"""
self.manager.create(self.server, enabled, weekly, daily)
class BackupScheduleManager(base.Manager):
"""
Manage server backup schedules.
"""
resource_class = BackupSchedule
def get(self, server):
"""
Get the current backup schedule for a server.
:arg server: The server (or its ID).
:rtype: :class:`BackupSchedule`
"""
s = base.getid(server)
schedule = self._get('/servers/%s/backup_schedule' % s,
'backupSchedule')
schedule.server = server
return schedule
# Backup schedules use POST for both create and update, so allow both here.
# Unlike the rest of the API, POST here returns no body, so we can't use
# the nice little helper methods.
def create(self, server, enabled=True, weekly=BACKUP_WEEKLY_DISABLED,
daily=BACKUP_DAILY_DISABLED):
"""
Create or update the backup schedule for the given server.
:arg server: The server (or its ID).
:arg enabled: boolean; should this schedule be enabled?
:arg weekly: Run a weekly backup on this day
(one of the `BACKUP_WEEKLY_*` constants)
:arg daily: Run a daily backup at this time
(one of the `BACKUP_DAILY_*` constants)
"""
s = base.getid(server)
body = {'backupSchedule': {
'enabled': enabled, 'weekly': weekly, 'daily': daily
}}
self.api.client.post('/servers/%s/backup_schedule' % s, body=body)
update = create
def delete(self, server):
"""
Remove the scheduled backup for `server`.
:arg server: The server (or its ID).
"""
s = base.getid(server)
self._delete('/servers/%s/backup_schedule' % s)

130
cloudservers/base.py Normal file
View File

@ -0,0 +1,130 @@
"""
Base utilities to build API operation managers and objects on top of.
"""
from cloudservers.exceptions import NotFound
# Python 2.4 compat
try:
all
except NameError:
def all(iterable):
return True not in (not x for x in iterable)
class Manager(object):
"""
Managers interact with a particular type of API (servers, flavors, images,
etc.) and provide CRUD operations for them.
"""
resource_class = None
def __init__(self, api):
self.api = api
def _list(self, url, response_key):
resp, body = self.api.client.get(url)
return [self.resource_class(self, res) for res in body[response_key]]
def _get(self, url, response_key):
resp, body = self.api.client.get(url)
return self.resource_class(self, body[response_key])
def _create(self, url, body, response_key):
resp, body = self.api.client.post(url, body=body)
return self.resource_class(self, body[response_key])
def _delete(self, url):
resp, body = self.api.client.delete(url)
def _update(self, url, body):
resp, body = self.api.client.put(url, body=body)
class ManagerWithFind(Manager):
"""
Like a `Manager`, but with additional `find()`/`findall()` methods.
"""
def find(self, **kwargs):
"""
Find a single item with attributes matching ``**kwargs``.
This isn't very efficient: it loads the entire list then filters on
the Python side.
"""
rl = self.findall(**kwargs)
try:
return rl[0]
except IndexError:
raise NotFound(404, "No %s matching %s." %
(self.resource_class.__name__, kwargs))
def findall(self, **kwargs):
"""
Find all items with attributes matching ``**kwargs``.
This isn't very efficient: it loads the entire list then filters on
the Python side.
"""
found = []
searches = kwargs.items()
for obj in self.list():
try:
if all(getattr(obj, attr) == value
for (attr, value) in searches):
found.append(obj)
except AttributeError:
continue
return found
class Resource(object):
"""
A resource represents a particular instance of an object (server, flavor,
etc). This is pretty much just a bag for attributes.
"""
def __init__(self, manager, info):
self.manager = manager
self._info = info
self._add_details(info)
def _add_details(self, info):
for (k, v) in info.iteritems():
setattr(self, k, v)
def __getattr__(self, k):
self.get()
if k not in self.__dict__:
raise AttributeError(k)
else:
return self.__dict__[k]
def __repr__(self):
reprkeys = sorted(k for k in self.__dict__.keys() if k[0] != '_' and
k != 'manager')
info = ", ".join("%s=%s" % (k, getattr(self, k)) for k in reprkeys)
return "<%s %s>" % (self.__class__.__name__, info)
def get(self):
new = self.manager.get(self.id)
self._add_details(new._info)
def __eq__(self, other):
if not isinstance(other, self.__class__):
return False
if hasattr(self, 'id') and hasattr(other, 'id'):
return self.id == other.id
return self._info == other._info
def getid(obj):
"""
Abstracts the common pattern of allowing both an object or an object's ID
(integer) as a parameter when dealing with relationships.
"""
try:
return obj.id
except AttributeError:
return int(obj)

113
cloudservers/client.py Normal file
View File

@ -0,0 +1,113 @@
import time
import urlparse
import urllib
import httplib2
try:
import json
except ImportError:
import simplejson as json
# Python 2.5 compat fix
if not hasattr(urlparse, 'parse_qsl'):
import cgi
urlparse.parse_qsl = cgi.parse_qsl
import cloudservers
from cloudservers import exceptions
class CloudServersClient(httplib2.Http):
USER_AGENT = 'python-cloudservers/%s' % cloudservers.__version__
def __init__(self, user, apikey, auth_url):
super(CloudServersClient, self).__init__()
self.user = user
self.apikey = apikey
self.auth_url = auth_url
self.management_url = None
self.auth_token = None
# httplib2 overrides
self.force_exception_to_status_code = True
def request(self, *args, **kwargs):
kwargs.setdefault('headers', {})
kwargs['headers']['User-Agent'] = self.USER_AGENT
if 'body' in kwargs:
kwargs['headers']['Content-Type'] = 'application/json'
kwargs['body'] = json.dumps(kwargs['body'])
# print "-------------"
# print "ARGS:", args
resp, body = super(CloudServersClient, self).request(*args, **kwargs)
# print "RESPONSE", resp
# print "BODY", body
if body:
try:
body = json.loads(body)
except ValueError, e:
pass
else:
body = None
if resp.status in (400, 401, 403, 404, 413, 500, 501):
raise exceptions.from_response(resp, body)
return resp, body
def _cs_request(self, url, method, **kwargs):
if not self.management_url:
self.authenticate()
# Perform the request once. If we get a 401 back then it
# might be because the auth token expired, so try to
# re-authenticate and try again. If it still fails, bail.
try:
kwargs.setdefault('headers', {})['X-Auth-Token'] = self.auth_token
resp, body = self.request(self.management_url + url, method,
**kwargs)
return resp, body
except exceptions.Unauthorized, ex:
try:
self.authenticate()
resp, body = self.request(self.management_url + url, method,
**kwargs)
return resp, body
except exceptions.Unauthorized:
raise ex
def get(self, url, **kwargs):
url = self._munge_get_url(url)
return self._cs_request(url, 'GET', **kwargs)
def post(self, url, **kwargs):
return self._cs_request(url, 'POST', **kwargs)
def put(self, url, **kwargs):
return self._cs_request(url, 'PUT', **kwargs)
def delete(self, url, **kwargs):
return self._cs_request(url, 'DELETE', **kwargs)
def authenticate(self):
headers = {'X-Auth-User': self.user, 'X-Auth-Key': self.apikey}
resp, body = self.request(self.auth_url, 'GET', headers=headers)
self.management_url = resp['x-server-management-url']
self.auth_token = resp['x-auth-token']
def _munge_get_url(self, url):
"""
Munge GET URLs to always return uncached content.
The Cloud Servers API caches data *very* agressively and doesn't
respect cache headers. To avoid stale data, then, we append a little
bit of nonsense onto GET parameters; this appears to force the data not
to be cached.
"""
scheme, netloc, path, query, frag = urlparse.urlsplit(url)
query = urlparse.parse_qsl(query)
query.append(('fresh', str(time.time())))
query = urllib.urlencode(query)
return urlparse.urlunsplit((scheme, netloc, path, query, frag))

View File

@ -0,0 +1,95 @@
class CloudServersException(Exception):
"""
The base exception class for all exceptions this library raises.
"""
def __init__(self, code, message=None, details=None):
self.code = code
self.message = message or self.__class__.message
self.details = details
def __str__(self):
return "%s (HTTP %s)" % (self.message, self.code)
class BadRequest(CloudServersException):
"""
HTTP 400 - Bad request: you sent some malformed data.
"""
http_status = 400
message = "Bad request"
class Unauthorized(CloudServersException):
"""
HTTP 401 - Unauthorized: bad credentials.
"""
http_status = 401
message = "Unauthorized"
class Forbidden(CloudServersException):
"""
HTTP 403 - Forbidden: your credentials don't give you access to this
resource.
"""
http_status = 403
message = "Forbidden"
class NotFound(CloudServersException):
"""
HTTP 404 - Not found
"""
http_status = 404
message = "Not found"
class OverLimit(CloudServersException):
"""
HTTP 413 - Over limit: you're over the API limits for this time period.
"""
http_status = 413
message = "Over limit"
# NotImplemented is a python keyword.
class HTTPNotImplemented(CloudServersException):
"""
HTTP 501 - Not Implemented: the server does not support this operation.
"""
http_status = 501
message = "Not Implemented"
# In Python 2.4 Exception is old-style and thus doesn't have a __subclasses__()
# so we can do this:
# _code_map = dict((c.http_status, c)
# for c in CloudServersException.__subclasses__())
#
# Instead, we have to hardcode it:
_code_map = dict((c.http_status, c) for c in [BadRequest, Unauthorized,
Forbidden, NotFound, OverLimit, HTTPNotImplemented])
def from_response(response, body):
"""
Return an instance of a CloudServersException or subclass
based on an httplib2 response.
Usage::
resp, body = http.request(...)
if resp.status != 200:
raise exception_from_response(resp, body)
"""
cls = _code_map.get(response.status, CloudServersException)
if body:
message = "n/a"
details = "n/a"
if hasattr(body, 'keys'):
error = body[body.keys()[0]]
message = error.get('message', None)
details = error.get('details', None)
return cls(code=response.status, message=message, details=details)
else:
return cls(code=response.status)

33
cloudservers/flavors.py Normal file
View File

@ -0,0 +1,33 @@
from cloudservers import base
class Flavor(base.Resource):
"""
A flavor is an available hardware configuration for a server.
"""
def __repr__(self):
return "<Flavor: %s>" % self.name
class FlavorManager(base.ManagerWithFind):
"""
Manage :class:`Flavor` resources.
"""
resource_class = Flavor
def list(self):
"""
Get a list of all flavors.
:rtype: list of :class:`Flavor`.
"""
return self._list("/flavors/detail", "flavors")
def get(self, flavor):
"""
Get a specific flavor.
:param flavor: The ID of the :class:`Flavor` to get.
:rtype: :class:`Flavor`
"""
return self._get("/flavors/%s" % base.getid(flavor), "flavor")

61
cloudservers/images.py Normal file
View File

@ -0,0 +1,61 @@
from cloudservers import base
class Image(base.Resource):
"""
An image is a collection of files used to create or rebuild a server.
"""
def __repr__(self):
return "<Image: %s>" % self.name
def delete(self):
"""
Delete this image.
"""
return self.manager.delete(self)
class ImageManager(base.ManagerWithFind):
"""
Manage :class:`Image` resources.
"""
resource_class = Image
def get(self, image):
"""
Get an image.
:param image: The ID of the image to get.
:rtype: :class:`Image`
"""
return self._get("/images/%s" % base.getid(image), "image")
def list(self):
"""
Get a list of all images.
:rtype: list of :class:`Image`
"""
return self._list("/images/detail", "images")
def create(self, name, server):
"""
Create a new image by snapshotting a running :class:`Server`
:param name: An (arbitrary) name for the new image.
:param server: The :class:`Server` (or its ID) to make a snapshot of.
:rtype: :class:`Image`
"""
data = {"image": {"serverId": base.getid(server), "name": name}}
return self._create("/images", data, "image")
def delete(self, image):
"""
Delete an image.
It should go without saying that you can't delete an image
that you didn't create.
:param image: The :class:`Image` (or its ID) to delete.
"""
self._delete("/images/%s" % base.getid(image))

56
cloudservers/ipgroups.py Normal file
View File

@ -0,0 +1,56 @@
from cloudservers import base
class IPGroup(base.Resource):
def __repr__(self):
return "<IP Group: %s>" % self.name
def delete(self):
"""
Delete this group.
"""
self.manager.delete(self)
class IPGroupManager(base.ManagerWithFind):
resource_class = IPGroup
def list(self):
"""
Get a list of all groups.
:rtype: list of :class:`IPGroup`
"""
return self._list("/shared_ip_groups/detail", "sharedIpGroups")
def get(self, group):
"""
Get an IP group.
:param group: ID of the image to get.
:rtype: :class:`IPGroup`
"""
return self._get("/shared_ip_groups/%s" % base.getid(group),
"sharedIpGroup")
def create(self, name, server=None):
"""
Create a new :class:`IPGroup`
:param name: An (arbitrary) name for the new image.
:param server: A :class:`Server` (or its ID) to make a member
of this group.
:rtype: :class:`IPGroup`
"""
data = {"sharedIpGroup": {"name": name}}
if server:
data['sharedIpGroup']['server'] = base.getid(server)
return self._create('/shared_ip_groups', data, "sharedIpGroup")
def delete(self, group):
"""
Delete a group.
:param group: The :class:`IPGroup` (or its ID) to delete.
"""
self._delete("/shared_ip_groups/%s" % base.getid(group))

380
cloudservers/servers.py Normal file
View File

@ -0,0 +1,380 @@
from cloudservers import base
REBOOT_SOFT, REBOOT_HARD = 'SOFT', 'HARD'
class Server(base.Resource):
def __repr__(self):
return "<Server: %s>" % self.name
def delete(self):
"""
Delete (i.e. shut down and delete the image) this server.
"""
self.manager.delete(self)
def update(self, name=None, password=None):
"""
Update the name or the password for this server.
:param name: Update the server's name.
:param password: Update the root password.
"""
self.manager.update(self, name, password)
def share_ip(self, ipgroup, address, configure=True):
"""
Share an IP address from the given IP group onto this server.
:param ipgroup: The :class:`IPGroup` that the given address belongs to.
:param address: The IP address to share.
:param configure: If ``True``, the server will be automatically
configured to use this IP. I don't know why you'd
want this to be ``False``.
"""
self.manager.share_ip(self, ipgroup, address, configure)
def unshare_ip(self, address):
"""
Stop sharing the given address.
:param address: The IP address to stop sharing.
"""
self.manager.unshare_ip(self, address)
def reboot(self, type=REBOOT_SOFT):
"""
Reboot the server.
:param type: either :data:`REBOOT_SOFT` for a software-level reboot,
or `REBOOT_HARD` for a virtual power cycle hard reboot.
"""
self.manager.reboot(self, type)
def pause(self):
"""
Pause -- Pause the running server.
"""
self.manager.pause(self)
def unpause(self):
"""
Unpause -- Unpause the paused server.
"""
self.manager.unpause(self)
def suspend(self):
"""
Suspend -- Suspend the running server.
"""
self.manager.suspend(self)
def resume(self):
"""
Resume -- Resume the suspended server.
"""
self.manager.resume(self)
def rescue(self):
"""
Rescue -- Rescue the problematic server.
"""
self.manager.rescue(self)
def unrescue(self):
"""
Unrescue -- Unrescue the rescued server.
"""
self.manager.unrescue(self)
def diagnostics(self):
"""Diagnostics -- Retrieve server diagnostics."""
self.manager.diagnostics(self)
def actions(self):
"""Actions -- Retrieve server actions."""
self.manager.actions(self)
def rebuild(self, image):
"""
Rebuild -- shut down and then re-image -- this server.
:param image: the :class:`Image` (or its ID) to re-image with.
"""
self.manager.rebuild(self, image)
def resize(self, flavor):
"""
Resize the server's resources.
:param flavor: the :class:`Flavor` (or its ID) to resize to.
Until a resize event is confirmed with :meth:`confirm_resize`, the old
server will be kept around and you'll be able to roll back to the old
flavor quickly with :meth:`revert_resize`. All resizes are
automatically confirmed after 24 hours.
"""
self.manager.resize(self, flavor)
def confirm_resize(self):
"""
Confirm that the resize worked, thus removing the original server.
"""
self.manager.confirm_resize(self)
def revert_resize(self):
"""
Revert a previous resize, switching back to the old server.
"""
self.manager.revert_resize(self)
@property
def backup_schedule(self):
"""
This server's :class:`BackupSchedule`.
"""
return self.manager.api.backup_schedules.get(self)
@property
def public_ip(self):
"""
Shortcut to get this server's primary public IP address.
"""
if len(self.addresses['public']) == 0:
return ""
return self.addresses['public'][0]
@property
def private_ip(self):
"""
Shortcut to get this server's primary private IP address.
"""
if len(self.addresses['private']) == 0:
return ""
return self.addresses['private'][0]
class ServerManager(base.ManagerWithFind):
resource_class = Server
def get(self, server):
"""
Get a server.
:param server: ID of the :class:`Server` to get.
:rtype: :class:`Server`
"""
return self._get("/servers/%s" % base.getid(server), "server")
def list(self):
"""
Get a list of servers.
:rtype: list of :class:`Server`
"""
return self._list("/servers/detail", "servers")
def create(self, name, image, flavor, ipgroup=None, meta=None, files=None):
"""
Create (boot) a new server.
:param name: Something to name the server.
:param image: The :class:`Image` to boot with.
:param flavor: The :class:`Flavor` to boot onto.
:param ipgroup: An initial :class:`IPGroup` for this server.
:param meta: A dict of arbitrary key/value metadata to store for this
server. A maximum of five entries is allowed, and both
keys and values must be 255 characters or less.
:param files: A dict of files to overrwrite on the server upon boot.
Keys are file names (i.e. ``/etc/passwd``) and values
are the file contents (either as a string or as a
file-like object). A maximum of five entries is allowed,
and each file must be 10k or less.
There's a bunch more info about how a server boots in Rackspace's
official API docs, page 23.
"""
body = {"server": {
"name": name,
"imageId": base.getid(image),
"flavorId": base.getid(flavor),
}}
if ipgroup:
body["server"]["sharedIpGroupId"] = base.getid(ipgroup)
if meta:
body["server"]["metadata"] = meta
# Files are a slight bit tricky. They're passed in a "personality"
# list to the POST. Each item is a dict giving a file name and the
# base64-encoded contents of the file. We want to allow passing
# either an open file *or* some contents as files here.
if files:
personality = body['server']['personality'] = []
for filepath, file_or_string in files.items():
if hasattr(file_or_string, 'read'):
data = file_or_string.read()
else:
data = file_or_string
personality.append({
'path': filepath,
'contents': data.encode('base64'),
})
return self._create("/servers", body, "server")
def update(self, server, name=None, password=None):
"""
Update the name or the password for a server.
:param server: The :class:`Server` (or its ID) to update.
:param name: Update the server's name.
:param password: Update the root password.
"""
if name is None and password is None:
return
body = {"server": {}}
if name:
body["server"]["name"] = name
if password:
body["server"]["adminPass"] = password
self._update("/servers/%s" % base.getid(server), body)
def delete(self, server):
"""
Delete (i.e. shut down and delete the image) this server.
"""
self._delete("/servers/%s" % base.getid(server))
def share_ip(self, server, ipgroup, address, configure=True):
"""
Share an IP address from the given IP group onto a server.
:param server: The :class:`Server` (or its ID) to share onto.
:param ipgroup: The :class:`IPGroup` that the given address belongs to.
:param address: The IP address to share.
:param configure: If ``True``, the server will be automatically
configured to use this IP. I don't know why you'd
want this to be ``False``.
"""
server = base.getid(server)
ipgroup = base.getid(ipgroup)
body = {'shareIp': {'sharedIpGroupId': ipgroup,
'configureServer': configure}}
self._update("/servers/%s/ips/public/%s" % (server, address), body)
def unshare_ip(self, server, address):
"""
Stop sharing the given address.
:param server: The :class:`Server` (or its ID) to share onto.
:param address: The IP address to stop sharing.
"""
server = base.getid(server)
self._delete("/servers/%s/ips/public/%s" % (server, address))
def reboot(self, server, type=REBOOT_SOFT):
"""
Reboot a server.
:param server: The :class:`Server` (or its ID) to share onto.
:param type: either :data:`REBOOT_SOFT` for a software-level reboot,
or `REBOOT_HARD` for a virtual power cycle hard reboot.
"""
self._action('reboot', server, {'type': type})
def rebuild(self, server, image):
"""
Rebuild -- shut down and then re-image -- a server.
:param server: The :class:`Server` (or its ID) to share onto.
:param image: the :class:`Image` (or its ID) to re-image with.
"""
self._action('rebuild', server, {'imageId': base.getid(image)})
def resize(self, server, flavor):
"""
Resize a server's resources.
:param server: The :class:`Server` (or its ID) to share onto.
:param flavor: the :class:`Flavor` (or its ID) to resize to.
Until a resize event is confirmed with :meth:`confirm_resize`, the old
server will be kept around and you'll be able to roll back to the old
flavor quickly with :meth:`revert_resize`. All resizes are
automatically confirmed after 24 hours.
"""
self._action('resize', server, {'flavorId': base.getid(flavor)})
def pause(self, server):
"""
Pause the server.
"""
self.api.client.post('/servers/%s/pause' % base.getid(server), body={})
def unpause(self, server):
"""
Unpause the server.
"""
self.api.client.post('/servers/%s/unpause' % base.getid(server),
body={})
def suspend(self, server):
"""
Suspend the server.
"""
self.api.client.post('/servers/%s/suspend' % base.getid(server),
body={})
def resume(self, server):
"""
Resume the server.
"""
self.api.client.post('/servers/%s/resume' % base.getid(server),
body={})
def rescue(self, server):
"""
Rescue the server.
"""
self.api.client.post('/servers/%s/rescue' % base.getid(server),
body={})
def unrescue(self, server):
"""
Unrescue the server.
"""
self.api.client.post('/servers/%s/unrescue' % base.getid(server),
body={})
def diagnostics(self, server):
"""Retrieve server diagnostics."""
return self.api.client.get("/servers/%s/diagnostics" %
base.getid(server))
def actions(self, server):
"""Retrieve server actions."""
return self._list("/servers/%s/actions" % base.getid(server),
"actions")
def confirm_resize(self, server):
"""
Confirm that the resize worked, thus removing the original server.
:param server: The :class:`Server` (or its ID) to share onto.
"""
self._action('confirmResize', server)
def revert_resize(self, server):
"""
Revert a previous resize, switching back to the old server.
:param server: The :class:`Server` (or its ID) to share onto.
"""
self._action('revertResize', server)
def _action(self, action, server, info=None):
"""
Perform a server "action" -- reboot/rebuild/resize/etc.
"""
self.api.client.post('/servers/%s/action' % base.getid(server),
body={action: info})

554
cloudservers/shell.py Normal file
View File

@ -0,0 +1,554 @@
"""
Command-line interface to the Cloud Servers API.
"""
import argparse
import cloudservers
import getpass
import httplib2
import os
import prettytable
import sys
import textwrap
# Choices for flags.
DAY_CHOICES = [getattr(cloudservers, i).lower()
for i in dir(cloudservers)
if i.startswith('BACKUP_WEEKLY_')]
HOUR_CHOICES = [getattr(cloudservers, i).lower()
for i in dir(cloudservers)
if i.startswith('BACKUP_DAILY_')]
def pretty_choice_list(l):
return ', '.join("'%s'" % i for i in l)
# Sentinal for boot --key
AUTO_KEY = object()
# Decorator for args
def arg(*args, **kwargs):
def _decorator(func):
# Because of the sematics of decorator composition if we just append
# to the options list positional options will appear to be backwards.
func.__dict__.setdefault('arguments', []).insert(0, (args, kwargs))
return func
return _decorator
class CommandError(Exception):
pass
def env(e):
return os.environ.get(e, '')
class CloudserversShell(object):
# Hook for the test suite to inject a fake server.
_api_class = cloudservers.CloudServers
def __init__(self):
self.parser = argparse.ArgumentParser(
prog='cloudservers',
description=__doc__.strip(),
epilog='See "cloudservers help COMMAND" '\
'for help on a specific command.',
add_help=False,
formatter_class=CloudserversHelpFormatter,
)
# Global arguments
self.parser.add_argument('-h', '--help',
action='help',
help=argparse.SUPPRESS,
)
self.parser.add_argument('--debug',
default=False,
action='store_true',
help=argparse.SUPPRESS)
self.parser.add_argument('--username',
default=env('CLOUD_SERVERS_USERNAME'),
help='Defaults to env[CLOUD_SERVERS_USERNAME].')
self.parser.add_argument('--apikey',
default=env('CLOUD_SERVERS_API_KEY'),
help='Defaults to env[CLOUD_SERVERS_API_KEY].')
auth_url = env('CLOUD_SERVERS_URL')
if auth_url == '':
auth_url = 'https://auth.api.rackspacecloud.com/v1.0'
self.parser.add_argument('--url',
default=auth_url,
help='Defaults to env[CLOUD_SERVERS_URL].')
# Subcommands
subparsers = self.parser.add_subparsers(metavar='<subcommand>')
self.subcommands = {}
# Everything that's do_* is a subcommand.
for attr in (a for a in dir(self) if a.startswith('do_')):
# I prefer to be hypen-separated instead of underscores.
command = attr[3:].replace('_', '-')
callback = getattr(self, attr)
desc = callback.__doc__ or ''
help = desc.strip().split('\n')[0]
arguments = getattr(callback, 'arguments', [])
subparser = subparsers.add_parser(command,
help=help,
description=desc,
add_help=False,
formatter_class=CloudserversHelpFormatter
)
subparser.add_argument('-h', '--help',
action='help',
help=argparse.SUPPRESS,
)
self.subcommands[command] = subparser
for (args, kwargs) in arguments:
subparser.add_argument(*args, **kwargs)
subparser.set_defaults(func=callback)
def main(self, argv):
# Parse args and call whatever callback was selected
args = self.parser.parse_args(argv)
# Short-circuit and deal with help right away.
if args.func == self.do_help:
self.do_help(args)
return 0
# Deal with global arguments
if args.debug:
httplib2.debuglevel = 1
user, apikey, url = args.username, args.apikey, args.url
if not user:
raise CommandError("You must provide a username, either via "
"--username or via env[CLOUD_SERVERS_USERNAME]")
if not apikey:
raise CommandError("You must provide an API key, either via "
"--apikey or via env[CLOUD_SERVERS_API_KEY]")
self.cs = self._api_class(user, apikey, url)
try:
self.cs.authenticate()
except cloudservers.Unauthorized:
raise CommandError("Invalid Cloud Servers credentials.")
args.func(args)
@arg('command', metavar='<subcommand>', nargs='?',
help='Display help for <subcommand>')
def do_help(self, args):
"""
Display help about this program or one of its subcommands.
"""
if args.command:
if args.command in self.subcommands:
self.subcommands[args.command].print_help()
else:
raise CommandError("'%s' is not a valid subcommand." %
args.command)
else:
self.parser.print_help()
@arg('server', metavar='<server>', help='Name or ID of server.')
@arg('--enable', dest='enabled', default=None, action='store_true',
help='Enable backups.')
@arg('--disable', dest='enabled', action='store_false',
help='Disable backups.')
@arg('--weekly', metavar='<day>', choices=DAY_CHOICES,
help='Schedule a weekly backup for <day> (one of: %s).' %
pretty_choice_list(DAY_CHOICES))
@arg('--daily', metavar='<time-window>', choices=HOUR_CHOICES,
help='Schedule a daily backup during <time-window> (one of: %s).' %
pretty_choice_list(HOUR_CHOICES))
def do_backup_schedule(self, args):
"""
Show or edit the backup schedule for a server.
With no flags, the backup schedule will be shown. If flags are given,
the backup schedule will be modified accordingly.
"""
server = self._find_server(args.server)
# If we have some flags, update the backup
backup = {}
if args.daily:
backup['daily'] = getattr(cloudservers, 'BACKUP_DAILY_%s' %
args.daily.upper())
if args.weekly:
backup['weekly'] = getattr(cloudservers, 'BACKUP_WEEKLY_%s' %
args.weekly.upper())
if args.enabled is not None:
backup['enabled'] = args.enabled
if backup:
server.backup_schedule.update(**backup)
else:
print_dict(server.backup_schedule._info)
@arg('server', metavar='<server>', help='Name or ID of server.')
def do_backup_schedule_delete(self, args):
"""
Delete the backup schedule for a server.
"""
server = self._find_server(args.server)
server.backup_schedule.delete()
@arg('--flavor',
default=None,
metavar='<flavor>',
help="Flavor ID (see 'cloudservers flavors'). "\
"Defaults to 256MB RAM instance.")
@arg('--image',
default=None,
metavar='<image>',
help="Image ID (see 'cloudservers images'). "\
"Defaults to Ubuntu 10.04 LTS.")
@arg('--ipgroup',
default=None,
metavar='<group>',
help="IP group name or ID (see 'cloudservers ipgroup-list').")
@arg('--meta',
metavar="<key=value>",
action='append',
default=[],
help="Record arbitrary key/value metadata. "\
"May be give multiple times.")
@arg('--file',
metavar="<dst-path=src-path>",
action='append',
dest='files',
default=[],
help="Store arbitrary files from <src-path> locally to <dst-path> "\
"on the new server. You may store up to 5 files.")
@arg('--key',
metavar='<path>',
nargs='?',
const=AUTO_KEY,
help="Key the server with an SSH keypair. "\
"Looks in ~/.ssh for a key, "\
"or takes an explicit <path> to one.")
@arg('name', metavar='<name>', help='Name for the new server')
def do_boot(self, args):
"""Boot a new server."""