Initial commit from fork
This commit is contained in:
commit
7304ed80df
27
LICENSE
Normal file
27
LICENSE
Normal 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
3
MANIFEST.in
Normal file
@ -0,0 +1,3 @@
|
||||
include README.rst
|
||||
recursive-include docs *
|
||||
recursive-include tests *
|
135
README.rst
Normal file
135
README.rst
Normal 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
62
cloudservers/__init__.py
Normal 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()
|
103
cloudservers/backup_schedules.py
Normal file
103
cloudservers/backup_schedules.py
Normal 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
130
cloudservers/base.py
Normal 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
113
cloudservers/client.py
Normal 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))
|
95
cloudservers/exceptions.py
Normal file
95
cloudservers/exceptions.py
Normal 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
33
cloudservers/flavors.py
Normal 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
61
cloudservers/images.py
Normal 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
56
cloudservers/ipgroups.py
Normal 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
380
cloudservers/servers.py
Normal 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
554
cloudservers/shell.py
Normal 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."""
|
||||