From f8496672cc61ffba052a8c9626e24fde18519010 Mon Sep 17 00:00:00 2001 From: Brian Lamar Date: Wed, 3 Aug 2011 16:36:03 -0400 Subject: [PATCH] Split everything down the middle into v1_0 and v1_1, including tests. --- .gitignore | 5 +- novaclient/__init__.py | 70 --- novaclient/v1_0/__init__.py | 74 +++ novaclient/{ => v1_0}/accounts.py | 3 +- novaclient/{ => v1_0}/backup_schedules.py | 2 +- novaclient/{ => v1_0}/base.py | 6 +- novaclient/{ => v1_0}/client.py | 12 +- novaclient/{ => v1_0}/exceptions.py | 0 novaclient/{ => v1_0}/flavors.py | 3 +- novaclient/{ => v1_0}/images.py | 4 +- novaclient/{ => v1_0}/ipgroups.py | 2 +- novaclient/{ => v1_0}/servers.py | 4 +- novaclient/{ => v1_0}/shell.py | 44 +- novaclient/{ => v1_0}/zones.py | 4 +- novaclient/v1_1/__init__.py | 66 +++ novaclient/v1_1/base.py | 214 ++++++++ novaclient/v1_1/client.py | 154 ++++++ novaclient/v1_1/exceptions.py | 100 ++++ novaclient/v1_1/flavors.py | 41 ++ novaclient/v1_1/images.py | 86 ++++ novaclient/v1_1/servers.py | 300 +++++++++++ novaclient/v1_1/shell.py | 584 ++++++++++++++++++++++ novaclient/v1_1/zones.py | 196 ++++++++ tests/test_backup_schedules.py | 58 --- tests/test_flavors.py | 37 -- tests/test_images.py | 47 -- tests/test_ipgroups.py | 48 -- tests/test_servers.py | 174 ------- tests/v1_0/__init__.py | 0 tests/{fakeserver.py => v1_0/fakes.py} | 30 +- tests/{ => v1_0}/test_accounts.py | 13 +- tests/{ => v1_0}/test_auth.py | 14 +- tests/v1_0/test_backup_schedules.py | 60 +++ tests/{ => v1_0}/test_base.py | 42 +- tests/{ => v1_0}/test_client.py | 11 +- tests/v1_0/test_flavors.py | 42 ++ tests/v1_0/test_images.py | 51 ++ tests/v1_0/test_ipgroups.py | 52 ++ tests/v1_0/test_servers.py | 180 +++++++ tests/{ => v1_0}/test_shell.py | 15 +- tests/{ => v1_0}/test_zones.py | 25 +- tests/{ => v1_0}/testfile.txt | 0 tests/{ => v1_0}/utils.py | 0 tests/v1_1/__init__.py | 0 tests/v1_1/fakes.py | 496 ++++++++++++++++++ tests/v1_1/test_base.py | 61 +++ tests/v1_1/test_client.py | 52 ++ tests/v1_1/test_flavors.py | 42 ++ tests/v1_1/test_images.py | 51 ++ tests/v1_1/test_servers.py | 125 +++++ tests/v1_1/test_shell.py | 234 +++++++++ tests/v1_1/testfile.txt | 1 + tests/v1_1/utils.py | 29 ++ 53 files changed, 3425 insertions(+), 539 deletions(-) create mode 100644 novaclient/v1_0/__init__.py rename novaclient/{ => v1_0}/accounts.py (94%) rename novaclient/{ => v1_0}/backup_schedules.py (99%) rename novaclient/{ => v1_0}/base.py (98%) rename novaclient/{ => v1_0}/client.py (95%) rename novaclient/{ => v1_0}/exceptions.py (100%) rename novaclient/{ => v1_0}/flavors.py (96%) rename novaclient/{ => v1_0}/images.py (98%) rename novaclient/{ => v1_0}/ipgroups.py (98%) rename novaclient/{ => v1_0}/servers.py (99%) rename novaclient/{ => v1_0}/shell.py (97%) rename novaclient/{ => v1_0}/zones.py (99%) create mode 100644 novaclient/v1_1/__init__.py create mode 100644 novaclient/v1_1/base.py create mode 100644 novaclient/v1_1/client.py create mode 100644 novaclient/v1_1/exceptions.py create mode 100644 novaclient/v1_1/flavors.py create mode 100644 novaclient/v1_1/images.py create mode 100644 novaclient/v1_1/servers.py create mode 100644 novaclient/v1_1/shell.py create mode 100644 novaclient/v1_1/zones.py delete mode 100644 tests/test_backup_schedules.py delete mode 100644 tests/test_flavors.py delete mode 100644 tests/test_images.py delete mode 100644 tests/test_ipgroups.py delete mode 100644 tests/test_servers.py create mode 100644 tests/v1_0/__init__.py rename tests/{fakeserver.py => v1_0/fakes.py} (96%) rename tests/{ => v1_0}/test_accounts.py (62%) rename tests/{ => v1_0}/test_auth.py (81%) create mode 100644 tests/v1_0/test_backup_schedules.py rename tests/{ => v1_0}/test_base.py (50%) rename tests/{ => v1_0}/test_client.py (88%) create mode 100644 tests/v1_0/test_flavors.py create mode 100644 tests/v1_0/test_images.py create mode 100644 tests/v1_0/test_ipgroups.py create mode 100644 tests/v1_0/test_servers.py rename tests/{ => v1_0}/test_shell.py (97%) rename tests/{ => v1_0}/test_zones.py (84%) rename tests/{ => v1_0}/testfile.txt (100%) rename tests/{ => v1_0}/utils.py (100%) create mode 100644 tests/v1_1/__init__.py create mode 100644 tests/v1_1/fakes.py create mode 100644 tests/v1_1/test_base.py create mode 100644 tests/v1_1/test_client.py create mode 100644 tests/v1_1/test_flavors.py create mode 100644 tests/v1_1/test_images.py create mode 100644 tests/v1_1/test_servers.py create mode 100644 tests/v1_1/test_shell.py create mode 100644 tests/v1_1/testfile.txt create mode 100644 tests/v1_1/utils.py diff --git a/.gitignore b/.gitignore index b32d8aa7a..355cfbd22 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ +.coverage +*,cover +cover *.pyc -.idea \ No newline at end of file +.idea diff --git a/novaclient/__init__.py b/novaclient/__init__.py index a0807c710..08bd9d9a7 100644 --- a/novaclient/__init__.py +++ b/novaclient/__init__.py @@ -14,74 +14,4 @@ # License for the specific language governing permissions and limitations # under the License. -""" -novaclient module. -""" - __version__ = '2.5' - -from novaclient.accounts import Account, AccountManager -from novaclient.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 novaclient.client import OpenStackClient -from novaclient.exceptions import (OpenStackException, BadRequest, - Unauthorized, Forbidden, NotFound, OverLimit) -from novaclient.flavors import FlavorManager, Flavor -from novaclient.images import ImageManager, Image -from novaclient.ipgroups import IPGroupManager, IPGroup -from novaclient.servers import (ServerManager, Server, REBOOT_HARD, - REBOOT_SOFT) -from novaclient.zones import Zone, ZoneManager - - -class OpenStack(object): - """ - Top-level object to access the OpenStack Nova API. - - Create an instance with your creds:: - - >>> os = OpenStack(USERNAME, API_KEY, PROJECT_ID, AUTH_URL) - - Then call methods on its managers:: - - >>> os.servers.list() - ... - >>> os.flavors.list() - ... - - &c. - """ - - def __init__(self, username, apikey, projectid, - auth_url='https://auth.api.rackspacecloud.com/v1.0', timeout=None): - self.backup_schedules = BackupScheduleManager(self) - self.client = OpenStackClient(username, apikey, projectid, auth_url, - timeout=timeout) - self.flavors = FlavorManager(self) - self.images = ImageManager(self) - self.ipgroups = IPGroupManager(self) - self.servers = ServerManager(self) - self.zones = ZoneManager(self) - self.accounts = AccountManager(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:`novaclient.Unauthorized` if the - credentials are wrong. - """ - self.client.authenticate() diff --git a/novaclient/v1_0/__init__.py b/novaclient/v1_0/__init__.py new file mode 100644 index 000000000..7b2c9737f --- /dev/null +++ b/novaclient/v1_0/__init__.py @@ -0,0 +1,74 @@ +# Copyright 2010 Jacob Kaplan-Moss +# Copyright 2011 OpenStack LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from novaclient.v1_0 import accounts +from novaclient.v1_0 import backup_schedules +from novaclient.v1_0 import client +from novaclient.v1_0 import exceptions +from novaclient.v1_0 import flavors +from novaclient.v1_0 import images +from novaclient.v1_0 import ipgroups +from novaclient.v1_0 import servers +from novaclient.v1_0 import zones + + +class Client(object): + """ + Top-level object to access the OpenStack Compute v1.0 API. + + Create an instance with your creds:: + + >>> os = novaclient.v1_0.Client(USERNAME, API_KEY, PROJECT_ID, AUTH_URL) + + Then call methods on its managers:: + + >>> os.servers.list() + ... + >>> os.flavors.list() + ... + + &c. + """ + + def __init__(self, username, apikey, projectid, auth_url=None, timeout=None): + """Initialize v1.0 Openstack Client.""" + self.backup_schedules = backup_schedules.BackupScheduleManager(self) + self.flavors = flavors.FlavorManager(self) + self.images = images.ImageManager(self) + self.ipgroups = ipgroups.IPGroupManager(self) + self.servers = servers.ServerManager(self) + self.zones = zones.ZoneManager(self) + self.accounts = accounts.AccountManager(self) + + auth_url = auth_url or "https://auth.api.rackspacecloud.com/v1.0" + + self.client = client.HTTPClient(username, + apikey, + projectid, + auth_url, + timeout=timeout) + + 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:`novaclient.Unauthorized` if the + credentials are wrong. + """ + self.client.authenticate() diff --git a/novaclient/accounts.py b/novaclient/v1_0/accounts.py similarity index 94% rename from novaclient/accounts.py rename to novaclient/v1_0/accounts.py index 01ff2f52e..264ce8431 100644 --- a/novaclient/accounts.py +++ b/novaclient/v1_0/accounts.py @@ -1,9 +1,10 @@ -from novaclient import base +from novaclient.v1_0 import base class Account(base.Resource): pass class AccountManager(base.BootingManagerWithFind): + resource_class = Account def create_instance_for(self, account_id, name, image, flavor, diff --git a/novaclient/backup_schedules.py b/novaclient/v1_0/backup_schedules.py similarity index 99% rename from novaclient/backup_schedules.py rename to novaclient/v1_0/backup_schedules.py index 662e31489..78f4d49f2 100644 --- a/novaclient/backup_schedules.py +++ b/novaclient/v1_0/backup_schedules.py @@ -3,7 +3,7 @@ Backup Schedule interface. """ -from novaclient import base +from novaclient.v1_0 import base BACKUP_WEEKLY_DISABLED = 'DISABLED' BACKUP_WEEKLY_SUNDAY = 'SUNDAY' diff --git a/novaclient/base.py b/novaclient/v1_0/base.py similarity index 98% rename from novaclient/base.py rename to novaclient/v1_0/base.py index e402039fe..3dbec636d 100644 --- a/novaclient/base.py +++ b/novaclient/v1_0/base.py @@ -19,7 +19,7 @@ Base utilities to build API operation managers and objects on top of. """ -from novaclient.exceptions import NotFound +from novaclient.v1_0 import exceptions # Python 2.4 compat try: @@ -68,7 +68,7 @@ class Manager(object): obj_class = self.resource_class return [obj_class(self, res) for res in body[response_key] if res] - + def _get(self, url, response_key): resp, body = self.api.client.get(url) return self.resource_class(self, body[response_key]) @@ -101,7 +101,7 @@ class ManagerWithFind(Manager): try: return rl[0] except IndexError: - raise NotFound(404, "No %s matching %s." % + raise exceptions.NotFound(404, "No %s matching %s." % (self.resource_class.__name__, kwargs)) def findall(self, **kwargs): diff --git a/novaclient/client.py b/novaclient/v1_0/client.py similarity index 95% rename from novaclient/client.py rename to novaclient/v1_0/client.py index 103679a45..202f6da56 100644 --- a/novaclient/client.py +++ b/novaclient/v1_0/client.py @@ -20,16 +20,16 @@ if not hasattr(urlparse, 'parse_qsl'): urlparse.parse_qsl = cgi.parse_qsl import novaclient -from novaclient import exceptions +from novaclient.v1_0 import exceptions _logger = logging.getLogger(__name__) -class OpenStackClient(httplib2.Http): +class HTTPClient(httplib2.Http): USER_AGENT = 'python-novaclient/%s' % novaclient.__version__ def __init__(self, user, apikey, projectid, auth_url, timeout=None): - super(OpenStackClient, self).__init__(timeout=timeout) + super(HTTPClient, self).__init__(timeout=timeout) self.user = user self.apikey = apikey self.projectid = projectid @@ -45,7 +45,7 @@ class OpenStackClient(httplib2.Http): def http_log(self, args, kwargs, resp, body): if not _logger.isEnabledFor(logging.DEBUG): return - + string_parts = ['curl -i'] for element in args: if element in ('GET','POST'): @@ -66,10 +66,10 @@ class OpenStackClient(httplib2.Http): kwargs['headers']['Content-Type'] = 'application/json' kwargs['body'] = json.dumps(kwargs['body']) - resp, body = super(OpenStackClient, self).request(*args, **kwargs) + resp, body = super(HTTPClient, self).request(*args, **kwargs) self.http_log(args, kwargs, resp, body) - + if body: try: body = json.loads(body) diff --git a/novaclient/exceptions.py b/novaclient/v1_0/exceptions.py similarity index 100% rename from novaclient/exceptions.py rename to novaclient/v1_0/exceptions.py diff --git a/novaclient/flavors.py b/novaclient/v1_0/flavors.py similarity index 96% rename from novaclient/flavors.py rename to novaclient/v1_0/flavors.py index bfede1e13..4dc4ac994 100644 --- a/novaclient/flavors.py +++ b/novaclient/v1_0/flavors.py @@ -3,8 +3,7 @@ Flavor interface. """ - -from novaclient import base +from novaclient.v1_0 import base class Flavor(base.Resource): diff --git a/novaclient/images.py b/novaclient/v1_0/images.py similarity index 98% rename from novaclient/images.py rename to novaclient/v1_0/images.py index d911cc747..ec36fe34a 100644 --- a/novaclient/images.py +++ b/novaclient/v1_0/images.py @@ -3,7 +3,7 @@ Image interface. """ -from novaclient import base +from novaclient.v1_0 import base class Image(base.Resource): @@ -60,7 +60,7 @@ class ImageManager(base.ManagerWithFind): if image_type not in ("backup", "snapshot"): raise Exception("Invalid image_type: must be backup or snapshot") - + if image_type == "backup": if not rotation: raise Exception("rotation is required for backups") diff --git a/novaclient/ipgroups.py b/novaclient/v1_0/ipgroups.py similarity index 98% rename from novaclient/ipgroups.py rename to novaclient/v1_0/ipgroups.py index 86cd3cb43..66821ee70 100644 --- a/novaclient/ipgroups.py +++ b/novaclient/v1_0/ipgroups.py @@ -3,7 +3,7 @@ IP Group interface. """ -from novaclient import base +from novaclient.v1_0 import base class IPGroup(base.Resource): diff --git a/novaclient/servers.py b/novaclient/v1_0/servers.py similarity index 99% rename from novaclient/servers.py rename to novaclient/v1_0/servers.py index 18392e4de..a2b014f05 100644 --- a/novaclient/servers.py +++ b/novaclient/v1_0/servers.py @@ -20,7 +20,7 @@ Server interface. """ import urllib -from novaclient import base +from novaclient.v1_0 import base REBOOT_SOFT, REBOOT_HARD = 'SOFT', 'HARD' @@ -228,7 +228,7 @@ class ServerManager(base.BootingManagerWithFind): qparams[opt] = val query_string = "?%s" % urllib.urlencode(qparams) if qparams else "" - + detail = "" if detailed: detail = "/detail" diff --git a/novaclient/shell.py b/novaclient/v1_0/shell.py similarity index 97% rename from novaclient/shell.py rename to novaclient/v1_0/shell.py index b420e1c3b..b4dcef2d2 100644 --- a/novaclient/shell.py +++ b/novaclient/v1_0/shell.py @@ -20,7 +20,6 @@ Command-line interface to the OpenStack Nova API. """ import argparse -import novaclient import getpass import httplib2 import os @@ -29,12 +28,17 @@ import sys import textwrap import uuid +import novaclient.v1_0 +from novaclient.v1_0 import backup_schedules +from novaclient.v1_0 import exceptions +from novaclient.v1_0 import servers + # Choices for flags. -DAY_CHOICES = [getattr(novaclient, i).lower() - for i in dir(novaclient) +DAY_CHOICES = [getattr(backup_schedules, i).lower() + for i in dir(backup_schedules) if i.startswith('BACKUP_WEEKLY_')] -HOUR_CHOICES = [getattr(novaclient, i).lower() - for i in dir(novaclient) +HOUR_CHOICES = [getattr(backup_schedules, i).lower() + for i in dir(backup_schedules) if i.startswith('BACKUP_DAILY_')] @@ -66,7 +70,7 @@ def env(e): class OpenStackShell(object): # Hook for the test suite to inject a fake server. - _api_class = novaclient.OpenStack + _api_class = novaclient.v1_0.Client def __init__(self): self.parser = argparse.ArgumentParser( @@ -98,7 +102,7 @@ class OpenStackShell(object): help='Defaults to env[NOVA_API_KEY].') self.parser.add_argument('--projectid', - default=env('NOVA_PROJECT_ID'), + default=env('NOVA_PROJECT_ID'), help='Defaults to env[NOVA_PROJECT_ID].') auth_url = env('NOVA_URL') @@ -165,7 +169,7 @@ class OpenStackShell(object): self.cs = self._api_class(user, apikey, projectid, url) try: self.cs.authenticate() - except novaclient.Unauthorized: + except exceptions.Unauthorized: raise CommandError("Invalid OpenStack Nova credentials.") args.func(args) @@ -208,10 +212,10 @@ class OpenStackShell(object): # If we have some flags, update the backup backup = {} if args.daily: - backup['daily'] = getattr(novaclient, 'BACKUP_DAILY_%s' % + backup['daily'] = getattr(backup_schedules, 'BACKUP_DAILY_%s' % args.daily.upper()) if args.weekly: - backup['weekly'] = getattr(novaclient, 'BACKUP_WEEKLY_%s' % + backup['weekly'] = getattr(backup_schedules, 'BACKUP_WEEKLY_%s' % args.weekly.upper()) if args.enabled is not None: backup['enabled'] = args.enabled @@ -281,7 +285,7 @@ class OpenStackShell(object): except IOError, e: raise CommandError("Can't open '%s': %s" % (keyfile, e)) - return (args.name, image, flavor, ipgroup, metadata, files, + return (args.name, image, flavor, ipgroup, metadata, files, reservation_id, min_count, max_count) @arg('--flavor', @@ -461,7 +465,7 @@ class OpenStackShell(object): for from_key, to_key in convert: if from_key in keys and to_key not in keys: setattr(item, to_key, item._info[from_key]) - + def do_flavor_list(self, args): """Print a list of available 'flavors' (sizes of servers).""" flavors = self.cs.flavors.list() @@ -630,8 +634,8 @@ class OpenStackShell(object): @arg('--hard', dest='reboot_type', action='store_const', - const=novaclient.REBOOT_HARD, - default=novaclient.REBOOT_SOFT, + const=servers.REBOOT_HARD, + default=servers.REBOOT_SOFT, help='Perform a hard reboot (instead of a soft one).') @arg('server', metavar='', help='Name or ID of server.') def do_reboot(self, args): @@ -766,7 +770,7 @@ class OpenStackShell(object): def do_zone(self, args): """Show or edit a child zone. No zone arg for this zone.""" zone = self.cs.zones.get(args.zone) - + # If we have some flags, update the zone zone_delta = {} if args.api_url: @@ -790,7 +794,7 @@ class OpenStackShell(object): print_dict(zone._info) @arg('api_url', metavar='', help="URL for the Zone's API") - @arg('zone_username', metavar='', + @arg('zone_username', metavar='', help='Authentication username.') @arg('password', metavar='', help='Authentication password.') @arg('weight_offset', metavar='', @@ -799,7 +803,7 @@ class OpenStackShell(object): help='Child Zone weight scale (typically 1.0).') def do_zone_add(self, args): """Add a new child zone.""" - zone = self.cs.zones.create(args.api_url, args.zone_username, + zone = self.cs.zones.create(args.api_url, args.zone_username, args.password, args.weight_offset, args.weight_scale) print_dict(zone._info) @@ -820,7 +824,7 @@ class OpenStackShell(object): """Add new IP address to network.""" server = self._find_server(args.server) server.add_fixed_ip(args.network_id) - + @arg('server', metavar='', help='Name or ID of server.') @arg('address', metavar='
', help='IP Address.') def do_remove_fixed_ip(self, args): @@ -844,7 +848,7 @@ class OpenStackShell(object): """Get a flavor by name, ID, or RAM size.""" try: return self._find_resource(self.cs.flavors, flavor) - except novaclient.NotFound: + except exceptions.NotFound: return self.cs.flavors.find(ram=flavor) def _find_resource(self, manager, name_or_id): @@ -858,7 +862,7 @@ class OpenStackShell(object): return manager.get(name_or_id) except ValueError: return manager.find(name=name_or_id) - except novaclient.NotFound: + except exceptions.NotFound: raise CommandError("No %s with a name or ID of '%s' exists." % (manager.resource_class.__name__.lower(), name_or_id)) diff --git a/novaclient/zones.py b/novaclient/v1_0/zones.py similarity index 99% rename from novaclient/zones.py rename to novaclient/v1_0/zones.py index 5bcee5501..f711ec219 100644 --- a/novaclient/zones.py +++ b/novaclient/v1_0/zones.py @@ -17,14 +17,14 @@ Zone interface. """ -from novaclient import base +from novaclient.v1_0 import base class Weighting(base.Resource): def __init__(self, manager, info): self.name = "n/a" super(Weighting, self).__init__(manager, info) - + def __repr__(self): return "" % self.name diff --git a/novaclient/v1_1/__init__.py b/novaclient/v1_1/__init__.py new file mode 100644 index 000000000..955b27e53 --- /dev/null +++ b/novaclient/v1_1/__init__.py @@ -0,0 +1,66 @@ +# Copyright 2010 Jacob Kaplan-Moss +# Copyright 2011 OpenStack LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from novaclient.v1_1 import client +from novaclient.v1_1 import exceptions +from novaclient.v1_1 import flavors +from novaclient.v1_1 import images +from novaclient.v1_1 import servers + + +class Client(object): + """ + Top-level object to access the OpenStack Compute v1.0 API. + + Create an instance with your creds:: + + >>> os = novaclient.v1_1.Client(USERNAME, API_KEY, PROJECT_ID, AUTH_URL) + + Then call methods on its managers:: + + >>> os.servers.list() + ... + >>> os.flavors.list() + ... + + &c. + """ + + def __init__(self, username, apikey, projectid, auth_url=None, timeout=None): + """Initialize v1.0 Openstack Client.""" + self.flavors = flavors.FlavorManager(self) + self.images = images.ImageManager(self) + self.servers = servers.ServerManager(self) + + auth_url = auth_url or "https://auth.api.rackspacecloud.com/v1.0" + + self.client = client.HTTPClient(username, + apikey, + projectid, + auth_url, + timeout=timeout) + + 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:`novaclient.Unauthorized` if the + credentials are wrong. + """ + self.client.authenticate() diff --git a/novaclient/v1_1/base.py b/novaclient/v1_1/base.py new file mode 100644 index 000000000..f283d8596 --- /dev/null +++ b/novaclient/v1_1/base.py @@ -0,0 +1,214 @@ +# Copyright 2010 Jacob Kaplan-Moss + +# Copyright 2011 OpenStack LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Base utilities to build API operation managers and objects on top of. +""" + +from novaclient.v1_1 import exceptions + +# Python 2.4 compat +try: + all +except NameError: + def all(iterable): + return True not in (not x for x in iterable) + + +def getid(obj): + """ + Abstracts the common pattern of allowing both an object or an object's ID + (UUID) as a parameter when dealing with relationships. + """ + + # Try to return the object's UUID first, if we have a UUID. + try: + if obj.uuid: + return obj.uuid + except AttributeError: + pass + try: + return obj.id + except AttributeError: + return obj + + +class Manager(object): + """ + Managers interact with a particular type of API (servers, flavors, images, + etc.) and provide CRUD operations for them. + """ + resource_class = None + + def __init__(self, api): + self.api = api + + def _list(self, url, response_key, obj_class=None, body=None): + resp = None + if body: + resp, body = self.api.client.post(url, body=body) + else: + resp, body = self.api.client.get(url) + + if obj_class is None: + obj_class = self.resource_class + return [obj_class(self, res) + for res in body[response_key] if res] + + 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, return_raw=False): + resp, body = self.api.client.post(url, body=body) + if return_raw: + return body[response_key] + return self.resource_class(self, body[response_key]) + + def _delete(self, url): + resp, body = self.api.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 exceptions.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 BootingManagerWithFind(ManagerWithFind): + """Like a `ManagerWithFind`, but has the ability to boot servers.""" + def _boot(self, resource_url, response_key, name, image, flavor, + meta=None, files=None, return_raw=False): + """ + 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 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. + :param return_raw: If True, don't try to coearse the result into + a Resource object. + """ + body = {"server": { + "name": name, + "imageRef": getid(image), + "flavorRef": getid(flavor), + }} + 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(resource_url, body, response_key, + return_raw=return_raw) + + +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) + if new: + self._add_details(new._info) + + def __eq__(self, other): + if not isinstance(other, self.__class__): + return False + if hasattr(self, 'id') and hasattr(other, 'id'): + return self.id == other.id + return self._info == other._info diff --git a/novaclient/v1_1/client.py b/novaclient/v1_1/client.py new file mode 100644 index 000000000..a805826d8 --- /dev/null +++ b/novaclient/v1_1/client.py @@ -0,0 +1,154 @@ +# Copyright 2010 Jacob Kaplan-Moss +""" +OpenStack Client interface. Handles the REST calls and responses. +""" + +import time +import urlparse +import urllib +import httplib2 +import logging + +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 novaclient +from novaclient.v1_1 import exceptions + +_logger = logging.getLogger(__name__) + +class HTTPClient(httplib2.Http): + + USER_AGENT = 'python-novaclient/%s' % novaclient.__version__ + + def __init__(self, user, apikey, projectid, auth_url, timeout=None): + super(HTTPClient, self).__init__(timeout=timeout) + self.user = user + self.apikey = apikey + self.projectid = projectid + self.auth_url = auth_url + self.version = 'v1.0' + + self.management_url = None + self.auth_token = None + + # httplib2 overrides + self.force_exception_to_status_code = True + + def http_log(self, args, kwargs, resp, body): + if not _logger.isEnabledFor(logging.DEBUG): + return + + string_parts = ['curl -i'] + for element in args: + if element in ('GET','POST'): + string_parts.append(' -X %s' % element) + else: + string_parts.append(' %s' % element) + + for element in kwargs['headers']: + string_parts.append(' -H "%s: %s"' % (element,kwargs['headers'][element])) + + _logger.debug("REQ: %s\n" % "".join(string_parts)) + _logger.debug("RESP:%s %s\n", resp,body) + + 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']) + + resp, body = super(HTTPClient, self).request(*args, **kwargs) + + self.http_log(args, kwargs, resp, body) + + if body: + try: + body = json.loads(body) + except ValueError, e: + pass + else: + body = None + + if resp.status in (400, 401, 403, 404, 408, 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 + if self.projectid: + kwargs['headers']['X-Auth-Project-Id'] = self.projectid + + 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): + scheme, netloc, path, query, frag = urlparse.urlsplit( + self.auth_url) + path_parts = path.split('/') + for part in path_parts: + if len(part) > 0 and part[0] == 'v': + self.version = part + break + + headers = {'X-Auth-User': self.user, + 'X-Auth-Key': self.apikey} + if self.projectid: + headers['X-Auth-Project-Id'] = self.projectid + 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 OpenStack Nova 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)) diff --git a/novaclient/v1_1/exceptions.py b/novaclient/v1_1/exceptions.py new file mode 100644 index 000000000..1709d806f --- /dev/null +++ b/novaclient/v1_1/exceptions.py @@ -0,0 +1,100 @@ +# Copyright 2010 Jacob Kaplan-Moss +""" +Exception definitions. +""" + +class OpenStackException(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(OpenStackException): + """ + HTTP 400 - Bad request: you sent some malformed data. + """ + http_status = 400 + message = "Bad request" + + +class Unauthorized(OpenStackException): + """ + HTTP 401 - Unauthorized: bad credentials. + """ + http_status = 401 + message = "Unauthorized" + + +class Forbidden(OpenStackException): + """ + HTTP 403 - Forbidden: your credentials don't give you access to this + resource. + """ + http_status = 403 + message = "Forbidden" + + +class NotFound(OpenStackException): + """ + HTTP 404 - Not found + """ + http_status = 404 + message = "Not found" + + +class OverLimit(OpenStackException): + """ + 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(OpenStackException): + """ + 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 OpenStackException.__subclasses__()) +# +# Instead, we have to hardcode it: +_code_map = dict((c.http_status, c) for c in [BadRequest, Unauthorized, + Forbidden, NotFound, OverLimit, HTTPNotImplemented]) + + +def from_response(response, body): + """ + Return an instance of an OpenStackException 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, OpenStackException) + 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) diff --git a/novaclient/v1_1/flavors.py b/novaclient/v1_1/flavors.py new file mode 100644 index 000000000..f4a82a962 --- /dev/null +++ b/novaclient/v1_1/flavors.py @@ -0,0 +1,41 @@ +# Copyright 2010 Jacob Kaplan-Moss +""" +Flavor interface. +""" + +from novaclient.v1_1 import base + + +class Flavor(base.Resource): + """ + A flavor is an available hardware configuration for a server. + """ + def __repr__(self): + return "" % self.name + + +class FlavorManager(base.ManagerWithFind): + """ + Manage :class:`Flavor` resources. + """ + resource_class = Flavor + + def list(self, detailed=True): + """ + Get a list of all flavors. + + :rtype: list of :class:`Flavor`. + """ + detail = "" + if detailed: + detail = "/detail" + return self._list("/flavors%s" % 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") diff --git a/novaclient/v1_1/images.py b/novaclient/v1_1/images.py new file mode 100644 index 000000000..48d86ac8d --- /dev/null +++ b/novaclient/v1_1/images.py @@ -0,0 +1,86 @@ +# Copyright 2010 Jacob Kaplan-Moss +""" +Image interface. +""" + +from novaclient.v1_1 import base + + +class Image(base.Resource): + """ + An image is a collection of files used to create or rebuild a server. + """ + def __repr__(self): + return "" % 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, detailed=True): + """ + Get a list of all images. + + :rtype: list of :class:`Image` + """ + detail = "" + if detailed: + detail = "/detail" + return self._list("/images%s" % detail, "images") + + + def create(self, server, name, image_type=None, backup_type=None, rotation=None): + """ + 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` + """ + if image_type is None: + image_type = "snapshot" + + if image_type not in ("backup", "snapshot"): + raise Exception("Invalid image_type: must be backup or snapshot") + + if image_type == "backup": + if not rotation: + raise Exception("rotation is required for backups") + elif not backup_type: + raise Exception("backup_type required for backups") + elif backup_type not in ("daily", "weekly"): + raise Exception("Invalid backup_type: must be daily or weekly") + + data = {"image": {"serverId": base.getid(server), "name": name, + "image_type": image_type, "backup_type": backup_type, + "rotation": rotation}} + 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)) diff --git a/novaclient/v1_1/servers.py b/novaclient/v1_1/servers.py new file mode 100644 index 000000000..fbba4ae18 --- /dev/null +++ b/novaclient/v1_1/servers.py @@ -0,0 +1,300 @@ +# Copyright 2010 Jacob Kaplan-Moss + +# Copyright 2011 OpenStack LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Server interface. +""" + +import urllib +from novaclient.v1_1 import base + +REBOOT_SOFT, REBOOT_HARD = 'SOFT', 'HARD' + + +class Server(base.Resource): + def __repr__(self): + return "" % 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): + """ + 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) + + def create_image(self, name, metadata=None): + """ + Create an image based on this server. + + :param name: The name of the image to create + :param metadata: The metadata to associated with the image. + """ + self.manager.create_image(self, name, metadata) + + def change_password(self, password): + """ + Update the root password on this server. + + :param password: The password to set. + """ + self.manager.change_password(self, password) + + 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 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 public_ip(self): + """ + Shortcut to get this server's primary public IP address. + """ + if len(self.addresses['public']) == 0: + return "" + return self.addresses['public'] + + @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'] + + @property + def image_id(self): + """ + Shortcut to get the image identifier. + """ + return self.image["id"] + + @property + def flavor_id(self): + """ + Shortcut to get the flavor identifier. + """ + return self.flavor["id"] + + + + +class ServerManager(base.BootingManagerWithFind): + 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, detailed=True, search_opts=None): + """ + Get a list of servers. + Optional detailed returns details server info. + Optional reservation_id only returns instances with that + reservation_id. + + :rtype: list of :class:`Server` + """ + if search_opts is None: + search_opts = {} + qparams = {} + # only use values in query string if they are set + for opt, val in search_opts.iteritems(): + if val: + qparams[opt] = val + + query_string = "?%s" % urllib.urlencode(qparams) if qparams else "" + + detail = "" + if detailed: + detail = "/detail" + return self._list("/servers%s%s" % (detail, query_string), "servers") + + def create(self, name, image, flavor, 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 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. + """ + return self._boot("/servers", "server", name, image, flavor, + meta=meta, files=files) + + def update(self, server, name=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. + """ + if name is None: + return + + body = { + "server": { + "name": name, + }, + } + + 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 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 create_image(self, server, name, metadata=None): + """ + Create an image based on this server. + + :param server: The :class:`Server` (or its ID) to create image from. + :param name: The name of the image to create + :param metadata: The metadata to associated with the image. + """ + body = { + "name": name, + "metadata": metadata or {}, + } + self._action('createImage', server, body) + + def change_password(self, server, password): + """ + Update the root password on a server. + + :param server: The :class:`Server` (or its ID) to share onto. + :param password: The password to set. + """ + body = { + "adminPass": password, + } + self._action('changePassword', server, body) + + 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, {'imageRef': 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, {'flavorRef': base.getid(flavor)}) + + 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}) diff --git a/novaclient/v1_1/shell.py b/novaclient/v1_1/shell.py new file mode 100644 index 000000000..7f9217e09 --- /dev/null +++ b/novaclient/v1_1/shell.py @@ -0,0 +1,584 @@ +# Copyright 2010 Jacob Kaplan-Moss + +# Copyright 2011 OpenStack LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Command-line interface to the OpenStack Nova API. +""" + +import argparse +import getpass +import httplib2 +import os +import prettytable +import sys +import textwrap +import uuid + +import novaclient.v1_1 +from novaclient.v1_1 import exceptions +from novaclient.v1_1 import servers + + +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 OpenStackShell(object): + + # Hook for the test suite to inject a fake server. + _api_class = novaclient.v1_1.Client + + def __init__(self): + self.parser = argparse.ArgumentParser( + prog='nova', + description=__doc__.strip(), + epilog='See "nova help COMMAND" '\ + 'for help on a specific command.', + add_help=False, + formatter_class=OpenStackHelpFormatter, + ) + + # 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('NOVA_USERNAME'), + help='Defaults to env[NOVA_USERNAME].') + + self.parser.add_argument('--apikey', + default=env('NOVA_API_KEY'), + help='Defaults to env[NOVA_API_KEY].') + + self.parser.add_argument('--projectid', + default=env('NOVA_PROJECT_ID'), + help='Defaults to env[NOVA_PROJECT_ID].') + + auth_url = env('NOVA_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[NOVA_URL].') + + # Subcommands + subparsers = self.parser.add_subparsers(metavar='') + 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=OpenStackHelpFormatter + ) + subparser.add_argument('-h', '--help', + action='help', + help=argparse.SUPPRESS, + ) + self.subcommands[command] = subparser + for (args, kwargs) in arguments: + subparser.add_argument(*args, **kwargs) + subparser.set_defaults(func=callback) + + def main(self, argv): + # Parse args 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, projectid, url = args.username, args.apikey, \ + args.projectid, args.url + + #FIXME(usrleon): Here should be restrict for project id same as + # for username or apikey but for compatibility it is not. + + if not user: + raise CommandError("You must provide a username, either via " + "--username or via env[NOVA_USERNAME]") + if not apikey: + raise CommandError("You must provide an API key, either via " + "--apikey or via env[NOVA_API_KEY]") + + self.cs = self._api_class(user, apikey, projectid, url) + try: + self.cs.authenticate() + except exceptions.Unauthorized: + raise CommandError("Invalid OpenStack Nova credentials.") + + args.func(args) + + @arg('command', metavar='', nargs='?', + help='Display help for ') + 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() + + def _boot(self, args, reservation_id=None, min_count=None, max_count=None): + """Boot a new server.""" + flavor = args.flavor or self.cs.flavors.find(ram=256) + image = args.image or self.cs.images.find(name="Ubuntu 10.04 LTS "\ + "(lucid)") + + metadata = dict(v.split('=') for v in args.meta) + + files = {} + for f in args.files: + dst, src = f.split('=', 1) + try: + files[dst] = open(src) + except IOError, e: + raise CommandError("Can't open '%s': %s" % (src, e)) + + if args.key is AUTO_KEY: + possible_keys = [os.path.join(os.path.expanduser('~'), '.ssh', k) + for k in ('id_dsa.pub', 'id_rsa.pub')] + for k in possible_keys: + if os.path.exists(k): + keyfile = k + break + else: + raise CommandError("Couldn't find a key file: tried " + "~/.ssh/id_dsa.pub or ~/.ssh/id_rsa.pub") + elif args.key: + keyfile = args.key + else: + keyfile = None + + if keyfile: + try: + files['/root/.ssh/authorized_keys2'] = open(keyfile) + except IOError, e: + raise CommandError("Can't open '%s': %s" % (keyfile, e)) + + return (args.name, image, flavor, metadata, files) + + @arg('--flavor', + default=None, + metavar='', + help="Flavor ID (see 'novaclient flavors'). "\ + "Defaults to 256MB RAM instance.") + @arg('--image', + default=None, + metavar='', + help="Image ID (see 'novaclient images'). "\ + "Defaults to Ubuntu 10.04 LTS.") + @arg('--meta', + metavar="", + action='append', + default=[], + help="Record arbitrary key/value metadata. "\ + "May be give multiple times.") + @arg('--file', + metavar="", + action='append', + dest='files', + default=[], + help="Store arbitrary files from locally to "\ + "on the new server. You may store up to 5 files.") + @arg('--key', + metavar='', + nargs='?', + const=AUTO_KEY, + help="Key the server with an SSH keypair. "\ + "Looks in ~/.ssh for a key, "\ + "or takes an explicit to one.") + @arg('name', metavar='', help='Name for the new server') + def do_boot(self, args): + """Boot a new server.""" + name, image, flavor, metadata, files = self._boot(args) + + server = self.cs.servers.create(args.name, image, flavor, + meta=metadata, + files=files) + print_dict(server._info) + + def _translate_flavor_keys(self, collection): + convert = [('ram', 'memory_mb'), ('disk', 'local_gb')] + for item in collection: + keys = item.__dict__.keys() + for from_key, to_key in convert: + if from_key in keys and to_key not in keys: + setattr(item, to_key, item._info[from_key]) + + @arg('--fixed_ip', + dest='fixed_ip', + metavar='', + default=None, + help='Only match against fixed IP.') + @arg('--reservation_id', + dest='reservation_id', + metavar='', + default=None, + help='Only return instances that match reservation_id.') + @arg('--recurse_zones', + dest='recurse_zones', + metavar='<0|1>', + nargs='?', + type=int, + const=1, + default=0, + help='Recurse through all zones if set.') + @arg('--ip', + dest='ip', + metavar='', + default=None, + help='Search with regular expression match by IP address') + @arg('--ip6', + dest='ip6', + metavar='', + default=None, + help='Search with regular expression match by IPv6 address') + @arg('--server_name', + dest='server_name', + metavar='', + default=None, + help='Search with regular expression match by server name') + @arg('--name', + dest='display_name', + metavar='', + default=None, + help='Search with regular expression match by display name') + @arg('--instance_name', + dest='name', + metavar='', + default=None, + help='Search with regular expression match by instance name') + def do_list(self, args): + """List active servers.""" + recurse_zones = args.recurse_zones + search_opts = { + 'reservation_id': args.reservation_id, + 'fixed_ip': args.fixed_ip, + 'recurse_zones': recurse_zones, + 'ip': args.ip, + 'ip6': args.ip6, + 'name': args.name, + 'server_name': args.server_name, + 'display_name': args.display_name} + if recurse_zones: + to_print = ['UUID', 'Name', 'Status', 'Public IP', 'Private IP'] + else: + to_print = ['ID', 'Name', 'Status', 'Public IP', 'Private IP'] + print_list(self.cs.servers.list(search_opts=search_opts), + to_print) + + def do_flavor_list(self, args): + """Print a list of available 'flavors' (sizes of servers).""" + flavors = self.cs.flavors.list() + self._translate_flavor_keys(flavors) + print_list(flavors, [ + 'ID', + 'Name', + 'Memory_MB', + 'Swap', + 'Local_GB', + 'VCPUs', + 'RXTX_Quota', + 'RXTX_Cap']) + + def do_image_list(self, args): + """Print a list of available images to boot from.""" + print_list(self.cs.images.list(), ['ID', 'Name', 'Status']) + + @arg('server', metavar='', help='Name or ID of server.') + @arg('name', metavar='', help='Name of backup or snapshot.') + @arg('--image-type', + metavar='', + default='snapshot', + help='type of image (default: snapshot)') + @arg('--backup-type', + metavar='', + default=None, + help='type of backup') + @arg('--rotation', + default=None, + type=int, + metavar='', + help="Number of backups to retain. Used for backup image_type.") + def do_create_image(self, args): + """Create a new image by taking a snapshot of a running server.""" + server = self._find_server(args.server) + server.create_image(args.name) + + @arg('image', metavar='', help='Name or ID of image.') + def do_image_delete(self, args): + """ + Delete an image. + + It should go without saying, but you can only delete images you + created. + """ + image = self._find_image(args.image) + image.delete() + + @arg('--hard', + dest='reboot_type', + action='store_const', + const=servers.REBOOT_HARD, + default=servers.REBOOT_SOFT, + help='Perform a hard reboot (instead of a soft one).') + @arg('server', metavar='', help='Name or ID of server.') + def do_reboot(self, args): + """Reboot a server.""" + self._find_server(args.server).reboot(args.reboot_type) + + @arg('server', metavar='', help='Name or ID of server.') + @arg('image', metavar='', help="Name or ID of new image.") + def do_rebuild(self, args): + """Shutdown, re-image, and re-boot a server.""" + server = self._find_server(args.server) + image = self._find_image(args.image) + server.rebuild(image) + + @arg('server', metavar='', help='Name (old name) or ID of server.') + @arg('name', metavar='', help='New name for the server.') + def do_rename(self, args): + """Rename a server.""" + self._find_server(args.server).update(name=args.name) + + @arg('server', metavar='', help='Name or ID of server.') + @arg('flavor', metavar='', help="Name or ID of new flavor.") + def do_resize(self, args): + """Resize a server.""" + server = self._find_server(args.server) + flavor = self._find_flavor(args.flavor) + server.resize(flavor) + + @arg('server', metavar='', help='Name or ID of server.') + def do_migrate(self, args): + """Migrate a server.""" + self._find_server(args.server).migrate() + + @arg('server', metavar='', help='Name or ID of server.') + def do_pause(self, args): + """Pause a server.""" + self._find_server(args.server).pause() + + @arg('server', metavar='', help='Name or ID of server.') + def do_unpause(self, args): + """Unpause a server.""" + self._find_server(args.server).unpause() + + @arg('server', metavar='', help='Name or ID of server.') + def do_suspend(self, args): + """Suspend a server.""" + self._find_server(args.server).suspend() + + @arg('server', metavar='', help='Name or ID of server.') + def do_resume(self, args): + """Resume a server.""" + self._find_server(args.server).resume() + + @arg('server', metavar='', help='Name or ID of server.') + def do_rescue(self, args): + """Rescue a server.""" + self._find_server(args.server).rescue() + + @arg('server', metavar='', help='Name or ID of server.') + def do_unrescue(self, args): + """Unrescue a server.""" + self._find_server(args.server).unrescue() + + @arg('server', metavar='', help='Name or ID of server.') + def do_diagnostics(self, args): + """Retrieve server diagnostics.""" + print_dict(self.cs.servers.diagnostics(args.server)[1]) + + @arg('server', metavar='', help='Name or ID of server.') + def do_actions(self, args): + """Retrieve server actions.""" + print_list( + self.cs.servers.actions(args.server), + ["Created_At", "Action", "Error"]) + + @arg('server', metavar='', help='Name or ID of server.') + def do_resize_confirm(self, args): + """Confirm a previous resize.""" + self._find_server(args.server).confirm_resize() + + @arg('server', metavar='', help='Name or ID of server.') + def do_resize_revert(self, args): + """Revert a previous resize (and return to the previous VM).""" + self._find_server(args.server).revert_resize() + + @arg('server', metavar='', help='Name or ID of server.') + def do_root_password(self, args): + """ + Change the root password for a server. + """ + server = self._find_server(args.server) + p1 = getpass.getpass('New password: ') + p2 = getpass.getpass('Again: ') + if p1 != p2: + raise CommandError("Passwords do not match.") + server.change_password(p1) + + @arg('server', metavar='', help='Name or ID of server.') + def do_show(self, args): + """Show details about the given server.""" + s = self._find_server(args.server) + + info = s._info.copy() + addresses = info.pop('addresses') + for network_name in addresses.keys(): + ips = map(lambda x: x["addr"], addresses[network_name]) + info['%s ip' % network_name] = ', '.join(ips) + + flavor = info.get('flavor', {}) + flavor_id = flavor.get('id') + if flavor_id: + info['flavor'] = self._find_flavor(flavor_id).name + image = info.get('image', {}) + image_id = image.get('id') + if image_id: + info['image'] = self._find_image(image_id).name + + print_dict(info) + + @arg('server', metavar='', help='Name or ID of server.') + def do_delete(self, args): + """Immediately shut down and delete a server.""" + self._find_server(args.server).delete() + + def _find_server(self, server): + """Get a server by name or ID.""" + return self._find_resource(self.cs.servers, server) + + def _find_image(self, image): + """Get an image by name or ID.""" + return self._find_resource(self.cs.images, image) + + def _find_flavor(self, flavor): + """Get a flavor by name, ID, or RAM size.""" + try: + return self._find_resource(self.cs.flavors, flavor) + except exceptions.NotFound: + return self.cs.flavors.find(ram=flavor) + + def _find_resource(self, manager, name_or_id): + """Helper for the _find_* methods.""" + try: + if isinstance(name_or_id, int) or name_or_id.isdigit(): + return manager.get(int(name_or_id)) + + try: + uuid.UUID(name_or_id) + return manager.get(name_or_id) + except ValueError: + return manager.find(name=name_or_id) + except exceptions.NotFound: + raise CommandError("No %s with a name or ID of '%s' exists." % + (manager.resource_class.__name__.lower(), name_or_id)) + + +# I'm picky about my shell help. +class OpenStackHelpFormatter(argparse.HelpFormatter): + def start_section(self, heading): + # Title-case the headings + heading = '%s%s' % (heading[0].upper(), heading[1:]) + super(OpenStackHelpFormatter, self).start_section(heading) + + +# Helpers +def print_list(objs, fields, formatters={}): + pt = prettytable.PrettyTable([f for f in fields], caching=False) + pt.aligns = ['l' for f in fields] + + for o in objs: + row = [] + for field in fields: + if field in formatters: + row.append(formatters[field](o)) + else: + field_name = field.lower().replace(' ', '_') + data = getattr(o, field_name, '') + row.append(data) + pt.add_row(row) + + pt.printt(sortby=fields[0]) + + +def print_dict(d): + pt = prettytable.PrettyTable(['Property', 'Value'], caching=False) + pt.aligns = ['l', 'l'] + [pt.add_row(list(r)) for r in d.iteritems()] + pt.printt(sortby='Property') + + +def main(): + try: + OpenStackShell().main(sys.argv[1:]) + + except Exception, e: + if httplib2.debuglevel == 1: + raise # dump stack. + else: + print >> sys.stderr, e + sys.exit(1) diff --git a/novaclient/v1_1/zones.py b/novaclient/v1_1/zones.py new file mode 100644 index 000000000..4e8baa3ef --- /dev/null +++ b/novaclient/v1_1/zones.py @@ -0,0 +1,196 @@ +# Copyright 2011 OpenStack LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Zone interface. +""" + +from novaclient.v1_1 import base + + +class Weighting(base.Resource): + def __init__(self, manager, info): + self.name = "n/a" + super(Weighting, self).__init__(manager, info) + + def __repr__(self): + return "" % self.name + + def to_dict(self): + """Return the original info setting, which is a dict.""" + return self._info + + +class Zone(base.Resource): + def __init__(self, manager, info): + self.name = "n/a" + self.is_active = "n/a" + self.capabilities = "n/a" + super(Zone, self).__init__(manager, info) + + def __repr__(self): + return "" % self.api_url + + def delete(self): + """ + Delete a child zone. + """ + self.manager.delete(self) + + def update(self, api_url=None, username=None, password=None, + weight_offset=None, weight_scale=None): + """ + Update the name for this child zone. + + :param api_url: Update the child zone's API URL. + :param username: Update the child zone's username. + :param password: Update the child zone's password. + :param weight_offset: Update the child zone's weight offset. + :param weight_scale: Update the child zone's weight scale. + """ + self.manager.update(self, api_url, username, password, + weight_offset, weight_scale) + + +class ZoneManager(base.BootingManagerWithFind): + resource_class = Zone + + def info(self): + """ + Get info on this zone. + + :rtype: :class:`Zone` + """ + return self._get("/zones/info", "zone") + + def get(self, zone): + """ + Get a child zone. + + :param server: ID of the :class:`Zone` to get. + :rtype: :class:`Zone` + """ + return self._get("/zones/%s" % base.getid(zone), "zone") + + def list(self, detailed=True): + """ + Get a list of child zones. + :rtype: list of :class:`Zone` + """ + detail = "" + if detailed: + detail = "/detail" + return self._list("/zones%s" % detail, "zones") + + def create(self, api_url, username, password, + weight_offset=0.0, weight_scale=1.0): + """ + Create a new child zone. + + :param api_url: The child zone's API URL. + :param username: The child zone's username. + :param password: The child zone's password. + :param weight_offset: The child zone's weight offset. + :param weight_scale: The child zone's weight scale. + """ + body = {"zone": { + "api_url": api_url, + "username": username, + "password": password, + "weight_offset": weight_offset, + "weight_scale": weight_scale + }} + + return self._create("/zones", body, "zone") + + def boot(self, name, image, flavor, ipgroup=None, meta=None, files=None, + zone_blob=None, reservation_id=None, min_count=None, + max_count=None): + """ + Create (boot) a new server while being aware of Zones. + + :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. + :param zone_blob: a single (encrypted) string which is used internally + by Nova for routing between Zones. Users cannot populate + this field. + :param reservation_id: a UUID for the set of servers being requested. + :param min_count: minimum number of servers to create. + :param max_count: maximum number of servers to create. + """ + if not min_count: + min_count = 1 + if not max_count: + max_count = min_count + return self._boot("/zones/boot", "reservation_id", name, image, flavor, + ipgroup=ipgroup, meta=meta, files=files, + zone_blob=zone_blob, reservation_id=reservation_id, + return_raw=True, min_count=min_count, + max_count=max_count) + + def select(self, *args, **kwargs): + """ + Given requirements for a new instance, select hosts + in this zone that best match those requirements. + """ + # 'specs' may be passed in as None, so change to an empty string. + specs = kwargs.get("specs") or "" + url = "/zones/select" + weighting_list = self._list(url, "weights", Weighting, body=specs) + return [wt.to_dict() for wt in weighting_list] + + def delete(self, zone): + """ + Delete a child zone. + """ + self._delete("/zones/%s" % base.getid(zone)) + + def update(self, zone, api_url=None, username=None, password=None, + weight_offset=None, weight_scale=None): + """ + Update the name or the api_url for a zone. + + :param zone: The :class:`Zone` (or its ID) to update. + :param api_url: Update the API URL. + :param username: Update the username. + :param password: Update the password. + :param weight_offset: Update the child zone's weight offset. + :param weight_scale: Update the child zone's weight scale. + """ + + body = {"zone": {}} + if api_url: + body["zone"]["api_url"] = api_url + if username: + body["zone"]["username"] = username + if password: + body["zone"]["password"] = password + if weight_offset: + body["zone"]["weight_offset"] = weight_offset + if weight_scale: + body["zone"]["weight_scale"] = weight_scale + if not len(body["zone"]): + return + self._update("/zones/%s" % base.getid(zone), body) diff --git a/tests/test_backup_schedules.py b/tests/test_backup_schedules.py deleted file mode 100644 index 60b2ac5ea..000000000 --- a/tests/test_backup_schedules.py +++ /dev/null @@ -1,58 +0,0 @@ - -from novaclient.backup_schedules import * -from fakeserver import FakeServer -from utils import assert_isinstance - -cs = FakeServer() - - -def test_get_backup_schedule(): - s = cs.servers.get(1234) - - # access via manager - b = cs.backup_schedules.get(server=s) - assert_isinstance(b, BackupSchedule) - cs.assert_called('GET', '/servers/1234/backup_schedule') - - b = cs.backup_schedules.get(server=1234) - assert_isinstance(b, BackupSchedule) - cs.assert_called('GET', '/servers/1234/backup_schedule') - - # access via instance - assert_isinstance(s.backup_schedule, BackupSchedule) - cs.assert_called('GET', '/servers/1234/backup_schedule') - - # Just for coverage's sake - b = s.backup_schedule.get() - cs.assert_called('GET', '/servers/1234/backup_schedule') - - -def test_create_update_backup_schedule(): - s = cs.servers.get(1234) - - # create/update via manager - cs.backup_schedules.update( - server=s, - enabled=True, - weekly=BACKUP_WEEKLY_THURSDAY, - daily=BACKUP_DAILY_H_1000_1200 - ) - cs.assert_called('POST', '/servers/1234/backup_schedule') - - # and via instance - s.backup_schedule.update(enabled=False) - cs.assert_called('POST', '/servers/1234/backup_schedule') - - -def test_delete_backup_schedule(): - s = cs.servers.get(1234) - - # delete via manager - cs.backup_schedules.delete(s) - cs.assert_called('DELETE', '/servers/1234/backup_schedule') - cs.backup_schedules.delete(1234) - cs.assert_called('DELETE', '/servers/1234/backup_schedule') - - # and via instance - s.backup_schedule.delete() - cs.assert_called('DELETE', '/servers/1234/backup_schedule') diff --git a/tests/test_flavors.py b/tests/test_flavors.py deleted file mode 100644 index cf4c6cfb8..000000000 --- a/tests/test_flavors.py +++ /dev/null @@ -1,37 +0,0 @@ -from novaclient import Flavor, NotFound -from fakeserver import FakeServer -from utils import assert_isinstance -from nose.tools import assert_raises, assert_equal - -cs = FakeServer() - - -def test_list_flavors(): - fl = cs.flavors.list() - cs.assert_called('GET', '/flavors/detail') - [assert_isinstance(f, Flavor) for f in fl] - - -def test_list_flavors_undetailed(): - fl = cs.flavors.list(detailed=False) - cs.assert_called('GET', '/flavors') - [assert_isinstance(f, Flavor) for f in fl] - - -def test_get_flavor_details(): - f = cs.flavors.get(1) - cs.assert_called('GET', '/flavors/1') - assert_isinstance(f, Flavor) - assert_equal(f.ram, 256) - assert_equal(f.disk, 10) - - -def test_find(): - f = cs.flavors.find(ram=256) - cs.assert_called('GET', '/flavors/detail') - assert_equal(f.name, '256 MB Server') - - f = cs.flavors.find(disk=20) - assert_equal(f.name, '512 MB Server') - - assert_raises(NotFound, cs.flavors.find, disk=12345) diff --git a/tests/test_images.py b/tests/test_images.py deleted file mode 100644 index 1cc150a3f..000000000 --- a/tests/test_images.py +++ /dev/null @@ -1,47 +0,0 @@ -from novaclient import Image -from fakeserver import FakeServer -from utils import assert_isinstance -from nose.tools import assert_equal - -cs = FakeServer() - - -def test_list_images(): - il = cs.images.list() - cs.assert_called('GET', '/images/detail') - [assert_isinstance(i, Image) for i in il] - - -def test_list_images_undetailed(): - il = cs.images.list(detailed=False) - cs.assert_called('GET', '/images') - [assert_isinstance(i, Image) for i in il] - - -def test_get_image_details(): - i = cs.images.get(1) - cs.assert_called('GET', '/images/1') - assert_isinstance(i, Image) - assert_equal(i.id, 1) - assert_equal(i.name, 'CentOS 5.2') - - -def test_create_image(): - i = cs.images.create(server=1234, name="Just in case") - cs.assert_called('POST', '/images') - assert_isinstance(i, Image) - - -def test_delete_image(): - cs.images.delete(1) - cs.assert_called('DELETE', '/images/1') - - -def test_find(): - i = cs.images.find(name="CentOS 5.2") - assert_equal(i.id, 1) - cs.assert_called('GET', '/images/detail') - - iml = cs.images.findall(status='SAVING') - assert_equal(len(iml), 1) - assert_equal(iml[0].name, 'My Server Backup') diff --git a/tests/test_ipgroups.py b/tests/test_ipgroups.py deleted file mode 100644 index 98a6f151d..000000000 --- a/tests/test_ipgroups.py +++ /dev/null @@ -1,48 +0,0 @@ -from novaclient import IPGroup -from fakeserver import FakeServer -from utils import assert_isinstance -from nose.tools import assert_equal - -cs = FakeServer() - - -def test_list_ipgroups(): - ipl = cs.ipgroups.list() - cs.assert_called('GET', '/shared_ip_groups/detail') - [assert_isinstance(ipg, IPGroup) for ipg in ipl] - - -def test_list_ipgroups_undetailed(): - ipl = cs.ipgroups.list(detailed=False) - cs.assert_called('GET', '/shared_ip_groups') - [assert_isinstance(ipg, IPGroup) for ipg in ipl] - - -def test_get_ipgroup(): - ipg = cs.ipgroups.get(1) - cs.assert_called('GET', '/shared_ip_groups/1') - assert_isinstance(ipg, IPGroup) - - -def test_create_ipgroup(): - ipg = cs.ipgroups.create("My group", 1234) - cs.assert_called('POST', '/shared_ip_groups') - assert_isinstance(ipg, IPGroup) - - -def test_delete_ipgroup(): - ipg = cs.ipgroups.get(1) - ipg.delete() - cs.assert_called('DELETE', '/shared_ip_groups/1') - cs.ipgroups.delete(ipg) - cs.assert_called('DELETE', '/shared_ip_groups/1') - cs.ipgroups.delete(1) - cs.assert_called('DELETE', '/shared_ip_groups/1') - - -def test_find(): - ipg = cs.ipgroups.find(name='group1') - cs.assert_called('GET', '/shared_ip_groups/detail') - assert_equal(ipg.name, 'group1') - ipgl = cs.ipgroups.findall(id=1) - assert_equal(ipgl, [IPGroup(None, {'id': 1})]) diff --git a/tests/test_servers.py b/tests/test_servers.py deleted file mode 100644 index b88ed9212..000000000 --- a/tests/test_servers.py +++ /dev/null @@ -1,174 +0,0 @@ -import StringIO -from nose.tools import assert_equal -from fakeserver import FakeServer -from utils import assert_isinstance -from novaclient import Server - -cs = FakeServer() - - -def test_list_servers(): - sl = cs.servers.list() - cs.assert_called('GET', '/servers/detail') - [assert_isinstance(s, Server) for s in sl] - - -def test_list_servers_undetailed(): - sl = cs.servers.list(detailed=False) - cs.assert_called('GET', '/servers') - [assert_isinstance(s, Server) for s in sl] - - -def test_get_server_details(): - s = cs.servers.get(1234) - cs.assert_called('GET', '/servers/1234') - assert_isinstance(s, Server) - assert_equal(s.id, 1234) - assert_equal(s.status, 'BUILD') - - -def test_create_server(): - s = cs.servers.create( - name="My server", - image=1, - flavor=1, - meta={'foo': 'bar'}, - ipgroup=1, - files={ - '/etc/passwd': 'some data', # a file - '/tmp/foo.txt': StringIO.StringIO('data') # a stream - } - ) - cs.assert_called('POST', '/servers') - assert_isinstance(s, Server) - - -def test_update_server(): - s = cs.servers.get(1234) - - # Update via instance - s.update(name='hi') - cs.assert_called('PUT', '/servers/1234') - s.update(name='hi', password='there') - cs.assert_called('PUT', '/servers/1234') - - # Silly, but not an error - s.update() - - # Update via manager - cs.servers.update(s, name='hi') - cs.assert_called('PUT', '/servers/1234') - cs.servers.update(1234, password='there') - cs.assert_called('PUT', '/servers/1234') - cs.servers.update(s, name='hi', password='there') - cs.assert_called('PUT', '/servers/1234') - - -def test_delete_server(): - s = cs.servers.get(1234) - s.delete() - cs.assert_called('DELETE', '/servers/1234') - cs.servers.delete(1234) - cs.assert_called('DELETE', '/servers/1234') - cs.servers.delete(s) - cs.assert_called('DELETE', '/servers/1234') - - -def test_find(): - s = cs.servers.find(name='sample-server') - cs.assert_called('GET', '/servers/detail') - assert_equal(s.name, 'sample-server') - - # Find with multiple results arbitraility returns the first item - s = cs.servers.find(flavorId=1) - sl = cs.servers.findall(flavorId=1) - assert_equal(sl[0], s) - assert_equal([s.id for s in sl], [1234, 5678]) - - -def test_share_ip(): - s = cs.servers.get(1234) - - # Share via instance - s.share_ip(ipgroup=1, address='1.2.3.4') - cs.assert_called('PUT', '/servers/1234/ips/public/1.2.3.4') - - # Share via manager - cs.servers.share_ip(s, ipgroup=1, address='1.2.3.4', configure=False) - cs.assert_called('PUT', '/servers/1234/ips/public/1.2.3.4') - - -def test_unshare_ip(): - s = cs.servers.get(1234) - - # Unshare via instance - s.unshare_ip('1.2.3.4') - cs.assert_called('DELETE', '/servers/1234/ips/public/1.2.3.4') - - # Unshare via manager - cs.servers.unshare_ip(s, '1.2.3.4') - cs.assert_called('DELETE', '/servers/1234/ips/public/1.2.3.4') - - -def test_reboot_server(): - s = cs.servers.get(1234) - s.reboot() - cs.assert_called('POST', '/servers/1234/action') - cs.servers.reboot(s, type='HARD') - cs.assert_called('POST', '/servers/1234/action') - - -def test_rebuild_server(): - s = cs.servers.get(1234) - s.rebuild(image=1) - cs.assert_called('POST', '/servers/1234/action') - cs.servers.rebuild(s, image=1) - cs.assert_called('POST', '/servers/1234/action') - - -def test_resize_server(): - s = cs.servers.get(1234) - s.resize(flavor=1) - cs.assert_called('POST', '/servers/1234/action') - cs.servers.resize(s, flavor=1) - cs.assert_called('POST', '/servers/1234/action') - - -def test_confirm_resized_server(): - s = cs.servers.get(1234) - s.confirm_resize() - cs.assert_called('POST', '/servers/1234/action') - cs.servers.confirm_resize(s) - cs.assert_called('POST', '/servers/1234/action') - - -def test_revert_resized_server(): - s = cs.servers.get(1234) - s.revert_resize() - cs.assert_called('POST', '/servers/1234/action') - cs.servers.revert_resize(s) - cs.assert_called('POST', '/servers/1234/action') - - -def test_migrate_server(): - s = cs.servers.get(1234) - s.migrate() - cs.assert_called('POST', '/servers/1234/action') - cs.servers.migrate(s) - cs.assert_called('POST', '/servers/1234/action') - - -def test_add_fixed_ip(): - s = cs.servers.get(1234) - s.add_fixed_ip(1) - cs.assert_called('POST', '/servers/1234/action') - cs.servers.add_fixed_ip(s, 1) - cs.assert_called('POST', '/servers/1234/action') - - -def test_remove_fixed_ip(): - s = cs.servers.get(1234) - s.remove_fixed_ip('10.0.0.1') - cs.assert_called('POST', '/servers/1234/action') - cs.servers.remove_fixed_ip(s, '10.0.0.1') - cs.assert_called('POST', '/servers/1234/action') diff --git a/tests/v1_0/__init__.py b/tests/v1_0/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/fakeserver.py b/tests/v1_0/fakes.py similarity index 96% rename from tests/fakeserver.py rename to tests/v1_0/fakes.py index 232c7b350..2ee96e0ae 100644 --- a/tests/fakeserver.py +++ b/tests/v1_0/fakes.py @@ -6,21 +6,29 @@ wrong the tests might fail. I've indicated in comments the places where actual behavior differs from the spec. """ -import httplib2 +from __future__ import absolute_import + import urlparse import urllib -from nose.tools import assert_equal -from novaclient import OpenStack -from novaclient.client import OpenStackClient -from utils import fail, assert_in, assert_not_in, assert_has_keys + +import httplib2 + +from novaclient.v1_0 import Client +from novaclient.v1_0.client import HTTPClient + +from .utils import fail, assert_in, assert_not_in, assert_has_keys -class FakeServer(OpenStack): +def assert_equal(value_one, value_two): + assert value_one == value_two + + +class FakeClient(Client): def __init__(self, username=None, password=None, project_id=None, auth_url=None): - super(FakeServer, self).__init__('username', 'apikey', + super(FakeClient, self).__init__('username', 'apikey', 'project_id', 'auth_url') - self.client = FakeClient() + self.client = FakeHTTPClient() def assert_called(self, method, url, body=None): """ @@ -56,7 +64,7 @@ class FakeServer(OpenStack): found = True break - assert found, 'Expected %s %s; got %s' % \ + assert found, 'Expected %s; got %s' % \ (expected, self.client.callstack) if body is not None: assert_equal(entry[2], body) @@ -67,7 +75,7 @@ class FakeServer(OpenStack): pass -class FakeClient(OpenStackClient): +class FakeHTTPClient(HTTPClient): def __init__(self): self.username = 'username' self.apikey = 'apikey' @@ -412,7 +420,7 @@ class FakeClient(OpenStackClient): def get_zones_detail(self, **kw): return (200, {'zones': [ - {'id': 1, 'api_url': 'http://foo.com', 'username': 'bob', + {'id': 1, 'api_url': 'http://foo.com', 'username': 'bob', 'password': 'qwerty'}, {'id': 2, 'api_url': 'http://foo.com', 'username': 'alice', 'password': 'password'} diff --git a/tests/test_accounts.py b/tests/v1_0/test_accounts.py similarity index 62% rename from tests/test_accounts.py rename to tests/v1_0/test_accounts.py index 37e37c553..93eeed92f 100644 --- a/tests/test_accounts.py +++ b/tests/v1_0/test_accounts.py @@ -1,14 +1,13 @@ +from __future__ import absolute_import + import StringIO -from nose.tools import assert_equal +from .fakes import FakeClient -from fakeserver import FakeServer -from novaclient import Account - -cs = FakeServer() +os = FakeClient() def test_instance_creation_for_account(): - s = cs.accounts.create_instance_for( + s = os.accounts.create_instance_for( account_id='test_account', name="My server", image=1, @@ -19,4 +18,4 @@ def test_instance_creation_for_account(): '/etc/passwd': 'some data', # a file '/tmp/foo.txt': StringIO.StringIO('data') # a stream }) - cs.assert_called('POST', '/accounts/test_account/create_instance') + os.assert_called('POST', '/accounts/test_account/create_instance') diff --git a/tests/test_auth.py b/tests/v1_0/test_auth.py similarity index 81% rename from tests/test_auth.py rename to tests/v1_0/test_auth.py index 206fb851d..d27907cb4 100644 --- a/tests/test_auth.py +++ b/tests/v1_0/test_auth.py @@ -1,11 +1,13 @@ import mock -import novaclient import httplib2 from nose.tools import assert_raises, assert_equal +import novaclient.v1_0 +from novaclient.v1_0 import exceptions + def test_authenticate_success(): - cs = novaclient.OpenStack("username", "apikey", "project_id") + cs = novaclient.v1_0.Client("username", "apikey", "project_id") auth_response = httplib2.Response({ 'status': 204, 'x-server-management-url': @@ -32,19 +34,19 @@ def test_authenticate_success(): def test_authenticate_failure(): - cs = novaclient.OpenStack("username", "apikey", "project_id") + cs = novaclient.v1_0.Client("username", "apikey", "project_id") auth_response = httplib2.Response({'status': 401}) mock_request = mock.Mock(return_value=(auth_response, None)) @mock.patch.object(httplib2.Http, "request", mock_request) def test_auth_call(): - assert_raises(novaclient.Unauthorized, cs.client.authenticate) + assert_raises(exceptions.Unauthorized, cs.client.authenticate) test_auth_call() def test_auth_automatic(): - client = novaclient.OpenStack("username", "apikey", "project_id").client + client = novaclient.v1_0.Client("username", "apikey", "project_id").client client.management_url = '' mock_request = mock.Mock(return_value=(None, None)) @@ -59,7 +61,7 @@ def test_auth_automatic(): def test_auth_manual(): - cs = novaclient.OpenStack("username", "apikey", "project_id") + cs = novaclient.v1_0.Client("username", "apikey", "project_id") @mock.patch.object(cs.client, 'authenticate') def test_auth_call(m): diff --git a/tests/v1_0/test_backup_schedules.py b/tests/v1_0/test_backup_schedules.py new file mode 100644 index 000000000..831f81067 --- /dev/null +++ b/tests/v1_0/test_backup_schedules.py @@ -0,0 +1,60 @@ +from __future__ import absolute_import + +from novaclient.v1_0 import backup_schedules + +from .fakes import FakeClient +from .utils import assert_isinstance + +os = FakeClient() + + +def test_get_backup_schedule(): + s = os.servers.get(1234) + + # access via manager + b = os.backup_schedules.get(server=s) + assert_isinstance(b, backup_schedules.BackupSchedule) + os.assert_called('GET', '/servers/1234/backup_schedule') + + b = os.backup_schedules.get(server=1234) + assert_isinstance(b, backup_schedules.BackupSchedule) + os.assert_called('GET', '/servers/1234/backup_schedule') + + # access via instance + assert_isinstance(s.backup_schedule, backup_schedules.BackupSchedule) + os.assert_called('GET', '/servers/1234/backup_schedule') + + # Just for coverage's sake + b = s.backup_schedule.get() + os.assert_called('GET', '/servers/1234/backup_schedule') + + +def test_create_update_backup_schedule(): + s = os.servers.get(1234) + + # create/update via manager + os.backup_schedules.update( + server=s, + enabled=True, + weekly=backup_schedules.BACKUP_WEEKLY_THURSDAY, + daily=backup_schedules.BACKUP_DAILY_H_1000_1200 + ) + os.assert_called('POST', '/servers/1234/backup_schedule') + + # and via instance + s.backup_schedule.update(enabled=False) + os.assert_called('POST', '/servers/1234/backup_schedule') + + +def test_delete_backup_schedule(): + s = os.servers.get(1234) + + # delete via manager + os.backup_schedules.delete(s) + os.assert_called('DELETE', '/servers/1234/backup_schedule') + os.backup_schedules.delete(1234) + os.assert_called('DELETE', '/servers/1234/backup_schedule') + + # and via instance + s.backup_schedule.delete() + os.assert_called('DELETE', '/servers/1234/backup_schedule') diff --git a/tests/test_base.py b/tests/v1_0/test_base.py similarity index 50% rename from tests/test_base.py rename to tests/v1_0/test_base.py index 8477987f7..db440bbe6 100644 --- a/tests/test_base.py +++ b/tests/v1_0/test_base.py @@ -1,59 +1,61 @@ +from __future__ import absolute_import import mock -import novaclient.base -from novaclient import Flavor -from novaclient.exceptions import NotFound -from novaclient.base import Resource from nose.tools import assert_equal, assert_not_equal, assert_raises -from fakeserver import FakeServer -cs = FakeServer() +from novaclient.v1_0 import flavors +from novaclient.v1_0 import exceptions +from novaclient.v1_0 import base + +from .fakes import FakeClient + +os = FakeClient() def test_resource_repr(): - r = Resource(None, dict(foo="bar", baz="spam")) + r = base.Resource(None, dict(foo="bar", baz="spam")) assert_equal(repr(r), "") def test_getid(): - assert_equal(novaclient.base.getid(4), 4) + assert_equal(base.getid(4), 4) class O(object): id = 4 - assert_equal(novaclient.base.getid(O), 4) + assert_equal(base.getid(O), 4) def test_resource_lazy_getattr(): - f = Flavor(cs.flavors, {'id': 1}) + f = flavors.Flavor(os.flavors, {'id': 1}) assert_equal(f.name, '256 MB Server') - cs.assert_called('GET', '/flavors/1') + os.assert_called('GET', '/flavors/1') # Missing stuff still fails after a second get assert_raises(AttributeError, getattr, f, 'blahblah') - cs.assert_called('GET', '/flavors/1') + os.assert_called('GET', '/flavors/1') def test_eq(): # Two resources of the same type with the same id: equal - r1 = Resource(None, {'id': 1, 'name': 'hi'}) - r2 = Resource(None, {'id': 1, 'name': 'hello'}) + r1 = base.Resource(None, {'id': 1, 'name': 'hi'}) + r2 = base.Resource(None, {'id': 1, 'name': 'hello'}) assert_equal(r1, r2) # Two resoruces of different types: never equal - r1 = Resource(None, {'id': 1}) - r2 = Flavor(None, {'id': 1}) + r1 = base.Resource(None, {'id': 1}) + r2 = flavors.Flavor(None, {'id': 1}) assert_not_equal(r1, r2) # Two resources with no ID: equal if their info is equal - r1 = Resource(None, {'name': 'joe', 'age': 12}) - r2 = Resource(None, {'name': 'joe', 'age': 12}) + r1 = base.Resource(None, {'name': 'joe', 'age': 12}) + r2 = base.Resource(None, {'name': 'joe', 'age': 12}) assert_equal(r1, r2) def test_findall_invalid_attribute(): # Make sure findall with an invalid attribute doesn't cause errors. # The following should not raise an exception. - cs.flavors.findall(vegetable='carrot') + os.flavors.findall(vegetable='carrot') # However, find() should raise an error - assert_raises(NotFound, cs.flavors.find, vegetable='carrot') + assert_raises(exceptions.NotFound, os.flavors.find, vegetable='carrot') diff --git a/tests/test_client.py b/tests/v1_0/test_client.py similarity index 88% rename from tests/test_client.py rename to tests/v1_0/test_client.py index b22de0347..e7a63b7d7 100644 --- a/tests/test_client.py +++ b/tests/v1_0/test_client.py @@ -1,6 +1,7 @@ import mock import httplib2 -from novaclient.client import OpenStackClient + +from novaclient.v1_0 import client from nose.tools import assert_equal fake_response = httplib2.Response({"status": 200}) @@ -8,15 +9,15 @@ fake_body = '{"hi": "there"}' mock_request = mock.Mock(return_value=(fake_response, fake_body)) -def client(): - cl = OpenStackClient("username", "apikey", "project_id", "auth_test") +def get_client(): + cl = client.HTTPClient("username", "apikey", "project_id", "auth_test") cl.management_url = "http://example.com" cl.auth_token = "token" return cl def test_get(): - cl = client() + cl = get_client() @mock.patch.object(httplib2.Http, "request", mock_request) @mock.patch('time.time', mock.Mock(return_value=1234)) @@ -34,7 +35,7 @@ def test_get(): def test_post(): - cl = client() + cl = get_client() @mock.patch.object(httplib2.Http, "request", mock_request) def test_post_call(): diff --git a/tests/v1_0/test_flavors.py b/tests/v1_0/test_flavors.py new file mode 100644 index 000000000..3c9f644c3 --- /dev/null +++ b/tests/v1_0/test_flavors.py @@ -0,0 +1,42 @@ +from __future__ import absolute_import + +from nose.tools import assert_raises, assert_equal + +from novaclient.v1_0 import flavors +from novaclient.v1_0 import exceptions + +from .fakes import FakeClient +from .utils import assert_isinstance + +os = FakeClient() + + +def test_list_flavors(): + fl = os.flavors.list() + os.assert_called('GET', '/flavors/detail') + [assert_isinstance(f, flavors.Flavor) for f in fl] + + +def test_list_flavors_undetailed(): + fl = os.flavors.list(detailed=False) + os.assert_called('GET', '/flavors') + [assert_isinstance(f, flavors.Flavor) for f in fl] + + +def test_get_flavor_details(): + f = os.flavors.get(1) + os.assert_called('GET', '/flavors/1') + assert_isinstance(f, flavors.Flavor) + assert_equal(f.ram, 256) + assert_equal(f.disk, 10) + + +def test_find(): + f = os.flavors.find(ram=256) + os.assert_called('GET', '/flavors/detail') + assert_equal(f.name, '256 MB Server') + + f = os.flavors.find(disk=20) + assert_equal(f.name, '512 MB Server') + + assert_raises(exceptions.NotFound, os.flavors.find, disk=12345) diff --git a/tests/v1_0/test_images.py b/tests/v1_0/test_images.py new file mode 100644 index 000000000..9e9bb4732 --- /dev/null +++ b/tests/v1_0/test_images.py @@ -0,0 +1,51 @@ +from __future__ import absolute_import + +from nose.tools import assert_equal + +from novaclient.v1_0 import images + +from .fakes import FakeClient +from .utils import assert_isinstance + +os = FakeClient() + + +def test_list_images(): + il = os.images.list() + os.assert_called('GET', '/images/detail') + [assert_isinstance(i, images.Image) for i in il] + + +def test_list_images_undetailed(): + il = os.images.list(detailed=False) + os.assert_called('GET', '/images') + [assert_isinstance(i, images.Image) for i in il] + + +def test_get_image_details(): + i = os.images.get(1) + os.assert_called('GET', '/images/1') + assert_isinstance(i, images.Image) + assert_equal(i.id, 1) + assert_equal(i.name, 'CentOS 5.2') + + +def test_create_image(): + i = os.images.create(server=1234, name="Just in case") + os.assert_called('POST', '/images') + assert_isinstance(i, images.Image) + + +def test_delete_image(): + os.images.delete(1) + os.assert_called('DELETE', '/images/1') + + +def test_find(): + i = os.images.find(name="CentOS 5.2") + assert_equal(i.id, 1) + os.assert_called('GET', '/images/detail') + + iml = os.images.findall(status='SAVING') + assert_equal(len(iml), 1) + assert_equal(iml[0].name, 'My Server Backup') diff --git a/tests/v1_0/test_ipgroups.py b/tests/v1_0/test_ipgroups.py new file mode 100644 index 000000000..3c89d4c68 --- /dev/null +++ b/tests/v1_0/test_ipgroups.py @@ -0,0 +1,52 @@ +from __future__ import absolute_import + +from nose.tools import assert_equal + +from novaclient.v1_0 import ipgroups + +from .fakes import FakeClient +from .utils import assert_isinstance + +os = FakeClient() + + +def test_list_ipgroups(): + ipl = os.ipgroups.list() + os.assert_called('GET', '/shared_ip_groups/detail') + [assert_isinstance(ipg, ipgroups.IPGroup) for ipg in ipl] + + +def test_list_ipgroups_undetailed(): + ipl = os.ipgroups.list(detailed=False) + os.assert_called('GET', '/shared_ip_groups') + [assert_isinstance(ipg, ipgroups.IPGroup) for ipg in ipl] + + +def test_get_ipgroup(): + ipg = os.ipgroups.get(1) + os.assert_called('GET', '/shared_ip_groups/1') + assert_isinstance(ipg, ipgroups.IPGroup) + + +def test_create_ipgroup(): + ipg = os.ipgroups.create("My group", 1234) + os.assert_called('POST', '/shared_ip_groups') + assert_isinstance(ipg, ipgroups.IPGroup) + + +def test_delete_ipgroup(): + ipg = os.ipgroups.get(1) + ipg.delete() + os.assert_called('DELETE', '/shared_ip_groups/1') + os.ipgroups.delete(ipg) + os.assert_called('DELETE', '/shared_ip_groups/1') + os.ipgroups.delete(1) + os.assert_called('DELETE', '/shared_ip_groups/1') + + +def test_find(): + ipg = os.ipgroups.find(name='group1') + os.assert_called('GET', '/shared_ip_groups/detail') + assert_equal(ipg.name, 'group1') + ipgl = os.ipgroups.findall(id=1) + assert_equal(ipgl, [ipgroups.IPGroup(None, {'id': 1})]) diff --git a/tests/v1_0/test_servers.py b/tests/v1_0/test_servers.py new file mode 100644 index 000000000..e32b3d61b --- /dev/null +++ b/tests/v1_0/test_servers.py @@ -0,0 +1,180 @@ +from __future__ import absolute_import + +import StringIO + +from nose.tools import assert_equal + +from novaclient.v1_0 import servers + +from .fakes import FakeClient +from .utils import assert_isinstance + + +os = FakeClient() + + +def test_list_servers(): + sl = os.servers.list() + os.assert_called('GET', '/servers/detail') + [assert_isinstance(s, servers.Server) for s in sl] + + +def test_list_servers_undetailed(): + sl = os.servers.list(detailed=False) + os.assert_called('GET', '/servers') + [assert_isinstance(s, servers.Server) for s in sl] + + +def test_get_server_details(): + s = os.servers.get(1234) + os.assert_called('GET', '/servers/1234') + assert_isinstance(s, servers.Server) + assert_equal(s.id, 1234) + assert_equal(s.status, 'BUILD') + + +def test_create_server(): + s = os.servers.create( + name="My server", + image=1, + flavor=1, + meta={'foo': 'bar'}, + ipgroup=1, + files={ + '/etc/passwd': 'some data', # a file + '/tmp/foo.txt': StringIO.StringIO('data') # a stream + } + ) + os.assert_called('POST', '/servers') + assert_isinstance(s, servers.Server) + + +def test_update_server(): + s = os.servers.get(1234) + + # Update via instance + s.update(name='hi') + os.assert_called('PUT', '/servers/1234') + s.update(name='hi', password='there') + os.assert_called('PUT', '/servers/1234') + + # Silly, but not an error + s.update() + + # Update via manager + os.servers.update(s, name='hi') + os.assert_called('PUT', '/servers/1234') + os.servers.update(1234, password='there') + os.assert_called('PUT', '/servers/1234') + os.servers.update(s, name='hi', password='there') + os.assert_called('PUT', '/servers/1234') + + +def test_delete_server(): + s = os.servers.get(1234) + s.delete() + os.assert_called('DELETE', '/servers/1234') + os.servers.delete(1234) + os.assert_called('DELETE', '/servers/1234') + os.servers.delete(s) + os.assert_called('DELETE', '/servers/1234') + + +def test_find(): + s = os.servers.find(name='sample-server') + os.assert_called('GET', '/servers/detail') + assert_equal(s.name, 'sample-server') + + # Find with multiple results arbitraility returns the first item + s = os.servers.find(flavorId=1) + sl = os.servers.findall(flavorId=1) + assert_equal(sl[0], s) + assert_equal([s.id for s in sl], [1234, 5678]) + + +def test_share_ip(): + s = os.servers.get(1234) + + # Share via instance + s.share_ip(ipgroup=1, address='1.2.3.4') + os.assert_called('PUT', '/servers/1234/ips/public/1.2.3.4') + + # Share via manager + os.servers.share_ip(s, ipgroup=1, address='1.2.3.4', configure=False) + os.assert_called('PUT', '/servers/1234/ips/public/1.2.3.4') + + +def test_unshare_ip(): + s = os.servers.get(1234) + + # Unshare via instance + s.unshare_ip('1.2.3.4') + os.assert_called('DELETE', '/servers/1234/ips/public/1.2.3.4') + + # Unshare via manager + os.servers.unshare_ip(s, '1.2.3.4') + os.assert_called('DELETE', '/servers/1234/ips/public/1.2.3.4') + + +def test_reboot_server(): + s = os.servers.get(1234) + s.reboot() + os.assert_called('POST', '/servers/1234/action') + os.servers.reboot(s, type='HARD') + os.assert_called('POST', '/servers/1234/action') + + +def test_rebuild_server(): + s = os.servers.get(1234) + s.rebuild(image=1) + os.assert_called('POST', '/servers/1234/action') + os.servers.rebuild(s, image=1) + os.assert_called('POST', '/servers/1234/action') + + +def test_resize_server(): + s = os.servers.get(1234) + s.resize(flavor=1) + os.assert_called('POST', '/servers/1234/action') + os.servers.resize(s, flavor=1) + os.assert_called('POST', '/servers/1234/action') + + +def test_confirm_resized_server(): + s = os.servers.get(1234) + s.confirm_resize() + os.assert_called('POST', '/servers/1234/action') + os.servers.confirm_resize(s) + os.assert_called('POST', '/servers/1234/action') + + +def test_revert_resized_server(): + s = os.servers.get(1234) + s.revert_resize() + os.assert_called('POST', '/servers/1234/action') + os.servers.revert_resize(s) + os.assert_called('POST', '/servers/1234/action') + + +def test_migrate_server(): + s = os.servers.get(1234) + s.migrate() + os.assert_called('POST', '/servers/1234/action') + os.servers.migrate(s) + os.assert_called('POST', '/servers/1234/action') + + +def test_add_fixed_ip(): + s = os.servers.get(1234) + s.add_fixed_ip(1) + os.assert_called('POST', '/servers/1234/action') + os.servers.add_fixed_ip(s, 1) + os.assert_called('POST', '/servers/1234/action') + + +def test_remove_fixed_ip(): + s = os.servers.get(1234) + s.remove_fixed_ip('10.0.0.1') + os.assert_called('POST', '/servers/1234/action') + os.servers.remove_fixed_ip(s, '10.0.0.1') + os.assert_called('POST', '/servers/1234/action') diff --git a/tests/test_shell.py b/tests/v1_0/test_shell.py similarity index 97% rename from tests/test_shell.py rename to tests/v1_0/test_shell.py index 17ba5a442..bc607292e 100644 --- a/tests/test_shell.py +++ b/tests/v1_0/test_shell.py @@ -1,10 +1,15 @@ +from __future__ import absolute_import + import os import mock import httplib2 + from nose.tools import assert_raises, assert_equal -from novaclient.shell import OpenStackShell, CommandError -from fakeserver import FakeServer -from utils import assert_in + +from novaclient.v1_0.shell import OpenStackShell, CommandError + +from .fakes import FakeClient +from .utils import assert_in # Patch os.environ to avoid required auth info. @@ -21,7 +26,7 @@ def setup(): # of asserting that certain API calls were made. global shell, _shell, assert_called, assert_called_anytime _shell = OpenStackShell() - _shell._api_class = FakeServer + _shell._api_class = FakeClient assert_called = lambda m, u, b=None: _shell.cs.assert_called(m, u, b) assert_called_anytime = lambda m, u, b=None: \ _shell.cs.assert_called_anytime(m, u, b) @@ -330,7 +335,7 @@ def test_zone_add(): shell('zone-add http://zzz frank xxx 0.0 1.0') assert_called( 'POST', '/zones', - {'zone': {'api_url': 'http://zzz', 'username': 'frank', + {'zone': {'api_url': 'http://zzz', 'username': 'frank', 'password': 'xxx', 'weight_offset': '0.0', 'weight_scale': '1.0'}} ) diff --git a/tests/test_zones.py b/tests/v1_0/test_zones.py similarity index 84% rename from tests/test_zones.py rename to tests/v1_0/test_zones.py index cd773ad3e..2e6aa8487 100644 --- a/tests/test_zones.py +++ b/tests/v1_0/test_zones.py @@ -1,28 +1,33 @@ -import StringIO -from nose.tools import assert_equal -from fakeserver import FakeServer -from utils import assert_isinstance -from novaclient import Zone +from __future__ import absolute_import -os = FakeServer() +import StringIO + +from nose.tools import assert_equal + +from novaclient.v1_0 import zones + +from .fakes import FakeClient +from .utils import assert_isinstance + +os = FakeClient() def test_list_zones(): sl = os.zones.list() os.assert_called('GET', '/zones/detail') - [assert_isinstance(s, Zone) for s in sl] + [assert_isinstance(s, zones.Zone) for s in sl] def test_list_zones_undetailed(): sl = os.zones.list(detailed=False) os.assert_called('GET', '/zones') - [assert_isinstance(s, Zone) for s in sl] + [assert_isinstance(s, zones.Zone) for s in sl] def test_get_zone_details(): s = os.zones.get(1) os.assert_called('GET', '/zones/1') - assert_isinstance(s, Zone) + assert_isinstance(s, zones.Zone) assert_equal(s.id, 1) assert_equal(s.api_url, 'http://foo.com') @@ -31,7 +36,7 @@ def test_create_zone(): s = os.zones.create(api_url="http://foo.com", username='bob', password='xxx') os.assert_called('POST', '/zones') - assert_isinstance(s, Zone) + assert_isinstance(s, zones.Zone) def test_update_zone(): diff --git a/tests/testfile.txt b/tests/v1_0/testfile.txt similarity index 100% rename from tests/testfile.txt rename to tests/v1_0/testfile.txt diff --git a/tests/utils.py b/tests/v1_0/utils.py similarity index 100% rename from tests/utils.py rename to tests/v1_0/utils.py diff --git a/tests/v1_1/__init__.py b/tests/v1_1/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/v1_1/fakes.py b/tests/v1_1/fakes.py new file mode 100644 index 000000000..fc7793bf4 --- /dev/null +++ b/tests/v1_1/fakes.py @@ -0,0 +1,496 @@ +""" +A fake server that "responds" to API methods with pre-canned responses. + +All of these responses come from the spec, so if for some reason the spec's +wrong the tests might fail. I've indicated in comments the places where actual +behavior differs from the spec. +""" + +from __future__ import absolute_import + +import urlparse +import urllib + +import httplib2 + +from novaclient.v1_1 import Client +from novaclient.v1_1.client import HTTPClient + +from .utils import fail, assert_in, assert_not_in, assert_has_keys + + +def assert_equal(value_one, value_two): + try: + assert value_one == value_two + except AssertionError: + print "%(value_one)s does not equal %(value_two)s" % locals() + raise + + +class FakeClient(Client): + def __init__(self, username=None, password=None, project_id=None, + auth_url=None): + super(FakeClient, self).__init__('username', 'apikey', + 'project_id', 'auth_url') + self.client = FakeHTTPClient() + + def assert_called(self, method, url, body=None): + """ + Assert than an API method was just called. + """ + expected = (method, url) + called = self.client.callstack[-1][0:2] + + assert self.client.callstack, \ + "Expected %s %s but no calls were made." % expected + + assert expected == called, 'Expected %s %s; got %s %s' % \ + (expected + called) + + if body is not None: + assert_equal(self.client.callstack[-1][2], body) + + self.client.callstack = [] + + def assert_called_anytime(self, method, url, body=None): + """ + Assert than an API method was called anytime in the test. + """ + expected = (method, url) + + assert self.client.callstack, \ + "Expected %s %s but no calls were made." % expected + + found = False + for entry in self.client.callstack: + called = entry[0:2] + if expected == entry[0:2]: + found = True + break + + assert found, 'Expected %s; got %s' % \ + (expected, self.client.callstack) + if body is not None: + assert_equal(entry[2], body) + + self.client.callstack = [] + + def authenticate(self): + pass + + +class FakeHTTPClient(HTTPClient): + def __init__(self): + self.username = 'username' + self.apikey = 'apikey' + self.auth_url = 'auth_url' + self.callstack = [] + + def _cs_request(self, url, method, **kwargs): + # Check that certain things are called correctly + if method in ['GET', 'DELETE']: + assert_not_in('body', kwargs) + elif method in ['PUT', 'POST']: + assert_in('body', kwargs) + + # Call the method + munged_url = url.strip('/').replace('/', '_').replace('.', '_') + callback = "%s_%s" % (method.lower(), munged_url) + if not hasattr(self, callback): + fail('Called unknown API method: %s %s' % (method, url)) + + # Note the call + self.callstack.append((method, url, kwargs.get('body', None))) + + status, body = getattr(self, callback)(**kwargs) + return httplib2.Response({"status": status}), body + + def _munge_get_url(self, url): + return url + + # + # Limits + # + + def get_limits(self, **kw): + return (200, {"limits": { + "rate": [ + { + "verb": "POST", + "URI": "*", + "regex": ".*", + "value": 10, + "remaining": 2, + "unit": "MINUTE", + "resetTime": 1244425439 + }, + { + "verb": "POST", + "URI": "*/servers", + "regex": "^/servers", + "value": 50, + "remaining": 49, + "unit": "DAY", "resetTime": 1244511839 + }, + { + "verb": "PUT", + "URI": "*", + "regex": ".*", + "value": 10, + "remaining": 2, + "unit": "MINUTE", + "resetTime": 1244425439 + }, + { + "verb": "GET", + "URI": "*changes-since*", + "regex": "changes-since", + "value": 3, + "remaining": 3, + "unit": "MINUTE", + "resetTime": 1244425439 + }, + { + "verb": "DELETE", + "URI": "*", + "regex": ".*", + "value": 100, + "remaining": 100, + "unit": "MINUTE", + "resetTime": 1244425439 + } + ], + "absolute": { + "maxTotalRAMSize": 51200, + "maxIPGroups": 50, + "maxIPGroupMembers": 25 + } + }}) + + # + # Servers + # + + def get_servers(self, **kw): + return (200, {"servers": [ + {'id': 1234, 'name': 'sample-server'}, + {'id': 5678, 'name': 'sample-server2'} + ]}) + + def get_servers_detail(self, **kw): + return (200, {"servers": [ + { + "id": 1234, + "name": "sample-server", + "image": { + "id": 2, + }, + "flavor": { + "id": 1, + }, + "hostId": "e4d909c290d0fb1ca068ffaddf22cbd0", + "status": "BUILD", + "progress": 60, + "addresses": { + "public": [{ + "addr": "1.2.3.4", + "version": 4, + }, + { + "addr": "5.6.7.8", + "version": 4, + }], + "private": [{ + "addr": "10.11.12.13", + "version": 4, + }], + }, + "metadata": { + "Server Label": "Web Head 1", + "Image Version": "2.1" + } + }, + { + "id": 5678, + "name": "sample-server2", + "image": { + "id": 2, + }, + "flavor": { + "id": 1, + }, + "hostId": "9e107d9d372bb6826bd81d3542a419d6", + "status": "ACTIVE", + "addresses": { + "public": [{ + "addr": "1.2.3.5", + "version": 4, + }, + { + "addr": "5.6.7.9", + "version": 4, + }], + "private": [{ + "addr": "10.13.12.13", + "version": 4, + }], + }, + "metadata": { + "Server Label": "DB 1" + } + } + ]}) + + def post_servers(self, body, **kw): + assert_equal(body.keys(), ['server']) + assert_has_keys(body['server'], + required=['name', 'imageRef', 'flavorRef'], + optional=['metadata', 'personality']) + if 'personality' in body['server']: + for pfile in body['server']['personality']: + assert_has_keys(pfile, required=['path', 'contents']) + return (202, self.get_servers_1234()[1]) + + def get_servers_1234(self, **kw): + r = {'server': self.get_servers_detail()[1]['servers'][0]} + return (200, r) + + def get_servers_5678(self, **kw): + r = {'server': self.get_servers_detail()[1]['servers'][1]} + return (200, r) + + def put_servers_1234(self, body, **kw): + assert_equal(body.keys(), ['server']) + assert_has_keys(body['server'], optional=['name', 'adminPass']) + return (204, None) + + def delete_servers_1234(self, **kw): + return (202, None) + + # + # Server Addresses + # + + def get_servers_1234_ips(self, **kw): + return (200, {'addresses': + self.get_servers_1234()[1]['server']['addresses']}) + + def get_servers_1234_ips_public(self, **kw): + return (200, {'public': + self.get_servers_1234_ips()[1]['addresses']['public']}) + + def get_servers_1234_ips_private(self, **kw): + return (200, {'private': + self.get_servers_1234_ips()[1]['addresses']['private']}) + + def put_servers_1234_ips_public_1_2_3_4(self, body, **kw): + assert_equal(body.keys(), ['shareIp']) + assert_has_keys(body['shareIp'], required=['sharedIpGroupId', + 'configureServer']) + return (202, None) + + def delete_servers_1234_ips_public_1_2_3_4(self, **kw): + return (202, None) + + # + # Server actions + # + + def post_servers_1234_action(self, body, **kw): + assert_equal(len(body.keys()), 1) + action = body.keys()[0] + if action == 'reboot': + assert_equal(body[action].keys(), ['type']) + assert_in(body[action]['type'], ['HARD', 'SOFT']) + elif action == 'rebuild': + assert_equal(body[action].keys(), ['imageRef']) + elif action == 'resize': + assert_equal(body[action].keys(), ['flavorRef']) + elif action == 'confirmResize': + assert_equal(body[action], None) + # This one method returns a different response code + return (204, None) + elif action == 'revertResize': + assert_equal(body[action], None) + elif action == 'changePassword': + assert_equal(body[action].keys(), ["adminPass"]) + elif action == 'createImage': + assert_equal(body[action].keys(), ["name", "metadata"]) + else: + fail("Unexpected server action: %s" % action) + return (202, None) + + # + # Flavors + # + + def get_flavors(self, **kw): + return (200, {'flavors': [ + {'id': 1, 'name': '256 MB Server'}, + {'id': 2, 'name': '512 MB Server'} + ]}) + + def get_flavors_detail(self, **kw): + return (200, {'flavors': [ + {'id': 1, 'name': '256 MB Server', 'ram': 256, 'disk': 10}, + {'id': 2, 'name': '512 MB Server', 'ram': 512, 'disk': 20} + ]}) + + def get_flavors_1(self, **kw): + return (200, {'flavor': self.get_flavors_detail()[1]['flavors'][0]}) + + def get_flavors_2(self, **kw): + return (200, {'flavor': self.get_flavors_detail()[1]['flavors'][1]}) + + # + # Images + # + def get_images(self, **kw): + return (200, {'images': [ + {'id': 1, 'name': 'CentOS 5.2'}, + {'id': 2, 'name': 'My Server Backup'} + ]}) + + def get_images_detail(self, **kw): + return (200, {'images': [ + { + 'id': 1, + 'name': 'CentOS 5.2', + "updated": "2010-10-10T12:00:00Z", + "created": "2010-08-10T12:00:00Z", + "status": "ACTIVE" + }, + { + "id": 743, + "name": "My Server Backup", + "serverId": 12, + "updated": "2010-10-10T12:00:00Z", + "created": "2010-08-10T12:00:00Z", + "status": "SAVING", + "progress": 80 + } + ]}) + + def get_images_1(self, **kw): + return (200, {'image': self.get_images_detail()[1]['images'][0]}) + + def get_images_2(self, **kw): + return (200, {'image': self.get_images_detail()[1]['images'][1]}) + + def post_images(self, body, **kw): + assert_equal(body.keys(), ['image']) + assert_has_keys(body['image'], required=['serverId', 'name', 'image_type', 'backup_type', 'rotation']) + return (202, self.get_images_1()[1]) + + def delete_images_1(self, **kw): + return (204, None) + + # + # Backup schedules + # + def get_servers_1234_backup_schedule(self, **kw): + return (200, {"backupSchedule": { + "enabled": True, + "weekly": "THURSDAY", + "daily": "H_0400_0600" + }}) + + def post_servers_1234_backup_schedule(self, body, **kw): + assert_equal(body.keys(), ['backupSchedule']) + assert_has_keys(body['backupSchedule'], required=['enabled'], + optional=['weekly', 'daily']) + return (204, None) + + def delete_servers_1234_backup_schedule(self, **kw): + return (204, None) + + # + # Shared IP groups + # + def get_shared_ip_groups(self, **kw): + return (200, {'sharedIpGroups': [ + {'id': 1, 'name': 'group1'}, + {'id': 2, 'name': 'group2'}, + ]}) + + def get_shared_ip_groups_detail(self, **kw): + return (200, {'sharedIpGroups': [ + {'id': 1, 'name': 'group1', 'servers': [1234]}, + {'id': 2, 'name': 'group2', 'servers': [5678]}, + ]}) + + def get_shared_ip_groups_1(self, **kw): + return (200, {'sharedIpGroup': + self.get_shared_ip_groups_detail()[1]['sharedIpGroups'][0]}) + + def post_shared_ip_groups(self, body, **kw): + assert_equal(body.keys(), ['sharedIpGroup']) + assert_has_keys(body['sharedIpGroup'], required=['name'], + optional=['server']) + return (201, {'sharedIpGroup': { + 'id': 10101, + 'name': body['sharedIpGroup']['name'], + 'servers': 'server' in body['sharedIpGroup'] and \ + [body['sharedIpGroup']['server']] or None + }}) + + def delete_shared_ip_groups_1(self, **kw): + return (204, None) + + # + # Zones + # + def get_zones(self, **kw): + return (200, {'zones': [ + {'id': 1, 'api_url': 'http://foo.com', 'username': 'bob'}, + {'id': 2, 'api_url': 'http://foo.com', 'username': 'alice'}, + ]}) + + + def get_zones_detail(self, **kw): + return (200, {'zones': [ + {'id': 1, 'api_url': 'http://foo.com', 'username': 'bob', + 'password': 'qwerty'}, + {'id': 2, 'api_url': 'http://foo.com', 'username': 'alice', + 'password': 'password'} + ]}) + + def get_zones_1(self, **kw): + r = {'zone': self.get_zones_detail()[1]['zones'][0]} + return (200, r) + + def get_zones_2(self, **kw): + r = {'zone': self.get_zones_detail()[1]['zones'][1]} + return (200, r) + + def post_zones(self, body, **kw): + assert_equal(body.keys(), ['zone']) + assert_has_keys(body['zone'], + required=['api_url', 'username', 'password'], + optional=['weight_offset', 'weight_scale']) + + return (202, self.get_zones_1()[1]) + + def put_zones_1(self, body, **kw): + assert_equal(body.keys(), ['zone']) + assert_has_keys(body['zone'], optional=['api_url', 'username', + 'password', + 'weight_offset', + 'weight_scale']) + return (204, None) + + def delete_zones_1(self, **kw): + return (202, None) + + # + # Accounts + # + def post_accounts_test_account_create_instance(self, body, **kw): + assert_equal(body.keys(), ['server']) + assert_has_keys(body['server'], + required=['name', 'imageRef', 'flavorRef'], + optional=['metadata', 'personality']) + if 'personality' in body['server']: + for pfile in body['server']['personality']: + assert_has_keys(pfile, required=['path', 'contents']) + return (202, self.get_servers_1234()[1]) diff --git a/tests/v1_1/test_base.py b/tests/v1_1/test_base.py new file mode 100644 index 000000000..9db08eb80 --- /dev/null +++ b/tests/v1_1/test_base.py @@ -0,0 +1,61 @@ +from __future__ import absolute_import + +import mock +from nose.tools import assert_equal, assert_not_equal, assert_raises + +from novaclient.v1_1 import flavors +from novaclient.v1_1 import exceptions +from novaclient.v1_1 import base + +from .fakes import FakeClient + +os = FakeClient() + + +def test_resource_repr(): + r = base.Resource(None, dict(foo="bar", baz="spam")) + assert_equal(repr(r), "") + + +def test_getid(): + assert_equal(base.getid(4), 4) + + class O(object): + id = 4 + assert_equal(base.getid(O), 4) + + +def test_resource_lazy_getattr(): + f = flavors.Flavor(os.flavors, {'id': 1}) + assert_equal(f.name, '256 MB Server') + os.assert_called('GET', '/flavors/1') + + # Missing stuff still fails after a second get + assert_raises(AttributeError, getattr, f, 'blahblah') + os.assert_called('GET', '/flavors/1') + + +def test_eq(): + # Two resources of the same type with the same id: equal + r1 = base.Resource(None, {'id': 1, 'name': 'hi'}) + r2 = base.Resource(None, {'id': 1, 'name': 'hello'}) + assert_equal(r1, r2) + + # Two resoruces of different types: never equal + r1 = base.Resource(None, {'id': 1}) + r2 = flavors.Flavor(None, {'id': 1}) + assert_not_equal(r1, r2) + + # Two resources with no ID: equal if their info is equal + r1 = base.Resource(None, {'name': 'joe', 'age': 12}) + r2 = base.Resource(None, {'name': 'joe', 'age': 12}) + assert_equal(r1, r2) + + +def test_findall_invalid_attribute(): + # Make sure findall with an invalid attribute doesn't cause errors. + # The following should not raise an exception. + os.flavors.findall(vegetable='carrot') + + # However, find() should raise an error + assert_raises(exceptions.NotFound, os.flavors.find, vegetable='carrot') diff --git a/tests/v1_1/test_client.py b/tests/v1_1/test_client.py new file mode 100644 index 000000000..7cf90e368 --- /dev/null +++ b/tests/v1_1/test_client.py @@ -0,0 +1,52 @@ +import mock +import httplib2 + +from novaclient.v1_1 import client +from nose.tools import assert_equal + +fake_response = httplib2.Response({"status": 200}) +fake_body = '{"hi": "there"}' +mock_request = mock.Mock(return_value=(fake_response, fake_body)) + + +def get_client(): + cl = client.HTTPClient("username", "apikey", "project_id", "auth_test") + cl.management_url = "http://example.com" + cl.auth_token = "token" + return cl + + +def test_get(): + cl = get_client() + + @mock.patch.object(httplib2.Http, "request", mock_request) + @mock.patch('time.time', mock.Mock(return_value=1234)) + def test_get_call(): + resp, body = cl.get("/hi") + mock_request.assert_called_with("http://example.com/hi?fresh=1234", + "GET", + headers={"X-Auth-Token": "token", + "X-Auth-Project-Id": "project_id", + "User-Agent": cl.USER_AGENT}) + # Automatic JSON parsing + assert_equal(body, {"hi": "there"}) + + test_get_call() + + +def test_post(): + cl = get_client() + + @mock.patch.object(httplib2.Http, "request", mock_request) + def test_post_call(): + cl.post("/hi", body=[1, 2, 3]) + mock_request.assert_called_with("http://example.com/hi", "POST", + headers={ + "X-Auth-Token": "token", + "X-Auth-Project-Id": "project_id", + "Content-Type": "application/json", + "User-Agent": cl.USER_AGENT}, + body='[1, 2, 3]' + ) + + test_post_call() diff --git a/tests/v1_1/test_flavors.py b/tests/v1_1/test_flavors.py new file mode 100644 index 000000000..51ca7a1bf --- /dev/null +++ b/tests/v1_1/test_flavors.py @@ -0,0 +1,42 @@ +from __future__ import absolute_import + +from nose.tools import assert_raises, assert_equal + +from novaclient.v1_1 import flavors +from novaclient.v1_1 import exceptions + +from .fakes import FakeClient +from .utils import assert_isinstance + +os = FakeClient() + + +def test_list_flavors(): + fl = os.flavors.list() + os.assert_called('GET', '/flavors/detail') + [assert_isinstance(f, flavors.Flavor) for f in fl] + + +def test_list_flavors_undetailed(): + fl = os.flavors.list(detailed=False) + os.assert_called('GET', '/flavors') + [assert_isinstance(f, flavors.Flavor) for f in fl] + + +def test_get_flavor_details(): + f = os.flavors.get(1) + os.assert_called('GET', '/flavors/1') + assert_isinstance(f, flavors.Flavor) + assert_equal(f.ram, 256) + assert_equal(f.disk, 10) + + +def test_find(): + f = os.flavors.find(ram=256) + os.assert_called('GET', '/flavors/detail') + assert_equal(f.name, '256 MB Server') + + f = os.flavors.find(disk=20) + assert_equal(f.name, '512 MB Server') + + assert_raises(exceptions.NotFound, os.flavors.find, disk=12345) diff --git a/tests/v1_1/test_images.py b/tests/v1_1/test_images.py new file mode 100644 index 000000000..03b63b7cb --- /dev/null +++ b/tests/v1_1/test_images.py @@ -0,0 +1,51 @@ +from __future__ import absolute_import + +from nose.tools import assert_equal + +from novaclient.v1_1 import images + +from .fakes import FakeClient +from .utils import assert_isinstance + +os = FakeClient() + + +def test_list_images(): + il = os.images.list() + os.assert_called('GET', '/images/detail') + [assert_isinstance(i, images.Image) for i in il] + + +def test_list_images_undetailed(): + il = os.images.list(detailed=False) + os.assert_called('GET', '/images') + [assert_isinstance(i, images.Image) for i in il] + + +def test_get_image_details(): + i = os.images.get(1) + os.assert_called('GET', '/images/1') + assert_isinstance(i, images.Image) + assert_equal(i.id, 1) + assert_equal(i.name, 'CentOS 5.2') + + +def test_create_image(): + i = os.images.create(server=1234, name="Just in case") + os.assert_called('POST', '/images') + assert_isinstance(i, images.Image) + + +def test_delete_image(): + os.images.delete(1) + os.assert_called('DELETE', '/images/1') + + +def test_find(): + i = os.images.find(name="CentOS 5.2") + assert_equal(i.id, 1) + os.assert_called('GET', '/images/detail') + + iml = os.images.findall(status='SAVING') + assert_equal(len(iml), 1) + assert_equal(iml[0].name, 'My Server Backup') diff --git a/tests/v1_1/test_servers.py b/tests/v1_1/test_servers.py new file mode 100644 index 000000000..1ffb7b959 --- /dev/null +++ b/tests/v1_1/test_servers.py @@ -0,0 +1,125 @@ +from __future__ import absolute_import + +import StringIO + +from nose.tools import assert_equal + +from novaclient.v1_1 import servers + +from .fakes import FakeClient +from .utils import assert_isinstance + + +os = FakeClient() + + +def test_list_servers(): + sl = os.servers.list() + os.assert_called('GET', '/servers/detail') + [assert_isinstance(s, servers.Server) for s in sl] + + +def test_list_servers_undetailed(): + sl = os.servers.list(detailed=False) + os.assert_called('GET', '/servers') + [assert_isinstance(s, servers.Server) for s in sl] + + +def test_get_server_details(): + s = os.servers.get(1234) + os.assert_called('GET', '/servers/1234') + assert_isinstance(s, servers.Server) + assert_equal(s.id, 1234) + assert_equal(s.status, 'BUILD') + + +def test_create_server(): + s = os.servers.create( + name="My server", + image=1, + flavor=1, + meta={'foo': 'bar'}, + files={ + '/etc/passwd': 'some data', # a file + '/tmp/foo.txt': StringIO.StringIO('data') # a stream + } + ) + os.assert_called('POST', '/servers') + assert_isinstance(s, servers.Server) + + +def test_update_server(): + s = os.servers.get(1234) + + # Update via instance + s.update(name='hi') + os.assert_called('PUT', '/servers/1234') + + # Silly, but not an error + s.update() + + # Update via manager + os.servers.update(s, name='hi') + os.assert_called('PUT', '/servers/1234') + + +def test_delete_server(): + s = os.servers.get(1234) + s.delete() + os.assert_called('DELETE', '/servers/1234') + os.servers.delete(1234) + os.assert_called('DELETE', '/servers/1234') + os.servers.delete(s) + os.assert_called('DELETE', '/servers/1234') + + +def test_find(): + s = os.servers.find(name='sample-server') + os.assert_called('GET', '/servers/detail') + assert_equal(s.name, 'sample-server') + + # Find with multiple results arbitraility returns the first item + s = os.servers.find(flavor_id=1) + sl = os.servers.findall(flavor_id=1) + assert_equal(sl[0], s) + assert_equal([s.id for s in sl], [1234, 5678]) + + +def test_reboot_server(): + s = os.servers.get(1234) + s.reboot() + os.assert_called('POST', '/servers/1234/action') + os.servers.reboot(s, type='HARD') + os.assert_called('POST', '/servers/1234/action') + + +def test_rebuild_server(): + s = os.servers.get(1234) + s.rebuild(image=1) + os.assert_called('POST', '/servers/1234/action') + os.servers.rebuild(s, image=1) + os.assert_called('POST', '/servers/1234/action') + + +def test_resize_server(): + s = os.servers.get(1234) + s.resize(flavor=1) + os.assert_called('POST', '/servers/1234/action') + os.servers.resize(s, flavor=1) + os.assert_called('POST', '/servers/1234/action') + + +def test_confirm_resized_server(): + s = os.servers.get(1234) + s.confirm_resize() + os.assert_called('POST', '/servers/1234/action') + os.servers.confirm_resize(s) + os.assert_called('POST', '/servers/1234/action') + + +def test_revert_resized_server(): + s = os.servers.get(1234) + s.revert_resize() + os.assert_called('POST', '/servers/1234/action') + os.servers.revert_resize(s) + os.assert_called('POST', '/servers/1234/action') diff --git a/tests/v1_1/test_shell.py b/tests/v1_1/test_shell.py new file mode 100644 index 000000000..baf117b93 --- /dev/null +++ b/tests/v1_1/test_shell.py @@ -0,0 +1,234 @@ +from __future__ import absolute_import + +import os +import mock +import httplib2 + +from nose.tools import assert_raises, assert_equal + +from novaclient.v1_1.shell import OpenStackShell, CommandError + +from .fakes import FakeClient +from .utils import assert_in + + +# Patch os.environ to avoid required auth info. +def setup(): + global _old_env + fake_env = { + 'NOVA_USERNAME': 'username', + 'NOVA_API_KEY': 'password', + 'NOVA_PROJECT_ID': 'project_id' + } + _old_env, os.environ = os.environ, fake_env.copy() + + # Make a fake shell object, a helping wrapper to call it, and a quick way + # of asserting that certain API calls were made. + global shell, _shell, assert_called, assert_called_anytime + _shell = OpenStackShell() + _shell._api_class = FakeClient + assert_called = lambda m, u, b=None: _shell.cs.assert_called(m, u, b) + assert_called_anytime = lambda m, u, b=None: \ + _shell.cs.assert_called_anytime(m, u, b) + shell = lambda cmd: _shell.main(cmd.split()) + + +def teardown(): + global _old_env + os.environ = _old_env + + +def test_boot(): + shell('boot --image 1 some-server') + assert_called( + 'POST', '/servers', + {'server': {'flavorRef': 1, 'name': 'some-server', 'imageRef': '1'}} + ) + + shell('boot --image 1 --meta foo=bar --meta spam=eggs some-server ') + assert_called( + 'POST', '/servers', + {'server': {'flavorRef': 1, 'name': 'some-server', 'imageRef': '1', + 'metadata': {'foo': 'bar', 'spam': 'eggs'}}} + ) + + +def test_boot_files(): + testfile = os.path.join(os.path.dirname(__file__), 'testfile.txt') + expected_file_data = open(testfile).read().encode('base64') + + shell('boot some-server --image 1 --file /tmp/foo=%s --file /tmp/bar=%s' % + (testfile, testfile)) + + assert_called( + 'POST', '/servers', + {'server': {'flavorRef': 1, 'name': 'some-server', 'imageRef': '1', + 'personality': [ + {'path': '/tmp/bar', 'contents': expected_file_data}, + {'path': '/tmp/foo', 'contents': expected_file_data} + ]} + } + ) + + +def test_boot_invalid_file(): + invalid_file = os.path.join(os.path.dirname(__file__), 'asdfasdfasdfasdf') + assert_raises(CommandError, shell, 'boot some-server --image 1 ' + '--file /foo=%s' % invalid_file) + + +def test_boot_key_auto(): + mock_exists = mock.Mock(return_value=True) + mock_open = mock.Mock() + mock_open.return_value = mock.Mock() + mock_open.return_value.read = mock.Mock(return_value='SSHKEY') + + @mock.patch('os.path.exists', mock_exists) + @mock.patch('__builtin__.open', mock_open) + def test_shell_call(): + shell('boot some-server --image 1 --key') + assert_called( + 'POST', '/servers', + {'server': {'flavorRef': 1, 'name': 'some-server', 'imageRef': '1', + 'personality': [{ + 'path': '/root/.ssh/authorized_keys2', + 'contents': ('SSHKEY').encode('base64')}, + ]} + } + ) + + test_shell_call() + + +def test_boot_key_auto_no_keys(): + mock_exists = mock.Mock(return_value=False) + + @mock.patch('os.path.exists', mock_exists) + def test_shell_call(): + assert_raises(CommandError, shell, 'boot some-server --image 1 --key') + + test_shell_call() + + +def test_boot_key_file(): + testfile = os.path.join(os.path.dirname(__file__), 'testfile.txt') + expected_file_data = open(testfile).read().encode('base64') + shell('boot some-server --image 1 --key %s' % testfile) + assert_called( + 'POST', '/servers', + {'server': {'flavorRef': 1, 'name': 'some-server', 'imageRef': '1', + 'personality': [ + {'path': '/root/.ssh/authorized_keys2', 'contents': + expected_file_data}, + ]} + } + ) + + +def test_boot_invalid_keyfile(): + invalid_file = os.path.join(os.path.dirname(__file__), 'asdfasdfasdfasdf') + assert_raises(CommandError, shell, 'boot some-server ' + '--image 1 --key %s' % invalid_file) + + +def test_flavor_list(): + shell('flavor-list') + assert_called_anytime('GET', '/flavors/detail') + + +def test_image_list(): + shell('image-list') + assert_called('GET', '/images/detail') + + +def test_create_image(): + shell('create-image sample-server mysnapshot') + assert_called( + 'POST', '/servers/1234/action', + {'createImage': {'name': 'mysnapshot', "metadata": {}}} + ) + + +def test_image_delete(): + shell('image-delete 1') + assert_called('DELETE', '/images/1') + + +def test_list(): + shell('list') + assert_called('GET', '/servers/detail') + + +def test_reboot(): + shell('reboot sample-server') + assert_called('POST', '/servers/1234/action', {'reboot': {'type': 'SOFT'}}) + shell('reboot sample-server --hard') + assert_called('POST', '/servers/1234/action', {'reboot': {'type': 'HARD'}}) + + +def test_rebuild(): + shell('rebuild sample-server 1') + assert_called('POST', '/servers/1234/action', {'rebuild': {'imageRef': 1}}) + + +def test_rename(): + shell('rename sample-server newname') + assert_called('PUT', '/servers/1234', {'server': {'name': 'newname'}}) + + +def test_resize(): + shell('resize sample-server 1') + assert_called('POST', '/servers/1234/action', {'resize': {'flavorRef': 1}}) + + +def test_resize_confirm(): + shell('resize-confirm sample-server') + assert_called('POST', '/servers/1234/action', {'confirmResize': None}) + + +def test_resize_revert(): + shell('resize-revert sample-server') + assert_called('POST', '/servers/1234/action', {'revertResize': None}) + + +@mock.patch('getpass.getpass', mock.Mock(return_value='p')) +def test_root_password(): + shell('root-password sample-server') + assert_called('POST', '/servers/1234/action', {'changePassword': {'adminPass': 'p'}}) + + +def test_show(): + shell('show 1234') + # XXX need a way to test multiple calls + # assert_called('GET', '/servers/1234') + assert_called('GET', '/images/2') + + +def test_delete(): + shell('delete 1234') + assert_called('DELETE', '/servers/1234') + shell('delete sample-server') + assert_called('DELETE', '/servers/1234') + + +def test_help(): + @mock.patch.object(_shell.parser, 'print_help') + def test_help(m): + shell('help') + m.assert_called() + + @mock.patch.object(_shell.subcommands['delete'], 'print_help') + def test_help_delete(m): + shell('help delete') + m.assert_called() + + test_help() + test_help_delete() + + assert_raises(CommandError, shell, 'help foofoo') + + +def test_debug(): + httplib2.debuglevel = 0 + shell('--debug list') + assert httplib2.debuglevel == 1 diff --git a/tests/v1_1/testfile.txt b/tests/v1_1/testfile.txt new file mode 100644 index 000000000..90763c69f --- /dev/null +++ b/tests/v1_1/testfile.txt @@ -0,0 +1 @@ +OH HAI! \ No newline at end of file diff --git a/tests/v1_1/utils.py b/tests/v1_1/utils.py new file mode 100644 index 000000000..f878a5e26 --- /dev/null +++ b/tests/v1_1/utils.py @@ -0,0 +1,29 @@ +from nose.tools import ok_ + + +def fail(msg): + raise AssertionError(msg) + + +def assert_in(thing, seq, msg=None): + msg = msg or "'%s' not found in %s" % (thing, seq) + ok_(thing in seq, msg) + + +def assert_not_in(thing, seq, msg=None): + msg = msg or "unexpected '%s' found in %s" % (thing, seq) + ok_(thing not in seq, msg) + + +def assert_has_keys(dict, required=[], optional=[]): + keys = dict.keys() + for k in required: + assert_in(k, keys, "required key %s missing from %s" % (k, dict)) + allowed_keys = set(required) | set(optional) + extra_keys = set(keys).difference(set(required + optional)) + if extra_keys: + fail("found unexpected keys: %s" % list(extra_keys)) + + +def assert_isinstance(thing, kls): + ok_(isinstance(thing, kls), "%s is not an instance of %s" % (thing, kls))